Select › Color Range: sampled colours, Fuzziness, Invert, a live preview and the panel (13.3b)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 06:30:26 -04:00
1 parent 462fe805b1
commit 0b7eb6cb6f
29 files changed
+1535 -71

No files matched your search

+2 -1
View File
@@ -152,12 +152,13 @@ Platform adaptations, each forced by a missing Qt analog:
- The guide for agents (12.13c, Swift 1.3: `c317306`): `docs/writing-comp-files.md` twins Swift's guide and `docs/project-format.md` Swift's format page, both adapted to Linux (a project is a folder, the example lives under `~/Pictures`, the save swaps folders with `renameat2`, a replaced project folder is noticed too, sources named by the twin's files). Swift's top-level `AGENTS.md` for agents becomes the section at the head of this file. README gains Swift's lines (scrubbing, saving while editing, projects changed on disk, working with AI agents). The format page's claims follow the twin, where Swift's page is out of date: the pixel budgets scale with memory, the save's exchange has a two-step fallback, only `maskPlacement` needs a mask, the common adjustment fields are required and later kinds default to their own values, folders pass adjustments through, text becomes pixels after Canvas Size, Image Size and Crop, and a missing face may fall to another family. `WritingProjectsGuideTests` reads the guide itself: the minimal manifest loads with the fields it shows (visibility, opacity, blend, placement, the image named after its layer), the Curves layer's points are pinned and its rendered pixel is the background through those curves, removing any common adjustment field refuses the project, a folder passes an Invert through, `hsvSettings` wins over the legacy Hue/Saturation fields (the guide says where to edit a saved one), and the guide's lists of blend modes, adjustment kinds and samplings equal the twin's `rawValue`s. The guide asks for 8-bit RGBA layers and accepts what the store accepts: RGB, grayscale and lower-depth grayscale layers, never 16 bits. `ExternalChangeTests::theGuidesSameSizeRecipeReloads` runs the same-size recipe against a watched project.
- Quitting while text is edited (12.14a, Swift 1.3.1: `a18d86c`, `afd622e`, `4a4cf85`, `221d6cc`): `ProjectWorkspace::confirmQuit` first applies every tab's open text in quit order (`finishTextEditing`, Swift's), so closing the window (the twin's quit) no longer stops at `canSwitch`, which an open draft refuses; text that cannot be applied (a busy project) stops the quit with the draft kept. Swift's `applicationShouldTerminate` guard has no twin: closing the window reaches `confirmQuit` directly. Swift's cursor commits (the arrow restored off the canvas, the I-beam kept across it while text is open) need no code here: a Qt widget owns its cursor, so leaving the canvas shows the next widget's, and the canvas already shows the Type tool's I-beam away from the box. Tests: `QuitWhileEditingTextTests` (Swift's two through the real window's close: the text applied as one `New Text Layer` step before the question, Cancel keeping the window, then Don't Save; every tab in quit order with its own content and its undo; a refused draft in the middle of three tabs stopping the quit where it stands, the tab before applied, the tab after untouched, nothing asked; edited existing text as one `Edit Text` step on the same layer; an unchanged existing draft and a blank new one closing with no step and no question), `InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas` (the I-beam away from the box, after a resize drag, no application-wide cursor on leaving, and neither the window nor a sibling taking the I-beam).
- Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`.
- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "<Title>. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arLine truncated
- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "<Title>. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`, `ColorRangeSheet`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing theLine truncated
- Colouring only the selected letters (12.14d1, Swift 1.3.2: `2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): `LayerTextStyle` gains Swift's optional `colorRuns` (`LayerTextColorRun`: location and length in UTF-16 units as `qint64`, Swift's `Int`, and sRGB red, green and blue), valid when sorted, apart, not empty, within the content and within Int, with Swift's `color(at:)` (`color`), `setColor(_:in:)` (an empty span or the whole text recolours the base and drops the runs) and `replaceCharacters(in:withLength:)` (new units take the colour before them, or at the start the first letter's), both working through Swift's per-unit colours and joining neighbours of one colour. `TextSpan` is Swift's `NSRange`; `TextDraft.selection` sits last. `TypeTool.h` declares `PaletteColor` opaquely and `ColorPalette.h` includes it, because `ColorPickerState::EditedText` now holds the text's whole style (Swift's `(draftID, style)`). The manifest writes `colorRuns` only when set, each run's five keys required, and the store refuses runs before version 10, which saves now write (versions 1–10). `TextLines` gives each paragraph its runs as `QTextLayout` formats before laying it out, so a run's letters draw once in their colour, as CoreText's runs do: formats handed to `draw` are an overlay, which would leave the base colour in their antialiased edges. The palette follows Swift's: the foreground swatch and the Type bar's picker paint the draft's selection (`setDraftTextColor`), previews too; Cancel puts back the base and the runs the text had when the picker opened (`restoreDraftTextColors`), the runs only while the content is unchanged; the Type bar's picker now keeps that style as the palette's does; `typeColor` is the first selected letter's colour, or with a caret the letter before it; Fill (`recolorText`) recolours every letter, runs gone, even when the base colour already matches; the text defaults never keep runs, so new text starts in one colour. Swift's clearing of the runs when new text begins is left out as dead, since the defaults it starts from never hold runs. The inline editor is the twin's own, so Swift's `shouldChangeTextIn` bookkeeping sits in `publish`, which takes each edit's exact replacements (`Step`: the range taken, the new length) in order: typing and deleting, the clipboard (the preedit out, the selection replaced, the preedit back), input methods (the old preedit out, the selection taken and the commit's replacement as one step when the commit lands where the selection was, as NSTextView replaces it, the new preedit in, and a lone preedit over the selection one replacement for the colours, as `setMarkedText` makes it, the selection's removal still history; an update, a cursor's move or a commit of the preedit where it stands is one replacement of the marked text for the colours, as `setMarkedText` and `insertText` replace it whole, even with the same string, so a leading preedit keeps the colour it took) and undo and redo; each step moves the runs against the text's length as the steps before it left it, as Swift's content changes between its calls. Each undo entry keeps the plain steps from its text to the next newer state (`since`: preedits never count, a preedit left as text by a key does), and undo and redo replay their inverses newest first, as NSTextView undoes each replacement, so a letter put back takes the colour before it, as Swift's does; a guess from the text alone would put colours on the wrong twin of a repeated letter. The draft learns the selection on every publish and every move (`noteSelection`, Swift's `textViewDidChangeSelection`). Swift's reset of runs that no longer fit after an edit is left out: every edit moves them. The editor lays its lines out from the style without runs: its glyphs are clear, and the formats would paint them in their colours over the layer beneath. The caret takes the colour of the letter before it (Swift's `insertionPointColor`). Swift's see-through selection under the picker (`b5fe653`) needs nothing: the twin draws the selection translucent, in the inactive colour when the canvas lacks the keys, and `4503998` only adds a test. Equivalent mutants: the inherited colour's `colors[start]` for `colors.front()` (at the start they are one) and `TextLines`' clip of a run to its paragraph (Qt clips a format to its layout's text). Docs: `project-format.md` and `writing-comp-files.md` say version 10, as Swift's. Tests: `TextColorModelTests` (Swift's model and invalid runs, every channel checked, runs of one colour joining only where they meet), `TextColorRunsTests` (Swift's other two: the selection painted and reopened with version 9 refused, Cancel restoring; the swatch's colour, the Type bar's picker, Fill and new text, the manifest's keys, runs across paragraphs, the run's Codable refusals, Cancel over mixed colours for both pickers), `TextColorEditorTests` (the selection reaching the draft, runs through typing, deleting, undo and paste, twins of one letter through Line truncated
- Faces for the selected letters (13.2, Swift 1.3.4: `0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test): `LayerTextStyle` gains Swift's optional `fontRuns` (`LayerTextFontRun`: location, length, fontName), `fontNameAt` (Swift's `fontName(at:)`: C++ cannot give a method a member's name), `uniformFontName` and `setFont`; `replaceCharacters` moves colours and faces alike, so the editor's steps, undo and the clipboard carry faces as they carry colours. The run members live in `TypeTool+Runs.cpp`, since `TypeTool.cpp` would pass 500 lines. A run's name follows Swift's guard: not empty, 200 graphemes at most, no newline character (LF, CR, VT, FF, NEL, U+2028, U+2029). `TextLines` lays each run in its face as a format (`TextLayout::font(style, face)` keeps the style's size, spacing, kerning and hinting); the editor lays out the faces, for the glyphs' places, and drops the colours alone. The manifest writes `fontRuns` when set; the store writes version 11 and refuses faces before it. `TypeFontPicker` shows a selection in several faces as (Multiple), a top item that is no font (its item data), and drops it once one face shows; a chosen face applies to the draft's selection, and the face shown or (Multiple) changes nothing. A fix beyond Swift: Swift's `textDidChange` copies the pending style's runs but not its face, so deleting until every letter left is in one run's face drops the run and turns those letters back to the old base face; the twin keeps the folded face (`deletingToOneFaceKeepsItsLetters`). Swift's `holdsSelection` has no twin: AppKit's text view loses its highlight when the menu takes the focus, while the inline editor keeps its selection unfocused (`TextFontControlsTests` holds it). Swift's reset of `fontRuns` in `beginText` is dead, as the colour's is: the defaults never carry runs. Equivalent mutants: `uniformFontName`'s clamp of its index (runs are sorted and apart), the layout's clamp of a run to its paragraph (Qt clips a format past the text, undocumented, so the clamp stays) and the decoder's string check (any other value reads as an empty name, which validity refuses). Tests: `TextFontRunsTests` (Swift's three; the store's rules; layout in mono, bold and across a break), `TextFontControlsTests` (the menu, the kept selection, typing and undo, the folded face, a click placed by the faces), version 11 across the store's tests.
- Sampling and open text (12.14d2, Swift 1.3.2: `810c88a`, `d448625`): closing the colour picker, by OK or Cancel, while text is open asks for canvas focus (`closeColorPicker` counts `canvasFocusRequest` before its final signal), and `CanvasView::consumeFocusRequest` now hands the keys to the canvas with a draft open too, where the editor takes them, as Swift makes the text view first responder, so typing and Ctrl+Return reach the text after sampling from the canvas. Swift also takes a sampling click for an open panel before its window sees it, so the panel stays key; a window manager activates whatever window is clicked, and handing the panel the keys back at the press would take the canvas's focus and end the sampling it began, so the twin hands them back at the release, as Swift did before that commit (known difference). `canAdjust` (so `canAdjustColors`, `canVignette` and the fill) and `canInvert` refuse while text is open, since the editor draws that text, not its layer, and a preview of it would be wrong; the Image and Filter menus follow. Tests: `PickingTests::openTextTakesTheKeysBackFromThePicker` (the foreground, background and Type bar pickers, new and existing text, OK and Cancel, one request each, typing after and Ctrl+Return applying, any focus request, no request without text for any picker by OK or Cancel, the owner's field keeping the keys), `InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField` (a request now takes the keys from a button, a modal dialog keeping them with text open, a tool panel's field keeping them while the canvas waits for its window's turn), `TextEditingGatesTests` (the gates and the entries with new and reopened text, open and closed, Levels, Hue/Saturation, filters and Invert refused with nothing changed, content-aware fill and Camera Raw, Vignette on an empty layer).
- The Dither filter (12.15, Swift 1.3.3: `f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `kernels/DitherPixels.c/.h` are Swift's, unchanged, built with the other kernels. `Document/Dither.h/.cpp` twin Swift's file: `DitherStyle`, `DitherPixelShape` and `DitherColors` by raw value in Swift's order (`allDitherStyles`, `allDitherPixelShapes`, `allDitherColors`; `ditherGroup` stands for Swift's `groups`, with `diffuses`, `hasTones`, `isHalftone` and `drawsMarks`); `DitherSettings` keeps Swift's fields, defaults and ranges, `normalized` rounds and clamps as Swift's (a value that is no number falls back) and keeps the first 64 graphemes of the characters that are no line break (Swift's `Character.isNewline`: LF, CR, CRLF, VT, FF, NEL, U+2028, U+2029). `apply` converts the image to premultiplied RGBA first. Chunky pixels are each block's area averaged exactly, a block past the image's edge counting its missing pixels as clear, where CoreGraphics draws the image at its high quality into a fractional rectangle (known difference: a box average, not CoreGraphics' filter); the small image is dithered and blown back up pixel for pixel, and a dot shape runs `dither_dots` with the dark colour or black for its gaps. `glyphs` draws each distinct grapheme of the characters into a gray map of the cell's size, white on black, centred on its ink (`QPainterPath::boundingRect`, Swift's `CTLineGetImageBounds`), in fontconfig's bold monospace at a whole pixel size of 1.15 cells (known difference: Swift's bold SF Mono at the exact size), filled by the winding rule so overlapping outlines (a letter and its combining accent) stay inked, as text rendering fills them (Qt's `addText` paths are odd-even), sorted by coverage, stably. A kernel that finds no memory is `ExportError(render)`, as is a source whose conversion finds none (`FilterFailureTests`); a glyph map (4 KB at most) cannot be starved alone under the address-space harness, so its refusal is untested, as is the glyphs' order when two coverages tie (no two characters are known to ink alike in every font). Equivalent mutant: the conversion's own null check (the small or working context of a null source refuses the same way). Making the small image gives the same pixels without it (averaged into the top left of a full-size copy whose clear rest the kernel skips), but its kernel planes would then be full size: `FilterFailureTests` dithers chunky 4000-pixel Original colours within the limit the unchunky case exceeds. Swift's `Set<Character>` treats canonically equivalent graphemes as one: the glyphs' seen set compares their NFC forms, and draws the first. Leaving the Characters field (`editingFinished`) shows what the settings kept of the typing. `FilterKind::dither` sits after Bloom / Glow, as Swift's menu has it; `FilterSettings.dither` normalizes with the rest; `PixelFilter::run` applies it at the job's own pixels; its previews are made at full size, as noise's and grain's are. The picker's `ColorPickerTarget` gains the `dither` kind and a `light` flag (Swift's `.dither(light:)`, titled "Color Picker (Dither Dark Color)" or "Light"), with `openDitherColorPicker`, `previewDitherColor` and `setDitherColor`; Cancel and OK of the filter close it, as they close the Gradient Map's and the Vignette's (`filterColour`). The sheet's `dither` (`UI/FilterSheet+Dither.cpp`) is Swift's `ditherControls`: Style as a menu with separators between its groups, Pixel Size, Cell Size (halftone and ASCII), Angle (halftone), Characters (ASCII: a monospace field that writes as typed and shows the settings while unfocused), Tones (diffusion and ordered), Diffusion (diffusion), Density, Contrast, Colors as a menu, the Dark and Light swatches (Two Colors), Pixel Shape (chunky pixels) and Light on Dark (marks), each with Swift's help; menus and swatches follow the settings through `m_follows`. The busy line now shows only for a commit or an automatic kind's preparation, Swift's rule against a quick preview's flicker. `e70da06` needs nothing: `main` shows the window before it hands the files named at launch to the workspace. `29f0bea`'s guide sentence joins `writing-comp-files.md`. Swift has no Dither tests: `DitherTests` (styles, groups and defaults, normalization, Bayer's exact pattern and tones, diffusion, two colours and the original ones, alpha, chunky blocks and their cut edges, dots, marks either way, glyphs) `DitherKernelTests` (each diffusion, Bayer matrix, halftone shape and Mac pattern by hand-worked pixels, Original and Two Colors marks either way, a premultiplied block average, colour-byte rounding, Original dots' black gaps and smoothed rims, each range's clamp, rounding and fallback, a lone CR, a straight-alpha source premultiplied first, a non-default cell size on the screen and in ASCII, an accent's overlap inked as text is) and `DitherSheetTests` (the kind's place and full-size preview, the commit, an ASCII commit on the worker, the two pickers and their refusals, the controls by style, the sLine truncated
- Dither's ASCII as text (13.3a, Swift 1.3.4: `2309a85`): `kernels/DitherPixels.c/.h` are v1.3.4's, whose glyph cells take `glyphWidth` and `glyphHeight`. `DitherSettings` gains `textSize` (6–64, 14, rounded); ASCII draws at full resolution, chunky pixels and their shape left alone. A glyph's cell is one bold monospace M wide (its advance, rounded) and `textSize` tall; the face is `lround(lineHeight / 1.2)` whole pixels where CoreText takes the fraction (known difference, as before); every letter stands on one baseline, centred in the line as Swift's AppKit metrics set it (`(height − (ascent + descent)) / 2 + descent` from the bottom, rounded), and is centred on its advance. The sheet shows Text Size for ASCII ("The height of each line of characters") and hides Pixel Size, Cell Size and Pixel Shape there. Equivalent mutants: the horizontal centring (a monospace advance is the cell, so the offset rounds to 0; only a double-width fallback glyph would move, and the dev image has none) and the kernel's cell rows counted by the width (a cell is narrower than tall, so only rows past the picture are added, never read). Tests: the glyph geometry and coverage against references drawn here at line heights 20 and 64 in `DitherTests`, the tiles, the picked table over dark and light columns and every cell's pick from its whole cell's ink (edge cells included, predicted here) in `DitherKernelTests`, the rows, ends, steps, field and unit in `DitherSheetTests`.
- Select › Color Range (13.3b, Swift 1.3.4: `c3e360a`): `kernels/WandPixels.c/.h` are v1.3.4's, with `color_range_mask`. `Document/ColorRangeSelection.h/.cpp` twin Swift's file, `ColorRangeSelection+Session.h` its session half; `ColorRangeEdit` is a class in the session's `std::optional`, its `id` standing for Swift's object identity, its `image` the live composite at document size (`selectionSample` with every layer, premultiplied), so the kernel reads it directly where Swift draws it into a context first. `colorAt` averages the 3 × 3 pixels round the floored point, those past the picture clear as in Swift's context, and unpremultiplies with Swift's rounding; a point off the picture, clear or no number samples nothing. Each update is a worker of its own (`QtConcurrent::run` and a watcher a change, as Swift starts a task each) and lands only on the same edit, generation and document; it says so at once and again when its match lands, which sets the selection without a step (antialiased as the selection tools are) or, on a `MagicWandError` or `ExportError`, keeps the selection, shows the error and logs it on `omaphoto.app`. The preview is the mask as `Grayscale8` scaled smooth to twice the panel's fit, where Swift draws it at medium quality. OK applies the match as one `Color Range` step through `setSelection` (or `deselect` for a match of nothing) and always announces the close; with no colour or an error it keeps the old selection without a step; Cancel puts the old selection back. The gates (`canEditLayers`, `canUseHistory`, `canStartProjectOperation`) wait for the panel, and closing it resumes file requests (Swift's `didSet`; on opening it is a no-op, since an open panel blocks every waiter, and is left out). The sheet's writes (`setColorRangeSampleMode`, `setColorRangeHeld`, `setColorRangeFuzziness` clamped whole to 0–200, `setColorRangeInvert`) notify, an unchanged one silent. On the canvas Color Range is `picking`; a left press, Space aside, samples with Shift adding and Alt taking away (Alt first, as Swift's switch reads them) and refocuses the panel, after Levels' checks; the eyedropper cursor carries a black + or − on a white disc bottom right (`eyedropperCursor(ratio, badge)`) for the held key or the chosen eyedropper, and `syncPicking` redraws it when either changes; Shift and Alt reach `setColorRangeHeld` through the modifier monitor. Swift's `keepColorRangeCursor`, `pointerOverCanvas` and cursor-update monitor work round AppKit's cursor rects and have no twin. `UI/ColorRangeSheet` twins Swift's sheet: the three eyedroppers (`UI/SampleButton`, moved unchanged from the Hue/Saturation sheet so both share it), lit for the effective mode; the preview (black, the picture, a 20% white rim, the canvas's shape within 292 by 200); the caption in `PlaceholderText` (Alt for Option); Fuzziness as a scrubbable title, a slider and a 48-point field; Invert; the error in `BrightText`; Cancel and the default OK; 340 wide, 24 of padding. `ContentView` shows it in `colorRangePanel` while an edit is open, its close button and Escape cancelling, and the Select menu's Color Range… follows Subject, enabled by `canSelectColorRange`. Swift has no tests for this commit: `ColorRangeTests` pins the kernel, the sampling, the modes, Fuzziness, Invert, the preview, OK, Cancel, the gates, the announcements and an outline too detailed (a 2100-pixel checkerboard); `ColorRangeCanvasTests` the canvas's clicks and keys, the badged cursors, the sheet and the panel with its menu entry; `ColorRangeSheetTests` the sheet's layout, field, preview, Invert and error. Equivalent mutants: the no-colours branch's reset of the selection and preview (before any colour both hold those values already), the watcher's `deleteLater` (memory alone), the warning and `syncPicking`'s memory of the shown badge (it only spares cursor updates). Swift's `document` and `colorRange == nil` terms in `canSelectColorRange` are dead (`canEditLayers` reads both) and left out. Untested: the preview's two allocation checks and the worker's `ExportError` catch (only memory starvation reaches them), and the sheet's `releaseFocus` (without it the commit reads members that happen to live; no sanitizer runs).
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
+3
View File
@@ -91,6 +91,7 @@ add_library(compositor_core STATIC
src/Document/ShapeTool.cpp
src/Document/TypeTool.cpp
src/Document/TypeTool+Runs.cpp
src/Document/ColorRangeSelection.cpp
src/Document/SmudgeLiquify.cpp
src/UI/BlendModePicker.cpp
src/UI/BrushControls.cpp
@@ -116,6 +117,8 @@ add_library(compositor_core STATIC
src/UI/LayersPanel.cpp
src/UI/LevelsSheet.cpp
src/UI/HueSaturationSheet.cpp
src/UI/SampleButton.cpp
src/UI/ColorRangeSheet.cpp
src/UI/HueSaturationSheet+Spectrum.cpp
src/UI/CurvesControls.cpp
src/UI/NativeLayerList.cpp
+4 -2
View File
@@ -419,8 +419,10 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`)
- 13.2 review: round 1 (codex) FAIL: the folded face's parity unstated, and test gaps (overflow, start-of-text inheritance, newlines, an independent bold reference, editor geometry); round 2 (codex) FAIL: test gaps (neighbouring faces, size and tracking in layout, colour with faces, a missing location); round 3 (Fable 5.1) FAIL: test gaps (a negative location, a run starting at the span's end, fractional numbers, an unlisted face's own name). All fixed, each mutant caught; closed after the third round.
- [x] 13.3a Part two: Dither's ASCII as readable text (`2309a85`, the kernel and the sheet).
- 13.3a review (Fable 5.1): round 1 FAIL (test gaps: the face's divisor and the width's rounding at larger sizes, the coverage's mean, the Text Size row's ends, decimals and unit, the picked table over non-square cells); round 2 FAIL (the kernel's averaging window, height and width); round 3 PASS.
- [ ] 13.3b Part three: Select › Color Range (`c3e360a`: `ColorRangeSelection`, `WandPixels.c`, the sheet, the canvas's sampling).
- [ ] 13.4 Version 1.3.5, part one: Export JPEG and Canvas Size take the app's colour picker (`c580a38`); Export JPEG's zoomable preview and tidier layout (`ac6f309`); `908bed9` and `158cd53` judged (Swift concurrency and warnings).
- [x] 13.3b Part three: Select › Color Range (`c3e360a`: `ColorRangeSelection`, `WandPixels.c`, the sheet, the canvas's sampling).
- 13.3b review (Fable 5.1): round 1 FAIL (the sheet's native keys unbound; test gaps: the click's rows and floor, stale and foreign results, the selection kept on error, the preview's truncation); round 2 FAIL (OK after an error, the antialiased flag; two dead gate terms noted); round 3 PASS.
- [ ] 13.4a Version 1.3.5, part one: Export JPEG and Canvas Size take the app's colour picker (`c580a38`); `908bed9` and `158cd53` judged: Qt signals arrive on the GUI thread, and the digest already hashes a local's bytes.
- [ ] 13.4b Part two: Export JPEG's zoomable preview and tidier layout, the View menu's zoom on it, one OK on the import error (`ac6f309`).
- [ ] 13.5 Part two: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`); View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`).
- [ ] 13.6 Part three: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`).
- [ ] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone.
+31
View File
@@ -172,3 +172,34 @@ fail:
free(lens);
return -1;
}
static inline int color_near(const int *rgb, const uint8_t *colors, int count, int fuzziness) {
for (int i = 0; i < count; ++i) {
const uint8_t *c = colors + i * 3;
if (abs(rgb[0] - c[0]) <= fuzziness && abs(rgb[1] - c[1]) <= fuzziness && abs(rgb[2] - c[2]) <= fuzziness) return 1;
}
return 0;
}
long color_range_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
const uint8_t *include, int includeCount, const uint8_t *exclude, int excludeCount,
int fuzziness, int invert, uint8_t *mask) {
long count = 0;
for (size_t y = 0; y < height; ++y) {
const uint8_t *row = rgba + y * stride;
uint8_t *out = mask + y * width;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
int matches = 0;
if (px[3]) {
int rgb[3];
for (int c = 0; c < 3; ++c) rgb[c] = (px[c] * 255 + px[3] / 2) / px[3];
matches = color_near(rgb, include, includeCount, fuzziness) && !color_near(rgb, exclude, excludeCount, fuzziness);
}
if (invert) matches = !matches;
out[x] = matches ? 255 : 0;
count += matches;
}
}
return count;
}
+7
View File
@@ -10,6 +10,13 @@
// `mask` (width * height bytes). Returns the number selected, or -1 when memory runs out.
long wand_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
size_t seedX, size_t seedY, size_t radius, int tolerance, int contiguous, uint8_t *mask);
// Select > Color Range over premultiplied RGBA (as above). A pixel matches when every color channel is within
// `fuzziness` of one of the `includeCount` colors in `include` and of none in `exclude` (straight sRGB, 3 bytes
// each). Transparent pixels never match. With `invert`, the pixels that don't match are selected instead. Writes
// 255 for selected, 0 elsewhere, into `mask`, and returns the number selected.
long color_range_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
const uint8_t *include, int includeCount, const uint8_t *exclude, int excludeCount,
int fuzziness, int invert, uint8_t *mask);
// Outline of the nonzero pixels of `mask`, along pixel edges, as closed loops of corner points
// (x, y pairs in pixel-edge coordinates). Outer boundaries run clockwise and holes
// counterclockwise in top-left coordinates, so the winding rule fills exactly those pixels.
+11
View File
@@ -1,4 +1,5 @@
#include "ContentView.h"
#include "UI/ColorRangeSheet.h"
#include "UI/LassoControls.h"
#include "UI/EffectsSheet.h"
#include "UI/FilterSheet.h"
@@ -41,6 +42,16 @@ void ContentView::synchronizePanels()
m_selectionAmountPanel.close();
}
}
// Swift's onChange of colorRange == nil.
if (m_colorRangeShown != m_session.colorRange().has_value()) {
m_colorRangeShown = m_session.colorRange().has_value();
if (m_colorRangeShown) {
m_colorRangePanel.onClose = [this] { m_session.cancelColorRange(); };
m_colorRangePanel.show(QStringLiteral("Color Range"), new ColorRangeSheet(m_session));
} else {
m_colorRangePanel.close();
}
}
if (m_filterShown != m_session.filterEdit().has_value()) {
m_filterShown = m_session.filterEdit().has_value();
if (m_filterShown) {
+2
View File
@@ -102,5 +102,7 @@ private:
FloatingPanel m_effectsPanel{QStringLiteral("effectsPanel"), *this};
FloatingPanel m_selectionAmountPanel{QStringLiteral("selectionAmountPanel"), *this};
std::optional<SelectionAmountOperation> m_selectionAmountShown;
FloatingPanel m_colorRangePanel{QStringLiteral("colorRangePanel"), *this};
bool m_colorRangeShown = false;
std::optional<LayerEffectSelection> m_effectsShown;
};
@@ -0,0 +1,15 @@
// Select › Color Range's panel; its selection shows until OK.
const std::optional<ColorRangeEdit> &colorRange() const { return m_colorRange; }
bool canSelectColorRange() const;
void beginColorRange();
// A canvas click: Shift adds, Alt takes away.
void sampleColorRange(QPointF point, bool shift, bool option);
// Matches off the UI thread; a newer change supersedes it.
void updateColorRange();
void commitColorRange();
void cancelColorRange();
// The sheet's and the canvas's writes, Swift's edit fields.
void setColorRangeSampleMode(HueSampleMode mode);
void setColorRangeHeld(std::optional<HueSampleMode> held);
void setColorRangeFuzziness(double fuzziness);
void setColorRangeInvert(bool invert);
+235
View File
@@ -0,0 +1,235 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
extern "C" {
#include "WandPixels.h"
}
// Swift's ColorRangeSelection.swift: the session's half.
namespace {
struct ColorRangeJob {
QImage image;
std::vector<uchar> include;
std::vector<uchar> exclude;
int fuzziness;
bool invert;
};
struct ColorRangeResult {
std::optional<QPainterPath> path;
QImage preview;
std::optional<QString> error;
};
// The straight colour under `point`, over 3 × 3 pixels.
std::optional<std::vector<uchar>> colorAt(const QImage &image, QPointF point)
{
if (!std::isfinite(point.x()) || !std::isfinite(point.y()))
return std::nullopt;
const double fx = std::floor(point.x()), fy = std::floor(point.y());
if (fx < 0 || fy < 0 || fx >= image.width() || fy >= image.height())
return std::nullopt;
const int x = int(fx), y = int(fy);
std::array<int, 4> sums{};
// Past the picture's edge, Swift's context holds clear pixels.
for (int row = y - 1; row <= y + 1; ++row)
for (int column = x - 1; column <= x + 1; ++column) {
if (row < 0 || column < 0 || row >= image.height() || column >= image.width())
continue;
const uchar *pixel = image.constScanLine(row) + column * 4;
for (int channel = 0; channel < 4; ++channel)
sums[size_t(channel)] += pixel[channel];
}
if (sums[3] <= 0)
return std::nullopt;
std::vector<uchar> color(3);
for (int channel = 0; channel < 3; ++channel)
color[size_t(channel)] = uchar(std::min(255, (sums[size_t(channel)] * 255 + sums[3] / 2) / sums[3]));
return color;
}
std::vector<uchar> colorRangeMask(const ColorRangeJob &job)
{
const int width = job.image.width(), height = job.image.height();
try {
std::vector<uchar> mask(size_t(width) * size_t(height));
color_range_mask(job.image.constBits(), size_t(width), size_t(height), size_t(job.image.bytesPerLine()), job.include.data(),
int(job.include.size() / 3), job.exclude.data(), int(job.exclude.size() / 3), job.fuzziness, job.invert ? 1 : 0,
mask.data());
return mask;
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
// The mask shrunk to the panel's preview, twice its size.
QImage preview(const std::vector<uchar> &mask, int width, int height)
{
const double scale = std::min(ColorRangeEdit::previewSize.width() / width, ColorRangeEdit::previewSize.height() / height) * 2;
const int w = std::max(1, int(width * scale)), h = std::max(1, int(height * scale));
QImage full(width, height, QImage::Format_Grayscale8);
if (full.isNull())
throw ExportError(ExportError::Kind::render);
for (int y = 0; y < height; ++y)
std::copy_n(mask.data() + size_t(y) * size_t(width), width, full.scanLine(y));
const QImage shrunk = full.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (shrunk.isNull())
throw ExportError(ExportError::Kind::render);
return shrunk;
}
ColorRangeResult colorRangeResult(const ColorRangeJob &job)
{
ColorRangeResult result;
try {
const std::vector<uchar> mask = colorRangeMask(job);
result.preview = preview(mask, job.image.width(), job.image.height());
if (std::any_of(mask.begin(), mask.end(), [](uchar value) { return value != 0; }))
result.path = MagicWand::outline(mask, job.image.width(), job.image.height());
} catch (const MagicWandError &error) {
result.error = QString::fromUtf8(error.what());
} catch (const ExportError &error) {
result.error = QString::fromUtf8(error.what());
}
return result;
}
}
bool EditorSession::canSelectColorRange() const
{
return canEditSelection();
}
void EditorSession::beginColorRange()
{
if (!canSelectColorRange())
return;
const std::optional<QImage> image = selectionSample(*m_document, true);
if (!image)
return;
m_colorRange.emplace(*image, m_document->selection);
notify();
}
void EditorSession::sampleColorRange(QPointF point, bool shift, bool option)
{
if (!m_colorRange)
return;
ColorRangeEdit &edit = m_colorRange.value();
const std::optional<std::vector<uchar>> color = colorAt(edit.image, point);
if (!color)
return;
switch (option ? HueSampleMode::remove : shift ? HueSampleMode::add : edit.sampleMode) {
case HueSampleMode::replace:
edit.include = *color;
edit.exclude.clear();
break;
case HueSampleMode::add:
edit.include.insert(edit.include.end(), color->begin(), color->end());
break;
case HueSampleMode::remove:
edit.exclude.insert(edit.exclude.end(), color->begin(), color->end());
break;
}
updateColorRange();
}
void EditorSession::updateColorRange()
{
if (!m_colorRange || !m_document)
return;
ColorRangeEdit &edit = m_colorRange.value();
const int generation = ++edit.generation;
if (!edit.hasColors()) {
m_document->selection = edit.original;
edit.preview = QImage();
notify();
return;
}
const ColorRangeJob job{edit.image, edit.include, edit.exclude, int(std::round(edit.fuzziness)), edit.invert};
// A watcher a change, as Swift starts a task each.
auto *watcher = new QFutureWatcher<ColorRangeResult>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, id = edit.id, generation, document = m_document->id] {
watcher->deleteLater();
const ColorRangeResult result = watcher->result();
if (!m_colorRange || m_colorRange->id != id || m_colorRange->generation != generation || !m_document || m_document->id != document)
return;
m_colorRange->error = result.error;
m_colorRange->preview = result.preview;
if (result.error) {
qCWarning(lcApp).noquote() << "Color Range could not select:" << *result.error;
} else {
m_document->selection = result.path ? std::optional(DocumentSelection{*result.path, m_selectionAntialiased}) : std::nullopt;
}
notify();
});
watcher->setFuture(QtConcurrent::run([job] { return colorRangeResult(job); }));
// The colours changed now; their match lands later.
notify();
}
void EditorSession::commitColorRange()
{
if (!m_colorRange)
return;
const std::optional<DocumentSelection> result = m_document->selection;
const ColorRangeEdit edit = std::exchange(m_colorRange, std::nullopt).value();
m_document->selection = edit.original;
resumeFileRequests();
if (edit.hasColors() && !edit.error) {
if (result)
setSelection(result, QStringLiteral("Color Range"));
else
deselect();
}
// Closing is news even when the selection stays.
notify();
}
void EditorSession::cancelColorRange()
{
if (!m_colorRange)
return;
m_document->selection = m_colorRange->original;
m_colorRange.reset();
resumeFileRequests();
notify();
}
void EditorSession::setColorRangeSampleMode(HueSampleMode mode)
{
if (!m_colorRange || m_colorRange->sampleMode == mode)
return;
m_colorRange->sampleMode = mode;
notify();
}
void EditorSession::setColorRangeHeld(std::optional<HueSampleMode> held)
{
if (!m_colorRange || m_colorRange->held == held)
return;
m_colorRange->held = held;
notify();
}
void EditorSession::setColorRangeFuzziness(double fuzziness)
{
// Swift's binding: whole, within the range, changed or nothing.
const double clamped = std::min(ColorRangeEdit::fuzzinessHigh, std::max(ColorRangeEdit::fuzzinessLow, std::round(fuzziness)));
if (!m_colorRange || m_colorRange->fuzziness == clamped)
return;
m_colorRange->fuzziness = clamped;
updateColorRange();
}
void EditorSession::setColorRangeInvert(bool invert)
{
if (!m_colorRange)
return;
m_colorRange->invert = invert;
updateColorRange();
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "Document/HueSaturation.h"
#include "Document/Selection.h"
#include <QImage>
#include <QSizeF>
#include <QUuid>
#include <optional>
#include <vector>
// Select › Color Range: pixels near the clicked colours, anywhere.
class ColorRangeEdit {
public:
static constexpr double fuzzinessLow = 0, fuzzinessHigh = 200;
// The panel's preview fits in this, in points.
static constexpr QSizeF previewSize{292, 200};
ColorRangeEdit(QImage image, std::optional<DocumentSelection> original) : image(std::move(image)), original(std::move(original)) {}
double fuzziness = 40;
bool invert = false;
// The next click: start over, add or take away.
HueSampleMode sampleMode = HueSampleMode::replace;
// Shift (add) or Alt (take away) held, over the mode.
std::optional<HueSampleMode> held;
HueSampleMode effectiveMode() const { return held.value_or(sampleMode); }
// The selection in black and white; null until a colour.
QImage preview;
// Straight sRGB colours, three bytes each.
std::vector<uchar> include;
std::vector<uchar> exclude;
std::optional<QString> error;
// The composite at document size: what colours are matched against.
QImage image;
std::optional<DocumentSelection> original;
int generation = 0;
// Swift's object identity: a result lands on its own edit.
QUuid id = QUuid::createUuid();
bool hasColors() const { return !include.empty(); }
};
+2 -2
View File
@@ -9,8 +9,8 @@
bool EditorSession::canStartProjectOperation() const
{
return !m_selectionAmountOperation && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument
&& !m_showsImporter && !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID;
return !m_selectionAmountOperation && !m_colorRange && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke
&& !m_showsNewDocument && !m_showsImporter && !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID;
}
void EditorSession::waitForFileRequest(std::function<void()> ready)
+3 -3
View File
@@ -198,8 +198,8 @@ void EditorSession::cycleToolMode()
bool EditorSession::canUseHistory() const
{
return !m_selectionAmountOperation && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument
&& !m_showsImporter && !m_renamingLayerID && !m_importError && !m_transformEdit && !m_levels;
return !m_selectionAmountOperation && !m_colorRange && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke
&& !m_showsNewDocument && !m_showsImporter && !m_renamingLayerID && !m_importError && !m_transformEdit && !m_levels;
}
void EditorSession::undo()
@@ -256,7 +256,7 @@ void EditorSession::endEdit()
bool EditorSession::canEditLayers() const
{
return !m_selectionAmountOperation && !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting
return !m_selectionAmountOperation && !m_colorRange && !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting
&& !m_showsNewDocument && !m_showsImporter
&& !m_renamingLayerID && !m_transformEdit && !m_cropRect && !m_gradientEdit && !m_filterEdit && !m_pixelMove && !m_levels
&& !m_hueSaturation && !m_adjustmentEditingID;
+3 -2
View File
@@ -2,6 +2,7 @@
#include "Document/BrushStroke.h"
#include "Document/CloneStamp.h"
#include "Document/ColorPalette.h"
#include "Document/ColorRangeSelection.h"
#include "Document/Gradient.h"
#include "Document/Distort.h"
#include "Document/DocumentLimits.h"
@@ -282,7 +283,6 @@ public:
void setBrushError(std::optional<QString> error);
void notify() { emit changed(); }
// Swift's extensions, each declared in its twin's header.
#include "Document/EditorSession+Projects.h"
#include "IO/ImageResizer+Session.h"
@@ -319,6 +319,7 @@ public:
#include "Document/CameraRaw+Session.h"
#include "Document/Guides+Session.h"
#include "Document/ObjectSelection+Session.h"
#include "Document/ColorRangeSelection+Session.h"
signals:
void changed();
@@ -346,7 +347,6 @@ private:
mutable std::map<QUuid, DistortPreviewCache> m_distortPreviewCache;
mutable std::map<QUuid, DistortEffectsCache> m_distortEffectsCache;
mutable std::optional<MaskDistortPreviewCache> m_maskDistortPreviewCache;
std::optional<TransformEdit> m_transformEdit;
std::optional<TransformDuplicate> m_transformDuplicate;
bool m_transformAutoSelect = ToolDefaults::boolean(QStringLiteral("autoSelect"), false);
@@ -495,4 +495,5 @@ private:
int m_selectionContractAmount = 1;
int m_selectionFeatherAmount = 2;
std::optional<SelectionAmountOperation> m_selectionAmountOperation;
std::optional<ColorRangeEdit> m_colorRange;
};
+17 -2
View File
@@ -200,7 +200,7 @@ QCursor CanvasView::wandCursor(SelectionMode mode, double ratio)
return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y()));
}
QCursor CanvasView::eyedropperCursor(double ratio)
QCursor CanvasView::eyedropperCursor(double ratio, HueSampleMode badge)
{
QPixmap pixmap = cursorPixmap(QSize(24, 24), ratio);
QPainter painter(&pixmap);
@@ -220,6 +220,18 @@ QCursor CanvasView::eyedropperCursor(double ratio)
for (int step = 0; step < 16; ++step)
draw(QPointF(std::cos(step * M_PI / 8), std::sin(step * M_PI / 8)) * 1.25, Qt::white);
draw(QPointF(), Qt::black);
if (badge != HueSampleMode::replace) {
// Bottom right, clear of the tip: a signed disc.
const QRectF spot(12.5, 12.5, 11, 11);
painter.setPen(QPen(Qt::black, 1));
painter.setBrush(Qt::white);
painter.drawEllipse(spot);
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::black);
painter.drawRect(QRectF(spot.center().x() - 3, spot.center().y() - 0.8, 6, 1.6));
if (badge == HueSampleMode::add)
painter.drawRect(QRectF(spot.center().x() - 0.8, spot.center().y() - 3, 1.6, 6));
}
// The dropper's tip is the hot spot, as Swift's.
const QPointF tip = glyph.topLeft() + EyedropperIcon::tip() * scale;
return QCursor(pixmap, int(std::round(tip.x())), int(std::round(tip.y())));
@@ -263,7 +275,7 @@ void CanvasView::updateCursor()
setCursor(Qt::ClosedHandCursor);
// Picking outranks Space and the tools, as Swift's cursor rects.
else if (picking())
setCursor(eyedropperCursor(devicePixelRatio()));
setCursor(eyedropperCursor(devicePixelRatio(), m_session.colorRange() ? m_session.colorRange().value().effectiveMode() : HueSampleMode::replace));
else if (m_session.hueTargeting())
setCursor(Qt::SizeHorCursor);
else if (m_spaceHeld || tool == NavigationTool::hand)
@@ -365,6 +377,9 @@ void CanvasView::modifiersChanged()
// As Swift's monitor: Alt may start or end picking.
synchronizeDisplay();
m_session.updateHeldSelectionKeys(m_shiftHeld, m_optionHeld);
// Color Range: Shift adds and Alt takes away while held.
if (m_session.colorRange())
m_session.setColorRangeHeld(m_optionHeld ? std::optional(HueSampleMode::remove) : m_shiftHeld ? std::optional(HueSampleMode::add) : std::nullopt);
updateCursor();
updateBrushCursor();
}
@@ -156,6 +156,13 @@ void CanvasView::press(QMouseEvent *event, int clicks)
// Open Levels leaves the canvas to panning and zooming.
if (m_session.levels() && !m_spaceHeld && m_session.tool() != NavigationTool::hand && m_session.tool() != NavigationTool::zoom)
return;
// Color Range takes the colour; its panel keeps the keys.
if (m_session.colorRange() && !m_spaceHeld) {
m_session.sampleColorRange(m_session.viewport.documentPoint(point, m_session.document()->size()), event->modifiers().testFlag(Qt::ShiftModifier),
event->modifiers().testFlag(Qt::AltModifier));
FloatingPanel::refocus(QStringLiteral("colorRangePanel"));
return;
}
if (!m_spaceHeld && pickingPress(point))
return;
if (m_spaceHeld || m_session.tool() == NavigationTool::hand) {
+7 -1
View File
@@ -14,7 +14,7 @@ bool CanvasView::palettePicking() const
bool CanvasView::picking() const
{
const std::optional<FilterEdit> &edit = m_session.filterEdit();
return palettePicking() || m_session.colorPicker() || m_session.hueSampleMode() || (m_session.levels() && m_session.levels()->sampleMode)
return palettePicking() || m_session.colorPicker() || m_session.hueSampleMode() || (m_session.levels() && m_session.levels()->sampleMode) || m_session.colorRange()
|| (edit
&& (edit->rawPanel.samplesWhiteBalance || edit->rawPanel.samplesPointColor || edit->rawPanel.samplesDefringe
|| edit->rawPanel.drawingGeometryGuide));
@@ -23,6 +23,12 @@ bool CanvasView::picking() const
// Picking or targeting begins or ends: a sampling goes.
void CanvasView::syncPicking()
{
// The sheet's eyedroppers change the badge, not the sampling.
const std::optional<HueSampleMode> range = m_session.colorRange() ? std::optional(m_session.colorRange().value().effectiveMode()) : std::nullopt;
if (range != m_displayedRangeMode) {
m_displayedRangeMode = range;
updateCursor();
}
if (m_displayedPicking == picking() && m_displayedTargeting == m_session.hueTargeting())
return;
m_displayedPicking = picking();
+4 -2
View File
@@ -51,8 +51,8 @@ public:
static QCursor selectionCursor(SelectionIcon icon, SelectionMode mode, double ratio);
// The wand, its sparkle at the hot spot, badged alike.
static QCursor wandCursor(SelectionMode mode, double ratio);
// Swift's eyedropper: haloed white, its tip the hot spot.
static QCursor eyedropperCursor(double ratio);
// Swift's eyedropper, haloed, its tip the hot spot; badged.
static QCursor eyedropperCursor(double ratio, HueSampleMode badge = HueSampleMode::replace);
// The brush circle as last drawn, for tests.
const BrushCursorOverlay &brushCursor() const { return m_brushCursor; }
const SampleRingOverlay &sampleRing() const { return m_sampleRing; }
@@ -397,6 +397,8 @@ private:
bool m_samplingColor = false;
bool m_displayedPicking = false;
bool m_displayedTargeting = false;
// Color Range's eyedropper, which its badge shows.
std::optional<HueSampleMode> m_displayedRangeMode;
// Where a targeted drag began, in view points.
std::optional<QPointF> m_hueTargetStart;
std::optional<CropDrag> m_cropDrag;
+180
View File
@@ -0,0 +1,180 @@
#include "UI/ColorRangeSheet.h"
#include "UI/ColorPickerSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/NumericScrub.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
#include <QPainter>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSlider>
#include <QVBoxLayout>
#include <cmath>
namespace {
QString rangeHelp(HueSampleMode mode)
{
switch (mode) {
case HueSampleMode::replace: return QStringLiteral("Click the image to select that color");
case HueSampleMode::add: return QStringLiteral("Click the image to add that color to the selection");
case HueSampleMode::remove: return QStringLiteral("Click the image to take that color out of the selection");
}
throw std::logic_error("unknown sample mode");
}
// The selection in black and white, shaped like the canvas.
class RangePreview : public QWidget {
public:
RangePreview(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session)
{
const QSizeF limit = ColorRangeEdit::previewSize;
const QSizeF size = m_session.colorRange() ? QSizeF(m_session.colorRange()->image.size()) : limit;
const double scale = std::min(limit.width() / size.width(), limit.height() / size.height());
setFixedSize(int(std::round(size.width() * scale)), int(std::round(size.height() * scale)));
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(rect(), Qt::black);
if (m_session.colorRange() && !m_session.colorRange()->preview.isNull()) {
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.drawImage(QRectF(rect()), m_session.colorRange()->preview);
}
// Swift's strokeBorder: a white line inside, at 20%.
painter.setPen(QPen(QColor(255, 255, 255, 51), 1));
painter.drawRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5));
}
private:
EditorSession &m_session;
};
}
ColorRangeSheet::ColorRangeSheet(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_preview(new RangePreview(session, this)), m_caption(new QLabel(this)), m_slider(new QSlider(Qt::Horizontal, this)),
m_field(new PickerField(
[this] {
bool number = false;
const double typed = m_field->locale().toDouble(m_field->text(), &number);
if (m_field->isModified() && number && std::isfinite(typed))
m_session.setColorRangeFuzziness(typed);
m_field->setModified(false);
synchronize();
},
nullptr, this)),
m_invert(new QCheckBox(QStringLiteral("Invert"), this)), m_error(new QLabel(this)), m_ok(new QPushButton(QStringLiteral("OK"), this))
{
setFixedWidth(340);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
auto *droppers = new QHBoxLayout;
droppers->setSpacing(6);
for (size_t index = 0; index < allHueSampleModes.size(); ++index) {
const HueSampleMode mode = allHueSampleModes[index];
auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { sampleEyedropper(painter, mode, ink); }, this);
button->setObjectName(QStringLiteral("colorRange") + rawValue(mode));
button->setToolTip(rangeHelp(mode));
button->setAccessibleName(rawValue(mode) + QStringLiteral(" color"));
connect(button, &QAbstractButton::clicked, this, [this, mode] { m_session.setColorRangeSampleMode(mode); });
m_eyedroppers[index] = button;
droppers->addWidget(button);
}
droppers->addStretch(1);
column->addLayout(droppers);
column->addWidget(m_preview, 0, Qt::AlignHCenter);
m_caption->setObjectName(QStringLiteral("colorRangeCaption"));
m_caption->setWordWrap(true);
QFont callout = m_caption->font();
callout.setPixelSize(12);
m_caption->setFont(callout);
m_caption->setForegroundRole(QPalette::PlaceholderText);
column->addWidget(m_caption);
// Fuzziness: a scrubbable title, the slider, the field.
auto *row = new QWidget(this);
row->setToolTip(QStringLiteral("How far a color may be from the picked ones and still be selected"));
auto *fuzziness = new QHBoxLayout(row);
fuzziness->setContentsMargins(0, 0, 0, 0);
fuzziness->setSpacing(10);
auto *title = new QLabel(QStringLiteral("Fuzziness"), row);
new NumericScrub(title, {.sensitivity = 1, .low = ColorRangeEdit::fuzzinessLow, .high = ColorRangeEdit::fuzzinessHigh, .step = std::nullopt,
.value = [this] { return m_session.colorRange() ? m_session.colorRange()->fuzziness : 40; },
.set = [this](double value) {
// Its value replaces the field's typing.
m_field->setModified(false);
m_session.setColorRangeFuzziness(value);
}});
title->setBuddy(m_slider);
m_slider->setObjectName(QStringLiteral("fuzzinessSlider"));
m_slider->setRange(int(ColorRangeEdit::fuzzinessLow), int(ColorRangeEdit::fuzzinessHigh));
connect(m_slider, &QSlider::valueChanged, this, [this](int value) {
m_field->setModified(false);
m_session.setColorRangeFuzziness(value);
});
m_field->setObjectName(QStringLiteral("fuzzinessField"));
m_field->setAccessibleName(QStringLiteral("Fuzziness"));
m_field->setPlaceholderText(QStringLiteral("Fuzziness"));
m_field->setAlignment(Qt::AlignRight);
m_field->setFixedWidth(48);
fuzziness->addWidget(title);
fuzziness->addWidget(m_slider, 1);
fuzziness->addWidget(m_field);
column->addWidget(row);
m_invert->setObjectName(QStringLiteral("colorRangeInvert"));
m_invert->setToolTip(QStringLiteral("Select everything except those colors, such as all but a green screen"));
connect(m_invert, &QCheckBox::clicked, this, [this](bool checked) { m_session.setColorRangeInvert(checked); });
column->addWidget(m_invert);
m_error->setObjectName(QStringLiteral("colorRangeError"));
m_error->setWordWrap(true);
m_error->setForegroundRole(QPalette::BrightText);
column->addWidget(m_error);
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::HLine);
divider->setForegroundRole(QPalette::Mid);
column->addWidget(divider);
auto *buttons = new QHBoxLayout;
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] { m_session.cancelColorRange(); });
m_ok->setDefault(true);
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitColorRange(); });
buttons->addWidget(cancel);
buttons->addStretch(1);
buttons->addWidget(m_ok);
column->addLayout(buttons);
NativeShortcut::bind(*this, m_ok, cancel);
connect(&m_session, &EditorSession::changed, this, &ColorRangeSheet::synchronize);
synchronize();
}
ColorRangeSheet::~ColorRangeSheet()
{
releaseFocus(*this);
}
void ColorRangeSheet::synchronize()
{
const std::optional<ColorRangeEdit> &edit = m_session.colorRange();
if (!edit)
return;
for (size_t index = 0; index < m_eyedroppers.size(); ++index)
m_eyedroppers[index]->setChecked(edit->effectiveMode() == allHueSampleModes[index]);
m_caption->setText(edit->hasColors() ? QStringLiteral("Shift-click adds a color, Alt-click takes one away.")
: QStringLiteral("Click the image to pick the color to select."));
{
const QSignalBlocker quiet(m_slider);
m_slider->setValue(int(edit->fuzziness));
}
if (!m_field->hasFocus() || !m_field->isModified())
m_field->setText(m_field->locale().toString(edit->fuzziness, 'f', 0));
m_invert->setChecked(edit->invert);
m_error->setText(edit->error.value_or(QString()));
m_error->setVisible(edit->error.has_value());
m_preview->update();
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "Document/EditorSession.h"
#include <QWidget>
#include <array>
class QCheckBox;
class QLabel;
class QPushButton;
class QSlider;
class PickerField;
class SampleButton;
// Swift's ColorRangeSheet: eyedroppers, preview, Fuzziness, Invert.
class ColorRangeSheet : public QWidget {
Q_OBJECT
public:
explicit ColorRangeSheet(EditorSession &session, QWidget *parent = nullptr);
~ColorRangeSheet() override;
private:
void synchronize();
EditorSession &m_session;
std::array<SampleButton *, 3> m_eyedroppers{};
QWidget *m_preview;
QLabel *m_caption;
QSlider *m_slider;
PickerField *m_field;
QCheckBox *m_invert;
QLabel *m_error;
QPushButton *m_ok;
};
+2
View File
@@ -198,6 +198,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
});
add(select, QStringLiteral("subject"), QStringLiteral("Subject"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_A),
[this] { session().selectSubject(SelectionMode::replace, {}); });
add(select, QStringLiteral("colorRange"), QStringLiteral("Color Range…"), QKeySequence(), [this] { session().beginColorRange(); });
add(select, QStringLiteral("maskBlackAreas"), QStringLiteral("Mask's Black Areas"), QKeySequence(), [this] {
if (const std::optional<QUuid> id = session().activeLayerID())
session().loadMaskSelection(*id);
@@ -380,6 +381,7 @@ void CompositorMenus::synchronize()
action(QStringLiteral("layerPixels"))->setEnabled(active && active->asset && s.canEditSelection());
action(QStringLiteral("maskBlackAreas"))->setEnabled(active && active->mask && s.canEditSelection());
action(QStringLiteral("subject"))->setEnabled(s.canSelectSubject());
action(QStringLiteral("colorRange"))->setEnabled(s.canSelectColorRange());
for (const char *name : {"expandSelection", "contractSelection", "featherSelection"})
action(QString::fromLatin1(name))->setEnabled(s.canModifySelection());
action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
+2 -54
View File
@@ -3,6 +3,7 @@
#include "Rendering/EyedropperIcon.h"
#include "UI/ColorPickerSheet.h"
#include "UI/NumericScrub.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QComboBox>
#include <QFrame>
@@ -62,59 +63,6 @@ HueSaturationSettings current(const EditorSession &session)
return session.hueSaturation() ? session.hueSaturation()->settings : HueSaturationSettings();
}
// Swift's plain sampling buttons: a glyph, tinted when chosen.
class SampleButton : public QToolButton {
public:
SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent) : QToolButton(parent), m_glyph(std::move(glyph))
{
setCheckable(true);
setFixedSize(24, 20);
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (isChecked()) {
QColor tint = palette().color(QPalette::Highlight);
tint.setAlphaF(0.25f);
painter.setPen(Qt::NoPen);
painter.setBrush(tint);
painter.drawRoundedRect(QRectF(rect()), 4, 4);
}
m_glyph(painter, palette().color(QPalette::WindowText));
}
private:
const std::function<void(QPainter &, const QColor &)> m_glyph;
};
// Swift's eyedropper, badged plus or minus for Add and Remove.
void eyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink)
{
// A 14-point glyph, centred in the 24 by 20 frame.
painter.save();
painter.translate(5, 3);
painter.save();
painter.scale(14.0 / 18, 14.0 / 18);
painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
EyedropperIcon::paint(painter);
painter.restore();
if (mode != HueSampleMode::replace) {
// An 8-point disc, bottom right, nudged 3 and 1.
QPainterPath badge;
badge.addEllipse(QRectF(9, 7, 8, 8));
QPainterPath sign;
sign.setFillRule(Qt::WindingFill);
sign.addRect(QRectF(10.75, 10.4, 4.5, 1.2));
if (mode == HueSampleMode::add)
sign.addRect(QRectF(12.4, 8.75, 1.2, 4.5));
painter.fillPath(badge.subtracted(sign), ink);
}
painter.restore();
}
// Swift's hand.point.up.left: a finger up and left, outlined.
void pointingHand(QPainter &painter, const QColor &ink)
{
@@ -133,7 +81,7 @@ void pointingHand(QPainter &painter, const QColor &ink)
QToolButton *sampleButton(HueSampleMode mode, QWidget *parent)
{
auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { eyedropper(painter, mode, ink); }, parent);
auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { sampleEyedropper(painter, mode, ink); }, parent);
button->setObjectName(QStringLiteral("hue") + rawValue(mode));
button->setToolTip(help(mode));
button->setAccessibleName(rawValue(mode) + QStringLiteral(" color"));
+48
View File
@@ -0,0 +1,48 @@
#include "UI/SampleButton.h"
#include "Rendering/EyedropperIcon.h"
#include <QPainter>
#include <QPainterPath>
SampleButton::SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent) : QToolButton(parent), m_glyph(std::move(glyph))
{
setCheckable(true);
setFixedSize(24, 20);
}
void SampleButton::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (isChecked()) {
QColor tint = palette().color(QPalette::Highlight);
tint.setAlphaF(0.25f);
painter.setPen(Qt::NoPen);
painter.setBrush(tint);
painter.drawRoundedRect(QRectF(rect()), 4, 4);
}
m_glyph(painter, palette().color(QPalette::WindowText));
}
void sampleEyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink)
{
// A 14-point glyph, centred in the 24 by 20 frame.
painter.save();
painter.translate(5, 3);
painter.save();
painter.scale(14.0 / 18, 14.0 / 18);
painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
EyedropperIcon::paint(painter);
painter.restore();
if (mode != HueSampleMode::replace) {
// An 8-point disc, bottom right, nudged 3 and 1.
QPainterPath badge;
badge.addEllipse(QRectF(9, 7, 8, 8));
QPainterPath sign;
sign.setFillRule(Qt::WindingFill);
sign.addRect(QRectF(10.75, 10.4, 4.5, 1.2));
if (mode == HueSampleMode::add)
sign.addRect(QRectF(12.4, 8.75, 1.2, 4.5));
painter.fillPath(badge.subtracted(sign), ink);
}
painter.restore();
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "Document/HueSaturation.h"
#include <QToolButton>
#include <functional>
// Swift's plain sampling buttons: a glyph, tinted when chosen.
class SampleButton : public QToolButton {
Q_OBJECT
public:
SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent);
protected:
void paintEvent(QPaintEvent *event) override;
private:
const std::function<void(QPainter &, const QColor &)> m_glyph;
};
// Swift's eyedropper, badged plus or minus for Add and Remove.
void sampleEyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink);
+211
View File
@@ -0,0 +1,211 @@
#include "AcceptanceFixtures.h"
#include "SelectionCanvasFixtures.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ColorRangeSheet.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QLabel>
#include <QPushButton>
#include <QSlider>
// Swift 1.3.4's Color Range: canvas and panel.
namespace {
// Red left, blue right, filling the canvas.
struct Ranged : Canvas {
Ranged()
{
QImage image(400, 300, QImage::Format_RGBA8888_Premultiplied);
image.fill(QColor(200, 30, 30));
for (int y = 0; y < 300; ++y)
for (int x = 200; x < 400; ++x)
image.setPixelColor(x, y, QColor(30, 40, 210));
session.insert(ImportedImage(image, image, QStringLiteral("Halves")));
session.beginColorRange();
canvas->synchronizeDisplay();
}
const ColorRangeEdit &edit() const { return session.colorRange().value(); }
};
QWidget *panel()
{
for (QWidget *widget : QApplication::topLevelWidgets())
if (widget->objectName() == QStringLiteral("colorRangePanel") && widget->isVisible())
return widget;
return nullptr;
}
}
class ColorRangeCanvasTests : public QObject {
Q_OBJECT
private slots:
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
void aClickTakesTheColourShiftAddsAltTakesAway();
void theEyedropperSaysWhatAClickWillDo();
void theSheetDrivesTheEdit();
void thePanelFollowsTheEditAndTheMenu();
};
void ColorRangeCanvasTests::aClickTakesTheColourShiftAddsAltTakesAway()
{
Ranged shown;
shown.click(QPointF(50, 50));
QCOMPARE(shown.edit().include, (std::vector<uchar>{200, 30, 30}));
shown.click(QPointF(300, 50), Qt::ShiftModifier);
QCOMPARE(shown.edit().include, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
shown.click(QPointF(50, 50), Qt::AltModifier);
QCOMPARE(shown.edit().exclude, (std::vector<uchar>{200, 30, 30}));
// Alt with Shift takes away too, as Swift's order reads.
shown.click(QPointF(300, 50), Qt::AltModifier | Qt::ShiftModifier);
QCOMPARE(shown.edit().exclude, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
// With Space held the press pans instead.
const QSizeF pan = shown.session.viewport.pan;
QTest::keyPress(shown.canvas, Qt::Key_Space);
shown.drag(QPointF(100, 100), QPointF(110, 120));
QTest::keyRelease(shown.canvas, Qt::Key_Space);
QCOMPARE(shown.session.viewport.pan, pan + QSizeF(10, 20));
QCOMPARE(shown.edit().exclude.size(), size_t(6));
// The match lands on the canvas as the selection.
shown.click(QPointF(300, 50));
QTRY_VERIFY(shown.session.selection() && shown.coverage(300, 50) == 255);
QCOMPARE(shown.coverage(50, 50), 0);
// Under the Eyedropper the click is the range's alone.
shown.session.cancelColorRange();
shown.session.selectTool(NavigationTool::eyedropper);
shown.session.beginColorRange();
const PaletteColor foreground = shown.session.foregroundColor();
shown.click(QPointF(50, 50));
QVERIFY(shown.session.foregroundColor() == foreground && shown.edit().include == (std::vector<uchar>{200, 30, 30}));
}
void ColorRangeCanvasTests::theEyedropperSaysWhatAClickWillDo()
{
Ranged shown;
const double ratio = shown.canvas->devicePixelRatio();
const QCursor plain = CanvasView::eyedropperCursor(ratio), add = CanvasView::eyedropperCursor(ratio, HueSampleMode::add),
remove = CanvasView::eyedropperCursor(ratio, HueSampleMode::remove);
QVERIFY(shown.shows(plain));
// A disc bottom right: white, a black sign across.
const QImage badged = image(add);
const auto gray = [ratio](const QCursor &cursor, QPoint at) { return qGray(image(cursor).pixel(at * ratio)); };
QVERIFY(gray(add, QPoint(20, 16)) > 230 && gray(add, QPoint(18, 18)) < 80 && gray(remove, QPoint(18, 18)) < 80);
// A plus has its upright; a minus has none.
QVERIFY(gray(add, QPoint(18, 15)) < 80 && gray(remove, QPoint(18, 15)) > 230);
// The bar spans the disc; a black rim rings it.
QVERIFY(gray(add, QPoint(16, 18)) < 80 && gray(remove, QPoint(20, 18)) < 80 && gray(remove, QPoint(18, 23)) < 80);
QCOMPARE(image(plain).pixelColor(QPoint(20, 16) * ratio).alpha(), 0);
// Held keys show it; the chosen eyedropper too.
QTest::keyPress(shown.canvas, Qt::Key_Shift);
QCOMPARE(shown.edit().held, std::optional(HueSampleMode::add));
QVERIFY(shown.shows(add));
QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::ShiftModifier);
QCOMPARE(shown.edit().held, std::optional(HueSampleMode::remove));
QVERIFY(shown.shows(remove));
// QTest would let go of Shift too: Alt alone goes.
QKeyEvent alt(QEvent::KeyRelease, Qt::Key_Alt, Qt::ShiftModifier);
QApplication::sendEvent(shown.canvas, &alt);
QCOMPARE(shown.edit().held, std::optional(HueSampleMode::add));
QTest::keyRelease(shown.canvas, Qt::Key_Shift);
QCOMPARE(shown.edit().held, std::optional<HueSampleMode>());
QVERIFY(shown.shows(plain));
shown.session.setColorRangeSampleMode(HueSampleMode::remove);
shown.canvas->synchronizeDisplay();
QVERIFY(shown.shows(remove));
// Closed, the tool's own cursor returns.
shown.session.cancelColorRange();
shown.canvas->synchronizeDisplay();
QVERIFY(!shown.shows(plain) && !shown.shows(remove));
}
void ColorRangeCanvasTests::theSheetDrivesTheEdit()
{
Ranged shown;
ColorRangeSheet sheet(shown.session);
sheet.show();
QVERIFY(QTest::qWaitForWindowExposed(&sheet));
const auto dropper = [&sheet](const char *mode) { return sheet.findChild<SampleButton *>(QStringLiteral("colorRange") + QString::fromLatin1(mode)); };
QVERIFY(dropper("Sample")->isChecked() && !dropper("Add")->isChecked());
QCOMPARE(dropper("Add")->toolTip(), QStringLiteral("Click the image to add that color to the selection"));
QCOMPARE(dropper("Remove")->accessibleName(), QStringLiteral("Remove color"));
dropper("Add")->click();
QVERIFY(shown.edit().sampleMode == HueSampleMode::add && dropper("Add")->isChecked() && !dropper("Sample")->isChecked());
// Held keys light the eyedropper a click will use.
shown.session.setColorRangeHeld(HueSampleMode::remove);
QVERIFY(dropper("Remove")->isChecked() && !dropper("Add")->isChecked());
shown.session.setColorRangeHeld(std::nullopt);
QLabel *caption = sheet.findChild<QLabel *>(QStringLiteral("colorRangeCaption"));
QCOMPARE(caption->text(), QStringLiteral("Click the image to pick the color to select."));
shown.session.sampleColorRange(QPointF(50, 50), false, false);
QCOMPARE(caption->text(), QStringLiteral("Shift-click adds a color, Alt-click takes one away."));
// Fuzziness: the slider, the field, the title's scrub.
QSlider *slider = sheet.findChild<QSlider *>(QStringLiteral("fuzzinessSlider"));
QVERIFY(slider->minimum() == 0 && slider->maximum() == 200 && slider->value() == 40);
slider->setValue(120);
QCOMPARE(shown.edit().fuzziness, 120.0);
PickerField *field = sheet.findChild<PickerField *>(QStringLiteral("fuzzinessField"));
QCOMPARE(field->text(), QStringLiteral("120"));
field->setFocus();
field->selectAll();
QTest::keyClicks(field, QStringLiteral("33.6"));
QTest::keyClick(field, Qt::Key_Return);
QVERIFY(shown.edit().fuzziness == 34 && slider->value() == 34);
QLabel *title = nullptr;
for (QLabel *label : sheet.findChildren<QLabel *>())
title = label->text() == QStringLiteral("Fuzziness") ? label : title;
QVERIFY(title && title->buddy() == slider);
QTest::mousePress(title, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2));
QMouseEvent move(QEvent::MouseMove, QPointF(22, 2), title->mapToGlobal(QPointF(22, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(title, &move);
QTest::mouseRelease(title, Qt::LeftButton, Qt::NoModifier, QPoint(22, 2));
QCOMPARE(shown.edit().fuzziness, 54.0);
// Invert, and an error shown in the warning colour.
QCheckBox *invert = sheet.findChild<QCheckBox *>(QStringLiteral("colorRangeInvert"));
invert->click();
QVERIFY(shown.edit().invert);
QLabel *error = sheet.findChild<QLabel *>(QStringLiteral("colorRangeError"));
QVERIFY(error->isHidden() && error->foregroundRole() == QPalette::BrightText);
// OK keeps the match as a step; the edit closes.
QTRY_VERIFY(!shown.edit().preview.isNull());
QPushButton *ok = nullptr;
for (QPushButton *button : sheet.findChildren<QPushButton *>())
ok = button->text() == QStringLiteral("OK") ? button : ok;
QVERIFY(ok && ok->isDefault());
ok->click();
QVERIFY(!shown.session.colorRange());
QCOMPARE(shown.session.history.undoName(), QStringLiteral("Color Range"));
}
void ColorRangeCanvasTests::thePanelFollowsTheEditAndTheMenu()
{
App app;
QVERIFY(scene(400, 300).save(app.path("scene.png")));
app.session().createDocument(400, 300);
app.importFile(app.path("scene.png"));
QAction &entry = app.action("colorRange");
QCOMPARE(entry.text(), QStringLiteral("Color Range…"));
QVERIFY(entry.isEnabled());
entry.trigger();
QVERIFY(app.session().colorRange());
QTRY_VERIFY(panel());
QCOMPARE(panel()->windowTitle(), QStringLiteral("Color Range"));
QVERIFY(panel()->findChild<ColorRangeSheet *>());
QVERIFY(!entry.isEnabled());
// Escape is Cancel: the panel and the edit go.
QTRY_COMPARE(QApplication::activeWindow(), panel());
QTest::keyClick(panel(), Qt::Key_Escape);
QTRY_VERIFY(!panel());
QVERIFY(!app.session().colorRange() && entry.isEnabled());
// Reopened: a canvas click samples; the panel keeps the keys.
entry.trigger();
QTRY_VERIFY(panel());
app.window.activateWindow();
QTRY_COMPARE(QApplication::activeWindow(), &app.window);
app.press(QPointF(200, 150));
app.release(QPointF(200, 150));
QVERIFY(app.session().colorRange().value().hasColors());
QTRY_COMPARE(QApplication::activeWindow(), panel());
app.session().commitColorRange();
QTRY_VERIFY(!panel());
}
QTEST_MAIN(ColorRangeCanvasTests)
#include "ColorRangeCanvasTests.moc"
+233
View File
@@ -0,0 +1,233 @@
#include "Document/EditorSession.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ColorRangeSheet.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QDialog>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QSlider>
#include <QVBoxLayout>
#include <QtTest>
// Swift 1.3.4's ColorRangeSheet: its layout, field, preview, words.
namespace {
// Red left, blue right; the sheet over an open edit.
struct Sheet {
EditorSession session;
std::unique_ptr<ColorRangeSheet> sheet;
Sheet(QSize size = QSize(400, 300))
{
QImage image(size, QImage::Format_RGBA8888_Premultiplied);
image.fill(QColor(200, 30, 30));
for (int y = 0; y < size.height(); ++y)
for (int x = size.width() / 2; x < size.width(); ++x)
image.setPixelColor(x, y, QColor(30, 40, 210));
session.createDocument(size.width(), size.height());
session.insert(ImportedImage(image, image, QStringLiteral("Halves")));
session.beginColorRange();
sheet = std::make_unique<ColorRangeSheet>(session);
sheet->show();
if (!QTest::qWaitForWindowActive(sheet.get()))
throw std::runtime_error("the sheet never became active");
}
const ColorRangeEdit &edit() const { return session.colorRange().value(); }
template <typename Widget> Widget &child(const char *name) const
{
Widget *found = sheet->findChild<Widget *>(QString::fromLatin1(name));
if (!found)
throw std::runtime_error(std::string("no child ") + name);
return *found;
}
// A click on the left half, and its match landing.
void sampleRed()
{
QSignalSpy changed(&session, &EditorSession::changed);
session.sampleColorRange(QPointF(10, 10), false, false);
QVERIFY(QTest::qWaitFor([&changed] { return changed.count() >= 2; }));
}
};
}
class ColorRangeSheetTests : public QObject {
Q_OBJECT
private slots:
void theSheetLaysOutAsSwifts();
void theFieldTakesTypingAsSwiftsBinding();
void thePreviewPaintsTheMatch();
void thePreviewShrinksSmoothly();
void invertAndErrorsFollowTheEdit();
};
void ColorRangeSheetTests::theSheetLaysOutAsSwifts()
{
Sheet shown;
auto *column = qobject_cast<QVBoxLayout *>(shown.sheet->layout());
QVERIFY(column && shown.sheet->width() == 340 && column->spacing() == 16);
QCOMPARE(column->contentsMargins(), QMargins(24, 24, 24, 24));
// The eyedroppers in Swift's order, 6 apart, then a stretch.
auto *droppers = qobject_cast<QHBoxLayout *>(column->itemAt(0)->layout());
QVERIFY(droppers && droppers->spacing() == 6 && droppers->count() == 4 && droppers->itemAt(3)->spacerItem());
QCOMPARE(droppers->itemAt(0)->widget(), &shown.child<SampleButton>("colorRangeSample"));
QCOMPARE(droppers->itemAt(2)->widget(), &shown.child<SampleButton>("colorRangeRemove"));
// The preview, centred, the canvas's shape within 292 by 200.
QWidget *preview = column->itemAt(1)->widget();
QVERIFY(preview && column->itemAt(1)->alignment() == Qt::AlignHCenter);
QCOMPARE(preview->size(), QSize(267, 200));
QCOMPARE(preview->mapTo(shown.sheet.get(), QPoint()).x(), (340 - 267) / 2);
// The caption: wrapped, callout-sized, secondary.
auto &caption = shown.child<QLabel>("colorRangeCaption");
QVERIFY(column->itemAt(2)->widget() == &caption && caption.wordWrap() && caption.font().pixelSize() == 12);
QCOMPARE(caption.foregroundRole(), QPalette::PlaceholderText);
// Fuzziness: title, slider stretching, a 48-point field.
QWidget *row = column->itemAt(3)->widget();
QCOMPARE(row->toolTip(), QStringLiteral("How far a color may be from the picked ones and still be selected"));
auto *fuzziness = qobject_cast<QHBoxLayout *>(row->layout());
QVERIFY(fuzziness->spacing() == 10 && fuzziness->contentsMargins() == QMargins() && fuzziness->count() == 3);
QCOMPARE(qobject_cast<QLabel *>(fuzziness->itemAt(0)->widget())->text(), QStringLiteral("Fuzziness"));
QVERIFY(fuzziness->itemAt(1)->widget() == &shown.child<QSlider>("fuzzinessSlider") && fuzziness->stretch(1) == 1);
auto &field = shown.child<PickerField>("fuzzinessField");
QVERIFY(fuzziness->itemAt(2)->widget() == &field && field.width() == 48 && field.alignment() == Qt::AlignRight);
QVERIFY(field.accessibleName() == QStringLiteral("Fuzziness") && field.placeholderText() == QStringLiteral("Fuzziness"));
// Invert, the error, a rule, then Cancel and OK apart.
auto &invert = shown.child<QCheckBox>("colorRangeInvert");
QVERIFY(column->itemAt(4)->widget() == &invert);
QCOMPARE(invert.toolTip(), QStringLiteral("Select everything except those colors, such as all but a green screen"));
auto &error = shown.child<QLabel>("colorRangeError");
QVERIFY(column->itemAt(5)->widget() == &error && error.wordWrap());
auto *rule = qobject_cast<QFrame *>(column->itemAt(6)->widget());
QVERIFY(rule && rule->frameShape() == QFrame::HLine && rule->foregroundRole() == QPalette::Mid);
auto *buttons = qobject_cast<QHBoxLayout *>(column->itemAt(7)->layout());
QVERIFY(buttons && buttons->count() == 3 && buttons->itemAt(1)->spacerItem());
auto *cancel = qobject_cast<QPushButton *>(buttons->itemAt(0)->widget());
auto *ok = qobject_cast<QPushButton *>(buttons->itemAt(2)->widget());
QVERIFY(cancel->text() == QStringLiteral("Cancel") && ok->text() == QStringLiteral("OK") && ok->isDefault());
// In a panel, a dialog, only OK takes Return.
QDialog panel;
shown.sheet->setParent(&panel);
QVERIFY(!cancel->autoDefault() && ok->autoDefault());
shown.sheet->setParent(nullptr);
// Cancel puts the old selection back and closes the edit.
cancel->click();
QVERIFY(!shown.session.colorRange());
}
void ColorRangeSheetTests::theFieldTakesTypingAsSwiftsBinding()
{
Sheet shown;
auto &field = shown.child<PickerField>("fuzzinessField");
auto &slider = shown.child<QSlider>("fuzzinessSlider");
QVERIFY(field.text() == QStringLiteral("40") && slider.value() == 40);
// Typed, then Return: whole numbers, shown whole.
field.setFocus();
QTRY_VERIFY(field.hasFocus());
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("40.4"));
QTest::keyClick(&field, Qt::Key_Return);
QVERIFY(shown.edit().fuzziness == 40 && field.text() == QStringLiteral("40"));
// The slider replaces typing, as the title's scrub does.
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("77"));
slider.setValue(90);
QVERIFY(shown.edit().fuzziness == 90 && field.text() == QStringLiteral("90"));
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("77"));
QLabel *title = qobject_cast<QLabel *>(qobject_cast<QHBoxLayout *>(field.parentWidget()->layout())->itemAt(0)->widget());
QTest::mousePress(title, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2));
QMouseEvent move(QEvent::MouseMove, QPointF(12, 2), title->mapToGlobal(QPointF(12, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(title, &move);
QTest::mouseRelease(title, Qt::LeftButton, Qt::NoModifier, QPoint(12, 2));
QVERIFY(shown.edit().fuzziness == 100 && field.text() == QStringLiteral("100"));
// Text that is no number goes back.
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("lots"));
QTest::keyClick(&field, Qt::Key_Return);
QVERIFY(shown.edit().fuzziness == 100 && field.text() == QStringLiteral("100"));
// The sheet going commits what its field holds.
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("123"));
shown.sheet.reset();
QCOMPARE(shown.edit().fuzziness, 123.0);
}
void ColorRangeSheetTests::thePreviewPaintsTheMatch()
{
Sheet shown;
QWidget *preview = shown.sheet->layout()->itemAt(1)->widget();
// Black before a colour, inside a faint white rim.
QImage drawn = preview->grab().toImage();
QCOMPARE(drawn.pixelColor(100, 100), QColor(Qt::black));
QCOMPARE(qGray(drawn.pixel(0, 100)), 51);
// A match repaints it: white over the red half.
int painted = 0;
struct Counter : QObject {
int &count;
explicit Counter(int &count) : count(count) {}
bool eventFilter(QObject *, QEvent *event) override
{
count += event->type() == QEvent::Paint;
return false;
}
} counter(painted);
preview->installEventFilter(&counter);
shown.sampleRed();
QTRY_VERIFY(painted > 0);
drawn = preview->grab().toImage();
QVERIFY(qGray(drawn.pixel(60, 100)) > 240 && qGray(drawn.pixel(200, 100)) < 15);
}
void ColorRangeSheetTests::thePreviewShrinksSmoothly()
{
// Red then stripes a pixel wide: the preview halves them.
QImage image(584, 400, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < 400; ++y)
for (int x = 0; x < 584; ++x)
image.setPixelColor(x, y, x < 40 || x % 2 ? QColor(200, 30, 30) : QColor(30, 40, 210));
EditorSession session;
session.createDocument(584, 400);
session.insert(ImportedImage(image, image, QStringLiteral("Stripes")));
session.beginColorRange();
ColorRangeSheet sheet(session);
QWidget *preview = sheet.layout()->itemAt(1)->widget();
QCOMPARE(preview->size(), QSize(292, 200));
QSignalSpy changed(&session, &EditorSession::changed);
session.sampleColorRange(QPointF(10, 10), false, false);
QVERIFY(QTest::qWaitFor([&changed] { return changed.count() >= 2; }));
const QImage drawn = preview->grab().toImage();
QVERIFY(qGray(drawn.pixel(10, 100)) > 240);
QVERIFY(std::abs(qGray(drawn.pixel(150, 100)) - 128) < 20);
}
void ColorRangeSheetTests::invertAndErrorsFollowTheEdit()
{
Sheet shown;
auto &invert = shown.child<QCheckBox>("colorRangeInvert");
shown.session.setColorRangeInvert(true);
QVERIFY(invert.isChecked());
shown.session.setColorRangeInvert(false);
QVERIFY(!invert.isChecked());
// A checkerboard past the edge limit: the error shows.
QImage board(2100, 2100, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < board.height(); ++y) {
auto *row = reinterpret_cast<quint32 *>(board.scanLine(y));
for (int x = 0; x < board.width(); ++x)
row[x] = (x + y) % 2 && (x > 2 || y > 2) ? 0xFFFFFFFF : 0xFF000000;
}
EditorSession session;
session.createDocument(2100, 2100);
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
session.beginColorRange();
ColorRangeSheet sheet(session);
auto *error = sheet.findChild<QLabel *>(QStringLiteral("colorRangeError"));
QVERIFY(error->isHidden());
session.setColorRangeFuzziness(0);
session.sampleColorRange(QPointF(1, 1), false, false);
QTRY_VERIFY_WITH_TIMEOUT(!error->isHidden(), 30000);
QCOMPARE(error->text(), QString::fromUtf8(MagicWandError(MagicWandError::Kind::tooDetailed).what()));
QCOMPARE(error->foregroundRole(), QPalette::BrightText);
}
QTEST_MAIN(ColorRangeSheetTests)
#include "ColorRangeSheetTests.moc"
+403
View File
@@ -0,0 +1,403 @@
#include "Document/EditorSession.h"
#include "SelectionFixtures.h"
#include <QFutureWatcher>
#include <QtTest>
extern "C" {
#include "WandPixels.h"
}
// Swift 1.3.4's Color Range: model, kernel, session.
namespace {
// Red, a band of blue, and a clear corner.
QImage picture()
{
QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied);
image.fill(QColor(200, 30, 30));
for (int y = 0; y < 20; ++y)
for (int x = 20; x < 30; ++x)
image.setPixelColor(x, y, QColor(30, 40, 210));
for (int y = 0; y < 5; ++y)
for (int x = 35; x < 40; ++x)
image.setPixelColor(x, y, Qt::transparent);
return image;
}
struct Opened {
EditorSession session;
Opened()
{
session.createDocument(40, 20);
session.insert(ImportedImage(picture(), picture(), QStringLiteral("Picture")));
session.beginColorRange();
}
const ColorRangeEdit &edit() const { return session.colorRange().value(); }
// A change, then its match landing: a second signal.
void land(const std::function<void()> &change)
{
QSignalSpy changed(&session, &EditorSession::changed);
change();
QVERIFY(QTest::qWaitFor([&changed] { return changed.count() >= 2; }));
}
void sample(QPointF point, bool shift = false, bool option = false)
{
land([&] { session.sampleColorRange(point, shift, option); });
}
int covered(int x, int y) const { return coverage(session, x, y); }
};
std::vector<uchar> rgb(int r, int g, int b)
{
return {uchar(r), uchar(g), uchar(b)};
}
// A 2100 checkerboard, black in its corner: past the limit.
QImage checkerboard()
{
QImage board(2100, 2100, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < board.height(); ++y) {
auto *row = reinterpret_cast<quint32 *>(board.scanLine(y));
for (int x = 0; x < board.width(); ++x)
row[x] = (x + y) % 2 && (x > 2 || y > 2) ? 0xFFFFFFFF : 0xFF000000;
}
return board;
}
// The session's workers: its own watchers and one a change.
int watchers(const EditorSession &session)
{
return int(session.findChildren<QFutureWatcherBase *>(Qt::FindDirectChildrenOnly).size());
}
}
class ColorRangeTests : public QObject {
Q_OBJECT
private slots:
void theKernelMatchesWithinFuzziness();
void aClickPicksTheAverageAroundIt();
void addTakeAwayAndStartOver();
void fuzzinessInvertAndThePreview();
void okIsOneStepAndCancelPutsBack();
void theGatesWaitForThePanel();
void everyChangeIsAnnounced();
void anOutlineTooDetailedIsShownAndKeptOut();
void anErrorKeepsTheLastMatch();
void onlyTheNewestMatchLands();
void thePreviewTruncatesAsSwifts();
};
void ColorRangeTests::theKernelMatchesWithinFuzziness()
{
// Four pixels: red, red off by 10, blue, clear.
const std::array<uchar, 16> pixels{200, 30, 30, 255, 210, 30, 30, 255, 30, 40, 210, 255, 0, 0, 0, 0};
std::array<uchar, 4> mask{};
const std::vector<uchar> red = rgb(200, 30, 30);
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 9, 0, mask.data()), 1L);
QCOMPARE(mask, (std::array<uchar, 4>{255, 0, 0, 0}));
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 10, 0, mask.data()), 2L);
QCOMPARE(mask, (std::array<uchar, 4>{255, 255, 0, 0}));
// Taken away wins; inverted, clear pixels are selected too.
const std::vector<uchar> off = rgb(210, 30, 30);
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, off.data(), 1, 10, 0, mask.data()), 0L);
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 10, 1, mask.data()), 2L);
QCOMPARE(mask, (std::array<uchar, 4>{0, 0, 255, 255}));
// Premultiplied pixels are read straight: half clear red matches red.
const std::array<uchar, 4> half{100, 15, 15, 128};
std::array<uchar, 1> one{};
QCOMPARE(color_range_mask(half.data(), 1, 1, 4, red.data(), 1, nullptr, 0, 1, 0, one.data()), 1L);
}
void ColorRangeTests::aClickPicksTheAverageAroundIt()
{
Opened opened;
QVERIFY(!opened.edit().hasColors() && opened.edit().preview.isNull());
opened.sample(QPointF(5.7, 10.2));
QCOMPARE(opened.edit().include, rgb(200, 30, 30));
// On the seam: three red and six blue, averaged.
opened.sample(QPointF(20, 10));
QCOMPARE(opened.edit().include, rgb((3 * 200 + 6 * 30 + 4) / 9, (3 * 30 + 6 * 40 + 4) / 9, (3 * 30 + 6 * 210 + 4) / 9));
// At the clear corner's edge only coloured pixels count.
opened.sample(QPointF(34, 2));
QCOMPARE(opened.edit().include, rgb(200, 30, 30));
// The click's pixel is floored: 19.6 reads round 19.
opened.sample(QPointF(19.6, 10));
QCOMPARE(opened.edit().include, rgb((6 * 200 + 3 * 30 + 4) / 9, (6 * 30 + 3 * 40 + 4) / 9, (6 * 30 + 3 * 210 + 4) / 9));
// In the clear corner, past the edge, no number: nothing.
for (const QPointF point : {QPointF(38, 2), QPointF(-1, 3), QPointF(40, 3), QPointF(qQNaN(), 3)}) {
const int generation = opened.edit().generation;
opened.session.sampleColorRange(point, false, false);
QCOMPARE(opened.edit().generation, generation);
}
// Three rows, one of each colour: all three count.
QImage rows(3, 3, QImage::Format_RGBA8888_Premultiplied);
for (int x = 0; x < 3; ++x) {
rows.setPixelColor(x, 0, QColor(90, 0, 0));
rows.setPixelColor(x, 1, QColor(0, 90, 0));
rows.setPixelColor(x, 2, QColor(0, 0, 90));
}
EditorSession session;
session.createDocument(3, 3);
session.insert(ImportedImage(rows, rows, QStringLiteral("Rows")));
session.beginColorRange();
session.sampleColorRange(QPointF(1, 1), false, false);
QCOMPARE(session.colorRange().value().include, rgb(30, 30, 30));
}
void ColorRangeTests::addTakeAwayAndStartOver()
{
Opened opened;
opened.sample(QPointF(5, 5));
QCOMPARE(opened.covered(5, 5), 255);
QCOMPARE(opened.covered(25, 5), 0);
QVERIFY(opened.session.selection().value().antialiased);
// The match takes the session's antialiasing.
opened.session.setSelectionAntialiased(false);
opened.sample(QPointF(6, 5));
QVERIFY(!opened.session.selection().value().antialiased);
// Shift adds blue; Alt takes red away.
opened.sample(QPointF(25, 5), true, false);
QCOMPARE(opened.edit().include, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
QCOMPARE(opened.covered(25, 5), 255);
opened.sample(QPointF(5, 5), true, true);
QCOMPARE(opened.edit().exclude, rgb(200, 30, 30));
QVERIFY(opened.covered(5, 5) == 0 && opened.covered(25, 5) == 255);
// The chosen eyedroppers act without keys; replace starts over.
opened.session.setColorRangeSampleMode(HueSampleMode::remove);
opened.sample(QPointF(25, 5));
QCOMPARE(opened.edit().exclude, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
QVERIFY(!opened.session.selection());
opened.session.setColorRangeSampleMode(HueSampleMode::replace);
opened.sample(QPointF(25, 5));
QVERIFY(opened.edit().include == rgb(30, 40, 210) && opened.edit().exclude.empty());
QCOMPARE(opened.covered(25, 5), 255);
// Held keys outrank the chosen eyedropper.
opened.session.setColorRangeHeld(HueSampleMode::add);
QCOMPARE(opened.edit().effectiveMode(), HueSampleMode::add);
}
void ColorRangeTests::fuzzinessInvertAndThePreview()
{
Opened opened;
opened.sample(QPointF(5, 5));
// The preview: the mask at twice the panel's fit.
const double scale = std::min(292.0 / 40, 200.0 / 20) * 2;
QCOMPARE(opened.edit().preview.size(), QSize(int(40 * scale), int(20 * scale)));
QCOMPARE(qGray(opened.edit().preview.pixel(10, 10)), 255);
QCOMPARE(qGray(opened.edit().preview.pixel(int(25 * scale), 10)), 0);
// Fuzziness: whole, 0 to 200; wide, blue joins.
opened.land([&] { opened.session.setColorRangeFuzziness(250.4); });
QCOMPARE(opened.edit().fuzziness, 200.0);
QCOMPARE(opened.covered(25, 5), 255);
opened.session.setColorRangeFuzziness(-3);
QCOMPARE(opened.edit().fuzziness, 0.0);
opened.session.setColorRangeFuzziness(12.6);
QCOMPARE(opened.edit().fuzziness, 13.0);
// Invert: all but red, the clear corner included.
opened.land([&] { opened.session.setColorRangeInvert(true); });
QVERIFY(opened.covered(5, 5) == 0 && opened.covered(25, 5) == 255 && opened.covered(38, 2) == 255);
// No colours: the old selection and no preview.
Opened empty;
empty.session.updateColorRange();
QVERIFY(!empty.session.selection() && empty.edit().preview.isNull());
}
void ColorRangeTests::okIsOneStepAndCancelPutsBack()
{
Opened opened;
QPainterPath corner;
corner.addRect(0, 0, 4, 4);
const DocumentSelection before{corner, true};
opened.session.cancelColorRange();
opened.session.setSelection(before, QStringLiteral("Before"));
opened.session.beginColorRange();
QCOMPARE(opened.edit().original.value().path, before.path);
opened.sample(QPointF(25, 5));
QCOMPARE(opened.covered(1, 1), 0);
// Cancel: the selection there was, no step.
const int steps = opened.session.history.undoCount();
opened.session.cancelColorRange();
QVERIFY(!opened.session.colorRange() && opened.covered(1, 1) == 255 && opened.covered(25, 5) == 0);
QCOMPARE(opened.session.history.undoCount(), steps);
// OK: one step, named; undo puts the old back.
opened.session.beginColorRange();
opened.sample(QPointF(25, 5));
opened.session.commitColorRange();
QCOMPARE(opened.session.history.undoCount(), steps + 1);
QCOMPARE(opened.session.history.undoName(), QStringLiteral("Color Range"));
QCOMPARE(opened.covered(25, 5), 255);
opened.session.undo();
QVERIFY(opened.covered(1, 1) == 255 && opened.covered(25, 5) == 0);
// OK with no colour keeps the old selection, no step.
opened.session.beginColorRange();
opened.session.commitColorRange();
QCOMPARE(opened.session.history.undoCount(), steps);
QCOMPARE(opened.covered(1, 1), 255);
// OK on a match of nothing deselects.
opened.session.beginColorRange();
opened.sample(QPointF(5, 5));
opened.sample(QPointF(5, 5), false, true);
QVERIFY(!opened.session.selection());
opened.session.commitColorRange();
QVERIFY(!opened.session.selection());
QCOMPARE(opened.session.history.undoName(), QStringLiteral("Deselect"));
}
void ColorRangeTests::theGatesWaitForThePanel()
{
Opened opened;
QVERIFY(!opened.session.canEditLayers() && !opened.session.canUseHistory() && !opened.session.canStartProjectOperation());
QVERIFY(!opened.session.canSelectColorRange());
// A second begin keeps the open edit.
const QUuid id = opened.edit().id;
opened.session.beginColorRange();
QCOMPARE(opened.edit().id, id);
opened.session.cancelColorRange();
QVERIFY(opened.session.canEditLayers() && opened.session.canUseHistory() && opened.session.canStartProjectOperation());
QVERIFY(opened.session.canSelectColorRange());
// A request waiting on the panel runs once it closes.
opened.session.beginColorRange();
bool ran = false;
opened.session.waitForFileRequest([&ran] { ran = true; });
QTest::qWait(10);
QVERIFY(!ran);
opened.session.cancelColorRange();
QTRY_VERIFY(ran);
// OK frees one too.
opened.session.beginColorRange();
ran = false;
opened.session.waitForFileRequest([&ran] { ran = true; });
QTest::qWait(10);
QVERIFY(!ran);
opened.session.commitColorRange();
QTRY_VERIFY(ran);
// Without a document there is nothing to open.
EditorSession empty;
QVERIFY(!empty.canSelectColorRange());
empty.beginColorRange();
QVERIFY(!empty.colorRange());
}
void ColorRangeTests::everyChangeIsAnnounced()
{
Opened opened;
QSignalSpy changed(&opened.session, &EditorSession::changed);
// Before a colour, Fuzziness and Invert still say so.
opened.session.setColorRangeFuzziness(50);
QCOMPARE(changed.count(), 1);
opened.session.setColorRangeInvert(true);
QCOMPARE(changed.count(), 2);
opened.session.setColorRangeInvert(false);
// Each eyedropper and held key; an unchanged one is silent.
changed.clear();
opened.session.setColorRangeSampleMode(HueSampleMode::add);
opened.session.setColorRangeSampleMode(HueSampleMode::add);
opened.session.setColorRangeHeld(HueSampleMode::remove);
opened.session.setColorRangeHeld(HueSampleMode::remove);
opened.session.setColorRangeFuzziness(50);
QCOMPARE(changed.count(), 2);
// A click says so now, and as its match lands.
opened.session.setColorRangeHeld(std::nullopt);
opened.session.setColorRangeSampleMode(HueSampleMode::replace);
changed.clear();
opened.session.sampleColorRange(QPointF(5, 5), false, false);
QCOMPARE(changed.count(), 1);
QTRY_COMPARE(changed.count(), 2);
QCOMPARE(opened.covered(5, 5), 255);
}
void ColorRangeTests::anOutlineTooDetailedIsShownAndKeptOut()
{
const QImage board = checkerboard();
EditorSession session;
session.createDocument(2100, 2100);
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
session.beginColorRange();
session.setColorRangeFuzziness(0);
session.sampleColorRange(QPointF(1, 1), false, false);
QTRY_VERIFY_WITH_TIMEOUT(session.colorRange().value().error, 30000);
QCOMPARE(session.colorRange().value().error.value(), QString::fromUtf8(MagicWandError(MagicWandError::Kind::tooDetailed).what()));
// The preview still shows; the selection stays as it was.
QVERIFY(!session.colorRange().value().preview.isNull() && !session.selection());
const int steps = session.history.undoCount();
session.commitColorRange();
QVERIFY(!session.selection() && !session.colorRange());
QCOMPARE(session.history.undoCount(), steps);
}
void ColorRangeTests::anErrorKeepsTheLastMatch()
{
// A red band on top; a black block amid checks.
QImage board = checkerboard();
for (int y = 0; y < 10; ++y)
for (int x = 0; x < board.width(); ++x)
board.setPixelColor(x, y, QColor(200, 30, 30));
for (int y = 100; y < 103; ++y)
for (int x = 100; x < 103; ++x)
board.setPixelColor(x, y, Qt::black);
EditorSession session;
session.createDocument(2100, 2100);
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
session.beginColorRange();
session.setColorRangeFuzziness(0);
session.sampleColorRange(QPointF(10, 4), false, false);
QTRY_VERIFY_WITH_TIMEOUT(session.selection(), 30000);
QVERIFY(coverage(session, 10, 4) == 255 && coverage(session, 500, 500) == 0);
// Black matches every dark check: too detailed, the band stays.
session.sampleColorRange(QPointF(101, 101), false, false);
QTRY_VERIFY_WITH_TIMEOUT(session.colorRange().value().error, 30000);
QVERIFY(coverage(session, 10, 4) == 255 && coverage(session, 500, 500) == 0);
// OK after an error: the old selection, no step.
const int steps = session.history.undoCount();
session.commitColorRange();
QVERIFY(!session.selection() && session.history.undoCount() == steps);
}
void ColorRangeTests::onlyTheNewestMatchLands()
{
EditorSession session;
session.createDocument(2100, 2100);
const QImage board = checkerboard();
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
const int idle = watchers(session);
session.beginColorRange();
session.setColorRangeFuzziness(0);
// A slow trace, then a gray that matches nothing, quickly.
session.sampleColorRange(QPointF(1, 1), false, false);
session.sampleColorRange(QPointF(3, 3), false, false);
QTRY_COMPARE_WITH_TIMEOUT(watchers(session), idle, 30000);
QVERIFY(!session.colorRange().value().error && !session.colorRange().value().preview.isNull());
// A closed edit's trace lands on no later edit.
session.cancelColorRange();
session.beginColorRange();
session.sampleColorRange(QPointF(1, 1), false, false);
session.cancelColorRange();
session.beginColorRange();
// Both at generation one: the edit's id tells them apart.
session.sampleColorRange(QPointF(3, 3), false, false);
QCOMPARE(session.colorRange().value().generation, 1);
QTRY_COMPARE_WITH_TIMEOUT(watchers(session), idle, 30000);
QVERIFY(!session.colorRange().value().error);
// Nor on another document.
Opened opened;
const int quiet = watchers(opened.session);
opened.session.sampleColorRange(QPointF(5, 5), false, false);
opened.session.createDocument(40, 20);
QTRY_COMPARE_WITH_TIMEOUT(watchers(opened.session), quiet, 30000);
QVERIFY(!opened.session.selection() && opened.edit().preview.isNull());
}
void ColorRangeTests::thePreviewTruncatesAsSwifts()
{
EditorSession session;
session.createDocument(400, 300);
QImage red(400, 300, QImage::Format_RGBA8888_Premultiplied);
red.fill(QColor(200, 30, 30));
session.insert(ImportedImage(red, red, QStringLiteral("Red")));
session.beginColorRange();
session.sampleColorRange(QPointF(5, 5), false, false);
QTRY_VERIFY(!session.colorRange().value().preview.isNull());
QCOMPARE(session.colorRange().value().preview.size(), QSize(533, 400));
}
QTEST_MAIN(ColorRangeTests)
#include "ColorRangeTests.moc"
+5 -2
View File
@@ -34,7 +34,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
{"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"keyboardShortcuts", ""}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"},
{"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["},
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"maskBlackAreas", ""},
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"colorRange", ""}, {"maskBlackAreas", ""},
{"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"},
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"trim", ""}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
{"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""},
@@ -53,7 +53,10 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
QCOMPARE(menus[0]->actions().size(), 12);
QCOMPARE(menus[1]->actions().size(), 13);
QCOMPARE(menus[2]->actions().size(), 16);
QCOMPARE(menus[3]->actions().size(), 10);
QCOMPARE(menus[3]->actions().size(), 11);
// Swift 1.3.4's Color Range… follows Subject.
const QList<QAction *> select = menus[3]->actions();
QCOMPARE(select.indexOf(&bar.action("colorRange")), select.indexOf(&bar.action("subject")) + 1);
QCOMPARE(menus[4]->actions().size(), 16);
// Swift's Trim… follows Image Size….
const QList<QAction *> image = menus[4]->actions();
+4
View File
@@ -1,6 +1,7 @@
#include "InlineTextFixtures.h"
#include "UI/CanvasSizeSheet.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ColorRangeSheet.h"
#include "UI/EffectsSheet.h"
#include "UI/FilterSheet.h"
#include "UI/HueSaturationSheet.h"
@@ -245,6 +246,9 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys()
session.beginFilter(FilterKind::gaussianBlur);
QCOMPARE(keys(FilterSheet(session)), both);
session.cancelFilter();
session.beginColorRange();
QCOMPARE(keys(ColorRangeSheet(session)), both);
session.cancelColorRange();
}
void RemappedKeysTests::aKeyLeftAloneReachesTheParent()
+5
View File
@@ -175,6 +175,11 @@ compositor_test(TextColorEditorTests)
compositor_test(TextEditingGatesTests)
compositor_test(TextFontRunsTests)
compositor_test(TextFontControlsTests)
compositor_test(ColorRangeTests)
compositor_test(ColorRangeCanvasTests)
compositor_test(ColorRangeSheetTests)
# It calls the colour-range kernel itself.
target_include_directories(ColorRangeTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(DitherTests)
compositor_test(DitherSheetTests)
compositor_test(DitherKernelTests)