Typed text drawn as its pixels; effects kept while typing, painting masks and undoing (12.12a)

This commit is contained in:
雷电芽衣 committed 2026-09-29 12:47:36 -04:00
1 parent 8fbb104c48
commit 136ee65a48
23 files changed
+1125 -153

No files matched your search

+3 -2
View File
@@ -83,7 +83,7 @@ Platform adaptations, each forced by a missing Qt analog:
- 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<BrushStroke>`, 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 passeLine truncated
- 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 `QWidgetLiLine truncated
- 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 (clear glyphs since 12.12a); 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. ELine truncated
- 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 (knownLine truncated
- 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; itLine truncated
- The Eyedropper and picking (8.10b): `EditorCanvas+Picking.cpp` holds Swift's sampling. `palettePicking` is the Eyedropper, or Alt with the Brush, Spot Healing or the Gradient while no stroke runs and no gradient end is dragged; `picking` adds an open picker (the Levels and hue sample modes arrive with 9.1 and 9.3). A press while picking, Space aside, samples at once and keeps the colour it began from; a drag samples on; the release ends it and, with a picker open, hands the picker the keys back (`ColorPickerPanelController::refocus`, which the canvas includes, as Swift's calls it). `sampleColor` samples into the open picker, else sets the foreground through `setForegroundColor` while the palette can be edited (a chosen mask too, as Swift sets `foregroundColor`), and rings the point. Qt can lose a release: a move without the left button, a lost focus or the next press ends a sampling. Picking outranks every tool's press, a double click (the second click is a press, so the inline editor and a polygonal Lasso stand aside while the picker is open, Swift's `hitTest`), a hover with no button held (the eyedropper alone: the brush circle neither moves nor shows, as Swift's `mouseMoved` returns first; a right-button drag still sizes the tip, as Swift's `rightMouseDragged` ignores picking) and the cursor (after the drag cursors, before Space). `syncPicking` ends a sampling when picking begins or ends: `synchronizeDisplay` runs it, as does every modifier change (Swift's monitor), a sampling press before it begins (Alt may first show in the press, and the foreground's signal would otherwise end the new sampling) and every move (Alt let go unseen). `pickingRelease` returns nothing, where Swift returns from `mouseUp`: a sampling press begins no other gesture, so the rest of the release finds nothing to end. `SampleRingOverlay` is no widget, like `BrushCursorOverlay`: 116 points round the pointer, a 24-point ring of gray 0.45 under a 16-point one, the sampled colour on the top half and the original below, each half replacing the clip and one restore dropping it, drawn over the overlays and under the inline editor; `showsSampleRing` (a getter and a setter that notifies) hides it. The Eyedropper's bar is Swift's inline one, built in `ContentView`: the title and a "Sample Ring" `QCheckBox` 16 points on, following the session under a `QSignalBlocker`. `eyedropperCursor` draws the symbol into Swift's 20-point box of a 24-point image, sixteen white offsets of 1.25 and then black; its hot spot is the drawn tip, (5, 19), as Swift's (3, 21) is its symbol's tip. The glyph is `Rendering/EyedropperIcon`, which the rail and the cursor share, as they share `SelectionIcons`. A mounted canvas leaves the keys to an open picker (Swift's `viewDidMoveToWindow`). The application event filter moved, unchanged, beside the modifier helpers in `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. Equivalent mutant: the ring's `setBrush(Qt::NoBrush)` (the painter reaches it with no brush).
@@ -100,7 +100,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`.
- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and both resizers leave them out (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy.
- Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, one surface (200 million since 12.11a). Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning).
- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's. A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too.
- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's (the recent results and `renderNow` since 12.12a). A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too.
- `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels).
- The canvas's effects (9.6c): `DisplayState::Layer` carries `effects`, `drawLayers` calls `prepare`, and `synchronizeDisplay` hands a finished stroke's surface to the cache as a seed first (`handOnStrokeSurface`, Swift's opening lines). `drawOwn` keeps Swift's order: the warp, the distorted effects, the distortion's preview, the effects preview, then the stroke, the shape's preview and the colour edits' previews, so a layer with effects shows its effects over its own pixels while Levels, Hue/Saturation or a filter previews it, as Swift's does (`EffectsCanvasTests` pins it). A fix beyond Swift: Swift asks the cache twice a draw, first with the mask unplaced and the stored transform, then with what the layer shows; the first request wins, so a placed mask clips the preview unplaced, and while a transform edit moves such a layer the two requests cancel each other and render without end. The twin asks once, with the shown mask and transform, before the distortion's branches, so the mask is worked out first. The drawing functions lost their `const`: they update the stroke surface and hand the cache a repaint. `drawStroke` moved to `EditorCanvas+Effects.cpp` beside `strokeSurface` and `handOnStrokeSurface`, keeping `EditorCanvas.cpp` under 500 lines. `Distort.cpp` gains `DistortEffectsCache`, both `distortedEffects` (`const` with a mutable cache, as `distortPreview`; a warp that throws leaves the effects off, Swift's `try?`) and `commitDistort`'s seed, the cache cleared after the step as Swift's `defer` clears it. Swift has no tests for any of this: `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests` and `EffectsCanvasTests` pin its code. Equivalent mutants: the pool's thread count (a result lands by its request's id, whatever the order), the destructor's `clear` and every cancelling of a superseded request, the worker's two checks included (the landing's id check drops them; they only spare renders), the matching's `&&` between two absent masks (the sources must match anyway), `!previous` before the seed (a landed result clears its seed), the resizes skipped at full size (a draw at 1:1 copies); in the surface, the reach's floor of one (an overlay reads each pixel alone) and the stroke's `+ 2` (the spread reads `lround(size)`), `inner`'s cut at the bounds (QPainter clips the draw alike), the first region's margin (the reach goes as far) and the window's skip of tiles off its region (it only spares draws); `commitDistort`'s clearing of its cache (keyed by its inputs, the cache only frees memory). `EffectsFailureTests` starves a stroke's first pass through `AddressSpaceLimit`, in a binary of its own: the canvas logs it and shows the last effects. Untested failure paths: `rendered`'s and the landing's tests of a result, and the resize's null check, differ only when a preview fails, and a preview asks too little for `AddressSpaceLimit` to reach a worker's arena.
- Layer effects' panel (9.6d): `UI/EffectsSheet.h/.cpp` twin Swift's sheet: 340 points wide with 20 of padding and 16 between rows, the kind's headline (13 pixels, semibold), the stroke's Position as two touching checkable `QToolButton`s in an exclusive `QButtonGroup` (Swift's segmented picker; its hidden label is the box's accessible name), the colour as the Type bar's `SwatchButton` (36 by 18, radius 3, a half-black rim, `<Kind> color` its tooltip and accessible name, black while the effect is missing) opening `openEffectColorPicker`, Swift's `slider` rows (a 64-point title as the slider's buddy, a 130-point slider over a thousand steps of its range that sets the value unrounded, as Swift's continuous binding, a 48-point right-aligned `PickerField` with the title as its accessible name and placeholder, its unit two points after), then Cancel and OK ten points apart, OK the default button and Cancel no auto-default, so Return anywhere means OK, as Swift's `.defaultAction`. A field shows its value whole, ties to even as Swift's `.number` format rounds; it applies a readable finite number on leaving or on Return (which then goes on to OK), clamped to the slider's range as Swift's binding, reverts other text, keeps its typing while other changes arrive, and steps with Up and Down (Shift ten) from the session's value, clamped, the step replacing any typing and showing, as the picker's entries do; a change through the row's own slider (a wheel leaves the keys with the field) replaces it too. Each change is a step, as Swift's `changeEffects`. On every change of the picker's colour the sheet calls `previewEffectColor` (Swift's `onChange`), and it repaints its swatch on every change. While the layer lacks the effect the controls hide, as Swift's `if let effect`. `ContentView::synchronizePanels` first runs Swift's layers check (an edited effect gone from its layer, or the layer gone, closes an effect picker without its colour and clears `effectsEditing` and `effectsEditingOriginal`; a palette's picker stays), then shows the sheet in `effectsPanel`, titled by the kind, whenever `effectsEditing` changes (a new choice, a new sheet), its close button and Escape cancelling, and closes it when the choice clears. The footer's effects button (`sparkles` in `LayerIcons`: three four-pointed stars) is an instant-popup menu of the four kinds, each with an ellipsis, resting while `canEditEffects` does not hold. While an effect is chosen (`selectedEffect`, so a choice an undo took away names the layer again) the trash says `Delete selected effect` and the Layer menu's Delete `Delete <Kind>`. The canvas's left press lets go of a chosen effect before any guard, Swift's first line of `mouseDown`; other buttons keep it. Swift has no tests for the window's effects: `EffectsPanelTests` pins them, and `LayerEffectsSessionTests` the new writers' signals. Equivalent mutants: the fields' `OmitGroupSeparator` (no range reaches a thousand) and OK's explicit `setDefault` (a dialog makes its first auto-default button the default as it shows, and Cancel is none).
@@ -137,6 +137,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Photoshop Large Documents, background-only files and oversized layers (12.11b1, Swift 1.2.8–1.2.10: `b8e3cd1`, `e6bcfa1`, `1fcf854`). Swift's `PSDReader.swift` runs to 543 lines, so the twin splits it: `IO/PSD/PSDReader+Records.h/.cpp` hold the cursor, the raw records, their pixels and the cut (`PSDRecords`), `PSDReader.cpp` the header, the assembly and the merged image. A version-2 header is a Large Document (`Header::isPSB`): the layer section's and layer info's lengths, each channel's length and the additional-info keys Swift lists (`psbLargeKeys`) take eight bytes (`Cursor::length`, a length past `qint64` too large), and PackBits rows count in four bytes (`PSDChannelCoder::decode`'s `largeDocument`); another version is `unsupportedVersion`, whose message names no format now (Swift's words, OmaPhoto for Compositor). The import panel offers `*.psb`, a dropped picture prefers the PSB type right after PSD's (`ImageFileDrop`, Swift's order), and the MIME package declares `image/x-photoshop-large-image` (a `*.psb` glob and the version-2 signature as magic) for the desktop entry, as Swift's Info.plist declares the type. A file whose records do not fit the budget together (`fitsBudget`, the check `decodeChannels` makes, summed) has every layer and mask cut to the canvas (`cropToCanvas`, the bounds Swift's `crop` gives, the channels' source bounds kept) and is judged again, refused only if it still does not fit; a cut channel is decoded straight into its part (`PSDCrop`: raw rows copied in part, PackBits rows outside it skipped by their counts, one routine with the whole image as its crop for an uncut channel), and each cut layer's report says so (`PSDRecord::croppedToCanvas`, the note first in its layer's notes). A file with no layer records, Photoshop's background alone, reads as its merged image (`PSDReader::merged`, ImageIO's read for Swift): the header, its ICC profile (resource 1039) attached as Qt reads it and sRGB when it has none, the sides and the remaining budget judged from the 26-byte header before the file is read, its ICC profile (resource 1039) copied only for this read, since a layered read skips it as Swift's does, as Swift asks ImageIO's properties before it decodes (the layered canvas's one-surface ceiling does not apply, as those properties know none), the first three of its channels' planes from the image data section (every channel's row counts precede the rows), opaque; `ImageImporter::decode`'s `flattenedPhotoshop` sends it through the importer's own colour conversion and thumbnail, and when the Photoshop worker finds no records (`PhotoshopRead::layerless`) the session ends the reading first, as Swift does, then decodes the merged image through the ordinary decoder (`decode(url, flattened)`, which also takes the merged reader's `PSDError` and `ExportError`) and inserts the one layer without the conversion sheet. Known difference: ImageIO also reads 16-bit and non-RGB merged images; the twin's reader keeps the layered path's 8-bit RGB rule. The second `fitsBudget` refuses before any pixel is read (decoding the first layer could fail otherwise, and differently), and the crop's `max` cuts an inverted record to nothing, reported. A source's bounds and the cut stay 64-bit (`PSDCrop`, the coder's widths): once a cut makes a source past the side limit acceptable, its width can pass `int`, and Swift's `Int` holds it; PackBits rows decode straight into the crop's columns, so no row the width of the source is held, and the counts table is judged against the data before it is allocated. A PackBits row past the crop writes through a checked `std::span`, so a lost skip aborts. Tests: `PSBImportTests` (Swift's three; the merged image, its profile, budget and surface rule; the session's import at a point), `CropToCanvasImportTests` (Swift's five; the layers summed before any cut, a layer wholly before the left edge), the version and oversized cases in `PSDRoundTripTests` and `PSDReaderLayerTests`, the MIME package in `DesktopIntegrationTests`.
- Photoshop text (12.11b2, Swift 1.2.10: `b8cce6e`, `fa2b342`): `IO/PSD/PSDText.h/.cpp` twin Swift's file, a namespace; Swift's 682 lines split into `PSDText+Engine` (the text engine's PostScript dictionary, a `std::variant` tree read through `walk`, `number`, `boolean`, `string` and `array`) and `PSDText+Descriptor` (Adobe's action descriptors, `Reader` and the item lookups; a rectangle is a normalised `QRectF`, and a side's key is chosen by presence before its type is read, Swift's `??`). A type layer (`TySh`, else `tySh`; 8,000,000 bytes at most) reads as Swift's: version 1, a finite matrix, version 50, the text descriptor; vertical text, shear and uneven scale refuse (the matrix's scale alone maps engine sizes to pixels, never the document's resolution); a warp other than none, faux bold or italic, runs whose twelve-field signature differs from the first, and justification other than left, right or centre each add Swift's note; the words come from `Txt ` (else `Txt`), cleaned of a leading BOM or NUL, trailing NULs and CR line ends, and only when that item is missing from the engine's `EngineDict/Editor/Text`; 1 to 100,000 UTF-16 units. A frame (`bounds`) wider and taller than the letters (`boundingBox`) by more than four makes box text, its corner mapped through the matrix; rectangles read standardised, as `CGRect` reads them. `render` draws through `EditorSession::textImage` and places the anchor (the frame's padded corner, or point text's first baseline at its alignment's x, `TextLines::baseline`, new here) on the document point through the layer transform, a transform that is not valid being `ProjectError(tooLarge)`. The reader parses type first, so a parsed type layer never becomes a live shape or a rasterized vector mask; the builder draws the text (any `std::runtime_error` keeps the pixels with Swift's rasterized note, Swift's `try?`), reports the parser's notes and a missing face, and makes a live text layer. A missing face is one fontconfig does not list by PostScript name (`TextLayout::availableFonts`), where Swift asks `NSFont`; its note says the text was drawn with a substitute font, since Qt draws fontconfig's match where macOS draws the system font. Engine numbers read as Swift's `Double(text)` (whole token, C locale, overflow infinite, through `strtod_l`); a name with a high byte reads empty and an identifier with one refuses, Swift's ASCII decoding; an odd count of UTF-16 bytes in an engine string reads empty, and a lone surrogate in either reader becomes U+FFFD (Qt's decoder keeps it; Swift's strings repair it), a valid pair kept. Fixes beyond Swift: nesting deeper than 512 in either reader refuses the block, where Swift's recursion overflows its stack, and a font index or justification past `Int64` reads as absent (left, with the note), where Swift's `Int()` traps. Left out as dead: `placement`'s finite-scale guard (a scale over 1e-6 from finite numbers), `parse`'s font-size guard (every path clamps it), the inner frame guard (the final `isValid` refuses the same style), `baseline`'s empty sample and no-glyph fallback (the words are never empty, and point text's image holds its first line), the frame's `width > 1` and `height > 1` (each side already passes the letters' by four) and `Double(text)`'s empty token (a number starts with a byte it keeps). Equivalent mutants: `runs.size() > 1` as `> 0` (it only keeps `begin() + 1` off an empty list), `rounded`'s `Int64` bound (past it the cast is undefined, and x86 yields a value no index or justification matches), the string's end after a lone backslash (the dictionary cannot close then) and the retry's `start > 0` (a retry at zero fails again). Tests: `PSDTextTests` (Swift's ten, the builder's notes, rendered pixels and exact transform, the matrix's two per cent, each signature field, a rendered frame's corner, the baseline in ink, frames placed and flipped, the style's fallbacks, a type layer with a shape), `PSDTextReaderTests` (the engine's forms, deep nesting, every descriptor type and cap, the list and reference counts, nesting and names, lone surrogates, colours in one, three and four channels, each channel as a second style, the sources' precedence, rounding, scaled leading, the `Txt` key, negative exponents, both versions and the limits at their edges); `PSDFixture+Text.h` twins `tySh`, and `PSDFixture::data` now takes several additional blocks.
- Text at the click, text on the Move tool's double click, and SVG (12.11c, Swift 1.2.10: `292cb6b`, `072fa08`, `a5721b2`, `0534e00`): a click with the Type tool puts new text's first baseline on the pointer, the box its padding to the left and its first baseline above (`TextLines::baseline(0)` of the style, the laid line's descent, where Swift reads the font's descender: they agree within a hundredth of a pixel); a dragged box sits exactly where it was drawn. Swift's `setHiddenUntilMouseMoves(false)` has no twin: Qt never hides the pointer while typing. The canvas's press is `press(event, clicks)`, Swift's `mouseDown` with its click count: `mousePressEvent` passes one and a double click's fall-through two, so a triple click counts one (the Type tool keeps its own count); with the Move tool a second click on the topmost shown live text under the pointer (a hidden folder hides what it holds) runs `commitTransform`, selects it, opens its editor and synchronizes the inline editor (`beginLiveTextEdit`, in `EditorCanvas+Transform.cpp`), else the press goes on to guides and the transform drag. No transform edit is open there (`canEditLayers`), but `commitTransform` also ends an opacity drag and a blend preview, which `selectLayer` leaves when the text is already active. In the layer list a live text row's size column says "Text · Double-click to edit"; a double click on its thumbnail opens it and on its name renames it, as Swift's merge (`3258c66`) kept, where the branch commit opened text anywhere. Opening existing text puts the caret after it once the editor takes the keys, unless a click already placed it (that merge's step in `InlineTextEditor.synchronize`): the queued focus request moves an untouched caret (0, 0) to the end, for a draft on a layer alone; the editor keeps its canvas as a `CanvasView` to reach itself there. SVG goes by its suffix, `svg` or `svgz` in any case (the importer's filter lists both, the desktop entry `image/svg+xml` and `image/svg+xml-compressed`, which shared-mime-info keeps apart) (Swift's `UTType.svg`), after camera raw and before Photoshop's bytes, through the ordinary decoder with the canvas's size: `ImageImporter::decodeSVG` renders once through Qt SVG's `QSvgRenderer` (a new dependency: `qt6-svg-dev` in the dev image, `qt6-svg`, `qt6-qtsvg-devel` and `qt6.qtsvg` in the distro images, the PKGBUILD and the flake; the DEB and RPM find the library themselves), fitted to the canvas by the smaller ratio, else at the declared size, each side rounded and one pixel at least, refused past the side limit or the remaining budget, tagged sRGB, named without its suffix. Drops need nothing, since a dropped picture of any `image/` type already imports. Known differences: Qt SVG draws SVG Tiny 1.2 where macOS's renderer draws more of SVG 1.1 (filters, masks, some CSS), `defaultSize` truncates a declared size to whole pixels (40.9 by 20.9 draws 40 by 20; a side that truncates to nothing falls back to Qt's bounds, which round, so a declared side under half a pixel refuses and one from half a pixel draws a pixel wide, where Swift draws any positive side a pixel wide at least), and a drawing whose declared size is empty takes its content's bounds, where `NSImage` measures nothing; Swift's `isValid` guard is folded into the size test, since Qt measures a drawing it cannot read as nothing. Tests: Swift's two `TypeToolTests` changes and the baseline in ink (`clickedTextSitsOnItsBaseline`); `LiveTextDoubleClickTests` (zoomed and turned text, a hidden folder, the cleanup, the caret); `SVGImportTests`. Tests that set text up at a known corner use `beginTextAt` (`SessionFixtures.h`), which pins the draft's corner as Swift's clipping test does.
- Typed text as pixels, effects kept on (12.12a, Swift 1.2.11: `fe6cb2b`, `b683acb`): the canvas draws text being typed as the pixels `textImage` makes of the draft's style (remade only when the style changes and let go at the first draw without a draft, as Swift's `drawNewText` asks with no draft, each remake a debug line on `omaphoto.rendering`, a style that cannot be drawn logged and drawn as nothing), where the editor shows it: an edited layer in its place with its opacity and blend mode (its mask left off, as Swift's), new text just above the active layer, or on top when the active layer is not drawn (a folder, a hidden or blank layer, none). The editor's glyphs are clear: it draws its frame, handles, caret, marked text's underline and a selection at 45% of the highlight over those pixels. An underline without a colour of its own, the preedit's or an input method's, takes the text's colour, since clear glyphs would hide it (Swift's `NSTextView` draws marked text itself). `DisplayState` carries the draft's style and shown transform, so typing repaints the whole view, as effects reach past the box; a handle drag at a zoom can move the box a fraction with the style unchanged, so the transform counts on its own. The edited layer's effects are redone as it is typed, synchronously at the preview size (`EffectsPreviewCache::renderNow`, the worker's scaling moved into `scaled`), through its enabled mask or its placed mask clipped over the typed box; until a change is redone the last effects stand in. When the edit ends and the layer holds the text as last typed, the next draw seeds those effects (`handOnDraftEffects`); a cancel of changed text, or other text, seeds nothing. The cache keeps the last three finished results per layer: pixels an undo puts back show their effects at once, a known result clearing any seed; a mask added, removed or replaced on the same pixels keeps the last preview until the new one lands, where it dropped before. A painted mask keeps its layer's effects through the stroke: `LayerEffectsSurface::MaskStroke` holds the stroke's tiles, their map into the surface's grid and the coverage of any region (the old mask, then the tiles over it, as a tile already holds the old mask it covers; Swift's white fill beneath is dead, since past the old mask there are no pixels), and the surface redoes the regions the mapped tiles touch; a placed mask's surface keeps the layer's grid and takes coverage from `placedMaskPreview` (`placedMaskSurface`). Swift's `restoreToolCursor` has no twin: `syncPicking` already sets the cursor both ways, and a widget's cursor cannot be left over from another window. `strokeDirtyRect` moved to `EditorCanvas+Brush.cpp`, the typed text to `EditorCanvas+DraftText.cpp`. Left out as dead: the cache's filter of a result already recent (a request matching one returns before it renders). Equivalent mutant: the mapped tile rect's one-pixel inset (the redo region is widened by the effects' reach). Tests: `TypedTextCanvasTests` (Swift's `textLooksTheSameWhileEditingAndOnceCommitted` at 1.5 and 4, with the box's inside compared since the editor's frame draws on the canvas; placement above the active layer, opacity and a blend mode with and without effects, effects kept and handed on, masks linked and placed, cancelled text, remakes counted and the image let go once editing ends, typed effects redone when a handle moves a placed-mask box a fraction, the whole view repainted when typing or when a handle moves the box a fraction), `MaskEffectsCanvasTests` (linked and placed masks keep effects mid-stroke, a placed mask sixty times finer against a whole render; untouched tiles keep the old mask), `EffectsPreviewCacheTests` (recent results, `renderNow`, the looser keep), `LayerEffectsSurfaceTests` (a mask stroke redone where its tiles map), and the inline editor's tests read the 45% selection through `highlightedOver`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is 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.
+1
View File
@@ -178,6 +178,7 @@ add_library(compositor_core STATIC
src/Rendering/EditorCanvas+CameraRaw.cpp
src/Rendering/EditorCanvas+Brush.cpp
src/Rendering/EditorCanvas+Crop.cpp
src/Rendering/EditorCanvas+DraftText.cpp
src/Rendering/EditorCanvas+Effects.cpp
src/Rendering/EditorCanvas+Grid.cpp
src/Rendering/EditorCanvas+Guides.cpp
+3 -1
View File
@@ -382,7 +382,9 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.11b1 Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`). Codex review: 3 rounds; the third stopped before its report after one finding (a layered read copied the ICC resource), fixed and closed.
- [x] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). Codex review: 3 rounds; the third's findings (test gaps: rounding, scaled leading, the `Txt` key, negative exponents, versions, the limits' edges) fixed and closed.
- [x] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). Codex review: 3 rounds; the third's one finding (a test gap: a stale caret step meeting a replacing draft) fixed and closed.
- [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing.
- [x] 12.12a Version 1.2.11, part one: text being typed shown as the pixels it will commit to (`fe6cb2b`); layer effects kept on screen while text is edited, masks are painted and steps undone (`b683acb`). Codex review: 3 rounds, PASS.
- [ ] 12.12b Part two: masks painted anywhere on the canvas (`e753f26`, `2ffb092`), a moved unlinked mask kept in place under effects (`8d2eade`), mask thumbnails' ground white or black as the canvas treats it (`a247676`).
- [ ] 12.12c Part three: numeric label scrubbing (`2d36d6e`, `680a522`); the build-warning fixes (`075a71b`, `725b934`) where they change behaviour.
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects.
- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish.
- [ ] 12.15 Version 1.3.3: the Dither filter.
+13
View File
@@ -229,3 +229,16 @@ bool CanvasView::brushBracket(const QString &text)
m_session.changeBrushHardness(text == u"}");
return true;
}
// The stroke's dirty rect in view points, two points out.
std::optional<QRectF> CanvasView::strokeDirtyRect() const
{
const BrushStroke *stroke = m_session.brushStroke();
const std::optional<CanvasDocument> &document = m_session.document();
if (!stroke || !document || !stroke->dirtyDocumentRect())
return std::nullopt;
const QRectF &dirty = *stroke->dirtyDocumentRect();
const QPointF origin = m_session.viewport.viewPoint(dirty.topLeft(), document->size());
const double scale = m_session.viewport.pointsPerPixel();
return QRectF(origin, QSizeF(dirty.width() * scale, dirty.height() * scale)).adjusted(-2, -2, 2, 2);
}
+86
View File
@@ -0,0 +1,86 @@
#include "Document/LayerEffects+Renderer.h"
#include "Logging.h"
#include "Rendering/EditorCanvas.h"
#include "Rendering/InlineTextEditor.h"
// Typed text, drawn as the pixels it commits to.
std::optional<CanvasView::DraftText> CanvasView::draftText()
{
const std::optional<TextDraft> &draft = m_session.textDraft();
if (!draft || !m_inlineTextEditor) {
m_draftTextCache.reset();
return std::nullopt;
}
// Remade only when the style changes.
if (!m_draftTextCache || m_draftTextCache->style != draft->style) {
try {
m_draftTextCache = DraftTextCache{draft->style, EditorSession::textImage(draft->style)};
qCDebug(lcRendering) << "typed text remade";
} catch (const std::runtime_error &error) {
qCWarning(lcRendering) << "typed text could not be drawn:" << error.what();
m_draftTextCache.reset();
return std::nullopt;
}
}
return DraftText{m_draftTextCache->image, m_inlineTextEditor->shownTransform()};
}
// In its layer's place, effects redone as it is typed.
void CanvasView::drawTypedText(const ImageLayer &layer, const LayerRenderer::Options &options, const Center &center, QPainter &target)
{
const std::optional<DraftText> text = draftText();
if (!text)
return;
const LayerEffects effects = layer.effects.value_or(LayerEffects()).visible();
if (!effects.isEmpty() && effects.isValid()) {
// Redone only when the pixels, their place or effects change.
if (!m_draftEffects || m_draftEffects->image.cacheKey() != text->image.cacheKey() || m_draftEffects->effects != effects
|| m_draftEffects->transform != text->transform) {
std::optional<QImage> mask;
if (layer.mask && layer.mask->placement)
mask = layer.mask->clipImage(*layer.mask->placement, text->transform, text->image.width(), text->image.height(), 2048);
else if (layer.mask)
mask = layer.mask->enabledImage();
const std::optional<EffectsPreviewCache::Result> built = m_session.effectsPreviews.renderNow(text->image, mask, effects);
m_draftEffects = built ? std::optional(DraftEffects{text->image, effects, text->transform, built->image, built->inset}) : std::nullopt;
m_draftEffectsSource = DraftEffectsSource{layer.id, m_session.textDraft().value().style};
}
// The last effects stand in until a change is redone.
if (m_draftEffects) {
const LayerTransform grown = LayerEffectsRenderer::placed(text->transform, m_draftEffects->rendered, m_draftEffects->inset);
LayerRenderer::draw(m_draftEffects->rendered, grown, center(grown.center()), target,
{.scale = options.scale, .opacity = options.opacity, .blendMode = options.blendMode, .clip = options.clip});
return;
}
}
LayerRenderer::draw(text->image, text->transform, center(text->transform.center()), target,
{.scale = options.scale, .opacity = options.opacity, .blendMode = options.blendMode, .clip = options.clip});
}
// New text: above the active layer, else on top.
void CanvasView::drawNewText(bool &drawn, double scale, const Center &center, QPainter &target, const QImage &clip)
{
// Asked without a draft too: that lets the cache go.
const std::optional<TextDraft> &draft = m_session.textDraft();
if (drawn || (draft && draft->layerID))
return;
const std::optional<DraftText> text = draftText();
if (!text)
return;
drawn = true;
LayerRenderer::draw(text->image, text->transform, center(text->transform.center()), target, {.scale = scale, .clip = clip});
}
// Once committed, the typed text's effects stand in: nothing blinks.
void CanvasView::handOnDraftEffects(const CanvasDocument &document)
{
if (m_session.textDraft() || !m_draftEffects || !m_draftEffectsSource)
return;
const int index = indexOf(document.layers, m_draftEffectsSource->layerID);
if (index >= 0 && document.layers[size_t(index)].liveText() && document.layers[size_t(index)].liveText()->style == m_draftEffectsSource->style)
m_session.effectsPreviews.seed(m_draftEffectsSource->layerID, m_draftEffects->rendered,
LayerEffectsRenderer::placed(m_draftEffects->transform, m_draftEffects->rendered, m_draftEffects->inset));
m_draftEffects.reset();
m_draftEffectsSource.reset();
}
+60 -2
View File
@@ -29,18 +29,32 @@ void CanvasView::drawStroke(const BrushStroke &stroke, const ImageLayer &layer,
target, options);
return;
}
// With effects on, a surface redoes them per mask change.
const auto drawSurface = [&](LayerEffectsSurface *surface) {
if (!surface || !surface->image())
return false;
const LayerTransform grown = LayerEffectsRenderer::placed(transform, *surface->image(), surface->margin);
surface->placement = grown;
LayerRenderer::draw(*surface->image(), grown, center(grown.center()), target, bare);
return true;
};
if (stroke.layer.mask && stroke.layer.mask->placement) {
// A placed mask: the layer draws through its own grid.
if (!layer.asset)
return;
const std::optional<QImage> preview = stroke.placedMaskPreview(*stroke.layer.mask->placement);
if (preview && drawSurface(placedMaskSurface(layer, stroke, *stroke.layer.mask->placement, *preview)))
return;
LayerRenderer::Options through = options;
through.mask = stroke.placedMaskPreview(*stroke.layer.mask->placement).value_or(QImage());
through.mask = preview.value_or(QImage());
if (layer.asset->raster)
TiledLayerRenderer::drawRaster(layer.asset->raster, transform, center(transform.center()), target, through);
else
LayerRenderer::draw(layer.asset->image(), transform, center(transform.center()), target, through);
return;
}
if (drawSurface(strokeSurface(layer, stroke, std::nullopt)))
return;
TiledLayerRenderer::drawMaskStroke(stroke.width, stroke.height, stroke.sourceRect, stroke.patches(),
stroke.layer.mask ? std::optional(stroke.layer.mask->asset) : std::nullopt, image, raster, transform,
center(transform.center()), target, bare);
@@ -57,7 +71,51 @@ LayerEffectsSurface *CanvasView::strokeSurface(const ImageLayer &layer, const Br
m_strokeSurface = LayerEffectsSurface::make(layer.id, effects, grid, stroke.sourceRect);
if (!m_strokeSurface)
return nullptr;
m_strokeSurface->update(stroke.layer.asset, stroke.patches(), mask);
if (!stroke.isMask) {
m_strokeSurface->update(stroke.layer.asset, stroke.patches(), mask);
return m_strokeSurface.get();
}
// The old mask, then the stroke's tiles over it.
const std::optional<QImage> old = stroke.layer.mask ? std::optional(stroke.layer.mask->asset.image()) : std::nullopt;
const std::vector<BrushPatch> patches = stroke.patches();
const QRectF sourceRect = stroke.sourceRect;
m_strokeSurface->update(stroke.layer.asset, {}, std::nullopt, LayerEffectsSurface::MaskStroke{patches, QTransform(), [old, patches, sourceRect](const QRectF &region) {
QImage coverage = BrushRaster::context(int(region.width()), int(region.height()), true);
QPainter painter(&coverage);
painter.translate(-region.left(), -region.top());
if (old)
BrushRaster::draw(*old, sourceRect, painter);
for (const BrushPatch &patch : patches) {
if (patch.rect.intersects(region))
BrushRaster::draw(patch.image, patch.rect, painter);
}
return coverage;
}});
return m_strokeSurface.get();
}
// A placed mask paints its grid; the surface, the layer's.
LayerEffectsSurface *CanvasView::placedMaskSurface(const ImageLayer &layer, const BrushStroke &stroke, const LayerTransform &placement, const QImage &preview)
{
const LayerEffects effects = layer.effects.value_or(LayerEffects()).visible();
if (effects.isEmpty() || !effects.isValid() || !stroke.layer.asset)
return nullptr;
const QSize base = stroke.layer.asset->size();
const QRectF full(QPointF(0, 0), QSizeF(base));
if (!m_strokeSurface || !m_strokeSurface->matches(layer.id, effects, full.size(), full))
m_strokeSurface = LayerEffectsSurface::make(layer.id, effects, full.size(), full);
if (!m_strokeSurface)
return nullptr;
const QTransform toGrid = BrushRaster::pixelToDocument(placement, stroke.width, stroke.height)
* BrushRaster::pixelToDocument(stroke.layer.transform, base.width(), base.height()).inverted();
m_strokeSurface->update(stroke.layer.asset, {}, std::nullopt, LayerEffectsSurface::MaskStroke{stroke.patches(), toGrid, [preview, full](const QRectF &region) {
QImage coverage = BrushRaster::context(int(region.width()), int(region.height()), true);
QPainter painter(&coverage);
painter.translate(-region.left(), -region.top());
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.drawImage(full, preview);
return coverage;
}});
return m_strokeSurface.get();
}
+17 -18
View File
@@ -83,7 +83,9 @@ CanvasView::DisplayState CanvasView::displayState() const
.renderBounds = renderBounds(),
.viewport = m_session.viewport,
.layers = {},
.folderMasks = {}};
.folderMasks = {},
.textStyle = m_session.textDraft() ? std::optional(m_session.textDraft()->style) : std::nullopt,
.textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt};
if (!document)
return state;
// A hidden source still clips: live masks count every layer.
@@ -111,19 +113,6 @@ CanvasView::DisplayState CanvasView::displayState() const
return state;
}
// The stroke's dirty rect in view points, two points out.
std::optional<QRectF> CanvasView::strokeDirtyRect() const
{
const BrushStroke *stroke = m_session.brushStroke();
const std::optional<CanvasDocument> &document = m_session.document();
if (!stroke || !document || !stroke->dirtyDocumentRect())
return std::nullopt;
const QRectF &dirty = *stroke->dirtyDocumentRect();
const QPointF origin = m_session.viewport.viewPoint(dirty.topLeft(), document->size());
const double scale = m_session.viewport.pointsPerPixel();
return QRectF(origin, QSizeF(dirty.width() * scale, dirty.height() * scale)).adjusted(-2, -2, 2, 2);
}
bool CanvasView::synchronizeDisplay()
{
handOnStrokeSurface();
@@ -310,6 +299,7 @@ FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit,
void CanvasView::drawLayers(const CanvasDocument &document, double scale, const Center &center, QPainter &context)
{
handOnDraftEffects(document);
m_session.effectsPreviews.prepare(document.layers);
std::map<QUuid, ImageLayer> byID;
for (const ImageLayer &layer : document.layers) {
@@ -322,12 +312,16 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
canvas->update();
};
const auto drawOwn = [&](QUuid id, QPainter &target, const QImage &clip) {
// Open text shows in its editor instead.
if (!byID.contains(id) || (m_session.textDraft() && m_session.textDraft().value().layerID == id))
if (!byID.contains(id))
return;
const ImageLayer &layer = byID.at(id);
// Its folders dim it with everything inside them.
const double opacity = layer.effectiveOpacity(byID);
// Text being edited draws as it will be committed.
if (m_session.textDraft() && m_session.textDraft()->layerID == id) {
drawTypedText(layer, {.scale = scale, .opacity = opacity, .blendMode = m_session.displayedBlendMode(layer), .clip = clip}, center, target);
return;
}
// A stroke or pixel move stands in for the pixels.
const PixelMove *move = m_session.pixelMove();
const std::optional<GradientEdit> &gradient = m_session.gradientEdit();
@@ -412,10 +406,13 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
LayerRenderer::draw(layer.asset->image(), transform, center(transform.center()), target, options);
};
// Above the active layer, where the new layer will go.
bool drewNewText = false;
const auto drawOwnWithDraft = [&](QUuid id, QPainter &target, const QImage &clip) {
drawOwn(id, target, clip);
if (id == m_session.activeLayerID())
drawShapeDraft(scale, center, target, clip);
if (id != m_session.activeLayerID())
return;
drawShapeDraft(scale, center, target, clip);
drawNewText(drewNewText, scale, center, target, clip);
};
const auto parent = [&](QUuid id) { return byID.contains(id) ? byID.at(id).parentID : std::nullopt; };
// A mask being painted exists as the edit's tiles alone.
@@ -460,6 +457,8 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
const QPointF origin = center(transform.center());
return [clip, origin, scale](const QPainter &painter, QImage &coverage) { clip.apply(origin, painter, coverage, scale); };
}, context, [&](QUuid id, const QImage &clip) { live.drawComposite(id, context, clip); });
// Else on top: the active layer, a folder or hidden.
drawNewText(drewNewText, scale, center, context, QImage());
}
void CanvasView::drawDocumentPixels(const QRectF &view, const QRectF &pixels, const CanvasDocument &document, QPainter &context)
+33
View File
@@ -119,6 +119,9 @@ private:
CanvasViewport viewport;
std::vector<Layer> layers;
std::vector<FolderMask> folderMasks;
// Typed text, which the canvas draws as pixels.
std::optional<LayerTextStyle> textStyle;
std::optional<LayerTransform> textTransform;
friend bool operator==(const DisplayState &, const DisplayState &) = default;
};
// A Zoom press: drags zoom about it, clicks step.
@@ -143,7 +146,17 @@ private:
void drawStroke(const BrushStroke &stroke, const ImageLayer &layer, const LayerTransform &transform, const std::optional<QImage> &mask,
const LayerRenderer::Options &options, double scale, const Center &center, QPainter &target);
void drawLayers(const CanvasDocument &document, double scale, const Center &center, QPainter &context);
// Typed text as pixels, where the editor shows it.
struct DraftText {
QImage image;
LayerTransform transform;
};
std::optional<DraftText> draftText();
void drawTypedText(const ImageLayer &layer, const LayerRenderer::Options &options, const Center &center, QPainter &target);
void drawNewText(bool &drawn, double scale, const Center &center, QPainter &target, const QImage &clip);
void handOnDraftEffects(const CanvasDocument &document);
// Swift's effects surface on the canvas (EditorCanvas+Effects.cpp).
LayerEffectsSurface *placedMaskSurface(const ImageLayer &layer, const BrushStroke &stroke, const LayerTransform &placement, const QImage &preview);
LayerEffectsSurface *strokeSurface(const ImageLayer &layer, const BrushStroke &stroke, const std::optional<QImage> &mask);
// The stroke is over: its surface stands in meanwhile.
void handOnStrokeSurface();
@@ -359,6 +372,26 @@ private:
SampleRingOverlay m_sampleRing;
// The painted layer's effects, made as its stroke starts.
std::unique_ptr<LayerEffectsSurface> m_strokeSurface;
// Typed text rendered, remade when its style changes.
struct DraftTextCache {
LayerTextStyle style;
QImage image;
};
std::optional<DraftTextCache> m_draftTextCache;
// Its effects, what they came from, and for which text.
struct DraftEffects {
QImage image;
LayerEffects effects;
LayerTransform transform;
QImage rendered;
double inset;
};
std::optional<DraftEffects> m_draftEffects;
struct DraftEffectsSource {
QUuid layerID;
LayerTextStyle style;
};
std::optional<DraftEffectsSource> m_draftEffectsSource;
// The colour a sampling press began from, for the ring.
PaletteColor m_samplingOriginal = PaletteColor::black();
bool m_samplingColor = false;
+74 -40
View File
@@ -57,6 +57,7 @@ void EffectsPreviewCache::prepare(const std::vector<ImageLayer> &layers)
entry = m_entries.erase(entry);
}
std::erase_if(m_seeds, [&](const auto &seed) { return !ids.contains(seed.first); });
std::erase_if(m_recent, [&](const auto &recent) { return !ids.contains(recent.first); });
// About 64 MiB of output shared by every effect layer.
m_sideLimit = std::min(1536, std::max(32, int(std::sqrt(16'777'216.0 / double(std::max<size_t>(1, ids.size()))))));
}
@@ -80,10 +81,22 @@ std::optional<EffectsPreviewCache::Result> EffectsPreviewCache::preview(const Im
if (m_entries.contains(layer.id)) {
const Entry &old = m_entries.at(layer.id);
old.request.cancelled->store(true);
// Kept on the same pixels, an effect hidden or not.
if (old.request.image.identity() == request.image.identity() && old.request.maskSource == maskSource)
// Kept on the same pixels, an effect or mask changed.
if (old.request.image.identity() == request.image.identity())
previous = old.result;
}
// Pixels an undo put back: their effects are known.
if (m_recent.contains(layer.id)) {
const std::vector<Entry> &recent = m_recent.at(layer.id);
for (size_t index = recent.size(); index-- > 0;) {
if (!recent[index].request.matches(request))
continue;
const Entry found = recent[index];
m_entries.insert_or_assign(layer.id, found);
m_seeds.erase(layer.id);
return found.result;
}
}
if (!previous && m_seeds.contains(layer.id))
previous = m_seeds.at(layer.id);
m_entries.insert_or_assign(layer.id, Entry{request, previous});
@@ -107,8 +120,14 @@ void EffectsPreviewCache::land(QUuid layerID, QUuid requestID, const std::option
if (!m_entries.contains(layerID) || m_entries.at(layerID).request.id != requestID)
return;
m_entries.at(layerID).result = result;
if (result)
if (result) {
m_seeds.erase(layerID);
// A match returns before rendering: none repeats here.
std::vector<Entry> &recent = m_recent[layerID];
recent.push_back(m_entries.at(layerID));
if (recent.size() > 3)
recent.erase(recent.begin());
}
completion();
}
@@ -124,45 +143,60 @@ std::optional<EffectsPreviewCache::Result> EffectsPreviewCache::render(const Req
{
try {
// A painted layer flattens here, off the UI thread.
const QImage image = request.image.image();
const double margin = LayerEffectsRenderer::margin(request.effects);
// The margins count too; even a 500px stroke stays bounded.
const double factor = std::min(1.0, double(request.sideLimit - 8) / (double(std::max(image.width(), image.height())) + 2 * margin));
const int width = std::max(1, int(std::round(image.width() * factor)));
const int height = std::max(1, int(std::round(image.height() * factor)));
// Swift's high quality: each axis filtered by its own measure.
const auto resized = [&](const QImage &source, bool mask) {
// A mask travels as alpha, so its values pass exactly.
const QImage scaled = (mask ? BrushRaster::alphaView(source) : source)
.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(mask ? QImage::Format_Alpha8 : QImage::Format_RGBA8888_Premultiplied);
const QImage result = mask ? QImage(scaled.constBits(), width, height, scaled.bytesPerLine(), QImage::Format_Grayscale8).copy() : scaled;
if (result.isNull())
throw ExportError(ExportError::Kind::render);
return result;
};
const QImage pixels = factor == 1 ? image : resized(image, false);
const std::optional<QImage> mask = request.mask ? std::optional(factor == 1 ? *request.mask : resized(*request.mask, true)) : std::nullopt;
LayerEffects effects = request.effects;
if (effects.stroke)
effects.stroke->size *= factor;
if (effects.shadow) {
effects.shadow->distance *= factor;
effects.shadow->blur *= factor;
}
// Swift forgets the inner shadow and glows: too wide.
if (effects.innerShadow) {
effects.innerShadow->distance *= factor;
effects.innerShadow->blur *= factor;
}
if (effects.outerGlow)
effects.outerGlow->size *= factor;
if (effects.innerGlow)
effects.innerGlow->size *= factor;
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(pixels, mask, effects);
return Result{rendered.image, rendered.inset, std::nullopt};
return scaled(request.image.image(), request.mask, request.effects, request.sideLimit);
} catch (const ExportError &error) {
qCWarning(lcRendering) << "an effects preview could not be made:" << error.what();
}
return std::nullopt;
}
std::optional<EffectsPreviewCache::Result> EffectsPreviewCache::renderNow(const QImage &image, const std::optional<QImage> &mask,
const LayerEffects &effects) const
{
try {
return scaled(image, mask, effects, m_sideLimit);
} catch (const ExportError &error) {
qCWarning(lcRendering) << "typed text's effects could not be made:" << error.what();
}
return std::nullopt;
}
// Scaled to the side limit, rendered, its settings scaled alike.
EffectsPreviewCache::Result EffectsPreviewCache::scaled(const QImage &image, const std::optional<QImage> &requestMask, LayerEffects effects, int sideLimit)
{
const double margin = LayerEffectsRenderer::margin(effects);
// The margins count too; even a 500px stroke stays bounded.
const double factor = std::min(1.0, double(sideLimit - 8) / (double(std::max(image.width(), image.height())) + 2 * margin));
const int width = std::max(1, int(std::round(image.width() * factor)));
const int height = std::max(1, int(std::round(image.height() * factor)));
// Swift's high quality: each axis filtered by its own measure.
const auto resized = [&](const QImage &source, bool mask) {
// A mask travels as alpha, so its values pass exactly.
const QImage scaled = (mask ? BrushRaster::alphaView(source) : source)
.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(mask ? QImage::Format_Alpha8 : QImage::Format_RGBA8888_Premultiplied);
const QImage result = mask ? QImage(scaled.constBits(), width, height, scaled.bytesPerLine(), QImage::Format_Grayscale8).copy() : scaled;
if (result.isNull())
throw ExportError(ExportError::Kind::render);
return result;
};
const QImage pixels = factor == 1 ? image : resized(image, false);
const std::optional<QImage> mask = requestMask ? std::optional(factor == 1 ? *requestMask : resized(*requestMask, true)) : std::nullopt;
if (effects.stroke)
effects.stroke->size *= factor;
if (effects.shadow) {
effects.shadow->distance *= factor;
effects.shadow->blur *= factor;
}
// Swift forgets the inner shadow and glows: too wide.
if (effects.innerShadow) {
effects.innerShadow->distance *= factor;
effects.innerShadow->blur *= factor;
}
if (effects.outerGlow)
effects.outerGlow->size *= factor;
if (effects.innerGlow)
effects.innerGlow->size *= factor;
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(pixels, mask, effects);
return Result{rendered.image, rendered.inset, std::nullopt};
}
+5
View File
@@ -28,6 +28,8 @@ public:
// The last result; `completion` runs once a new one lands.
std::optional<Result> preview(const ImageLayer &layer, const std::optional<QImage> &mask, const LayerTransform &transform,
const std::optional<LayerTransform> &maskPlacement, const std::function<void()> &completion);
// Rendered at once, at the preview size: typed text's.
std::optional<Result> renderNow(const QImage &image, const std::optional<QImage> &mask, const LayerEffects &effects) const;
private:
struct Request {
@@ -48,12 +50,15 @@ private:
std::optional<Result> result;
};
static std::optional<Result> render(const Request &request);
static Result scaled(const QImage &image, const std::optional<QImage> &mask, LayerEffects effects, int sideLimit);
void cancel(QUuid id);
void land(QUuid layerID, QUuid requestID, const std::optional<Result> &result, const std::function<void()> &completion);
std::map<QUuid, Entry> m_entries;
// Warped effects handed in, shown until the worker catches up.
std::map<QUuid, Result> m_seeds;
// Three finished previews a layer, newest last, for undo.
std::map<QUuid, std::vector<Entry>> m_recent;
int m_sideLimit = 1536;
// Swift's serial queue: one render at a time.
QThreadPool m_worker;
+9 -2
View File
@@ -143,18 +143,25 @@ void InlineTextEditor::draw(QPainter &painter) const
const double padding = LayerTextStyle::padding;
painter.setTransform(textTransform());
const QColor ink = QColor::fromRgbF(float(style().red), float(style().green), float(style().blue));
painter.setPen(ink);
const QColor highlight = palette.color(m_focused ? QPalette::Active : QPalette::Inactive, QPalette::Highlight);
// Clear glyphs: the canvas draws the text's own pixels beneath.
painter.setPen(Qt::transparent);
// The selection shows through to those pixels.
QColor highlight = palette.color(m_focused ? QPalette::Active : QPalette::Inactive, QPalette::Highlight);
highlight.setAlphaF(0.45f);
// The input method's formats, else NSTextView's underline.
QList<QTextLayout::FormatRange> formats;
if (m_composing) {
for (QTextLayout::FormatRange format : m_composing->formats) {
format.start += m_composing->marked.start;
// Clear glyphs: an underline keeps the text's colour.
if (!format.format.underlineColor().isValid())
format.format.setUnderlineColor(ink);
formats << format;
}
if (formats.isEmpty()) {
QTextCharFormat underline;
underline.setFontUnderline(true);
underline.setUnderlineColor(ink);
formats << QTextLayout::FormatRange{m_composing->marked.start, m_composing->marked.end - m_composing->marked.start, underline};
}
}
+20 -4
View File
@@ -59,15 +59,20 @@ double LayerEffectsSurface::reach() const
return reach;
}
void LayerEffectsSurface::update(const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, const std::optional<QImage> &mask)
void LayerEffectsSurface::update(const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, const std::optional<QImage> &mask,
std::optional<MaskStroke> maskStroke)
{
m_maskStroke = std::move(maskStroke);
std::optional<QRectF> dirty;
std::map<std::pair<qint64, qint64>, qint64> seen;
for (const BrushPatch &patch : patches) {
for (const BrushPatch &patch : m_maskStroke ? m_maskStroke->patches : patches) {
const std::pair<qint64, qint64> key(qint64(patch.rect.left()), qint64(patch.rect.top()));
seen.insert_or_assign(key, patch.image.cacheKey());
if (!m_taken.contains(key) || m_taken.at(key) != patch.image.cacheKey())
dirty = dirty ? dirty->united(patch.rect) : patch.rect;
if (m_taken.contains(key) && m_taken.at(key) == patch.image.cacheKey())
continue;
// A placed mask paints its grid; mapped into the layer's.
const QRectF rect = m_maskStroke ? m_maskStroke->toGrid.mapRect(patch.rect).adjusted(-1, -1, 1, 1) : patch.rect;
dirty = dirty ? dirty->united(rect) : rect;
}
const bool first = !m_image;
const std::optional<QRectF> region = first ? std::optional(QRectF(QPointF(0, 0), grid).adjusted(-margin, -margin, margin, margin)) : dirty;
@@ -112,6 +117,17 @@ QImage LayerEffectsSurface::window(const QRectF &region, const std::optional<Imp
QImage window = BrushRaster::context(int(region.width()), int(region.height()), false);
QPainter painter(&window);
painter.translate(-region.left(), -region.top());
if (m_maskStroke) {
// The pixels through the mask as the stroke leaves it.
const QImage live = m_maskStroke->coverage(region);
if (base && base->raster)
base->raster->draw(sourceRect, painter);
else if (base)
BrushRaster::draw(base->image(), sourceRect, painter);
painter.setCompositionMode(QPainter::CompositionMode_DestinationIn);
painter.drawImage(region, BrushRaster::alphaView(live));
return window;
}
if (base && base->raster)
base->raster->draw(sourceRect, painter);
else if (base)
+12 -1
View File
@@ -2,7 +2,9 @@
#include "Document/BrushStroke.h"
#include "Document/LayerEffects.h"
#include <QImage>
#include <QTransform>
#include <QUuid>
#include <functional>
#include <map>
#include <memory>
#include <optional>
@@ -14,8 +16,16 @@ public:
static std::unique_ptr<LayerEffectsSurface> make(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect);
// Whether it still fits the stroke and settings.
bool matches(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect) const;
// A mask painted: tiles, their map into the grid, coverage.
struct MaskStroke {
std::vector<BrushPatch> patches;
QTransform toGrid;
// The mask over a grid region, gray, white showing; throws.
std::function<QImage(const QRectF &)> coverage;
};
// Everything at first, then only where the paint changed.
void update(const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, const std::optional<QImage> &mask);
void update(const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, const std::optional<QImage> &mask,
std::optional<MaskStroke> maskStroke = std::nullopt);
const std::optional<QImage> &image() const { return m_image; }
const QUuid layerID;
@@ -40,4 +50,5 @@ private:
// Each tile's origin and the pixels last taken from it.
std::map<std::pair<qint64, qint64>, qint64> m_taken;
std::optional<QImage> m_image;
std::optional<MaskStroke> m_maskStroke;
};
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "Document/LayerEffects+Renderer.h"
#include "IO/ImageExporter.h"
#include "Rendering/EditorCanvas.h"
#include "RenderFixtures.h"
#include "SessionFixtures.h"
#include <QtTest>
// A square with a stroke effect, on a white canvas.
namespace {
const QColor yellow(255, 255, 0);
LayerEffects outline(double size)
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1};
return effects;
}
// White document, a square 25..35 by 15..25, at 100%.
struct Scene {
EditorSession session;
CanvasView canvas{session};
QUuid square;
explicit Scene(QRgb colour = qRgba(0, 0, 255, 255))
{
session.createDocument(60, 40);
session.setShowsTransformControls(false);
canvas.resize(300, 200);
session.viewport.resize(QSizeF(300, 200), 1, QSizeF(60, 40));
session.zoom(1);
const QImage white = solid(60, 40, qRgba(255, 255, 255, 255));
session.insert(ImportedImage(white, white, QStringLiteral("White")));
const QImage pixels = solid(10, 10, colour);
session.insert(ImportedImage(pixels, pixels, QStringLiteral("Square")), QPointF(30, 20));
square = session.activeLayerID().value();
}
QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); }
// The colour shown at a document pixel's centre.
QColor at(QPoint pixel)
{
return shot().pixelColor(shown(QPointF(pixel) + QPointF(0.5, 0.5)));
}
// The view pixel holding a document point.
QPoint shown(QPointF point)
{
const QPointF view = session.viewport.viewPoint(point, QSizeF(60, 40));
return QPoint(int(std::floor(view.x())), int(std::floor(view.y())));
}
// Drawn once, then the worker's preview awaited.
EffectsPreviewCache::Result landed()
{
shot();
if (!QTest::qWaitFor([&] { return session.effectsPreviews.rendered(square).has_value(); }, 5000))
throw std::runtime_error("no preview landed");
return session.effectsPreviews.rendered(square).value();
}
// The worst channel gap to the export, pixel by pixel.
int gapToExport()
{
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32);
const QImage image = shot();
int worst = 0;
for (int y = 0; y < 40; ++y) {
for (int x = 0; x < 60; ++x) {
const QRgb expected = exported.pixel(x, y);
const QRgb actual = image.pixel(shown(QPointF(x + 0.5, y + 0.5)));
worst = std::max({worst, std::abs(qRed(expected) - qRed(actual)), std::abs(qGreen(expected) - qGreen(actual)),
std::abs(qBlue(expected) - qBlue(actual))});
}
}
return worst;
}
};
}
+1 -65
View File
@@ -2,76 +2,12 @@
#include "IO/ImageExporter.h"
#include "Rendering/EditorCanvas.h"
#include "RenderFixtures.h"
#include "EffectsCanvasFixtures.h"
#include "SessionFixtures.h"
#include <QtTest>
// Swift's effects on the canvas: previews, the paint's surface, distortions.
namespace {
const QColor yellow(255, 255, 0);
LayerEffects outline(double size)
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1};
return effects;
}
// White document, a square 25..35 by 15..25, at 100%.
struct Scene {
EditorSession session;
CanvasView canvas{session};
QUuid square;
explicit Scene(QRgb colour = qRgba(0, 0, 255, 255))
{
session.createDocument(60, 40);
session.setShowsTransformControls(false);
canvas.resize(300, 200);
session.viewport.resize(QSizeF(300, 200), 1, QSizeF(60, 40));
session.zoom(1);
const QImage white = solid(60, 40, qRgba(255, 255, 255, 255));
session.insert(ImportedImage(white, white, QStringLiteral("White")));
const QImage pixels = solid(10, 10, colour);
session.insert(ImportedImage(pixels, pixels, QStringLiteral("Square")), QPointF(30, 20));
square = session.activeLayerID().value();
}
QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); }
// The colour shown at a document pixel's centre.
QColor at(QPoint pixel)
{
return shot().pixelColor(shown(QPointF(pixel) + QPointF(0.5, 0.5)));
}
// The view pixel holding a document point.
QPoint shown(QPointF point)
{
const QPointF view = session.viewport.viewPoint(point, QSizeF(60, 40));
return QPoint(int(std::floor(view.x())), int(std::floor(view.y())));
}
// Drawn once, then the worker's preview awaited.
EffectsPreviewCache::Result landed()
{
shot();
if (!QTest::qWaitFor([&] { return session.effectsPreviews.rendered(square).has_value(); }, 5000))
throw std::runtime_error("no preview landed");
return session.effectsPreviews.rendered(square).value();
}
// The worst channel gap to the export, pixel by pixel.
int gapToExport()
{
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32);
const QImage image = shot();
int worst = 0;
for (int y = 0; y < 40; ++y) {
for (int x = 0; x < 60; ++x) {
const QRgb expected = exported.pixel(x, y);
const QRgb actual = image.pixel(shown(QPointF(x + 0.5, y + 0.5)));
worst = std::max({worst, std::abs(qRed(expected) - qRed(actual)), std::abs(qGreen(expected) - qGreen(actual)),
std::abs(qBlue(expected) - qBlue(actual))});
}
}
return worst;
}
};
// Counts the paints a widget receives.
struct Painted : QObject {
int &count;
+73 -6
View File
@@ -68,6 +68,8 @@ private slots:
void aMaskHidesPixelsInTheReducedPreview();
void aReducedPreviewScalesEveryLength();
void aClosedCacheLandsNothing();
void undoneStepsReuseTheirEffects();
void renderNowRendersAtThePreviewSize();
};
void EffectsPreviewCacheTests::aPreviewLandsAfterItsDelayAsTheRendererDrawsIt()
@@ -150,17 +152,82 @@ void EffectsPreviewCacheTests::settingsKeepTheLastPreviewButNewPixelsDropIt()
layer.effects->colorOverlay = ColorOverlayEffect();
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), second.image.cacheKey());
landed(cache, layer, landings, 3);
// New pixels, or a mask switched on, drop it too.
// New pixels drop it.
layer.asset = ImportedImage(filled(20, 10, QColor(255, 0, 0)), QImage(), QStringLiteral("Red"));
QVERIFY(!ask(cache, layer, landings));
landed(cache, layer, landings, 4);
const qint64 red = landed(cache, layer, landings, 4).image.cacheKey();
// A mask added or removed on those pixels keeps it.
layer.mask = LayerMask(LayerMask::assetFrom(gray(20, 10, 255)));
QVERIFY(!ask(cache, layer, landings, layer.mask->enabledImage()));
landed(cache, layer, landings, 5);
// A mask switched off is no source.
QCOMPARE(ask(cache, layer, landings, layer.mask->enabledImage()).value().image.cacheKey(), red);
QVERIFY(landed(cache, layer, landings, 5).image.cacheKey() != red);
// Switched off again, the unmasked effects are known: no render.
layer.mask->isEnabled = false;
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), red);
QTest::qWait(200);
QCOMPARE(*landings.count, 5);
}
void EffectsPreviewCacheTests::undoneStepsReuseTheirEffects()
{
EffectsPreviewCache cache;
ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3));
const Landings landings;
// Four pixels in turn, each rendered and landed.
std::vector<ImportedImage> images;
std::vector<qint64> keys;
for (int step = 0; step < 4; ++step) {
images.emplace_back(filled(20, 10, QColor(0, 0, 60 + 60 * step)), QImage(), QStringLiteral("Step"));
layer.asset = images.back();
QVERIFY(!ask(cache, layer, landings));
keys.push_back(landed(cache, layer, landings, step + 1).image.cacheKey());
}
// Undone and redone across draws: the three newest are known.
for (const int step : {2, 1, 3}) {
cache.prepare({layer});
layer.asset = images[size_t(step)];
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), keys[size_t(step)]);
}
QTest::qWait(200);
QCOMPARE(*landings.count, 4);
// Undone and redone across draws: three newest are known.
cache.seed(layer.id, filled(4, 4, QColor(0, 255, 0)), layer.transform);
layer.asset = images[2];
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), keys[2]);
layer.asset = images[0];
QVERIFY(!ask(cache, layer, landings));
landed(cache, layer, landings, 6);
// A layer without effects forgets them.
cache.prepare({});
layer.asset = images[3];
QVERIFY(!ask(cache, layer, landings));
}
void EffectsPreviewCacheTests::renderNowRendersAtThePreviewSize()
{
EffectsPreviewCache cache;
const QImage large = filled(400, 200, QColor(0, 0, 255));
const LayerEffectsRenderer::Rendered whole = LayerEffectsRenderer::render(large, std::nullopt, outline(3));
// Within the default 1536 a side: the whole render.
const EffectsPreviewCache::Result now = cache.renderNow(large, std::nullopt, outline(3)).value();
QCOMPARE(now.image, whole.image);
QCOMPARE(now.inset, whole.inset);
// Sharing the budget with a thousand, as the worker reduces.
const ImageLayer layer = layerOf(large, outline(3));
std::vector<ImageLayer> many(999, layerOf(filled(2, 2, QColor(0, 0, 255)), outline(3)));
for (ImageLayer &each : many)
each.id = QUuid::createUuid();
many.push_back(layer);
cache.prepare(many);
const Landings landings;
ask(cache, layer, landings);
const EffectsPreviewCache::Result worker = landed(cache, layer, landings, 1);
const EffectsPreviewCache::Result reduced = cache.renderNow(large, std::nullopt, outline(3)).value();
QCOMPARE(reduced.image, worker.image);
QCOMPARE(reduced.inset, worker.inset);
// 129 a side: sides at 121 over the margined width.
const double factor = 121.0 / (400 + 2 * LayerEffectsRenderer::margin(outline(3)));
const LayerEffects scaledOutline = outline(3 * factor);
QCOMPARE(reduced.image.size(), LayerEffectsRenderer::render(filled(int(std::lround(400 * factor)), int(std::lround(200 * factor)), QColor(0, 0, 255)),
std::nullopt, scaledOutline).image.size());
}
void EffectsPreviewCacheTests::onlyTheNewestRequestLands()
+8
View File
@@ -41,6 +41,14 @@ inline bool reddish(QColor colour)
return colour.red() > 128 && colour.green() < 90 && colour.blue() < 90;
}
// The selection's highlight at 45% over what shows beneath.
inline bool highlightedOver(QColor under, QColor over, QColor highlight)
{
const auto mixed = [](int beneath, int ink, int shown) { return std::abs(shown - (beneath * 0.55 + ink * 0.45)) <= 2; };
return under != over && mixed(under.red(), highlight.red(), over.red()) && mixed(under.green(), highlight.green(), over.green())
&& mixed(under.blue(), highlight.blue(), over.blue());
}
inline bool greenish(QColor colour)
{
return colour.green() > 128 && colour.red() < 90 && colour.blue() < 90;
+13 -9
View File
@@ -58,12 +58,15 @@ void InlineTextDrawTests::theFrameAndHandlesSitOnTheBox()
QVERIFY(image.pixelColor(shown.onScreen(QPointF(size.width() / 4, 4))) != frame);
// No plus without overflow.
QVERIFY(image.pixelColor(shown.onScreen(QPointF(size.width(), size.height()))) == QColor(Qt::white));
// Text draws over the frame, a subview over its view.
// Tight leading lifts the H into the frame, still over.
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("H");
style.leading = 30;
});
QVERIFY(reddish(shown.grab().pixelColor(shown.onScreen(QPointF(24, 0.25)))));
QCOMPARE(qAlpha(EditorSession::textImage(shown.session.textDraft().value().style).pixel(24, 0)), 255);
const QColor over = shown.grab().pixelColor(shown.onScreen(QPointF(24, 0.25)));
QVERIFY2(!reddish(over) && std::abs(over.red() - accent.red()) < 40 && std::abs(over.blue() - accent.blue()) < 40, qPrintable(over.name()));
QVERIFY(where(shown.grab(), reddish).isValid());
}
void InlineTextDrawTests::aTurnedFlippedLayerIsEditedInPlace()
@@ -151,17 +154,18 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText()
QTRY_VERIFY(shown.canvas->hasFocus());
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("iiii"));
const QImage plain = shown.grab();
QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
const auto mixed = [&](QPoint at, QColor highlight) { return highlightedOver(plain.pixelColor(at), shown.grab().pixelColor(at), highlight); };
const auto highlighted = [&](QPoint at) { return mixed(at, shown.canvas->palette().color(QPalette::Active, QPalette::Highlight)); };
// Above the letters, the line's height is highlighted.
const QPoint above = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(above), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QVERIFY(highlighted(above));
const double line = shown.session.textDraft().value().style.lineHeight();
const QPoint below = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + line - 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(below), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QVERIFY(highlighted(shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + line - 3)).toPoint()));
// To the last selected letter's edge.
const double selected = QFontMetricsF(TextLayout::font(shown.session.textDraft().value().style)).horizontalAdvance(QStringLiteral("iiii"));
const QPoint last = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + selected - 3, LayerTextStyle::padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(last), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QVERIFY(highlighted(shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + selected - 3, LayerTextStyle::padding + 3)).toPoint()));
// No caret while something is selected.
const QPointF mapped = shown.editor().textTransform().map(
QPointF(LayerTextStyle::padding + QFontMetricsF(TextLayout::font(shown.session.textDraft().value().style)).horizontalAdvance(QStringLiteral("iiii")) + 0.25,
@@ -170,7 +174,7 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText()
QVERIFY(!reddish(shown.grab().pixelColor(end)));
QTest::keyClick(shown.canvas, Qt::Key_End);
QVERIFY(reddish(shown.grab().pixelColor(end)));
QVERIFY(shown.grab().pixelColor(above) != shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QCOMPARE(shown.grab().pixelColor(above), plain.pixelColor(above));
// Unfocused, a selection shows in the inactive colour.
QPalette palette = shown.canvas->palette();
palette.setColor(QPalette::Inactive, QPalette::Highlight, QColor(40, 200, 40));
@@ -180,7 +184,7 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText()
field->show();
field->setFocus();
QTRY_VERIFY(!shown.canvas->hasFocus());
QCOMPARE(shown.grab().pixelColor(above), QColor(40, 200, 40));
QVERIFY(mixed(above, QColor(40, 200, 40)));
}
void InlineTextDrawTests::textPastTheBoxShowsAPlus()
+16 -3
View File
@@ -22,6 +22,18 @@ void InlineTextPreeditTests::markedTextIsUnderlined()
const QRect band = shown.editor().textTransform().mapRect(
QRectF(LayerTextStyle::padding, baseline + 1, metrics.horizontalAdvance(QStringLiteral("nn")) - 4, metrics.descent() - 2)).toAlignedRect();
QVERIFY(where(shown.grab().copy(band), reddish).isValid());
// The input method's own underline, uncoloured, takes the text's.
QTextCharFormat underlined;
underlined.setFontUnderline(true);
QInputMethodEvent styled(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, underlined)});
QApplication::sendEvent(shown.canvas, &styled);
QVERIFY(where(shown.grab().copy(band), reddish).isValid());
// Its own colour stands.
underlined.setUnderlineColor(Qt::blue);
QInputMethodEvent blue(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, underlined)});
QApplication::sendEvent(shown.canvas, &blue);
QVERIFY(!where(shown.grab().copy(band), reddish).isValid());
QVERIFY(where(shown.grab().copy(band), [](QColor colour) { return colour.blue() > 128 && colour.red() < 90 && colour.green() < 90; }).isValid());
// A format that is none keeps the underline, as Qt's.
QInputMethodEvent formless(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, QVariant())});
QApplication::sendEvent(shown.canvas, &formless);
@@ -87,12 +99,13 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
shown.session.cancelText();
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("xx"));
const QImage plain = shown.grab();
QInputMethodEvent selecting(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 0, 2, QVariant())});
QApplication::sendEvent(shown.canvas, &selecting);
QCOMPARE(shown.session.textDraft().value().style.content, QString("xxnn"));
const QPalette::ColorGroup group = shown.canvas->hasFocus() ? QPalette::Active : QPalette::Inactive;
const QPoint first = shown.editor().textTransform().map(QPointF(padding + 3, padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(first), shown.canvas->palette().color(group, QPalette::Highlight));
QVERIFY(highlightedOver(plain.pixelColor(first), shown.grab().pixelColor(first), shown.canvas->palette().color(group, QPalette::Highlight)));
// Past the preedit, it sits after the preedit's letters.
shown.session.cancelText();
beginTextAt(shown.session, QPointF(20, 30));
@@ -102,8 +115,8 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
QCOMPARE(shown.session.textDraft().value().style.content, QString("nnxx"));
const double nn = metrics.horizontalAdvance(QStringLiteral("nn"));
const QPoint x = shown.editor().textTransform().map(QPointF(padding + nn + 3, padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(x), shown.canvas->palette().color(group, QPalette::Highlight));
QVERIFY(shown.grab().pixelColor(first) != shown.canvas->palette().color(group, QPalette::Highlight));
QVERIFY(highlightedOver(plain.pixelColor(x), shown.grab().pixelColor(x), shown.canvas->palette().color(group, QPalette::Highlight)));
QCOMPARE(shown.grab().pixelColor(first), plain.pixelColor(first));
}
QTEST_MAIN(InlineTextPreeditTests)
+31
View File
@@ -96,6 +96,7 @@ private slots:
void aSmallerMaskScalesSmoothlyAsTheRenderersDoes();
void aSurfaceHoldsEightyMillionPixelsAtMost();
void aSurfaceFitsTheStrokeItWasMadeFor();
void aMaskStrokeRedoesWhereItsTilesMap();
};
void LayerEffectsSurfaceTests::theFirstUpdateDrawsWhatTheRendererDraws()
@@ -192,5 +193,35 @@ void LayerEffectsSurfaceTests::aSurfaceFitsTheStrokeItWasMadeFor()
QVERIFY(!surface->matches(id, everyKind(), QSizeF(40, 30), QRectF(6, 5, 20, 10)));
}
void LayerEffectsSurfaceTests::aMaskStrokeRedoesWhereItsTilesMap()
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = 3, .red = 1, .green = 1, .blue = 0, .opacity = 1};
const ImportedImage base(solid(40, 40, qRgba(0, 0, 255, 255)), QImage(), QStringLiteral("Blue"));
// The mask as the stroke leaves it, over a region.
auto mask = std::make_shared<QImage>(BrushRaster::context(40, 40, true));
mask->fill(255);
const auto coverage = [mask](const QRectF &region) {
QImage live = BrushRaster::context(int(region.width()), int(region.height()), true);
QPainter painter(&live);
BrushRaster::draw(*mask, QRectF(-region.topLeft(), QSizeF(40, 40)), painter);
return live;
};
// A finer grid's tile, landing at 10 to 12.56.
const QTransform toGrid = QTransform::fromScale(0.01, 0.01);
const QRectF tile(1000, 1000, 256, 256);
const std::unique_ptr<LayerEffectsSurface> surface = LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(40, 40), QRectF(0, 0, 40, 40));
surface->update(base, {}, std::nullopt, LayerEffectsSurface::MaskStroke{{BrushPatch{tile, solid(4, 4, 0)}}, toGrid, coverage});
// A hole under the tile, its pixels anew: redone there.
QPainter hole(mask.get());
hole.fillRect(QRectF(10, 10, 3, 3), Qt::black);
hole.end();
surface->update(base, {}, std::nullopt, LayerEffectsSurface::MaskStroke{{BrushPatch{tile, solid(4, 4, 0)}}, toGrid, coverage});
const std::unique_ptr<LayerEffectsSurface> fresh = LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(40, 40), QRectF(0, 0, 40, 40));
fresh->update(base, {}, std::nullopt, LayerEffectsSurface::MaskStroke{{}, toGrid, coverage});
QCOMPARE(surface->image().value(), fresh->image().value());
QVERIFY(surface->image().value() != LayerEffectsRenderer::render(base.image(), std::nullopt, effects).image);
}
QTEST_GUILESS_MAIN(LayerEffectsSurfaceTests)
#include "LayerEffectsSurfaceTests.moc"
+159
View File
@@ -0,0 +1,159 @@
#include "EffectsCanvasFixtures.h"
// Swift 1.2.11 (b683acb): a painted mask keeps effects on.
namespace {
bool nearly(QColor colour, QColor expected)
{
return std::abs(colour.red() - expected.red()) <= 40 && std::abs(colour.green() - expected.green()) <= 40
&& std::abs(colour.blue() - expected.blue()) <= 40;
}
// Black down the mask, eight wide; a later pass cuts.
void paintHole(Scene &scene)
{
EditorSession &session = scene.session;
session.selectLayerTarget(session.activeLayerID().value(), true);
session.selectTool(NavigationTool::brush);
session.setMaskPaintWhite(false);
BrushSettings settings = session.brushSettings();
settings.diameter = 8;
settings.hardness = 1;
session.setBrushSettings(settings);
session.beginBrush(QPointF(30, 2));
session.continueBrush(QPointF(30, 6));
scene.shot();
session.continueBrush(QPointF(30, 35));
}
}
class MaskEffectsCanvasTests : public QObject {
Q_OBJECT
private slots:
void aPaintedMaskRedoesTheEffects();
void aPlacedMaskRedoesThemInTheLayersGrid();
void untouchedTilesKeepTheOldMask();
void aFinerPlacedMaskMapsIntoTheLayersGrid();
};
void MaskEffectsCanvasTests::aPaintedMaskRedoesTheEffects()
{
Scene scene;
scene.session.setEffects(outline(2));
scene.session.addLayerMask(true);
scene.landed();
QVERIFY(nearly(scene.at(QPoint(27, 20)), QColor(0, 0, 255)));
paintHole(scene);
// Mid-stroke the outline follows the hole: its middle is clear.
QVERIFY2(nearly(scene.at(QPoint(27, 20)), yellow), qPrintable(scene.at(QPoint(27, 20)).name()));
QVERIFY2(nearly(scene.at(QPoint(33, 20)), yellow), qPrintable(scene.at(QPoint(33, 20)).name()));
QCOMPARE(scene.at(QPoint(30, 20)), QColor(Qt::white));
QVERIFY(nearly(scene.at(QPoint(25, 20)), QColor(0, 0, 255)));
// Committed, the surface stands in until new effects land.
QVERIFY(scene.session.finishBrushImmediately());
scene.canvas.synchronizeDisplay();
QVERIFY(scene.session.effectsPreviews.rendered(scene.square).value().placement);
QVERIFY(nearly(scene.at(QPoint(27, 20)), yellow));
QTRY_VERIFY(!scene.session.effectsPreviews.rendered(scene.square).value().placement);
QVERIFY(nearly(scene.at(QPoint(27, 20)), yellow));
}
void MaskEffectsCanvasTests::aPlacedMaskRedoesThemInTheLayersGrid()
{
Scene scene;
scene.session.setEffects(outline(2));
scene.session.addLayerMask(true);
// A white mask in the layer's grid, placed on it.
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &square = record(snapshot, scene.square);
QImage white = BrushRaster::context(10, 10, true);
white.fill(255);
snapshot.masks.insert_or_assign(scene.square, ImportedImage(white, white, QStringLiteral("Mask")));
square.maskPlacement = square.transform;
square.maskLinked = false;
});
scene.session.selectLayer(scene.square);
QVERIFY(layerWith(scene.session, scene.square).mask.value().placement);
scene.landed();
paintHole(scene);
QVERIFY2(nearly(scene.at(QPoint(27, 20)), yellow), qPrintable(scene.at(QPoint(27, 20)).name()));
QVERIFY2(nearly(scene.at(QPoint(33, 20)), yellow), qPrintable(scene.at(QPoint(33, 20)).name()));
QCOMPARE(scene.at(QPoint(30, 20)), QColor(Qt::white));
QVERIFY(scene.session.finishBrushImmediately());
scene.canvas.synchronizeDisplay();
QVERIFY(nearly(scene.at(QPoint(27, 20)), yellow));
}
void MaskEffectsCanvasTests::untouchedTilesKeepTheOldMask()
{
// A layer wider than a tile, painted at one end.
EditorSession session;
CanvasView canvas{session};
session.createDocument(700, 40);
canvas.resize(700, 40);
session.viewport.resize(QSizeF(700, 40), 1, QSizeF(700, 40));
session.zoom(1);
const QImage blue = solid(600, 10, qRgba(0, 0, 255, 255));
session.insert(ImportedImage(blue, blue, QStringLiteral("Wide")), QPointF(350, 20));
session.setEffects(outline(2));
session.addLayerMask(true);
canvas.grab();
session.selectLayerTarget(session.activeLayerID().value(), true);
session.selectTool(NavigationTool::brush);
session.setMaskPaintWhite(false);
session.beginBrush(QPointF(100, 20));
const QImage shot = canvas.grab().toImage();
const QPointF far = session.viewport.viewPoint(QPointF(600.5, 20.5), QSizeF(700, 40));
QCOMPARE(shot.pixelColor(far.toPoint()), QColor(0, 0, 255));
}
void MaskEffectsCanvasTests::aFinerPlacedMaskMapsIntoTheLayersGrid()
{
// The committed mask's landed effects are the oracle.
Scene scene;
scene.session.setEffects(outline(2));
scene.session.addLayerMask(true);
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &square = record(snapshot, scene.square);
QImage white = BrushRaster::context(600, 600, true);
white.fill(255);
snapshot.masks.insert_or_assign(scene.square, ImportedImage(white, white, QStringLiteral("Mask")));
square.maskPlacement = square.transform;
square.maskLinked = false;
});
scene.session.selectLayer(scene.square);
scene.landed();
scene.session.selectLayerTarget(scene.square, true);
scene.session.selectTool(NavigationTool::brush);
scene.session.setMaskPaintWhite(false);
BrushSettings settings = scene.session.brushSettings();
settings.diameter = 1;
settings.hardness = 1;
scene.session.setBrushSettings(settings);
scene.session.beginBrush(QPointF(32, 16));
scene.shot();
scene.session.continueBrush(QPointF(32, 24));
const QImage painting = scene.shot();
// The oracle: a whole render through the stroke's mask.
const ImageLayer square = layerWith(scene.session, scene.square);
const QImage preview = scene.session.brushStroke()->placedMaskPreview(square.mask.value().placement.value()).value();
const LayerEffectsRenderer::Rendered expected = LayerEffectsRenderer::render(square.asset.value().image(), preview, outline(2));
int largest = 0;
for (int y = 10; y < 30; ++y) {
for (int x = 20; x < 40; ++x) {
const QPoint at(x - 25 + int(expected.inset), y - 15 + int(expected.inset));
if (!expected.image.rect().contains(at))
continue;
const QColor over = expected.image.pixelColor(at);
const QColor shown = painting.pixelColor(scene.shown(QPointF(x + 0.5, y + 0.5)));
// Over white, premultiplied: the channel plus what shows through.
const auto on = [&](int channel) { return channel + 255 - over.alpha(); };
largest = std::max({largest, std::abs(shown.red() - on(qRound(over.redF() * over.alphaF() * 255))),
std::abs(shown.green() - on(qRound(over.greenF() * over.alphaF() * 255))),
std::abs(shown.blue() - on(qRound(over.blueF() * over.alphaF() * 255)))});
}
}
QVERIFY2(largest <= 2, qPrintable(QString::number(largest)));
}
QTEST_MAIN(MaskEffectsCanvasTests)
#include "MaskEffectsCanvasTests.moc"
+2
View File
@@ -49,6 +49,7 @@ compositor_test(ShapeToolTests)
compositor_test(ShapeLayerTests)
compositor_test(ShapeCanvasTests)
compositor_test(TypeToolTests)
compositor_test(TypedTextCanvasTests)
compositor_test(LiveTextDoubleClickTests)
compositor_test(SVGImportTests)
compositor_test(TextLayoutTests)
@@ -198,6 +199,7 @@ compositor_test(LayerEffectsKernelTests)
compositor_test(EffectsPreviewCacheTests)
compositor_test(LayerEffectsSurfaceTests)
compositor_test(EffectsCanvasTests)
compositor_test(MaskEffectsCanvasTests)
compositor_test(EffectsFailureTests)
compositor_test(EffectsPanelTests)
compositor_test(LayerEffectRowTests)
+411
View File
@@ -0,0 +1,411 @@
#include "InlineTextDrawFixtures.h"
#include "Document/LayerEffects+Renderer.h"
// Swift 1.2.11: typed text drawn as its pixels.
namespace {
// Only the box's inside, where frame and handles never draw.
int largestChange(const QImage &before, const QImage &after, QRect inside)
{
int largest = 0;
inside &= before.rect();
for (int y = inside.top(); y <= inside.bottom(); ++y) {
for (int x = inside.left(); x <= inside.right(); ++x) {
const QRgb a = before.pixel(x, y), b = after.pixel(x, y);
largest = std::max({largest, std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), std::abs(qBlue(a) - qBlue(b))});
}
}
return largest;
}
LayerEffects yellowStroke()
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = 3, .red = 1, .green = 1, .blue = 0, .opacity = 1};
return effects;
}
// The caret draws in the text's colour: keys go elsewhere.
void unfocus(DrawnText &shown)
{
QCoreApplication::processEvents();
shown.canvas->clearFocus();
if (!QTest::qWaitFor([&] { return !shown.canvas->hasFocus(); }))
throw std::runtime_error("the canvas kept the keys");
}
// Debug lines on the rendering category, counted.
int remakes = 0;
QtMessageHandler passOn = nullptr;
void countRemakes(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (message == QLatin1String("typed text remade"))
++remakes;
passOn(type, context, message);
}
// The union of the regions a widget paints.
struct PaintedRegion : QObject {
QRegion region;
bool eventFilter(QObject *, QEvent *event) override
{
if (event->type() == QEvent::Paint)
region |= static_cast<QPaintEvent *>(event)->region();
return false;
}
};
bool yellowish(QColor colour)
{
return colour.red() > 200 && colour.green() > 200 && colour.blue() < 60;
}
}
class TypedTextCanvasTests : public QObject {
Q_OBJECT
private slots:
void textLooksTheSameWhileEditingAndOnceCommitted_data();
void textLooksTheSameWhileEditingAndOnceCommitted();
void newTextSitsAboveTheActiveLayer();
void editedTextKeepsItsOpacityAndEffects();
void typedTextFollowsEachChange();
void aMaskHidesTypedTextsEffects();
void cancelledTextHandsOnNothing();
void typingRepaintsTheWholeView();
void aHandleThatOnlyMovesRepaintsTheWholeView();
void typedTextBlendsAsItsLayer();
void typedTextIsRemadeOnlyWhenItChanges();
void aMovedBoxRedoesItsTypedEffects();
};
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted_data()
{
QTest::addColumn<double>("zoom");
QTest::newRow("smooth") << 1.5;
QTest::newRow("hard") << 4.0;
}
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted()
{
QFETCH(double, zoom);
DrawnText shown;
shown.session.zoom(zoom);
shown.canvas->synchronizeDisplay();
const QImage blank = shown.grab();
// The box's middle near the view's; it grows by four.
shown.session.beginText(QPointF(163, 205), true);
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("Sharp");
style.fontSize = 24;
});
// Unfocused, no caret draws; the frame stays at the edge.
unfocus(shown);
const QRect inside = shown.editor().boxTransform().mapRect(QRectF(QPointF(0, 0), shown.editor().logicalSize())).toAlignedRect().adjusted(10, 10, -10, -10);
const QImage editing = shown.grab();
QVERIFY(largestChange(blank, editing, inside) > 100);
QVERIFY(shown.session.finishText());
QVERIFY(shown.session.activeLayer().value().liveText());
const QImage committed = shown.grab();
QVERIFY2(largestChange(editing, committed, inside) <= 2, qPrintable(QString::number(largestChange(editing, committed, inside))));
}
void TypedTextCanvasTests::newTextSitsAboveTheActiveLayer()
{
DrawnText shown;
// A drawn active layer, a green dot, under a cover.
QImage green = BrushRaster::context(2, 2, false);
green.fill(Qt::green);
shown.session.addPixelLayer(green, QPointF(300, 250), QStringLiteral("Dot"), QStringLiteral("Add"));
const QUuid below = shown.session.activeLayerID().value();
QImage white = BrushRaster::context(400, 300, false);
white.fill(Qt::white);
shown.session.addPixelLayer(white, QPointF(0, 0), QStringLiteral("Cover"), QStringLiteral("Add"));
const QUuid cover = shown.session.activeLayerID().value();
shown.session.selectLayer(below);
shown.session.selectTool(NavigationTool::type);
beginTextAt(shown.session, QPointF(40, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Hidden"); });
unfocus(shown);
QVERIFY(!where(shown.grab(), reddish).isValid());
shown.session.cancelText();
// Above the cover it shows, as its layer will.
shown.session.selectLayer(cover);
beginTextAt(shown.session, QPointF(40, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Shown"); });
unfocus(shown);
QVERIFY(where(shown.grab(), reddish).isValid());
shown.session.cancelText();
// A hidden active layer draws nothing: text goes on top.
shown.session.toggleLayerVisibility(below);
shown.session.selectLayer(below);
beginTextAt(shown.session, QPointF(40, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Top"); });
unfocus(shown);
QVERIFY(where(shown.grab(), reddish).isValid());
}
void TypedTextCanvasTests::editedTextKeepsItsOpacityAndEffects()
{
DrawnText shown;
const QUuid id = shown.text(QPointF(40, 40), QStringLiteral("HH"));
shown.session.beginOpacityEdit();
shown.session.setLayerOpacity(0.5);
shown.session.finishOpacityEdit();
shown.session.editActiveText();
unfocus(shown);
// Half opacity: half red over the checkerboard, no full red.
const QRect ink = where(shown.grab(), [](QColor colour) { return colour.red() > 150 && colour.red() < 190 && colour.green() < 60; });
QVERIFY(ink.isValid());
QVERIFY(!where(shown.grab(), [](QColor colour) { return colour.red() > 240 && colour.green() < 60; }).isValid());
shown.session.cancelText();
shown.session.beginOpacityEdit();
shown.session.setLayerOpacity(1);
shown.session.finishOpacityEdit();
// Effects stay on while typing, redone at once.
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHH"); });
QVERIFY(where(shown.grab(), yellowish).isValid());
// Committed, the typed effects stand in: nothing blinks.
QVERIFY(shown.session.finishText());
shown.grab();
const EffectsPreviewCache::Result seeded = shown.session.effectsPreviews.rendered(id).value();
QVERIFY(seeded.placement);
QVERIFY(where(shown.grab(), yellowish).isValid());
// Handed on once: drawing again seeds nothing more.
QTRY_VERIFY((shown.grab(), !shown.session.effectsPreviews.rendered(id).value().placement));
// Cancelled, typed effects the layer never took hand on nothing.
shown.session.editActiveText();
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHHH"); });
shown.grab();
shown.session.cancelText();
shown.grab();
QVERIFY(!shown.session.effectsPreviews.rendered(id).value().placement);
}
void TypedTextCanvasTests::typedTextFollowsEachChange()
{
DrawnText shown;
shown.text(QPointF(40, 40), QStringLiteral("H"));
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
unfocus(shown);
const auto count = [&](bool (*test)(QColor)) {
const QImage image = shown.grab();
int found = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = 0; x < image.width(); ++x)
found += test(image.pixelColor(x, y));
return found;
};
const int ink = count(reddish), rim = count(yellowish);
QVERIFY(ink > 0 && rim > 0);
// More letters: more pixels and more effects, redrawn.
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHH"); });
QVERIFY(count(reddish) > ink * 2);
QVERIFY(count(yellowish) > rim * 2);
}
void TypedTextCanvasTests::aMaskHidesTypedTextsEffects()
{
// A black mask hides the whole text, linked.
DrawnText shown;
const QUuid id = shown.text(QPointF(40, 150), QStringLiteral("HHHH"));
shown.session.setEffects(yellowStroke());
shown.session.addLayerMask(false);
shown.session.editActiveText();
unfocus(shown);
QVERIFY(!where(shown.grab(), reddish).isValid());
QVERIFY(!where(shown.grab(), yellowish).isValid());
shown.session.cancelText();
// A frame between, as the app always draws one.
shown.grab();
// Placed over the left half, its black middle hides there.
LayerTransform box;
rewrite(shown.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &text = record(snapshot, id);
box = text.transform;
QImage ring = BrushRaster::context(30, 30, true);
ring.fill(255);
QPainter hole(&ring);
hole.fillRect(QRect(10, 10, 10, 10), Qt::black);
hole.end();
snapshot.masks.insert_or_assign(id, ImportedImage(ring, ring, QStringLiteral("Mask")));
text.maskPlacement = LayerTransform{.origin = box.origin, .size = QSizeF(box.size.width() / 2, box.size.height())};
text.maskLinked = false;
});
shown.session.selectLayer(id);
shown.session.editActiveText();
unfocus(shown);
const QImage image = shown.grab();
const QSizeF size = shown.documentSize();
const auto view = [&](double x, double y) {
return shown.session.viewport.viewPoint(box.origin + QPointF(box.size.width() * x, box.size.height() * y), size).toPoint();
};
// Stretched over the whole text, that band would show.
QVERIFY(!where(image.copy(QRect(view(1 / 6.0 + 0.02, 0.4), view(1 / 3.0 - 0.02, 0.6))), reddish).isValid());
QVERIFY(where(image.copy(QRect(view(0.5, 0), view(1, 1))), reddish).isValid());
}
void TypedTextCanvasTests::cancelledTextHandsOnNothing()
{
// Effects never rendered for these pixels: a seed would show.
DrawnText shown;
const QUuid id = shown.text(QPointF(40, 40), QStringLiteral("HH"));
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHHH"); });
shown.grab();
shown.session.cancelText();
shown.grab();
const std::optional<EffectsPreviewCache::Result> shownEffects = shown.session.effectsPreviews.rendered(id);
QVERIFY(!shownEffects || !shownEffects.value().placement);
}
void TypedTextCanvasTests::typingRepaintsTheWholeView()
{
// Box text's style alone changes; its effects need the view.
DrawnText shown;
shown.session.beginText(QRectF(40, 100, 100, 60));
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("H");
style.fontSize = 20;
});
QVERIFY(shown.session.finishText());
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
// Any preview still to land has landed and painted.
QTest::qWait(400);
PaintedRegion painted;
shown.canvas->installEventFilter(&painted);
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HH"); });
QTRY_VERIFY(!painted.region.isEmpty());
QCOMPARE(painted.region.boundingRect(), shown.canvas->rect());
}
void TypedTextCanvasTests::aHandleThatOnlyMovesRepaintsTheWholeView()
{
// At 4x, one point's drag moves the box, same size.
DrawnText shown;
shown.session.beginText(QRectF(185, 135, 50, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("H");
style.fontSize = 20;
});
shown.session.zoom(4);
shown.canvas->synchronizeDisplay();
const LayerTextStyle before = shown.session.textDraft().value().style;
const QPointF left = shown.editor().boxTransform().map(QPointF(0, shown.editor().logicalSize().height() / 2));
shown.press(left);
QTest::qWait(50);
PaintedRegion painted;
shown.canvas->installEventFilter(&painted);
shown.move(left + QPointF(1, 0));
QCOMPARE(shown.session.textDraft().value().style, before);
QTRY_VERIFY(!painted.region.isEmpty());
QCOMPARE(painted.region.boundingRect(), shown.canvas->rect());
shown.release(left + QPointF(1, 0));
}
void TypedTextCanvasTests::typedTextBlendsAsItsLayer()
{
// Red multiplied over green shows black, typed as committed.
for (const bool effects : {false, true}) {
DrawnText shown;
QImage green = BrushRaster::context(400, 300, false);
green.fill(Qt::green);
shown.session.addPixelLayer(green, QPointF(0, 0), QStringLiteral("Green"), QStringLiteral("Add"));
shown.text(QPointF(40, 150), QStringLiteral("HH"));
shown.session.setLayerBlendMode(LayerBlendMode::multiply);
if (effects)
shown.session.setEffects(yellowStroke());
QTRY_VERIFY(!effects || (shown.grab(), shown.session.effectsPreviews.rendered(shown.session.activeLayerID().value())));
const QImage committed = shown.grab();
shown.session.editActiveText();
unfocus(shown);
const QRect inside = shown.editor().boxTransform().mapRect(QRectF(QPointF(0, 0), shown.editor().logicalSize())).toAlignedRect().adjusted(10, 10, -10, -10);
const QImage typed = shown.grab();
QVERIFY(!where(typed.copy(inside), reddish).isValid());
QVERIFY2(largestChange(committed, typed, inside) <= 2, qPrintable(QString::number(largestChange(committed, typed, inside))));
}
}
void TypedTextCanvasTests::typedTextIsRemadeOnlyWhenItChanges()
{
QLoggingCategory::setFilterRules(QStringLiteral("omaphoto.rendering.debug=true"));
passOn = qInstallMessageHandler(countRemakes);
DrawnText shown;
beginTextAt(shown.session, QPointF(40, 150));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("H"); });
remakes = 0;
shown.grab();
shown.grab();
QCOMPARE(remakes, 1);
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HH"); });
shown.grab();
shown.grab();
QCOMPARE(remakes, 2);
// Ended, the image goes: the same style is made again.
const LayerTextStyle typed = shown.session.textDraft().value().style;
shown.session.cancelText();
shown.grab();
beginTextAt(shown.session, QPointF(40, 150));
shown.session.changeTextStyle([&](LayerTextStyle &style) { style = typed; });
shown.grab();
QCOMPARE(remakes, 3);
qInstallMessageHandler(passOn);
QLoggingCategory::setFilterRules(QString());
}
void TypedTextCanvasTests::aMovedBoxRedoesItsTypedEffects()
{
// A placed mask's band over box text with effects.
DrawnText shown;
shown.session.beginText(QRectF(185, 135, 50, 60));
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("HH");
style.fontSize = 20;
});
QVERIFY(shown.session.finishText());
const QUuid id = shown.session.activeLayerID().value();
shown.session.setEffects(yellowStroke());
shown.session.addLayerMask(true);
rewrite(shown.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &text = record(snapshot, id);
QImage band = BrushRaster::context(50, 40, true);
band.fill(255);
QPainter painter(&band);
painter.fillRect(QRect(20, 0, 10, 40), Qt::black);
painter.end();
snapshot.masks.insert_or_assign(id, ImportedImage(band, band, QStringLiteral("Mask")));
text.maskPlacement = LayerTransform{.origin = text.transform.origin, .size = QSizeF(40, text.transform.size.height())};
text.maskLinked = false;
});
shown.session.selectLayer(id);
shown.session.editActiveText();
unfocus(shown);
shown.session.zoom(4);
shown.canvas->synchronizeDisplay();
shown.grab();
// A fresh canvas renders the effects anew; they agree.
const LayerTextStyle before = shown.session.textDraft().value().style;
const LayerTransform moved = shown.editor().shownTransform();
const QPointF left = shown.editor().boxTransform().map(QPointF(0, shown.editor().logicalSize().height() / 2));
shown.press(left);
shown.move(left + QPointF(1, 0));
shown.release(left + QPointF(1, 0));
QCOMPARE(shown.session.textDraft().value().style, before);
QVERIFY(shown.editor().shownTransform().origin != moved.origin);
QVERIFY(layerWith(shown.session, id).mask.value().placement);
const QImage kept = shown.grab();
QVERIFY(where(kept, reddish).isValid() && where(kept, yellowish).isValid());
// A fresh canvas renders the effects anew: they must agree.
CanvasView fresh(shown.session);
fresh.resize(shown.canvas->size());
fresh.synchronizeDisplay();
const QImage anew = fresh.grab().toImage().convertToFormat(QImage::Format_RGB32);
const QRect inside = shown.editor().boxTransform().mapRect(QRectF(QPointF(0, 0), shown.editor().logicalSize())).toAlignedRect().adjusted(10, 10, -10, -10);
QVERIFY2(largestChange(kept, anew, inside) <= 2, qPrintable(QString::number(largestChange(kept, anew, inside))));
}
QTEST_MAIN(TypedTextCanvasTests)
#include "TypedTextCanvasTests.moc"