From ee34c338d4b3821d4d2164835b6d23caa190a1a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 17:15:26 -0400 Subject: [PATCH] =?UTF-8?q?12.1:=20feathered=20selections,=20Select=20Subj?= =?UTF-8?q?ect,=20Soft=20Light,=20the=20fixed-width=20font=20menu=20(Compo?= =?UTF-8?q?sitor=201.1.1=E2=80=931.1.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +- CMakeLists.txt | 4 + TASKS.md | 5 +- src/ContentView+Panels.cpp | 12 ++ src/ContentView.h | 2 + src/Document/EditorSession+Imports.cpp | 4 +- src/Document/EditorSession+Jobs.h | 9 ++ src/Document/EditorSession.cpp | 8 +- src/Document/EditorSession.h | 10 ++ src/Document/FloatingSelection.cpp | 4 +- src/Document/LayerAppearance.cpp | 4 +- src/Document/LayerAppearance.h | 4 +- src/Document/LayerMask.cpp | 12 +- src/Document/MaskTracing.cpp | 5 + src/Document/MaskTracing.h | 2 + src/Document/Selection+Session.h | 5 + src/Document/Selection.cpp | 99 +++++++++++-- src/Document/Selection.h | 8 ++ src/Document/SelectionClipboard.cpp | 2 +- src/Document/SelectionEdits.cpp | 4 +- src/Document/SubjectRemoval+Session.h | 8 ++ src/Document/SubjectRemoval.cpp | 66 +++++++++ src/Document/TypeTool.cpp | 5 +- src/Rendering/InlineTextEditor.cpp | 10 +- src/Rendering/InlineTextEditor.h | 3 + src/Rendering/LayerRenderer.cpp | 2 + src/UI/CompositorMenus.cpp | 17 ++- src/UI/LassoControls.cpp | 106 +++++++++++++- src/UI/LassoControls.h | 25 ++++ src/UI/TypeControls.cpp | 65 +++++++-- src/UI/TypeControls.h | 21 ++- tests/BlendModeTests.cpp | 7 +- tests/CompositorMenusTests.cpp | 6 +- tests/LayerAppearanceTests.cpp | 2 +- tests/MaskTracingTests.cpp | 7 + tests/ProjectManifestTests.cpp | 2 +- tests/SelectMenuTests.cpp | 32 +++-- tests/SelectSubjectTests.cpp | 126 +++++++++++++++++ tests/SelectionAmountSheetTests.cpp | 140 ++++++++++++++++++ tests/SelectionFeatherTests.cpp | 188 +++++++++++++++++++++++++ tests/TypeControlsTests.cpp | 38 +++-- tests/TypeFontPickerTests.cpp | 97 +++++++++++++ 42 files changed, 1087 insertions(+), 94 deletions(-) create mode 100644 src/Document/SubjectRemoval+Session.h create mode 100644 tests/SelectSubjectTests.cpp create mode 100644 tests/SelectionAmountSheetTests.cpp create mode 100644 tests/SelectionFeatherTests.cpp create mode 100644 tests/TypeFontPickerTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 30396d4..07196e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ Platform adaptations, each forced by a missing Qt analog: - The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`: sigma a tenth of the brush within 1.5 and 30; the layer blurred with clear past the document's edge; a mask on its edge tone, its placed box black under its coverage, blurred with the edge repeated. Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact; it costs 20–110 ms for a 4K document on 32 cores (sigma 1.5 to 30), paid as a Blur stroke begins, since Swift's sample is taken whole. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` and `blurSample` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre). - The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr`, as a pixel move's, because the canvas draws it while the commit renders, and the raster's pointer stands for Swift's `gradientEdit === edit` (a new line on the same target keeps the raster, as Swift keeps the edit). `commitGradient` takes a completion callback, posted on every path; `resolveGradient` posts the commit, Swift's `Task`, so a switch of tool, layer or target applies it once the caller returns; `invertPixels` and `beginFilter` commit it first and call themselves again from its callback, as Swift awaits it. `fillGradient` fills the canvas through `paintCanvas` with a `QLinearGradient`, or a `QRadialGradient` centred on the start with the line as its radius; Qt's pad spread is CoreGraphics' `drawsBeforeStartLocation` and `drawsAfterEndLocation`, and two stops interpolate alike in both. `gradientColors` makes a mask's colours gray from the palette's red, where Swift makes `DeviceGray` colours. `undo` first discards a pending gradient and `canUndo` counts one, as Swift's. The palette's model came forward from 8.10 into `ColorPalette.cpp` (`PaletteColor`, `foregroundColor` with `setForegroundColor` through `setBrushSettings`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`), and `fillSelection` reads it; the Type bar's share of `setPaletteColor` came with 8.9a, the picker and the swatches came with 8.10a. On the canvas (`EditorCanvas+Gradient.cpp`) a press within ten points of the end, then the start, grabs it, else begins a line; Shift turns the dragged end to a multiple of 45° around the other; Escape cancels and Return or Enter applies, after the selection's keys; losing focus drops the drag. `TransformOverlay` draws the line in place of the handles while one is pending, as Swift's: a radial's dashed rim at the line's length, the line black 3 under white 1, each end a white disc with a black rim, a gray ring and its colour. A pending gradient on a folder's mask clips the folder's layers at once through `liveFolderMaskClip`, as a brush stroke does. `GradientControls` is the tool's `ToolHeaderBar`: the shapes as checkable `QToolButton`s in a `QButtonGroup`, `GradientSwatch` (white, gray checks of four points, the colours, clipped to a rounded rect, the rim unclipped as Swift's `strokeBorder` overlay), the style `QComboBox`, Reverse, the slider in thousandths from 1% and the percent field (a `SelectionAmountField`, whose own clamp stands for the binding's), the Mask note in `PlaceholderText`, and Cancel and Apply while pending. Equivalent mutants: the gray branch of `gradientColors` and of `fillSelection` (a mask's palette is black or white), and the swatch's rim clipped too (3/255 at its corner pixels, measured). A press that marks a drag without an edit was equivalent until 8.10b's Alt eyedropper read the drag; a test holds it now. - The Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), `installProject` loads shapes and `applyDocumentSize` does not, as Swift's rebuild leaves them out after a crop or a resize; where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing. -- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passes it on, the image resizer not. The manifest's `text` is Swift's `Codable`: every key required but `boxSize` (synthesized decoding ignores the defaults), the alignment by raw value, the box as two numbers; the store refuses a project whose text fails `isValid` or has no pixels (Swift's `validate`; its adjustment term is dead, since an adjustment layer's own rule refuses pixels, 9.5a), and a snapshot installed past the store loads such text as pixels (`LayerText::loaded`). `TypeControls` is the tool's `ToolHeaderBar`: the face as a `QComboBox` of fontconfig's names and the style's own, sorted; size, tracking and leading as `TextStyleField`s, `QLineEdit`s that take each readable edit as Swift's value bindings do, show the session's number unless focused, step by one on the arrows (Shift ten), keep their typing while a menu or popup borrows the focus, and keep the focus on Return, showing the number again, selected, as an `NSTextField` does: Swift's Type bar alone has no `releasesFocusOnCommit`; Escape is the field's own. They print Swift's `.number`: at most three decimals, none trailing; digits are not grouped, as in every field of the twin. The size's parse check is left out: text that is no number reads as zero, which no size passes. The size steps within 1–2000; the leading shows Auto as its placeholder, clamps what is typed to 0–5000 and steps from the height Auto works out to. The alignments are checkable `QToolButton`s with Swift's `text.align` glyphs drawn as paths, redrawn on `PaletteChange` as the Move bar's lock is; Cancel and Done show while a draft is open, else Edit Text, enabled on live text; the bar rests without a document or while busy shows. The fields scroll as the Move tool's do. The colour swatch and `openTextColorPicker` came with 8.10a. A live text row in the panel shows the rail's Type glyph 1.2 times up, centred (Swift's `textformat`, 1.21 times up), "Editable text layer", "Select text: " and "Text · Double-click to edit"; `ThumbnailKey` carries `editableText`, so text that loads as pixels redraws. A double click on a control makes the row active, then opens its text (Swift's `renameClickedLayer`); on the name it renames. The inline editor, the canvas's gestures and the edited layer hidden under its draft come with 8.9b. Equivalent mutants: `ImageLayer`'s equality without its text term (every text change brings new pixels); `TextLines` without its stop after a paragraph that overflowed (every later paragraph overflows at once, so the stop spares only their layouts). +- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passes it on, the image resizer not. The manifest's `text` is Swift's `Codable`: every key required but `boxSize` (synthesized decoding ignores the defaults), the alignment by raw value, the box as two numbers; the store refuses a project whose text fails `isValid` or has no pixels (Swift's `validate`; its adjustment term is dead, since an adjustment layer's own rule refuses pixels, 9.5a), and a snapshot installed past the store loads such text as pixels (`LayerText::loaded`). `TypeControls` is the tool's `ToolHeaderBar`: the face as a `TypeFontPicker` (since 12.1: its list of fontconfig's names and the style's own, sorted, loads when the menu first opens); size, tracking and leading as `TextStyleField`s, `QLineEdit`s that take each readable edit as Swift's value bindings do, show the session's number unless focused, step by one on the arrows (Shift ten), keep their typing while a menu or popup borrows the focus, and keep the focus on Return, showing the number again, selected, as an `NSTextField` does: Swift's Type bar alone has no `releasesFocusOnCommit`; Escape is the field's own. They print Swift's `.number`: at most three decimals, none trailing; digits are not grouped, as in every field of the twin. The size's parse check is left out: text that is no number reads as zero, which no size passes. The size steps within 1–2000; the leading shows Auto as its placeholder, clamps what is typed to 0–5000 and steps from the height Auto works out to. The alignments are checkable `QToolButton`s with Swift's `text.align` glyphs drawn as paths, redrawn on `PaletteChange` as the Move bar's lock is; Cancel and Done show while a draft is open, else Edit Text, enabled on live text; the bar rests without a document or while busy shows. The fields scroll as the Move tool's do. The colour swatch and `openTextColorPicker` came with 8.10a. A live text row in the panel shows the rail's Type glyph 1.2 times up, centred (Swift's `textformat`, 1.21 times up), "Editable text layer", "Select text: " and "Text · Double-click to edit"; `ThumbnailKey` carries `editableText`, so text that loads as pixels redraws. A double click on a control makes the row active, then opens its text (Swift's `renameClickedLayer`); on the name it renames. The inline editor, the canvas's gestures and the edited layer hidden under its draft come with 8.9b. Equivalent mutants: `ImageLayer`'s equality without its text term (every text change brings new pixels); `TextLines` without its stop after a paragraph that overflowed (every later paragraph overflows at once, so the stop spares only their layouts). - The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view; the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. Each event follows `QWidgetLineControl::processInputMethodEvent` on the text without the preedit: input (a commit, a changed preedit, a replacement) takes the selection first, then the replacement counts from the cursor, a range removed whole or not at all, a zero length inserting at its offset, and where nothing commits the cursor goes where Qt leaves it; an event that is no input changes nothing (Qt's fields remove a selection only on input, and ibus sends empty preedits when an engine switches modes). A `Selection` attribute sets the selection after the commit; while a preedit shows, that selection stays beside it (the preedit's `anchor`, in the text without it), drawn and answered to queries, until the next input takes it, and the attribute ends a typing run as a move does; a draft set over a composing one ends the composing; `TextFormat` attributes style the preedit, counted from its start (a format that is none is passed over, as Qt's fields do), else it is underlined; a `Cursor` attribute places the caret, hides it at length zero and colours it with a `QColor` value. Copy, Cut, Paste and Select All, from the menu or their keys, work around a preedit as Qt's fields do: on the text without it and the selection beside it (`plain`), the preedit then going back at the cursor (`settle`), so Select All then a commit replaces everything; undo takes a preedit away with the step, since it records nothing. Other keys reach the editor while a preedit shows only when the input method lets them through, and act on the text as it stands: typing and deletions end the composing, the preedit staying as text, as NSTextView unmarks it. Text holds 100,000 UTF-16 units (`shouldChangeTextIn`). The canvas makes the editor in `synchronizeDisplay` (Swift's `synchronizeInlineText`, whose twin lives in `InlineTextEditor.cpp`), skips the edited layer in `drawOwn`, the editor's repaints covering its box while it is edited; a draft that begins, ends or is replaced repaints the whole canvas, as Swift's `needsDisplay`, because the editor's box need not cover the layer's (text shrunk while edited, a layer stretched one way, which point text shows at its width's scale); while a draft is open every key goes to the editor (a key it leaves goes on to `QWidget::keyPressEvent`, which ignores it for the ancestors, none of which takes keys: its mutant is equivalent; so is the blend keys' focus term, since `canEditLayers` refuses a blend step while text is open), the text view's standard keys and Backspace with any modifier (the fills' keys; Delete has no menu key and needs no claim) win `ShortcutOverride` ahead of the menus, Shift+Tab stays put, focus shows and hides the caret (its blink is `caretBlink`, a named child timer of the canvas, running only with focus, so tests read it), input methods are enabled through `setAttribute` alone (Qt updates the input method itself and commits a preedit on the way out, so the editor goes first and that commit finds none) and follow the caret: every edit, move and view change updates them while the canvas is the focus object, as `QWidget::updateMicroFocus`, and a focus request leaves the keys where they are, as Swift's `consumeFocusRequest`. The menus follow Swift's text branch: with a draft open Undo and Redo are bare and enabled, and Undo, Redo, Cut, Copy, Paste and Select All reach the editor of the focused canvas, a text field's first. The pointer (placing the caret, dragging a selection, the handles, the text box, the cursors) comes with 8.9c. - The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (known differences, the first a fix). Equivalent mutants: `drawTextBoxDraft`'s save, restore and brush (it draws last, on a painter each overlay before it restores) and its guard (no box maps to a null rect, which draws nothing). Known differences: `NSTextView`'s context menu and its dragging of selected text are AppKit's own; the Edit menu carries Cut, Copy and Paste. `arrowPath` and `duplicateCursor` moved to `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. - The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (Unicode space separators and the tab, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; its close button cancels, and Swift's guard there is left out, since closing no picker does nothing. `UI/ColorPaletteControls` twins Swift's file under the rail: two 24-point swatches (a 1.5 white ring inside a black rim), the swap (X) and defaults (D) glyphs drawn as paths in the secondary ink, dimmed while disabled, the whole resting while the palette cannot be edited, both swatches repainted whole on every change (they overlap); a mask's swatch offers Black · Hide or White · Reveal in a popup under the swatches (Swift's popover), which a change of target closes, and a mask chosen cancels an open picker; the panel follows the session's picker by its id. Swift's frame is 36 points and its two buttons reach 3 points past it, so the twin's widget is 42 points square with the frame inset by 3, in a rail column 42 wide with the tools centred, the swatches 18 points below the last tool (Swift's spacing of 10 and padding of 8). `SwatchButton` draws every swatch: the palette's (radius 6, white ring 1.5), the Brush bar's (34 by 18, radius 4, white ring 1, black rim), the Shape bar's Fill and the Type bar's (36 by 18, radius 3, a half-black rim). The Brush bar's shows the foreground for the Brush and Spot Healing, not Clone Stamp, the Smear or a mask, resting while the palette cannot be edited; the Type bar's shows `typeColor` and opens `openTextColorPicker`. X and D swap and reset the palette on the canvas and in the list, Shift allowed, not with Ctrl, Alt or Meta; `ProjectWorkspace::canSwitch` refuses while a picker is open. Tests wait for the panel to take the keys (`QApplication::activeWindow`): a tool window reads as active whenever its parent is, and offscreen a hidden panel stays the focus window, where a window manager hands the keys back. Equivalent mutants: `raise` (offscreen keeps no stacking order), `refocus`'s `isVisible` (offscreen activates no hidden window), `show`'s `wasVisible` guard (a shown panel stands where it was remembered), and in `sampleCompositeColor` Swift's `min(alpha, value)` (a premultiplied channel never passes its alpha), `painter.end()` before the read (the raster engine writes at once) and the warning. @@ -111,13 +111,14 @@ Platform adaptations, each forced by a missing Qt analog: - Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` twins Swift's sheet: the title, the 560 by 330 preview on gray 0.12 (the encoded preview fitted, centred and smooth; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a 36 by 18 `SwatchButton` that opens a `QColorDialog` without alpha (the same colour asks nothing, as Swift's `onChange`), the raster's size grouped with "px · sRGB", then the error in `BrightText`, or the file's size and "· encoded preview, fitted to window", or "Updating preview…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `JPEGExportSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). - File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`. - Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not. +- Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files. - 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 "), 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 the mean of its edge pixels, as Swift's `edgeTone`. `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. - `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update). - The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set<int>` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`. - `LayerMask::clipImage` returns nil when the placed grid cannot be rendered, as Swift's `try?` does, and logs a warning on `omaphoto.rendering` so the loss is visible. -- `SeparableBlend` has no twin. It exists because CoreGraphics gets Color Dodge and Color Burn wrong; QPainter's follow the PDF formulas (`LayerRendererTests::everyBlendModeMatchesThePdfFormulas` proves all thirteen modes within 2/255). +- `SeparableBlend` has no twin. It exists because CoreGraphics gets Color Dodge and Color Burn wrong; QPainter's follow the PDF formulas (`BlendModeTests::everyBlendModeMatchesThePdfFormulas` proves all fourteen modes within 2/255). - `Rendering/HslBlend` is Linux-only: Hue, Saturation, Color and Luminosity, which QPainter lacks, blended by hand over a copy of the backdrop. - Known difference: CoreGraphics enlarges "High quality" layers with a sharper kernel; QPainter offers bilinear only, so `InterpolationQuality::high` and `low` draw alike when enlarging. Reductions are sharp on both (halvings). - Where an Apple framework threads internally (vImage, Core Image, Metal), the twin threads with QtConcurrent. diff --git a/CMakeLists.txt b/CMakeLists.txt index e42b4b1..52739ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -291,6 +291,7 @@ compositor_test(TextLayoutTests) compositor_test(TypeSessionTests) compositor_test(TypeStoreTests) compositor_test(TypeControlsTests) +compositor_test(TypeFontPickerTests) compositor_test(InlineTextEditorTests) compositor_test(InlineTextInputTests) compositor_test(InlineTextDrawTests) @@ -425,6 +426,7 @@ compositor_test(SubjectRemovalTests) compositor_test(SubjectRemovalModelTests) compositor_test(SubjectRemovalFailureTests) compositor_test(RemoveBackgroundTests) +compositor_test(SelectSubjectTests) compositor_test(LayerEffectsRendererTests) compositor_test(LayerFlipTests) compositor_test(LayerMergeTests) @@ -432,6 +434,7 @@ compositor_test(PixelEditTests) compositor_test(PixelInvertTests) compositor_test(PixelMoveTests) compositor_test(SelectionTests) +compositor_test(SelectionFeatherTests) compositor_test(SelectionSourceTests) compositor_test(SelectionEditTests) compositor_test(SelectionSignalTests) @@ -439,6 +442,7 @@ compositor_test(SelectMenuTests) compositor_test(SelectionCanvasTests) compositor_test(SelectionOverlayTests) compositor_test(LassoControlsTests) +compositor_test(SelectionAmountSheetTests) compositor_test(MagicWandTests) compositor_test(SelectionClipboardTests) compositor_test(SmartEditTests) diff --git a/TASKS.md b/TASKS.md index cf38683..0ddb62d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -297,7 +297,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.0 Port Compositor 1.3: survey the upstream changes from 1.1 to 1.3.x and split them into ordered items here before any code. - 12.0 survey: the twin mirrors Compositor at 7451cef (the 1.1 feed); the target is tag v1.3.3, 220 commits later, 164 files (157 Swift and C, the seven app icon PNGs), about 17,400 lines added (`git diff 7451cef v1.3.3 -- Compositor CompositorTests`). The spec is checked out at `~/Documents/Github/Compositor-spec`, pinned to v1.3.3. Items follow upstream's versions, since each builds on the last; each ports its range's Swift and C changes with their tests, and a later fix to a feature travels with the item that ports the feature (named below where it crosses items), so no item ports a state upstream abandoned. A change reaches an item by the release that contains it (`git describe --contains`), not by the log's date order. Already held by the twin, so ported as no code but their AGENTS.md notes updated to say Swift now agrees: `f6a74c9` (Levels no longer converts soft edges twice; the Levels known difference), `121a15f` (Color Dodge and Color Burn in sRGB; the `SeparableBlend` note), `5775dd1` and `6230e41` (effects through snapshots; the 9.6a fix beyond Swift), `90ee29f` (Select All in text fields), `b5cb4fa` (M and L choose their tools only; the 7.2 note) in 12.5b, and `7111885` (the distort tests' folding; the 6.2 note) in 12.6. Open Recent's list lives in OmaPhoto's own settings (`QSettings`), where Swift reads `NSDocumentController`'s: Qt has no API for the desktop's recent documents. The user's decisions (2026-09-26): LibRaw decodes RAW where Swift uses `CIRAWFilter`; Qt SVG renders SVG where Swift uses `NSImage`; the Magic tool's Object mode takes U²-Net's foreground mask and selects the connected region under the click, where Swift uses Vision's `VNGenerateForegroundInstanceMaskRequest`. Left out, macOS alone: automatic updates and the appcast, the title bar's drag, "always dark" alerts and panels (Linux follows the Omarchy theme), opening a project while the app is quit. - 12.0 round 1: FAIL, six findings: the reload on disk sat in 1.2.11 though 1.3 merged it (moved to 12.13 by `git describe --contains`), the reshaped app icon had no item (12.6), fixes crossing items were placed by version (the rule stated, `d9b3f9e` to 12.2, `5d7adba` to 12.8c, `96edd5f` to 12.8b, `b683acb` and `a5721b2` named), changes the twin holds were unmarked (listed with their AGENTS.md notes), the counts were off, Open Recent's list had no home (QSettings). Round 2: PASS, no findings. -- [ ] 12.1 Versions 1.1.1–1.1.4: feathered selection modifiers, the Soft Light blend mode, the Type tool's responsiveness, a fixed-width font menu. +- [x] 12.1 Versions 1.1.1–1.1.4: feathered selection modifiers, the Soft Light blend mode, the Type tool's responsiveness, a fixed-width font menu. +- 12.1 work: feather (the coverage blurred at sigma feather / 2, edges repeated; the bounds, clip, copy region and masks from a selection grow by twice the feather), the Select menu's Expand…, Contract… and Feather… through the amount sheet, the bar's Feather, Select ▸ Subject on U²-Net, Soft Light, `TypeFontPicker`, the inline editor's measure cache and `setTextDraft`'s narrower resume. Evidence: 209 of 209 in the dev image, lint clean; mutants over Selection, the sheet and bar, Select Subject, the menus, the font picker, the mask from a selection, the copy region, `whitePixels` and the renderer: every one caught but the two equivalent ones AGENTS.md names; dead terms removed (`canSelectSubject`'s document and busy, the sheet's `trimmed`, the picker's name test). +- 12.1 round 1: FAIL, two findings. Choosing the face already shown restyled, opening live text for editing, where Swift's `choose` writes nothing (the picker returns on the shown face; `theFaceShownChangesNothing`, its mutant caught); AGENTS.md named `LayerRendererTests` for the blend formulas (`BlendModeTests`). +- 12.1 round 2: PASS, no findings. - [ ] 12.2 Versions 1.1.5–1.1.6: folder duplication and the refined layer controls, folder opacity with 1.2.0's store rule for a dimmed folder (`d9b3f9e`), the keyboard shortcuts window. - [ ] 12.3 Version 1.1.7: rulers, guides, the layout grid and Snap To. - [ ] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region). diff --git a/src/ContentView+Panels.cpp b/src/ContentView+Panels.cpp index e93cdfa..d688add 100644 --- a/src/ContentView+Panels.cpp +++ b/src/ContentView+Panels.cpp @@ -1,4 +1,5 @@ #include "ContentView.h" +#include "UI/LassoControls.h" #include "UI/EffectsSheet.h" #include "UI/FilterSheet.h" #include "UI/HueSaturationSheet.h" @@ -27,6 +28,17 @@ void ContentView::synchronizePanels() m_adjustmentPanel.close(); } } + // Swift's onChange of the amount's question: a sheet per question. + if (m_selectionAmountShown != m_session.selectionAmountOperation()) { + m_selectionAmountShown = m_session.selectionAmountOperation(); + if (m_selectionAmountShown) { + m_selectionAmountPanel.onClose = [this] { m_session.setSelectionAmountOperation(std::nullopt); }; + m_selectionAmountPanel.show(rawValue(*m_selectionAmountShown) + QStringLiteral(" Selection"), + new SelectionAmountSheet(m_session, *m_selectionAmountShown)); + } else { + m_selectionAmountPanel.close(); + } + } if (m_filterShown != m_session.filterEdit().has_value()) { m_filterShown = m_session.filterEdit().has_value(); if (m_filterShown) { diff --git a/src/ContentView.h b/src/ContentView.h index fe093d8..13c1fc4 100644 --- a/src/ContentView.h +++ b/src/ContentView.h @@ -89,5 +89,7 @@ private: FloatingPanel m_filterPanel{QStringLiteral("filterPanel"), *this}; bool m_filterShown = false; FloatingPanel m_effectsPanel{QStringLiteral("effectsPanel"), *this}; + FloatingPanel m_selectionAmountPanel{QStringLiteral("selectionAmountPanel"), *this}; + std::optional<SelectionAmountOperation> m_selectionAmountShown; std::optional<LayerEffectSelection> m_effectsShown; }; diff --git a/src/Document/EditorSession+Imports.cpp b/src/Document/EditorSession+Imports.cpp index 7d74a24..ab573e9 100644 --- a/src/Document/EditorSession+Imports.cpp +++ b/src/Document/EditorSession+Imports.cpp @@ -4,8 +4,8 @@ bool EditorSession::canStartProjectOperation() const { - return !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_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) diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h index 91e2550..06b7b92 100644 --- a/src/Document/EditorSession+Jobs.h +++ b/src/Document/EditorSession+Jobs.h @@ -56,6 +56,15 @@ struct SessionJobs { std::optional<QPainterPath> path; std::optional<QString> failure; }; + struct Subjecting { + SelectionMode mode; + QUuid documentID; + std::function<void()> done; + }; + struct Subjected { + std::optional<QImage> mask; + std::optional<QString> failure; + }; // The last stroke's end, for a Shift-click's line. struct LastBrushPoint { QPointF point; diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index c6419a6..b787f67 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -16,6 +16,7 @@ EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(thi connect(&m_baker, &QFutureWatcher<Baked>::finished, this, &EditorSession::finishBake); connect(&m_inverter, &QFutureWatcher<Inverted>::finished, this, &EditorSession::finishInvert); connect(&m_wand, &QFutureWatcher<Wanded>::finished, this, &EditorSession::finishWand); + connect(&m_subject, &QFutureWatcher<Subjected>::finished, this, &EditorSession::finishSubject); connect(&m_filterPreview, &QFutureWatcher<Filtered>::finished, this, &EditorSession::finishFilterPreview); connect(&m_filterCommit, &QFutureWatcher<FilterMade>::finished, this, &EditorSession::finishFilterCommit); connect(&m_cropCommit, &QFutureWatcher<Cropped>::finished, this, &EditorSession::finishCropCommit); @@ -183,8 +184,8 @@ void EditorSession::cycleToolMode() bool EditorSession::canUseHistory() const { - return !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_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument + && !m_showsImporter && !m_renamingLayerID && !m_importError && !m_transformEdit && !m_levels; } void EditorSession::undo() @@ -241,7 +242,8 @@ void EditorSession::endEdit() bool EditorSession::canEditLayers() const { - return !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting && !m_showsNewDocument && !m_showsImporter + return !m_selectionAmountOperation && !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; } diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 4cb9009..9efb24a 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -139,6 +139,11 @@ public: void setSelectionExpandAmount(int amount); int selectionContractAmount() const { return m_selectionContractAmount; } void setSelectionContractAmount(int amount); + int selectionFeatherAmount() const { return m_selectionFeatherAmount; } + void setSelectionFeatherAmount(int amount); + // The amount sheet's question, open while set. + std::optional<SelectionAmountOperation> selectionAmountOperation() const { return m_selectionAmountOperation; } + void setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation); const std::optional<DocumentSelection> &selectionMoveOrigin() const { return m_selectionMoveOrigin; } const WandSettings &wandSettings() const { return m_wandSettings; } void setWandSettings(const WandSettings &settings); @@ -267,6 +272,7 @@ public: #include "Document/LayerFlip+Session.h" #include "Document/Selection+Session.h" #include "Document/MagicWand+Session.h" +#include "Document/SubjectRemoval+Session.h" #include "Document/EditorSession+Brush.h" #include "Document/CloneStamp+Session.h" #include "Document/ColorPalette+Session.h" @@ -356,6 +362,8 @@ private: WandSettings m_wandSettings; std::optional<Wanding> m_wanding; QFutureWatcher<Wanded> m_wand; + std::optional<Subjecting> m_subjecting; + QFutureWatcher<Subjected> m_subject; std::unique_ptr<BrushStroke> m_brushStroke; std::unique_ptr<PixelMove> m_pixelMove; BrushSettings m_brushSettings; @@ -434,4 +442,6 @@ private: bool m_selectionAntialiased = true; int m_selectionExpandAmount = 1; int m_selectionContractAmount = 1; + int m_selectionFeatherAmount = 2; + std::optional<SelectionAmountOperation> m_selectionAmountOperation; }; diff --git a/src/Document/FloatingSelection.cpp b/src/Document/FloatingSelection.cpp index 97b2ab0..2adde73 100644 --- a/src/Document/FloatingSelection.cpp +++ b/src/Document/FloatingSelection.cpp @@ -122,9 +122,9 @@ void EditorSession::mergeFloatingTransform(const TransformEdit &edit, const Floa if (current && edit.corners) { const QTransform placement = BrushRaster::pixelToDocument(floating.original, int(floating.pixelSize.width()), int(floating.pixelSize.height())); if (const std::optional<QPainterPath> path = DistortWarp::mapPath(current->path, placement, floating.pixelSize, edit.draft, *edit.corners)) - moved = DocumentSelection{*path, current->antialiased}; + moved = DocumentSelection{*path, current->antialiased, current->feather}; } else if (const std::optional<QTransform> shift = floatingSelectionTransform(edit); current && shift) { - moved = DocumentSelection{shift->map(current->path), current->antialiased}; + moved = DocumentSelection{shift->map(current->path), current->antialiased, current->feather}; } std::erase_if(m_document->layers, [&](const ImageLayer &layer) { return layer.id == edit.layerID; }); ImageLayer &layer = m_document->layers[indexOf(m_document->layers, source.id)]; diff --git a/src/Document/LayerAppearance.cpp b/src/Document/LayerAppearance.cpp index e1d1207..23fab9c 100644 --- a/src/Document/LayerAppearance.cpp +++ b/src/Document/LayerAppearance.cpp @@ -6,9 +6,9 @@ #include <utility> namespace { -const std::array<std::pair<LayerBlendMode, const char *>, 13> names{{ +const std::array<std::pair<LayerBlendMode, const char *>, 14> names{{ {LayerBlendMode::normal, "Normal"}, {LayerBlendMode::multiply, "Multiply"}, {LayerBlendMode::screen, "Screen"}, - {LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::darken, "Darken"}, {LayerBlendMode::lighten, "Lighten"}, + {LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::softLight, "Soft Light"}, {LayerBlendMode::darken, "Darken"}, {LayerBlendMode::lighten, "Lighten"}, {LayerBlendMode::difference, "Difference"}, {LayerBlendMode::colorDodge, "Color Dodge"}, {LayerBlendMode::colorBurn, "Color Burn"}, {LayerBlendMode::hue, "Hue"}, {LayerBlendMode::saturation, "Saturation"}, {LayerBlendMode::color, "Color"}, {LayerBlendMode::luminosity, "Luminosity"}, diff --git a/src/Document/LayerAppearance.h b/src/Document/LayerAppearance.h index 5ac0c16..1a54289 100644 --- a/src/Document/LayerAppearance.h +++ b/src/Document/LayerAppearance.h @@ -4,14 +4,14 @@ #include <optional> enum class LayerBlendMode { - normal, multiply, screen, overlay, darken, lighten, difference, + normal, multiply, screen, overlay, softLight, darken, lighten, difference, colorDodge, colorBurn, hue, saturation, color, luminosity }; // Swift's `allCases`: the order of the blend menu. inline constexpr std::array allLayerBlendModes{ LayerBlendMode::normal, LayerBlendMode::multiply, LayerBlendMode::screen, LayerBlendMode::overlay, - LayerBlendMode::darken, LayerBlendMode::lighten, LayerBlendMode::difference, LayerBlendMode::colorDodge, + LayerBlendMode::softLight, LayerBlendMode::darken, LayerBlendMode::lighten, LayerBlendMode::difference, LayerBlendMode::colorDodge, LayerBlendMode::colorBurn, LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color, LayerBlendMode::luminosity}; diff --git a/src/Document/LayerMask.cpp b/src/Document/LayerMask.cpp index e346374..a63efbc 100644 --- a/src/Document/LayerMask.cpp +++ b/src/Document/LayerMask.cpp @@ -1,5 +1,6 @@ #include "Document/Distort.h" #include "Document/LayerMask.h" +#include "Document/PixelAdjust.h" #include "Document/BrushStroke.h" #include "Document/EditorSession.h" #include "IO/ImageExporter.h" @@ -281,12 +282,11 @@ void EditorSession::addMask(bool revealing) try { if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000) throw ProjectError(ProjectError::Kind::tooLarge); - QImage image = BrushRaster::context(width, height, true); - image.fill(revealing ? Qt::white : Qt::black); - const QTransform toPixels = BrushRaster::pixelToDocument(active->transform, width, height).inverted(); - QPainter painter(&image); - painter.setRenderHint(QPainter::Antialiasing, current->antialiased); - painter.fillPath(toPixels.map(current->path), revealing ? Qt::black : Qt::white); + // The selection's coverage, soft where it is feathered. + QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height, + BrushRaster::pixelToDocument(active->transform, width, height)); + if (revealing) + image.invertPixels(); const LayerMask mask(LayerMask::assetFrom(image)); finishOpacityEdit(); beginEdit(QStringLiteral("Add Mask from Selection")); diff --git a/src/Document/MaskTracing.cpp b/src/Document/MaskTracing.cpp index 26a114e..48380c5 100644 --- a/src/Document/MaskTracing.cpp +++ b/src/Document/MaskTracing.cpp @@ -89,6 +89,11 @@ std::optional<QPainterPath> MaskTracing::darkPixels(const QImage &image) return trace(image, false, [](uchar value) { return value < 128; }); } +std::optional<QPainterPath> MaskTracing::whitePixels(const QImage &image) +{ + return trace(image, false, [](uchar value) { return value >= 128; }); +} + std::optional<QPainterPath> MaskTracing::opaquePixels(const QImage &image) { return trace(image, true, [](uchar value) { return value >= 128; }); diff --git a/src/Document/MaskTracing.h b/src/Document/MaskTracing.h index 198def4..4214beb 100644 --- a/src/Document/MaskTracing.h +++ b/src/Document/MaskTracing.h @@ -6,5 +6,7 @@ // Raster coverage traced into an outline along exact pixel edges. namespace MaskTracing { std::optional<QPainterPath> darkPixels(const QImage &image); +// A mask's pixels at least half white: what it shows. +std::optional<QPainterPath> whitePixels(const QImage &image); std::optional<QPainterPath> opaquePixels(const QImage &image); } diff --git a/src/Document/Selection+Session.h b/src/Document/Selection+Session.h index 66bec04..33a4914 100644 --- a/src/Document/Selection+Session.h +++ b/src/Document/Selection+Session.h @@ -31,6 +31,11 @@ public: bool canModifySelection() const; void expandSelection(int amount); void contractSelection(int amount); + // Menu commands ask an amount; the bar applies directly. + void promptSelectionAmount(SelectionAmountOperation operation); + void confirmSelectionAmount(int amount); + // Softens the edge further each time, as blurs stack. + void featherSelection(int amount); void selectAll(); void deselect(); void invertSelection(); diff --git a/src/Document/Selection.cpp b/src/Document/Selection.cpp index be4a3b8..4fda9e8 100644 --- a/src/Document/Selection.cpp +++ b/src/Document/Selection.cpp @@ -1,6 +1,7 @@ #include "Document/Selection.h" #include "Document/BrushStroke.h" #include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" #include <QPainterPathStroker> #include <cmath> #include <stdexcept> @@ -40,6 +41,19 @@ QString rawValue(LassoKind kind) throw std::logic_error("unknown lasso kind"); } +QString rawValue(SelectionAmountOperation operation) +{ + switch (operation) { + case SelectionAmountOperation::expand: + return QStringLiteral("Expand"); + case SelectionAmountOperation::contract: + return QStringLiteral("Contract"); + case SelectionAmountOperation::feather: + return QStringLiteral("Feather"); + } + throw std::logic_error("unknown selection amount operation"); +} + QString rawValue(SelectionMode mode) { switch (mode) { @@ -61,22 +75,31 @@ bool DocumentSelection::isEmpty() const QImage DocumentSelection::coverage(int width, int height) const { QImage image = BrushRaster::context(width, height, true); - QPainter painter(&image); - fill(painter, path, antialiased); - return image; + { + QPainter painter(&image); + fill(painter, path, antialiased || feather > 0); + } + if (!(feather > 0)) + return image; + // A feathered edge fades either side of the outline. + return PixelAdjust::gaussianBlur(image, feather / 2, true); +} + +QRectF DocumentSelection::coverageBounds() const +{ + // Four standard deviations keep the falloff past the outline. + const double reach = std::ceil(feather * 2); + return path.boundingRect().adjusted(-reach, -reach, reach, reach); } SelectionClip DocumentSelection::clip(QSizeF canvas) const { - const QRect region = path.boundingRect().adjusted(-1, -1, 1, 1).toAlignedRect() + const QRect region = coverageBounds().adjusted(-1, -1, 1, 1).toAlignedRect() .intersected(QRect(0, 0, int(canvas.width()), int(canvas.height()))); if (isEmpty() || region.isEmpty()) return SelectionClip{QRectF(), std::nullopt}; - QImage image = BrushRaster::context(region.width(), region.height(), true); - QPainter painter(&image); - painter.translate(-region.x(), -region.y()); - fill(painter, path, antialiased); - return SelectionClip{QRectF(region), image}; + const DocumentSelection local{path.translated(-region.x(), -region.y()), antialiased, feather}; + return SelectionClip{QRectF(region), local.coverage(region.width(), region.height())}; } QRectF DragBox::rect(QPointF anchor, QPointF point, bool square, bool fromCenter) @@ -162,6 +185,19 @@ void EditorSession::setSelectionContractAmount(int amount) notify(); } +void EditorSession::setSelectionFeatherAmount(int amount) +{ + m_selectionFeatherAmount = amount; + notify(); +} + +void EditorSession::setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation) +{ + m_selectionAmountOperation = operation; + resumeFileRequests(); + notify(); +} + void EditorSession::beginLasso(QPointF point, SelectionMode mode) { // The Magic Wand selects with a click, never an outline. @@ -344,7 +380,7 @@ void EditorSession::moveSelection(QSizeF offset) return; // Whole pixels, so edges stay crisp; not re-clipped. m_document->selection = DocumentSelection{m_selectionMoveOrigin->path.translated(std::round(offset.width()), std::round(offset.height())), - m_selectionMoveOrigin->antialiased}; + m_selectionMoveOrigin->antialiased, m_selectionMoveOrigin->feather}; notify(); } @@ -380,6 +416,45 @@ void EditorSession::contractSelection(int amount) resizeSelection(-amount, QStringLiteral("Contract Selection")); } +void EditorSession::promptSelectionAmount(SelectionAmountOperation operation) +{ + if (!canModifySelection()) + return; + setSelectionAmountOperation(operation); +} + +void EditorSession::confirmSelectionAmount(int amount) +{ + const std::optional<SelectionAmountOperation> operation = m_selectionAmountOperation; + if (!operation || amount < 1 || amount > (*operation == SelectionAmountOperation::feather ? 250 : 500)) + return; + setSelectionAmountOperation(std::nullopt); + switch (*operation) { + case SelectionAmountOperation::expand: + setSelectionExpandAmount(amount); + expandSelection(amount); + break; + case SelectionAmountOperation::contract: + setSelectionContractAmount(amount); + contractSelection(amount); + break; + case SelectionAmountOperation::feather: + setSelectionFeatherAmount(amount); + featherSelection(amount); + break; + } +} + +void EditorSession::featherSelection(int amount) +{ + const std::optional<DocumentSelection> current = selection(); + if (!canModifySelection() || !current || amount <= 0) + return; + // Two soft edges spread less than their sum. + const double softened = std::sqrt(current->feather * current->feather + double(amount) * amount); + setSelection(DocumentSelection{current->path, current->antialiased, std::min(250.0, softened)}, QStringLiteral("Feather Selection")); +} + void EditorSession::resizeSelection(double delta, const QString &name) { const std::optional<DocumentSelection> current = selection(); @@ -392,7 +467,7 @@ void EditorSession::resizeSelection(double delta, const QString &name) stroker.setJoinStyle(Qt::RoundJoin); const QPainterPath band = stroker.createStroke(current->path); const QPainterPath result = delta > 0 ? current->path.united(band).intersected(canvasPath(*m_document)) : current->path.subtracted(band); - setSelection(DocumentSelection{result, current->antialiased}, name); + setSelection(DocumentSelection{result, current->antialiased, current->feather}, name); } void EditorSession::selectAll() @@ -414,5 +489,5 @@ void EditorSession::invertSelection() const std::optional<DocumentSelection> current = selection(); if (!m_document || !current) return; - setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased}, QStringLiteral("Inverse")); + setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased, current->feather}, QStringLiteral("Inverse")); } diff --git a/src/Document/Selection.h b/src/Document/Selection.h index e679b1f..9107cb7 100644 --- a/src/Document/Selection.h +++ b/src/Document/Selection.h @@ -18,11 +18,15 @@ struct SelectionClip { struct DocumentSelection { QPainterPath path; bool antialiased = true; + // How far the edge fades, in pixels; 0 is hard. + double feather = 0; // An explicit empty selection: later edits touch nothing. bool isEmpty() const; // Gray coverage at document resolution, white where selected. QImage coverage(int width, int height) const; + // The outline's box, grown by the feather's visible falloff. + QRectF coverageBounds() const; SelectionClip clip(QSizeF canvas) const; friend bool operator==(const DocumentSelection &, const DocumentSelection &) = default; }; @@ -35,6 +39,10 @@ QString rawValue(LassoKind kind); enum class SelectionMode { replace, add, subtract }; QString rawValue(SelectionMode mode); +// What Select's Expand, Contract and Feather ask an amount for. +enum class SelectionAmountOperation { expand, contract, feather }; +QString rawValue(SelectionAmountOperation operation); + // A drag's box in whole pixels; shared with Shape. namespace DragBox { QRectF rect(QPointF anchor, QPointF point, bool square, bool fromCenter); diff --git a/src/Document/SelectionClipboard.cpp b/src/Document/SelectionClipboard.cpp index 543db77..24179fd 100644 --- a/src/Document/SelectionClipboard.cpp +++ b/src/Document/SelectionClipboard.cpp @@ -49,7 +49,7 @@ std::optional<QRectF> EditorSession::selectionCopyRegion() const return std::nullopt; const QRectF canvas(QPointF(0, 0), m_document->size()); const std::optional<DocumentSelection> current = selection(); - const QRectF bounds = current ? current->path.boundingRect() : canvas; + const QRectF bounds = current ? current->coverageBounds() : canvas; // Path operations leave float noise: round with a tolerance. constexpr double tolerance = 0.001; const double minX = std::floor(bounds.left() + tolerance), minY = std::floor(bounds.top() + tolerance); diff --git a/src/Document/SelectionEdits.cpp b/src/Document/SelectionEdits.cpp index 008e346..b10f356 100644 --- a/src/Document/SelectionEdits.cpp +++ b/src/Document/SelectionEdits.cpp @@ -234,7 +234,7 @@ PixelMove::PixelMove(std::shared_ptr<BrushStroke> raster, DocumentSelection orig DocumentSelection PixelMove::movedSelection() const { - return DocumentSelection{QTransform::fromTranslate(offset.width(), offset.height()).map(origin.path), origin.antialiased}; + return DocumentSelection{QTransform::fromTranslate(offset.width(), offset.height()).map(origin.path), origin.antialiased, origin.feather}; } // Ctrl-drag: starts moving the selected pixels; false with none. @@ -292,7 +292,7 @@ std::optional<DocumentSelection> EditorSession::displayedSelection() const const std::optional<DocumentSelection> current = selection(); if (m_transformEdit && current) { if (const std::optional<QTransform> matrix = floatingSelectionTransform(*m_transformEdit)) - return DocumentSelection{matrix->map(current->path), current->antialiased}; + return DocumentSelection{matrix->map(current->path), current->antialiased, current->feather}; } return current; } diff --git a/src/Document/SubjectRemoval+Session.h b/src/Document/SubjectRemoval+Session.h new file mode 100644 index 0000000..ae4952f --- /dev/null +++ b/src/Document/SubjectRemoval+Session.h @@ -0,0 +1,8 @@ +// Swift's SubjectRemoval extension: Select → Subject. +public: + bool canSelectSubject() const; + // The canvas's foreground, as Remove Background finds it, selected. + void selectSubject(SelectionMode mode, std::function<void()> done); + +private: + void finishSubject(); diff --git a/src/Document/SubjectRemoval.cpp b/src/Document/SubjectRemoval.cpp index a2cb6b2..c90f9b5 100644 --- a/src/Document/SubjectRemoval.cpp +++ b/src/Document/SubjectRemoval.cpp @@ -1,10 +1,14 @@ #include "Document/SubjectRemoval.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/MaskTracing.h" #include "Document/GuidedMatte.h" #include "Document/PixelAdjust.h" #include "IO/ImageExporter.h" #include "Logging.h" #include <QMutex> #include <QStandardPaths> +#include <QtConcurrent> #include <algorithm> #include <array> #include <cmath> @@ -236,3 +240,65 @@ QImage SubjectRemoval::run(const QImage &image, const FilterSettings &settings) } return result; } + +bool EditorSession::canSelectSubject() const +{ + // canEditLayers reads the document and the busy project. + return canEditSelection(); +} + +void EditorSession::selectSubject(SelectionMode mode, std::function<void()> done) +{ + // The caller resumes from the event loop, as after await. + const auto finish = [this, &done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (!canSelectSubject()) { + finish(); + return; + } + QImage shown; + try { + shown = BrushRaster::context(m_document->width, m_document->height, false); + QPainter painter(&shown); + drawLiveComposite(*m_document, painter); + } catch (const ExportError &error) { + qCWarning(lcApp) << "Select Subject cannot draw the canvas:" << error.what(); + finish(); + return; + } + setIsProjectBusy(true); + m_subjecting = Subjecting{mode, m_document->id, std::move(done)}; + m_subject.setFuture(QtConcurrent::run([shown]() -> Subjected { + try { + return Subjected{SubjectRemoval::subjectMask(shown, std::nullopt, FilterSettings()), std::nullopt}; + } catch (const SubjectRemovalError &error) { + return Subjected{std::nullopt, QString::fromUtf8(error.what())}; + } catch (const ExportError &error) { + return Subjected{std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::finishSubject() +{ + const Subjected result = m_subject.result(); + const Subjecting pending = std::exchange(m_subjecting, std::nullopt).value(); + setIsProjectBusy(false); + if (m_document && m_document->id == pending.documentID) { + if (result.failure) { + qCWarning(lcApp).noquote() << "Select Subject failed:" << *result.failure; + setBrushError(result.failure); + } else if (const std::optional<QPainterPath> traced = MaskTracing::whitePixels(*result.mask)) { + // White where the subject is: its outline is the selection. + const QTransform toDocument = BrushRaster::pixelToDocument(LayerTransform{.origin = QPointF(0, 0), .size = m_document->size()}, + result.mask->width(), result.mask->height()); + applySelection(toDocument.map(*traced), pending.mode, QStringLiteral("Select Subject")); + } else { + qCWarning(lcApp) << "Select Subject traced no outline"; + } + } + if (pending.done) + QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); +} diff --git a/src/Document/TypeTool.cpp b/src/Document/TypeTool.cpp index 7ae0a3d..9a4b12b 100644 --- a/src/Document/TypeTool.cpp +++ b/src/Document/TypeTool.cpp @@ -62,8 +62,11 @@ std::optional<LayerText> ImageLayer::liveText() const void EditorSession::setTextDraft(std::optional<TextDraft> draft) { + // Only a draft that ends frees waiting file requests. + const bool ended = m_textDraft && !draft; m_textDraft = std::move(draft); - resumeFileRequests(); + if (ended) + resumeFileRequests(); notify(); } diff --git a/src/Rendering/InlineTextEditor.cpp b/src/Rendering/InlineTextEditor.cpp index 5032d33..368e0bf 100644 --- a/src/Rendering/InlineTextEditor.cpp +++ b/src/Rendering/InlineTextEditor.cpp @@ -37,7 +37,15 @@ void InlineTextEditor::synchronize(const TextDraft &draft) } const LayerTextStyle &style = draft.style; // Point text has no box: it is what is typed. - m_logicalSize = EditorSession::textBoxSize(style); + if (style.boxSize) { + m_logicalSize = *style.boxSize; + } else { + if (m_measuredStyle != style) { + m_measuredSize = EditorSession::textBoxSize(style); + m_measuredStyle = style; + } + m_logicalSize = m_measuredSize; + } LayerTransform transform = draft.transform.value_or(LayerTransform{.origin = draft.origin, .size = m_logicalSize}); // Point text grows at its layer's scale, corner kept. const int index = draft.layerID ? indexOf(document.layers, *draft.layerID) : -1; diff --git a/src/Rendering/InlineTextEditor.h b/src/Rendering/InlineTextEditor.h index 7ef1919..10a38de 100644 --- a/src/Rendering/InlineTextEditor.h +++ b/src/Rendering/InlineTextEditor.h @@ -136,6 +136,9 @@ private: QUuid m_draftID; std::optional<LayerTextStyle> m_shownStyle; QSizeF m_logicalSize{360, 160}; + // Point text's measure, kept while its style holds. + std::optional<LayerTextStyle> m_measuredStyle; + QSizeF m_measuredSize; LayerTransform m_shownTransform; double m_handleSize = 6; std::unique_ptr<TextLines> m_lines; diff --git a/src/Rendering/LayerRenderer.cpp b/src/Rendering/LayerRenderer.cpp index 66e47b2..89fcb9f 100644 --- a/src/Rendering/LayerRenderer.cpp +++ b/src/Rendering/LayerRenderer.cpp @@ -20,6 +20,8 @@ QPainter::CompositionMode compositionMode(LayerBlendMode mode) return QPainter::CompositionMode_Screen; case LayerBlendMode::overlay: return QPainter::CompositionMode_Overlay; + case LayerBlendMode::softLight: + return QPainter::CompositionMode_SoftLight; case LayerBlendMode::darken: return QPainter::CompositionMode_Darken; case LayerBlendMode::lighten: diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index 5674cbe..f1493b5 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -139,13 +139,19 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi if (const std::optional<QUuid> id = session().activeLayerID()) session().loadLayerSelection(*id); }); + add(select, QStringLiteral("subject"), QStringLiteral("Subject"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_A), + [this] { session().selectSubject(SelectionMode::replace, {}); }); add(select, QStringLiteral("maskBlackAreas"), QStringLiteral("Mask's Black Areas"), QKeySequence(), [this] { if (const std::optional<QUuid> id = session().activeLayerID()) session().loadMaskSelection(*id); }); select->addSeparator(); - add(select, QStringLiteral("expandSelection"), QStringLiteral("Expand by 1 px"), QKeySequence(), [this] { session().expandSelection(session().selectionExpandAmount()); }); - add(select, QStringLiteral("contractSelection"), QStringLiteral("Contract by 1 px"), QKeySequence(), [this] { session().contractSelection(session().selectionContractAmount()); }); + add(select, QStringLiteral("expandSelection"), QStringLiteral("Expand…"), QKeySequence(), + [this] { session().promptSelectionAmount(SelectionAmountOperation::expand); }); + add(select, QStringLiteral("contractSelection"), QStringLiteral("Contract…"), QKeySequence(), + [this] { session().promptSelectionAmount(SelectionAmountOperation::contract); }); + add(select, QStringLiteral("featherSelection"), QStringLiteral("Feather…"), QKeySequence(), + [this] { session().promptSelectionAmount(SelectionAmountOperation::feather); }); QMenu *image = bar.addMenu(QStringLiteral("&Image")); add(image, QStringLiteral("curves"), QStringLiteral("Curves…"), QKeySequence(Qt::CTRL | Qt::Key_M), [this] { session().beginFilter(FilterKind::curves); }); @@ -277,10 +283,9 @@ void CompositorMenus::synchronize() action(QStringLiteral("inverse"))->setEnabled(selected && s.canEditSelection()); action(QStringLiteral("layerPixels"))->setEnabled(active && active->asset && s.canEditSelection()); action(QStringLiteral("maskBlackAreas"))->setEnabled(active && active->mask && s.canEditSelection()); - action(QStringLiteral("expandSelection"))->setText(QStringLiteral("Expand by %1 px").arg(s.selectionExpandAmount())); - action(QStringLiteral("expandSelection"))->setEnabled(s.canModifySelection()); - action(QStringLiteral("contractSelection"))->setText(QStringLiteral("Contract by %1 px").arg(s.selectionContractAmount())); - action(QStringLiteral("contractSelection"))->setEnabled(s.canModifySelection()); + action(QStringLiteral("subject"))->setEnabled(s.canSelectSubject()); + for (const char *name : {"expandSelection", "contractSelection", "featherSelection"}) + action(QString::fromLatin1(name))->setEnabled(s.canModifySelection()); action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation()); for (const FilterKind kind : allFilterKinds) { if (kind != FilterKind::contentAwareFill) diff --git a/src/UI/LassoControls.cpp b/src/UI/LassoControls.cpp index ca3eb99..27515f2 100644 --- a/src/UI/LassoControls.cpp +++ b/src/UI/LassoControls.cpp @@ -1,5 +1,8 @@ #include "UI/LassoControls.h" +#include <QBoxLayout> #include <QFrame> +#include <QLabel> +#include <QSignalBlocker> #include <QRegularExpressionValidator> #include <QFocusEvent> #include <QKeyEvent> @@ -98,6 +101,9 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent) m_contract(new QPushButton(QStringLiteral("Contract"), this)), m_contractAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionContractAmount()); }, [this](double amount) { m_session.setSelectionContractAmount(int(std::lround(amount))); }, this)), + m_feather(new QPushButton(QStringLiteral("Feather"), this)), + m_featherAmount(new SelectionAmountField(session, 1, 250, [this] { return double(m_session.selectionFeatherAmount()); }, + [this](double amount) { m_session.setSelectionFeatherAmount(int(std::lround(amount))); }, this)), m_empty(new QLabel(QStringLiteral("Empty selection"), this)), m_deselect(new QPushButton(QStringLiteral("Deselect"), this)) { m_toleranceLabel->setFont(ToolHeaderStyle::controlFont()); @@ -132,6 +138,11 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent) connect(m_contract, &QPushButton::clicked, this, [this] { m_session.contractSelection(m_session.selectionContractAmount()); }); m_contractAmount->setObjectName(QStringLiteral("selectionContractAmount")); m_contractAmount->setToolTip(m_contract->toolTip()); + m_feather->setObjectName(QStringLiteral("featherSelection")); + m_feather->setToolTip(QStringLiteral("Fade the edge of the selection by this many pixels")); + connect(m_feather, &QPushButton::clicked, this, [this] { m_session.featherSelection(m_session.selectionFeatherAmount()); }); + m_featherAmount->setObjectName(QStringLiteral("selectionFeatherAmount")); + m_featherAmount->setToolTip(m_feather->toolTip()); m_empty->setObjectName(QStringLiteral("emptySelection")); m_empty->setFont(ToolHeaderStyle::controlFont()); m_empty->setForegroundRole(QPalette::PlaceholderText); @@ -141,6 +152,7 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent) for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_freehand, m_polygonal, m_replace, m_add, m_subtract, m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, m_antialias, divider, m_expand, m_expandAmount, new QLabel(QStringLiteral("px"), this), m_contract, m_contractAmount, + new QLabel(QStringLiteral("px"), this), m_feather, m_featherAmount, new QLabel(QStringLiteral("px"), this)}) row->insertWidget(row->count() - 1, widget); row->addWidget(m_empty); @@ -202,13 +214,105 @@ void LassoControls::synchronize() if (m_antialias->isChecked() != m_session.selectionAntialiased()) m_antialias->setChecked(m_session.selectionAntialiased()); const bool modifies = m_session.canModifySelection(); - for (QWidget *widget : std::initializer_list<QWidget *>{m_expand, m_expandAmount, m_contract, m_contractAmount}) + for (QWidget *widget : std::initializer_list<QWidget *>{m_expand, m_expandAmount, m_contract, m_contractAmount, m_feather, m_featherAmount}) widget->setEnabled(modifies); m_expandAmount->sync(m_session.selectionExpandAmount()); m_contractAmount->sync(m_session.selectionContractAmount()); + m_featherAmount->sync(m_session.selectionFeatherAmount()); const std::optional<DocumentSelection> selection = m_session.selection(); m_empty->setVisible(selection && selection->isEmpty()); m_deselect->setVisible(selection.has_value()); m_deselect->setEnabled(m_session.canEditSelection()); setEnabled(!m_session.showsBusy() && m_session.document().has_value()); } + +SelectionAmountSheet::SelectionAmountSheet(EditorSession &session, SelectionAmountOperation operation, QWidget *parent) + : QWidget(parent), m_session(session), m_maximum(operation == SelectionAmountOperation::feather ? 250 : 500), + m_slider(new QSlider(Qt::Horizontal, this)), m_input(new QLineEdit(this)), + m_note(new QLabel(QStringLiteral("Enter a whole number from 1 to %1 px.").arg(m_maximum), this)), m_ok(new QPushButton(QStringLiteral("OK"), this)) +{ + const int start = operation == SelectionAmountOperation::expand ? session.selectionExpandAmount() + : operation == SelectionAmountOperation::contract ? session.selectionContractAmount() + : session.selectionFeatherAmount(); + setFixedWidth(380); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(16); + auto *row = new QHBoxLayout; + row->setSpacing(10); + auto *title = new QLabel(QStringLiteral("Amount"), this); + title->setMinimumWidth(60); + title->setBuddy(m_slider); + m_slider->setObjectName(QStringLiteral("amountSlider")); + m_slider->setRange(1, m_maximum); + m_input->setObjectName(QStringLiteral("amountField")); + m_input->setAccessibleName(QStringLiteral("Amount")); + m_input->setPlaceholderText(QStringLiteral("Amount")); + m_input->setFixedWidth(56); + m_input->setAlignment(Qt::AlignRight); + m_input->setText(QString::number(start)); + row->addWidget(title); + row->addWidget(m_slider, 1); + row->addWidget(m_input); + row->addSpacing(2); + row->addWidget(new QLabel(QStringLiteral("px"), this)); + column->addLayout(row); + m_note->setObjectName(QStringLiteral("amountNote")); + m_note->setForegroundRole(QPalette::PlaceholderText); + QFont callout = m_note->font(); + callout.setPixelSize(12); + m_note->setFont(callout); + // Hidden, the note keeps its room, as SwiftUI's opacity does. + QSizePolicy kept = m_note->sizePolicy(); + kept.setRetainSizeWhenHidden(true); + m_note->setSizePolicy(kept); + column->addWidget(m_note); + 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->setObjectName(QStringLiteral("amountCancel")); + cancel->setAutoDefault(false); + m_ok->setObjectName(QStringLiteral("amountOK")); + m_ok->setDefault(true); + buttons->addWidget(cancel); + buttons->addStretch(1); + buttons->addWidget(m_ok); + column->addLayout(buttons); + connect(m_slider, &QSlider::valueChanged, this, [this](int value) { + m_input->setText(QString::number(value)); + }); + connect(m_input, &QLineEdit::textChanged, this, [this] { refresh(); }); + connect(cancel, &QPushButton::clicked, this, [this] { m_session.setSelectionAmountOperation(std::nullopt); }); + connect(m_ok, &QPushButton::clicked, this, [this] { + if (const std::optional<int> chosen = amount()) + m_session.confirmSelectionAmount(*chosen); + }); + refresh(); +} + +void SelectionAmountSheet::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + m_input->setFocus(); +} + +std::optional<int> SelectionAmountSheet::amount() const +{ + bool ok = false; + const int value = m_input->text().toInt(&ok); + if (!ok || value < 1 || value > m_maximum) + return std::nullopt; + return value; +} + +void SelectionAmountSheet::refresh() +{ + const std::optional<int> chosen = amount(); + const QSignalBlocker quiet(m_slider); + m_slider->setValue(chosen.value_or(1)); + m_note->setVisible(!chosen); + m_ok->setEnabled(chosen.has_value()); +} diff --git a/src/UI/LassoControls.h b/src/UI/LassoControls.h index d720b42..669eceb 100644 --- a/src/UI/LassoControls.h +++ b/src/UI/LassoControls.h @@ -6,6 +6,7 @@ #include <QComboBox> #include <QLineEdit> #include <QPushButton> +#include <QSlider> #include <QToolButton> #include <functional> @@ -70,6 +71,30 @@ private: SelectionAmountField *const m_expandAmount; QPushButton *const m_contract; SelectionAmountField *const m_contractAmount; + QPushButton *const m_feather; + SelectionAmountField *const m_featherAmount; QLabel *const m_empty; QPushButton *const m_deselect; }; + +// Select's Expand, Contract or Feather amount, in a floating panel. +class SelectionAmountSheet : public QWidget { + Q_OBJECT +public: + SelectionAmountSheet(EditorSession &session, SelectionAmountOperation operation, QWidget *parent = nullptr); + +protected: + void showEvent(QShowEvent *event) override; + +private: + // A whole number in range, else none; toInt trims spaces. + std::optional<int> amount() const; + void refresh(); + + EditorSession &m_session; + const int m_maximum; + QSlider *const m_slider; + QLineEdit *const m_input; + QLabel *const m_note; + QPushButton *const m_ok; +}; diff --git a/src/UI/TypeControls.cpp b/src/UI/TypeControls.cpp index 67fec1d..6105a01 100644 --- a/src/UI/TypeControls.cpp +++ b/src/UI/TypeControls.cpp @@ -1,4 +1,7 @@ #include "UI/TypeControls.h" +#include <QAbstractItemView> +#include <QStylePainter> +#include <QSignalBlocker> #include "UI/ColorPaletteControls.h" #include "Document/EditorSession.h" #include "Rendering/TextLayout.h" @@ -82,8 +85,52 @@ void TextStyleField::focusOutEvent(QFocusEvent *event) QLineEdit::focusOutEvent(event); } +TypeFontPicker::TypeFontPicker(QWidget *parent) : QComboBox(parent) +{ + setAccessibleName(QStringLiteral("Font")); +} + +void TypeFontPicker::sync(const QString &name) +{ + if (view()->isVisible()) + return; + if (findText(name) < 0) + addItem(name); + setCurrentIndex(findText(name)); +} + +void TypeFontPicker::showPopup() +{ + if (!m_loaded) { + // Every installed face, and the style's own though missing. + const QString selected = currentText(); + QStringList names = TextLayout::availableFonts(); + if (!names.contains(selected)) + names << selected; + names.sort(); + const QSignalBlocker quiet(this); + clear(); + addItems(names); + setCurrentIndex(findText(selected)); + m_loaded = true; + } + QComboBox::showPopup(); +} + +void TypeFontPicker::paintEvent(QPaintEvent *) +{ + QStylePainter painter(this); + QStyleOptionComboBox option; + initStyleOption(&option); + painter.drawComplexControl(QStyle::CC_ComboBox, option); + const QRect field = style()->subControlRect(QStyle::CC_ComboBox, &option, QStyle::SC_ComboBoxEditField, this); + // The label draws inset a pixel each side. + option.currentText = fontMetrics().elidedText(option.currentText, Qt::ElideRight, field.width() - 2); + painter.drawControl(QStyle::CE_ComboBoxLabel, option); +} + TypeControls::TypeControls(EditorSession &session, QWidget *parent) - : ToolHeaderBar(QStringLiteral("Type"), parent), m_session(session), m_font(new QComboBox), + : ToolHeaderBar(QStringLiteral("Type"), parent), m_session(session), m_font(new TypeFontPicker), m_size(new TextStyleField( [this] { return number(m_session.currentTextStyle().fontSize, locale()); }, [this](const QString &text) { @@ -136,10 +183,13 @@ TypeControls::TypeControls(EditorSession &session, QWidget *parent) fields->setContentsMargins(0, 0, 0, 0); fields->setSpacing(10); m_font->setObjectName(QStringLiteral("typeFont")); - m_font->setFixedWidth(185); + m_font->setFixedWidth(210); m_font->setToolTip(QStringLiteral("Font face, including bold and italic variants")); connect(m_font, &QComboBox::activated, this, [this](int index) { const QString name = m_font->itemText(index); + // The face already shown changes nothing, as Swift's choose. + if (name == m_session.currentTextStyle().fontName) + return; m_session.changeTextStyle([&name](LayerTextStyle &style) { style.fontName = name; }); }); m_size->setObjectName(QStringLiteral("typeSize")); @@ -223,16 +273,7 @@ QToolButton *TypeControls::alignment(TextAlignment value) void TypeControls::synchronize() { const LayerTextStyle style = m_session.currentTextStyle(); - // Every installed face, and the style's own though missing. - QStringList names = TextLayout::availableFonts(); - if (!names.contains(style.fontName)) - names << style.fontName; - names.sort(); - if (m_font->count() != names.size() || m_font->findText(style.fontName) < 0) { - m_font->clear(); - m_font->addItems(names); - } - m_font->setCurrentIndex(m_font->findText(style.fontName)); + m_font->sync(style.fontName); for (size_t index = 0; index < m_alignments.size(); ++index) m_alignments[index]->setChecked(style.alignment == allTextAlignments[index]); for (TextStyleField *field : {m_size, m_tracking, m_leading}) diff --git a/src/UI/TypeControls.h b/src/UI/TypeControls.h index f407d28..061119f 100644 --- a/src/UI/TypeControls.h +++ b/src/UI/TypeControls.h @@ -1,12 +1,12 @@ #pragma once #include "Document/TypeTool.h" #include "UI/ToolHeaderStyle.h" +#include <QComboBox> #include <QLineEdit> #include <array> #include <functional> class EditorSession; -class QComboBox; class QPushButton; class QToolButton; class SwatchButton; @@ -32,6 +32,23 @@ private: bool m_borrowed = false; }; +// Swift's TypeFontPicker: the catalog loads when the menu opens. +class TypeFontPicker : public QComboBox { + Q_OBJECT +public: + explicit TypeFontPicker(QWidget *parent = nullptr); + // Shows `name`; an open menu is left alone. + void sync(const QString &name); + void showPopup() override; + +protected: + // A long name is cut short, never widening the control. + void paintEvent(QPaintEvent *event) override; + +private: + bool m_loaded = false; +}; + // Swift's TypeControls: font, size, alignment, spacing, Done. class TypeControls : public ToolHeaderBar { Q_OBJECT @@ -47,7 +64,7 @@ private: void synchronize(); EditorSession &m_session; - QComboBox *const m_font; + TypeFontPicker *const m_font; TextStyleField *const m_size; // Open text's colour, else the next text's: the foreground. SwatchButton *const m_colour; diff --git a/tests/BlendModeTests.cpp b/tests/BlendModeTests.cpp index c4342d3..c535b4c 100644 --- a/tests/BlendModeTests.cpp +++ b/tests/BlendModeTests.cpp @@ -70,6 +70,11 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source) case LayerBlendMode::multiply: result[channel] = b * s; break; case LayerBlendMode::screen: result[channel] = b + s - b * s; break; case LayerBlendMode::overlay: result[channel] = hardLight(s, b); break; + case LayerBlendMode::softLight: { + const double d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : std::sqrt(b); + result[channel] = s <= 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (d - b); + break; + } case LayerBlendMode::darken: result[channel] = std::min(b, s); break; case LayerBlendMode::lighten: result[channel] = std::max(b, s); break; case LayerBlendMode::difference: result[channel] = std::abs(b - s); break; @@ -132,7 +137,7 @@ void BlendModeTests::everyBlendModeMatchesThePdfFormulas_data() QTest::addColumn<LayerBlendMode>("mode"); const std::pair<const char *, LayerBlendMode> modes[] = { {"normal", LayerBlendMode::normal}, {"multiply", LayerBlendMode::multiply}, {"screen", LayerBlendMode::screen}, - {"overlay", LayerBlendMode::overlay}, {"darken", LayerBlendMode::darken}, {"lighten", LayerBlendMode::lighten}, + {"overlay", LayerBlendMode::overlay}, {"softLight", LayerBlendMode::softLight}, {"darken", LayerBlendMode::darken}, {"lighten", LayerBlendMode::lighten}, {"difference", LayerBlendMode::difference}, {"colorDodge", LayerBlendMode::colorDodge}, {"colorBurn", LayerBlendMode::colorBurn}, {"hue", LayerBlendMode::hue}, {"saturation", LayerBlendMode::saturation}, {"color", LayerBlendMode::color}, {"luminosity", LayerBlendMode::luminosity}}; diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 877b536..96c718e 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -36,8 +36,8 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() {"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"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", ""}, {"maskBlackAreas", ""}, - {"expandSelection", ""}, {"contractSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"}, + {"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"maskBlackAreas", ""}, + {"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"}, {"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""}, {"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""}, {"newHueSaturationAdjustment", ""}, {"newLevelsAdjustment", ""}, {"newCurvesAdjustment", ""}, {"newExposureAdjustment", ""}, @@ -54,7 +54,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() QCOMPARE(menus[0]->actions().size(), 11); QCOMPARE(menus[1]->actions().size(), 12); QCOMPARE(menus[2]->actions().size(), 6); - QCOMPARE(menus[3]->actions().size(), 8); + QCOMPARE(menus[3]->actions().size(), 10); QCOMPARE(menus[4]->actions().size(), 13); QCOMPARE(menus[5]->actions().size(), 5); QCOMPARE(menus[6]->actions().size(), 22); diff --git a/tests/LayerAppearanceTests.cpp b/tests/LayerAppearanceTests.cpp index 0c9a470..bba90a1 100644 --- a/tests/LayerAppearanceTests.cpp +++ b/tests/LayerAppearanceTests.cpp @@ -311,7 +311,7 @@ void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps() QCOMPARE(session.blendPreview(), std::optional(EditorSession::BlendPreview{top, LayerBlendMode::hue})); QCOMPARE(int(changes.count()), 0); QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay); - QCOMPARE(int(allLayerBlendModes.size()), 13); + QCOMPARE(int(allLayerBlendModes.size()), 14); for (size_t index = 0; index < allLayerBlendModes.size(); ++index) QCOMPARE(int(allLayerBlendModes[index]), int(index)); } diff --git a/tests/MaskTracingTests.cpp b/tests/MaskTracingTests.cpp index 330e489..e4345ff 100644 --- a/tests/MaskTracingTests.cpp +++ b/tests/MaskTracingTests.cpp @@ -63,6 +63,7 @@ private slots: void everyPatternFillsBackToItself_data(); void everyPatternFillsBackToItself(); void darkMeansBelowHalfGray(); + void whiteMeansAtLeastHalfGray(); void opaqueMeansAtLeastHalfAlpha(); void nothingToTraceIsNil(); void otherPixelFormatsAreConverted(); @@ -129,6 +130,12 @@ void MaskTracingTests::darkMeansBelowHalfGray() QVERIFY(!MaskTracing::darkPixels(mask({"#.", ".."}, 128, 255)).has_value()); } +void MaskTracingTests::whiteMeansAtLeastHalfGray() +{ + QCOMPARE(filled(MaskTracing::whitePixels(mask({"#.", ".."}, 128, 127)).value(), 2, 2), QStringList({"#.", ".."})); + QVERIFY(!MaskTracing::whitePixels(mask({"#.", ".."}, 127, 0)).has_value()); +} + void MaskTracingTests::opaqueMeansAtLeastHalfAlpha() { QCOMPARE(filled(MaskTracing::opaquePixels(layer({"#.", ".."}, 128, 127)).value(), 2, 2), QStringList({"#.", ".."})); diff --git a/tests/ProjectManifestTests.cpp b/tests/ProjectManifestTests.cpp index c8bcc97..e5d5882 100644 --- a/tests/ProjectManifestTests.cpp +++ b/tests/ProjectManifestTests.cpp @@ -202,7 +202,7 @@ void ProjectManifestTests::encodingWritesSwiftsKeysAndLeavesAbsentOnesOut() void ProjectManifestTests::everyRawValueRoundTrips() { - const QStringList blends{"Normal", "Multiply", "Screen", "Overlay", "Darken", "Lighten", "Difference", "Color Dodge", "Color Burn", + const QStringList blends{"Normal", "Multiply", "Screen", "Overlay", "Soft Light", "Darken", "Lighten", "Difference", "Color Dodge", "Color Burn", "Hue", "Saturation", "Color", "Luminosity"}; for (int index = 0; index < blends.size(); ++index) { QCOMPARE(rawValue(LayerBlendMode(index)), blends[index]); diff --git a/tests/SelectMenuTests.cpp b/tests/SelectMenuTests.cpp index 4e2dd0b..69a2a5e 100644 --- a/tests/SelectMenuTests.cpp +++ b/tests/SelectMenuTests.cpp @@ -23,9 +23,11 @@ private slots: void SelectMenuTests::selectEntriesFollowTheSelectionAndActOnIt() { Bar bar; - for (const char *name : {"selectAll", "deselect", "inverse", "layerPixels", "maskBlackAreas", "expandSelection", "contractSelection"}) + for (const char *name : {"selectAll", "deselect", "inverse", "layerPixels", "subject", "maskBlackAreas", "expandSelection", "contractSelection", + "featherSelection"}) QVERIFY2(!bar.action(name).isEnabled(), name); bar.session().createDocument(50, 50, true); + QVERIFY(bar.action("subject").isEnabled()); QVERIFY(bar.action("selectAll").isEnabled()); // A blank layer has no pixels and no mask. QVERIFY(!bar.action("layerPixels").isEnabled() && !bar.action("maskBlackAreas").isEnabled()); @@ -34,18 +36,25 @@ void SelectMenuTests::selectEntriesFollowTheSelectionAndActOnIt() QCOMPARE(bar.session().history.undoName(), QString("Select All")); QVERIFY(bar.action("deselect").isEnabled() && bar.action("inverse").isEnabled()); QVERIFY(bar.action("expandSelection").isEnabled() && bar.action("contractSelection").isEnabled()); - QCOMPARE(bar.action("expandSelection").text(), QString("Expand by 1 px")); - QCOMPARE(bar.action("contractSelection").text(), QString("Contract by 1 px")); - bar.session().setSelectionExpandAmount(3); - bar.session().setSelectionContractAmount(7); - QCOMPARE(bar.action("expandSelection").text(), QString("Expand by 3 px")); - QCOMPARE(bar.action("contractSelection").text(), QString("Contract by 7 px")); + QCOMPARE(bar.action("expandSelection").text(), QString("Expand…")); + QCOMPARE(bar.action("contractSelection").text(), QString("Contract…")); + QCOMPARE(bar.action("featherSelection").text(), QString("Feather…")); + // Each asks for its amount, which the sheet confirms. bar.action("contractSelection").trigger(); + QCOMPARE(bar.session().selectionAmountOperation(), std::optional(SelectionAmountOperation::contract)); + QVERIFY(!bar.action("contractSelection").isEnabled() && !bar.action("subject").isEnabled()); + bar.session().confirmSelectionAmount(7); QCOMPARE(bar.session().history.undoName(), QString("Contract Selection")); QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(7, 7, 36, 36)); bar.action("expandSelection").trigger(); + QCOMPARE(bar.session().selectionAmountOperation(), std::optional(SelectionAmountOperation::expand)); + bar.session().confirmSelectionAmount(3); QCOMPARE(bar.session().history.undoName(), QString("Expand Selection")); QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(4, 4, 42, 42)); + bar.action("featherSelection").trigger(); + QCOMPARE(bar.session().selectionAmountOperation(), std::optional(SelectionAmountOperation::feather)); + bar.session().confirmSelectionAmount(2); + QCOMPARE(bar.session().history.undoName(), QString("Feather Selection")); bar.action("inverse").trigger(); QCOMPARE(bar.session().history.undoName(), QString("Inverse")); bar.action("deselect").trigger(); @@ -65,10 +74,17 @@ void SelectMenuTests::selectEntriesFollowTheSelectionAndActOnIt() QCOMPARE(bar.session().history.undoName(), QString("Load Mask Selection")); // Busy, every entry waits; a draft holds Expand and Contract. bar.session().setIsProjectBusy(true); - for (const char *name : {"deselect", "inverse", "layerPixels", "maskBlackAreas", "expandSelection", "contractSelection"}) + for (const char *name : {"deselect", "inverse", "layerPixels", "subject", "maskBlackAreas", "expandSelection", "contractSelection", "featherSelection"}) QVERIFY2(!bar.action(name).isEnabled(), name); QVERIFY(bar.action("selectAll").isEnabled()); bar.session().setIsProjectBusy(false); + // Subject runs the model on what the canvas shows. + bar.action("subject").trigger(); + QVERIFY(bar.session().isProjectBusy()); + QTRY_VERIFY_WITH_TIMEOUT(!bar.session().isProjectBusy(), 20000); + // A white layer has no subject, and says so. + QVERIFY(bar.session().brushError().has_value()); + bar.session().setBrushError(std::nullopt); bar.session().selectTool(NavigationTool::lasso); bar.session().beginLasso(QPointF(1, 1), SelectionMode::replace); QVERIFY(!bar.action("expandSelection").isEnabled() && bar.action("deselect").isEnabled()); diff --git a/tests/SelectSubjectTests.cpp b/tests/SelectSubjectTests.cpp new file mode 100644 index 0000000..92b5cb9 --- /dev/null +++ b/tests/SelectSubjectTests.cpp @@ -0,0 +1,126 @@ +#include "Document/BrushStroke.h" +#include "SelectionFixtures.h" +#include <QPainter> + +// Select ▸ Subject: U²-Net's subject becomes the selection. +namespace { +// A red disc on white, or flat white. +std::unique_ptr<EditorSession> withImage(bool disc) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(400, 300); + QImage image = BrushRaster::context(400, 300, false); + image.fill(Qt::white); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(217, 26, 26)); + if (disc) + painter.drawEllipse(QPointF(200, 150), 80, 80); + painter.end(); + session->insert(ImportedImage(image, image, QStringLiteral("Picture"))); + return session; +} + +bool selected(EditorSession &session, SelectionMode mode) +{ + bool done = false; + session.selectSubject(mode, [&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 20000); +} +} + +class SelectSubjectTests : public QObject { + Q_OBJECT +private slots: + void theSubjectBecomesTheSelection(); + void theModeCombinesWithTheSelection(); + void aFlatPictureSelectsNothingAndSaysSo(); + void aRefusalAnswersAndChangesNothing(); + void anotherDocumentTakesNoOutline(); +}; + +void SelectSubjectTests::theSubjectBecomesTheSelection() +{ + const auto session = withImage(true); + QVERIFY(session->canSelectSubject()); + bool done = false; + session->selectSubject(SelectionMode::replace, [&done] { done = true; }); + QVERIFY(session->isProjectBusy()); + QVERIFY(!session->canSelectSubject()); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); + QVERIFY(!session->isProjectBusy()); + QCOMPARE(session->history.undoName(), QString("Select Subject")); + QCOMPARE(coverage(*session, 200, 150), 255); + QCOMPARE(coverage(*session, 270, 150), 255); + QCOMPARE(coverage(*session, 295, 150), 0); + QCOMPARE(coverage(*session, 10, 10), 0); + session->undo(); + QVERIFY(!session->selection()); +} + +void SelectSubjectTests::theModeCombinesWithTheSelection() +{ + const auto session = withImage(true); + session->applySelection(rectPath(QRectF(10, 10, 30, 30)), SelectionMode::replace, "Select"); + QVERIFY(selected(*session, SelectionMode::add)); + QCOMPARE(coverage(*session, 20, 20), 255); + QCOMPARE(coverage(*session, 200, 150), 255); + QVERIFY(selected(*session, SelectionMode::subtract)); + QCOMPARE(coverage(*session, 20, 20), 255); + QCOMPARE(coverage(*session, 200, 150), 0); + QVERIFY(selected(*session, SelectionMode::replace)); + QCOMPARE(coverage(*session, 20, 20), 0); + QCOMPARE(coverage(*session, 200, 150), 255); +} + +void SelectSubjectTests::aFlatPictureSelectsNothingAndSaysSo() +{ + const auto session = withImage(false); + QVERIFY(selected(*session, SelectionMode::replace)); + QVERIFY(!session->selection()); + QVERIFY(session->brushError().value().contains("subject")); + QVERIFY(!session->isProjectBusy()); +} + +void SelectSubjectTests::aRefusalAnswersAndChangesNothing() +{ + EditorSession empty; + QVERIFY(!empty.canSelectSubject()); + QSignalSpy quiet(&empty, &EditorSession::changed); + QVERIFY(selected(empty, SelectionMode::replace)); + QCOMPARE(int(quiet.count()), 0); + const auto session = withImage(true); + session->setIsProjectBusy(true); + QVERIFY(!session->canSelectSubject()); + QSignalSpy changes(session.get(), &EditorSession::changed); + QVERIFY(selected(*session, SelectionMode::replace)); + QCOMPARE(int(changes.count()), 0); + QVERIFY(!session->selection()); +} + +void SelectSubjectTests::anotherDocumentTakesNoOutline() +{ + const auto session = withImage(true); + EditorSession other; + other.createDocument(400, 300); + bool done = false; + session->selectSubject(SelectionMode::replace, [&done] { done = true; }); + session->installProject(other.projectSnapshot().value(), QString()); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); + QVERIFY(!session->selection()); + QCOMPARE(session->history.undoName(), QString()); + QVERIFY(!session->isProjectBusy()); + // A closed project takes none either. + const auto closing = withImage(true); + done = false; + closing->selectSubject(SelectionMode::replace, [&done] { done = true; }); + closing->clearProject(); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); + QVERIFY(!closing->document()); + QVERIFY(!closing->brushError()); + QVERIFY(!closing->isProjectBusy()); +} + +QTEST_GUILESS_MAIN(SelectSubjectTests) +#include "SelectSubjectTests.moc" diff --git a/tests/SelectionAmountSheetTests.cpp b/tests/SelectionAmountSheetTests.cpp new file mode 100644 index 0000000..b7ed268 --- /dev/null +++ b/tests/SelectionAmountSheetTests.cpp @@ -0,0 +1,140 @@ +#include "ContentView.h" +#include "SelectionFixtures.h" +#include "UI/LassoControls.h" +#include <QApplication> +#include <QLabel> +#include <QSlider> + +// Select's amount sheet, its panel, and the bar's Feather. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +QWidget *amountPanel() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == QStringLiteral("selectionAmountPanel") && widget->isVisible()) + return widget; + } + return nullptr; +} +} + +class SelectionAmountSheetTests : public QObject { + Q_OBJECT +private slots: + void theSheetTakesAWholeNumberInRange(); + void cancelAndThePanelsCloseForgetTheQuestion(); + void theBarFeathersByItsAmount(); +}; + +void SelectionAmountSheetTests::theSheetTakesAWholeNumberInRange() +{ + const auto session = selectionSession(); + lasso(*session, square(20, 20, 40)); + session->setSelectionFeatherAmount(9); + session->promptSelectionAmount(SelectionAmountOperation::feather); + SelectionAmountSheet sheet(*session, SelectionAmountOperation::feather); + sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&sheet)); + QCOMPARE(sheet.width(), 380); + auto &field = find<QLineEdit>(sheet, "amountField"); + auto &slider = find<QSlider>(sheet, "amountSlider"); + auto ¬e = find<QLabel>(sheet, "amountNote"); + auto &ok = find<QPushButton>(sheet, "amountOK"); + QTRY_VERIFY(field.hasFocus()); + QCOMPARE(field.text(), QString("9")); + QCOMPARE(slider.maximum(), 250); + QCOMPARE(slider.value(), 9); + QVERIFY(!note.isVisible() && ok.isEnabled() && ok.isDefault()); + QCOMPARE(note.text(), QString("Enter a whole number from 1 to 250 px.")); + const int height = sheet.height(); + // Out of range: the note shows, OK rests. + for (const char *typed : {"251", "0", "2.5", "x", ""}) { + field.setText(QString::fromLatin1(typed)); + QVERIFY2(note.isVisible() && !ok.isEnabled(), typed); + QCOMPARE(sheet.height(), height); + } + slider.setValue(40); + QCOMPARE(field.text(), QString("40")); + QVERIFY(ok.isEnabled()); + field.setText(QStringLiteral(" 12 ")); + QCOMPARE(slider.value(), 12); + QCOMPARE(note.font().pixelSize(), 12); + QTest::mouseClick(&ok, Qt::LeftButton); + QVERIFY(!session->selectionAmountOperation()); + QCOMPARE(session->selectionFeatherAmount(), 12); + QCOMPARE(session->selection().value().feather, 12.0); + // Expand and Contract reach 500. + SelectionAmountSheet expand(*session, SelectionAmountOperation::expand); + QCOMPARE(find<QSlider>(expand, "amountSlider").maximum(), 500); + QCOMPARE(find<QLineEdit>(expand, "amountField").text(), QString("1")); + QCOMPARE(find<QLabel>(expand, "amountNote").text(), QString("Enter a whole number from 1 to 500 px.")); +} + +void SelectionAmountSheetTests::cancelAndThePanelsCloseForgetTheQuestion() +{ + EditorSession session; + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + session.createDocument(100, 100, true); + session.selectAll(); + session.promptSelectionAmount(SelectionAmountOperation::contract); + QWidget *panel = nullptr; + QTRY_VERIFY((panel = amountPanel())); + QCOMPARE(panel->windowTitle(), QString("Contract Selection")); + QTest::mouseClick(&find<QPushButton>(*panel, "amountCancel"), Qt::LeftButton); + QVERIFY(!session.selectionAmountOperation()); + QTRY_VERIFY(!amountPanel()); + QCOMPARE(session.history.undoName(), QString("Select All")); + // Escape closes the panel, forgetting the question too. + session.promptSelectionAmount(SelectionAmountOperation::expand); + QTRY_VERIFY((panel = amountPanel())); + QCOMPARE(panel->windowTitle(), QString("Expand Selection")); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Escape); + QVERIFY(!session.selectionAmountOperation()); + QTRY_VERIFY(!amountPanel()); + // Return in the field is OK: it applies and closes. + session.promptSelectionAmount(SelectionAmountOperation::feather); + QTRY_VERIFY((panel = amountPanel())); + QCOMPARE(panel->windowTitle(), QString("Feather Selection")); + QTRY_COMPARE(QApplication::focusWidget(), &find<QLineEdit>(*panel, "amountField")); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + QCOMPARE(session.history.undoName(), QString("Feather Selection")); + QTRY_VERIFY(!amountPanel()); +} + +void SelectionAmountSheetTests::theBarFeathersByItsAmount() +{ + const auto session = selectionSession(); + LassoControls controls(*session); + controls.show(); + QVERIFY(QTest::qWaitForWindowExposed(&controls)); + auto &feather = find<QPushButton>(controls, "featherSelection"); + auto &amount = find<QLineEdit>(controls, "selectionFeatherAmount"); + QVERIFY(!feather.isEnabled() && !amount.isEnabled()); + QCOMPARE(amount.text(), QString("2")); + lasso(*session, square(20, 20, 40)); + QVERIFY(feather.isEnabled() && amount.isEnabled()); + amount.setFocus(); + QTRY_VERIFY(amount.hasFocus()); + amount.selectAll(); + QTest::keyClicks(&amount, "300"); + QCOMPARE(session->selectionFeatherAmount(), 250); + amount.selectAll(); + QTest::keyClicks(&amount, "3"); + QTest::mouseClick(&feather, Qt::LeftButton); + QCOMPARE(session->selection().value().feather, 3.0); + QCOMPARE(session->history.undoName(), QString("Feather Selection")); +} + +QTEST_MAIN(SelectionAmountSheetTests) +#include "SelectionAmountSheetTests.moc" diff --git a/tests/SelectionFeatherTests.cpp b/tests/SelectionFeatherTests.cpp new file mode 100644 index 0000000..7b4b139 --- /dev/null +++ b/tests/SelectionFeatherTests.cpp @@ -0,0 +1,188 @@ +#include "SelectionFixtures.h" + +// Feather softens the selection itself, so everything clipped fades. +namespace { +// Values strictly between clear and full along one row. +int fading(const QImage &image, int row, int channel, int stride) +{ + int count = 0; + for (int x = 0; x < image.width(); ++x) { + const int value = image.constScanLine(row)[x * stride + channel]; + count += value > 8 && value < 247; + } + return count; +} +} + +class SelectionFeatherTests : public QObject { + Q_OBJECT +private slots: + void featherSoftensTheSelectionAndWhatItClips(); + void feathersAddAsBlursAndStopAtTheirLimit(); + void featherTravelsWithTheOutline(); + void theClipGrowsByTheFalloff(); + void theAmountSheetAsksThenApplies(); + void aMaskFromAFeatheredSelectionFades(); +}; + +void SelectionFeatherTests::featherSoftensTheSelectionAndWhatItClips() +{ + EditorSession session; + session.createDocument(60, 20, true); + session.addBlankLayer(); + session.selectAll(); + session.setSelectionFeatherAmount(6); + session.featherSelection(6); + QVERIFY(session.canModifySelection()); + QCOMPARE(session.selection().value().feather, 6.0); + QCOMPARE(session.history.undoName(), QString("Feather Selection")); + session.setSelection(DocumentSelection{rectPath(QRectF(20, 0, 20, 20)), true, 6}, "probe"); + const SelectionClip clip = session.selection().value().clip(QSizeF(60, 20)); + const QImage coverage = clip.coverage.value(); + QVERIFY2(fading(coverage, coverage.height() / 2, 0, 1) >= 4, "the clip has no soft edge"); + // A copy takes the fade too, within the canvas. + QCOMPARE(session.selectionCopyRegion().value(), QRectF(8, 0, 44, 20)); + session.setForegroundColor(PaletteColor{1, 0, 0}); + bool done = false; + session.fillSelection(EditorSession::FillSource::foreground, [&] { done = true; }); + QTRY_VERIFY(done); + const QImage filled = session.activeLayer().value().asset.value().image().convertToFormat(QImage::Format_RGBA8888_Premultiplied); + QVERIFY2(fading(filled, filled.height() / 2, 3, 4) >= 4, "the fill has a hard edge"); + // Trimmed to the fade: whole inside, faint at the ends. + QCOMPARE(session.activeLayer().value().transform.origin, QPointF(12, 0)); + QCOMPARE(filled.width(), 36); + QCOMPARE(int(filled.constScanLine(10)[18 * 4 + 3]), 255); + QCOMPARE(int(filled.constScanLine(10)[3]), 1); +} + +void SelectionFeatherTests::feathersAddAsBlursAndStopAtTheirLimit() +{ + const auto session = selectionSession(); + QSignalSpy changes(session.get(), &EditorSession::changed); + session->featherSelection(4); + QVERIFY(!session->selection()); + QCOMPARE(int(changes.count()), 0); + lasso(*session, square(10, 10, 40)); + session->featherSelection(3); + session->featherSelection(4); + QCOMPARE(session->selection().value().feather, 5.0); + session->featherSelection(0); + session->featherSelection(-3); + QCOMPARE(session->selection().value().feather, 5.0); + session->featherSelection(250); + QCOMPARE(session->selection().value().feather, 250.0); + session->undo(); + QCOMPARE(session->selection().value().feather, 5.0); + // A hard edge stays hard: clear or full, nothing between. + session->setSelectionAntialiased(false); + lasso(*session, {QPointF(10, 10), QPointF(60, 20), QPointF(20, 70)}); + const QImage hard = session->selection().value().coverage(100, 100); + for (int y = 0; y < 100; ++y) + QCOMPARE(fading(hard, y, 0, 1), 0); +} + +void SelectionFeatherTests::featherTravelsWithTheOutline() +{ + const auto session = selectionSession(); + lasso(*session, square(20, 20, 40)); + session->featherSelection(4); + session->expandSelection(2); + QCOMPARE(session->selection().value().feather, 4.0); + session->contractSelection(1); + QCOMPARE(session->selection().value().feather, 4.0); + session->invertSelection(); + QCOMPARE(session->selection().value().feather, 4.0); + session->invertSelection(); + QVERIFY(session->beginSelectionMove()); + session->moveSelection(QSizeF(5, 0)); + QCOMPARE(session->selection().value().feather, 4.0); + QCOMPARE(bounds(*session).left(), 24.0); +} + +void SelectionFeatherTests::theClipGrowsByTheFalloff() +{ + const DocumentSelection soft{rectPath(QRectF(10, 10, 20, 20)), true, 3}; + QCOMPARE(soft.coverageBounds(), QRectF(4, 4, 32, 32)); + QCOMPARE(soft.clip(QSizeF(100, 100)).rect, QRectF(3, 3, 34, 34)); + QCOMPARE(soft.clip(QSizeF(20, 20)).rect, QRectF(3, 3, 17, 17)); + // A feathered edge is antialiased first, whatever the choice. + QPainterPath triangle; + triangle.addPolygon(QPolygonF{QPointF(5, 5), QPointF(35, 12), QPointF(12, 33)}); + QCOMPARE((DocumentSelection{triangle, false, 1}.coverage(40, 40)), (DocumentSelection{triangle, true, 1}.coverage(40, 40))); + // The canvas edge repeats: a whole canvas stays whole there. + const QImage whole = DocumentSelection{rectPath(QRectF(0, 0, 40, 40)), true, 6}.coverage(40, 40); + QCOMPARE(int(whole.constScanLine(0)[0]), 255); + QCOMPARE(int(whole.constScanLine(39)[20]), 255); + const DocumentSelection hard{rectPath(QRectF(10, 10, 20, 20)), true, 0}; + QCOMPARE(hard.coverageBounds(), QRectF(10, 10, 20, 20)); + // Past the outline it fades, then nothing. + const QImage image = soft.coverage(40, 40); + QVERIFY(image.constScanLine(20)[8] > 0); + QCOMPARE(int(image.constScanLine(20)[2]), 0); + QCOMPARE(int(image.constScanLine(20)[20]), 255); + // Either side of the outline mirrors the other. + QCOMPARE(image.constScanLine(20)[9] + image.constScanLine(20)[10], 255); +} + +void SelectionFeatherTests::theAmountSheetAsksThenApplies() +{ + const auto session = selectionSession(); + QSignalSpy changes(session.get(), &EditorSession::changed); + session->promptSelectionAmount(SelectionAmountOperation::feather); + QVERIFY(!session->selectionAmountOperation()); + QCOMPARE(int(changes.count()), 0); + lasso(*session, square(20, 20, 40)); + session->promptSelectionAmount(SelectionAmountOperation::feather); + QCOMPARE(session->selectionAmountOperation(), std::optional(SelectionAmountOperation::feather)); + QVERIFY(!session->canEditLayers()); + QVERIFY(!session->canUseHistory()); + QVERIFY(!session->canStartProjectOperation()); + session->confirmSelectionAmount(0); + session->confirmSelectionAmount(251); + QVERIFY(session->selectionAmountOperation().has_value()); + QCOMPARE(session->selection().value().feather, 0.0); + session->confirmSelectionAmount(250); + QVERIFY(!session->selectionAmountOperation()); + QCOMPARE(session->selectionFeatherAmount(), 250); + QCOMPARE(session->selection().value().feather, 250.0); + QVERIFY(session->canEditLayers()); + // A file request waits for the question, then runs. + session->promptSelectionAmount(SelectionAmountOperation::expand); + bool ran = false; + session->waitForFileRequest([&ran] { ran = true; }); + QTest::qWait(10); + QVERIFY(!ran); + session->setSelectionAmountOperation(std::nullopt); + QTRY_VERIFY(ran); + session->promptSelectionAmount(SelectionAmountOperation::expand); + session->confirmSelectionAmount(501); + QVERIFY(session->selectionAmountOperation().has_value()); + session->confirmSelectionAmount(3); + QCOMPARE(session->selectionExpandAmount(), 3); + QCOMPARE(session->history.undoName(), QString("Expand Selection")); + QCOMPARE(bounds(*session), QRectF(17, 17, 46, 46)); + session->promptSelectionAmount(SelectionAmountOperation::contract); + session->confirmSelectionAmount(500); + QCOMPARE(session->selectionContractAmount(), 500); + QVERIFY(session->selection().value().isEmpty()); + // No question open: an amount does nothing. + session->undo(); + session->confirmSelectionAmount(5); + QCOMPARE(session->selectionContractAmount(), 500); +} + +void SelectionFeatherTests::aMaskFromAFeatheredSelectionFades() +{ + EditorSession session; + session.createDocument(60, 20, true); + session.setSelection(DocumentSelection{rectPath(QRectF(20, 0, 20, 20)), true, 6}, "probe"); + session.addMask(false); + const QImage mask = session.activeLayer().value().mask.value().asset.image(); + QCOMPARE(mask, session.document().value().layers.back().mask.value().asset.image()); + QVERIFY2(fading(mask, 10, 0, 1) >= 4, "the mask has a hard edge"); + QCOMPARE(int(mask.constScanLine(10)[30]), 255); + QCOMPARE(int(mask.constScanLine(10)[2]), 0); +} + +QTEST_GUILESS_MAIN(SelectionFeatherTests) +#include "SelectionFeatherTests.moc" diff --git a/tests/TypeControlsTests.cpp b/tests/TypeControlsTests.cpp index 31fd5fa..5938cbe 100644 --- a/tests/TypeControlsTests.cpp +++ b/tests/TypeControlsTests.cpp @@ -39,7 +39,7 @@ private slots: void sizeAndTrackingFollowTheLocale(); void leadingIsAutoWhenEmptyAndClamps(); void fieldsKeepTypingAndTheFocus(); - void alignmentAndFontChangeTheDraft(); + void alignmentChangesTheDraft(); void theButtonsFollowTheDraft(); void theBarRestsWithoutADocumentOrWhileBusy(); void aTextRowShowsAndOpensItsText(); @@ -50,17 +50,27 @@ void TypeControlsTests::theBarShowsTheStyleItEdits() Bar shown; QCOMPARE(shown.bar.title->text(), QString("Type")); auto &font = find<QComboBox>(shown.bar, "typeFont"); - QCOMPARE(font.width(), 185); + QCOMPARE(font.width(), 210); QCOMPARE(font.toolTip(), QString("Font face, including bold and italic variants")); - // Every face, and Helvetica though missing, sorted. + QCOMPARE(font.accessibleName(), QString("Font")); + // The catalog waits for the menu: the style's face alone. + QCOMPARE(font.count(), 1); + QCOMPARE(font.currentText(), QString("Helvetica")); + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Aachen"); }); + QCOMPARE(font.count(), 2); + font.showPopup(); + font.hidePopup(); + // Every face, and the style's own though missing, sorted. QStringList names = TextLayout::availableFonts(); - names << QStringLiteral("Helvetica"); + names << QStringLiteral("Aachen"); names.sort(); QStringList listed; for (int index = 0; index < font.count(); ++index) listed << font.itemText(index); QCOMPARE(listed, names); - QCOMPARE(font.currentText(), QString("Helvetica")); + QCOMPARE(font.currentText(), QString("Aachen")); + QCOMPARE(font.itemText(0), QString("Aachen")); + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Helvetica"); }); TextStyleField &size = shown.field("typeSize"), &tracking = shown.field("typeTracking"), &leading = shown.field("typeLeading"); QVERIFY(size.width() == 52 && tracking.width() == 45 && leading.width() == 52); QCOMPARE(size.text(), QString("72")); @@ -296,10 +306,9 @@ void TypeControlsTests::fieldsKeepTypingAndTheFocus() QCOMPARE(shown.session.currentTextStyle().fontSize, 1.0); } -void TypeControlsTests::alignmentAndFontChangeTheDraft() +void TypeControlsTests::alignmentChangesTheDraft() { Bar shown; - auto &font = find<QComboBox>(shown.bar, "typeFont"); QToolButton &left = shown.align("typeAlignLeft"), ¢er = shown.align("typeAlignCenter"); // Without a draft the defaults change. center.click(); @@ -310,21 +319,6 @@ void TypeControlsTests::alignmentAndFontChangeTheDraft() left.click(); QCOMPARE(shown.session.textDraft().value().style.alignment, TextAlignment::left); QVERIFY(left.isChecked() && !center.isChecked()); - // A face writes its name; Helvetica then leaves the list. - const int bold = font.findText(QStringLiteral("DejaVuSans-Bold")); - QVERIFY(bold >= 0); - emit font.activated(bold); - QCOMPARE(shown.session.textDraft().value().style.fontName, QString("DejaVuSans-Bold")); - QCOMPARE(font.currentText(), QString("DejaVuSans-Bold")); - QCOMPARE(font.findText(QStringLiteral("Helvetica")), -1); - QCOMPARE(font.count(), TextLayout::availableFonts().size()); - // A missing face named elsewhere joins the list. - shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Zapfino"); }); - QCOMPARE(font.currentText(), QString("Zapfino")); - QCOMPARE(font.count(), TextLayout::availableFonts().size() + 1); - QCOMPARE(font.itemText(font.count() - 1), QString("Zapfino")); - shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Aachen"); }); - QVERIFY(font.currentText() == QString("Aachen") && font.itemText(0) == QString("Aachen") && font.findText("Zapfino") < 0); // Editing an existing text layer opens its draft. shown.session.cancelText(); const QUuid text = addText(shown.session, QStringLiteral("Aligned")); diff --git a/tests/TypeFontPickerTests.cpp b/tests/TypeFontPickerTests.cpp new file mode 100644 index 0000000..3cca6c7 --- /dev/null +++ b/tests/TypeFontPickerTests.cpp @@ -0,0 +1,97 @@ +#include "TypeControlsFixtures.h" +#include "Rendering/TextLayout.h" +#include "UI/TypeControls.h" +#include <QAbstractItemView> +#include <QComboBox> +#include <QStyleOptionComboBox> +#include <QtTest> + +// The Type bar's font menu: loaded late, cut short. +class TypeFontPickerTests : public QObject { + Q_OBJECT +private slots: + void aFaceIsChosenKeptAndCutShort(); + void theFaceShownChangesNothing(); +}; + +void TypeFontPickerTests::aFaceIsChosenKeptAndCutShort() +{ + Bar shown; + auto &font = find<QComboBox>(shown.bar, "typeFont"); + // Open text on a layer: a face restyles its draft. + addText(shown.session, QStringLiteral("Face")); + shown.session.editActiveText(); + QVERIFY(shown.session.textDraft().has_value()); + font.showPopup(); + font.hidePopup(); + // A face writes its name; the loaded list keeps Helvetica. + const int bold = font.findText(QStringLiteral("DejaVuSans-Bold")); + QVERIFY(bold >= 0); + emit font.activated(bold); + QCOMPARE(shown.session.textDraft().value().style.fontName, QString("DejaVuSans-Bold")); + QCOMPARE(font.currentText(), QString("DejaVuSans-Bold")); + QVERIFY(font.findText(QStringLiteral("Helvetica")) >= 0); + QCOMPARE(font.count(), TextLayout::availableFonts().size() + 1); + // A missing face named elsewhere joins the list's end. + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Zapfino"); }); + QCOMPARE(font.currentText(), QString("Zapfino")); + QCOMPARE(font.count(), TextLayout::availableFonts().size() + 2); + QCOMPARE(font.itemText(font.count() - 1), QString("Zapfino")); + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Aachen"); }); + QVERIFY(font.currentText() == QString("Aachen") && font.itemText(font.count() - 1) == QString("Aachen") && font.findText("Zapfino") >= 0); + // An open menu is left alone; closed, it follows. + font.showPopup(); + QVERIFY(font.findText("Zapfino") >= 0); + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Zapfino"); }); + QCOMPARE(font.currentText(), QString("Aachen")); + font.hidePopup(); + QTRY_VERIFY(!font.view()->isVisible()); + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Zapfino"); }); + QCOMPARE(font.currentText(), QString("Zapfino")); + // A long name is cut, the width kept. + QStyleOptionComboBox option; + option.initFrom(&font); + const int room = font.style()->subControlRect(QStyle::CC_ComboBox, &option, QStyle::SC_ComboBoxEditField, &font).width() - 2; + const auto image = [](QWidget &widget) { return widget.grab().toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); }; + // One name far past the room, one just past. + QString nearly; + for (int wide = 0; wide < 40 && nearly.isEmpty(); ++wide) { + for (int thin = 0; thin < 40 && nearly.isEmpty(); ++thin) { + const QString name = QString(wide, QLatin1Char('W')) + QString(thin, QLatin1Char('i')); + const int advance = font.fontMetrics().horizontalAdvance(name); + if (advance > room && advance <= room + 2) + nearly = name; + } + } + QVERIFY(!nearly.isEmpty()); + for (const QString &name : {QString(120, QLatin1Char('W')), nearly}) { + shown.restyle([&](LayerTextStyle &style) { style.fontName = name; }); + QCOMPARE(font.width(), 210); + QComboBox plain; + plain.setFixedSize(font.size()); + plain.setPalette(font.palette()); + plain.setFont(font.font()); + plain.addItem(font.fontMetrics().elidedText(name, Qt::ElideRight, room)); + QVERIFY(plain.currentText().endsWith(QChar(0x2026))); + QCOMPARE(image(font), image(plain)); + } +} + +void TypeFontPickerTests::theFaceShownChangesNothing() +{ + Bar shown; + auto &font = find<QComboBox>(shown.bar, "typeFont"); + addText(shown.session, QStringLiteral("Face")); + shown.session.selectTool(NavigationTool::move); + QVERIFY(!shown.session.textDraft()); + font.showPopup(); + font.hidePopup(); + QSignalSpy changes(&shown.session, &EditorSession::changed); + emit font.activated(font.findText(QStringLiteral("Helvetica"))); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.tool(), NavigationTool::move); + QCOMPARE(int(changes.count()), 0); +} + +QTEST_MAIN(TypeFontPickerTests) +#include "TypeFontPickerTests.moc"