OmaPhoto 1.0.0: the Linux port of Compositor

A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit 20f56cd7ba
564 files changed
+100804

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
# A version tag builds the three packages, each on its own system, and publishes them.
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
deb:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# The dev image carries ONNX Runtime, its notices and the model.
- run: docker build -t omaphoto-dev -f Dockerfile.dev .
- run: |
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src omaphoto-dev sh -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \
-DOMAPHOTO_BUNDLE_ORT=ON -DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx &&
cmake --build build --target omaphoto && cd build && cpack -G DEB'
mv build/omaphoto_*.deb omaphoto_amd64.deb
- uses: actions/upload-artifact@v4
with:
name: deb
path: omaphoto_amd64.deb
rpm:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: docker build -t omaphoto-fedora scripts/distro/fedora
- run: |
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src omaphoto-fedora sh -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \
-DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx &&
cmake --build build --target omaphoto && cd build && cpack -G RPM'
mv build/omaphoto-*.x86_64.rpm omaphoto.x86_64.rpm
- uses: actions/upload-artifact@v4
with:
name: rpm
path: omaphoto.x86_64.rpm
arch:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: docker build -t omaphoto-arch scripts/distro/arch
# makepkg refuses root; a user of the runner's id builds.
- run: |
docker run --rm -v "$PWD:/src" omaphoto-arch sh -c "
useradd -u $(id -u) builder && chown -R builder /src &&
su builder -c 'cd /src/packaging/arch && makepkg -f'"
mv packaging/arch/omaphoto-[0-9]*-x86_64.pkg.tar.zst omaphoto-x86_64.pkg.tar.zst
- uses: actions/upload-artifact@v4
with:
name: arch
path: omaphoto-x86_64.pkg.tar.zst
publish:
needs: [deb, rpm, arch]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
merge-multiple: true
path: packages
- working-directory: packages
run: sha256sum omaphoto_amd64.deb omaphoto.x86_64.rpm omaphoto-x86_64.pkg.tar.zst > SHA256SUMS
- env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" packages/* --title "OmaPhoto ${GITHUB_REF_NAME#v}" \
--notes "Install with the script for your system: see the README's Install section."
+7
View File
@@ -0,0 +1,7 @@
/build/
/build-*/
/result
/packaging/arch/src/
/packaging/arch/pkg/
/packaging/arch/*.pkg.tar.zst
/packaging/arch/u2net.onnx
+152
View File
@@ -0,0 +1,152 @@
# OmaPhoto — agent notes
OmaPhoto is the Linux port of Compositor, the macOS app at https://github.com/robbietilton/Compositor. That repository's Swift tree (`Compositor/`, `CompositorTests/`) is the specification; this repository mirrors it. Keep a checkout of it beside this one to read the Swift sources; never edit it for OmaPhoto work.
## Trees
- The repository root: the app. C++20, Qt 6 Widgets (floor Qt 6.4), CMake, Qt Test. CPU raster only: QPainter plus the C kernels in `kernels/`, copied unchanged from Compositor's `Compositor/Rendering/*.c` and compiled in place.
- `packaging/`: the desktop entry, MIME type, icons (Compositor's app icon) and the Arch `PKGBUILD`; `flake.nix` at the root for NixOS.
- `scripts/install/`: one install script per distribution family, each fetching the latest release's package.
- `.github/workflows/release.yml`: a `v*` tag builds the DEB, RPM and Arch package and publishes them.
- `TASKS.md`: ordered task list and progress truth. Its entries before 11.4b speak of the port's old home, `linux/` inside Compositor's repository.
Names: the app is OmaPhoto (binary `omaphoto`, app id `io.github.ZacharyZhang_NY.OmaPhoto`, logging `omaphoto.*`, build options `OMAPHOTO_*`). Compositor's project format stays whole (`.comp`, `application/x-compositor-project`), so projects move between the two apps; C++ types keep their Swift twins' names (`CompositorApp`, `CompositorMenus`).
## Mirror rule
Each Swift file has one Linux twin under `src/` in the same folder (`Document`, `Rendering`, `UI`, `IO`) with the same type and function names. Tests in `tests/` twin `CompositorTests/`. A Swift file over 500 lines splits as `Name+Part`. A Swift extension of `EditorSession` declares its members in `Name+Session.h` (`EditorSession+Brush.h` and `EditorSession+Projects.h` for the extensions named after the session): C++ declares members inside the class, so `EditorSession.h`, the twin of `EditorSession.swift` with every stored property, includes each fragment inside its body. A fragment holds declarations alone, its extension's private helpers under `private:`; state stays in `EditorSession.h`, as Swift keeps stored properties in the class. Qt types are used directly: `QImage` for `CGImage`, `QRectF`, `QPointF`, `QTransform`, `QUuid`. No wrapper types. Nothing outside `UI/` includes `UI/` headers, except where the Swift source does.
Platform adaptations, each forced by a missing Qt analog:
- `ImportedImage` exposes pixels through `image()`, size through `size()`, identity through `identity()`. CoreGraphics backs a painted layer's `CGImage` with a lazy data provider; Qt has none. `image()` flattens painted tiles on first use, so commits, display and history never flatten a layer. Never cache its result in a model type.
- Image identity (`===` on `CGImage`) is `ImageIdentity`: the `RasterSnapshot` pointer for painted assets, `QImage::cacheKey()` otherwise.
- Layer pixels are `QImage::Format_RGBA8888_Premultiplied`; masks are `QImage::Format_Grayscale8`. `BrushRaster::context` makes both.
- Contexts are top-left already, so the CoreGraphics y-flips vanish. `BrushRaster::draw` has no `mask` flag: both CoreGraphics paths are a plain copy in Qt. It takes an optional source part instead of `CGImage.cropping(to:)`, which Qt cannot do without a copy.
- `BrushRaster::visibleRect` stands in for `boundingBoxOfClipPath`.
- Images are drawn into explicit target rects. The positional `drawImage(x, y, image)` scales by the source's device pixel ratio; CoreGraphics never does.
- `DownsampleCache` halves with its own Lanczos-3 filter (vImage has no Linux twin), threaded with QtConcurrent. Swift's label-overloaded `image(_:drawnAt:)` and `image(_:level:)` are `imageDrawnAt` and `imageAtLevel`. Its constructor takes a pixel budget so eviction can be tested; the app uses `shared()`.
- `LayerRenderer::draw` takes Swift's labelled arguments as an `Options` struct. QPainter has no mask clip, so a masked layer is composed aside in device pixels; the mask is drawn with the same placement into a zero-filled `Alpha8` surface and multiplied in 1:1 (`DestinationIn`), so nothing outside the mask survives; the result is composited with the layer's opacity and blend mode. Work that can throw finishes before `save()`. `LayerRenderer` needs a painter on a `QImage`.
- CoreGraphics multiplies nested mask clips inside the context; QPainter has no soft clip. `FolderMaskClip::apply` multiplies a folder mask into device-sized `Alpha8` coverage, `FolderMaskClip::draw` hands each layer the product of its folders' coverage (memoized per folder), and draw routines take it as `LayerRenderer::Options::clip`.
- `TiledLayerRenderer` keeps Swift's pieces, interiors, margins and piece cache, but not its clip surgery. CoreGraphics needs a transparency layer, cleared interiors, subtracted clip paths and device snapping so tiles blend exactly once; the twin draws the base, then for each piece clears and redraws the device pixels whose centres lie in its interior, a half-open `QRegion` computed per scanline, so neighbouring pieces share no pixel and leave none out at any rotation, and the region stays hard while the image keeps its antialiased outline (a path clip turns soft once `Antialiasing` is on). Where an interior ends with its image, the region opens one device pixel outward for that outline, measured through the painter's whole device transform, so it holds at any reduction and under unequal painter scales; the interior already spans the halving overhang there. All of it happens inside `LayerRenderer::composite`, which applies opacity, blend mode, mask and folder clip once. QPainter steps texture coordinates in fixed point, so a piece can differ from the whole image by 3/255 on noise (the tests allow 4 upright, 12 rotated as Swift does). A layer edge off the halving grid differs more, on macOS too: a piece keeps the halving spill past that edge, a whole image crops it (10/255 measured at 0.05×).
- Live strokes reuse that design. `LayerRenderer::composite` takes an optional `veil` callback that repaints the mask coverage after the mask is drawn (coverage starts full when the layer has no mask). `drawStroke` draws the unchanged pixels, then its pieces; where a piece reaches past the old bounds it repaints the coverage with a mask piece (white, then the old mask), so paint there is revealed, where Swift draws a second unmasked pass with holes. `drawMaskStroke` draws the layer once and lets mask pieces replace their interiors of the coverage, where Swift redraws the layer clipped to each piece. A commit's larger pieces take over stretches of the old outline that the base drew during the stroke; the base crops its halving spill, so that outline changes by up to 12/255 on commit, on macOS too.
- `LayerRenderer::drawBrushPreview` replaces Swift's even-odd clips with `CompositionMode_Source` copies into the aside surface, hard-edged (`composite` runs with nearest sampling and the layer's interpolation quality); the source stays clipped to the grid and a raster's base to the source bounds, as Swift's clips keep them. Its `BrushPreview` struct carries Swift's labelled arguments. A raster counts as a source by itself, so a caller never flattens a painted asset just to pass a non-nil image. It throws `std::logic_error` on an empty pixel grid, where Swift would divide by zero.
- `LiveMaskRenderer` has no `bounds`. Swift allocates its coverage and group surfaces over the clip's bounding box, one pixel per user unit, and clips with CoreGraphics; the twin's surfaces are the painter's device in device pixels, drawn with the painter's device transform, because clip coverage must be what `Options::clip` takes. `drawOwn` receives that coverage, and an outer clip (folder masks) multiplies in; a clipping stack composites its group one to one through `LayerRenderer::composite`, under the painter's own opacity as in Swift; `draw` saves and restores the painter around `drawOwn`, exceptions included. The canvas keeps this cheap by painting each dirty rect through an image surface (item 4.4). The constructor takes a pixel budget so tests can reach the limit; passing it, or failing to allocate, logs a warning on `omaphoto.rendering` where Swift skips in silence.
- `AdjustmentSurface::draw` allocates what the painter can show in device pixels (Swift: one pixel per user unit), lets the body draw in the painter's own coordinates, and draws the surface back under the painter's own opacity, composition mode and clip, as `CGContext.draw` inherits them. A surface that cannot be allocated, or passes the budget, logs a warning and draws nothing. On Linux it also gives the hand-blended HSL modes a backdrop on devices without readable pixels (widgets, pixmaps).
- `ImageExporter` is a namespace of functions. Swift's actor only serialises calls; the twin keeps no state, and callers run it off the UI thread. Files are `QString` paths. PNG and JPEG go through `QImageWriter` with an sRGB tag and the resolution as dots per metre; the JPEG preview is read back with `QImageReader`, scaled to 1000 pixels at most. `write` replaces the file through `QSaveFile` and throws `std::runtime_error` with the system's message, where Swift rethrows the Cocoa error. Exports log on `omaphoto.io`.
- `ProjectStore` is a namespace too. Swift's synthesized `Codable` is written by hand in `IO/ProjectStore+Manifest.cpp`: the same keys, the same refusals (wrong types, missing required keys, non-canonical UUIDs), unknown keys ignored, `CGPoint` and `CGSize` as two-number arrays, UUIDs upper case (`uuidString`), two-space indentation so the 4 MiB limit bites where Swift's does. Asset files carry the upper-case names macOS writes; Linux file systems tell the cases apart.
- Saving stages a hidden sibling folder, syncs its files and folders to disk, then swaps it in with `renameat2(RENAME_EXCHANGE)`; a first save is a plain `rename`. Where the system knows no exchange (FAT sticks, old kernels) the store logs a warning and replaces in two moves. Either way the parent folder is synced after the swap, the old project is kept until that sync succeeds, and a failed sync puts it back before the error is thrown. If the way back fails too, nothing is deleted: the old project stays where the swap left it (the hidden sibling, or its `.old` neighbour after two moves) and a critical log on `omaphoto.io` names that path. `ProjectReplaceTests` defines `rename`, `renameat2`, `fsync` and `read` in the test executable, where they take libc's place, to order and fail each call. There is no `NSFileCoordinator`. A missing manifest is `ProjectError(invalid)` and a missing asset `missingImage`, where Swift passes on a Cocoa error; a failed write throws `std::runtime_error` with the system's message. `QFile` reports no error when a read fails after partial data, so the manifest's byte count is checked against the file's size. The manifest's `version`, `width` and `height` are `qint64`, Swift's `Int`, read from Qt's exact integer (`QJsonValue::toVariant` as `LongLong`), never through a double: a wild number decodes and is refused by its own rule, with every digit of its value in the version error. One fact of Qt's parser stays, checked against Swift 6.0.3's decoder: a number token outside Int64, or one with a fraction or an exponent, passes through a double inside Qt. So the 1,024 integers below Int64's minimum and `-9.223372036854775808e18` read as the minimum where Swift refuses them, and `9007199254740993.0` loses its last digit where Swift keeps it. Every such value lies outside each valid range; only the kind or the number of the refusal differs. `ProjectTests` pins one. A whole number with a fraction or an exponent (`5.0`, `5e0`) reads exactly, as Swift's slow path reads it; a token that underflows a double (`1e-400`) fails Qt's whole parse as an illegal number, where Swift reads zero. Only a Grain seed (9.5a), every `UInt32` of which is valid, turns that into a project Swift opens and the twin refuses; `aSeedReadsWhatQtsParserReads` pins it.
- Qt refuses image decodes over 128 MB (`QImageReader::allocationLimit`). `ImageImporter::liftAllocationLimit` lifts that limit for the store and the importer: their own pixel budgets are checked from the file headers before any pixel is read.
- `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.
- `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; `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<QUrl>` (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`.
- 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<QWidget>`), 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; `markSaved()` needs no signal of its own, because every path from a save goes on to free the project, and that notifies. `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `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).
- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayer` bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor's `?? centre` fallback is dead (the copied layer is always among the copies) and is a checked read instead.
- `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own.
- `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`.
- `EditorSession::drawLiveComposite` lives in `LiveLayerMask.cpp`, as in Swift, and draws the document as the export does, in document pixels, through the displayed transforms, placements and blend modes; `LayerMaskSessionTests` compares it with the export on every render.
- `ContentView` is one project's editor area, a `QWidget` that compares what it shows with what the session holds on every `changed()`; the window around it is `ProjectWorkspaceView` (4.3c), which owns the toolbar that SwiftUI lets `ContentView` declare. `ToolHeaderBar` carries `Q_OBJECT`: Qt 6.11's `findChild` refuses a class without one at compile time. Every tool has a bar of `ToolHeaderStyle::height`, so the canvas never jumps: a tool whose bar has not arrived yet shows an empty `ToolHeaderBar`, the idle and Eyedropper bars are titles alone, Hand and Zoom share one `NavigationToolHeader`, and a bar put in late is shown at once (a layout shows a late child only from the event loop). The welcome is made anew each time the document goes, as Swift's `if document == nil` makes a fresh view, so its fields and its clipboard suggestion start over. A rail button is a `QToolButton` that toggles itself on a click before the session answers, so the click's slot sets every button again from the session, which a refused `selectTool` leaves silent. `NewCanvasSheet` takes Return and Enter from anywhere on the sheet through widget shortcuts, as SwiftUI's `.defaultAction` does: `setDefault` alone reaches only a `QDialog`. Fonts are set in pixels, Swift's points on a display of one: `setPointSize` would grow them by the screen's DPI. Swift's `.secondary` text is the palette's `PlaceholderText`, its orange warning the palette's `BrightText`, which the theme of 4.5 fills; dividers are `Mid`. Labels and hints say Ctrl and Alt for ⌘ and ⌥. The status bar's hint is an eliding label and the one item that gives way when the window is narrow, as SwiftUI truncates it; zoom and size print through the English locale, as Swift's interpolation groups digits (`1,200 × 800 px`). Swift's last branch gives the Eyedropper the Zoom tool's hint; the twin says the same. Hints and icons that follow tool state (marquee and lasso kinds, brush and smear modes, shape kinds) arrive with that state. `NavigationToolHeader` applies on Return and Escape directly and again when focus leaves, where Swift applies only on the loss of focus: a Qt widget in a window that is not active loses no focus. Text that is no number reads as zero through `toDouble`, which the `> 0` test refuses, so no `ok` flag is read; `inf` reads as a number and the viewport refuses it. `NewCanvasSheet::clipboardDimensions` reads a copied picture's size from its header through `QImageReader` (no pixel is decoded), PNG before TIFF as in Swift, sides swapped for a stored quarter turn. The importer is a `QFileDialog` for existing files, and the two alerts are `QMessageBox`es opened over the view; each is taken down again when the session's flag or error is cleared from elsewhere, as a SwiftUI binding would dismiss it.
- `LayersPanel` twins `LayersPanel.swift`: the heading with its count, `LayerAppearanceControls`, the rows or the empty words, and the footer's `QToolButton`s (new layer, folder, `LayerMaskMenu`, the effects menu since 9.6d, the adjustment menu since 9.5d, delete). Its width is `ContentView`'s `layersPanelWidth`, kept in `QSettings` under Swift's `AppStorage` key; a stored width outside `minimumWidth…maximumWidth` is the default, logged. `PanelResizeEdge` lives in `ContentView.cpp` as in Swift: an 8-point grip with the split cursor over a `Mid` hairline, dragged left to widen, whole points, within range. Tests enable `QStandardPaths::setTestModeEnabled`, which sends `QSettings` to `~/.qttest`. The delete button is Swift's `deleteLayerOrMask` (a chosen effect goes first since 9.6a, then a selected mask, then the selection; since 9.6d its words name a chosen effect first).
- `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the cell's `actions()`, named as Swift's menu items. Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's and the mask button's icons; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception.
- Drags in the list (`NativeLayerList+Drag.cpp`): a row pressed and moved past `startDragDistance` starts a `QDrag` carrying the row's id, or its whole selection's, one id a line under `ProjectWorkspace::layerType` (Swift writes one pasteboard item a row); Alt at the press offers `CopyAction` alone under `CanvasView::duplicateCursor`, as the table's source mask does, and the drop then duplicates. `dropTarget` is Swift's `validateDrop`: a folder's middle half takes the drop in, the rest lands above the nearer row edge or below every row, a copy with a folder among the rows is refused, every row must pass `canPlaceLayer`; `acceptDrop` is `acceptDrop`, the place worked out once, the order reversed into a folder, the moved rows kept selected, one `Move Layer(s)` or `Duplicate Layer(s)` step. Swift refuses a drag from another table by its source; the twin's drag carries its list's token under `sourceType` and a drop takes only its own (Qt's synthetic drop events carry no `source()`, and two projects opened from one file share their layers' ids, so neither would do). A press arms the drag with its modifiers, on the row or on a thumbnail (a thumbnail press that targets goes on to arm the row, as Swift's `mouseDown` falls through to the table); every left press first drops whatever an earlier press armed, then arms unless it clipped (Swift returns after clipping) or was Ctrl's or Shift's; only a move with the left button held past `startDragDistance` starts the drag. An Alt press on a mask watches the left button's release application-wide (a popup opened mid-press takes it), as the eye does; a release seen on a `QWindow` is not counted, since the window sees the button's own release before the button does, and another button's release leaves the press, as Swift waits for the left one. A plain drag offers Move and Copy with Move preferred, so a project target can take the Copy Swift proposes (a tab, the canvas and the New button set `CopyAction` on a valid enter, move and drop and ignore the rest); the list itself duplicates only when a copy is offered alone (Alt at the press), whatever Ctrl makes Qt propose, and sets Move or Copy itself. A drag held near the viewport's edge scrolls the list by the overshoot into a 16-point margin and keeps scrolling on a timer until the pointer moves back inside or the drag ends (a leave or a drop stops it, so no tick reads a drag that is gone), as the table autoscrolls (Wayland sends no moves while the pointer holds still); `autoscrolling()` says whether the timer runs, for the tests. An Alt click that clips sets the cursor for the next click at once, as Swift's refresh after toggling. `LayerColumn` paints the indicator: a two-point accent line between rows or the accent-filled folder row. A mask's Alt-drag carries its layer's id under `maskType`, read back through `ProjectWorkspace::layerID` (a payload that is no id names no mask, an all-zero id included), and lands on the row under the pointer through `canCopyMask`. `QDrag::exec` needs the platform's drag loop, which `offscreen` has not: `LayerDragGestureTests` defines both `QDrag::exec` overloads in the test executable, where they take libQt6Gui's place (as `ProjectReplaceTests` does with libc), record what the drag offered and return `IgnoreAction`, so presses, thresholds and actions are tested to the call; `LayerDragTests` holds the drops and `LayerDragFixtures.h` what both share. The eye and thumbnail buttons live in `NativeLayerList+Buttons.cpp`. With Alt held the viewport's cursor follows the pointer (`cursorFor`): the clipping cursor over a row's strip (create or release, drawn as paths like the symbols, refused where `canToggleClippingMask` says no), the duplicate cursor over the rest of a row that is no folder and over a mask thumbnail, the arrow otherwise and with Ctrl; Alt or Ctrl pressed anywhere, and hover over any row or control (every cell and its children track the mouse), reach an application event filter installed while the list is shown, as Swift's `flagsChanged` monitor and tracking area; the list keeps the last hover point, so a modifier change is judged where the pointer sits (`QCursor::pos` is no witness offscreen), and a hover's own modifiers are the event's.
- `EditorSession::duplicateActiveLayer` and `duplicateLayer` (`Document/SelectionClipboard.cpp`, the rest of that extension arrives with 7.4) came forward from 5.1 for the Alt-drag: the copy is the layer with a new id and " copy" after its name, inserted above it, one `Duplicate Layer` step; `duplicateLayer` selects, duplicates and places the copy inside one transaction, refusing folders and a place `canPlaceLayer` refuses.
- `LayerMerge.cpp`, `LayerFlip.cpp` and `Filters.cpp` twin the Swift files of those names (`Filters.cpp` holds `PixelFilter::trimmed` alone until 9.x). `trimmed` returns a `Trimmed` struct where Swift returns a tuple, and reads the alpha bounds straight from a premultiplied `RGBA8888` image, drawing any other format into one first, where Swift always draws into a context. `mergePlan` is a private `MergePlan` struct. `mergeLayers` renders through `drawLiveComposite` into a document-sized context and takes the trimmed image's own size through `ImageLayer(asset, origin)`, where Swift also assigns `trimmed.transform`: at 1:1 the two are equal by construction. Its insertion index counts the kept layers before the anchor, so Swift's `?? layers.count` and the clamp are dead and left out; the hierarchy check stays (a folder selected together with its own child would put the result in a removed folder), and Swift's beeps are warnings on `omaphoto.rendering`, as is a context that cannot be allocated (Swift returns in silence). `flipLayers` reads the group box and the active layer through `value()`, since `canTransform` has proved both, and drops Swift's `ids.isEmpty` guard for the same reason; `commitTransform` has already run `finishOpacityEdit`, so Swift's second call in `mergeLayers` and `flipLayers` is left out. `LayerTransform::mirrored` is declared in `LayerTransform.h` and defined in `LayerFlip.cpp`, as Swift's extension. `beginDuplicateTransform` keeps a private `TransformDuplicate`; every target yields a copy (`duplicateActiveLayer` refuses nothing a target passes), so Swift's `copies.isEmpty` branch and its `copy != id` test are left out. `commitTransform` ends the duplicate after its own transaction (`endDuplicateTransform`, Swift's `defer`) on the layer path only: `selectLayers` has just made a copy active, so no mask is selected and the mask path never carries one. `cancelTransform` removes the copies, restores the selection and ends it. The Layer menu's Duplicate Layer entry calls `duplicateActiveLayer`, what Swift's `layerViaCopy` does without a selection (7.4 brings the selection); Merge shows `mergeTitle` and Flip Layer follows `canTransform`.
- The Move tool on the canvas (6.1): `TransformOverlay` is no widget. Swift's overlay view exists so that a selection never redraws the pixels; a transparent Qt child would repaint the canvas beneath it anyway, so the canvas draws the handles and guides itself, after its pixels, and `synchronizeDisplay` repaints only the union of the old and the new `drawnRect` (a guide spans the canvas) when the geometry or `snapGuides` changed, leaving its return value to the pixel state. `TransformOverlayGeometry` keeps Swift's handles, rotation grip and `hit`; `showsRotation` and the corner constructor arrive with 6.2. `resizeCursor` maps Swift's frame positions onto Qt's four resize shapes (right, bottom right, bottom, top right). `EditorCanvas+Transform.cpp` holds the drags (`beginTransformDrag`, `dragTransform`, `endTransformDrag`, `cancelTransformDrag`, Swift's `resignFirstResponder`), `transformPressLayer` and `transformCursor`; Ctrl stands for Cmd in picking, Ctrl+Shift extends the selection. The drag's cursor is kept in `m_dragCursor` until the release and outranks Space, where Swift disables the window's cursor rects; hover reaches `updateCursor` through mouse tracking and the last hover point, the application filter reads Alt and Ctrl as it read Alt, and a shown canvas reads both from the keyboard. The release synchronizes the display, so a Ctrl+T edit's guides go without a commit. `moveCursor` (the arrow with Swift's four-way badge) and `rotationCursor` (two arrows round a ring, for the SF symbol) are drawn as paths; the distort cursor arrives with 6.2. `SnapGuides` is a struct where Swift has a tuple, a public member like `viewport`, unannounced; `commitTransform` and `cancelTransform` clear it. `transformSnapTargets` and `snappedMove` live in `Document/Crop.cpp`, Swift's file. `TransformEdit.persistent` sits last, so the positional initialisers keep their order; a drag's edit and a duplicate's are not persistent. `transformAutoSelect`, `showsTransformControls` and `locksTransformRatio` are getters and setters that notify. Escape and Return end a drag with its edit and put the hover cursor back. A press that never moves commits an unchanged edit, which the history drops. The pixel tests hide the controls (`CanvasDisplayTests`, `HighDpiCanvasTests`) and preview through a drag's edit, since Swift's overlay never reaches its `draw` tests.
- Distort (6.2): `DistortWarp` keeps Swift's tests of usability and convexity, `homography` (a projective `QTransform` where Swift returns a closure, Qt dividing by the third row as Swift by `w`), `warp`, `warpTrimmed`, `carried` and `warpMask`. Core Image's perspective filter is `QTransform::quadToQuad` on the image's pixel rect and a `drawImage` under it, antialiased and smooth; a folded shape is two triangles under their affine maps, each a hard device-pixel region of the pixels whose centres the triangle holds, built per scanline from each edge's ends ordered by y, the left bound inclusive, so the halves partition the diagonal and meet a fractional shared edge with one x (a path clip turns soft once the image draws antialiased, and `QRegion`'s integer polygons round the vertices), copied through `BrushRaster::draw` without interpolation, as Swift's. In `distortPreview` a mask warp that throws is caught on its own, as Swift's `try?`: a linked mask covering its layer then draws none, a placed one falls back, with a folded shape, to its stored placement clipped over the warp. Its `Warped` and `Trimmed` structs stand for Swift's tuples; `mapPath` arrived with 8.2b and `distortedEffects` arrived with 9.6c. Swift's `DistortTests` call a bow tie unusable, but the source folds one as two triangles (both halves have area), so the twin's tests use a collapsed corner for the refused shape, as the source refuses it. `TransformEdit.corners` sits last, after `persistent`. The session's `distortPreview` and `maskDistortPreview` are `const` with mutable caches (`DistortPreviewCache` by layer, `MaskDistortPreviewCache`), keyed by `ImageIdentity` where Swift compares `CGImage` identity, because the canvas reads them from a const session; a warp that throws leaves a cached empty result, as Swift's `try?`. `commitDistort` catches `ProjectError` and `ExportError` into `brushError` as Swift catches its errors; `commitTransform` runs it after the mask path, before the plain one, and ends a duplicate after it. `commitMaskTransform` takes the corners path with `Distort Layer Mask`, `displayedMaskPlacement` its corners cases, `nudgeLayer` moves the corners with the draft. The overlay's corners constructor sets `showsRotation` false, `hit` skips the grip then, and the drawing leaves the stem and disc out; `geometry` returns the corners' geometry for a group or the edited layer. The canvas's `DisplayState` carries the edit's corners, so a moved corner repaints; `drawOwn` draws `distortPreview` first and takes `maskDistortPreview` for the mask. A Ctrl-drag on a handle begins the distortion and drags in `distort` mode with the white `distortCursor`; the cursor shows it over a handle under Ctrl or once distorted. The inspector's numbers rest while corners are set, as Swift's `.disabled`.
- `TransformInspector` is the Move tool's `ToolHeaderBar`: Auto Select and Show Controls as `QCheckBox`es, the numbers in a `QScrollArea` without bars (Swift's horizontal `ScrollView`, which takes the row's spare room and keeps its content at its own size: the row's end stretch rests, the content ends in a stretch of its own), the ratio lock a checkable `QToolButton` with the panel's link icon, the sampling a `QComboBox`, Flip H and V and Cancel and Apply as `QPushButton`s. `TransformValueField` applies as typed (Swift's `isFinite` test is left out: `previewTransform` refuses every transform an infinite number makes), shows the session's number while unfocused and puts it back on `FocusOut`, steps with Up and Down (Shift ten) writing the number it applied, keeps its typing while a menu or popup borrows the focus (`MenuBarFocusReason`, `PopupFocusReason`), and on Return or Escape hands the canvas the keys as Swift's `onSubmit` and `onExitCommand`; the pending edit waits there for the canvas's Return or Escape. From any other control of the bar, Return applies and Escape cancels, Swift's default and cancel actions. The lock's icon is redrawn on `PaletteChange`, as the panel's. Swift's `.id(activeLayerID)` remake is `synchronize` clearing any focus of its own when the active layer changes. `resizeBox` is Swift's `resize`, whose name would hide `QWidget::resize`; its `number >= 1` guard and the scale's `number > 0` are left out: a side under a pixel is a box `previewTransform` refuses. The Layer menu's Transform Layer entry calls `beginTransform` (Swift's `transformCommand` does the same without a selection; 7.4 brings the selection) and the View menu's Show Transform Controls is checkable under Ctrl+H, enabled in the Move tool with a document.
- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered). The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes.
- The Marquee and the Lasso on the canvas (7.2): Swift's lasso gestures live in `EditorCanvas+Selection.cpp`, the selection cursors in `EditorCanvas+Cursors.cpp`. The canvas tracks Shift as it tracks Alt and Ctrl (the application filter sees the key from any window, as Swift's `flagsChanged` monitor), and a key change reshapes a Marquee draft and the cursor's badge at once. A press with a selection tool is `lassoMouseDown`; a move is `moveSelectionTool` (a hover moves a polygonal draft's rubber band and refreshes the held keys, as Swift's `mouseMoved`); a release is `releaseSelectionTool`; the draft's keys and the outline's nudges are `selectionKey`, ahead of every other key; losing focus is `endSelectionGestures`. A double click closes a polygonal outline from `mouseDoubleClickEvent`, since Qt delivers the second press there (its first press adds the corner, as Swift's first `mouseDown` does), never while Space pans. A held M or L's repeats are swallowed, as Swift ignores them, so a tool chosen meanwhile stays. An outline drag keeps `moveSelectionCursor` under Space, as the transform drag keeps its own. The ants are a 120 ms `QTimer` stepping `TransformOverlay::antsPhase`, repainting `selectionRect` alone (the outline's own rect, not the handles beside it); `drawnRect` spans the handles, the outline and the draft, the last two stroked antialiased with butt caps (`Qt::FlatCap`, as CoreGraphics dashes: Qt's square caps would stretch each dash by the pen's width) as CoreGraphics strokes them (a 1-point line on whole coordinates blurs over two rows at 1×, on macOS too); `synchronizeDisplay` repaints the overlay when the displayed selection or draft changes, refreshes the overlay's cached rect with every full repaint (a pan moves the outline) and refreshes the cursor when a kind or the displayed mode changes. The two timers are named children of the canvas (`antsTimer`, `marqueeAutoscroll`), as the session's busy timer is, so tests read whether they run. Autoscroll is a 16 ms `QTimer` with Swift's speed curve; each step translates the viewport, drags the draft or the outline again under the still pointer, notifies and synchronizes. `moveSelectionCursor`, `selectionCursor` and `wandCursor` are drawn as paths: the crosshair, the icon haloed by eight white offsets and the badge, and the wand with Swift's own strokes (hot spot 7, 7 at the sparkle, the stick down right, the badge at 17, 5), since `QCursor::pixmap` is empty for a shape cursor; the icons are `Rendering/SelectionIcons`, which the rail's `ToolIcons` and the cursors share, so nothing in `Rendering/` includes `UI/`. `SelectionIcon` is that header's enum, not nested in `CanvasView`. Ctrl over the selection shows the move pointer until 7.4 brings the scissors; the wand's click and cursor are 7.3's. `pressMarqueeKey` and `pressLassoKey` choose their tools and nothing more, as Swift's functions do: Swift's `mKeyChoosesTheMarqueeThenSwitchesItsShape` and `lKeyChoosesTheLassoThenSwitchesItsMode` and the bar's `help` texts expect a second press to switch the kind, which no Swift code does (its `cycleToolMode` on Tab does, 8.x), so the twin tests the code's rule and its tooltips name no key. `LassoControls` is the selection tools' `ToolHeaderBar`: Swift's segmented pickers are checkable `QToolButton`s in a `QButtonGroup`, the amounts are `SelectionAmountField`s (a `QLineEdit` taking digits alone, every typed value clamped to 1–500 as Swift's binding clamps it, stepped by arrows within the bounds, Return and Escape handing the canvas the keys, a menu's borrowed focus keeping the typing as `TransformValueField` does), the wand's controls arrive with 7.3. `ContentView::hint` takes a `ToolIconKind` for the ellipse Marquee's and the polygonal Lasso's words, and the rail's buttons repaint with the kind.
- The Magic Wand (7.3): `WandPixels.c` compiles in place beside the other kernels, and `Document/MagicWand.cpp` wraps it as Swift's `MagicWand` does: `select` draws the image into a premultiplied `RGBA8888` context (the kernel's byte order, rows top down), floors the point, clamps the tolerance and hands the mask to `wand_mask`; `outline` frees the kernel's arrays through `unique_ptr` and builds a `WindingFill` path from its loops. `MagicWandError` carries Swift's two messages (`what()`), `tooDetailed` for the kernel's −2 and `memory` for −1. The session's `magicWand` takes a completion callback and runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, like the invert; the worker catches `MagicWandError` and `ExportError`, and the UI side applies the outline only while the same document is open (its id, as Swift compares), New with no match deselecting, New with a match `setSelection` without the canvas clip (Swift's shortcut), the other modes `applySelection`. `wandSample` draws the composite through `drawLiveComposite` or the active layer's own pixels through `LayerRenderer::draw` at 1:1, a folder or blank layer as clear; a context that cannot be allocated logs and refuses, as Swift's `try?` returns nil. `wandSettings` is a struct with a setter that notifies; `title` and `radius` stand for Swift's computed properties of `WandSampleSize`. The canvas hands a wand press to `magicWand` with the press's mode, and a click inside the selection to `magicWand` in New mode where another tool would deselect. `LassoControls` shows the wand's controls with the wand alone: the tolerance is a `SelectionAmountField` bounded 0–255, the sample size a `QComboBox` of Swift's titles, the source two checkable buttons, Contiguous a `QCheckBox`; `changeWand` rewrites one field of the settings. The mutant that pins `m_thisLayer` checked is equivalent, since the exclusive group's later `m_allLayers` line decides, as with the marquee's kinds.
- The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and counts the clipboard's `dataChanged` signals from the first copy as Swift reads the pasteboard's change count: a paste puts our pixels back in place while the count still matches, else takes another app's image through `QMimeData::imageData`, converted to sRGB first when it carries another colour space (CoreGraphics converts as it draws; `QPainter` copies the numbers), then to the working format, tagged sRGB and centred. A cleared clipboard has no `QMimeData` at all, so `canPaste` checks the pointer. A working copy that cannot be allocated is a warning, as Swift's `try?` beeps there, where a failed Layer via Copy sets `brushError` as Swift's `catch` does. The clipboard is touched only by a copy or a paste, so session tests without one run under `QTEST_GUILESS_MAIN` still; `SelectionClipboardTests` runs under `QTEST_MAIN`. `addPixelLayer`'s shape and text arrived with 8.8 and 8.9a. Swift's `cutSelection`, `PixelMove` and the whole `FloatingSelection` extension need `clearSelectedPixels` and the brush raster, so the cut arrived with 8.2 and the rest with 8.2b, where `canEditLayers` and `ProjectWorkspace::canSwitch` gained their `pixelMove` terms. The Edit menu's Copy and Paste check when chosen, as Swift's (a text field keeps its own, as with Select All); Copy Merged follows `canCopyMerged`; the Layer menu's Ctrl+J is `layerViaCopy`, titled by the selection and enabled as Swift's: `canCopyPixels`, or no selection on an editable layer that is no folder. Swift's beeps are warnings on `omaphoto.app`.
- Content-Aware Fill (7.5): `ContentFill.c` compiles in place beside the other kernels; `Document/ContentFill.cpp` includes its header in angle brackets, because the twin's own header shares the name. `ContentFill::run` draws the job's image into a premultiplied context and takes the pixels to make from `PixelAdjust::coverage` (the selection through the inverse mapping, as Swift clips the mask context and fills white), the kernel's 0 is `ContentFillError(noSource)` with Swift's message and its −1 `ExportError(render)`. `Document/Filters.h` holds what Swift's `Filters.swift` holds as far as it exists: `FilterKind` (`rawValue` and `isAutomatic` are free functions; the other kinds came with 9.4a), `FilterJob` (its settings, scale and seed came with 9.4a), `PixelFilter::run` (the kind's filter, then `PixelAdjust::blend` through the selection) and `FilterEdit`, a plain class in a `std::optional` of the session, since only the session writes it; its `id` stands for Swift's object identity, so a preview lands on the edit it was made for. `FilterEdit` grows through `grow` (the blur's growth and the preview copy came with 9.4a). The session's extension is `Filters+Session.cpp`: Swift's file holds 473 lines, its twin's two halves about 500. `canContentAwareFill` without its `isMaskSelected`, `filterEdit` and `isEmpty` terms (`canAdjustColors` holds all three; it sits in `HueSaturation.cpp`, the twin of its Swift file, alone until 9.3, with the terms that exist), `beginFilter` (`cancelCrop` waits for the crop), `updateFilter` (its settings came with 9.4a), `cancelFilter`, `commitFilter` with a completion callback, `finishFilterCommit` (Swift's guards, the mask carried through `LayerMask::clipImage`). Swift's `previewTask` is one `QFutureWatcher`: `renderFilterPreview` marks the edit preparing and starts the worker, or leaves the job pending behind the one running and runs it when that finishes, a run counting as running until its finish has run (`m_filterRendering`, as Levels' queue); a commit that awaits the preview (`isAutomatic`) is kept in `m_filterCommitWaiting` with its edit's id and called again from `finishFilterPreview` (every waiter is, and one whose edit has gone just returns); the wait is on `preparing`, which only the delivery clears; Preview off is 9.4a's; the workers catch `ContentFillError` and `ExportError` alone. `brushRevision` moves where Swift's does (a delivered preview, Preview switched either way, a cancel, a commit's end), so the canvas's `DisplayState` sees each new preview. `displayedTransform` and `displayedMaskPlacement` take the grown transform while the edit previews, and the canvas draws `previewImage` in the layer's place. `canEditLayers` and `ProjectWorkspace::canSwitch` read `filterEdit`. `FilterSheet` (a `filterPanel`'s content since 9.4b): the kind's words, Preview, the error in `BrightText`, "Limited to the selection" while a selection exists (never for an adjustment layer's editor, since 9.5d), Cancel, the spinner with "Working…" or "Applying…", and OK as the default button, disabled while an automatic kind prepares or failed, so Return then does nothing; Escape and the close button cancel, as Swift's `cancelAction` and `onClose`. The Edit menu's Content-Aware Fill… is Shift+Backspace (Swift's `.delete` is that key) and follows `canContentAwareFill`; the canvas takes Shift with Backspace or Delete ahead of the selection keys, as Swift's `keyDown` does.
- The brush on the CPU (8.1): `Rendering/BrushCoverage` twins `MetalBrushCoverage`: the shader's maths in float, pixel for pixel (the centre sample, the hard tip's antialiased silhouette over `max`, the soft tip's optical density under Gauss–Legendre quadrature per segment at the deposition spacing, the 20 cap, the permanent paint apart from the replaceable tail), one band of 32 rows a QtConcurrent task where Metal dispatched one tile a thread group. A `Tile` keeps the permanent density alone: Swift's preview buffer is the gray coverage context itself, written row by row, so nothing is copied back. A tile that cannot be allocated is `ExportError(render)`. The class with its `shared` device is a namespace. `Document/BrushStroke` keeps Swift's continuous path alone: the dab fallback for machines without Metal (`walk`, `dab`, `tip`, `stamp`, `gridTip`, `falloff`, `drawTail`, `removeTail`, `curve`, `tailBackup`, `distanceToNext`, `dirtyTiles`) is dead where the CPU coverage always exists, and `spacingFraction` stays because the integral reads it. `BrushStroke.swift` runs to 899 lines, so the twin splits: `BrushStroke.cpp` (`BrushRaster`, with `fill`, source-over of a colour at coverage × clip × alpha byte by byte on RGBA and gray alike, 1:1 by construction), `BrushStroke+Stroke.cpp` (the grid, samples, the centripetal Catmull–Rom subdivided within 0.2 px, keys, tiles, coverage and `publish`) and `BrushStroke+Commit.cpp` (`transform(for:)`, `paintSnapshot`, `commitInput`, `BrushCommit` as a namespace, since Swift's actor only serialises). The grid is worked out first (`Grid`) so the const members can follow Swift's `let`s; Swift's guards throw `ProjectError(tooLarge)` as one, and the extent is judged as a double before it becomes an `int`, because past `int` the conversion is undefined where Swift's `Int` just holds the number. The stroke's source is the asset itself (`m_source`, an `ImportedImage`), so a painted layer draws from its tiles and never flattens; `commitInput` hands the asset on, and `BrushCommit::render` draws it the same way. A selection clips the context in Swift; `fill` takes the selection's `PixelAdjust::coverage` on the tile's grid as its clip and rounds coverage × clip × alpha once, as CoreGraphics composites a clipped context. A commit whose crop cannot be allocated is `ExportError(render)`, as Swift's `cropping` returning nil; so is a tile's context that cannot be copied on publish (`QImage::detach` leaves a null image where CoreGraphics' `makeImage` returns nil). Tile keys (row × columns + column) are `qint64`, Swift's `Int`: a small layer scaled to a point across a big canvas passes `int`. A tile is rebuilt whole on every publish (`dirtyTiles` only ever narrowed the dab fallback's rebuilds); `base` is the tile's first pixels, kept apart by QImage's copy on write. `BrushSettings` holds the tip and colour; `erasing`, `healing` and `healingMode` arrive with 8.2 and 8.4, `selectionClip` is here since `publish` reads it, `clone`, `isBlur`, `replacesWithClone`, `editName`, `fillGradient`, `fill`, `clearPixels`, `paintCanvas`, `liftSelection`, `moveLifted` and `heal` with their items. `BrushIntersectionTests` runs the one path where Swift loops over `useGPU`; `BrushTests` holds the stroke-level halves of Swift's session tests, driven on a `BrushStroke` made from a layer, with `LayerRenderer::drawBrushPreview` as the live view and `BrushCommit::render` as the commit; the session halves arrive with 8.2. The early return of a segment past the tip's radius is an equivalent mutant: the density there is zero anyway.
- The session's brush (8.2): `EditorSession+Brush.cpp` twins Swift's extension as far as the tools exist: `canPaint` with the terms that exist (the adjustment term since 9.5a), `makeRasterEdit` (the budgets, the selection clip; `pixelLimit` is `qint64`, Swift's `Int`, and may run below zero when other layers use more than the budget), `beginBrush` for the Brush (Spot Healing joins with 8.4, the other brush tools with their items; erase and the mask's black or white as Swift sets them), `continueBrush`, `shiftLineStart`, `cancelBrush`, `finishBrushImmediately` (busy, it waits; otherwise it flushes and commits the snapshot), `finishBrush` (Swift's `async` wrapper is a plain call here), `commitPaintSnapshot` (the tiles land at once with their undo entry; a mask that covered the old grid is regrown through `RasterSnapshot::replacing`), `commitRasterEdit` (a completion callback, the render through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ExportError` alone, the UI side keeping Swift's guards and the mask's `isEnabled` as it stands at commit), `usesOpacityKeys`, `typeOpacityDigit` (Swift's uptime is `std::chrono::steady_clock` unless the caller passes a time, as the tests do), `changeBrushHardness`, `changeBrushSize`. `brushStroke` is a `std::unique_ptr` read through a pointer, since a stroke's const members forbid copying; `brushSettings`, `brushMode` (`BrushToolMode` in `Document/SmudgeLiquify.h`, whose smear and liquify arrive with 8.6) and `maskPaintWhite` are getters and setters that notify; `lastBrushPoint` and `pendingOpacityDigit` are private structs. `brushRevision` exists after all, Swift's counter, because the canvas's `DisplayState` must change with every step of a stroke, whose patches it does not hold; a step that changes nothing else repaints the stroke's `dirtyDocumentRect` alone, in view points two points out (`strokeDirtyRect`), as Swift's `setNeedsDisplay` does, and any other change repaints the view. While a folder's mask is painted, the clip for the layers inside comes from the edit's tiles (`liveFolderMaskClip`, Swift's `liveMaskEdit`): `drawBrushPreview` draws the old mask and the patches, nearest, into gray coverage the size of the device, hidden outside the mask's grid, and that multiplies into the folder coverage; the old mask, or a painted mask's base, is halved near the size drawn first, as Swift's `displayImage` does, so a fine mask keeps its shade while painted. The session's private structs (`Decoded`, `Wanded`, `RasterMade` and the rest) live in `EditorSession+Jobs.h` as `SessionJobs` and are aliased in the class, so the header stays under 500 lines. The gates (`canInvert` and `canAdjustColors` among them), `selectLayer`, `selectLayers`, `selectTool` and `selectLayerTarget` refuse while a stroke lasts, `canStartProjectOperation` too, and `importImages` finishes a stroke first, as Swift's do; the tips' parking by tool family arrived with 8.5. `SelectionEdits.cpp` gains `canEditPixels`, `fillSelection` (the palette's colours are black and white until 8.10's `ColorPalette`; on a mask the background is the opposite of `maskPaintWhite`, as Swift's `paletteColor`), `clearSelectedPixels`, `deleteKeyPressed` (its effects term since 9.6a) and `applyPixelEdit`; `cutSelection` sits in `SelectionClipboard.cpp`. `BrushStroke` gains `erasing` in its settings (`BrushRaster::fill` takes `DestinationOut` for it, Swift's blend mode on the context; a mask never erases, so that mode on a gray context is a `logic_error`), `paintCanvas` (the base redrawn, the body under the inverse mapping and a hard canvas clip, then `PixelAdjust::blend` through the selection where Swift clips), `fill` and `clearPixels`; `placedMaskPreview` is declared in `BrushStroke.h` and defined in `LayerMask.cpp`, as Swift's extension, and a preview that cannot be made logs on `omaphoto.rendering` where Swift's `try?` is silent. On the canvas a pixel stroke draws its whole grid placed by `paintTransform` through `TiledLayerRenderer::drawStroke`, a mask stroke through `drawMaskStroke` under the layer's transform, and a mask on its own placement draws the layer through `placedMaskPreview`; the mask's clip takes the stroke's owner as Swift's `base` does. Delete and Backspace without Ctrl, Alt or Meta are `deleteKeyPressed` on the canvas and, plain, in the layer list; Shift with them stays the fill's. The Edit menu gains Cut (Ctrl+X, a text field's own), Fill with Foreground Color (Alt+Backspace) and Fill with Background Color (Ctrl+Backspace), which in a text field delete a word or the line as Swift's `deleteWordBackward` and `deleteToBeginningOfLine` (the selection when there is one, else backwards from the caret, so nothing goes at the start) (reachable from the opacity field alone: a rename or an empty document disables them, as `canEditPixels` does), and Clear Selection Pixels. `tests/BrushFixtures.h` shares the fixtures; `BrushSessionTests` and `PixelEditTests` hold the session halvLine truncated
- Moving and floating selected pixels (8.2b): `PixelMove` is a class in `Document/SelectionEdits.h`, Swift's `final class`. Its raster is a `std::shared_ptr<BrushStroke>`, because the canvas draws it while the commit runs, so `commitRasterEdit` takes a `std::shared_ptr<const BrushStroke>` (the brush hands its stroke over); its `offset` starts at zero, since a default `QSizeF` is −1 by −1. `beginPixelMove`, `movePixels`, `finishPixelMove` (a completion callback; the move stays set until the commit lands, so pixels and outline never show at the old place), `cancelPixelMove` and `nudgePixels` (Swift's beep is a warning on `omaphoto.app`) live in `SelectionEdits.cpp`; `displayedSelection` follows a pixel move and a floating edit. `BrushStroke::liftSelection` draws the source into the region, from its tiles when painted, and multiplies the selection's coverage in (`PixelAdjust::coverage` under `DestinationIn`) where Swift clips; `moveLifted` rebuilds each tile from its base, cuts the hole through the same coverage under `DestinationOut` (not for a copy) and places the lifted pixels on whole pixels: a landing between pixels (a scaled or turned layer) is resampled first by its fraction, bilinear and rows in parallel (`BrushRaster::shiftedByFraction`, shared with Clone Stamp), because QPainter snaps an unscaled image to whole pixels (probed on 6.4), where Swift draws it with `.high`. `FloatingSelection.cpp` twins Swift's file: `FloatingTransform` rides in `TransformEdit.floating` (last) as a `std::shared_ptr<const FloatingTransform>`, because the edit is copied freely and holds the whole document from before; `canTransformSelection`, `transformCommand`, `beginSelectionTransform` (a completion callback: the outer `Transform Selection` edit opens, `clearSelectedPixels` runs, and its callback inserts the `Floating Selection` layer), `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, and `FloatingMerge::merge`, a namespace with a `Merged` struct for Swift's tuple. The merge writes pixels, transform and mask into the source layer in place, where Swift rebuilds the layer from listed fields: the fields that list leaves out do not exist yet (`TASKS.md`, "Session hooks owed"). `DistortWarp::mapPath` came forward from 6.2's list: it walks the path's elements (Qt stores a quad as a cubic) and keeps the fill rule. `beginPixelMove` and `canTransformSelection` leave out Swift's `pixelMove == nil`, `transformEdit == nil` and `!selection.isEmpty`, which `canPaint` reads already (through `canEditPixels`). `displayedMaskPlacement`'s `floating == nil` term is dead and left out: the floating layer is made without a mask, and the open edit blocks adding one; `beginDistort` edits the edit in place, so `floating` carries over. The session's private structs sit in `SessionJobs`, now a private base struct of `EditorSession` (the aliases went; `MergePlan` and `TransformDuplicate` joined it), so the header stays under 500 lines. On the canvas a Ctrl-press inside the selection with a selection tool lifts and moves its pixels, Ctrl-Alt copies them, under `movePixelsCursor` (the arrow with scissors drawn as paths and haloed, where Swift badges the SF symbol) or the duplicate cursor, kept under Space as every drag keeps its own; `updateCursor` picks the outline and pixel drags' cursors ahead of Space, so `lassoCursor`, its only caller's helper, checks neither (Swift's has other callers); Ctrl-arrows nudge the selected pixels in any tool, Shift ten, never with Alt or Meta (macOS's Option and Control), ahead of the Move tool's arrows; losing focus cancels a pixel drag; `drawOwn` draws the move's raster as a stroke, and a step repaints the whole view, as Swift's (only a brush stroke repaints its rect). The Layer menu's Ctrl+T is `transformCommand`, titled `Transform Selection` while `canTransformSelection` holds and enabled by either gate. `canEditLayers`, `canInvert`, `canAdjustColors` and `ProjectWorkspace::canSwitch` read `pixelMove`.
- The brush on the canvas (8.3): `EditorCanvas+Brush.cpp` holds Swift's gestures. A press begins a stroke, Shift drawing on from `shiftLineStart` through the press point; a drag continues it, and Shift holds an axis that the first three pixels from its anchor settle (the press under Shift, else the last pixel reached), the pixel waiting at the anchor until then. Swift clears the axis when Shift is let go and again when a new anchor is set; the first is dead and left out. The release reaches its own point, then `finishBrushImmediately` commits. A busy project holds the stroke: a drag, the release, Escape and a lost focus leave it. A stroke goes on only under a drag with the left button, as Swift's `mouseDragged` (a hover after a busy release paints nothing), and wheels and pinches neither pan nor zoom while it lasts, as Swift's `scrollWheel` and `magnify` refuse. A right-drag with a brush tool, no stroke and no Space, sizes the tip from the press (two view points a pixel, rounded, 1 to 2000), or with Shift sets hardness over 200 points, each putting the other back; the circle stays at the press. `BrushCursorOverlay` is no widget, like `TransformOverlay`: the canvas draws the circle last, white 2.5 under black 1, and while hardness is dragged a dashed inner ring (four points on, three off, given in pen widths as Qt counts dashes); `update` returns the old and new rings three points out, as Swift's `setNeedsDisplay`. Swift's `hardness > 0` is dead (a zero ellipse strokes nothing) and left out. The circle follows the pointer, hover included but not a pan (Swift's `mouseDragged` pans first), hides under Space, on leaving and on a lost focus, and shows with the brush tools alone. The canvas's `event` takes Tab before the focus chain (Shift-Tab stays the chain's) for `cycleToolMode`, which switches the Marquee's and the Lasso's shapes, the Brush's mode and Spot Healing's type (the other tools' modes arrive with them); B and E choose the Brush and its mode; a digit reaches `typeOpacityDigit` only where `usesOpacityKeys` holds, else it goes on to the parent, as Swift's `super.keyDown`; brackets step size and hardness, and the application filter takes them from any control but a text field unless the canvas has focus, so a focused canvas's `keyPressEvent` re-reads the modifiers as Swift's `keyDown` does. The layer list takes Tab and Shift-Tab (Swift's table cycles on both), B, E and digits alike. `BrushControls` is every brush tool's `ToolHeaderBar`: Paint and Erase in an exclusive group, size (1–2000), hardness and opacity as a slider in thousandths beside a whole-percent field (opacity from 1%), the sliders following the session under `QSignalBlocker` so nothing rounded is written back, the fields stepping with Up and Down from the session's exact value as Swift's `arrowSteps` does (a slider's 33.3% steps to 34.3%; `SelectionAmountField` takes Swift's `value` and `change` closures, and the Lasso and Wand fields round in `change` as theirs do, an equivalent mutant since their values are whole), and the mask's black-or-white picker and note while a mask is selected; the colour swatch arrived with 8.10a. `ContentView+Status.cpp` holds `percent`, `iconKind` and `hint`, the eraser's words and icon among them; `iconKind` checks its tool although every consumer ignores a kind that is not its own (an equivalent mutant). `updateCursor` and the modifier helpers moved to `EditorCanvas+Cursors.cpp`. `ContentView`'s destructor stops listening first: `~QWidget` closes a shown window after the members are gone, and the canvas's focus-out then notifies (`cancelBrush` bumps `brushRevision`, as Swift's does).
- Brush speed (8.3's gate: 4K at a scale of 2, an 800 px soft brush, median frame at most 8 ms): 6.8–7.4 ms measured, from 47. The soft tip's density comes from a table of 8192 steps over d²/r² from h² to the rim, in radii because hardness alone shapes it, linear between steps; inside h the tip is solid, the table's first value, so a near-hard tip keeps every step across its fade (from 0.9995 on, a table over the whole radius missed the falloff by up to 46 levels). Once the table's steps are finer than float can tell apart ((1 − h²) / 8192 under float's epsilon, from about 0.9995), even that table misses by a few levels: there `tipDensity` evaluates the shader's own formula, float for float, for dabs and segments alike, its solid and empty ends returning at once what the formula yields, so only samples in the thin fade pay for `exp` and `log`. `BrushCoverageTests` holds dabs and strokes on both sides of the switch against the shader's own quadrature, replayed in float. The kernel keeps the shader's order of float operations to the letter (`midpoint ± halfLength · node − projection`, never regrouped): 8192 pixels along a near-hard stroke a regrouping moved a pixel three levels, and a reference written from the twin's own code repeated it. Its one read per lookup is checked at the upper step, so a lost bound aborts. Only a segment passing through the solid core can look up inside it, so the lower clamp runs for those alone (`quadrature<true>`): for every segment it cost 0.3 ms a frame. Bands of 32 rows are the tasks. The halving's bands take their rows, sums and source row from the caller's scratch, so a worker allocates nothing and a failed allocation throws where the caller catches it (QtConcurrent wraps a worker's exception in `QUnhandledException`). `publish` copies each tile and makes its selection coverage on the calling thread, so a failure throws there and a worker's malloc arena never meets a starved allocator, then fills side by side. Pieces compose and halve side by side (`sideBySide`), each halving unthreaded: Qt 6.4 deadlocks on a `blockingMap` inside a map task. The Lanczos colour rows sum four channels in a GCC vector (`vector_size(16)`), each lane as the scalar sum, and the vertical pass runs tap by tap over whole rows; `BrushCoverage.cpp` and `DownsampleCache.cpp` build at `-O3`, where GCC vectorises them. The shadow is skipped when the dirty rect lies inside the document, whose opaque fill covers it. Equivalent mutants: that skip, `fill`'s skip of a zero weight, the colour row's right clamp (it only ever reads padding), the table's interpolation (under a third of a level), its lower clamp at step 1 (the first steps of every fade sit at the density's cap), the clamped quadrature for every segment, the formula for every hardness and `tipDensity`'s two early returns (each only costs time). `BrushPerformanceTests` runs under `BRUSH_BENCHMARK=1`.
- Canvas speed (11.1): `CanvasPerformanceTests` (under `CANVAS_BENCHMARK=1`, at a backing scale of two, as the brush benchmark) pans a 1000-point canvas by trackpad deltas through the canvas's own wheel path, at fit and at Actual Pixels (`zoom(1)`, as the menu sets it), and zooms it in 5% steps around its middle, over a 3840 × 2160 document of ten noise layers (photographs and halves, opacities, multiply, screen, overlay and two Hue layers). Measured on the dev container (32 cores): pan at fit 30 ms median (p95 37), pan at Actual Pixels 68 ms (p95 74), zoom 77 ms (p95 86); with every layer normal 16, 15 and 29 ms. The hand-blended HSL modes are the cost: `HslBlend::blend` ran one thread over the layer's device area, 40 ms a Hue layer at fit; its rows now blend side by side (pointers taken first, since `scanLine` detaches), the same bytes, 3× faster (pan at fit 85 → 30 ms), and the mode is checked before the rows start, since a worker's exception comes back wrapped. At Actual Pixels a Hue layer still costs about 25 ms at 2× (the backdrop's copy and conversion, the enlarged surface and its draw back); the rest is QPainter's single-threaded smooth scaling, about 40 megapixels a frame at this size. Painting the canvas in parallel bands would need its caches (pieces, strokes, effects previews) made safe across threads, and is not done. The guards sit at half again these medians (45, 100 and 115 ms), so a regression fails. Compared with `docs/brush-performance.md`: Swift measures strokes only (2.6–2.8 ms median on macOS); the twin's stroke gate is 8.3's (6.8–7.4 ms at 4K, scale two), and Swift has no pan or zoom figures to compare. `Rendering/PoolMap` (Linux-only) wraps every `QtConcurrent::blockingMap`: Qt 6.4's deadlocks when a pool thread waits on a full pool (a worker's blur or blend on one core, or two workers on two), so a pool thread lends its place while it waits (`releaseThread`, then `reserveThread`, Qt's documented remedy); `PoolMapTests` runs the blend, the blur and a map on a pool of one thread.
- Desktop integration (11.2): the app id is `io.github.ZacharyZhang_NY.OmaPhoto` (Compositor's was its bundle identifier, `com.wonderassembly.compositor`), which names the desktop entry, the icon and the MIME package, and which `main` gives Qt as the desktop file name, so a Wayland compositor matches the window to its entry and icon; on X11 Qt 6.4 names the window class after the application (`omaphoto`, `OmaPhoto`), so the entry's `StartupWMClass` is `OmaPhoto`. `packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop` launches `omaphoto %F` (files named at launch reach `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate) and lists Swift's two document types: its projects (`application/x-compositor-project`) and JPEG, PNG, HEIC and TIFF. `io.github.ZacharyZhang_NY.OmaPhoto.xml` declares the project type for shared-mime-info, a folder (`inode/directory`) named `*.comp`, commented "OmaPhoto Project", where Swift says "Compositor Project"; a file manager may still open such a folder as a folder, since a project is a directory on Linux. `cmake --install` lays down the app, the entry, the MIME package, Compositor's app icon (`packaging/icons`) at six hicolor sizes (16 to 512) and U²-Net's licence under `share/licenses/omaphoto`; `OMAPHOTO_MODEL`, a path, installs the model as `share/omaphoto/u2net.onnx` whatever its own name, the file itself when given a link (`file(REAL_PATH)`), where `SubjectRemoval` looks, and packages pass it (11.3). A direct install (no `DESTDIR`) runs `update-mime-database` on its prefix, required, so the project type registers at once; a staged install leaves that to the package's hooks. `DesktopIntegrationTests` reads both files, stages an install and compares the whole file list (the model included when the build was configured with one, read from its cache), and installs directly with a stand-in `update-mime-database` first on the path and any `DESTDIR` of the caller cleared; the call in `main` is not under test.
- Packages (11.3): one CMake install serves every package. It lays down the app, its entry, MIME package, icons and both licences (the app's MIT `LICENSE` and U²-Net's, under `share/licenses/omaphoto`), and `OMAPHOTO_MODEL` adds the model. `OMAPHOTO_BUNDLE_ORT` (off by default) installs ONNX Runtime beside the app in `lib/compositor` with the app's run path set there, for Ubuntu 24.04, which packages none, and Microsoft's `LICENSE` and `ThirdPartyNotices.txt` with it (`OMAPHOTO_ORT_NOTICES`, where `Dockerfile.dev` keeps them from the release; a configure that cannot find them fails); every other system brings its own. `DesktopIntegrationTests` expects the runtime (in the configured library folder), its version link and its notices in a bundling build, as it expects the model in a build given one, reading what CMake itself evaluated through compile definitions (both checked by hand in a second build). CPack makes the DEB (Ubuntu) and the RPM (Fedora), each on its own system: the DEB's library dependencies come from `dpkg-shlibdeps`, which finds the bundled runtime through the run path, and it names what no library does (`libheif-plugin-libde265` for HEVC, `qt6-image-formats-plugins` for TIFF, `shared-mime-info`, `hicolor-icon-theme`); the RPM asks for `qt6-qtimageformats`, `shared-mime-info` and `hicolor-icon-theme`, its libraries found by rpmbuild, and suggests RPM Fusion's `libheif-freeworld`, since Fedora's own libheif decodes no HEVC. Packages stage their install (`DESTDIR`), so their systems' hooks and triggers refresh the MIME and icon caches. `Dockerfile.dev` gains `dpkg-dev`, `file` and `fakeroot` for the DEB. `packaging/arch/PKGBUILD` builds this checkout for Arch and Omarchy (`makepkg` there), fetching the model by its checksum and depending on `onnxruntime`, which `onnxruntime-cpu` and the GPU builds provide. `flake.nix` sits at the repository root; it builds the app alone and installs staged (`DESTDIR=/`) so the store holds no MIME cache. The DEB was built and read back in the dev image; the RPM, the PKGBUILD and the flake are built on their own systems in 11.4.
- Distro checks (11.4): `scripts/distro-check.sh arch|fedora|nixos` builds `scripts/distro/<name>/Dockerfile` and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since Qt 6.11's `QTransform::map` clips w for points past the horizon where Swift divides whatever its sign; a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators. Tests follow the systems where only a message or a count differs: libheif 1.23's longer messages, Qt 6.9's TIFF plugin logging libtiff's errors as criticals, fonts named rather than counted, the Type icon's letters in the system's own sans (Fedora's is Noto: the icon sheet compares them only under DejaVu Sans), and `QT_FORCE_STDERR_LOGGING` for the launched app, since a journald-built Qt logs to the journal when stderr is no console. The five starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there.
- Spot Healing (8.4): `HealPixels.c` compiles in place beside the other kernels. `SpotHealingMode`, `allSpotHealingModes` and `rawValue` (Swift's names) sit in `BrushStroke.h`, and `BrushSettings` gains `healing` and `healingMode`. While the stroke lasts its tiles show Swift's wash, gray 0.12 at 45% through the coverage, never erasing. `heal` takes the painted bounds from the kernel's `heal_coverage_bounds`, draws the source into a region about three spots wider, and hands the kernel the coverage packed row by row, as Swift's gray context holds it (the twin copies each tile's rows where Swift draws each coverage image); the seed is `QRandomGenerator::global()`, Swift's `UInt32.random`. Each touched tile is then its base with the healed region copied over, blended with the base through `PixelAdjust::blend` under a selection, where Swift clips the context. The kernel's failure is `ProjectError(tooLarge)`; pixels or packed coverage that cannot be allocated are `ExportError(render)`, as Swift's contexts. The session's `beginBrush` takes Spot Healing on pixels alone, never on a mask, as Swift's guard; `finishBrushImmediately` heals before the commit, one `Spot Healing` step; `spotHealingMode` is a getter and a setter that notifies; Tab steps the type. `isBrushTool` is Swift's four tools: the circle, the right-drag tip, the brackets, the digits and `BrushControls` serve each, and a press with Clone Stamp or the Smear begins nothing until 8.5 and 8.6 bring them. `ContentView` keeps one bar across the four, as Swift's single `isBrushTool` branch keeps its view; the bar titles itself by tool (Spot Healing, Clone Stamp, Smear, Eraser, Brush), shows Paint and Erase for the Brush alone and the three types for Spot Healing alone, and names opacity Strength for the Smear; the clone's Aligned and Sample arrive with 8.5, the Smear's modes with 8.6. Equivalent mutant: the healed tile starting from its context rather than its base, since the region holds the whole wash and the copy replaces it.
- Clone Stamp (8.5): `Document/CloneStamp.h` holds `CloneSettings`, `CloneStamp.cpp` Swift's extension: `setCloneSource` (a point that is no number is refused; a new source restarts the alignment), `cloneStrokeOffset`, `cloneSamplePoint` and `cloneSample`, whose context that cannot be allocated logs on `omaphoto.app` where Swift's `try?` is silent. The session keeps `cloneSource`, `cloneSettings` (a setter that notifies), `cloneOffset` and `parkedBrushTips` by `tipFamily` (Clone Stamp 1, the Smear 2, the rest 0; the two parked tips start at Swift's 40, 0, 1). `selectTool` parks size, hardness and opacity when the family changes and reads the tip it restores with `at`: the session starts in family 0 and leaving a family parks it, so Swift's `if let` always finds one. `beginTransform` and Transform Selection set the Move tool directly, as Swift's do, and park nothing. `beginBrush` takes Clone Stamp on pixels alone, asks for a source first (`brushError`: "Alt-click where Clone Stamp should copy from first."), samples the document as the stroke starts and fixes the offset; Tab toggles the sample; the step is `Clone Stamp`. `BrushStroke::Clone` is Swift's tuple. `publish` makes each tile's view of the sample on the calling thread (`clonePixels`): under a scale or turn QPainter draws it bilinear, Swift's medium quality; a whole translation copies it; a fractional one is drawn at the floor and shifted by `BrushRaster::shiftedByFraction`, since QPainter snaps an unscaled image. `fillClone` then composites it source-over through coverage × opacity × selection, each byte rounded once, where Swift clips the context to the coverage. Swift's `!isMask || isBlur` holds since 8.6: a mask takes a clone only from Blur. The bar shows Aligned (a `QCheckBox` written on `clicked`, so the session's own changes write nothing back), This Layer and All Layers, and "Alt-click to set the source" in `PlaceholderText` while there is no source. On the canvas an Alt-press with Clone Stamp sets the source and paints nothing; Swift's cursor refresh there is dead (Alt is held, so the crosshair stays) and left out. With a source the pointer hides unless Alt or Space is held. `updateBrushCursor` gives the overlay the source crosshair in view points and, between strokes and without Alt, `clonePreview` (the source around the stamped point at screen resolution, at most 1024 pixels, kept under Swift's key; Swift's `.medium` there is overridden by `LayerRenderer`'s own quality and left out) with `cloneTip` (one click painted by the brush engine, kept by size and hardness). `BrushCursorOverlay` gains `marker`, `preview`, `previewOpacity` and `tip`, images compared by `cacheKey` as Swift's `!==`; under an antialiased ellipse clip at the brush's opacity it draws the preview and cuts it to the tip inside `AdjustmentSurface::draw`, Swift's transparency layer, so the tip applies at the drawing's resolution however far a capped preview is enlarged, and draws the crosshair white 3 under black 1 with round caps. A modifier change refreshes the brush cursor, as Swift's monitor does. Equivalent mutants: `displayedTransform` in `cloneSample` and `clonePreview` (it differs from the stored transform only in a transform edit, which leaves Clone Stamp for Move, or a filter edit, which refuses strokes; the preview would show it only with a filter sheet open over Clone Stamp).
- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`: sigma a tenth of the brush within 1.5 and 30; the layer blurred with clear past the document's edge; a mask on its edge tone, its placed box black under its coverage, blurred with the edge repeated. Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact; it costs 20–110 ms for a 4K document on 32 cores (sigma 1.5 to 30), paid as a Blur stroke begins, since Swift's sample is taken whole. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` and `blurSample` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre).
- The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr<BrushStroke>`, as a pixel move's, because the canvas draws it while the commit renders, and the raster's pointer stands for Swift's `gradientEdit === edit` (a new line on the same target keeps the raster, as Swift keeps the edit). `commitGradient` takes a completion callback, posted on every path; `resolveGradient` posts the commit, Swift's `Task`, so a switch of tool, layer or target applies it once the caller returns; `invertPixels` and `beginFilter` commit it first and call themselves again from its callback, as Swift awaits it. `fillGradient` fills the canvas through `paintCanvas` with a `QLinearGradient`, or a `QRadialGradient` centred on the start with the line as its radius; Qt's pad spread is CoreGraphics' `drawsBeforeStartLocation` and `drawsAfterEndLocation`, and two stops interpolate alike in both. `gradientColors` makes a mask's colours gray from the palette's red, where Swift makes `DeviceGray` colours. `undo` first discards a pending gradient and `canUndo` counts one, as Swift's. The palette's model came forward from 8.10 into `ColorPalette.cpp` (`PaletteColor`, `foregroundColor` with `setForegroundColor` through `setBrushSettings`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`), and `fillSelection` reads it; the Type bar's share of `setPaletteColor` came with 8.9a, the picker and the swatches came with 8.10a. On the canvas (`EditorCanvas+Gradient.cpp`) a press within ten points of the end, then the start, grabs it, else begins a line; Shift turns the dragged end to a multiple of 45° around the other; Escape cancels and Return or Enter applies, after the selection's keys; losing focus drops the drag. `TransformOverlay` draws the line in place of the handles while one is pending, as Swift's: a radial's dashed rim at the line's length, the line black 3 under white 1, each end a white disc with a black rim, a gray ring and its colour. A pending gradient on a folder's mask clips the folder's layers at once through `liveFolderMaskClip`, as a brush stroke does. `GradientControls` is the tool's `ToolHeaderBar`: the shapes as checkable `QToolButton`s in a `QButtonGroup`, `GradientSwatch` (white, gray checks of four points, the colours, clipped to a rounded rect, the rim unclipped as Swift's `strokeBorder` overlay), the style `QComboBox`, Reverse, the slider in thousandths from 1% and the percent field (a `SelectionAmountField`, whose own clamp stands for the binding's), the Mask note in `PlaceholderText`, and Cancel and Apply while pending. Equivalent mutants: the gray branch of `gradientColors` and of `fillSelection` (a mask's palette is black or white), and the swatch's rim clipped too (3/255 at its corner pixels, measured). A press that marks a drag without an edit was equivalent until 8.10b's Alt eyedropper read the drag; a test holds it now.
- The Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), `installProject` loads shapes and `applyDocumentSize` does not, as Swift's rebuild leaves them out after a crop or a resize; where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing.
- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passeLine truncated
- The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view; the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. Each event follows `QWidgetLiLine truncated
- The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (knownLine truncated
- The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (Unicode space separators and the tab, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; itLine truncated
- The Eyedropper and picking (8.10b): `EditorCanvas+Picking.cpp` holds Swift's sampling. `palettePicking` is the Eyedropper, or Alt with the Brush, Spot Healing or the Gradient while no stroke runs and no gradient end is dragged; `picking` adds an open picker (the Levels and hue sample modes arrive with 9.1 and 9.3). A press while picking, Space aside, samples at once and keeps the colour it began from; a drag samples on; the release ends it and, with a picker open, hands the picker the keys back (`ColorPickerPanelController::refocus`, which the canvas includes, as Swift's calls it). `sampleColor` samples into the open picker, else sets the foreground through `setForegroundColor` while the palette can be edited (a chosen mask too, as Swift sets `foregroundColor`), and rings the point. Qt can lose a release: a move without the left button, a lost focus or the next press ends a sampling. Picking outranks every tool's press, a double click (the second click is a press, so the inline editor and a polygonal Lasso stand aside while the picker is open, Swift's `hitTest`), a hover with no button held (the eyedropper alone: the brush circle neither moves nor shows, as Swift's `mouseMoved` returns first; a right-button drag still sizes the tip, as Swift's `rightMouseDragged` ignores picking) and the cursor (after the drag cursors, before Space). `syncPicking` ends a sampling when picking begins or ends: `synchronizeDisplay` runs it, as does every modifier change (Swift's monitor), a sampling press before it begins (Alt may first show in the press, and the foreground's signal would otherwise end the new sampling) and every move (Alt let go unseen). `pickingRelease` returns nothing, where Swift returns from `mouseUp`: a sampling press begins no other gesture, so the rest of the release finds nothing to end. `SampleRingOverlay` is no widget, like `BrushCursorOverlay`: 116 points round the pointer, a 24-point ring of gray 0.45 under a 16-point one, the sampled colour on the top half and the original below, each half replacing the clip and one restore dropping it, drawn over the overlays and under the inline editor; `showsSampleRing` (a getter and a setter that notifies) hides it. The Eyedropper's bar is Swift's inline one, built in `ContentView`: the title and a "Sample Ring" `QCheckBox` 16 points on, following the session under a `QSignalBlocker`. `eyedropperCursor` draws the symbol into Swift's 20-point box of a 24-point image, sixteen white offsets of 1.25 and then black; its hot spot is the drawn tip, (5, 19), as Swift's (3, 21) is its symbol's tip. The glyph is `Rendering/EyedropperIcon`, which the rail and the cursor share, as they share `SelectionIcons`. A mounted canvas leaves the keys to an open picker (Swift's `viewDidMoveToWindow`). The application event filter moved, unchanged, beside the modifier helpers in `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. Equivalent mutant: the ring's `setBrush(Qt::NoBrush)` (the painter reaches it with no brush).
- Levels in the session (9.1a): `Document/Levels.h/.cpp` twin Swift's file, `Levels+Session.h` its session half; `LevelsAutomatic.h/.cpp` and `LevelsAutomatic+Session.h` the other file. `LevelsPixels.c` compiles in place beside the other kernels. `LevelsChannel`, `LevelsSample` and `LevelsAuto` keep Swift's raw values (`rawValue`) and case order (`allLevelsChannels`, `allLevelsSamples`, `allLevelsAutos`); `LevelsSample` is declared opaquely in `Levels.h`, since `LevelsEdit` holds one, and defined with the automatic levels, as Swift's. `LevelsSettings::sampling` is declared in `Levels.h` and defined in `LevelsAutomatic.cpp`, as Swift's extension; `settings(mode, histogram)` stands for `LevelsAuto.settings(histogram:)`; `setCurrent` is Swift's `current` setter, storing the range normalized. A histogram is `LevelsHistogram`, four arrays of 256 bins where Swift has `[[Double]]`, and `LevelsHistogramDisplay::scale` takes one channel's bins. `LevelsFilter::run` leaves soft edges to the kernel. Known difference: Swift's latest commit (cc5aac4, "Fix dark soft edges in Hue/Saturation and Levels") unpremultiplies each soft edge before `levels_apply`, which unpremultiplies again, so an edge is processed twice and Swift's own `inputClippingGammaOutputInversionAndAlpha` fails: an inverted (64, 32, 0, 128) comes out (0, 32, 64, 128) where the test expects (64, 96, 128, 128); the twin holds the test. The histogram hands the kernel one row at a time with the mask's own row, since Qt pads a gray image's rows and the kernel accumulates; neither it nor the filter's tables take heap memory of their own, whose failure would throw `std::bad_alloc` past the worker's catch. `LevelsEdit` is a plain class in a `std::optional` of the session, like `FilterEdit`, its `id` standing for Swift's object identity; past 8000 pixels a side its preview source is a scaled copy (sides truncated, one pixel at least), drawn from a painted layer's tiles without flattening them. The session keeps a watcher each for the histogram, the preview and the commit, with the edit each serves: where Swift cancels a task, the twin forgets whose it is (`m_levelsPreviewFor`; a histogram needs no token, since a new edit's count replaces the watcher's future, whose signal Qt then drops), so a result for a turned-off, closed or committing edit drops; one preview runs at a time, the newest job waiting, and a commit drops the waiting job too. A run counts as running until its finish has run (`m_levelsRendering`), as Swift's task clears itself only after its await: Qt's `setFuture` drops a replaced future's pending signal, and `isRunning` and `isFinished` both end with the worker (probed on 6.4.2). A histogram or preview that fails logs on `omaphoto.app` where Swift's `try?` is silent; a failed histogram leaves the bins empty and ready, as Swift's. `setLevelsSampleMode` is the sheet's write (Swift's sheet sets `sampleMode` and bumps `brushRevision` itself); it notifies. The commit writes the layer in place and drops shape and text, as the filter's does. `beginLevels` leaves out Swift's `levels == nil` and its `activeLayer` and `document` guard (`canAdjustColors` reads all three); `hueSaturation` arrives with 9.3, `cancelCrop` with 10.1, the adjustment layer hooks of `updateLevels`, `cancelLevels` and `commitLevels` came with 9.5c. The gates, `canAdjustColors` and `canStartProjectOperation` read `levels`, `selectLayer` and `selectTool` refuse while it is open, and closing it resumes file requests, Swift's `didSet`; the commit's end resumes them through `setIsProjectBusy(false)`, after the edit is gone. `commitLevels` hands its worker the asset, so a painted layer flattens there, where a failure is caught (Swift's lazy image flattens as its task draws). Left out as dead: the display scale's zero-peak guard (with no bin counted the interior is empty too), the preview flag in `previewImage` and in a landing preview (turning it off clears the preview and forgets the run), and in the automatic levels contrast's `low < high` (each channel's low lies below its high, so theirs do), neutral's `0 < mean < 1` and its 9.99 cap (weight at both ends of a range keeps the mean a thousandth inside, so gamma stays under 9.97), and the sample's 0 and 255 clamps (a sample is 0 to 1). Equivalent mutants: the white sample's `max(black + 1, …)` (`normalized` keeps white above black), `sampleLevels`' bounds on the layer's own pixels (a pixel off the image draws clear, which the alpha test refuses), the histogram's `committing` check (a count finishing during a commit, a race no test orders), the commit's check that the layer kept its pixels and place (nothing edits a layer while the project is busy), the tables' reservation and the warnings. `LevelsFailureTests` starves allocations through `AddressSpaceLimit` in a binary of its own: a canvas-wide stroke leaves hundreds of MB free inside malloc's arenas, which `malloc_trim` cannot unmap, so its painted layers are rasters built directly; worLine truncated
- The Levels sheet (9.1b): `UI/LevelsSheet.h/.cpp` twin Swift's sheet; `Histogram`, `Gradient`, `Track` and `Handle` are private widgets of that file, and the histogram and the tracks follow the session's `changed()` themselves. Each handle is a 22 by 20 widget of its own, a child of the sheet placed over its track, as Swift's `ForEach` makes a view each: a later handle lies on top (white over gamma over black), a press drags only its own, each carries Swift's label, and at either end half of it lies in the sheet's padding, where a child of the track would be clipped. The output gradient is made before the output handles, so they lie on its last row as SwiftUI stacks the later view. A drag reads the pointer along its track, Swift's named coordinate space. The triangle is a path (apex 3.75, base 14.25, 12 wide, Swift's 12-point `triangle.fill`); its half-point gray shadow is a gray rim, since Qt has no blur. The histogram's colours are SwiftUI's gray and, from Apple's HIG for macOS 26 (the Swift target), its dark red, green and blue: (142, 142, 147), (255, 66, 69), (48, 209, 88), (0, 145, 255), data colours like the canvas's grays, whatever the theme. The entries are the picker's `PickerField`s: a number applies on Return or on leaving, as Swift's value binding, other text goes back, and an entry keeps its typing until then, unless its own handle is dragged, which takes no focus and replaces the typing with its value (9.6f); numbers are read and shown in the widget's locale with Swift's decimals, as Swift's `.number` format follows the user's (a German gamma reads `2,5`; no level reaches a thousand, so nothing is grouped). A click on OK takes the focus first, so a typed entry applies before the commit, where AppKit's buttons take no focus and the typing is lost; Return in an entry goes on to OK, the panel's default, as in the picker. The eyedroppers are checkable buttons set from the session, the chosen glyph in the accent and the others in the secondary ink, for Swift's `.tint`, redrawn on `PaletteChange`. The channel picker is named by its label (`setBuddy`): on Linux Qt names a combo box by its label relation, never by `accessibleName`. Alt+P is the Preview box's own shortcut in the panel and the canvas's key when the canvas has the keys. For an adjustment layer's editor the caption says the underlying pixels (9.5d). `ContentView+Panels.cpp` holds `synchronizePanels`, Swift's `onChange(of: levels == nil)`, and since 10.1 the importer and the alerts, because `ContentView.cpp` runs to 500 lines: a `FloatingPanel` named `levelsPanel` shows a new sheet when an edit opens, its close cancelling as Swift's `onClose`, and hides when the edit closes. The canvas draws `previewImage` in the layer's place after a filter's; a press in a sample mode is `sampleLevels` and hands the panel the keys (`FloatingPanel::refocus`); other presses wait for Space, the Hand or the Zoom while Levels is open; Escape, Return, Enter and Alt+P are its keys and every other key but Space passes on; `picking` reads the sample mode, so the eyedropper cursor shows; the application filter's brackets rest. `ContentView::acceptsDrop` needs no term of its own: `canSwitch` reads `canStartProjectOperation`, which reads `levels`. The Image menu's Levels… is Ctrl+L and follows `canAdjustColors` (its `hueSaturation` term arrives with 9.3); Import Images and the New canvas button rest while Levels is open. Undo and Redo are bare, since Levels closes the history, and enabled only while a field has focus, as for a busy project: Swift's bare entries act only on a text view. Equivalent mutants: the histogram's zero-peak guard and its `max(0, …)`, each standing in for the other (with no bin counted, 0/0), its `min(1, …)` (the widget clips a taller bar) and the bars' `+ 0.1` (Qt covers abutting rectangles of one path exactly); the drag's clamp of the tone, white's `max(black + 1, …)` and gamma's `min(0.999, …)` (`normalized` does each); either of a track's move and resize events alone (a layout moves and sizes it together); the canvas's `!preview` before the Levels preview (a filter edit and Levels never overlap); passing an unhandled key on (nothing above the canvas takes keys) and the base `changeEvent` (the sheet paints nothing of its own). Qt sizes the loading note itself: a shown child without a layout is adjusted to its hint.
- Curves and the image adjustments (9.2): `Document/Curves.h/.cpp` and `Document/ImageAdjustments.h/.cpp` twin Swift's files, `UI/CurvesControls.h/.cpp` Swift's view. `AdjustPixels.c` was already compiled for the downsample cache; Curves and Exposure run `levels_apply` from `LevelsPixels.c`, as Swift's do. `CurvesSettings.channels` is a vector of point vectors, as Swift's array, so `isValid` still counts four. `value` finds Swift's `lastIndex(where:)` with a loop; a NaN input takes the first segment at t = 0, as Swift's `max(0, .nan)` gives 0. `ImageAdjustmentPixels::run` hands its body the premultiplied RGBA context `BrushRaster::context` makes and returns that context. `AdjustmentColor` keeps Swift's two initialisers as constructors; the palette's has its callers since 9.4a (a Gradient Map starts from the palette, its picker) and gains 9.5's (new layers), as `normalized` serves the filter's settings since 9.4a and the adjustment's with 9.5; `ends`, which `apply` reads, gains the filter sheet as a reader with 9.4b and returns an `Ends` struct for Swift's tuple. `GrainSettings::apply` takes an optional seed that replaces the stored one and hands the same image back when the amount is 0. Left out as dead: the `isFinite` terms of `AdjustmentColor.isValid` and of the curves' points (the ranges refuse NaN and infinities), and the points' x range (ends at 0 and 255 with x strictly rising keep every x inside). `CurvesControls` takes Swift's binding as a value and a change function and shows the settings on `synchronize`, which each of its own edits calls and its owner will (9.4's filter sheet); a channel shown anew drops the selection and the drag, Swift's `onChange`. The graph (`curvesGraph`) is a private widget 260 tall: black at 35%, white quarter lines at 12%, the curve two wide, 8-point dots with the chosen one in the accent; presses and drags follow Swift's gesture (a point within 14 curve units is taken, else one is added between x 1 and 254, more than a unit from the others, 32 at most; an inner point keeps a unit from its neighbours; the left button alone). Remove point keeps Swift's guard (an inner point that is still there), so a selection another change has left behind removes nothing. The readout (`curvesReadout`) truncates as Swift's `Int(…)`; SwiftUI's `monospacedDigit` has no Qt 6.4 twin (font features arrive in 6.6), so the digits are the font's own. Carried from 9.1b's review: the Type bar's size and tracking read and print through its locale, as Swift's `.number` fields, without group separators, as every field of the twin; leading keeps `Double(text)`'s period, as Swift's. Equivalent mutants: the curves' `2…` lower count (one point cannot hold both ends), `<` for `<=` in the segment search (the curve is continuous at its points), `< 0` for `<= 0` in the inner slope (a zero slope's reciprocal is infinite, so the harmonic mean is zero anyway) and the final 0…255 clamp (Swift's slopes never overshoot a valid curve); Exposure's `max(0, …)` before the gamma and its table's lower clamp (a negative light ends at zero either way), the gradient map's byte clamp (its colours lie in 0…1); the controls' `x > 1` and `x < 254` (a point more than a unit from both ends lies inside them) and Remove's upper bound (Remove rests on the last point).
- Hue/Saturation in the session (9.3a): `Document/HueSaturation.h` twins Swift's types. Swift's file runs to 574 lines, so the twin splits it: `HueSaturation.cpp` (ranges, bands, sample modes, settings, the edit), `HueSaturation+Filter.cpp` (the cube) and `HueSaturation+Session.cpp` (the session extension, `canAdjustColors` with it; `HueSaturation+Session.h` its fragment). `ColorRange` and `HueSampleMode` keep Swift's raw values and case order (`allColorRanges`, `colorRanges`, `allHueSampleModes`); `defaultBand` is a free function where Swift has a property, and `help` gives the modes' help; their SF Symbols, the eyedropper and its badges, are the sheet's to draw (9.3b). `HueSaturationSettings` keeps Swift's dictionaries as `std::map`s, so the constructor's entry for its range and the setters' inserts compare as Swift's do; Swift's computed `hue`, `saturation`, `lightness` and `band` are getter and setter pairs. `HueSaturationFilter` is a namespace; `HueResponse` and `Color` stand for Swift's tuples, and `cube` returns the floats Swift packs into `Data`. Core Image's `CIColorCube` becomes a CPU lookup, rows in parallel: each pixel unpremultiplied, read trilinearly from the 33-point cube, premultiplied again and rounded once; Core Image's own precision may differ by a level, and Swift's tests allow eight. A failed table or row allocation is `ExportError(render)`, since a worker's `bad_alloc` would pass its catch; `HueSaturationFailureTests` reaches that net through a picture one pixel wide, whose row list asks as much as its pixels. A selection blends the result over the job's own pixels, as the Levels filter does, where Swift draws them into a context first: the same bytes, since layer pixels are that context's format. `HueSaturationEdit` is a class in a `std::optional`, as `LevelsEdit`, its `id` standing for Swift's identity; its preview source is made as the Levels edit's (to 8000 pixels a side, from a painted layer's tiles without flattening). The session keeps a watcher for previews and one for the commit. Where Swift cancels the preview task (Preview off, nothing to show, OK) the twin forgets whose it is, so that result drops; a result for a closed edit fails the id check, Swift's `hueSaturation === edit`. A run counts as running until its finish has run (`m_hueSaturationRendering`, as Levels' queue), so a failure is reported even when the next job waits. Known difference: Swift's run for a closed edit returns before it looks at the queue, so a job queued behind it waits for the next change; the twin's runs it. Known difference: Swift's task never looks at its cancellation, so a render still running when Preview goes off, or when OK is pressed, lands. A failed render sets `brushError` whichever edit asked, as Swift's `adjustedPixels` does. Known difference: Swift's `beginHueSaturation` resolves a pending gradient without waiting, so its edit holds the pixels from before the gradient and the commit, finding the layer changed, lands nothing; the twin waits for the gradient, as `beginLevels` does. `commitHueSaturation` refuses while a commit runs (Swift has no guard; its second commit would find the layer changed and land nothing), keeps a copy of the edit for the finish (`CommittingHue`), frees the project before the edit goes (Swift's two `defer`s) and names the new asset after the layer; the rebuild drops shape and text. `sampledHue` reads the composite through `PickerHSB`, as Swift's; `beginHueTargeting` takes Swift's `max(by:)`, the first of the strongest claims. `setHueSampleMode` and `setHueTargeting` are the sheet's writes and notify on a change; `endHueTargeting` changes nothing observable and stays silent. Swift's beeps are warnings on `omaphoto.app`. Left out as dead: `beginHueSaturation`'s `document` and `activeLayer` guard (`canAdjustColors` reads both) and the targeting's `?? .reds` (the ranges are never empty). Equivalent mutants: the weight's fallbacks for ramps of no width (no hue lies inside one) and its `<=` at the plateau's end (both sides give 1); `toHSL`'s cap on saturation, `toRGB`'s gray return and clamp (the arithmetic stays within 0 and 1 but for rounding, which later clamps absorb); a clear pixel's fill (its lookup is multiplied by alpha 0); the response's skips of a still range or a zero weight (each adds nothing); the saturation's floor of zero (`toRGB` draws gray at or below it); the amount's clamps and the lightness clamp (a sum past ±100 reaches white or black either way). The hooks: `canEditLayers`, `canContentAwareFill` (so `beginFilter`), `beginLevels` and `ProjectWorkspace::canSwitch` read `hueSaturation`; `canUseHistory`, `canStartProjectOperation`, `selectLayer` and `selectTool` do not, as in Swift.
- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Swift's sliders run freely; the twin's step in hundredths and show the session's value under `QSignalBlocker`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: a text draft and a polygonal outline can both be open under Hue/Saturation (Swift's `canAdjustColors` asks for neither). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held.
- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and 100 megapixels at most (`ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, since Swift's expects a blur clamped at the layer's old edge where the code grows and trims), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again).
- The filter sheet (9.4b): `UI/FilterSheet.h/.cpp` twin Swift's file, `FilterSheet` and `GradientMapControls`. The sheet is the content of a `FloatingPanel` named `filterPanel`, which `ContentView::synchronizePanels` shows with the edit, titled by its kind, and closes with it, its close button and Escape cancelling (Swift's `onChange(of: filterEdit == nil)`). The kind's controls are made once, since an edit's kind never changes. Swift's `control` is a title (sixty points at least, the slider's buddy), a slider over a thousand steps of its range, or of the range's logarithm where Swift's is logarithmic, the value rounded to the field's decimals as Swift's binding rounds it, and a 56-point `PickerField` with its unit two points after (Swift's `unitSuffix`), which applies a readable finite number on Return or on leaving (normalizing clamps it), gives up its typing when its own slider changes the value (a wheel leaves the keys with the field; a notch that rounds to the same value changes nothing; 9.6f), and shows Swift's `.fractionLength(0...decimals)`: at most that many decimals, none trailing, in the field's locale, digits never grouped, as in every field of the twin. The sliders show the session's number under a `QSignalBlocker`, so a dragged thumb sits at the rounded value, as Swift's binding shows it; a step that rounds to the setting it already holds changes nothing, so the thumb keeps its place and arrow steps add up. Add Noise's distribution is two checkable buttons in a `QButtonGroup` beside its label (Swift's segmented picker), Monochromatic a `QCheckBox`; Lens Correction's note is callout-sized in `PlaceholderText`; Curves hosts `CurvesControls` through the settings. `GradientMapControls` draws Swift's gradient bar (20 high, rounded 4, a 35% black rim inside), the ends as the palette's `SwatchButton`s (24 points, radius 6, a white ring of 1.5 inside a black rim) with their titles, help and accessible names, and Reverse; a swatch opens the picker on its end, and on every change of the picker's colour the sheet calls `previewGradientMapColor` (Swift's `onChange`), which ignores a palette picker. The controls wait for the sheet's first synchronize, which runs once they are built. `settings` and `update` keep Swift's fallbacks for a sheet whose edit has gone: the defaults, Preview on. The Image menu gains Curves… (Ctrl+M) before Levels…, and Exposure…, Gradient Map… and Grain… after Hue/Saturation…; the Filter menu (`Fil&ter`, between Image and Layer, as Swift declares it) lists `allFilterKinds` but the fill and the image adjustments (`isImageAdjustment`), Remove Background among them since 9.7; every entry rests while colours cannot be adjusted or Hue/Saturation is open, as Swift's. Tests: `FilterSheetTests` (7.5's, moved to the panel), `FilterControlsTests` (each kind's controls, the sliders' travel, the fields, the Gradient Map's swatches, bar and repaint), the menus' in `SelectMenuTests` and `CompositorMenusTests`. Equivalent mutants: Swift's `?? .gaussianBlur` for the kind (the panel makes a sheet only for an open edit) and the memory of the picker's colour (`previewGradientMapColor` with an unchanged colour changes nothing).
- Adjustment layers in the document and the store (9.5a): `Document/LayerAdjustment.h/.cpp` twin Swift's file, `LayerAdjustment+Session.h` its session half. `AdjustmentKind` keeps Swift's raw values (`rawValue`, and `adjustmentKind(text)` for the manifest) and case order (`allAdjustmentKinds`); `filterKind` came with 9.5c, its reader; `symbol` names an SF Symbol, whose glyph the panel draws (9.5d). `LayerAdjustment` keeps Swift's fields: the legacy Hue/Saturation four, the optional `hsvSettings`, Levels and Curves, and the optional Exposure, Gradient Map and Grain, whose computed properties are getter and setter pairs; `resolvedHSV` falls back on the legacy four. `isValid` keeps Swift's bounds (hue within 360 either way, the rest within 100, each range's values alike, every band's handles finite, Levels as normalized, then every kind's own rules whatever the kind) and leaves out the `isFinite` terms the bounds already refuse and Levels' count (the twin holds four ranges). `apply` arrives with 9.5b, its reader. `ImageLayer.adjustment` joins equality and travels wherever Swift lists it: the record (`hierarchyRecord`, so the snapshot), `ImageLayer(record, …)` (so an install and a resize's rebuild), both resizers and duplicates. A copy between projects needs no term of its own: the baker bakes nothing for a layer without pixels, and the placement unlinks it, where Swift skips the bake. The manifest's `adjustment` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Adjustment.cpp` over the readers the manifest shares (`ManifestJson` in `IO/ProjectStore+Json.h`): every key required but the four optionals, which stay out when absent; a `ColorRange`-keyed dictionary is an array of key and value in turn, since Swift's `ColorRange` declares no `CodingKeyRepresentable` (a range Swift does not name refuses it, and so does an odd count, whose last key has no value to read, so Swift's count check has no twin; a later pair wins, as a dictionary assigns); the seed is a `UInt32`. The store refuses an adjustment before version 7, on a folder, with pixels or invalid; Swift's text rule's adjustment term is dead, since text needs pixels. `LiveMaskGraph::validate` refuses clipping to an adjustment layer, so `canLinkMask` refuses one as a source; `canPaint` refuses an adjustment layer's pixels, not its mask. `addAdjustment` puts a blank layer named by its kind just above the active one (into an active folder, uncollapsed; Swift's `addBlankLayer` alone lifts a layer above the folder's contents), as one `New … Adjustment` step, a Gradient Map running from the foreground to the background and each Grain with a seed of its own, 10,000 layers at most, then asks for its editor (Swift's `adjustmentEditingID`, 9.5c). `updateAdjustment` writes valid settings alone, with no step of its own, and moves `brushRevision`. Tests: `LayerAdjustmentTests` (kinds, validity, the optionals, the manifest's keys and refusals, the store's and the graph's rules, adding, updating, travel), `AdjustmentLayerTests` (Swift's `legacyHSVStillDecodesAndRenders`; the rest arrive with 9.5b and 9.5c), Swift's `settingsSaveAndOlderAdjustmentsStillOpen` in `ImageAdjustmentTests`, and an unbaked copy in `ProjectCopyTests`.
- 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. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `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`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` 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. A fix beyond Swift: Swift 1.1 gave the record an `effects` key and taught its exporter to draw it, but its `projectSnapshot` and `installProject` never carry the key (the same commit wired `text` through both), so Swift's saves and exports drop every effect its canvas shows. 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, the filter's, Levels' and Hue/Saturation's commits, a floating merge, a copy between projects and both resizers leave them out. 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, 100 million. Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning).
- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's. A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too.
- `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels).
- The canvas's effects (9.6c): `DisplayState::Layer` carries `effects`, `drawLayers` calls `prepare`, and `synchronizeDisplay` hands a finished stroke's surface to the cache as a seed first (`handOnStrokeSurface`, Swift's opening lines). `drawOwn` keeps Swift's order: the warp, the distorted effects, the distortion's preview, the effects preview, then the stroke, the shape's preview and the colour edits' previews, so a layer with effects shows its effects over its own pixels while Levels, Hue/Saturation or a filter previews it, as Swift's does (`EffectsCanvasTests` pins it). A fix beyond Swift: Swift asks the cache twice a draw, first with the mask unplaced and the stored transform, then with what the layer shows; the first request wins, so a placed mask clips the preview unplaced, and while a transform edit moves such a layer the two requests cancel each other and render without end. The twin asks once, with the shown mask and transform, before the distortion's branches, so the mask is worked out first. The drawing functions lost their `const`: they update the stroke surface and hand the cache a repaint. `drawStroke` moved to `EditorCanvas+Effects.cpp` beside `strokeSurface` and `handOnStrokeSurface`, keeping `EditorCanvas.cpp` under 500 lines. `Distort.cpp` gains `DistortEffectsCache`, both `distortedEffects` (`const` with a mutable cache, as `distortPreview`; a warp that throws leaves the effects off, Swift's `try?`) and `commitDistort`'s seed, the cache cleared after the step as Swift's `defer` clears it. Swift has no tests for any of this: `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests` and `EffectsCanvasTests` pin its code. Equivalent mutants: the pool's thread count (a result lands by its request's id, whatever the order), the destructor's `clear` and every cancelling of a superseded request, the worker's two checks included (the landing's id check drops them; they only spare renders), the matching's `&&` between two absent masks (the sources must match anyway), `!previous` before the seed (a landed result clears its seed), the resizes skipped at full size (a draw at 1:1 copies); in the surface, the reach's floor of one (an overlay reads each pixel alone) and the stroke's `+ 2` (the spread reads `lround(size)`), `inner`'s cut at the bounds (QPainter clips the draw alike), the first region's margin (the reach goes as far) and the window's skip of tiles off its region (it only spares draws); `commitDistort`'s clearing of its cache (keyed by its inputs, the cache only frees memory). `EffectsFailureTests` starves a stroke's first pass through `AddressSpaceLimit`, in a binary of its own: the canvas logs it and shows the last effects. Untested failure paths: `rendered`'s and the landing's tests of a result, and the resize's null check, differ only when a preview fails, and a preview asks too little for `AddressSpaceLimit` to reach a worker's arena.
- Layer effects' panel (9.6d): `UI/EffectsSheet.h/.cpp` twin Swift's sheet: 340 points wide with 20 of padding and 16 between rows, the kind's headline (13 pixels, semibold), the stroke's Position as two touching checkable `QToolButton`s in an exclusive `QButtonGroup` (Swift's segmented picker; its hidden label is the box's accessible name), the colour as the Type bar's `SwatchButton` (36 by 18, radius 3, a half-black rim, `<Kind> color` its tooltip and accessible name, black while the effect is missing) opening `openEffectColorPicker`, Swift's `slider` rows (a 64-point title as the slider's buddy, a 130-point slider over a thousand steps of its range that sets the value unrounded, as Swift's continuous binding, a 48-point right-aligned `PickerField` with the title as its accessible name and placeholder, its unit two points after), then Cancel and OK ten points apart, OK the default button and Cancel no auto-default, so Return anywhere means OK, as Swift's `.defaultAction`. A field shows its value whole, ties to even as Swift's `.number` format rounds; it applies a readable finite number on leaving or on Return (which then goes on to OK), clamped to the slider's range as Swift's binding, reverts other text, keeps its typing while other changes arrive, and steps with Up and Down (Shift ten) from the session's value, clamped, the step replacing any typing and showing, as the picker's entries do; a change through the row's own slider (a wheel leaves the keys with the field) replaces it too. Each change is a step, as Swift's `changeEffects`. On every change of the picker's colour the sheet calls `previewEffectColor` (Swift's `onChange`), and it repaints its swatch on every change. While the layer lacks the effect the controls hide, as Swift's `if let effect`. `ContentView::synchronizePanels` first runs Swift's layers check (an edited effect gone from its layer, or the layer gone, closes an effect picker without its colour and clears `effectsEditing` and `effectsEditingOriginal`; a palette's picker stays), then shows the sheet in `effectsPanel`, titled by the kind, whenever `effectsEditing` changes (a new choice, a new sheet), its close button and Escape cancelling, and closes it when the choice clears. The footer's effects button (`sparkles` in `LayerIcons`: three four-pointed stars) is an instant-popup menu of the four kinds, each with an ellipsis, resting while `canEditEffects` does not hold. While an effect is chosen (`selectedEffect`, so a choice an undo took away names the layer again) the trash says `Delete selected effect` and the Layer menu's Delete `Delete <Kind>`. The canvas's left press lets go of a chosen effect before any guard, Swift's first line of `mouseDown`; other buttons keep it. Swift has no tests for the window's effects: `EffectsPanelTests` pins them, and `LayerEffectsSessionTests` the new writers' signals. Equivalent mutants: the fields' `OmitGroupSeparator` (no range reaches a thousand) and OK's explicit `setDefault` (a dialog makes its first auto-default button the default as it shows, and Cancel is none).
- Effects in the list (9.6e): `LayerEffectRow` (declared in `NativeLayerList.h`, its code in `NativeLayerList+Effects.cpp`) twins Swift's private class: a 24-point row under its layer for each effect, in Swift's order, the cell 52 points tall and 24 more for each (`rowHeight`, `effectHeight`). The list activates its column's layout after configuring the cells, as `noteHeightOfRows` changes heights at once, so no press lands on a row that a taller one still covers. The eye (20 by 22, 38 points in plus the layer's step, flat, `eye` or `eye.slash` in `PlaceholderText`, named `Hide <Kind>` or `Show <Kind>`) toggles the effect in one step and rests while `canEditLayers` does not hold; the name (11 pixels, `WindowText`, `PlaceholderText` while hidden) ends in an ellipsis when narrow, as `.byTruncatingTail`; the row's tooltip is Swift's with Alt for Option, its accessible name `<Kind> effect`, and an accessible interface of Swift's group role chooses the effect on the press action (`accessibilityPerformPress`). The rows are made anew only when the kinds change, else reconfigured in place; the old ones are hidden and deleted later, because a row's own double click may cancel a new effect's panel on the same layer, whose Cancel removes that effect and rebuilds the rows under the press, where Swift's reference counting keeps the row alive. A chosen effect's row shows `Highlight` at 30%, and while one is chosen the cell's selection hides, as Swift's table selection does. Presses follow Swift's `mouseDown` order: Alt without Ctrl on the strip (the bottom 10 points of the taller row, over its last effect; Swift's `min(10, height / 3)` is dead, rows being at least 52) clips through `clickRow`; on an effect, Alt without Ctrl arms a copy while layers can be edited, the release choosing the effect and a move past `startDragDistance` with Alt still held starting a `QDrag` of `UUID:Kind` under `effectType` with the list's token, offering Copy alone under the duplicate cursor, as the row's Alt drag does; any other left press chooses the effect, a double click editing it (Swift's `clickCount > 1`); the eye's Alt presses are the row's, ignored in its filter so they go on to it (Swift's `hitTest`). An effect dropped from the same list (Swift compares the source's session) lands on the row under the pointer, filled, as a copy, where `canCopyEffect` allows. A left press below every row lets go of the chosen effect and, without Ctrl or Shift, of every layer, as Swift's table with `allowsEmptySelection` does (a gap the list of 4.6 left). Known difference: the two points between rows take no press, where Swift's rows take their spacing. Swift has no tests for the list's effects: `LayerEffectRowTests` pins them, with `QDrag::exec` defined in its binary as in `LayerDragGestureTests`.
- Remove Background (9.7): Apple's Vision has no Linux twin, so `Document/SubjectRemoval` runs U²-Net (Qin et al., Apache-2.0, its text in `licenses/U-2-Net.txt`) on ONNX Runtime (MIT), as rembg converted it (MIT): `u2net.onnx`, 175,997,641 bytes, SHA-256 `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491`, the MD5 rembg pins. Chosen over `u2netp` (poorer edges), `silueta` (no clear source) and IS-Net's `isnet-general-use` (sharper, but the DIS repository puts only its code and metric under Apache-2.0 and its DIS5K training set under terms of its own, so the weights' terms are unclear); 90 ms a mask on 32 cores, measured. The model is found with `QStandardPaths::locate(GenericDataLocation, "omaphoto/u2net.onnx")`, so a package installs it as `share/omaphoto/u2net.onnx` and the dev image carries it in `/usr/local/share`. `Dockerfile.dev` fetches it and Microsoft's ONNX Runtime 1.21.0 build (Ubuntu 24.04 packages none; Ubuntu 25.04 on and Arch ship theirs with the same CMake config), both by pinned checksum; that build's CMake config goes to `/usr/local/lib/cmake`, which Ubuntu's CMake searches, and its library to `lib64`, where the config looks for it; CMake links `onnxruntime::onnxruntime`. One `Ort::Session` serves the process, loaded on first use; a load that throws leaves it unloaded, so the next use tries again. Every model step passes through `vision`, where ONNX Runtime's exceptions become `SubjectRemovalError(model)` and a failed allocation (the input buffer, the runtime wrapper's own vectors) becomes `ExportError(render)`, so none passes a worker's catch. `SubjectRemovalError` twins `SubjectRemoval.Failure`: `noSubject` with Swift's words, and a `model` kind for a missing model (naming every folder looked in) or ONNX Runtime's own message, where Swift passes on Vision's error. The input is the paper's and rembg's: the image premultiplied (clear reads as black), scaled to 320 by 320, divided by its brightest byte, then ImageNet's mean and spread; the first output is stretched from its lowest to its highest value, truncated to bytes as rembg does, and scaled to the layer's grid. An output with no value at or above one half is Swift's `noSubject` (flat images give under 0.03, measured). Known difference: U²-Net's mask is made at 320 points, so Basic's edges are softer than Vision's; Advanced's Refine pulls them onto the layer's own. The raw mask of the last image is kept by `cacheKey`, Swift's `MaskCache`. `Document/GuidedMatte` twins Swift's file: `box` keeps Swift's float sums, the column pass running a row at a time; `filter` frees what it no longer needs; `levels` scales through premultiplied RGBA (Qt's smooth scaling of Grayscale8 strays by a level shrinking and up to ten enlarging, probed) and reads gray over black through `qGray`; `refine` draws back up with `SmoothTransformation`, and the model's mask is enlarged through premultiplied RGBA too. `refined` does Core Image's Shift Edge (a Gaussian with the edge repeated through `PixelAdjust::gaussianBlur`, then Swift's clamp-and-scale cut) and Contrast in float over bytes, rounded once; the blur rounds to bytes first, a level at most. Contrast runs unconditionally, being the identity at 0. The preview (`run`) refines a copy 1400 long at most and multiplies each premultiplied byte by the mask into pixels of its own, copied before the loop so a copy that finds no memory is `ExportError(render)` (a shared image would copy inside the loop and come back null); the commit (`subjectMask`) refines at full size, multiplies in a mask of the layer's own grid (another grid is a `logic_error`, never read past) (disabled or not, which is then shown, as Swift's), blends through a selection over that mask or over white, and writes the mask in one `Remove Background` step with the mask chosen; its worker flattens painted pixels and masks. A fix beyond Swift: a placed mask's pixels were dropped but its placement kept, so the new mask, made in the layer's grid, sat at the old placement; the twin clears the placement. The filter sheet shows Swift's words, Quality as two touching checkable buttons (the label hidden, its accessible name) and Advanced's Refine, Contrast and Shift Edge rows with Swift's help, made once and shown while Advanced is chosen, as Swift's `if`. Equivalent mutants: the cache's key (only the time differs) and the commit worker's catch of `SubjectRemovalError` (after its preview succeeded, only ONNX Runtime failing mid-run could throw there). Tests: `GuidedMatteTests` (the box against plain sums, the filter against He's steps in doubles, overshoot included), `SubjectRemovalTests` (Swift's `visionRequestRunsOnAnImage` among them, on a red disc the model finds), `SubjectRemovalModelTests` (a binary of its own: a broken model, a missing one, then the real one), `SubjectRemovalFailureTests` (a starved preview and a starved model input, under `AddressSpaceLimit`, glibc's mmap threshold fixed so large requests map memory the limit counLine truncated
- Crop (10.1): `Document/Crop.h/.cpp` twin Swift's file, `Crop+Session.h` its session half. `CropGeometry` is a namespace; `CropDrag::Mode` is a kind and an index, as `TransformDrag::Mode` twins an enum with payloads; `CropSnap`'s `nearest` and Swift's local `shift` are file helpers; `cropSnapTargets` returns `SnapGuides`, Swift's tuple. `valid` and `commitCrop` read the frame normalised, as `CGRect`'s `width` and `minX` read it standardised; `valid`'s `isFinite` terms are left out, since the ranges refuse NaN and infinities. `cropRect`, `cropRatioChoice` and `cropError` are getters and setters that notify on a change. `commitCrop` takes a completion callback, posted on every path; the snapshot is taken and the worker started before anything changes, the resize runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ProjectError` and `ExportError` (`CanvasResizer::resize` turns a `std::bad_alloc` of its records and maps into `ExportError(render)`, as 9.7's `vision` does), and the result is moved out (`takeResult`) and installed through `applyDocumentSize`, one `Crop` step; memory that runs out while the snapshot is taken or the result lands sets `cropError` to the render error and changes nothing else, so the project is never left busy. Only a crop that landed drops its frame (Swift drops it first); a failure sets `cropError` to the error's text and keeps it, and the project is freed after either, Swift's `defer`. Hooks: `selectTool` cancels on a change of tool and opens the Crop tool on the whole canvas, Free, when no frame is drawn; `restore`, `beginTransform`, `importImages`, `beginLevels`, `beginFilter` and `ProjectController::begin` cancel; `installProject` and `clearProject` drop the frame and announce it with their own signal; `canEditLayers` reads `cropRect`, `canUseHistory` does not, as in Swift. The canvas's gestures live in `EditorCanvas+Crop.cpp`. A press on a grip resizes (the first region holding the point, corners first, Swift's `first(where:)`), one inside a drawn frame that is not the whole canvas moves it, any other drops the frame and draws anew; `CGRect.contains` leaves out the far edges, so the file's own `contains` does too, where `QRectF::contains` takes them. The snap is built at the press, 8 view points (`cropSnapDistance`); Alt keeps the middle still, Ctrl leaves the edges unsnapped (Swift's Control, as the Move tool's snapping reads it), and either key changing mid-drag reshapes the frame at the last hover point, as Swift's monitor reads the mouse. Swift's `?? CGRect(origin: pixel, size: .zero)` and its `if let cropSnap` are dead (the Crop tool always shows a frame; the snap is built with the drag) and are checked reads. A drag goes on under the left button alone, with the Crop tool, a free project and a document, as Swift's `mouseDragged`; a move without the left button ends it, since Qt can lose a release. `endCropDrag` is Swift's `cropDrag = nil` with its `didSet`: the release, a lost focus, Escape (cancel) and Return or Enter (commit) end the drag and free its cursor. Wheels and pinches rest mid-drag. The drag keeps the cursor its press showed in `m_dragCursor` (Swift's `.current`, with the cursor rects disabled), taken before a new frame drops the old one. Cursors: picking first, then the open hand under Space, then over a grip Qt's resize shape for Swift's frame position (top left and bottom right `SizeFDiagCursor`, top right and bottom left `SizeBDiagCursor`, top and bottom `SizeVerCursor`, left and right `SizeHorCursor`; Qt's shapes say no inward or outward), elsewhere the crosshair. `TransformOverlay` gains `cropViewRect`, `cropResizeRegions` (a `CropRegion` struct for Swift's tuple) and `drawCrop`: the canvas outside the frame under 60% black (a `QPainterPath`'s own odd-even rule), a white frame, the thirds at 40% white in one path so their crossings paint once, and 8-point white grips with black rims, antialiased, with CoreGraphics' butt caps and miter joins. Known difference: at 1× QPainter strokes one-point lines through its cosmetic stroker, which draws no joins, so the outer corner pixels of the frame and the grips stay partly clear, as the text box's (8.9c). The dimming spans the canvas, so a frame that appears or goes repaints the canvas; a frame that changes repaints the old and the new frame with their grips (`drawnRect`), since outside both the dimming stays. That is the twin's `cropDraggingRedrawsOnlyTheOverlay`: Qt has no layers, so the union of the frames repaints, pixels included, never the canvas. Under the Crop tool `renderBounds` takes the frame's union, so a frame past the canvas shows the checkerboard it would take in and redraws the canvas, as in Swift. `CropControls` is the tool's `ToolHeaderBar`: its row spaced 14 as Swift's `HStack`, Swift's labelled picker a "Ratio" `QLabel` (its combo box's buddy) and a `QComboBox` sharing 170 points, a choice that changes the ratio calling `changeCropRatio` (Swift's Line truncated
- Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom Swift's `ColorPicker` as a label and a 36 by 18 `SwatchButton` that opens a `QColorDialog` without alpha, the desktop's colour dialog where SwiftUI opens the system panel; then Cancel and the default OK, which rests while the draft is invalid, so Swift's guard in its action is left out. Units, Relative and Lock change only through their own controls, so nothing writes them back, and so does the image sheet's Resample. A wheel over Units changes the unit without the focus, so both sheets take the focus to Units first: a size still being typed commits in the unit it meant. `UI/ImageSizeSheet` is Swift's sheet: Pixels and Percent only while resampling, Width and Height converting by unit (Swift's binding refuses a number that is not positive; without resampling a size sets the resolution), Lock, disabled without resampling, the resolution with its unit (in inches or centimetres a new resolution scales the pixels, Swift's `onChange`), Resample (turned off, the pixels go back, the lock closes and Pixels or Percent becomes Inches), Sampling while resampling, the explanation, the result or the invalid note, Cancel and the default Resize. The controller's `canvasSize` and `imageSize` live in `IO/ProjectController+Resize.cpp` (with 10.3's exports the controller's file would pass 500 lines) and take a completion callback: refused without a window, a document or `begin`, as Swift's guards; the sheet is a window-modal `QDialog` over the window (`sheet`, Swift's `beginSheet`), whose Escape and close button cancel; the project stays busy until the sheet answers and the resize lands, or its alert is dismissed, Swift's `defer`. `resizeProject` takes the snapshot and starts the worker before anything changes, the worker catching `std::runtime_error` (both of the resizers' errors), and moves the result out and lands it through `applyDocumentSize` or `applyImageSize` (staged since 10.1); memory that runs out while the snapshot is taken or the result lands is explained as the render error. The alert opens from the handler, once the unwinding has freed what failed. The IO file includes the sheets, as Swift's controller hosts them. The Image menu gains Canvas Size… (Ctrl+Alt+C) and Image Size… (Ctrl+Alt+I), enabled with a document while the controller can start, and Flip Canvas Horizontal and Vertical, enabled by `canEditLayers`. Tests: `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests` (the sheets over a window, typed and answered by keys, a cancel, a refusal, a failure's alert), `ProjectResizeFailureTests` (a binary of its own, `main` as `CropFailureTests`': the image resizer, the snapshot, the worker and the landing each run out), the canvas flips in `LayerFlipTests`, the entries in `CompositorMenusTests`.
- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` twins Swift's sheet: the title, the 560 by 330 preview on gray 0.12 (the encoded preview fitted, centred and smooth; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a 36 by 18 `SwatchButton` that opens a `QColorDialog` without alpha (the same colour asks nothing, as Swift's `onChange`), the raster's size grouped with "px · sRGB", then the error in `BrightText`, or the file's size and "· encoded preview, fitted to window", or "Updating preview…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `JPEGExportSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads).
- File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-<UUID>` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`.
- Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
- `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update).
- The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set<int>` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`.
- `LayerMask::clipImage` returns nil when the placed grid cannot be rendered, as Swift's `try?` does, and logs a warning on `omaphoto.rendering` so the loss is visible.
- `SeparableBlend` has no twin. It exists because CoreGraphics gets Color Dodge and Color Burn wrong; QPainter's follow the PDF formulas (`LayerRendererTests::everyBlendModeMatchesThePdfFormulas` proves all thirteen modes within 2/255).
- `Rendering/HslBlend` is Linux-only: Hue, Saturation, Color and Luminosity, which QPainter lacks, blended by hand over a copy of the backdrop.
- Known difference: CoreGraphics enlarges "High quality" layers with a sharper kernel; QPainter offers bilinear only, so `InterpolationQuality::high` and `low` draw alike when enlarging. Reductions are sharp on both (halvings).
- Where an Apple framework threads internally (vImage, Core Image, Metal), the twin threads with QtConcurrent.
- A Swift error enum becomes an exception class with a `Kind` enum; `what()` carries the Swift `errorDescription`.
- `@Observable` classes stay plain C++ where only `EditorSession` mutates them; the session notifies the UI.
Deliberate name breaks: `MetalBrushCoverage` → `BrushCoverage`, `MetalLayerEffects` → `LayerEffectsKernel`.
## Linux-only behaviour
- Tool icons: SF Symbols have no Linux twin, and icon themes differ by distro, so `src/UI/ToolIcons` draws each tool's icon as paths in an 18-point square, in one colour that the caller takes from the palette (the Omarchy theme retints them for free). Swift's two hand-drawn icons keep their geometry there (`GradientToolIcon`'s Floyd–Steinberg fade, `CloneStampToolIcon`'s four parts); their Swift homes (`GradientControls`, `BrushControls`) arrive later. The eyedropper's glyph lives in `Rendering/EyedropperIcon`, since the canvas's cursor draws it too. Icons that follow tool state (ellipse marquee, polygonal lasso, eraser) arrive with that state. `tests/fixtures/ToolIcons.png` is the reference sheet: `ToolIconTests` redraws it, allows a font a few pixels and a stroke none, and writes the fresh sheet beside the test binary; after a wanted change, look at it and commit it.
- Omarchy theme: `src/UI/OmarchyTheme` reads `~/.local/state/omarchy/current/theme/colors.toml` (Omarchy 4.x: flat `key = "value"` lines, `mode` dark or light, the twenty-two stock themes' keys required, `orange` optional and `yellow` its stand-in) into `OmarchyColors`, applies them as the application palette (Window and Text from `background` and `foreground`, Base from `darker_background`, Button from `lighter_background`, Highlight and Link from `accent` with the background as highlighted text, PlaceholderText from `dark_foreground`, BrightText from `orange` or `yellow`, Mid from `muted`, the disabled roles from `muted`) and sets the Fusion style, which follows a palette on every desktop where a platform style does not; no stylesheet is needed. Keys and values read in double or single quotes. A missing key, or a recognised key whose value is bare or half-quoted or no `#rrggbb`, refuses the file with the key's name; a file that exists but cannot be opened, or sits behind a folder that cannot be searched (`stat` fails other than `ENOENT`), is refused too; either way the app keeps the palette it has and logs a warning, where an absent file means the built-in dark, Swift's grays, installed before the first load. `omarchy-theme-set` removes `current/theme`, moves `next-theme` in and writes `theme.name`, so the watcher holds `current/`, `current/theme` and the file, adds them back after each load (a removed path leaves a `QFileSystemWatcher`) and renews the file's watch every load (it follows the inode, which an atomic replacement swaps), watches the nearest ancestor the watcher accepts while `current/` is missing or barred, letting it go once the directory is watched, and loads once 100 ms after the last event. A read that stops short (an error, or fewer bytes than the file holds) is refused like a malformed file. Each load logs the theme's name and mode on `omaphoto.app`. `main` makes the theme before the window. Widgets take colours from the palette, never from literals; the canvas keeps Swift's document grays. The UI font stays Qt's default: `omarchy font current` is not followed.
- No updater. Distro packages own updates.
- Distribution (11.4b): releases live on GitHub under fixed asset names (`omaphoto_amd64.deb`, `omaphoto.x86_64.rpm`, `omaphoto-x86_64.pkg.tar.zst`) with `SHA256SUMS`, so `releases/latest/download/<name>` always reaches the newest. `scripts/install/ubuntu.sh`, `fedora.sh` and `arch.sh` read `/etc/os-release` (`ID` or `ID_LIKE`; Omarchy says `ID_LIKE=arch`), refuse another family or another machine than x86_64 (the DEB, built on Ubuntu 24.04, names Ubuntu's `t64` libraries, which Debian 13 lacks, so Debian is no family of it), verify the package against the sums and install it with apt, dnf or pacman under sudo, so the package manager resolves dependencies; apt reads the file as its own user, so the download folder is opened to it. pacman asks which build provides `onnxruntime`. Fedora's script names RPM Fusion's `libheif-freeworld` when it is missing, since Fedora's libheif decodes no HEVC, and adds no repository itself. `nixos.sh` installs the flake into the user's profile (`nix profile install`, the name every Nix still takes). The README runs each script after downloading it, so the package manager's questions read the terminal and fish runs it too. `release.yml` builds each package with the command the distro checks use, the DEB bundling ONNX Runtime from the dev image, the Arch package through `makepkg` as a user; the version is CMake's `project` version, the PKGBUILD's and the flake's, kept equal by hand.
- Remove Background runs an open-licensed model on ONNX Runtime.
- A `.comp` project is a directory on Linux; its layout matches the macOS package.
- Shortcuts map ⌘ to Ctrl and ⌥ to Alt.
## Commands
No sudo is needed. Docker does the work.
- `scripts/dev.sh build` — configure and build in the Ubuntu 24.04 container, warnings as errors. With no `CMAKE_BUILD_TYPE` the build is `RelWithDebInfo`: pixel code must stay optimized. Every target is compiled with `_GLIBCXX_ASSERTIONS`: an empty `std::optional` or a bad index aborts, in the tests and in the app, where it would otherwise pass by luck.
- `scripts/dev.sh test` — build, then run every test headless (`QT_QPA_PLATFORM=offscreen`). `HOME` and `/tmp` sit on a tmpfs, as in the distro checks: the store's syncs on the container's disk wait on a busy host.
- `scripts/dev.sh run` — run the app on the host Wayland display.
- `scripts/mutate.py FILE [--targets T1,T2]` — mutation runner, run from the repository root. Reads `old ==> new` blocks separated by `@@` lines on stdin, or with `--delete [--from N --to M]` removes each statement line in turn. A mutant counts as caught only when it builds and a test fails. Run it on every item before review.
- `scripts/heic-fixtures.sh` — rewrites the HEIC test fixtures in a throwaway container that has `heif-enc` and x265. Its parts are real files under `scripts/heic-fixtures/` (`Dockerfile`, `sources.cpp`, `generate.sh`), so lint reads each in its own language.
- `scripts/lint.py` — the house rules, from the repository root: every file under 500 lines, every comment one line and at most ten words. Each file is read by a scanner for its own language: C++ (`//` and `/* */` outside literals), Python (its own tokenizer), CMake (`#` outside quotes, escapes and bracket arguments, which open only where an argument begins, tracked as state through escapes; bracket comments too), Docker (any line that begins with `#`) and shell. The shell scanner reads the subset the scripts keep to (quotes, escapes, `$( )` and `${ }` to any depth, here-strings, `#` where a word begins) and refuses the rest by name and line, so nothing passes unread: `<<` (a heredoc or a shift), backticks, `$'…'`, arithmetic, `((`, `case` inside `$( )`, and a continued line anywhere but between words (it could split `$(`, `<<` or a keyword). Write scripts inside that subset. A file of any other kind under `scripts` fails lint. It reads `src`, `tests`, everything under `scripts`, `CMakeLists.txt` and `Dockerfile.dev`. `--self-test` checks the checker. Run both before every review.
## Code rules
Fail fast. No silent fallbacks. Fix causes. Log key paths through `QLoggingCategory` (`src/Logging.h`). No dead code. Files stay under 500 lines. Comments: one line, ten words at most, only when needed. No abstraction layers, no reserved extension points. Tests assert concrete values and fail when the code breaks. Tests read a returned `std::optional` through `.value()`, never `*` or `->`: an empty one must fail the test, not read stale memory. Code reads a found element through a checked form, a layer by `indexOf` and `[]`, a map by `at`: when its guard is lost the read aborts or throws, where an iterator at `end()` reads wild memory and the tests pass by luck (`_GLIBCXX_ASSERTIONS` checks `[]`, not iterators).
+460
View File
@@ -0,0 +1,460 @@
cmake_minimum_required(VERSION 3.22)
project(omaphoto VERSION 1.0.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_AUTOMOC ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE RelWithDebInfo)
endif()
option(OMAPHOTO_WERROR "Treat warnings as errors" OFF)
find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Test)
find_package(libheif REQUIRED)
find_package(Fontconfig REQUIRED)
find_package(onnxruntime REQUIRED)
add_library(compositor_core STATIC
src/Logging.cpp
src/ContentView.cpp
src/ContentView+Status.cpp
src/ContentView+Panels.cpp
src/Document/BrushStroke.cpp
src/Document/BrushStroke+Stroke.cpp
src/Document/BrushStroke+Commit.cpp
src/Document/CanvasSize.cpp
src/Document/Distort.cpp
src/Document/DocumentHistory.cpp
src/Document/EditorSession.cpp
src/Document/EditorSession+Brush.cpp
src/Document/EditorSession+Imports.cpp
src/Document/EditorSession+Layers.cpp
src/Document/EditorSession+Model.cpp
src/Document/EditorSession+Projects.cpp
src/Document/EditorSession+Transform.cpp
src/Document/LayerAdjustment.cpp
src/Document/LayerEffects.cpp
src/Document/LayerEffects+Session.cpp
src/Document/LayerEffects+Renderer.cpp
src/Document/AdjustmentEditing.cpp
src/Document/LayerAppearance.cpp
src/Document/LayerGroups.cpp
src/Document/LayerMask.cpp
src/Document/LayerTransform.cpp
src/Document/LiveLayerMask.cpp
src/Document/PixelAdjust.cpp
src/Document/ProjectWorkspace.cpp
src/Document/SelectionClipboard.cpp
src/Document/Crop.cpp
src/Document/Filters.cpp
src/Document/GuidedMatte.cpp
src/Document/SubjectRemoval.cpp
src/Document/FloatingSelection.cpp
src/Document/Gradient.cpp
src/Document/Filters+Session.cpp
src/Document/Levels.cpp
src/Document/LevelsAutomatic.cpp
src/Document/Curves.cpp
src/Document/ImageAdjustments.cpp
src/Document/HueSaturation.cpp
src/Document/HueSaturation+Filter.cpp
src/Document/HueSaturation+Session.cpp
src/Document/BlurTool.cpp
src/Document/CloneStamp.cpp
src/Document/ColorPalette.cpp
src/Document/ContentFill.cpp
src/Document/LayerFlip.cpp
src/Document/LayerMerge.cpp
src/Document/MagicWand.cpp
src/Document/MaskTracing.cpp
src/Document/PixelInvert.cpp
src/Document/Selection.cpp
src/Document/SelectionEdits.cpp
src/Document/ShapeTool.cpp
src/Document/TypeTool.cpp
src/Document/SmudgeLiquify.cpp
src/UI/BlendModePicker.cpp
src/UI/BrushControls.cpp
src/UI/CropControls.cpp
src/UI/CanvasSizeSheet.cpp
src/UI/ImageSizeSheet.cpp
src/UI/JPEGExportSheet.cpp
src/UI/ByteCounts.cpp
src/UI/SliderSnap.cpp
src/UI/GradientControls.cpp
src/UI/ShapeControls.cpp
src/UI/TypeControls.cpp
src/UI/CanvasThumbnail.cpp
src/UI/ColorPaletteControls.cpp
src/UI/ColorPickerSheet.cpp
src/UI/CompositorMenus.cpp
src/UI/LayerAppearanceControls.cpp
src/UI/LayerIcons.cpp
src/UI/LassoControls.cpp
src/UI/LayerMaskMenu.cpp
src/UI/LayersPanel.cpp
src/UI/LevelsSheet.cpp
src/UI/HueSaturationSheet.cpp
src/UI/CurvesControls.cpp
src/UI/NativeLayerList.cpp
src/UI/NativeLayerList+Buttons.cpp
src/UI/NativeLayerList+Cell.cpp
src/UI/NativeLayerList+Drag.cpp
src/UI/NativeLayerList+Effects.cpp
src/UI/NavigationToolHeader.cpp
src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp
src/UI/FilterSheet.cpp
src/UI/FloatingPanel.cpp
src/UI/NewCanvasSheet.cpp
src/UI/OmarchyTheme.cpp
src/UI/ProjectTabs.cpp
src/UI/ProjectWorkspaceView.cpp
src/UI/ToolHeaderStyle.cpp
src/UI/ToolIcons.cpp
src/IO/CanvasResizer.cpp
src/IO/ImageExporter.cpp
src/IO/ImageImporter.cpp
src/IO/ImageResizer.cpp
src/IO/ProjectController.cpp
src/IO/ProjectController+Resize.cpp
src/IO/ProjectController+Export.cpp
src/IO/ImageFileDrop.cpp
src/IO/ProjectStore.cpp
src/IO/ProjectStore+Adjustment.cpp
src/IO/ProjectStore+Effects.cpp
src/IO/ProjectStore+Manifest.cpp
src/Rendering/AdjustmentSurface.cpp
src/Rendering/CanvasViewport.cpp
src/Rendering/DownsampleCache.cpp
src/Rendering/EffectsPreviewCache.cpp
src/Rendering/EditorCanvas.cpp
src/Rendering/EditorCanvas+Brush.cpp
src/Rendering/EditorCanvas+Crop.cpp
src/Rendering/EditorCanvas+Effects.cpp
src/Rendering/EditorCanvas+Gradient.cpp
src/Rendering/EditorCanvas+Cursors.cpp
src/Rendering/BrushCursorOverlay.cpp
src/Rendering/EditorCanvas+Navigation.cpp
src/Rendering/EditorCanvas+Picking.cpp
src/Rendering/EditorCanvas+Selection.cpp
src/Rendering/EditorCanvas+Shape.cpp
src/Rendering/InlineTextEditor.cpp
src/Rendering/InlineTextEditor+Editing.cpp
src/Rendering/InlineTextEditor+Input.cpp
src/Rendering/InlineTextEditor+Pointer.cpp
src/Rendering/TextLayout.cpp
src/Rendering/EditorCanvas+Transform.cpp
src/Rendering/SelectionIcons.cpp
src/Rendering/EyedropperIcon.cpp
src/Rendering/SampleRingOverlay.cpp
src/Rendering/TransformOverlay.cpp
src/Rendering/HslBlend.cpp
src/Rendering/LayerEffectsKernel.cpp
src/Rendering/LayerEffectsSurface.cpp
src/Rendering/LayerRenderer.cpp
src/Rendering/LiveMaskRenderer.cpp
src/Rendering/BrushCoverage.cpp
src/Rendering/RasterSnapshot.cpp
src/Rendering/TiledLayerRenderer.cpp
)
# Shared macOS pixel kernels, always optimized.
set(OMAPHOTO_KERNELS ${CMAKE_CURRENT_SOURCE_DIR}/kernels)
target_sources(compositor_core PRIVATE ${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c)
set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c
PROPERTIES COMPILE_OPTIONS -O2)
# The per-pixel loops vectorise only at -O3.
set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3)
target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS})
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE heif Fontconfig::Fontconfig onnxruntime::onnxruntime)
target_compile_options(compositor_core PUBLIC -Wall -Wextra
$<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>)
# Empty optionals and bad indexes abort, not pass by luck.
target_compile_definitions(compositor_core PUBLIC
OMAPHOTO_VERSION="${PROJECT_VERSION}" _GLIBCXX_ASSERTIONS)
add_executable(omaphoto src/CompositorApp.cpp)
target_link_libraries(omaphoto PRIVATE compositor_core)
include(GNUInstallDirs)
install(TARGETS omaphoto RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
install(FILES packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages)
# Swift's app icon, one size a hicolor folder.
foreach(size 16 32 64 128 256 512)
install(FILES packaging/icons/app-icon-${size}.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${size}x${size}/apps RENAME io.github.ZacharyZhang_NY.OmaPhoto.png)
endforeach()
install(FILES licenses/U-2-Net.txt LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/omaphoto)
# A direct install registers the project type; packages use hooks.
install(CODE "
if(\"\$ENV{DESTDIR}\" STREQUAL \"\")
find_program(UPDATE_MIME update-mime-database REQUIRED)
execute_process(COMMAND \${UPDATE_MIME} \"\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/mime\" COMMAND_ERROR_IS_FATAL ANY)
endif()")
# Ubuntu 24.04 packages no ONNX Runtime: its package carries one.
option(OMAPHOTO_BUNDLE_ORT "Install ONNX Runtime beside the app" OFF)
set(OMAPHOTO_ORT_NOTICES /usr/local/share/doc/onnxruntime CACHE PATH "ONNX Runtime's LICENSE and notices")
if(OMAPHOTO_BUNDLE_ORT)
# Microsoft's licence travels with its library.
foreach(notice LICENSE ThirdPartyNotices.txt)
if(NOT EXISTS ${OMAPHOTO_ORT_NOTICES}/${notice})
message(FATAL_ERROR "bundling ONNX Runtime needs ${OMAPHOTO_ORT_NOTICES}/${notice}")
endif()
install(FILES ${OMAPHOTO_ORT_NOTICES}/${notice} DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/omaphoto/onnxruntime)
endforeach()
install(IMPORTED_RUNTIME_ARTIFACTS onnxruntime::onnxruntime LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/omaphoto)
set_target_properties(omaphoto PROPERTIES INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}/omaphoto")
endif()
# Debian and Fedora packages; each built on its own system.
set(CPACK_PACKAGE_NAME omaphoto)
set(CPACK_PACKAGE_VENDOR "ZacharyZhang-NY")
set(CPACK_PACKAGE_CONTACT "ZacharyZhang-NY")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Layered image editing for Linux, a port of Compositor")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/ZacharyZhang-NY/OmaPhoto")
set(CPACK_PACKAGING_INSTALL_PREFIX /usr)
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
set(CPACK_DEBIAN_PACKAGE_SECTION graphics)
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
# What no library names: HEVC decoding, TIFF, the MIME tools.
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libheif-plugin-libde265, qt6-image-formats-plugins, shared-mime-info, hicolor-icon-theme")
set(CPACK_RPM_FILE_NAME RPM-DEFAULT)
set(CPACK_RPM_PACKAGE_LICENSE MIT)
set(CPACK_RPM_PACKAGE_GROUP Applications/Multimedia)
set(CPACK_RPM_PACKAGE_REQUIRES "qt6-qtimageformats, shared-mime-info, hicolor-icon-theme")
# Fedora decodes HEVC through RPM Fusion's libheif-freeworld.
set(CPACK_RPM_PACKAGE_SUGGESTS libheif-freeworld)
include(CPack)
# Remove Background's model, which packages fetch by checksum.
set(OMAPHOTO_MODEL "" CACHE FILEPATH "u2net.onnx to install")
if(OMAPHOTO_MODEL)
# The file itself: a link leads back to this machine.
file(REAL_PATH "${OMAPHOTO_MODEL}" model)
install(FILES ${model} DESTINATION ${CMAKE_INSTALL_DATADIR}/omaphoto RENAME u2net.onnx)
endif()
enable_testing()
function(compositor_test name)
add_executable(${name} tests/${name}.cpp)
target_link_libraries(${name} PRIVATE compositor_core Qt6::Test)
add_test(NAME ${name} COMMAND ${name})
set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
endfunction()
compositor_test(AdjustmentSurfaceTests)
compositor_test(BlendModePickerTests)
compositor_test(BlendShortcutTests)
compositor_test(LayersPanelTests)
compositor_test(LayerListFocusTests)
compositor_test(LayerDragTests)
compositor_test(LayerDragGestureTests)
compositor_test(ProjectDropTests)
compositor_test(BlendModeTests)
compositor_test(BrushCanvasTests)
compositor_test(BrushGestureTests)
compositor_test(BrushKeyTests)
compositor_test(BrushCursorOverlayTests)
compositor_test(BrushControlsTests)
compositor_test(BrushPerformanceTests)
compositor_test(CanvasPerformanceTests)
compositor_test(PoolMapTests)
compositor_test(DesktopIntegrationTests)
# The install it checks needs the app built.
add_dependencies(DesktopIntegrationTests omaphoto)
# What this build installs, as CMake itself reads it.
target_compile_definitions(DesktopIntegrationTests PRIVATE OMAPHOTO_BUNDLED=$<BOOL:${OMAPHOTO_BUNDLE_ORT}>
OMAPHOTO_LIBDIR="${CMAKE_INSTALL_LIBDIR}" OMAPHOTO_MODEL_PATH="${OMAPHOTO_MODEL}")
compositor_test(BrushIntersectionTests)
compositor_test(BrushSessionTests)
compositor_test(BrushPreviewTests)
compositor_test(BrushRasterTests)
compositor_test(BrushTests)
compositor_test(BrushCoverageTests)
compositor_test(SpotHealingTests)
compositor_test(CloneStampTests)
compositor_test(CloneStampCanvasTests)
compositor_test(SmudgeLiquifyTests)
compositor_test(BlurToolTests)
compositor_test(SmearCanvasTests)
compositor_test(GradientTests)
compositor_test(GradientCanvasTests)
compositor_test(ShapeToolTests)
compositor_test(ShapeLayerTests)
compositor_test(ShapeCanvasTests)
compositor_test(TypeToolTests)
compositor_test(TextLayoutTests)
compositor_test(TypeSessionTests)
compositor_test(TypeStoreTests)
compositor_test(TypeControlsTests)
compositor_test(InlineTextEditorTests)
compositor_test(InlineTextInputTests)
compositor_test(InlineTextDrawTests)
compositor_test(InlineTextPreeditTests)
compositor_test(InlineTextPointerTests)
compositor_test(InlineTextHandleTests)
compositor_test(ColorPickerTests)
compositor_test(FloatingPanelTests)
compositor_test(ColorSwatchTests)
compositor_test(ColorPaletteControlsTests)
compositor_test(ColorPickerSheetTests)
compositor_test(EyedropperTests)
compositor_test(PickingTests)
compositor_test(LevelsTests)
compositor_test(LevelsSessionTests)
compositor_test(LevelsModelTests)
compositor_test(LevelsEdgeTests)
compositor_test(LevelsFailureTests)
compositor_test(LevelsSheetTests)
compositor_test(LevelsHandleTests)
compositor_test(LevelsCanvasTests)
compositor_test(ImageAdjustmentTests)
compositor_test(ImageAdjustmentModelTests)
compositor_test(CurvesControlsTests)
compositor_test(HueSaturationTests)
compositor_test(HueSaturationModelTests)
compositor_test(HueSaturationSessionTests)
compositor_test(HueSaturationFailureTests)
compositor_test(HueSaturationSheetTests)
compositor_test(HueSaturationSpectrumTests)
compositor_test(HueSaturationCanvasTests)
compositor_test(CanvasDisplayTests)
compositor_test(CanvasNavigationTests)
compositor_test(CanvasSizeTests)
compositor_test(CanvasThumbnailTests)
compositor_test(ClippingStackTests)
compositor_test(CompositorTests)
compositor_test(CompositorMenusTests)
compositor_test(CompositorUITestsLaunchTests)
compositor_test(ContentViewTests)
compositor_test(ContentViewStatusTests)
compositor_test(DistortTests)
compositor_test(DownsampleTests)
compositor_test(DrawCoverageTests)
compositor_test(EditorSessionSignalTests)
compositor_test(EditorSessionTests)
compositor_test(ExportTests)
compositor_test(FolderMaskClipTests)
compositor_test(EditorCanvasTests)
compositor_test(GroupSessionTests)
compositor_test(GroupTests)
compositor_test(GroupingSelectionTests)
compositor_test(HighDpiCanvasTests)
compositor_test(HistoryTests)
compositor_test(ImageImportSessionTests)
compositor_test(ImageImportTests)
compositor_test(ImageSizeTests)
compositor_test(JPEGExportTests)
compositor_test(LayerAppearanceControlsTests)
compositor_test(LayerAppearanceTests)
compositor_test(LayerMaskEditTests)
compositor_test(LayerMaskSessionTests)
compositor_test(LayerMaskTests)
compositor_test(LayerRendererTests)
compositor_test(LayerTests)
compositor_test(LiveMaskRendererTests)
compositor_test(LiveMaskBakeTests)
compositor_test(LiveMaskDeleteTests)
compositor_test(LiveMaskEditTests)
compositor_test(LiveMaskSessionTests)
compositor_test(LiveMaskTests)
compositor_test(MaskPlacementTests)
compositor_test(MaskSignalTests)
compositor_test(MaskTracingTests)
compositor_test(MaskTransformTests)
compositor_test(ProjectManifestTests)
compositor_test(NativeLayerListTests)
compositor_test(NavigationToolHeaderTests)
compositor_test(NewCanvasSheetTests)
compositor_test(OmarchyThemeTests)
compositor_test(ProjectControllerTests)
compositor_test(ProjectCopyTests)
compositor_test(ProjectOpenTests)
compositor_test(ProjectReceiveTests)
compositor_test(ProjectReplaceTests)
compositor_test(ProjectWorkspaceFlowTests)
compositor_test(ProjectWorkspaceSignalTests)
compositor_test(ProjectWorkspaceTests)
compositor_test(ProjectWorkspaceViewTests)
# It starts the real app beside itself.
add_dependencies(ProjectWorkspaceViewTests omaphoto)
compositor_test(ProjectSessionTests)
compositor_test(ProjectTests)
compositor_test(RasterSnapshotSessionTests)
compositor_test(RasterSnapshotTests)
compositor_test(SessionGateTests)
compositor_test(TiledLayerTests)
compositor_test(TiledPieceTests)
compositor_test(TiledStrokeTests)
compositor_test(TransformSessionTests)
compositor_test(ToolIconTests)
compositor_test(TransformTests)
compositor_test(TransformOverlayTests)
compositor_test(TransformPressTests)
compositor_test(TransformInspectorTests)
compositor_test(TransformSnapTests)
compositor_test(CursorTests)
compositor_test(DistortCanvasTests)
compositor_test(DistortMaskTests)
compositor_test(FiltersTests)
compositor_test(FilterTests)
compositor_test(FilterModelTests)
# It checks what the filters hand Swift's C kernels.
target_include_directories(FilterModelTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(FilterSessionTests)
compositor_test(FilterFailureTests)
compositor_test(LayerAdjustmentTests)
compositor_test(AdjustmentLayerTests)
compositor_test(AdjustmentEditorTests)
compositor_test(AdjustmentPanelTests)
compositor_test(LayerEffectsTests)
compositor_test(LayerEffectsSessionTests)
compositor_test(LayerEffectsKernelTests)
compositor_test(EffectsPreviewCacheTests)
compositor_test(LayerEffectsSurfaceTests)
compositor_test(EffectsCanvasTests)
compositor_test(EffectsFailureTests)
compositor_test(EffectsPanelTests)
compositor_test(LayerEffectRowTests)
compositor_test(GuidedMatteTests)
compositor_test(SubjectRemovalTests)
compositor_test(SubjectRemovalModelTests)
compositor_test(SubjectRemovalFailureTests)
compositor_test(RemoveBackgroundTests)
compositor_test(LayerEffectsRendererTests)
compositor_test(LayerFlipTests)
compositor_test(LayerMergeTests)
compositor_test(PixelEditTests)
compositor_test(PixelInvertTests)
compositor_test(PixelMoveTests)
compositor_test(SelectionTests)
compositor_test(SelectionSourceTests)
compositor_test(SelectionEditTests)
compositor_test(SelectionSignalTests)
compositor_test(SelectMenuTests)
compositor_test(SelectionCanvasTests)
compositor_test(SelectionOverlayTests)
compositor_test(LassoControlsTests)
compositor_test(MagicWandTests)
compositor_test(SelectionClipboardTests)
compositor_test(SmartEditTests)
compositor_test(FilterSheetTests)
compositor_test(FilterControlsTests)
compositor_test(FloatingSelectionTests)
compositor_test(CropTests)
compositor_test(CropSessionTests)
compositor_test(CropCanvasTests)
compositor_test(CropControlsTests)
compositor_test(CropFailureTests)
compositor_test(CanvasSizeSheetTests)
compositor_test(ImageSizeSheetTests)
compositor_test(ProjectResizeTests)
compositor_test(JPEGExportSheetTests)
compositor_test(ProjectExportTests)
compositor_test(ProjectExportFailureTests)
compositor_test(ByteCountsTests)
compositor_test(ImageFileDropTests)
compositor_test(SliderSnapTests)
compositor_test(ProjectResizeFailureTests)
+25
View File
@@ -0,0 +1,25 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ cmake ninja-build qt6-base-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \
qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev \
ca-certificates curl dpkg-dev file fakeroot \
&& rm -rf /var/lib/apt/lists/*
# Ubuntu 24.04 packages no ONNX Runtime: Microsoft's own build.
RUN curl -fsSL -o /tmp/ort.tgz \
https://github.com/microsoft/onnxruntime/releases/download/v1.21.0/onnxruntime-linux-x64-1.21.0.tgz \
&& echo "7485c7e7aac6501b27e353dcbe068e45c61ab51fbaf598d13970dfae669d20bf /tmp/ort.tgz" | sha256sum -c - \
&& mkdir -p /tmp/ort /usr/local/include/onnxruntime /usr/local/lib64 \
&& tar -xzf /tmp/ort.tgz -C /tmp/ort --strip-components=1 \
&& cp -r /tmp/ort/include/. /usr/local/include/onnxruntime/ \
&& mkdir -p /usr/local/lib/cmake && mv /tmp/ort/lib/cmake/onnxruntime /usr/local/lib/cmake/ \
&& cp -a /tmp/ort/lib/. /usr/local/lib64/ \
&& mkdir -p /usr/local/share/doc/onnxruntime \
&& cp /tmp/ort/LICENSE /tmp/ort/ThirdPartyNotices.txt /usr/local/share/doc/onnxruntime/ \
&& echo /usr/local/lib64 > /etc/ld.so.conf.d/onnxruntime.conf && ldconfig \
&& rm -rf /tmp/ort /tmp/ort.tgz
# U²-Net (Apache-2.0), as rembg converted it (MIT), for Remove Background.
RUN mkdir -p /usr/local/share/omaphoto \
&& curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \
https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \
&& echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c -
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Wonder Assembly LLC (Compositor)
Copyright (c) 2026 ZacharyZhang-NY (OmaPhoto, the Linux port)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+69
View File
@@ -0,0 +1,69 @@
# OmaPhoto
OmaPhoto is a layered image editor for Linux, made first for [Omarchy](https://omarchy.org). It follows your Omarchy theme and switches with it.
It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) by Wonder Assembly, a macOS app written in Swift. Compositor's source is the specification: OmaPhoto keeps its tools, its menus and its shortcuts (Ctrl for ⌘, Alt for ⌥). It reads and writes Compositor's `.comp` projects, so a project moves between the two apps. OmaPhoto is independent of Wonder Assembly.
## What it does
- Layers, folders, masks, clipping masks, blend modes and opacity
- Selections: marquee, lasso, magic wand, load from a layer, expand and contract
- Brush, eraser, spot healing, clone stamp, smudge, blur and liquify, gradient, shapes, type
- Move, transform and distort layers and selections
- Levels, Curves, Hue/Saturation, Exposure, Gradient Map and Grain, as edits or as adjustment layers
- Layer effects: stroke, drop shadow, color overlay, inner shadow
- Filters: Gaussian and motion blur, noise, lens correction, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Crop, canvas size, image size; PNG and JPEG export
- Imports JPEG, PNG, TIFF and HEIC
## Install
Each script downloads the newest release's package, checks it against the release's `SHA256SUMS` and installs it with your system's package manager. Packages are built for x86_64.
Arch and Omarchy:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/arch.sh && bash arch.sh
```
Ubuntu 24.04 or later, and its derivatives (Linux Mint 22, Pop!_OS 24.04):
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/ubuntu.sh && bash ubuntu.sh
```
The DEB uses Ubuntu's package names, so Debian itself builds from source (below).
Fedora:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/fedora.sh && bash fedora.sh
```
Fedora's own libheif decodes no HEVC. For HEIC import, add [RPM Fusion](https://rpmfusion.org/Configuration) and install `libheif-freeworld`; the script reminds you.
NixOS installs from the flake into your profile:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/nixos.sh && bash nixos.sh
```
Or add `github:ZacharyZhang-NY/OmaPhoto` as a flake input and put its `packages.x86_64-linux.default` in your configuration.
The packages themselves sit on the [releases page](https://github.com/ZacharyZhang-NY/OmaPhoto/releases).
## Build from source
The build runs in Docker, so the host needs only Docker:
```sh
scripts/dev.sh build # configure and build in Ubuntu 24.04
scripts/dev.sh test # every test, headless
scripts/dev.sh run # the app on your Wayland display
```
By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, fontconfig and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos` builds and tests on those systems.
## Licence
MIT, as Compositor is; see `LICENSE`. Remove Background uses U²-Net (Apache-2.0, `licenses/U-2-Net.txt`) through ONNX Runtime (MIT). The Ubuntu package carries ONNX Runtime with its notices.
+402
View File
@@ -0,0 +1,402 @@
# Compositor for Linux — task list
This file is the progress truth. Work top to bottom. Do not skip. One item at a time, main agent only.
## Ground rules
- No PRD exists. Chapters follow the README feature sections; order bends only where a dependency forces it.
- The Swift tree is the spec and stays untouched. Each Swift file gets a Linux twin with the same type and function names.
- Swift files over 500 lines split by concern with the repo's own `Name+Part` convention.
- Done means: code, twin tests green in the dev container, codex review passed (max 3 rounds), box ticked.
- A failed third round ends an item's reviews. Its findings are fixed, proven by tests and mutants, logged as such, and named in the next item's review scope.
- Review: Herdr split pane, codex agent `reviewer` on `gpt-6-astra` at effort `max` in YOLO mode, scoped to the item's files. The user's shell aliases `codex` to `codex --yolo`, so the pane start passes only `-m gpt-6-astra -c model_reasoning_effort="max"`.
- No commits unless the user asks.
## Decisions (user-confirmed 2026-09-20)
- Stack: C++20, Qt 6 Widgets (floor: Qt 6.4, Ubuntu 24.04). CPU raster: QPainter plus the eight C kernels, compiled in place from `Compositor/Rendering/*.c`.
- Remove Background: ONNX Runtime with an open-licensed model.
- No updater on Linux. No Dokploy. Final output: packages and the acceptance table.
- Omarchy source: `~/.local/state/omarchy/current/theme/colors.toml` (Omarchy 4.x). Absent file means built-in dark. Omarchy 3.x is out of scope.
## Cut (razor)
GPU backend (unless item 8.3 fails its gate), AppImage, Flatpak, CI pipelines, theme plug-ins, theme settings UI.
## 0 Foundation
- [x] 0.1 Dev container, CMake skeleton, logging categories, `AGENTS.md`. New: `linux/Dockerfile.dev`, `linux/CMakeLists.txt`, `linux/scripts/dev.sh`, `linux/src/Logging`, `linux/src/CompositorApp.cpp` (`main`), `linux/src/ContentView` (window shell: title, 1180×780 default, 800×520 minimum). Verify: container builds, the window test passes and saves an offscreen screenshot.
## 1 Document model
- [x] 1.1 `LayerTransform`, `LayerAppearance`, `CanvasViewport`, `ImageLayer`, `CanvasDocument`, `NavigationTool`, `ImportedImage`. Tests: twins of the pure-geometry cases in `TransformTests`, `LayerAppearanceTests`.
- [x] 1.2 `DocumentHistory`. Tests: `HistoryTests`.
- [x] 1.3 `RasterSnapshot`, `BrushRaster`/`BrushPatch` helpers. Tests: `RasterSnapshotTests`.
- [x] 1.4 `LayerGroups`: `LayerHierarchy`, `hierarchyRecord`, the `CanvasDocument` hierarchy views; `ProjectLayerRecord` and `ProjectError` arrive here. Tests: `GroupTests` (hierarchy cases). The `EditorSession` extension and its tests move to 4.1.
- [x] 1.5 `LayerMask` core: the struct, `solid`, `assetFrom`, placement rules, `background`, `placed`, `drawSmooth`, `maskTransform`, mask fields in the record and in history. Tests: `LayerMaskTests` (class level). Moved on: `clipImage`, `MaskPlacementCache`, `FolderMaskClip` to 2.1; `ProjectSnapshot.mask` to 3.1; session mask editing and the Swift `LayerMaskTests` twins to 4.1; mask distort to 6.2; `placedMaskPreview` to 8.2.
- [x] 1.6 `LiveMaskGraph.validate`, `maskSourceID` on the layer and record, `MaskTracing`. Tests: `LiveMaskTests` (graph cases), `MaskTracingTests`. Moved on: the session clipping functions, `adoptClipping`, `releaseDetachedClipping`, `LiveMaskBaker`, the delete choice and the Swift `LiveMaskTests`/`MaskTransformTests` twins to 4.1; `drawLiveComposite` to 4.4; `loadMaskSelection`/`loadLayerSelection` to 7.1; the adjustment-source rule of `validate` to 9.5.
## 2 Rendering
- [x] 2.1a `DownsampleCache`: Lanczos halvings in place of vImage, threaded with QtConcurrent, `rgba_clamp_premultiplied` from `AdjustPixels.c`. Tests: the cache cases of `DownsampleTests`.
- [x] 2.1b `LayerRenderer.draw`: placement, opacity, every blend mode (the four non-separable ones by hand in `HslBlend`), soft masks without a CoreGraphics mask clip; decide `SeparableBlend` by measuring Qt's dodge and burn against the PDF formulas. Tests: the drawing cases of `DownsampleTests`, blend and mask cases. `drawCoverage` moves to 3.3, its first caller; `drawBrushPreview` to 2.2.
- [x] 2.1c From `LayerMask`: `clipImage`, `MaskPlacementCache`, `FolderMaskClip`.
- [x] 2.2a `TiledLayerRenderer` for committed rasters: `Piece`, `support`, `aligned`, `interiors`, `piece`, `TiledPieceCache`, `Frame`, `drawRaster`; `LayerRenderer`'s compose-aside path becomes shared. Tests: the committed part of `tiledLayersDrawLikeOneImage`, geometry and cache cases.
- [x] 2.2b `TiledLayerRenderer.drawStroke`, `drawMaskStroke`, `drawCropped`, `Piece.offsetBy` and the offset of `drawCommitted`, `Frame.device` and `Frame.sampling` (first read by `drawMaskStroke`), `LayerRenderer.drawBrushPreview`. Tests: the live-stroke parts of `TiledLayerTests` that need no session (`TiledStrokeTests`), `BrushPreviewTests`.
- [x] 2.3 `LiveMaskRenderer` (coverage, chains, clipping stacks, `drawComposite`), `AdjustmentSurface`; `BrushPixels.c` joins the build for its alpha kernels. Tests: `LiveMaskRendererTests`, `ClippingStackTests` (the pixel values of the Swift `LiveMaskTests`, drawn without a session), `AdjustmentSurfaceTests`. Moved on: `LiveMaskRenderer.adjust` and the adjustment hooks to 9.5 (they need `LayerAdjustment`); `LayerEffectsSurface` to 9.6 (it needs `LayerEffects` and the effects kernel).
- [x] 2.4 `ImageExporter` (flatten, PNG, JPEG, resolution metadata, atomic write); `ProjectManifest`, `ProjectSnapshot` and `ProjectSnapshot.mask` arrive here as data, because `render` takes a snapshot. Tests: `ExportTests`, `JPEGExportTests`. Moved on: layer effects in `render` to 9.6, adjustment layers in `render` to 9.5, the cancellation points of `jpeg` to 10.3 (their only caller).
## 3 IO
- [x] 3.1 `ProjectStore`: `.comp` read and write at versions 1–7 (verified in `ProjectStore.swift`: `(1...7).contains`), the manifest codec (`ProjectStore+Manifest`), every validation rule and limit, atomic replace with a two-step fallback, `rawValue` for `LayerBlendMode` and `LayerSampling`. Tests: `ProjectTests` (store level; the session cases wait for 4.1 and 4.2), `ProjectManifestTests`, `ProjectReplaceTests`. Moved on: the record fields `adjustment` (9.5), `shape` (8.8), `effects` (9.6) and `text` (8.9) with their validation rules; until then their keys are ignored on load.
- [x] 3.2 `ImageImporter`: JPEG, PNG, TIFF, HEIC (libheif), EXIF orientation, sRGB conversion, pixel budget, thumbnails, `ImageImportError`. Tests: `ImageImportTests` (decode level), HEIC fixtures from `scripts/heic-fixtures.sh`. Moved on: `importPlacementAndPartialFailure`, `dropPositionUsesDocumentCoordinates` and `queuedImportsAreNotLost` to 4.1 (the session's import queue); `fileDropProvidersReachImporterInOrder` to 10.4 (`ImageFileDrop`). Packaging (11.x) must depend on the HEVC decoder plugin by name (`libheif-plugin-libde265` on Ubuntu; Fedora keeps it in RPM Fusion as `libheif-freeworld`).
- [x] 3.3 `ImageResizer`, `CanvasResizer`, `CanvasSize`, `LayerRenderer.drawCoverage`. Tests: `ImageSizeTests`, `CanvasSizeTests` (store level), `DrawCoverageTests`. Also: `lint.py` reads the comments of shell and Python scripts, `CMakeLists.txt` and `Dockerfile.dev` (3.2 round 2). Moved on: `EditorSession.applyImageSize` and `applyDocumentSize` with the undo halves of the Swift tests to 4.1; `CanvasUnit.allCases` to 10.2, its first reader; the record fields the resizers drop or keep (shape 8.8, text 8.9, effects 9.6) are named in AGENTS.md; 7.x must give `drawCoverage` an opaque device under a soft selection clip.
## 4 Shell, canvas, theme, layers panel
- [x] 4.1a `EditorSession` core: the class and its change notification, document and tool state, the gates (`canEditLayers`, `canUseHistory`, each reading only the state that exists so far; see "Session hooks owed"), `selectLayer`, `selectTool`, history (`beginEdit`, `endEdit`, `undo`, `redo`), layers (add, delete, rename, visibility and its swipe, reorder, move), `createDocument`, `insert`, `fit`, `zoom`, and from the `LayerGroups` extension what the core's own folder branches need to be tested: `descendantIDs`, `addGroup`, `toggleGroupExpansion`. New: `EditorSessionTests`, `EditorSessionSignalTests` (the `changed()` contract). Tests: `LayerTests` (the session cases), `CompositorTests` (replace `zoomStaysWithinItsRange` with the `limitsAndNewDocumentReset` twin), the session cases of `HistoryTests`. In scope by the third-round rule: the `lint.py` fixes of 3.3 round 3. Moved on: `CanvasEntryTests` to 4.3 and 4.4 (they test `CanvasView` and `NewCanvasSheet`); `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` to 4.6 (`NativeLayerList`); `selectionAndRenameDoNotInvalidateCanvasButPixelChangesDo` to 4.4.
- [x] 4.1b Groups and selection in the session (the rest of the `LayerGroups` extension): `selectLayers`, `extendSelection`, `groupSelectedLayers`, `canPlaceLayer`, `placeLayer`, `moveActiveLayerOutOfGroup`, `layerRows`, and the multi-delete owed since 4.1a: `deleteSelectedLayers`, `finishDeletingLayers`. Tests: `GroupingSelectionTests`; of `GroupTests` the session cases `nestedGroupsMoveOutCollapseAndDeleteUndo` and `hiddenParentOverridesChildrenAndExportOrderFollowsGroups` (its session half, an inserted image standing in for the import); the new mutators join `EditorSessionSignalTests` and the round trip of `HistoryTests`. Moved on: the export lines of `hiddenParentOverrides…` and all of `groupsRoundTripAndSurviveImageAndCanvasResize` to 4.2 (they need `projectSnapshot`); the `canTransform` lines of both test files to 4.1c; the import in `hiddenParentOverrides…` to 4.1d; `placeLayer`'s clipping calls to 4.1e (no session call can clip a layer before `linkMask`).
- [x] 4.1c Transform edits in the session: `TransformEdit`, `TransformGroup`, `canTransform`, `beginTransform`, `previewTransform`, `commitTransform`, `cancelTransform`, `nudgeLayer`, the group box (`transformsAsGroup`, `groupTransformMembers`, `groupTransformBox`), `transformPixelSize`, `displayedTransform`, `editedTransform`, `pendingTransform`, and the hooks owed: `selectLayer`, `selectTool`, `selectLayers` and `createDocument` commit an open edit, the gates read it, `extendSelection` works through it. `DistortWarp::corners` comes forward from 6.2 (the group box is made of corners). Tests: the session cases of `TransformTests` (`blankLayersHaveNoTransformHandlesOrEditing` without its overlay lines, `previewCommitCancelAndUndoPreserveSources`, `scalePercentSetsBothSidesAboutTheCenter` through the session, `noOpInvalidValuesAndSwitchingTools`); new `TransformSessionTests` (Swift tests no group box, nudge or hook); the `canTransform` lines of `GroupTests` and `GroupingSelectionTests`; the transform calls join `EditorSessionSignalTests` and the round trip of `HistoryTests`. Moved on: `TransformEdit.persistent` with the argument of `beginTransform`, `snapGuides` and the overlay lines of `blankLayers…` to 6.1 (canvas and overlay read them); `beginDuplicateTransform`, `transformDuplicate` and `duplicateTransformPreservesOriginalAndSupportsUndoAndCancel` to 5.1 (they need `duplicateActiveLayer`); `layerListToolKeysAndTransformNudge` to 4.6 (it drives the layer table); mask-alone edits, `transformTargetsMask`, mask placements on commit, `blendPreview` and `finishOpacityEdit` to 4.1e; `corners` to 6.2; floating edits to 7.4; the grown transform of `displayedTransform` to 7.5.
- [x] 4.1d Import queue and busy gating: `importImages` (URLs in, a callback when the request is in), the queue and its drain, `waitForProjectAccess`, `waitForFileRequest`, `canStartProjectOperation`, `showsBusy` and its delay; the hooks owed: the six setters resume file requests, `isProjectBusy` frees project waiters and drives `showsBusy`, `importImages` commits a transform first. Tests: new `ImageImportSessionTests` (the twin's `ImageImportTests` stands at 480 lines) with Swift's `importPlacementAndPartialFailure`, `dropPositionUsesDocumentCoordinates`, `queuedImportsAreNotLost` and the queue's own cases (one undo step a request, waiting while busy, the pixel budget over what the canvas holds, what each callback sees, the decoding thread, a session that ends mid-import); new `SessionGateTests` (every gate of a file request, waiters that look again and keep their line, waiters that end with their session, the quarter second); an import and a long wait in `EditorSessionSignalTests`; `GroupTests::hiddenParentOverridesChildrenAndExportOrderFollowsGroups` imports its image. Moved on: `applyImageSize`, `applyDocumentSize` and the undo halves of `ImageSizeTests` and `CanvasSizeTests` to 4.2 (those tests start from `projectSnapshot`); the security scope of a URL has no Linux twin.
- [x] 4.1e1 Appearance in the session (the `LayerAppearance` extension; 4.1e is split in three because its Swift sources run to 750 lines): `blendPreview`, `displayedBlendMode`, `previewBlendMode`, `canEditAppearance`, `opacityEditLayerID`, `beginOpacityEdit`, `finishOpacityEdit`, `setLayerOpacity`, `setSelectedLayersOpacity`, `cycleBlendMode`, `setLayerBlendMode`; the hook owed: `commitTransform` clears the preview and finishes an opacity edit. Tests: of `LayerAppearanceTests` the cases `hoverPreviewIsTemporaryAndNeverChangesSavedState` (its snapshot line waits for 4.1e2) and `opacityDragIsOneUndoAndKeepsSources`; the selected layers' opacity and the blend cycle by cases of their own. Moved on: `moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo` to 8.x (`typeOpacityDigit` lives in `EditorSession+Brush`); `blendModesAndOpacityMatchKnownPixels` and `appearancePersistsThroughSaveResizeAndTransparentExport` to 4.1e2 (`projectSnapshot`). `refreshCanvasPreview` has no twin: `changed()` reaches the canvas.
- [x] 4.1e2 Projects in the session (`EditorSession+Projects`, 56 lines, brought forward from 4.2): `projectSnapshot`, `installProject`, `clearProject`, `createNewProject`, with `applyImageSize` and `applyDocumentSize` from the `ImageResizer` file. It comes before the masks because the twin's document is read-only from outside: where Swift's tests write a mask straight into `session.document`, the twin's tests install a snapshot. Tests, all waiting on `projectSnapshot` until now: the export lines of `GroupTests::hiddenParentOverridesChildrenAndExportOrderFollowsGroups` and all of `groupsRoundTripAndSurviveImageAndCanvasResize`; the undo halves of `ImageSizeTests` and `CanvasSizeTests`; of `LayerAppearanceTests` the snapshot line of `hoverPreview…`, `blendModesAndOpacityMatchKnownPixels` and `appearancePersistsThroughSaveResizeAndTransparentExport`; `LayerTests::newCanvasStartsWithOneSelectedEmptyLayer` (its `canPaint` line waits for 8.x); new `ProjectSessionTests` for the five functions themselves; the session cases of `GroupTests` moved to `GroupSessionTests` (the file stood at 483 lines).
- [x] 4.1e3 Layer masks in the session (the `LayerMask` extension): `isMaskSelected`, `selectLayerTarget`, `canEditMask`, `addLayerMask`, `toggleLayerMask`, `deleteLayerMask`, `canCopyMask`, `copyMask`, `toggleMaskLink`, `displayedMaskPlacement`, `commitMaskTransform`; the hooks owed: `setActiveLayerID` and `restore` keep or drop the mask target, `installProject` and `clearProject` drop it, a mask alone transforms (`TransformEdit.mask`, `transformTargetsMask`, `beginTransform`, `editedTransform`, `pendingTransform`, `transformPixelSize`), `commitTransform` moves mask placements. Tests: new `LayerMaskSessionTests` (the seven session cases of Swift's `LayerMaskTests`, their direct writes going through `rewrite`), new `MaskTransformTests` (Swift's five), new `LayerMaskEditTests` (copying, links, the target across undo and projects, group moves, a mask alone), new `MaskSignalTests`; `tests/SessionFixtures.h` and `tests/SessionRecord.h` share fixtures and the observer's record. Moved on: `addMask` from a selection to 7.1; distorted masks (`corners` in `displayedMaskPlacement` and `commitMaskTransform`, `maskDistortPreview`) to 6.2; the grown transform to 7.5; floating edits to 7.4; `folderMaskCanBePaintedInvertedAndLoadedAsASelection`, the painting half of `placementAndLinkAreSavedAndPaintingFollowsThePlacement` and `placedMaskPreview` to 8.x; `canvasShowsAFolderMaskWhileItIsBeingPainted` and the live-composite half of `renderBoth` to 4.4.
- [x] 4.1e4 Live masks in the session (the `LiveLayerMask` extension with `LiveMaskBaker`): `canLinkMask`, `linkMask`, `adoptClipping`, `removeLiveMask`, `canToggleClippingMask`, `toggleClippingMask`, `releaseDetachedClipping`, `deleteWithLiveMaskChoice` with its alert and its bake off the UI thread, `brushError`; the hooks owed: `deleteLayer` and `deleteSelectedLayers` ask the choice, `finishDeletingLayer(s)` take `baked` and free live masks, `placeLayer` adopts and releases clipping. `PixelAdjust::thumbnail` comes forward from 9.2 for the baker. Tests: new `LiveMaskSessionTests` (the five session cases of Swift's `LiveMaskTests`), new `LiveMaskEditTests` (links and their refusals, runs freed by a release, siblings, adoption, stacks broken by a move, dependents freed baked or not, a folder with the null id, clipping under a selection of several), new `LiveMaskBakeTests` (a bake with an offset target and a translucent source, a masked source in a shape that is no square, a stretched source with a placed mask and no stated opacity, a source without pixels, a snapshot that repeats an id, thumbnails), new `LiveMaskDeleteTests` (the real alert answered three ways, the plural text, folders, a bake that queues for a worker while the caller goes on, a failing bake), clipping in `MaskSignalTests`. Moved on: the adjustment term of `canLinkMask` to 9.5; `drawLiveComposite` stays with 4.4.
- [x] 4.2a `ProjectController` without a workspace (the session's own project functions went ahead as 4.1e2): `canStart`, `begin`, `save` and Save As (the save dialog, the store off the UI thread, the session's `projectPath` setter, `markSaved`, the save generation), `open` (a supplied path or the open dialog, validation before the live document is touched, `confirmReplacement`, the reload when the confirmation's save replaced the opened file, `installProject`), `newCanvas`, `close`, `confirmQuit`, `confirmReplacement` (the alert answered three ways), `showError`, `receive` and its queue (`waitForFileRequest`, more than one project refused, a project opened before the images go in, the point only without a project). Tests: new `ProjectControllerTests`, the real dialogs driven from timers. In scope by the third-round rule: the target-mask fixture of 4.1e4 round 3 (`LiveMaskBakeTests::aBakeMapsTheSourceIntoTheTargetsOwnPixels`). Moved on: `exportPNG` and `exportJPEG` to 10.3, `canvasSize` and `imageSize` to 10.2, `cancelCrop` in `begin` to 10.1, the workspace branches to 4.2b; recent documents and security scopes have no twin.
- [x] 4.2b `ProjectWorkspace` and `ProjectTab`: tabs, `canSwitch`, `addTab`, `select`, `newCanvas`, `open` and `loadProject`, `close`, `removeTab`, `quitOrder`, `confirmQuit`, `closeWindow`, `receive`, `copyLayer`, the layer-row branch of `receiveProviders`, the controller's workspace branches (`canStart`, `open`, `newCanvas`, `close`, `receive`), the session's `skipsInitialClipboardCanvasSize`. Tests: `ProjectWorkspaceTests` (Swift's six and the tabs' own rules), new `ProjectWorkspaceFlowTests`, `ProjectCopyTests`, `ProjectWorkspaceSignalTests`. In scope by the third-round rule: the default-completion test of 4.2a round 3 (`ProjectControllerTests::aCallWithoutACompletionIsWhole`). Moved on: the file branch of `receiveProviders` to 10.4 (`ImageFileDrop`), `canReceiveDrag` to 4.3 (`ProjectTabs`), the adjustment, `pixelMove` and `colorPicker` terms of `canSwitch` to the items that bring that state.
- [x] 4.3a Tool icons (`UI/ToolIcons`, Linux only: SF Symbols have no twin): one drawn icon a tool (paths tinted from the palette, no icon theme: they differ by distro), the Swift extras that exist so far (`GradientToolIcon`, `CloneStampToolIcon`); variants that follow tool state (ellipse marquee, polygonal lasso, eraser) arrive with that state. Tests: every tool has an icon that paints pixels, follows the palette, and differs from the others.
- [x] 4.3b `ContentView` as one project's editor area (a `QWidget`; the window moves to 4.3c): the tool header slot (`ToolHeaderStyle`; the idle and eyedropper bars, `NavigationToolHeader` for Hand and Zoom; every other tool's bar arrives with its tool), the tool rail's buttons (labels as tooltips, the selected state, scrolling when short; icons from 4.3a), the canvas slot with the welcome (`NewCanvasSheet`: fields, validation, `Create canvas` as the default button, `Open project`, `Import image`, the clipboard's size skipped once for the first tab), the status bar (zoom, size, colour note, busy and importing notes, each tool's hint), the importer's file dialog, the alerts for `importError` and `brushError`. GUI test: the twin of `testCreateCanvasAndNavigation` as far as it needs no window chrome. Moved on: the canvas itself to 4.4, the layers panel to 4.6, `cropError` to 10.1, the floating panels to 9.x, drops to 10.4, `ColorPaletteControls` to 8.10.
- [x] 4.3c The window: `ProjectWorkspaceView` (a `QMainWindow`: 1180×780, minimum 800×520, the current tab's `ContentView` rebuilt on a switch and disabled while the workspace manages), the toolbar (New canvas, the `ProjectTabStrip` with its tab buttons, modified dot and close button, Fit, 100%, zoom in and out), closing through `closeWindow`, quitting through `confirmQuit`, files from argv through `receive`. GUI tests: the launch test moves here; tabs select, close and show their state. Moved on: drops on tabs and `canReceiveDrag` to 4.7 and 10.4.
- [x] 4.3d Menus and shortcuts (⌘→Ctrl, ⌥→Alt): the `CompositorApp` commands whose session functions exist (File, Edit's undo and redo with their names and the text-field rule, View, Layer); each later item adds its own entries (see "Menu items owed"). Tests: every entry's shortcut, enabling and effect.
- [x] 4.4 `EditorCanvas` display: compositing (`drawLiveComposite`), zoom, pan, pixel grid, checkerboard, focus. Paint each dirty rect through an image surface (`AdjustmentSurface`): HSL blend modes and clip coverage need an image-backed device, and device-sized coverage then costs only the dirty area. Probe `BrushRaster::visibleRect` on a widget painter at device pixel ratio 2 and test it. From `LayerMaskTests`: `canvasShowsAFolderMaskWhileItIsBeingPainted` (with 8.x) and the live-composite half of `renderBoth` in `LayerMaskSessionTests` (export and canvas must agree).
- [x] 4.5 **Omarchy theme**: `UI/OmarchyTheme` reads `colors.toml`, builds the palette (no stylesheet: Fusion follows the palette, and a stylesheet would freeze it), watches `current/`, logs each load; the canvas surround takes the palette's `Base` so a light theme's welcome reads. Tests: parse, light/dark, missing file, live switch changes grabbed pixels, a switch reaching the real window. In scope by the third-round rule: 4.4's Alt-from-any-window fix.
- [x] 4.6 Layers panel A: `LayersPanel` (heading, empty words, footer, the resize edge and its stored width), `NativeLayerList` rows (hierarchy, clipping marks, hidden fade, masks and their link), select (plain, Ctrl, Shift, Alt on the strip, thumbnails and mask targets), rename, eye swipe, the context menu, keys, thumbnails that redraw only on change, `BlendModePicker`, `LayerAppearanceControls`, `LayerMaskMenu`, `LayerIcons`. Tests: `CanvasThumbnailTests`, `BlendShortcutTests`, `NativeLayerListTests`, `LayersPanelTests`, `BlendModePickerTests`, `LayerAppearanceControlsTests`; the moved `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` and `layerListToolKeysAndTransformNudge`. In scope by the third-round rule: the ancestor watch and the short-read check of 4.5 round 3. Moved on: see "Layers panel owed".
- [x] 4.7 Layers panel B: drag reorder and nest (`dragData`, `dropTarget`, `acceptDrop`, the `LayerColumn` indicator), Alt-drag duplicate (`duplicateActiveLayer` and `duplicateLayer` forward from 5.1) and its cursor, the clipping cursors under Alt, mask drags (`maskType`, `copyMask`), drags between projects (`draggedLayerSource`, `canReceiveDrag`, drops on tabs, the canvas and the New canvas button, several ids a drop). Tests: `LayerDragTests`, `ProjectDropTests`, the twin of `duplicatingALayerByDraggingPlacesTheCopyAsOneStep` in `EditorSessionTests`. In scope by the third-round rule: the four fixes of 4.6 round 3. Not driven: `QDrag::exec` (no drag loop offscreen). Moved on: file and picture drops to 10.4.
### Menu items owed
The menu bar of 4.3d holds only what works. Each item adds its entries with its tests: 9.x the Image menu's adjustments (Levels… since 9.1b, Hue/Saturation… since 9.3b, Curves… and the rest since 9.4b), the Filter menu (9.4b), New Adjustment Layer and Edit Adjustment (9.5d); 10.1 nothing; 10.2 Canvas Size, Image Size, Flip Canvas (done); 10.3 Export PNG, Export JPEG (done). `Check for Updates` and the Hide entries have no twin (no updater; the desktop hides windows).
### Tool bars, hints and icons owed
`ContentView` of 4.3b shows an empty bar for a tool whose bar has not arrived, the hint of each tool's first mode, and one icon a tool. Each item adds its own, with tests: 10.1 `CropControls` and the `cropError` alert (done); 9.x the floating panels (done); 10.4 drops on the canvas. The canvas of 4.4 draws layers, masks, folder masks and live masks, pans and zooms; each item adds its own, with tests: 9.x adjustments (9.5b, done) and effects (9.6c, done) in `DisplayState` and `drawLayers`, `picking`'s hue sample mode with its press (9.3b, done), and the press clearing `effectSelection` (9.6d, done); 10.1 the crop union in `renderBounds` and the crop cursors (done).
### Layers panel owed
The panel of 4.6 shows and edits what the session holds so far. Each item adds its own, with tests: the effects rows under a layer came with 9.6e, the footer's effects button and the delete words came with 9.6d, the `deleteLayerOrMask` effects term with 9.6a; the adjustment icons, their double click and the footer's adjustment button came with 9.5d.
### Session hooks owed
`EditorSession` grows with the tools. Each item below adds its own terms to the core written in 4.1a, with tests.
- 4.1e1: `commitTransform` clears `blendPreview` and finishes an opacity edit.
- 4.1e2: `installProject` and `clearProject` drop an open transform; from 4.1e3 on they clear `isMaskSelected`, from 10.1 they cancel a crop.
- 9.5: the store's text rule refuses an adjustment layer too.
- 9.6 (done in 9.6a): where Swift rebuilds a layer from listed fields (`commitPaintSnapshot`, `commitRasterEdit`, `invertPixels`, `mergeFloatingTransform`, the filter's, Levels' and Hue/Saturation's commits), each rebuild that leaves out `effects` drops them there, as the shape and text are dropped since 8.8 and 8.9a. The adjustment needs no such drop (9.5c, checked): every rebuild edits a layer's pixels, which an adjustment layer never has, and mask edits write in place.
- 9.x: the gates read `levels` (9.1a, done), `hueSaturation` (9.3a, done) and `adjustmentEditingID` (9.5c, done); `beginLevels` commits a pending gradient first (9.1a, done) and `beginHueSaturation` resolves one (9.3a, done); `beginLevels`, `canContentAwareFill` and `beginFilter` read `hueSaturation` (9.3a, done); `updateLevels`, `cancelLevels` and `commitLevels` hand an adjustment layer's edit its preview, cancel and commit first, as `updateFilter`, `cancelFilter` and `commitFilter` do, and the Hue/Saturation three (`previewAdjustmentEditing`, `finishAdjustmentEditing`, 9.5c, done); the colour picker's effect target and `previewEffectColor` (9.6a, done); the Levels sheet's caption says underlying pixels for an adjustment layer (9.5d); `selectLayer` and `selectLayers` clear `effectSelection` (9.6a, done); `canStartProjectOperation` reads `levels` (9.1a, done) and `adjustmentEditingID` (9.5c, done), and their setters resume file requests; `selectLayerTarget` clears `effectSelection` too (9.6a, done); `canLinkMask` refuses an adjustment layer as a source (9.5a, done); `deleteLayerOrMask` gains its effects term (9.6a, done), as does `deleteKeyPressed`.
- 10.1 (done): `selectTool` cancels and starts a crop; `restore` and `beginTransform` cancel it; `canEditLayers` reads `cropRect`; `importImages` cancels a crop. `ProjectController::begin` cancels a crop; so do `beginLevels` and `beginFilter`.
- `ProjectWorkspace::canSwitch` (4.2b) gains a term with each state it reads: `pixelMove` (8.2b, done), `gradientEdit` (8.7, done), `colorPicker` (8.10a, done), `hueSaturation` (9.3a, done).
- 10.2 and 10.3 bring the controller's `canvasSize` and `imageSize` (10.2, done), `exportPNG` and `exportJPEG` (10.3, done); 10.4 the file branch of `receiveProviders`.
## 5 Layers (README)
- [x] 5.1 `LayerMerge` (`mergePlan`, `canMergeLayers`, `mergeTitle`, `mergeLayers`, with `PixelFilter::trimmed` in `Filters.cpp`), `LayerFlip` (`LayerTransform::mirrored`, `flipLayers`), `beginDuplicateTransform` and `transformDuplicate` (`duplicateActiveLayer` and `duplicateLayer` went to 4.7); the Layer menu's Duplicate Layer, Merge and Flip Layer entries. Clipping masks in the panel were done by 4.6 and 4.7 (the indent, the Alt-click and its cursors) and 4.3d (the menu entry). Tests: `LayerMergeTests`, `LayerFlipTests`, `FiltersTests`, the twin of `duplicateTransformPreservesOriginalAndSupportsUndoAndCancel` and a folder case in `TransformTests`, the entries in `CompositorMenusTests`, the calls in `EditorSessionSignalTests`. In scope by the third-round rule: the three fixes of 4.7 round 3. Moved on: `flipCanvas` to 10.2, `layerViaCopy` to 7.4.
## 6 Transform (README)
- [x] 6.1 `TransformOverlay` (`TransformOverlayGeometry`, drawn by the canvas), the Move tool's drags on the canvas (move, resize, rotate, Alt duplicate, Ctrl picking, Ctrl+Shift extending, auto-select), snapping and guides (`transformSnapTargets`, `snappedMove`, `snapGuides`), the Move cursors, `TransformInspector` with its fields' arrow steps, `TransformEdit.persistent`, `transformAutoSelect`, `showsTransformControls`, `locksTransformRatio`, the Transform Layer and Show Transform Controls entries. Tests: `TransformOverlayTests` (the two geometry cases of Swift's `TransformTests`), `TransformPressTests` (Swift's two, and the handles, snapping, auto-select, Escape, focus, the wheel and a selection), `CursorTests` (Swift's two Move cases), `TransformInspectorTests`, `TransformSnapTests`, the entries in `CompositorMenusTests`, the bar in `ContentViewTests`, the settings in `EditorSessionSignalTests`; `blankLayersHaveNoTransformHandlesOrEditing`'s overlay lines in `TransformOverlayTests`. Moved on: the distort cursor, `showsRotation` and the corner geometry to 6.2; Transform Selection to 7.4; the selection cases of `CursorTests` to 7.x.
- [x] 6.2 `Distort` (`DistortWarp`: usability, convexity, `homography`, `warp`, `warpTrimmed`, `carried`, `warpMask`; `beginDistort`, `previewCorners`, `distortPreview`, `commitDistort`, `TransformEdit.corners`), the axis lock through `TransformDrag::corners`, mask distort (`commitMaskTransform`'s corners path, `MaskDistortPreviewCache`, `maskDistortPreview`, `displayedMaskPlacement`'s corners cases), the overlay's corner geometry, the canvas's Ctrl-drag, distort cursor and previews, the inspector's rest. Tests: `DistortTests` (Swift's three and the corner order, plus folded shapes, flips, `carried`, `warpMask`, nudges, groups), `DistortMaskTests` (masks along, a mask alone, split at the 500-line rule), `DistortCanvasTests`. Moved on: `mapPath` to 7.x, `distortedEffects` and `DistortEffectsCache` to 9.x.
## 7 Selections (README)
- [x] 7.1 `Selection` (`DocumentSelection`, `SelectionClip`, `LassoKind`, `SelectionMode`, `DragBox`, `LassoDraft`, the session's outlines, modes, held keys, moves, nudges, Expand and Contract, Select All, Deselect, Inverse), `SelectionEdits` (`deleteLayerOrMask`, `canInvert`, `invertPixels` with `PixelInvert` and `PixelAdjust::coverage` and `blend`), `loadMaskSelection` and `loadLayerSelection`, `addMask` from a selection, the Select menu, the Image menu's Invert, the panel's Ctrl-click loading and its cursor; the hooks owed: `CanvasDocument.selection`, `selectTool` cancels a lasso. Tests: `SelectionTests` and `SelectionSourceTests` (Swift's session cases; the four that drive `CanvasView` move to 7.2), `SelectionEditTests` (masks from a selection, the delete target, Invert; the fill, clear, gradient, brush and pixel-move cases move to 7.4 and 8.x), new `PixelInvertTests`, `SelectionSignalTests`, `SelectMenuTests` (the menu fixture now in `tests/MenuFixtures.h`), `LayerTests::deletingAMultiSelectionRemovesEveryLayerInOneStep`, the Ctrl-click and cursor cases of `NativeLayerListTests`, `LayersPanelTests` and `LayerDragGestureTests`. In scope by the third-round rule: the three fixes of 6.2 round 3. Moved on: `pressMarqueeKey`, `pressLassoKey`, `displayedSelection`, `CanvasView.selectionCursors` and `moveSelectionCursor` to 7.2; `canEditPixels`, `fillSelection`, `clearSelectedPixels`, `deleteKeyPressed` and `brushRevision` to 8.x; `PixelMove` and its calls to 7.4.
- [x] 7.2 The Marquee and the Lasso on the canvas (`lassoMouseDown`, `moveSelectionTool`, `releaseSelectionTool`, `selectionKey`, `endSelectionGestures`, `dragSelection`, `dragMarqueeDraft`, a double click closing a polygonal outline, Shift tracked like Alt and Ctrl, `pressMarqueeKey`, `pressLassoKey`, `displayedSelection`), the marching ants and the draft drawn by `TransformOverlay` (`antsPhase`, `drawSelection`, `drawLassoDraft`, `drawnRect`), the marquee autoscroll, the cursors (`moveSelectionCursor`, `selectionCursor`, `wandCursor`, with `Rendering/SelectionIcons` shared by `ToolIcons`), `LassoControls` with `SelectionAmountField`, the ellipse Marquee's and the polygonal Lasso's rail icons and hints. Tests: new `SelectionCanvasTests` (Swift's four canvas cases of `SelectionTests`, the drafts, moves, keys, cursors, ants, autoscroll, drawing and focus), new `LassoControlsTests`; the kinds in `ToolIconTests` (the reference sheet regrown) and `ContentViewTests`. In scope by the third-round rule: the loaders' flatten guard of 7.1 round 3. Swift's `CursorTests` cases: `leavingTheCanvasRestoresTheArrowWithEveryTool` and `aDragReleasedOutsideTheCanvasRestoresTheArrow` have no twin (a Qt widget's cursor never leaves it); the layer-list cases are 4.6's and 4.7's; the selection part of `duplicateAndDistortCursors` (Ctrl and Ctrl-Alt inside the selection) waits for 7.4. Moved on: the wand's click and cursor use to 7.3; the Ctrl-drag of pixels, `pixelDragStart` and the scissors to 7.4.
- [x] 7.3 `MagicWand` (`WandSampleSize`, `WandSettings`, `MagicWandError`, `MagicWand::select` and `outline` over `WandPixels.c`; the session's `wandSettings`, `magicWand`, `wandSample`), the wand's click on the canvas and its click inside a selection, the wand's controls in `LassoControls`. Tests: new `MagicWandTests` (Swift's six, the last through the canvas; plus refusals, a document gone mid-wand, the too-detailed error and Swift's titles), the wand's controls in `LassoControlsTests`, its settings and click in `SelectionSignalTests`. In scope by the third-round rule: the three fixes of 7.2 round 3.
- 7.3 round 1: FAIL, six findings. A painted asset's flatten threw past the sample's catch (the try covers the drawing, tested with a 48 MB raster starved by `AddressSpaceLimit`); a point that is no number passed the negative bounds and deselected (Swift's positive bounds refuse it); the amount field's validator refused a seventh digit (digits alone, a long long clamped, more digits than one holds the top); the active layer's own pixels were sampled without tests of placement, hidden and faint layers (a moved, scaled, hidden, masked, faint layer and a pending transform are read as they show); the document guard had no test (a snapshot with another id installed mid-wand drops the outline); the tests read `->path` (now `.value()`).
- 7.3 round 2: FAIL, four findings. The flatten test's 48 MB canvas sample failed before the raster flattened, so the narrowed catch passed alone (a tiny canvas keeps the large raster, and `malloc_trim` returns freed heap before the limit); the overflow test began at 255 (it starts at 7); two comments ran long; one test read `*path` (now `.value()`). Also in this round: `ProjectControllerTests::nothingToSaveOrABusySessionIsRefusedUnasked` took its record before the busy indicator had dimmed and compared it after up to a quarter second of work, a ceiling that a loaded machine breaks; it now waits for the dim first.
- 7.3 round 3: FAIL, two findings, the last round allowed. A new wand outline's Anti-alias setting had no test (a hard outline is asserted); one comment ran long. By the third-round rule the reviews of 7.3 end here; both fixes join 7.4's review scope.
- 7.4 round 1: FAIL, five findings. Paste dereferenced a cleared clipboard's missing `QMimeData` (`canPaste` and `paste` check the pointer; the refusals test clears the clipboard). Another app's image kept its own colour space's numbers (`sRGBCopy` converts a tagged image to sRGB first and tags the result; a linear gray 128 pastes as 188). A mask copied through a soft selection edge took the coverage once where Swift clips the ground and the white each (the two passes are multiplied on their own; `aSoftSelectionEdgeClipsBothMaskPasses` reads 192 at half coverage). Layer via Copy kept Duplicate Layer's enablement (now `canCopyPixels` or no selection on a pixel layer; a blank layer's Select All disables it). A working copy that could not be allocated set `brushError` where Swift's `try?` only beeps (a warning; `anImageTooLargeToCopyWarnsWithoutAnAlert` starves the address space). Eleven mutants over the fixes, all caught.
- 7.4 round 2: FAIL, two findings, both test gaps. Copy Merged's clip was tested through rectangles alone (a triangle lasso now asserts a clear far corner and a soft hypotenuse). A single layer's copy was not held to Swift's rule that the pixels come as stored, at their transformed places (`aCopyTakesTheLayersOwnPixelsAtTheirTransformedPlaces`: a hidden bar at 25% under a black mask, turned 90°, copies opaque where the turn puts it). Five mutants over those rules (the merged clip, opacity, mask, visibility, rotation), all caught.
- 7.4 round 3: PASS.
- 7.5 round 1: FAIL, six findings. A commit waiting for the preview could land on a later edit (the waiter carries its edit's id and returns without applying once that edit is gone); a second waiting caller lost its callback (every waiter is kept and resumed, the first commits, the next finds it committing); a commit between the worker's end and its delivery took nothing (the wait is on `preparing`, which only the delivery clears); Return on a focused Cancel cancelled (`setAutoDefault(false)`: Return is OK's from anywhere, nothing while OK waits); the mask carry was tested with white alone (a drawn mask's block is read at its carried place); growth was not held to the layer's turn (a layer turned 90° keeps it, its red block at the same document point after the commit). Seven mutants over the fixes (the waiter's id, the waiters kept, the wait on `preparing` twice, the mask carried as white, the turn dropped, Cancel's auto-default), all caught.
- 7.5 round 2: FAIL, two findings. Preview off while a job waited its turn behind a cancelled edit's worker cleared the job and left the edit preparing for good (an automatic kind's queued job now runs on and caches unseen; the clearing of `pending` and `preparedPreview` is 9.4's, for kinds with settings). The undelivered-preview test slept where it should have waited for the worker (`QThreadPool::globalInstance()->waitForDone()`, so the finish slot is queued and not yet run). Two mutants, both caught.
- 7.5 round 3: FAIL, one finding, the last round allowed. The Preview-off test did not force its job to wait behind the cancelled worker (the pool now holds one thread with a gated task ahead, and the test asserts the job is pending before Preview goes off). By the third-round rule the reviews of 7.5 end here; the fix joins 8.1's review scope.
- [x] 7.4 `SelectionClipboard` (`PixelClipboard`, `CopiedPixels`, `selectionCopyRegion`, `canCopyPixels`, `renderSelectedPixels`, `canCopyMerged`, `renderMergedPixels`, `copyMergedSelection`, `copySelection`, `store`, `canPaste`, `paste`, `layerViaCopy`, `addPixelLayer`, `nextLayerName`), system clipboard images both ways, the Edit menu's Copy, Copy Merged and Paste, the Layer menu's Layer via Copy. Tests: new `SelectionClipboardTests` (Swift's copy, layer-via-copy, lasso and merged cases; plus a mask's gray, the region's rounding, another app's image, the refusals and the signals), the entries in `SelectMenuTests`, the counts in `CompositorMenusTests`. In scope by the third-round rule: the two fixes of 7.3 round 3. Moved on: `cutSelection`, `PixelMove` (`beginPixelMove`, `movePixels`, `finishPixelMove`, `cancelPixelMove`, `nudgePixels`, the scissors and the Ctrl-drag on the canvas) and all of `FloatingSelection` (`FloatingTransform`, `canTransformSelection`, `transformCommand`, `beginSelectionTransform`, `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, `FloatingMerge`, `TransformEdit.floating`, `displayedSelection`'s floating term, the Transform Selection entry) to 8.2, since they clear or lift pixels through the brush raster; Swift's `cutLeavesAHole…`, `transformSelectionMoves…`, `escapeRestores…`, `applyingAnUnchangedTransform…` and `scalingAndMovingPastTheLayerEdge…` go with them. `SmartEditTests` is Content-Aware Fill's and Subject Removal's: 7.5 and 9.x.
- [x] 7.5 `ContentFill` (`ContentFill.c`), extend past edges: `ContentFillError`, `ContentFill::run`; from `Filters.swift` what the fill needs: `FilterKind` (its one case), `FilterJob`, `PixelFilter::run`, `FilterEdit` (`grow`, `previewImage`, `previewJob`), the session's `canAdjustColors`, `canContentAwareFill`, `beginFilter`, `updateFilter`, `renderFilterPreview`, `cancelFilter`, `commitFilter`; the grown transform in `displayedTransform` and `displayedMaskPlacement`; the canvas's preview and Shift-Delete; `FilterSheet` for the automatic kind; the Edit menu's Content-Aware Fill…; `canEditLayers` and `ProjectWorkspace::canSwitch` read `filterEdit`. Tests: new `SmartEditTests` (Swift's three fill cases; plus the growth past the edge with the mask carried, a solid, placed and disabled mask, a soft edge, the refusals and hooks, a layer changed meanwhile, the signals), new `FilterSheetTests`, the entry in `SelectMenuTests` and `CompositorMenusTests`, the keys and the preview in `SelectionCanvasTests`, the switch in `ProjectWorkspaceTests`. Moved on: `FilterSettings`, `FilterJob.settings`, `scale` and `seed`, the preview copy (`previewSource`, `previewScale`, `previewMapping`, `previewLimit`), `blurMargin`, `growForBlur`, `grownMargin`, the other kinds of `PixelFilter::run`, `beginFilter`'s beep guard and Gradient Map start, `filterSettings`, the trim on commit, `commitBackgroundMask` and the Filter menu to 9.4 and 9.7; `cancelCrop` in `beginFilter` to the crop; `previewAdjustmentEditing`, `finishAdjustmentEditing` and `adjustmentOriginal` to 9.5; `colorPicker` to 8.10; `FloatingPanel` to 9.1; `visionRequestRunsOnAnImage` to 9.7.
## 8 Painting and retouching (README)
- [x] 8.1 Brush coverage on CPU: `Rendering/BrushCoverage` (`Tile`, `Work`, `tile`, `render`; `BrushSegment` for Swift's SIMD4), `BrushSettings`, `BrushRaster::fill`, `BrushStroke` (the constructor and its guards, `spacingFraction`, `append`, `flush`, `continuousCurve`, `continuousKeys`, `renderContinuous`, `publish`, `allocateTile`, `patches`, `dirtyDocumentRect`, `selectionClip`, `pixelLimit`, `committedBounds`, `committedTransform`, `transform(for:)`, `paintSnapshot`, `commitInput`), `BrushCommit` (`render`, `expandMask`). Tests: new `BrushIntersectionTests` (Swift's three; `exportCrossingExample` drives the session and goes with 8.2), new `BrushTests` (the stroke-level halves of Swift's `continuousStrokeCrossesTiles…`, `softBrushProducesPartialAlpha…`, `brushStaysCircular…`, `maskedImagePainting…`, `paintedBoundsTrim…`, `opacityCaps…`, `softStrokeBuilds…`, `spacedDabs…`, `sparseMouseSamples…`, `liveStrokeReaches…`, `largeBlankCanvas…`; plus a selection clip, mask painting and `expandMask`, the refusals, the hard rim and the canvas edge, growth past the layer, a painted source that never flattens), `tests/BrushFixtures.h`. In scope by the third-round rule: the fix of 7.5 round 3. Moved on: the dab fallback (dead here, see AGENTS.md); `erasing`, `healing`, `healingMode`, `clearPixels`, `paintCanvas`, `liftSelection`, `moveLifted`, the session halves of `BrushTests` and `exportCrossingExample` to 8.2; the key tests (`bracketKeys…`, `shiftBrackets…`, `numberKeys…`) to 8.3; `heal` to 8.4; `clone`, `replacesWithClone` to 8.5; `isBlur` to 8.6; `fillGradient` and `fill` to 8.7; `editName` with its first writer.
- 8.1 round 1: FAIL, five findings. Tile keys overflowed `int` on a big grid (a 1200 px layer scaled to a point far across a 10,000 px canvas; keys, sets and maps are `qint64`); a crop that could not allocate returned an empty asset where Swift throws (`image.isNull()` is `ExportError(render)`, tested under `AddressSpaceLimit`); the tests read no untouched pixel inside a touched tile (both an ordinary and a painted source now read one beside the dab, in the preview and the commit); the antialias width under a scale was untested (a 64 px source shown at 128 reads 64 at the rim); an assertion ended in `|| true` (the materialisation is asserted before the read that flattens). Six mutants over the fixes: five caught, `columns` narrowed to `int` is equivalent (only the product passes int).
- 8.1 round 2: FAIL, three findings. A tile copy that could not be allocated (the tile's context from its base, or the coverage's copy under a selection) left a null image for `fill` to refuse as a logic error, where Swift throws its render error (both detach explicitly and throw `ExportError(render)`; `aTileThatCannotBeCopiedIsARenderError` takes every free chunk under an address-space limit, so the copy must map and fails); the selection's coverage was never tested on a transformed grid (a 64 px source shown at 128 from (40, 20), a selection from x = 80, a dab at (80, 80): 85 opaque, 75 clear); a mask on its own placement was never tested (a 16 px mask shown at 32 from (80, 40) takes a dab at (88, 48) in its pixel (4, 4)). Four mutants over the fixes: three caught, the coverage's null check is equivalent (a null coverage reaches the same error through its selection coverage's context).
- 8.1 round 3: FAIL, two findings, the last round allowed. The grid's extent became an `int` before its guard (a 30,000 px strip shown at a point far across a big canvas makes three billion pixels: undefined in C++, a plain refusal in Swift's `Int`; the extent is judged as a double first). The coverage-copy test could not tell the fix from the old logic error: under exhaustion the selection coverage's own context fails with the same error, and sparing one chunk for the copy does not hold (the painter's freed buffers serve the later copy), so that mutant stays equivalent in effect and the check keeps the error at the copy. By the third-round rule the reviews of 8.1 end here; the extent guard and this note join 8.2's review scope.
- [x] 8.2 `EditorSession+Brush` (`canPaint`, `makeRasterEdit`, `beginBrush`, `continueBrush`, `shiftLineStart`, `cancelBrush`, `finishBrushImmediately`, `finishBrush`, `commitPaintSnapshot`, `commitRasterEdit`, `usesOpacityKeys`, `typeOpacityDigit`, `changeBrushHardness`, `changeBrushSize`; `brushStroke`, `brushSettings`, `brushMode`, `maskPaintWhite`, `brushRevision`), erase, mask painting, Shift lines, `placedMaskPreview`; `BrushStroke::paintCanvas`, `fill`, `clearPixels`; `SelectionEdits` (`canEditPixels`, `fillSelection`, `clearSelectedPixels`, `deleteKeyPressed`, `applyPixelEdit`), `cutSelection`; the gates and hooks that read the stroke; the canvas's live stroke and Delete key, the list's Delete key; the Edit menu's Cut, the two fills and Clear Selection Pixels. Tests: new `BrushSessionTests` and `PixelEditTests` (the session halves of Swift's `BrushTests`, `shiftBracketsStepHardnessByQuarters`, the brush half of `numberKeysSetBrushAndGradientOpacity`, `largeBlankCanvas…`, `foldersHiddenLayers…`; plus the stroke's hold on the session, the Shift line, erase, the fills and clears, the Delete key, the signals), new `RasterSnapshotSessionTests` (Swift's five), new `BrushCanvasTests` (`canvasShift` twice, the repaint per step, a placed mask's stroke, the Delete key), new `BrushRasterTests` (failed tile copies, `clearPixels` and `fill`), `folderMaskCanBePaintedInvertedAndLoadedAsASelection` in `LayerMaskSessionTests`, the painting half of `placementAndLinkAreSavedAndPaintingFollowsThePlacement`, the `canPaint` line of `LayerTests`, `moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo`, the cut in `SelectionClipboardTests`, the entries in `SelectMenuTests` and `CompositorMenusTests`, the list's Delete key. In scope by the third-round rule: the two fixes of 8.1 round 3. Moved on: `PixelMove` and `FloatingSelection` (`liftSelection`, `moveLifted`, the scissors, the Ctrl-drag, Transform Selection) to 8.2b; the palette's colours in `fillSelection` and `paletteColor` to 8.10; the tips' parking (`parkedBrushTips`, `tipFamily`) to 8.5; `warpStroke` to 8.6; the gradient half of `numberKeysSetBrushAndGradientOpacity` to 8.7; the canvas's brush press, drag, release, cursor and keys, `exportCrossingExample` and the rail's eraser icon to 8.3.
- 8.2 round 1: FAIL, five findings. Invert and the fill opened mid-stroke (`canInvert` and `canAdjustColors` read the stroke; a stroke on a red layer refuses both and the cancel frees them). Painting a folder's mask showed nothing until the commit (the live clip comes from the edit's tiles, tested at 1x and 3x). Every step of a stroke repainted the whole canvas (a step repaints the stroke's dirty rect alone, two points out; a paint spy reads the first tile and then two). A raster commit's mask guard was untested (the mask replaced while a clear is in flight: nothing lands, the project is freed). The session-wide budget was untested (six 16-million layers leave a stroke 4 million, and a 2000-pixel tip is refused; `pixelLimit` became `qint64` without a clamp, as Swift's `Int`, so seven other masks leave −12 million). Eight mutants over the fixes, all caught.
- 8.2 round 2: FAIL, two findings. The live folder-mask clip drew the old mask, or a painted mask's base, at full size under nearest sampling, so a fine mask shimmered while painted (both are halved near the size drawn first, as Swift's `displayImage`; a one-pixel-striped folder mask at 25% keeps an untouched pixel's shade within 3/255 during and after two strokes, the second on the painted mask's tiles). A raster commit put back the mask's switch as the stroke had read it, so a mask switched off meanwhile came back on (the commit keeps `isEnabled` as it stands, Swift's `current.mask?.isEnabled ?? mask.isEnabled`; a mask switched off while a clear is in flight stays off). Three mutants over the fixes, all caught. Since then, for the mirror rule and for Qt 6.11: `canAdjustColors` moved to `HueSaturation.cpp`, the twin of its Swift file, and `ToolHeaderBar` gained `Q_OBJECT`. Docker could not start containers that day (another job saturated the disk), so the build and tests ran in the same image copied into RAM, through bubblewrap.
- 8.2 round 3: FAIL, one finding, which ends the item's reviews by the third-round rule after five, two and one. In a text field both fills deleted forward at the start (`del()` after an empty backward selection turned "45" into "5"); they now delete the selection when there is one, else backwards from the caret through `backspace()`, as Swift's `deleteWordBackward` and `deleteToBeginningOfLine`, so nothing goes at the start. Proven by new cases in `cutTheFillsAndClearFollowTheSelectionAndTheField` (the caret at the start, mid-text, a selection either way round) and four mutants, all caught; carried into 8.2b's review.
- [x] 8.2b `PixelMove` (`beginPixelMove`, `movePixels`, `finishPixelMove`, `cancelPixelMove`, `nudgePixels`, `displayedSelection`'s terms, `BrushStroke::liftSelection` and `moveLifted`, the scissors, the Ctrl-drag and the Ctrl-arrows on the canvas) and `FloatingSelection` (`FloatingTransform`, `canTransformSelection`, `transformCommand`, `beginSelectionTransform`, `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, `FloatingMerge`, `TransformEdit.floating`, `DistortWarp::mapPath`, the floating branches of `commitTransform`, `cancelTransform` and `transformPixelSize`, the Transform Selection entry); `canEditLayers`, `canInvert`, `canAdjustColors` and `ProjectWorkspace::canSwitch` read `pixelMove`; `brushRevision` moves at the rest of Swift's sites (the invert; the filter's preview, Preview switched, cancel and commit), so the canvas's filter-preview key went. Tests: new `PixelMoveTests` (Swift's four pixel-move cases of `SelectionEditTests`, plus the hold on the session, a failed move, a move across tiles and back, a layer scaled and hanging off the canvas, a landing between pixels computed by hand, and what a move needs and ends), new `FloatingSelectionTests` (Swift's `transformSelectionMoves…`, `escapeRestores…`, `applyingAnUnchangedTransform…` and `scalingAndMovingPastTheLayerEdge…`, plus a distorted selection, a merge that cannot grow, the refusals, the floating layer's place and look, a source gone mid-lift, and a mask grown right, left and placed apart), `mapPathCarriesCurvesFlipsAndTheFillRule` in `DistortTests`, `cutLeavesAHoleAndPasteRestoresThePixels` (8.2's cut test under Swift's name), the scissors in `CursorTests`, the Ctrl-drag and Ctrl-arrows in `SelectionCanvasTests`, the switch in `ProjectWorkspaceTests`, the entry in `SelectMenuTests`, the counter in `SmartEditTests` and `SelectionEditTests`. In scope by the third-round rule: the fills' text-field fix of 8.2 round 3.
- 8.2b round 1: FAIL, three findings. Space mid-drag showed the open hand where Swift keeps the scissors or the copy cursor (`updateCursor` picks the pixel drag's cursor ahead of Space, and `lassoCursor` lost its two dead drag branches; `CursorTests` holds Space during a move and a copy drag). A merge onto a scaled or turned source was untested (`aTransformedSourceTakesTheMergeInItsOwnGrid`: a source doubled at 20,10 keeps its grid and transform with the block where it was put, and one turned a quarter lands the block below where it was lifted). A folded distortion's dropped outline was untested (`aFoldedDistortionDropsTheOutline`: a bow tie commits, the selection goes, Undo brings it back). Five mutants over the fixes, all caught.
- 8.2b round 2: FAIL, one finding. A move from a painted layer was untested, so a lift that flattened the whole raster passed (`aPaintedLayerMovesWithoutFlattening`: a dab makes the layer a raster, and the lift, the move and the commit leave it unflattened before any pixel is read; the hole and the landing are checked after). One mutant, caught.
- 8.2b round 3: FAIL, one finding, which ends the item's reviews by the third-round rule after three, one and one. The painted-source test moved red pixels onto red, so a lift that placed nothing passed; the move now goes 60 pixels into the blue half and red is asserted there. One mutant (the raster's pixels never drawn into the lift), caught; carried into 8.3's review.
- [x] 8.3 `BrushControls`, `BrushCursorOverlay`, the canvas's brush gestures (press, drag, release, Shift lines and their axis, the right-drag tip), keys on the canvas and in the list (Tab and `cycleToolMode`, B, E, digits, brackets), the rail's eraser icon and hint (`ContentView+Status.cpp`). **Gate** passed: twin of `BrushPerformanceTests`, 4K at a scale of 2, 800 px soft; median frame 6.8–7.4 ms, from 47 (see AGENTS.md, "Brush speed"). Fixed on the way: a shown `ContentView` destroyed with its canvas focused read freed members (it stops listening first), and a replaced tab's session ended before its editor (the window holds the shown tab). Tests: new `BrushGestureTests`, `BrushKeyTests`, `BrushControlsTests`, `BrushCursorOverlayTests`, `BrushPerformanceTests` (under `BRUSH_BENCHMARK=1`), `ContentViewStatusTests` (split from `ContentViewTests`); `aSoftDabFollowsItsFalloffWithinAStep` and `aSoftClipScalesEachCoverageByteOnce` in `BrushTests`, `aMaskHalvesAsItsColorTwin` and `aMaskEdgeRepeatsItsLastColumn` in `DownsampleTests`, `tabSwitchesEachToolsShapeOrMode` in `EditorSessionTests`, `tabAndTheBrushKeysStayWithTheList` in `LayerListFocusTests`, `aShownViewClosesWithoutHearingItsSession` in `ContentViewTests`, `aReplacedTabOutlivesItsEditor` in `ProjectWorkspaceViewTests`, the eraser in `ToolIconTests`, `exportCrossingExample` in `BrushIntersectionTests` (under its variable); the key tests moved on from 8.1. In scope by the third-round rule: the painted-source test of 8.2b round 3. Moved on: the colour swatch, the x and d keys and Alt's eyedropper to 8.10; the other brush tools' circle, tip, brackets, digits and modes to 8.4–8.8; the bracket filter's `levels` term to 9.x.
- 8.3 round 1: FAIL, five findings. A halving band allocated its sums and source row inside the worker, where QtConcurrent wraps `bad_alloc` past the caller's catch (the bands now take them from the caller's scratch; `bandsTakeTheirScratchFromTheCaller` refuses any lone row of sums). Wheel and pinch panned and zoomed mid-stroke (both refuse now, as Swift's; `aStrokeHoldsTheViewStill`). A hover after a busy release painted into the kept stroke (only a left-button drag goes on; `aBusyProjectHoldsTheStroke`). Near-hard soft tips missed the falloff by up to 46 levels, because the table spread its steps over the whole radius (it spans h² to the rim now, solid inside; `BrushCoverageTests` holds 0.9995, 0.9999 and 0.99999 across the fade, the reviewer's own pixel among them); the lower clamp that the core needs runs only for segments through it, which keeps the gate's 2.75 ms model time. The bar's arrows stepped from the rounded number (they step from the session's value, as Swift's `arrowSteps`). Seventeen mutants over the fixes: eleven caught; two found the table's double build and span no better than float to 0.99999, so both are float now; four are equivalents (the lower clamp at step 1, the clamped quadrature everywhere, the Lasso fields' rounding of whole values, twice).
- 8.3 round 2: FAIL, one finding. At hardness 0.99999 and above the table still missed the shader's formula (190 for 188 on a 10 px dab, 22 levels at 0.999999 and 2000 px): its steps had passed float's spacing. Where (1 − h²) / 8192 falls under float's epsilon, `tipDensity`, the shader's own float formula, now replaces the table for dabs and segments; the table builds from the same function. A scratch probe against the shader's quadrature, replayed in float, found the kernel exact on both sides of the switch (hardness 0 to 0.999999, 10 to 2000 px) and the table off by 4 levels on a near-hard segment. New `nearFullHardnessTheFormulaReplacesTheTable` (dabs from 0.999 to 0.999999, both pixels named in the reviews) and `aStrokesSegmentsFollowTheShaderEitherSideOfTheSwitch` in `BrushCoverageTests`. Eight mutants: five caught (the table on segments only once the stroke test existed); three equivalents (the formula for every hardness and the two early returns of `tipDensity`), recorded in AGENTS.md.
- 8.3 round 3: FAIL, one finding, which ends the item's reviews by the third-round rule after five, one and one. The quadrature's template regrouped the shader's `midpoint ± halfLength · node − projection` as `(midpoint − projection) ± halfLength · node`; 8192 pixels along a 0.999999-hard 10 px stroke that gave 14 where the shader gives 17, and the test's reference, copied from the twin, repeated the regrouping. The shader's order is back in both, and `farAlongAStrokeTheNodesRoundAsTheShaders` pins 17 at (8202, 10) before and after a flush. Two mutants (the regrouped order for both nodes, and for one), both caught; carried into 8.4's review.
- [x] 8.4 Spot Healing (`HealPixels.c`): `SpotHealingMode`, `BrushSettings.healing` and `healingMode`, the wash in `publish`, `BrushStroke::heal`; the session's `spotHealingMode`, Spot Healing in `beginBrush`, the heal in `finishBrushImmediately` and its step's name, the type on Tab; Swift's `isBrushTool` for the circle, the tip drag, the brackets, the digits and the bar; `BrushControls` titled for every brush tool, with the types and Strength. Tests: new `SpotHealingTests` (Swift's `healsTheBlemishUnderTheBrushAndNothingElse` in each type; plus a mask never healed, Tab and the digits, the wash then the surface, a mask stroke, the selection's bound, a painted source, the kernel's and the coverage's failed allocations, the types' difference and random grain, opacity part way, a spot across tiles), `spotHealingTakesTheBrushGestures` in `BrushGestureTests`, `eachBrushToolTitlesTheBarAndShowsItsPicker` in `BrushControlsTests`, the four brush tools' bars in `ContentViewTests`. In scope by the third-round rule: the fix of 8.3 round 3. Moved on: the clone's Aligned, Sample and Tab to 8.5; the Smear's modes and hints to 8.6; Alt's eyedropper under Spot Healing to 8.10.
- 8.4 round 1: FAIL, one finding. `aPaintedSourceHealsFromItsTiles` gave its raster no patches, so a heal that read the raster's base alone passed every test. The raster now paints the blemished stripes over a blue base: the spot must heal to the stripes' gray and five pixels away from the brush must keep the painted stripes, with nothing flattened. Two mutants (the heal drawing the base alone, and nothing), both caught. The 8.3 fix carried in held.
- 8.4 round 2: PASS. The painted-source test holds the heal to the raster's patches; the item closes after one finding and none.
- [x] 8.5 `CloneStamp` and its preview tip: `CloneSettings`, `setCloneSource`, `cloneStrokeOffset`, `cloneSamplePoint`, `cloneSample`; the session's clone state and `parkedBrushTips` in `selectTool`; the clone in `beginBrush`, `publish` (`clonePixels`, `fillClone`) and the step's name; Tab's sample; the bar's Aligned, Sample and note; the overlay's crosshair and preview; the canvas's Alt-click, hidden pointer and preview. On the way: the session header split into per-extension `Name+Session.h` fragments (AGENTS.md, mirror rule), `displayedSelection` moved to `SelectionEdits.cpp`, its Swift file. Tests: new `CloneStampTests` (Swift's two; plus the families' opacity and the Smear's tip, the sample point and offset, a refused source, the active or every visible layer, a mask, a scaled layer, half and quarter pixels, opacity and the selection), new `CloneStampCanvasTests` (the Alt-click, the crosshair and preview through a stroke, Alt and Space, the kept preview and what redraws it, one click's softness and the tip's size, the Brush's Alt-click, zoom, the layer or every layer, a cancelled stroke), `cloneStampShowsAlignedSampleAndTheSourceNote` in `BrushControlsTests`, the marker, the cut preview and identity in `BrushCursorOverlayTests`. Moved on: `replacesWithClone` and the clone on a mask to 8.6, with their first writer.
- 8.5 round 1: FAIL, one finding. The overlay cut the tip into the preview at the preview's size, which is capped at 1024 pixels, so a 2000-pixel hard brush at 200% previewed 220 of 255 three pixels inside its rim. The overlay now draws the preview and cuts it to the tip inside `AdjustmentSurface::draw`, Swift's transparency layer at the drawing's resolution, and keeps no cut copy. `theTipCutsAtTheDrawingsResolution` draws a tip of one-pixel columns over a preview enlarged four times, each column keeping or dropping the green, and checks the enlarged preview is smoothed. Five mutants (the old cut, the tip ignored, no smoothing, a square clip, no opacity), all caught. The header split was audited: no declaration lost or changed in access.
- 8.5 round 2: PASS. The tip cuts at the drawing's resolution, the header split holds; the item closes after one finding and none.
- [x] 8.6 `BlurTool`, `SmudgeLiquify`: `BlurToolMode`, `WarpStroke` (`append`, `pickUp`, `smudge`, `push`, `weight`), `beginWarp`, `finishWarp`, `blurSample`, `PixelAdjust::gaussianBlur` (Core Image's Gaussian); `BrushStroke`'s `isBlur`, `replacesWithClone` and `editName`, gray clones; the session's `blurMode` and `warpStroke` in Swift's six places, the warp in `beginBrush`, `continueBrush`, `cancelBrush` and `finishBrushImmediately`, Tab's mode, the step names; the bar's three modes and the Smear's hints; the canvas's warp copy and its holds. Tests: new `SmudgeLiquifyTests` (the warp against Swift's arithmetic written again, four brushes along one path; the clamps; Liquify's step, what it lands and Undo; Smudge's carry; clear pixels clearing; the mask refused; the session held; a changed layer; Tab), new `BlurToolTests` (the bell curve and a line's profile, clear and clamped edges, gray and refusals, sigma by size, a mask's tone, placement and turn, Blur strokes on pixels and masks, a mask between pixels), new `SmearCanvasTests` (the copy then the commit, Escape, the held view and tip, the copy through a mask and at half opacity), `theSmearShowsItsThreeModes` in `BrushControlsTests`, the Smear's words in `ContentViewStatusTests`. Swift has no tests of these tools; the twin's are its own. The owed note that `selectLayers` and `selectLayerTarget` read the warp was wrong: Swift's read the brush stroke alone.
- 8.6 round 1: FAIL, one finding. Blur resampled a mask's gray sample in gray, so at a fractional placement the padding past the image came in as opaque black: a white mask placed half a pixel past the canvas's left edge fell to 128 in its first column under a hard Blur stroke. `clonePixels` now resamples every clone into premultiplied colour, a gray one too, so the edge keeps its coverage as alpha, and `fillClone` composites that alpha into gray masks; `shiftedByFraction` is colour-only again. `aMaskPastTheCanvasEdgeKeepsItsTone` holds 255 over white and 128 over black in that column. Two mutants (the sample's alpha ignored, and gray taken as opaque), both caught.
- 8.6 round 2: PASS. The mask edge keeps its tone; the item closes after one finding and none.
- [x] 8.7 `Gradient` (`GradientStyle`, `GradientShape`, `GradientSettings`, `GradientEdit`; `beginGradient`, `moveGradient`, `refreshGradient`, `gradientColors`, `endGradientDrag`, `cancelGradient`, `commitGradient`, `resolveGradient`; `gradientSettings` and `gradientEdit`), `BrushStroke::fillGradient`, the palette's model from 8.10 (`PaletteColor`, `foregroundColor`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`) and its colours in `fillSelection`; the hooks owed (`selectLayer`, `selectLayers`, `selectTool` and `selectLayerTarget` resolve it, `undo`, `canUndo` and `restore`, `canEditLayers`, `invertPixels` and `beginFilter` commit it first, `ProjectWorkspace::canSwitch`); Tab's shape and the digits' opacity; the overlay's line and ends; the canvas's drag, ends, Shift, keys and a folder mask's live clip; `GradientControls` with its swatch. Tests: new `GradientTests` (Swift's eight; plus the resolve on a switch of tool, layer or target, the holds on layer edits and the workspace, Invert and filters applying it first, the gradient half of `numberKeysSetBrushAndGradientOpacity` and Tab, the palette, a mask's palette, a kept target, revisions, busy and lineless commits), new `GradientCanvasTests` (the drag and its line, the ends within ten points, Shift at 45°, Escape and Return, the overlay as Swift draws it, a folder's pending mask, the bar, the swatch), the bar in `ContentViewTests`. Moved on: the Type bar's share of `setPaletteColor` to 8.9; the picker, the swatches, Alt's eyedropper and the x and d keys to 8.10.
- 8.7 round 1: FAIL, one finding. The new tests read the pending gradient, the document and the overlay's geometry through `->`, against the rule that tests read a returned optional through `.value()`: a drag that left no edit would have aborted the binary under `_GLIBCXX_ASSERTIONS` instead of failing the test. Both files now read through `.value()`, and so do four older tests that read `document()` the same way (`CloneStampTests`, `CloneStampCanvasTests`, `ProjectDropTests`, `SmudgeLiquifyTests`). A mutant that loses the drag's edit now fails with QtTest's report. The reviewer found no functional issue; its own oracle matched 128 gradients within 2/255.
- 8.7 round 2: PASS. The tests read optionals through `.value()`, a lost edit fails with a report; the item closes after one finding and none.
- [x] 8.8 `ShapeTool` (`ShapeKind`, `path`, `LayerShapeStyle`, `LayerShape`, `ShapeDraft`, `liveShape`; the session's `shapeKind`, `shapeCornerRadius`, `shapeLineWidth`, `shapeDraft`, `beginShape`, `shapeLineEnds`, `dragShape`, `cancelShape`, `toggleShapeKind`, `finishShape`, `nextShapeName`, `redrawShape`, `shapeTransformPreview`, `shapeImage`), `ImageLayer.shape`, the manifest's `shape`, the hooks owed (`selectTool`, `commitTransform`, Tab) and the rebuilds that drop a shape, the resizers, the canvas's draft, gestures and keys, the Move tool's rounded preview, `ShapeControls`, the Shape hint by kind. Tests: new `ShapeToolTests` (Swift's four; plus a line's ends, box, width, caps and dot, Shift's eighths, the budget, names, refusals, Tab and kinds, signals, `path`, flat drags, smooth edges and thin lines), new `ShapeLayerTests` (redraws at the radius, rounded and one side, masks, groups, what is not redrawn, the Move preview and its cache, painting and every rebuild dropping the shape, a mask stroke and a duplicate keeping it, projects keeping live shapes only, the manifest's refusals, the resizers, older lines against independent strokes, a line's ends, a distorted shape), new `ShapeCanvasTests` (the draft and the layer, its place above the active layer, Shift and Alt, Escape and focus, Shift-U, a line's width and caps at any zoom, a rounded draft's corners, the repainted areas, a folder mask's clip, the Move preview, the bar), the Shape bar in `ContentViewTests`, the hint by kind in `ContentViewStatusTests`. Moved on: the Fill swatch and its picker to 8.10.
- 8.8 round 1: FAIL, two findings. A line a pixel wide took QPainter's thin-line path, so a diagonal covered a pixel at 64 where CoreGraphics' stroke gives 144: lines now fill a round-capped outline (`strokedLine`, `QPainterPathStroker`) on the layer and on the draft, and a width of 1 matches 1.001 within 3/255 in `edgesAreSmoothAndLinesAPixelWide` and `aPixelWideDraftIsItsOutline`. A draft on a blank layer stayed stale when the layer moved mid-drag, since `DisplayState` skips blank layers where Swift's observation redraws: the draft's target now counts, and `aDraftFollowsItsLayerInTheStack` raises it over a sibling, hides it, and holds that another blank layer still counts for nothing. Seven mutants (the old pen twice, the dot, the cap, the width, the filter twice), all caught.
- 8.8 round 2: FAIL, one finding. A new active layer mid-drag (Undo restoring an earlier one) moved the draft below another layer without a repaint, since both layers had pixels and `ShapeShown` did not name the draft's target: it now carries the active layer's id, and `aDraftFollowsANewTarget` undoes mid-drag, expects a paint and the upper layer on top. One mutant (the target left out), caught.
- 8.8 round 3: PASS. The draft follows its target through Undo; the item closes after three findings over two rounds.
- [x] 8.9a `TypeTool` (`TextAlignment`, `LayerTextStyle`, `LayerText`, `TextDraft`, `liveText`; the session's `textDraft`, `textDefaults`, `beginText` at a point and in a box, `editActiveText`, `applyText`, `finishText`, `cancelText`, `recolorText`, `currentTextStyle`, `changeTextStyle`, `layerName`, `textBoxSize`, `textImage`), `Rendering/TextLayout` (Linux-only: fontconfig's faces by PostScript name, TextKit's lines on `QTextLayout`), `ImageLayer.text`, the manifest's `text`, the hooks owed (the gates, `canStartProjectOperation`, `selectLayer`, `selectLayers` and `selectTool` finishing text, the swatch's colour, Fill's recolouring) and the rebuilds that drop text, the resizers, `TypeControls`, the panel's text rows. Tests: new `TypeToolTests` (Swift's eight), new `TextLayoutTests` (faces, measures, boxes, wrapping and dropping, alignment, baselines, the limits, a box under its padding, colour), new `TypeSessionTests` (the gates and file requests, switching, the swatch, Fill, styles, names, a click's target, the corner and the mask, dragged boxes, the style's bounds, the signals, what `applyText` takes, recolouring), new `TypeStoreTests` (the manifest's keys and refusals, invalid styles, the store's rule, stale text, every rebuild, the resizers), new `TypeControlsTests` (the bar's fields, formats, steps, borrowed focus, Return, alignments and their ink under a new theme, faces, buttons, layout, rest, and the list's text row), the Type bar in `ContentViewTests`, a macOS text style in `ProjectManifestTests`. Fixed on the way: the Move bar's fields took half the spare room and grew their buttons, where Swift's `ScrollView` takes it all and keeps its content's size (`theFieldsTakeTheSpareRoom` in `TransformInspectorTests`). Moved on: the colour swatch and `openTextColorPicker` to 8.10.
- 8.9a round 1: FAIL, four findings. `finishText` handed `applyText` the session's own draft by reference, and `applyText` cleared it before reading it (undefined: an -O0 probe lost the text; the -O2 suite passed with it, so no test can hold it): the draft is now taken by value, as Swift passes its struct. `TextLines` parted paragraphs at LF alone: CR, CRLF and U+2029 part them too (Qt breaks at U+2028 itself). Tabs took Qt's 80-pixel default: `NSParagraphStyle`'s twelve stops 28 points apart hold now, and a tab past the last one moves down a line and leads it, the paragraph laid in pieces (`setNumColumns` left the tab hanging on the line above). Two tests read an optional through `->`; they read `.value()`. Tests: `paragraphsBreakWhereCocoaBreaksThem` and a tab test in `TextLayoutTests`. Mutants of the stops, the pieces and the separators: 17, all caught; a dead offset in the tab scan went.
- 8.9a round 2: FAIL, two findings, both in round 1's emulation of Cocoa's late tabs (a tab past the twelfth stop moving down a line): the scan measured from a line's logical start, so after right-to-left text a late tab stayed put, and each late tab laid the rest of its paragraph again, 11.7 s for 10,000 tabs on a commit. The emulation went: tabs stop every 28 points throughout (`setTabStopDistance`), Cocoa's stops up to 336, and past them the stops go on where Cocoa wraps, a known difference recorded in AGENTS.md. `tabsStopEvery28Points` in `TextLayoutTests` holds the stops, a tab after text and the thirteenth stop; two mutants of the distance, caught.
- 8.9a round 3: PASS. Tabs stop every 28 points, the known difference past 336 recorded; 10,000 tabs lay out in about a millisecond. The item closes after six findings over two rounds.
- [x] 8.9b `InlineTextEditor` (`Rendering/InlineTextEditor.h/.cpp`, `InlineTextEditor+Editing.cpp`, `InlineTextEditor+Input.cpp`), the editing half: the model (content, selection, marked text), `synchronize` (logical size, shown transform, point text growing at the layer's scale, handle size, the clamp, a fresh draft's undo and focus), the text surface drawn under the layer's transform with its flips (text, selection, caret, marked underline), the frame, the handles and the overflow plus; the keys (typing as `QInputControl` judges it, deletions, movement, selection, Return, Tab, Escape, Ctrl+Return, Alt's spacing), input methods (marked text in the content, replacements counted from the preedit, events that bring nothing), local undo, the plain-text clipboard, the 100,000-unit cap; `TextLines` gains ranges, lines, the caret, line bounds, positions, a draw with a selection and marked text, and no line past the laid text; the canvas hides the edited layer, hands the editor its keys and input methods, lets its keys win over shortcuts, keeps Shift+Tab and takes the focus for a fresh draft; the menus' Undo, Redo, Cut, Copy, Paste and Select All reach the editor while a draft is open. Tests: new `InlineTextEditorTests` (growth and handles, undo steps, a draft set over another, moves, columns, wrapped and hidden lines, deletions, Return, Tab, Escape and finishing, spacing), new `InlineTextInputTests` (what types, Shift-+ and Shift-− in open text, the cap, the clipboard, composing, replacements, input methods following the caret), new `InlineTextDrawTests` (the draft instead of its layer, the frame and handles, a turned and flipped layer, the caret and its blink, the selection, the plus, focus, keys ahead of shortcuts, the Edit menu, repaints of edits, a paste, a shrunk layer left or replaced, the caret's line, a stretched layer opened), new `InlineTextPreeditTests` (the underline, the input method's formats and caret), `linesStopWhereTheBoxDoes` and `aSelectionFollowsItsRunsToTheLinesEdge` in `TextLayoutTests`, the fixtures `InlineTextFixtures.h` and `InlineTextDrawFixtures.h`.
- 8.9b round 1: FAIL, eight findings. A final newline had no line, so Home after Return went to the line above: `TextLines` lays TextKit's extra line for the caret, even past the box, and measures leave it out. Cancelling a composition dropped the redo: history follows Qt's input method contract, the preedit recording nothing and what commits or removes text making a typing step from the text before it. Input method events ignored a zero-length replacement's offset, `Selection` attributes, `TextFormat` attributes and the `Cursor` attribute's visibility and colour: each is applied now. Arrows moved forward through right-to-left text: in such a paragraph Right goes back and Left on, words alike, and a plain arrow collapses to its own side, as Qt's fields move. A selection across directions was drawn by logical extents: it comes from Qt's visual runs per line, stretched to the fixed line, and runs on to a line's edge where it passes the line, as TextKit's. Opening a layer stretched one way left its pixels showing: a draft that begins, ends or is replaced repaints the canvas, as Swift's `needsDisplay`. Tests: the extra line, right-to-left moves over LF, CR and U+2029, the preedit's history, the zero-length replacement, the `Selection` attribute, a stretched layer, the caret line's repaint, the new `InlineTextPreeditTests`, and the selection's runs, edges and margins. Mutants of the fixes: 53, all caught once two tests came in (a tall line's full highlight, letters after a preedit left bare); `drawSelection`'s early return went, since it only saved work.
- 8.9b round 2: FAIL, five findings; round 1's eight hold. A composition outlived a draft set over it: `synchronize` clears it with the steps. `inputMethod` now follows `QWidgetLineControl::processInputMethodEvent` step by step: input takes the selection before an offset counts, a range goes whole or not at all, the cursor lands where Qt's leaves it, and a `Selection` attribute's selection stays beside a preedit as its `anchor` until the next input takes it, drawn and queried, its move ending a typing run. A final U+2028 now gets the line Qt's `createLine` makes after it, the caret's when it ends the text, and a line ends before the separator breaking it. Tests: a composition ended by another draft, a final U+2028 and the line end before it, input taking a selection, the selection beside a preedit (drawn before and after it, queried, kept by an update, taken by a commit), a Selection ending a run, a mid-text deletion, a range whole or not at all, a final CR and U+2029, U+2028 inside a paragraph and a cut box. Mutants of the fixes: 35, all caught once one test came in (a selection after the preedit read without it); a redundant anchor write went.
- 8.9b round 3: FAIL, two findings; round 2's five hold. While a preedit showed, Copy, Cut and Paste ignored the selection an input method set beside it, and Select All was ignored by the next commit. The Edit commands now work around a preedit as Qt's fields do: on the text without it and its selection (`plain`), the preedit then going back at the cursor (`settle`); the input method's formats count from the preedit's start, so it can move. The input methods and the clipboard moved to `InlineTextEditor+Input.cpp`, keeping the editing file under 500 lines. Tests: `theEditMenuWorksAroundAPreedit` (copy, cut and its undo past a later commit, paste, Select All then a commit), a paste before a preedit, a format on a preedit that does not start the text. Mutants of the fixes: 15, all caught once three tests came in (Select All ending a typing run, a paste dropping the redo, typing straight into a paste). Under the third-round rule the reviews end here; these fixes go to 8.9c's review.
- [x] 8.9c The pointer on open text (`InlineTextEditor+Pointer.cpp`): the canvas's text gestures (`beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`), the editor's `press` (Swift's `hitTest`: the handles' bands, the padding, the text view), `NSTextView`'s clicks (the caret, Shift, words and paragraphs by double and triple clicks, drags by the click's unit, through the text's mirror), the handles' resizing (point text becoming a box, the 16-pixel floor, the limits) and their cursors turned with the box, a click or a handle landing a preedit, `TextLines::character`; the canvas's Type-tool press, double click, drag, release and Escape, and its cursor over the editor. Tab cycles a mode only without a draft, as 8.9b's keys already route it. Tests: new `InlineTextPointerTests` (a click or a drag beginning text, the box drawn in the accent and antialiased, dropped, thin drags, a refused box repainted away; open text finished and other text opened by a click, active or not, hidden text left shut, a box widened leftwards landing where it shows; the caret, drags, Shift and its drag, a move without the button, a later line, a flipped layer's clicks; words from any letter, word drags both ways, paragraphs with CRLF, a fourth click and a far third single; a click and a handle landing a preedit), new `InlineTextHandleTests` (the eight cursors, the bands either side of each edge, a press's cursor and a release's, a narrow box's third; a press making a box, each edge dragged, the 16-pixel floor, the 30,000-pixel limit; a turned box's edges; a scaled layer's growth kept and its 300,000-pixel limit; the padding and a drag from it).
- 8.9c round 1: FAIL, five findings; both carried 8.9b findings hold. An undone New Canvas mid-drag crashed the box's repaint: Swift's guards hold (`dragTextGesture`, and `textBoxDraftRect` for the drawing), so without a document the box draws, drags and makes nothing. Qt can lose a release where AppKit cannot: a move without the left button, a lost focus or the next press ends a text gesture (`endTextGesture`: the box drawn goes, repainted away; a selection or resize stops where it is). Shift-click extended by characters after a double or triple click: the click's unit outlives its release, and Shift drags on from it while the selection it made stands; a key's selection, other text or another draft bring the caret's unit back. A triple click on an empty last line took the paragraph before it: paragraphs are found from the position itself, and an empty last line has no word either. Clicks below the lines landed on the last one and drags above the first kept their x: below is the end of the laid text, above the start. Typing while the button is down ends the drag. Tests: `anUndoneCanvasLeavesTheBoxInert`, `aLostReleaseDropsTheBox`, `shiftExtendsByTheLastClicksUnit` and `blankSpaceAndEmptyLinesTakeClicks` in `InlineTextPointerTests`, `aLostReleaseEndsAResize` in `InlineTextHandleTests`. Mutants of the fixes: 39, then 6 again, all caught but `drawTextBoxDraft`'s guard (no box maps to a null rect, which draws nothing; recorded). The survivors took out a dead term (`synchronize`'s first call is always fresh) and `release`'s return value (the canvas's own release path ends alike), and brought a test: below an overfull box's lines is the end of what shows.
- 8.9c round 2: FAIL, three findings; round 1's five hold. A far drag over a squashed layer (a 30,000-pixel box ten pixels tall, font size 1) turned a row past `int`'s range into a wild index: the row is judged as a double first. An edit kept a held gesture: typing during a resize was undone by the next move, which wrote back the draft the resize began with (Swift's drag would too), and the same letter typed over its selection left the drag running; an edit (`publish`) and a spacing key (`changeSpacing`) now end a drag or a resize, as a press does. A style change kept Shift's unit, where Swift's style sync sets the selection anew and resets NSTextView's granularity: the unit goes wherever the selection is clamped, which also ends a drag there (a fresh draft of the same style keeps it, as Swift's). Tests: `aSquashedLayerTakesFarDrags` and `anEditEndsAResize` (typing, a spacing key) in `InlineTextHandleTests`; in `shiftExtendsByTheLastClicksUnit`, the spacing keys and a style set elsewhere, a style set mid-drag and the same letter typed over its selection (a fresh draft of the same style is no longer tested for a reset: Swift keeps its unit). Mutants of the fixes: 5, all caught.
- 8.9c round 3: FAIL, one finding; round 2's three hold. A valid rotation of 10^12 degrees drew the box as 280 degrees but gave its corners the cursor of none: the count of eighths passed `int`'s range, where Swift's `Int` holds it. The rotation is reduced by 360 first. Test: a 10^12-degree layer's corner cursor in `aTurnedBoxResizesAlongItsEdges`. Mutant: 1, caught. Under the third-round rule the reviews end here; this fix goes to 8.10's review.
- [x] 8.10a The palette and its picker: the rest of `ColorPalette` (`PickerHSB`, `quantized`, `hex`, the hex parser, `ColorPickerTarget` for the palette and text, `ColorPickerState`; the session's `colorPicker`, `openColorPicker`, `typeColor`, `openTextColorPicker`, `closeColorPicker`, `setColorPickerHSB`, `sampleIntoColorPicker`, `sampleCompositeColor`), `FloatingPanel` (from 9.1), `ColorPickerSheet` in its panel, `ColorPaletteControls` under the rail (swatches, swap, defaults, the mask's choice), the Brush bar's colour swatch, the Shape bar's Fill swatch and the Type bar's colour swatch, the X and D keys on the canvas and in the list, `ProjectWorkspace::canSwitch` reading `colorPicker`. Tests: new `ColorPickerTests` (Swift's eight, the hex parser's edges, a half-clear pixel sampled exactly, the gates, the text picker with and without a draft, a mask keeping its foreground), new `ColorPickerSheetTests` (the field and the strip picking with the left button, their drawing, the entries' typing, steps, bounds and borrowed focus, Return, Escape and the close button), new `ColorPaletteControlsTests` (the swatches and their repaints, the picker's panel, swap and defaults and their ink, the mask's choice, a busy project, the rail), new `ColorSwatchTests` (the three bars' swatches, X and D), new `FloatingPanelTests` (Swift's layout pass, placing, keeping and remembering, the keys on every show, the close button and Escape, refocus by name), the picker's gate in `ProjectWorkspaceTests`, the Fill label in `ShapeCanvasTests`. Moved on: the inline editor standing aside while the picker is open (Swift's `hitTest` and cursor) to 8.10b, with the picking it serves.
- 8.10a round 1: FAIL, two findings; the carried 8.9c finding holds. The picker's controls had no accessible names: each carries Swift's label (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color), each entry Swift's title as its placeholder, and the strip the hue's rounded degrees as its description, since Qt's plain widgets hold no settable value. The hue arrows were clipped at 0° and 360°, where SwiftUI lets them overflow the strip: the sheet draws them over the strip's place and repaints five points past either end. Tests: `everyControlCarriesSwiftsLabel` (read through `QAccessible`) in `ColorPickerSheetTests`, and in `theFieldStripAndPreviewDrawAsSwifts` the arrows past both ends, their antialiased edge and their repainted room. Mutants of the fixes: 23, all caught once the edge test came in.
- 8.10a round 2: PASS. The labels and the arrows' overflow hold at 1× and 2×; the item closes after two findings over one round.
- [x] 8.10b The Eyedropper and picking on the canvas: the tool's bar with `showsSampleRing`, `SampleRingOverlay`, the eyedropper cursor (`Rendering/EyedropperIcon`, shared with the rail), sampling by the Eyedropper, by Alt under the Brush, Spot Healing and the Gradient, and into the open picker, which takes the keys back; the canvas's focus request sparing the picker; the inline editor standing aside while the picker is open (its cursor and `hitTest`, through picking's rank). Tests: new `EyedropperTests` (the composite into the foreground, off the document, busy and with a mask; the ring's place, colours, repaint and setting; Alt under the three tools and not under Clone Stamp or the Shape tool, never during a stroke or a gradient drag, and again after a refused gradient press; the open picker taking samples and giving the keys back; the cursor, its drawing and its rank over Space; picking ending mid-drag; lost releases; Space panning; the inline editor standing aside, double clicks included; the brush circle; a mounted canvas; the bar).
- 8.10b round 1: FAIL, three findings. When Alt first showed in the press itself, the foreground's signal synced the display, saw picking begin and ended the sampling just started, so the drag sampled nothing and the ring stayed after the release: `syncPicking` runs before a sampling begins and on every move (Alt let go unseen ends it there). Picking's hover swallowed a right-button drag with Alt held or the picker open: the hover counts only with no button held, as Swift's `mouseMoved`, so the tip sizes as Swift's `rightMouseDragged` does. The "Sample Ring" checkbox did not follow the session: it follows every change under a `QSignalBlocker`. The tests split into `EyedropperTests` and `PickingTests` over `EyedropperFixtures.h`. Tests: `altFirstSeenInThePressSamplesOn` (the canvas synced as `ContentView` syncs it) and `aRightDragSizesTheTipWhilePicking` in `PickingTests`, the checkbox set from the session in `theBarTogglesTheRing`. Mutants of the fixes: 11, all caught.
- 8.10b round 2: PASS. The three fixes hold against the original probes; the item closes after three findings over one round.
## 9 Adjustments and filters (README)
- [x] 9.1a Levels in the session: `Levels` (`LevelsChannel`, `LevelRange`, `LevelsSettings`, `LevelsHistogramDisplay`, `LevelsJob`, `LevelsFilter` over `LevelsPixels.c`, `LevelsEdit`; the session's `levels`, `beginLevels`, `updateLevels`, `setLevelsSampleMode`, `cancelLevels`, `commitLevels`), `LevelsAutomatic` (`LevelsSample`, `LevelsAuto`, `LevelsSettings::sampling`, `autoLevels`, `sampleLevels`), and the hooks owed: the gates, `canAdjustColors` and `canStartProjectOperation` read `levels`, `selectLayer` and `selectTool` refuse while it is open, closing it resumes file requests, and a pending gradient lands first. Tests: new `LevelsTests` (Swift's nine, the soft edge held to Swift's test), new `LevelsSessionTests` (the gates and a waiting file request, a commit's busy project and its refusals, the gradient first, the scaled preview from a painted layer's tiles and a one-pixel line, the rebuild dropping a shape, one preview at a time, Auto waiting for the histogram, the sample mode's signal, sampling through the layer's placement), new `LevelsModelTests` (clamps and defaults, the input clamped, settings stored and compared normalized, the scale's sorted finite interior, auto's thousandth tails, a single tone, the widest channel, neutral's gamma and floor, sampling past a channel's range, the composite and outputs reset), new `LevelsEdgeTests` (an open transform and lasso ended, every signal and redraw, the queue dropped with Preview, an identity commit at once, stale runs that never land, the rebuild dropping text, the preview for its own layer, truncated scaled sides, samples at the document's edge, on clear and soft pixels), new `LevelsFailureTests` (a failed scaled copy, sample and commit reaching `brushError`, a failed count and preview leaving nothing), the shared `LevelsFixtures.h`. Fixed on the way: the commit flattened a painted layer on the UI thread outside any catch; the flatten moved into the worker.
- 9.1a round 1: FAIL, two findings. The histogram packed the selection's coverage into a `std::vector`, whose failed allocation threw `std::bad_alloc` past the worker's catch and aborted the app: the kernel accumulates, so it now takes one row at a time with the mask's own row, and the histogram's bins and the filter's tables live on the stack. A commit forgot the running preview but kept the job queued behind it, which then started and landed mid-commit: a commit drops the queue too. Tests: `histogramRowsKeepTheirOwnCoverage` in `LevelsModelTests` (a two-row selection three pixels wide, where mask rows are padded), a queued preview dropped by a commit in `stalePreviewsNeverLand`. Mutants of the fixes: 4, all caught.
- 9.1a round 2: PASS. Both fixes hold against the original memory-limit and commit-race probes; the item closes after two findings over one round.
- [x] 9.1b The Levels sheet in its panel and on the canvas: `LevelsSheet` (the channel, the histogram and its loading note, the input and output handles and fields, the eyedroppers and their note, Auto, Preview with Alt+P, Reset, the caption, Cancel and OK with the spinner, resting while it commits), the panel (`levelsPanel`, cancelled by its close), the canvas's preview in place of the layer, the sample mode in `picking` and its press with the panel refocused, presses held to Space, Hand and Zoom while Levels is open, its keys (Escape, Return, Alt+P, Space alone), the brackets refused, `ContentView`'s drop guard and New canvas button, the Image menu's Levels… (Ctrl+L), Undo and Redo bare, Import Images resting. Tests: new `LevelsSheetTests` (the loading note, names and labels, the layout, the histogram's bars, colours and repaint, entries as Swift's bindings, the eyedroppers and their glyph, Auto, Preview and Reset, Cancel and the resting commit, a theme change), new `LevelsHandleTests` (the triangles, every handle's drag and bounds, the topmost press), new `LevelsCanvasTests` (the preview in place, a sample press handing the panel the keys, presses held to Space, the Hand and the Zoom, the keys, the brackets, the panel following the edit, Return from an entry or any button reaching OK), the shared `LevelsSheetFixtures.h`, the Levels panel's layout pass in `FloatingPanelTests`, the menu entry and its gates in `SelectMenuTests` and `CompositorMenusTests`, the eyedroppers' device pixels in `HighDpiCanvasTests`.
- 9.1b round 1: FAIL, two findings. The entries read and printed numbers with a period whatever the locale, so a German `2,5` went back to the old gamma, where Swift's `.number` format follows the user's locale: they read and print through the widget's locale now. Two new tests read a layer's asset through `->`: `.value()` now. Tests: `entriesReadAndWriteTheLocale` in `LevelsSheetTests`. Mutants of the fix: 2, both caught. The same gap in the Type bar's size and tracking (8.9a) is carried into 9.2.
- 9.1b round 2: PASS. Both fixes hold against the round's locale and panel probes; the item closes after two findings over one round.
- [x] 9.2 `Curves` (`CurvePoint`, `CurvesSettings`: validity, Swift's monotone Hermite `value`, `apply` through `levels_apply`), `CurvesControls` (the channel, the graph and its gesture, the readout, Remove point, Reset curve), `ImageAdjustments` (`ImageAdjustmentPixels`, `AdjustmentColor`, `ExposureSettings`, `GradientMapSettings`, `GrainSettings` over `AdjustPixels.c`); `PixelAdjust` and `PixelInvert` came forward in 7.1, 8.x and 9.1a, and Swift's `PixelInvert.thumbnail` shim has no twin (its callers call `PixelAdjust::thumbnail`). Their callers arrive with 9.4 (filters) and 9.5 (adjustment layers). Tests: new `ImageAdjustmentTests` (Swift's exposure, gradient map and grain tests, and the model half of `curvesIdentityAndImageCommandPreserveAlpha`), new `ImageAdjustmentModelTests` (curves' validity, interpolation and channels, every setting's range, normalizing and table, the gradient map's ends and colours, the grain's seed and place, the kernel's pixels), new `CurvesControlsTests`. In scope, carried from 9.1b's review: the Type bar's size and tracking read and print the locale's decimal mark, as Swift's `.number` fields (`sizeAndTrackingFollowTheLocale` in `TypeControlsTests`, whose fixture moved to `TypeControlsFixtures.h`).
- 9.2 round 1: PASS. No findings; the item closes in one round.
- [x] 9.3a Hue/Saturation in the session: `HueSaturation` (`ColorRange`, `HueBand`, `HueSampleMode`, `HueTargetDrag`, `RangeAdjustment`, `HueSaturationSettings`, `HueSaturationJob`, `AdjustedPixels`, `HueSaturationFilter` with Core Image's colour cube on the CPU, `HueSaturationEdit`; the session's `hueSaturation`, `beginHueSaturation`, `updateHueSaturation`, `commitHueSaturation`, `sampledHue`, `sampleHueRange`, the targeted adjustment, `cancelHueSaturation`), and the hooks owed: `canEditLayers`, `ProjectWorkspace::canSwitch`, `beginLevels`, `canContentAwareFill` and `beginFilter` read `hueSaturation`, `beginHueSaturation` resolves a pending gradient. Tests: `HueSaturationTests` (Swift's twelve), new `HueSaturationModelTests` (ranges, bands, settings, the HSL maths, the cube, the filter, the preview's size), `HueSaturationSessionTests` (gates, queue, commits, signals, samples, targeting), `HueSaturationFailureTests` (memory: the preview, the edit's copy, the commit's flatten, the filter's rows).
- 9.3a round 1: FAIL, one finding. A preview whose worker had finished but whose callback had not yet run counted as idle, so a new job replaced the watcher's future and Qt dropped the old result, and with it a failure's `brushError`, where Swift's task clears itself only after its await. A run now counts as running until its finish has run (`m_hueSaturationRendering`); Levels' and the filters' queues had the same gate (`isRunning`, and `isFinished` too, end with the worker: probed on 6.4.2) and take the same fix. Tests: `aDoneRenderLandsBeforeTheNextStarts` in `HueSaturationSessionTests`, `aFailureReportsThoughTheNextPreviewWaits` in `HueSaturationFailureTests`, `aDoneRunLandsBeforeTheNextStarts` in `LevelsEdgeTests`, a done but undelivered fill in `commitsThatWaitForThePreviewEachResume` (`SmartEditTests`). Mutants of the fixes: 9, all caught.
- 9.3a round 2: PASS. The fix holds against the round's reproduction, and restoring the old gate fails each queue's regression; the item closes after one finding over one round.
- [x] 9.3b The Hue/Saturation sheet in its panel and on the canvas: `HueSaturationSheet` with `SpectrumEditor`, the panel (`adjustmentPanel`), the canvas's preview, `picking`'s hue sample mode and targeting with their presses, drags and double clicks, the Image menu's Hue/Saturation… (Ctrl+U) and the `hueSaturation` term of Levels…. Tests: new `HueSaturationSheetTests` (layout, bindings, fields, toggles), `HueSaturationSpectrumTests` (the drawn glyphs, the spectrum's drags and paint), `HueSaturationCanvasTests` (preview, eyedropper, targeting, the panel, Return); `adjustmentPanelSurvivesALayoutPass` in `FloatingPanelTests`, `hueSaturationOpensOnPixelsAndRestsLevels` in `SelectMenuTests`, the shortcut in `CompositorMenusTests`.
- 9.3b round 1: FAIL, one finding. With the Brush chosen, Alt pressed during a targeted drag turned the cursor into the eyedropper, and it stayed so while the drag still changed saturation, where Swift sets the resize cursor again on every targeted drag. `targetingMove` does the same now. Test: Alt mid-drag in `targetingDragsSaturationOrWithCtrlTheHue`. Mutants of the fix: 3, all caught.
- 9.3b round 2: PASS. The fix holds against the round's reproduction, and removing it fails the new test; the item closes after one finding over one round.
- [x] 9.4a Filters in the session: `FilterKind`'s blurs, noise, lens and four image adjustments (Remove Background comes with 9.7), `FilterSettings`, `FilterJob` with settings, scale and seed, `PixelFilter::run` for each kind (`NoisePixels.c` and `LensPixels.c` compiled in place, Core Image's motion blur on the CPU), `FilterEdit`'s preview copy and blur growth, the session's remembered `filterSettings`, `beginFilter` for every kind (Gradient Map from the palette), `updateFilter(settings, preview)`, the commit's no-op closes and the blur's trim, and the colour picker's Gradient Map target. Tests: new `FilterTests` (Swift's), `FilterModelTests`, `FilterSessionTests` and `FilterFailureTests`; in `ImageAdjustmentTests` Swift's `imageMenuExposureChangesTheLayerInOneStep`, `gradientMapColorsUseTheAppColorPicker` and the command half of `curvesIdentityAndImageCommandPreserveAlpha`. A fix beyond Swift: a covering mask follows a trimmed blur's box. The Filter menu's `isImageAdjustment` and its assertion in Swift's Exposure test arrive with 9.4b.
- 9.4a round 1: no verdict. The service withheld the reviewer's output while it probed allocation failures through a malloc interposer; its probes pointed at two paths, both fixed before round 2. `PixelFilter::trimmed` handed on a null crop when the copy found no memory, so a blur could commit a null asset: it throws `ExportError(render)` now (`aTrimThatFindsNoMemoryIsARenderError`). The motion blur's row list, and the Gaussian's now that it runs in the filter worker, threw `bad_alloc` past the worker's catch: both go through `rowNumbers`, which turns it into `ExportError(render)` (`theBlursFailAsAContextWhenTheirRowsFindNoMemory`); removing either conversion, or the crop's check, fails the new tests.
- 9.4a round 2: PASS. Both allocation fixes hold, and the reviewer's four mutants of them (the crop's check, the conversion, each blur's use of it) fail the new tests; the item closes after two rounds, the first without a verdict.
- [x] 9.4b The filter sheet in its panel: `FilterSheet` (each kind's controls, `GradientMapControls`, `CurvesControls` hosted) in a `FloatingPanel` (`filterPanel`), the Image menu's Curves…, Exposure…, Gradient Map… and Grain…, and the Filter menu (with `isImageAdjustment` and the list of kinds, its readers). Tests: `FilterSheetTests` (7.5's, moved to the panel), new `FilterControlsTests`, the menus' in `SelectMenuTests` and `CompositorMenusTests`, `allFilterKinds` and `isImageAdjustment` in `FilterModelTests`, and the `isImageAdjustment` line of Swift's `imageMenuExposureChangesTheLayerInOneStep`.
- 9.4b round 1: FAIL, one finding. Arrow keys stalled on the filter sliders: a step of a thousandth rounded back to the setting, the update still notified, and the sync put the thumb back, so 100 Right presses left Radius at 1. A step that rounds to the setting it already holds now changes nothing, so the thumb keeps its place and steps add up (`arrowKeysStepTheSlidersOn`: 100 presses take Radius from 1 to 3.8 and Angle from 0 to 31; removing the guard fails it).
- 9.4b round 2: PASS. The fix clears every stall of round 1, drags that return, typed precision and Preview off hold, and removing the guard fails the regression; the item closes after one finding over one round.
- [x] 9.5a Adjustment layers in the document and the store: `AdjustmentKind`, `LayerAdjustment` (validity, `resolvedHSV`), `ImageLayer.adjustment` and everything that carries a layer's fields (the record, the snapshot and its install, `hierarchyRecord`, duplicates, copies between projects, the resizers), the manifest's `adjustment` (Swift's synthesized `Codable`: optional settings left out when absent, `ColorRange`-keyed dictionaries as key and value pairs), the store's rule (version 7, no pixels, no folder, valid; text never on an adjustment), `LiveMaskGraph.validate`'s rule that an adjustment layer is no clipping source, `canLinkMask`'s and `canPaint`'s terms, `addAdjustment` and `updateAdjustment`. Tests: Swift's `settingsSaveAndOlderAdjustmentsStillOpen` (in `ImageAdjustmentTests`) and `legacyHSVStillDecodesAndRenders` (a new `AdjustmentLayerTests`), new `LayerAdjustmentTests`, an unbaked copy in `ProjectCopyTests`.
- 9.5a round 1: FAIL, one finding. A Grain seed written `1e-400` fails Qt's whole parse (an illegal number) where Swift's decoder reads zero, and nothing recorded it. AGENTS.md's note on Qt's parser now names it, and `aSeedReadsWhatQtsParserReads` pins it beside what agrees: a whole number written `5.0` or `5e0` reads as Swift reads it.
- 9.5a round 2: PASS. The note and the pin match the round's reproduction; the item closes after one finding over one round.
- [x] 9.5b Adjustment layers drawn: `LayerAdjustment::apply` with its region, `LiveMaskRenderer.adjust` with its three hooks and the adjustment branches of `prepareStacks` and `drawComposite`, their wiring in `ImageExporter.render`, `drawLiveComposite` (through `AdjustmentSurface` when an adjustment exists) and the canvas, a clipped adjustment inside its stack. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`) with five of the twin's own, two cases in `LiveMaskRendererTests`, three in `CanvasDisplayTests`.
- 9.5b round 1: FAIL, three findings. `adjust` mixed bytes as RGBA while its snapshot kept the target's format, so an ARGB32 target swapped red and blue; and its copy back kept the target's pixel ratio, so a ratio-2 target drew doubled. The snapshot is now `RGBA8888_Premultiplied` at ratio one, and the copy back cancels the painter's whole device transform (`anAdjustmentTakesAnyImageTarget`: ARGB32, and ratio two against a painter scaled twice, under normal and Multiply; undoing any of the three changes fails it). The app reaches `adjust` only with such images already, so the fixes guard the renderer's own contract. Third, AGENTS.md claimed Grain stays in the document on the canvas; like Swift's, the canvas hands it the view's region: the entry now says so and `grainFollowsTheViewOnTheCanvas` pins it.
- 9.5b round 2: FAIL, one finding. Both renderer fixes hold against the round 1 probes, and the entry matches the canvas. But `grainFollowsTheViewOnTheCanvas` kept the random seed its layer was made with, and under seed 95 the pan leaves the sampled pixel at 133: the test now pins seed 7, which passes both of its checks every run.
- 9.5b round 3: PASS. The pinned seed holds; the item closes after four findings over two rounds.
- [x] 9.5c Editing adjustments in the session: `AdjustmentEditing` (`beginAdjustmentEditing` over the layers beneath, `previewAdjustmentEditing`, `finishAdjustmentEditing`), `adjustmentOriginal` and `adjustmentEditingID` with its setter, `addAdjustment` opening the editor, the hooks in Levels, Hue/Saturation and the filters, `adjustmentEditingID` in `canEditLayers` and `canStartProjectOperation`, `AdjustmentKind`'s `filterKind`. Tests: Swift's `AdjustmentEditorTests` (every kind) and `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel`, with the twin's own for the refusals, a failed render, the gates and file requests.
- 9.5c round 1: FAIL, one finding. `finishAdjustmentEditing` called directly with a Gradient Map picker open closes the filter edit under the picker, and closing the picker then read the missing edit through `value()` and threw: 9.4a had left out `setGradientMapColor`'s guards as dead, which 9.5c revived. The guards are back as Swift has them (no filter, or another kind, writes nothing; the `committing` term stays out as dead). Test: `aGradientMapPickerOutlivesItsEditor` (OK and Cancel, the picker closed over no filter and over a Curves edit opened meanwhile). Mutants of the fix: 3, all caught. The first pass ran 76 mutants: 72 caught once four rewrites built, two equivalent, and the dead folder term removed.
- 9.5c round 2: PASS. The crash no longer reproduces on OK or Cancel, and the `committing` term's omission matches the commit's order; the item closes after one finding over one round.
- [x] 9.5d Editing adjustments in the window: the layers panel's `.task(id:)` that begins editing, its footer's New adjustment layer menu, the rows' adjustment icons (six kinds, drawn), their words and double click, no link for an adjustment's mask; the Layer menu's New Adjustment Layer and Edit Adjustment…; the sheets' captions for an adjustment. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` (every kind) in `FloatingPanelTests`, the panel's and the menus' entries, the rows. Also: `GradientCanvasTests::theBarSetsTheGradient` waited for the commit's step but not for the edit to end, which happens one turn of the event loop later, so under a loaded suite its mask was refused; it now waits for both.
- 9.5d round 1: PASS. The menus, rows, glyphs, captions and the editor's lifecycle match Swift and the recorded adaptations; probes of all six kinds, reopening and a panel made anew passed. The item closes with no findings.
- [x] 9.6a Layer effects in the document and the session: `LayerEffects` (the four effects, `LayerEffectKind`, `LayerEffectSelection`), the manifest's `effects`, `ImageLayer.effects` where Swift lists it (equality, the record, duplicates; the rebuilds and a copy between projects that leave it out), the session's `effectsEditing`, `effectsEditingOriginal`, `effectSelection`, `canEditEffects`, `activeEffects`, `editingEffects`, `selectedEffect`, `addEffect`, `selectEffect`, `finishEffectsEditing`, `setEffects`, `changeEffects`, `canCopyEffect`, `copyEffect`, `toggleEffect`, `removeSelectedEffect`, the colour picker's effect target (`openEffectColorPicker`, `previewEffectColor`), and the hooks (`selectLayer`, `selectLayers` and `selectLayerTarget` clear `effectSelection`; `deleteLayerOrMask` removes a selected effect first). Swift has no effects tests: the twin's pin Swift's code.
- 9.6a round 1: PASS. The code matches Swift and the recorded adaptations (the fix beyond Swift that saves and loads effects among them); probes of picker transitions, refused selections and codec edge cases passed. The item closes with no findings. Mutants: 119, all caught once ten were rewritten to build, except three `isGroup` terms removed as dead.
- [x] 9.6b Layer effects drawn: `LayerEffectsKernel` (Metal's passes on the CPU, threaded), `LayerEffectsRenderer` (`cached`, `placed`, `margin`, `render` and its coverages), effects in `ImageExporter::render` and `drawLiveComposite`. Tests: new `LayerEffectsKernelTests` (the shader's loops replayed, byte for byte) and `LayerEffectsRendererTests`.
- 9.6b round 1: FAIL, three findings. A mask smaller than its layer was scaled up nearest, so its edge went hard where CoreGraphics' clip scales smoothly: `masked` now sets `SmoothPixmapTransform` (a 2 by 1 mask over 64 by 16 ramps 123 to 132 across the middle). The composition's workers called `scanLine()` on the shared result, and `detach()` bumps a non-atomic counter: they now write through one pointer taken first (the result's detach count stays under 8 on a 4,000-row image). The spread's queues took the larger side squared, 112 MB for a 30,000 by 10 layer: each pass now needs its bands times its line length. Tests: `thinLayersAndTallOnesAreCheap` (the thin layer under a 64 MiB limit, the detach count) and the mask ramp in `renderingPadsMasksThenRunsTheKernel`; undoing each fix fails them. The other parallel workers in the tree already take their pointers first.
- 9.6b round 2: PASS. The three fixes hold against the original reproductions; the item closes after three findings over one round.
- [x] 9.6c Layer effects on the canvas: `EffectsPreviewCache` (one worker, a 60 ms wait, the newest request, seeds, the shared budget), `LayerEffectsSurface` (a painted layer's effects redone as far as the paint reaches), `DistortEffectsCache`, `distortedEffects` and `commitDistort`'s seed, the canvas's `DisplayState` effects, `prepare`, the surface's hand-over and `drawOwn`'s branches in Swift's order. Fixes beyond Swift, recorded in AGENTS.md: the inner shadow scaled in a reduced preview; the surface widened and read by a reach that counts the inner shadow, its windows stopped at its bounds; one request a draw. Tests: new `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests`, `EffectsCanvasTests`, `EffectsFailureTests`; a distorted layer's mask in `DistortCanvasTests`.
- 9.6c round 1: FAIL, two findings. A reduced preview halved a mask by its layer's reduction, so a 2 by 1 mask on a 6,000-pixel layer became one gray pixel and the whole preview half clear: each source is now reduced by its own measure (a 2 by 1 mask in `aMaskHidesPixelsInTheReducedPreview`). A surface pass that could not copy the surface painted into a null image, published it and marked its tiles taken, so the effects vanished for the rest of the stroke: a pass now paints on a copy, checks its painter, and publishes the image and marks its tiles only once whole (`aStarvedLaterPassKeepsTheLastSurface`, first in `EffectsFailureTests`, since heap memory an earlier test freed would serve the copy without a new mapping). Undoing each fix fails its test; the redundant `detach` before the painter (which detaches itself) went.
- 9.6c round 2: FAIL, one finding. The round 1 fix took the larger of the two axes' scales, so a source that shrinks across and grows down went unfiltered: a 6000 by 1 striped mask on a 1527 by 25 preview aliased to alpha 0 to 255. Each source is now scaled by `QImage::scaled`'s smooth filter, each axis on its own (a mask as alpha, so its values pass exactly); the stripes a row high reduce to alpha 125 to 129, as the same stripes a hundred rows high, in `aMaskHidesPixelsInTheReducedPreview`. Undoing the filter, the alpha route or its format fails the test.
- 9.6c round 3: PASS. The fix holds against the stripes in both directions and the earlier reproductions (allocation failure included); 44 masks of constant coverage keep their values exactly. The item closes after three findings over three rounds.
- [x] 9.6d Layer effects' panel: `EffectsSheet` (each kind's controls, the colour swatches, Cancel and OK) in `ContentView`'s `effectsPanel` with the vanished-effect check, the footer's effects menu, the delete words for an effect (the trash's help, the Layer menu's title), the canvas press that clears `effectSelection`, and the session writers they need.
- 9.6d round 1: FAIL, one finding. An arrow step changed the effect from the session's value but left typed text in the field, so Return then applied the typing over the step: with Size at 4, typing 12 and pressing Up gave 5, then 12. A step now drops the typing and shows the stepped value, even where it clamps, as the picker's entries do (`theSheetEditsItsEffect`, at 23 and at the 500 bound). Leaving out either the flag's reset or the refresh fails the test.
- 9.6d round 2: FAIL, one finding. A wheel over the Size slider changed the size while the field kept the keys and its typing, so Return then applied the typing over the wheel: from 4, typing 12 and a notch down gave 2.497, then 12. The slider's change now drops the field's typing and shows its value, as a step does (`aWheelOnTheSliderReplacesTheTyping`, then Return keeping the wheel's value). Leaving out either the flag's reset or the refresh fails the test. The older sheets keep a field's typing across their own sliders, handles and pickers too (Levels, Hue/Saturation, the filters, the colour picker); 9.6f fixes them.
- 9.6d round 3: PASS. Both fixes hold, clamped steps and wheel changes included; 48 probes of the controls passed, unrelated pending typing and the panel's layer among them. The item closes after two findings over three rounds.
- [x] 9.6e Layer effects in the list: the effect rows under a layer (eye, name, selection, a click to choose, a double click to edit, an Alt-drag copy onto another row), the rows' heights, the list's press order (the clipping strip, then an effect row, else the choice clears) and its selection hidden while an effect is chosen.
- 9.6e round 1: PASS. Window-level probes of the panel's focus, a row rebuilt under its own press, eye and row drags, the selection's painting and a stale armed press all held; 86 mutants, all caught once six were rewritten to build.
- [x] 9.6f Pending typing yields to the same value's other controls in the older sheets, as 9.6d's review found: a wheel over a slider (the filters, Hue/Saturation), a dragged Levels handle, the colour picker's field and strip. Each replaces its entry's typing and shows its value, with tests.
- 9.6f round 1: PASS. Window-level probes held, and eight independent mutants removing a reset or a refresh were caught; 14 mutants, all caught once the Levels test pressed a handle without moving.
- [x] 9.7 Remove Background: `SubjectRemoval` (U²-Net on ONNX Runtime where Swift runs Vision: the model's input and mask, `noSubject` and a `model` failure, the mask cache, Advanced's refine, shift and contrast, the preview and the commit's mask), `GuidedMatte`, the kind with its quality and three settings, the full-size preview, `commitBackgroundMask` (a fix beyond Swift: a placed mask's placement goes with its pixels), the sheet's words, Quality and Advanced rows, the Filter menu's entry. The model chosen with its licence checked on the upstream repositories: U²-Net (Apache-2.0, `linux/licenses/U-2-Net.txt`) as rembg converted it (MIT); ONNX Runtime (MIT) from Microsoft's build in the dev image, from the distributions' packages elsewhere; both fetched by pinned checksum in `Dockerfile.dev`. Tests: new `GuidedMatteTests`, `SubjectRemovalTests`, `SubjectRemovalModelTests`, `RemoveBackgroundTests`; the sheet in `FilterControlsTests`, the kind in `FilterModelTests` and both menu tests.
- 9.7 round 1: FAIL, one finding. The preview's result shared the layer's pixels until the loop wrote them, so a copy that found no memory left a null image, stored as the preview with no error (a 6000 by 4000 layer with its mask cached, 8 MiB of room). The result is now copied before the loop, and a failed copy is `ExportError(render)` (`SubjectRemovalFailureTests`, a binary of its own). Leaving out the copy or its check fails the test.
- 9.7 round 2: FAIL, one finding. The model's input buffer threw `std::bad_alloc`, which passed the workers' catches and rethrew on the UI thread (an uncached 320 by 320 image, 512 KiB of room). `vision`, the door every model step goes through, now turns `std::bad_alloc` into `ExportError(render)`, covering ONNX Runtime's wrapper as well (`aModelRunThatFindsNoMemoryIsARenderError`, glibc's mmap threshold fixed so the buffer maps memory the limit counts). Leaving out the conversion fails the test. Swept after: `subjectMask` now refuses a kept mask of another grid as a `logic_error` rather than reading past it.
- 9.7 round 3: PASS. Both earlier failures now report recoverable errors; a failed commit keeps the layer and the history; independent mutants removing either fix were caught. The item closes after two findings over three rounds.
## 10 Canvas and files (README)
- [x] 10.1 `Crop`: `CropGeometry`, `CropDrag`, `CropSnap`, the session's `cropSnapTargets`, `visibleCropRect`, `cropRatio`, `cancelCrop`, `changeCropRatio` and `commitCrop`, its state (`cropRect`, `cropRatioChoice`, `cropError`) and hooks (see "Session hooks owed"); the canvas's gestures (`EditorCanvas+Crop.cpp`), cursors, the overlay's frame and the crop union in `renderBounds`; `CropControls` and the `Couldn’t crop` alert. Tests: new `CropTests` (Swift's six session and geometry tests and the twin's own geometry), `CropSessionTests`, `CropFailureTests`, `CropCanvasTests` (Swift's `cropDraggingRedrawsOnlyTheOverlay` and the gestures), `CropControlsTests`, the bar and the alert in `ContentViewTests`. Moved on: `flipCanvas`'s `cancelCrop` to 10.2.
- 10.1 round 1: FAIL, two findings. A crop that found no memory for the resized records (1,024 layers, 512 KiB of room) threw `std::bad_alloc` past the worker's catch, which rethrew on the UI thread and left the project busy: `CanvasResizer::resize` now turns it into `ExportError(render)`, which the worker catches (`CropFailureTests`, a binary of its own whose workers share the main heap). A press that drew a new frame took its cursor after dropping the old frame, so beside the canvas's edge it showed a resize arrow where Swift keeps the crosshair it showed: the cursor is now taken first (`theCursorShowsWhatAPressTakes`, a press at 5, 150). Leaving out the conversion, the worker's catch or the early cursor fails its test.
- 10.1 round 2: FAIL, one finding. Memory could still run out on the UI thread: the snapshot copied before the worker started, and the landing (the result's copy, the new document, the history's step) after the frame was dropped, so the project stayed busy and `done` never ran. `commitCrop` now takes the snapshot and starts the worker before anything changes, `finishCropCommit` moves the result out and lands it, dropping the frame only once it has landed, and `applyDocumentSize` builds the document and records the step on a copy of the history before publishing either (`DocumentHistory::adopt`); each failure sets `cropError` to the render error and frees the project. Tests in `CropFailureTests`: the snapshot, the worker and the landing each run out in turn, and a sweep of rooms from nothing to 16 MiB finds every size change landing whole or not at all; `aCropIsOneStepThatEndsRedoAndMarksTheProject` pins what `adopt` carries. Leaving out any guard, the staging or any of `adopt`'s three fields fails a test; taking a copy of the result instead of moving it only asks more memory.
- 10.1 round 3: FAIL, one finding (P3). A test read a returned optional through `->` (`cancellationAndViewportMappingDoNotEditDocument`, three reads), against the tests' rule; they read through `.value()` now, and the crop tests hold no other such read. The runtime findings of rounds 1 and 2 were verified fixed, with eleven independent mutants caught. The item closes after three rounds, the last finding fixed.
- [x] 10.2 `CanvasSizeSheet`, `ImageSizeSheet`, the controller's `canvasSize` and `imageSize` (the sheets over the window, the resize off the UI thread, alerts), `flipCanvas`, the Image menu's Canvas Size, Image Size and Flip Canvas entries. Tests: new `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests`, `ProjectResizeFailureTests`; the flips in `LayerFlipTests`, the entries in `CompositorMenusTests`.
- 10.2 round 1: FAIL, two findings. A wheel over Units changes the unit without taking the focus, so a size still being typed was read in the new unit (50 pixels typed, Units scrolled to Inches, Return: 5,000 pixels at 100 dpi); both sheets now take the focus to Units first, so the typing commits in the unit it meant (`aWheelOverUnitsCommitsTheTypingFirst` in both sheet tests, which activate the sheet since focus moves only in the active window; two mutants, caught). Three test reads of optionals used `->` or `*`; they read through `.value()` now.
- 10.2 round 2: FAIL, one finding (P3). The sheets printed a halfway number away from zero (17 pixels at 16 per inch showed 1.063 inches) where Foundation's formatter rounds halves to even (1.062); both sheets round the thousandths with `std::nearbyint` before printing (`halfwayNumbersRoundToEven` in both sheet tests, 1.0625 and 1.1875; two mutants, caught).
- 10.2 round 3: PASS, no findings.
- [x] 10.3 `JPEGExportSheet`, the controller's `exportPNG` and `exportJPEG` (the panels, the sheet over the window, the work off the UI thread, alerts), the cancellation points of `ImageExporter.jpeg`, the render's memory door, `ByteCounts` (Foundation's byte counts, shared with the canvas sheet), the File menu's Export PNG and Export JPEG entries; Copy Merged came with 7.4. Tests: new `JPEGExportSheetTests`, `ProjectExportTests`, `ProjectExportFailureTests`, `ByteCountsTests`; the cancellation points in `JPEGExportTests`, the entries in `CompositorMenusTests`.
- 10.3 round 1: FAIL, three findings. The JPEG encoding let `std::bad_alloc` escape its worker (a 24-megapixel noisy image with 170 MiB of room); `jpeg` turns it into `ExportError(render)` at its door, as `render` does (`anEncodingWithoutRoomIsARenderError`). The PNG snapshot's copy for the worker ran unguarded in the panel's callback, leaving the project busy; it is guarded and explained, as is `exportWork`'s launch (`aPanelsHandOffThatFindsNoMemoryIsExplained`). The JPEG sheet still used a file-local byte count; it calls `ByteCounts::file`. Mutants: the two doors' conversions and the hand-off's guard, caught; the launch's own guard is recorded as unreached.
- 10.3 round 2: FAIL, one finding. The panel's callback, and the refusal's alert, still copied the completion that holds the PNG snapshot (a megabyte for 1,024 layers), so a copy could fail before the panel opened or while the refusal was prepared; `exportPanel` and `showError` move their callbacks now (`thePanelOpensWithoutCopyingTheSnapshot`, room for one snapshot but not two; `aRefusedNameCopiesNoSnapshot`; three mutants, caught).
- 10.3 round 3: PASS, no findings.
- [x] 10.4 `ImageFileDrop` (`importProviders`, `holdsImages`): files and pictures dropped from file managers and other apps, the file branch of `receiveProviders`, and files and pictures taken by the canvas, the tab buttons and the New canvas button. Tests: new `ImageFileDropTests` (Swift's `fileDropProvidersReachImporterInOrder`, a picture copied to a file, what cannot be read, the drop targets).
- 10.4 round 1: FAIL, four findings. A dropped picture's copy was trusted after `QFile::write`, which only buffers, so a copy cut short (a file size limit) imported gray pixels; the flush is checked and a failed copy removed (`aPictureThatCannotBeKeptIsUnreadable`). Several files went to the importer at once, so a second failure replaced the first; `receiveProviders` now imports one provider at a time behind the error before it, Swift's gate (`eachFileWaitsBehindTheErrorBeforeIt`). An open import alert kept its first words; `showAlert` updates it (`anOpenAlertShowsTheGrownError`). A canvas without a workspace refused files; it imports into its own session under Swift's guards (`aCanvasAloneImportsUnderSwiftsGuards`).
- 10.4 round 2: FAIL, one finding. The whole-drag provider copied every advertised format, so a lazy drag offering five pictures handed over 54 MB for a 22 KB PNG and could run out of memory past every catch; it now copies the preferred picture alone (`aLazyDragGivesOnlyThePictureChosen`, a drag that records what it is asked for). Mutants: copying every format again, caught; keeping the web addresses, equivalent and so left out.
- 10.4 round 3: PASS, no findings.
- [x] 10.5 `SliderSnap` (an application event filter that snaps a track press before Qt tracks it, installed in `main`); `NavigationToolHeader`, `ToolHeaderStyle`, Hand and Zoom came with 4.3b and 4.4. Tests: new `SliderSnapTests`.
- 10.5 round 1: FAIL, two findings. A slider whose owner rounds the value (Gaussian Blur's radius) moved the knob away from the press, so Qt took the press for a groove click and left the slider held down; the snap now presses where the knob is and consumes the original. A left press with another button held snapped, though Qt refuses it; the snap now asks for the left button alone. Tests: a rounding owner's drag and release, a held button; four mutants, caught.
- 10.5 round 2: PASS, no findings.
## 11 Fluency, packaging, distros
- [x] 11.1 Performance pass: pan/zoom frame time at 4K with 10 layers, timings logged, compared with `docs/brush-performance.md`. Done so far: `CanvasPerformanceTests` (logged medians and p95s, regression guards) and `HslBlend::blend` in parallel rows (Hue layers 3× faster); see AGENTS.md, "Canvas speed (11.1)".
- 11.1 round 1: FAIL, two findings. The parallel blend deadlocked an export worker on a one-thread pool; so did the older parallel kernels (the Gaussian blur, probed), since Qt 6.4's `blockingMap` waits on a full pool from inside it. All eleven now go through `PoolMap::blocking`, which lends a pool thread's place while it waits (`PoolMapTests`; two mutants, caught). The benchmark's "100%" set zoom 2, which is 200% at a backing scale of two; it sets `zoom(1)` now, as Actual Pixels does, and asserts it; the honest median there is 68 ms, and the guards and the entry say so.
- 11.1 round 2: PASS, no findings.
- [x] 11.2 Desktop integration: `.desktop`, icon, `.comp` MIME type, `cmake --install` (`linux/packaging`, the install rules, the desktop file name in `main`, `COMPOSITOR_MODEL`). Tests: new `DesktopIntegrationTests`.
- 11.2 round 1: FAIL, four findings. `StartupWMClass` named the app id where Qt 6.4's X11 class is `Compositor`; it says `Compositor`. The model kept its own file name; it is installed as `u2net.onnx`. The install test could not pass for a build given the model; it reads the build's cache and expects the model then (checked by hand in a second build configured with it). A direct install left the project type unregistered; it runs `update-mime-database`, required, when no `DESTDIR` stages it (`aDirectInstallRegistersTheProjectType`, a stand-in tool; two mutants, caught).
- 11.2 round 2: FAIL, two findings. A model given as a link was installed as the link, dangling once the build machine's file went; the file itself is installed now (`file(REAL_PATH)`), and the staged test asserts no link. The direct-install test inherited a caller's `DESTDIR`; it clears it. Both checked by hand, since the suite runs without a model or a `DESTDIR`: a second build given a linked model, and the suite under `DESTDIR` set.
- 11.2 round 3: PASS, no findings.
- [x] 11.3 Packaging: `PKGBUILD` (Arch, Omarchy), `flake.nix` (NixOS), CPack DEB (Ubuntu) and RPM (Fedora); the app's licence installed, `COMPOSITOR_BUNDLE_ORT` for Ubuntu, packaging tools in `Dockerfile.dev`. The DEB is built and read back here; the others are built in 11.4.
- 11.3 round 1: FAIL, three findings. The bundled runtime shipped without Microsoft's licence and notices; the image keeps them from the release and a bundling install lays them under `share/licenses/compositor/onnxruntime`, a configure without them failing. The install test failed for a bundling build; it reads `COMPOSITOR_BUNDLE_ORT` and expects the library, its version link and the notices (a second build, bundling with the model, passes). A flake comment ran to eleven words.
- 11.3 round 2: FAIL, two findings in the install test: it looked for the runtime in `lib`, where a `/usr` prefix on Ubuntu gives `lib/x86_64-linux-gnu`, and took only `ON` for the bundle option, where CMake takes any true value. CMake now hands the test its own evaluated values (`COMPOSITOR_BUNDLED`, `COMPOSITOR_LIBDIR`, `COMPOSITOR_MODEL_PATH`), and a second build bundling with `TRUE` under `/usr` passes.
- 11.3 round 3: PASS, no findings.
- [ ] 11.4 Distro checks in Docker: build and full test run on Ubuntu 24.04, Fedora, Arch, NixOS. Seen early on the Arch host (Qt 6.11.2, GCC 16), in a build without warnings as errors: `findChild` needs `Q_OBJECT` on the class (fixed in 8.2: `ToolHeaderBar`); 13 of 107 targets fail or time out: BrushTests (the address-space crop test commits), HighDpiCanvasTests, ImageImportTests, TiledPieceTests, ToolIconTests, TransformInspectorTests, FilterSheetTests (Return on a focused Cancel no longer waits for OK), CompositorMenusTests; ContentViewTests, ProjectOpenTests, ProjectWorkspaceFlowTests, ProjectWorkspaceViewTests and ProjectControllerTests time out. Each is judged here: a Qt change the app must follow, or a test that leans on 6.4.
- 11.4 state: `linux/scripts/distro-check.sh arch|fedora|nixos` builds `linux/scripts/distro/<name>/Dockerfile` and runs dev.sh's configure, build and full test run in it (warnings as errors, the tree mounted read-only); NixOS builds the flake's package, then runs the tests in the flake's new `devShells.default` (plugin path, DejaVu through fontconfig, the model on the data path). Real containers start now through `sudo docker`; Ubuntu is the dev image itself. Still to do: run Arch (started), Fedora and NixOS, judge each failure, build the RPM on Fedora (`cpack -G RPM`) and the PKGBUILD on Arch (`makepkg`, as a user, not root), then review.
- 11.4 Arch run 1 (Qt 6.11.2, GCC 16, in a container): the build needed three fixes (`QImage::flipped` from Qt 6.9 in `ImageImporter`, `Q_OBJECT` on `PickerField` and `SwatchButton` for Qt 6.11's `findChild`, a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators); 193 of 205 tests pass. Failing, to judge: BrushTests, SpotHealingTests, TextLayoutTests, ImageImportTests, OmarchyThemeTests, ProjectWorkspaceViewTests, ProjectTests, EffectsCanvasTests (aborted), EffectsFailureTests, SubjectRemovalFailureTests, ProjectExportTests, ImageFileDropTests. Log: the scratchpad's arch-check.log.
- 11.4 Arch run 2 onward: running as uid 1000 (as dev.sh) cleared the four tests root broke (read-only folders and unreadable files mean nothing to root). Fixed since: TextLayoutTests counted Ubuntu's DejaVu split (now: the named faces, sorted, once each); ImageFileDropTests dropped before the last import ended (now waits for `canSwitch`); ImageImportTests (libheif 1.23's longer message; Qt 6.9's TIFF plugin logs libtiff errors as criticals, expected from 6.9 on); EffectsCanvasTests: Qt 6.11's `QTransform::map` clips w for points past the horizon where Swift divides by it whatever its sign, so `DistortWarp` projects points itself (`project` in `Distort.cpp`). Left: ProjectWorkspaceViewTests (the app launched with a file) and the memory-starvation tests (BrushTests, SpotHealingTests, EffectsFailureTests, SubjectRemovalFailureTests). A kept Arch build: `~/.cache/compositor-arch` (mount it at /build in compositor-arch).
- 11.4 Arch, later fixes: ProjectWorkspaceViewTests launched the app, whose logs a journald-built Qt sends to the journal when stderr is no console; the test sets `QT_FORCE_STDERR_LOGGING` for it. The five starvation tests (BrushTests' crop, SpotHealingTests' two, EffectsFailureTests' later pass, SubjectRemovalFailureTests' model run) place their failure by Qt 6.4's own allocations (the address limit itself works on Arch, probed: 20 MB refused under a 2 MB margin); they skip off Qt 6.4 (`placesStarvation` in `AddressSpaceLimit.h`), and BrushTests, SpotHealingTests and EffectsFailureTests fix glibc's mmap threshold as the other failure binaries do. Full Arch check rerunning.
- 11.4 Arch, the last timeout: ProjectManifestTests ran 50–120 s in the container against 0.7 s on Ubuntu, its CPU idle: files synced under a `HOME` on the container's overlay filesystem wait on this machine's busy disk (1 s with `HOME` in RAM). The check now keeps `HOME` and `TMPDIR` on a 4 GB tmpfs, as the dev image's RAM build does; an environment matter, not the app's.
- 11.4 after the reboot (real Docker, no sudo): Arch 205 of 205. Fedora 42 (Qt 6.10) 203 of 205, both fonts: Fedora splits DejaVu, so its image installs Serif and Mono beside Sans, as Ubuntu's `fonts-dejavu-core` and Arch's `ttf-dejavu` carry them; and Fedora's own default sans is Noto (`default-fonts-core-sans`), in which the Type tool's "Aa" draws, so `ToolIconTests` compares the Type cells with the DejaVu sheet only under DejaVu Sans (the other tests hold that icon's ink, box and uniqueness under any face; a band 8 points narrower fails on Fedora). Rerun: 205 of 205.
- 11.4 packages: the RPM (`cpack -G RPM` on Fedora) installs with dnf on a fresh Fedora 42 with RPM Fusion, which brings `libheif-freeworld`; its trigger registers the project type, and every library resolves. The PKGBUILD builds with `makepkg` as a user on Arch and installs with pacman, whose hook registers the project type; every library resolves. It builds from the checkout, `Compositor/Assets.xcassets` (the icons) included.
- 11.4 NixOS: the flake had no `flake.lock`, and the tree is read-only there; the lock is committed now, pinning nixpkgs. The package builds. Its first test run, as root, failed the four tests root breaks and three on fonts: the shell's `makeFontsConf` reads its rules from `/etc/fonts/conf.d`, which NixOS keeps and a bare container lacks, so Helvetica fell to DejaVu Math TeX Gyre; the shell now includes fontconfig's own `conf.d` from the store. Nix writes its store as root with root's home, and the tests then drop to uid 1000 (`chroot --userspec`, coreutils) with `HOME` and `TMPDIR` back on the tmpfs, since `nix develop` points `TMPDIR` at a folder root made.
- [ ] 11.4b Standalone OmaPhoto: the port moves to its own public repository, `ZacharyZhang-NY/OmaPhoto` (the user's decision, 2026-09-26), the app at the root, the kernels in `kernels/`, the icons in `packaging/icons/`; renamed where a user sees it (name, binary, app id, desktop entry, icon, packages, logging, build options), Compositor's project format kept; the README credits Compositor; one install script per family in `scripts/install/`; `release.yml` builds and publishes the packages on a tag. Version 1.0.0.
- [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
## Skills by phase
Planning (done): geju, goudi, hai-razor, hai-architecture, senior-architect, clean-architecture, ai-coding-discipline, karpathy-guidelines, be-serious, herdr. Coding (from 0.1): clean-code, code-simplification. IO (3.1, loaded): ddia-principles, applied as encoding and evolution (new code reads versions 1 to 7, unknown keys are ignored, a newer version is refused by name), reliability (a save is whole or absent, synced before the swap) and limits checked before bytes are read. UI (4.3): steve-jobs-perspective. Tests (4.3): e2e-testing-patterns, systematic-debugging. Research (9.7): deep-research. Packaging (11.2): devops-engineer. auth-implementation-patterns: loaded at 3.1; the app has no accounts, sessions or network auth, so only its one general rule applies: a project file is untrusted input, validated before use.
## Review log
One line per review: item, round, verdict, findings fixed.
- 0.1 round 1: FAIL. `dev.sh run` prefixed an absolute `WAYLAND_DISPLAY` with `XDG_RUNTIME_DIR`. Fixed.
- 0.1 round 2: PASS.
- 1.1 round 1: FAIL. `CanvasViewport` lacked `==`; `validDimension` accepted a trailing newline and an embedded NUL; no test asserted the pan update. Fixed.
- 1.1 round 2: FAIL. The viewport inequality check changed two fields at once. Fixed: one field per assertion, every `==` term mutation-checked.
- 1.1 round 3: PASS.
- 1.2 round 1: FAIL. No test proved that `reset()` issues a fresh revision. Fixed; every revision line mutation-checked.
- 1.2 round 2: PASS.
- 1.3 round 1: FAIL. `visibleRect` ignored the window mapping and device pixel ratio; the nearest-sampling test began with smoothing off; nothing proved layer equality leaves painted tiles unflattened. Fixed after measuring QPainter in the container.
- 1.3 round 2: PASS.
- 1.4 round 1: FAIL. `renderLayers` overwrote duplicate ids where Swift traps; no test used an absent `isGroup`; `visibleLayers` order unchecked. Fixed.
- 1.4 round 2: FAIL (new codex session after the Herdr workspace changed). Nested collapse, absent-flag validation and error kinds were untested. Fixed; `ProjectError` now forces the version payload.
- 1.4 round 3: PASS.
- 1.5 round 1: FAIL. A null thumbnail from `scaled()` was accepted where Swift throws; flag tests used equal values; `placed` was tested square only; painted-mask equality and masks on pixel layers were untested. Fixed.
- 1.5 round 2: PASS.
- 1.6 round 1: FAIL. The tracer converted pixels instead of drawing over a cleared bitmap (alpha mishandled, null conversion unchecked); source-id equality untested for two present ids. Fixed. Tests now read returned optionals through `.value()`.
- 1.6 round 2: FAIL. A positional `drawImage` shrank images with a device pixel ratio. Fixed; rule recorded in `AGENTS.md`.
- 1.6 round 3: PASS.
- 2.1a round 1: FAIL. Workers raced on `QImage::scanLine()`'s detach counter; a scratch `bad_alloc` escaped `halve()`; colour fixtures had equal channels. Fixed: raw row pointers, one pre-allocated scratch block, a distinct-channel test, a test-only `operator new` that fails on demand.
- 2.1a round 2: PASS.
- 2.1b round 1: FAIL. A rotated layer leaked through a black mask at antialiased edges; the HSL copy inherited painter opacity; HSL rounding could exceed alpha; a throw after `save()` skipped `restore()`; the test file passed 500 lines. Fixed: mask drawn into a zero-filled `Alpha8` surface and multiplied 1:1, absolute opacity, alpha-first rounding, throwing work before `save()`, tests split.
- 2.1b round 2: FAIL. Mask-painter antialiasing was unverified. Fixed with the coverage-squared law; every hint of every painter mutated.
- 2.1b round 3: PASS.
- 2.1c round 1: FAIL. A failed copy of shared coverage passed as "no folder masks"; the thumbnail background, `flipY` and the 64-folder limit were untested. Fixed; folder clip tests split out; the RLIMIT boilerplate became `tests/AddressSpaceLimit.h`.
- 2.1c round 2: PASS.
- 2.2a round 1: FAIL. Replacing with `Source` doubled antialiased coverage at outer edges; `mutate.py` could report "caught" when nothing ran; every fixture had a zero alignment. Fixed: pieces clear and redraw an exact half-open device-pixel region, the runner judges builds and tests apart, an aligned fixture was added.
- 2.2a round 2: FAIL. The opening for the antialiased outline shrank below a device pixel at extreme reductions. Fixed with room measured in device pixels.
- 2.2a round 3: FAIL, the last round allowed. The room used the painter's horizontal scale, so unequal painter scales under-opened the vertical outline. Fixed: the room comes from the whole device-to-grid transform. Proven by `theOutlineOpensByOneDevicePixel` (the reviewer's regression and two sheared rows) and 22 mutants of `opened` and the culling, all caught. Also in this round: the halving overhang left the opening (proved dead), `Piece.offsetBy`, the offset of `drawCommitted`, `Frame.device` and `Frame.sampling` moved to 2.2b, piece and cache tests split into `TiledPieceTests`. The fix is in 2.2b's review scope.
- 2.2b round 1: FAIL. `drawBrushPreview` drew a cropped raster's base past the source bounds; Swift clips it. I had skipped the clip on an unchecked assumption. Fixed: the base is clipped to the source bounds and the source to the grid; both pinned by tests and mutants.
- 2.2b round 2: FAIL. The grid-clipping test used a plain image only, so the nested raster clips went unchecked (`IntersectClip` to `ReplaceClip` on the base clip passed). Fixed with a painted-source row that keeps the overhanging tile and the untouched-pixel assertions.
- 2.2b round 3: PASS. The round also judged the 2.2a round-3 fix (`opened` from the whole device transform) and found nothing.
- 2.3 round 1: FAIL. The adjustment surface and a stack's group ignored the painter's own opacity and composition mode; a surface that could not be allocated threw where Swift returns; `draw` lacked Swift's save and restore around `drawOwn`; child order and rectangular devices were untested. Fixed: the surface is drawn back by hand under the painter's state, allocation failure logs and returns, a scoped save guards `drawOwn`, tests for each.
- 2.3 round 2: FAIL. `LiveMaskRendererTests.cpp` had grown to 510 lines. Split: the stack cases moved to `ClippingStackTests`, the fixtures to `LiveMaskFixtures.h`.
- 2.3 round 3: PASS.
- 2.4 round 1: FAIL. A 2400x1 JPEG asked for a 1000x0 preview and threw; the two mask flags were only tested with equal values; only width reached the 30,000 edge; comments passed ten words. Fixed: the preview keeps one pixel across, both flag combinations, a 1x30,000 render, all 93 long comments in `linux/` shortened, and `linux/scripts/lint.py` now checks file length and comments.
- 2.4 round 2: FAIL, on the new `lint.py` only: it missed block comments, read `//` inside string literals as comments, and let a 500-line file without a final newline pass. Fixed with a small tokenizer (line and block comments, string, character and raw string literals), `splitlines()` for the count, and a `--self-test` of 14 fixtures whose every rule was mutated.
- 2.4 round 3: FAIL, the last round allowed, again on `lint.py` only (the exporter drew no finding after round 1): a digit separator (`1'000`) opened a character literal and hid every later comment, and a continued string did not advance the line count. Fixed: numbers and identifiers are tokens, newlines inside literals count; proven by `--self-test` (20 fixtures) and 13 mutants of the checker, all caught; the tree is still clean. The fix is in 3.1's review scope.
- 3.1 round 1: FAIL. A parent-folder sync failing after the swap threw while the scope guard deleted the old project; a manifest read failing after partial data went unnoticed (`QFile` reports no error then); the UUID pattern's `$` let a trailing newline pass; `version`, `width` and `height` were narrowed to 32 bits where Swift's `Int` holds 64. Fixed: every way in has its way back, taken when the sync fails; the byte count is checked against the file's size; `\\A…\\z`; `qint64` with the value kept in the version error. The lint fix of 2.4 drew no finding.
- 3.1 round 2: FAIL. When the parent sync failed and the way back failed too, the scope guard still deleted the hidden sibling, which then held the old project, and the log pointed at it; `version`, `width` and `height` were read through a double, so 9007199254740993 lost a digit and 9223372036854775807 was `invalid` where Swift names the version. Fixed: a failed way back sets the guard's `keep`, nothing is deleted, and the critical log names where the old project stays (rows for an exchange, for each of the two moves back, and for a first save); integers come from Qt's exact `LongLong`, in the header and the codec alike, with rows at 2^53 + 1 and both ends of Int64. Swift's decoder was run in a `swift:6.0-jammy` container to settle the edges; one Qt parser fact is declared in AGENTS.md and pinned.
- 3.1 round 3: PASS. The reviewer ran Swift's decoder too and accepted the declared parser fact.
- 3.2 round 1: FAIL. A HEIC that names Display P3 in an `nclx` box instead of an ICC profile kept its numbers and was tagged sRGB (the reviewer swapped the fixture's profile in flight to show it); `heic-fixtures.sh` went on after a failed step and still exited 0. Fixed: the importer reads the `nclx` box (primaries by chromaticity, the sRGB or the BT.709 curve, everything else refused with its reason), proven by rows patched from one lossless fixture with independently derived values; the script runs under `set -eu` with each step on its own line, and a broken generator now exits 1 and writes nothing.
- 3.2 round 2: FAIL. Linear light and the two gamma curves were refused though Qt converts them; two new comments in `heic-fixtures.sh` ran to 11 and 12 words, and `lint.py` does not read scripts. Fixed: linear, gamma 2.2 and 2.8 go through Qt's transfer functions, xvYCC and BT.1361 share the video table, each with a row of derived values and its mutants; both comments are shortened and every comment in `linux/scripts`, `CMakeLists.txt` and `Dockerfile.dev` was counted once by hand. Teaching `lint.py` to read scripts is taken up in 3.3.
- 3.2 round 3: PASS.
- 3.3 round 1: FAIL, seven findings. `drawCoverage` forced Screen and so ignored the painter's own mode, and turned by `rotate(degrees)` where `draw` turns by `radians()`; `lint.py` missed `#` comments without a space and flagged `#` lines inside quoted strings; the tests read optionals with `*`, gave `maskEnabled` and `maskLinked` the same value, left masks out of the resolution-only case, and proved an unflattened asset by pointer only. Fixed: white with the mask as alpha under the inherited mode, opacity and clip, placed as `draw` places; real scanners for shell, CMake and Docker with the fixture generator split into real files; `.value()`, opposite flags by rows, a mask through the resolution-only path, `hasMaterializedPixels` before and after.
- 3.3 round 2: FAIL, five findings. Under Source or Clear the white-with-alpha source erased where the mask was zero, which a clip would not; the CMake scanner counted a bracket comment's closer as a word; the shell scanner took `$((1 << shift))` for a heredoc, cut hyphenated tags short, missed escaped blanks and `\#`, and skipped comments inside `"$( )"`. Fixed: each composition mode maps to the one under which alpha equals a clip's coverage, held against an oracle for all 24 modes over opaque, half clear and clear pixels, raster operations refused; the shell scanner was rewritten as a strict subset that refuses what it cannot read (113 self-test cases, 58 mutants, one equivalent); CMake escapes and bracket closers fixed.
- 3.3 round 3: FAIL, the last round allowed, on `lint.py` only (the C++ and its tests drew no finding): an escaped blank before `[[` was taken for an argument boundary in CMake, and a continued line splitting `$` from `(` hid a command substitution in shell. Fixed: the CMake scanner tracks whether it stands inside an unquoted argument, escapes included; the shell scanner reads a continued line only between words (after a blank, a tab or a newline) and refuses it anywhere else, inside double quotes after `$` too, so no operator or keyword can be split. Proven by the reviewer's two inputs as regressions among 133 self-test cases and by 74 mutants of the scanners, all caught. The fix is in 4.1's review scope.
- 4.1a round 1: FAIL, five findings. `beginEdit` closed undo and redo without telling observers; `zoom` with a value that is no number, refused by the viewport, still signalled; `descendantIDs` copied the layer list once per visited folder, quadratic on a wide folder; the tests never read back what the setters stored and read one optional through `->`; the CMake scanner of `lint.py` forgot it stood inside an unquoted argument after a quote, so `a"b"[[` opened a bracket argument. Fixed: `beginEdit` emits; `fit` and `zoom` emit only when the view changed; one lookup of children by parent, built once, walked once (`aWideFolderIsWalkedOnce` walks 9,999 children in under two seconds); `settersKeepExactlyWhatTheyAreGiven` and `observersSeeFinishedStates`; `.value()`; the quote keeps the argument state, with two regressions among 135 self-test cases and 75 scanner mutants, all caught. The standard library's assertions (`_GLIBCXX_ASSERTIONS`) are on for every target, after a mutant that read an empty optional passed by luck; with them it aborts. Nine mutants of the fixes, all caught.
- 4.1a round 2: FAIL, one finding. The transaction test read `canUndo()` only after `beginEdit` returned, so a signal sent before `history.begin` passed while observers stayed stale. Fixed as a class, not a case: `theLastSignalOfEveryChangeSeesWhatItLeaves` records everything an observer can read (active layer, selection, folded folders, tool, flags, error, rename, undo and redo with their names and count, the modified mark, the view, every layer) inside the `changed()` slot and compares it with the state each call leaves, for every mutator of the item; a call that says nothing fails too. The signal tests moved to `EditorSessionSignalTests` (the session file stood at 496 lines). 29 mutants that move a signal or a transaction's end ahead of the state it covers: 27 caught at once; two showed that redo after `insert` and `addGroup` was never checked for the active layer, so `everyLayerEditRoundTripsWithSelection` gained both steps, and all 29 die.
- 4.1a round 3: PASS.
- 4.1b round 1: FAIL, two findings, both in the tests (the code matched Swift line by line). No fixture nested deep enough: mutants that looked only at immediate parents, for the shared ancestor and for a selected ancestor, passed. And the check that taking a non-active layer out of a selection keeps the active one leaned on hash order: a mutant passed 7 of 40 runs. Fixed: `nestedSelectionsShareAnAncestorAboveTheirParents` builds G holding A(C(a)) and B(b), selects A, a and b with b first in hierarchy order, and holds the wrapper's parent, every parent link and the flat order; the deselection check takes the same third layer out of the same three twice, once under each survivor as primary, so no hash order can save a wrong choice. Added on my own reading: `wrappedLayersKeepTheirStackingOrder` (hierarchy order, not list order) and a target that beats `atBottom` in `placeLayer`. Eleven mutants of these, all caught, the reviewer's three among them.
- 4.1b round 2: PASS.
- 4.1c round 1: FAIL, two findings, both in the tests (no source line drew one). The 64-step walk from a layer up to a selected folder had no fixture at its edge: 63 and 65 passed. And no group edit ran on members with a turn, flips or sampling, so originals rebuilt from origin and size alone passed. Fixed: `aFolderReachesSixtyThreeLevelsDown` nests 64 folders around an image: the second folder carries it, the first does not; `aGroupMoveKeepsEachTurnFlipAndSampling` gives two members opposite flips, different turns and samplings, holds the saved originals, the preview and the commit of a group nudge to the whole transform, and holds a single edit's draft the same way. Added on my own reading: a nudge past the valid origins changes nothing, and a hidden folder's handles fall back to its own transform. Eleven mutants, all caught, the reviewer's three among them.
- 4.1c round 2: PASS.
- 4.1d round 1: FAIL, four findings. A request's callback ran inline, so the last caller saw `isImporting` still true and no error yet, where Swift's resumed caller sees both settled; the waiters were released on every write of `isProjectBusy`, so saying busy again could put a later waiter ahead of an earlier one (Swift releases only when busy ends); the default coarse timer fired at 243 ms; and two tests held ceilings on time (a 4 to 1 ratio for the import call, 400 ms for the indicator) that a scheduler pause breaks. Fixed: callbacks are posted to the event loop; the release stands under `if (!busy)`; the timer is precise and a child of the session; the tests hold the callback's view of both a first and a last request, the line of waiters across a repeated busy, the floor of 250 ms read inside the signal handler, the timer's remaining time right after busy is said again, and the decoding thread taken from the importer's own log. Five mutants of the fixes, all caught; 20 runs under full CPU load, no failure.
- 4.1d round 2: FAIL, three findings, all in the tests. A `qWait` before the timer was inspected could deliver the timer under a scheduler pause; a non-empty import without a callback was never run, so the guard on `done` was unpinned; and no test ended a session while a posted callback was still pending, so a callback posted to a longer-lived receiver passed. Fixed: `qSleep` holds the events back until the timer is read; an import with the default callback; a high-priority event ends the session between the drain's end and the callback's delivery, and the callback must never run. The same property is now held for both kinds of waiter (`waitersEndWithTheirSession`). The gate and busy tests moved to `SessionGateTests` (the import file stood at 481 lines). Four mutants, all caught.
- 4.1d round 3: PASS.
- 4.1e1 round 1: FAIL, four findings, all in the tests. Every display without a preview expected Normal, so a constant passed; the preview's clearing in `commitTransform` was only reached through `selectLayer`, whose later signal hid a missing one; no transform was committed inside an open opacity drag, so ending the drag only when no transform is open passed; and the selected-layers setter met NaN but no infinity. Fixed: a stored Multiply shows with and after a preview; `commitTransform` called with only a preview open must signal, and the slot must see it cleared; a nudge committed inside a drag gives two steps that undo one by one (`Transform Layer`, then `Layer Opacity`); NaN and both infinities go through both setters with opacity, history and signal count held. Five mutants, all caught.
- 4.1e1 round 2: FAIL, three findings, again in the tests. Both layers of the cross-layer case held Normal, so a fallback to the active layer's mode passed; no cycle ran under an open preview, so a cycle starting from the displayed mode passed; and no drag lost its layer, so the guard for a missing target, and a signal on that refusal, were unpinned. Fixed: two layers with different stored modes, each shown as its own with and without a preview on the other; a cycle from stored Screen under a Hue preview gives Overlay and clears the preview; a drag whose layer is deleted changes neither document nor history and says nothing, and the deletion rides in the drag's one step. Added by the same reading: a refused cycle leaves a preview and says nothing; a lone opacity step is named. Six mutants, all caught.
- 4.1e1 round 3: PASS.
- 4.1e2 round 1: FAIL, three findings, all in the tests. The accepted edges of `createNewProject` were held on one axis each (30,000 wide, 1 high) and not on the other; the fixture's assets bore their records' names, so a layer named from its asset passed; and the resolution-only resize was never applied through the session, so an `applyDocumentSize` that refused unchanged dimensions passed. Fixed: 1 by 30,000 is taken as 30,000 by 1 is; the fixture's images are named like camera files while the layers keep their own names; the resolution-only case installs, applies, holds 300 and one step, undoes and holds 72. Six mutants, all caught.
- 4.1e2 round 2: FAIL, two findings, in the tests. No project without an active layer was installed over a session that had one, so keeping the old selection passed; and no snapshot was taken of a canvas without layers, so answering none for it passed. Fixed: an unselected project clears the active layer and the selection; an empty canvas gives a snapshot with its id, size, resolution and nothing else, and installs as an empty document. Added by the same reading: applying a size leaves the session's active layer and path alone, whatever the snapshot calls active. Three mutants, all caught.
- 4.1e2 round 3: PASS.
- 4.1e3 round 1: FAIL, three findings, all in the tests. Every copy of a mask came from the active layer, so pixels or placement read from the active layer passed; the mask outside the group had no placement, so dropping it in the preview passed; and the unknown-id case stopped before asking `canEditMask`, so the guard for a missing active layer was unpinned. Fixed: a copy between two layers that are not active, with a third active and masked, holds the source's pixels, placement and flags; the outsider's mask has a placement that the preview and the commit must keep; one selected id without a layer can edit no mask, and `addLayerMask` changes nothing. Four mutants, all caught.
- 4.1e3 round 2: FAIL, two findings, in the tests, both about what Swift permits. `copyMask` and `toggleMaskLink` ask only for editable layers, yet every test ran them with one layer selected, so the stricter `canEditMask` passed; and folders appeared only as forbidden targets, never as the valid mask sources they are. Fixed: `whatOnlyNeedsEditableLayersWorksWithSeveralSelected` copies a folder's placed mask onto a layer and toggles links on a layer and on a folder, all under a selection of two; a mask target is chosen while a sheet is up. By the same reading: a mask switched off still shows its placement and rides along with its layer. Six stricter-gate mutants, all caught.
- 4.1e3 round 3: PASS.
- 4.1e4 round 1: FAIL, six findings. One bug: `releaseDetachedClipping` keyed its runs by `parentID.value_or(QUuid())`, so a folder whose id is all zeros shared the root's run and a move freed a valid link; the runs are keyed by the optional parent now. One missing trap: the baker let a snapshot with a repeated id overwrite its own records, where Swift's dictionary of unique keys traps; it throws `logic_error` after the target and its pixels are found, and the worker catches `ExportError` alone so that a bug crashes. Four test gaps: no source with a raster mask of its own in a bake; only square targets, so swapped sides passed; clipping never run under a selection of several, so the stricter `canEditMask` passed for all four gates; busy after the call did not prove a worker. Fixed with `LiveMaskBakeTests` (three by two, six mask values), `clippingWorksWithSeveralLayersSelected`, and a bake that queues behind a held thread pool with a lazy raster: the call returns before the raster flattens, and it flattens while the caller runs no event.
- 4.1e4 round 2: FAIL, four findings, all in the tests. The worker test stranded its held threads when a check failed before their release, and the process hung at exit: a scope guard lets them go and joins them now. The baker's source and target always shared their pixel size, so the target's sides passed for the source's grid: `aStretchedSourceBakesThroughItsOwnGrid` stretches three by two pixels over six by four under a placed mask and checks all 24 alphas. Every baked source stated its opacity, so `.value()` passed for `.value_or(1)`: that source states none now. No source lacked pixels, so the guard before the image read could go: `aSourceWithoutPixelsHidesTheTarget` bakes under a blank layer and under a link the snapshot lacks; both give a clear image. Looking for more of that kind showed a class: with the guard before an iterator read removed, `*end()` read wild memory and every test passed by luck (the image guard, five end-of-list guards, two sibling bounds). The reads are checked now (`indexOf`, moved to `EditorSession+Model`, with `[]` under `_GLIBCXX_ASSERTIONS`; `map.at`), so a lost guard aborts or throws; AGENTS.md makes that a code rule. `finishDeletingLayer` of an unknown id reads nothing after its guard, so its silence is asserted in `MaskSignalTests`.
- 4.1e4 round 3: FAIL, one finding, in the tests: no bake fixture gave the target a raster mask of its own, so a baker that pressed that mask into the pixels as well (applied twice after the deletion) passed. Fixed and proven: the target of `aBakeMapsTheSourceIntoTheTargetsOwnPixels` wears a 128-gray mask, the baked alpha stays 128, and the mutant that bakes the mask gives 64 and fails. The third round ends the reviews of 4.1e4; this fix is in the scope of the review of 4.2a.
- 4.2a round 1: FAIL, four findings. A use-after-free: the session keeps the drop queue's waiter and the import's callback, both of which captured the controller, so a controller that ended first was called after its death; both hold it through a `QPointer` now, and `aControllerThatEndsLeavesTheSessionSafe` ends one behind a sheet and one mid-import (the thread pool held). A save name with a foreign suffix made a project that drops then read as a picture: `.comp` is appended unless the name ends in it. A path with a trailing slash left the project without a name in the question and in Save As: `open` cleans its path. And nothing proved the store off the caller's thread: the store's own log lines are now caught with the thread they come from.
- 4.2a round 2: FAIL, two findings, both in the fixes of round 1. The suffix appended after the panel closed could land on an existing project and replace it unasked (`Kept.png` over `Kept.png.comp`): a completed name that meets anything on disk is refused with an alert now, and the old project's manifest is asserted to survive. `QDir::cleanPath` collapsed `link/../Other.comp`, which names another folder when `link` is a symbolic link: `open` trims only the slashes at the end, and a test opens distinct projects at the two places.
- 4.2a round 3: FAIL, one finding, in the tests: every save and open passed a completion, so `finish` without its `if (done)` passed, and a plain `controller.save()` then threw `std::bad_function_call` from the event loop. Fixed and proven: `aCallWithoutACompletionIsWhole` saves, opens and is refused with nobody to tell; the mutant fails it with the unhandled exception; the queue's two completion guards were checked the same way (both caught). The third round ends the reviews of 4.2a; this fix is in the scope of the review of 4.2b.
- 4.2b round 1: FAIL, five findings. `receiveNext` did not hold the tab it imported into, so `removeTab` during the import destroyed the session with its callback and left the workspace managing for good: the callback holds the tab now (tested with the decode held). `samePlace` matched any two places that no longer exist, both canonicalising to the empty string, so a missing project "opened" as the tab of another that had moved away: it falls back on the cleaned absolute path. After a copy the workspace's last signal came before the sessions were freed and showed `canSwitch` false for good: managing ends last. A project named `.comp` had an empty title: `nameWithoutSuffix` cuts as Swift does. And two reads broke the checked-read rule (the source tab's iterator, the baked map): a shared pointer, whose null read aborts, and `at`; `removeTab` erases by predicate and returns when nothing went.
- 4.2b round 2: FAIL, two findings, both in the tests. The suffix cases called only the helper, so `title()` back on `completeBaseName()` passed: they go through a session's path and `title()` now. And every workspace call passed a completion, the very gap of 4.2a round 3 one class further: `everyCallIsWholeWithoutACompletion` runs `open`, `confirmQuit`, `close`, `receive`, `receiveProviders` and `copyLayer` with nobody to tell, refused and carried out; both `finish` guards die without it. Added by the same reading: `ProjectWorkspaceSignalTests` (the last signal of every flow sees what the flow leaves; an empty drop; a workspace that ends under a question or during a copy), and the window handed to the tab that comes forward, not the one that leaves.
- 4.2b round 3: PASS.
- 4.3a round 1: FAIL, three findings, all in `ToolIcons.cpp`. The fade's `setClipPath` replaced the caller's clip, so a widget repainting a strip got ink beside it: it intersects now, and `anIconKeepsToTheCallersClip` draws every icon under a clip of eight pixels. The fade's frame was stroked after the clip ended, where Swift strokes inside it, so half the frame lay outside Swift's square: the stroke moved inside the clip, the outer margin is asserted clear, the reference sheet renewed. And `idle` was refused after the painter had been saved and changed: it is refused first, and the test holds the painter's state and the absence of Qt's warning.
- 4.3a round 2: FAIL, two findings, both leaks into the drawing. The fade's dots were filled one by one, which leaves translucent seams between neighbours at a fractional scale (alpha 173 at side 22.5): they are one path now, as in Swift, and `theFadeHasNoSeamsAtAFractionalScale` holds every pixel that lies wholly inside lit dots at 255. A caller's opaque background mode filled the marquee's dashes and the letters with its background colour: the mode is set transparent inside the saved state. By the same reading the letters no longer start from the caller's font, and `nothingOfTheCallersPainterShowsInAnIcon` draws every icon from a hostile painter (dotted red pen, blue brush, red opaque background, underlined slanted spaced font, a brush origin) and wants the very pixels a fresh painter gives, with the caller's settings back afterwards.
- 4.3a round 3: PASS.
- 4.3b round 1: FAIL, five findings. A rail button toggled itself on a click the busy session refused, so two were down: the click's slot sets every button again. The welcome was hidden and shown, so it kept its fields and its clipboard suggestion across projects where Swift makes a fresh view: it is made anew when the document goes, and the journey test clears a project with a picture copied. The Eyedropper's bar lacked its title. Return with a button in focus created nothing (`setDefault` reaches only a `QDialog`): sheet-wide shortcuts now. And the importer's test picked one file, so a handoff of the first alone passed: it picks two and checks their order.
- 4.3b round 2: FAIL, one finding, in the tests: the refusal test never showed its sheet, so Return reached no shortcut and the guard in `create` went unproven. The sheet is shown and active there now, and each refused size is pressed by button, by Return in the width, by Enter in the height and by Return with a button in focus; the guard's three mutants die.
- 4.3b round 3: PASS.
- 4.3c round 1: FAIL, five findings. The `closeConfirmed` mark stayed on the window, so a window shown again with new work closed unasked: the mark is cleared when consumed. The strip scrolled to the front tab before the row was laid out, so a tab beyond the viewport stayed out of sight; and the fix showed the worse fault under it: the resizable scroll area squeezed every tab to 40 pixels instead of scrolling; the row is sized to its tabs by hand before the scroll, tested with eleven tabs. The editor kept its 520-pixel minimum under the toolbar, so at 800 × 520 the status bar sat below the window: the window owns the minimum now, asserted with the status bar inside. The title stayed `Compositor` and lacked Qt's `[*]`, so `setWindowModified` warned: the title is the front tab's with the placeholder. And the launch test called `receive` itself, so `main` was untested: it now starts the built app with a project and a picture and reads their arrival in its log.
- 4.3c round 2: FAIL, two findings. A title's late layout widened the row after the scroll, leaving the front tab's close button outside the viewport: the row's layout request scrolls again, tested with a front title that grows at the far end. And the launch test read arrival but not order: it asserts the project's log line before the picture's; `main` with the arguments reversed fails it.
- 4.3c round 3: PASS.
- 4.3d round 1: FAIL, five findings. Focus changes never reached the menus, so Undo stayed disabled in the welcome's field and stale after leaving the zoom field: the menus sync on `QApplication::focusChanged`, tested without a `notify()`. A `QPlainTextEdit` branch advertised a text undo that both handlers lacked: gone. Three test gaps: Actual Pixels was triggered at 100% (the zoom is halved first now), Import Images' importing gate went untested, and every multi-selection carried a chosen mask, so the plural title passed as the singular (a bare pair asserts it).
- 4.3d round 2: FAIL, one finding. Alt+E opens Edit from the keyboard and the menu bar takes focus, so the field's Undo went to the document's: the menus keep the last field to hold focus while the bar borrows it (`focusMoved`), and a test opens Edit with Alt+E over the welcome and over a canvas, undoes through the field, and sees focus come back.
- 4.3d round 3: FAIL, one finding. The borrowed-focus test undid through the field but never redid, so Redo reverting to the focus widget passed: the test now triggers Redo while the bar holds focus and sees the text return; that mutant is caught. By the third-round rule the reviews of 4.3d end here; the fix joins 4.4's review scope.
- 4.4 round 1: FAIL, four findings. The pixel grid's path filled odd-even, so a crossing of two hairlines stayed unfilled (Swift fills the union): `Qt::WindingFill`, with a test half a point over where each line fills a whole pixel and their crossing fills once. Swift's window-wide key monitor was missing: an application event filter, installed while the canvas is shown, steps the blend mode on Shift-+ and Shift-− from any control of the window but a text field, and reads Alt for the Zoom cursor from any control, where the canvas alone saw its own keys; tested from the canvas, a button, a field, another window, and hidden. The smoothing test accepted the shadow's and the border's blends: it now reads the two pixels astride the boundary at 190%.
- 4.4 round 2: FAIL, four findings. Alt held before the canvas mounted, or released while it was hidden, left the Zoom cursor wrong: `showEvent` reads `QApplication::keyboardModifiers()` as Swift reads the flags when mounting, tested through the window's own key events for both sequences. The canvas slot's grid kept Qt's nine-point margins, a gutter Swift's canvas has not: zero now, and the view test holds the canvas to its container. The high-DPI surface test grabbed the child alone, whose grab drops the window offset: it grabs the window too and reads the child's corners there, so a surface placed by `combinedTransform` fails. The navigation tests zoomed about the viewport's centre, the default anchor: their pointers sit off-centre and the document point under each stays put.
- 4.4 round 3: FAIL, one finding. An Alt release delivered to an open menu, a window of its own, missed the canvas's window guard and left the Zoom cursor on the minus: the filter reads Alt from every window, as Swift's application-wide modifier monitor does, and keeps the window guard for the blend keys; the test pops a menu, releases Alt into it and sees the plus; the guard put back before Alt fails it. By the third-round rule the reviews of 4.4 end here; the fix joins 4.5's review scope.
- 4.5 round 1: FAIL, six findings. A malformed file at launch left Qt's own palette (the built-in dark is installed before the first load); a file that exists but cannot be opened counted as absent and dropped the theme (absence and a failed open are told apart; a failed open keeps the palette with a warning); single-quoted values were refused (either quote reads); a state directory that did not exist at launch was never found (the nearest existing ancestor is watched until it appears, then dropped); the tab strip's row kept the fill `setWidget` turns on (off now, with the margin's pixel held to the toolbar's); the built-in `Base` had no assertion of its own.
- 4.5 round 2: FAIL, four findings. A file replaced atomically under a held handle left the watcher on the old inode, so a later edit went unseen (the file's watch is renewed on each load); a folder without search permission made the file look absent and dropped the theme (`stat` tells ENOENT from a barred path; the latter keeps the palette); keys in quotes, valid TOML, were refused (keys read in either quote); a malformed `orange` fell to yellow in silence (a recognised key with a bare or half-quoted value refuses the file).
- 4.7 round 3: FAIL, three findings. The mask press's release filter cleared the press on the window's copy of the release before the button's own (a release seen on a `QWindow` no longer counts); another button's release ended a pending mask press (only the left one's does); the autoscroll's stop on leave and drop was untested (`autoscrolling()` is read before and after both). Tests in `LayerDragGestureTests` (Alt and Alt+Shift clicks through the window, a middle button mid-press) and `LayerDragTests`. By the third-round rule the reviews of 4.7 end here; the three fixes join 5.1's review scope.
- 5.1 round 1: FAIL, one finding. The merge tests never baked a mask, a folder mask, a blend mode or a clipping stack inside the merge, so dropping any of them from the flat copy passed. `LayerMergeTests::masksBlendsAndClipsBakeAsTheCanvasShowsThem` compares the live composite before and after each such merge and pins pixels; five mutants that clear a field of the copy are caught.
- 5.1 round 2: FAIL, three findings. The linked-mask flip asserted only the mask's centre and size (its corners through `unitToDocument` are pinned now, and mutants that zero the rotation or the flip of the followed placement are caught); the trim's sampling assertion compared defaults (the fixture is `nearest` now, a mutant forcing `high` is caught); one test comment ran to eleven words.
- 5.1 round 3: PASS.
- 6.1 round 1: FAIL, six findings. A menu borrowing a field's focus reformatted its typing (`12.` became `12`); Return and Escape did nothing from the bar's other controls; a snapped drag released under a Ctrl+T edit left its guide drawn; a Ctrl released while the canvas was hidden stayed held; Space mid-drag replaced the drag's cursor; the lock's icon kept its colour through a theme switch. Tests in `TransformInspectorTests`, `TransformPressTests` (paint events) and `CursorTests`.
- 6.1 round 2: FAIL, three findings. No canvas drag carried Shift (a Shift-resize of a locked corner now asserts 150 × 125 where the lock would give 138 × 138); no test pinched mid-drag (a `ZoomNativeGesture` during a drag now leaves the viewport as it was); one comment ran to twelve words.
- 6.1 round 3: PASS.
- 6.2 round 1: FAIL, four findings, all in `Distort.cpp`. The folded halves' path clip went soft once the image drew antialiased (a device-pixel region now, as the tiled renderer's) and drew source-over with smoothing where Swift copies (`BrushRaster::draw`); a placed linked mask under a folded shape previewed at its followed placement where the commit keeps the stored one; a mask warp that threw discarded the layer's preview where Swift catches it alone. Tests in `DistortTests` (a seam-free diagonal, a half-transparent fold, the stored placement, a mask too wide to warp).
- 6.2 round 2: FAIL, three findings. The integer polygon region dropped edge pixels a hard path clip keeps (the region is built per scanline from the triangle's floating-point edges now); `commitMaskTransform` made the mask's asset inside its transaction, so a throw left it open (the asset comes first now); no test distorted a placed linked mask under a convex shape. Tests in `DistortTests`.
- 6.2 round 3: FAIL, three findings. The two halves met a fractional shared edge from opposite ends, so rounding left holes along the diagonal (each edge's ends are ordered by y now); the transaction test threw before the asset was made (`DistortTests` now takes over `QImage::scaled` in its executable, as `ProjectReplaceTests` takes over libc, and fails the thumbnail after a successful warp); the widened right bound changed pixels beside the diagonal unseen (a patterned source pins them). By the third-round rule the reviews of 6.2 end here; the three fixes join 7.1's review scope.
- 7.1 round 1: FAIL, seven findings. An invert landing after `clearProject` read the missing document (the index is looked up only while a document exists, tested with a project cleared mid-invert); the mask half of the identity guard had no test (a mask replaced mid-invert keeps its gray and records nothing); the hard-clip test sampled beside the diagonal (it samples the pixel centres on it); the lasso tests could not tell winding from odd-even (the same square traced twice fills); `PixelAdjust::coverage`'s smooth sampling had no test (a clip enlarged 2× shows a soft boundary); the mask thumbnail's tooltip lacked Swift's Ctrl words (and the pixel thumbnail's is `configure`'s "Select image pixels", as Swift's `configure` overwrites the longer text); two record reads used `*` and `->` (now `.value()`). The three 6.2 fixes drew no finding.
- 7.1 round 2: FAIL, two findings. A selection move dragged on after `clearProject` read the missing document (`moveSelection` returns without one, tested with a project cleared mid-drag); a painted asset whose flatten fails threw past the invert's cleanup, leaving the project busy with no error and no callback (the image is fetched inside the try; a 48 MB raster starved by `AddressSpaceLimit` reports, frees the project and completes).
- 7.1 round 3: FAIL, one finding, the last round allowed. The two loaders let a painted asset's flatten failure escape their handlers (`ExportError` out of a Ctrl-click or a menu entry): each fetches the pixels inside a try, warns on `compositor.app` and returns, tested with two 48 MB rasters starved by `AddressSpaceLimit`. By the third-round rule the reviews of 7.1 end here; the fix joins 7.2's review scope.
- 7.2 round 1: FAIL, nine findings. A pan left the ants' old rect cached, so a later deselect repainted the wrong place (the overlay's rect is refreshed with every full repaint); the amount field lost its typing to a menu (borrowed focus keeps it, as `TransformValueField`); a double click while Space panned closed a polygonal outline (`!m_spaceHeld`); a held M or L's repeats chose their tools again, ending a fresh draft (repeats are swallowed, as Swift ignores them); Space mid-drag put the hand over a moving outline (the outline drag keeps its pointer like the transform drag); the ants and the draft were drawn aliased (antialiased now, as CoreGraphics draws them); the wand cursor was the rail's icon (Swift's own paths: hot spot 7, 7, the stick down right, the badge at 17, 5); the ants test could not see a missing repaint or a running timer (a paint spy counts the ticks and their rect, then their absence); the autoscroll test held no curve (each tick's 4.8 points and the 40-point cap are pinned).
- 7.2 round 2: FAIL, five findings. An ants tick repainted the whole overlay rect, the Move tool's box included (a tick repaints `selectionRect` alone, tested with the box around a layer); Qt's square caps stretched each dash by its width where CoreGraphics' butt caps do not (`FlatCap` on the ants and the cursors' dashed boxes; a 2× row shows eight on, eight off); the wand test took transparent black for a stick (the pixel's alpha is asserted); the autoscroll interval had no test (16 ms and the ants' 120 ms are read from the named timers); a popup's borrowed focus had no test (the amount field is tried with `PopupFocusReason` too).
- 7.2 round 3: FAIL, three findings, the last round allowed. The amount field's validator refused a fourth digit where Swift's binding clamps (digits only now, the bounds clamp; 1000 typed reads 500); the draft's pens kept Qt's square caps (butt caps, tested at 2× beyond an open endpoint); the cursors' dashed boxes had no test of their caps (their white gaps are counted at 2×). By the third-round rule the reviews of 7.2 end here; the three fixes join 7.3's review scope.
- 4.7 round 2: FAIL, six findings. A spent press stayed armed for presses that arm nothing (every left press first drops the earlier arm); Ctrl mid-drag made a local drop a duplicate (the list duplicates only when a copy is offered alone, and sets Move or Copy itself); an Alt mask press released in a popup lived on (its release is watched application-wide, as the eye's); the project targets accepted every move after a valid enter (a move is judged again and ignored when invalid); autoscrolling stopped with the pointer (a timer scrolls on until the pointer moves back inside or the drag ends); an Alt click that clipped left the old cursor (the cursor is set for the next click at once). Tests in `LayerDragTests`, `LayerDragGestureTests` (the gesture half, split off at 500 lines) and `ProjectDropTests`.
- 4.7 round 1: FAIL, nine findings. Thumbnail presses never armed a row drag (they arm it after targeting, as Swift's `mouseDown` falls through to the table); a stranger list's rows with the same ids moved local layers (a drag carries its list's token, a drop takes only its own); an Alt press on the clipping strip armed a copy drag (a press that clipped arms nothing); a spent press dragged from the middle button (a drag needs the left button held); drags never autoscrolled (the list scrolls by the overshoot into a 16-point margin); hover over rows and Ctrl changes left the cursor stale (every cell tracks the mouse, the filter reads hover, Alt and Ctrl, the last hover point is kept); plain drags offered Move alone while project targets copy (Move and Copy offered, project targets accept Copy); a malformed mask id could name an all-zero layer (`ProjectWorkspace::layerID` parses it). `LayerDragTests` defines `QDrag::exec` in the test executable, so presses, thresholds and actions are tested to the call; 24 mutants over the fixes.
- 4.6 round 1: FAIL, ten findings. An eye swipe cut short by a row rebuild left its transaction open (the button's destructor ends it from the event loop); a fractional opacity was applied twice, rounded the second time (Return and Escape only clear focus, the FocusOut applies once); Alt+E committed a rename and the opacity field (a FocusOut by the menu bar or a popup is no leaving); thumbnail clicks targeted before reading Shift and Alt (Swift's `mouseDown` order restored); a cancelled blend menu kept its target and refused the next keyboard choice (the queued close clears it); panel icons kept the old theme's colours (icons redraw on `PaletteChange`); Shift+Down replaced the selection and Down walked out of view (extend, and `ensureWidgetVisible`); eye swipes never autoscrolled (scroll by the overshoot); a double click on a pixel thumbnail did not rename (the thumbnail leaves it to the row); names were read as HTML (`Qt::PlainText`). Each has a test in `LayerListFocusTests` or the existing files; 16 mutants over the fixes all die.
- 4.6 round 2: FAIL, four findings. A swipe whose release landed in a popup opened mid-press stayed open (the eye watches the left button's release application-wide, as Swift's tracking loop misses none); Shift+Up after two Shift+Downs dropped the middle row (Shift ranges from the anchor, the far end of the selected run); Alt-click on a mask thumbnail chose nothing (it chooses the mask on a release without a drag, as `dragMaskCopy` does); a Shift double click on a mask renamed the layer (a double click with a modifier is a second press of the button's own). Tests in `LayerListFocusTests`; the mutants over the fixes all die.
- 4.6 round 3: FAIL, four findings. A slider drag whose release landed in a popup opened mid-drag kept its opacity step open (the controls watch the left button's release application-wide while the slider is down and let it go, so `sliderReleased` finishes the edit); a middle button's release ended an eye swipe (only the left one's does, as Swift waits for it); an Alt double click on the clipping strip renamed the layer (the second press clips again, as Swift's two `mouseDown`s); Alt+Shift on a mask targeted it without toggling (`selectMask`'s Shift toggle runs on the release). Tests in `LayerListFocusTests`, driven through the window's event path; the mutants over the fixes all die. By the third-round rule the reviews of 4.6 end here; the four fixes join 4.7's review scope.
- 4.5 round 3: FAIL, two findings. A state directory that could not be watched (no permission) was never found again once opened: `watch` climbs to the nearest ancestor `QFileSystemWatcher` accepts and lets it go once the directory is watched, tested with a barred `current/` opened later. A read cut short by an error applied its valid prefix as the theme: the bytes read are checked against the error state and the file's size, as `ProjectStore::load` checks its manifest, tested with a `read` defined in the test executable that gives 64 bytes and then `EIO`. By the third-round rule the reviews of 4.5 end here; both fixes join 4.6's review scope. Widgets take a changed application palette from the event loop: tests of a kept palette read `QApplication::palette()`.
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1790323409,
"narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+41
View File
@@ -0,0 +1,41 @@
{
# OmaPhoto: Compositor for Linux, built with CMake.
description = "OmaPhoto, layered image editing and compositing for Linux";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
# Remove Background's model, pinned by its checksum.
model = pkgs.fetchurl {
url = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx";
sha256 = "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491";
};
in {
packages.x86_64-linux.default = pkgs.stdenv.mkDerivation {
pname = "omaphoto";
version = "1.0.0";
src = self;
nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ];
buildInputs = with pkgs; [ qt6.qtbase qt6.qtimageformats libheif libde265 fontconfig onnxruntime ];
cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ];
ninjaFlags = [ "omaphoto" ];
# Staged: profiles build MIME caches outside the store.
installPhase = ''
runHook preInstall
DESTDIR=/ cmake --install . --prefix $out
runHook postInstall
'';
};
# The tests' shell: plugins, fonts and the model found at run time.
devShells.x86_64-linux.default = pkgs.mkShell {
inputsFrom = [ self.packages.x86_64-linux.default ];
QT_PLUGIN_PATH = "${pkgs.qt6.qtimageformats}/lib/qt-6/plugins:${pkgs.qt6.qtbase}/lib/qt-6/plugins";
FONTCONFIG_FILE = pkgs.makeFontsConf {
fontDirectories = [ pkgs.dejavu_fonts ];
# fontconfig's own rules, which NixOS keeps in /etc.
includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ];
};
XDG_DATA_DIRS = "${pkgs.runCommand "u2net" { } "mkdir -p $out/share/omaphoto && ln -s ${model} $out/share/omaphoto/u2net.onnx"}/share";
};
};
}
+96
View File
@@ -0,0 +1,96 @@
#include "AdjustPixels.h"
#include <math.h>
void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stride, const uint8_t *table) {
for (size_t y = 0; y < height; y++) {
uint8_t *p = rgba + y * stride;
for (size_t x = 0; x < width; x++, p += 4) {
unsigned a = p[3];
if (a == 0) continue;
unsigned r = p[0], g = p[1], b = p[2];
if (a < 255) {
r = (r * 255u + a / 2) / a;
g = (g * 255u + a / 2) / a;
b = (b * 255u + a / 2) / a;
if (r > 255) r = 255;
if (g > 255) g = 255;
if (b > 255) b = 255;
}
unsigned level = (2126u * r + 7152u * g + 722u * b + 5000u) / 10000u;
const uint8_t *color = table + (level > 255 ? 255 : level) * 3;
p[0] = (uint8_t)((color[0] * a + 127u) / 255u);
p[1] = (uint8_t)((color[1] * a + 127u) / 255u);
p[2] = (uint8_t)((color[2] * a + 127u) / 255u);
}
}
}
static inline uint32_t mix32(uint32_t x) {
x ^= x >> 16;
x *= 0x7feb352dU;
x ^= x >> 15;
x *= 0x846ca68bU;
x ^= x >> 16;
return x;
}
// A value in −1…1 for an integer lattice point, fixed by the point and the seed. Two uniform halves
// summed give a triangular spread, closer to film grain than flat noise.
static inline float lattice(int64_t ix, int64_t iy, uint32_t seed) {
uint32_t h = mix32((uint32_t)ix * 0x9E3779B1U ^ mix32((uint32_t)iy * 0x85EBCA77U ^ seed));
return (float)(h & 0xFFFFU) / 65535.0f + (float)(h >> 16) / 65535.0f - 1.0f;
}
static inline float clamp255(float value) { return value < 0 ? 0 : value > 255 ? 255 : value; }
void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, double amount, double size,
double roughness, uint32_t seed, double originX, double originY, double unitsPerPixel) {
if (!(amount > 0) || !(unitsPerPixel > 0)) return;
if (!(size > 0)) size = 1;
float strength = (float)(amount > 100 ? 1.0 : amount / 100.0) * 0.35f * 255.0f;
float rough = (float)(roughness < 0 ? 0.0 : roughness > 100 ? 1.0 : roughness / 100.0);
uint32_t fineSeed = mix32(seed ^ 0xA511E9B3U);
for (size_t y = 0; y < height; y++) {
double v = originY + ((double)y + 0.5) * unitsPerPixel;
double cellY = floor(v / size);
float ty = (float)(v / size - cellY);
ty = ty * ty * (3.0f - 2.0f * ty);
int64_t iy = (int64_t)cellY, fineY = (int64_t)floor(v);
uint8_t *p = rgba + y * stride;
for (size_t x = 0; x < width; x++, p += 4) {
unsigned a = p[3];
if (a == 0) continue;
double u = originX + ((double)x + 0.5) * unitsPerPixel;
double cellX = floor(u / size);
float tx = (float)(u / size - cellX);
tx = tx * tx * (3.0f - 2.0f * tx);
int64_t ix = (int64_t)cellX;
float n00 = lattice(ix, iy, seed), n10 = lattice(ix + 1, iy, seed);
float n01 = lattice(ix, iy + 1, seed), n11 = lattice(ix + 1, iy + 1, seed);
float top = n00 + (n10 - n00) * tx, bottom = n01 + (n11 - n01) * tx;
// Blending neighbors narrows the spread; scaling restores about the lattice's own.
float smooth = (top + (bottom - top) * ty) * 1.6f;
float fine = lattice((int64_t)floor(u), fineY, fineSeed);
float noise = smooth + (fine - smooth) * rough;
float unpremultiply = a == 255 ? 1.0f : 255.0f / (float)a;
float r = p[0] * unpremultiply, g = p[1] * unpremultiply, b = p[2] * unpremultiply;
float level = (0.2126f * r + 0.7152f * g + 0.0722f * b) / 255.0f;
if (level > 1) level = 1;
// Film grain shows most in the midtones.
float delta = noise * strength * (0.4f + 2.4f * level * (1.0f - level));
float coverage = (float)a / 255.0f;
p[0] = (uint8_t)(clamp255(r + delta) * coverage + 0.5f);
p[1] = (uint8_t)(clamp255(g + delta) * coverage + 0.5f);
p[2] = (uint8_t)(clamp255(b + delta) * coverage + 0.5f);
}
}
}
void rgba_clamp_premultiplied(uint8_t *rgba, size_t count) {
for (size_t i = 0; i < count; i++, rgba += 4) {
uint8_t a = rgba[3];
if (rgba[0] > a) rgba[0] = a;
if (rgba[1] > a) rgba[1] = a;
if (rgba[2] > a) rgba[2] = a;
}
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef AdjustPixels_h
#define AdjustPixels_h
#include <stdint.h>
#include <stddef.h>
// Gradient Map on premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row): each pixel's
// luminance picks a color from `table` (256 × 3 straight sRGB bytes, darkest first). Alpha is kept and
// fully transparent pixels are left alone.
void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stride, const uint8_t *table);
// Film grain on premultiplied RGBA pixels: the same brightness change on all three channels, strongest
// in the midtones. `amount` is 0–100, `size` the grain's scale in document units, and `roughness`
// (0–100) mixes in per-unit noise. Pixel (x, y) sits at (originX + (x + 0.5) × unitsPerPixel,
// originY + (y + 0.5) × unitsPerPixel), and its grain depends only on that position and `seed`, so a
// piece of an image gets the same grain as that part of the whole.
void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, double amount, double size,
double roughness, uint32_t seed, double originX, double originY, double unitsPerPixel);
// After resampling with a filter that rings (Lanczos), premultiplied RGBA colors can exceed their alpha;
// this clamps each channel back to its pixel's alpha. `count` is the number of pixels.
void rgba_clamp_premultiplied(uint8_t *rgba, size_t count);
#endif
+50
View File
@@ -0,0 +1,50 @@
#include "BrushPixels.h"
void brush_alpha_bounds(const uint8_t *bytes, size_t width, size_t height, size_t stride, size_t bounds[4]) {
size_t left = width, right = 0, top = height, bottom = 0;
for (size_t y = 0; y < height; ++y) {
const uint8_t *row = bytes + y * stride;
size_t first = 0;
while (first < width && row[first * 4 + 3] == 0) ++first;
if (first == width) continue;
size_t last = width;
while (last > first && row[(last - 1) * 4 + 3] == 0) --last;
if (first < left) left = first;
if (last > right) right = last;
if (y < top) top = y;
bottom = y + 1;
}
bounds[0] = right ? left : 0;
bounds[1] = right ? top : 0;
bounds[2] = right;
bounds[3] = bottom;
}
void layer_unpremultiply_opaque(uint8_t *rgba, size_t stride, size_t width, size_t height) {
for (size_t y = 0; y < height; ++y) {
uint8_t *p = rgba + y * stride;
for (size_t x = 0; x < width; ++x, p += 4) {
unsigned a = p[3];
for (int c = 0; c < 3; ++c) {
unsigned v = a ? (p[c] * 255u + a / 2) / a : 0;
p[c] = v > 255 ? 255 : v;
}
p[3] = 255;
}
}
}
void layer_restore_alpha(uint8_t *rgba, size_t stride, const uint8_t *alpha, size_t alphaStride, size_t width, size_t height) {
for (size_t y = 0; y < height; ++y) {
uint8_t *p = rgba + y * stride;
for (size_t x = 0; x < width; ++x, p += 4) {
unsigned a = alpha[y * alphaStride + x];
for (int c = 0; c < 3; ++c) p[c] = (p[c] * a + 127) / 255;
p[3] = a;
}
}
}
void layer_extract_alpha(const uint8_t *rgba, size_t rgbaStride, uint8_t *gray, size_t grayStride, size_t width, size_t height) {
for (size_t y = 0; y < height; ++y)
for (size_t x = 0; x < width; ++x)
gray[y * grayStride + x] = rgba[y * rgbaStride + x * 4 + 3];
}
+10
View File
@@ -0,0 +1,10 @@
#ifndef BrushPixels_h
#define BrushPixels_h
#include <stdint.h>
#include <stddef.h>
// Half-open bounds of nonzero alpha in premultiplied RGBA. Empty returns all zero.
void brush_alpha_bounds(const uint8_t *bytes, size_t width, size_t height, size_t stride, size_t bounds[4]);
void layer_extract_alpha(const uint8_t *rgba, size_t rgbaStride, uint8_t *gray, size_t grayStride, size_t width, size_t height);
void layer_unpremultiply_opaque(uint8_t *rgba, size_t stride, size_t width, size_t height);
void layer_restore_alpha(uint8_t *rgba, size_t stride, const uint8_t *alpha, size_t alphaStride, size_t width, size_t height);
#endif
+93
View File
@@ -0,0 +1,93 @@
#include "ContentFill.h"
#include <stdlib.h>
#include <string.h>
#include <float.h>
static uint32_t next_random(uint32_t *state) {
*state = *state * 1664525u + 1013904223u;
return *state;
}
static double match(const uint8_t *pixels, size_t stride, const uint8_t *known,
int w, int h, int p, int q, int radius) {
int px=p%w, py=p/w, qx=q%w, qy=q/w, count=0;
double sum=0;
for(int dy=-radius; dy<=radius; ++dy) for(int dx=-radius; dx<=radius; ++dx) {
int x=px+dx,y=py+dy,sx=qx+dx,sy=qy+dy;
if(x<0||y<0||x>=w||y>=h||sx<0||sy<0||sx>=w||sy>=h||!known[y*w+x]) continue;
const uint8_t *a=pixels+y*stride+x*4, *b=pixels+sy*stride+sx*4;
for(int c=0;c<4;++c) { int d=a[c]-b[c]; sum+=d*d; }
++count;
}
return count ? sum/count : DBL_MAX;
}
int content_fill(uint8_t *pixels, size_t stride, const uint8_t *mask, size_t ms, int w, int h) {
size_t n=(size_t)w*h;
uint8_t *known=calloc(n,1), *target=calloc(n,1), *valid=calloc(n,1), *queued=calloc(n,1);
int *donors=malloc(n*sizeof(int)), *queue=malloc(n*sizeof(int)), *chosen=malloc(n*sizeof(int));
if(!known||!target||!valid||!queued||!donors||!queue||!chosen) {
free(known);free(target);free(valid);free(queued);free(donors);free(queue);free(chosen);return -1;
}
int radius=(w>=5 && h>=5)?2:0;
size_t missing=0, donorCount=0, head=0,tail=0, scan=0;
// Selected pixels are filled. Unselected opaque pixels are the image to match and copy from; unselected
// transparent ones are neither — nothing to match against, and left as they are.
for(int y=0;y<h;++y) for(int x=0;x<w;++x) {
int p=y*w+x; target[p]=mask[y*ms+x]!=0; known[p]=!target[p] && pixels[y*stride+x*4+3]==255; chosen[p]=-1;
if(target[p]) ++missing;
}
if(!missing) { donorCount=1; goto done; }
for(int y=0;y<h;++y) for(int x=0;x<w;++x) {
int p=y*w+x;
if(!known[p]) continue;
int ok=1;
for(int dy=-radius;dy<=radius&&ok;++dy) for(int dx=-radius;dx<=radius;++dx) {
int sx=x+dx,sy=y+dy;
if(sx<0||sy<0||sx>=w||sy>=h||!known[sy*w+sx]) {ok=0;break;}
}
if(ok) { valid[p]=1;donors[donorCount++]=p; }
}
if(!donorCount) goto done;
for(int y=0;y<h;++y) for(int x=0;x<w;++x) {
int p=y*w+x;
if(target[p] && ((x&&known[p-1])||(x+1<w&&known[p+1])||(y&&known[p-w])||(y+1<h&&known[p+w]))) {
queue[tail++]=p;queued[p]=1;
}
}
uint32_t seed=0x6d2b79f5;
for(;;) {
while(head<tail) {
int p=queue[head++],x=p%w,y=p/w,best=-1;
double score=DBL_MAX;
int neighbors[4]={x?p-1:-1,x+1<w?p+1:-1,y?p-w:-1,y+1<h?p+w:-1};
// Propagate coherent source offsets, then refine with randomized patch search.
for(int k=0;k<28;++k) {
int q=-1;
if(k<4) {
int t=neighbors[k];
if(t>=0) q=(chosen[t]>=0?chosen[t]:t)+(p-t);
} else q=donors[next_random(&seed)%donorCount];
if(q<0||(size_t)q>=n||!valid[q]) continue;
double s=match(pixels,stride,known,w,h,p,q,radius);
if(best<0||s<score) {score=s;best=q;}
}
if(best<0) best=donors[0];
for(int r=64;r>=1;r/=2) {
int qx=best%w+(int)(next_random(&seed)%(2*r+1))-r;
int qy=best/w+(int)(next_random(&seed)%(2*r+1))-r;
if(qx<0||qy<0||qx>=w||qy>=h||!valid[qy*w+qx]) continue;
int q=qy*w+qx; double s=match(pixels,stride,known,w,h,p,q,radius);
if(s<score) {score=s;best=q;}
}
memcpy(pixels+y*stride+x*4,pixels+(best/w)*stride+(best%w)*4,4);
known[p]=1;chosen[p]=best;
for(int k=0;k<4;++k) { int q=neighbors[k]; if(q>=0&&target[q]&&!known[q]&&!queued[q]) {queued[q]=1;queue[tail++]=q;} }
}
// A selected area that only transparency touches starts from the best random donor, then spreads.
while(scan<n && (!target[scan]||known[scan])) ++scan;
if(scan>=n) break;
queue[tail++]=(int)scan; queued[scan]=1;
}
done:
free(known);free(target);free(valid);free(queued);free(donors);free(queue);free(chosen);
return donorCount ? 1 : 0;
}
+4
View File
@@ -0,0 +1,4 @@
#include <stddef.h>
#include <stdint.h>
// Returns 1 on success, 0 when no source patch exists, -1 on allocation failure.
int content_fill(uint8_t *rgba, size_t stride, const uint8_t *mask, size_t maskStride, int width, int height);
+259
View File
@@ -0,0 +1,259 @@
#include "HealPixels.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
enum { OUTSIDE = 0, RING = 1, HOLE = 2 };
void heal_coverage_bounds(const uint8_t *gray, size_t width, size_t height, size_t stride, long bounds[4]) {
long x0 = (long)width, y0 = (long)height, x1 = 0, y1 = 0;
for (size_t y = 0; y < height; ++y) {
const uint8_t *row = gray + y * stride;
for (size_t x = 0; x < width; ++x) {
if (!row[x]) continue;
if ((long)x < x0) x0 = (long)x;
if ((long)x + 1 > x1) x1 = (long)x + 1;
if ((long)y < y0) y0 = (long)y;
if ((long)y + 1 > y1) y1 = (long)y + 1;
}
}
if (x1 <= x0 || y1 <= y0) x0 = y0 = x1 = y1 = 0;
bounds[0] = x0; bounds[1] = y0; bounds[2] = x1; bounds[3] = y1;
}
static inline uint32_t heal_hash(uint32_t x) {
x ^= x >> 16; x *= 0x7feb352dU;
x ^= x >> 15; x *= 0x846ca68bU;
x ^= x >> 16;
return x;
}
static inline double heal_unit(uint32_t key) { return (double)(heal_hash(key) >> 8) / 16777216.0; }
// Mean squared difference between the ring around the spot and the ring around the patch
// offset by (dx, dy). Infinite when the patch would overlap the spot or leave the image.
static double heal_score(const uint8_t *rgba, size_t stride, const uint8_t *role, long wx0, long wy0,
long ww, long wh, long dx, long dy, long W, long H) {
if (labs(dx) < ww && labs(dy) < wh) return INFINITY;
if (wx0 + dx < 0 || wy0 + dy < 0 || wx0 + ww + dx > W || wy0 + wh + dy > H) return INFINITY;
double sum = 0;
long n = 0;
for (long y = 0; y < wh; ++y) {
for (long x = 0; x < ww; ++x) {
if (role[y * ww + x] != RING) continue;
const uint8_t *t = rgba + (size_t)(wy0 + y) * stride + (size_t)(wx0 + x) * 4;
const uint8_t *s = rgba + (size_t)(wy0 + y + dy) * stride + (size_t)(wx0 + x + dx) * 4;
for (int c = 0; c < 4; ++c) { double d = (double)t[c] - s[c]; sum += d * d; }
++n;
}
}
return n ? sum / n : INFINITY;
}
// Solves for smooth values over HOLE pixels, fixed to the RING values around them. A coarser
// copy is solved first and used as the starting point, so large spots settle in few passes.
static void heal_solve(float *value, const uint8_t *role, long w, long h, int depth) {
int iterations = 300;
if (w > 32 && h > 32 && depth < 16) {
long cw = (w + 1) / 2, ch = (h + 1) / 2;
float *coarse = calloc((size_t)(cw * ch * 4), sizeof(float));
uint8_t *coarseRole = calloc((size_t)(cw * ch), 1);
if (coarse && coarseRole) {
for (long y = 0; y < ch; ++y) {
for (long x = 0; x < cw; ++x) {
int known = 0, hole = 0;
float knownSum[4] = {0, 0, 0, 0}, holeSum[4] = {0, 0, 0, 0};
for (long j = 0; j < 2; ++j) {
for (long i = 0; i < 2; ++i) {
long fx = x * 2 + i, fy = y * 2 + j;
if (fx >= w || fy >= h) continue;
long p = fy * w + fx;
if (role[p] == RING) { ++known; for (int c = 0; c < 4; ++c) knownSum[c] += value[p * 4 + c]; }
else if (role[p] == HOLE) { ++hole; for (int c = 0; c < 4; ++c) holeSum[c] += value[p * 4 + c]; }
}
}
long q = y * cw + x;
if (known) { coarseRole[q] = RING; for (int c = 0; c < 4; ++c) coarse[q * 4 + c] = knownSum[c] / known; }
else if (hole) { coarseRole[q] = HOLE; for (int c = 0; c < 4; ++c) coarse[q * 4 + c] = holeSum[c] / hole; }
}
}
heal_solve(coarse, coarseRole, cw, ch, depth + 1);
for (long y = 0; y < h; ++y) {
for (long x = 0; x < w; ++x) {
long p = y * w + x, q = (y / 2) * cw + x / 2;
if (role[p] == HOLE && coarseRole[q] == HOLE) memcpy(value + p * 4, coarse + q * 4, 4 * sizeof(float));
}
}
iterations = 40;
}
free(coarse);
free(coarseRole);
}
const float omega = 1.8f;
for (int it = 0; it < iterations; ++it) {
for (long y = 0; y < h; ++y) {
for (long x = 0; x < w; ++x) {
long p = y * w + x;
if (role[p] != HOLE) continue;
float sum[4] = {0, 0, 0, 0};
int n = 0;
long neighbors[4][2] = {{x - 1, y}, {x + 1, y}, {x, y - 1}, {x, y + 1}};
for (int k = 0; k < 4; ++k) {
long nx = neighbors[k][0], ny = neighbors[k][1];
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
long q = ny * w + nx;
if (role[q] == OUTSIDE) continue;
for (int c = 0; c < 4; ++c) sum[c] += value[q * 4 + c];
++n;
}
if (!n) continue;
for (int c = 0; c < 4; ++c) value[p * 4 + c] += omega * (sum[c] / n - value[p * 4 + c]);
}
}
}
}
int spot_heal(uint8_t *rgba, const uint8_t *coverage, size_t width, size_t height, size_t stride,
float opacity, int mode, uint32_t seed) {
long W = (long)width, H = (long)height;
long bounds[4];
heal_coverage_bounds(coverage, width, height, width, bounds);
if (bounds[2] <= bounds[0]) return 0;
long bw = bounds[2] - bounds[0], bh = bounds[3] - bounds[1], size = bw > bh ? bw : bh;
long ring = size / 8;
if (ring < 2) ring = 2;
if (ring > 16) ring = 16;
// Work box: the spot plus its ring, clipped to the image.
long wx0 = bounds[0] - ring < 0 ? 0 : bounds[0] - ring, wy0 = bounds[1] - ring < 0 ? 0 : bounds[1] - ring;
long wx1 = bounds[2] + ring > W ? W : bounds[2] + ring, wy1 = bounds[3] + ring > H ? H : bounds[3] + ring;
long ww = wx1 - wx0, wh = wy1 - wy0, wn = ww * wh;
uint8_t *role = calloc((size_t)wn, 1), *near = calloc((size_t)wn, 1);
long *prefix = malloc((size_t)((ww > wh ? ww : wh) + 1) * sizeof(long));
float *value = malloc((size_t)wn * 4 * sizeof(float));
int status = -1;
if (!role || !near || !prefix || !value) goto done;
for (long y = 0; y < wh; ++y)
for (long x = 0; x < ww; ++x)
role[y * ww + x] = coverage[(size_t)(wy0 + y) * width + (size_t)(wx0 + x)] ? HOLE : OUTSIDE;
// The ring: pixels within `ring` of the spot (a square dilation, row pass then column pass).
for (long y = 0; y < wh; ++y) {
prefix[0] = 0;
for (long x = 0; x < ww; ++x) prefix[x + 1] = prefix[x] + (role[y * ww + x] == HOLE);
for (long x = 0; x < ww; ++x) {
long lo = x - ring < 0 ? 0 : x - ring, hi = x + ring + 1 > ww ? ww : x + ring + 1;
near[y * ww + x] = prefix[hi] - prefix[lo] > 0;
}
}
for (long x = 0; x < ww; ++x) {
prefix[0] = 0;
for (long y = 0; y < wh; ++y) prefix[y + 1] = prefix[y] + near[y * ww + x];
for (long y = 0; y < wh; ++y) {
long lo = y - ring < 0 ? 0 : y - ring, hi = y + ring + 1 > wh ? wh : y + ring + 1;
if (role[y * ww + x] == OUTSIDE && prefix[hi] - prefix[lo] > 0) role[y * ww + x] = RING;
}
}
long ringCount = 0;
for (long p = 0; p < wn; ++p) ringCount += role[p] == RING;
if (!ringCount) { status = 0; goto done; }
// Source patch for Content-Aware and Proximity Match.
long ox = 0, oy = 0;
int haveSource = 0;
if (mode != 1) {
static const double factors[5] = {1.05, 1.35, 1.75, 2.25, 2.8};
int count = mode == 2 ? 2 : 5;
double best = INFINITY;
for (int f = 0; f < count; ++f) {
for (int a = 0; a < 24; ++a) {
double angle = a * M_PI / 12.0;
long dx = lround(cos(angle) * factors[f] * ww), dy = lround(sin(angle) * factors[f] * wh);
double score = heal_score(rgba, stride, role, wx0, wy0, ww, wh, dx, dy, W, H);
if (!isfinite(score)) continue;
score *= mode == 2 ? 1.0 + 0.6 * f : 1.0 + 0.1 * f; // nearer patches win ties
if (score < best) { best = score; ox = dx; oy = dy; }
}
}
if (isfinite(best)) {
// Fine-tune the alignment so repeating texture lines up.
long cx = ox, cy = oy;
double refined = heal_score(rgba, stride, role, wx0, wy0, ww, wh, cx, cy, W, H);
for (long j = -3; j <= 3; ++j) {
for (long i = -3; i <= 3; ++i) {
double score = heal_score(rgba, stride, role, wx0, wy0, ww, wh, cx + i, cy + j, W, H);
if (score < refined) { refined = score; ox = cx + i; oy = cy + j; }
}
}
haveSource = 1;
}
}
// Membrane: the edge difference between the original and the patch (or the original itself
// for a smooth fill), spread across the spot.
double mean[4] = {0, 0, 0, 0}, detail[3] = {0, 0, 0};
for (long y = 0; y < wh; ++y) {
for (long x = 0; x < ww; ++x) {
long p = y * ww + x;
if (role[p] != RING) { memset(value + p * 4, 0, 4 * sizeof(float)); continue; }
long ix = wx0 + x, iy = wy0 + y;
const uint8_t *t = rgba + (size_t)iy * stride + (size_t)ix * 4;
const uint8_t *s = haveSource ? rgba + (size_t)(iy + oy) * stride + (size_t)(ix + ox) * 4 : NULL;
for (int c = 0; c < 4; ++c) {
value[p * 4 + c] = (float)t[c] - (s ? s[c] : 0);
mean[c] += value[p * 4 + c];
}
if (!haveSource) {
// Fine detail around the spot: each pixel against the average of its neighbours.
for (int c = 0; c < 3; ++c) {
double around = 0; int n = 0;
long offsets[4][2] = {{ix - 1, iy}, {ix + 1, iy}, {ix, iy - 1}, {ix, iy + 1}};
for (int k = 0; k < 4; ++k) {
if (offsets[k][0] < 0 || offsets[k][1] < 0 || offsets[k][0] >= W || offsets[k][1] >= H) continue;
around += rgba[(size_t)offsets[k][1] * stride + (size_t)offsets[k][0] * 4 + c];
++n;
}
if (n) { double d = t[c] - around / n; detail[c] += d * d; }
}
}
}
}
for (int c = 0; c < 4; ++c) mean[c] /= ringCount;
for (long p = 0; p < wn; ++p)
if (role[p] == HOLE) for (int c = 0; c < 4; ++c) value[p * 4 + c] = (float)mean[c];
heal_solve(value, role, ww, wh, 0);
for (int c = 0; c < 3; ++c) detail[c] = sqrt(detail[c] / ringCount) * 0.9;
for (long y = 0; y < wh; ++y) {
for (long x = 0; x < ww; ++x) {
long p = y * ww + x;
if (role[p] != HOLE) continue;
long ix = wx0 + x, iy = wy0 + y;
uint8_t *t = rgba + (size_t)iy * stride + (size_t)ix * 4;
const uint8_t *s = haveSource ? rgba + (size_t)(iy + oy) * stride + (size_t)(ix + ox) * 4 : NULL;
double amount = coverage[(size_t)iy * width + (size_t)ix] / 255.0 * opacity;
double grain = 0;
if (!haveSource) {
uint32_t key = heal_hash(seed ^ heal_hash((uint32_t)(iy * W + ix)));
double u1 = heal_unit(key), u2 = heal_unit(key ^ 0x68e31da4U);
grain = sqrt(-2.0 * log(1.0 - u1)) * cos(2.0 * M_PI * u2);
}
double out[4];
for (int c = 0; c < 4; ++c) {
double healed = (s ? s[c] : 0) + value[p * 4 + c] + (c < 3 ? grain * detail[c] : 0);
out[c] = t[c] + (healed - t[c]) * amount;
}
double alpha = out[3] < 0 ? 0 : out[3] > 255 ? 255 : out[3];
t[3] = (uint8_t)lround(alpha);
for (int c = 0; c < 3; ++c) {
double v = out[c] < 0 ? 0 : out[c] > t[3] ? t[3] : out[c];
t[c] = (uint8_t)lround(v);
}
}
}
status = 0;
done:
free(role);
free(near);
free(prefix);
free(value);
return status;
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef HealPixels_h
#define HealPixels_h
#include <stdint.h>
#include <stddef.h>
// Half-open bounds of nonzero bytes in a gray bitmap; all zero when empty.
void heal_coverage_bounds(const uint8_t *gray, size_t width, size_t height, size_t stride, long bounds[4]);
// Spot healing, in place, over premultiplied RGBA (4 bytes per pixel, `stride` bytes per row).
// `coverage` (width * height bytes, 0–255) marks what to heal.
// mode 0, Content-Aware: copies texture from the nearby patch whose surrounding ring of pixels
// best matches the ring around the spot;
// mode 1, Create Texture: fills smoothly from the spot's edges and adds grain matching the
// detail around it;
// mode 2, Proximity Match: like 0, but takes the closest good patch.
// Copied texture is blended so it meets the surrounding tone exactly: the difference along the
// spot's edge is spread smoothly across it (a membrane fill). The result replaces the original
// by coverage × opacity. Returns 0, or -1 when memory runs out.
int spot_heal(uint8_t *rgba, const uint8_t *coverage, size_t width, size_t height, size_t stride,
float opacity, int mode, uint32_t seed);
#endif
+37
View File
@@ -0,0 +1,37 @@
#include "LensPixels.h"
#include <math.h>
void lens_distort(const uint8_t *source, uint8_t *destination, size_t width, size_t height, size_t stride, double k) {
double cx = width * 0.5, cy = height * 0.5;
double halfDiagonal2 = cx * cx + cy * cy;
for (size_t y = 0; y < height; ++y) {
double dy = y + 0.5 - cy;
uint8_t *out = destination + y * stride;
for (size_t x = 0; x < width; ++x) {
double dx = x + 0.5 - cx;
double scale = 1.0 - k * (dx * dx + dy * dy) / halfDiagonal2;
// Source position in pixel-center coordinates.
double sx = cx + dx * scale - 0.5, sy = cy + dy * scale - 0.5;
double fx0 = floor(sx), fy0 = floor(sy);
double fx = sx - fx0, fy = sy - fy0;
long x0 = (long)fx0, y0 = (long)fy0;
double sums[4] = {0, 0, 0, 0};
for (int j = 0; j < 2; ++j) {
long row = y0 + j;
if (row < 0 || row >= (long)height) continue;
double wy = j ? fy : 1 - fy;
if (wy == 0) continue;
const uint8_t *line = source + (size_t)row * stride;
for (int i = 0; i < 2; ++i) {
long column = x0 + i;
if (column < 0 || column >= (long)width) continue;
double weight = wy * (i ? fx : 1 - fx);
if (weight == 0) continue;
const uint8_t *p = line + (size_t)column * 4;
for (int c = 0; c < 4; ++c) sums[c] += weight * p[c];
}
}
for (int c = 0; c < 4; ++c) out[x * 4 + c] = (uint8_t)lround(sums[c]);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
#ifndef LensPixels_h
#define LensPixels_h
#include <stdint.h>
#include <stddef.h>
// Radial lens distortion over premultiplied RGBA (4 bytes per pixel, `stride` bytes per row, same
// layout for source and destination). Each destination pixel samples the source bilinearly at
// its offset from the image center scaled by (1 - k * r²), where r is that offset relative to the
// half-diagonal: k > 0 pulls samples inward (straightens barrel distortion, corners crop), k < 0
// pushes them outward (straightens pincushion distortion, corners turn transparent). Pixels
// outside the source are transparent. k = 0 copies the source exactly.
void lens_distort(const uint8_t *source, uint8_t *destination, size_t width, size_t height, size_t stride, double k);
#endif
+28
View File
@@ -0,0 +1,28 @@
#include "LevelsPixels.h"
#include <math.h>
void levels_apply(uint8_t *pixels, size_t count, const float *tables) {
for (size_t i=0; i<count; ++i) {
uint8_t *p = pixels + i*4;
float alpha = p[3];
if (!alpha) continue;
for (int channel=0; channel<3; ++channel) {
float x = fminf(255, p[channel]*255.0f/alpha);
int lo = (int)x, hi = lo < 255 ? lo+1 : 255;
const float *table = tables + channel*256;
float result = table[lo] + (table[hi]-table[lo])*(x-lo);
p[channel] = (uint8_t)fminf(alpha, fmaxf(0, roundf(result*alpha)));
}
}
}
void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins) {
for (size_t i=0; i<count; ++i) {
const uint8_t *p = pixels+i*4;
if (!p[3]) continue;
double weight = p[3]/255.0 * (coverage ? coverage[i]/255.0 : 1);
for (int channel=0; channel<3; ++channel) {
int value = (int)fmin(255, round(p[channel]*255.0/p[3]));
bins[(channel+1)*256+value] += weight;
bins[value] += weight/3.0;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
#include <stdint.h>
#include <stddef.h>
void levels_apply(uint8_t *pixels, size_t count, const float *tables);
void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins);
+41
View File
@@ -0,0 +1,41 @@
#include "NoisePixels.h"
#include <math.h>
// A well-mixed 32-bit hash, so neighbouring pixels get unrelated values.
static inline uint32_t noise_hash(uint32_t x) {
x ^= x >> 16; x *= 0x7feb352dU;
x ^= x >> 15; x *= 0x846ca68bU;
x ^= x >> 16;
return x;
}
// Uniform in [0, 1).
static inline float noise_unit(uint32_t key) { return (float)(noise_hash(key) >> 8) * (1.0f / 16777216.0f); }
void noise_add(uint8_t *rgba, size_t width, size_t height, size_t stride,
float amount, int gaussian, int monochromatic, uint32_t seed) {
float spread = amount / 100.0f * 127.5f;
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
uint8_t *p = row + x * 4;
unsigned alpha = p[3];
if (!alpha) continue;
uint32_t base = noise_hash(seed ^ noise_hash((uint32_t)(y * width + x)));
for (int c = 0; c < 3; ++c) {
uint32_t key = monochromatic ? base : base + (uint32_t)c * 0x9e3779b9U;
float n;
if (gaussian) {
// Box–Muller: two uniform values make one normally distributed one.
float u1 = noise_unit(key), u2 = noise_unit(key ^ 0x68e31da4U);
n = sqrtf(-2.0f * logf(1.0f - u1)) * cosf(6.2831853f * u2) * spread * (2.0f / 3.0f);
} else {
n = (noise_unit(key) * 2.0f - 1.0f) * spread;
}
float value = (float)p[c] * 255.0f / (float)alpha + n;
value = value < 0 ? 0 : value > 255 ? 255 : value;
p[c] = (uint8_t)lroundf(value * (float)alpha / 255.0f);
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
#ifndef NoisePixels_h
#define NoisePixels_h
#include <stdint.h>
#include <stddef.h>
// Adds noise to the color of premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per
// row), leaving alpha untouched and fully transparent pixels alone. `amount` is Photoshop's
// percentage: uniform noise spans ±amount% of half the range, Gaussian noise has a standard
// deviation of two thirds of that. Monochromatic adds the same value to all three channels.
// Each pixel's noise depends only on its position and `seed`, so the same seed gives the same grain.
void noise_add(uint8_t *rgba, size_t width, size_t height, size_t stride,
float amount, int gaussian, int monochromatic, uint32_t seed);
#endif
+174
View File
@@ -0,0 +1,174 @@
#include "WandPixels.h"
#include <stdlib.h>
#include <string.h>
enum { EAST = 1, SOUTH = 2, WEST = 4, NORTH = 8 };
// Outlines with more pixel edges than this are refused: the path would be too slow to draw.
static const size_t wand_edge_limit = 8000000;
static inline int wand_matches(const uint8_t *p, const int reference[4], int tolerance) {
for (int c = 0; c < 4; ++c) {
int d = (int)p[c] - reference[c];
if (d < -tolerance || d > tolerance) return 0;
}
return 1;
}
long wand_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
size_t seedX, size_t seedY, size_t radius, int tolerance, int contiguous, uint8_t *mask) {
if (!width || !height) return 0;
memset(mask, 0, width * height);
if (seedX >= width || seedY >= height) return 0;
size_t x0 = seedX > radius ? seedX - radius : 0, x1 = seedX + radius < width ? seedX + radius : width - 1;
size_t y0 = seedY > radius ? seedY - radius : 0, y1 = seedY + radius < height ? seedY + radius : height - 1;
unsigned long sums[4] = {0, 0, 0, 0}, samples = 0;
for (size_t y = y0; y <= y1; ++y)
for (size_t x = x0; x <= x1; ++x, ++samples)
for (int c = 0; c < 4; ++c) sums[c] += rgba[y * stride + x * 4 + c];
int reference[4];
for (int c = 0; c < 4; ++c) reference[c] = (int)((sums[c] + samples / 2) / samples);
long count = 0;
if (!contiguous) {
for (size_t y = 0; y < height; ++y) {
const uint8_t *row = rgba + y * stride;
uint8_t *out = mask + y * width;
for (size_t x = 0; x < width; ++x)
if (wand_matches(row + x * 4, reference, tolerance)) { out[x] = 255; ++count; }
}
return count;
}
// Scanline flood fill: each popped seed fills its whole horizontal run, then pushes one
// seed per matching run in the rows directly above and below it.
size_t capacity = 4096, top = 1;
size_t *stack = malloc(capacity * 2 * sizeof(size_t));
if (!stack) return -1;
stack[0] = seedX;
stack[1] = seedY;
while (top) {
--top;
size_t x = stack[top * 2], y = stack[top * 2 + 1];
const uint8_t *row = rgba + y * stride;
uint8_t *out = mask + y * width;
if (out[x] || !wand_matches(row + x * 4, reference, tolerance)) continue;
size_t left = x, right = x;
while (left > 0 && !out[left - 1] && wand_matches(row + (left - 1) * 4, reference, tolerance)) --left;
while (right + 1 < width && !out[right + 1] && wand_matches(row + (right + 1) * 4, reference, tolerance)) ++right;
memset(out + left, 255, right - left + 1);
count += (long)(right - left + 1);
for (int side = 0; side < 2; ++side) {
if (side == 0 ? y == 0 : y + 1 >= height) continue;
size_t ny = side == 0 ? y - 1 : y + 1;
const uint8_t *nrow = rgba + ny * stride;
const uint8_t *nout = mask + ny * width;
int inRun = 0;
for (size_t nx = left; nx <= right; ++nx) {
int candidate = !nout[nx] && wand_matches(nrow + nx * 4, reference, tolerance);
if (candidate && !inRun) {
if (top == capacity) {
size_t *grown = realloc(stack, capacity * 4 * sizeof(size_t));
if (!grown) { free(stack); return -1; }
stack = grown;
capacity *= 2;
}
stack[top * 2] = nx;
stack[top * 2 + 1] = ny;
++top;
}
inRun = candidate;
}
}
}
free(stack);
return count;
}
// Headings, clockwise on screen (y grows downward): east, south, west, north.
static inline int turn_right(int d) { return d == NORTH ? EAST : d << 1; }
static inline int turn_left(int d) { return d == EAST ? NORTH : d >> 1; }
int wand_trace(const uint8_t *mask, size_t width, size_t height,
int32_t **points, size_t *pointCount, int32_t **loops, size_t *loopCount) {
*points = NULL;
*loops = NULL;
*pointCount = 0;
*loopCount = 0;
if (!width || !height) return 0;
if (width >= INT32_MAX || height >= INT32_MAX) return -1;
// Each vertex of the (width + 1) × (height + 1) grid records the directed boundary edges
// leaving it: a selected pixel's unselected sides, walked clockwise around the pixel.
size_t stride = width + 1, vertices = stride * (height + 1), edges = 0;
uint8_t *out = calloc(vertices, 1);
if (!out) return -1;
for (size_t y = 0; y < height; ++y) {
const uint8_t *row = mask + y * width;
for (size_t x = 0; x < width; ++x) {
if (!row[x]) continue;
if (y == 0 || !mask[(y - 1) * width + x]) { out[y * stride + x] |= EAST; ++edges; }
if (x + 1 == width || !row[x + 1]) { out[y * stride + x + 1] |= SOUTH; ++edges; }
if (y + 1 == height || !mask[(y + 1) * width + x]) { out[(y + 1) * stride + x + 1] |= WEST; ++edges; }
if (x == 0 || !row[x - 1]) { out[(y + 1) * stride + x] |= NORTH; ++edges; }
}
if (edges > wand_edge_limit) { free(out); return -2; }
}
size_t pointCapacity = 1024, loopCapacity = 256, np = 0, nl = 0;
int32_t *pts = malloc(pointCapacity * 2 * sizeof(int32_t));
int32_t *lens = malloc(loopCapacity * sizeof(int32_t));
if (!pts || !lens) goto fail;
for (size_t start = 0; start < vertices; ++start) {
while (out[start]) {
size_t first = np, v = start;
int heading = 0, initial = 0;
do {
int bits = out[v], d;
// Where two loops meet at a corner, turning right keeps them apart.
if (!heading) d = bits & -bits;
else if (bits & turn_right(heading)) d = turn_right(heading);
else if (bits & heading) d = heading;
else if (bits & turn_left(heading)) d = turn_left(heading);
else d = bits & -bits;
if (!d) break;
out[v] &= (uint8_t)~d;
if (d != heading) {
if (np == pointCapacity) {
int32_t *grown = realloc(pts, pointCapacity * 4 * sizeof(int32_t));
if (!grown) goto fail;
pts = grown;
pointCapacity *= 2;
}
pts[np * 2] = (int32_t)(v % stride);
pts[np * 2 + 1] = (int32_t)(v / stride);
++np;
}
if (!heading) initial = d;
heading = d;
v = d == EAST ? v + 1 : d == WEST ? v - 1 : d == SOUTH ? v + stride : v - stride;
} while (v != start);
// The start is a corner unless the loop arrives on the heading it left with.
if (heading == initial && np > first) {
memmove(pts + first * 2, pts + (first + 1) * 2, (np - first - 1) * 2 * sizeof(int32_t));
--np;
}
if (nl == loopCapacity) {
int32_t *grown = realloc(lens, loopCapacity * 2 * sizeof(int32_t));
if (!grown) goto fail;
lens = grown;
loopCapacity *= 2;
}
lens[nl++] = (int32_t)(np - first);
}
}
free(out);
*points = pts;
*loops = lens;
*pointCount = np;
*loopCount = nl;
return 0;
fail:
free(out);
free(pts);
free(lens);
return -1;
}
+21
View File
@@ -0,0 +1,21 @@
#ifndef WandPixels_h
#define WandPixels_h
#include <stdint.h>
#include <stddef.h>
// Magic Wand match over premultiplied RGBA (4 bytes per pixel, `stride` bytes per row, rows
// top-down). The reference color is the average over a (2 * radius + 1)² square around the
// seed, clipped to the image. A pixel matches when every channel, alpha included, is within
// `tolerance` of it. Contiguous fills 4-connected from the seed (nothing when the seed itself
// doesn't match); otherwise every matching pixel. Writes 255 for selected, 0 elsewhere, into
// `mask` (width * height bytes). Returns the number selected, or -1 when memory runs out.
long wand_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
size_t seedX, size_t seedY, size_t radius, int tolerance, int contiguous, uint8_t *mask);
// Outline of the nonzero pixels of `mask`, along pixel edges, as closed loops of corner points
// (x, y pairs in pixel-edge coordinates). Outer boundaries run clockwise and holes
// counterclockwise in top-left coordinates, so the winding rule fills exactly those pixels.
// `points` receives 2 * pointCount values and `loops` each loop's corner count; both are
// malloc'd and must be freed. Returns 0 on success, -1 when memory runs out, and -2 when the
// outline is too detailed to be worth drawing.
int wand_trace(const uint8_t *mask, size_t width, size_t height,
int32_t **points, size_t *pointCount, int32_t **loops, size_t *loopCount);
#endif
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+22
View File
@@ -0,0 +1,22 @@
# Built from this checkout: run makepkg in linux/packaging/arch.
pkgname=omaphoto
pkgver=1.0.0
pkgrel=1
pkgdesc='Layered image editing for Linux, a port of Compositor'
arch=('x86_64')
url='https://github.com/ZacharyZhang-NY/OmaPhoto'
license=('MIT')
depends=('qt6-base' 'qt6-imageformats' 'libheif' 'libde265' 'fontconfig' 'onnxruntime' 'hicolor-icon-theme' 'shared-mime-info')
makedepends=('cmake' 'ninja')
source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx')
sha256sums=('8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491')
noextract=('u2net.onnx')
build() {
cmake -S "$startdir/../.." -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DOMAPHOTO_MODEL="$srcdir/u2net.onnx"
cmake --build build --target omaphoto
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=OmaPhoto
GenericName=Image Editor
Comment=Edit and composite layered images
Exec=omaphoto %F
Icon=io.github.ZacharyZhang_NY.OmaPhoto
Terminal=false
Categories=Graphics;2DGraphics;RasterGraphics;
MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;
StartupWMClass=OmaPhoto
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-compositor-project">
<comment>OmaPhoto Project</comment>
<sub-class-of type="inode/directory"/>
<glob pattern="*.comp"/>
</mime-type>
</mime-info>
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
repo=$(cd "$(dirname "$0")/.." && pwd)
image=omaphoto-dev
runtime=/run/user/$(id -u)
docker build -q -t "$image" -f "$repo/Dockerfile.dev" "$repo" >/dev/null
in_container() {
exec docker run --rm --init -u "$(id -u):$(id -g)" -e HOME=/tmp --tmpfs /tmp:rw,exec,size=4g \
--tmpfs "$runtime:uid=$(id -u),gid=$(id -g),mode=0700" -e XDG_RUNTIME_DIR="$runtime" \
-v "$repo:$repo" -w "$repo" "$@"
}
configure_and_build='cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON && cmake --build build'
case "${1:-}" in
build)
in_container "$image" sh -c "$configure_and_build"
;;
test)
in_container "$image" sh -c "$configure_and_build && ctest --test-dir build --output-on-failure --timeout 120"
;;
run)
socket=$WAYLAND_DISPLAY
[[ $socket = /* ]] || socket=$XDG_RUNTIME_DIR/$socket
socket=$(readlink -f "$socket")
# The desktop's theme, where Omarchy keeps it, read-only.
theme=$HOME/.local/state/omarchy
mounts=()
[[ -d $theme ]] && mounts=(-v "$theme:/tmp/.local/state/omarchy:ro")
in_container "${mounts[@]}" -v "$socket:$runtime/wayland-0" -e WAYLAND_DISPLAY=wayland-0 \
-e QT_QPA_PLATFORM=wayland "$image" build/omaphoto "${@:2}"
;;
*)
echo "usage: dev.sh build|test|run [args]" >&2
exit 2
;;
esac
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Builds and tests the app in one distribution's own container.
set -euo pipefail
distro=${1:?"usage: distro-check.sh arch|fedora|nixos"}
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/.." && pwd)
docker build -t "omaphoto-$distro" "$here/distro/$distro"
steps="cmake -S . -B /tmp/build -G Ninja -DOMAPHOTO_WERROR=ON && cmake --build /tmp/build && ctest --test-dir /tmp/build --output-on-failure --timeout 120"
# Not root, as dev.sh: tests refuse what root may do.
user=1000:1000
# Nix writes its store as root; the tests drop it.
if [ "$distro" = nixos ]; then
steps="HOME=/root nix build path:/src --no-link && HOME=/root nix develop path:/src --command chroot --userspec=1000:1000 --skip-chdir / env HOME=/scratch TMPDIR=/scratch sh -c '$steps'"
user=0:0
fi
# The tree read-only; home and temporary files in RAM.
docker run --rm -u "$user" -e QT_QPA_PLATFORM=offscreen -e HOME=/scratch -e TMPDIR=/scratch --tmpfs /scratch:rw,exec,size=4g \
-v "$repo:/src:ro" -w /src "omaphoto-$distro" sh -c "$steps"
+10
View File
@@ -0,0 +1,10 @@
# Arch and Omarchy: Qt, GCC and ONNX Runtime as shipped.
FROM archlinux:latest
RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 \
fontconfig onnxruntime-cpu ttf-dejavu curl \
&& pacman -Scc --noconfirm
# U²-Net for Remove Background, pinned by its checksum.
RUN mkdir -p /usr/local/share/omaphoto \
&& curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \
https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \
&& echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c -
+11
View File
@@ -0,0 +1,11 @@
# Fedora's own Qt and ONNX Runtime; HEVC from RPM Fusion.
FROM fedora:42
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-42.noarch.rpm \
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel libheif-freeworld \
fontconfig-devel onnxruntime-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \
&& dnf clean all
# U²-Net for Remove Background, pinned by its checksum.
RUN mkdir -p /usr/local/share/omaphoto \
&& curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \
https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \
&& echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c -
+3
View File
@@ -0,0 +1,3 @@
# NixOS's package set, through the repository's own flake.
FROM nixos/nix:latest
RUN echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Writes the HEIC fixtures; the dev image has no encoder.
set -euo pipefail
repo=$(cd "$(dirname "$0")/.." && pwd)
generator=$repo/scripts/heic-fixtures
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
docker build -q -t omaphoto-heic-fixtures "$generator" >/dev/null
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen \
-v "$generator:/generator:ro" -v "$work:/work" -v "$repo/tests/fixtures:/fixtures" -w /work \
omaphoto-heic-fixtures /generator/generate.sh
+5
View File
@@ -0,0 +1,5 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ qt6-base-dev libheif-examples libheif-plugin-x265 libheif-plugin-libde265 \
&& rm -rf /var/lib/apt/lists/*
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Runs inside the container: /generator read-only, /fixtures to fill.
set -eu
qt=/usr/include/x86_64-linux-gnu/qt6
g++ -std=c++20 -fPIC -I$qt -I$qt/QtCore -I$qt/QtGui /generator/sources.cpp -o sources -lQt6Gui -lQt6Core
./sources
encode="heif-enc -q 100 -p chroma=444"
$encode -o /fixtures/red.heic red.png
$encode --premultiplied-alpha -o /fixtures/premultiplied.heic premultiplied.png
$encode -o /fixtures/turned-p3.heic turned-p3.jpg
# Lossless, no ICC profile: the nclx box names Display P3.
heif-enc -L -p chroma=444 --matrix_coefficients=0 --colour_primaries 12 --transfer_characteristic 13 -o /fixtures/p3-nclx.heic warm.png
for name in red premultiplied turned-p3 p3-nclx; do
heif-info /fixtures/$name.heic | grep -E "^image|color profile|angle"
done
+46
View File
@@ -0,0 +1,46 @@
#include <QBuffer>
#include <QColorSpace>
#include <QFile>
#include <QGuiApplication>
#include <QImage>
#include <QImageWriter>
int main(int argc, char **argv)
{
QGuiApplication app(argc, argv);
// Red on the left; right: clear over half-clear blue.
QImage red(64, 32, QImage::Format_RGBA8888);
red.fill(Qt::transparent);
// (200, 100, 50) left; right: white over dark gray.
QImage warm(64, 32, QImage::Format_RGB888);
warm.fill(Qt::white);
for (int y = 16; y < 32; ++y) {
for (int x = 32; x < 64; ++x)
warm.setPixel(x, y, qRgb(16, 16, 16));
}
for (int y = 0; y < 32; ++y) {
for (int x = 0; x < 32; ++x) {
red.setPixel(x, y, qRgba(255, 0, 0, 255));
warm.setPixel(x, y, qRgb(200, 100, 50));
if (y >= 16)
red.setPixel(x + 32, y, qRgba(0, 0, 255, 128));
}
}
// The same picture with its colours already multiplied.
const QImage multiplied = red.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
const QImage stored(multiplied.constBits(), 64, 32, multiplied.bytesPerLine(), QImage::Format_RGBA8888);
bool written = red.save("red.png") && stored.save("premultiplied.png") && warm.save("warm.png");
// Display P3 by ICC profile, turned by EXIF orientation 6.
warm.setColorSpace(QColorSpace::DisplayP3);
QByteArray jpeg;
QBuffer buffer(&jpeg);
QImageWriter writer(&buffer, "jpeg");
writer.setQuality(100);
written = written && writer.write(warm);
QByteArray exif("\xFF\xE1\x00\x22" "Exif\0\0" "II\x2A\x00\x08\x00\x00\x00" "\x01\x00" "\x12\x01\x03\x00\x01\x00\x00\x00", 28);
exif += QByteArray("\x06\x00\x00\x00" "\x00\x00\x00\x00", 8);
jpeg.insert(2, exif);
QFile file("turned-p3.jpg");
written = written && file.open(QIODevice::WriteOnly) && file.write(jpeg) == jpeg.size();
return written ? 0 : 1;
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest package on Arch and Omarchy.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
package=omaphoto-x86_64.pkg.tar.zst
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
*" arch "*) ;;
*)
echo "This script is for Arch and Omarchy; this system is ${PRETTY_NAME:-unknown}." >&2
exit 1
;;
esac
if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's packages are built for x86_64 alone." >&2
exit 1
fi
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
curl -fL --proto '=https' -o "$work/$package" "$base/$package"
curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS"
# The package must match the checksum published beside it.
(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -)
# pacman asks which ONNX Runtime build provides onnxruntime.
sudo pacman -U "$work/$package"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest RPM on Fedora.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
package=omaphoto.x86_64.rpm
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
*" fedora "*) ;;
*)
echo "This script is for Fedora; this system is ${PRETTY_NAME:-unknown}." >&2
exit 1
;;
esac
if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's packages are built for x86_64 alone." >&2
exit 1
fi
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
curl -fL --proto '=https' -o "$work/$package" "$base/$package"
curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS"
# The package must match the checksum published beside it.
(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -)
sudo dnf install -y "$work/$package"
# Fedora's own libheif decodes no HEVC; RPM Fusion's does.
if ! rpm -q libheif-freeworld >/dev/null 2>&1; then
echo "HEIC import needs RPM Fusion's libheif-freeworld: https://rpmfusion.org/Configuration, then sudo dnf install libheif-freeworld"
fi
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Installs OmaPhoto on NixOS from its flake.
set -euo pipefail
if ! command -v nix >/dev/null; then
echo "This script needs Nix; NixOS carries it." >&2
exit 1
fi
if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's flake builds for x86_64 alone." >&2
exit 1
fi
nix --extra-experimental-features "nix-command flakes" profile install github:ZacharyZhang-NY/OmaPhoto
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest DEB on Ubuntu and its derivatives.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
package=omaphoto_amd64.deb
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
*" ubuntu "*) ;;
*)
echo "This script is for Ubuntu and its derivatives; this system is ${PRETTY_NAME:-unknown}." >&2
exit 1
;;
esac
if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's packages are built for x86_64 alone." >&2
exit 1
fi
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
curl -fL --proto '=https' -o "$work/$package" "$base/$package"
curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS"
# The package must match the checksum published beside it.
(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -)
# apt reads the file as its own user.
chmod a+rx "$work"
sudo apt-get install -y "$work/$package"
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env python3
"""House rules for OmaPhoto: files under 500 lines, comments of one line and at most ten words.
Reads C++ sources and tests, everything under scripts/, CMakeLists.txt and Dockerfile.dev, each with a
scanner for its own language. Run from the repository root; exits 1 and lists each violation.
`--self-test` checks the checker.
"""
import glob, io, os, re, sys, tokenize
def comments(text):
"""Yields (first line, last line, text) for each comment outside literals and numbers."""
index, line, size = 0, 1, len(text)
while index < size:
char, two = text[index], text[index:index + 2]
if two == '//':
end = text.find('\n', index)
end = size if end < 0 else end
yield line, line, text[index + 2:end]
index = end
elif two == '/*':
end = text.find('*/', index + 2)
end = size if end < 0 else end
body = text[index + 2:end]
yield line, line + body.count('\n'), body
line += body.count('\n')
index = end + 2
elif char.isdigit() or (char == '.' and text[index + 1:index + 2].isdigit()):
# A number keeps its digit separators: 1'000, 0xFF'FF.
index += 1
while index < size and (text[index].isalnum() or text[index] in '._'
or (text[index] == "'" and text[index + 1:index + 2].isalnum())):
index += 1
elif char.isalpha() or char == '_':
start = index
while index < size and (text[index].isalnum() or text[index] == '_'):
index += 1
if text[start:index].endswith('R') and text[index:index + 1] == '"':
opened = text.index('(', index)
closing = ')' + text[index + 1:opened] + '"'
end = text.index(closing, opened) + len(closing)
line += text.count('\n', index, end)
index = end
elif char in '"\'':
start, index = index, index + 1
while index < size and text[index] != char:
index += 2 if text[index] == '\\' else 1
index += 1
line += text.count('\n', start, index)
else:
line += char == '\n'
index += 1
def python_comments(text):
"""Yields (line, line, text) for each comment of a Python file; its tokenizer knows strings."""
for token in tokenize.generate_tokens(io.StringIO(text).readline):
if token.type == tokenize.COMMENT and not token.string.startswith('#!'):
yield token.start[0], token.start[0], token.string[1:]
def docker_comments(text):
"""Yields (line, line, text): Docker drops every line that begins with `#`, wherever it stands."""
for number, row in enumerate(text.splitlines(), 1):
if row.strip().startswith('#'):
yield number, number, row.strip()[1:]
class Unreadable(Exception):
"""Shell the scanner refuses to read; the file fails at that line."""
def shell_comments(text):
"""Returns (line, line, text) for each comment of a shell script.
Reads the shell our scripts keep to: quotes, escapes, `$( )` and `${ }` to any depth, here-strings,
and `#` where a word begins. What it cannot read exactly it refuses by raising Unreadable, so nothing
passes unread: `<<` (a heredoc or a shift), backticks, `$'…'`, `((`, `case` inside `$( )`, and a
continued line anywhere but between words (it could split `$(`, `<<` or a keyword).
"""
index, line, size, found = 0, 1, len(text), []
def refuse(what):
raise Unreadable('%d: lint does not read %s' % (line, what))
def single():
nonlocal index, line
end = text.find("'", index + 1)
if end < 0:
refuse('a quote left open')
line += text.count('\n', index, end)
index = end + 1
def expansion():
"""One step inside `"…"` or `${…}`: an escape, a substitution, or a character."""
nonlocal index, line
if text[index] == '$' and text.startswith('\\\n', index + 1):
refuse('a continued line that splits `$` from what it opens')
elif text[index] == '\\':
line += text[index + 1:index + 2] == '\n'
index += 2
elif text[index] == '`':
refuse('backticks')
elif text.startswith('$((', index):
refuse('arithmetic')
elif text.startswith('$(', index):
index += 2
code(')')
elif text.startswith('${', index):
index += 2
enclosed('}', 'a brace left open')
else:
line += text[index] == '\n'
index += 1
def enclosed(closer, otherwise):
"""To the `closer` of `"…"` or `${…}`; strings nest inside braces."""
nonlocal index
if closer == '"':
index += 1
while index < size and text[index] != closer:
if closer == '}' and text[index] == "'":
single()
elif closer == '}' and text[index] == '"':
enclosed('"', 'a quote left open')
else:
expansion()
if index >= size:
refuse(otherwise)
index += 1
def code(closer):
"""Shell code to `closer`, or to the end of the text."""
nonlocal index, line
word, depth = False, 0
while index < size:
char = text[index]
if char == '\\':
# A continued line is nothing; other escapes are words.
continued = text[index + 1:index + 2] == '\n'
if continued and index > 0 and text[index - 1] not in ' \t\n':
refuse('a continued line that splits a word or an operator')
line, word, index = line + continued, word or not continued, index + 2
elif char in '\'"':
single() if char == "'" else enclosed('"', 'a quote left open')
word = True
elif char == '`' or text.startswith("$'", index):
refuse('backticks' if char == '`' else "$'…' quoting")
elif text.startswith('${', index) or text.startswith('$(', index):
expansion()
word = True
elif text.startswith('<<<', index):
index, word = index + 3, False
elif text.startswith('<<', index) or text.startswith('((', index):
refuse('`<<`: a heredoc or a shift' if char == '<' else '`((`')
elif char == ')' and closer and depth == 0:
index += 1
return
elif char in ' \t\n;&|<>()':
depth += (char == '(') - (char == ')')
line, word, index = line + (char == '\n'), False, index + 1
elif char == '#' and not word:
end = text.find('\n', index)
end = size if end < 0 else end
found.append((line, line, text[index + 1:end]))
index = end
else:
if closer and not word and re.compile(r'case\b').match(text, index):
refuse('`case` inside `$( )`')
index, word = index + 1, True
if closer:
refuse('`$(` left open')
if text.startswith('#!'):
index = text.find('\n') if '\n' in text else size
code(None)
return found
def cmake_comments(text):
"""Yields (first line, last line, text) for each comment of a CMake file.
`"` strings and `\\` escapes, bracket arguments `[=[ ]=]` where an argument begins (inside an unquoted
argument, escapes included, `[[` is text), bracket comments `#[=[ ]=]`, and `#` anywhere else.
"""
index, line, size, argument = 0, 1, len(text), False
while index < size:
char = text[index]
if char == '\\':
line += text[index + 1:index + 2] == '\n'
index, argument = index + 2, True
elif char == '"':
# A quote inside an unquoted argument is part of it.
start, index = index, index + 1
while index < size and text[index] != '"':
index += 2 if text[index] == '\\' else 1
index += 1
line += text.count('\n', start, index)
elif (bracket := re.compile(r'#?\[(=*)\[').match(text, index)) and (char == '#' or not argument):
closer = ']' + bracket.group(1) + ']'
close = text.find(closer, bracket.end())
close = size if close < 0 else close
lines = text.count('\n', index, close)
if bracket.group(0).startswith('#'):
yield line, line + lines, text[bracket.end():close]
line, index, argument = line + lines, close + len(closer), False
elif char == '#':
end = text.find('\n', index)
end = size if end < 0 else end
yield line, line, text[index + 1:end]
index = end
else:
# An unquoted argument runs to a blank or a parenthesis.
line, index, argument = line + (char == '\n'), index + 1, char not in ' \t\n()'
def violations(path, text):
found = []
if len(text.splitlines()) >= 500:
found.append('%s: %d lines' % (path, len(text.splitlines())))
rows = text.splitlines()
previous = None
name = path.rsplit('/', 1)[-1]
scanners = [(('.cpp', '.h'), comments), (('.py',), python_comments), (('.sh',), shell_comments),
(('CMakeLists.txt', '.cmake'), cmake_comments)]
scanner = docker_comments if name.startswith('Dockerfile') else next((s for ends, s in scanners if name.endswith(ends)), None)
if scanner is None:
return found + ['%s: lint has no scanner for this kind of file' % path]
try:
found_comments = list(scanner(text))
except Unreadable as refusal:
return found + ['%s:%s' % (path, refusal)]
for first, last, body in found_comments:
if last > first:
found.append('%s:%d: comment runs over one line' % (path, first))
alone = rows[first - 1].strip().startswith(('//', '/*', '#'))
if alone and previous == first - 1:
found.append('%s:%d: comment runs over one line' % (path, first))
previous = last if alone else None
if len(body.split()) > 10:
found.append('%s:%d: comment of %d words' % (path, first, len(body.split())))
return found
def self_test():
eleven = 'one two three four five six seven eight nine ten eleven'
cases = [
('int a; // fine\n', 0),
('// ' + eleven + '\n', 1),
('/* ' + eleven + ' */\n', 1),
('/* two\n lines */\n', 1),
('// one\n// two\n', 1),
('int a; // one\nint b; // two\n', 0),
('const char *url = "http://example.com/a b c d e f g h i j k l";\n', 0),
("char slash = '/'; char quote = '\"'; // fine\n", 0),
('auto raw = R"x(a quote " then // ' + eleven + ')x"; // fine\n', 0),
('const char *escaped = "a \\" // b c d e f g h i j k l m";\n', 0),
("int value = 1'000; // " + eleven + '\n', 1),
("int mask = 0xFF'FF; int next = 2'0; // " + eleven + '\n', 1),
("double big = 1e+5; char c = 'x'; // fine\n", 0),
("auto wide = u8R\"(a quote \" then // " + eleven + ")\"; // fine\n", 0),
('const char *value = "first\\\nsecond";\n// first\n// second\n', 1),
('auto raw = R"(one\ntwo)";\n// first\n// second\n', 1),
('int a;\n' * 499, 0),
('int a;\n' * 500, 1),
('int a;\n' * 498 + 'int a;', 0),
('int a;\n' * 499 + 'int a;', 1),
]
for text, expected in cases:
got = violations('fixture.cpp', text)
assert len(got) == expected, (text[:60], expected, got)
ten = eleven[:-7]
scripts = [
('fixture.sh', '#!/usr/bin/env bash ' + eleven + '\necho hello\n', 0, ''),
('fixture.sh', '# ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', '# ' + ten + '\n', 0, ''),
('fixture.sh', '#' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', '#\t' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo hello # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo hello\t# ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo hello;# ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', '(# ' + eleven + '\necho)\n', 1, 'comment of 11 words'),
('fixture.sh', 'sleep 1 &# ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'true ||# ' + eleven + '\ntrue\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo >#' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'name() { echo; } # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'case x in\n a) echo;; # ' + eleven + '\nesac\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo "# ' + eleven + '"\n', 0, ''),
('fixture.sh', 'echo "first\n# ' + eleven + '\nlast"\n', 0, ''),
('fixture.sh', "sh -c '\n# " + eleven + "\n'\n", 0, ''),
('fixture.sh', 'echo "a \\" # ' + eleven + '"\n', 0, ''),
('fixture.sh', "echo 'a \\' # " + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo \\" # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo a#b ' + eleven + '\n', 0, ''),
('fixture.sh', 'echo a\\ #' + eleven + '\n', 0, ''),
('fixture.sh', 'echo a\\\n#b ' + eleven + '\n', 1, 'lint does not read a continued line that splits a word'),
('fixture.sh', 'echo a \\\n# ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo a \\\n b\n# one\n# two\n', 1, ':4: comment runs over one line'),
('fixture.sh', 'echo $# ${#name} ${name#a b} ${name:-# c} ' + eleven + '\n', 0, ''),
('fixture.sh', 'echo \\# ' + eleven + '\n', 0, ''),
('fixture.sh', 'echo $# # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'value="$(echo hi # ' + eleven + '\n)"\n', 1, ':1: comment of 11 words'),
('fixture.sh', 'value="$(echo "in $(echo deep # ' + eleven + '\n)")"\n', 1, 'comment of 11 words'),
('fixture.sh', 'value="${name:-$(echo # ' + eleven + '\n)}"\n', 1, 'comment of 11 words'),
('fixture.sh', 'value=$( (cd /tmp; pwd) ) # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'value="$(echo \\) # ' + eleven + '\n)"\n', 1, 'comment of 11 words'),
('fixture.sh', 'value="$(echo \\))" # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'cat <<< "text" # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'cat <<< text\n# ' + eleven + '\n', 1, ':2: comment of 11 words'),
('fixture.sh', 'echo\ncat <<EOF\nbody\nEOF\n', 1, ':2: lint does not read `<<`'),
('fixture.sh', 'cat <<-END-TEXT\nbody\nEND-TEXT\n', 1, 'lint does not read `<<`'),
('fixture.sh', 'value=$((1 << shift))\n', 1, 'lint does not read arithmetic'),
('fixture.sh', 'value="$((1 + 2))"\n', 1, 'lint does not read arithmetic'),
('fixture.sh', '((count += 1))\n', 1, 'lint does not read `((`'),
('fixture.sh', 'echo `date`\n', 1, 'lint does not read backticks'),
('fixture.sh', 'echo "`date`"\n', 1, 'lint does not read backticks'),
('fixture.sh', "echo $'it\\'s'\n", 1, "lint does not read $'"),
('fixture.sh', 'value=$(case x in a) echo;; esac)\n', 1, 'lint does not read `case` inside'),
('fixture.sh', 'value=$(echo showcase)\n', 0, ''),
('fixture.sh', 'echo "open\n', 1, 'a quote left open'),
('fixture.sh', "echo 'open\n", 1, 'a quote left open'),
('fixture.sh', 'echo ${open\n', 1, 'a brace left open'),
('fixture.sh', 'value=$(echo open\n', 1, '`$(` left open'),
('fixture.sh', "echo 'a\nb'\n# one\n# two\n", 1, ':4: comment runs over one line'),
('fixture.sh', 'echo "a\nb"\n# one\n# two\n', 1, ':4: comment runs over one line'),
('fixture.sh', "echo ${name:-'}'} # " + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo ${name:-"}"} # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo "it\'s" # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'echo \\ #' + eleven + '\n', 0, ''),
('fixture.sh', "echo 'a'#b " + eleven + '\n', 0, ''),
('fixture.sh', 'echo "a"#b ' + eleven + '\n', 0, ''),
('fixture.sh', 'echo $(date)#b ${name}#c ' + eleven + '\n', 0, ''),
('fixture.sh', 'cat <<<#' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.sh', 'value="$( (echo a) # ' + eleven + '\n)"\n', 1, 'comment of 11 words'),
('fixture.sh', 'value=$(caseless)\n', 0, ''),
('fixture.sh', 'value="$\\\n(printf x # ' + eleven + '\n)"\n', 1, ':1: lint does not read a continued line that splits `$`'),
('fixture.sh', 'value=$\\\n(printf x # ' + eleven + '\n)\n', 1, 'lint does not read a continued line'),
('fixture.sh', 'value=$(\\\n(1 + 2))\n', 1, 'lint does not read a continued line'),
('fixture.sh', 'cat <\\\n<EOF\n# ' + eleven + '\nEOF\n', 1, 'lint does not read a continued line'),
('fixture.sh', 'value=$(ca\\\nse x in a) echo;; esac)\n', 1, 'lint does not read a continued line'),
('fixture.sh', 'echo one \\\n\ttwo \\\n\\\n three # ' + eleven + '\n', 1, ':4: comment of 11 words'),
('fixture.sh', 'echo "one\\\ntwo" # ' + eleven + '\n', 1, ':2: comment of 11 words'),
('fixture.sh', 'echo one\t\\\n two # ' + eleven + '\n', 1, ':2: comment of 11 words'),
('fixture.sh', '# one\n# two\n', 1, 'comment runs over one line'),
('fixture.sh', '# one\n#\n', 1, 'comment runs over one line'),
('fixture.sh', '# one\necho\n# two\n', 0, ''),
('fixture.sh', 'echo one # first\necho two # second\n', 0, ''),
('CMakeLists.txt', ' # ' + eleven + '\n', 1, 'comment of 11 words'),
('CMakeLists.txt', 'set(NAME value)#' + eleven + '\n', 1, 'comment of 11 words'),
('CMakeLists.txt', 'set(NAME "# ' + eleven + '")\n', 0, ''),
('CMakeLists.txt', 'set(NAME "a \\" # ' + eleven + '")\n', 0, ''),
('CMakeLists.txt', 'set(NAME a\\# ' + eleven + ')\n', 0, ''),
('CMakeLists.txt', "message(it's) # " + eleven + '\n', 1, 'comment of 11 words'),
('CMakeLists.txt', 'set(NAME [[\n# ' + eleven + '\n]])\n', 0, ''),
('CMakeLists.txt', 'set(NAME [=[ ]] # ' + eleven + ' ]=])\n', 0, ''),
('CMakeLists.txt', 'set(NAME a[[b # ' + eleven + ' ]])\n', 1, 'comment of 12 words'),
('CMakeLists.txt', 'set(NAME\t[[b # ' + eleven + ' ]])\n', 0, ''),
('CMakeLists.txt', '[[ # ' + eleven + ' ]]', 0, ''),
('CMakeLists.txt', 'set(NAME a\\ [[b # ' + eleven + '\n]])\n', 1, 'comment of 11 words'),
('CMakeLists.txt', 'set(NAME "a"[[b # ' + eleven + ' ]])\n', 0, ''),
('CMakeLists.txt', 'set(NAME a"b"[[c # ' + eleven + '\n]])\n', 1, 'comment of 11 words'),
('CMakeLists.txt', 'set(NAME a"b # c"[[d # ' + eleven + '\n]])\n', 1, 'comment of 11 words'),
('CMakeLists.txt', 'set(NAME a)[[b # ' + eleven + ' ]]\n', 0, ''),
('CMakeLists.txt', 'set([[b # ' + eleven + ' ]])\n', 0, ''),
('CMakeLists.txt', 'set(NAME\n[[b # ' + eleven + ' ]])\n', 0, ''),
('CMakeLists.txt', 'set(NAME [[a]][[b # ' + eleven + ' ]])\n', 0, ''),
('CMakeLists.txt', '# first\nset(NAME\n# ' + ten + '\n[[b # ' + eleven + ' ]])\n', 0, ''),
('CMakeLists.txt', 'set(NAME a [==[b # ' + eleven + ' ]==] c[[d # ' + eleven + '\n)\n', 1, 'comment of 11 words'),
('CMakeLists.txt', 'set(NAME a#[[ ' + eleven + ' ]]\n)\n', 1, 'comment of 11 words'),
('CMakeLists.txt', '#[[ ' + ten + ' ]]\nset(NAME value)\n', 0, ''),
('CMakeLists.txt', '#[==[ ' + ten + ' ]==]\n', 0, ''),
('CMakeLists.txt', '#[[ two\nlines ]]\n', 1, 'comment runs over one line'),
('CMakeLists.txt', '#[=[ ' + eleven + ' ]=]\n', 1, 'comment of 11 words'),
('helpers.cmake', '# ' + eleven + '\n', 1, 'comment of 11 words'),
('Dockerfile.dev', '# fine\nFROM ubuntu:24.04\n', 0, ''),
('Dockerfile.dev', ' # ' + eleven + '\n', 1, 'comment of 11 words'),
('Dockerfile.dev', 'RUN echo "a # ' + eleven + '"\n', 0, ''),
('Dockerfile.dev', 'RUN echo "\\\n# ' + eleven + '\\\n"\n', 1, 'comment of 11 words'),
('Dockerfile', '# ' + ten + '\n', 0, ''),
('fixture.py', 'value = "# ' + eleven + '" # fine\n', 0, ''),
('fixture.py', 'value = 1 # ' + eleven + '\n', 1, 'comment of 11 words'),
('fixture.py', 'value = 1 # ' + ten + '\n', 0, ''),
('fixture.py', '#!/usr/bin/env python3 ' + eleven + '\nvalue = 1\n', 0, ''),
('fixture.py', '"""' + eleven + '\n' + eleven + '"""\n', 0, ''),
('fixture.py', '# one\n# two\n', 1, 'comment runs over one line'),
('fixture.py', "value = '''it's # " + eleven + "'''\n", 0, ''),
('notes.txt', 'plain text\n', 1, 'no scanner for this kind of file'),
('fixture.sh', 'echo\n' * 500, 1, '500 lines'),
]
for path, text, expected, message in scripts:
got = violations(path, text)
assert len(got) == expected and all(message in line for line in got), (path, text[:60], expected, message, got)
print('lint self-test: %d cases pass' % (len(cases) + len(scripts)))
if __name__ == '__main__':
if sys.argv[1:] == ['--self-test']:
self_test()
sys.exit(0)
paths = sorted(p for p in glob.glob('src/**/*', recursive=True) + glob.glob('tests/*') if p.endswith(('.cpp', '.h')))
paths += sorted(p for p in glob.glob('scripts/**/*', recursive=True) if os.path.isfile(p))
paths += ['CMakeLists.txt', 'Dockerfile.dev']
failures = [failure for path in paths for failure in violations(path, open(path).read())]
print('\n'.join(failures) if failures else 'lint: %d files clean' % len(paths))
sys.exit(1 if failures else 0)
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Mutation runner for OmaPhoto. Run from the repository root.
mutate.py FILE [--targets T1,T2] <<< 'old ==> new' blocks separated by lines of @@
mutate.py FILE --delete [--from N --to M] [--targets T1,T2] delete each statement line in turn
Verdicts: 'caught' means the mutant built and a test that really ran failed; 'UNBUILDABLE' means the
compiler rejected it, which proves nothing; 'SURVIVED' means every test passed. Anything else (Docker
not starting, a missing test executable, a failing baseline) stops the run with an error.
"""
import argparse, os, re, subprocess, sys
parser = argparse.ArgumentParser()
parser.add_argument('path')
parser.add_argument('--targets', default='')
parser.add_argument('--delete', action='store_true')
parser.add_argument('--from', dest='first', type=int, default=1)
parser.add_argument('--to', dest='last', type=int, default=10**9)
args = parser.parse_args()
repo = os.getcwd()
if not os.path.isfile(os.path.join(repo, 'CMakeLists.txt')):
sys.exit('run mutate.py from the repository root')
original = open(args.path).read()
targets = [t for t in args.targets.split(',') if t]
def docker(command):
uid, gid = os.getuid(), os.getgid()
runtime = '/run/user/%d' % uid
done = subprocess.run(
['docker', 'run', '--rm', '--init', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen',
'--tmpfs', '%s:uid=%d,gid=%d,mode=0700' % (runtime, uid, gid), '-e', 'XDG_RUNTIME_DIR=' + runtime,
'-v', '%s:%s' % (repo, repo), '-w', repo, 'omaphoto-dev', 'timeout', '600', 'sh', '-c', command],
capture_output=True, text=True)
return done.returncode, done.stdout + done.stderr
def build():
wanted = ' --target ' + ' '.join(targets) if targets else ''
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build' + wanted)
def test():
if targets:
missing = [t for t in targets if not os.path.isfile(os.path.join(repo, 'build', t))]
if missing:
sys.exit('test executables are missing after a successful build: %s' % missing)
return docker(' && '.join('./build/%s' % t for t in targets))
return docker('ctest --test-dir build --output-on-failure --timeout 120')
def judge():
code, out = build()
if code != 0:
if 'error:' in out or 'FAILED:' in out:
return 'UNBUILDABLE', next((l for l in out.split('\n') if 'error:' in l), '')
sys.exit('the build did not run:\n' + out[-2000:])
code, out = test()
if code == 0:
return 'SURVIVED', ''
if 'Start testing of' not in out and 'Timeout' not in out:
sys.exit('the tests did not run:\n' + out[-2000:])
return 'caught', next((l for l in out.split('\n') if l.startswith('FAIL!') or 'Timeout' in l or 'Segmentation' in l), 'test process died')
mutants = []
if args.delete:
skipped = ('const ', 'static ', '#', '//', 'using ', 'namespace ', 'struct ', 'class ', '}', 'return', 'break', 'continue')
lines = original.split('\n')
for number, line in enumerate(lines, 1):
stripped = line.strip()
if not (args.first <= number <= args.last) or not stripped.endswith(';') or stripped.startswith(skipped):
continue
mutated = lines[:]
mutated[number - 1] = line[:len(line) - len(line.lstrip())] + '(void)0;'
mutants.append(('L%d %s' % (number, stripped), '\n'.join(mutated)))
else:
for block in sys.stdin.read().split('\n@@\n'):
block = block.strip('\n')
if not block:
continue
old, new = block.split(' ==> ')
if original.count(old) != 1:
sys.exit('the text to mutate must occur exactly once: %r occurs %d times' % (old, original.count(old)))
mutants.append((old.replace('\n', ' '), original.replace(old, new)))
verdict, reason = judge()
if verdict != 'SURVIVED':
sys.exit('the unmutated baseline is not green (%s): %s' % (verdict, reason))
counts = {'caught': 0, 'SURVIVED': 0, 'UNBUILDABLE': 0}
try:
for label, text in mutants:
open(args.path, 'w').write(text)
verdict, reason = judge()
counts[verdict] += 1
print('%-11s | %-70s | %s' % (verdict, label[:70], reason[:100]))
sys.stdout.flush()
finally:
open(args.path, 'w').write(original)
print('mutants: %d caught: %d survivors: %d unbuildable: %d' % (len(mutants), counts['caught'], counts['SURVIVED'], counts['UNBUILDABLE']))
+33
View File
@@ -0,0 +1,33 @@
#include "Document/ProjectWorkspace.h"
#include "Logging.h"
#include "UI/OmarchyTheme.h"
#include "UI/ProjectWorkspaceView.h"
#include "UI/SliderSnap.h"
#include <QApplication>
#include <QUrl>
int main(int argc, char **argv)
{
qSetMessagePattern(QStringLiteral("%{time yyyy-MM-dd hh:mm:ss.zzz} %{type} %{category}: %{message}"));
QApplication application(argc, argv);
QApplication::setApplicationName(QStringLiteral("OmaPhoto"));
QApplication::setApplicationVersion(QStringLiteral(OMAPHOTO_VERSION));
// The desktop entry's name: icons and windows find each other.
QGuiApplication::setDesktopFileName(QStringLiteral("io.github.ZacharyZhang_NY.OmaPhoto"));
qCInfo(lcApp).noquote() << "OmaPhoto" << OMAPHOTO_VERSION << "on Qt" << qVersion()
<< "platform" << QGuiApplication::platformName();
// The desktop's colours, retinted when the theme switches.
OmarchyTheme theme;
// Slider knobs snap to a click on the track.
SliderSnap::install();
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
// Files named at launch, as Finder's Open With gives them.
QList<QUrl> files;
for (const QString &argument : QApplication::arguments().mid(1))
files << QUrl::fromLocalFile(argument);
if (!files.isEmpty())
workspace.receive(files);
return QApplication::exec();
}
+114
View File
@@ -0,0 +1,114 @@
#include "ContentView.h"
#include "UI/EffectsSheet.h"
#include "UI/FilterSheet.h"
#include "UI/HueSaturationSheet.h"
#include "UI/LevelsSheet.h"
#include <QFileDialog>
#include <QMessageBox>
void ContentView::synchronizePanels()
{
// A panel opens with its edit; close and Escape cancel.
if (m_levelsShown != m_session.levels().has_value()) {
m_levelsShown = m_session.levels().has_value();
if (m_levelsShown) {
m_levelsPanel.onClose = [this] { m_session.cancelLevels(); };
m_levelsPanel.show(QStringLiteral("Levels"), new LevelsSheet(m_session));
} else {
m_levelsPanel.close();
}
}
if (m_hueSaturationShown != m_session.hueSaturation().has_value()) {
m_hueSaturationShown = m_session.hueSaturation().has_value();
if (m_hueSaturationShown) {
m_adjustmentPanel.onClose = [this] { m_session.cancelHueSaturation(); };
m_adjustmentPanel.show(QStringLiteral("Hue/Saturation"), new HueSaturationSheet(m_session));
} else {
m_adjustmentPanel.close();
}
}
if (m_filterShown != m_session.filterEdit().has_value()) {
m_filterShown = m_session.filterEdit().has_value();
if (m_filterShown) {
m_filterPanel.onClose = [this] { m_session.cancelFilter(); };
m_filterPanel.show(rawValue(m_session.filterEdit()->kind), new FilterSheet(m_session));
} else {
m_filterPanel.close();
}
}
// Swift's onChange of the layers: a vanished effect's panel goes.
if (const std::optional<LayerEffectSelection> editing = m_session.effectsEditing()) {
const std::vector<ImageLayer> &layers = m_session.document() ? m_session.document()->layers : std::vector<ImageLayer>();
const int index = indexOf(layers, editing->layerID);
if (index < 0 || !layers[size_t(index)].effects || !layers[size_t(index)].effects->contains(editing->kind)) {
if (m_session.colorPicker() && m_session.colorPicker()->target.kind == ColorPickerTarget::Kind::effect)
m_session.closeColorPicker(false);
m_session.setEffectsEditing(std::nullopt);
m_session.setEffectsEditingOriginal(std::nullopt);
}
}
// Swift's onChange of effectsEditing: a panel for each choice.
if (m_effectsShown != m_session.effectsEditing()) {
m_effectsShown = m_session.effectsEditing();
if (m_effectsShown) {
m_effectsPanel.onClose = [this] { m_session.finishEffectsEditing(false); };
m_effectsPanel.show(rawValue(m_effectsShown->kind), new EffectsSheet(m_session, m_effectsShown->kind));
} else {
m_effectsPanel.close();
}
}
}
void ContentView::showImporter()
{
if (!m_session.showsImporter()) {
// Dismissed from elsewhere, as a cleared binding dismisses Swift's.
if (QFileDialog *panel = std::exchange(m_importer, nullptr))
panel->reject();
return;
}
if (m_importer)
return;
m_importer = new QFileDialog(this);
m_importer->setAttribute(Qt::WA_DeleteOnClose);
m_importer->setFileMode(QFileDialog::ExistingFiles);
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff)"));
connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) {
// Ours no longer: the flag must not reject it again.
if (m_importer != panel)
return;
m_importer = nullptr;
m_session.setShowsImporter(false);
if (result == QDialog::Accepted)
m_session.importImages(panel->selectedUrls());
});
m_importer->open();
}
void ContentView::showAlert(QPointer<QMessageBox> &alert, const QString &title, const std::optional<QString> &message,
const std::function<void()> &dismiss)
{
if (!message) {
if (QMessageBox *shown = std::exchange(alert, nullptr))
shown->reject();
return;
}
// SwiftUI's alert shows its message as it stands.
if (alert) {
alert->setInformativeText(*message);
return;
}
alert = new QMessageBox(this);
alert->setAttribute(Qt::WA_DeleteOnClose);
alert->setIcon(QMessageBox::Warning);
alert->setText(title);
alert->setInformativeText(*message);
alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole);
connect(alert, &QDialog::finished, this, [&alert, shown = alert.data(), dismiss] {
if (alert != shown)
return;
alert = nullptr;
dismiss();
});
alert->open();
}
+60
View File
@@ -0,0 +1,60 @@
#include "ContentView.h"
#include <QLocale>
#include <map>
// The status bar's words: zoom, and each tool's hint.
QString ContentView::percent(double zoom)
{
QString number = QLocale(QLocale::English, QLocale::UnitedStates).toString(zoom * 100, 'f', 1);
if (number.endsWith(QLatin1String(".0")))
number.chop(2);
return number + QLatin1Char('%');
}
// The kind two tools' icons and hints follow.
ToolIconKind ContentView::iconKind(NavigationTool tool) const
{
if (tool == NavigationTool::marquee && m_session.marqueeKind() == LassoKind::ellipse)
return ToolIconKind::ellipse;
if (tool == NavigationTool::lasso && m_session.lassoKind() == LassoKind::polygonal)
return ToolIconKind::polygonal;
if (tool == NavigationTool::brush && m_session.brushMode() == BrushToolMode::erase)
return ToolIconKind::eraser;
return ToolIconKind::plain;
}
QString ContentView::hint(NavigationTool tool, ToolIconKind kind, BlurToolMode smear, ShapeKind shape)
{
if (tool == NavigationTool::marquee && kind == ToolIconKind::ellipse)
return QStringLiteral("Drag an ellipse · Shift add · Alt subtract · Shift again mid-drag circle · Drag inside to move · Delete clears · Ctrl+D deselect");
if (tool == NavigationTool::lasso && kind == ToolIconKind::polygonal)
return QStringLiteral("Click corners · Click start, double-click or Enter to close · Delete removes corner · Escape cancel");
if (tool == NavigationTool::brush && kind == ToolIconKind::eraser)
return QStringLiteral("Drag to erase · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan");
if (tool == NavigationTool::blur)
return (smear == BlurToolMode::blur ? QStringLiteral("Drag to soften")
: smear == BlurToolMode::smudge ? QStringLiteral("Drag to smudge")
: QStringLiteral("Drag to push pixels"))
+ QStringLiteral(" · [ ] size · Shift-[ ] hardness · 1–0 strength · Space to pan");
if (tool == NavigationTool::shape)
return QStringLiteral("Drag to draw a shape on a new layer · Shift %1 · Alt from center · Shift-U or Tab for the next shape · Escape cancel · Space to pan")
.arg(shape == ShapeKind::line ? QStringLiteral("45°") : shape == ShapeKind::rectangle ? QStringLiteral("square") : QStringLiteral("circle"));
// Swift's last branch: the Eyedropper shows Zoom's words too.
static const std::map<NavigationTool, const char *> hints = {
{NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"},
{NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"},
{NavigationTool::cloneStamp, "Alt-click to set the source · Drag to clone · [ ] size · Shift-[ ] hardness · 1–0 opacity · Space to pan"},
{NavigationTool::spotHealing, "Drag over blemishes to heal · [ ] size · Shift-[ ] hardness · Escape cancel · Space to pan"},
{NavigationTool::type, "Drag a text box · Click text to edit · Drag box handles to resize · Ctrl+Return finish · Escape cancel"},
{NavigationTool::gradient, "Drag to draw · Drag ends to adjust · Shift 45° · 1–0 opacity · Enter apply · Escape cancel"},
{NavigationTool::crop, "Drag to crop · Enter apply · Escape cancel · Space to pan"},
{NavigationTool::move, "Drag to move · Handles to resize · Circle to rotate · 1–0 layer opacity · Space to pan"},
{NavigationTool::hand, "Drag to pan · Pinch to zoom"},
{NavigationTool::idle, "No tool selected · Press a tool's key to pick one · Space to pan"},
{NavigationTool::eyedropper, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"},
{NavigationTool::zoom, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"},
};
return QString::fromUtf8(hints.at(tool));
}
+453
View File
@@ -0,0 +1,453 @@
#include "ContentView.h"
#include "UI/ColorPaletteControls.h"
#include "Logging.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ImageFileDrop.h"
#include "UI/LayersPanel.h"
#include "UI/BrushControls.h"
#include "UI/CropControls.h"
#include "UI/GradientControls.h"
#include "UI/ShapeControls.h"
#include "UI/TypeControls.h"
#include "UI/LassoControls.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
#include "UI/NewCanvasSheet.h"
#include "UI/ToolHeaderStyle.h"
#include "UI/ToolIcons.h"
#include <QCheckBox>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFrame>
#include <QLocale>
#include <QPainter>
#include <QProgressBar>
#include <QMouseEvent>
#include <QScrollArea>
#include <QSettings>
#include <cmath>
#include <map>
namespace {
const NavigationTool railTools[] = {NavigationTool::move, NavigationTool::marquee, NavigationTool::lasso, NavigationTool::wand,
NavigationTool::crop, NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::cloneStamp,
NavigationTool::blur, NavigationTool::gradient, NavigationTool::shape, NavigationTool::type,
NavigationTool::eyedropper, NavigationTool::hand, NavigationTool::zoom};
// Swift's Eyedropper bar: its title and the ring's checkbox.
ToolHeaderBar *eyedropperBar(EditorSession &session, QWidget *parent)
{
auto *bar = new ToolHeaderBar(QStringLiteral("Eyedropper"), parent);
auto *ring = new QCheckBox(QStringLiteral("Sample Ring"), bar);
ring->setObjectName(QStringLiteral("sampleRing"));
ring->setChecked(session.showsSampleRing());
QObject::connect(ring, &QCheckBox::toggled, &session, &EditorSession::setShowsSampleRing);
// It follows the session, which others may set.
QObject::connect(&session, &EditorSession::changed, ring, [ring, &session] {
const QSignalBlocker quiet(ring);
ring->setChecked(session.showsSampleRing());
});
bar->row->setSpacing(16);
bar->row->insertWidget(1, ring);
return bar;
}
}
// A rail button: its tool's icon; a plate when chosen.
class ToolButton : public QToolButton {
public:
ToolButton(NavigationTool tool, QWidget *parent) : QToolButton(parent), m_tool(tool)
{
setCheckable(true);
setFixedSize(36, 36);
setFocusPolicy(Qt::NoFocus);
setToolTip(label(tool));
setAccessibleName(label(tool));
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QColor ink = palette().color(QPalette::WindowText);
if (isChecked()) {
QColor plate = ink, edge = ink;
plate.setAlphaF(0.12);
edge.setAlphaF(0.14);
painter.setPen(edge);
painter.setBrush(plate);
painter.drawRoundedRect(QRectF(0.5, 0.5, 35, 35), 7, 7);
}
ToolIcons::paint(painter, m_tool, QPointF(9, 9), ToolIcons::points, ink, m_kind);
}
public:
// The Marquee's and the Lasso's icons follow their kinds.
void setKind(ToolIconKind kind)
{
if (kind == m_kind)
return;
m_kind = kind;
update();
}
private:
const NavigationTool m_tool;
ToolIconKind m_kind = ToolIconKind::plain;
};
namespace {
// Swift's PanelResizeEdge: a divider with an 8-point grip.
class PanelResizeEdge : public QWidget {
public:
PanelResizeEdge(LayersPanel &panel, QWidget *parent) : QWidget(parent), m_panel(panel)
{
setObjectName(QStringLiteral("layersPanelEdge"));
setFixedWidth(8);
setCursor(Qt::SplitHCursor);
setToolTip(QStringLiteral("Drag to resize the panel"));
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(QRect(width() / 2, 0, 1, height()), palette().color(QPalette::Mid));
}
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_start = std::pair(event->globalPosition().x(), double(m_panel.width()));
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (!m_start)
return;
// Dragged left, the panel widens; whole points, within range.
const double wanted = std::round(m_start->second - (event->globalPosition().x() - m_start->first));
const double width = std::min(LayersPanel::maximumWidth, std::max(LayersPanel::minimumWidth, wanted));
if (width == m_panel.width())
return;
m_panel.setFixedWidth(int(width));
ContentView::setLayersPanelWidth(width);
}
void mouseReleaseEvent(QMouseEvent *) override { m_start = std::nullopt; }
private:
LayersPanel &m_panel;
std::optional<std::pair<double, double>> m_start;
};
// Swift's overlay: a 3-point accent ring inside the canvas.
class DropRing : public QWidget {
public:
explicit DropRing(QWidget *parent) : QWidget(parent)
{
setObjectName(QStringLiteral("dropRing"));
setAttribute(Qt::WA_TransparentForMouseEvents);
hide();
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(palette().color(QPalette::Highlight), 3));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(QRectF(rect()).adjusted(4.5, 4.5, -4.5, -4.5), 8, 8);
}
};
QFrame *divider(QFrame::Shape shape, QWidget *parent)
{
auto *line = new QFrame(parent);
line->setFrameShape(shape);
line->setFrameShadow(QFrame::Plain);
line->setForegroundRole(QPalette::Mid);
return line;
}
QLabel *statusText(QLabel *label)
{
QFont font = label->font();
font.setPixelSize(11);
label->setFont(font);
label->setForegroundRole(QPalette::PlaceholderText);
return label;
}
// A row's widest item shrinks first; this ends in dots.
class ElidedLabel : public QLabel {
public:
using QLabel::QLabel;
protected:
void paintEvent(QPaintEvent *) override
{
// A widget's painter starts with its foreground role's pen.
QPainter painter(this);
painter.drawText(rect(), Qt::AlignRight | Qt::AlignVCenter, fontMetrics().elidedText(text(), Qt::ElideRight, width()));
}
};
}
ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent)
: QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout),
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
m_activity(statusText(new ElidedLabel(this))),
m_spinner(new QProgressBar(this))
{
setMinimumSize(800, 520);
m_column->setContentsMargins(0, 0, 0, 0);
m_column->setSpacing(0);
auto *tools = new QWidget(this);
auto *toolColumn = new QVBoxLayout(tools);
// Room for the palette's overhang; the tools stay centred.
toolColumn->setContentsMargins(7, 16, 7, 12);
toolColumn->setSpacing(10);
for (const NavigationTool tool : railTools) {
auto *button = new ToolButton(tool, tools);
// A refused click stays silent: set the buttons again.
connect(button, &QToolButton::clicked, this, [this, tool] {
m_session.selectTool(tool);
synchronize();
});
toolColumn->addWidget(button, 0, Qt::AlignHCenter);
m_toolButtons.push_back({tool, button});
}
// Swift's 8 points above the swatches, less their overhang.
toolColumn->addSpacing(5);
toolColumn->addWidget(new ColorPaletteControls(session, tools), 0, Qt::AlignHCenter);
toolColumn->addStretch(1);
// Too short a window scrolls the rail, not the bars.
auto *rail = new QScrollArea(this);
rail->setObjectName(QStringLiteral("toolRail"));
rail->setWidget(tools);
rail->setWidgetResizable(true);
rail->setFixedWidth(56);
rail->setFrameShape(QFrame::NoFrame);
rail->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
rail->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto *canvas = new QWidget(this);
canvas->setLayout(m_canvasSlot);
// Swift's canvas fills its area: no layout gutter.
m_canvasSlot->setContentsMargins(0, 0, 0, 0);
// The welcome sits over the canvas, as Swift's ZStack.
m_canvasSlot->addWidget(m_canvas, 0, 0);
m_canvasSlot->addWidget(m_dropRing, 0, 0);
setAcceptDrops(true);
auto *middle = new QHBoxLayout;
middle->setSpacing(0);
middle->addWidget(rail);
middle->addWidget(divider(QFrame::VLine, this));
middle->addWidget(canvas, 1);
m_layersPanel->setFixedWidth(int(layersPanelWidth()));
middle->addWidget(new PanelResizeEdge(*m_layersPanel, this));
middle->addWidget(m_layersPanel);
m_zoom->setObjectName(QStringLiteral("zoomStatus"));
m_zoom->setFixedWidth(62);
m_dimensions->setObjectName(QStringLiteral("canvasDimensions"));
m_colour->setObjectName(QStringLiteral("colourStatus"));
m_activity->setObjectName(QStringLiteral("activityStatus"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setFixedSize(36, 6);
auto *status = new QWidget(this);
status->setObjectName(QStringLiteral("statusBar"));
status->setFixedHeight(30);
auto *statusRow = new QHBoxLayout(status);
statusRow->setContentsMargins(18, 0, 18, 0);
statusRow->setSpacing(16);
statusRow->addWidget(m_zoom);
statusRow->addWidget(m_dimensions);
statusRow->addWidget(m_colour);
statusRow->addWidget(m_spinner, 0, Qt::AlignRight);
// The hint takes what is left, and shrinks first.
statusRow->addWidget(m_activity, 1);
m_column->addWidget(divider(QFrame::HLine, this));
m_column->addLayout(middle, 1);
m_column->addWidget(divider(QFrame::HLine, this));
m_column->addWidget(status);
// The filter panel follows the session by itself.
connect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
synchronize();
}
// ~QWidget closes the window after members go; stop listening first.
ContentView::~ContentView()
{
disconnect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
}
// Another tab's layer, files or a picture, while free.
bool ContentView::acceptsDrop(const QMimeData &data) const
{
if (!(data.hasFormat(ProjectWorkspace::layerType) || ImageFileDrop::holdsImages(data)))
return false;
// Swift's session guards; a workspace's are canSwitch's own.
if (!m_workspace)
return !m_session.levels() && !m_session.isProjectBusy() && !m_session.showsNewDocument() && !m_session.showsImporter()
&& !m_session.renamingLayerID();
return m_workspace->canSwitch() && m_workspace->canReceiveDrag(data, m_workspace->current().id);
}
void ContentView::dragEnterEvent(QDragEnterEvent *event)
{
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
m_dropRing->raise();
m_dropRing->show();
}
void ContentView::dragMoveEvent(QDragMoveEvent *event)
{
if (!acceptsDrop(*event->mimeData())) {
event->ignore();
return;
}
event->setDropAction(Qt::CopyAction);
event->accept();
}
void ContentView::dragLeaveEvent(QDragLeaveEvent *)
{
m_dropRing->hide();
}
void ContentView::dropEvent(QDropEvent *event)
{
m_dropRing->hide();
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
// Over the canvas the drop names a document point.
std::optional<QPointF> point;
const QPointF inCanvas = m_canvas->mapFrom(this, event->position().toPoint());
if (m_session.document() && m_canvas->rect().contains(inCanvas.toPoint()))
point = m_session.viewport.documentPoint(inCanvas, m_session.document()->size());
if (m_workspace)
m_workspace->receiveProviders(*event->mimeData(), m_workspace->current().id, point);
else
ImageFileDrop::importProviders(*event->mimeData(), m_session, point);
}
double ContentView::layersPanelWidth()
{
const double stored = QSettings().value(QStringLiteral("layersPanelWidth"), LayersPanel::defaultWidth).toDouble();
// A stored width outside the range is the default, logged.
if (stored >= LayersPanel::minimumWidth && stored <= LayersPanel::maximumWidth)
return stored;
qCWarning(lcApp) << "layersPanelWidth" << stored << "is out of range, using" << LayersPanel::defaultWidth;
return LayersPanel::defaultWidth;
}
void ContentView::setLayersPanelWidth(double width)
{
QSettings().setValue(QStringLiteral("layersPanelWidth"), width);
}
// Shows what the session holds: every line compares, then sets.
void ContentView::synchronize()
{
m_canvas->consumeFocusRequest(m_session.canvasFocusRequest());
m_canvas->synchronizeDisplay();
if (m_session.document() && !m_hadDocument)
QMetaObject::invokeMethod(&m_session, &EditorSession::requestCanvasFocus, Qt::QueuedConnection);
m_hadDocument = m_session.document().has_value();
showHeader(m_session.tool());
for (const auto &[tool, button] : m_toolButtons) {
button->setChecked(m_session.tool() == tool);
button->setKind(iconKind(tool));
}
const std::optional<CanvasDocument> &document = m_session.document();
showWelcome(!document);
m_zoom->setVisible(document.has_value());
m_dimensions->setVisible(document.has_value());
if (document) {
const QLocale english(QLocale::English, QLocale::UnitedStates);
m_zoom->setText(percent(m_session.viewport.zoom()));
m_dimensions->setText(QStringLiteral("%1 × %2 px").arg(english.toString(document->width), english.toString(document->height)));
}
m_colour->setText(document ? QStringLiteral("sRGB · Transparent") : QStringLiteral("Ready when you are"));
m_spinner->setVisible(m_session.showsBusy() || m_session.isImporting());
m_activity->setText(m_session.showsBusy() ? QStringLiteral("Working…") : m_session.isImporting() ? QStringLiteral("Importing images…")
: hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind()));
showImporter();
showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); });
showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); });
showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); });
synchronizePanels();
}
// Swift's `if document == nil` makes a fresh sheet.
void ContentView::showWelcome(bool shown)
{
if (shown == (m_welcome != nullptr))
return;
delete m_welcome;
m_welcome = nullptr;
if (!shown)
return;
m_welcome = new NewCanvasSheet(
m_session, [this](int width, int height) { m_session.createNewProject(width, height); },
[this] {
if (m_projects)
m_projects->open();
},
this);
m_canvasSlot->addWidget(m_welcome, 0, 0, Qt::AlignCenter);
m_welcome->show();
}
void ContentView::showHeader(NavigationTool tool)
{
// Shared bars: Hand and Zoom, selection tools, brush tools.
const auto kind = [](NavigationTool each) {
return each == NavigationTool::hand || each == NavigationTool::zoom ? NavigationTool::hand
: isSelectionTool(each) ? NavigationTool::lasso
: isBrushTool(each) ? NavigationTool::brush
: each;
};
if (m_shownTool && kind(*m_shownTool) == kind(tool))
return;
m_shownTool = tool;
delete m_header;
// A tool without a bar yet keeps the bar's height.
if (tool == NavigationTool::idle)
m_header = new ToolHeaderBar(QStringLiteral("Select a tool"), this);
else if (tool == NavigationTool::eyedropper)
m_header = eyedropperBar(m_session, this);
else if (kind(tool) == NavigationTool::hand)
m_header = new NavigationToolHeader(m_session, this);
else if (tool == NavigationTool::move)
m_header = new TransformInspector(m_session, this);
else if (isSelectionTool(tool))
m_header = new LassoControls(m_session, this);
else if (isBrushTool(tool))
m_header = new BrushControls(m_session, this);
else if (tool == NavigationTool::gradient)
m_header = new GradientControls(m_session, this);
else if (tool == NavigationTool::shape)
m_header = new ShapeControls(m_session, this);
else if (tool == NavigationTool::type)
m_header = new TypeControls(m_session, this);
else if (tool == NavigationTool::crop)
m_header = new CropControls(m_session, this);
else
m_header = new ToolHeaderBar(QString(), this);
m_header->setObjectName(QStringLiteral("toolHeader"));
m_column->insertWidget(0, m_header);
// A layout shows a late child only later: show now.
m_header->show();
}
+93
View File
@@ -0,0 +1,93 @@
#pragma once
#include "Document/EditorSession.h"
#include "IO/ProjectController.h"
#include "Rendering/EditorCanvas.h"
#include "UI/FloatingPanel.h"
#include "UI/ToolIcons.h"
#include <QGridLayout>
#include <QLabel>
#include <QPointer>
#include <QStackedLayout>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
class LayersPanel;
class ToolButton;
class ProjectWorkspace;
class QFileDialog;
class QMimeData;
class QMessageBox;
class QProgressBar;
// One project's editor area: bar, rail, canvas, status.
class ContentView : public QWidget {
Q_OBJECT
public:
// Without a controller, Open project has nowhere to go.
explicit ContentView(EditorSession &session, ProjectController *projects = nullptr, QWidget *parent = nullptr);
~ContentView() override;
// The status bar's words for a tool, by its kind.
static QString hint(NavigationTool tool, ToolIconKind kind = ToolIconKind::plain, BlurToolMode smear = BlurToolMode::liquify,
ShapeKind shape = ShapeKind::rectangle);
// Zoom as Swift prints it: percent, one decimal at most.
static QString percent(double zoom);
// The Layers panel's width, remembered across launches.
static double layersPanelWidth();
static void setLayersPanelWidth(double width);
LayersPanel &layersPanel() const { return *m_layersPanel; }
// Drops of layers from other tabs go to the workspace.
void setWorkspace(ProjectWorkspace *workspace) { m_workspace = workspace; }
bool acceptsDrop(const QMimeData &data) const;
protected:
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dragLeaveEvent(QDragLeaveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
void synchronize();
// Swift's onChange of levels: its panel opens and closes.
void synchronizePanels();
ToolIconKind iconKind(NavigationTool tool) const;
void showHeader(NavigationTool tool);
void showWelcome(bool shown);
void showImporter();
void showAlert(QPointer<QMessageBox> &alert, const QString &title, const std::optional<QString> &message,
const std::function<void()> &dismiss);
EditorSession &m_session;
const QPointer<ProjectController> m_projects;
QVBoxLayout *const m_column;
QWidget *m_header = nullptr;
std::optional<NavigationTool> m_shownTool;
std::vector<std::pair<NavigationTool, ToolButton *>> m_toolButtons;
QGridLayout *const m_canvasSlot;
CanvasView *const m_canvas;
LayersPanel *const m_layersPanel;
// An accent ring while a drop may land.
QWidget *const m_dropRing;
ProjectWorkspace *m_workspace = nullptr;
// A document that appears hands the canvas the keys.
bool m_hadDocument = false;
QWidget *m_welcome = nullptr;
QLabel *const m_zoom;
QLabel *const m_dimensions;
QLabel *const m_colour;
QLabel *const m_activity;
QProgressBar *const m_spinner;
QPointer<QFileDialog> m_importer;
QPointer<QMessageBox> m_importAlert;
QPointer<QMessageBox> m_brushAlert;
QPointer<QMessageBox> m_cropAlert;
FloatingPanel m_levelsPanel{QStringLiteral("levelsPanel"), *this};
bool m_levelsShown = false;
FloatingPanel m_adjustmentPanel{QStringLiteral("adjustmentPanel"), *this};
bool m_hueSaturationShown = false;
FloatingPanel m_filterPanel{QStringLiteral("filterPanel"), *this};
bool m_filterShown = false;
FloatingPanel m_effectsPanel{QStringLiteral("effectsPanel"), *this};
std::optional<LayerEffectSelection> m_effectsShown;
};
+12
View File
@@ -0,0 +1,12 @@
// Swift's AdjustmentEditing extension: the colour editors write layer settings.
public:
// Draws what lies beneath off the UI thread; `done` follows.
void beginAdjustmentEditing(QUuid id, std::function<void()> done = {});
// Writes the edited settings, or the original with Preview off.
bool previewAdjustmentEditing(bool preview);
// OK keeps the edited settings; Cancel puts the original back.
bool finishAdjustmentEditing(bool commit);
private:
LayerAdjustment editedAdjustment() const;
void openAdjustmentEditor(QUuid id, const LayerAdjustment &original, const AdjustmentInput &made);
+147
View File
@@ -0,0 +1,147 @@
#include "Document/EditorSession.h"
#include "Document/LayerGroups.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QtConcurrent>
// Swift's AdjustmentEditing.swift: pixels beneath feed histogram and sampling.
void EditorSession::beginAdjustmentEditing(QUuid id, std::function<void()> done)
{
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (m_adjustmentEditingID != id || m_adjustmentOriginal || m_levels || m_hueSaturation || m_filterEdit || index < 0
|| !m_document->layers[size_t(index)].adjustment) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
const LayerAdjustment original = *m_document->layers[size_t(index)].adjustment;
ProjectSnapshot source = projectSnapshot().value();
// Records stay for live masks and folders; beneath alone shows.
QSet<QUuid> underneath;
for (const LayerHierarchy::Entry &entry : LayerHierarchy::entries(source.manifest.layers)) {
if (entry.layer.id == id)
break;
underneath.insert(entry.layer.id);
}
for (ProjectLayerRecord &layer : source.manifest.layers) {
if (!underneath.contains(layer.id))
layer.isVisible = false;
}
// A watcher per call, as Swift's calls are tasks.
auto *watcher = new QFutureWatcher<AdjustmentInput>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, id, original, done = std::move(done)] {
watcher->deleteLater();
openAdjustmentEditor(id, original, watcher->result());
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
});
// A painted layer flattens there, where a failure is caught.
watcher->setFuture(QtConcurrent::run([source]() -> AdjustmentInput {
try {
const QImage image = ImageExporter::render(source).image;
return {ImportedImage(image, PixelAdjust::thumbnail(image), QStringLiteral("Adjustment input")), std::nullopt};
} catch (const ExportError &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::openAdjustmentEditor(QUuid id, const LayerAdjustment &original, const AdjustmentInput &made)
{
// Swift's guard after the await; an open editor wins.
if (m_adjustmentEditingID != id || m_adjustmentOriginal)
return;
std::optional<QString> failure = made.failure;
// Beyond Swift: an edit begun meanwhile keeps its editor.
if (!failure && (m_levels || m_hueSaturation || m_filterEdit)) {
qCWarning(lcApp) << "an adjustment's editor gave way to an edit begun meanwhile";
m_adjustmentEditingID.reset();
resumeFileRequests();
notify();
return;
}
if (!failure) {
try {
const ImageLayer layer(made.asset.value(), QPointF(0, 0));
switch (original.kind) {
case AdjustmentKind::levels:
m_levels.emplace(layer, std::nullopt);
m_levels->settings = original.levels;
countLevelsHistogram();
break;
case AdjustmentKind::hsv:
m_hueSaturation.emplace(layer.id, made.asset.value(), std::nullopt, layer.transform);
m_hueSaturation->settings = original.resolvedHSV();
break;
case AdjustmentKind::curves:
case AdjustmentKind::exposure:
case AdjustmentKind::gradientMap:
case AdjustmentKind::grain: {
FilterSettings settings;
settings.curves = original.curves;
settings.exposure = original.exposure();
settings.gradientMap = original.gradientMap();
settings.grain = original.grain();
m_filterEdit.emplace(filterKind(original.kind).value(), layer, std::nullopt, settings, std::nullopt);
break;
}
}
} catch (const ExportError &error) {
failure = QString::fromUtf8(error.what());
}
}
if (failure) {
m_adjustmentEditingID.reset();
resumeFileRequests();
setBrushError(failure);
return;
}
m_adjustmentOriginal = original;
beginEdit(QStringLiteral("Edit %1 Adjustment").arg(rawValue(original.kind)));
}
// The kind's edit lives as long as the original does.
LayerAdjustment EditorSession::editedAdjustment() const
{
LayerAdjustment value = m_adjustmentOriginal.value();
switch (value.kind) {
case AdjustmentKind::levels: value.levels = m_levels.value().settings; break;
case AdjustmentKind::hsv: value.hsvSettings = m_hueSaturation.value().settings; break;
case AdjustmentKind::curves: value.curves = m_filterEdit.value().settings.curves; break;
case AdjustmentKind::exposure: value.setExposure(m_filterEdit.value().settings.exposure); break;
case AdjustmentKind::gradientMap: value.setGradientMap(m_filterEdit.value().settings.gradientMap); break;
case AdjustmentKind::grain: value.setGrain(m_filterEdit.value().settings.grain); break;
}
return value;
}
bool EditorSession::previewAdjustmentEditing(bool preview)
{
if (!m_adjustmentEditingID || !m_adjustmentOriginal)
return false;
updateAdjustment(*m_adjustmentEditingID, preview ? editedAdjustment() : *m_adjustmentOriginal);
return true;
}
bool EditorSession::finishAdjustmentEditing(bool commit)
{
if (!m_adjustmentEditingID || !m_adjustmentOriginal)
return false;
updateAdjustment(*m_adjustmentEditingID, commit ? editedAdjustment() : *m_adjustmentOriginal);
m_hueSampleMode.reset();
m_hueTargeting = false;
m_hueTargetDrag.reset();
m_levels.reset();
m_hueSaturation.reset();
m_filterEdit.reset();
endEdit();
m_adjustmentOriginal.reset();
m_adjustmentEditingID.reset();
resumeFileRequests();
++m_canvasFocusRequest;
++m_brushRevision;
notify();
return true;
}
+4
View File
@@ -0,0 +1,4 @@
// Swift's BlurTool extension: what a Blur stroke paints.
public:
// The active layer, or its mask, softened at document size.
std::optional<QImage> blurSample(const CanvasDocument &document, bool mask = false) const;
+52
View File
@@ -0,0 +1,52 @@
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath>
// Swift's BlurTool extension: the layer, or its mask, softened.
std::optional<QImage> EditorSession::blurSample(const CanvasDocument &document, bool mask) const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!layer)
return std::nullopt;
// The softening follows the brush's size.
const double sigma = std::min(30.0, std::max(1.5, m_brushSettings.diameter / 10));
try {
if (mask) {
if (!layer->mask)
return std::nullopt;
QImage context = BrushRaster::context(document.width, document.height, true);
// Past its pixels a mask keeps its edge tone.
const int tone = int(std::round(LayerMask::background(layer->mask->asset.thumbnail) * 255));
context.fill(QColor(tone, tone, tone));
QPainter painter(&context);
// Coverage only adds white: the mask's area starts black.
const LayerTransform placement = layer->maskTransform();
painter.save();
QTransform box;
box.translate(placement.center().x(), placement.center().y());
box.rotateRadians(placement.radians());
painter.setTransform(box, true);
painter.fillRect(QRectF(-placement.size.width() / 2, -placement.size.height() / 2, placement.size.width(), placement.size.height()),
Qt::black);
painter.restore();
LayerRenderer::drawCoverage(layer->mask->asset.image(), placement, painter);
painter.end();
return PixelAdjust::gaussianBlur(context, sigma, true);
}
if (!layer->asset)
return std::nullopt;
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
const LayerTransform transform = displayedTransform(*layer);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
painter.end();
return PixelAdjust::gaussianBlur(context, sigma, false);
} catch (const ExportError &error) {
qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what();
return std::nullopt;
}
}
+180
View File
@@ -0,0 +1,180 @@
#include "Document/BrushStroke.h"
#include "Document/LayerMask.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Rendering/RasterSnapshot.h"
#include <QRandomGenerator>
#include <cstring>
#include <new>
extern "C" {
#include "BrushPixels.h"
#include "HealPixels.h"
}
// The stroke as a layer: bounds, placement, the commit's image.
namespace {
QRectF integral(const QRectF &rect)
{
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
}
// Half-open bounds of the pixels that are there, or empty.
QRectF alphaBounds(const QImage &pixels)
{
size_t edges[4] = {0, 0, 0, 0};
brush_alpha_bounds(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), edges);
if (edges[2] <= edges[0] || edges[3] <= edges[1])
return QRectF();
return QRectF(edges[0], edges[1], edges[2] - edges[0], edges[3] - edges[1]);
}
void drawSource(const ImportedImage &source, const QRectF &rect, QPainter &painter)
{
if (source.raster)
source.raster->draw(rect, painter);
else
BrushRaster::draw(source.image(), rect, painter);
}
}
QRectF BrushStroke::committedBounds() const
{
return integral(m_allocatedBounds.value_or(sourceRect));
}
LayerTransform BrushStroke::committedTransform() const
{
return transform(committedBounds());
}
LayerTransform BrushStroke::transform(const QRectF &bounds) const
{
const QPointF center = pixelToDocument.map(bounds.center());
LayerTransform result = paintTransform;
result.size = {bounds.width() * paintTransform.size.width() / width, bounds.height() * paintTransform.size.height() / height};
result.origin = {center.x() - result.size.width() / 2, center.y() - result.size.height() / 2};
return result;
}
// Swift's heal: the painted spot rebuilt from nearby texture.
void BrushStroke::heal()
{
if (!settings.healing || isMask)
return;
std::optional<QRectF> painted;
for (const auto &[key, coverage] : m_coverage) {
long edges[4];
heal_coverage_bounds(coverage.constBits(), size_t(coverage.width()), size_t(coverage.height()), size_t(coverage.bytesPerLine()), edges);
if (edges[2] <= edges[0] || edges[3] <= edges[1])
continue;
const QRectF rect = QRectF(edges[0], edges[1], edges[2] - edges[0], edges[3] - edges[1]).translated(m_tiles.at(key).rect.topLeft());
painted = painted ? painted->united(rect) : rect;
}
if (!painted)
return;
// Room for the kernel's search, about three spots away.
const double reach = (std::max(painted->width(), painted->height()) + 32) * 3.2;
const QRect region = painted->adjusted(-reach, -reach, reach, reach).intersected(QRectF(0, 0, width, height)).toAlignedRect();
QImage pixels = BrushRaster::context(region.width(), region.height(), false);
{
QPainter painter(&pixels);
const QRectF placed = sourceRect.translated(-region.topLeft());
if (m_source && m_source->raster)
m_source->raster->draw(placed, painter);
else if (m_source)
BrushRaster::draw(m_source->image(), placed, painter);
}
// The kernel reads coverage rows packed, as Swift's contexts are.
const std::unique_ptr<uint8_t[]> painting(new (std::nothrow) uint8_t[size_t(region.width()) * size_t(region.height())]());
if (!painting)
throw ExportError(ExportError::Kind::render);
for (const auto &[key, coverage] : m_coverage) {
const QRect tile = m_tiles.at(key).rect.toRect().translated(-region.topLeft());
const QRect within = tile.intersected(QRect(QPoint(0, 0), region.size()));
for (int y = within.top(); y <= within.bottom(); ++y)
std::memcpy(painting.get() + size_t(y) * size_t(region.width()) + size_t(within.left()),
coverage.constScanLine(y - tile.top()) + (within.left() - tile.left()), size_t(within.width()));
}
if (spot_heal(pixels.bits(), painting.get(), size_t(region.width()), size_t(region.height()), size_t(pixels.bytesPerLine()),
float(settings.opacity), int(settings.healingMode), QRandomGenerator::global()->generate()) != 0)
throw ProjectError(ProjectError::Kind::tooLarge);
for (const auto &[key, coverage] : m_coverage) {
Tile &tile = m_tiles.at(key);
QImage healed = tile.base;
healed.detach();
if (healed.isNull())
throw ExportError(ExportError::Kind::render);
{
QPainter painter(&healed);
BrushRaster::draw(pixels, QRectF(region).translated(-tile.rect.topLeft()), painter);
}
// Swift clips the context; the selection blends here instead.
tile.context = selectionClip ? PixelAdjust::blend(healed, tile.base, *selectionClip,
QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument, false)
: healed;
tile.image = tile.context;
}
}
// Painting only adds alpha: the touched tiles alone are inspected.
PaintSnapshot BrushStroke::paintSnapshot() const
{
std::optional<QRectF> bounds = m_source ? std::optional(sourceRect) : std::nullopt;
for (const auto &[key, tile] : m_tiles) {
if (isMask)
break;
const QRectF rect = alphaBounds(tile.context);
if (rect.isEmpty())
continue;
const QRectF placed = rect.translated(tile.rect.topLeft());
bounds = bounds ? std::optional(bounds->united(placed)) : std::optional(placed);
}
const QRectF crop = bounds.value_or(committedBounds());
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask);
return {ImportedImage(raster, raster->thumbnail(), layer.name), transform(crop), crop};
}
BrushCommit::Input BrushStroke::commitInput() const
{
const QRectF bounds = committedBounds();
std::vector<BrushPatch> shifted;
for (const BrushPatch &patch : patches())
shifted.push_back({patch.rect.translated(-bounds.topLeft()), patch.image});
return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft())};
}
ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop)
{
if (input.sourceRect == crop)
return asset;
QImage context = BrushRaster::context(int(crop.width()), int(crop.height()), true);
// New canvas area has no mask; old coverage stays aligned.
context.fill(Qt::white);
QPainter painter(&context);
drawSource(asset, input.sourceRect.translated(-crop.topLeft()), painter);
painter.end();
return LayerMask::assetFrom(context);
}
BrushCommit::Output BrushCommit::render(const Input &input)
{
QImage context = BrushRaster::context(input.width, input.height, input.mask);
QPainter painter(&context);
if (input.source)
drawSource(*input.source, input.sourceRect, painter);
for (const BrushPatch &patch : input.patches)
BrushRaster::draw(patch.image, patch.rect, painter);
painter.end();
const QRectF fullBounds(0, 0, input.width, input.height);
if (input.mask)
return {LayerMask::assetFrom(context), fullBounds};
// Every pixel with alpha stays, the soft rim included.
const QRectF found = alphaBounds(context);
const QRectF crop = found.isEmpty() ? fullBounds : found;
const QImage image = crop == fullBounds ? context : context.copy(crop.toRect());
if (image.isNull())
throw ExportError(ExportError::Kind::render);
return {ImportedImage(image, PixelAdjust::thumbnail(image), input.name), crop};
}
+466
View File
@@ -0,0 +1,466 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Rendering/RasterSnapshot.h"
#include <QtConcurrent>
#include <cmath>
#include <functional>
#include <stdexcept>
// The stroke's geometry, samples, tiles and coverage.
namespace {
QRectF integral(const QRectF &rect)
{
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
}
// The clone through coverage and a clip: over, or copied.
void fillClone(const QImage &sample, const QImage &coverage, double alpha, QImage &context, const QImage &clip, bool replaces)
{
const bool gray = context.format() == QImage::Format_Grayscale8;
if (sample.format() != QImage::Format_RGBA8888_Premultiplied || (gray && replaces))
throw std::logic_error("a clone samples premultiplied colour, and a mask never copies");
const int channels = gray ? 1 : 4;
for (int y = 0; y < coverage.height(); ++y) {
const uchar *mask = coverage.constScanLine(y), *source = sample.constScanLine(y);
const uchar *within = clip.isNull() ? nullptr : clip.constScanLine(y);
uchar *row = context.scanLine(y);
for (int x = 0; x < coverage.width(); ++x) {
const double k = mask[x] / 255.0 * alpha * (within ? within[x] / 255.0 : 1.0);
// Copying replaces by coverage alone, so clear clears.
const double m = mask[x] / 255.0 * (within ? within[x] / 255.0 : 1.0);
if (replaces ? m <= 0 : k <= 0)
continue;
// A gray sample's edge keeps its coverage, as CoreGraphics draws.
const uchar *from = source + x * 4;
uchar *pixel = row + x * channels;
const double keep = replaces ? 1 - m : 1 - from[3] / 255.0 * k;
for (int c = 0; c < channels; ++c)
pixel[c] = uchar(pixel[c] * keep + from[c] * k + 0.5);
}
}
}
// The tiles a pixel rect touches, by key.
template <typename Body> void eachTile(const QRectF &affected, int width, const Body &body)
{
if (affected.isEmpty())
return;
const qint64 columns = (width + BrushStroke::tileSize - 1) / BrushStroke::tileSize;
const qint64 firstY = qint64(affected.top()) / BrushStroke::tileSize, lastY = (qint64(std::ceil(affected.bottom())) - 1) / BrushStroke::tileSize;
const qint64 firstX = qint64(affected.left()) / BrushStroke::tileSize, lastX = (qint64(std::ceil(affected.right())) - 1) / BrushStroke::tileSize;
for (qint64 y = firstY; y <= lastY; ++y) {
for (qint64 x = firstX; x <= lastX; ++x)
body(y * columns + x, x, y);
}
}
}
double BrushStroke::spacingFraction(double hardness)
{
return hardness >= 1 ? 0.015 : 0.025;
}
BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas)
{
// A placed mask paints in its grid; else the layer's.
const bool placedMask = mask && layer.mask && layer.mask->placement;
const LayerTransform base = placedMask ? *layer.mask->placement : layer.transform;
const int originalWidth = placedMask ? layer.mask->asset.size().width()
: layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width()));
const int originalHeight = placedMask ? layer.mask->asset.size().height()
: layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()));
if (originalWidth < 1 || originalWidth > 30'000 || originalHeight < 1 || originalHeight > 30'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight);
const QRectF originalBounds(0, 0, originalWidth, originalHeight);
const QRectF extent = mask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas)));
// Judged as a double: past int the conversion is undefined.
if (extent.width() < 1 || extent.width() > 1'000'000'000 || extent.height() < 1 || extent.height() > 1'000'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
Grid grid{int(extent.width()), int(extent.height()), originalBounds.translated(-extent.topLeft()),
QTransform::fromTranslate(extent.left(), extent.top()) * originalMapping, base};
grid.paintTransform.size = {grid.width * base.size.width() / originalWidth, grid.height * base.size.height() / originalHeight};
const QPointF center = originalMapping.map(extent.center());
grid.paintTransform.origin = {center.x() - grid.paintTransform.size.width() / 2, center.y() - grid.paintTransform.size.height() / 2};
if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > 2000
|| !std::isfinite(settings.hardness) || settings.hardness < 0 || settings.hardness > 1
|| !std::isfinite(settings.opacity) || settings.opacity < 0.01 || settings.opacity > 1)
throw ProjectError(ProjectError::Kind::tooLarge);
return grid;
}
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas)
: BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas)))
{
}
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid)
: layer(layer), isMask(mask), settings(settings), canvas(canvas), width(grid.width), height(grid.height), sourceRect(grid.sourceRect),
pixelToDocument(grid.pixelToDocument), paintTransform(grid.paintTransform),
m_source(mask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset),
m_paintColor(mask ? QColor::fromRgbF(settings.red, settings.red, settings.red) : QColor::fromRgbF(settings.red, settings.green, settings.blue))
{
}
void BrushStroke::append(QPointF point)
{
if (!std::isfinite(point.x()) || !std::isfinite(point.y()) || std::abs(point.x()) > 10'000'000 || std::abs(point.y()) > 10'000'000)
return;
if (!m_samples.empty() && m_samples.back() == point)
return;
m_samples.push_back(point);
if (m_samples.size() > 4)
m_samples.erase(m_samples.begin());
const size_t n = m_samples.size();
std::vector<BrushSegment> settled;
if (n == 1)
settled = {BrushSegment{float(point.x()), float(point.y()), float(point.x()), float(point.y())}};
else if (n >= 3)
settled = continuousCurve(m_samples[n - 3], m_samples[n - 2], m_samples[n >= 4 ? n - 4 : 0], point);
std::vector<BrushSegment> tail;
if (n >= 2)
tail = {BrushSegment{float(m_samples[n - 2].x()), float(m_samples[n - 2].y()), float(point.x()), float(point.y())}};
renderContinuous(settled, tail);
}
void BrushStroke::flush()
{
const size_t n = m_samples.size();
if (n < 2)
return;
const std::vector<BrushSegment> settled = continuousCurve(m_samples[n - 2], m_samples[n - 1], m_samples[n >= 3 ? n - 3 : 0], m_samples[n - 1]);
renderContinuous(settled, {});
m_samples = {m_samples[n - 1]};
}
// Centripetal Catmull–Rom, subdivided within 0.2 document pixels.
std::vector<BrushSegment> BrushStroke::continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const
{
const auto knot = [](double t, QPointF a, QPointF b) { return t + std::max(0.0001, std::sqrt(std::hypot(b.x() - a.x(), b.y() - a.y()))); };
const auto mix = [](QPointF a, QPointF b, double ta, double tb, double t) {
const double wa = (tb - t) / (tb - ta), wb = (t - ta) / (tb - ta);
return QPointF(a.x() * wa + b.x() * wb, a.y() * wa + b.y() * wb);
};
const double t0 = 0, t1 = knot(t0, before, start), t2 = knot(t1, start, end), t3 = knot(t2, end, after);
const auto point = [&](double u) {
if (u == 0)
return start;
if (u == 1)
return end;
const double t = t1 + (t2 - t1) * u;
const QPointF a = mix(before, start, t0, t1, t), b = mix(start, end, t1, t2, t), c = mix(end, after, t2, t3, t);
return mix(mix(a, b, t0, t2, t), mix(b, c, t1, t3, t), t1, t2, t);
};
std::vector<BrushSegment> result;
const std::function<void(QPointF, QPointF, double, double, int)> subdivide = [&](QPointF a, QPointF b, double lo, double hi, int depth) {
const double dx = b.x() - a.x(), dy = b.y() - a.y(), lengthSquared = dx * dx + dy * dy;
const auto error = [&](QPointF p) {
const double t = lengthSquared > 0 ? std::min(1.0, std::max(0.0, ((p.x() - a.x()) * dx + (p.y() - a.y()) * dy) / lengthSquared)) : 0;
return std::hypot(p.x() - a.x() - t * dx, p.y() - a.y() - t * dy);
};
const double mid = (lo + hi) / 2;
const QPointF m = point(mid);
const double deviation = std::max({error(m), error(point((lo + mid) / 2)), error(point((mid + hi) / 2))});
if (deviation <= 0.2 || depth >= 10) {
result.push_back(BrushSegment{float(a.x()), float(a.y()), float(b.x()), float(b.y())});
return;
}
subdivide(a, m, lo, mid, depth + 1);
subdivide(m, b, mid, hi, depth + 1);
};
subdivide(start, end, 0, 1, 0);
return result;
}
std::set<qint64> BrushStroke::continuousKeys(const std::vector<BrushSegment> &segments) const
{
std::set<qint64> keys;
const double reach = settings.diameter / 2 + 2;
const QTransform inverse = pixelToDocument.inverted();
for (const BrushSegment &s : segments) {
const QRectF box = QRectF(QPointF(std::min(s.x0, s.x1), std::min(s.y0, s.y1)), QPointF(std::max(s.x0, s.x1), std::max(s.y0, s.y1)))
.adjusted(-reach, -reach, reach, reach).intersected(canvas);
if (box.isEmpty())
continue;
const QRectF affected = integral(inverse.mapRect(box)).intersected(QRectF(0, 0, width, height));
eachTile(affected, width, [&](qint64 key, qint64, qint64) { keys.insert(key); });
}
return keys;
}
void BrushStroke::renderContinuous(const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail)
{
const std::set<qint64> tailKeys = continuousKeys(tail);
std::set<qint64> changed = continuousKeys(settled);
changed.insert(tailKeys.begin(), tailKeys.end());
changed.insert(m_tailKeys.begin(), m_tailKeys.end());
const qint64 columns = (width + tileSize - 1) / tileSize;
std::vector<BrushCoverage::Work> work;
for (qint64 key : changed) {
allocateTile(key, key % columns, key / columns);
const Tile &tile = m_tiles.at(key);
if (!m_coverage.contains(key)) {
m_coverage.emplace(key, BrushRaster::context(int(tile.rect.width()), int(tile.rect.height()), true));
m_coverageTiles.emplace(key, BrushCoverage::tile(int(tile.rect.width()), int(tile.rect.height())));
}
work.push_back({&m_coverageTiles.at(key), tile.rect, &m_coverage.at(key)});
}
BrushCoverage::render(work, settled, tail, pixelToDocument, settings, canvas.size());
m_tailKeys = tailKeys;
publish(changed);
}
// Each tile anew: original, then colour × coverage × opacity.
void BrushStroke::publish(const std::set<qint64> &changed)
{
struct Fill {
Tile *tile;
const QImage *coverage;
QImage clip;
QImage sample;
};
// Copies here, where a failure throws; fills side by side.
std::vector<Fill> work;
m_dirtyDocumentRect = std::nullopt;
for (qint64 key : changed) {
Tile &tile = m_tiles.at(key);
tile.context = tile.base;
tile.context.detach();
if (tile.context.isNull())
throw ExportError(ExportError::Kind::render);
work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(tile)});
const QRectF rect = pixelToDocument.mapRect(tile.rect).intersected(canvas);
m_dirtyDocumentRect = m_dirtyDocumentRect ? std::optional(m_dirtyDocumentRect->united(rect)) : std::optional(rect);
}
// Healing shows Swift's dark wash until heal() rebuilds the spot.
const bool healing = settings.healing && !isMask;
const QPainter::CompositionMode mode = !healing && settings.erasing && !isMask ? QPainter::CompositionMode_DestinationOut : QPainter::CompositionMode_SourceOver;
const QColor colour = healing ? QColor::fromRgbF(0.12, 0.12, 0.12) : m_paintColor;
const double alpha = healing ? 0.45 : settings.opacity;
PoolMap::blocking(work, [&](const Fill &item) {
Tile &tile = *item.tile;
if (clone && (!isMask || isBlur))
fillClone(item.sample, *item.coverage, settings.opacity, tile.context, item.clip, replacesWithClone);
else
BrushRaster::fill(colour, *item.coverage, QRectF(QPointF(0, 0), tile.rect.size()), alpha, tile.context, mode, item.clip);
tile.image = tile.context;
});
}
// Swift clips the context; the fill takes the selection instead.
QImage BrushStroke::selectionCoverage(const Tile &tile) const
{
if (!selectionClip)
return QImage();
return PixelAdjust::coverage(*selectionClip, int(tile.rect.width()), int(tile.rect.height()),
QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument);
}
// The clone as this tile sees it, before the fill.
QImage BrushStroke::clonePixels(const Tile &tile) const
{
if (!clone)
return QImage();
const int width = int(tile.rect.width()), height = int(tile.rect.height());
// Tile pixels to clone pixels: grid, document, then offset.
const QTransform toClone = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument
* QTransform::fromTranslate(clone->offset.width(), clone->offset.height());
// Colour even for gray clones: edges carry their coverage.
const QImage &image = clone->image;
if (toClone.type() > QTransform::TxTranslate) {
// Swift's medium quality: bilinear under a scale or turn.
QImage sample = BrushRaster::context(width, height, false);
QPainter painter(&sample);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.setTransform(toClone.inverted());
painter.drawImage(QRectF(QPointF(0, 0), image.size()), image);
painter.end();
return sample;
}
// QPainter snaps a translation; a fraction is resampled by hand.
const double left = std::floor(toClone.dx()), top = std::floor(toClone.dy());
const bool whole = left == toClone.dx() && top == toClone.dy();
QImage sample = BrushRaster::context(whole ? width : width + 1, whole ? height : height + 1, false);
QPainter painter(&sample);
BrushRaster::draw(image, QRectF(-left, -top, image.width(), image.height()), painter);
painter.end();
if (whole)
return sample;
const QImage part = BrushRaster::shiftedByFraction(sample, 1 - (toClone.dx() - left), 1 - (toClone.dy() - top)).copy(1, 1, width, height);
if (part.isNull())
throw ExportError(ExportError::Kind::render);
return part;
}
void BrushStroke::allocateTile(qint64 key, qint64 x, qint64 y)
{
if (m_tiles.contains(key))
return;
const QRectF rect(x * tileSize, y * tileSize, std::min<qint64>(tileSize, width - x * tileSize), std::min<qint64>(tileSize, height - y * tileSize));
const QRectF nextBounds = m_allocatedBounds ? m_allocatedBounds->united(rect) : m_source ? sourceRect.united(rect) : rect;
if (nextBounds.width() > 30'000 || nextBounds.height() > 30'000 || nextBounds.width() * nextBounds.height() > pixelLimit)
throw ProjectError(ProjectError::Kind::tooLarge);
m_allocatedBounds = nextBounds;
QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask);
QPainter painter(&context);
const QRectF initialRect = sourceRect.translated(-rect.topLeft());
if (m_source && m_source->raster) {
m_source->raster->draw(initialRect, painter);
} else if (m_source) {
// This tile's share of the source; a 1×1 stretches.
const QImage source = m_source->image();
const QRectF overlap = rect.intersected(sourceRect);
if (source.width() > 2 && source.height() > 2 && !overlap.isEmpty()) {
const double scaleX = source.width() / sourceRect.width(), scaleY = source.height() / sourceRect.height();
const QRectF crop = integral(QRectF((overlap.left() - sourceRect.left()) * scaleX, (overlap.top() - sourceRect.top()) * scaleY,
overlap.width() * scaleX, overlap.height() * scaleY));
BrushRaster::draw(source, overlap.translated(-rect.topLeft()), painter, crop);
} else {
BrushRaster::draw(source, initialRect, painter);
}
}
painter.end();
m_tiles.emplace(key, Tile{rect, context, std::nullopt, context});
}
std::vector<BrushPatch> BrushStroke::patches() const
{
std::vector<BrushPatch> result;
for (const auto &[key, tile] : m_tiles) {
if (tile.image)
result.push_back({tile.rect, *tile.image});
}
return result;
}
// Redrawing restarts from each tile's original content.
void BrushStroke::paintCanvas(bool withinSource, const std::function<void(QPainter &)> &draw)
{
QRectF area = canvas;
if (selectionClip)
area = area.intersected(selectionClip->rect);
if (area.isEmpty())
return;
const QTransform inverse = pixelToDocument.inverted();
QRectF affected = integral(inverse.mapRect(area)).intersected(QRectF(0, 0, width, height));
if (withinSource)
affected = affected.intersected(sourceRect);
if (affected.isEmpty())
return;
eachTile(affected, width, [&](qint64 key, qint64 x, qint64 y) {
allocateTile(key, x, y);
Tile &tile = m_tiles.at(key);
QImage drawn = m_source ? tile.base : BrushRaster::context(int(tile.rect.width()), int(tile.rect.height()), isMask);
drawn.detach();
if (drawn.isNull())
throw ExportError(ExportError::Kind::render);
QPainter painter(&drawn);
painter.setTransform(inverse * QTransform::fromTranslate(-tile.rect.left(), -tile.rect.top()));
painter.setClipRect(canvas);
draw(painter);
painter.end();
// Swift clips to the selection; the twin mixes through it.
if (selectionClip)
drawn = PixelAdjust::blend(drawn, tile.base, *selectionClip, QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument, isMask);
tile.context = drawn;
tile.image = drawn;
});
m_dirtyDocumentRect = canvas;
}
void BrushStroke::fillGradient(GradientShape shape, QPointF start, QPointF end, const std::array<QColor, 2> &colors, double opacity)
{
// Padded both ways, as Swift's before and after locations.
QGradient gradient = shape == GradientShape::linear
? QGradient(QLinearGradient(start, end))
: QGradient(QRadialGradient(start, std::hypot(end.x() - start.x(), end.y() - start.y())));
gradient.setColorAt(0, colors[0]);
gradient.setColorAt(1, colors[1]);
paintCanvas(false, [&](QPainter &painter) {
painter.setOpacity(std::min(1.0, std::max(0.0, opacity)));
painter.fillRect(canvas, gradient);
});
}
void BrushStroke::fill(const QColor &color)
{
paintCanvas(false, [&](QPainter &painter) { painter.fillRect(canvas, color); });
}
void BrushStroke::clearPixels()
{
paintCanvas(true, [&](QPainter &painter) {
painter.setCompositionMode(QPainter::CompositionMode_DestinationOut);
painter.fillRect(canvas, Qt::black);
});
}
// Cuts the selected pixels from the source; false with none.
bool BrushStroke::liftSelection()
{
if (isMask || !m_source || !selectionClip || !selectionClip->coverage)
return false;
const QRectF region = integral(pixelToDocument.inverted().mapRect(selectionClip->rect)).intersected(sourceRect);
if (region.width() < 1 || region.height() < 1)
return false;
QImage lifted = BrushRaster::context(int(region.width()), int(region.height()), false);
QPainter painter(&lifted);
painter.translate(-region.left(), -region.top());
if (m_source->raster)
m_source->raster->draw(sourceRect, painter);
else
BrushRaster::draw(m_source->image(), sourceRect, painter);
painter.end();
// Swift clips the context; the selection's coverage multiplies in.
const QImage clip = PixelAdjust::coverage(*selectionClip, lifted.width(), lifted.height(), QTransform::fromTranslate(region.left(), region.top()) * pixelToDocument);
QPainter multiplying(&lifted);
multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn);
multiplying.drawImage(QRectF(lifted.rect()), BrushRaster::alphaView(clip));
multiplying.end();
m_lifted = Lifted{lifted, region};
return true;
}
// Tiles anew: a hole, and the pixels `offset` away.
void BrushStroke::moveLifted(QSizeF offset, bool duplicate)
{
if (!m_lifted || !selectionClip)
return;
const QTransform inverse = pixelToDocument.inverted();
const QPointF zero = inverse.map(QPointF(0, 0)), moved = inverse.map(QPointF(offset.width(), offset.height()));
const QRectF target = m_lifted->rect.translated(moved.x() - zero.x(), moved.y() - zero.y());
const bool whole = target.left() == std::round(target.left()) && target.top() == std::round(target.top());
// QPainter snaps a fraction; Swift's high quality resamples it.
const QPointF place(std::floor(target.left()), std::floor(target.top()));
const QImage lifted = whole ? m_lifted->image : BrushRaster::shiftedByFraction(m_lifted->image, target.left() - place.x(), target.top() - place.y());
const QRectF needed = integral(m_lifted->rect.united(target)).intersected(QRectF(0, 0, width, height));
std::set<qint64> keys = m_moveTiles;
eachTile(needed, width, [&](qint64 key, qint64 x, qint64 y) {
allocateTile(key, x, y);
keys.insert(key);
});
for (qint64 key : keys) {
Tile &tile = m_tiles.at(key);
QImage drawn = tile.base;
drawn.detach();
if (drawn.isNull())
throw ExportError(ExportError::Kind::render);
QPainter painter(&drawn);
if (!duplicate) {
const QImage clip = PixelAdjust::coverage(*selectionClip, drawn.width(), drawn.height(),
QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument);
painter.setCompositionMode(QPainter::CompositionMode_DestinationOut);
painter.drawImage(QRectF(drawn.rect()), BrushRaster::alphaView(clip));
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
}
painter.drawImage(QRectF(place - tile.rect.topLeft(), lifted.size()), lifted);
painter.end();
tile.context = drawn;
tile.image = drawn;
}
m_moveTiles = keys;
m_dirtyDocumentRect = canvas;
}
+131
View File
@@ -0,0 +1,131 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <cmath>
#include <numeric>
#include <stdexcept>
QString rawValue(SpotHealingMode mode)
{
switch (mode) {
case SpotHealingMode::contentAware:
return QStringLiteral("Content-Aware");
case SpotHealingMode::createTexture:
return QStringLiteral("Create Texture");
case SpotHealingMode::proximityMatch:
return QStringLiteral("Proximity Match");
}
throw std::logic_error("unknown spot healing mode");
}
QImage BrushRaster::context(int width, int height, bool mask)
{
QImage surface(width, height, mask ? QImage::Format_Grayscale8 : QImage::Format_RGBA8888_Premultiplied);
if (surface.isNull())
throw ExportError(ExportError::Kind::render);
surface.fill(0);
return surface;
}
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
{
context.save();
context.setRenderHint(QPainter::SmoothPixmapTransform, false);
context.setCompositionMode(QPainter::CompositionMode_Source);
context.drawImage(rect, image, source.isNull() ? QRectF(image.rect()) : source);
context.restore();
}
QImage BrushRaster::alphaView(const QImage &mask)
{
if (mask.format() != QImage::Format_Grayscale8)
throw std::logic_error("a layer mask must be 8-bit gray");
return QImage(mask.constBits(), mask.width(), mask.height(), mask.bytesPerLine(), QImage::Format_Alpha8);
}
QRectF BrushRaster::visibleRect(const QPainter &context)
{
const QPaintDevice &device = *context.device();
// A widget's size is logical; an image's is device pixels.
QTransform toDevice = context.combinedTransform();
if (device.devType() == QInternal::Widget)
toDevice *= QTransform::fromScale(1 / device.devicePixelRatio(), 1 / device.devicePixelRatio());
const QRectF bounds = toDevice.inverted().mapRect(QRectF(0, 0, device.width(), device.height()));
return context.hasClipping() ? bounds.intersected(context.clipBoundingRect()) : bounds;
}
QTransform BrushRaster::pixelToDocument(const LayerTransform &transform, int width, int height)
{
const QPointF center = transform.center();
QTransform map;
map.translate(center.x(), center.y());
map.rotateRadians(transform.radians());
map.scale(transform.size.width() / width * (transform.flipX ? -1 : 1),
transform.size.height() / height * (transform.flipY ? -1 : 1));
map.translate(-width / 2.0, -height / 2.0);
return map;
}
// Source-over of the colour at coverage × alpha, bytewise.
void BrushRaster::fill(const QColor &color, const QImage &coverage, const QRectF &rect, double alpha, QImage &context,
QPainter::CompositionMode mode, const QImage &clip)
{
if (coverage.format() != QImage::Format_Grayscale8 || coverage.size() != rect.size().toSize() || rect.topLeft() != rect.topLeft().toPoint())
throw std::logic_error("coverage must be gray and 1:1 with its rect");
if (!clip.isNull() && (clip.format() != QImage::Format_Grayscale8 || clip.size() != coverage.size()))
throw std::logic_error("a clip must be gray and sized as the coverage");
// Erasing takes coverage out; a mask never erases.
const bool erasing = mode == QPainter::CompositionMode_DestinationOut;
if ((!erasing && mode != QPainter::CompositionMode_SourceOver) || (erasing && context.format() == QImage::Format_Grayscale8))
throw std::logic_error("a brush fills source-over, or erases pixels");
const int left = int(rect.left()), top = int(rect.top());
const bool gray = context.format() == QImage::Format_Grayscale8;
if (!gray && context.format() != QImage::Format_RGBA8888_Premultiplied)
throw std::logic_error("a brush context is premultiplied RGBA or gray");
// What each channel moves towards; erasing moves towards nothing.
const double target[4] = {erasing ? 0 : color.redF() * 255, erasing ? 0 : color.greenF() * 255, erasing ? 0 : color.blueF() * 255,
erasing ? 0 : 255.0};
const int channels = gray ? 1 : 4;
for (int y = 0; y < coverage.height(); ++y) {
const uchar *mask = coverage.constScanLine(y);
// A clip scales the coverage, rounded once, as CoreGraphics composites.
const uchar *within = clip.isNull() ? nullptr : clip.constScanLine(y);
uchar *row = context.scanLine(top + y) + left * channels;
for (int x = 0; x < coverage.width(); ++x) {
const double k = mask[x] / 255.0 * alpha * (within ? within[x] / 255.0 : 1.0);
if (k <= 0)
continue;
// Values are never negative: adding a half rounds them.
uchar *pixel = row + x * channels;
for (int c = 0; c < channels; ++c)
pixel[c] = uchar(pixel[c] * (1 - k) + target[c] * k + 0.5);
}
}
}
QImage BrushRaster::shiftedByFraction(const QImage &image, double fx, double fy)
{
QImage shifted = BrushRaster::context(image.width() + 1, image.height() + 1, false);
// Weights in 1/65536: the pixel, left, above, above left.
const qint64 here = std::lround((1 - fx) * (1 - fy) * 65536), left = std::lround(fx * (1 - fy) * 65536);
const qint64 above = std::lround((1 - fx) * fy * 65536), corner = 65536 - here - left - above;
uchar *const out = shifted.bits();
const uchar *const in = image.constBits();
const qsizetype outStride = shifted.bytesPerLine(), inStride = image.bytesPerLine();
const int width = image.width(), height = image.height();
std::vector<int> rows(size_t(shifted.height()));
std::iota(rows.begin(), rows.end(), 0);
PoolMap::blocking(rows, [&](int y) {
const uchar *row = y < height ? in + y * inStride : nullptr, *up = y > 0 ? in + (y - 1) * inStride : nullptr;
uchar *line = out + y * outStride;
for (int x = 0; x <= width; ++x) {
for (int c = 0; c < 4; ++c) {
const qint64 sum = (row && x < width ? here * row[x * 4 + c] : 0) + (row && x > 0 ? left * row[(x - 1) * 4 + c] : 0)
+ (up && x < width ? above * up[x * 4 + c] : 0) + (up && x > 0 ? corner * up[(x - 1) * 4 + c] : 0);
line[x * 4 + c] = uchar((sum + 32768) >> 16);
}
}
});
return shifted;
}
+190
View File
@@ -0,0 +1,190 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include "Document/Gradient.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include "Rendering/BrushCoverage.h"
#include <QImage>
#include <QPainter>
#include <array>
#include <functional>
#include <map>
#include <set>
struct BrushPatch {
QRectF rect;
QImage image;
};
// Swift's three ways of healing a spot, in its order.
enum class SpotHealingMode { contentAware, createTexture, proximityMatch };
inline constexpr std::array allSpotHealingModes{SpotHealingMode::contentAware, SpotHealingMode::createTexture, SpotHealingMode::proximityMatch};
QString rawValue(SpotHealingMode mode);
// Tip, colour, and what the stroke does with them.
struct BrushSettings {
double diameter = 40;
double hardness = 1;
double red = 0;
double green = 0;
double blue = 0;
// Caps the whole stroke: overlapping dabs never exceed it.
double opacity = 1;
// Erase clears the layer's pixels instead of painting on them.
bool erasing = false;
// Spot healing takes nearby pixels instead of the colour.
bool healing = false;
SpotHealingMode healingMode = SpotHealingMode::contentAware;
};
// Shared raster drawing; coverage is copied without color conversion.
namespace BrushRaster {
QImage context(int width, int height, bool mask);
// Copies `image`, or its `source` part, into `rect`.
void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF());
// A solid colour through gray coverage, and a clip, 1:1.
void fill(const QColor &color, const QImage &coverage, const QRectF &rect, double alpha, QImage &context,
QPainter::CompositionMode mode = QPainter::CompositionMode_SourceOver, const QImage &clip = QImage());
// Gray mask bytes read as alpha, without a copy.
QImage alphaView(const QImage &mask);
// CoreGraphics boundingBoxOfClipPath: the clip within the device.
QRectF visibleRect(const QPainter &context);
// Premultiplied pixels shifted right and down by fractions, bilinear.
QImage shiftedByFraction(const QImage &image, double fx, double fy);
QTransform pixelToDocument(const LayerTransform &transform, int width, int height);
}
// A stroke's pixels as a painted layer, placed and bounded.
struct PaintSnapshot {
ImportedImage asset;
LayerTransform transform;
QRectF bounds;
};
// Swift's BrushCommit actor: the stroke's tiles made one image.
namespace BrushCommit {
struct Input {
int width;
int height;
std::optional<ImportedImage> source;
std::vector<BrushPatch> patches;
bool mask;
QString name;
QRectF sourceRect;
};
struct Output {
ImportedImage asset;
QRectF pixelBounds;
};
ImportedImage expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop);
Output render(const Input &input);
}
// Only touched 256 px tiles hold pixels; snapshots copy those.
class BrushStroke {
public:
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas);
const ImageLayer layer;
const bool isMask;
const BrushSettings settings;
const QRectF canvas;
const int width;
const int height;
const QRectF sourceRect;
const QTransform pixelToDocument;
const LayerTransform paintTransform;
// Swift's Int: the budget left may run below zero.
qint64 pixelLimit = 100'000'000;
// Limits every edit to the document selection; nil when none.
std::optional<SelectionClip> selectionClip;
// Clone Stamp: a document-sized image, and each point's offset.
struct Clone {
QImage image;
QSizeF offset;
};
std::optional<Clone> clone;
// Blur: the clone is the layer softened, in place.
bool isBlur = false;
// The clone replaces what is under the tip, clearing too.
bool replacesWithClone = false;
// The undo name when the stroke's kind says none.
std::optional<QString> editName;
static constexpr int tileSize = 256;
// Soft-tip deposition rate, shared with the continuous integral.
static double spacingFraction(double hardness);
// A sample; the newest piece shows as a straight tail.
void append(QPointF point);
// Replaces the tail with the curve's last piece; repeatable.
void flush();
std::vector<BrushPatch> patches() const;
const std::optional<QRectF> &dirtyDocumentRect() const { return m_dirtyDocumentRect; }
// The selection, or the canvas, filled with one colour.
void fill(const QColor &color);
// Swift's fillGradient: across the canvas, linear or radial.
void fillGradient(GradientShape shape, QPointF start, QPointF end, const std::array<QColor, 2> &colors, double opacity);
// Pixels inside the selection made transparent, where pixels are.
void clearPixels();
// Cuts the selected pixels from the source; false with none.
bool liftSelection();
// The tiles anew: a hole, and the pixels `offset` away.
void moveLifted(QSizeF offset, bool duplicate);
// A placed mask as the stroke leaves it (LayerMask.cpp).
std::optional<QImage> placedMaskPreview(const LayerTransform &placement) const;
QRectF committedBounds() const;
LayerTransform committedTransform() const;
LayerTransform transform(const QRectF &bounds) const;
// Spot Healing's end: the painted spot rebuilt from around it.
void heal();
PaintSnapshot paintSnapshot() const;
BrushCommit::Input commitInput() const;
private:
struct Tile {
QRectF rect;
QImage context;
std::optional<QImage> image;
QImage base;
};
struct Lifted {
QImage image;
QRectF rect;
};
// The grid the stroke paints in, made before the members.
struct Grid {
int width;
int height;
QRectF sourceRect;
QTransform pixelToDocument;
LayerTransform paintTransform;
};
static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas);
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid);
std::vector<BrushSegment> continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const;
std::set<qint64> continuousKeys(const std::vector<BrushSegment> &segments) const;
void renderContinuous(const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail);
void publish(const std::set<qint64> &changed);
QImage selectionCoverage(const Tile &tile) const;
QImage clonePixels(const Tile &tile) const;
void allocateTile(qint64 key, qint64 x, qint64 y);
// `draw` in document coordinates over every tile the canvas covers.
void paintCanvas(bool withinSource, const std::function<void(QPainter &)> &draw);
// The pixels the stroke starts from: mask's or layer's.
const std::optional<ImportedImage> m_source;
const QColor m_paintColor;
std::optional<QRectF> m_allocatedBounds;
std::vector<QPointF> m_samples;
std::optional<QRectF> m_dirtyDocumentRect;
// Tile keys, row × columns + column, pass int.
std::map<qint64, Tile> m_tiles;
std::map<qint64, BrushCoverage::Tile> m_coverageTiles;
std::set<qint64> m_tailKeys;
// Per-tile gray coverage; the tile is recomposed from it.
std::map<qint64, QImage> m_coverage;
// Selected pixels cut from the source, in layer pixels.
std::optional<Lifted> m_lifted;
std::set<qint64> m_moveTiles;
};
+75
View File
@@ -0,0 +1,75 @@
#include "Document/CanvasSize.h"
#include <cmath>
QString rawValue(CanvasUnit unit)
{
switch (unit) {
case CanvasUnit::pixels:
return QStringLiteral("Pixels");
case CanvasUnit::percent:
return QStringLiteral("Percent");
case CanvasUnit::inches:
return QStringLiteral("Inches");
case CanvasUnit::centimeters:
return QStringLiteral("Centimeters");
}
Q_UNREACHABLE();
}
CanvasSizeDraft::CanvasSizeDraft(qint64 width, qint64 height, double resolution)
: originalWidth(width), originalHeight(height), resolution(resolution), width(double(width)), height(double(height))
{
}
bool CanvasSizeDraft::valid() const
{
// NaN and infinity fail both range tests.
return std::round(width) >= 1 && std::round(width) <= 30'000 && std::round(height) >= 1 && std::round(height) <= 30'000;
}
double CanvasSizeDraft::displayed(bool widthAxis) const
{
const double original = double(widthAxis ? originalWidth : originalHeight);
const double pixels = (widthAxis ? width : height) - (relative ? original : 0);
switch (unit) {
case CanvasUnit::pixels:
return pixels;
case CanvasUnit::percent:
return pixels / original * 100;
case CanvasUnit::inches:
return pixels / resolution;
case CanvasUnit::centimeters:
return pixels / resolution * 2.54;
}
Q_UNREACHABLE();
}
void CanvasSizeDraft::set(double value, bool widthAxis)
{
const double original = double(widthAxis ? originalWidth : originalHeight);
double pixels = value;
if (unit == CanvasUnit::percent)
pixels = value / 100 * original;
else if (unit == CanvasUnit::inches)
pixels = value * resolution;
else if (unit == CanvasUnit::centimeters)
pixels = value / 2.54 * resolution;
const double total = pixels + (relative ? original : 0);
if (widthAxis) {
width = total;
if (locked)
height = total * double(originalHeight) / double(originalWidth);
} else {
height = total;
if (locked)
width = total * double(originalWidth) / double(originalHeight);
}
}
QPointF CanvasSizeOptions::offset(qint64 fromWidth, qint64 oldHeight) const
{
if (contentOffset)
return *contentOffset;
// Floor: growth lands right and bottom, cuts left and top.
return QPointF(std::floor(double(width - fromWidth) * double(anchor % 3) / 2), std::floor(double(height - oldHeight) * double(anchor / 3) / 2));
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <QPointF>
#include <QString>
#include <QtGlobal>
#include <optional>
enum class CanvasUnit { pixels, percent, inches, centimeters };
// The name users see.
QString rawValue(CanvasUnit unit);
// What the canvas size sheet edits, in final pixels.
struct CanvasSizeDraft {
CanvasSizeDraft(qint64 width, qint64 height, double resolution);
qint64 originalWidth;
qint64 originalHeight;
double resolution;
double width;
double height;
bool relative = false;
bool locked = false;
CanvasUnit unit = CanvasUnit::pixels;
bool valid() const;
double displayed(bool widthAxis) const;
void set(double value, bool widthAxis);
};
struct CanvasExtensionColor {
double red;
double green;
double blue;
};
struct CanvasSizeOptions {
qint64 width;
qint64 height;
// Row-major, top-left through bottom-right.
qint64 anchor = 4;
std::optional<CanvasExtensionColor> fill = std::nullopt;
// Crop supplies its own translation of the content.
std::optional<QPointF> contentOffset = std::nullopt;
QPointF offset(qint64 fromWidth, qint64 oldHeight) const;
};
+10
View File
@@ -0,0 +1,10 @@
// Swift's CloneStamp extension: where Clone Stamp copies from.
public:
// Alt-click: the source; a new source starts a new alignment.
void setCloneSource(QPointF point);
// The whole-pixel offset a stroke from `point` copies with.
std::optional<QSizeF> cloneStrokeOffset(QPointF point) const;
// The source for a brush at `point`: the canvas's crosshair.
std::optional<QPointF> cloneSamplePoint(QPointF point) const;
// What a stroke copies from, document-sized, taken as it starts.
std::optional<QImage> cloneSample(const CanvasDocument &document) const;
+61
View File
@@ -0,0 +1,61 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath>
// Swift's CloneStamp extension: the source, its offset, the sample.
void EditorSession::setCloneSource(QPointF point)
{
if (!std::isfinite(point.x()) || !std::isfinite(point.y()))
return;
m_cloneSource = point;
m_cloneOffset = std::nullopt;
notify();
}
void EditorSession::setCloneSettings(const CloneSettings &settings)
{
m_cloneSettings = settings;
notify();
}
// Shared by stroke and preview: what a click stamps.
std::optional<QSizeF> EditorSession::cloneStrokeOffset(QPointF point) const
{
if (!m_cloneSource)
return std::nullopt;
if (m_cloneSettings.aligned && m_cloneOffset)
return m_cloneOffset;
return QSizeF(std::round(m_cloneSource->x() - point.x()), std::round(m_cloneSource->y() - point.y()));
}
// The source itself until a stroke fixes the offset.
std::optional<QPointF> EditorSession::cloneSamplePoint(QPointF point) const
{
if (!m_cloneSource)
return std::nullopt;
if (!m_cloneOffset || (!m_cloneSettings.aligned && !m_brushStroke))
return m_cloneSource;
return point + QPointF(m_cloneOffset->width(), m_cloneOffset->height());
}
std::optional<QImage> EditorSession::cloneSample(const CanvasDocument &document) const
{
try {
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
if (m_cloneSettings.sampleAllLayers) {
drawLiveComposite(document, painter);
} else if (const std::optional<ImageLayer> layer = activeLayer(); layer && layer->asset) {
const LayerTransform transform = displayedTransform(*layer);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
}
painter.end();
return context;
} catch (const ExportError &error) {
qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what();
return std::nullopt;
}
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Clone Stamp's options-bar settings.
struct CloneSettings {
// The source follows the brush, keeping its offset between strokes.
bool aligned = true;
// Every visible layer as shown, not the active one alone.
bool sampleAllLayers = false;
friend bool operator==(const CloneSettings &, const CloneSettings &) = default;
};
+32
View File
@@ -0,0 +1,32 @@
// Swift's ColorPalette extension: foreground and background colours.
public:
// The brush's own colour, as Swift's computed property.
PaletteColor foregroundColor() const;
void setForegroundColor(const PaletteColor &color);
bool canEditPalette() const;
// A mask's palette is black and white: its paint.
PaletteColor paletteColor(bool background) const;
void setPaletteColor(const PaletteColor &color, bool background);
void swapPaletteColors();
void resetPaletteColors();
void openColorPicker(bool background);
// The Type bar's swatch: open text's colour, else the foreground.
PaletteColor typeColor() const;
void openTextColorPicker();
void closeColorPicker(bool commit);
// The picker on an end of the open Gradient Map.
void openGradientMapColorPicker(bool highlights);
// The Gradient Map follows the picker's working colour.
void previewGradientMapColor();
// The picker on an effect of the open panel.
void openEffectColorPicker(LayerEffectKind kind);
// The effect follows the picker's working colour.
void previewEffectColor();
// Swift writes the picker's hsb in place; here, the session.
void setColorPickerHSB(const PickerHSB &hsb);
void sampleIntoColorPicker(QPointF point);
// The visible layers' sRGB at a pixel; none where clear.
std::optional<PaletteColor> sampleCompositeColor(QPointF point) const;
private:
void setGradientMapColor(const PaletteColor &color, bool highlights);
+338
View File
@@ -0,0 +1,338 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
#include <algorithm>
#include <cmath>
PaletteColor PaletteColor::quantized() const
{
const auto snap = [](double value) { return std::round(value * 255) / 255; };
return {snap(red), snap(green), snap(blue)};
}
QString PaletteColor::hex() const
{
const auto byte = [](double value) { return int(std::round(value * 255)); };
return QString::asprintf("%02X%02X%02X", byte(red), byte(green), byte(blue));
}
std::optional<PaletteColor> PaletteColor::fromHex(const QString &hex)
{
// Swift trims spaces and tabs, not line breaks.
const auto blank = [](QChar character) { return character.category() == QChar::Separator_Space || character == u'\t'; };
qsizetype start = 0, end = hex.size();
while (start < end && blank(hex.at(start)))
++start;
while (end > start && blank(hex.at(end - 1)))
--end;
QString text = hex.mid(start, end - start);
if (text.startsWith(u'#'))
text.remove(0, 1);
if (text.size() == 3)
text = QString(text.at(0)) + text.at(0) + text.at(1) + text.at(1) + text.at(2) + text.at(2);
if (text.size() != 6)
return std::nullopt;
// Swift's UInt32(_:radix:): a sign may lead; minus holds zero alone.
const bool negative = text.front() == u'-';
uint value = 0;
for (const QChar character : QStringView(text).mid(negative || text.front() == u'+' ? 1 : 0)) {
const char16_t unit = character.unicode();
const int digit = unit >= u'0' && unit <= u'9' ? unit - u'0' : unit >= u'a' && unit <= u'f' ? unit - u'a' + 10 : unit >= u'A' && unit <= u'F' ? unit - u'A' + 10 : -1;
if (digit < 0)
return std::nullopt;
value = value * 16 + uint(digit);
}
if (negative && value != 0)
return std::nullopt;
return PaletteColor{double((value >> 16) & 0xFF) / 255, double((value >> 8) & 0xFF) / 255, double(value & 0xFF) / 255};
}
PickerHSB::PickerHSB(const PaletteColor &color) : hue(0), saturation(0), brightness(0)
{
setRGB(color);
}
PaletteColor PickerHSB::rgb() const
{
const double h = std::fmod(std::fmod(hue, 360) + 360, 360) / 60;
const double c = brightness * saturation;
const double x = c * (1 - std::abs(std::fmod(h, 2) - 1));
const double m = brightness - c;
switch (int(h)) {
case 0:
return {c + m, x + m, m};
case 1:
return {x + m, c + m, m};
case 2:
return {m, c + m, x + m};
case 3:
return {m, x + m, c + m};
case 4:
return {x + m, m, c + m};
default:
return {c + m, m, x + m};
}
}
void PickerHSB::setRGB(const PaletteColor &color)
{
const double high = std::max({color.red, color.green, color.blue});
const double low = std::min({color.red, color.green, color.blue});
const double delta = high - low;
brightness = high;
if (high > 0)
saturation = delta / high;
if (delta <= 0)
return;
double h;
if (high == color.red)
h = (color.green - color.blue) / delta;
else if (high == color.green)
h = (color.blue - color.red) / delta + 2;
else
h = (color.red - color.green) / delta + 4;
h *= 60;
hue = h < 0 ? h + 360 : h;
}
QString ColorPickerTarget::title() const
{
if (kind == Kind::text)
return QStringLiteral("Color Picker (Text Color)");
if (kind == Kind::gradientMap)
return highlights ? QStringLiteral("Color Picker (Gradient Map Highlights)") : QStringLiteral("Color Picker (Gradient Map Shadows)");
if (kind == Kind::effect)
return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect));
return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)");
}
// Swift's ColorPalette extension: the palette and its picker.
PaletteColor EditorSession::foregroundColor() const
{
return {m_brushSettings.red, m_brushSettings.green, m_brushSettings.blue};
}
void EditorSession::setForegroundColor(const PaletteColor &color)
{
BrushSettings settings = m_brushSettings;
settings.red = color.red;
settings.green = color.green;
settings.blue = color.blue;
setBrushSettings(settings);
}
void EditorSession::setBackgroundColor(const PaletteColor &color)
{
m_backgroundColor = color;
refreshGradient();
notify();
}
bool EditorSession::canEditPalette() const
{
return !m_isProjectBusy && !m_brushStroke;
}
PaletteColor EditorSession::paletteColor(bool background) const
{
if (m_isMaskSelected)
return (background ? !m_maskPaintWhite : m_maskPaintWhite) ? PaletteColor::white() : PaletteColor::black();
return background ? m_backgroundColor : foregroundColor();
}
void EditorSession::setPaletteColor(const PaletteColor &color, bool background)
{
if (!canEditPalette())
return;
if (m_isMaskSelected) {
const bool white = color == PaletteColor::white();
setMaskPaintWhite(background ? !white : white);
} else if (background) {
setBackgroundColor(color);
} else {
setForegroundColor(color);
// Open text follows the swatch; drafts live under Type.
if (m_textDraft) {
changeTextStyle([&color](LayerTextStyle &style) {
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
}
}
}
void EditorSession::swapPaletteColors()
{
if (!canEditPalette())
return;
if (m_isMaskSelected) {
setMaskPaintWhite(!m_maskPaintWhite);
} else {
const PaletteColor old = foregroundColor();
setPaletteColor(m_backgroundColor, false);
setBackgroundColor(old);
}
}
void EditorSession::resetPaletteColors()
{
if (!canEditPalette())
return;
if (m_isMaskSelected) {
setMaskPaintWhite(false);
} else {
setPaletteColor(PaletteColor::black(), false);
setBackgroundColor(PaletteColor::white());
}
}
void EditorSession::openColorPicker(bool background)
{
if (!canEditPalette() || m_isMaskSelected)
return;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
notify();
}
PaletteColor EditorSession::typeColor() const
{
if (!m_textDraft)
return foregroundColor();
return {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue};
}
void EditorSession::openTextColorPicker()
{
if (!canEditPalette() || m_colorPicker || m_tool != NavigationTool::type)
return;
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::text, false, draftID}, typeColor());
notify();
}
void EditorSession::closeColorPicker(bool commit)
{
if (!m_colorPicker)
return;
// Closed first, so each signal sees it gone.
const ColorPickerState picker = *std::exchange(m_colorPicker, std::nullopt);
const PaletteColor color = picker.color();
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
if (commit && picker.target.kind == ColorPickerTarget::Kind::palette && !m_isMaskSelected) {
setPaletteColor(color, picker.target.background);
} else if (commit && picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
if (draftID) {
changeTextStyle([&color](LayerTextStyle &style) {
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
} else {
m_textDefaults.red = color.red;
m_textDefaults.green = color.green;
m_textDefaults.blue = color.blue;
}
// The text colour is the foreground: the swatch follows.
if (!m_isMaskSelected)
setForegroundColor(color);
} else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) {
// The end previewed the working colour; Cancel puts it back.
setGradientMapColor(commit ? color : picker.original, picker.target.highlights);
} else if (picker.target.kind == ColorPickerTarget::Kind::effect) {
const PaletteColor chosen = commit ? color : picker.original;
changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); });
}
notify();
}
void EditorSession::openGradientMapColorPicker(bool highlights)
{
// A commit keeps the project busy: no `committing` term.
if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::gradientMap)
return;
const AdjustmentColor value = highlights ? m_filterEdit->settings.gradientMap.highlights : m_filterEdit->settings.gradientMap.shadows;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::gradientMap, false, std::nullopt, highlights}, PaletteColor{value.red, value.green, value.blue});
notify();
}
void EditorSession::openEffectColorPicker(LayerEffectKind kind)
{
if (!canEditPalette() || m_colorPicker || !m_effectsEditing)
return;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::effect, false, std::nullopt, false, kind}, editingEffects().color(kind).value_or(PaletteColor::black()));
notify();
}
void EditorSession::previewEffectColor()
{
if (!m_colorPicker || m_colorPicker->target.kind != ColorPickerTarget::Kind::effect)
return;
const PaletteColor color = m_colorPicker->color();
const LayerEffectKind kind = m_colorPicker->target.effect;
changeEffects([&](LayerEffects &effects) { effects.setColor(color, kind); });
}
void EditorSession::previewGradientMapColor()
{
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap)
setGradientMapColor(m_colorPicker->color(), m_colorPicker->target.highlights);
}
void EditorSession::setGradientMapColor(const PaletteColor &color, bool highlights)
{
// An adjustment's editor can close under its picker (9.5c).
if (!m_filterEdit || m_filterEdit->kind != FilterKind::gradientMap)
return;
const FilterEdit &edit = *m_filterEdit;
FilterSettings settings = edit.settings;
(highlights ? settings.gradientMap.highlights : settings.gradientMap.shadows) = AdjustmentColor(color);
if (settings == edit.settings)
return;
updateFilter(settings, edit.preview);
}
void EditorSession::setColorPickerHSB(const PickerHSB &hsb)
{
if (!m_colorPicker || m_colorPicker->hsb == hsb)
return;
m_colorPicker->hsb = hsb;
notify();
}
void EditorSession::sampleIntoColorPicker(QPointF point)
{
if (!m_colorPicker)
return;
const std::optional<PaletteColor> color = sampleCompositeColor(point);
if (!color)
return;
PickerHSB hsb = m_colorPicker->hsb;
hsb.setRGB(*color);
setColorPickerHSB(hsb);
}
std::optional<PaletteColor> EditorSession::sampleCompositeColor(QPointF point) const
{
if (!m_document || !(point.x() >= 0 && point.y() >= 0 && point.x() < m_document->width && point.y() < m_document->height))
return std::nullopt;
try {
// The pixel under the point, drawn as the canvas shows.
QImage pixel = BrushRaster::context(1, 1, false);
QPainter painter(&pixel);
painter.translate(-std::floor(point.x()), -std::floor(point.y()));
drawLiveComposite(*m_document, painter);
painter.end();
const uchar *bytes = pixel.constBits();
const int alpha = bytes[3];
if (alpha == 0)
return std::nullopt;
const auto channel = [alpha](int value) { return std::round(double(std::min(alpha, value)) / alpha * 255) / 255; };
return PaletteColor{channel(bytes[0]), channel(bytes[1]), channel(bytes[2])};
} catch (const ExportError &error) {
qCWarning(lcApp) << "the colour under the pointer cannot be read:" << error.what();
return std::nullopt;
}
}
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <QColor>
#include <QString>
#include <QUuid>
#include <optional>
// Swift's PaletteColor: an sRGB colour without alpha.
struct PaletteColor {
double red;
double green;
double blue;
static constexpr PaletteColor black() { return {0, 0, 0}; }
static constexpr PaletteColor white() { return {1, 1, 1}; }
QColor color(double alpha = 1) const { return QColor::fromRgbF(float(red), float(green), float(blue), float(alpha)); }
// The 8-bit values painting and export store.
PaletteColor quantized() const;
QString hex() const;
// Swift's init?(hex:): RRGGBB or RGB, with or without #.
static std::optional<PaletteColor> fromHex(const QString &text);
friend bool operator==(const PaletteColor &, const PaletteColor &) = default;
};
// The picker's own truth, so hue survives grays and black.
struct PickerHSB {
PickerHSB(double hue, double saturation, double brightness) : hue(hue), saturation(saturation), brightness(brightness) {}
explicit PickerHSB(const PaletteColor &color);
double hue;
double saturation;
double brightness;
PaletteColor rgb() const;
// Grays keep the hue, black the saturation, as Photoshop's.
void setRGB(const PaletteColor &color);
friend bool operator==(const PickerHSB &, const PickerHSB &) = default;
};
enum class LayerEffectKind;
// The picker's targets: palette, text, effect, Gradient Map end.
struct ColorPickerTarget {
enum class Kind { palette, text, gradientMap, effect };
Kind kind;
bool background = false;
std::optional<QUuid> draftID = std::nullopt;
// A Gradient Map's end: highlights, else shadows.
bool highlights = false;
// An effect's colour: which effect.
LayerEffectKind effect{};
QString title() const;
friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default;
};
// The open picker's working colour: nothing is written until OK.
struct ColorPickerState {
ColorPickerState(const ColorPickerTarget &target, const PaletteColor &original) : target(target), original(original), hsb(original) {}
ColorPickerTarget target;
PaletteColor original;
PickerHSB hsb;
// Swift's object identity: a new picker opens its panel anew.
QUuid id = QUuid::createUuid();
PaletteColor color() const { return hsb.rgb().quantized(); }
};
+33
View File
@@ -0,0 +1,33 @@
#include "Document/ContentFill.h"
#include "Document/BrushStroke.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
// The kernel shares this file's name: angle brackets skip here.
extern "C" {
#include <ContentFill.h>
}
ContentFillError::ContentFillError(Kind kind)
: std::runtime_error("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image."), kind(kind)
{
}
QImage ContentFill::run(const FilterJob &job)
{
if (!job.selection)
throw ContentFillError(ContentFillError::Kind::noSource);
const int width = job.image.width(), height = job.image.height();
QImage pixels = BrushRaster::context(width, height, false);
QPainter painter(&pixels);
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
painter.end();
// White through the selection: the pixels to make.
const QImage mask = PixelAdjust::coverage(*job.selection, width, height, job.mapping);
const int result = content_fill(pixels.bits(), size_t(pixels.bytesPerLine()), mask.constBits(), size_t(mask.bytesPerLine()), width, height);
if (result == 0)
throw ContentFillError(ContentFillError::Kind::noSource);
if (result != 1)
throw ExportError(ExportError::Kind::render);
return pixels;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "Document/Filters.h"
#include <stdexcept>
// Swift's ContentFill.Failure.
class ContentFillError : public std::runtime_error {
public:
enum class Kind { noSource };
explicit ContentFillError(Kind kind);
const Kind kind;
};
// Content-Aware Fill: the selection made from the pixels around it.
namespace ContentFill {
QImage run(const FilterJob &job);
}
+15
View File
@@ -0,0 +1,15 @@
// Swift's Crop extension: snapping, the frame, the crop itself.
public:
SnapGuides transformSnapTargets(const QSet<QUuid> &moving) const;
LayerTransform snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance);
SnapGuides cropSnapTargets() const;
// The Crop tool's frame: the canvas until one is drawn.
std::optional<QRectF> visibleCropRect() const;
std::optional<double> cropRatio() const;
void cancelCrop();
void changeCropRatio();
// Off the UI thread; `done` follows the step or refusal.
void commitCrop(std::function<void()> done = {});
private:
void finishCropCommit();
+293
View File
@@ -0,0 +1,293 @@
#include "Document/Crop.h"
#include "Document/Distort.h"
#include "Document/EditorSession.h"
#include "IO/CanvasResizer.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include <QtConcurrent>
#include <cmath>
#include <new>
QRectF CropGeometry::snapped(const QRectF &rect)
{
const QRectF standard = rect.normalized();
const double x = std::round(standard.left()), y = std::round(standard.top());
return QRectF(x, y, std::max(1.0, std::round(standard.right()) - x), std::max(1.0, std::round(standard.bottom()) - y));
}
// Swift's isFinite terms are the ranges' own: NaN passes none.
bool CropGeometry::valid(const QRectF &rect)
{
const QRectF standard = rect.normalized();
return standard.width() >= 1 && standard.width() <= 30'000 && standard.height() >= 1 && standard.height() <= 30'000
&& std::abs(standard.left()) <= 1'000'000 && std::abs(standard.top()) <= 1'000'000;
}
QRectF CropGeometry::create(QPointF start, QPointF end, std::optional<double> ratio, bool symmetric)
{
double dx = end.x() - start.x(), dy = end.y() - start.y();
if (ratio) {
if (std::abs(dx) > std::abs(dy) * *ratio)
dy = (dy < 0 ? -1 : 1) * std::abs(dx) / *ratio;
else
dx = (dx < 0 ? -1 : 1) * std::abs(dy) * *ratio;
}
if (symmetric)
return snapped(QRectF(start.x() - std::abs(dx), start.y() - std::abs(dy), std::abs(dx) * 2, std::abs(dy) * 2));
return snapped(QRectF(std::min(start.x(), start.x() + dx), std::min(start.y(), start.y() + dy), std::abs(dx), std::abs(dy)));
}
QRectF CropDrag::updated(QPointF point, std::optional<double> ratio, bool symmetric) const
{
switch (mode.kind) {
case Kind::create: return CropGeometry::create(start, point, ratio, symmetric);
case Kind::move: return CropGeometry::snapped(original.translated(point - start));
case Kind::resize: {
const TransformDrag drag{.original = LayerTransform{.origin = original.topLeft(), .size = original.size()},
.start = start,
.mode = {TransformDrag::Kind::resize, mode.index},
.originalCorners = std::nullopt};
const LayerTransform next = drag.updated(point, ratio.has_value(), false, symmetric);
return CropGeometry::snapped(QRectF(next.origin, next.size));
}
}
throw std::logic_error("unknown crop drag");
}
namespace {
std::optional<double> nearest(double value, const std::vector<double> &targets, double tolerance)
{
std::optional<double> best;
for (const double target : targets) {
if (std::abs(target - value) > tolerance || (best && std::abs(*best - value) <= std::abs(target - value)))
continue;
best = target;
}
return best;
}
// The smallest shift that lands an edge on a target.
double shift(std::initializer_list<double> edges, const std::vector<double> &targets, double tolerance)
{
std::optional<double> best;
for (const double edge : edges) {
const std::optional<double> target = nearest(edge, targets, tolerance);
if (target && (!best || std::abs(*target - edge) < std::abs(*best)))
best = *target - edge;
}
return best.value_or(0);
}
}
// Moves snap their closest edges; the rest, the dragged side's.
QRectF CropSnap::apply(QRectF rect, const CropDrag &drag, QPointF point, std::optional<double> ratio, bool symmetric) const
{
if (!(tolerance > 0))
return rect;
bool horizontal = true, vertical = true;
switch (drag.mode.kind) {
case CropDrag::Kind::move: return rect.translated(shift({rect.left(), rect.right()}, xs, tolerance), shift({rect.top(), rect.bottom()}, ys, tolerance));
// A fixed ratio snaps moves alone, so it stays exact.
case CropDrag::Kind::create:
if (ratio)
return rect;
break;
case CropDrag::Kind::resize:
if (ratio)
return rect;
horizontal = LayerTransform::handles[size_t(drag.mode.index)].x() != 0.5;
vertical = LayerTransform::handles[size_t(drag.mode.index)].y() != 0.5;
break;
}
QRectF result = rect;
if (horizontal) {
if (std::abs(point.x() - result.left()) <= std::abs(point.x() - result.right())) {
if (const std::optional<double> x = nearest(result.left(), xs, tolerance); x && *x < result.right())
result = QRectF(*x, result.top(), result.right() - *x, result.height());
} else if (const std::optional<double> x = nearest(result.right(), xs, tolerance); x && *x > result.left()) {
result.setWidth(*x - result.left());
}
}
if (vertical) {
if (std::abs(point.y() - result.top()) <= std::abs(point.y() - result.bottom())) {
if (const std::optional<double> y = nearest(result.top(), ys, tolerance); y && *y < result.bottom())
result = QRectF(result.left(), *y, result.width(), result.bottom() - *y);
} else if (const std::optional<double> y = nearest(result.bottom(), ys, tolerance); y && *y > result.top()) {
result.setHeight(*y - result.top());
}
}
if (symmetric) {
// The snapped edge sets the half; its opposite mirrors it.
const QPointF centre = drag.mode.kind == CropDrag::Kind::create ? drag.start : drag.original.center();
if (horizontal) {
const double half = point.x() >= centre.x() ? result.right() - centre.x() : centre.x() - result.left();
if (half >= 0.5)
result = QRectF(centre.x() - half, result.top(), half * 2, result.height());
}
if (vertical) {
const double half = point.y() >= centre.y() ? result.bottom() - centre.y() : centre.y() - result.top();
if (half >= 0.5)
result = QRectF(result.left(), centre.y() - half, result.width(), half * 2);
}
}
return result;
}
namespace {
QRectF boxAround(const LayerTransform &transform)
{
const Corners corners = DistortWarp::corners(transform);
QPointF low = corners[0], high = corners[0];
for (const QPointF corner : corners) {
low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())};
high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())};
}
return QRectF(low, high);
}
}
// The canvas's edges and middle, and every other visible layer's.
SnapGuides EditorSession::transformSnapTargets(const QSet<QUuid> &moving) const
{
SnapGuides targets;
if (!m_document)
return targets;
const QSizeF size = m_document->size();
targets.xs = {0, size.width() / 2, size.width()};
targets.ys = {0, size.height() / 2, size.height()};
for (const ImageLayer &layer : m_document->renderLayers()) {
if (!layer.asset || moving.contains(layer.id))
continue;
const QRectF box = boxAround(displayedTransform(layer));
targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.center().x()), std::round(box.right())});
targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.center().y()), std::round(box.bottom())});
}
return targets;
}
// `draft` nudged onto a nearby edge or middle, within `tolerance`.
LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance)
{
const SnapGuides targets = transformSnapTargets(moving);
const TransformSnap::Offset snap = TransformSnap::offset(boxAround(draft), targets.xs, targets.ys, tolerance);
snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}};
if (snap.offset.isNull())
return draft;
LayerTransform snapped = draft;
snapped.origin += QPointF(snap.offset.width(), snap.offset.height());
return snapped;
}
// The canvas's edges and every visible layer's upright bounds.
SnapGuides EditorSession::cropSnapTargets() const
{
SnapGuides targets;
if (!m_document)
return targets;
const QSizeF size = m_document->size();
targets.xs = {0, size.width()};
targets.ys = {0, size.height()};
for (const ImageLayer &layer : m_document->renderLayers()) {
if (!layer.asset)
continue;
const QRectF box = boxAround(displayedTransform(layer));
targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.right())});
targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.bottom())});
}
return targets;
}
std::optional<QRectF> EditorSession::visibleCropRect() const
{
if (m_tool != NavigationTool::crop || !m_document)
return std::nullopt;
return m_cropRect ? m_cropRect : QRectF(QPointF(0, 0), m_document->size());
}
std::optional<double> EditorSession::cropRatio() const
{
if (m_cropRatioChoice == QStringLiteral("Original"))
return m_document ? std::optional(double(m_document->width) / double(m_document->height)) : std::nullopt;
if (m_cropRatioChoice == QStringLiteral("1:1"))
return 1;
if (m_cropRatioChoice == QStringLiteral("4:3"))
return 4.0 / 3;
if (m_cropRatioChoice == QStringLiteral("16:9"))
return 16.0 / 9;
return std::nullopt;
}
void EditorSession::cancelCrop()
{
setCropRect(std::nullopt);
}
// The frame takes the ratio about its middle, width kept.
void EditorSession::changeCropRatio()
{
const std::optional<QRectF> rect = visibleCropRect();
const std::optional<double> ratio = cropRatio();
if (!rect || !ratio)
return;
const double height = rect->width() / *ratio;
const QRectF next = CropGeometry::snapped(QRectF(rect->left(), rect->center().y() - height / 2, rect->width(), height));
if (CropGeometry::valid(next))
setCropRect(next);
}
// The canvas cut to the frame off the UI thread.
void EditorSession::commitCrop(std::function<void()> done)
{
bool started = false;
if (canStartProjectOperation() && m_cropRect && CropGeometry::valid(*m_cropRect)) {
// Swift reads a CGRect's width and minX standardised.
const QRectF rect = m_cropRect->normalized();
const CanvasSizeOptions options{.width = qint64(rect.width()), .height = qint64(rect.height()), .contentOffset = QPointF(-rect.left(), -rect.top())};
// Taken before anything changes: running out changes nothing.
try {
std::optional<ProjectSnapshot> snapshot = projectSnapshot();
if (snapshot) {
m_cropCommit.setFuture(QtConcurrent::run([snapshot = std::move(*snapshot), options]() -> Cropped {
try {
return Cropped{CanvasResizer::resize(snapshot, options), std::nullopt};
} catch (const ProjectError &error) {
return Cropped{std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return Cropped{std::nullopt, QString::fromUtf8(error.what())};
}
}));
started = true;
}
} catch (const std::bad_alloc &) {
setCropError(QString::fromUtf8(ExportError(ExportError::Kind::render).what()));
}
}
if (!started) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
m_committingCrop = std::move(done);
setIsProjectBusy(true);
}
void EditorSession::finishCropCommit()
{
const std::function<void()> done = std::exchange(m_committingCrop, {});
try {
// Moved out: a large project's copy could fail.
const Cropped result = m_cropCommit.future().takeResult();
if (result.snapshot) {
applyDocumentSize(*result.snapshot, QStringLiteral("Crop"));
// Only a crop that landed lets its frame go.
m_cropRect.reset();
} else {
setCropError(result.failure);
}
} catch (const std::bad_alloc &) {
setCropError(QString::fromUtf8(ExportError(ExportError::Kind::render).what()));
}
setIsProjectBusy(false);
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "Document/LayerTransform.h"
#include <QRectF>
#include <optional>
#include <vector>
// Swift's CropGeometry: frames on whole pixels, within Swift's bounds.
namespace CropGeometry {
QRectF snapped(const QRectF &rect);
bool valid(const QRectF &rect);
// From `start` to `end`, or out from `start` when symmetric.
QRectF create(QPointF start, QPointF end, std::optional<double> ratio, bool symmetric = false);
}
// Swift's CropDrag: a frame being made, moved or resized.
struct CropDrag {
enum class Kind { create, move, resize };
struct Mode {
Kind kind;
int index = 0;
};
QPointF start;
QRectF original;
Mode mode;
// Symmetric (Alt) keeps the frame's middle still.
QRectF updated(QPointF point, std::optional<double> ratio, bool symmetric = false) const;
};
// Swift's CropSnap: dragged edges drawn to nearby edges.
struct CropSnap {
std::vector<double> xs;
std::vector<double> ys;
// In document pixels.
double tolerance;
QRectF apply(QRectF rect, const CropDrag &drag, QPointF point, std::optional<double> ratio, bool symmetric = false) const;
};
+67
View File
@@ -0,0 +1,67 @@
#include "Document/Curves.h"
#include "Document/BrushStroke.h"
#include "IO/ProjectStore.h"
#include <QPainter>
#include <algorithm>
#include <array>
#include <cmath>
extern "C" {
#include "LevelsPixels.h"
}
bool CurvesSettings::isValid() const
{
// Fixed ends and rising x keep x in range.
return channels.size() == 4 && std::all_of(channels.begin(), channels.end(), [](const std::vector<CurvePoint> &points) {
return points.size() >= 2 && points.size() <= 32 && points.front().x == 0 && points.back().x == 255
&& std::all_of(points.begin(), points.end(), [](const CurvePoint &point) { return point.y >= 0 && point.y <= 255; })
&& std::adjacent_find(points.begin(), points.end(), [](const CurvePoint &a, const CurvePoint &b) { return !(a.x < b.x); }) == points.end();
});
}
double CurvesSettings::value(double x, size_t channel) const
{
const std::vector<CurvePoint> &p = channels.at(channel);
// Swift's lastIndex(where:), else the first segment.
size_t last = 0;
for (size_t index = 0; index < p.size(); ++index) {
if (p[index].x <= x)
last = index;
}
const size_t i = std::min(p.size() - 2, last);
std::vector<double> d;
for (size_t index = 0; index + 1 < p.size(); ++index)
d.push_back((p[index + 1].y - p[index].y) / (p[index + 1].x - p[index].x));
const auto slope = [&](size_t j) {
if (j == 0)
return d.front();
if (j == p.size() - 1)
return d.back();
if (d[j - 1] * d[j] <= 0)
return 0.0;
return 2 / (1 / d[j - 1] + 1 / d[j]);
};
const double h = p[i + 1].x - p[i].x, t = std::min(1.0, std::max(0.0, (x - p[i].x) / h));
const double y = (2 * t * t * t - 3 * t * t + 1) * p[i].y + (t * t * t - 2 * t * t + t) * h * slope(i)
+ (-2 * t * t * t + 3 * t * t) * p[i + 1].y + (t * t * t - t * t) * h * slope(i + 1);
return std::min(255.0, std::max(0.0, y));
}
QImage CurvesSettings::apply(const QImage &image) const
{
if (!isValid())
throw ProjectError(ProjectError::Kind::invalid);
QImage context = BrushRaster::context(image.width(), image.height(), false);
{
QPainter painter(&context);
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
}
// Each colour's curve, then the composite's.
std::array<float, 3 * 256> tables;
for (size_t channel = 1; channel <= 3; ++channel) {
for (size_t x = 0; x < 256; ++x)
tables[(channel - 1) * 256 + x] = float(value(value(double(x), channel), 0) / 255);
}
levels_apply(context.bits(), size_t(image.width()) * size_t(image.height()), tables.data());
return context;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "Document/Levels.h"
#include <QImage>
#include <vector>
struct CurvePoint {
double x;
double y;
friend bool operator==(const CurvePoint &, const CurvePoint &) = default;
};
// Swift's CurvesSettings: a curve a channel, the composite first.
struct CurvesSettings {
LevelsChannel channel = LevelsChannel::rgb;
std::vector<std::vector<CurvePoint>> channels = std::vector<std::vector<CurvePoint>>(4, {{0, 0}, {255, 255}});
bool isValid() const;
// Shape-preserving cubic Hermite: no overshoot between handles.
double value(double x, size_t channel) const;
// Throws ProjectError when invalid, ExportError when out of memory.
QImage apply(const QImage &image) const;
friend bool operator==(const CurvesSettings &, const CurvesSettings &) = default;
};
+14
View File
@@ -0,0 +1,14 @@
// Swift's Distort extension: four corners dragged apart.
public:
void beginDistort();
void previewCorners(const Corners &corners);
std::optional<DistortPreview> distortPreview(const ImageLayer &layer) const;
// A layer's effects warped with the pending distortion.
std::optional<DistortWarp::Warped> distortedEffects(const ImageLayer &layer, const QImage &image, double inset) const;
// For a known target: the commit's, once the edit ends.
std::optional<DistortWarp::Warped> distortedEffects(const ImageLayer &layer, const QImage &image, double inset, const DistortTarget &target) const;
private:
std::optional<DistortTarget> distortTarget(const ImageLayer &layer, const TransformEdit &edit, const Corners &shape) const;
void commitDistort(const TransformEdit &edit, const Corners &shape);
void distort(int index, const LayerTransform &transform, const Corners &corners);
+482
View File
@@ -0,0 +1,482 @@
#include "Document/Distort.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include <QPainterPath>
#include <QPolygonF>
#include <QRegion>
#include <cmath>
#include <functional>
extern "C" {
#include "BrushPixels.h"
}
namespace {
// Swift divides by w whatever its sign; Qt 6.11 clips.
QPointF project(const QTransform &map, QPointF point)
{
const double w = map.m13() * point.x() + map.m23() * point.y() + map.m33();
return QPointF((map.m11() * point.x() + map.m21() * point.y() + map.m31()) / w, (map.m12() * point.x() + map.m22() * point.y() + map.m32()) / w);
}
double area(QPointF a, QPointF b, QPointF c)
{
return (b.x() - a.x()) * (c.y() - a.y()) - (b.y() - a.y()) * (c.x() - a.x());
}
using Triangle = std::array<QPointF, 3>;
// The affine map taking three points to three others.
std::optional<QTransform> affine(const Triangle &source, const Triangle &target)
{
const QPointF u = source[1] - source[0], v = source[2] - source[0];
const QPointF uu = target[1] - target[0], vv = target[2] - target[0];
const double det = u.x() * v.y() - v.x() * u.y();
if (std::abs(det) <= 1e-9)
return std::nullopt;
const double a = (uu.x() * v.y() - vv.x() * u.y()) / det, c = (vv.x() * u.x() - uu.x() * v.x()) / det;
const double b = (uu.y() * v.y() - vv.y() * u.y()) / det, d = (vv.y() * u.x() - uu.y() * v.x()) / det;
return QTransform(a, b, c, d, target[0].x() - (a * source[0].x() + c * source[0].y()), target[0].y() - (b * source[0].x() + d * source[0].y()));
}
struct Target {
QPointF topLeft, topRight, bottomRight, bottomLeft;
};
// A flipped layer's pixels go to the opposite corners.
Target imageCorners(const Corners &corners, bool flipX, bool flipY)
{
const auto corner = [&](int x, int y) {
const int u = flipX ? 1 - x : x, v = flipY ? 1 - y : y;
return corners[size_t(std::array<int, 4>{0, 1, 3, 2}[size_t(v * 2 + u)])];
};
return {corner(0, 0), corner(1, 0), corner(1, 1), corner(0, 1)};
}
QRectF shapeBounds(const Corners &corners)
{
QPointF low = corners[0], high = corners[0];
for (const QPointF corner : corners) {
low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())};
high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())};
}
return QRectF(QPointF(std::floor(low.x()), std::floor(low.y())), QPointF(std::ceil(high.x()), std::ceil(high.y())));
}
// Pixels whose centres a triangle holds, left bound inclusive.
QRegion triangleRegion(const Triangle &triangle)
{
QRegion region;
const double top = std::min({triangle[0].y(), triangle[1].y(), triangle[2].y()});
const double bottom = std::max({triangle[0].y(), triangle[1].y(), triangle[2].y()});
for (int row = int(std::ceil(top - 0.5)); row + 0.5 < bottom; ++row) {
const double y = row + 0.5;
std::optional<double> left, right;
for (size_t index = 0; index < 3; ++index) {
// Ends ordered by y: a shared edge gives one x.
const QPointF a = std::min(triangle[index], triangle[(index + 1) % 3], [](QPointF p, QPointF q) { return p.y() < q.y(); });
const QPointF b = a == triangle[index] ? triangle[(index + 1) % 3] : triangle[index];
// An edge holds its lower end, not its upper one.
if (a.y() == b.y() || y < a.y() || y >= b.y())
continue;
const double x = a.x() + (b.x() - a.x()) * (y - a.y()) / (b.y() - a.y());
left = std::min(left.value_or(x), x);
right = std::max(right.value_or(x), x);
}
if (!left)
continue;
const int first = int(std::ceil(*left - 0.5)), last = int(std::ceil(*right - 0.5)) - 1;
if (last >= first)
region += QRect(first, row, last - first + 1, 1);
}
return region;
}
// A folded shape: each half by its own affine map.
void warpFolded(const QImage &image, const Target &target, const std::function<QPointF(QPointF)> &place, QPainter &painter)
{
const QRectF source(0, 0, image.width(), image.height());
const std::array<std::pair<Triangle, Triangle>, 2> halves = {
std::pair{Triangle{source.topLeft(), source.topRight(), source.bottomRight()}, Triangle{place(target.topLeft), place(target.topRight), place(target.bottomRight)}},
std::pair{Triangle{source.topLeft(), source.bottomRight(), source.bottomLeft()}, Triangle{place(target.topLeft), place(target.bottomRight), place(target.bottomLeft)}}};
for (const auto &[from, to] : halves) {
const std::optional<QTransform> map = affine(from, to);
if (!map)
continue;
painter.save();
// A hard region along the diagonal: the halves meet exactly.
painter.setClipRegion(triangleRegion(to));
painter.setTransform(*map);
BrushRaster::draw(image, source, painter);
painter.restore();
}
}
}
Corners DistortWarp::corners(const LayerTransform &transform)
{
return {transform.point({0, 0}), transform.point({1, 0}), transform.point({1, 1}), transform.point({0, 1})};
}
bool DistortWarp::isUsable(const Corners &corners)
{
for (const QPointF corner : corners) {
if (!std::isfinite(corner.x()) || !std::isfinite(corner.y()) || std::abs(corner.x()) > 1'000'000 || std::abs(corner.y()) > 1'000'000)
return false;
}
// Both halves need area, or one has nothing to draw.
return std::abs(area(corners[0], corners[1], corners[2])) > 0.01 && std::abs(area(corners[0], corners[2], corners[3])) > 0.01;
}
bool DistortWarp::isConvex(const Corners &corners)
{
if (!isUsable(corners))
return false;
double sign = 0;
for (size_t index = 0; index < 4; ++index) {
const QPointF a = corners[index], b = corners[(index + 1) % 4], c = corners[(index + 2) % 4];
const double cross = (b.x() - a.x()) * (c.y() - b.y()) - (b.y() - a.y()) * (c.x() - b.x());
if (std::abs(cross) <= 0.01)
return false;
if (sign == 0)
sign = cross < 0 ? -1 : 1;
else if ((cross < 0) != (sign < 0))
return false;
}
return true;
}
QTransform DistortWarp::homography(const Corners &c)
{
const double sx = c[0].x() - c[1].x() + c[2].x() - c[3].x(), sy = c[0].y() - c[1].y() + c[2].y() - c[3].y();
double g = 0, h = 0;
if (std::abs(sx) > 1e-9 || std::abs(sy) > 1e-9) {
const double dx1 = c[1].x() - c[2].x(), dx2 = c[3].x() - c[2].x(), dy1 = c[1].y() - c[2].y(), dy2 = c[3].y() - c[2].y();
const double den = dx1 * dy2 - dx2 * dy1;
if (std::abs(den) > 1e-12) {
g = (sx * dy2 - dx2 * sy) / den;
h = (dx1 * sy - sx * dy1) / den;
}
}
const double a = c[1].x() - c[0].x() + g * c[1].x(), b = c[3].x() - c[0].x() + h * c[3].x();
const double d = c[1].y() - c[0].y() + g * c[1].y(), e = c[3].y() - c[0].y() + h * c[3].y();
// Qt divides by the third row, as Swift by w.
return QTransform(a, d, g, b, e, h, c[0].x(), c[0].y(), 1);
}
DistortWarp::Warped DistortWarp::warp(const QImage &image, const LayerTransform &transform, const Corners &corners, bool isMask,
std::optional<double> limit)
{
if (!isUsable(corners))
throw ProjectError(ProjectError::Kind::invalid);
const QRectF bounds = shapeBounds(corners);
if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > 30'000 || bounds.height() > 30'000 || bounds.width() * bounds.height() > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const LayerTransform placed{.origin = bounds.topLeft(), .size = bounds.size(), .sampling = transform.sampling};
// A uniform 1 × 1 mask already covers any shape.
if (isMask && image.width() == 1 && image.height() == 1)
return {image, placed};
const double factor = limit ? std::min(1.0, *limit / std::max(bounds.width(), bounds.height())) : 1;
const int width = std::max(1, int(std::ceil(bounds.width() * factor))), height = std::max(1, int(std::ceil(bounds.height() * factor)));
const Target target = imageCorners(corners, transform.flipX, transform.flipY);
const auto place = [&](QPointF point) { return QPointF((point.x() - bounds.left()) * factor, (point.y() - bounds.top()) * factor); };
QImage surface = BrushRaster::context(width, height, isMask);
QPainter painter(&surface);
painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
if (!isConvex(corners)) {
warpFolded(image, target, place, painter);
return {surface, placed};
}
const QRectF source(0, 0, image.width(), image.height());
QTransform map;
if (!QTransform::quadToQuad(QPolygonF({source.topLeft(), source.topRight(), source.bottomRight(), source.bottomLeft()}),
QPolygonF({place(target.topLeft), place(target.topRight), place(target.bottomRight), place(target.bottomLeft)}), map))
throw ExportError(ExportError::Kind::render);
painter.setTransform(map);
painter.drawImage(source, image);
return {surface, placed};
}
DistortWarp::Trimmed DistortWarp::warpTrimmed(const QImage &image, const LayerTransform &transform, const Corners &corners)
{
const Warped warped = warp(image, transform, corners, false);
const QRect full = warped.image.rect();
size_t edges[4] = {0, 0, 0, 0};
brush_alpha_bounds(warped.image.constBits(), size_t(full.width()), size_t(full.height()), size_t(warped.image.bytesPerLine()), edges);
const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1]));
// Nothing visible, or nothing to trim: the warp itself.
if (crop.width() < 1 || crop.height() < 1 || crop == full)
return {warped.image, warped.transform, full};
LayerTransform placed = warped.transform;
placed.origin += crop.topLeft();
placed.size = crop.size();
return {warped.image.copy(crop), placed, crop};
}
Corners DistortWarp::carried(const LayerTransform &placement, const LayerTransform &transform, const Corners &corners)
{
QTransform toDocument;
toDocument.translate(transform.center().x(), transform.center().y());
toDocument.rotateRadians(transform.radians());
toDocument.scale(transform.size.width(), transform.size.height());
toDocument.translate(-0.5, -0.5);
const QTransform toUnit = toDocument.inverted();
const QTransform map = homography(corners);
const Corners own = DistortWarp::corners(placement);
Corners result;
for (size_t index = 0; index < 4; ++index)
result[index] = project(map, toUnit.map(own[index]));
return result;
}
DistortWarp::Warped DistortWarp::warpMask(const QImage &image, const LayerTransform &transform, const Corners &corners, double background,
std::optional<double> limit)
{
const Warped warped = warp(image, transform, corners, true, limit);
if (background <= 0 || warped.image.cacheKey() == image.cacheKey())
return warped;
const int width = warped.image.width(), height = warped.image.height();
QImage surface = BrushRaster::context(width, height, true);
surface.fill(qRound(background * 255));
QPainter painter(&surface);
painter.setRenderHint(QPainter::Antialiasing, true);
const double sx = width / warped.transform.size.width(), sy = height / warped.transform.size.height();
QPainterPath shape;
for (size_t index = 0; index < 4; ++index) {
const QPointF point((corners[index].x() - warped.transform.origin.x()) * sx, (corners[index].y() - warped.transform.origin.y()) * sy);
if (index == 0)
shape.moveTo(point);
else
shape.lineTo(point);
}
shape.closeSubpath();
painter.setClipPath(shape);
BrushRaster::draw(warped.image, QRectF(0, 0, width, height), painter);
return {surface, warped.transform};
}
// Ctrl-drag on a handle: the corners move freely; Apply resamples.
void EditorSession::beginDistort()
{
if (!m_transformEdit || m_transformEdit->corners || !m_transformEdit->draft.isValid())
return;
m_transformEdit->corners = DistortWarp::corners(m_transformEdit->draft);
m_transformEdit->persistent = true;
notify();
}
// A twisted or collapsed shape is ignored.
void EditorSession::previewCorners(const Corners &corners)
{
if (!m_transformEdit || !m_transformEdit->corners || !DistortWarp::isUsable(corners))
return;
m_transformEdit->corners = corners;
notify();
}
// A group's layers take the box's perspective each.
std::optional<DistortTarget> EditorSession::distortTarget(const ImageLayer &layer, const TransformEdit &edit, const Corners &shape) const
{
if (!edit.group)
return edit.layerID == layer.id ? std::optional(DistortTarget{edit.draft, shape}) : std::nullopt;
const auto original = edit.group->originals.constFind(layer.id);
if (original == edit.group->originals.constEnd())
return std::nullopt;
const LayerTransform transform = original->following(edit.group->box, edit.draft);
const Corners corners = DistortWarp::carried(transform, edit.draft, shape);
return DistortWarp::isUsable(corners) ? std::optional(DistortTarget{transform, corners}) : std::nullopt;
}
// `image` is the layer with effects round it, mask applied.
std::optional<DistortWarp::Warped> EditorSession::distortedEffects(const ImageLayer &layer, const QImage &image, double inset) const
{
if (!m_transformEdit || m_transformEdit->mask || !m_transformEdit->corners)
return std::nullopt;
const std::optional<DistortTarget> target = distortTarget(layer, *m_transformEdit, *m_transformEdit->corners);
if (!target)
return std::nullopt;
return distortedEffects(layer, image, inset, *target);
}
std::optional<DistortWarp::Warped> EditorSession::distortedEffects(const ImageLayer &layer, const QImage &image, double inset,
const DistortTarget &target) const
{
// The grown box takes the same perspective as the layer's.
const LayerTransform grown = LayerEffectsRenderer::placed(target.transform, image, inset);
const Corners carried = DistortWarp::carried(grown, target.transform, target.corners);
if (m_distortEffectsCache.contains(layer.id)) {
const DistortEffectsCache &cache = m_distortEffectsCache.at(layer.id);
if (cache.corners == carried && cache.image.cacheKey() == image.cacheKey())
return cache.result;
}
std::optional<DistortWarp::Warped> result;
// Swift's try?: effects that cannot warp are left off.
try {
result = DistortWarp::warp(image, grown, carried, false, 2048);
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
m_distortEffectsCache.insert_or_assign(layer.id, DistortEffectsCache{carried, image, result});
return result;
}
// The layer warped into the pending distortion, at preview size.
std::optional<DistortPreview> EditorSession::distortPreview(const ImageLayer &layer) const
{
if (!m_transformEdit || m_transformEdit->mask || !m_transformEdit->corners || !layer.asset)
return std::nullopt;
const std::optional<DistortTarget> target = distortTarget(layer, *m_transformEdit, *m_transformEdit->corners);
if (!target)
return std::nullopt;
const std::optional<QImage> mask = layer.mask ? layer.mask->enabledImage() : std::nullopt;
const std::optional<ImageIdentity> maskID = mask ? std::optional(layer.mask->asset.identity()) : std::nullopt;
const auto cached = m_distortPreviewCache.find(layer.id);
if (cached != m_distortPreviewCache.end() && cached->second.corners == target->corners && cached->second.draft == target->transform
&& cached->second.image == layer.asset->identity() && cached->second.mask == maskID)
return cached->second.result;
std::optional<DistortPreview> result;
try {
const DistortWarp::Warped warped = DistortWarp::warp(layer.asset->image(), target->transform, target->corners, false, 2048);
std::optional<QImage> warpedMask;
const std::optional<LayerMask> &owned = layer.mask;
if (owned && !owned->placement && owned->isLinked) {
// Swift's try?: a mask that cannot warp draws none.
try {
if (mask)
warpedMask = DistortWarp::warp(*mask, target->transform, target->corners, true, 2048).image;
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
} else if (owned) {
std::optional<QImage> carriedMask;
if (owned->isLinked && owned->placement) {
// A linked mask placed apart takes the same perspective.
const LayerTransform placement = owned->placement->following(layer.transform, target->transform);
const Corners carried = DistortWarp::carried(placement, target->transform, target->corners);
try {
if (DistortWarp::isConvex(carried)) {
const DistortWarp::Warped moved = DistortWarp::warpMask(owned->asset.image(), placement, carried, LayerMask::background(owned->asset.thumbnail), 2048);
carriedMask = LayerMask(ImportedImage(moved.image, owned->asset.thumbnail, owned->asset.name), owned->isEnabled)
.clipImage(moved.transform, warped.transform, warped.image.width(), warped.image.height(), 2048);
}
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
}
// Otherwise the mask stays where it is on the document.
warpedMask = carriedMask ? carriedMask : owned->clipImage(owned->placement.value_or(layer.transform), warped.transform, warped.image.width(), warped.image.height(), 2048);
}
result = DistortPreview{warped.image, warpedMask, warped.transform};
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
m_distortPreviewCache.insert_or_assign(layer.id, DistortPreviewCache{target->corners, target->transform, layer.asset->identity(), maskID, result});
return result;
}
// Apply: each layer resampled into its shape, one step.
void EditorSession::commitDistort(const TransformEdit &edit, const Corners &shape)
{
m_distortPreviewCache.clear();
std::vector<QUuid> ids;
if (edit.group) {
for (auto original = edit.group->originals.constBegin(); original != edit.group->originals.constEnd(); ++original)
ids.push_back(original.key());
} else {
ids.push_back(edit.layerID);
}
beginEdit(edit.group ? QStringLiteral("Distort Layers") : QStringLiteral("Distort"));
for (const QUuid &id : ids) {
const int index = indexOf(m_document->layers, id);
if (index < 0)
continue;
const std::optional<DistortTarget> target = distortTarget(m_document->layers[index], edit, shape);
if (!target)
continue;
// Warped effects stay on until the new pixels' effects land.
const std::optional<EffectsPreviewCache::Result> effects = effectsPreviews.rendered(id);
const std::optional<DistortWarp::Warped> warpedEffects =
effects ? distortedEffects(m_document->layers[index], effects->image, effects->inset, *target) : std::nullopt;
try {
distort(index, target->transform, target->corners);
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
// Placed where warped: the commit also crops the layer.
if (warpedEffects)
effectsPreviews.seed(id, warpedEffects->image, warpedEffects->transform);
}
endEdit();
m_distortEffectsCache.clear();
}
// The layer at `index` resampled onto `corners`.
void EditorSession::distort(int index, const LayerTransform &transform, const Corners &corners)
{
const ImageLayer layer = m_document->layers[index];
if (!layer.asset)
return;
const DistortWarp::Trimmed warped = DistortWarp::warpTrimmed(layer.asset->image(), transform, corners);
const ImportedImage asset(warped.image, PixelAdjust::thumbnail(warped.image), layer.name);
std::optional<LayerMask> mask = layer.mask;
if (layer.mask && !layer.mask->placement && layer.mask->isLinked) {
const DistortWarp::Warped warpedMask = DistortWarp::warp(layer.mask->asset.image(), transform, corners, true);
// A uniform mask passes through; others crop with the pixels.
const bool uniform = warpedMask.image.cacheKey() == layer.mask->asset.image().cacheKey();
mask = layer.mask->replacing(uniform ? layer.mask->asset : LayerMask::assetFrom(warpedMask.image.copy(warped.crop)));
} else if (layer.mask && layer.mask->isLinked && layer.mask->placement
&& DistortWarp::isConvex(DistortWarp::carried(layer.mask->placement->following(layer.transform, transform), transform, corners))) {
// A linked mask placed apart takes the same perspective.
const LayerTransform placement = layer.mask->placement->following(layer.transform, transform);
const DistortWarp::Warped moved = DistortWarp::warpMask(layer.mask->asset.image(), placement, DistortWarp::carried(placement, transform, corners),
LayerMask::background(layer.mask->asset.thumbnail));
const bool uniform = moved.image.cacheKey() == layer.mask->asset.image().cacheKey();
mask = LayerMask(uniform ? layer.mask->asset : LayerMask::assetFrom(moved.image), layer.mask->isEnabled, moved.transform, true);
} else if (layer.mask) {
// An unlinked mask keeps its place on the document.
mask->placement = layer.mask->placement.value_or(layer.transform);
}
m_document->layers[index].asset = asset;
m_document->layers[index].transform = warped.transform;
m_document->layers[index].mask = mask;
}
// The selection's outline carried through the distortion.
std::optional<QPainterPath> DistortWarp::mapPath(const QPainterPath &path, const QTransform &pixelToDocument, QSizeF pixelSize,
const LayerTransform &transform, const Corners &corners)
{
if (!isConvex(corners) || pixelSize.width() <= 0 || pixelSize.height() <= 0)
return std::nullopt;
const QTransform toPixels = pixelToDocument.inverted();
const QTransform map = homography(corners);
const auto carry = [&](QPointF point) {
const QPointF pixel = toPixels.map(point);
double u = pixel.x() / pixelSize.width(), v = pixel.y() / pixelSize.height();
if (transform.flipX)
u = 1 - u;
if (transform.flipY)
v = 1 - v;
return project(map, QPointF(u, v));
};
QPainterPath result;
result.setFillRule(path.fillRule());
for (int i = 0; i < path.elementCount(); ++i) {
const QPainterPath::Element element = path.elementAt(i);
if (element.isMoveTo()) {
result.moveTo(carry(element));
} else if (element.isLineTo()) {
result.lineTo(carry(element));
} else {
// A curve is its first control, then two data elements.
result.cubicTo(carry(element), carry(path.elementAt(i + 1)), carry(path.elementAt(i + 2)));
i += 2;
}
}
return result;
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include "Document/LayerTransform.h"
#include "IO/ImageImporter.h"
#include <QImage>
#include <QPainterPath>
#include <QRect>
#include <optional>
// Free distortion: the layer's four corners move on their own.
namespace DistortWarp {
struct Warped {
QImage image;
LayerTransform transform;
};
struct Trimmed {
QImage image;
LayerTransform transform;
// In the warp's pixels, for cropping a mask to match.
QRect crop;
};
// Clockwise from the top left, in document pixels.
Corners corners(const LayerTransform &transform);
// Four finite corners with some area to both halves.
bool isUsable(const Corners &corners);
// A shape a perspective can take, wound either way.
bool isConvex(const Corners &corners);
// The perspective taking the unit square onto `corners`.
QTransform homography(const Corners &corners);
// `image` under `transform`, resampled so its corners land on `corners`.
Warped warp(const QImage &image, const LayerTransform &transform, const Corners &corners, bool isMask,
std::optional<double> limit = std::nullopt);
// A full warp cropped to its visible pixels.
Trimmed warpTrimmed(const QImage &image, const LayerTransform &transform, const Corners &corners);
// Where `placement`'s corners land under `transform`'s perspective.
Corners carried(const LayerTransform &placement, const LayerTransform &transform, const Corners &corners);
// A path through the distortion; nil when the shape folds.
std::optional<QPainterPath> mapPath(const QPainterPath &path, const QTransform &pixelToDocument, QSizeF pixelSize,
const LayerTransform &transform, const Corners &corners);
// A mask warped like `warp`, its background outside the shape.
Warped warpMask(const QImage &image, const LayerTransform &transform, const Corners &corners, double background,
std::optional<double> limit = std::nullopt);
}
// Where a distortion takes a layer: transform and corners.
struct DistortTarget {
LayerTransform transform;
Corners corners;
};
// The layer warped for the canvas, its mask alongside.
struct DistortPreview {
QImage image;
std::optional<QImage> mask;
LayerTransform transform;
};
// Effects last warped for a distortion, kept while corners rest.
struct DistortEffectsCache {
Corners corners;
QImage image;
std::optional<DistortWarp::Warped> result;
};
// The canvas's last warped preview, kept while nothing changed.
struct DistortPreviewCache {
Corners corners;
LayerTransform draft;
ImageIdentity image;
std::optional<ImageIdentity> mask;
std::optional<DistortPreview> result;
};
+131
View File
@@ -0,0 +1,131 @@
#include "Document/DocumentHistory.h"
#include <algorithm>
#include <set>
#include <utility>
DocumentHistory::DocumentHistory(int entryLimit, qint64 retainedByteLimit)
: entryLimit(std::max(0, entryLimit)), retainedByteLimit(std::max<qint64>(0, retainedByteLimit))
{
}
void DocumentHistory::reset()
{
m_past.clear();
m_future.clear();
m_pending.reset();
m_depth = 0;
m_revision = QUuid::createUuid();
m_savedRevision = m_revision;
}
// A closed step changes these three; pending state is spent.
void DocumentHistory::adopt(DocumentHistory &&staged) noexcept
{
m_past = std::move(staged.m_past);
m_future = std::move(staged.m_future);
m_revision = staged.m_revision;
}
void DocumentHistory::begin(const QString &name, const std::optional<CanvasDocument> &document,
std::optional<QUuid> selection)
{
if (m_depth == 0) {
m_pending = Snapshot{document, selection, m_revision};
m_pendingName = name;
}
m_depth += 1;
}
void DocumentHistory::end(const std::optional<CanvasDocument> &document, std::optional<QUuid> selection)
{
if (m_depth == 0)
return;
m_depth -= 1;
if (m_depth != 0 || !m_pending)
return;
const Snapshot before = *std::exchange(m_pending, std::nullopt);
// Selecting, navigating and no-op edits keep redo history.
if (before.document == document)
return;
m_revision = QUuid::createUuid();
m_past.push_back({m_pendingName, before, Snapshot{document, selection, m_revision}});
m_future.clear();
trim(document);
}
std::optional<DocumentHistory::Snapshot> DocumentHistory::undo()
{
if (!canUndo())
return std::nullopt;
Entry entry = std::move(m_past.back());
m_past.pop_back();
m_future.push_back(entry);
m_revision = entry.before.revision;
trim(entry.before.document);
return entry.before;
}
std::optional<DocumentHistory::Snapshot> DocumentHistory::redo()
{
if (!canRedo())
return std::nullopt;
Entry entry = std::move(m_future.back());
m_future.pop_back();
m_past.push_back(entry);
m_revision = entry.after.revision;
trim(entry.after.document);
return entry.after;
}
qint64 DocumentHistory::retainedBytes(const std::optional<CanvasDocument> &current) const
{
const auto assets = [](const ImageLayer &layer) {
std::vector<const ImportedImage *> result;
if (layer.asset)
result.push_back(&*layer.asset);
if (layer.mask)
result.push_back(&layer.mask->asset);
return result;
};
std::set<ImageIdentity> seen;
if (current) {
for (const ImageLayer &layer : current->layers) {
for (const ImportedImage *asset : assets(layer)) {
seen.insert(asset->identity());
seen.insert({nullptr, asset->thumbnail.cacheKey()});
}
}
}
qint64 bytes = 0;
const auto count = [&](const Snapshot &snapshot) {
if (!snapshot.document)
return;
for (const ImageLayer &layer : snapshot.document->layers) {
for (const ImportedImage *asset : assets(layer)) {
if (seen.insert(asset->identity()).second)
bytes += asset->byteCount();
if (seen.insert({nullptr, asset->thumbnail.cacheKey()}).second)
bytes += asset->thumbnail.sizeInBytes();
}
}
};
for (const std::vector<Entry> *entries : {&m_past, &m_future}) {
for (const Entry &entry : *entries) {
count(entry.before);
count(entry.after);
}
}
return bytes;
}
void DocumentHistory::trim(const std::optional<CanvasDocument> &current)
{
while (int(m_past.size() + m_future.size()) > entryLimit || retainedBytes(current) > retainedByteLimit) {
if (!m_past.empty())
m_past.erase(m_past.begin());
else if (!m_future.empty())
m_future.erase(m_future.begin());
else
break;
}
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include <QString>
#include <QUuid>
#include <optional>
#include <vector>
// Snapshots share image data; layer edits copy no pixels.
class DocumentHistory {
public:
struct Snapshot {
std::optional<CanvasDocument> document;
std::optional<QUuid> activeLayerID;
QUuid revision;
};
const int entryLimit;
const qint64 retainedByteLimit;
explicit DocumentHistory(int entryLimit = 100, qint64 retainedByteLimit = 256LL * 1024 * 1024);
bool canUndo() const { return m_depth == 0 && !m_past.empty(); }
bool canRedo() const { return m_depth == 0 && !m_future.empty(); }
QString undoName() const { return m_past.empty() ? QString() : m_past.back().name; }
QString redoName() const { return m_future.empty() ? QString() : m_future.back().name; }
bool isModified() const { return m_revision != m_savedRevision; }
int undoCount() const { return int(m_past.size()); }
void markSaved() { m_savedRevision = m_revision; }
void reset();
// Takes the steps a staged copy of this history recorded.
void adopt(DocumentHistory &&staged) noexcept;
void begin(const QString &name, const std::optional<CanvasDocument> &document, std::optional<QUuid> selection);
void end(const std::optional<CanvasDocument> &document, std::optional<QUuid> selection);
std::optional<Snapshot> undo();
std::optional<Snapshot> redo();
qint64 retainedBytes(const std::optional<CanvasDocument> &current) const;
private:
struct Entry {
QString name;
Snapshot before;
Snapshot after;
};
void trim(const std::optional<CanvasDocument> &current);
std::vector<Entry> m_past;
std::vector<Entry> m_future;
QUuid m_revision = QUuid::createUuid();
QUuid m_savedRevision = m_revision;
std::optional<Snapshot> m_pending;
QString m_pendingName = QStringLiteral("Edit");
int m_depth = 0;
};
+407
View File
@@ -0,0 +1,407 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include "Rendering/RasterSnapshot.h"
#include <QtConcurrent>
#include <chrono>
#include <cmath>
// Swift's EditorSession+Brush: the stroke from press to commit.
namespace {
bool sameAsset(const std::optional<ImportedImage> &a, const std::optional<ImportedImage> &b)
{
return a.has_value() == b.has_value() && (!a || a->identity() == b->identity());
}
bool sameMask(const std::optional<LayerMask> &a, const std::optional<LayerMask> &b)
{
return a.has_value() == b.has_value() && (!a || a->asset.identity() == b->asset.identity());
}
double uptime()
{
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
}
bool EditorSession::canPaint() const
{
const std::optional<ImageLayer> layer = activeLayer();
const std::optional<DocumentSelection> current = selection();
// A folder has no pixels: only its mask paints.
return canEditLayers() && m_selectedLayerIDs.size() == 1 && layer && (!layer->isGroup || m_isMaskSelected) && !(current && current->isEmpty())
&& m_document->effectiveVisibleIDs().contains(layer->id) && (!m_isMaskSelected || (layer->mask && layer->mask->isEnabled))
&& (m_isMaskSelected || !layer->adjustment);
}
// A tiled raster edit of pixels or mask, within budget.
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings) const
{
if (!m_document)
throw ProjectError(ProjectError::Kind::tooLarge);
auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size());
qint64 used = 0;
for (const ImageLayer &other : m_document->layers) {
if (other.id == layer.id)
continue;
const std::optional<ImportedImage> &image = m_isMaskSelected ? (other.mask ? std::optional(other.mask->asset) : std::nullopt) : other.asset;
if (image)
used += qint64(image->size().width()) * image->size().height();
}
stroke->pixelLimit = 100'000'000 - used;
const std::optional<DocumentSelection> current = selection();
if (current)
stroke->selectionClip = current->clip(m_document->size());
if (!m_isMaskSelected && layer.mask) {
qint64 maskPixels = 0;
for (const ImageLayer &other : m_document->layers) {
if (other.id != layer.id && other.mask)
maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height();
}
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, 100'000'000 - maskPixels);
}
return stroke;
}
void EditorSession::beginBrush(QPointF point)
{
if (m_tool == NavigationTool::blur && m_blurMode != BlurToolMode::blur) {
beginWarp(point);
return;
}
// Spot Healing and Clone Stamp rework pixels, never a mask.
const bool paintsMasks = m_tool == NavigationTool::brush || m_tool == NavigationTool::blur;
if ((!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected)) || !canPaint())
return;
const ImageLayer layer = activeLayer().value();
std::optional<BrushStroke::Clone> clone;
if (m_tool == NavigationTool::cloneStamp) {
const std::optional<QSizeF> offset = cloneStrokeOffset(point);
if (!offset) {
setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first."));
return;
}
const std::optional<QImage> image = cloneSample(m_document.value());
if (!image)
return;
m_cloneOffset = offset;
clone = BrushStroke::Clone{*image, *offset};
}
// Blur paints a softened copy of the layer, in place.
if (m_tool == NavigationTool::blur) {
const std::optional<QImage> image = blurSample(m_document.value(), m_isMaskSelected);
if (!image)
return;
clone = BrushStroke::Clone{*image, QSizeF(0, 0)};
}
finishOpacityEdit();
try {
BrushSettings settings = m_brushSettings;
settings.healing = m_tool == NavigationTool::spotHealing;
settings.erasing = m_tool == NavigationTool::brush && m_brushMode == BrushToolMode::erase && !m_isMaskSelected;
settings.healingMode = m_spotHealingMode;
if (m_isMaskSelected) {
settings.red = m_maskPaintWhite ? 1 : 0;
settings.green = settings.red;
settings.blue = settings.red;
}
m_brushStroke = makeRasterEdit(layer, settings);
m_brushStroke->clone = clone;
m_brushStroke->isBlur = m_tool == NavigationTool::blur;
m_brushStroke->append(point);
m_lastBrushPoint = LastBrushPoint{point, layer.id, m_isMaskSelected};
++m_brushRevision;
resumeFileRequests();
notify();
} catch (const ProjectError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
}
}
void EditorSession::continueBrush(QPointF point)
{
if (m_warpStroke) {
m_warpStroke->append(point);
m_lastBrushPoint->point = point;
++m_brushRevision;
notify();
return;
}
if (!m_brushStroke)
return;
try {
m_brushStroke->append(point);
m_lastBrushPoint->point = point;
++m_brushRevision;
notify();
} catch (const ProjectError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
}
}
// Where a Shift-click's line starts, on the same target.
std::optional<QPointF> EditorSession::shiftLineStart() const
{
if (!m_lastBrushPoint || m_lastBrushPoint->layerID != m_activeLayerID || m_lastBrushPoint->mask != m_isMaskSelected)
return std::nullopt;
return m_lastBrushPoint->point;
}
void EditorSession::cancelBrush()
{
m_warpStroke.reset();
m_brushStroke.reset();
++m_brushRevision;
resumeFileRequests();
notify();
}
// Mouse-up's own call, before the next event arrives.
bool EditorSession::finishBrushImmediately()
{
if (m_warpStroke) {
if (m_isProjectBusy)
return false;
finishWarp();
return true;
}
if (!m_brushStroke)
return true;
if (m_isProjectBusy)
return false;
try {
m_brushStroke->flush();
if (m_brushStroke->settings.healing)
m_brushStroke->heal();
if (!m_brushStroke->patches().empty())
commitPaintSnapshot(*m_brushStroke);
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
cancelBrush();
return true;
}
void EditorSession::finishBrush()
{
finishBrushImmediately();
}
// Immutable tiles land at once, undo entry included.
void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
{
const PaintSnapshot result = stroke.paintSnapshot();
const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1;
if (!result.transform.isValid() || index < 0)
return;
const ImageLayer current = m_document->layers[index];
if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform)
return;
std::optional<LayerMask> mask = current.mask;
if (!stroke.isMask && mask && !mask->placement && result.bounds != stroke.sourceRect) {
// The mask grows with the layer, revealing past it.
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(mask->asset, stroke.sourceRect, {}, result.bounds, true);
mask = mask->replacing(ImportedImage(raster, raster->thumbnail(), mask->asset.name));
}
beginEdit(stroke.editName.value_or(stroke.isMask ? QStringLiteral("Paint Mask")
: stroke.settings.erasing ? QStringLiteral("Erase")
: stroke.isBlur ? QStringLiteral("Blur")
: stroke.clone ? QStringLiteral("Clone Stamp")
: stroke.settings.healing ? QStringLiteral("Spot Healing")
: QStringLiteral("Brush Stroke")));
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = result.transform;
layer.isGroup = false;
layer.mask = mask;
// Swift rebuilds the layer; effects stay, shape and text go.
layer.shape = std::nullopt;
layer.text = std::nullopt;
}
endEdit();
}
// A raster edit assembled off the UI thread; one step.
void EditorSession::commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply, std::function<void()> done)
{
// The caller resumes from the event loop, like await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (!stroke->committedTransform().isValid()) {
setBrushError(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what()));
finish();
return;
}
setIsProjectBusy(true);
const BrushCommit::Input input = stroke->commitInput();
m_committingRaster = CommittingRaster{std::move(stroke), name, std::move(alsoApply), std::move(done)};
m_rasterCommit.setFuture(QtConcurrent::run([input]() -> RasterMade {
try {
return RasterMade{BrushCommit::render(input), std::nullopt};
} catch (const ExportError &error) {
return RasterMade{std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishRasterCommit()
{
const RasterMade made = m_rasterCommit.result();
const CommittingRaster pending = std::move(m_committingRaster.value());
m_committingRaster = std::nullopt;
const auto finish = [&] {
setIsProjectBusy(false);
++m_brushRevision;
notify();
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
};
if (made.failure) {
setBrushError(made.failure);
finish();
return;
}
const BrushStroke &stroke = *pending.stroke;
const BrushCommit::Output &result = *made.output;
try {
const LayerTransform transform = stroke.transform(result.pixelBounds.translated(stroke.committedBounds().topLeft()));
if (!transform.isValid())
throw ProjectError(ProjectError::Kind::tooLarge);
std::optional<LayerMask> mask = stroke.layer.mask;
if (!stroke.isMask && mask && !mask->placement)
mask = mask->replacing(BrushCommit::expandMask(mask->asset, stroke.commitInput(), result.pixelBounds));
// Never over content that changed underneath it.
const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1;
if (index < 0) {
finish();
return;
}
const ImageLayer current = m_document->layers[index];
if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform || !sameMask(current.mask, stroke.layer.mask)) {
finish();
return;
}
beginEdit(pending.name);
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = transform;
layer.isGroup = false;
// Swift rebuilds the layer; effects stay, shape and text go.
layer.shape = std::nullopt;
layer.text = std::nullopt;
if (mask && current.mask)
mask->isEnabled = current.mask->isEnabled;
layer.mask = mask;
}
if (pending.alsoApply)
pending.alsoApply();
endEdit();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
finish();
}
bool EditorSession::usesOpacityKeys() const
{
return isBrushTool(m_tool) || m_tool == NavigationTool::gradient || m_tool == NavigationTool::move;
}
void EditorSession::typeOpacityDigit(int digit, std::optional<double> time)
{
if (!usesOpacityKeys() || m_brushStroke || m_isProjectBusy || digit < 0 || digit > 9)
return;
const double now = time.value_or(uptime());
int percent = digit == 0 ? 100 : digit * 10;
if (m_pendingOpacityDigit && now - m_pendingOpacityDigit->time < 0.6) {
percent = std::max(1, m_pendingOpacityDigit->digit * 10 + digit);
m_pendingOpacityDigit = std::nullopt;
} else {
m_pendingOpacityDigit = PendingOpacityDigit{digit, now};
}
const double value = percent / 100.0;
if (isBrushTool(m_tool)) {
m_brushSettings.opacity = value;
notify();
} else if (m_tool == NavigationTool::gradient) {
setGradientSettings(GradientSettings{m_gradientSettings.shape, m_gradientSettings.style, m_gradientSettings.reversed, value});
} else {
setSelectedLayersOpacity(value);
}
}
// Shift-[ and Shift-]: hardness in Photoshop's quarters.
void EditorSession::changeBrushHardness(bool increase)
{
if (m_brushStroke)
return;
const double quarter = m_brushSettings.hardness * 4;
const double step = increase ? std::floor(quarter + 0.001) + 1 : std::ceil(quarter - 0.001) - 1;
m_brushSettings.hardness = std::min(4.0, std::max(0.0, step)) / 4;
notify();
}
void EditorSession::changeBrushSize(bool increase)
{
if (m_brushStroke)
return;
// A fifth, at least a pixel: 2 would stay 2.
const double current = m_brushSettings.diameter;
const double stepped = increase ? std::max(current + 1, std::round(current * 1.2)) : std::min(current - 1, std::round(current / 1.2));
m_brushSettings.diameter = std::min(2000.0, std::max(1.0, stepped));
notify();
}
void EditorSession::setBrushSettings(const BrushSettings &settings)
{
m_brushSettings = settings;
refreshGradient();
notify();
}
void EditorSession::setBrushMode(BrushToolMode mode)
{
m_brushMode = mode;
notify();
}
void EditorSession::setBlurMode(BlurToolMode mode)
{
m_blurMode = mode;
notify();
}
void EditorSession::setSpotHealingMode(SpotHealingMode mode)
{
m_spotHealingMode = mode;
notify();
}
void EditorSession::setMaskPaintWhite(bool white)
{
m_maskPaintWhite = white;
refreshGradient();
notify();
}
+24
View File
@@ -0,0 +1,24 @@
// Swift's EditorSession+Brush extension: a stroke from press to commit.
public:
bool canPaint() const;
std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings()) const;
void beginBrush(QPointF point);
void continueBrush(QPointF point);
// Where a Shift-click's line starts; nil on another target.
std::optional<QPointF> shiftLineStart() const;
void cancelBrush();
// Mouse-up's own call; false while busy.
bool finishBrushImmediately();
void finishBrush();
void commitPaintSnapshot(const BrushStroke &stroke);
// Off the UI thread; `done` follows the step or refusal.
void commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply = {},
std::function<void()> done = {});
bool usesOpacityKeys() const;
// Digits set 10%…100%; two quick ones, an exact value.
void typeOpacityDigit(int digit, std::optional<double> time = std::nullopt);
void changeBrushHardness(bool increase);
void changeBrushSize(bool increase);
private:
void finishRasterCommit();
+124
View File
@@ -0,0 +1,124 @@
#include "Document/EditorSession.h"
#include "Logging.h"
#include <QtConcurrent>
bool EditorSession::canStartProjectOperation() const
{
return !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument && !m_showsImporter
&& !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID;
}
void EditorSession::waitForFileRequest(std::function<void()> ready)
{
if (canStartProjectOperation())
ready();
else
m_fileRequestWaiters.push_back(std::move(ready));
}
void EditorSession::resumeFileRequests()
{
if (!canStartProjectOperation())
return;
// Each waiter looks again when its turn comes.
for (const std::function<void()> &waiter : std::exchange(m_fileRequestWaiters, {}))
QMetaObject::invokeMethod(this, [this, waiter] { waitForFileRequest(waiter); }, Qt::QueuedConnection);
}
void EditorSession::waitForProjectAccess(std::function<void()> ready)
{
if (!m_isProjectBusy)
ready();
else
m_projectWaiters.push_back(std::move(ready));
}
void EditorSession::updateBusyIndicator()
{
// Quick operations never dim the controls.
if (m_isProjectBusy) {
if (!m_busyTimer.isActive() && !m_showsBusy)
m_busyTimer.start();
} else {
m_busyTimer.stop();
m_showsBusy = false;
}
}
void EditorSession::importImages(const QList<QUrl> &urls, std::optional<QPointF> point, std::function<void()> done)
{
if (urls.isEmpty()) {
if (done)
done();
return;
}
if (m_brushStroke)
finishBrush();
cancelCrop();
commitTransform();
waitForProjectAccess([this, urls, point, done] {
m_pendingImports.push_back({urls, point, done});
if (!m_isImporting) {
setIsImporting(true);
drainImports();
}
});
}
void EditorSession::drainImports()
{
if (m_pendingImports.empty()) {
setIsImporting(false);
if (!m_importFailures.isEmpty())
setImportError(std::exchange(m_importFailures, {}).join("\n\n"));
return;
}
beginEdit(QStringLiteral("Import Images"));
// Without a canvas the first image makes it.
m_importPoint = m_document ? m_pendingImports.front().point : std::nullopt;
m_importIndex = 0;
decodeNext();
}
void EditorSession::decodeNext()
{
const ImportRequest &request = m_pendingImports.front();
if (m_importIndex == request.urls.size()) {
endEdit();
qCInfo(lcApp) << "imported a request of" << request.urls.size() << "files";
// The caller goes on once the queue's state is settled.
if (request.done)
QMetaObject::invokeMethod(this, request.done, Qt::QueuedConnection);
m_pendingImports.pop_front();
drainImports();
return;
}
qint64 usedPixels = 0;
if (m_document) {
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height();
}
}
const QUrl url = request.urls[m_importIndex];
m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded {
try {
if (!url.isLocalFile())
throw ImageImportError(ImageImportError::Kind::unsupported);
return {ImageImporter::decode(url.toLocalFile(), remaining), QString()};
} catch (const ImageImportError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()};
}
}));
}
void EditorSession::finishDecode()
{
const Decoded decoded = m_decoder.result();
if (decoded.asset)
insert(*decoded.asset, m_importPoint);
else
m_importFailures << decoded.failure;
m_importIndex += 1;
decodeNext();
}
+136
View File
@@ -0,0 +1,136 @@
#pragma once
#include "Document/BrushStroke.h"
#include "Document/Filters.h"
#include "Document/HueSaturation.h"
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include "IO/ProjectStore.h"
#include <QImage>
#include <QPainterPath>
#include <QSet>
#include <QUrl>
#include <functional>
#include <map>
#include <optional>
// The private structs of EditorSession, its private base of types.
struct SessionJobs {
// A brush family's tip while another family is in use.
struct ParkedTip {
double diameter;
double hardness;
double opacity;
};
struct ImportRequest {
QList<QUrl> urls;
std::optional<QPointF> point;
std::function<void()> done;
};
struct Decoded {
std::optional<ImportedImage> asset;
QString failure;
};
struct Baked {
std::map<QUuid, ImportedImage> images;
std::optional<QString> failure;
};
// What an invert waits for and writes back.
struct Inverting {
QUuid layerID;
bool mask;
std::optional<ImageIdentity> image;
std::optional<ImageIdentity> maskImage;
std::function<void()> done;
};
struct Inverted {
std::optional<QImage> image;
std::optional<QString> failure;
};
// What a wand click waits for and applies.
struct Wanding {
SelectionMode mode;
QUuid documentID;
std::function<void()> done;
};
struct Wanded {
std::optional<QPainterPath> path;
std::optional<QString> failure;
};
// The last stroke's end, for a Shift-click's line.
struct LastBrushPoint {
QPointF point;
QUuid layerID;
bool mask;
};
struct PendingOpacityDigit {
int digit;
double time;
};
// A raster edit's result on the way to the layer.
struct RasterMade {
std::optional<BrushCommit::Output> output;
std::optional<QString> failure;
};
struct CommittingRaster {
std::shared_ptr<const BrushStroke> stroke;
QString name;
std::function<void()> alsoApply;
std::function<void()> done;
};
// A filter's preview, and its result for the layer.
struct Filtered {
std::optional<QImage> image;
std::optional<QString> failure;
// The job's settings, which the preview was made with.
FilterSettings settings;
};
struct FilterMade {
std::optional<ImportedImage> asset;
std::optional<LayerTransform> transform;
std::optional<QString> failure;
};
// The crop's resized project, or why it failed.
struct Cropped {
std::optional<ProjectSnapshot> snapshot;
std::optional<QString> failure;
};
// Levels' result for the layer.
struct Leveled {
std::optional<ImportedImage> asset;
std::optional<QString> failure;
};
// A commit's caller, waiting for the preview or the result.
struct Committing {
QUuid editID;
std::function<void()> done;
};
// Hue/Saturation's pixels, or why they failed.
struct Adjusted {
std::optional<AdjustedPixels> pixels;
std::optional<QString> failure;
};
// The layers beneath an adjustment, drawn, or why not.
struct AdjustmentInput {
std::optional<ImportedImage> asset;
std::optional<QString> failure;
};
// A Hue/Saturation commit keeps its edit and its caller.
struct CommittingHue {
HueSaturationEdit edit;
std::function<void()> done;
};
// A merge worked out once: what goes, what it becomes.
struct MergePlan {
std::vector<QUuid> ids;
QSet<QUuid> removed;
QString name;
std::optional<QUuid> parent;
QUuid anchor;
QString action;
};
struct TransformDuplicate {
std::vector<QUuid> copies;
QSet<QUuid> source;
QUuid primary;
};
};
+181
View File
@@ -0,0 +1,181 @@
#include "Document/EditorSession.h"
#include <algorithm>
#include <map>
void EditorSession::addBlankLayer()
{
if (!canEditLayers())
return;
const std::vector<ImageLayer> &existing = m_document->layers;
int number = 1;
const auto taken = [&](const QString &name) {
return std::any_of(existing.begin(), existing.end(), [&](const ImageLayer &layer) { return layer.name == name; });
};
while (taken(QStringLiteral("Layer %1").arg(number)))
++number;
ImageLayer layer(QStringLiteral("Layer %1").arg(number), m_document->size());
const std::optional<ImageLayer> active = activeLayer();
const bool intoFolder = active && active->isGroup;
layer.parentID = intoFolder ? m_activeLayerID : active ? active->parentID : std::nullopt;
if (layer.parentID)
m_collapsedGroupIDs.remove(*layer.parentID);
const int activeIndex = indexOf(existing, m_activeLayerID);
int insertion = activeIndex >= 0 ? activeIndex + 1 : int(existing.size());
// In a folder the layer goes above its topmost contents.
if (intoFolder) {
std::map<QUuid, std::optional<QUuid>> parents;
for (const ImageLayer &each : existing)
parents[each.id] = each.parentID;
const auto isInside = [&](QUuid id) {
std::optional<QUuid> parent = parents[id];
for (int steps = 0; parent && steps < 64; ++steps) {
if (*parent == *m_activeLayerID)
return true;
parent = parents.count(*parent) ? parents[*parent] : std::nullopt;
}
return false;
};
for (int index = int(existing.size()) - 1; index >= 0; --index) {
if (isInside(existing[index].id)) {
insertion = std::max(insertion, index + 1);
break;
}
}
}
beginEdit(QStringLiteral("New Blank Layer"));
m_document->layers.insert(m_document->layers.begin() + insertion, layer);
setActiveLayerID(layer.id);
endEdit();
}
void EditorSession::deleteLayer(QUuid id)
{
if (!canEditLayers() || indexOf(m_document->layers, id) < 0)
return;
if (!deleteWithLiveMaskChoice({id}))
finishDeletingLayer(id, {});
}
void EditorSession::deleteActiveLayer()
{
if (m_activeLayerID)
deleteLayer(*m_activeLayerID);
}
void EditorSession::deleteSelectedLayers()
{
if (!canEditLayers())
return;
// Captured first: deleting moves the active layer.
std::vector<QUuid> ids;
for (const ImageLayer &layer : m_document->layers) {
if (m_selectedLayerIDs.contains(layer.id))
ids.push_back(layer.id);
}
if (ids.size() <= 1)
deleteActiveLayer();
else if (!deleteWithLiveMaskChoice(ids))
finishDeletingLayers(ids, {});
}
void EditorSession::renameLayer(QUuid id, const QString &name)
{
const QString trimmed = name.trimmed();
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (m_isProjectBusy || m_isImporting || trimmed.isEmpty() || index < 0)
return;
beginEdit(QStringLiteral("Rename Layer"));
m_document->layers[index].name = trimmed;
endEdit();
}
void EditorSession::toggleLayerVisibility(QUuid id)
{
const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1;
if (index < 0)
return;
beginEdit(m_document->layers[index].isVisible ? QStringLiteral("Hide Layer") : QStringLiteral("Show Layer"));
m_document->layers[index].isVisible = !m_document->layers[index].isVisible;
endEdit();
}
std::optional<bool> EditorSession::beginVisibilitySwipe(QUuid id)
{
const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1;
if (index < 0)
return std::nullopt;
const bool visible = !m_document->layers[index].isVisible;
beginEdit(visible ? QStringLiteral("Show Layer") : QStringLiteral("Hide Layer"));
setVisibilityInSwipe(id, visible);
return visible;
}
void EditorSession::setVisibilityInSwipe(QUuid id, bool visible)
{
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (index < 0 || m_document->layers[index].isVisible == visible)
return;
m_document->layers[index].isVisible = visible;
notify();
}
void EditorSession::endVisibilitySwipe()
{
endEdit();
}
void EditorSession::reorderLayers(const std::set<int> &offsets, int destination)
{
if (!canEditLayers())
return;
std::vector<ImageLayer> listed(m_document->layers.rbegin(), m_document->layers.rend());
const int count = int(listed.size());
const bool inRange = std::all_of(offsets.begin(), offsets.end(), [&](int offset) { return offset >= 0 && offset < count; });
if (!inRange || destination < 0 || destination > count)
return;
// SwiftUI's move: the picked rows land before `destination`.
std::vector<ImageLayer> moved, kept;
int before = 0;
for (int index = 0; index < count; ++index) {
const bool picked = offsets.count(index) > 0;
(picked ? moved : kept).push_back(listed[index]);
before += picked && index < destination;
}
kept.insert(kept.begin() + (destination - before), moved.begin(), moved.end());
beginEdit(QStringLiteral("Reorder Layers"));
m_document->layers.assign(kept.rbegin(), kept.rend());
endEdit();
}
bool EditorSession::canMoveActiveLayer(int offset) const
{
const std::optional<ImageLayer> active = canEditLayers() ? activeLayer() : std::nullopt;
if (!active)
return false;
int position = -1, siblings = 0;
for (const ImageLayer &layer : m_document->layers) {
if (layer.parentID != active->parentID)
continue;
if (layer.id == active->id)
position = siblings;
++siblings;
}
return position >= 0 && position + offset >= 0 && position + offset < siblings;
}
void EditorSession::moveActiveLayer(int offset)
{
if (!canMoveActiveLayer(offset))
return;
const ImageLayer active = *activeLayer();
std::vector<int> siblings;
for (int index = 0; index < int(m_document->layers.size()); ++index) {
if (m_document->layers[index].parentID == active.parentID)
siblings.push_back(index);
}
const int from = indexOf(m_document->layers, active.id);
const auto position = std::find(siblings.begin(), siblings.end(), from) - siblings.begin();
beginEdit(QStringLiteral("Reorder Layers"));
std::swap(m_document->layers[from], m_document->layers[siblings[position + offset]]);
endEdit();
}
+126
View File
@@ -0,0 +1,126 @@
#include "Document/EditorSession+Model.h"
#include "IO/ProjectStore.h"
#include <algorithm>
ImageLayer::ImageLayer(ImportedImage imported, QPointF origin)
: id(QUuid::createUuid()), asset(std::move(imported)),
transform{.origin = origin, .size = QSizeF(asset->size())}, name(asset->name)
{
}
int indexOf(const std::vector<ImageLayer> &layers, std::optional<QUuid> id)
{
const auto found = std::find_if(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return id && layer.id == *id; });
return found == layers.end() ? -1 : int(found - layers.begin());
}
ImageLayer::ImageLayer(QString name, QSizeF blankSize)
: id(QUuid::createUuid()), transform{.origin = QPointF(0, 0), .size = blankSize}, name(std::move(name))
{
}
ImageLayer::ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedImage> asset, std::optional<LayerMask> mask)
: 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)
{
}
bool operator==(const ImageLayer &lhs, const ImageLayer &rhs)
{
const bool sameImage = lhs.asset.has_value() == rhs.asset.has_value()
&& (!lhs.asset || lhs.asset->identity() == rhs.asset->identity());
return lhs.id == rhs.id && lhs.name == rhs.name && lhs.isVisible == rhs.isVisible
&& lhs.transform == rhs.transform && sameImage && lhs.parentID == rhs.parentID
&& lhs.isGroup == rhs.isGroup && lhs.opacity == rhs.opacity && lhs.blendMode == rhs.blendMode
&& lhs.mask == rhs.mask && lhs.maskSourceID == rhs.maskSourceID && lhs.adjustment == rhs.adjustment && lhs.shape == rhs.shape
&& lhs.text == rhs.text && lhs.effects == rhs.effects;
}
CanvasDocument::CanvasDocument(int width, int height)
: id(QUuid::createUuid()), width(width), height(height)
{
}
CanvasDocument::CanvasDocument(const ProjectSnapshot &snapshot)
: id(snapshot.manifest.documentID), width(int(snapshot.manifest.width)), height(int(snapshot.manifest.height)),
resolution(snapshot.manifest.resolution.value_or(72))
{
for (const ProjectLayerRecord &record : snapshot.manifest.layers) {
const auto image = snapshot.images.find(record.id);
layers.push_back(ImageLayer(record, image == snapshot.images.end() ? std::nullopt : std::optional(image->second),
snapshot.mask(record)));
}
}
namespace {
// Swift's CharacterSet.whitespaces: category Zs and tab.
bool isWhitespace(QChar character)
{
return character == u'\t' || character.category() == QChar::Separator_Space;
}
// Swift's Int(String): optional sign, then ASCII digits only.
std::optional<int> wholeNumber(QStringView text)
{
qsizetype index = text.startsWith(u'+') || text.startsWith(u'-') ? 1 : 0;
if (index == text.size())
return std::nullopt;
for (; index < text.size(); ++index) {
if (text[index] < u'0' || text[index] > u'9')
return std::nullopt;
}
bool fits = false;
const int number = text.toInt(&fits);
return fits ? std::optional<int>(number) : std::nullopt;
}
}
std::optional<int> CanvasDocument::validDimension(const QString &value)
{
qsizetype first = 0, last = value.size();
while (first < last && isWhitespace(value[first]))
++first;
while (last > first && isWhitespace(value[last - 1]))
--last;
const std::optional<int> number = wholeNumber(QStringView(value).mid(first, last - first));
if (!number || *number < 1 || *number > 30'000)
return std::nullopt;
return number;
}
QString label(NavigationTool tool)
{
switch (tool) {
case NavigationTool::move: return QStringLiteral("Move / Transform (V)");
case NavigationTool::marquee: return QStringLiteral("Marquee (M)");
case NavigationTool::lasso: return QStringLiteral("Lasso (L)");
case NavigationTool::wand: return QStringLiteral("Magic Wand (W)");
case NavigationTool::crop: return QStringLiteral("Crop (C)");
case NavigationTool::brush: return QStringLiteral("Brush (B) · Eraser (E)");
case NavigationTool::spotHealing: return QStringLiteral("Spot Healing Brush (J)");
case NavigationTool::cloneStamp: return QStringLiteral("Clone Stamp (S) · Alt-click sets the source");
case NavigationTool::blur: return QStringLiteral("Smear (R)");
case NavigationTool::gradient: return QStringLiteral("Gradient (G)");
case NavigationTool::shape: return QStringLiteral("Shape (U) · Shift-U switches Rectangle/Ellipse");
case NavigationTool::type: return QStringLiteral("Type (T)");
case NavigationTool::eyedropper: return QStringLiteral("Eyedropper (I)");
case NavigationTool::hand: return QStringLiteral("Hand (H)");
case NavigationTool::zoom: return QStringLiteral("Zoom (Z)");
// No tool sits in no rail: nothing to name.
case NavigationTool::idle: break;
}
throw std::logic_error("no tool has no label");
}
bool isBrushTool(NavigationTool tool)
{
return tool == NavigationTool::brush || tool == NavigationTool::spotHealing
|| tool == NavigationTool::cloneStamp || tool == NavigationTool::blur;
}
bool isSelectionTool(NavigationTool tool)
{
return tool == NavigationTool::marquee || tool == NavigationTool::lasso || tool == NavigationTool::wand;
}
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "Document/LayerAdjustment.h"
#include "Document/LayerAppearance.h"
#include "Document/LayerGroups.h"
#include "Document/LayerMask.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
#include "Document/ShapeTool.h"
#include "Document/TypeTool.h"
#include "IO/ImageImporter.h"
#include <QString>
#include <QUuid>
#include <optional>
#include <vector>
struct ImageLayer {
QUuid id;
std::optional<ImportedImage> asset;
LayerTransform transform;
QString name;
bool isVisible = true;
std::optional<QUuid> parentID;
bool isGroup = false;
double opacity = 1;
LayerBlendMode blendMode = LayerBlendMode::normal;
std::optional<QUuid> maskSourceID;
std::optional<LayerMask> mask;
std::optional<LayerAdjustment> adjustment;
// Set on layers the Shape tool made; see `liveShape`.
std::optional<LayerShape> shape;
// A stroke, shadows or overlay drawn round the pixels.
std::optional<LayerEffects> effects;
std::optional<LayerText> text;
ImageLayer(ImportedImage imported, QPointF origin);
ImageLayer(QString name, QSizeF blankSize);
// A layer as a project records it.
ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedImage> asset, std::optional<LayerMask> mask);
QPointF origin() const { return transform.origin; }
QSizeF size() const { return transform.size; }
ProjectLayerRecord hierarchyRecord() const;
LayerTransform maskTransform() const;
// The shape this layer still is: none once repainted otherwise.
std::optional<LayerShape> liveShape() const;
// The text this layer still is: none once repainted otherwise.
std::optional<LayerText> liveText() const;
friend bool operator==(const ImageLayer &lhs, const ImageLayer &rhs);
};
// The layer's place in the list, or -1.
int indexOf(const std::vector<ImageLayer> &layers, std::optional<QUuid> id);
struct ProjectSnapshot;
struct CanvasDocument {
QUuid id;
int width;
int height;
double resolution = 72;
std::vector<ImageLayer> layers; // Bottom to top.
// Part of the document so undo covers it; never saved.
std::optional<DocumentSelection> selection;
CanvasDocument(int width, int height);
// The canvas a project holds, each layer as recorded.
explicit CanvasDocument(const ProjectSnapshot &snapshot);
QSizeF size() const { return QSizeF(width, height); }
static std::optional<int> validDimension(const QString &value);
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
QSet<QUuid> effectiveVisibleIDs() const;
std::vector<ImageLayer> renderLayers() const;
friend bool operator==(const CanvasDocument &lhs, const CanvasDocument &rhs) = default;
};
enum class NavigationTool {
move, marquee, lasso, wand, crop, brush, spotHealing, cloneStamp, blur,
gradient, shape, type, eyedropper, hand, zoom, idle
};
// The rail's tooltip: the tool's name and its key.
QString label(NavigationTool tool);
bool isBrushTool(NavigationTool tool);
bool isSelectionTool(NavigationTool tool);
+71
View File
@@ -0,0 +1,71 @@
#include "Document/EditorSession.h"
#include "Logging.h"
std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
{
if (!m_document)
return std::nullopt;
ProjectSnapshot snapshot{.manifest = {.resolution = m_document->resolution, .documentID = m_document->id, .width = m_document->width,
.height = m_document->height, .activeLayerID = m_activeLayerID, .layers = {}},
.images = {}};
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
snapshot.images.insert({layer.id, *layer.asset});
if (layer.mask)
snapshot.masks.insert({layer.id, layer.mask->asset});
snapshot.manifest.layers.push_back(layer.hierarchyRecord());
}
return snapshot;
}
void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path)
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
m_cropRect.reset();
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;
history.reset();
viewport.fit(m_document->size());
qCInfo(lcApp).noquote() << "installed a project of" << int(m_document->layers.size()) << "layers from" << path;
resumeFileRequests();
notify();
}
void EditorSession::setProjectPath(std::optional<QString> path)
{
m_projectPath = std::move(path);
notify();
}
void EditorSession::clearProject()
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
m_cropRect.reset();
m_transformEdit = std::nullopt;
m_document = std::nullopt;
setActiveLayerID(std::nullopt);
m_renamingLayerID = std::nullopt;
m_projectPath = std::nullopt;
history.reset();
resumeFileRequests();
notify();
}
void EditorSession::createNewProject(int width, int height)
{
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000)
return;
clearProject();
createDocument(width, height, true);
}
+7
View File
@@ -0,0 +1,7 @@
// Swift's EditorSession+Projects extension: snapshots in and out.
public:
std::optional<ProjectSnapshot> projectSnapshot() const;
// Only a package that loaded and validated whole is installed.
void installProject(const ProjectSnapshot &snapshot, const QString &path);
void clearProject();
void createNewProject(int width, int height);
+296
View File
@@ -0,0 +1,296 @@
#include "Document/Distort.h"
#include "Document/EditorSession.h"
#include <algorithm>
bool EditorSession::canTransform() const
{
if (!canEditLayers())
return false;
if (transformsAsGroup())
return !groupTransformMembers().empty();
const std::optional<ImageLayer> active = activeLayer();
return active && active->asset && m_document->effectiveVisibleIDs().contains(active->id);
}
bool EditorSession::transformsAsGroup() const
{
const std::optional<ImageLayer> active = activeLayer();
return m_selectedLayerIDs.size() > 1 || (m_selectedLayerIDs.size() == 1 && active && active->isGroup);
}
std::vector<ImageLayer> EditorSession::groupTransformMembers() const
{
if (!transformsAsGroup() || !m_document)
return {};
QHash<QUuid, std::optional<QUuid>> parents;
for (const ImageLayer &layer : m_document->layers)
parents.insert(layer.id, layer.parentID);
const QSet<QUuid> visible = m_document->effectiveVisibleIDs();
std::vector<ImageLayer> members;
for (const ImageLayer &layer : m_document->layers) {
if (!layer.asset || !visible.contains(layer.id))
continue;
// Selected itself, or inside a selected folder.
std::optional<QUuid> current = layer.id;
for (int step = 0; step < 64 && current; ++step) {
if (m_selectedLayerIDs.contains(*current)) {
members.push_back(layer);
break;
}
current = parents.value(*current);
}
}
return members;
}
std::optional<LayerTransform> EditorSession::groupTransformBox() const
{
const std::vector<ImageLayer> members = groupTransformMembers();
if (members.empty())
return std::nullopt;
QPointF low = DistortWarp::corners(members.front().transform)[0], high = low;
for (const ImageLayer &member : members) {
for (const QPointF corner : DistortWarp::corners(member.transform)) {
low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())};
high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())};
}
}
// Rounding can leave a unit layer's box under one pixel.
return LayerTransform{.origin = low, .size = {std::max(1.0, high.x() - low.x()), std::max(1.0, high.y() - low.y())}};
}
void EditorSession::beginTransform(bool persistent)
{
cancelCrop();
const std::optional<ImageLayer> layer = activeLayer();
if (!canTransform() || !layer)
return;
m_tool = NavigationTool::move;
if (transformsAsGroup()) {
const std::optional<LayerTransform> box = groupTransformBox();
if (!box)
return;
QHash<QUuid, LayerTransform> originals;
for (const ImageLayer &member : groupTransformMembers())
originals.insert(member.id, member.transform);
m_transformEdit = TransformEdit{layer->id, *box, TransformGroup{*box, originals}, false, persistent, std::nullopt, nullptr};
} else {
// Linked, layer and mask move together as the layer.
const bool maskAlone = transformTargetsMask();
m_transformEdit = TransformEdit{layer->id, maskAlone ? layer->maskTransform() : layer->transform, std::nullopt, maskAlone, persistent, std::nullopt, nullptr};
}
notify();
}
void EditorSession::setTransformAutoSelect(bool picks)
{
m_transformAutoSelect = picks;
notify();
}
void EditorSession::setShowsTransformControls(bool shows)
{
m_showsTransformControls = shows;
notify();
}
void EditorSession::setLocksTransformRatio(bool locks)
{
m_locksTransformRatio = locks;
notify();
}
// A folder has no pixels to copy: it just moves.
void EditorSession::beginDuplicateTransform()
{
if (m_transformDuplicate || !m_activeLayerID)
return;
const QUuid primary = *m_activeLayerID;
commitTransform();
if (!canTransform())
return;
const QSet<QUuid> selection = m_selectedLayerIDs;
// Bottom to top: the copies keep their order.
std::vector<QUuid> targets;
for (const ImageLayer &layer : m_document->layers) {
if (selection.contains(layer.id) && !layer.isGroup)
targets.push_back(layer.id);
}
if (targets.empty())
return;
beginEdit(targets.size() > 1 ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer"));
std::vector<QUuid> copies;
for (const QUuid &id : targets) {
selectLayer(id);
duplicateActiveLayer();
copies.push_back(m_activeLayerID.value());
}
m_transformDuplicate = TransformDuplicate{copies, selection, primary};
selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back());
beginTransform(false);
}
// The duplicate's step closes with its transform.
void EditorSession::endDuplicateTransform()
{
if (!m_transformDuplicate)
return;
m_transformDuplicate = std::nullopt;
endEdit();
}
void EditorSession::previewTransform(const LayerTransform &value)
{
if (!value.isValid() || !m_transformEdit)
return;
m_transformEdit->draft = value;
notify();
}
void EditorSession::commitTransform()
{
snapGuides = {};
// Whatever ends a transform ends these two as well.
if (m_blendPreview) {
m_blendPreview = std::nullopt;
notify();
}
finishOpacityEdit();
if (!m_transformEdit)
return;
if (m_transformEdit->floating) {
// Unchanged: restored exactly, so soft edges keep no seam.
const TransformEdit edit = *std::exchange(m_transformEdit, std::nullopt);
if (edit.draft == edit.floating->original && !edit.corners)
cancelFloatingTransform(*edit.floating);
else
mergeFloatingTransform(edit, *edit.floating);
return;
}
if (m_transformEdit->mask) {
commitMaskTransform();
return;
}
if (m_transformEdit->corners) {
const TransformEdit edit = *m_transformEdit;
m_transformEdit = std::nullopt;
commitDistort(edit, *edit.corners);
endDuplicateTransform();
return;
}
// One transaction: an edit that moves nothing records nothing.
beginEdit(m_transformEdit->group ? QStringLiteral("Transform Layers") : QStringLiteral("Transform Layer"));
for (int index = 0; index < int(m_document->layers.size()); ++index) {
ImageLayer &layer = m_document->layers[size_t(index)];
// A layer the box squeezes under a pixel stays put.
const std::optional<LayerTransform> moved = pendingTransform(layer);
if (!moved || !moved->isValid())
continue;
// A linked mask goes along; an unlinked one stays.
if (layer.mask)
layer.mask->placement = layer.mask->placementMovingLayer(layer.transform, *moved);
layer.transform = *moved;
redrawShape(index);
}
m_transformEdit = std::nullopt;
endEdit();
endDuplicateTransform();
}
void EditorSession::cancelTransform()
{
snapGuides = {};
if (!m_transformEdit)
return;
const std::shared_ptr<const FloatingTransform> floating = std::exchange(m_transformEdit, std::nullopt)->floating;
if (m_transformDuplicate) {
// The copies go and the old selection returns.
const TransformDuplicate duplicate = *m_transformDuplicate;
std::erase_if(m_document->layers, [&](const ImageLayer &layer) {
return std::find(duplicate.copies.begin(), duplicate.copies.end(), layer.id) != duplicate.copies.end();
});
selectLayers(duplicate.source, duplicate.primary);
endDuplicateTransform();
}
if (floating)
cancelFloatingTransform(*floating);
notify();
}
std::optional<QSizeF> EditorSession::transformPixelSize() const
{
if (m_transformEdit && m_transformEdit->group)
return m_transformEdit->group->box.size;
if (!m_transformEdit && transformsAsGroup()) {
const std::optional<LayerTransform> box = groupTransformBox();
return box ? std::optional(box->size) : std::nullopt;
}
// A mask has no pixel size to scale against.
if (transformTargetsMask())
return std::nullopt;
if (m_transformEdit && m_transformEdit->floating)
return m_transformEdit->floating->pixelSize;
const std::optional<ImageLayer> active = activeLayer();
return active && active->asset ? std::optional(QSizeF(active->asset->size())) : std::nullopt;
}
bool EditorSession::transformTargetsMask() const
{
if (m_transformEdit)
return m_transformEdit->mask;
const std::optional<ImageLayer> active = activeLayer();
return m_isMaskSelected && active && active->mask && !active->mask->isLinked;
}
LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const
{
if (const std::optional<LayerTransform> pending = pendingTransform(layer))
return *pending;
// Content-Aware Fill past the edge previews on the grown layer.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->grownTransform;
return layer.transform;
}
LayerTransform EditorSession::editedTransform(const ImageLayer &layer) const
{
if (m_transformEdit && m_transformEdit->layerID == layer.id)
return m_transformEdit->draft;
// Before an edit the handles sit on the group's box.
if (!m_transformEdit && layer.id == m_activeLayerID && transformsAsGroup())
return groupTransformBox().value_or(layer.transform);
return layer.id == m_activeLayerID && transformTargetsMask() ? layer.maskTransform() : layer.transform;
}
std::optional<LayerTransform> EditorSession::pendingTransform(const ImageLayer &layer) const
{
// An edit of the mask alone moves no layer.
if (!m_transformEdit || m_transformEdit->mask)
return std::nullopt;
if (m_transformEdit->group) {
const TransformGroup &group = *m_transformEdit->group;
const auto original = group.originals.constFind(layer.id);
return original == group.originals.constEnd() ? std::nullopt : std::optional(original->following(group.box, m_transformEdit->draft));
}
return m_transformEdit->layerID == layer.id ? std::optional(m_transformEdit->draft) : std::nullopt;
}
void EditorSession::nudgeLayer(double dx, double dy)
{
const bool alreadyEditing = m_transformEdit.has_value();
if (!alreadyEditing)
beginTransform(false);
if (!m_transformEdit)
return;
LayerTransform value = m_transformEdit->draft;
value.origin += QPointF(dx, dy);
previewTransform(value);
if (const std::optional<Corners> corners = m_transformEdit->corners) {
Corners moved = *corners;
for (QPointF &corner : moved)
corner += QPointF(dx, dy);
previewCorners(moved);
}
if (!alreadyEditing)
commitTransform();
}
+347
View File
@@ -0,0 +1,347 @@
#include "Document/EditorSession.h"
#include "Logging.h"
#include <cmath>
EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(this)
{
// A coarse timer may fire a twentieth early.
m_busyTimer.setTimerType(Qt::PreciseTimer);
m_busyTimer.setSingleShot(true);
m_busyTimer.setInterval(busyIndicatorDelay);
connect(&m_busyTimer, &QTimer::timeout, this, [this] {
m_showsBusy = true;
notify();
});
connect(&m_decoder, &QFutureWatcher<Decoded>::finished, this, &EditorSession::finishDecode);
connect(&m_baker, &QFutureWatcher<Baked>::finished, this, &EditorSession::finishBake);
connect(&m_inverter, &QFutureWatcher<Inverted>::finished, this, &EditorSession::finishInvert);
connect(&m_wand, &QFutureWatcher<Wanded>::finished, this, &EditorSession::finishWand);
connect(&m_filterPreview, &QFutureWatcher<Filtered>::finished, this, &EditorSession::finishFilterPreview);
connect(&m_filterCommit, &QFutureWatcher<FilterMade>::finished, this, &EditorSession::finishFilterCommit);
connect(&m_cropCommit, &QFutureWatcher<Cropped>::finished, this, &EditorSession::finishCropCommit);
connect(&m_levelsHistogram, &QFutureWatcher<std::optional<LevelsHistogram>>::finished, this, &EditorSession::finishLevelsHistogram);
connect(&m_levelsPreview, &QFutureWatcher<std::optional<QImage>>::finished, this, &EditorSession::finishLevelsPreview);
connect(&m_levelsCommit, &QFutureWatcher<Leveled>::finished, this, &EditorSession::finishLevelsCommit);
connect(&m_hueSaturationTask, &QFutureWatcher<Adjusted>::finished, this, &EditorSession::finishHueSaturationPreview);
connect(&m_hueSaturationCommit, &QFutureWatcher<Adjusted>::finished, this, &EditorSession::finishHueSaturationCommit);
connect(&m_rasterCommit, &QFutureWatcher<RasterMade>::finished, this, &EditorSession::finishRasterCommit);
}
void EditorSession::setActiveLayerID(std::optional<QUuid> id)
{
// Another layer: its pixels are the target again.
if (id != m_activeLayerID)
m_isMaskSelected = false;
m_activeLayerID = id;
m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>();
notify();
}
std::optional<ImageLayer> EditorSession::activeLayer() const
{
if (!m_document || !m_activeLayerID)
return std::nullopt;
const auto found = std::find_if(m_document->layers.begin(), m_document->layers.end(),
[&](const ImageLayer &layer) { return layer.id == *m_activeLayerID; });
return found == m_document->layers.end() ? std::nullopt : std::optional(*found);
}
void EditorSession::setIsProjectBusy(bool busy)
{
m_isProjectBusy = busy;
// Freed waiters each look again when their turn comes.
if (!busy) {
for (const std::function<void()> &waiter : std::exchange(m_projectWaiters, {}))
QMetaObject::invokeMethod(this, [this, waiter] { waitForProjectAccess(waiter); }, Qt::QueuedConnection);
}
resumeFileRequests();
updateBusyIndicator();
notify();
}
void EditorSession::setShowsNewDocument(bool shows)
{
m_showsNewDocument = shows;
resumeFileRequests();
notify();
}
void EditorSession::setShowsImporter(bool shows)
{
m_showsImporter = shows;
resumeFileRequests();
notify();
}
void EditorSession::setIsImporting(bool importing)
{
m_isImporting = importing;
resumeFileRequests();
notify();
}
void EditorSession::setImportError(std::optional<QString> error)
{
m_importError = std::move(error);
resumeFileRequests();
notify();
}
void EditorSession::setBrushError(std::optional<QString> error)
{
m_brushError = std::move(error);
notify();
}
void EditorSession::setRenamingLayerID(std::optional<QUuid> id)
{
m_renamingLayerID = id;
resumeFileRequests();
notify();
}
void EditorSession::setAdjustmentEditingID(std::optional<QUuid> id)
{
m_adjustmentEditingID = id;
resumeFileRequests();
notify();
}
void EditorSession::selectLayer(std::optional<QUuid> id)
{
dropEffectSelection();
if (id != m_activeLayerID && !finishText())
return;
if (m_brushStroke || m_warpStroke || m_levels)
return;
if (id != m_activeLayerID) {
commitTransform();
resolveGradient();
}
setActiveLayerID(id);
}
void EditorSession::selectTool(NavigationTool value)
{
if (m_tool != value && !finishText())
return;
if (m_isProjectBusy || m_brushStroke || m_warpStroke || m_levels)
return;
if (m_tool != value) {
commitTransform();
cancelCrop();
resolveGradient();
cancelLasso();
cancelShape();
}
// Each family's tip parks on leaving; sessions begin in 0.
const int from = tipFamily(m_tool), to = tipFamily(value);
if (from != to) {
const ParkedTip parked = m_parkedBrushTips.at(to);
m_parkedBrushTips[from] = ParkedTip{m_brushSettings.diameter, m_brushSettings.hardness, m_brushSettings.opacity};
m_brushSettings.diameter = parked.diameter;
m_brushSettings.hardness = parked.hardness;
m_brushSettings.opacity = parked.opacity;
}
m_tool = value;
// The Crop tool opens on the whole canvas, freely.
if (value == NavigationTool::crop && !m_cropRect && m_document) {
m_cropRatioChoice = QStringLiteral("Free");
m_cropRect = QRectF(QPointF(0, 0), m_document->size());
}
notify();
}
int EditorSession::tipFamily(NavigationTool tool)
{
return tool == NavigationTool::cloneStamp ? 1 : tool == NavigationTool::blur ? 2 : 0;
}
// Tab: the tool's next mode; others arrive with their tools.
void EditorSession::cycleToolMode()
{
if (m_isProjectBusy || m_brushStroke || m_warpStroke)
return;
if (m_tool == NavigationTool::marquee)
toggleMarqueeKind();
else if (m_tool == NavigationTool::lasso)
toggleLassoKind();
else if (m_tool == NavigationTool::shape)
toggleShapeKind();
else if (m_tool == NavigationTool::brush)
setBrushMode(m_brushMode == BrushToolMode::paint ? BrushToolMode::erase : BrushToolMode::paint);
else if (m_tool == NavigationTool::gradient)
setGradientSettings(GradientSettings{allGradientShapes[(size_t(m_gradientSettings.shape) + 1) % allGradientShapes.size()], m_gradientSettings.style,
m_gradientSettings.reversed, m_gradientSettings.opacity});
else if (m_tool == NavigationTool::blur)
setBlurMode(allBlurToolModes[(size_t(m_blurMode) + 1) % allBlurToolModes.size()]);
else if (m_tool == NavigationTool::spotHealing)
setSpotHealingMode(allSpotHealingModes[(size_t(m_spotHealingMode) + 1) % allSpotHealingModes.size()]);
else if (m_tool == NavigationTool::cloneStamp)
setCloneSettings(CloneSettings{m_cloneSettings.aligned, !m_cloneSettings.sampleAllLayers});
}
bool EditorSession::canUseHistory() const
{
return !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument && !m_showsImporter
&& !m_renamingLayerID && !m_importError && !m_transformEdit && !m_levels;
}
void EditorSession::undo()
{
// As Photoshop, the first Undo discards a pending gradient.
if (m_gradientEdit) {
cancelGradient();
return;
}
if (!canUndo())
return;
if (const std::optional<DocumentHistory::Snapshot> snapshot = history.undo())
restore(*snapshot);
}
void EditorSession::redo()
{
if (!canRedo())
return;
if (const std::optional<DocumentHistory::Snapshot> snapshot = history.redo())
restore(*snapshot);
}
void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
{
cancelCrop();
cancelGradient();
const auto canvas = [](const std::optional<CanvasDocument> &document) { return document ? std::optional(document->id) : std::nullopt; };
const bool changedCanvas = canvas(m_document) != canvas(snapshot.document);
// The mask stays the target while layer and mask remain.
const bool keepsMaskTarget = m_isMaskSelected && m_activeLayerID == snapshot.activeLayerID;
m_document = snapshot.document;
setActiveLayerID(snapshot.activeLayerID);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = keepsMaskTarget && active && active->mask;
if (changedCanvas && m_document)
viewport.fit(m_document->size());
qCInfo(lcApp) << "restored a history snapshot of" << (m_document ? int(m_document->layers.size()) : 0) << "layers";
notify();
}
void EditorSession::beginEdit(const QString &name)
{
history.begin(name, m_document, m_activeLayerID);
// An open transaction closes undo and redo: observers must know.
notify();
}
void EditorSession::endEdit()
{
history.end(m_document, m_activeLayerID);
notify();
}
bool EditorSession::canEditLayers() const
{
return !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting && !m_showsNewDocument && !m_showsImporter
&& !m_renamingLayerID && !m_transformEdit && !m_cropRect && !m_gradientEdit && !m_filterEdit && !m_pixelMove && !m_levels
&& !m_hueSaturation && !m_adjustmentEditingID;
}
void EditorSession::insert(const ImportedImage &asset, std::optional<QPointF> centeredAt)
{
beginEdit(QStringLiteral("Import Image"));
if (!m_document) {
m_document = CanvasDocument(asset.size().width(), asset.size().height());
viewport.fit(m_document->size());
}
const QPointF center = centeredAt.value_or(QPointF(m_document->width / 2.0, m_document->height / 2.0));
ImageLayer layer(asset, QPointF(std::floor(center.x() - asset.size().width() / 2.0), std::floor(center.y() - asset.size().height() / 2.0)));
// Beside the active layer, or inside the active folder.
const std::optional<ImageLayer> active = activeLayer();
layer.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt;
if (layer.parentID)
m_collapsedGroupIDs.remove(*layer.parentID);
m_document->layers.push_back(layer);
setActiveLayerID(layer.id);
endEdit();
}
void EditorSession::createDocument(int width, int height, bool emptyLayer)
{
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000)
return;
commitTransform();
beginEdit(QStringLiteral("New Canvas"));
CanvasDocument document(width, height);
// File > New starts with a selected blank "Layer 1".
std::optional<QUuid> selected;
if (emptyLayer) {
document.layers.push_back(ImageLayer(QStringLiteral("Layer 1"), document.size()));
selected = document.layers.back().id;
}
m_document = document;
setActiveLayerID(selected);
m_renamingLayerID = std::nullopt;
viewport.fit(m_document->size());
m_showsNewDocument = false;
// A closed sheet or an ended rename frees file requests.
resumeFileRequests();
qCInfo(lcApp) << "new canvas" << width << "x" << height;
endEdit();
}
void EditorSession::fit()
{
if (!m_document)
return;
const CanvasViewport before = viewport;
viewport.fit(m_document->size());
if (!(viewport == before))
notify();
}
void EditorSession::zoom(double value, std::optional<QPointF> anchor)
{
if (!m_document)
return;
// The viewport refuses a zoom that is no number.
const CanvasViewport before = viewport;
viewport.setZoom(value, anchor.value_or(viewport.center()), m_document->size());
if (!(viewport == before))
notify();
}
void EditorSession::setShowsPixelGrid(bool shows)
{
m_showsPixelGrid = shows;
notify();
}
void EditorSession::setCropRect(std::optional<QRectF> rect)
{
if (std::exchange(m_cropRect, rect) != rect)
notify();
}
void EditorSession::setCropRatioChoice(const QString &choice)
{
if (std::exchange(m_cropRatioChoice, choice) != choice)
notify();
}
void EditorSession::setCropError(std::optional<QString> error)
{
if (std::exchange(m_cropError, error) != error)
notify();
}
void EditorSession::setShowsSampleRing(bool shows)
{
m_showsSampleRing = shows;
notify();
}
void EditorSession::requestCanvasFocus()
{
++m_canvasFocusRequest;
notify();
}
+437
View File
@@ -0,0 +1,437 @@
#pragma once
#include "Document/BrushStroke.h"
#include "Document/CloneStamp.h"
#include "Document/ColorPalette.h"
#include "Document/Gradient.h"
#include "Document/Distort.h"
#include "Document/Filters.h"
#include "Document/LevelsAutomatic.h"
#include "Document/FloatingSelection.h"
#include "Document/MagicWand.h"
#include "Document/SelectionClipboard.h"
#include "Document/SelectionEdits.h"
#include "Document/SmudgeLiquify.h"
#include "Document/DocumentHistory.h"
#include "Document/EditorSession+Jobs.h"
#include "Document/EditorSession+Model.h"
#include "IO/ProjectStore.h"
#include "Rendering/CanvasViewport.h"
#include "Rendering/EffectsPreviewCache.h"
#include <QFutureWatcher>
#include <QObject>
#include <QSet>
#include <QTimer>
#include <QUrl>
#include <deque>
#include <map>
#include <functional>
#include <set>
class QPainter;
// The one editor state; `changed` stands in for Swift's observation.
class EditorSession : public QObject, private SessionJobs {
Q_OBJECT
public:
explicit EditorSession(QObject *parent = nullptr);
// The canvas edits this in place, then calls notify().
CanvasViewport viewport;
DocumentHistory history;
// Swift's @ObservationIgnored tuple: the canvas draws it unannounced.
SnapGuides snapGuides;
// The canvas's effects previews, rendered off the UI thread.
EffectsPreviewCache effectsPreviews;
const std::optional<CanvasDocument> &document() const { return m_document; }
NavigationTool tool() const { return m_tool; }
const QSet<QUuid> &collapsedGroupIDs() const { return m_collapsedGroupIDs; }
const QSet<QUuid> &selectedLayerIDs() const { return m_selectedLayerIDs; }
std::optional<QUuid> activeLayerID() const { return m_activeLayerID; }
void setActiveLayerID(std::optional<QUuid> id);
std::optional<ImageLayer> activeLayer() const;
// Blocks overlapping edits at once.
bool isProjectBusy() const { return m_isProjectBusy; }
void setIsProjectBusy(bool busy);
// Busy this long, in milliseconds, before the controls dim.
static constexpr int busyIndicatorDelay = 250;
bool showsBusy() const { return m_showsBusy; }
bool showsNewDocument() const { return m_showsNewDocument; }
void setShowsNewDocument(bool shows);
bool showsImporter() const { return m_showsImporter; }
void setShowsImporter(bool shows);
bool isImporting() const { return m_isImporting; }
void setIsImporting(bool importing);
std::optional<QString> importError() const { return m_importError; }
void setImportError(std::optional<QString> error);
std::optional<QUuid> renamingLayerID() const { return m_renamingLayerID; }
void setRenamingLayerID(std::optional<QUuid> id);
// The first tab's first canvas sheet skips the clipboard.
bool skipsInitialClipboardCanvasSize = false;
// Where the project lives on disk, once opened or saved.
const std::optional<QString> &projectPath() const { return m_projectPath; }
void setProjectPath(std::optional<QString> path);
bool canStartProjectOperation() const;
// `ready` runs at once, or when the session allows.
void waitForFileRequest(std::function<void()> ready);
void waitForProjectAccess(std::function<void()> ready);
void selectLayer(std::optional<QUuid> id);
void selectTool(NavigationTool value);
// Tab: the current tool's next mode.
void cycleToolMode();
// Whether the mask thumbnail, not the layer's, is the target.
bool isMaskSelected() const { return m_isMaskSelected; }
// A blend mode tried on by hovering; nothing is saved.
struct BlendPreview {
QUuid layerID;
LayerBlendMode mode;
friend bool operator==(const BlendPreview &, const BlendPreview &) = default;
};
const std::optional<BlendPreview> &blendPreview() const { return m_blendPreview; }
// A pending transform; layers change when it is committed.
const std::optional<TransformEdit> &transformEdit() const { return m_transformEdit; }
bool canTransform() const;
// Several layers, or a folder's contents, move in one box.
bool transformsAsGroup() const;
std::vector<ImageLayer> groupTransformMembers() const;
std::optional<LayerTransform> groupTransformBox() const;
void beginTransform(bool persistent = true);
// Alt-drag: the selection's copies move, one step.
void beginDuplicateTransform();
// The Move tool's settings (TransformInspector, Ctrl+H).
bool transformAutoSelect() const { return m_transformAutoSelect; }
void setTransformAutoSelect(bool picks);
bool showsTransformControls() const { return m_showsTransformControls; }
void setShowsTransformControls(bool shows);
bool locksTransformRatio() const { return m_locksTransformRatio; }
void setLocksTransformRatio(bool locks);
void previewTransform(const LayerTransform &value);
void commitTransform();
void cancelTransform();
std::optional<QSizeF> transformPixelSize() const;
LayerTransform displayedTransform(const ImageLayer &layer) const;
LayerTransform editedTransform(const ImageLayer &layer) const;
std::optional<LayerTransform> pendingTransform(const ImageLayer &layer) const;
// An unlinked mask, selected, transforms without its layer.
bool transformTargetsMask() const;
void nudgeLayer(double dx, double dy);
const std::optional<LassoDraft> &lassoDraft() const { return m_lassoDraft; }
LassoKind lassoKind() const { return m_lassoKind; }
void setLassoKind(LassoKind kind);
LassoKind marqueeKind() const { return m_marqueeKind; }
void setMarqueeKind(LassoKind kind);
// The options bar's mode; Shift adds, Alt subtracts.
SelectionMode selectionModeChoice() const { return m_selectionModeChoice; }
void setSelectionModeChoice(SelectionMode mode);
std::optional<SelectionMode> heldSelectionMode() const { return m_heldSelectionMode; }
bool selectionAntialiased() const { return m_selectionAntialiased; }
void setSelectionAntialiased(bool antialiased);
// Pixels Expand and Contract grow or shrink by.
int selectionExpandAmount() const { return m_selectionExpandAmount; }
void setSelectionExpandAmount(int amount);
int selectionContractAmount() const { return m_selectionContractAmount; }
void setSelectionContractAmount(int amount);
const std::optional<DocumentSelection> &selectionMoveOrigin() const { return m_selectionMoveOrigin; }
const WandSettings &wandSettings() const { return m_wandSettings; }
void setWandSettings(const WandSettings &settings);
const BrushStroke *brushStroke() const { return m_brushStroke.get(); }
const BrushSettings &brushSettings() const { return m_brushSettings; }
void setBrushSettings(const BrushSettings &settings);
BrushToolMode brushMode() const { return m_brushMode; }
void setBrushMode(BrushToolMode mode);
BlurToolMode blurMode() const { return m_blurMode; }
void setBlurMode(BlurToolMode mode);
// Smudge or Liquify under way; the canvas shows its copy.
const WarpStroke *warpStroke() const { return m_warpStroke.get(); }
SpotHealingMode spotHealingMode() const { return m_spotHealingMode; }
void setSpotHealingMode(SpotHealingMode mode);
bool maskPaintWhite() const { return m_maskPaintWhite; }
void setMaskPaintWhite(bool white);
const PaletteColor &backgroundColor() const { return m_backgroundColor; }
void setBackgroundColor(const PaletteColor &color);
// The open colour picker; the session notifies its changes.
const std::optional<ColorPickerState> &colorPicker() const { return m_colorPicker; }
const GradientSettings &gradientSettings() const { return m_gradientSettings; }
void setGradientSettings(const GradientSettings &settings);
// A gradient drawn and not yet applied.
const std::optional<GradientEdit> &gradientEdit() const { return m_gradientEdit; }
// Clone Stamp's Alt-clicked source, in document pixels, and options.
const std::optional<QPointF> &cloneSource() const { return m_cloneSource; }
const CloneSettings &cloneSettings() const { return m_cloneSettings; }
void setCloneSettings(const CloneSettings &settings);
// Counts every change to a stroke, so the canvas redraws.
int brushRevision() const { return m_brushRevision; }
// Selected pixels on the move, from a Ctrl-drag or Ctrl-arrow.
const PixelMove *pixelMove() const { return m_pixelMove.get(); }
// The one filter open; its sheet shows while it lasts.
const std::optional<FilterEdit> &filterEdit() const { return m_filterEdit; }
// The last committed filter's settings, where the next one starts.
const FilterSettings &filterSettings() const { return m_filterSettings; }
const std::optional<LevelsEdit> &levels() const { return m_levels; }
const std::optional<HueSaturationEdit> &hueSaturation() const { return m_hueSaturation; }
// The newest preview, waiting for the running one.
const std::optional<HueSaturationJob> &hueSaturationPending() const { return m_hueSaturationPending; }
std::optional<HueSampleMode> hueSampleMode() const { return m_hueSampleMode; }
bool hueTargeting() const { return m_hueTargeting; }
// The adjustment layer the colour editors write to.
std::optional<QUuid> adjustmentEditingID() const { return m_adjustmentEditingID; }
void setAdjustmentEditingID(std::optional<QUuid> id);
// Its settings as its editor opened, for Cancel.
const std::optional<LayerAdjustment> &adjustmentOriginal() const { return m_adjustmentOriginal; }
// The effect whose panel is open, bound to its layer.
const std::optional<LayerEffectSelection> &effectsEditing() const { return m_effectsEditing; }
void setEffectsEditing(std::optional<LayerEffectSelection> selection);
// Its layer's effects as the panel opened, for Cancel.
const std::optional<LayerEffects> &effectsEditingOriginal() const { return m_effectsEditingOriginal; }
void setEffectsEditingOriginal(std::optional<LayerEffects> original);
// The effect chosen in the list; see selectedEffect.
const std::optional<LayerEffectSelection> &effectSelection() const { return m_effectSelection; }
bool isModified() const { return history.isModified(); }
bool canUseHistory() const;
// Undo's first press discards a pending gradient.
bool canUndo() const { return canUseHistory() && (history.canUndo() || m_gradientEdit); }
bool canRedo() const { return canUseHistory() && history.canRedo(); }
void undo();
void redo();
// A transaction that nests; a whole gesture is one entry.
void beginEdit(const QString &name);
void endEdit();
bool canEditLayers() const;
void addBlankLayer();
void deleteLayer(QUuid id);
void deleteActiveLayer();
// Every selected layer, folders with their contents: one step.
void deleteSelectedLayers();
void renameLayer(QUuid id, const QString &name);
void toggleLayerVisibility(QUuid id);
// Pressing an eye, then dragging over others: one undo step.
std::optional<bool> beginVisibilitySwipe(QUuid id);
void setVisibilityInSwipe(QUuid id, bool visible);
void endVisibilitySwipe();
// The list runs top down; the document stores bottom up.
void reorderLayers(const std::set<int> &offsets, int destination);
bool canMoveActiveLayer(int offset) const;
void moveActiveLayer(int offset);
// Files decode off the UI thread; `done` follows this request.
void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {});
void insert(const ImportedImage &asset, std::optional<QPointF> centeredAt = std::nullopt);
void createDocument(int width, int height, bool emptyLayer = false);
void fit();
void zoom(double value, std::optional<QPointF> anchor = std::nullopt);
// The View menu's grid, shown from 800%.
bool showsPixelGrid() const { return m_showsPixelGrid; }
void setShowsPixelGrid(bool shows);
// The Eyedropper's ring: the sampled colour over the original.
bool showsSampleRing() const { return m_showsSampleRing; }
void setShowsSampleRing(bool shows);
// The Crop tool's frame in document pixels, once drawn.
const std::optional<QRectF> &cropRect() const { return m_cropRect; }
void setCropRect(std::optional<QRectF> rect);
// Swift's ratio names: Free, Original, 1:1, 4:3 and 16:9.
const QString &cropRatioChoice() const { return m_cropRatioChoice; }
void setCropRatioChoice(const QString &choice);
const std::optional<QString> &cropError() const { return m_cropError; }
void setCropError(std::optional<QString> error);
// Counted requests; the canvas takes focus on each new one.
int canvasFocusRequest() const { return m_canvasFocusRequest; }
void requestCanvasFocus();
// What Paste puts back in place, until another copy.
const std::optional<PixelClipboard> &pixelClipboard() const { return m_pixelClipboard; }
// What went wrong in a tool; the window shows it.
const std::optional<QString> &brushError() const { return m_brushError; }
void setBrushError(std::optional<QString> error);
void notify() { emit changed(); }
// Swift's extensions, each declared in its twin's header.
#include "Document/EditorSession+Projects.h"
#include "IO/ImageResizer+Session.h"
#include "Document/LayerMask+Session.h"
#include "Document/LayerAppearance+Session.h"
#include "Document/Crop+Session.h"
#include "Document/Distort+Session.h"
#include "Document/LayerMerge+Session.h"
#include "Document/LayerFlip+Session.h"
#include "Document/Selection+Session.h"
#include "Document/MagicWand+Session.h"
#include "Document/EditorSession+Brush.h"
#include "Document/CloneStamp+Session.h"
#include "Document/ColorPalette+Session.h"
#include "Document/Gradient+Session.h"
#include "Document/ShapeTool+Session.h"
#include "Document/TypeTool+Session.h"
#include "Document/SmudgeLiquify+Session.h"
#include "Document/BlurTool+Session.h"
#include "Document/SelectionEdits+Session.h"
#include "Document/SelectionClipboard+Session.h"
#include "Document/FloatingSelection+Session.h"
#include "Document/Filters+Session.h"
#include "Document/Levels+Session.h"
#include "Document/LevelsAutomatic+Session.h"
#include "Document/HueSaturation+Session.h"
#include "Document/MaskTracing+Session.h"
#include "Document/LayerGroups+Session.h"
#include "Document/LiveLayerMask+Session.h"
#include "Document/LayerAdjustment+Session.h"
#include "Document/AdjustmentEditing+Session.h"
#include "Document/LayerEffects+Session.h"
signals:
void changed();
private:
// Copies layers into this document in place, as Swift does.
friend class ProjectWorkspace;
void restore(const DocumentHistory::Snapshot &snapshot);
void resumeFileRequests();
void updateBusyIndicator();
void drainImports();
void decodeNext();
void finishDecode();
void endDuplicateTransform();
// Clone Stamp is family 1, the Smear 2, others 0.
static int tipFamily(NavigationTool tool);
std::optional<CanvasDocument> m_document;
NavigationTool m_tool = NavigationTool::move;
QSet<QUuid> m_collapsedGroupIDs;
QSet<QUuid> m_selectedLayerIDs;
// The canvas reads previews from a const session: mutable caches.
mutable std::map<QUuid, DistortPreviewCache> m_distortPreviewCache;
mutable std::map<QUuid, DistortEffectsCache> m_distortEffectsCache;
mutable std::optional<MaskDistortPreviewCache> m_maskDistortPreviewCache;
std::optional<TransformEdit> m_transformEdit;
std::optional<TransformDuplicate> m_transformDuplicate;
bool m_transformAutoSelect = false;
bool m_showsTransformControls = true;
bool m_locksTransformRatio = true;
std::optional<BlendPreview> m_blendPreview;
std::optional<QUuid> m_opacityEditLayerID;
bool m_isMaskSelected = false;
std::optional<QUuid> m_activeLayerID;
bool m_isProjectBusy = false;
bool m_showsNewDocument = false;
bool m_showsImporter = false;
bool m_isImporting = false;
std::optional<QString> m_importError;
std::optional<QUuid> m_renamingLayerID;
std::optional<QString> m_projectPath;
bool m_showsPixelGrid = true;
bool m_showsSampleRing = true;
std::optional<QRectF> m_cropRect;
QString m_cropRatioChoice = QStringLiteral("Free");
std::optional<QString> m_cropError;
QFutureWatcher<Cropped> m_cropCommit;
std::function<void()> m_committingCrop;
int m_canvasFocusRequest = 0;
bool m_showsBusy = false;
QTimer m_busyTimer;
std::vector<std::function<void()>> m_projectWaiters;
std::vector<std::function<void()>> m_fileRequestWaiters;
std::deque<ImportRequest> m_pendingImports;
// Where the request now decoding stands.
qsizetype m_importIndex = 0;
std::optional<QPointF> m_importPoint;
QStringList m_importFailures;
QFutureWatcher<Decoded> m_decoder;
std::optional<QString> m_brushError;
// The layers a bake under way will delete.
std::vector<QUuid> m_bakingIDs;
QFutureWatcher<Baked> m_baker;
std::optional<Inverting> m_inverting;
QFutureWatcher<Inverted> m_inverter;
WandSettings m_wandSettings;
std::optional<Wanding> m_wanding;
QFutureWatcher<Wanded> m_wand;
std::unique_ptr<BrushStroke> m_brushStroke;
std::unique_ptr<PixelMove> m_pixelMove;
BrushSettings m_brushSettings;
BrushToolMode m_brushMode = BrushToolMode::paint;
BlurToolMode m_blurMode = BlurToolMode::liquify;
std::unique_ptr<WarpStroke> m_warpStroke;
SpotHealingMode m_spotHealingMode = SpotHealingMode::contentAware;
bool m_maskPaintWhite = false;
PaletteColor m_backgroundColor = PaletteColor::white();
std::optional<ColorPickerState> m_colorPicker;
GradientSettings m_gradientSettings;
std::optional<GradientEdit> m_gradientEdit;
ShapeKind m_shapeKind = ShapeKind::rectangle;
double m_shapeCornerRadius = 0;
double m_shapeLineWidth = 4;
std::optional<ShapeDraft> m_shapeDraft;
mutable std::map<QUuid, ShapePreview> m_shapeTransformPreviewCache;
std::optional<TextDraft> m_textDraft;
LayerTextStyle m_textDefaults;
std::optional<QPointF> m_cloneSource;
CloneSettings m_cloneSettings;
// The offset aligned strokes keep, fixed as a stroke begins.
std::optional<QSizeF> m_cloneOffset;
// Clone Stamp and the Smear keep their own soft tips.
std::map<int, ParkedTip> m_parkedBrushTips{{1, {40, 0, 1}}, {2, {40, 0, 1}}};
std::optional<LastBrushPoint> m_lastBrushPoint;
int m_brushRevision = 0;
std::optional<PendingOpacityDigit> m_pendingOpacityDigit;
std::optional<CommittingRaster> m_committingRaster;
QFutureWatcher<RasterMade> m_rasterCommit;
std::optional<FilterEdit> m_filterEdit;
FilterSettings m_filterSettings;
QFutureWatcher<Filtered> m_filterPreview;
// Out until its finish runs: setFuture drops a pending signal.
bool m_filterRendering = false;
// Which edit's preview is being made; a cancelled edit's drops.
QUuid m_filterPreviewFor;
std::vector<Committing> m_filterCommitWaiting;
std::optional<Committing> m_committingFilter;
QFutureWatcher<FilterMade> m_filterCommit;
std::optional<LevelsEdit> m_levels;
// Which edit a running preview serves; others drop.
QUuid m_levelsPreviewFor;
QFutureWatcher<std::optional<QImage>> m_levelsPreview;
bool m_levelsRendering = false;
QFutureWatcher<std::optional<LevelsHistogram>> m_levelsHistogram;
std::optional<Committing> m_committingLevels;
QFutureWatcher<Leveled> m_levelsCommit;
std::optional<HueSaturationEdit> m_hueSaturation;
std::optional<HueSaturationJob> m_hueSaturationPending;
// Which edit a running preview serves; others drop.
QUuid m_hueSaturationPreviewFor;
QFutureWatcher<Adjusted> m_hueSaturationTask;
bool m_hueSaturationRendering = false;
std::optional<CommittingHue> m_committingHueSaturation;
QFutureWatcher<Adjusted> m_hueSaturationCommit;
std::optional<HueSampleMode> m_hueSampleMode;
bool m_hueTargeting = false;
std::optional<HueTargetDrag> m_hueTargetDrag;
std::optional<QUuid> m_adjustmentEditingID;
std::optional<LayerAdjustment> m_adjustmentOriginal;
std::optional<LayerEffectSelection> m_effectsEditing;
std::optional<LayerEffects> m_effectsEditingOriginal;
std::optional<LayerEffectSelection> m_effectSelection;
std::optional<PixelClipboard> m_pixelClipboard;
// Swift's pasteboard change count, counted from the first copy.
int m_clipboardChanges = 0;
bool m_watchingClipboard = false;
std::optional<LassoDraft> m_lassoDraft;
LassoKind m_lassoKind = LassoKind::freehand;
LassoKind m_marqueeKind = LassoKind::rectangle;
SelectionMode m_selectionModeChoice = SelectionMode::replace;
std::optional<SelectionMode> m_heldSelectionMode;
// The selection as a drag began; one step per drag.
std::optional<DocumentSelection> m_selectionMoveOrigin;
bool m_selectionAntialiased = true;
int m_selectionExpandAmount = 1;
int m_selectionContractAmount = 1;
};
+335
View File
@@ -0,0 +1,335 @@
#include "Document/BrushStroke.h"
#include "Document/ContentFill.h"
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "Document/SubjectRemoval.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include <QtConcurrent>
// Swift's session extension in Filters.swift.
// `canAdjustColors` already refuses an empty selection.
bool EditorSession::canContentAwareFill() const
{
return canAdjustColors() && selection().has_value() && !m_hueSaturation;
}
void EditorSession::beginFilter(FilterKind kind)
{
if (kind == FilterKind::contentAwareFill && !canContentAwareFill())
return;
// Swift beeps; `canAdjustColors` reads the open filter.
if (m_hueSaturation || !canAdjustColors()) {
qCWarning(lcApp) << "a filter cannot open here";
return;
}
// A pending gradient lands first, then the filter begins.
if (m_gradientEdit) {
commitGradient([this, kind] { beginFilter(kind); });
return;
}
commitTransform();
cancelCrop();
cancelLasso();
const ImageLayer layer = activeLayer().value();
const QSizeF size = m_document->size();
const std::optional<DocumentSelection> current = selection();
FilterSettings settings = m_filterSettings;
// Gradient Map starts from the palette, as Photoshop's.
if (kind == FilterKind::gradientMap)
settings.gradientMap = GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)};
try {
// Content-Aware Fill extends the layer over the selection's reach.
std::optional<QRectF> area;
if (kind == FilterKind::contentAwareFill && current) {
const QRectF box = current->path.boundingRect().intersected(QRectF(QPointF(0, 0), size));
if (!box.isEmpty())
area = box;
}
m_filterEdit.emplace(kind, layer, current ? std::optional(current->clip(size)) : std::nullopt, settings, area);
updateFilter(m_filterEdit->settings, true);
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
}
void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
{
if (!m_filterEdit || m_filterEdit->committing)
return;
FilterEdit &edit = *m_filterEdit;
edit.settings = settings.normalized();
edit.preview = preview;
// A bigger blur needs more room round the layer.
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
try {
edit.growForBlur();
edit.preparedPreview.reset();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
}
// An adjustment layer takes the settings; its pixels stay.
if (previewAdjustmentEditing(preview)) {
notify();
return;
}
// Automatic kinds render once.
if (isAutomatic(edit.kind) && edit.preparedPreview && edit.preparedSettings == edit.settings) {
++m_brushRevision;
notify();
return;
}
if (!preview) {
// A job behind another edit's run is Swift's running task.
if (m_filterRendering && m_filterPreviewFor == edit.id)
edit.pending.reset();
edit.preparedPreview.reset();
++m_brushRevision;
notify();
return;
}
edit.pending = edit.previewJob();
renderFilterPreview();
}
// Renders the newest job; one that arrives mid-render waits.
void EditorSession::renderFilterPreview()
{
if (!m_filterEdit || !m_filterEdit->pending)
return;
FilterEdit &edit = *m_filterEdit;
edit.preparing = true;
edit.previewError = std::nullopt;
// One worker at a time: a job waits its turn.
if (!m_filterRendering) {
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
m_filterPreviewFor = edit.id;
m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job]() -> Filtered {
try {
return Filtered{PixelFilter::run(job), std::nullopt, job.settings};
} catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
} catch (const SubjectRemovalError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
} catch (const ExportError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
}
}));
}
notify();
}
void EditorSession::finishFilterPreview()
{
m_filterRendering = false;
const Filtered result = m_filterPreview.result();
// An edit cancelled meanwhile takes nothing; a new one waited.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
FilterEdit &edit = *m_filterEdit;
edit.preparing = false;
edit.previewError = result.failure;
if (edit.preview || isAutomatic(edit.kind)) {
edit.preparedPreview = result.image;
edit.preparedSettings = result.settings;
++m_brushRevision;
}
notify();
}
renderFilterPreview();
// Each waiting commit looks again; a gone edit's just returns.
for (const Committing &waiter : std::exchange(m_filterCommitWaiting, {})) {
if (m_filterEdit && m_filterEdit->id == waiter.editID)
commitFilter(waiter.done);
else
QMetaObject::invokeMethod(this, waiter.done, Qt::QueuedConnection);
}
}
void EditorSession::cancelFilter()
{
// A Gradient Map colour being picked goes with the panel.
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap)
closeColorPicker(false);
if (finishAdjustmentEditing(false) || !m_filterEdit || m_filterEdit->committing)
return;
m_filterEdit.reset();
++m_brushRevision;
notify();
}
void EditorSession::commitFilter(std::function<void()> done)
{
// The caller resumes from the event loop, as after await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap)
closeColorPicker(true);
if (finishAdjustmentEditing(true) || !m_filterEdit || m_filterEdit->committing) {
finish();
return;
}
FilterEdit &edit = *m_filterEdit;
// An automatic filter commits what its preview made, once delivered.
if (isAutomatic(edit.kind) && edit.preparing) {
m_filterCommitWaiting.push_back(Committing{edit.id, done ? done : [] {}});
return;
}
if (isAutomatic(edit.kind) && (!edit.preparedPreview || edit.previewError)) {
finish();
return;
}
// Nothing to change closes as Cancel does, with no step.
if ((edit.kind == FilterKind::lensCorrection && edit.settings.distortion == 0)
|| (edit.kind == FilterKind::exposure && edit.settings.exposure == ExposureSettings())
|| (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0)) {
cancelFilter();
finish();
return;
}
edit.committing = true;
// Swift cancels the running preview: its result and queue drop.
edit.pending.reset();
m_filterPreviewFor = QUuid();
m_filterSettings = edit.settings;
setIsProjectBusy(true);
m_committingFilter = Committing{edit.id, std::move(done)};
// A mask hides the background, which can come back.
if (edit.kind == FilterKind::removeBackground) {
commitBackgroundMask(edit);
return;
}
const std::optional<QImage> cached = isAutomatic(edit.kind) && edit.preparedSettings == edit.settings ? edit.preparedPreview : std::nullopt;
const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur;
// A painted layer flattens in the worker, which catches failures.
m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = edit.settings,
selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached,
grown = edit.grownTransform, spreads]() -> FilterMade {
try {
QImage image = cached ? *cached : PixelFilter::run(FilterJob{kind, grownImage ? *grownImage : original.image(), settings, 1, selection, mapping, seed});
std::optional<LayerTransform> placed = grown;
// A blur is cut back to the pixels it left.
if (spreads && grown) {
const PixelFilter::Trimmed trimmed = PixelFilter::trimmed(image, *grown);
image = trimmed.image;
placed = trimmed.transform;
}
return FilterMade{ImportedImage(image, PixelAdjust::thumbnail(image), rawValue(kind)), placed, std::nullopt};
} catch (const ContentFillError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::commitBackgroundMask(const FilterEdit &edit)
{
// A mask in the layer's own grid is multiplied in.
const int index = indexOf(m_document->layers, edit.layerID);
std::optional<ImportedImage> existing;
if (index >= 0 && m_document->layers[index].mask) {
const LayerMask &owned = *m_document->layers[index].mask;
if (!owned.placement && owned.asset.size() == edit.original.size())
existing = owned.asset;
}
// Painted pixels and masks flatten in the worker.
m_filterCommit.setFuture(QtConcurrent::run([original = edit.original, existing, selection = edit.selection, mapping = edit.mapping,
settings = edit.settings]() -> FilterMade {
try {
const QImage source = original.image();
const std::optional<QImage> under = existing ? std::optional(existing->image()) : std::nullopt;
QImage mask = SubjectRemoval::subjectMask(source, under, settings);
// With a selection, only its part of the mask changes.
if (selection) {
QImage base = under ? *under : BrushRaster::context(source.width(), source.height(), true);
if (!under)
base.fill(255);
mask = PixelAdjust::blend(mask, base, *selection, mapping, true);
}
return FilterMade{LayerMask::assetFrom(mask), std::nullopt, std::nullopt};
} catch (const SubjectRemovalError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishFilterCommit()
{
const FilterMade made = m_filterCommit.result();
const FilterEdit edit = std::exchange(m_filterEdit, std::nullopt).value();
const Committing pending = std::exchange(m_committingFilter, std::nullopt).value();
// The preview stayed up till now: the canvas never flashed.
const auto finish = [&] {
++m_brushRevision;
setIsProjectBusy(false);
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
};
if (made.failure) {
setBrushError(made.failure);
finish();
return;
}
const int index = m_document ? indexOf(m_document->layers, edit.layerID) : -1;
if (index < 0) {
finish();
return;
}
const ImageLayer current = m_document->layers[index];
if (!current.asset || current.asset->identity() != edit.original.identity() || current.transform != edit.transform) {
finish();
return;
}
if (edit.kind == FilterKind::removeBackground) {
beginEdit(rawValue(edit.kind));
// Swift keeps the mask's switches; a placement would misplace it.
LayerMask mask = current.mask ? current.mask->replacing(*made.asset) : LayerMask(*made.asset);
mask.isEnabled = true;
mask.placement = std::nullopt;
m_document->layers[index].mask = mask;
m_isMaskSelected = true;
endEdit();
finish();
return;
}
try {
// The mask moves onto the new box, edge tone beyond.
std::optional<LayerMask> mask = current.mask;
if (edit.grownTransform && current.mask && !current.mask->placement
&& (current.mask->asset.size().width() > 1 || current.mask->asset.size().height() > 1)) {
LayerMask enabled = *current.mask;
enabled.isEnabled = true;
const QSize size = made.asset->size();
const std::optional<QImage> carried = enabled.clipImage(current.transform, made.transform.value(), size.width(), size.height());
if (!carried)
throw ExportError(ExportError::Kind::render);
mask = current.mask->replacing(LayerMask::assetFrom(*carried));
}
beginEdit(rawValue(edit.kind));
ImageLayer layer = current;
layer.asset = made.asset;
layer.transform = made.transform.value_or(current.transform);
layer.mask = mask;
// Swift rebuilds the layer without shape, effects and text.
layer.shape = std::nullopt;
layer.effects = std::nullopt;
layer.text = std::nullopt;
m_document->layers[index] = layer;
endEdit();
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
finish();
}
+16
View File
@@ -0,0 +1,16 @@
// Swift's Filters extension: one filter at a time.
public:
bool canContentAwareFill() const;
void beginFilter(FilterKind kind);
// The settings normalized; Preview off drops the queue and preview.
void updateFilter(const FilterSettings &settings, bool preview);
void cancelFilter();
// Off the UI thread; `done` follows the step or refusal.
void commitFilter(std::function<void()> done = {});
private:
void renderFilterPreview();
void finishFilterPreview();
void finishFilterCommit();
// Swift's commitBackgroundMask: the subject kept by a mask.
void commitBackgroundMask(const FilterEdit &edit);
+242
View File
@@ -0,0 +1,242 @@
#include "Document/Filters.h"
#include "Document/BrushStroke.h"
#include "Document/ContentFill.h"
#include "Document/EditorSession+Model.h"
#include "Document/PixelAdjust.h"
#include "Document/SubjectRemoval.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Rendering/RasterSnapshot.h"
#include <algorithm>
#include <cmath>
#include <numbers>
extern "C" {
#include "BrushPixels.h"
#include "LensPixels.h"
#include "NoisePixels.h"
}
QString rawValue(FilterKind kind)
{
switch (kind) {
case FilterKind::gaussianBlur: return QStringLiteral("Gaussian Blur");
case FilterKind::motionBlur: return QStringLiteral("Motion Blur");
case FilterKind::addNoise: return QStringLiteral("Add Noise");
case FilterKind::lensCorrection: return QStringLiteral("Lens Correction");
case FilterKind::removeBackground: return QStringLiteral("Remove Background");
case FilterKind::contentAwareFill: return QStringLiteral("Content-Aware Fill");
case FilterKind::curves: return QStringLiteral("Curves");
case FilterKind::exposure: return QStringLiteral("Exposure");
case FilterKind::gradientMap: return QStringLiteral("Gradient Map");
case FilterKind::grain: return QStringLiteral("Grain");
}
throw std::logic_error("unknown filter kind");
}
bool isAutomatic(FilterKind kind)
{
return kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground;
}
QString rawValue(BackgroundQuality quality)
{
return quality == BackgroundQuality::basic ? QStringLiteral("Basic") : QStringLiteral("Advanced");
}
bool isImageAdjustment(FilterKind kind)
{
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain;
}
FilterSettings FilterSettings::normalized() const
{
using ImageAdjustmentPixels::clamp;
FilterSettings result = *this;
result.radius = clamp(radius, 0.1, 250, 1);
result.angle = clamp(angle, -90, 90, 0);
result.distance = clamp(distance, 1, 2000, 10);
result.amount = clamp(amount, 0.1, 400, 10);
result.distortion = clamp(distortion, -100, 100, 0);
result.refineEdges = clamp(refineEdges, 0, 40, 12);
result.matteContrast = clamp(matteContrast, 0, 100, 25);
result.shiftEdge = clamp(shiftEdge, -10, 10, 0);
result.exposure = exposure.normalized();
result.gradientMap = gradientMap.normalized();
result.grain = grain.normalized();
return result;
}
PixelFilter::Trimmed PixelFilter::trimmed(const QImage &image, const LayerTransform &placed)
{
const QRectF full(image.rect());
// Bounds read premultiplied bytes; other formats get drawn.
QImage pixels = image;
if (pixels.format() != QImage::Format_RGBA8888_Premultiplied) {
pixels = BrushRaster::context(image.width(), image.height(), false);
QPainter painter(&pixels);
BrushRaster::draw(image, full, painter);
}
size_t edges[4] = {0, 0, 0, 0};
brush_alpha_bounds(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), edges);
const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1]));
if (crop.width() < 1 || crop.height() < 1 || QRectF(crop) == full)
return {image, placed};
LayerTransform result = placed;
result.size = {crop.width() * placed.size.width() / full.width(), crop.height() * placed.size.height() / full.height()};
const QPointF middle = BrushRaster::pixelToDocument(placed, image.width(), image.height()).map(QRectF(crop).center());
result.origin = {middle.x() - result.size.width() / 2, middle.y() - result.size.height() / 2};
// Swift's crop shares pixels; a copy may find no memory.
const QImage cropped = image.copy(crop);
if (cropped.isNull())
throw ExportError(ExportError::Kind::render);
return {cropped, result};
}
namespace {
// The image drawn into a fresh premultiplied context.
QImage drawn(const QImage &image)
{
QImage context = BrushRaster::context(image.width(), image.height(), false);
QPainter painter(&context);
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return context;
}
}
QImage PixelFilter::run(const FilterJob &job)
{
const FilterSettings settings = job.settings.normalized();
const int width = job.image.width(), height = job.image.height();
QImage image;
switch (job.kind) {
case FilterKind::curves: image = settings.curves.apply(job.image); break;
case FilterKind::exposure: image = settings.exposure.apply(job.image); break;
case FilterKind::gradientMap: image = settings.gradientMap.apply(job.image); break;
// Grain sits in layer pixels; the job's seed patterns it.
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
case FilterKind::contentAwareFill: image = ContentFill::run(job); break;
// Unclamped: a blur spreads into the room made for it.
case FilterKind::gaussianBlur: image = PixelAdjust::gaussianBlur(drawn(job.image), settings.radius * job.scale, false); break;
case FilterKind::motionBlur:
image = PixelAdjust::motionBlur(drawn(job.image), settings.distance * job.scale * motionRadiusPerPixel, settings.angle * std::numbers::pi / 180);
break;
case FilterKind::addNoise:
image = drawn(job.image);
noise_add(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0,
settings.monochromatic ? 1 : 0, job.seed);
break;
case FilterKind::lensCorrection: {
// Relative to the image's size, so previews bend alike.
const QImage source = drawn(job.image);
image = BrushRaster::context(width, height, false);
lens_distort(source.constBits(), image.bits(), size_t(width), size_t(height), size_t(source.bytesPerLine()), settings.distortion / 100 * lensStrength);
break;
}
}
if (!job.selection)
return image;
return PixelAdjust::blend(image, job.image, *job.selection, job.mapping, false);
}
namespace {
QRectF integral(const QRectF &rect)
{
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
}
}
FilterEdit::FilterEdit(FilterKind kind, const ImageLayer &layer, std::optional<SelectionClip> selection, const FilterSettings &settings,
std::optional<QRectF> area)
: kind(kind), layerID(layer.id), original(layer.asset ? *layer.asset : throw ProjectError(ProjectError::Kind::invalid)),
transform(layer.transform), selection(std::move(selection)), settings(settings.normalized())
{
prepare(original, transform);
if (area)
grow(integral(mapping.inverted().mapRect(*area)));
growForBlur();
}
double FilterEdit::blurMargin(FilterKind kind, const FilterSettings &settings)
{
switch (kind) {
case FilterKind::gaussianBlur: return settings.radius * 3 + 2;
case FilterKind::motionBlur: return settings.distance / 2 + 2;
default: return 0;
}
}
void FilterEdit::growForBlur()
{
const double margin = blurMargin(kind, settings);
if (!(margin > grownMargin))
return;
const QSize size = original.size();
grow(QRectF(-margin, -margin, size.width() + 2 * margin, size.height() + 2 * margin));
}
// The layer's pixels in a grid covering `extent` (layer pixels).
void FilterEdit::grow(const QRectF &extent)
{
const QSize size = original.size();
const QRectF bounds(0, 0, size.width(), size.height());
const QRectF target = integral(bounds.united(extent));
if (target == bounds)
return;
if (target.width() > 30'000 || target.height() > 30'000 || target.width() * target.height() > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
QImage grown = BrushRaster::context(int(target.width()), int(target.height()), false);
const QRectF inside = bounds.translated(-target.left(), -target.top());
QPainter painter(&grown);
if (original.raster)
original.raster->draw(inside, painter);
else
BrushRaster::draw(original.image(), inside, painter);
painter.end();
const QTransform toDocument = BrushRaster::pixelToDocument(transform, size.width(), size.height());
LayerTransform expanded = transform;
expanded.size = {target.width() * transform.size.width() / bounds.width(), target.height() * transform.size.height() / bounds.height()};
const QPointF middle = toDocument.map(target.center());
expanded.origin = {middle.x() - expanded.size.width() / 2, middle.y() - expanded.size.height() / 2};
grownImage = grown;
grownTransform = expanded;
grownMargin = std::min({bounds.left() - target.left(), bounds.top() - target.top(), target.right() - bounds.right(), target.bottom() - bounds.bottom()});
prepare(ImportedImage(grown, QImage(), QString()), expanded);
}
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
{
const QSize size = source.size();
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise and grain preview whole: enlarged, grain looks coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground;
const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height()));
if (!(factor < 1)) {
previewSource = source.image();
previewScale = 1;
previewMapping = mapping;
return;
}
const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor));
previewSource = BrushRaster::context(width, height, false);
QPainter painter(&previewSource);
const QRectF rect(0, 0, width, height);
if (source.raster)
source.raster->draw(rect, painter);
else
BrushRaster::draw(source.image(), rect, painter);
painter.end();
previewScale = double(width) / size.width();
previewMapping = BrushRaster::pixelToDocument(placed, width, height);
}
std::optional<QImage> FilterEdit::previewImage(QUuid id) const
{
return preview && id == layerID ? preparedPreview : std::nullopt;
}
FilterJob FilterEdit::previewJob() const
{
return FilterJob{kind, previewSource, settings, previewScale, selection, previewMapping, seed};
}
+141
View File
@@ -0,0 +1,141 @@
#pragma once
#include "Document/Curves.h"
#include "Document/ImageAdjustments.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include <QImage>
#include <QRandomGenerator>
#include <QTransform>
#include <QUuid>
#include <array>
#include <cmath>
struct ImageLayer;
// The Filter and Image menus' filters.
enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain };
// The menu's title, Swift's rawValue.
QString rawValue(FilterKind kind);
// Made without settings: its preview is its result.
bool isAutomatic(FilterKind kind);
// The Image menu's colour adjustments, not under Filter.
bool isImageAdjustment(FilterKind kind);
// Swift's allCases, the menus' order.
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
FilterKind::lensCorrection, FilterKind::removeBackground, FilterKind::contentAwareFill,
FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap,
FilterKind::grain};
// Remove Background's mask: the model's alone, or refined.
enum class BackgroundQuality { basic, advanced };
QString rawValue(BackgroundQuality quality);
inline constexpr std::array allBackgroundQualities{BackgroundQuality::basic, BackgroundQuality::advanced};
// Every filter's settings; each filter reads only its own.
struct FilterSettings {
// Gaussian Blur's radius in layer pixels: the blur's sigma.
double radius = 1;
// Motion Blur's angle, counterclockwise from horizontal, as Photoshop's.
double angle = 0;
double distance = 10;
// Add Noise's strength, Photoshop's percentage.
double amount = 10;
bool gaussian = false;
// The same noise on every channel: brightness alone.
bool monochromatic = false;
// Positive straightens barrel distortion, negative pincushion.
double distortion = 0;
CurvesSettings curves{};
ExposureSettings exposure{};
GradientMapSettings gradientMap{};
GrainSettings grain{};
BackgroundQuality backgroundQuality = BackgroundQuality::basic;
// How far, in pixels, the mask is pulled onto edges.
double refineEdges = 12;
// Pushes the mask's grays apart, 0 to 100, clearing haze.
double matteContrast = 25;
// Contracts (negative) or grows the mask's edge, in pixels.
double shiftEdge = 0;
FilterSettings normalized() const;
friend bool operator==(const FilterSettings &, const FilterSettings &) = default;
};
// What a filter runs on: grid, settings, selection, placement.
struct FilterJob {
FilterKind kind;
QImage image;
FilterSettings settings;
// Pixels in `image` a layer pixel: smaller previews blur less.
double scale;
std::optional<SelectionClip> selection;
QTransform mapping;
// Add Noise's pattern: the same seed, the same grain.
quint32 seed = 0;
};
// Swift's PixelFilter.
namespace PixelFilter {
struct Trimmed {
QImage image;
LayerTransform transform;
};
// CIMotionBlur's radius a streak pixel: an even streak's spread.
inline const double motionRadiusPerPixel = 1 / std::sqrt(12.0);
// Remove Distortion at ±100 moves the corners this share.
inline constexpr double lensStrength = 0.35;
// The pixels that are there, placed where they were.
Trimmed trimmed(const QImage &image, const LayerTransform &placed);
// The filter over the job's image, through its selection.
QImage run(const FilterJob &job);
}
// An open filter: the layer's pixels, preview copy, grown grid.
class FilterEdit {
public:
// Previews render from a copy no longer than this.
static constexpr double previewLimit = 2048;
// `area`: a document area the grid must cover (Content-Aware Fill).
FilterEdit(FilterKind kind, const ImageLayer &layer, std::optional<SelectionClip> selection, const FilterSettings &settings,
std::optional<QRectF> area);
// The room a blur needs round the layer, in pixels.
static double blurMargin(FilterKind kind, const FilterSettings &settings);
// Pads the grid for the blur; it only ever grows.
void growForBlur();
std::optional<QImage> previewImage(QUuid id) const;
FilterJob previewJob() const;
// Swift's object identity: a preview lands on its own edit.
const QUuid id = QUuid::createUuid();
const FilterKind kind;
const QUuid layerID;
const ImportedImage original;
const LayerTransform transform;
const std::optional<SelectionClip> selection;
QTransform mapping;
QImage previewSource;
double previewScale = 1;
QTransform previewMapping;
// The layer padded out, and the transform placing it.
std::optional<QImage> grownImage;
std::optional<LayerTransform> grownTransform;
// How far the padding reaches past every side.
double grownMargin = 0;
FilterSettings settings;
bool preview = true;
bool committing = false;
std::optional<QString> previewError;
bool preparing = false;
// Add Noise's grain, fixed while the panel is open.
const quint32 seed = QRandomGenerator::global()->generate();
std::optional<QImage> preparedPreview;
// The settings the prepared preview was made with.
std::optional<FilterSettings> preparedSettings;
std::optional<FilterJob> pending;
private:
void grow(const QRectF &extent);
// Swift's prepared and prepare: mapping and preview copy.
void prepare(const ImportedImage &source, const LayerTransform &placed);
};
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff. Show more