diff --git a/AGENTS.md b/AGENTS.md index 0413d2b..2d14227 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,14 +48,14 @@ Platform adaptations, each forced by a missing Qt analog: - `ImageImporter` is a namespace; the Swift actor only serialises calls and caches a `CIContext`. The type comes from the file's bytes, as `CGImageSourceGetType` reads it: `QImageReader::setDecideFormatFromContent` for JPEG, PNG and TIFF (TIFF needs `qt6-image-formats-plugins`), libheif's `heif_get_file_mime_type` for HEIC (`image/heic`: HEVC in HEIF). Other ISO media (AVIF, plain HEIF, video) and every other format Qt recognises are `unsupported`; bytes nothing recognises are `unreadable`. HEIC decodes through libheif, which applies the file's own turn and crop boxes, so its EXIF orientation is never applied again. Its colours come from the ICC profile, else from the `nclx` box, which ImageIO reads for Swift: the box's primaries (libheif hands over their chromaticities) with the curve it names: sRGB, linear, gamma 2.2 or 2.8 through Qt's own transfer functions, and BT.709's curve (shared by BT.601, BT.2020, xvYCC and BT.1361 between zero and one) as a table; unspecified primaries or curve count as sRGB's, like untagged pixels. Qt 6.4's `QColorSpace` constructor that takes points and a table builds an invalid space (probed), so the table is set afterwards. Curves with no conversion here (PQ and HLG, which need tone mapping; SMPTE 240M, SMPTE 428, the logarithmic ones), primaries Qt cannot hold and a box libheif rejects are `unreadable`, each with its reason in the log: no HEIC imports with colours the importer knows to be wrong. `heif_init` loads the decoder plugins (Ubuntu ships HEVC decoding as `libheif-plugin-libde265`); a missing decoder is `unreadable`, with libheif's message in the log. For Qt's formats the importer reads the pixels as stored, converts them to sRGB while the ICC profile is still attached, and only then applies the EXIF or TIFF orientation itself: Qt 6.4's `setAutoTransform` drops the colour space. Untagged pixels count as sRGB, as CoreImage counts them. Qt 6.4 reads no orientation from a PNG's `eXIf` chunk (probed: `supportsOption(ImageTransformation)` is false), so such a PNG imports as stored; JPEG, TIFF and HEIC turn upright. The result is `RGBA8888_Premultiplied`, tagged sRGB. A thumbnail's long side is 96 and its short side rounds up, as Swift's `extent.integral` does. The HEIC fixtures in `tests/fixtures` are committed; `scripts/heic-fixtures.sh` makes them and stops at the first failure, because the dev image carries no HEVC encoder. The tests patch the lossless `p3-nclx.heic` in memory to name other primaries and curves. - `LayerRenderer::drawCoverage`: Swift clips to the mask and fills white; QPainter has no soft clip. The twin builds white whose alpha is the mask (premultiplied, from the halved mask `draw` would use, since CoreGraphics filters a shrinking mask and QPainter's bilinear filter aliases) and draws it under the painter's own opacity and clip, in the composition mode under which alpha stands for a clip's coverage: the painter's own for every mode that is linear in the source and keeps pixels where the source is clear (source-over, the blend modes, the rest of Porter and Duff's), and its exact stand-in for the seven that are not (Source and Plus become SourceOver, Clear becomes DestinationOut, SourceIn SourceAtop, SourceOut Xor, DestinationIn Destination, DestinationAtop DestinationOver). `DrawCoverageTests` holds all 24 modes against the oracle, a fill of opaque white mixed in by coverage, over opaque, half clear and clear pixels. Those seven take no painter opacity (CoreGraphics scales the source by it, Qt mixes the result by it, and only linear modes make the two agree): below full opacity they are a `logic_error`, as is any raster operation. No caller sets a mode today. Source-over that is white through coverage, 255 a + d(1 - a), on gray and colour devices alike. It takes the placement `draw` takes, `radians()` included, so a wild angle turns both alike. A mask that is not gray is a `logic_error`; a white image that cannot be allocated is `ExportError(render)`. - QPainter places an unscaled, unturned image on whole device pixels whatever the hints say; CoreGraphics would blur it by the fraction. Layers the app makes sit on whole pixels, Shape lines aside (8.8), and any scale or turn takes QPainter's filtered path, so tests of soft edges draw under a scale. -- `ImageResizer` and `CanvasResizer` are namespaces. `Task.checkCancellation()` in the Swift resizer has no twin: no caller cancels a resize. Each turns a `std::bad_alloc` into `ExportError(render)` at its door (since 10.1 and 10.2), so the workers that run them catch it. A layer's new box is judged by `LayerTransform::isValid` before any number becomes an integer, where Swift converts first: `Int(Double)` traps on a wild value, C++ would be undefined. Records are rebuilt field by field as Swift rebuilds them, so a field Swift leaves out is dropped once that field exists (the image resizer passes on neither shape, effects nor text; the canvas resizer passes on shape and text, not effects). Options hold `qint64`, Swift's `Int`, like the manifest. `CanvasSizeDraft` keeps plain members where Swift has `let`, so the struct stays assignable. +- `ImageResizer` and `CanvasResizer` are namespaces. `Task.checkCancellation()` in the Swift resizer has no twin: no caller cancels a resize. Each turns a `std::bad_alloc` into `ExportError(render)` at its door (since 10.1 and 10.2), so the workers that run them catch it. A layer's new box is judged by `LayerTransform::isValid` before any number becomes an integer, where Swift converts first: `Int(Double)` traps on a wild value, C++ would be undefined. Records are rebuilt field by field as Swift rebuilds them, so a field Swift leaves out is dropped once that field exists (the image resizer passes on effects, scaled by the root of its two factors, but neither shape nor text; the canvas resizer passes on all three, since 14.22). Options hold `qint64`, Swift's `Int`, like the manifest. `CanvasSizeDraft` keeps plain members where Swift has `let`, so the struct stays assignable. - `EditorSession` is a `QObject`. One signal, `changed()`, stands in for Swift's observation: every method that changes state emits it, a refused request stays silent, and widgets compare what they show with what the session holds, as `CanvasView.synchronizeDisplay` does. `beginEdit` emits as `endEdit` does, because an open transaction closes `canUndo` and `canRedo`. `fit` and `zoom` emit only when the view changed, so a zoom that is no number stays silent. The canvas edits `viewport` in place and then calls `notify()`. A Swift property with a `didSet` is a private member with a getter and a setter (`activeLayerID`, `isProjectBusy`, `showsNewDocument`, `showsImporter`, `isImporting`, `importError`, `renamingLayerID`); `document`, `tool`, `collapsedGroupIDs` and `selectedLayerIDs` are read through getters and change only through session methods; `history` and `viewport` are public members, as Swift's tests use them. `activeLayer()` returns a copy, as Swift's computed property does. `EditorSessionSignalTests` holds the signal's contract: every change is announced, a refusal is silent, and the last signal of a change sees the state the call leaves behind, read inside the slot. - `EditorSession.swift` runs to 741 lines, so the twin splits: `EditorSession+Model.h/.cpp` hold `ImageLayer`, `CanvasDocument` and `NavigationTool` (their own header also breaks the include cycle with `DocumentHistory`, which a member by value needs complete), `EditorSession.h/.cpp` the core, `EditorSession+Layers.cpp` the layer operations, `EditorSession+Transform.cpp` the transform edits, `EditorSession+Imports.cpp` the import queue and the busy gates (its queue's declarations in `EditorSession+Imports.h` since 14.2, keeping the header under 500 lines); `EditorSession+Projects.cpp` twins the Swift file of that name. A session function that Swift defines in an extension lives in the twin of that Swift file, declared in its `Name+Session.h`: the `LayerGroups` extension (folders, the multi-selection, grouping, placing, `layerRows`) in `LayerGroups.cpp`, `finishDeletingLayer(s)` in `LiveLayerMask.cpp`. - `groupSelectedLayers` follows Swift's steps (what stays keeps its place, the folder sits at the topmost selected branch, the wrapped layers go last in hierarchy order) with two terms dropped because they are dead in the twin: the `nil` that ends each ancestor chain (the twin's shared parent starts as the root) and the clamp of the insertion index (it counts layers that stay, so it cannot pass their number). `placeLayer` drops Swift's `target != id` for the same reason: the target is sought after the layer is taken out, so a layer is never found above itself. `canPlaceLayer` is tested by itself as well as through `placeLayer`, whose validation would hide its terms; the panel asks it before a drop. `selectLayers` and `extendSelection` fall back on any selected layer, as Swift's `Set.first` does: tests never depend on which. A session's document always passes `LayerHierarchy::validate` (every mutator checks before it commits), so every `parentID` names a folder of the document; `groupSelectedLayers` reads parents through `std::map::at`, which throws if that ever breaks. - Transform edits: `TransformEdit` holds what exists so far (`layerID`, `draft`, `group`); `persistent`, `corners`, `mask` and `floating` arrive with the items that read them (`TASKS.md`, "Session hooks owed"). A draft changes only through `previewTransform`, which refuses an invalid value, and begins as a valid transform or box, so it is valid by construction. `commitTransform` therefore gives each layer what `pendingTransform` gives it, one definition for preview and commit, inside one transaction, and skips a layer whose own result is invalid (a box squeezed until a member falls under a pixel); Swift's `edit.draft.isValid` and `firstIndex` guards are left out, since nothing can reach them. An edit that moves nothing records nothing, as in Swift: the history drops a transaction that changed nothing. `canTransform` and `groupTransformMembers` drop Swift's `isGroup` terms (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), and `beginTransform` drops `transformEdit == nil` (`canTransform` reads it through `canEditLayers`). `groupTransformBox` keeps `max(1, …)`: rounding leaves a one-pixel layer at origin 1.3 a box under one pixel, and a test holds that. `DistortWarp::corners` came forward from 6.2 for the group box. - Swift's `async` session calls (`importImages`, `waitForProjectAccess`, `waitForFileRequest`) take a completion callback in the twin. A waiter runs at once when the session allows it; otherwise it is kept, and the setter that frees it posts each waiter to the event loop (`Qt::QueuedConnection`), where it looks again when its turn comes, as a Swift continuation re-enters its `while` loop: no waiter runs inside the setter, and the first may take the project before the second looks. `importImages` takes `QList` (a URL that is no local file is `unsupported`, Swift's `isFileURL` guard), decodes one file at a time through `QtConcurrent::run` and a `QFutureWatcher` (the worker catches `ImageImportError` and hands back the asset or the failure text; anything else is a bug and is left to crash), inserts on the UI thread, and wraps each request in one `Import Images` transaction. A request's callback is posted to the event loop too, so the caller goes on as a resumed Swift task does: after the transaction ended and, for the last request, after `isImporting` fell and the failures were published; for an earlier request while the next one decodes. Only a project that stops being busy frees project waiters: saying busy again leaves them in line. `showsBusy` is a single-shot `QTimer` of `busyIndicatorDelay` (250 ms), a `Qt::PreciseTimer` because a coarse one fires up to a twentieth early, and a child of the session so a test can read its remaining time; tests of time assert floors and states, never ceilings; `createDocument` clears its two flags and then resumes waiting file requests once, where Swift's two `didSet`s each try. The session therefore needs an event loop: every test that makes one uses `QTEST_GUILESS_MAIN`. - Appearance: `allLayerBlendModes` stands for Swift's `LayerBlendMode.allCases`, the order of the blend menu. Swift's `refreshCanvasPreview` callback has no twin: `changed()` reaches the canvas, and `previewBlendMode` emits it only when the preview changes, so hovering over one entry redraws once. `blendPreview` is a small struct where Swift has a tuple. `opacityEditLayerID` is a private member: only the session reads it; an open opacity drag shows outside as an open transaction (`canUndo` is false). Inside a drag `setLayerOpacity` opens a nested transaction where Swift writes into the open one: the history counts depth, so the drag is still one step, and the branch Swift needs is gone. -- Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; shapes and text set them apart: `installProject` loads both after `CanvasDocument(snapshot)`, `applyDocumentSize` does not, as Swift's rebuild leaves them out. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`. +- Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; since 14.22 the layer constructor also loads its live shape and text, Swift's `documentLayers`, so an install and a resize's rebuild keep them alike. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`. - Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files. - Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too. - `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; a save marks the saved revision before `setProjectPath`, whose signal then shows the saved state (12.13b: the write lands after the project is free again). `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no security scopes; recent projects are 12.14b's. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it). @@ -91,8 +91,8 @@ Platform adaptations, each forced by a missing Qt analog: - 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. `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). +- 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`), a snapshot's layers load shapes, an install and a resize's rebuild alike (since 14.22, Swift's `documentLayers`); 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; a snapshot's layers load text, an install and a resize's rebuild alike (since 14.22); 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). - The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view (clear glyphs since 12.12a); the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. Each event follows `QWidgetLineControl::processInputMethodEvent` on the text without the preedit: input (a commit, a changed preedit, a replacement) takes the selection first, then the replacement counts from the cursor, a range removed whole or not at all, a zero length inserting at its offset, and where nothing commits the cursor goes where Qt leaves it; an event that is no input changes nothing (Qt's fields remove a selection only on input, and ibus sends empty preedits when an engine switches modes). A `Selection` attribute sets the selection after the commit; while a preedit shows, that selection stays beside it (the preedit's `anchor`, in the text without it), drawn and answered to queries, until the next input takes it, and the attribute ends a typing run as a move does; a draft set over a composing one ends the composing; `TextFormat` attributes style the preedit, counted from its start (a format that is none is passed over, as Qt's fields do), else it is underlined; a `Cursor` attribute places the caret, hides it at length zero and colours it with a `QColor` value. Copy, Cut, Paste and Select All, from the menu or their keys, work around a preedit as Qt's fields do: on the text without it and the selection beside it (`plain`), the preedit then going back at the cursor (`settle`), so Select All then a commit replaces everything; undo takes a preedit away with the step, since it records nothing. Other keys reach the editor while a preedit shows only when the input method lets them through, and act on the text as it stands: typing and deletions end the composing, the preedit staying as text, as NSTextView unmarks it. Text holds 100,000 UTF-16 units (`shouldChangeTextIn`). The canvas makes the editor in `synchronizeDisplay` (Swift's `synchronizeInlineText`, whose twin lives in `InlineTextEditor.cpp`), draws the edited layer as its typed pixels since 12.12a; a draft that begins, ends or is replaced repaints the whole canvas, as Swift's `needsDisplay`, because the editor's box need not cover the layer's (text shrunk while edited, a layer stretched one way, which point text shows at its width's scale); while a draft is open every key goes to the editor (a key it leaves goes on to `QWidget::keyPressEvent`, which ignores it for the ancestors, none of which takes keys: its mutant is equivalent; so is the blend keys' focus term, since `canEditLayers` refuses a blend step while text is open), the text view's standard keys and Backspace with any modifier (the fills' keys; Delete has no menu key and needs no claim) win `ShortcutOverride` ahead of the menus, Shift+Tab stays put, focus shows and hides the caret (its blink is `caretBlink`, a named child timer of the canvas, running only with focus, so tests read it), input methods are enabled through `setAttribute` alone (Qt updates the input method itself and commits a preedit on the way out, so the editor goes first and that commit finds none) and follow the caret: every edit, move and view change updates them while the canvas is the focus object, as `QWidget::updateMicroFocus`, and a focus request hands open text the keys, as Swift's `consumeFocusRequest` hands them to the text view since 12.14d2. The menus follow Swift's text branch: with a draft open Undo and Redo are bare and enabled, and Undo, Redo, Cut, Copy, Paste and Select All reach the editor of the focused canvas, a text field's first. The pointer (placing the caret, dragging a selection, the handles, the text box, the cursors) comes with 8.9c. - The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (known differences, the first a fix). Equivalent mutants: `drawTextBoxDraft`'s save, restore and brush (it draws last, on a painter each overlay before it restores) and its guard (no box maps to a null rect, which draws nothing). Known differences: `NSTextView`'s context menu and its dragging of selected text are AppKit's own; the Edit menu carries Cut, Copy and Paste. `arrowPath` and `duplicateCursor` moved to `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. - The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (`trimmedWhitespaces` in `EditorSession+Model.h`, shared with `validDimension` and New Canvas since 14.3: the tab, Unicode's space separators and U+200B, as Swift 6.0.3's Foundation lists them, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; its close button cancels, and Swift's guard there is left out, since closing no picker does nothing. `UI/ColorPaletteControls` twins Swift's file under the rail: two 24-point swatches (a 1.5 white ring inside a black rim), the swap (X) and defaults (D) glyphs drawn as paths in the secondary ink, dimmed while disabled, the whole resting while the palette cannot be edited, both swatches repainted whole on every change (they overlap); a mask's swatch offers Black · Hide or White · Reveal in a popup under the swatches (Swift's popover), which a change of target closes, and a mask chosen cancels an open picker; the panel follows the session's picker by its id. Swift's frame is 36 points and its two buttons reach 3 points past it, so the twin's widget is 42 points square with the frame inset by 3, in a rail column 42 wide with the tools centred, the swatches 18 points below the last tool (Swift's spacing of 10 and padding of 8). `SwatchButton` draws every swatch: the palette's (radius 6, white ring 1.5), the Brush bar's (34 by 18, radius 4, white ring 1, black rim), the Shape bar's Fill and the Type bar's (36 by 18, radius 3, a half-black rim). The Brush bar's shows the foreground for the Brush and Spot Healing, not Clone Stamp, the Smear or a mask, resting while the palette cannot be edited; the Type bar's shows `typeColor` and opens `openTextColorPicker`. X and D swap and reset the palette on the canvas and in the list, Shift allowed, not with Ctrl, Alt or Meta; `ProjectWorkspace::canSwitch` refuses while a picker is open. Tests wait for the panel to take the keys (`QApplication::activeWindow`): a tool window reads as active whenever its parent is, and offscreen a hidden panel stays the focus window, where a window manager hands the keys back. Equivalent mutants: `raise` (offscreen keeps no stacking order), `refocus`'s `isVisible` (offscreen activates no hidden window), `show`'s `wasVisible` guard (a shown panel stands where it was remembered), and in `sampleCompositeColor` Swift's `min(alpha, value)` (a premultiplied channel never passes its alpha), `painter.end()` before the read (the raster engine writes at once) and the warning. @@ -108,7 +108,7 @@ Platform adaptations, each forced by a missing Qt analog: - Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes` and `anAdjustmentsMaskClipsItOnTheCanvas` in `CanvasDisplayTests`. - Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does. - Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`. -- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and both resizers leave them out (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy. +- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and the canvas resizer keep them since 14.22, the image resizer scaling them (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy. - Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, one surface (200 million since 12.11a). Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning). - Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's (the recent results and `renderNow` since 12.12a). A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too. - `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels). @@ -200,6 +200,7 @@ Platform adaptations, each forced by a missing Qt analog: - Export As (14.18, Swift 1.4.8: `9287cf1`, `450af5f`, `b1db93d`): `ProjectController::exportAs(start, done)` replaces `exportJPEG`; `ExportFormat` (png, jpeg, pdf, Swift's raw values through `rawValue`, `allExportFormats`) starts the sheet, else the last format exported this run (`m_lastExportFormat`, PNG at first), and the File menu's Export JPEG… opens it on JPEG; Export As… (Ctrl+Alt+Shift+W, a new shortcut definition) on the last. The flow is the JPEG one: the render in a worker, the sheet, then the format's panel (Export PNG, Export JPEG or Export PDF, suggesting `.png`, `.jpeg` or `.pdf`), the write in a worker, one alert titled "Couldn’t export" for a failure past the render. `UI/ExportAsSheet` (Swift's sheet, renamed from the JPEG one): titled Export As; the format as three checkable buttons; the size as two `PickerField`s keeping the proportions (a number applies on Return or leaving, else both fields show the size again; a scrub or menu replaces their typing) and a scale menu of 25, 50, 75, 100 and 200%, every scale held between a hundredth and the side and surface limits; Quality for JPEG alone (saved only from a JPEG export), the page size in inches at the document's DPI for PDF alone, the background swatch ("Export Background") for JPEG and PDF; "Transparency kept · sRGB" for PNG, else "sRGB"; a change of format or size asks again only when the settings change, as Swift's `task(id:)`. The encoding resizes first (`ImageExporter::resized`: smooth, the same raster at the same size, `tooLarge` past the limits) and encodes by format (`pngData`; `jpeg`; `pdfData` over `flattened` for the preview). `JPEGPreview` takes the export's size beside its image (`setImage(image, pixels, updating)`), so Fit and 100% follow the chosen size, and draws Swift 1.4.8's checkerboard (20-point tiles of grays 0.30 and 0.35, anchored at the image) behind what it shows. Swift's PDF comes from `CGContext`'s PDF writer; `QPdfWriter` rounds a page to whole points, so `ImageExporter::pdfData` writes PDF 1.4 by hand: one page whose MediaBox is the print size in points (72 / resolution a pixel, 72 when none), the background as a filled rectangle when given, the pixels as a Flate RGB image with a gray soft mask, straight colour (adaptation; checked against poppler's `pdfinfo` and `pdftoppm`). A Flate stream that `qCompress` cannot make (it returns nothing when memory runs out) is `ExportError(render)`, never an empty stream. A zoom set before the first image (Ctrl+1 while the sheet still encodes) centres that image when it lands, as Swift's preview, made with its first result, centres on appearing. The page's inches round as Swift's formatter: its shortest digits, halves to even (45 pixels at 72 DPI are 0.62 in). A size field reads Swift's `Int` field (`typedInteger`, checked against Swift 6.0.3): a sign, decimal digits of any script read by code point, a fraction and an exponent (its leading zeros dropped), the number truncated toward zero and exact within Int64 (`1e3` is 1000, `12.7` is 12, `-9.223372036854775808e18` is Int64's least, `1e-400` is 0, `128e` is 128), grouping refused; known difference: ICU's lenient parse also reads a leading number before other text (`7abc` is 7, `1,000` is 1, `--5` is −5), where the twin puts the size back; and ICU rounds some long fractions before Swift truncates them (`199.99999999999999` is 200, `0.99999999999999999` is 1, while `9007199254740993.5` stays …993 and `12.99999999999999` stays 12), a rule the measured cases do not pin down, so the twin truncates the digits as typed. Encoding finds no memory as `ExportError(render)` at `encode`'s door, so Export As's PNG worker never meets a `bad_alloc`. Equivalent mutant: the parser's `point <= 0` as `< 0` (a point at zero leaves no digits, which read as 0). Tests: `ExportPDFTests` (the page, its size, the straight pixels and alpha, rows and channels, the background, a refusal, resizing, flattening), `ExportPDFFailureTests` (a binary of its own: a 360-pixel PDF, small enough that Qt converts it on the calling thread (a pool thread that cannot start hangs Qt 6.4's conversion), under limits from 64 KB to 4 MB throws or inflates whole, both reached, and a PNG likewise throws or matches the unstarved file), `ExportAsSheetTests` (formats and their rows and bytes, the size and its limits, the checkerboard, the page against Swift 6.0.3's formatter), `JPEGPreviewTests::aZoomBeforeTheFirstImageCentresIt`, `ProjectExportTests` (each format through the controller, the menu's Export As on the last format), the entry in `CompositorMenusTests` and `KeyboardShortcutsTests` (its chord, group and place). Equivalent mutants: none found. - Filter › Scanlines (14.19, Swift 1.4.9: `06c3a22`, `1187736`, `cdde327`): Scanlines leaves Dither for a filter of its own. `kernels/DitherPixels.c/.h` are `cdde327`'s, with 13.17's adaptation carried on: Swift's GCD blocks become static band functions with context structs (`scanlines_tone_band`, `line_scan_band`, `blur_band`, `spread_band`, `column_band`, `split_band`), each failure flag an `atomic_int`, every band through `dither_bands`; the arithmetic is Swift's word for word, and `dither_glow`'s new easing near white came with it. `ScanlinesTests::theKernelDrawsSwiftsOwnBytes` holds that: Swift's own file, built unchanged with clang's blocks and libdispatch in the `swift:6.0.3` image, drew five scenes (37 by 53 and 70 by 300, every setting, both directions of Displace, 32 bands of columns, lines and rows) and a glow, and the twin's bytes hash the same (FNV-1a). `Document/Scanlines.h/.cpp` twin Swift's file: `ScanlinesSettings` with Swift's fields, defaults (glow 0 now, thickness 70, smoothness 50) and clamps, Line Spacing and Color Split rounded, `apply` handing the kernel the normalized settings and Two Colors' bytes (black on white otherwise), then 13.17's `glowing` moved here unchanged. `DitherStyle` loses `scanlines` (`usesPixelSize` is ASCII alone, `drawsMarks` every style without tones) and `DitherSettings` its four fields. `FilterKind::scanlines` ("Scanlines") sits after Dither in Swift's order, so the Filter menu lists it there; it previews whole, as Dither does. Its two colours reuse Dither's picker (`openDitherColorPicker`, `setDitherColor`, Swift's), choosing the edit's own settings, and the titles drop "Dither" (`Color Picker (Light Color)`, `(Dark Color)`). The sheet's `scanlines` (`UI/FilterSheet+Scanlines.cpp`) is Swift's `scanlinesControls`: twelve rows with Swift's ranges, units and help, then the Colors menu and swatches, which `colorChoices` builds for Dither too (the screen's and the lines' colour help for Scanlines). Equivalent mutant: `glowing` run at glow 0 (it adds nothing). Tests: `ScanlinesTests` (Swift's eight, Swift's own bytes, each setting reaching its field, Two Colors rounded, every clamp through `FilterSettings` too, clear pixels, the eased glow against a full-size blur), `ScanlinesSheetTests` (the menu place and a commit, the sheet's rows, ranges and fields, both pickers), the order in `FilterModelTests`, `FinishingFilterTests`, `DitherSheetTests` and `CompositorMenusTests`, the acceptance run's Scanlines row through the menu. - The Navigator (14.20, Swift 1.4.9: `6490bb3`, `3e09948`; `3c6cf5e`'s conflict resolved toward the minimap, which supersedes the panel): `Rendering/NavigatorGeometry.h/.cpp` twin Swift's file: `NavigatorGeometry` (the document fitted and centred in a box, `thumbnailRect`, `documentPoint` held to the document's edges), and `CanvasViewport::visibleDocumentRect` and `centerView`, declared in `CanvasViewport.h` and defined there, as Swift's extension; `centerView` goes through `translate`, so the view stops following Fit. Swift's `min` and `abs` in `visibleDocumentRect` are dead (the viewport never flips) and left out. `EditorSession::navigatorImage` (`EditorSession+Navigator.cpp`, its fragment `EditorSession+Navigator.h`) twins Swift's: the document drawn at the picture's size, never enlarged, each side rounded, a pixel at least, every layer and placed mask scaled by its axis, blur radius and motion distance by the width's, through a bare session's `drawLiveComposite` so an edit in progress stays out; a context that cannot be allocated, or a painted layer that cannot flatten while it draws, logs on `omaphoto.rendering` and gives no picture, where Swift's `try?` is silent (`NavigatorFailureTests`, a binary of its own: a 4000 by 3000 painted layer under 8 MB of room, the minimap shown without a picture). Swift's medium interpolation on the context is dead here (`LayerRenderer` sets each layer's own) and left out. `UI/NavigatorMinimap` twins Swift's view: a `QWidget` in `ContentView`'s canvas overlay (the mask badge's layout, its margins 12, top right), following the session's `changed()` itself: shown while View › Navigator holds (`navigator.visible` in `QSettings`, Swift's `AppStorage`, off at first), Canvas Only is off, a document is open and the zoom is 300% or more; hidden it drops its picture, key and wait, as SwiftUI drops the view. Its size is `sizeFor` (Swift's `size(for:)`: `QWidget::size` takes the name), fitted inside 126 by 90 and rounded; it fills gray 0.11, draws the 300-pixel picture smoothly over its whole box, the view's box in the accent 2.5 wide inside its rect (three points at least), clipped to a rounded rect of 4, then a 25% white rim. Swift's `task(id:)` is `RenderKey` (the document's id, size and layers and `brushRevision`, so a selection or guide redraws nothing): the first picture renders at once, each later key after a 250 ms single-shot precise `navigatorWait`. A left press or drag centres the view on the point (`centerView`, then `notify`), the arrow its cursor over the canvas's. Known difference: Swift's shadow (radius 6) has no twin, since a widget paints only inside itself. View › Navigator (300% and above) is a checkable entry before Pixel Grid that writes the setting and notifies; the command palette finds it. Tests: `NavigatorTests` (Swift's nine; the geometry's edge cases, both axes and a placed mask scaled, a layer and its mask off the origin, a streak shrunk at its edge, both blurs by the width's factor where the two factors differ, centring at a backing scale of two, the wait restarted by a later edit, a transform in progress left out, the minimap's showing, waiting, clicks, cursor, box, gray and rim), `ViewMenuTests::theNavigatorEntryShowsTheMinimap` (the entry's place and words, the minimap 12 points in at the canvas's top right), the View menu's count in `CompositorMenusTests`. Equivalent mutants: none found. +- Effects, text and shapes kept (14.22, Swift 1.4.9: `94e90bd`): `ImageLayer(record, asset, mask)` loads its live shape and text (`LayerShape::loaded`, `LayerText::loaded`), Swift's `ProjectSnapshot.documentLayers`, so `installProject` and `applyDocumentSize` (Canvas Size, Image Size, Crop, Trim) build the same layers; the canvas resizer carries effects, and Image Size scales them by the root of its two factors through `LayerEffects::scaled` (stroke, glows and blurs to 500, distances to 5,000, Photoshop's Scale Styles) while its resampling still flattens text and shapes, and a size that changes only the resolution keeps all three. Hue/Saturation, Levels, Invert and a floating merge keep the layer's effects. `docs/project-format.md` says so. Tests: `LayerEffectsSurviveTests` (Swift's five sizing cases; a stretch's mean factor and the ranges), `LayerEffectsTests::rebuildsAndResizesKeepThem` (the four rebuilds, both resizers), the shape and text halves in `ShapeLayerTests` and `TypeStoreTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/docs/project-format.md b/docs/project-format.md index 87cc00e..77dd392 100644 --- a/docs/project-format.md +++ b/docs/project-format.md @@ -47,8 +47,8 @@ Later fields are optional and not gated on the version, so older readers ignore ### Editable text -Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). From version 11, optional `fontRuns` lists letters set in another face than `fontName`: the same `location` and `length`, plus `fontName`. Runs of either kind are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color or face. +Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication, masks, Canvas Size, Crop, Trim and an Image Size that changes only the resolution preserve it; destructive pixel operations, resampling included, rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). From version 11, optional `fontRuns` lists letters set in another face than `fontName`: the same `location` and `length`, plus `fontName`. Runs of either kind are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color or face. ### Layer effects -An optional `effects` record contains independent `stroke`, `shadow`, `colorOverlay`, `innerShadow`, `outerGlow` and `innerGlow` records. Stroke carries a size (0–500 layer pixels), a color, an opacity and an `inside` flag choosing which side of the edge it sits on; drop shadow and inner shadow each carry an angle, a distance, a blur, a color and an opacity; color overlay carries a color and an opacity; outer glow and inner glow each carry a size (0–500 layer pixels), a color and an opacity. Each supports optional `enabled` visibility (missing means visible); hidden effects keep all parameters and remain listed under their layer. Effects, including their visibility, are saved and participate in document undo. Canvas previews run on a serial background worker with a shared pixel budget; exports render the full-resolution effects. A record omitting an effect means that layer does not have it, so older readers see the effects they understand and ignore the rest. +An optional `effects` record contains independent `stroke`, `shadow`, `colorOverlay`, `innerShadow`, `outerGlow` and `innerGlow` records. Stroke carries a size (0–500 layer pixels), a color, an opacity and an `inside` flag choosing which side of the edge it sits on; drop shadow and inner shadow each carry an angle, a distance, a blur, a color and an opacity; color overlay carries a color and an opacity; outer glow and inner glow each carry a size (0–500 layer pixels), a color and an opacity. Each supports optional `enabled` visibility (missing means visible); hidden effects keep all parameters and remain listed under their layer. Effects, including their visibility, are saved and participate in document undo. They stay with their layer through every edit, pixel edits included; Image Size scales their sizes, distances and blurs with the image. Canvas previews run on a serial background worker with a shared pixel budget; exports render the full-resolution effects. A record omitting an effect means that layer does not have it, so older readers see the effects they understand and ignore the rest. diff --git a/src/Document/EditorSession+Model.cpp b/src/Document/EditorSession+Model.cpp index 9998c2e..128e152 100644 --- a/src/Document/EditorSession+Model.cpp +++ b/src/Document/EditorSession+Model.cpp @@ -24,7 +24,8 @@ ImageLayer::ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedI : id(record.id), asset(std::move(asset)), transform(record.transform), name(record.name), isVisible(record.isVisible), parentID(record.parentID), isGroup(record.isGroup == true), opacity(record.opacity.value_or(1)), blendMode(record.blendMode.value_or(LayerBlendMode::normal)), maskSourceID(record.maskSourceID), mask(std::move(mask)), - adjustment(record.adjustment), effects(record.effects) + adjustment(record.adjustment), shape(LayerShape::loaded(record.shape, this->asset)), effects(record.effects), + text(LayerText::loaded(record.text, this->asset)) { } diff --git a/src/Document/EditorSession+Projects.cpp b/src/Document/EditorSession+Projects.cpp index 09d50c1..69a79e6 100644 --- a/src/Document/EditorSession+Projects.cpp +++ b/src/Document/EditorSession+Projects.cpp @@ -32,12 +32,6 @@ void EditorSession::installProject(const ProjectSnapshot &snapshot, const QStrin m_guideDrag = std::nullopt; m_transformEdit = std::nullopt; m_document = CanvasDocument(snapshot); - // Install loads shapes and text; Swift's resize rebuild does not. - for (size_t index = 0; index < m_document->layers.size(); ++index) { - ImageLayer &layer = m_document->layers[index]; - layer.shape = LayerShape::loaded(snapshot.manifest.layers[index].shape, layer.asset); - layer.text = LayerText::loaded(snapshot.manifest.layers[index].text, layer.asset); - } setActiveLayerID(snapshot.manifest.activeLayerID); m_projectPath = path; m_renamingLayerID = std::nullopt; diff --git a/src/Document/FloatingSelection.cpp b/src/Document/FloatingSelection.cpp index 12bcf26..ad806e1 100644 --- a/src/Document/FloatingSelection.cpp +++ b/src/Document/FloatingSelection.cpp @@ -133,9 +133,8 @@ void EditorSession::mergeFloatingTransform(const TransformEdit &edit, const Floa layer.transform = merged.transform; layer.isGroup = false; layer.mask = merged.mask; - // Swift rebuilds the layer without shape, effects and text. + // Swift rebuilds the layer without shape and text. layer.shape = std::nullopt; - layer.effects = std::nullopt; layer.text = std::nullopt; m_document->selection = moved; setActiveLayerID(source.id); diff --git a/src/Document/HueSaturation+Session.cpp b/src/Document/HueSaturation+Session.cpp index f8e97a0..1484a5d 100644 --- a/src/Document/HueSaturation+Session.cpp +++ b/src/Document/HueSaturation+Session.cpp @@ -165,9 +165,8 @@ void EditorSession::finishHueSaturationCommit() beginEdit(QStringLiteral("Hue/Saturation")); ImageLayer &layer = m_document->layers[index]; layer.asset = ImportedImage(made.pixels->image, made.pixels->thumbnail.value_or(made.pixels->image), layer.name); - // Swift rebuilds the layer without shape, effects and text. + // Swift rebuilds the layer without shape and text. layer.shape = std::nullopt; - layer.effects = std::nullopt; layer.text = std::nullopt; endEdit(); } diff --git a/src/Document/LayerEffects.cpp b/src/Document/LayerEffects.cpp index e2911e7..929f670 100644 --- a/src/Document/LayerEffects.cpp +++ b/src/Document/LayerEffects.cpp @@ -85,6 +85,24 @@ bool LayerEffects::isValid() const && (!innerShadow || innerShadow->isValid()) && (!outerGlow || outerGlow->isValid()) && (!innerGlow || innerGlow->isValid()); } +// Swift 1.4.9's Scale Styles for Image Size. +LayerEffects LayerEffects::scaled(double factor) const +{ + const auto scale = [factor](double value, double limit) { return std::min(limit, value * factor); }; + LayerEffects result = *this; + if (result.stroke) + result.stroke->size = scale(stroke->size, 500); + if (result.shadow) + result.shadow->distance = scale(shadow->distance, 5000), result.shadow->blur = scale(shadow->blur, 500); + if (result.innerShadow) + result.innerShadow->distance = scale(innerShadow->distance, 5000), result.innerShadow->blur = scale(innerShadow->blur, 500); + if (result.outerGlow) + result.outerGlow->size = scale(outerGlow->size, 500); + if (result.innerGlow) + result.innerGlow->size = scale(innerGlow->size, 500); + return result; +} + std::vector<LayerEffectKind> LayerEffects::kinds() const { std::vector<LayerEffectKind> result; diff --git a/src/Document/LayerEffects.h b/src/Document/LayerEffects.h index 7a94129..3ad935c 100644 --- a/src/Document/LayerEffects.h +++ b/src/Document/LayerEffects.h @@ -127,6 +127,8 @@ struct LayerEffects { void setEnabled(bool enabled, LayerEffectKind kind); // The effects that are switched on. LayerEffects visible() const; + // Resampled by `factor`: sizes, distances and blurs, within range. + LayerEffects scaled(double factor) const; friend bool operator==(const LayerEffects &, const LayerEffects &) = default; }; diff --git a/src/Document/Levels.cpp b/src/Document/Levels.cpp index 3d1aee3..ae21b22 100644 --- a/src/Document/Levels.cpp +++ b/src/Document/Levels.cpp @@ -361,9 +361,8 @@ void EditorSession::finishLevelsCommit() beginEdit(QStringLiteral("Levels")); ImageLayer layer = current; layer.asset = made.asset; - // Swift rebuilds the layer without shape, effects and text. + // Swift rebuilds the layer without shape and text. layer.shape = std::nullopt; - layer.effects = std::nullopt; layer.text = std::nullopt; m_document->layers[index] = layer; endEdit(); diff --git a/src/Document/SelectionEdits.cpp b/src/Document/SelectionEdits.cpp index 7cf5183..46f110e 100644 --- a/src/Document/SelectionEdits.cpp +++ b/src/Document/SelectionEdits.cpp @@ -211,9 +211,8 @@ void EditorSession::finishInvert() const ImportedImage asset(*result.image, PixelAdjust::thumbnail(*result.image), current.name); beginEdit(QStringLiteral("Invert")); m_document->layers[size_t(index)].asset = asset; - // Swift rebuilds the layer without shape, effects and text. + // Swift rebuilds the layer without shape and text. m_document->layers[size_t(index)].shape = std::nullopt; - m_document->layers[size_t(index)].effects = std::nullopt; m_document->layers[size_t(index)].text = std::nullopt; } endEdit(); diff --git a/src/IO/CanvasResizer.cpp b/src/IO/CanvasResizer.cpp index 0dc463c..bd3b842 100644 --- a/src/IO/CanvasResizer.cpp +++ b/src/IO/CanvasResizer.cpp @@ -41,7 +41,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions .opacity = layer.opacity, .blendMode = layer.blendMode, .maskFile = layer.maskFile, .maskEnabled = layer.maskEnabled, .maskSourceID = layer.maskSourceID, .adjustment = layer.adjustment, .maskPlacement = placement, - .maskLinked = layer.maskLinked, .shape = layer.shape, + .maskLinked = layer.maskLinked, .shape = layer.shape, .effects = layer.effects, .text = layer.text}); } std::map<QUuid, ImportedImage> images = snapshot.images; diff --git a/src/IO/ImageResizer.cpp b/src/IO/ImageResizer.cpp index 873a587..46db7e7 100644 --- a/src/IO/ImageResizer.cpp +++ b/src/IO/ImageResizer.cpp @@ -42,6 +42,8 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions if (options.width * options.height > DocumentLimits::maxSurfacePixels) throw ProjectError(ProjectError::Kind::tooLarge); const double sx = double(options.width) / double(old.width), sy = double(options.height) / double(old.height); + // Effects scale by the factors' mean; text and shapes flatten. + const double effectScale = std::sqrt(sx * sy); // Guides scale with the pixels. if (manifest.guides) { for (CanvasGuide &guide : *manifest.guides) @@ -108,7 +110,8 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions .opacity = layer.opacity, .blendMode = layer.blendMode, .maskFile = layer.maskFile, .maskEnabled = layer.maskEnabled, .maskSourceID = layer.maskSourceID, .adjustment = layer.adjustment, .maskPlacement = placement, - .maskLinked = layer.maskLinked}); + .maskLinked = layer.maskLinked, + .effects = layer.effects ? std::optional(layer.effects->scaled(effectScale)) : std::nullopt}); } qCInfo(lcIO) << "resized" << old.width << "x" << old.height << "to" << options.width << "x" << options.height; return {.manifest = manifest, .images = images, .masks = masks}; diff --git a/tests/LayerEffectsSurviveTests.cpp b/tests/LayerEffectsSurviveTests.cpp new file mode 100644 index 0000000..ef1f8b2 --- /dev/null +++ b/tests/LayerEffectsSurviveTests.cpp @@ -0,0 +1,162 @@ +#include "Document/CanvasSize.h" +#include "Document/ImageTrim.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageResizer.h" +#include "SessionFixtures.h" +#include <QtTest> + +// Swift 1.4.9's: effects, live text and shapes outlast size changes. +class LayerEffectsSurviveTests : public QObject { + Q_OBJECT +private slots: + void canvasSizeKeepsEffectsTextAndShapes(); + void cropKeepsEffectsTextAndShapes(); + void trimKeepsEffectsTextAndShapes(); + void resolutionOnlyImageSizeKeepsEffectsTextAndShapes(); + void imageSizeKeepsEffectsScaledWithTheImage(); + void aStretchScalesByTheFactorsMeanWithinRange(); +}; + +namespace { +LayerEffects effects() +{ + LayerEffects made; + made.stroke = StrokeEffect{.size = 4}; + made.shadow = ShadowEffect{.distance = 20, .blur = 10}; + made.outerGlow = OuterGlowEffect{.size = 8}; + return made; +} + +// What a layer carries beyond its pixels. +struct Extras { + std::optional<LayerEffects> effects; + std::optional<LayerTextStyle> text; + std::optional<LayerShapeStyle> shape; + friend bool operator==(const Extras &, const Extras &) = default; +}; + +std::vector<Extras> extras(const EditorSession &session, const std::vector<QUuid> &ids) +{ + std::vector<Extras> found; + for (const QUuid id : ids) { + const ImageLayer layer = layerWith(session, id); + found.push_back({layer.effects, layer.liveText() ? std::optional(layer.liveText()->style) : std::nullopt, + layer.liveShape() ? std::optional(layer.liveShape()->style) : std::nullopt}); + } + return found; +} + +// A clear canvas holding text and a shape, with effects. +std::unique_ptr<EditorSession> textAndShape(std::vector<QUuid> &ids) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(200, 120); + session->selectTool(NavigationTool::type); + session->beginText(QPointF(30, 60)); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Text"); + if (!session->applyText(draft)) + throw std::runtime_error("no text"); + const QUuid text = session->activeLayerID().value(); + session->selectTool(NavigationTool::shape); + session->beginShape(QPointF(120, 30)); + session->dragShape(QPointF(170, 80), false, false); + session->finishShape(); + const QUuid shape = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, text).effects = record(snapshot, shape).effects = effects(); }); + ids = {text, shape}; + const std::vector<Extras> before = extras(*session, ids); + if (shape == text || !before[0].text || !before[1].shape) + throw std::runtime_error("no live text or shape"); + return session; +} +} + +void LayerEffectsSurviveTests::canvasSizeKeepsEffectsTextAndShapes() +{ + std::vector<QUuid> ids; + const auto session = textAndShape(ids); + const std::vector<Extras> before = extras(*session, ids); + session->applyDocumentSize(CanvasResizer::resize(session->projectSnapshot().value(), CanvasSizeOptions{.width = 260, .height = 160, .anchor = 4}), + QStringLiteral("Canvas Size")); + QCOMPARE(session->document().value().width, 260); + QVERIFY(extras(*session, ids) == before); +} + +void LayerEffectsSurviveTests::cropKeepsEffectsTextAndShapes() +{ + std::vector<QUuid> ids; + const auto session = textAndShape(ids); + const std::vector<Extras> before = extras(*session, ids); + session->selectTool(NavigationTool::crop); + session->setCropRect(QRectF(10, 10, 180, 100)); + bool done = false; + session->commitCrop([&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session->document().value().width, 180); + QVERIFY(extras(*session, ids) == before); +} + +void LayerEffectsSurviveTests::trimKeepsEffectsTextAndShapes() +{ + std::vector<QUuid> ids; + const auto session = textAndShape(ids); + const std::vector<Extras> before = extras(*session, ids); + session->applyDocumentSize(ImageTrim::trim(session->projectSnapshot().value(), TrimOptions()).value(), QStringLiteral("Trim")); + QVERIFY(session->document().value().width < 200); + QVERIFY(extras(*session, ids) == before); +} + +void LayerEffectsSurviveTests::resolutionOnlyImageSizeKeepsEffectsTextAndShapes() +{ + std::vector<QUuid> ids; + const auto session = textAndShape(ids); + const std::vector<Extras> before = extras(*session, ids); + session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), ImageSizeOptions{.width = 200, .height = 120, .resolution = 300})); + QCOMPARE(session->document().value().resolution, 300.0); + QVERIFY(extras(*session, ids) == before); +} + +// Resampled pixels take their effects with them, as Scale Styles. +void LayerEffectsSurviveTests::imageSizeKeepsEffectsScaledWithTheImage() +{ + std::vector<QUuid> ids; + const auto session = textAndShape(ids); + session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), ImageSizeOptions{.width = 100, .height = 60, .resolution = 72})); + QCOMPARE(session->document().value().width, 100); + for (const Extras &kept : extras(*session, ids)) { + QVERIFY(!kept.text && !kept.shape); + const LayerEffects scaled = kept.effects.value(); + QVERIFY(scaled.stroke.value().size == 2 && scaled.outerGlow.value().size == 4); + QVERIFY(scaled.shadow.value().distance == 10 && scaled.shadow.value().blur == 5 && scaled.shadow.value().angle == effects().shadow.value().angle); + } +} + +void LayerEffectsSurviveTests::aStretchScalesByTheFactorsMeanWithinRange() +{ + LayerEffects made; + made.stroke = StrokeEffect{.size = 300}; + made.shadow = ShadowEffect{.distance = 3000, .blur = 300, .opacity = 0.4}; + made.innerShadow = InnerShadowEffect{.distance = 10, .blur = 4}; + made.innerGlow = InnerGlowEffect{.size = 6}; + made.colorOverlay = ColorOverlayEffect{.opacity = 0.3}; + const LayerEffects doubled = made.scaled(2); + // Each held to the range the effect accepts. + QVERIFY(doubled.stroke.value().size == 500 && doubled.shadow.value().distance == 5000 && doubled.shadow.value().blur == 500); + QVERIFY(doubled.innerShadow.value().distance == 20 && doubled.innerShadow.value().blur == 8 && doubled.innerGlow.value().size == 12); + QVERIFY(doubled.shadow.value().opacity == 0.4 && doubled.colorOverlay == made.colorOverlay && !doubled.outerGlow && doubled.isValid()); + // Stretched 4 by 1: the mean factor is 2. + EditorSession session; + session.createDocument(20, 20); + QImage pixels(20, 20, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::red); + session.insert(ImportedImage(pixels, pixels, QStringLiteral("Red"))); + const QUuid id = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).effects = effects(); }); + session.applyImageSize(ImageResizer::resize(session.projectSnapshot().value(), ImageSizeOptions{.width = 80, .height = 20, .resolution = 72})); + QVERIFY(layerWith(session, id).effects == effects().scaled(2)); + QCOMPARE(layerWith(session, id).effects.value().stroke.value().size, 8.0); +} + +QTEST_MAIN(LayerEffectsSurviveTests) +#include "LayerEffectsSurviveTests.moc" diff --git a/tests/LayerEffectsTests.cpp b/tests/LayerEffectsTests.cpp index 18f3157..1d899e0 100644 --- a/tests/LayerEffectsTests.cpp +++ b/tests/LayerEffectsTests.cpp @@ -75,7 +75,7 @@ private slots: void decodingRefusesWhatSwiftRefuses(); void effectsSaveLoadAndExportWithTheirLayer(); void duplicatesAndBrushCommitsKeepThem(); - void rebuildsCopiesAndResizesDropThem(); + void rebuildsAndResizesKeepThem(); }; void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder() @@ -308,28 +308,28 @@ void LayerEffectsTests::duplicatesAndBrushCommitsKeepThem() QVERIFY(session->activeLayer().value().effects == every()); } -void LayerEffectsTests::rebuildsCopiesAndResizesDropThem() +// Swift 1.4.9 (`94e90bd`): every rebuild keeps them. +void LayerEffectsTests::rebuildsAndResizesKeepThem() { - // Swift rebuilds these layers from lists that leave effects out. - const auto dropped = [](const std::function<void(EditorSession &)> &edit, const char *name) { + const auto kept = [](const std::function<void(EditorSession &)> &edit, const char *name) { const std::unique_ptr<EditorSession> session = withEffects(); edit(*session); - return session->history.undoName() == QString::fromLatin1(name) && !session->activeLayer().value().effects; + return session->history.undoName() == QString::fromLatin1(name) && session->activeLayer().value().effects == every(); }; - QVERIFY(dropped([](EditorSession &session) { QVERIFY(awaited([&](std::function<void()> done) { session.invertPixels(std::move(done)); })); }, "Invert")); - QVERIFY(dropped([](EditorSession &session) { + QVERIFY(kept([](EditorSession &session) { QVERIFY(awaited([&](std::function<void()> done) { session.invertPixels(std::move(done)); })); }, "Invert")); + QVERIFY(kept([](EditorSession &session) { session.beginLevels(); LevelsSettings settings; settings.ranges[0].outputWhite = 100; session.updateLevels(settings, false); QVERIFY(awaited([&](std::function<void()> done) { session.commitLevels(std::move(done)); })); }, "Levels")); - QVERIFY(dropped([](EditorSession &session) { + QVERIFY(kept([](EditorSession &session) { session.beginHueSaturation(); session.updateHueSaturation(HueSaturationSettings(90), false); QVERIFY(awaited([&](std::function<void()> done) { session.commitHueSaturation(std::move(done)); })); }, "Hue/Saturation")); - QVERIFY(dropped([](EditorSession &session) { + QVERIFY(kept([](EditorSession &session) { session.applySelection(rectPath(QRectF(2, 2, 6, 4)), SelectionMode::replace, QStringLiteral("Select")); QVERIFY(awaited([&](std::function<void()> done) { session.beginSelectionTransform(std::move(done)); })); LayerTransform draft = session.transformEdit().value().draft; @@ -337,10 +337,13 @@ void LayerEffectsTests::rebuildsCopiesAndResizesDropThem() session.previewTransform(draft); session.commitTransform(); }, "Transform Selection")); - // Both resizers rebuild their records without them, as Swift's. + // The canvas resizer carries them; the image resizer scales them. const ProjectSnapshot snapshot = withEffects()->projectSnapshot().value(); - QVERIFY(!ImageResizer::resize(snapshot, ImageSizeOptions{.width = 40, .height = 20, .resolution = 72}).manifest.layers[0].effects); - QVERIFY(!CanvasResizer::resize(snapshot, CanvasSizeOptions{.width = 30, .height = 12}).manifest.layers[0].effects); + QVERIFY(CanvasResizer::resize(snapshot, CanvasSizeOptions{.width = 30, .height = 12}).manifest.layers[0].effects == every()); + LayerEffects doubled = every(); + doubled.stroke->size = 14, doubled.shadow->distance = 24, doubled.shadow->blur = 12; + doubled.innerShadow->distance = 6, doubled.innerShadow->blur = 4, doubled.outerGlow->size = 66, doubled.innerGlow->size = 28; + QVERIFY(ImageResizer::resize(snapshot, ImageSizeOptions{.width = 40, .height = 20, .resolution = 72}).manifest.layers[0].effects == doubled); } QTEST_GUILESS_MAIN(LayerEffectsTests) diff --git a/tests/ShapeLayerTests.cpp b/tests/ShapeLayerTests.cpp index 7801201..ef7253b 100644 --- a/tests/ShapeLayerTests.cpp +++ b/tests/ShapeLayerTests.cpp @@ -379,9 +379,13 @@ void ShapeLayerTests::resizersKeepOrDropTheShape() QCOMPARE(canvas.manifest.layers[1].shape, snapshot.manifest.layers[1].shape); const ProjectSnapshot image = ImageResizer::resize(snapshot, {.width = 200, .height = 160, .resolution = 72}); QVERIFY(!image.manifest.layers[1].shape); - // Swift's rebuild after a resize leaves every shape out. + // Since 1.4.9 the rebuild keeps the shape live. session->applyDocumentSize(canvas, QStringLiteral("Canvas Size")); QCOMPARE(session->history.undoName(), QString("Canvas Size")); + QVERIFY(session->document().value().layers[1].liveShape()); + QCOMPARE(session->document().value().layers[1].shape.value().style, snapshot.manifest.layers[1].shape.value()); + // Image Size resamples it into pixels. + session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), {.width = 240, .height = 180, .resolution = 72})); QVERIFY(!session->document().value().layers[1].shape); } diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 359e3fe..1825962 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -267,6 +267,7 @@ compositor_test(AdjustmentLayerTests) compositor_test(AdjustmentEditorTests) compositor_test(AdjustmentPanelTests) compositor_test(LayerEffectsTests) +compositor_test(LayerEffectsSurviveTests) compositor_test(OuterGlowTests) compositor_test(InnerGlowTests) compositor_test(ColorOverlayTests) diff --git a/tests/TypeStoreTests.cpp b/tests/TypeStoreTests.cpp index a1cccd0..357248f 100644 --- a/tests/TypeStoreTests.cpp +++ b/tests/TypeStoreTests.cpp @@ -230,8 +230,11 @@ void TypeStoreTests::resizersKeepOrDropTheText() const ProjectSnapshot canvas = CanvasResizer::resize(snapshot, {.width = 500, .height = 400}); QCOMPARE(canvas.manifest.layers[1].text, snapshot.manifest.layers[1].text); QVERIFY(!ImageResizer::resize(snapshot, {.width = 800, .height = 600, .resolution = 72}).manifest.layers[1].text); - // Swift's rebuild after a resize leaves the text out. + // Since 1.4.9 the rebuild keeps the text live. session->applyDocumentSize(canvas, QStringLiteral("Canvas Size")); + QVERIFY(session->document().value().layers[1].liveText()); + QCOMPARE(session->document().value().layers[1].text.value().style, snapshot.manifest.layers[1].text.value()); + session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), {.width = 1000, .height = 800, .resolution = 72})); QVERIFY(!session->document().value().layers[1].text); }