From 3757eb8910a35eca9b74e11c089efca7dbbb7f2c Mon Sep 17 00:00:00 2001 From: RaidenMei Date: Sat, 3 Oct 2026 19:16:59 -0400 Subject: [PATCH] 13.19d: 2190a1b verified; sizeLabel's ties tested; tests read optionals through value(); two mask tests brought to 13.12 --- AGENTS.md | 4 +-- TASKS.md | 6 +++- tests/BlurNoiseAdjustmentTests.cpp | 6 ++-- tests/BrushFixtures.h | 6 ++-- tests/CanvasEditContractTests.cpp | 2 +- tests/CanvasFixtures.h | 2 +- tests/EffectsCanvasTests.cpp | 4 +-- tests/EffectsPanelTests.cpp | 46 ++++++++++++------------- tests/EffectsPreviewCacheTests.cpp | 28 ++++++++-------- tests/FilterSessionTests.cpp | 2 +- tests/FinishingFilterTests.cpp | 12 +++---- tests/HistoryTests.cpp | 46 ++++++++++++------------- tests/ImageAdjustmentTests.cpp | 2 +- tests/ImageFileDropTests.cpp | 4 +-- tests/ImageTrimTests.cpp | 2 +- tests/InnerGlowTests.cpp | 12 +++---- tests/LayerAdjustmentTests.cpp | 10 +++--- tests/LayerEffectRowTests.cpp | 18 +++++----- tests/LayerEffectsKernelTests.cpp | 50 ++++++++++++++-------------- tests/LayerEffectsRendererTests.cpp | 12 +++---- tests/LayerEffectsSessionTests.cpp | 44 ++++++++++++------------ tests/LayerEffectsTests.cpp | 10 +++--- tests/LayerMaskTests.cpp | 24 ++++++------- tests/LayerTests.cpp | 2 +- tests/MaskAloneTests.cpp | 4 +-- tests/NativeResolutionPaintTests.cpp | 5 +++ tests/OuterGlowTests.cpp | 10 +++--- tests/PSDFixture+Text.h | 6 ++-- tests/PSDFixture.h | 16 ++++----- tests/PSDReaderFixtures.h | 8 ++--- tests/RasterSnapshotTests.cpp | 10 +++--- tests/RawDevelopTests.cpp | 14 ++++---- tests/RemoveBackgroundTests.cpp | 4 +-- tests/SelectionFeatherTests.cpp | 2 +- tests/SelectionSignalTests.cpp | 4 +++ tests/SessionRecord.h | 8 ++--- tests/ShapeLayerTests.cpp | 2 +- tests/SmudgeLiquifyTests.cpp | 2 +- tests/TextColorModelTests.cpp | 2 +- tests/WritingProjectsGuideTests.cpp | 2 +- 40 files changed, 233 insertions(+), 220 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6614a29..14f435e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Platform adaptations, each forced by a missing Qt analog: - Distro checks (11.4): `scripts/distro-check.sh arch|fedora|fedora44|nixos|resolute` builds `scripts/distro//Dockerfile` (for `resolute`, `Dockerfile.dev` on Ubuntu 26.04; `fedora` is 43 since 11.4d, `fedora44` the same file on 44 since 12.16) and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path with shared-mime-info's beside it (a NixOS system has the database in its profile through `xdg.mime.enable`; without it Qt knows no camera RAW type, missed until 12.16) and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since `QTransform::map` clamps w for points past the horizon from Qt 6.10 (6.10.2 and 6.11.2 probed; 6.4 divides), where Swift divides whatever its sign, and `DistortTests` pins it past the horizon (on 6.4 the two agree, so a mutant there is equivalent); a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators. Tests follow the systems where only a message or a count differs: libheif 1.23's longer messages, Qt 6.9's TIFF plugin logging libtiff's errors as criticals, fonts named rather than counted, the Type icon's letters in the system's own sans (Fedora's is Noto: the icon sheet compares them only under DejaVu Sans), and `QT_FORCE_STDERR_LOGGING` for the launched app, since a journald-built Qt logs to the journal when stderr is no console. The five starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there. `ProjectExportFailureTests` opens a panel and an alert once in `initTestCase`, before any limit, as the app's window has before any export: a snapshot that fails opens its alert while the limit holds, and on every system that alert was the process's first font lookup and the panel's first open, costs the app never meets there. In the NixOS shell, which reads fontconfig's whole `conf.d` without a cache, the lookup's failed allocation segfaulted inside fontconfig (`FcHashTableFind`, found under gdb); on the dev image a panel test run alone ran out inside the panel's first open. `theJPEGRenderTakesTheSnapshotWithoutACopy` holds the JPEG render's snapshot to a move under a 2 MB limit, as `thePanelOpensWithoutCopyingTheSnapshot` holds the PNG panel's. The flake leaves out `dev.sh`'s `build/`, which nixpkgs' CMake hook would take for its own build folder, and the `Compositor-Ref` spec, which a `path:` flake would copy whole. - Spot Healing (8.4): `HealPixels.c` compiles in place beside the other kernels. `SpotHealingMode`, `allSpotHealingModes` and `rawValue` (Swift's names) sit in `BrushStroke.h`, and `BrushSettings` gains `healing` and `healingMode`. While the stroke lasts its tiles show Swift's wash, gray 0.12 at 45% through the coverage, never erasing. `heal` takes the painted bounds from the kernel's `heal_coverage_bounds`, draws the source into a region about three spots wider, and hands the kernel the coverage packed row by row, as Swift's gray context holds it (the twin copies each tile's rows where Swift draws each coverage image); the seed is `QRandomGenerator::global()`, Swift's `UInt32.random`. Each touched tile is then its base with the healed region copied over, blended with the base through `PixelAdjust::blend` under a selection, where Swift clips the context. The kernel's failure is `ProjectError(tooLarge)`; pixels or packed coverage that cannot be allocated are `ExportError(render)`, as Swift's contexts. The session's `beginBrush` takes Spot Healing on pixels alone, never on a mask, as Swift's guard; `finishBrushImmediately` heals before the commit, one `Spot Healing` step; `spotHealingMode` is a getter and a setter that notifies; Tab steps the type. `isBrushTool` is Swift's four tools: the circle, the right-drag tip, the brackets, the digits and `BrushControls` serve each, and a press with Clone Stamp or the Smear begins nothing until 8.5 and 8.6 bring them. `ContentView` keeps one bar across the four, as Swift's single `isBrushTool` branch keeps its view; the bar titles itself by tool (Spot Healing, Clone Stamp, Smear, Eraser, Brush), shows Paint and Erase for the Brush alone and the three types for Spot Healing alone, and names opacity Strength for the Smear; the clone's Aligned and Sample arrive with 8.5, the Smear's modes with 8.6. Equivalent mutant: the healed tile starting from its context rather than its base, since the region holds the whole wash and the copy replaces it. - Clone Stamp (8.5): `Document/CloneStamp.h` holds `CloneSettings`, `CloneStamp.cpp` Swift's extension: `setCloneSource` (a point that is no number is refused; a new source restarts the alignment), `cloneStrokeOffset`, `cloneSamplePoint` and `cloneSample`, whose context that cannot be allocated logs on `omaphoto.app` where Swift's `try?` is silent. The session keeps `cloneSource`, `cloneSettings` (a setter that notifies), `cloneOffset` and `parkedBrushTips` by `tipFamily` (Clone Stamp 1, the Smear 2, the rest 0; the two parked tips start at Swift's 40, 0, 1). `selectTool` parks size, hardness and opacity when the family changes and reads the tip it restores with `at`: the session starts in family 0 and leaving a family parks it, so Swift's `if let` always finds one. `beginTransform` and Transform Selection set the Move tool directly, as Swift's do, and park nothing. `beginBrush` takes Clone Stamp on pixels alone, asks for a source first (`brushError`: "Alt-click where Clone Stamp should copy from first."), samples the document as the stroke starts and fixes the offset; Tab toggles the sample; the step is `Clone Stamp`. `BrushStroke::Clone` is Swift's tuple. `publish` makes each tile's view of the sample on the calling thread (`clonePixels`): under a scale or turn QPainter draws it bilinear, Swift's medium quality; a whole translation copies it; a fractional one is drawn at the floor and shifted by `BrushRaster::shiftedByFraction`, since QPainter snaps an unscaled image. `fillClone` then composites it source-over through coverage × opacity × selection, each byte rounded once, where Swift clips the context to the coverage. Swift's `!isMask || isBlur` holds since 8.6: a mask takes a clone only from Blur. The bar shows Aligned (a `QCheckBox` written on `clicked`, so the session's own changes write nothing back), This Layer and All Layers, and "Alt-click to set the source" in `PlaceholderText` while there is no source. On the canvas an Alt-press with Clone Stamp sets the source and paints nothing; Swift's cursor refresh there is dead (Alt is held, so the crosshair stays) and left out. With a source the pointer hides unless Alt or Space is held. `updateBrushCursor` gives the overlay the source crosshair in view points and, between strokes and without Alt, `clonePreview` (the source around the stamped point at screen resolution, at most 1024 pixels, kept under Swift's key; Swift's `.medium` there is overridden by `LayerRenderer`'s own quality and left out) with `cloneTip` (one click painted by the brush engine, kept by size and hardness). `BrushCursorOverlay` gains `marker`, `preview`, `previewOpacity` and `tip`, images compared by `cacheKey` as Swift's `!==`; under an antialiased ellipse clip at the brush's opacity it draws the preview and cuts it to the tip inside `AdjustmentSurface::draw`, Swift's transparency layer, so the tip applies at the drawing's resolution however far a capped preview is enlarged, and draws the crosshair white 3 under black 1 with round caps. A modifier change refreshes the brush cursor, as Swift's monitor does. Equivalent mutants: `displayedTransform` in `cloneSample` and `clonePreview` (it differs from the stored transform only in a transform edit, which leaves Clone Stamp for Move, or a filter edit, which refuses strokes; the preview would show it only with a filter sheet open over Clone Stamp). -- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`, in the stroke's own grid since 13.11 and in pieces since 13.16 (see those bullets). Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact; it costs 20–110 ms for a 4K document on 32 cores (sigma 1.5 to 30), paid as a Blur stroke begins, since Swift's sample is taken whole. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre). +- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`, in the stroke's own grid since 13.11 and in pieces since 13.16 (see those bullets). Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre). - The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr`, as a pixel move's, because the canvas draws it while the commit renders, and the raster's pointer stands for Swift's `gradientEdit === edit` (a new line on the same target keeps the raster, as Swift keeps the edit). `commitGradient` takes a completion callback, posted on every path; `resolveGradient` posts the commit, Swift's `Task`, so a switch of tool, layer or target applies it once the caller returns; `invertPixels` and `beginFilter` commit it first and call themselves again from its callback, as Swift awaits it. `fillGradient` fills the canvas through `paintCanvas` with a `QLinearGradient`, or a `QRadialGradient` centred on the start with the line as its radius; Qt's pad spread is CoreGraphics' `drawsBeforeStartLocation` and `drawsAfterEndLocation`, and two stops interpolate alike in both. `gradientColors` makes a mask's colours gray from the palette's red, where Swift makes `DeviceGray` colours. `undo` first discards a pending gradient and `canUndo` counts one, as Swift's. The palette's model came forward from 8.10 into `ColorPalette.cpp` (`PaletteColor`, `foregroundColor` with `setForegroundColor` through `setBrushSettings`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`), and `fillSelection` reads it; the Type bar's share of `setPaletteColor` came with 8.9a, the picker and the swatches came with 8.10a. On the canvas (`EditorCanvas+Gradient.cpp`) a press within ten points of the end, then the start, grabs it, else begins a line; Shift turns the dragged end to a multiple of 45° around the other; Escape cancels and Return or Enter applies, after the selection's keys; losing focus drops the drag. `TransformOverlay` draws the line in place of the handles while one is pending, as Swift's: a radial's dashed rim at the line's length, the line black 3 under white 1, each end a white disc with a black rim, a gray ring and its colour. A pending gradient on a folder's mask clips the folder's layers at once through `liveFolderMaskClip`, as a brush stroke does. `GradientControls` is the tool's `ToolHeaderBar`: the shapes as checkable `QToolButton`s in a `QButtonGroup`, `GradientSwatch` (white, gray checks of four points, the colours, clipped to a rounded rect, the rim unclipped as Swift's `strokeBorder` overlay), the style `QComboBox`, Reverse, the slider in thousandths from 1% and the percent field (a `SelectionAmountField`, whose own clamp stands for the binding's), the Mask note in `PlaceholderText`, and Cancel and Apply while pending. Equivalent mutants: the gray branch of `gradientColors` and of `fillSelection` (a mask's palette is black or white), and the swatch's rim clipped too (3/255 at its corner pixels, measured). A press that marks a drag without an edit was equivalent until 8.10b's Alt eyedropper read the drag; a test holds it now. - The Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), `installProject` loads shapes and `applyDocumentSize` does not, as Swift's rebuild leaves them out after a crop or a resize; where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing. - The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passes it on, the image resizer not. The manifest's `text` is Swift's `Codable`: every key required but `boxSize` (synthesized decoding ignores the defaults), the alignment by raw value, the box as two numbers; the store refuses a project whose text fails `isValid` or has no pixels (Swift's `validate`; its adjustment term is dead, since an adjustment layer's own rule refuses pixels, 9.5a), and a snapshot installed past the store loads such text as pixels (`LayerText::loaded`). `TypeControls` is the tool's `ToolHeaderBar`: the face as a `TypeFontPicker` (since 12.1: its list of fontconfig's names and the style's own, sorted, loads when the menu first opens); size, tracking and leading as `TextStyleField`s, `QLineEdit`s that take each readable edit as Swift's value bindings do, show the session's number unless focused, step by one on the arrows (Shift ten), keep their typing while a menu or popup borrows the focus, and keep the focus on Return, showing the number again, selected, as an `NSTextField` does: Swift's Type bar alone has no `releasesFocusOnCommit`; Escape is the field's own. They print Swift's `.number`: at most three decimals, none trailing; digits are not grouped, as in every field of the twin. The size's parse check is left out: text that is no number reads as zero, which no size passes. The size steps within 1–2000; the leading shows Auto as its placeholder, clamps what is typed to 0–5000 and steps from the height Auto works out to. The alignments are checkable `QToolButton`s with Swift's `text.align` glyphs drawn as paths, redrawn on `PaletteChange` as the Move bar's lock is; Cancel and Done show while a draft is open, else Edit Text, enabled on live text; the bar rests without a document or while busy shows. The fields scroll as the Move tool's do. The colour swatch and `openTextColorPicker` came with 8.10a. A live text row in the panel shows the rail's Type glyph 1.2 times up, centred (Swift's `textformat`, 1.21 times up), "Editable text layer", "Select text: " and "Text" (Swift 1.1.5's words); `ThumbnailKey` carries `editableText`, so text that loads as pixels redraws. A double click on a control makes the row active, then opens its text (Swift's `renameClickedLayer`); on the name it renames. The inline editor, the canvas's gestures and the edited layer hidden under its draft come with 8.9b. Equivalent mutants: `ImageLayer`'s equality without its text term (every text change brings new pixels); `TextLines` without its stop after a paragraph that overflowed (every later paragraph overflows at once, so the stop spares only their layouts). @@ -175,7 +175,7 @@ Platform adaptations, each forced by a missing Qt analog: - Blending (13.9a: `6dc2219`, `8ab32c9`, `5a8f6ce`, `e6c964b`). Held, proven: clipping stacks and live-mask coverage at device resolution (the twin's `LiveMaskRenderer` has no `bounds`: its group, alpha and coverage surfaces are the painter's device since 4.x, so Swift's `resolution` and `pixelSize` have no twin); an adjustment in any mode, over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`adjust`'s `blended` goes through `LayerRenderer::composite`, which hand-blends every mode QPainter lacks through `HslBlend`, so Swift's `SeparableBlend.blend` has no twin); a clipping stack composited in its base's mode in every mode (`drawComposite` hands `m_stackModes` to `LayerRenderer::composite`, the same path), on the canvas, in export and in Copy Merged alike. Ported: Soft Light by Photoshop's formula, as Core Image computes it (2bs + b²(1 − 2s) up to half, 2b(1 − s) + √b(2s − 1) above), in `SeparableBlend::channel`, and `needsSurface` names Soft Light, so `HslBlend` blends it by hand everywhere a layer, a stack or an adjustment is drawn; `LayerRenderer::compositionMode` loses its Soft Light case (dead now). Compared first: Qt's `CompositionMode_SoftLight` is the PDF's (W3C) formula, which equals Photoshop's below a source of half and above it wherever the backdrop passes 0.25; under that it takes the PDF's cubic for √b and runs up to 17 levels darker (backdrop 5/255, source 1: 19 where Photoshop gives 36). Swift's 0.9 over 0.5 gives 170 under either formula. Known difference: Swift's Core Image is "within 5 levels of Photoshop's"; the twin is Photoshop's own formula, so it may differ from Swift's by those few levels. Tests: `BlendingTests` (Swift's `clippingStacksBlendInTheirBasesMode` for Linear Dodge, Color Dodge and Multiply: Swift's numbers, Copy Merged equal to export, the canvas within a level; adjustments in Difference and Linear Light over the document and Vivid Light and Subtract inside a stack, by the numbers, at full and half opacity, in all three places; Swift's `softLightMatchesPhotoshop` plus a dark backdrop and the lower branch; Swift's `softEdgedLayersBlendTheSame` for Soft Light, Overlay, Multiply and Color Dodge: a radial soft layer over noise, Copy Merged equal to export, the canvas within a level), `BlendModeTests::everyBlendModeMatchesThePdfFormulas` (its Soft Light reference is Photoshop's now; its backdrop of 0.1 under sources past half catches the PDF's cubic (at 0.25 the cubic equals √0.25)), `ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels` (a half-unit stripe stays sharp at a device scale of two, in a stack and through coverage from a source further down). Equivalent mutant: Soft Light's `source <= 0.5` for `< 0.5` (both branches give the backdrop at a half). Judgments: stacks and coverage at device resolution, adjustments in any mode and a stack in its base's mode were held (proven by the tests above); Soft Light ported. Mutants: 5 on Soft Light, 4 caught, one equivalent (above). - The Metal canvas (13.9b: `cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`). OmaPhoto stays a CPU canvas (the port's decision): Swift's GPU canvas, its `GPUCanvas`, `GPUNoise`, `MetalWarp` and `CanvasLinesOverlay` and the GPU branches of `EditorCanvas`, have no twin; the twin's canvas is the Core Graphics canvas those commits compare against. Ported: Grain and Add Noise anchored to the document (`54dd707`): `LiveMaskRenderer::adjustmentRegion` (Swift's `adjustmentRegion`) maps what the painter shows into the document, and the canvas's `drawLayers` sets it from its centre mapping and zoom, so the smooth path counts noise in frame pixels from the document's corner and Grain in document pixels, as export does, and the crisp path's surface maps to document pixels, so a partial repaint keeps one field; the blur's scale still comes from the unmapped region. This retires 9.5b's and 12.8c's known differences (Grain and noise following the view, a partial repaint's own field); `CanvasDisplayTests::grainFollowsTheViewOnTheCanvas` is gone. Export and `drawLiveComposite` already draw in document pixels and set no region, as Swift's. Tests: `AdjustmentCanvasTests` (Swift's `matchesEveryAdjustment`: every kind through a ramp mask at 80% over a photo and a translucent layer, the canvas within a level of the export over its checkerboard; Swift's `patternsStayWithTheDocument` for Add Noise and Grain: equal to export at 100% and 200%, panned and not, and at 80% a whole-pixel pan moving the pattern with the image and a partial repaint showing the whole's pattern within two levels of resampling), `CanvasEditContractTests` (Swift's `aNewMaskRevealsTheWholeLayer`, upright and turned: a one-pixel mask on a scaled layer changes no covered pixel, the canvas as the export; `matchesWhileMovingPixels`, moving and duplicating on a masked layer at 60% under a soft selection: the preview equals the commit and the export outside the ants, the hole only for a move; `matchesWhileDraggingAGradient` in a selection at 70%: the pending gradient equals its commit outside the overlays, nothing outside the selection; `5708ed3`'s failed fill cancelling with `brushError`, no step, the document unchanged; `matchesALiveMaskOutsideAClippingStack`: a turned source with a layer between, the canvas as the export), `CanvasContractFixtures.h` (the scene, the export over the canvas's checkerboard, a gap that skips the overlays' bands, the ramp mask). Judgments, each Swift contract held unless named above: `cdf8674`: rotation, flips, scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom, sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on pixel layers, filter, Levels and Hue/Saturation previews in the layer's place: held; the CPU paths are the reference and are pinned by `LayerRendererTests` (placement, sampling rules, halvings, overhang), `DownsampleTests`, `BlendModeTests`, `CanvasDisplayTests` (checkerboard, shadow and border, crisp from 200%, the rich scene against the export), `FolderOpacityTests`, `FolderMaskClipTests`, `MaskPlacementTests`, `LevelsCanvasTests`, `HueSaturationCanvasTests`, `VignetteCanvasTests`. `4b04323`: a moved or duplicated selection's preview and commit: held and now proven on a masked, translucent layer (above); the rounded offset is pinned by a move of 5.6 landing at 6 (`EffectsCanvasTests::movingPixelsKeepTheEffects`). Swift's laziness (`PixelMove.applyOffset`, `holed`, `liftedTarget`) only spares the GPU canvas a rebuild per move; the twin rebuilds eagerly in `movePixels`, which realises the tiles before display and commit by construction, and a failure there cancels the move with `brushError` (`PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels`). `5708ed3`, `91cb0b1`: live paint, erase and mask paint, paint past the old bounds under the old mask (`TiledStrokeTests`, `BrushPreviewTests`, `MaskPaintAnywhereTests`), Smudge and Liquify previews through the mask, opacity and placement (`SmearCanvasTests`, `FolderOpacityTests`): held. `GradientEdit.Fill` and `applyFill` are the GPU's laziness; the twin fills in `refreshGradient`, before display and commit by construction, a failure cancelling with `brushError` (now tested), and the commit publishes what was filled. `MetalWarpTests.matchesTheCPU`: the twin's `WarpStroke` is the CPU reference (`SmudgeLiquifyTests`); nothing to port. `93de9e7`: a one-pixel white mask revealing its layer: held (QPainter's smooth draw clamps an image's edge, as Core Graphics draws it; now tested); the live paint grid's extent and edge sampling: held (`TiledStrokeTests`, `DownsampleTests`, `TiledLayerTests`). `clampedToExtent` is GPU-only. `54dd707`: every adjustment kind with alpha, masks and opacity, blurs at the drawn scale and direction, adjustment results equal to the CPU reference, deterministic seeds: held (the canvas runs the CPU filters themselves; now tested for every kind); Grain and Add Noise anchoring: ported (above). `f773f7b`: non-adjacent live-mask sources (now tested), distortion previews (`DistortCanvasTests`, `DistortMaskTests`, `EffectsCanvasTests`), text previews keeping and handing on effects (`TypedTextCanvasTests`), drafts above the active layer (`ShapeCanvasTests`, `TypedTextCanvasTests`; on a stack's base the draft is drawn by `drawOwnWithDraft` inside the stack's group, as Swift's: it joins the base, alpha included, under the clipped layers: `CanvasEditContractTests::aDraftOnAStacksBaseSitsInsideTheStack`), new text when the active layer cannot draw, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`EffectsCanvasTests`, its `movingPixelsKeepTheEffects` and `aGradientKeepsTheEffects` for Swift's `movingWithEffects` and `gradientWithEffects`, `MaskEffectsCanvasTests`, `BrushCanvasTests`, `MaskPaintAnywhereTests`): held. `distortShape`, the now-internal `imageCorners` and `editedText`/`handOffTextEffects` are GPU plumbing or a refactor of what the twin already draws. Known difference: Swift moves the pixel grid and a new text frame into an overlay view below its transform overlay, so the grid draws over the document's border hairline and the text frame under the other overlays; the twin draws the grid inside the document under the hairline and the text frame last. Both sit above the pixels and take no input; the order shows only on the hairline's edge pixels (a 13% white line), and no other overlay shows while a text frame is dragged. A port was tried and left: QPainter's antialiased hairlines make the order unprovable to a level. `4c5e1a6`, `d04f159`: texture retention, eviction and blits are GPU mechanisms; the CPU's sharp reductions are `DownsampleCache`'s, pinned by `DownsampleTests`. `b1b7490` only words a test's diagnostics. Tests tried and left: a distortion's preview against its commit differs on noise (the commit resamples the warp at its trimmed place), which Swift's tests never claim (they compare two previews); `DistortCanvasTests` pins the preview. Mutants: the region's mapping and use in `LiveMaskRenderer::adjust` and each term of the canvas's `adjustmentRegion`: 6, all caught (the width and height terms only once the partial repaint was compared). - Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors. -- Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. Like the brush, the gradient's refusal while the editor is busy is none, which clears a stale alert where Swift's first guard returns untouched. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal, and the order at each seam that two reasons can share: two layers before a folder, a folder before its hiding, hiding before a mask turned off, an adjustment before an empty selection, a folder's or adjustment's mask past the folder or adjustment; the mask-off and adjustment tests exclude each other, so their order is unreachable; a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the bar's Radius (13.16; Swift's tenth of the brush, 1.5 to 30, at 13.11) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first, softened through `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, in the pieces the brush reaches since 13.16, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheRadius`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeCoarser`: 16 M and four canvases; `aMaskSampleKeepsItsEdgeTone`), the tests' document clones through `documentClone` (`BrushFixtures.h`). Cancel and Apply, and a scaled layer's row (13.11, `67cc31e`): `TransformInspector` puts Cancel and Apply in a row of their own (`pendingTransform`, spaced 12) that shows only while the edit waits (`transformEdit()->persistent`: typed values, Ctrl+T, a distortion begun so); a handle drag's edit leaves it unseen. Swift's `.opacity(0)` keeps the buttons in the layout and their keys alive, so the row keeps its place under a `QGraphicsOpacityEffect` faded over 0.12 s with CSS's ease-out (`QEasingCurve` Bézier 0, 0, 0.58, 1, SwiftUI's `.easeOut`), passes clicks through (`WA_TransparentForMouseEvents`, Swift's `allowsHitTesting(false)`) and takes no focus while unseen; Return and Escape still reach the edit through the bar, and a remapped key through `NativeShortcut` (the buttons stay `isVisible`). Known difference: Qt has no per-widget accessibility hiding short of hiding the widget, so a screen reader still finds the unseen buttons (Swift's `accessibilityHidden`). `sizeLabel(const ImageLayer &)` (declared in `NativeLayerList.h`, defined in `NativeLayerList+Cell.cpp`, Swift's extension in its UI file) gives the row "W × H px", and once the width differs from the asset's by a twentieth of a percent or more, " · " and the percent to one place at most, ties to even (no whole pixel count lands on a tie, so that rule is unreachable), in the user's locale as Swift's `.formatted`; a blank layer has no pixels to measure. Tests: `TransformInspectorTests::cancelAndApplyShowOnlyWhileAnEditWaits` (a drag's edit unseen, its click falling through and Return applying; a waiting edit fading in, clicked; Cancel), `theFieldsTakeTheSpareRoom` (the row's place), `NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled` (Swift's, the threshold either side, a rounded tenth, a blank layer), the scaled row itself in `NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled` (`NativeLayerListTests` holds unscaled rows). Mutants (13.11): paint refusal 8 (2 survivors pinned by a folder's mask and an adjustment's mask, 2 equivalent as above), gradient 2, Smear 1, Inverse 1; `blurSample` 19 + 5 reruns (the unbuildable `1.0` replaced by `abs` for `sqrt`; the determinant's sign pinned by a layer turned 45°, the coarse sample's placement, size and σ × fit by an exact comparison of a 4200 × 3900 layer's sample), one survivor left untested: the `maxSurfacePixels` cap on the budget, reached only past a 50-million-pixel canvas with an 800 MB sample; `cloneSample` 4, `clonePixels` 3 (the scale pinned by `aGridCloneIsScaledOntoItsPlace`), `gridRect` 2, `beginBrush` 2 (a blank layer's Blur begins no stroke); `TransformInspector` 14, equivalent: the `pending == m_pending` guard (only restarts a fade toward the same end) and `setStartValue` (an animation without one starts from the property's value); `sizeLabel` 6 + 2 reruns, all caught. +- Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. Like the brush, the gradient's refusal while the editor is busy is none, which clears a stale alert where Swift's first guard returns untouched. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal, and the order at each seam that two reasons can share: two layers before a folder, a folder before its hiding, hiding before a mask turned off, an adjustment before an empty selection, a folder's or adjustment's mask past the folder or adjustment; the mask-off and adjustment tests exclude each other, so their order is unreachable; a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the bar's Radius (13.16; Swift's tenth of the brush, 1.5 to 30, at 13.11) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first, softened through `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, in the pieces the brush reaches since 13.16, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheRadius`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeCoarser`: 16 M and four canvases; `aMaskSampleKeepsItsEdgeTone`), the tests' document clones through `documentClone` (`BrushFixtures.h`). Cancel and Apply, and a scaled layer's row (13.11, `67cc31e`): `TransformInspector` puts Cancel and Apply in a row of their own (`pendingTransform`, spaced 12) that shows only while the edit waits (`transformEdit()->persistent`: Ctrl+T, Transform Selection, a distortion begun so; a value typed into such an edit joins it, while a value typed with no edit open applies when its field finishes, 13.13); a handle drag's edit leaves it unseen. Swift's `.opacity(0)` keeps the buttons in the layout and their keys alive, so the row keeps its place under a `QGraphicsOpacityEffect` faded over 0.12 s with CSS's ease-out (`QEasingCurve` Bézier 0, 0, 0.58, 1, SwiftUI's `.easeOut`), passes clicks through (`WA_TransparentForMouseEvents`, Swift's `allowsHitTesting(false)`) and takes no focus while unseen; Return and Escape still reach the edit through the bar, and a remapped key through `NativeShortcut` (the buttons stay `isVisible`). Known difference: Qt has no per-widget accessibility hiding short of hiding the widget, so a screen reader still finds the unseen buttons (Swift's `accessibilityHidden`). `sizeLabel(const ImageLayer &)` (declared in `NativeLayerList.h`, defined in `NativeLayerList+Cell.cpp`, Swift's extension in its UI file) gives the row "W × H px", and once the width differs from the asset's by a twentieth of a percent or more, " · " and the percent to one place at most, ties to even (a box 1062.5 wide over 1000 pixels is 106.2%), in the user's locale as Swift's `.formatted`; a blank layer has no pixels to measure. Tests: `TransformInspectorTests::cancelAndApplyShowOnlyWhileAnEditWaits` (a drag's edit unseen, its click falling through and Return applying; a waiting edit fading in, clicked; Cancel), `theFieldsTakeTheSpareRoom` (the row's place), `NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled` (Swift's, the threshold either side, a rounded tenth, ties either way, a blank layer, the scaled row in the list; `NativeLayerListTests` holds unscaled rows). Mutants (13.11): paint refusal 8 (2 survivors pinned by a folder's mask and an adjustment's mask, 2 equivalent as above), gradient 2, Smear 1, Inverse 1; `blurSample` 19 + 5 reruns (the unbuildable `1.0` replaced by `abs` for `sqrt`; the determinant's sign pinned by a layer turned 45°, the coarse sample's placement, size and σ × fit by an exact comparison of a 4200 × 3900 layer's sample), one survivor left untested: the `maxSurfacePixels` cap on the budget, reached only past a 50-million-pixel canvas with an 800 MB sample; `cloneSample` 4, `clonePixels` 3 (the scale pinned by `aGridCloneIsScaledOntoItsPlace`), `gridRect` 2, `beginBrush` 2 (a blank layer's Blur begins no stroke); `TransformInspector` 14, equivalent: the `pending == m_pending` guard (only restarts a fade toward the same end) and `setStartValue` (an animation without one starts from the property's value); `sizeLabel` 6 + 2 reruns, all caught. - Masks as Photoshop's (13.12, `b3419ab`): `addMask` with a selection now reveals it (white inside, black outside, the coverage as drawn) and, `revealing` false, hides it (the coverage inverted), one `Reveal Selection` or `Hide Selection` step; the row menu's Reveal All and Hide All call it as before, so Hide All with a selection hides the selection. `LayerMaskMenu` reads Alt from `QGuiApplication::keyboardModifiers()` when clicked, Swift's `NSApp.currentEvent`, so Alt-click adds the opposite, with Swift's help in Alt's words. The session keeps `viewsMaskAlone` (a getter and a setter that notifies on a change, the badge's write; `EditorSession.h`, defined in `EditorSession.cpp` beside Swift's `didSet`) and `maskAloneLayer()` (Swift's computed property: the view asked, the mask targeted, the active layer with a mask); `isMaskSelected` gains Swift's `didSet` as a private `setIsMaskSelected` (declared in `LayerMask+Session.h`, since `EditorSession.h` sits at 499 lines; defined in `EditorSession.cpp`), which every former write of `m_isMaskSelected` now calls, so targeting the pixels, another layer, a deleted mask or a load ends the view. `maskAloneLayer` and `toggleMaskAlone` live in `LayerMask+Alone.cpp`, since `LayerMask.cpp` already held 495 lines. `toggleMaskAlone` targets the mask through `selectLayerTarget` and shows it unless it already showed; where that refuses and leaves another target, so does the toggle, but with the project busy and the same mask already targeted the refusal is silent and the view still flips, as Swift's does. The canvas's `DisplayState` gains `maskAlone` (the mask asset's `ImageIdentity`, whether or not enabled, since the composite may skip it), and `drawLayers` hands off to `drawMaskAlone` (`EditorCanvas+Masks.cpp`, beside `liveFolderMaskClip`, which moved there to keep `EditorCanvas.cpp` under 500 lines): the canvas filled with the mask's edge tone, then a brush stroke or gradient laid into that mask through `LayerRenderer::drawBrushPreview` (the painted mask's own raster when it has one), else the mask at its displayed placement or the layer's transform, gray where it shows; the crisp zoom path draws through `drawLayers` too. The list's mask thumbnail: Alt's release without a drag calls `toggleMaskAlone`, only inside the thumbnail after a move under `startDragDistance` (4.7's rule; Swift's `dragMaskCopy` toggles on any left-up before a 3-point drag) (Shift no longer toggles there, as Swift's), the tooltip names Alt-click, and the targeted ring is white while that mask shows alone (`setTargeted`'s `alone`; Swift's `maskAloneLayer?.id == layerID` is left out as dead: the ring shows only on the active row, whose layer is the one shown alone). `LayerListFocusTests` and `LayerDragGestureTests` expected Alt-Shift to toggle the mask; they now expect Swift's view. `MaskAloneBadge` (in `UI/LayerMaskMenu`, Swift's file) is 26 points tall, 75% black with a 14% white rim, radius 13: the mask glyph at 11, "Layer Mask" 12 px semibold, the layer's name 12 px at 60% white, elided within 220 points, and an `xmark` button (a new `LayerIcon`, Swift's bold `xmark` as two round-capped strokes) with Swift's tooltip in Alt's words and accessible name, which ends the view. `ContentView` keeps one badge, shown while `maskAloneLayer` holds, in a layout nested in the canvas slot (it starts hidden through `synchronize`, which the constructor runs) (bottom, centred, 14 points up), so the room beside and below it stays the canvas's; Qt child widgets take their own clicks, so Swift's AppKit hosting has no twin. Swift's GPU fix (a mask stroke's grid starting at the mask's background) is the twin's behaviour already: `allocateTile` fills a mask tile with `maskBackground`. Tests: `MaskAloneTests` (Swift's three, plus another layer ending the view and the setter's signal, a stroke and a gradient shown as committed, a disabled and a placed mask, the crisp zoom, the badge's words, place, elision and clicks, the list's Alt-click, Shift ignored and the white ring; the view across undo and redo and when the shown mask is targeted again; a pending pixel gradient on the layer and another layer's pending mask gradient kept out of the view while their commits are posted), `SelectionEditTests` (Swift's renamed two and the scaled layer's hiding mask), `LayersPanelTests::theMaskButtonUsesTheSelection` (plain and Alt clicks with and without a selection, the tooltips, the row's Hide All). Mutants: the session 10 + 1 rerun (the active-layer term of `toggleMaskAlone` pinned by a busy refusal with another layer's mask targeted), equivalent: `maskAloneLayer`'s `m_isMaskSelected` and `layer->mask` terms (every path that clears either clears the view through `setIsMaskSelected`; Swift's guards, kept); `drawMaskAlone` 6 + 3 reruns (the placement pinned off the old box), the stroke's `isMask` and layer terms are live, not equivalent: `resolveGradient` only posts the commit, so a pending pixel gradient, or another layer's mask gradient, keeps its raster while the view shows (now tested); the canvas branch and state 2; the list 4 + 2 (a release off the thumbnail pinned); the button and badge 5 + 3 (margins and spacing pinned); `ContentView` 5 (the constructor's `hide` was dead and is gone). All caught otherwise. - Quit mid-edit and Scanlines (13.17, Swift 1.4.4). Quit (`1aad384`): `ProjectWorkspace::settlePendingEdits` twins Swift's: on the tab on screen a pending gradient is applied (`commitGradient`), then a pixel move (`finishPixelMove`), then a filter, Hue/Saturation, Levels, Color Range, the Expand/Contract/Feather panel (`setSelectionAmountOperation(nullopt)`) and the colour picker are cancelled (their Cancel, so nothing un-OK'd lands). It takes a completion callback (Swift's `async`), run from the event loop once both commits have landed, and only while the workspace lives (`QPointer`), the tab kept alive in the callbacks as every workspace flow keeps it. `confirmQuit` refuses at once while the workspace manages (Swift's guard moved there from `applicationShouldTerminate`; the twin's window close reaches `confirmQuit` directly, so this guard is what stops a second close mid-quit), applies open text, settles, and only then asks `canSwitch`; a refusal there (a busy project, a commit still running) is a warning on `omaphoto.app` where Swift beeps. Swift's `gradientEdit != nil` and `pixelMove != nil` tests before awaiting are left out: both twins return (posted) when there is nothing to commit. Swift's `finishAdjustmentEditing(commit: false)` is left out as dead: `cancelFilter`, `cancelHueSaturation` and `cancelLevels`, each called before it, call it first, and an adjustment's editor is always one of those three (its request and original are set and cleared with them; while the editor still renders, `finishAdjustmentEditing` does nothing in Swift either, and `canSwitch` refuses the quit). Tests: `QuitPendingEditsTests` (the tab on screen settled and another left as it is; a quit refused while an adjustment's editor is asked for and not yet rendered, then cancelled once open; Swift's `quittingAppliesAPendingGradient` and `quittingCancelsAnOpenDialog`; a pixel move applied as one `Move Pixels` step; Color Range putting the selection back, the selection-amount panel, the colour picker not taking its colour; an adjustment's editor putting the settings back; a workspace gone mid-settle calling nothing; closing the window applying the gradient before the Save question; a busy project refused with the warning, nothing asked or applied; a second quit while the first asks refused unsettled). Scanlines (`9c99853`, `cd2f998`): `DitherStyle::scanlines` ("Scanlines (CRT)") joins the fourth group; `usesPixelSize` (false for ASCII and Scanlines: no chunky pixels, no Pixel Size or Pixel Shape rows) and `drawsMarks` without Scanlines, as Swift's. `DitherSettings` gains `lineSpacing` (2–32, rounded, 4), `glow` (0–100, 35), `dots` (0–100, 0) and `wobble` (0–64 px, unrounded, 0), normalized as Swift's; the kernel's cell is the line spacing for Scanlines. `kernels/DitherPixels.c/.h` are cd2f998's, with one adaptation: Swift's kernel threads through GCD (`dispatch_apply`) and Apple blocks, which GCC cannot compile. `in_bands` keeps Swift's split (one band under 64 items, else 32) but takes a C function and a context; the three block bodies (the tone pass, the scanlines, the glow) are moved word for word into static functions with context structs (`tone_job`, `scan_job`, `glow_job`), and `__block int failed` is a C11 `atomic_int`. The bands run through `dither_bands`, a C hook defined in `Document/Dither.cpp` on `PoolMap::blocking` (QtConcurrent, so a filter worker lends its pool place). The arithmetic is unchanged; this is a forced difference (GCC has no blocks or GCD), agreed with the coordinator. `bandsDrawAsOneBandDoes` holds 32 bands against one: the top rows of a 300-row picture equal a 62-row picture's, which runs as one band in both passes. `glowing` twins Swift's: sigma `lineSpacing × 3 + 3`, shrunk by `max(1, floor(sigma / 4))`, blurred at `sigma / shrink`, scaled back and added through `dither_glow` at `glow / 100 × 2.5`. Core Image clamps to the extent before it shrinks, so the twin pads the image by the blur's reach (three sigmas, a whole number of shrink steps) with its edges repeated, the far sides filled to a whole number of steps so the shrink and the enlargement are both exactly the shrink (`theGlowScalesBackExactlyOffTheStep`), shrinks that with `QImage::scaled`'s smooth filter (an area average, where Core Image samples bilinearly), blurs with the edge repeated (`PixelAdjust::gaussianBlur`), scales back bilinearly and crops the middle; it stays within three levels of light of Swift's first commit's full-size blur (`theGlowIsTheBlurredLightAtItsAmount`, both axes). The sheet adds Line Spacing (px), Glow (%), Dots (%) and Wobble (px) with Swift's help under Scanlines, and Pixel Size and Pixel Shape follow `usesPixelSize`. Tests: `ScanlinesTests` (Swift's four `DitherTests`; the beam's rows for white, 0.35 gray and a striped average; the style's flags and that pixel size, shape and cell size change nothing; the four settings' clamps, the rows' units and ranges read from the sliders, rounding and defaults; Two Colors' screen and Original's black; clear pixels; every band of tall images, 32 bands and a short last one; the glow against a full-size Gaussian at 20% and 100%, its fade, and alpha; the sheet's rows, help, slider ends and fields), `DitherTests` and `DitherSheetTests` (the eleventh style in names, groups and the menu). Equivalent mutants: a pad of one sigma for three (the padded edge repeats the image's own edge, so the light differs by under a level), the shrunk blur without edge repetition (the pad keeps its reach off the edge), the kernel's `start < end` (a band past the end runs no line) and its band count (it only spreads the work). `settlePendingEdits` calls `done` directly: it already runs from the session's posted callback, after both commits. Untested path: the kernel's scan row that cannot be allocated (`failed`, a few kilobytes per band). Swift's beep on a refused quit is a warning on `omaphoto.app`. - Camera Raw curves as Photoshop's (13.18, Swift 1.4.5: `60d9117`): `kernels/AdjustPixels.c/.h` are 60d9117's: the tone curve runs on red, green and blue alike (`toneLut`, Swift's rename of `lumaLut`), and Refine Saturation means what Swift's now means: 0 the curve per channel, below 0 easing toward the colour scaled to the curved brightness (−100 brightness alone), above 0 pushing the curved colour from its luminance by 1 + the amount. `CameraRawCurveSettings::parametric` twins Swift's: unchanged with all four amounts at 0; otherwise 33 anchors, each `bend` of `bend` (shadows and highlights past the outer dividers, then darks and lights either side of the middle one, gamma curves of strength 1.66), run through the same curve as Image › Curves (`point`), so the regions meet without a corner. `bend` is a private static, as Swift's; `lumaTable` is `toneTable`. The twin's normalization of the dividers (5–90, +2 apart, 98 at most) still clamps what a drag writes. The graph's gestures (`UI/CameraRawColorControls+Curve.cpp`, beside the controls' file) are Swift's single `DragGesture`: a left press picks once (`parametricDrag`: within 18 points of the bottom the nearest divider, the first of equals; elsewhere the region whose tone range holds the press, a tone on a divider joining the region above; `pointDrag`: the point nearest by distance within 0.055, chosen; else a new point there, if fewer than 16, inside x 0.01–0.99 and over a hundredth from every point's x, sorted in, stored and chosen), then `continueDrag` runs at the press and on every left-button move: a point's output follows the pointer, an inner point's x stays a hundredth inside its neighbours, an end keeps its x; a divider follows, shadow below dark − 2 and dark below light − 2; a region's amount is its start minus the drag's height over the graph's times 200, within ±100, rounded. Left out as dead, since normalizing does each (`CameraRawCurveSettings::normalized`: amounts, dividers 5–90, +2 apart, 98 at most, and the repair's 0–1 outputs): Swift's 2–98 clamp of a divider, dark's `shadow + 2` floor and light's `dark + 2` floor, and the 0–1 clamps of a dragged or new point's output (a press inside the graph is inside 0–1 anyway). The new point's `x > 0.01` and `x < 0.99` are dead too: the ends sit at 0 and 1, and a point within a hundredth of any x is refused. `bend`'s `lower > 0` and `upper < 1` are dead (no tone lies below 0 or above 1). The release lets go. Qt can lose a release, so every press picks afresh. A double click is Qt's second press: it picks and drags as a press (Swift's drag sees it too), then on the point page removes the nearest inner point by x within 0.04 and drops the choice, and lets go of the drag (Swift removes at the tap's end, which ends the drag). Swift re-picks on each move while a press picked nothing; with the same start and points it picks nothing again, so the twin leaves that out. The chosen point (Swift's `selected`) draws in `Highlight`, the accent, and the readout shows only while it exists, following the drag; another channel drops the choice and the drag (Swift's `onChange`). The graph's help and Refine Saturation's are Swift's new words. Swift's `CameraRawMixerControls` gains the same `Drag` and `selected` state but never reads it: dead, left out. A fix beyond Swift: Swift holds an inner point exactly a hundredth from its neighbours, where the curve's repair (`x > kept.x + 0.01`) drops it (or drops the next point), and the drag's index then names another point; the twin holds it a billionth further inside, so the repair keeps it and the drag stays on it (`aPointIsPickedByDistanceAndKept`, `endPointsStayAndARealDoubleClickRemoves`, both failing before the fix; `CameraRawColorTests` pins the repair's strict `>` at exactly a hundredth, which the fix rests on). A fix beyond Swift: a preset's points drop the choice (TASKS asks for it; Swift's `applyPreset` keeps the index, which then lights whatever point holds it); Custom changes nothing and keeps it. Tests: `CameraRawCurveTests` (Swift's `parametricCurveIsSmooth`, `parametricCurveMatchesPhotoshop`, `curveDeepensColorLikePhotoshop`; each anchor against the bend written apart, and the smooth curve between; each region bending only its range, dividers at the ends bending nothing; the tone table through the point curve; Refine Saturation at 0 per channel through the table, −100 brightness with the hue kept, −50 halfway, +50 half again from luminance), `CameraRawCurveGraphTests` (a double click's 0.04 reach by x, and one in the divider strip moving the divider; regions lifted and lowered from their start, clamped and rounded, picked once by tone; dividers in the strip, nearest and first of equals, kept ordered and within the settings' limits; points by distance, kept through the drag, a new point added and dragged by the same press, ends moving their outputs alone; refusals near an edge, a point's x, at sixteen; the chosen point lit and read out as it moves, ends and new points included; a channel or preset dropping the choice, Custom keeping it, a held drag let go; double clicks removing an inner point and the choice, adding then removing on empty ground, ends staying; other buttons, a lost release, a release letting go; the help), and the existing `CameraRawColorTests`, `CameraRawCurveMixerTests`, `CameraRawGroupDrawTests`, `CameraRawGroupRowsTests`, `CameraRawGroupWordsTests` brought to the new rules and words. Equivalent mutant: `parametric`'s shortcut when all four amounts are 0 (33 anchors on the diagonal give the diagonal; it only spares the work). The kernel's refine branches, Swift's own code, were mutated too and all caught. Swift's `selected` survives a preset; TASKS asks for a preset to clear it, and a stale index lights a point the user never chose, so the twin clears it (a fix beyond Swift). An inner point dragged onto a neighbour vanished in Swift; fixed beyond Swift at the coordinator's request. diff --git a/TASKS.md b/TASKS.md index 3e427da..ab9ca4f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -475,7 +475,11 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - [x] 13.19c `63e42ac` (13.8's `upright` pin) built, run, its mutant caught. - 13.19c work: `SharpCanvasTests` passes; `transform.radians() == 0` set to `true` in `LayerRenderer::draw` and in `TiledLayerRenderer`, each caught by the turned rows (dropping `upright` inside `interpolation` leaves the parameter unused, which `-Werror` refuses). - 13.19c review: round 1 PASS. - - [ ] 13.19d `2190a1b` (13.10–13.12 tests) built and run; its open items closed: the 12.5b, 13.11 and 8.6 bullets, `sizeLabel`'s tie claim, 13.12's pending pixel gradient. + - [x] 13.19d `2190a1b` (13.10–13.12 tests) built and run; its open items closed: the 12.5b, 13.11 and 8.6 bullets, `sizeLabel`'s tie claim, 13.12's pending pixel gradient. + - 13.19d work: `MaskAloneTests`, `PaintRefusalTests` and `SelectionCanvasTests` pass. Mutants: the three reachable `paintRefusal` seams swapped, caught (mask off and adjustment exclude each other, an equivalent swap); the Shift axis test without either `abs`, without both, and `>`, caught; `drawMaskAlone` without its `isMask` or its layer term, caught; `setIsMaskSelected` keeping the view, caught. The 12.5b, 8.6 and 13.11 lines match the code. `sizeLabel`'s tie claim was false (1062.5 over 1000 pixels is exactly 106.25%): ties to even are now tested both ways (ICU's default rounding, which Swift's `.formatted` uses), `round` and `floor(x + 0.5)` caught. + - 13.19d review: round 1 FAIL: the 8.6 bullet's whole-sample blur timing, the 13.11 bullet's typed values as persistent, `->` on optionals in `MaskAloneTests`. Fixed; the same `->` in `CanvasEditContractTests`, `ImageFileDropTests`, `RawDevelopTests` and `SessionRecord.h` went too. Their run found `SelectionSignalTests` stale since 13.11 (Inverse of everything deselects, so the `deselect` after it is silent): the test now says so. + - 13.19d round 2: FAIL: other tests still read optionals through `->` and `*`, through aliases a text scan missed. Found by the compiler: in a throwaway container `std::optional`'s `operator->` and `operator*` were marked deprecated and every target built (`-O0`); 271 sites in 30 test files and headers, each now `.value()`, and the rerun finds none in `tests/` with every unit compiled. The full suite at `-j8` then failed `FilterSessionTests` and `SelectionFeatherTests` alone too: both still called `addMask(false)` to reveal a selection, the meaning 13.12 flipped (`0b0d36a`); now `addMask(true)`. 329 of 329 at `-j8`. + - 13.19d round 3: PASS. - [ ] 13.19e `70318de` (13.13 and 13.16 fixes and tests) built and run. - [ ] 13.19f The fixes since `81894f7` already tested (`382c9c2` for 13.9a and 13.9b, `6735aad` for 13.7, 13.14 and 13.15, `5c8e18c` for 13.17 and 13.18) reviewed: review 3's scope. - [ ] 13.19g The full suite under `ctest -j16`; the shared state behind the five parallel failures found and isolated. diff --git a/tests/BlurNoiseAdjustmentTests.cpp b/tests/BlurNoiseAdjustmentTests.cpp index 2be6384..a68a00e 100644 --- a/tests/BlurNoiseAdjustmentTests.cpp +++ b/tests/BlurNoiseAdjustmentTests.cpp @@ -191,14 +191,14 @@ void BlurNoiseAdjustmentTests::theStoreTakesTheKindsFromVersion9() QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid)); // Invalid settings refuse it at any version. snapshot.manifest.version = 9; - snapshot.manifest.layers[1].adjustment->setGaussianRadius(300); + snapshot.manifest.layers[1].adjustment.value().setGaussianRadius(300); QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid)); } // The rule reads the kind: older kinds may carry keys. ProjectSnapshot older = twoLayers(); older.manifest.version = 7; older.manifest.layers[1].adjustment = LayerAdjustment{AdjustmentKind::levels}; - older.manifest.layers[1].adjustment->setGaussianRadius(5); + older.manifest.layers[1].adjustment.value().setGaussianRadius(5); QCOMPARE(stored(older), std::nullopt); } @@ -211,7 +211,7 @@ void BlurNoiseAdjustmentTests::eachNoiseLayerRollsItsOwnSeed() session.setAdjustmentEditingID(std::nullopt); session.addAdjustment(AdjustmentKind::addNoise); const LayerAdjustment second = session.activeLayer().value().adjustment.value(); - QVERIFY(first.noiseSeed && second.noiseSeed && *first.noiseSeed != *second.noiseSeed); + QVERIFY(first.noiseSeed && second.noiseSeed && first.noiseSeed.value() != second.noiseSeed.value()); // Only noise rolls one; the rest keep their defaults. session.setAdjustmentEditingID(std::nullopt); session.addAdjustment(AdjustmentKind::gaussianBlur); diff --git a/tests/BrushFixtures.h b/tests/BrushFixtures.h index 2478e9e..cafd61a 100644 --- a/tests/BrushFixtures.h +++ b/tests/BrushFixtures.h @@ -28,11 +28,11 @@ inline QImage preview(const BrushStroke &stroke, QSizeF canvas) QPainter painter(&context); const ImageLayer &layer = stroke.layer; const std::optional &asset = layer.asset; - const QImage image = asset && !asset->raster ? asset->image() : QImage(); + const QImage image = asset && !asset.value().raster ? asset.value().image() : QImage(); LayerRenderer::drawBrushPreview(image, stroke.paintTransform, stroke.paintTransform.center(), painter, - {.opacity = layer.opacity, .blendMode = layer.blendMode, .mask = layer.mask ? layer.mask->enabledImage().value_or(QImage()) : QImage()}, + {.opacity = layer.opacity, .blendMode = layer.blendMode, .mask = layer.mask ? layer.mask.value().enabledImage().value_or(QImage()) : QImage()}, {.patches = stroke.patches(), .pixelWidth = stroke.width, .pixelHeight = stroke.height, .paintingMask = stroke.isMask, - .sourceRect = stroke.sourceRect, .raster = asset ? asset->raster : nullptr}); + .sourceRect = stroke.sourceRect, .raster = asset ? asset.value().raster : nullptr}); painter.end(); return context; } diff --git a/tests/CanvasEditContractTests.cpp b/tests/CanvasEditContractTests.cpp index 391f640..845764d 100644 --- a/tests/CanvasEditContractTests.cpp +++ b/tests/CanvasEditContractTests.cpp @@ -151,7 +151,7 @@ void CanvasEditContractTests::aGradientThatCannotFillCancels() scene.session.selectTool(NavigationTool::gradient); scene.session.beginGradient(QPointF(10, 30)); QVERIFY(scene.session.gradientEdit()); - scene.session.gradientEdit()->raster->pixelLimit = 100; + scene.session.gradientEdit().value().raster->pixelLimit = 100; scene.session.moveGradient(std::nullopt, QPointF(70, 30)); QVERIFY(!scene.session.gradientEdit() && scene.session.brushError().has_value()); QCOMPARE(scene.session.history.undoCount(), count); diff --git a/tests/CanvasFixtures.h b/tests/CanvasFixtures.h index 3001aeb..9b86894 100644 --- a/tests/CanvasFixtures.h +++ b/tests/CanvasFixtures.h @@ -19,7 +19,7 @@ struct Shown { explicit Shown(std::optional document = QSize(100, 100), QSize size = QSize(400, 300)) : canvas(new CanvasView(session, &window)) { if (document) - session.createDocument(document->width(), document->height()); + session.createDocument(document.value().width(), document.value().height()); window.resize(size); canvas->setGeometry(QRect(QPoint(0, 0), size)); window.show(); diff --git a/tests/EffectsCanvasTests.cpp b/tests/EffectsCanvasTests.cpp index 409e749..62d06cf 100644 --- a/tests/EffectsCanvasTests.cpp +++ b/tests/EffectsCanvasTests.cpp @@ -253,8 +253,8 @@ void EffectsCanvasTests::aSurfaceIsMadeAnewForOtherSettings() QVERIFY(session.finishBrushImmediately()); // A canvas alone follows nothing: the old surface stays. LayerEffects cyan = outline(2); - cyan.stroke->red = 0; - cyan.stroke->blue = 1; + cyan.stroke.value().red = 0; + cyan.stroke.value().blue = 1; session.setEffects(cyan); session.beginBrush(QPointF(45, 32)); session.continueBrush(QPointF(55, 32)); diff --git a/tests/EffectsPanelTests.cpp b/tests/EffectsPanelTests.cpp index 82e24a9..ac1b8f3 100644 --- a/tests/EffectsPanelTests.cpp +++ b/tests/EffectsPanelTests.cpp @@ -146,56 +146,56 @@ void EffectsPanelTests::theSheetEditsItsEffect() auto &slider = find(sheet, "sizeSlider"); QCOMPARE(size.text(), QString("4")); type(size, "12"); - QCOMPARE(editor.effects().stroke->size, 12.0); + QCOMPARE(editor.effects().stroke.value().size, 12.0); // The slider spans 0 to 20; typing reaches 500. QCOMPARE(slider.value(), 600); // No finite number changes nothing; the field goes back. type(size, "abc"); - QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12"); + QVERIFY(editor.effects().stroke.value().size == 12 && size.text() == "12"); type(size, "inf"); - QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12"); + QVERIFY(editor.effects().stroke.value().size == 12 && size.text() == "12"); // Up steps one, Shift ten; past the range, clamped. QTest::keyClick(&size, Qt::Key_Up); - QCOMPARE(editor.effects().stroke->size, 13.0); + QCOMPARE(editor.effects().stroke.value().size, 13.0); QTest::keyClick(&size, Qt::Key_Up, Qt::ShiftModifier); - QCOMPARE(editor.effects().stroke->size, 23.0); + QCOMPARE(editor.effects().stroke.value().size, 23.0); // Past its end the thumb rests there, writing nothing back. QCOMPARE(slider.value(), 1000); // A step replaces pending typing, even where it clamps. typing(size, "12"); QTest::keyClick(&size, Qt::Key_Up); - QVERIFY(editor.effects().stroke->size == 24 && size.text() == "24" && !size.isModified()); + QVERIFY(editor.effects().stroke.value().size == 24 && size.text() == "24" && !size.isModified()); size.clearFocus(); - QCOMPARE(editor.effects().stroke->size, 24.0); + QCOMPARE(editor.effects().stroke.value().size, 24.0); type(size, "900"); - QVERIFY(editor.effects().stroke->size == 500 && size.text() == "500"); + QVERIFY(editor.effects().stroke.value().size == 500 && size.text() == "500"); typing(size, "12"); QTest::keyClick(&size, Qt::Key_Up); - QVERIFY(editor.effects().stroke->size == 500 && size.text() == "500" && !size.isModified()); + QVERIFY(editor.effects().stroke.value().size == 500 && size.text() == "500" && !size.isModified()); size.clearFocus(); - QCOMPARE(editor.effects().stroke->size, 500.0); + QCOMPARE(editor.effects().stroke.value().size, 500.0); // The slider sets any value; the field rounds, ties even. slider.setValue(525); - QVERIFY(editor.effects().stroke->size == 10.5 && size.text() == "10"); + QVERIFY(editor.effects().stroke.value().size == 10.5 && size.text() == "10"); // Leaving an untouched field keeps the exact value. size.setFocus(); size.clearFocus(); - QCOMPARE(editor.effects().stroke->size, 10.5); + QCOMPARE(editor.effects().stroke.value().size, 10.5); // Typing waits while other changes arrive. auto &opacity = find(sheet, "opacityField"); QCOMPARE(opacity.text(), QString("100")); typing(size, "3"); find(sheet, "opacitySlider").setValue(500); - QVERIFY(editor.effects().stroke->opacity == 0.5 && opacity.text() == "50" && size.text() == "3"); + QVERIFY(editor.effects().stroke.value().opacity == 0.5 && opacity.text() == "50" && size.text() == "3"); size.clearFocus(); - QCOMPARE(editor.effects().stroke->size, 3.0); + QCOMPARE(editor.effects().stroke.value().size, 3.0); // Inside or Outside, as Swift's segments, following an undo. const QList position = find(sheet, "strokePosition").findChildren(); QVERIFY(position.size() == 2 && position[0]->text() == "Outside" && position[1]->text() == "Inside" && position[0]->isChecked()); position[1]->click(); - QVERIFY(editor.effects().stroke->inside && position[1]->isChecked()); + QVERIFY(editor.effects().stroke.value().inside && position[1]->isChecked()); editor.session.undo(); - QVERIFY(!editor.effects().stroke->inside && position[0]->isChecked()); + QVERIFY(!editor.effects().stroke.value().inside && position[0]->isChecked()); position[1]->click(); // The swatch opens the picker, whose colour shows at once. auto &swatch = find(sheet, "effectColor"); @@ -207,7 +207,7 @@ void EffectsPanelTests::theSheetEditsItsEffect() Painted counter(paints); swatch.installEventFilter(&counter); editor.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); - QCOMPARE(editor.effects().stroke->color(), (PaletteColor{1, 0, 0})); + QCOMPARE(editor.effects().stroke.value().color(), (PaletteColor{1, 0, 0})); QTRY_VERIFY(paints > 0); // A black rim, a white ring inside, then the colour. const QImage shot = swatch.grab().toImage(); @@ -218,7 +218,7 @@ void EffectsPanelTests::theSheetEditsItsEffect() // Return commits, then goes on to OK, keeping everything. typing(size, "7"); QTest::keyClick(&size, Qt::Key_Return); - QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke->size == 7 && editor.effects().stroke->inside); + QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke.value().size == 7 && editor.effects().stroke.value().inside); QTRY_VERIFY(!editor.sheet()); } @@ -236,11 +236,11 @@ void EffectsPanelTests::aWheelOnTheSliderReplacesTheTyping() Qt::NoScrollPhase, false); QApplication::sendEvent(&slider, &wheel); const double wheeled = 20.0 * (200 - QApplication::wheelScrollLines()) / 1000; - QCOMPARE(editor.effects().stroke->size, wheeled); + QCOMPARE(editor.effects().stroke.value().size, wheeled); QVERIFY(size.hasFocus() && size.text() == QString::number(std::lround(wheeled)) && !size.isModified()); // Return then keeps the slider's value, going on to OK. QTest::keyClick(&size, Qt::Key_Return); - QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke->size == wheeled); + QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke.value().size == wheeled); } void EffectsPanelTests::theSheetKeepsSwiftsMeasures() @@ -355,14 +355,14 @@ void EffectsPanelTests::cancelAndEscapePutTheEffectBack() auto &cancel = find(*editor.sheet(), "effectsCancel"); cancel.setFocus(); QTest::keyClick(&cancel, Qt::Key_Return); - QVERIFY(editor.effects().shadow->angle == -45 && !editor.session.effectsEditing()); + QVERIFY(editor.effects().shadow.value().angle == -45 && !editor.session.effectsEditing()); // An existing shadow: Cancel puts it back as it was. editor.session.selectEffect(LayerEffectKind::shadow, editor.layer, true); QCOMPARE(find(*editor.sheet(), "distanceField").text(), QString("20")); find(*editor.sheet(), "distanceSlider").setValue(400); - QCOMPARE(editor.effects().shadow->distance, 40.0); + QCOMPARE(editor.effects().shadow.value().distance, 40.0); find(*editor.sheet(), "effectsCancel").click(); - QVERIFY(editor.effects().shadow->distance == 20 && !editor.session.effectsEditing()); + QVERIFY(editor.effects().shadow.value().distance == 20 && !editor.session.effectsEditing()); // A new overlay's panel: Escape takes the overlay away again. editor.session.addEffect(LayerEffectKind::colorOverlay); QVERIFY(editor.effects().colorOverlay); diff --git a/tests/EffectsPreviewCacheTests.cpp b/tests/EffectsPreviewCacheTests.cpp index 502364c..0ab05a3 100644 --- a/tests/EffectsPreviewCacheTests.cpp +++ b/tests/EffectsPreviewCacheTests.cpp @@ -92,7 +92,7 @@ void EffectsPreviewCacheTests::aPreviewLandsAfterItsDelayAsTheRendererDrawsIt() QCOMPARE(*landings.count, 1); // No effects, or none shown: nothing, and the entry goes. ImageLayer hidden = layer; - hidden.effects->stroke->enabled = false; + hidden.effects.value().stroke.value().enabled = false; QVERIFY(!ask(cache, hidden, landings) && !cache.rendered(layer.id)); QTest::qWait(150); QVERIFY(!cache.rendered(layer.id)); @@ -110,7 +110,7 @@ void EffectsPreviewCacheTests::movingTheLayerKeepsThePreviewUnlessAMaskIsPlaced( EffectsPreviewCache cache; ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); layer.mask = LayerMask(LayerMask::assetFrom(gray(20, 10, 255))); - const QImage mask = layer.mask->enabledImage().value(); + const QImage mask = layer.mask.value().enabledImage().value(); const Landings landings; ask(cache, layer, landings, mask); const qint64 first = landed(cache, layer, landings, 1).image.cacheKey(); @@ -149,7 +149,7 @@ void EffectsPreviewCacheTests::settingsKeepTheLastPreviewButNewPixelsDropIt() const EffectsPreviewCache::Result second = landed(cache, layer, landings, 2); QCOMPARE(second.inset, 7.0); // Another kind shown or hidden: the last preview stands. - layer.effects->colorOverlay = ColorOverlayEffect(); + layer.effects.value().colorOverlay = ColorOverlayEffect(); QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), second.image.cacheKey()); landed(cache, layer, landings, 3); // New pixels drop it. @@ -158,10 +158,10 @@ void EffectsPreviewCacheTests::settingsKeepTheLastPreviewButNewPixelsDropIt() 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))); - QCOMPARE(ask(cache, layer, landings, layer.mask->enabledImage()).value().image.cacheKey(), red); + QCOMPARE(ask(cache, layer, landings, layer.mask.value().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; + layer.mask.value().isEnabled = false; QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), red); QTest::qWait(200); QCOMPARE(*landings.count, 5); @@ -325,7 +325,7 @@ void EffectsPreviewCacheTests::preparingDropsLayersWithoutEffectsAndSharesTheBud ask(cache, small, landings); landed(cache, small, landings, 5); ImageLayer off = small; - off.effects->stroke->enabled = false; + off.effects.value().stroke.value().enabled = false; cache.seed(wide.id, filled(4, 4, Qt::green), placedAt(QPointF(0, 0), QSizeF(4, 4))); cache.prepare({off}); QVERIFY(!cache.rendered(small.id) && !cache.rendered(wide.id)); @@ -374,7 +374,7 @@ void EffectsPreviewCacheTests::aMaskHidesPixelsInTheReducedPreview() } QVERIFY(low == 125 && high == 129); layer.mask = LayerMask(LayerMask::assetFrom(stripes.scaled(6000, 100))); - ask(cache, layer, landings, layer.mask->enabledImage()); + ask(cache, layer, landings, layer.mask.value().enabledImage()); QVERIFY(landed(cache, layer, landings, 4).image == row); } @@ -397,13 +397,13 @@ void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength() // The renderer on the reduced pixels, every length reduced alike. const double factor = 1528.0 / (6000 + 2 * LayerEffectsRenderer::margin(effects)); const QImage reduced = image.scaled(1518, 25, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - effects.stroke->size *= factor; - effects.shadow->distance *= factor; - effects.shadow->blur *= factor; - effects.innerShadow->distance *= factor; - effects.innerShadow->blur *= factor; - effects.outerGlow->size *= factor; - effects.innerGlow->size *= factor; + effects.stroke.value().size *= factor; + effects.shadow.value().distance *= factor; + effects.shadow.value().blur *= factor; + effects.innerShadow.value().distance *= factor; + effects.innerShadow.value().blur *= factor; + effects.outerGlow.value().size *= factor; + effects.innerGlow.value().size *= factor; QVERIFY(result.image == LayerEffectsRenderer::render(reduced, std::nullopt, effects).image); } diff --git a/tests/FilterSessionTests.cpp b/tests/FilterSessionTests.cpp index 4c54659..04438a6 100644 --- a/tests/FilterSessionTests.cpp +++ b/tests/FilterSessionTests.cpp @@ -260,7 +260,7 @@ void FilterSessionTests::aCoveringMaskStaysPutUnderABlur() session->insert(ImportedImage(image, image, QStringLiteral("Quarter"))); const QUuid id = session->activeLayerID().value(); session->applySelection(rectPath(QRectF(0, 0, 20, 20)), SelectionMode::replace, "Select"); - session->addMask(false); + session->addMask(true); session->selectLayerTarget(id, false); session->beginFilter(FilterKind::gaussianBlur); QVERIFY(committed(*session)); diff --git a/tests/FinishingFilterTests.cpp b/tests/FinishingFilterTests.cpp index 0219e01..0987938 100644 --- a/tests/FinishingFilterTests.cpp +++ b/tests/FinishingFilterTests.cpp @@ -276,11 +276,11 @@ void FinishingFilterTests::aPreviewForAnOlderGridShowsOnThatGrid() int older = 0; QObject::connect(&session, &EditorSession::changed, &session, [&] { const std::optional &edit = session.filterEdit(); - if (!edit || !edit->preparedPreview) + if (!edit || !edit.value().preparedPreview) return; - if (QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size) + if (QSizeF(edit.value().preparedPreview.value().size()) != edit.value().preparedTransform.value().size) mismatched = true; - older += edit->preparedPreview->size() != edit->previewSource.size(); + older += edit.value().preparedPreview.value().size() != edit.value().previewSource.size(); }); session.updateFilter(FilterSettings{.bloomRadius = 5}, true); const LayerTransform grid = session.filterEdit().value().grownTransform.value(); @@ -289,7 +289,7 @@ void FinishingFilterTests::aPreviewForAnOlderGridShowsOnThatGrid() QVERIFY(session.filterEdit().value().grownTransform.value().size.width() > grid.size.width()); QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing); QVERIFY(!mismatched && older > 0); - QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size()); + QCOMPARE(session.filterEdit().value().preparedPreview.value().size(), session.filterEdit().value().previewSource.size()); QCOMPARE(session.filterEdit().value().preparedTransform, session.filterEdit().value().grownTransform); // Preview off: nothing waits; the stale render ends it. session.updateFilter(FilterSettings{.bloomRadius = 5}, true); @@ -313,7 +313,7 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditShowsOnItsGrid() bool mismatched = false, armed = true; QObject::connect(&session, &EditorSession::changed, &session, [&] { const std::optional &edit = session.filterEdit(); - if (edit && edit->preparedPreview && QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size) + if (edit && edit.value().preparedPreview && QSizeF(edit.value().preparedPreview.value().size()) != edit.value().preparedTransform.value().size) mismatched = true; // The waiting job starts: Preview comes back next turn. if (std::exchange(armed, false)) @@ -322,7 +322,7 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditShowsOnItsGrid() QTRY_VERIFY(!armed); QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing); QVERIFY(!mismatched); - QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size()); + QCOMPARE(session.filterEdit().value().preparedPreview.value().size(), session.filterEdit().value().previewSource.size()); } void FinishingFilterTests::theVignettePickerPreviewsAndPutsBack() diff --git a/tests/HistoryTests.cpp b/tests/HistoryTests.cpp index 0daac68..b78d72b 100644 --- a/tests/HistoryTests.cpp +++ b/tests/HistoryTests.cpp @@ -48,8 +48,8 @@ void HistoryTests::undoAndRedoReturnTheRecordedSnapshots() DocumentHistory history; const std::optional before = documentNamed("Before"); std::optional after = before; - after->layers[0].name = "After"; - const QUuid selection = before->layers[0].id; + after.value().layers[0].name = "After"; + const QUuid selection = before.value().layers[0].id; history.begin("Rename Layer", before, std::nullopt); history.end(after, selection); QCOMPARE(history.undoCount(), 1); @@ -96,9 +96,9 @@ void HistoryTests::nestedTransactionsRecordOneEntry() history.redo(); history.begin("Layer Setup", start, std::nullopt); QVERIFY(!history.canUndo()); - edited->layers[0].name = "B"; + edited.value().layers[0].name = "B"; history.begin("Inner", edited, std::nullopt); - edited->layers[0].name = "C"; + edited.value().layers[0].name = "C"; history.end(edited, std::nullopt); QCOMPARE(history.undoCount(), 1); QVERIFY(!history.canUndo()); @@ -114,12 +114,12 @@ void HistoryTests::noOpEditsPreserveRedo() DocumentHistory history; const std::optional start = documentNamed("A"); std::optional renamed = start; - renamed->layers[0].name = "B"; + renamed.value().layers[0].name = "B"; history.begin("Rename Layer", start, std::nullopt); history.end(renamed, std::nullopt); history.undo(); history.begin("Rename Layer", start, std::nullopt); - history.end(start, start->layers[0].id); + history.end(start, start.value().layers[0].id); QCOMPARE(history.undoCount(), 0); QVERIFY(history.canRedo()); QVERIFY(!history.isModified()); @@ -203,16 +203,16 @@ void HistoryTests::historyBoundsEntriesAndUniqueRetainedPixels() { DocumentHistory history(2, 0); std::optional doc = CanvasDocument(64, 32); - doc->layers.push_back(ImageLayer(asset(64, 32, "fixture"), QPointF(0, 0))); + doc.value().layers.push_back(ImageLayer(asset(64, 32, "fixture"), QPointF(0, 0))); for (const QString name : {"A", "B", "C"}) { - history.begin("Rename", doc, doc->layers[0].id); - doc->layers[0].name = name; - history.end(doc, doc->layers[0].id); + history.begin("Rename", doc, doc.value().layers[0].id); + doc.value().layers[0].name = name; + history.end(doc, doc.value().layers[0].id); } QCOMPARE(history.undoCount(), 2); QCOMPARE(history.retainedBytes(doc), 0); - history.begin("Delete", doc, doc->layers[0].id); - doc->layers.clear(); + history.begin("Delete", doc, doc.value().layers[0].id); + doc.value().layers.clear(); history.end(doc, std::nullopt); QCOMPARE(history.undoCount(), 0); QCOMPARE(history.retainedBytes(doc), 0); @@ -222,15 +222,15 @@ void HistoryTests::retainedBytesCountEachHistoryOnlyImageOnce() { DocumentHistory history; std::optional doc = CanvasDocument(64, 32); - doc->layers.push_back(ImageLayer(asset(10, 10, "old"), QPointF(0, 0))); + doc.value().layers.push_back(ImageLayer(asset(10, 10, "old"), QPointF(0, 0))); for (const QString name : {"A", "B"}) { history.begin("Rename", doc, std::nullopt); - doc->layers[0].name = name; + doc.value().layers[0].name = name; history.end(doc, std::nullopt); } QCOMPARE(history.retainedBytes(doc), 0); history.begin("Replace", doc, std::nullopt); - doc->layers[0].asset = asset(20, 10, "new"); + doc.value().layers[0].asset = asset(20, 10, "new"); history.end(doc, std::nullopt); const qint64 oldBytes = 10 * 10 * 4 + 8 * 8 * 4; QCOMPARE(history.retainedBytes(doc), oldBytes); @@ -242,13 +242,13 @@ void HistoryTests::byteLimitDropsTheOldestPastEntryFirst() const qint64 small = 10 * 10 * 4 + 8 * 8 * 4; DocumentHistory history(100, small); std::optional doc = CanvasDocument(64, 32); - doc->layers.push_back(ImageLayer(asset(10, 10, "x"), QPointF(0, 0))); + doc.value().layers.push_back(ImageLayer(asset(10, 10, "x"), QPointF(0, 0))); history.begin("First", doc, std::nullopt); - doc->layers[0].asset = asset(10, 10, "y"); + doc.value().layers[0].asset = asset(10, 10, "y"); history.end(doc, std::nullopt); QCOMPARE(history.undoCount(), 1); history.begin("Second", doc, std::nullopt); - doc->layers[0].asset = asset(10, 10, "z"); + doc.value().layers[0].asset = asset(10, 10, "z"); history.end(doc, std::nullopt); QCOMPARE(history.undoCount(), 1); QCOMPARE(history.undoName(), QString("Second")); @@ -260,10 +260,10 @@ void HistoryTests::undoTrimsRedoWhenItsPixelsExceedTheLimit() const qint64 small = 10 * 10 * 4 + 8 * 8 * 4; DocumentHistory history(100, small); std::optional doc = CanvasDocument(64, 32); - doc->layers.push_back(ImageLayer(asset(10, 10, "small"), QPointF(0, 0))); + doc.value().layers.push_back(ImageLayer(asset(10, 10, "small"), QPointF(0, 0))); const std::optional before = doc; history.begin("Enlarge", doc, std::nullopt); - doc->layers[0].asset = asset(40, 40, "large"); + doc.value().layers[0].asset = asset(40, 40, "large"); history.end(doc, std::nullopt); QCOMPARE(history.undoCount(), 1); const DocumentHistory::Snapshot undone = history.undo().value(); @@ -277,12 +277,12 @@ void HistoryTests::undoTrimsThePastBeforeTheRedoStack() const qint64 small = 10 * 10 * 4 + 8 * 8 * 4; DocumentHistory history(100, 2 * small); std::optional doc = CanvasDocument(64, 32); - doc->layers.push_back(ImageLayer(asset(10, 10, "a"), QPointF(0, 0))); + doc.value().layers.push_back(ImageLayer(asset(10, 10, "a"), QPointF(0, 0))); history.begin("First", doc, std::nullopt); - doc->layers[0].asset = asset(10, 10, "b"); + doc.value().layers[0].asset = asset(10, 10, "b"); history.end(doc, std::nullopt); history.begin("Second", doc, std::nullopt); - doc->layers[0].asset = asset(15, 15, "c"); + doc.value().layers[0].asset = asset(15, 15, "c"); history.end(doc, std::nullopt); QCOMPARE(history.undoCount(), 2); history.undo(); diff --git a/tests/ImageAdjustmentTests.cpp b/tests/ImageAdjustmentTests.cpp index ecf0434..de7da3a 100644 --- a/tests/ImageAdjustmentTests.cpp +++ b/tests/ImageAdjustmentTests.cpp @@ -244,7 +244,7 @@ void ImageAdjustmentTests::newAdjustmentLayersStartFromThePaletteRenderAndEditIn session.setAdjustmentEditingID(std::nullopt); session.addAdjustment(AdjustmentKind::grain); const std::vector &layers = session.document().value().layers; - QCOMPARE(std::count_if(layers.begin(), layers.end(), [](const ImageLayer &layer) { return layer.adjustment && layer.adjustment->kind == AdjustmentKind::grain; }), 2); + QCOMPARE(std::count_if(layers.begin(), layers.end(), [](const ImageLayer &layer) { return layer.adjustment && layer.adjustment.value().kind == AdjustmentKind::grain; }), 2); QVERIFY2(session.activeLayer().value().adjustment.value().grain().seed != first, "each Grain layer gets its own pattern"); QVERIFY(std::find(allAdjustmentKinds.begin(), allAdjustmentKinds.end(), AdjustmentKind::exposure) != allAdjustmentKinds.end() && filterKind(AdjustmentKind::exposure) == FilterKind::exposure); diff --git a/tests/ImageFileDropTests.cpp b/tests/ImageFileDropTests.cpp index 6ae0183..4c7bfa9 100644 --- a/tests/ImageFileDropTests.cpp +++ b/tests/ImageFileDropTests.cpp @@ -135,7 +135,7 @@ void ImageFileDropTests::aPictureIsCopiedToAFileFirst() sky.image = PSDFixture::colorImage(4, 2, 0, 0, 1); sky.bounds = QRectF(0, 0, 4, 2); QMimeData photoshop; - photoshop.setData("image/vnd.adobe.photoshop", PSDFixture::data(PSDDocument{4, 2, 72, {sky}}, *sky.image)); + photoshop.setData("image/vnd.adobe.photoshop", PSDFixture::data(PSDDocument{4, 2, 72, {sky}}, sky.image.value())); photoshop.setData("application/x-other", "ignored"); QCOMPARE(ImageFileDrop::providers(photoshop).front()->formats(), QStringList{"image/vnd.adobe.photoshop"}); photoshop.setData("image/tiff", "tiff first"); @@ -165,7 +165,7 @@ void ImageFileDropTests::aPictureIsCopiedToAFileFirst() done = false; ImageFileDrop::importProviders(raw, developed, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(developed.rawDevelop()); - developed.finishRawDevelop(developed.rawDevelop()->settings); + developed.finishRawDevelop(developed.rawDevelop().value().settings); QTRY_VERIFY(done); QCOMPARE(names(developed), QStringList{"Dropped"}); QCOMPARE(takeCopy(before), QString("Dropped.dng")); diff --git a/tests/ImageTrimTests.cpp b/tests/ImageTrimTests.cpp index a8849de..2ce3fa8 100644 --- a/tests/ImageTrimTests.cpp +++ b/tests/ImageTrimTests.cpp @@ -222,7 +222,7 @@ void ImageTrimTests::theSheetSaysSwiftsWordsAndReturnsItsChoice() ok.click(); QVERIFY(answer.value() == edges(TrimBasedOn::bottomRightPixelColor, false, true, false, false)); sheet.findChild("trimCancel")->click(); - QVERIFY(answer && !answer->has_value()); + QVERIFY(answer && !answer.value().has_value()); } void ImageTrimTests::theControllerTrimsThroughTheSheetAsOneStep() diff --git a/tests/InnerGlowTests.cpp b/tests/InnerGlowTests.cpp index 58835c6..9061572 100644 --- a/tests/InnerGlowTests.cpp +++ b/tests/InnerGlowTests.cpp @@ -77,7 +77,7 @@ void InnerGlowTests::innerGlowCodableRoundTrip() QCOMPARE(ManifestJson::effects(encoded), original); // Absent `enabled` stays absent, read as shown. LayerEffects shown = original; - shown.innerGlow->enabled = std::nullopt; + shown.innerGlow.value().enabled = std::nullopt; QVERIFY(!ManifestJson::encoded(shown).value("innerGlow").toObject().contains("enabled")); QCOMPARE(ManifestJson::effects(ManifestJson::encoded(shown)), shown); // A key of the wrong type, or missing, refuses it. @@ -165,10 +165,10 @@ void InnerGlowTests::itSitsOverTheOverlayAndUnderTheInnerShadow() const LayerEffectsRenderer::Rendered shadowed = LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects); QCOMPARE(at(shadowed, 0, 20), at(covered, 0, 20)); // A sideways shadow covers the glow on one edge. - effects.innerShadow->distance = 4; - const int x = effects.innerShadow->offset().width() > 0 ? 0 : 39; + effects.innerShadow.value().distance = 4; + const int x = effects.innerShadow.value().offset().width() > 0 ? 0 : 39; const QColor edge = at(LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects), x, 20); - QVERIFY(std::abs(effects.innerShadow->offset().width()) > 3.9); + QVERIFY(std::abs(effects.innerShadow.value().offset().width()) > 3.9); QVERIFY(edge.redF() > 0.9 && edge.greenF() < 0.1); } @@ -195,11 +195,11 @@ void InnerGlowTests::theSessionAddsCopiesAndCancelsIt() session.addEffect(LayerEffectKind::innerGlow); QCOMPARE(session.history.undoName(), QString("Add Inner Glow")); QVERIFY(session.activeEffects().innerGlow == InnerGlowEffect()); - session.changeEffects([](LayerEffects &effects) { effects.innerGlow->size = 44; }); + session.changeEffects([](LayerEffects &effects) { effects.innerGlow.value().size = 44; }); session.finishEffectsEditing(false); QVERIFY(!session.activeEffects().innerGlow); session.addEffect(LayerEffectKind::innerGlow); - session.changeEffects([](LayerEffects &effects) { effects.innerGlow->size = 44; }); + session.changeEffects([](LayerEffects &effects) { effects.innerGlow.value().size = 44; }); session.finishEffectsEditing(true); // Adding it again keeps the glow it has. session.addEffect(LayerEffectKind::innerGlow); diff --git a/tests/LayerAdjustmentTests.cpp b/tests/LayerAdjustmentTests.cpp index aac835e..0fc6858 100644 --- a/tests/LayerAdjustmentTests.cpp +++ b/tests/LayerAdjustmentTests.cpp @@ -132,14 +132,14 @@ void LayerAdjustmentTests::validityFollowsSwiftsBounds() QCOMPARE((LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = hue, .saturation = saturation, .lightness = lightness}.isValid()), valid); // Each range's values too, and every band's handles finite. LayerAdjustment ranged = full(); - ranged.hsvSettings->adjustments[ColorRange::blues] = {0, 0, 101}; + ranged.hsvSettings.value().adjustments[ColorRange::blues] = {0, 0, 101}; QVERIFY(!ranged.isValid()); ranged = full(); - ranged.hsvSettings->bands[ColorRange::cyans].falloffEnd = std::numeric_limits::infinity(); + ranged.hsvSettings.value().bands[ColorRange::cyans].falloffEnd = std::numeric_limits::infinity(); QVERIFY(!ranged.isValid()); // The legacy fields count only when no range settings exist. ranged = full(); - ranged.hsvSettings->adjustments[ColorRange::reds] = {-360, -100, -100}; + ranged.hsvSettings.value().adjustments[ColorRange::reds] = {-360, -100, -100}; QVERIFY(ranged.isValid()); // Levels as normalized, then every kind's own rules. LayerAdjustment levels{AdjustmentKind::hsv}; @@ -308,7 +308,7 @@ void LayerAdjustmentTests::theStoreKeepsAdjustmentsToVersion7WithoutPixels() blank.isGroup = true; QCOMPARE(stored(snapshot), ProjectError::Kind::invalid); blank.isGroup = std::nullopt; - blank.adjustment->hue = 400; + blank.adjustment.value().hue = 400; QCOMPARE(stored(snapshot), ProjectError::Kind::invalid); } @@ -440,7 +440,7 @@ void LayerAdjustmentTests::adjustmentsTravelWithTheirLayers() session->duplicateActiveLayer(); QVERIFY(session->activeLayer().value().adjustment == full()); ImageLayer changed = session->activeLayer().value(); - changed.adjustment->hue = 11; + changed.adjustment.value().hue = 11; QVERIFY(!(changed == session->activeLayer().value())); } diff --git a/tests/LayerEffectRowTests.cpp b/tests/LayerEffectRowTests.cpp index 5335b83..0a86e0a 100644 --- a/tests/LayerEffectRowTests.cpp +++ b/tests/LayerEffectRowTests.cpp @@ -237,15 +237,15 @@ void LayerEffectRowTests::theEyeShowsOrHidesItsEffect() LayerEffectRow &stroke = *listed.effects(0)[0]; // One step each way; the choice stays as it was. listed.click(listed.eye(stroke), QPoint(10, 11)); - QVERIFY(!layerWith(listed.session, top).effects->stroke->isEnabled() && listed.session.history.undoName() == "Hide Stroke"); + QVERIFY(!layerWith(listed.session, top).effects.value().stroke.value().isEnabled() && listed.session.history.undoName() == "Hide Stroke"); QVERIFY(!listed.chosen() && listed.eye(stroke).accessibleName() == "Show Stroke"); listed.click(listed.eye(stroke), QPoint(10, 11)); - QVERIFY(layerWith(listed.session, top).effects->stroke->isEnabled() && listed.session.history.undoName() == "Show Stroke"); + QVERIFY(layerWith(listed.session, top).effects.value().stroke.value().isEnabled() && listed.session.history.undoName() == "Show Stroke"); // It rests while layers cannot be edited. listed.session.setIsProjectBusy(true); QVERIFY(!listed.eye(stroke).isEnabled()); listed.click(listed.eye(stroke), QPoint(10, 11)); - QVERIFY(layerWith(listed.session, top).effects->stroke->isEnabled() && !listed.chosen()); + QVERIFY(layerWith(listed.session, top).effects.value().stroke.value().isEnabled() && !listed.chosen()); listed.session.setIsProjectBusy(false); QVERIFY(listed.eye(stroke).isEnabled()); } @@ -270,14 +270,14 @@ void LayerEffectRowTests::pressesFollowSwiftsOrder() listed.session.dropEffectSelection(); listed.press(listed.eye(shadow), QPoint(10, 5), Qt::AltModifier); listed.release(listed.eye(shadow), QPoint(10, 5), Qt::AltModifier); - QVERIFY(layerWith(listed.session, top).effects->shadow->isEnabled() && listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); + QVERIFY(layerWith(listed.session, top).effects.value().shadow.value().isEnabled() && listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); // An Alt double click on the eye hides nothing either. listed.session.dropEffectSelection(); QTest::mouseDClick(listed.list.windowHandle(), Qt::LeftButton, Qt::AltModifier, listed.at(listed.eye(shadow), QPoint(12, 5))); - QVERIFY(layerWith(listed.session, top).effects->shadow->isEnabled() && listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); + QVERIFY(layerWith(listed.session, top).effects.value().shadow.value().isEnabled() && listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); // On the strip, the eye's Alt press clips too. listed.click(listed.eye(shadow), QPoint(10, 20), Qt::AltModifier); - QVERIFY(!layerWith(listed.session, top).maskSourceID && !listed.chosen() && layerWith(listed.session, top).effects->shadow->isEnabled()); + QVERIFY(!layerWith(listed.session, top).maskSourceID && !listed.chosen() && layerWith(listed.session, top).effects.value().shadow.value().isEnabled()); // Its own row's press lets go, keeping the layer. listed.session.selectEffect(LayerEffectKind::stroke, top); listed.click(listed.cell(0), QPoint(listed.cell(0).width() - 20, 20)); @@ -295,7 +295,7 @@ void LayerEffectRowTests::pressesFollowSwiftsOrder() QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); listed.release(shadow, QPoint(150, 5), Qt::AltModifier | Qt::ControlModifier); listed.click(listed.eye(shadow), QPoint(10, 5), Qt::AltModifier | Qt::ControlModifier); - QVERIFY(!layerWith(listed.session, top).effects->shadow->isEnabled()); + QVERIFY(!layerWith(listed.session, top).effects.value().shadow.value().isEnabled()); // A plain press on another effect chooses it at once. listed.press(stroke, QPoint(150, 12)); QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::stroke})); @@ -412,7 +412,7 @@ void LayerEffectRowTests::effectDropsLandOnTheRowUnderThePointer() const std::unique_ptr probe(effectDrag(listed.list, payload)); QCOMPARE(listed.list.dropTarget(*probe, Qt::CopyAction, onMiddle).value(), (LayerDropTarget{.row = 1, .onRow = true, .atBottom = false, .copying = true})); QVERIFY(listed.drop(effectDrag(listed.list, payload), onMiddle)); - QVERIFY(listed.lastAction == Qt::CopyAction && layerWith(listed.session, middle).effects->stroke->size == 12); + QVERIFY(listed.lastAction == Qt::CopyAction && layerWith(listed.session, middle).effects.value().stroke.value().size == 12); QVERIFY(listed.session.history.undoName() == "Copy Stroke" && listed.chosen() == (LayerEffectSelection{middle, LayerEffectKind::stroke})); // Refused: its own row, a missing kind, strangers, bad words. QVERIFY(!listed.drop(effectDrag(listed.list, payload), onTop)); @@ -448,7 +448,7 @@ void LayerEffectRowTests::aRowRebuiltUnderItsOwnPressLivesOn() QTest::mouseDClick(shadow.data(), Qt::LeftButton, Qt::NoModifier, QPoint(150, 12)); QVERIFY(shadow && !shadow->isVisible()); QVERIFY(listed.session.effectsEditing() == (LayerEffectSelection{top, LayerEffectKind::shadow}) && listed.list.hasFocus()); - QVERIFY(!layerWith(listed.session, top).effects->stroke && listed.effects(0).size() == 1); + QVERIFY(!layerWith(listed.session, top).effects.value().stroke && listed.effects(0).size() == 1); QTRY_VERIFY(!shadow); } diff --git a/tests/LayerEffectsKernelTests.cpp b/tests/LayerEffectsKernelTests.cpp index 1ea8992..033c889 100644 --- a/tests/LayerEffectsKernelTests.cpp +++ b/tests/LayerEffectsKernelTests.cpp @@ -86,32 +86,32 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects) for (int x = 0; x < pixels.width(); ++x) shader.shape.push_back(float(pixels.constScanLine(y)[x * 4 + 3]) / 255.f); } - const auto on = [](const auto &effect) { return effect && effect->isEnabled() && effect->opacity > 0; }; - const bool stroke = on(effects.stroke) && effects.stroke->size > 0; + const auto on = [](const auto &effect) { return effect && effect.value().isEnabled() && effect.value().opacity > 0; }; + const bool stroke = on(effects.stroke) && effects.stroke.value().size > 0; std::vector ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size()), glow(shader.shape.size()), innerGlow(shader.shape.size()); if (stroke) { - const int reach = std::max(1, int(std::lround(effects.stroke->size))); - const std::vector moved = shader.spread(shader.spread(shader.shape, reach, effects.stroke->inside, true), reach, effects.stroke->inside, false); + const int reach = std::max(1, int(std::lround(effects.stroke.value().size))); + const std::vector moved = shader.spread(shader.spread(shader.shape, reach, effects.stroke.value().inside, true), reach, effects.stroke.value().inside, false); for (size_t index = 0; index < ring.size(); ++index) - ring[index] = std::clamp(effects.stroke->inside ? shader.shape[index] - moved[index] : moved[index] - shader.shape[index], 0.f, 1.f); + ring[index] = std::clamp(effects.stroke.value().inside ? shader.shape[index] - moved[index] : moved[index] - shader.shape[index], 0.f, 1.f); } if (on(effects.shadow)) - cast = shader.softened(shader.shift(shader.shape, float(effects.shadow->offset().width()), float(effects.shadow->offset().height())), - float(effects.shadow->blur / 2)); - const bool glowing = on(effects.outerGlow) && effects.outerGlow->size > 0; + cast = shader.softened(shader.shift(shader.shape, float(effects.shadow.value().offset().width()), float(effects.shadow.value().offset().height())), + float(effects.shadow.value().blur / 2)); + const bool glowing = on(effects.outerGlow) && effects.outerGlow.value().size > 0; if (glowing) - glow = shader.softened(shader.shape, float(effects.outerGlow->size / 2)); - const bool glowingInside = on(effects.innerGlow) && effects.innerGlow->size > 0; + glow = shader.softened(shader.shape, float(effects.outerGlow.value().size / 2)); + const bool glowingInside = on(effects.innerGlow) && effects.innerGlow.value().size > 0; if (glowingInside) { - const std::vector softened = shader.softened(shader.shape, float(effects.innerGlow->size / 2)); + const std::vector softened = shader.softened(shader.shape, float(effects.innerGlow.value().size / 2)); for (size_t index = 0; index < innerGlow.size(); ++index) innerGlow[index] = std::clamp(shader.shape[index] * (1.f - softened[index]), 0.f, 1.f); } if (on(effects.innerShadow)) { - const std::vector moved = shader.softened(shader.shift(shader.shape, float(effects.innerShadow->offset().width()), - float(effects.innerShadow->offset().height())), - float(effects.innerShadow->blur / 2)); + const std::vector moved = shader.softened(shader.shift(shader.shape, float(effects.innerShadow.value().offset().width()), + float(effects.innerShadow.value().offset().height())), + float(effects.innerShadow.value().blur / 2)); for (size_t index = 0; index < inner.size(); ++index) inner[index] = std::clamp(shader.shape[index] * (1.f - moved[index]), 0.f, 1.f); } @@ -127,25 +127,25 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects) alpha = coverage + alpha * (1.f - coverage); }; if (on(effects.shadow)) - blend(effects.shadow->color(), std::clamp(cast[index] * float(effects.shadow->opacity), 0.f, 1.f)); + blend(effects.shadow.value().color(), std::clamp(cast[index] * float(effects.shadow.value().opacity), 0.f, 1.f)); if (glowing) - blend(effects.outerGlow->color(), std::clamp(glow[index] * (1.f - shader.shape[index]) * float(effects.outerGlow->opacity), 0.f, 1.f)); - const float strokeCoverage = stroke ? std::clamp(ring[index] * float(effects.stroke->opacity), 0.f, 1.f) : 0.f; - if (stroke && !effects.stroke->inside) - blend(effects.stroke->color(), strokeCoverage); + blend(effects.outerGlow.value().color(), std::clamp(glow[index] * (1.f - shader.shape[index]) * float(effects.outerGlow.value().opacity), 0.f, 1.f)); + const float strokeCoverage = stroke ? std::clamp(ring[index] * float(effects.stroke.value().opacity), 0.f, 1.f) : 0.f; + if (stroke && !effects.stroke.value().inside) + blend(effects.stroke.value().color(), strokeCoverage); const uchar *source = pixels.constScanLine(y) + x * 4; const float sourceAlpha = float(source[3]) / 255.f; for (int channel = 0; channel < 3; ++channel) colour[channel] = float(source[channel]) / 255.f + colour[channel] * (1.f - sourceAlpha); alpha = sourceAlpha + alpha * (1.f - sourceAlpha); if (on(effects.colorOverlay)) - blend(effects.colorOverlay->color(), std::clamp(shader.shape[index] * float(effects.colorOverlay->opacity), 0.f, 1.f)); + blend(effects.colorOverlay.value().color(), std::clamp(shader.shape[index] * float(effects.colorOverlay.value().opacity), 0.f, 1.f)); if (glowingInside) - blend(effects.innerGlow->color(), std::clamp(innerGlow[index] * float(effects.innerGlow->opacity), 0.f, 1.f)); + blend(effects.innerGlow.value().color(), std::clamp(innerGlow[index] * float(effects.innerGlow.value().opacity), 0.f, 1.f)); if (on(effects.innerShadow)) - blend(effects.innerShadow->color(), std::clamp(inner[index] * float(effects.innerShadow->opacity), 0.f, 1.f)); - if (stroke && effects.stroke->inside) - blend(effects.stroke->color(), strokeCoverage); + blend(effects.innerShadow.value().color(), std::clamp(inner[index] * float(effects.innerShadow.value().opacity), 0.f, 1.f)); + if (stroke && effects.stroke.value().inside) + blend(effects.stroke.value().color(), strokeCoverage); uchar *out = result.scanLine(y) + x * 4; for (int channel = 0; channel < 3; ++channel) out[channel] = uchar(std::clamp(colour[channel], 0.f, 1.f) * 255.f + 0.5f); @@ -219,7 +219,7 @@ void LayerEffectsKernelTests::everyEffectMatchesTheShader_data() all.innerShadow = inner.innerShadow; all.outerGlow = glow.outerGlow; all.innerGlow = innerGlow.innerGlow; - all.shadow->blur = 0; + all.shadow.value().blur = 0; wide.stroke = StrokeEffect{.size = 9, .red = 1, .opacity = 1}; wide.shadow = ShadowEffect{.angle = 200, .distance = 0.5, .blur = 0.01, .opacity = 1}; QTest::newRow("outside stroke") << outside; diff --git a/tests/LayerEffectsRendererTests.cpp b/tests/LayerEffectsRendererTests.cpp index a286666..3a941a2 100644 --- a/tests/LayerEffectsRendererTests.cpp +++ b/tests/LayerEffectsRendererTests.cpp @@ -60,11 +60,11 @@ void LayerEffectsRendererTests::theMarginHoldsWhatReachesOut() LayerEffects cast = stroke(10); cast.shadow = ShadowEffect{.distance = 5, .blur = 2.5}; QCOMPARE(LayerEffectsRenderer::margin(cast), 15.0); - cast.shadow->blur = 4; + cast.shadow.value().blur = 4; QCOMPARE(LayerEffectsRenderer::margin(cast), 19.0); // Switched off, it takes no room. - cast.shadow->enabled = false; - cast.stroke->enabled = false; + cast.shadow.value().enabled = false; + cast.stroke.value().enabled = false; QCOMPARE(LayerEffectsRenderer::margin(cast), 2.0); } @@ -113,11 +113,11 @@ void LayerEffectsRendererTests::renderingPadsMasksThenRunsTheKernel() QVERIFY(pixel(soft, 2, 10)[3] == 0 && pixel(soft, 65, 10)[3] == 255); // Switched-off effects are none; invalid or vast ones refuse. LayerEffects hidden = stroke(3); - hidden.stroke->enabled = false; + hidden.stroke.value().enabled = false; QCOMPARE(LayerEffectsRenderer::render(image, std::nullopt, hidden).image.size(), QSize(24, 14)); // A switched-off effect is not judged. LayerEffects wild = stroke(600); - wild.stroke->enabled = false; + wild.stroke.value().enabled = false; QVERIFY(LayerEffectsRenderer::render(image, std::nullopt, wild).image == LayerEffectsRenderer::render(image, std::nullopt, hidden).image); try { LayerEffectsRenderer::render(image, std::nullopt, stroke(501)); @@ -142,7 +142,7 @@ void LayerEffectsRendererTests::theCacheKeepsEightWithinItsBudget() QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, std::nullopt)); QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, LayerEffects())); LayerEffects hidden = stroke(2); - hidden.stroke->enabled = false; + hidden.stroke.value().enabled = false; QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, hidden)); QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, stroke(600))); // The same pixels, mask and effects: the same result. diff --git a/tests/LayerEffectsSessionTests.cpp b/tests/LayerEffectsSessionTests.cpp index 73e6835..0d1f296 100644 --- a/tests/LayerEffectsSessionTests.cpp +++ b/tests/LayerEffectsSessionTests.cpp @@ -83,7 +83,7 @@ void LayerEffectsSessionTests::addingAnEffectTakesItsDefaultsAndOpensItsPanel() present.shadow = ShadowEffect{.distance = 33}; session.setEffects(present); session.addEffect(LayerEffectKind::shadow); - QVERIFY(two.effects(two.top).value().shadow->distance == 33); + QVERIFY(two.effects(two.top).value().shadow.value().distance == 33); session.finishEffectsEditing(true); const int kept = session.history.undoCount(); session.addEffect(LayerEffectKind::colorOverlay); @@ -128,15 +128,15 @@ void LayerEffectsSessionTests::cancelPutsBackThisPanelsEffectAlone() session.setEffects(start); session.selectEffect(LayerEffectKind::stroke, two.top, true); QVERIFY(session.effectsEditingOriginal() == start); - session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 9; }); + session.changeEffects([](LayerEffects &effects) { effects.stroke.value().size = 9; }); QCOMPARE(session.history.undoName(), QString("Edit Stroke")); // Another effect changed meanwhile keeps its change. LayerEffects other = two.effects(two.top).value(); - other.shadow->distance = 30; + other.shadow.value().distance = 30; session.setEffects(other); session.finishEffectsEditing(false); const LayerEffects after = two.effects(two.top).value(); - QVERIFY(after.stroke->size == 3 && after.shadow->distance == 30); + QVERIFY(after.stroke.value().size == 3 && after.shadow.value().distance == 30); QCOMPARE(session.history.undoName(), QString("Cancel Stroke")); QVERIFY(!session.effectsEditing() && !session.effectsEditingOriginal()); // The chosen effect stays chosen while its layer shows it. @@ -147,26 +147,26 @@ void LayerEffectsSessionTests::cancelPutsBackThisPanelsEffectAlone() QVERIFY(!two.effects(two.top).value().innerShadow); QVERIFY(!session.effectSelection()); session.selectEffect(LayerEffectKind::shadow, two.top, true); - session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 2; }); + session.changeEffects([](LayerEffects &effects) { effects.shadow.value().blur = 2; }); const int steps = session.history.undoCount(); session.finishEffectsEditing(true); - QVERIFY(session.history.undoCount() == steps && two.effects(two.top).value().shadow->blur == 2); + QVERIFY(session.history.undoCount() == steps && two.effects(two.top).value().shadow.value().blur == 2); // Nothing open, nothing to finish. session.finishEffectsEditing(false); QCOMPARE(session.history.undoCount(), steps); // Opening another panel cancels the open one's changes. session.selectEffect(LayerEffectKind::stroke, two.top, true); - session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 40; }); + session.changeEffects([](LayerEffects &effects) { effects.stroke.value().size = 40; }); session.selectEffect(LayerEffectKind::shadow, two.top, true); - QVERIFY(two.effects(two.top).value().stroke->size == 3 && session.history.undoName() == QString("Cancel Stroke")); + QVERIFY(two.effects(two.top).value().stroke.value().size == 3 && session.history.undoName() == QString("Cancel Stroke")); session.finishEffectsEditing(true); // An overlay's Cancel puts back the overlay alone. session.addEffect(LayerEffectKind::colorOverlay); session.finishEffectsEditing(true); session.selectEffect(LayerEffectKind::colorOverlay, two.top, true); - session.changeEffects([](LayerEffects &effects) { effects.colorOverlay->opacity = 0.25; }); + session.changeEffects([](LayerEffects &effects) { effects.colorOverlay.value().opacity = 0.25; }); session.finishEffectsEditing(false); - QCOMPARE(two.effects(two.top).value().colorOverlay->opacity, 1.0); + QCOMPARE(two.effects(two.top).value().colorOverlay.value().opacity, 1.0); // Its layer's effects all gone, Cancel has nothing to restore. session.selectEffect(LayerEffectKind::shadow, two.top, true); session.setEffects(LayerEffects(), two.top); @@ -195,7 +195,7 @@ void LayerEffectsSessionTests::setEffectsIsOneStepOnAValidChange() // The same again, an invalid one, a missing layer: nothing. session.setEffects(effects, two.bottom); LayerEffects wild = effects; - wild.stroke->size = 501; + wild.stroke.value().size = 501; session.setEffects(wild, two.bottom); session.setEffects(effects, QUuid::createUuid()); QCOMPARE(session.history.undoCount(), steps + 1); @@ -220,18 +220,18 @@ void LayerEffectsSessionTests::panelEditsStayWithTheirLayer() EditorSession &session = two.session; session.addEffect(LayerEffectKind::stroke); session.selectLayer(two.bottom); - session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 12; }); - QVERIFY(two.effects(two.top).value().stroke->size == 12 && !two.effects(two.bottom)); + session.changeEffects([](LayerEffects &effects) { effects.stroke.value().size = 12; }); + QVERIFY(two.effects(two.top).value().stroke.value().size == 12 && !two.effects(two.bottom)); // Once its effect is gone, edits have nowhere to go. LayerEffects without = two.effects(two.top).value(); without.stroke.reset(); without.shadow = ShadowEffect(); session.setEffects(without, two.top); const int steps = session.history.undoCount(); - session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 1; }); + session.changeEffects([](LayerEffects &effects) { effects.shadow.value().blur = 1; }); QCOMPARE(session.history.undoCount(), steps); session.finishEffectsEditing(true); - session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 1; }); + session.changeEffects([](LayerEffects &effects) { effects.shadow.value().blur = 1; }); QCOMPARE(session.history.undoCount(), steps); } @@ -251,7 +251,7 @@ void LayerEffectsSessionTests::copyingAnEffectClosesTheTargetsPanelFirst() target.stroke = StrokeEffect(); session.setEffects(target, two.bottom); session.selectEffect(LayerEffectKind::shadow, two.bottom, true); - session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 7; }); + session.changeEffects([](LayerEffects &effects) { effects.shadow.value().blur = 7; }); session.copyEffect(LayerEffectKind::shadow, two.top, two.bottom); QCOMPARE(session.history.undoName(), QString("Copy Drop Shadow")); QVERIFY(!session.effectsEditing() && two.effects(two.bottom).value().shadow == source.shadow); @@ -259,7 +259,7 @@ void LayerEffectsSessionTests::copyingAnEffectClosesTheTargetsPanelFirst() QVERIFY(session.effectSelection() == (LayerEffectSelection{two.bottom, LayerEffectKind::shadow}) && session.activeLayerID() == two.bottom); // The open edit was kept: one undo brings it back. session.undo(); - QVERIFY(two.effects(two.bottom).value().shadow->blur == 7); + QVERIFY(two.effects(two.bottom).value().shadow.value().blur == 7); session.redo(); // No source, a blank or folder target, busy: no copy. QVERIFY(!session.canCopyEffect(LayerEffectKind::shadow, QUuid::createUuid(), two.bottom)); @@ -278,9 +278,9 @@ void LayerEffectsSessionTests::togglingAndRemovingAnEffect() session.addEffect(LayerEffectKind::stroke); session.finishEffectsEditing(true); session.toggleEffect(LayerEffectKind::stroke, two.top); - QVERIFY(two.effects(two.top).value().stroke->enabled == false && session.history.undoName() == QString("Hide Stroke")); + QVERIFY(two.effects(two.top).value().stroke.value().enabled == false && session.history.undoName() == QString("Hide Stroke")); session.toggleEffect(LayerEffectKind::stroke, two.top); - QVERIFY(two.effects(two.top).value().stroke->enabled == true && session.history.undoName() == QString("Show Stroke")); + QVERIFY(two.effects(two.top).value().stroke.value().enabled == true && session.history.undoName() == QString("Show Stroke")); // Toggling a missing effect, or none at all, does nothing. const int steps = session.history.undoCount(); session.toggleEffect(LayerEffectKind::shadow, two.top); @@ -381,7 +381,7 @@ void LayerEffectsSessionTests::thePickerEditsTheOpenEffect() QVERIFY(session.colorPicker()); const int steps = session.history.undoCount(); session.previewEffectColor(); - QVERIFY(session.history.undoCount() == steps && two.effects(two.top).value().stroke->red == 1); + QVERIFY(session.history.undoCount() == steps && two.effects(two.top).value().stroke.value().red == 1); session.openEffectColorPicker(LayerEffectKind::shadow); QVERIFY(session.colorPicker().value().target.kind == ColorPickerTarget::Kind::palette); session.closeColorPicker(false); @@ -408,7 +408,7 @@ void LayerEffectsSessionTests::thePickerEditsTheOpenEffect() session.setColorPickerHSB(PickerHSB(PaletteColor{1, 1, 0})); session.previewEffectColor(); session.addEffect(LayerEffectKind::stroke); - QVERIFY(!session.colorPicker() && two.effects(two.top).value().shadow->color() == PaletteColor::black()); + QVERIFY(!session.colorPicker() && two.effects(two.top).value().shadow.value().color() == PaletteColor::black()); } void LayerEffectsSessionTests::everyChangeIsAnnouncedLast() @@ -424,7 +424,7 @@ void LayerEffectsSessionTests::everyChangeIsAnnouncedLast() return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); }; QCOMPARE(stale([&] { session.addEffect(LayerEffectKind::stroke); }), QString()); - QCOMPARE(stale([&] { session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 6; }); }), QString()); + QCOMPARE(stale([&] { session.changeEffects([](LayerEffects &effects) { effects.stroke.value().size = 6; }); }), QString()); QCOMPARE(stale([&] { session.openEffectColorPicker(LayerEffectKind::stroke); }), QString()); QCOMPARE(stale([&] { session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); }), QString()); QCOMPARE(stale([&] { session.previewEffectColor(); }), QString()); diff --git a/tests/LayerEffectsTests.cpp b/tests/LayerEffectsTests.cpp index 34259a4..18f3157 100644 --- a/tests/LayerEffectsTests.cpp +++ b/tests/LayerEffectsTests.cpp @@ -118,16 +118,16 @@ void LayerEffectsTests::validityFollowsSwiftsBounds() QVERIFY(!InnerShadowEffect{.angle = -360.5}.isValid() && !InnerShadowEffect{.distance = -1}.isValid() && !InnerShadowEffect{.blur = notANumber}.isValid()); // One bad effect spoils the set. LayerEffects effects = every(); - effects.innerShadow->blur = infinity; + effects.innerShadow.value().blur = infinity; QVERIFY(!effects.isValid()); effects = every(); - effects.stroke->size = 501; + effects.stroke.value().size = 501; QVERIFY(!effects.isValid()); effects = every(); - effects.shadow->distance = -1; + effects.shadow.value().distance = -1; QVERIFY(!effects.isValid()); effects = every(); - effects.colorOverlay->opacity = 2; + effects.colorOverlay.value().opacity = 2; QVERIFY(!effects.isValid()); } @@ -163,7 +163,7 @@ void LayerEffectsTests::theHelpersReadAndWriteOneKind() effects.setEnabled(true, LayerEffectKind::stroke); effects.setEnabled(false, LayerEffectKind::innerShadow); effects.setEnabled(true, LayerEffectKind::shadow); - QVERIFY(effects.stroke->enabled == true && effects.innerShadow->enabled == false && !effects.shadow); + QVERIFY(effects.stroke.value().enabled == true && effects.innerShadow.value().enabled == false && !effects.shadow); // Visible keeps what is switched on. const LayerEffects shown = effects.visible(); QVERIFY(shown.stroke == effects.stroke && !shown.innerShadow && !shown.shadow && !shown.colorOverlay); diff --git a/tests/LayerMaskTests.cpp b/tests/LayerMaskTests.cpp index 8483930..f51ad64 100644 --- a/tests/LayerMaskTests.cpp +++ b/tests/LayerMaskTests.cpp @@ -351,8 +351,8 @@ void LayerMaskTests::maskTransformPrefersThePlacement() QCOMPARE(layer.maskTransform(), layer.transform); layer.mask = wideMask(); QCOMPARE(layer.maskTransform(), layer.transform); - layer.mask->placement = LayerTransform{.origin = {5, 6}, .size = {7, 8}}; - QCOMPARE(layer.maskTransform(), *layer.mask->placement); + layer.mask.value().placement = LayerTransform{.origin = {5, 6}, .size = {7, 8}}; + QCOMPARE(layer.maskTransform(), layer.mask.value().placement.value()); } void LayerMaskTests::layersCompareAndRecordTheirMasks() @@ -370,7 +370,7 @@ void LayerMaskTests::layersCompareAndRecordTheirMasks() QVERIFY(!(masked == layer)); QCOMPARE(masked, ImageLayer(masked)); ImageLayer disabled = masked; - disabled.mask->isEnabled = false; + disabled.mask.value().isEnabled = false; QVERIFY(!(disabled == masked)); const ProjectLayerRecord hidden = disabled.hierarchyRecord(); QCOMPARE(hidden.maskEnabled, std::optional(false)); @@ -382,12 +382,12 @@ void LayerMaskTests::layersCompareAndRecordTheirMasks() QCOMPARE(record.maskPlacement, std::nullopt); QCOMPARE(record.maskLinked, std::optional(true)); ImageLayer unlinked = masked; - unlinked.mask->isLinked = false; - unlinked.mask->placement = LayerTransform{.origin = {5, 6}, .size = {7, 8}}; + unlinked.mask.value().isLinked = false; + unlinked.mask.value().placement = LayerTransform{.origin = {5, 6}, .size = {7, 8}}; const ProjectLayerRecord moved = unlinked.hierarchyRecord(); QCOMPARE(moved.maskEnabled, std::optional(true)); QCOMPARE(moved.maskLinked, std::optional(false)); - QCOMPARE(moved.maskPlacement, unlinked.mask->placement); + QCOMPARE(moved.maskPlacement, unlinked.mask.value().placement); } void LayerMaskTests::historyCountsMaskPixels() @@ -395,18 +395,18 @@ void LayerMaskTests::historyCountsMaskPixels() DocumentHistory history; std::optional document = CanvasDocument(40, 20); const ImportedImage pixels(BrushRaster::context(40, 20, false), QImage(), "Photo"); - document->layers.push_back(ImageLayer(pixels, QPointF(0, 0))); - document->layers[0].mask = LayerMask(LayerMask::assetFrom(gray(200, 100, 255))); - const qint64 maskBytes = document->layers[0].mask->asset.byteCount() - + document->layers[0].mask->asset.thumbnail.sizeInBytes(); + document.value().layers.push_back(ImageLayer(pixels, QPointF(0, 0))); + document.value().layers[0].mask = LayerMask(LayerMask::assetFrom(gray(200, 100, 255))); + const qint64 maskBytes = document.value().layers[0].mask.value().asset.byteCount() + + document.value().layers[0].mask.value().asset.thumbnail.sizeInBytes(); QCOMPARE(maskBytes, qint64(200 * 100 + 96 * 48)); history.begin("Disable Layer Mask", document, std::nullopt); - document->layers[0].mask->isEnabled = false; + document.value().layers[0].mask.value().isEnabled = false; history.end(document, std::nullopt); QCOMPARE(history.undoCount(), 1); QCOMPARE(history.retainedBytes(document), 0); history.begin("Delete Layer Mask", document, std::nullopt); - document->layers[0].mask.reset(); + document.value().layers[0].mask.reset(); history.end(document, std::nullopt); QCOMPARE(history.retainedBytes(document), maskBytes); } diff --git a/tests/LayerTests.cpp b/tests/LayerTests.cpp index 26c18a3..b07ad50 100644 --- a/tests/LayerTests.cpp +++ b/tests/LayerTests.cpp @@ -29,7 +29,7 @@ void LayerTests::importedLayerTakesItsSizeAndNameFromTheAsset() QCOMPARE(layer.name, QString("Photo")); QCOMPARE(layer.origin(), QPointF(40, -10)); QCOMPARE(layer.size(), QSizeF(320, 200)); - QCOMPARE(layer.asset->image().size(), QSize(320, 200)); + QCOMPARE(layer.asset.value().image().size(), QSize(320, 200)); QVERIFY(layer.isVisible); QCOMPARE(layer.opacity, 1.0); QCOMPARE(layer.blendMode, LayerBlendMode::normal); diff --git a/tests/MaskAloneTests.cpp b/tests/MaskAloneTests.cpp index 6682a85..926bb69 100644 --- a/tests/MaskAloneTests.cpp +++ b/tests/MaskAloneTests.cpp @@ -326,7 +326,7 @@ void MaskAloneTests::aPendingGradientElsewhereStaysOutOfTheView() session.selectLayerTarget(shown.id, false); session.beginGradient(QPointF(0, 80)); session.moveGradient(std::nullopt, QPointF(200, 80)); - QVERIFY(session.gradientEdit().has_value() && !session.gradientEdit()->raster->isMask); + QVERIFY(session.gradientEdit().has_value() && !session.gradientEdit().value().raster->isMask); session.toggleMaskAlone(shown.id); // The commit is posted: the pixels' raster lingers, unshown. QVERIFY(session.maskAloneLayer().has_value() && session.gradientEdit().has_value()); @@ -338,7 +338,7 @@ void MaskAloneTests::aPendingGradientElsewhereStaysOutOfTheView() session.addLayerMask(); session.beginGradient(QPointF(0, 80)); session.moveGradient(std::nullopt, QPointF(200, 80)); - QVERIFY(session.gradientEdit().has_value() && session.gradientEdit()->raster->isMask && session.gradientEdit()->raster->layer.id == other); + QVERIFY(session.gradientEdit().has_value() && session.gradientEdit().value().raster->isMask && session.gradientEdit().value().raster->layer.id == other); session.toggleMaskAlone(shown.id); QCOMPARE(session.maskAloneLayer().value().id, shown.id); QVERIFY(session.gradientEdit().has_value()); diff --git a/tests/NativeResolutionPaintTests.cpp b/tests/NativeResolutionPaintTests.cpp index 5fbb3e4..7fc4147 100644 --- a/tests/NativeResolutionPaintTests.cpp +++ b/tests/NativeResolutionPaintTests.cpp @@ -284,6 +284,11 @@ void NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled() QCOMPARE(sizeLabel(layer), QString("500 × 300 px · 50%")); layer.transform.size = QSizeF(2333, 2000); QCOMPARE(sizeLabel(layer), QString("2333 × 2000 px · 233.3%")); + // Exact ties go to the even tenth. + layer.transform.size = QSizeF(1062.5, 300); + QCOMPARE(sizeLabel(layer), QString("1063 × 300 px · 106.2%")); + layer.transform.size = QSizeF(1187.5, 300); + QCOMPARE(sizeLabel(layer), QString("1188 × 300 px · 118.8%")); // A blank layer has no pixels to measure against. QCOMPARE(sizeLabel(ImageLayer(QStringLiteral("Blank"), QSizeF(30, 20))), QString("30 × 20 px")); // The row shows it. diff --git a/tests/OuterGlowTests.cpp b/tests/OuterGlowTests.cpp index cc54184..49059bf 100644 --- a/tests/OuterGlowTests.cpp +++ b/tests/OuterGlowTests.cpp @@ -80,7 +80,7 @@ void OuterGlowTests::outerGlowCodableRoundTrip() QCOMPARE(ManifestJson::effects(encoded), original); // Absent `enabled` stays absent, read as shown. LayerEffects shown = original; - shown.outerGlow->enabled = std::nullopt; + shown.outerGlow.value().enabled = std::nullopt; QVERIFY(!ManifestJson::encoded(shown).value("outerGlow").toObject().contains("enabled")); QCOMPARE(ManifestJson::effects(ManifestJson::encoded(shown)), shown); // A key of the wrong type, or missing, refuses it. @@ -104,7 +104,7 @@ void OuterGlowTests::layerEffectsIntegration() QVERIFY(effects.color(LayerEffectKind::outerGlow) == (PaletteColor{1, 0, 0})); QVERIFY((effects.kinds() == std::vector{LayerEffectKind::outerGlow})); effects.setColor(PaletteColor{0, 1, 0}, LayerEffectKind::outerGlow); - QVERIFY(effects.outerGlow->green == 1 && effects.outerGlow->red == 0); + QVERIFY(effects.outerGlow.value().green == 1 && effects.outerGlow.value().red == 0); effects.setEnabled(false, LayerEffectKind::outerGlow); QVERIFY(!effects.isEnabled(LayerEffectKind::outerGlow) && !effects.visible().outerGlow); effects.remove(LayerEffectKind::outerGlow); @@ -202,7 +202,7 @@ void OuterGlowTests::theGlowWidensTheMargin() { LayerEffects effects = glowing(7.2, 1, 1, 1, 1); QCOMPARE(LayerEffectsRenderer::margin(effects), 24.0); - effects.outerGlow->enabled = false; + effects.outerGlow.value().enabled = false; QCOMPARE(LayerEffectsRenderer::margin(effects), 2.0); } @@ -215,11 +215,11 @@ void OuterGlowTests::theSessionAddsCopiesAndCancelsIt() session.addEffect(LayerEffectKind::outerGlow); QCOMPARE(session.history.undoName(), QString("Add Outer Glow")); QVERIFY(session.activeEffects().outerGlow == OuterGlowEffect()); - session.changeEffects([](LayerEffects &effects) { effects.outerGlow->size = 44; }); + session.changeEffects([](LayerEffects &effects) { effects.outerGlow.value().size = 44; }); session.finishEffectsEditing(false); QVERIFY(!session.activeEffects().outerGlow); session.addEffect(LayerEffectKind::outerGlow); - session.changeEffects([](LayerEffects &effects) { effects.outerGlow->size = 44; }); + session.changeEffects([](LayerEffects &effects) { effects.outerGlow.value().size = 44; }); session.finishEffectsEditing(true); session.insert(ImportedImage(square(), QImage(), QStringLiteral("Two"))); const QUuid second = session.activeLayerID().value(); diff --git a/tests/PSDFixture+Text.h b/tests/PSDFixture+Text.h index 08b76fa..82e7f6c 100644 --- a/tests/PSDFixture+Text.h +++ b/tests/PSDFixture+Text.h @@ -149,7 +149,7 @@ inline QByteArray engine(const TypeBlock &block) return "<<\n/EngineDict\n<<\n/Editor\n<<\n/Text " + parenthesized(block.engineText.value_or(block.text)) + "\n>>\n/ParagraphRun\n<<\n/RunArray\n[\n<<\n" + "/ParagraphSheet\n<<\n/Properties\n<<\n/Justification " + block.justification + "\n>>\n>>\n>>\n]\n>>\n" + "/StyleRun\n<<\n/RunArray\n[\n" + runs + "\n]\n>>\n>>\n/ResourceDict\n<<\n/FontSet\n[\n<<\n/Name " + parenthesized(block.font) - + "\n>>\n" + (block.secondFont ? "<<\n/Name " + parenthesized(*block.secondFont) + "\n>>\n" : QByteArray()) + "]\n>>\n>>"; + + "\n>>\n" + (block.secondFont ? "<<\n/Name " + parenthesized(block.secondFont.value()) + "\n>>\n" : QByteArray()) + "]\n>>\n>>"; } inline QByteArray tySh(const TypeBlock &block) @@ -164,9 +164,9 @@ inline QByteArray tySh(const TypeBlock &block) items.emplace_back(block.wordsKey, textItem(block.text)); items.emplace_back("Ornt", enumItem("Ornt", block.vertical ? "Vrtc" : "Hrzn")); if (block.bounds) - items.emplace_back("bounds", rectItem(*block.bounds)); + items.emplace_back("bounds", rectItem(block.bounds.value())); if (block.glyphBounds) - items.emplace_back("boundingBox", rectItem(*block.glyphBounds)); + items.emplace_back("boundingBox", rectItem(block.glyphBounds.value())); if (block.withEngine) items.emplace_back("EngineData", rawItem(engine(block))); descriptor(data, "TxLr", items); diff --git a/tests/PSDFixture.h b/tests/PSDFixture.h index 98a4f26..84c2760 100644 --- a/tests/PSDFixture.h +++ b/tests/PSDFixture.h @@ -128,27 +128,27 @@ inline std::vector emptyChannels() inline Prepared layer(const PSDRecord &record, bool large = false) { - const int width = record.image ? record.image->width() : 0, height = record.image ? record.image->height() : 0; + const int width = record.image ? record.image.value().width() : 0, height = record.image ? record.image.value().height() : 0; const int left = int(std::round(record.bounds.left())), top = int(std::round(record.bounds.top())); Prepared prepared; prepared.record = record; if (record.image && width > 0 && height > 0) { - const Planes split = planes(*record.image); + const Planes split = planes(record.image.value()); for (const auto &[id, plane] : {std::pair &>(-1, split.alpha), {0, split.red}, {1, split.green}, {2, split.blue}}) prepared.channels.push_back({id, channelPayload(plane, width, height, large)}); } else { prepared.channels = emptyChannels(); } if (record.mask) - prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height(), large)}); + prepared.channels.push_back({-2, channelPayload(grayPlane(record.mask.value()), record.mask.value().width(), record.mask.value().height(), large)}); prepared.top = top; prepared.left = left; prepared.bottom = top + height; prepared.right = left + width; prepared.maskTop = top; prepared.maskLeft = left; - prepared.maskBottom = top + (record.mask ? record.mask->height() : 0); - prepared.maskRight = left + (record.mask ? record.mask->width() : 0); + prepared.maskBottom = top + (record.mask ? record.mask.value().height() : 0); + prepared.maskRight = left + (record.mask ? record.mask.value().width() : 0); return prepared; } @@ -170,9 +170,9 @@ inline Prepared emptyLayer(const QString &name, const QString &blendKey, int sec prepared.isDivider = section == 3; prepared.channels = emptyChannels(); if (record.mask) { - prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height(), large)}); - prepared.maskBottom = record.mask->height(); - prepared.maskRight = record.mask->width(); + prepared.channels.push_back({-2, channelPayload(grayPlane(record.mask.value()), record.mask.value().width(), record.mask.value().height(), large)}); + prepared.maskBottom = record.mask.value().height(); + prepared.maskRight = record.mask.value().width(); } return prepared; } diff --git a/tests/PSDReaderFixtures.h b/tests/PSDReaderFixtures.h index 1ffcca3..e76f570 100644 --- a/tests/PSDReaderFixtures.h +++ b/tests/PSDReaderFixtures.h @@ -78,10 +78,10 @@ inline QByteArray file(const std::vector &records, const QByteArray &res PSDFixture::Buffer extra; if (record.mask) { extra.u32(20); - extra.i32(record.mask->top()); - extra.i32(record.mask->left()); - extra.i32(record.mask->top() + record.mask->height()); - extra.i32(record.mask->left() + record.mask->width()); + extra.i32(record.mask.value().top()); + extra.i32(record.mask.value().left()); + extra.i32(record.mask.value().top() + record.mask.value().height()); + extra.i32(record.mask.value().left() + record.mask.value().width()); extra.u8(record.maskDefault); extra.u8(record.maskFlags); extra.u16(0); diff --git a/tests/RasterSnapshotTests.cpp b/tests/RasterSnapshotTests.cpp index f60763d..15ac8cf 100644 --- a/tests/RasterSnapshotTests.cpp +++ b/tests/RasterSnapshotTests.cpp @@ -437,15 +437,15 @@ void RasterSnapshotTests::historyBudgetNeverFlattensPaintedTiles() const auto raster = std::make_shared(40, 30, QImage(), QRectF(), std::vector()); DocumentHistory history; std::optional document = CanvasDocument(40, 30); - document->layers.push_back(ImageLayer(painted(raster), QPointF(0, 0))); - QVERIFY(document->layers[0] == ImageLayer(document->layers[0])); + document.value().layers.push_back(ImageLayer(painted(raster), QPointF(0, 0))); + QVERIFY(document.value().layers[0] == ImageLayer(document.value().layers[0])); history.begin("Select Layer", document, std::nullopt); - history.end(document, document->layers[0].id); + history.end(document, document.value().layers[0].id); QCOMPARE(history.undoCount(), 0); QVERIFY(!raster->hasMaterializedPixels()); history.begin("Delete Layer", document, std::nullopt); - const qint64 thumbnailBytes = document->layers[0].asset->thumbnail.sizeInBytes(); - document->layers.clear(); + const qint64 thumbnailBytes = document.value().layers[0].asset.value().thumbnail.sizeInBytes(); + document.value().layers.clear(); history.end(document, std::nullopt); QCOMPARE(history.retainedBytes(document), qint64(40 * 30 * 4) + thumbnailBytes); QVERIFY(!raster->hasMaterializedPixels()); diff --git a/tests/RawDevelopTests.cpp b/tests/RawDevelopTests.cpp index 305e6f1..d2c7dce 100644 --- a/tests/RawDevelopTests.cpp +++ b/tests/RawDevelopTests.cpp @@ -72,10 +72,10 @@ void RawDevelopTests::theSheetsSettingsDevelopTheLayer() bool done = false; session.importImages({raw, png(folder)}, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(session.rawDevelop()); - QCOMPARE(session.rawDevelop()->path, raw.toLocalFile()); - QVERIFY(session.rawDevelop()->settings == RawImporter::asShot(raw.toLocalFile()).value()); + QCOMPARE(session.rawDevelop().value().path, raw.toLocalFile()); + QVERIFY(session.rawDevelop().value().settings == RawImporter::asShot(raw.toLocalFile()).value()); QVERIFY(session.isImporting() && !session.document()); - RawDevelopSettings settings = session.rawDevelop()->settings; + RawDevelopSettings settings = session.rawDevelop().value().settings; settings.boost = 0; settings.exposure = 1; // The sheet hears at once that it has gone. @@ -139,7 +139,7 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported() QFile broken(gray.toLocalFile()); QVERIFY(broken.open(QIODevice::WriteOnly) && broken.write("gone") == 4); broken.close(); - session.finishRawDevelop(session.rawDevelop()->settings); + session.finishRawDevelop(session.rawDevelop().value().settings); QTRY_VERIFY(done); QCOMPARE(session.importError().value(), "Gray.dng: " + unreadable); QCOMPARE(names(session), QStringList{"Plain"}); @@ -149,7 +149,7 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported() const QUrl warm = dng(folder, "Warm.dng"); session.importImages({warm}, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(session.rawDevelop()); - RawDevelopSettings candle = session.rawDevelop()->settings; + RawDevelopSettings candle = session.rawDevelop().value().settings; candle.temperature = 2000; session.finishRawDevelop(candle); QTRY_VERIFY(done); @@ -240,7 +240,7 @@ void RawDevelopTests::theSheetPreviewsAndAnswers() QTest::qWait(50); QCOMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139)); QVERIFY(!spinner->isHidden()); - RawDevelopSettings dimmer = session.rawDevelop()->settings; + RawDevelopSettings dimmer = session.rawDevelop().value().settings; dimmer.exposure = -1; wait->start(); QTRY_COMPARE_WITH_TIMEOUT(preview->grab().toImage().pixelColor(200, 170), centre(RawImporter::develop(path, dimmer)), 1000); @@ -249,7 +249,7 @@ void RawDevelopTests::theSheetPreviewsAndAnswers() exposure->setValue(100); QCOMPARE(label(sheet, "rawExposureValue"), QString("1.00 EV")); QVERIFY(reset->isEnabled()); - RawDevelopSettings brighter = session.rawDevelop()->settings; + RawDevelopSettings brighter = session.rawDevelop().value().settings; brighter.exposure = 1; const QColor expected = centre(RawImporter::develop(path, brighter)); QTRY_COMPARE(preview->grab().toImage().pixelColor(200, 170), expected); diff --git a/tests/RemoveBackgroundTests.cpp b/tests/RemoveBackgroundTests.cpp index e1eb57b..7355b4f 100644 --- a/tests/RemoveBackgroundTests.cpp +++ b/tests/RemoveBackgroundTests.cpp @@ -75,12 +75,12 @@ void RemoveBackgroundTests::thePreviewClearsTheBackgroundAndTheCommitMasksIt() const FilterEdit &edit = session->filterEdit().value(); QVERIFY(edit.previewSource.size() == QSize(400, 300) && !edit.previewError); const QImage preview = edit.previewImage(id).value(); - QVERIFY(preview.pixelColor(0, 0).alpha() == 0 && preview.pixelColor(200, 150) == before.asset->image().pixelColor(200, 150)); + QVERIFY(preview.pixelColor(0, 0).alpha() == 0 && preview.pixelColor(200, 150) == before.asset.value().image().pixelColor(200, 150)); const int steps = session->history.undoCount(); QVERIFY(committed(*session)); // The pixels stay; a mask hides the background instead. const ImageLayer after = layerWith(*session, id); - QVERIFY(after.asset->identity() == before.asset->identity() && after.transform == before.transform); + QVERIFY(after.asset.value().identity() == before.asset.value().identity() && after.transform == before.transform); const LayerMask &mask = after.mask.value(); QVERIFY(mask.isEnabled && !mask.placement && mask.isLinked); QVERIFY(mask.asset.image().format() == QImage::Format_Grayscale8 && mask.asset.size() == QSize(400, 300)); diff --git a/tests/SelectionFeatherTests.cpp b/tests/SelectionFeatherTests.cpp index 7b4b139..f01d84d 100644 --- a/tests/SelectionFeatherTests.cpp +++ b/tests/SelectionFeatherTests.cpp @@ -176,7 +176,7 @@ void SelectionFeatherTests::aMaskFromAFeatheredSelectionFades() EditorSession session; session.createDocument(60, 20, true); session.setSelection(DocumentSelection{rectPath(QRectF(20, 0, 20, 20)), true, 6}, "probe"); - session.addMask(false); + session.addMask(true); const QImage mask = session.activeLayer().value().mask.value().asset.image(); QCOMPARE(mask, session.document().value().layers.back().mask.value().asset.image()); QVERIFY2(fading(mask, 10, 0, 1) >= 4, "the mask has a hard edge"); diff --git a/tests/SelectionSignalTests.cpp b/tests/SelectionSignalTests.cpp index e91d3fc..829af91 100644 --- a/tests/SelectionSignalTests.cpp +++ b/tests/SelectionSignalTests.cpp @@ -53,7 +53,11 @@ void SelectionSignalTests::selectionCallsAnnounceAndRefuseInSilence() QVERIFY(session->selection().has_value()); QVERIFY(announced([&] { session->selectAll(); })); QVERIFY(!announced([&] { session->selectAll(); })); + // Inverse of everything leaves nothing to deselect. QVERIFY(announced([&] { session->invertSelection(); })); + QVERIFY(!session->selection().has_value()); + QVERIFY(!announced([&] { session->deselect(); })); + QVERIFY(announced([&] { session->selectAll(); })); QVERIFY(announced([&] { session->deselect(); })); QVERIFY(announced([&] { session->toggleMarqueeKind(); })); QVERIFY(announced([&] { session->toggleLassoKind(); })); diff --git a/tests/SessionRecord.h b/tests/SessionRecord.h index d6ca78c..4f3d9b8 100644 --- a/tests/SessionRecord.h +++ b/tests/SessionRecord.h @@ -27,10 +27,10 @@ inline QStringList described(const EditorSession &session) "tool error " + session.brushError().value_or(QStringLiteral("none")), "renaming " + text(session.renamingLayerID()), "adjusting " + text(session.adjustmentEditingID()) + (session.adjustmentOriginal().has_value() ? " open" : ""), - "effects " + (session.effectsEditing() ? text(session.effectsEditing()->layerID) + " " + rawValue(session.effectsEditing()->kind) : QStringLiteral("closed")) - + (session.effectsEditingOriginal() ? " " + QJsonDocument(ManifestJson::encoded(*session.effectsEditingOriginal())).toJson(QJsonDocument::Compact) : QString()) - + " chosen " + (session.effectSelection() ? text(session.effectSelection()->layerID) + " " + rawValue(session.effectSelection()->kind) : QStringLiteral("none")), - "picker " + (session.colorPicker() ? session.colorPicker()->target.title() + " " + session.colorPicker()->color().hex() : QStringLiteral("closed")), + "effects " + (session.effectsEditing() ? text(session.effectsEditing().value().layerID) + " " + rawValue(session.effectsEditing().value().kind) : QStringLiteral("closed")) + + (session.effectsEditingOriginal() ? " " + QJsonDocument(ManifestJson::encoded(session.effectsEditingOriginal().value())).toJson(QJsonDocument::Compact) : QString()) + + " chosen " + (session.effectSelection() ? text(session.effectSelection().value().layerID) + " " + rawValue(session.effectSelection().value().kind) : QStringLiteral("none")), + "picker " + (session.colorPicker() ? session.colorPicker().value().target.title() + " " + session.colorPicker().value().color().hex() : QStringLiteral("closed")), QStringLiteral("mask target %1").arg(int(session.isMaskSelected())), QStringLiteral("history %1%2").arg(int(session.canUndo())).arg(int(session.canRedo())), QStringLiteral("names %1/%2").arg(session.history.undoName(), session.history.redoName()), diff --git a/tests/ShapeLayerTests.cpp b/tests/ShapeLayerTests.cpp index ab4d015..7801201 100644 --- a/tests/ShapeLayerTests.cpp +++ b/tests/ShapeLayerTests.cpp @@ -320,7 +320,7 @@ void ShapeLayerTests::aProjectKeepsLiveShapesOnly() // Swift's keys; a rectangle writes no line fields. QCOMPARE(firstShape(snapshot, 1).keys(), (QStringList{"blue", "cornerRadius", "green", "kind", "red"})); QCOMPARE(firstShape(snapshot, 1).value("kind").toString(), QString("Rectangle")); - QCOMPARE(firstShape(snapshot, 2).value("start").toArray(), (QJsonArray{line.start->x(), line.start->y()})); + QCOMPARE(firstShape(snapshot, 2).value("start").toArray(), (QJsonArray{line.start.value().x(), line.start.value().y()})); QCOMPARE(firstShape(snapshot, 2).value("lineWidth").toDouble(), 3.0); const ProjectManifest decoded = ProjectManifest::decoded(snapshot.manifest.encoded()); QCOMPARE(decoded.layers[1].shape, snapshot.manifest.layers[1].shape); diff --git a/tests/SmudgeLiquifyTests.cpp b/tests/SmudgeLiquifyTests.cpp index ebe3c56..9b5fd06 100644 --- a/tests/SmudgeLiquifyTests.cpp +++ b/tests/SmudgeLiquifyTests.cpp @@ -126,7 +126,7 @@ struct Oracle { pickUp(point); return; } - const QPointF from = *last; + const QPointF from = last.value(); const double distance = std::hypot(point.x() - from.x(), point.y() - from.y()); const double spacing = std::max(1.0, diameter * (smudging ? 0.005 : 0.025)); if (distance < spacing) diff --git a/tests/TextColorModelTests.cpp b/tests/TextColorModelTests.cpp index e78ef86..4cdb23b 100644 --- a/tests/TextColorModelTests.cpp +++ b/tests/TextColorModelTests.cpp @@ -125,7 +125,7 @@ void TextColorModelTests::everyChannelOfARunIsChecked() session->setPaletteColor(red, false); QVERIFY(session->finishText()); ProjectSnapshot snapshot = session->projectSnapshot().value(); - snapshot.manifest.layers.back().text->colorRuns->front().blue = 2; + snapshot.manifest.layers.back().text.value().colorRuns.value().front().blue = 2; QTemporaryDir root; try { ProjectStore::save(snapshot, root.filePath(QStringLiteral("Bad.comp"))); diff --git a/tests/WritingProjectsGuideTests.cpp b/tests/WritingProjectsGuideTests.cpp index ac1886a..19382bb 100644 --- a/tests/WritingProjectsGuideTests.cpp +++ b/tests/WritingProjectsGuideTests.cpp @@ -220,7 +220,7 @@ void WritingProjectsGuideTests::hsvSettingsWinOverTheLegacyFields() QJsonObject adjustment = layer.value(QStringLiteral("adjustment")).toObject(); adjustment.insert(QStringLiteral("hue"), legacyHue); if (masterHue) { - const QJsonObject values{{"hue", *masterHue}, {"saturation", 0}, {"lightness", 0}}; + const QJsonObject values{{"hue", masterHue.value()}, {"saturation", 0}, {"lightness", 0}}; adjustment.insert(QStringLiteral("hsvSettings"), QJsonObject{{"range", "Master"}, {"colorize", false}, {"invertRange", false}, {"adjustments", QJsonArray{"Master", values}}, {"bands", QJsonArray{}}}); }