71 Commits
Author SHA1 Message Date
ZacharyZhang-NY 1fcbec84e9 Move to Gitea: install scripts, links and a release script that builds the DEBs, RPMs, Arch package and a new AppImage 2026-10-11 01:40:28 -04:00
ZacharyZhang-NY 4c7d98b17f 13.19j: the change log names the blend menu's Escape fix 2026-10-04 01:01:43 -04:00
ZacharyZhang-NY c854e42116 13.19i: distro checks pass on all five; the pickers preview only while open and end on the popup's Hide, as Qt 6.10 changed both 2026-10-04 01:01:26 -04:00
ZacharyZhang-NY c59e85babc 13.19h: the acceptance run checks the seventeen rows new in 1.4.5 through the window's own controls; shots retaken 2026-10-03 23:45:53 -04:00
ZacharyZhang-NY 8bd3fc3264 13.19g: a raster commit's continuation runs where the commit ends, as Swift's await returns; nothing lands between 2026-10-03 22:54:20 -04:00
ZacharyZhang-NY 28f6337d13 13.19f: review 3's scope; the tab strip's other-tab guard and the divider's second press pinned 2026-10-03 20:46:39 -04:00
ZacharyZhang-NY 1de10e711c 13.19e: 70318de verified; the Smear's allocation guards starved in WarpFailureTests; the commit's thinning and the bar's deferred finish pinned 2026-10-03 20:08:40 -04:00
ZacharyZhang-NY 3757eb8910 13.19d: 2190a1b verified; sizeLabel's ties tested; tests read optionals through value(); two mask tests brought to 13.12 2026-10-03 19:16:59 -04:00
ZacharyZhang-NY ab34fd6ace 13.19b and 13.19c: main builds; the upright pin and its mutants 2026-10-03 18:01:44 -04:00
ZacharyZhang-NY d053405aa9 13.19a: the spec at Compositor-Ref, out of the flake's source; the resumed step list 2026-10-03 17:36:48 -04:00
ZacharyZhang-NY 1f42c6241f Ignore the Compositor-Ref checkout 2026-10-03 17:06:45 -04:00
ZacharyZhang-NY 6ccff8a9d7 AGENTS and TASKS: status before the system reinstall 2026-10-01 23:09:59 -04:00
ZacharyZhang-NY 19d87370e9 TASKS: where the 1.4.5 work stopped 2026-10-01 23:02:57 -04:00
ZacharyZhang-NY 8204e6a28b Merge branch 'port-d'
# Conflicts:
#	AGENTS.md
2026-10-01 23:02:47 -04:00
ZacharyZhang-NY cf0ec57095 Merge branch 'port-c'
# Conflicts:
#	AGENTS.md
2026-10-01 23:02:04 -04:00
ZacharyZhang-NY 9e14109da3 Merge branch 'port-b' 2026-10-01 23:01:56 -04:00
ZacharyZhang-NY 70318de2e8 Review fixes in progress, unverified: stopped by the user before the tests ran 2026-10-01 23:01:53 -04:00
ZacharyZhang-NY 2190a1b67e Review fixes in progress, unverified: stopped by the user before the tests ran 2026-10-01 23:01:53 -04:00
ZacharyZhang-NY 63e42acc7c Review fixes in progress, unverified: stopped by the user before the tests ran 2026-10-01 23:01:53 -04:00
ZacharyZhang-NY 76dad20b63 Merge branch 'port-a' 2026-10-01 20:03:04 -04:00
ZacharyZhang-NY b2a22f6c51 Release paperwork for OmaPhoto 1.4.5: version, change log, README, acceptance table (13.19) 2026-10-01 20:02:35 -04:00
ZacharyZhang-NY 1e6d3ead1b Merge branch 'port-e' 2026-10-01 20:02:25 -04:00
ZacharyZhang-NY 5c8e18c5af Review 2 fixes for 13.17 and 13.18: the glow scales back exactly, tests for the front tab, a rendering editor, the Scanlines rows, removePoint's reach, repair's strict bound, the divider strip; bullets corrected 2026-10-01 20:02:02 -04:00
ZacharyZhang-NY 8dfb6f0ae4 Merge branch 'port-a' 2026-10-01 20:00:54 -04:00
ZacharyZhang-NY e72cfbac2a AGENTS: the eye's black fill order recorded as equivalent (13.15) 2026-10-01 20:00:41 -04:00
ZacharyZhang-NY 6735aad4ef Review 1 fixes for 13.7, 13.14, 13.15: a lost tab release ends the drag (Swift's dragTimer); pins for the pill spacing, the Ungroup chord, the eye under the arrows, the Quick Look digest 2026-10-01 20:00:30 -04:00
ZacharyZhang-NY 85bacdd558 Merge branch 'port-b' 2026-10-01 19:21:25 -04:00
ZacharyZhang-NY 382c9c20ad Review 1 fixes for 13.9a and 13.9b: the rounded move, effects while moving and dragging a gradient, a draft in a clipping stack pinned; stale Soft Light, nine-mode and 12.7a lines corrected 2026-10-01 19:20:40 -04:00
ZacharyZhang-NY 9ccf39010c ScrubbableSheetsTests builds against 13.4a's CanvasSizeSheet 2026-10-01 19:19:51 -04:00
ZacharyZhang-NY 81894f744a Merge 13.13 and 13.16: held keys flip the Move bar, its values apply as one step, resize handles snap; Liquify sharp, Smudge without ghosts, Blur's Radius in pieces; AGENTS and TASKS 2026-10-01 18:41:19 -04:00
ZacharyZhang-NY 7c6265af77 Merge branch 'main' into port-d 2026-10-01 18:38:20 -04:00
ZacharyZhang-NY c7e8bdfe8e Merge 13.11 (cc9bf95); Blur pieces and per-tile clone pieces on its grid sampling (13.16, part two) 2026-10-01 18:38:05 -04:00
ZacharyZhang-NY de8f23acbe Blur softens only the pieces the brush reaches; a clone is cut once per tile (13.16, part two) 2026-10-01 18:18:57 -04:00
ZacharyZhang-NY 72230b09b8 Merge 13.17 and 13.18: a dragged curve point stays on its neighbour, bands match one band, SheetTeardownTests on 13.4a's sheet; AGENTS and TASKS 2026-10-01 18:18:42 -04:00
ZacharyZhang-NY a004db172b Merge branch 'port-e' 2026-10-01 18:11:24 -04:00
ZacharyZhang-NY a77cce4598 Camera Raw curves as Photoshop's: the fitted parametric curve, the tone curve on each channel, one drag in the graph (13.18) 2026-10-01 18:10:13 -04:00
ZacharyZhang-NY f91849ade5 AGENTS and TASKS for 13.10, 13.11, 13.12; liveFolderMaskClip beside drawMaskAlone 2026-10-01 18:08:33 -04:00
ZacharyZhang-NY e243751f64 Merge branch 'port-c' 2026-10-01 18:06:09 -04:00
ZacharyZhang-NY 0b0d36a7df Masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (13.12) 2026-10-01 18:04:14 -04:00
ZacharyZhang-NY d571a090d3 AGENTS and TASKS for 13.8, 13.9a, 13.9b 2026-10-01 17:51:22 -04:00
ZacharyZhang-NY 90a8e2860e Merge branch 'port-b' 2026-10-01 17:48:41 -04:00
ZacharyZhang-NY cc9bf95722 Say why a brush cannot paint; paint at the layer's own resolution; Cancel and Apply only when an edit waits, scaled layers' scale (13.11) 2026-10-01 17:48:38 -04:00
ZacharyZhang-NY cbd8612014 Liquify keeps pixels sharp, Smudge without ghost copies, Blur's own Radius, the largest Smear commits (13.16, part one) 2026-10-01 17:48:08 -04:00
ZacharyZhang-NY d2c8507fc7 Grain and Add Noise stay with the document on the canvas; the GPU canvas's contracts judged and pinned on the CPU canvas (13.9b) 2026-10-01 17:47:52 -04:00
ZacharyZhang-NY 06843364fe Soft Light by Photoshop's formula; stacks, coverage and adjustments in every mode proven (13.9a) 2026-10-01 17:47:52 -04:00
ZacharyZhang-NY cb85b86d58 Sharper canvas, PSD adjustments and masks, Photoshop's Saturation, Levels and Hue/Saturation on every core, a growing blur's preview kept, the hierarchy kept (13.8) 2026-10-01 17:47:51 -04:00
ZacharyZhang-NY 77afa0c9ba AGENTS and TASKS for 13.7, 13.14, 13.15 2026-10-01 17:30:07 -04:00
ZacharyZhang-NY 91dc6f2252 Tab drags: the press measured where it lands; another button's move drags nothing (13.14) 2026-10-01 17:25:57 -04:00
ZacharyZhang-NY 5bfbc6cd5d Quit settles a pending gradient, a pixel move and open dialogs; Dither › Scanlines (CRT) on all cores (13.17) 2026-10-01 17:14:47 -04:00
ZacharyZhang-NY 5be68a4f96 Tab drags: the three-point threshold, an unheld move and stacking pinned; no minimum hint (13.14) 2026-10-01 17:06:31 -04:00
ZacharyZhang-NY b50657bcdb Move bar: held Ctrl and Shift flip Auto Select and the lock, values apply as one step, resize handles snap (13.13) 2026-10-01 16:56:16 -04:00
ZacharyZhang-NY 01bb579068 Tab drags: a right move by one, a lost release, a close mid-drag pinned (13.14) 2026-10-01 16:43:33 -04:00
ZacharyZhang-NY fe3198777d New Canvas's dots are filled, never outlined: pinned (13.7) 2026-10-01 16:30:30 -04:00
ZacharyZhang-NY 6ea248367d Layer › Ungroup Layers (Ctrl+Shift+G), in a folder's menu too; Alt over a mask shows the eye pointer (13.15) 2026-10-01 16:22:27 -04:00
ZacharyZhang-NY 3f2b99f045 Project tabs drag to reorder; tabs that don't fit gather in a menu (13.14) 2026-10-01 16:22:27 -04:00
ZacharyZhang-NY 0616f9b006 New Canvas presets in a More menu; every save writes QuickLook/Preview.jpg (13.7) 2026-10-01 15:58:57 -04:00
ZacharyZhang-NY 51e373af4c Shift keeps moved pixels on a line; Ctrl+A selects the canvas from the Layers panel (13.10) 2026-10-01 14:35:13 -04:00
ZacharyZhang-NY 765e071c0c mutate.py: cores from OMAPHOTO_CPUS, 32 by default 2026-10-01 14:28:41 -04:00
ZacharyZhang-NY 9ed7c20681 Font previews in the Type bar; Ctrl+Z while typing judged and pinned (13.6) 2026-10-01 14:26:36 -04:00
ZacharyZhang-NY 93a4c62b8f View › Grid Settings…: spacing, subdivisions, colour, style and opacity, kept across launches (13.5b) 2026-10-01 12:43:50 -04:00
ZacharyZhang-NY 3992bc913d The Marquee, shapes and a moved selection snap to View › Snap To; Ctrl draws freely (13.5a) 2026-10-01 09:55:24 -04:00
ZacharyZhang-NY 64ebd1cdf9 Export JPEG: a zoomable full-size preview, its buttons and the View menu's zoom keys (13.4b) 2026-10-01 08:53:43 -04:00
ZacharyZhang-NY bc49b722eb Export JPEG and Canvas Size take the app's colour picker; a panel opens over a modal sheet (13.4a) 2026-10-01 07:16:16 -04:00
ZacharyZhang-NY 0b7eb6cb6f Select › Color Range: sampled colours, Fuzziness, Invert, a live preview and the panel (13.3b) 2026-10-01 06:30:26 -04:00
ZacharyZhang-NY 462fe805b1 Dither's ASCII as readable text: monospaced cells on one baseline, Text Size (13.3a) 2026-10-01 01:31:10 -04:00
ZacharyZhang-NY dcedfea53f Faces for the selected letters, project format version 11 (13.2) 2026-10-01 00:05:20 -04:00
雷电芽衣 802ae82f72 README: the software-cursor workaround for garbled cursors on NVIDIA with Hyprland (13.1, #9) 2026-09-30 21:05:48 -04:00
雷电芽衣 39dcbc7705 Survey Compositor 1.3.4 to 1.4.5 into items 13.1 to 13.19; spec pinned at v1.4.5 (13.0) 2026-09-30 20:44:51 -04:00
雷电芽衣 e94fd73573 Acceptance run with screenshots and the MVP table; window teardown no longer reaches destroyed menus or toolbar (11.5) 2026-09-30 14:39:11 -04:00
雷电芽衣 51bec23cc5 Live installs of 1.3.3 on every supported system (12.16) 2026-09-30 13:07:34 -04:00
雷电芽衣 c2d4637c34 One RPM per Fedora release, 43 and 44, each with its own LibRaw (12.16) 2026-09-30 12:36:18 -04:00
349 changed files with 16844 additions and 1952 deletions

No files matched your search

-91
View File
@@ -1,91 +0,0 @@
# A version tag builds the four packages, each on its own system, and publishes them.
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
version:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
# The tag must name the version each build file carries.
- run: |
v=${GITHUB_REF_NAME#v}
grep -qF "project(omaphoto VERSION $v " CMakeLists.txt || { echo "CMakeLists.txt is not $v"; exit 1; }
grep -qxF "pkgver=$v" packaging/arch/PKGBUILD || { echo "PKGBUILD is not $v"; exit 1; }
grep -qF "version = \"$v\";" flake.nix || { echo "flake.nix is not $v"; exit 1; }
deb:
needs: version
runs-on: ubuntu-24.04
# One DEB per Ubuntu LTS: each names its own libraries.
strategy:
matrix:
include:
- codename: noble
base: ubuntu:24.04
- codename: resolute
base: ubuntu:26.04
steps:
- uses: actions/checkout@v7
# The dev image carries ONNX Runtime, its notices and the model.
- run: docker build -t omaphoto-dev --build-arg BASE=${{ matrix.base }} -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-${{ matrix.codename }}_amd64.deb
- uses: actions/upload-artifact@v7
with:
name: deb-${{ matrix.codename }}
path: omaphoto-${{ matrix.codename }}_amd64.deb
rpm:
needs: version
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- 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_BUNDLE_ORT=ON -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@v7
with:
name: rpm
path: omaphoto.x86_64.rpm
arch:
needs: version
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- 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@v7
with:
name: arch
path: omaphoto-x86_64.pkg.tar.zst
publish:
needs: [deb, rpm, arch]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
merge-multiple: true
path: packages
- working-directory: packages
run: sha256sum omaphoto-noble_amd64.deb omaphoto-resolute_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."
+6
View File
@@ -5,3 +5,9 @@
/packaging/arch/pkg/
/packaging/arch/*.pkg.tar.zst
/packaging/arch/u2net.onnx
/Compositor-Ref/
/build-appimage/
/AppDir/
/*.AppImage
/release/
+51 -23
View File
@@ -1,6 +1,10 @@
# 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. Its checkout at `../Compositor-spec`, pinned to the tag being ported (v1.3.3 since item 12), is where the Swift sources are read; never edit it for OmaPhoto work.
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. Its checkout at `Compositor-Ref`, inside this repository and ignored by git, pinned to the tag being ported (v1.4.5 since item 13), is where the Swift sources are read; never edit it for OmaPhoto work.
## Status
Item 13 (Compositor v1.4.5, released as OmaPhoto 1.4.5) resumed on 2026-10-03 after a system reinstall. Items 13.2 to 13.18 are ported. The release, 13.19, is open; its steps are listed under 13.19 in `TASKS.md` and checked off as each passes review. Read the status lines under 13.19 in `TASKS.md` before any work. They list what is unfinished, the failing tests, the setup after a reinstall and the next steps. Work in this one checkout: no worktrees or copies beside it.
## Designing or editing a project
@@ -11,7 +15,8 @@ To make or change an image in a `.comp` project, the source is not needed: read
- 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.
- `scripts/release.sh`: builds a tag's DEBs, RPMs, Arch package and AppImage in Docker and publishes them as a release on the project's Gitea, https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto (the GitHub repository is gone; GitHub suspended the account in 2026-10).
- `docs/acceptance.md`: the MVP acceptance table, its screenshots in `docs/acceptance/`.
- `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`).
@@ -36,10 +41,10 @@ Platform adaptations, each forced by a missing Qt analog:
- `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`.
- `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`, at full size up to `JPEGResult::previewLimit` (8192) a side (13.4b). `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.
- Qt refuses image decodes over 128 MB (`QImageReader::allocationLimit`). `ImageImporter::liftAllocationLimit` lifts that limit for the store, the importer and the JPEG export's full-size preview (13.4b): 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.
@@ -67,23 +72,24 @@ Platform adaptations, each forced by a missing Qt analog:
- 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` agree since 1.2.1 (`7111885`): a bow tie is usable and folds as two triangles, not convex, and a collapsed corner is refused by both tests; the preview takes a folded shape. `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); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, then a focused layer list, which selects every row with the top one primary, as `NSTableView`'s `selectAll(_:)` does in Swift's chain (`NativeLayerList::selectAllRows`), and only then the canvas. 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.
- 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); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, and then the canvas, a focused layer list included: Swift's table hands Select All to the canvas since 1.4 (`451281e`), so the list's own Select All of every row (12.5b) is gone. 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 tests once expected a second press to switch the kind, which no Swift code does (its `cycleToolMode` on Tab does, 8.x); since 1.2.0 (`b5cb4fa`) they test the code's rule, as the twin does (`…AndKeepsTheShapeLastSet`), and the twin's 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 keeps a `QPointer` to the `QMimeData` it hands the clipboard where Swift reads the pasteboard's change count (since 12.10b; Qt's `dataChanged` is no count, since Wayland announces one copy twice): while this app owns the clipboard Qt returns that very object (offscreen, X11's client clipboard, Wayland's selection source), any other copy, a history manager's restore of the same bytes included, is another object, and a replaced object is deleted, which nulls the pointer, so a copy once replaced never counts again, as a change count never returns; a paste puts our pixels back in place while the clipboard still holds that object (`clipboardHolds`), 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 (a folder too since 12.10b). 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
- 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; its continuation runs where the commit ends, not from the event loop, because Swift's caller resumes from `await` in the same job: `commitGradient` cancels the gradient and runs its caller's next step (a filter, Levels, Hue/Saturation or Invert begun on the same layer), a fill or clear hands on to its caller (`beginSelectionTransform` installs the floating edit), and the pixel move clears itself, all before any event lands; only early paths (nothing to commit, a busy project) post, as Swift's `Task` hops (since 13.19g: a press could arrive with the project free and the finished gradient still pending, which refused it; `CommitContinuationTests`)), `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, plaLine 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.
- The acceptance run (11.5): `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window as `main` builds it (`AcceptanceFixtures.h`'s `App`: an `OmarchyTheme` on a folder of the test's own, the workspace, the window at 1180 × 780), one scenario for each row of the README's feature list, through menu entries, canvas gestures, sheets and the real panels (`DialogDesk`), and check pixels, layers, sizes and files; `AcceptanceNewTests`, `AcceptanceEditTests` and `AcceptanceFinishTests` (13.19h) do the same for each row new in 1.4.5. With `ACCEPTANCE_SHOTS` naming a folder, each scenario saves the window with its shown floating panels drawn over it at their places (offscreen draws no title bars), after 300 ms of events, since posted geometry and previews land from the event loop; `docs/acceptance/` holds the committed shots and `docs/acceptance.md` the table. A fix the run found: `~CompositorMenus` disconnects from `QApplication::focusChanged` first, because its Keyboard Shortcuts panel, a member, hides as it goes and moves the focus into the half-destroyed menus (quitting with that panel active crashed); `KeyboardShortcutsSheetTests::theWindowGoesWhileThePanelHoldsTheKeys` holds it. So does `~ProjectWorkspaceView` with its session watch: a floating panel whose field holds typing (the colour picker, Levels, Hue/Saturation, a filter) commits as it hides, and the session's signal reached the window's toolbar and tab strip, already gone; `WindowTeardownTests` holds all four.
- 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/omaphoto` (under the library folder) with the app's run path set there, and Microsoft's `LICENSE` and `ThirdPartyNotices.txt` with it (`OMAPHOTO_ORT_NOTICES`, where the images keep them from the release; a configure that cannot find them fails). Every package bundles (11.4d, issue #5): ONNX Runtime stamps each release's symbols with its version (`VERS_1.20.1`), so a package linked against a system's runtime refuses to install, or to start, once that system updates it. The DEB, the RPM and the Arch package carry Microsoft's 1.21.0 build, pinned by checksum, which the dev, Fedora and Arch images install into `/usr/local` too, so the tests run against the runtime that ships; the flake builds from nixpkgs' own, since Nix rebuilds its closure with it. `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 but the bundled runtime left out of what it provides and requires (`__provides_exclude_from`, `__requires_exclude` in `CPACK_RPM_SPEC_MORE_DEFINE`), 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 and Microsoft's runtime by their checksums; its `prepare()` lays the runtime out as that build's CMake config expects (`lib64`, `include/onnxruntime` beside `lib/cmake`) and the package bundles it. `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|resolute` builds `scripts/distro/<name>/Dockerfile` (for `resolute`, `Dockerfile.dev` on Ubuntu 26.04; Fedora is 43 since 11.4d) and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path with shared-mime-info's beside it (a NixOS system has the database in its profile through `xdg.mime.enable`; without it Qt knows no camera RAW type, missed until 12.16) and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since `QTransform::map` clamps w for points past the horizon from Qt 6.10 (6.10.2 and 6.11.2 probed; 6.4 divides), where Swift divides whatever its sign, and `DistortTests` pins it past the horizon (on 6.4 the two agree, so a mutant there is equivalent); a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators. Tests follow the systems where only a message or a count differs: libheif 1.23's longer messages, Qt 6.9's TIFF plugin logging libtiff's errors as criticals, fonts named rather than counted, the Type icon's letters in the system's own sans (Fedora's is Noto: the icon sheet compares them only under DejaVu Sans), and `QT_FORCE_STDERR_LOGGING` for the launched app, since a journald-built Qt logs to the journal when stderr is no console. The five starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there. `ProjectExportFailureTests` opens a panel and an alert once in `initTestCase`, before any limit, as the app's window has before any export: a snapshot that fails opens its alert while the limit holds, and on every system that alert was the process's first font lookup and the panel's first open, costs the app never meets there. In the NixOS shell, which reads fontconfig's whole `conf.d` without a cache, the lookup's failed allocation segfaulted inside fontconfig (`FcHashTableFind`, found under gdb); on the dev image a panel test run alone ran out inside the panel's first open. `theJPEGRenderTakesTheSnapshotWithoutACopy` holds the JPEG render's snapshot to a move under a 2 MB limit, as `thePanelOpensWithoutCopyingTheSnapshot` holds the PNG panel's. The flake leaves out `dev.sh`'s `build/`, which nixpkgs' CMake hook would take for its own build folder.
- Distro checks (11.4): `scripts/distro-check.sh arch|fedora|fedora44|nixos|resolute` builds `scripts/distro/<name>/Dockerfile` (for `resolute`, `Dockerfile.dev` on Ubuntu 26.04; `fedora` is 43 since 11.4d, `fedora44` the same file on 44 since 12.16) and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path with shared-mime-info's beside it (a NixOS system has the database in its profile through `xdg.mime.enable`; without it Qt knows no camera RAW type, missed until 12.16) and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since `QTransform::map` clamps w for points past the horizon from Qt 6.10 (6.10.2 and 6.11.2 probed; 6.4 divides), where Swift divides whatever its sign, and `DistortTests` pins it past the horizon (on 6.4 the two agree, so a mutant there is equivalent); a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators; from Qt 6.10 a `QComboBox` emits `highlighted` when its current item changes with the popup closed and closes on Escape without calling `hidePopup` (both probed in 13.19i), so `TypeFontPicker` and `BlendModePicker` preview only while the popup shows and end the preview on the popup window's `Hide` event, which every version sends on every close (on 6.10 a programmatic highlight had saved an original that a later close put back over typed text, and Escape had kept the preview). Tests follow the systems where only a message or a count differs (`GridSettingsSheetTests` sets an English default locale, since Qt 6.10's C locale groups no digits): 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 six starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (Dither's since 13.19i, where Qt 6.10 converted on threads the limit starved first) (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there. `ProjectExportFailureTests` opens a panel and an alert once in `initTestCase`, before any limit, as the app's window has before any export: a snapshot that fails opens its alert while the limit holds, and on every system that alert was the process's first font lookup and the panel's first open, costs the app never meets there. In the NixOS shell, which reads fontconfig's whole `conf.d` without a cache, the lookup's failed allocation segfaulted inside fontconfig (`FcHashTableFind`, found under gdb); on the dev image a panel test run alone ran out inside the panel's first open. `theJPEGRenderTakesTheSnapshotWithoutACopy` holds the JPEG render's snapshot to a move under a 2 MB limit, as `thePanelOpensWithoutCopyingTheSnapshot` holds the PNG panel's. The flake leaves out `dev.sh`'s `build/`, which nixpkgs' CMake hook would take for its own build folder, and the `Compositor-Ref` spec, which a `path:` flake would copy whole.
- Spot Healing (8.4): `HealPixels.c` compiles in place beside the other kernels. `SpotHealingMode`, `allSpotHealingModes` and `rawValue` (Swift's names) sit in `BrushStroke.h`, and `BrushSettings` gains `healing` and `healingMode`. While the stroke lasts its tiles show Swift's wash, gray 0.12 at 45% through the coverage, never erasing. `heal` takes the painted bounds from the kernel's `heal_coverage_bounds`, draws the source into a region about three spots wider, and hands the kernel the coverage packed row by row, as Swift's gray context holds it (the twin copies each tile's rows where Swift draws each coverage image); the seed is `QRandomGenerator::global()`, Swift's `UInt32.random`. Each touched tile is then its base with the healed region copied over, blended with the base through `PixelAdjust::blend` under a selection, where Swift clips the context. The kernel's failure is `ProjectError(tooLarge)`; pixels or packed coverage that cannot be allocated are `ExportError(render)`, as Swift's contexts. The session's `beginBrush` takes Spot Healing on pixels alone, never on a mask, as Swift's guard; `finishBrushImmediately` heals before the commit, one `Spot Healing` step; `spotHealingMode` is a getter and a setter that notifies; Tab steps the type. `isBrushTool` is Swift's four tools: the circle, the right-drag tip, the brackets, the digits and `BrushControls` serve each, and a press with Clone Stamp or the Smear begins nothing until 8.5 and 8.6 bring them. `ContentView` keeps one bar across the four, as Swift's single `isBrushTool` branch keeps its view; the bar titles itself by tool (Spot Healing, Clone Stamp, Smear, Eraser, Brush), shows Paint and Erase for the Brush alone and the three types for Spot Healing alone, and names opacity Strength for the Smear; the clone's Aligned and Sample arrive with 8.5, the Smear's modes with 8.6. Equivalent mutant: the healed tile starting from its context rather than its base, since the region holds the whole wash and the copy replaces it.
- Clone Stamp (8.5): `Document/CloneStamp.h` holds `CloneSettings`, `CloneStamp.cpp` Swift's extension: `setCloneSource` (a point that is no number is refused; a new source restarts the alignment), `cloneStrokeOffset`, `cloneSamplePoint` and `cloneSample`, whose context that cannot be allocated logs on `omaphoto.app` where Swift's `try?` is silent. The session keeps `cloneSource`, `cloneSettings` (a setter that notifies), `cloneOffset` and `parkedBrushTips` by `tipFamily` (Clone Stamp 1, the Smear 2, the rest 0; the two parked tips start at Swift's 40, 0, 1). `selectTool` parks size, hardness and opacity when the family changes and reads the tip it restores with `at`: the session starts in family 0 and leaving a family parks it, so Swift's `if let` always finds one. `beginTransform` and Transform Selection set the Move tool directly, as Swift's do, and park nothing. `beginBrush` takes Clone Stamp on pixels alone, asks for a source first (`brushError`: "Alt-click where Clone Stamp should copy from first."), samples the document as the stroke starts and fixes the offset; Tab toggles the sample; the step is `Clone Stamp`. `BrushStroke::Clone` is Swift's tuple. `publish` makes each tile's view of the sample on the calling thread (`clonePixels`): under a scale or turn QPainter draws it bilinear, Swift's medium quality; a whole translation copies it; a fractional one is drawn at the floor and shifted by `BrushRaster::shiftedByFraction`, since QPainter snaps an unscaled image. `fillClone` then composites it source-over through coverage × opacity × selection, each byte rounded once, where Swift clips the context to the coverage. Swift's `!isMask || isBlur` holds since 8.6: a mask takes a clone only from Blur. The bar shows Aligned (a `QCheckBox` written on `clicked`, so the session's own changes write nothing back), This Layer and All Layers, and "Alt-click to set the source" in `PlaceholderText` while there is no source. On the canvas an Alt-press with Clone Stamp sets the source and paints nothing; Swift's cursor refresh there is dead (Alt is held, so the crosshair stays) and left out. With a source the pointer hides unless Alt or Space is held. `updateBrushCursor` gives the overlay the source crosshair in view points and, between strokes and without Alt, `clonePreview` (the source around the stamped point at screen resolution, at most 1024 pixels, kept under Swift's key; Swift's `.medium` there is overridden by `LayerRenderer`'s own quality and left out) with `cloneTip` (one click painted by the brush engine, kept by size and hardness). `BrushCursorOverlay` gains `marker`, `preview`, `previewOpacity` and `tip`, images compared by `cacheKey` as Swift's `!==`; under an antialiased ellipse clip at the brush's opacity it draws the preview and cuts it to the tip inside `AdjustmentSurface::draw`, Swift's transparency layer, so the tip applies at the drawing's resolution however far a capped preview is enlarged, and draws the crosshair white 3 under black 1 with round caps. A modifier change refreshes the brush cursor, as Swift's monitor does. Equivalent mutants: `displayedTransform` in `cloneSample` and `clonePreview` (it differs from the stored transform only in a transform edit, which leaves Clone Stamp for Move, or a filter edit, which refuses strokes; the preview would show it only with a filter sheet open over Clone Stamp).
- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`: 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 Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`, in the stroke's own grid since 13.11 and in pieces since 13.16 (see those bullets). Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre).
- The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr<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
@@ -99,7 +105,7 @@ Platform adaptations, each forced by a missing Qt analog:
- 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 one surface at most (`DocumentLimits`, 200 megapixels since 12.11a; `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, which grows and trims, as Swift's has since 1.2.0, `7c4512a`), `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`.
- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes` and `anAdjustmentsMaskClipsItOnTheCanvas` in `CanvasDisplayTests`.
- Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does.
- Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`.
- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and both resizers leave them out (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy.
@@ -111,29 +117,29 @@ Platform adaptations, each forced by a missing Qt analog:
- 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; `DialogDesk::aroundAccept` sets a limit around a panel's accept alone, since a dialog that paints under one can fail in FreeType and abort; the project holds 2,048 layers and the panel limits sit at 5,120 KB, between the snapshot's peak of 3,456 KB and a copying mutant's 6,912 KB, measured in 12.16, because the heap's free chunks count on top of a limit and shift with load; `projectSnapshot` reserves its record list, since a growing list held two at once (5,120 to 5,376 KB at its peak, which made the old 2,560 KB limits for 1,024 layers fail one run in three), and `aSnapshotHoldsOneRecordList` holds that at 4,608 KB; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a mapping that size), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with takeLine 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 a label and a `DialogColorSwatch` on the app's picker (13.4a); 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 `JPEGPreview` on gray 0.12 (13.4b; 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 `DialogColorSwatch` on the app's picker (13.4a; the same colour asks nothing, as Swift's `onChange`), then one row of the raster's size grouped with "px · sRGB", the error in `BrightText`, or the file's size, or "Updating…", 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; `DialogDesk::aroundAccept` sets a limit around a panel's accept alone, since a dialog that paints under one can fail in FreeType and abort; the project holds 2,048 layers and the panel limits sit at 5,120 KB, between the snapshot's peak of 3,456 KB and a copying mutant's 6,912 KB, measured in 12.16, because the heap's free chunks count on top of a limit and shift with load; `projectSnapshot` reserves its record list, since a growing list held two at once (5,120 to 5,376 KB at its peak, which made the old 2,560 KB limits for 1,024 layers fail one run in three), and `aSnapshotHoldsOneRecordList` holds that at 4,608 KB; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a mapping that size), `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_erLine truncated
- 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.
- Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files.
- Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay (since 13.9a hand-blended by Photoshop's formula, see that bullet). `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files.
- Folders, opacity and Snap (12.2a, Swift 1.1.5–1.1.6): a folder takes an opacity that multiplies into each layer inside it, never composited as a unit, since folders pass through. `LayerOpacity::effective` (in `LayerGroups.h`) walks up to 64 folders, a lookup standing for Swift's closure; `ImageLayer::effectiveOpacity`, `ProjectLayerRecord::effectiveOpacity` (declared in `ProjectStore.h`, defined in `LayerGroups.cpp`, as Swift's extension) and `CanvasDocument::effectiveOpacities` twin Swift's, the last refusing a repeated id with a `logic_error` where Swift's dictionary traps. Every drawing path takes it: the export, `drawLiveComposite`, the canvas (its every draw, adjustments included, and `DisplayState`, so a folder's change redraws) and the live-mask baker's sources, whose records are pointers, so it calls `LayerOpacity::effective` itself. `canEditOpacity` is one layer, a folder included; `canEditAppearance` stays for blending, so `LayerAppearanceControls` rests with `canEditOpacity` and its Blend caption and picker with `canEditAppearance`; `setSelectedLayersOpacity` dims selected folders too. The format moves to version 8 here (Swift's guides arrive under it in 12.3; until then a version-8 project's `guides` key is ignored as unknown), because 1.2.0's store rule (`d9b3f9e`) lets a folder dim from version 8 on, its blend mode always normal. View ▸ Snap is `snappingEnabled`, a getter and a setter that clears the guides and notifies (Swift's `didSet`); `snappedMove` and the crop drag read it. The transform box draws its accent alone. The effects sheet's sliders span Swift's new ranges (Size 0–20, Distance 0–100, the inner shadow's 0–50, Blur 0–100) while typing and steps reach the effect's own limits (500, 5,000, 500), clamped by `apply`; `QSlider` pins a larger value at its end by itself, so Swift's clamp of the thumb has no twin; the swatch takes a white ring inside a black rim, where SwiftUI's `contentShape` has no need of a twin (a Qt button takes its whole rect). The layer list takes T for Type, as 1.1.6's table does. Equivalent mutant: `canEditOpacity`'s `activeLayer()` term (one selected layer is the active one). `drawPixelGrid` moved to `EditorCanvas+Grid.cpp`, keeping the canvas file under 500 lines. Tests: `FolderOpacityTests` (the walk, the export, the composite, adjustments, effects, the canvas in each way it draws, the baker, Swift's `aDimmedFolderSavesAndReopens`), folder duplication in `LayerDuplicateTests` (moved out of `EditorSessionTests` at 500 lines), `TransformTests` and `LayerDragTests`, the controls in `LayerAppearanceControlsTests`, Snap in `TransformSnapTests`, `CropCanvasTests` and `ViewMenuTests` (the View menu tests, moved out of `CompositorMenusTests`), the ranges and swatch in `EffectsPanelTests`.
- Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `omaphoto.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`.
- Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a namespace), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`.
- Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a struct since 13.5b), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`.
- Rulers, and guides on the canvas (12.3b, Swift 1.1.7): `UI/CanvasRulers.h/.cpp` twin Swift's file: `CanvasRuler::thickness`, `CanvasRulerCorner` and `CanvasRulerView` (a widget per strip, repainting on every `changed()`, where Swift's canvas asks each ruler to redraw), with Swift's `majorStep` and `label`. The canvas is the rulers' sibling, so a ruler maps its points through the screen (`inCanvas`), where Swift reads its own bounds as the canvas's. A press hands the canvas the keys and begins a guide from that ruler (horizontal from the top one), a drag moves it, a release over a ruler drops it; a ruler has no mouse tracking, so it sees no buttonless move and leaves a lost release to the canvas's next press. `ContentView` frames the canvas slot in a grid with the corner and the two strips, shown while `showsRulers` holds and a document is open. `TransformOverlay` draws the layout grid (majors solid gray 0.7 at 45%, subdivisions dotted one point on and two off in gray 0.55 at 28% once four points apart, both a hairline) and the guides (`EditorSession::guideColor`, a hairline across the whole view) first, as Swift's `draw`; the canvas redraws the view when the grid's flag, the guides' flag or the displayed guides change (`GuidesShown`), Swift's observation. Guide drags live in `EditorCanvas+Guides.cpp`: with the Move tool a press on a guide (`canEditGuides` and `hitGuide`) moves it ahead of any layer under the resize cursor of its axis, a drag follows, a release over a ruler (`isOverRuler`: a point left of or above the canvas while the rulers show) deletes it; Escape cancels it ahead of a transform edit's keys; wheels and pinches rest while one is dragged; the Move tool's hover shows the guide's resize cursor after the handles and before a layer's move cursor. Qt can lose a release: a buttonless move and a lost focus finish the drag where it is. Equivalent mutant: the ruler press's `canEditGuides` test (`beginGuideCreation` asks it again). Tests: `GuideCanvasTests` (Swift's `moveToolDragsAGuideAndRulerDropDeletesIt` and `rulerStepUsesNicePixelIntervals`; the drawing, repaints, cursors, keys, lost releases, the rulers' layout, ticks, repaints, focus and drags), the canvas's place in `ContentViewTests`.
- The Magic tool's Object mode (12.4, Swift 1.1.8): `Document/ObjectSelection.h/.cpp` twin Swift's file, `ObjectSelection+Session.h` its session half; `WandMode` (Swift's raw values, `allWandModes`) sits in `Selection.h`. Vision's foreground instances have no Linux twin (the user's decision): `SubjectRemoval::foreground` hands back U²-Net's mask at the image's size (the Remove Background model and cache, 9.7), which `select` scales to the model's 320 grid, cuts at half, and splits into 8-connected regions (`region`, the neighbours a traced outline joins); the region under the clicked cell is the instance, background none, and a picture where the model finds no subject none, as Vision's empty result. Its cells are enlarged smoothly to the image and refined by `GuidedMatte::refine` (radius 5) on the image, for Swift's edge-preserving upsample, then cut at half; `adjusted` erodes or dilates a pixel ring a step (eight neighbours, ten steps at most, the image's edge counting as inside, Swift's `eroded` and `dilated`), `MagicWand::outline` traces it and `smoothed` rounds the stairs as Swift's (the start Qt's close repeats dropped first, since a CGPath's close adds no point, then a subpath under three points dropped, Douglas–Peucker at 1.6 from the leftmost point, Chaikin three times, the fill rule kept; Swift's later drops of a repeated end and Chaikin's guard under three points are dead after that and left out). A `bad_alloc` is `ExportError(render)`; Swift's `unsupported` has no twin. The worker catches `SubjectRemovalError`, `MagicWandError` (the trace's refusals) and `ExportError`, each landing in `brushError` as Swift's `catch`. `selectionSample` is the wand's sample made public as Swift's; `selectObject` runs through the wand's watcher and landing (`Wanding` carries the step's name, `Object Selection`), with a completion callback, the offset clamped to ten either way and the anti-alias setting as its smoothing. `pressWandKey` chooses the tool, W on the canvas and in the list, a repeat ignored; Tab steps the mode; the tool's label, its shortcut definition (`Magic`) and the bar's title say Magic. The bar (`LassoControls`) shows Wand and Object as checkable buttons for Magic alone, the colour wand's controls in Wand mode, and in Object mode This Layer and All Layers and an Edge field of −10 to 10 px (`SelectionAmountField` takes a sign when its range goes below zero; a lone minus changes nothing, an overflow reads as its end), with Swift's help; the anti-alias help follows the mode. The canvas's press runs `selectObject` ahead of the colour wand, a click inside the selection picks afresh, the cursor is the crosshair with `SelectionIcon::objectSelection` (haloed by eight offsets as every selection icon, where Swift strokes it white), and the rail draws the icon for Object mode (`ToolIconKind::object`); the status hint follows the mode. Known difference: U²-Net finds one salient mask, so objects touching in it are one region where Vision may tell instances apart. Left out as dead: the mode picker's `cancelLasso` (Magic never holds a draft: `beginLasso` refuses it and `selectTool` cancels one), `selectObject`'s busy term (`canEditSelection` reads it) and its clamp of the offset (`adjusted` clamps), and a null check of the enlarged cells (`GuidedMatte::refine` checks its own). Equivalent mutants: the grid's cut at exactly 128, `simplifyClosed`'s guard under four points (the fallback returns the same points), `finish`'s three-point threshold at four (a traced loop has four corners), the Chaikin reserve, the edge's rounding (typing and arrows give whole numbers), W calling `selectTool` (`pressWandKey` is that call). Untested failure paths: the region's own 320-cell image, 100 KB, which no limit starves without starving the grid before it, and the worker's `MagicWandError`: a refined mask never nears eight million edges, and the trace's image-sized buffer asks less than the guided filter before it. Tests: `ObjectSelectionTests` (regions, edges, smoothing, the model on red discs, the session, keys, the canvas), the bar in `LassoControlsTests`, the icon sheet, the rail and hints in `ContentViewTests` and `ContentViewStatusTests`.
- Photoshop import (12.5a, Swift 1.2.0): `src/IO/PSD/` twins Swift's `IO/PSD` folder. `PSDTypes` holds `PSDError` (Swift's five cases and words), `PSDConversion`, `PSDRecord`, `PSDDocument` and `fromPSD` (Swift's `LayerBlendMode.fromPSD`); Swift's `PSDLayerKind.other` is never assigned, so the case and its note are left out. `PSDChannelCoder` decodes raw and PackBits planes (a no-op run skipped, every overrun `truncated`) and builds `RGBA8888_Premultiplied` pixels tagged sRGB, rounding as Swift's `(c × a + 127) / 255`, and `Grayscale8` masks; a surface that cannot be allocated is `ExportError(render)` through `BrushRaster::context`, where Swift says `truncated`. `PSDReader` reads Swift's fields in Swift's order over a `Cursor` whose bounds check cannot overflow (an 8B64 length near `qint64`'s top reads as truncated, where Swift's `offset + count` traps), maps the file where it can and reads it where it cannot (a pipe, a mount without `mmap`), as `mappedIfSafe` does (a file that cannot be opened or read is `unreadable`, where Swift passes on the Cocoa error), logs every refusal on `omaphoto.io` and rethrows it. Pascal names are Mac OS Roman through a table of the 128 high code points taken from Python's `mac_roman` (Qt decodes it only through ICU); keys are ASCII or nothing, as Swift's `.ascii`. `PSDAdjustments::parse` (in `PSDReader+Adjustments.cpp`) reads Levels, Curves and Hue/Saturation as Swift does. `PSDVector` draws vector masks with `QPainterPath` (tight `boundingRect`, a path empty only without elements as a `CGPath`), fills by nonzero winding and strokes with `Qt::SvgMiterJoin` at 10, SVG's miter rule being CoreGraphics' (probed: kept to a ratio of 9.57, bevelled from 10.43; `Qt::MiterJoin` measures from the join point and clips instead); `integral` stays in doubles, so a wild box reaches the size check instead of an overflowing `QRect`. `PSDDocumentBuilder` makes the layers and conversions; a shape's identity is its asset's. `ImageImporter::loadPhotoshop` and `photoshopAssets` run on the import's worker, `makeImport` on the UI thread. In the session (`EditorSession+Imports.cpp`) a file whose first bytes are `8BPS` takes the Photoshop path: `beginPSDReading` shows the sheet while a `QFutureWatcher` reads it, `finishPSDReading` fills it or takes it away, `finishConversion` answers from the event loop (a resumed task), Cancel while reading drops the file silently, a failure joins the import's failures, and a confirmed file lands through `insertPhotoshop` (a new canvas at the file's size and resolution, or one folder named for the file inside the active folder, centred on a drop point, 10,000 layers at most). A request of Photoshop files alone is one `Import Photoshop File` step. Fixes beyond Swift: `insertPhotoshop` validates the hierarchy first, so folders nested past its depth refuse the file (`truncated`), and a folder clipped to the layer below takes no link but the base note, where Swift links it; either way Swift admits a document its store refuses. Left out as dead: the gates' `showsConversionSheet` terms and its `didSet` (the sheet shows only while importing, which the gates refuse already), Swift's test hook `confirmConversions`, `confirmPSDConversions` and `NativeLayerList.moveLayer` (no caller), the request's `id`, the builder's folder and adjustment checks on a clipping base (only pixel layers become bases), the reader's `layerSectionEnd`, the default mask colour, the reader's plane-size checks (a plane decodes to its size), resolution's finiteness test and its floor, the vector code's finiteness tests on boxes built from finite numbers and the origination's notes (never set), Curves' two length checks past the first, `pixelSize`'s area in doubles (its boxes are whole, so the whole pixels decide alone), the session's catch around `makeImport` (every image arrives with its asset, so nothing there allocates), and the new folder's removal from the collapsed set (a fresh id is in no set). `UI/PSDConversionSheet` is Swift's sheet in a window-modal `QDialog` (`conversionSheet`) that `ContentView` shows from the session's request: the title at 17 pixels bold, the words, a spinner while reading or the list of layer names over their notes, Cancel and the default confirm button, resting while reading; Escape and the close button cancel, and a sheet the session takes away answers nothing; `open()` makes the dialog window-modal. The request shown is kept, so an unrelated change keeps the content, and replaced content hides at once and is deleted later. The importer's filter and the desktop entry take `.psd` (`image/vnd.adobe.photoshop`); a dropped picture is copied as `Dropped.<suffix>` into a folder of its own, as Swift 1.2.0 keeps a dropped file's name. Equivalent mutants: the cursor's own bounds refusal and `skip`'s (every read past the end meets another refusal: a short signature, a short plane), PackBits' count and row-end checks (the branches refuse the same bytes; they spare reading past theLine truncated
- Outer Glow and brush Smoothing (12.5b, Swift 1.2.0): `OuterGlowEffect` joins the effects with Swift's defaults (20 pixels, white, 75%) and bounds (0–500, 0–1), `LayerEffectKind::outerGlow` ("Outer Glow") last, so the footer's menu, the list's rows and every helper take it; the manifest's `outerGlow` is Swift's `Codable` (`size`, `opacity`, the colour, `enabled` only while set). `LayerEffectsKernel` blurs the shape's coverage at sigma size ÷ 2, Metal's glow buffer (a size under 0.02 copies it, as Swift's zero shift does), and composes it over the drop shadow and under an outside stroke, clamped to glow × (1 − shape) × opacity; the renderer's margin takes three sizes, the stroke surface's reach three sizes and two. Swift's CPU `outerGlowCoverage` serves machines without Metal and is dead here, as the other CPU coverages are (9.6b), so Swift's CPU and Metal parity test has no twin. The preview cache keeps the last preview while one effect is hidden or shown on the same pixels (Swift's `dbe82b0`). A fix beyond Swift: a reduced preview scales the glow's size, as it scales the inner shadow's lengths (9.6c). The sheet shows the glow's swatch, Size (0–100 on the slider, 500 typed, px) and Opacity. Smoothing is Swift's string: `BrushSettings::smoothing` (0–100); while the Brush paints (either mode) with smoothing, the brush (`m_brushAnchor`) moves only once the pointer (`m_brushPointer`) pulls further than smoothing ÷ the viewport's zoom, and then by the excess along the string; the release paints on to the pointer; a cancel drops both. The other brush tools follow the pointer. Equivalent mutants: the press's pointer and the cancel's two resets (each makes the others' work redundant), `>` at the string's exact length (a move of zero), the release's tool and smoothing terms (otherwise it appends the last point again), and the surface's glow reach between the blur's three sigmas and Swift's three sizes. `BrushControls` shows Smoothing for the Brush alone: a 100-point slider in tenths and a 42-point `SelectionAmountField` of 0–100 with Swift's help. The rest of 1.2.0 is held already (TASKS.md 12.0, each note now saying Swift agrees) or not code (`NativeLayerList.moveLayer` has no caller; the spelling touches shader comments), but for two follow-ups of `90ee29f` and `7c4512a`: Select All never rests and reaches a focused layer list (7.1's note), and the invert test holds no wall clock. Tests: `OuterGlowTests` (Swift's, but the parity test), the glow in `LayerEffectsKernelTests`' shader replay, `LayerEffectsSurfaceTests`' rows and `EffectsPreviewCacheTests`' scaling, the kinds, manifest and menu in their files; `BrushSmoothingTests` and the bar in `BrushControlsTests`.
- Outer Glow and brush Smoothing (12.5b, Swift 1.2.0): `OuterGlowEffect` joins the effects with Swift's defaults (20 pixels, white, 75%) and bounds (0–500, 0–1), `LayerEffectKind::outerGlow` ("Outer Glow") last, so the footer's menu, the list's rows and every helper take it; the manifest's `outerGlow` is Swift's `Codable` (`size`, `opacity`, the colour, `enabled` only while set). `LayerEffectsKernel` blurs the shape's coverage at sigma size ÷ 2, Metal's glow buffer (a size under 0.02 copies it, as Swift's zero shift does), and composes it over the drop shadow and under an outside stroke, clamped to glow × (1 − shape) × opacity; the renderer's margin takes three sizes, the stroke surface's reach three sizes and two. Swift's CPU `outerGlowCoverage` serves machines without Metal and is dead here, as the other CPU coverages are (9.6b), so Swift's CPU and Metal parity test has no twin. The preview cache keeps the last preview while one effect is hidden or shown on the same pixels (Swift's `dbe82b0`). A fix beyond Swift: a reduced preview scales the glow's size, as it scales the inner shadow's lengths (9.6c). The sheet shows the glow's swatch, Size (0–100 on the slider, 500 typed, px) and Opacity. Smoothing is Swift's string: `BrushSettings::smoothing` (0–100); while the Brush paints (either mode) with smoothing, the brush (`m_brushAnchor`) moves only once the pointer (`m_brushPointer`) pulls further than smoothing ÷ the viewport's zoom, and then by the excess along the string; the release paints on to the pointer; a cancel drops both. The other brush tools follow the pointer. Equivalent mutants: the press's pointer and the cancel's two resets (each makes the others' work redundant), `>` at the string's exact length (a move of zero), the release's tool and smoothing terms (otherwise it appends the last point again), and the surface's glow reach between the blur's three sigmas and Swift's three sizes. `BrushControls` shows Smoothing for the Brush alone: a 100-point slider in tenths and a 42-point `SelectionAmountField` of 0–100 with Swift's help. The rest of 1.2.0 is held already (TASKS.md 12.0, each note now saying Swift agrees) or not code (`NativeLayerList.moveLayer` has no caller; the spelling touches shader comments), but for two follow-ups of `90ee29f` and `7c4512a`: Select All never rests and, since 13.10, reaches the canvas from a focused layer list (7.1's note), and the invert test holds no wall clock. Tests: `OuterGlowTests` (Swift's, but the parity test), the glow in `LayerEffectsKernelTests`' shader replay, `LayerEffectsSurfaceTests`' rows and `EffectsPreviewCacheTests`' scaling, the kinds, manifest and menu in their files; `BrushSmoothingTests` and the bar in `BrushControlsTests`.
- Camera RAW (12.6a, Swift 1.2.1): Core Image's RAW filter has no Linux twin, so `IO/RawImporter` develops through LibRaw (the user's decision), its thread-safe `libraw_r` found by pkg-config; `Dockerfile.dev` installs `libraw-dev`, the PKGBUILD and the flake depend on `libraw`, the distro images carry it. A RAW is any file whose extension names a type inheriting `image/x-dcraw` in the MIME database (`matches`, `isRawType` for a dropped picture, `globs` for the importer's filter), Swift's `UTType.rawImage`. `RawDevelopSettings` keeps Swift's fields, `isAsShot` and `reset`; `RawDevelopRequest` is Swift's tuple. White balance: `planckian` is Kim et al.'s cubic (1667–25000 K), `whitePoint` moves off the locus along its greener uv normal by tint ÷ 3000, `temperatureOf` inverts it by a mired search and golden sections: Adobe's scale and sign (the DNG SDK's `kTintScale`), so a positive tint is a greener white and turns the picture magenta, and D65 reads about +10, as Lightroom's Daylight. Known difference: CIRAWFilter's temperature and tint follow Apple's own model; these follow Adobe's convention on CIE 1960 uv. `asShot` reads the camera's multipliers (`cam_mul`, else dcraw's daylight `pre_mul`) and turns the neutral into xy through LibRaw's `rgb_cam` (a DNG's `cam_xyz` stays zero) with `pre_mul` put back; `develop` works back from the chosen white to camera multipliers (`user_mul`, the second green the first's), reading the camera's `pre_mul`, `rgb_cam` and size from unpack's copy (`rawdata`), since a process overwrites `color` with its own multipliers and a half-size one halves `sizes`; a white the matrix maps to a response at or below zero is refused, logged, where LibRaw would drop the balance in silence; the refusal is `ImageImportError(whiteBalance)`, a kind of Linux's own with words of its own, since `CIRAWFilter` takes every white and real cameras' matrices reach it inside the sliders (the warm and green corner). It takes LibRaw's 16-bit sRGB at a power of one (linear), with neither auto-brightening nor the maximum's adjustment (`adjust_maximum_thr` 0, so a frame near white keeps its level), clipped at white in camera space (`highlight` 0, so a clipped photosite stays neutral: unclipped, the matrix turns it magenta), where LibRaw divides by the smallest multiplier; the twin multiplies the smallest back, so the balance is anchored on green, as Core Image's, where dcraw alone brightens the frame as the temperature moves. Known difference: where a multiplier falls below green's (the slider's far ends), the frame's white falls to that multiplier. It makes the look itself, rows side by side: exposure multiplies linear light by 2^EV, clamped, then Boost mixes sRGB's curve (0) with LibRaw's BT.709 curve (1). Known difference: Apple's Boost curve is undisclosed; at 1 this is LibRaw's default curve. The result is opaque `RGBA8888_Premultiplied` tagged sRGB, oriented by LibRaw. Only three-colour sensors develop; a four-colour one is `unsupported` (dcraw's matrices hold no fourth to invert). A preview past its limit decodes at half size, then fits smoothly. `Queue` is Swift's actor: one develop at a time under a mutex, the unpacked frame kept for the previews of one file, a full develop always fresh, `release` letting go; Swift keys the cache by the limit too, since its filter's scale is fixed when made, where LibRaw halves per develop. LibRaw's refusals are `unreadable`, logged with its message on `omaphoto.io`; a surface that cannot be made is `ExportError(render)`. In the session (`EditorSession+Imports.cpp`) a RAW goes before the Photoshop check: `pixelSize` (unreadable when it fails) and Swift's budget (`tooLarge` past 30,000 a side or the pixels left), then `developRaw` shows the sheet with the camera's settings (the defaults when they cannot be read) and keeps the answer, `finishRawDevelop` clears the request, releases the queue on a worker and answers from the event loop (a resumed task): nothing skips the file, settings develop the whole frame on the import's worker, which catches `ImageImportError` and `ExportError`, into a layer named for the file. `rawDevelop` alone stands for Swift's `rawDevelop` and `showsRawDevelop`, which are set and cleared together; the latter's `didSet` is dead (the sheet shows only while importing, which holds file requests anyway), and so is the test hook `confirmRawDevelop`. `UI/RawDevelopSheet` is Swift's sheet in a window-modal `QDialog` (`rawDevelopSheet`) that `ContentView` shows while the request stands, its close button and Escape cancelling and a sheet the session takes away answering nothing: the title at 17 pixels bold, the 560 by 340 preview on 35% black rounded 6 with a spinner while a develop runs, four rows (a 90-point title as the buddy of a 300-point slider, the value 80 points wide in `PlaceholderText`: Exposure in hundredths, Temperature and Tint whole, Boost in thousandths, printed as Swift's `%.Nf`), Reset (resting while as shot), Cancel and the default Line truncated
- Middle-button pan, tool defaults, Auto Select and the icon (12.6b, Swift 1.2.1): `Document/ToolDefaults` twins Swift's enum as a namespace: `boolean` (Swift's `bool`, a C++ keyword) and `set` keep Auto Select, the transform controls, the pixel grid, the layout grid, guides, rulers, Snap, the four Snap To switches and Lock Guides in `QSettings` under Swift's `tool.` keys, read by the session's members as they are made and written by each setter, and by the two places that show guides by themselves (`beginGuideCreation`, `addGuide`), as Swift's `didSet` sees those writes. Swift returns the defaults under XCTest; Qt has no such flag, and most test binaries run outside `QStandardPaths`' test mode, so the twin keeps the defaults until `ToolDefaults::enable()`, which `main` calls first (not under test, as the theme's construction is not). The settings file keeps text, so a stored value reads as `true` or `false`; anything else is ignored with a warning on `omaphoto.app`, where Swift's `as? Bool` is silent. The canvas's middle button (Swift's `otherMouseDown` for button 2) pans from any tool while a document is open, on a drag point of its own (`m_middlePanPoint`), so a left drag under way goes on beside it: a move with both buttons pans, then gives the left its turn (so a Hand drag pans twice), and a right-button tip drag waits for the middle's release. Known difference: macOS sends one drag event a motion, so which button Swift's canvas follows then is AppKit's choice. The press hides the brush circle and shows the closed hand, which outranks every other cursor; the release puts the tool's cursor and circle back. Qt can lose a release: a move without the middle button ends the pan. Unlike Swift's `mouseDown`, the press takes no focus. `transformPressLayer` gains Swift's foreground rule: with Auto Select, a press inside the active layer's box goes to the layer under the pointer when that is painted above it (a background holding every press no longer hides the layer on top); `above` reads both layers through checked lookups, since the pointer's layer and the visible active one are both drawn, where Swift guards each index. Swift's `underPointer != active.id` is left out as dead (`above` refuses a layer against itself); equivalent mutant: `>=` in `above` (only the active layer equals itself, and `selectLayer` of the active layer changes nothing). The reshaped app icon (`45ab8fd`) replaces `packaging/icons`. Tests: `ToolDefaultsTests` (the defaults until enabled, every toggle across sessions and as text, guides shown by a guide, a strange value), `theMiddleButtonPansFromAnyTool` in `CanvasNavigationTests`, Swift's `autoSelectPicksForegroundLayerStackedOnSelectedBackground` and a mask box past its layer in `TransformPressTests`, whose older Auto Select test now expects the layer above.
- Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, unchanged through 1.3.3), so a base or an adjustment in one of them draws as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`.
- Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, through 1.3.3; Swift 1.4 blends both in every mode, 13.9a), so a base or an adjustment in one of them drew as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`.
- Black & White, Color Balance and Invert (12.7b, Swift 1.2.2): `kernels/AdjustPixels.c/.h` are v1.2.2's, with `adjust_black_white` and `adjust_color_balance`, run through `ImageAdjustmentPixels::run` on one thread as Swift's. `BlackWhiteSettings` and `ColorBalanceSettings` keep Swift's fields, defaults and bounds (the `isFinite` terms left out, as the bounds refuse NaN and infinities); `apply` throws `ProjectError(invalid)` past them and Color Balance hands an untouched image back. Swift's `FilterSettings.normalized` does not clamp either, so a number typed past a slider's range fails the preview with that error and a commit sets `brushError` with no step, as in Swift: the filter's two workers catch `ProjectError` beside their other errors, where it would otherwise escape as `QUnhandledException` and abort. `FilterKind` gains both after Grain (`isImageAdjustment`), `AdjustmentKind` Invert, Black & White and Color Balance in Swift's order, with `isEditable` (all but Invert) and `filterKind`; `LayerAdjustment` gains the two optionals with Swift's computed getter and setter pairs, validity and `apply` (Invert through `PixelInvert::run`, whole image, colour only); the manifest's `blackWhiteSettings` and `colorBalanceSettings` are Swift's synthesized `Codable`, every key required, left out while absent. `addAdjustment` opens the editor for editable kinds alone: an Invert layer applies at once. The editors carry both settings through `beginAdjustmentEditing` and `editedAdjustment`. A fix beyond Swift: Swift's Edit Adjustment… stays enabled on an Invert layer, whose editor switch breaks after the original is kept and the transaction opened, so nothing ever closes them and the session stays refused; the twin's entry rests for a kind that is not editable, and the two switches throw a `logic_error` for Invert, which nothing reaches. As Swift's row, an Invert layer says "Adjustment · Double-click to edit", yet a double click on its glyph renames it (`m_editableAdjustment`, Swift's `isEditable` test). The Image menu lists Black & White… and Color Balance… before Exposure…, the Layer menu's New Adjustment Layer entries take an ellipsis when editable, and a menu doubles the ampersand of "Black & White" (a lone one is a mnemonic in Qt), in the kinds' entries and in Undo and Redo's step names alike; object names drop it (`blackWhite`, `newBlackWhiteAdjustment`). `LayerIcons` draws Swift's three new symbols as paths: `circle.righthalf.filled`, `circle.filled.pattern.diagonalline.rectangle` (a rounded frame, diagonal lines, a filled circle) and `scale.3d` (three axes with arrowheads). The filter sheet's two kinds live in `UI/FilterSheet+Adjustments.cpp` (the sheet's file neared 500 lines): the six families and Tint's Hue and Saturation, shown while Tint is on (Swift's `if`, the rows `m_shownWhen` holds with Advanced's), and Color Balance's three headlines (13 pixels, semibold, Swift's `.headline`, as the effects sheet's) over three rows each and Preserve Luminosity; each `flag` is Swift's `Toggle` with its help as the tooltip. `NativeLayerList` stops listening in its destructor, as `ContentView` does: closing a list window hides it first, and a rename's focus-out then commits into a list half destroyed. Tests: Swift's `invertAppliesWithoutAnEditor` in `AdjustmentLayerTests`, its editor test over the editable kinds in `AdjustmentEditorTests` and `FloatingPanelTests`, `ColorAdjustmentTests` (the kernels against Photoshop's defaults and a tint worked in HSL, the tones of Color Balance and Preserve Luminosity, bounds, every manifest key, the sheet), the kinds, keys and menus in `FilterModelTests`, `LayerAdjustmentTests`, `AdjustmentPanelTests`, `CompositorMenusTests` and `SelectMenuTests`. `ProjectExportFailureTests`' two panel limits rose from 2,048 KB: the thousand-layer snapshot grew by the two optionals of every record (2,560 KB since 12.8d).
- The Camera Raw filter's model (12.8a, Swift 1.2.3): `kernels/AdjustPixels.c/.h` are v1.2.3's, with the Camera Raw kernels and Grain's particle size (`59b5040`); that kernel warns under `-Wunused-parameter` and `-Wmisleading-indentation` through 1.3.3 (both lines date from `9982fad`; `075a71b` fixes another), so CMake silences those two for that file alone. `Document/CameraRaw.h/.cpp`, `CameraRawColor`, `CameraRawDetailOptics` and `CameraRawGeometryCalibration` twin Swift's four files; Swift's `CameraRaw.swift` runs to 638 lines, so its session extension is `CameraRaw+Session.cpp` with `CameraRaw+Session.h`. The enums keep Swift's raw values and order; `CameraRawClipping` keeps Swift's bits (highlights 1, shadows 2); `Balance` and `Gains` are structs for Swift's tuples; `region` returns a reference where Swift writes through a key path. `CameraRawDrag` sits in `CameraRaw.h`, where Swift's sits in `CameraRawColor.swift`: it holds the whole settings, which C++ needs complete. Swift's panel state sits on `FilterEdit` (the shown groups, pages, swatch, point index, the tools, the guide draft, clipping, sharpen mask, the scope and its mode, the readout); the twin gathers it in `CameraRawPanel`, `FilterEdit::rawPanel`, written through `setCameraRawPanel`, which notifies while an edit is open. `renderSettings` is Swift's settings with the hidden groups left out (`applying`); the commit renders and keeps those, so a hidden group stays out next time, and an identity grade closes as Cancel does. `previewJob` carries the panel's views; the preview's worker runs `CameraRawScope::preview`, whose scope lands on the panel. A fix beyond Swift: Swift's `preview` drops the point colour's view (it runs the job itself only for clipping or the sharpen mask, through 1.3.3), so Visualize never shows; the twin runs the job for it too. Geometry's warp is `QTransform::quadToQuad` from the image's rect to Swift's corners flipped to y down, drawn smooth and antialiased; a warp Qt cannot solve is a `logic_error`. Constrain Crop fits the frame inside `brush_alpha_bounds`; a crop whose copy finds no memory is `ExportError(render)`, where Swift's `cropping` returning nil falls back to the uncropped warp. Left out as dead: `apply`'s `isValid` guard and with it `isValid` (settings arrive normalized, and nothing else asks), `nudged`'s guard for an empty channel (`normalized` repairs every curve to two points; `at` throws if that breaks), the vectorscope's wrap of a negative hue (its angle's cosine and sine turn round alike), the parametric curve's `max(0, weight)` (a tone inside its region weighs 0 to 1), each group's `applying(shows:)` (no caller), and the defringe eyedropper's own `normalized` (`updateFilter` normalizes). Equivalent mutants: `tone < shadow` against `<=` (the regions meet with equal weight), the defringe tie's `<` against `<=`, the mixer's `weights > 0` against `>= 0` (a zero weight adds nothing), the identity commit's cancel (the history drops an unchanged step), and Auto's `committing` term (a committing edit refuses `updateFilter`, and the landing asks again). The Filter menu and the sheet leave Camera Raw out until its panel (12.8b). Tests: `CameraRawTests`, `CameraRawSessionTests` (Swift's session tests; the preview's views and scope), `CameraRawToolsTests`, `CameraRawRulesTests`, `CameraRawColorTests`, `CameraRawLensTests` (the kernel's calibration called directly), `CameraRawWiringTests` (Effects, Optics and Detail against their kernels called with the header's order, a clip view on an untouched grade, Auto in linear light, every eye's fields, Offset, Aspect and Constrain Crop against placed pictures, the scope's channels and hues, the curve, mixer and grading call with its floats written out, the sharpen-mask view, each lens flag alone, Grain's scale, a dull colour's reach, Rotate, Scale and Vertical against placed pictures), `CameraRawFixtures.h`, Grain's size in `ImageAdjustmentTests`, the kind in `FilterModelTests`.
- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; Camera Raw's own rows keep the track they are made with, since the rows whose tracks change (12.8b2's mixer) are made anew; since 12.14c `reshape` is Swift's track and bounds update in `updateNSView`, for the filter and Hue/Saturation sheets. `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`.
- The Camera Raw panel's other groups (12.8b2, Swift 1.2.3): `UI/CameraRawColorControls.h/.cpp` (Curve), `+Mixer.cpp` and `+Grading.cpp`, `UI/CameraRawDetailOpticsControls` and `UI/CameraRawGeometryCalibrationControls` twin Swift's three files, each view a `QWidget` over the session, synchronized on `changed()`. `UI/CameraRawRow` is Linux-only: the row each Swift view builds for itself (`sharpenSlider`, `opticsSlider`, `geometrySlider`, `calibrationSlider`, the curve's and mixer's sliders, 12.8b1's `slider`): a title of at least or exactly a width, `CameraRawSlider`, a field or none, a double click on the title resetting where Swift's main groups do; each view rounds the slider's value as its Swift twin does (Detail to whole numbers, Optics when its range starts at zero, Geometry and Calibration always, the rest not). Swift's segmented pickers are `cameraRawSegments`, touching checkable buttons in an exclusive `QButtonGroup`. The curve graph: a still press and release adds a point, a drag past two points moves the nearest divider or inner point, a double click removes the nearest inner point within 0.04 and its trailing release adds nothing, so the net is Swift's: SwiftUI adds on each click, `repair` drops the second as a duplicate and the double tap removes one. The graph is `CameraRawCurveGraph`: a class defined in a `.cpp` outside an anonymous namespace is global, and the Curves sheet's `CurveGraph` would share its vtable across the link. The mixer's rows whose help or track follow its tab, swatch or chosen point are made anew when those change, as SwiftUI renders them anew; a picked colour's swatch is shown at once, since a layout shows late children only later. The grading wheel fills a `QConicalGradient`, which runs counterclockwise from the right as the hue does (1.2.9's `96edd5f`), takes presses and double clicks inside its circle alone (Swift's `contentShape`), and Swift's `theDockedPanelFitsTheGradingWheels` holds; its stops are 96edd5f's own, pure HSV hues every 30 degrees (v1.2.3's six SwiftUI system colours predate that fix). Detail's idle noise rows are disabled under a 45% `QGraphicsOpacityEffect`, Swift's `.opacity(0.45).disabled`. Targeted Adjustment and Draw Guides are checkable push buttons, checked while armed, where Swift tints them (known difference); their SF Symbols, `scope` and `line.diagonal`, are `LayerIcons` paths. The Filter menu lists Camera Raw Filter… after Lens Correction, as Swift's. Known differences: a mixer family's field still being typed in loses its typing when the tab changes (the row is made anew), where SwiftUI's field would commit into the new tab; Equivalent mutants: the wheel's saturation cap (`normalized` clamps it), the picked swatches made anew only when their count changes (it spares rebuilds; Qt guards a swatch deleted in its own click) and the page menu's size policy (layout). The point-colour eyedropper is flat like the other two, where Swift leaves it bordered (cosmetic). Cosmetic, Swift's values and unpinned: the swatches' 18 points, Detail's idle 45%, the hue ranges' inset (title width and 10), the rows' spacing of 10 and the 11-pixel subheadlines. Tests: `CameraRawRowTable.h` walked by `CameraRawGroupRowsTests` (every row against Swift's values), `CameraRawGroupDrawTests` (drawing, pages, gestures, the panel through `FilterSheet`), `CameraRawGroupWordsTests` (every text, captions, buddies, the pickers' states, the mixer's pages, the wheel's lower half and rim, the curve's line, repaint and selected point), `CameraRawCurveMixerTests`, `CameraRawGradingLensTests`, the menu's entries in `CompositorMenusTests`.
- Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held.
- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return Line truncated
- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmeLine truncated
- The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`.
- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). A new front tab scrolls into view, posted as Swift posts its reveal, once the row is laid out; any other change keeps the offset, which Qt clamps, where the strip used to follow every layout (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`.
- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). Since 13.14 the strip no longer scrolls: tabs that do not fit gather under the overflow pill and the front tab is always shown (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`.
- The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu.
- Whole layers on the clipboard and several duplicates (12.10b, Swift 1.2.5: `5ef5b0a`, `c873453`, `245db93`, `2f1537f`). `CopiedLayer` (in `Document/SelectionClipboard.h`) is the session's `copiedLayer`: Copy with no selection and no chosen mask (`canCopyLayer`) takes the selected layers whole; a layer with pixels still copies them as well, for other apps, and a folder or adjustment puts only its id on the clipboard under Swift's `com.compositor.copied-layer`, dropping the pixel copy. Any copy of pixels drops it; it is judged as a pixel copy is (`clipboardHolds`), so a copy anywhere since, in this app or another, ends it for good, a history entry set again included, and Wayland's second announcement of the same copy does not. The copies' active one is the active layer's copy, else the first id's with a copy in order (Swift's `values.first` is any). `ProjectWorkspace::pasteCopiedLayer` finds the tab whose copy still stands, keeps the ids its document still holds, and duplicates them in place as one `Paste` step, refused while that project cannot edit its layers, or copies them into the front tab through `copyLayers`; false leaves Paste to pixels. The Edit menu's Copy runs with `canCopyPixels` or `canCopyLayer`, and Paste asks the workspace first, a text field and open text before both. Ctrl+J without a selection duplicates a folder too (`layerViaCopy`), and its entry is enabled for any active layer; a folder under a selection copies nothing. `beginDuplicateTransform` stacks its copies through `duplicateLayers`, the copies being what is selected after it that was not before, the active copy leading. The copy between projects moved to `ProjectWorkspace+Copy.cpp`, since the workspace's file reached 500 lines. Tests: `LayerClipboardTests` (Swift has none), the updated folder cases in `SelectionClipboardTests`, `CompositorMenusTests` and `TransformTests`, and `effectsTravelWithTheCopy` in `ProjectCopyTests`.
- Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`.
@@ -150,18 +156,39 @@ Platform adaptations, each forced by a missing Qt analog:
- The guide for agents (12.13c, Swift 1.3: `c317306`): `docs/writing-comp-files.md` twins Swift's guide and `docs/project-format.md` Swift's format page, both adapted to Linux (a project is a folder, the example lives under `~/Pictures`, the save swaps folders with `renameat2`, a replaced project folder is noticed too, sources named by the twin's files). Swift's top-level `AGENTS.md` for agents becomes the section at the head of this file. README gains Swift's lines (scrubbing, saving while editing, projects changed on disk, working with AI agents). The format page's claims follow the twin, where Swift's page is out of date: the pixel budgets scale with memory, the save's exchange has a two-step fallback, only `maskPlacement` needs a mask, the common adjustment fields are required and later kinds default to their own values, folders pass adjustments through, text becomes pixels after Canvas Size, Image Size and Crop, and a missing face may fall to another family. `WritingProjectsGuideTests` reads the guide itself: the minimal manifest loads with the fields it shows (visibility, opacity, blend, placement, the image named after its layer), the Curves layer's points are pinned and its rendered pixel is the background through those curves, removing any common adjustment field refuses the project, a folder passes an Invert through, `hsvSettings` wins over the legacy Hue/Saturation fields (the guide says where to edit a saved one), and the guide's lists of blend modes, adjustment kinds and samplings equal the twin's `rawValue`s. The guide asks for 8-bit RGBA layers and accepts what the store accepts: RGB, grayscale and lower-depth grayscale layers, never 16 bits. `ExternalChangeTests::theGuidesSameSizeRecipeReloads` runs the same-size recipe against a watched project.
- Quitting while text is edited (12.14a, Swift 1.3.1: `a18d86c`, `afd622e`, `4a4cf85`, `221d6cc`): `ProjectWorkspace::confirmQuit` first applies every tab's open text in quit order (`finishTextEditing`, Swift's), so closing the window (the twin's quit) no longer stops at `canSwitch`, which an open draft refuses; text that cannot be applied (a busy project) stops the quit with the draft kept. Swift's `applicationShouldTerminate` guard has no twin: closing the window reaches `confirmQuit` directly. Swift's cursor commits (the arrow restored off the canvas, the I-beam kept across it while text is open) need no code here: a Qt widget owns its cursor, so leaving the canvas shows the next widget's, and the canvas already shows the Type tool's I-beam away from the box. Tests: `QuitWhileEditingTextTests` (Swift's two through the real window's close: the text applied as one `New Text Layer` step before the question, Cancel keeping the window, then Don't Save; every tab in quit order with its own content and its undo; a refused draft in the middle of three tabs stopping the quit where it stands, the tab before applied, the tab after untouched, nothing asked; edited existing text as one `Edit Text` step on the same layer; an unchanged existing draft and a blank new one closing with no step and no question), `InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas` (the I-beam away from the box, after a resize drag, no application-wide cursor on leaving, and neither the window nor a sibling taking the I-beam).
- Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`.
- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "<Title>. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arLine truncated
- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "<Title>. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`, `ColorRangeSheet`, `GridSettingsSheet`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, Line truncated
- Colouring only the selected letters (12.14d1, Swift 1.3.2: `2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): `LayerTextStyle` gains Swift's optional `colorRuns` (`LayerTextColorRun`: location and length in UTF-16 units as `qint64`, Swift's `Int`, and sRGB red, green and blue), valid when sorted, apart, not empty, within the content and within Int, with Swift's `color(at:)` (`color`), `setColor(_:in:)` (an empty span or the whole text recolours the base and drops the runs) and `replaceCharacters(in:withLength:)` (new units take the colour before them, or at the start the first letter's), both working through Swift's per-unit colours and joining neighbours of one colour. `TextSpan` is Swift's `NSRange`; `TextDraft.selection` sits last. `TypeTool.h` declares `PaletteColor` opaquely and `ColorPalette.h` includes it, because `ColorPickerState::EditedText` now holds the text's whole style (Swift's `(draftID, style)`). The manifest writes `colorRuns` only when set, each run's five keys required, and the store refuses runs before version 10, which saves now write (versions 1–10). `TextLines` gives each paragraph its runs as `QTextLayout` formats before laying it out, so a run's letters draw once in their colour, as CoreText's runs do: formats handed to `draw` are an overlay, which would leave the base colour in their antialiased edges. The palette follows Swift's: the foreground swatch and the Type bar's picker paint the draft's selection (`setDraftTextColor`), previews too; Cancel puts back the base and the runs the text had when the picker opened (`restoreDraftTextColors`), the runs only while the content is unchanged; the Type bar's picker now keeps that style as the palette's does; `typeColor` is the first selected letter's colour, or with a caret the letter before it; Fill (`recolorText`) recolours every letter, runs gone, even when the base colour already matches; the text defaults never keep runs, so new text starts in one colour. Swift's clearing of the runs when new text begins is left out as dead, since the defaults it starts from never hold runs. The inline editor is the twin's own, so Swift's `shouldChangeTextIn` bookkeeping sits in `publish`, which takes each edit's exact replacements (`Step`: the range taken, the new length) in order: typing and deleting, the clipboard (the preedit out, the selection replaced, the preedit back), input methods (the old preedit out, the selection taken and the commit's replacement as one step when the commit lands where the selection was, as NSTextView replaces it, the new preedit in, and a lone preedit over the selection one replacement for the colours, as `setMarkedText` makes it, the selection's removal still history; an update, a cursor's move or a commit of the preedit where it stands is one replacement of the marked text for the colours, as `setMarkedText` and `insertText` replace it whole, even with the same string, so a leading preedit keeps the colour it took) and undo and redo; each step moves the runs against the text's length as the steps before it left it, as Swift's content changes between its calls. Each undo entry keeps the plain steps from its text to the next newer state (`since`: preedits never count, a preedit left as text by a key does), and undo and redo replay their inverses newest first, as NSTextView undoes each replacement, so a letter put back takes the colour before it, as Swift's does; a guess from the text alone would put colours on the wrong twin of a repeated letter. The draft learns the selection on every publish and every move (`noteSelection`, Swift's `textViewDidChangeSelection`). Swift's reset of runs that no longer fit after an edit is left out: every edit moves them. The editor lays its lines out from the style without runs: its glyphs are clear, and the formats would paint them in their colours over the layer beneath. The caret takes the colour of the letter before it (Swift's `insertionPointColor`). Swift's see-through selection under the picker (`b5fe653`) needs nothing: the twin draws the selection translucent, in the inactive colour when the canvas lacks the keys, and `4503998` only adds a test. Equivalent mutants: the inherited colour's `colors[start]` for `colors.front()` (at the start they are one) and `TextLines`' clip of a run to its paragraph (Qt clips a format to its layout's text). Docs: `project-format.md` and `writing-comp-files.md` say version 10, as Swift's. Tests: `TextColorModelTests` (Swift's model and invalid runs, every channel checked, runs of one colour joining only where they meet), `TextColorRunsTests` (Swift's other two: the selection painted and reopened with version 9 refused, Cancel restoring; the swatch's colour, the Type bar's picker, Fill and new text, the manifest's keys, runs across paragraphs, the run's Codable refusals, Cancel over mixed colours for both pickers), `TextColorEditorTests` (the selection reaching the draft, runs through typing, deleting, undo and paste, twins of one letter through Line truncated
- Faces for the selected letters (13.2, Swift 1.3.4: `0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test): `LayerTextStyle` gains Swift's optional `fontRuns` (`LayerTextFontRun`: location, length, fontName), `fontNameAt` (Swift's `fontName(at:)`: C++ cannot give a method a member's name), `uniformFontName` and `setFont`; `replaceCharacters` moves colours and faces alike, so the editor's steps, undo and the clipboard carry faces as they carry colours. The run members live in `TypeTool+Runs.cpp`, since `TypeTool.cpp` would pass 500 lines. A run's name follows Swift's guard: not empty, 200 graphemes at most, no newline character (LF, CR, VT, FF, NEL, U+2028, U+2029). `TextLines` lays each run in its face as a format (`TextLayout::font(style, face)` keeps the style's size, spacing, kerning and hinting); the editor lays out the faces, for the glyphs' places, and drops the colours alone. The manifest writes `fontRuns` when set; the store writes version 11 and refuses faces before it. `TypeFontPicker` shows a selection in several faces as (Multiple), a top item that is no font (its item data), and drops it once one face shows; a chosen face applies to the draft's selection, and the face shown or (Multiple) changes nothing. A fix beyond Swift: Swift's `textDidChange` copies the pending style's runs but not its face, so deleting until every letter left is in one run's face drops the run and turns those letters back to the old base face; the twin keeps the folded face (`deletingToOneFaceKeepsItsLetters`). Swift's `holdsSelection` has no twin: AppKit's text view loses its highlight when the menu takes the focus, while the inline editor keeps its selection unfocused (`TextFontControlsTests` holds it). Swift's reset of `fontRuns` in `beginText` is dead, as the colour's is: the defaults never carry runs. Equivalent mutants: `uniformFontName`'s clamp of its index (runs are sorted and apart), the layout's clamp of a run to its paragraph (Qt clips a format past the text, undocumented, so the clamp stays) and the decoder's string check (any other value reads as an empty name, which validity refuses). Tests: `TextFontRunsTests` (Swift's three; the store's rules; layout in mono, bold and across a break), `TextFontControlsTests` (the menu, the kept selection, typing and undo, the folded face, a click placed by the faces), version 11 across the store's tests.
- Sampling and open text (12.14d2, Swift 1.3.2: `810c88a`, `d448625`): closing the colour picker, by OK or Cancel, while text is open asks for canvas focus (`closeColorPicker` counts `canvasFocusRequest` before its final signal), and `CanvasView::consumeFocusRequest` now hands the keys to the canvas with a draft open too, where the editor takes them, as Swift makes the text view first responder, so typing and Ctrl+Return reach the text after sampling from the canvas. Swift also takes a sampling click for an open panel before its window sees it, so the panel stays key; a window manager activates whatever window is clicked, and handing the panel the keys back at the press would take the canvas's focus and end the sampling it began, so the twin hands them back at the release, as Swift did before that commit (known difference). `canAdjust` (so `canAdjustColors`, `canVignette` and the fill) and `canInvert` refuse while text is open, since the editor draws that text, not its layer, and a preview of it would be wrong; the Image and Filter menus follow. Tests: `PickingTests::openTextTakesTheKeysBackFromThePicker` (the foreground, background and Type bar pickers, new and existing text, OK and Cancel, one request each, typing after and Ctrl+Return applying, any focus request, no request without text for any picker by OK or Cancel, the owner's field keeping the keys), `InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField` (a request now takes the keys from a button, a modal dialog keeping them with text open, a tool panel's field keeping them while the canvas waits for its window's turn), `TextEditingGatesTests` (the gates and the entries with new and reopened text, open and closed, Levels, Hue/Saturation, filters and Invert refused with nothing changed, content-aware fill and Camera Raw, Vignette on an empty layer).
- The Dither filter (12.15, Swift 1.3.3: `f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `kernels/DitherPixels.c/.h` are Swift's, unchanged, built with the other kernels. `Document/Dither.h/.cpp` twin Swift's file: `DitherStyle`, `DitherPixelShape` and `DitherColors` by raw value in Swift's order (`allDitherStyles`, `allDitherPixelShapes`, `allDitherColors`; `ditherGroup` stands for Swift's `groups`, with `diffuses`, `hasTones`, `isHalftone` and `drawsMarks`); `DitherSettings` keeps Swift's fields, defaults and ranges, `normalized` rounds and clamps as Swift's (a value that is no number falls back) and keeps the first 64 graphemes of the characters that are no line break (Swift's `Character.isNewline`: LF, CR, CRLF, VT, FF, NEL, U+2028, U+2029). `apply` converts the image to premultiplied RGBA first. Chunky pixels are each block's area averaged exactly, a block past the image's edge counting its missing pixels as clear, where CoreGraphics draws the image at its high quality into a fractional rectangle (known difference: a box average, not CoreGraphics' filter); the small image is dithered and blown back up pixel for pixel, and a dot shape runs `dither_dots` with the dark colour or black for its gaps. `glyphs` draws each distinct grapheme of the characters into a gray map of the cell's size, white on black, centred on its ink (`QPainterPath::boundingRect`, Swift's `CTLineGetImageBounds`), in fontconfig's bold monospace at a whole pixel size of 1.15 cells (known difference: Swift's bold SF Mono at the exact size), filled by the winding rule so overlapping outlines (a letter and its combining accent) stay inked, as text rendering fills them (Qt's `addText` paths are odd-even), sorted by coverage, stably. A kernel that finds no memory is `ExportError(render)`, as is a source whose conversion finds none (`FilterFailureTests`); a glyph map (4 KB at most) cannot be starved alone under the address-space harness, so its refusal is untested, as is the glyphs' order when two coverages tie (no two characters are known to ink alike in every font). Equivalent mutant: the conversion's own null check (the small or working context of a null source refuses the same way). Making the small image gives the same pixels without it (averaged into the top left of a full-size copy whose clear rest the kernel skips), but its kernel planes would then be full size: `FilterFailureTests` dithers chunky 4000-pixel Original colours within the limit the unchunky case exceeds. Swift's `Set<Character>` treats canonically equivalent graphemes as one: the glyphs' seen set compares their NFC forms, and draws the first. Leaving the Characters field (`editingFinished`) shows what the settings kept of the typing. `FilterKind::dither` sits after Bloom / Glow, as Swift's menu has it; `FilterSettings.dither` normalizes with the rest; `PixelFilter::run` applies it at the job's own pixels; its previews are made at full size, as noise's and grain's are. The picker's `ColorPickerTarget` gains the `dither` kind and a `light` flag (Swift's `.dither(light:)`, titled "Color Picker (Dither Dark Color)" or "Light"), with `openDitherColorPicker`, `previewDitherColor` and `setDitherColor`; Cancel and OK of the filter close it, as they close the Gradient Map's and the Vignette's (`filterColour`). The sheet's `dither` (`UI/FilterSheet+Dither.cpp`) is Swift's `ditherControls`: Style as a menu with separators between its groups, Pixel Size, Cell Size (halftone and ASCII), Angle (halftone), Characters (ASCII: a monospace field that writes as typed and shows the settings while unfocused), Tones (diffusion and ordered), Diffusion (diffusion), Density, Contrast, Colors as a menu, the Dark and Light swatches (Two Colors), Pixel Shape (chunky pixels) and Light on Dark (marks), each with Swift's help; menus and swatches follow the settings through `m_follows`. The busy line now shows only for a commit or an automatic kind's preparation, Swift's rule against a quick preview's flicker. `e70da06` needs nothing: `main` shows the window before it hands the files named at launch to the workspace. `29f0bea`'s guide sentence joins `writing-comp-files.md`. Swift has no Dither tests: `DitherTests` (styles, groups and defaults, normalization, Bayer's exact pattern and tones, diffusion, two colours and the original ones, alpha, chunky blocks and their cut edges, dots, marks either way, glyphs) `DitherKernelTests` (each diffusion, Bayer matrix, halftone shape and Mac pattern by hand-worked pixels, Original and Two Colors marks either way, a premultiplied block average, colour-byte rounding, Original dots' black gaps and smoothed rims, each range's clamp, rounding and fallback, a lone CR, a straight-alpha source premultiplied first, a non-default cell size on the screen and in ASCII, an accent's overlap inked as text is) and `DitherSheetTests` (the kind's place and full-size preview, the commit, an ASCII commit on the worker, the two pickers and their refusals, the controls by style, the sLine truncated
- Dither's ASCII as text (13.3a, Swift 1.3.4: `2309a85`): `kernels/DitherPixels.c/.h` are v1.3.4's, whose glyph cells take `glyphWidth` and `glyphHeight`. `DitherSettings` gains `textSize` (6–64, 14, rounded); ASCII draws at full resolution, chunky pixels and their shape left alone. A glyph's cell is one bold monospace M wide (its advance, rounded) and `textSize` tall; the face is `lround(lineHeight / 1.2)` whole pixels where CoreText takes the fraction (known difference, as before); every letter stands on one baseline, centred in the line as Swift's AppKit metrics set it (`(height − (ascent + descent)) / 2 + descent` from the bottom, rounded), and is centred on its advance. The sheet shows Text Size for ASCII ("The height of each line of characters") and hides Pixel Size, Cell Size and Pixel Shape there. Equivalent mutants: the horizontal centring (a monospace advance is the cell, so the offset rounds to 0; only a double-width fallback glyph would move, and the dev image has none) and the kernel's cell rows counted by the width (a cell is narrower than tall, so only rows past the picture are added, never read). Tests: the glyph geometry and coverage against references drawn here at line heights 20 and 64 in `DitherTests`, the tiles, the picked table over dark and light columns and every cell's pick from its whole cell's ink (edge cells included, predicted here) in `DitherKernelTests`, the rows, ends, steps, field and unit in `DitherSheetTests`.
- Select › Color Range (13.3b, Swift 1.3.4: `c3e360a`): `kernels/WandPixels.c/.h` are v1.3.4's, with `color_range_mask`. `Document/ColorRangeSelection.h/.cpp` twin Swift's file, `ColorRangeSelection+Session.h` its session half; `ColorRangeEdit` is a class in the session's `std::optional`, its `id` standing for Swift's object identity, its `image` the live composite at document size (`selectionSample` with every layer, premultiplied), so the kernel reads it directly where Swift draws it into a context first. `colorAt` averages the 3 × 3 pixels round the floored point, those past the picture clear as in Swift's context, and unpremultiplies with Swift's rounding; a point off the picture, clear or no number samples nothing. Each update is a worker of its own (`QtConcurrent::run` and a watcher a change, as Swift starts a task each) and lands only on the same edit, generation and document; it says so at once and again when its match lands, which sets the selection without a step (antialiased as the selection tools are) or, on a `MagicWandError` or `ExportError`, keeps the selection, shows the error and logs it on `omaphoto.app`. The preview is the mask as `Grayscale8` scaled smooth to twice the panel's fit, where Swift draws it at medium quality. OK applies the match as one `Color Range` step through `setSelection` (or `deselect` for a match of nothing) and always announces the close; with no colour or an error it keeps the old selection without a step; Cancel puts the old selection back. The gates (`canEditLayers`, `canUseHistory`, `canStartProjectOperation`) wait for the panel, and closing it resumes file requests (Swift's `didSet`; on opening it is a no-op, since an open panel blocks every waiter, and is left out). The sheet's writes (`setColorRangeSampleMode`, `setColorRangeHeld`, `setColorRangeFuzziness` clamped whole to 0–200, `setColorRangeInvert`) notify, an unchanged one silent. On the canvas Color Range is `picking`; a left press, Space aside, samples with Shift adding and Alt taking away (Alt first, as Swift's switch reads them) and refocuses the panel, after Levels' checks; the eyedropper cursor carries a black + or − on a white disc bottom right (`eyedropperCursor(ratio, badge)`) for the held key or the chosen eyedropper, and `syncPicking` redraws it when either changes; Shift and Alt reach `setColorRangeHeld` through the modifier monitor. Swift's `keepColorRangeCursor`, `pointerOverCanvas` and cursor-update monitor work round AppKit's cursor rects and have no twin. `UI/ColorRangeSheet` twins Swift's sheet: the three eyedroppers (`UI/SampleButton`, moved unchanged from the Hue/Saturation sheet so both share it), lit for the effective mode; the preview (black, the picture, a 20% white rim, the canvas's shape within 292 by 200); the caption in `PlaceholderText` (Alt for Option); Fuzziness as a scrubbable title, a slider and a 48-point field; Invert; the error in `BrightText`; Cancel and the default OK; 340 wide, 24 of padding. `ContentView` shows it in `colorRangePanel` while an edit is open, its close button and Escape cancelling, and the Select menu's Color Range… follows Subject, enabled by `canSelectColorRange`. Swift has no tests for this commit: `ColorRangeTests` pins the kernel, the sampling, the modes, Fuzziness, Invert, the preview, OK, Cancel, the gates, the announcements and an outline too detailed (a 2100-pixel checkerboard); `ColorRangeCanvasTests` the canvas's clicks and keys, the badged cursors, the sheet and the panel with its menu entry; `ColorRangeSheetTests` the sheet's layout, field, preview, Invert and error. Equivalent mutants: the no-colours branch's reset of the selection and preview (before any colour both hold those values already), the watcher's `deleteLater` (memory alone), the warning and `syncPicking`'s memory of the shown badge (it only spares cursor updates). Swift's `document` and `colorRange == nil` terms in `canSelectColorRange` are dead (`canEditLayers` reads both) and left out. Untested: the preview's two allocation checks and the worker's `ExportError` catch (only memory starvation reaches them), and the sheet's `releaseFocus` (without it the commit reads members that happen to live; no sanitizer runs).
- Dialog colours (13.4a, Swift 1.3.5: `c580a38`): Export JPEG's background for transparency and Canvas Size's custom extension open the app's picker, where they opened the desktop's `QColorDialog`. `ColorPickerTarget` gains `dialog` with its `dialogTitle` (`Color Picker (JPEG Background)`); the change Swift keeps in the session as `dialogColorChange` rides in `ColorPickerState::dialogChange`, so it goes with its picker. `openDialogColorPicker` refuses while a picker is open; `previewDialogColor` hands the dialog the working colour and changes nothing of the session's, so it is silent, as Swift's `@ObservationIgnored` closure; `closeColorPicker` tells it the chosen colour or the original. `pickingForDialog` keeps the canvas from picking, and the picker's sheet leaves out "Click the canvas to sample" for a dialog. `DialogColorSwatch` lives in `UI/ColorPickerSheet+Dialog.h/.cpp`, where Swift keeps it in `ColorPickerSheet.swift`: it derives from `SwatchButton`, whose header includes the picker's. It is drawn as the brush's swatch (34 by 18, radius 4, a white ring, a black rim), named by its title, opens the picker on a click, hands it each new working colour on `changed()` (Swift's `onChange`, the last colour remembered), and closes it, keeping the colour, when hidden (Swift's `onDisappear`: Canvas Size's swatch hides when the extension leaves Custom). The sheets close it before Cancel, OK and Export, OK first so an open picker's colour is the fill, and in their destructors, before their members go, since the swatch's change writes into them. Both sheets take the session; Canvas Size reads the palette from it. A window-modal sheet blocks its window's other windows, the picker's panel among them, where a macOS sheet leaves the app's panels free: `FloatingPanel::show` parents a panel to `QApplication::activeModalWidget()` while one is up, else to its owner's window, moving a panel made earlier across; a panel under a sheet dies with it and the next is the window's again. Judged: `908bed9` (Qt delivers `applicationStateChanged` on the GUI thread, so `RecentProjects` hops nowhere) and `158cd53` (`ProjectDigest` already hashes a local's bytes). Tests: `DialogColorPickerTests` (the session, the swatch, the panel over a modal sheet, the canvas), the sheets' swatches in `JPEGExportSheetTests` and `CanvasSizeSheetTests`, the panel over the real Canvas Size sheet in `ProjectResizeTests`. Equivalent mutants: the swatch's memory of the picker's colour, kept or not (a sheet ignores a colour it holds).
- Export JPEG's zoomable preview (13.4b, Swift 1.3.5: `ac6f309`): the exporter decodes the preview at full size, 8192 a side at most, so 100% shows the file's own pixels. `JPEGPreview` (declared in `UI/JPEGExportSheet.h`, defined in `JPEGExportSheet+Preview.cpp`, as Swift keeps it beside the sheet) is a `QAbstractScrollArea` of Swift's 560 by 330 frame: Fit, or a zoom from Swift's steps (25% to 800%, `step` a thousandth either side), 1 being one image pixel a device pixel (`shownSize` divides the export's size by the device pixel ratio, as Swift by `displayScale`, so a preview decoded smaller still spans it); an image smaller than the view sits in its middle; a new zoom keeps the view's middle on the same part of the image, or starts at the image's middle from Fit (Swift's `keepCentered`, whose `before.width > 0` guard is dead and left out); a left drag moves it (the open hand, closed while dragging, the arrow in Fit); the wheel scrolls; a left double click switches Fit and 100%; from 100% up it draws nearest, below 100% through an area-averaged copy kept per size (`QImage::scaled`, Swift's high quality; QPainter's bilinear filter aliases a big reduction), dropped only for a new image. Without mouse tracking Qt delivers no buttonless move, so a lost release cannot be seen, and a new press restarts the drag. The zoom reaches the sheet through a callback given at construction. The sheet's title row carries Fit (resting in Fit) and the two magnifiers (`LayerIcons`' `plusMagnifyingGlass` and `minusMagnifyingGlass`, redrawn on `PaletteChange`), each resting at its end, with Swift's help naming the key and the zoom; the title row sits 8 above the preview (Swift's `padding(.bottom, -8)`); the size, the result and the buttons share the bottom row, 12 apart. Swift's `previewZoom` hook routes the View menu's Fit Canvas, Actual Pixels, Zoom In and Zoom Out to the preview; a window-modal sheet blocks the window's menus, so the sheet binds those four keys itself as `QShortcut`s, as remapped (`ShortcutSettings::menu`), again on every remap, and no session hook is needed. The import error's second OK has no twin: the alert already holds one button. Tests: `JPEGPreviewTests` (each drawing against `QImage::scaled` references), `JPEGExportSheetTests`, `JPEGExportTests`' limit, the keys through the real menus in `ProjectExportTests`, device pixels in `HighDpiCanvasTests`. Equivalent mutants: the nearest branch from above 1 rather than from 1 (at 100% the averaged copy is the image itself), and dropping the averaged copy for an image already held (it only costs a new average).
- Snapping while drawing (13.5a, Swift 1.3.5: `d6e3e93`, `8b1369a`): `snappedPoint` and `snappedSelectionOffset` live in `Crop.cpp`, Swift's file. A drawn point moves each axis to the nearest crop target within reach (the first of the nearest, reach inclusive) and shows the lines met; a moved selection snaps its rounded box's edges or middle through `TransformSnap::offset`, an axis Shift holds left out; with snapping off, or no move begun, the input comes back and the lines go. The canvas's `snappedCorner` snaps a Marquee's start (a Lasso's never), a Marquee's dragged corner, a shape's start and dragged corner, ten view points' reach, and the selection drag; Ctrl, Swift's Control as the crop and the Move tool read it, draws and moves freely and clears the lines. Every release clears them, as Swift's `mouseUp`. Tests: Swift's `drawnPointsSnapToTheSnapToTargets` in `GuideTests`; a moved selection's offsets (rounding, a locked axis, a turned ellipse's own box) and the gestures at 100% and 200% in `SnapDrawingCanvasTests`.
- Grid Settings (13.5b, Swift 1.3.5: `1c819d0`, `db5eafa`): `LayoutGrid` is Swift's struct: spacing 2–4096 and subdivisions 1–64, never finer than a pixel, clamped as it is made, plain members where Swift has `let`, so it stays assignable (as `CanvasSizeDraft`); its lines are counted from the origin, so an uneven step stays on the majors. `GridAppearance` holds Photoshop's presets by Swift's raw values (`allGridPresets`, `rawValue`, `presetColor`, none for Custom), the styles (`allGridStyles`, `dashes` in screen points), Custom's colour and the majors' opacity (1% at least when drawn; subdivisions at 28/45 of it). Both are the person's: `ToolDefaults` gains `integer`, `text` (empty when nothing is stored) and their `set`s, and the session reads `LayoutGrid::stored()` and `GridAppearance::stored()` as its members are made, under Swift's keys (`gridSpacing`, `gridSubdivisions`, `gridColor`, `gridCustomColor` as hex, `gridStyle`, `gridOpacity`); a stored value that cannot be read is warned of on `omaphoto.app` and left at its default, where Swift falls back in silence. `setLayoutGrid` and `setGridAppearance` store and notify, silent when unchanged. Snap To Grid, the guide snapping and the overlay read the session's grid; the overlay draws the majors in the chosen colour, opacity and dashes (counted in pen widths for Qt) and the dotted subdivisions in that colour, and `GuidesShown` carries both, so a change repaints. `ProjectController::gridSettings` lives in `ProjectController+Resize.cpp` beside the other sheets, since the controller's own file would pass 500 lines: refused without a window or while a modal sheet is up (Swift's `attachedSheet`), it shows the grid, opens `GridSettingsSheet` in the window-modal `sheet` titled Grid, previews every change through the session, and on its answer puts back the grid's visibility and either keeps the settings or restores them; nothing is undone or saved, and the project is not busy. `UI/GridSettingsSheet` twins Swift's: the title, Color (a preset menu and the `DialogColorSwatch` "Grid Color"), Style, Opacity (a half-point scrub, a slider and a 48-point field with "%", stepped by arrows, clamped), a rule, "Gridline every" with "pixels" (a point's scrub) and Subdivisions (a fifth of a point's), the note (the step to two places, grouped as Swift's `formatted()`, or the limits in `BrightText`), Cancel, Restore Defaults (Custom's colour kept) and the default OK, resting while invalid; 360 wide, 24 in, 18 apart. A field takes any whole number, as Swift's binding, so OK rests until the grid is valid, and previews only valid grids. A picked colour becomes Custom's, remembering the preset it left so picking that colour again returns to it; a preset chosen from the menu ends that. A fix beyond Swift: the picker reports 8-bit colours, so Swift's comparisons miss presets that are no byte (Light Gray's 0.7): opening the picker on Light Gray turns the grid Custom at once. The twin compares both sides in the picker's 8 bits, since its Cancel hands back the exact original, so opening and Cancel both keep a preset. Left out as dead: Swift's reset of the pick in its return branch (returning to a preset ends the pick through the preset's own change, in Swift too) and `ToolDefaults::text`'s fallback (Swift's callers pass ""). Untested: the destructor's `releaseFocus` and picker close (without them the swatch's hide writes into members already gone; no sanitizer runs). Tests: `GuideGridTests` (Swift's four grid tests, the settings kept), `GridSettingsSheetTests`, the drawing in `GuideCanvasTests`, the flow in `ProjectResizeTests`, the entry in `ViewMenuTests` and `CompositorMenusTests`.
- Typing's undo and font previews (13.6, Swift 1.3.5: `ee68ba9`, `1bd1df4`). Swift's first commit lets the text view answer Undo and Redo itself, since the window's history took them; the twin's editor has answered them since 8.9b (the keys win `ShortcutOverride`, the menus reach the focused canvas's editor), so only Swift's case is added as a test: typing in a new box and Ctrl+Z through the real menus clears it in one step. The session's `previewFont`, `keepFontPreview` and `endFontPreview` twin Swift's: open text only, each face tried from the style as it was before the first (`m_fontPreviewOriginal`, Swift's `@ObservationIgnored` member) on the draft's selection, refused when invalid or unchanged, kept on a choice, put back on a close without one. `TypeFontPicker` draws each face's name in that face through `Qt::FontRole`, which the combo's menu delegate honours, at the menu's pixel size; a face that cannot draw the letters of its own name (`QRawFont`, the face itself and not fontconfig's fallbacks) keeps the menu's font, as Swift's symbol fonts do. Which faces can is worked out once on a worker when the first picker is made (Swift's `prepareStyledNames`), joined when the menu loads, a name still missing worked out then. The picker takes Swift's preview closure at construction: a highlighted face shows (the `(Multiple)` row does not), and every close posts the revert to the event loop, since Qt reports a choice after `hidePopup`; a choice keeps the preview first, which empties the original, so the revert finds nothing. Swift's `chose` flag does the same work twice and is left out as dead. Equivalent mutants: each preview built from the draft rather than the original (the selection holds still while the menu is open), `previewFont`'s validity term, Swift's too (`setFont` keeps a valid style valid), the worker's start-once guard and its join when the menu loads (without either the same answers are worked out again, costing only time), and the letters-only test of a face's name (every installed name in the dev image is letters and hyphens DejaVu covers); untested in the dev image: a face that cannot draw its own name (only DejaVu is installed). Tests: `TypeFontPreviewTests`, the Ctrl+Z case in `InlineTextDrawTests`.
- New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose sizes print as the fields' exact text, else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `omaphoto.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change (a test rewrites and deletes it under the digest). The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the staged `QuickLook` folder's `mkdir` failure (a fresh staged folder; the write after it would fail with its own message), and the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the digest unchanged when the folder is rewritten or deleted, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`.
- Tabs reorder and overflow (13.14, Swift `1faf7a0`): `UI/ProjectTabLayout.h/.cpp` twin Swift's file: `projectTabSpacing`, `ProjectTabSlot`, `ProjectTabPillSlot`, `ProjectTabOverflow` (`contentWidth()` for Swift's computed property), `projectTabOverflowLabel` and `projectTabOverflow`, widths a `std::map` read through `at` (every tab has one), the pill's width a `std::function`. Left out as dead: `span`'s empty guard (never asked of no tabs) and `contentWidth`'s pill fallback (a pill always has a tab after it). `ProjectWorkspace::moveTab` is Swift's, clamped, and emits `changed()` only when a tab moved. `ProjectTabStrip` is now a plain `QWidget` that takes the toolbar's free width (the spacer after it went; its size hint is every tab in a row, its minimum none) and places its children by hand from `projectTabOverflow` over its own width: the overflow pill at x 0 and the visible tabs at y 3, 28 high, each as wide as its button's size hint (so the unsaved dot's extra width counts, as before), hidden tabs hidden; it lays out again on `resizeEvent`, on the workspace's signal and on each tab's session signal (a title or a dot changes widths; a hidden button posts no layout request). The QScrollArea, its offset and `showFront` are gone, as Swift's scroller and reveal are; the selected tab is always shown by the pinning. `OverflowTabsPill` (a `QToolButton`, `projectTabsOverflow`) paints an inactive tab's capsule, the label "N more tabs" at 12 pixels medium and a drawn `chevron.down`, the label and chevron at half opacity while the workspace cannot switch (the capsule stays, as Swift's `.opacity` sits before its background); tooltip and accessible name are the label; its width is Swift's `projectTabOverflowPillWidth` (the label's ceiling plus 11, 4, 10, 11). Its press opens a `QMenu` (`projectTabsOverflowMenu`, deleted on close) four points below it, as `popUp(positioning:)` does, listing the hidden tabs in order, "• " before an unsaved one, a choice selecting; while the workspace cannot switch no menu opens. The reorder twins Swift's: `ProjectTabButton` watches its title button's left press, move and release in global coordinates (Swift's `.global` space) and reports `TabDragPhase::changed` once the press has moved three points and `ended` on the release; the strip's `Reorder` is `TabReorderState` (others, widths, compacted x, start, origin, translation, target), `nearestSlot`, `renderX`, `makeReorder` and `commitReorder` as Swift's (a neighbour closed mid-drag has no place, as Swift's `if let`s). A drag begins only while the workspace can switch and across at least three points, selects its tab first and raises it over the others (Swift's `zIndex`); it commits on release only if the workspace can still switch; the others ease to their places through a 150 ms `QPropertyAnimation` of `pos` (`OutQuad` for SwiftUI's `easeOut`), the dragged tab following the pointer at once, and every tab eases home when it ends. Qt can lose a release (a modal alert opened mid-drag, such as the Revert question `select` may raise, blocks the strip's window): Swift's `dragTimer` is `tabReleaseWatch`, a 100 ms `QTimer` running while a drag lasts, which ends the drag uncommitted once `QGuiApplication::mouseButtons()` holds no button, so the tabs ease home; a drag whose tab closes is dropped on the workspace's signal too. Swift's `guard reorder?.id == id` stays. Known difference: Swift's empty title bar beside the tabs drags the window (`TitleBarDragView`); Qt's toolbar is no title bar, so the strip's empty part does nothing. `NewTabDropSlot` has no twin, as before. Equivalent mutant: reporting `ended` on every release, a press that never moved three points included (with no drag begun, `ended` only lays the strip out again; Swift's `onEnded` fires once the gesture moved three points, begun or not, as the twin's does). Tests: `ProjectTabLayoutTests` (Swift's six, the bounds at exactly fitting and a point below, the pill's spacing included, a lone wide pinned tab, the pill's width asked by count), `ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments` (Swift's, plus its signal), `ProjectTabDragTests` (following the pointer, clamping, the gap, the slide's duration, curve and end, stacking, landing left and right and in place, a small or upward move as a click, a busy project at the press and at the drop, a tab closed mid-drag, a release lost under a modal alert ending the drag while a held one lasts past the timer, and another tab pressed before the timer looks moving nothing (the drags go through the window, so the application tracks the buttons), a three-point move and a move under another button, the overflow pill's label, place and width, the menu's entries, place and choice, pinning, the busy pill), and `ProjectWorkspaceViewTests` (overflow in the window; the strip ends before the zoom buttons). `ProjectTabStripTests::onlyANewFrontTabScrollsIntoView` went with the scrolling.
- Ungroup Layers and the mask pointer (13.15, Swift `1318f1e`, `6c3b9a5`, `6955a6f`): `EditorSession::canUngroupLayers` and `ungroupLayers` sit in `LayerGroups.cpp` (declared in `LayerGroups+Session.h`), as Swift's extension: the folder's direct children, their parent now the folder's own, take its place in the list in their order; `releaseDetachedClipping` then frees clips that no longer sit on their base; `finishOpacityEdit` ends a drag first; one `Ungroup Layers` step selects the children with the lowest active (none for an empty folder) and forgets the folder's collapse. Swift's `LayerHierarchy.validate` guard is dead and left out: unwrapping a folder only shortens parent chains, so nothing it refuses can appear; its `document` guard is `canEditLayers`'. The Layer menu gains Ungroup Layers (Ctrl+Shift+G) after Group Selected Layers, enabled by `canUngroupLayers`, and `ShortcutDefinition::all` Swift's "Ungroup Layers" entry (g, Cmd+Shift), so it can be remapped; the list's context menu shows it after Group Selected Layers for a right-clicked folder alone (Swift reads the clicked row, not the active layer), enabled by `canUngroupLayers`. `NativeLayerList::showMaskCursor` twins Swift's `showMaskCursor`: the duplicate pointer drawn over an eye in a 7.5 by 5.5 box at the hot spot plus (15, 18), haloed by sixteen white copies one point out, the image widened to hold it (29 by 32 at 1×), the hot spot the pointer's; Swift's `eye.fill` is drawn as paths (an almond of two quadratic curves, a ring cut round a filled pupil). Over a mask thumbnail with Alt, `cursorFor` shows it whatever `canEditLayers` says, as Swift's now does (13.12 brings the Alt-click that shows the mask alone). Equivalent mutants: the eye's black fill drawn after the arrows (it never meets an arrow pixel; the halo's order is pinned), and the context entry enabled by `canEditLayers` (the right-click makes the folder active, so `canUngroupLayers` says the same). Tests: `GroupSessionTests` (Swift's three ungroup tests; collapse forgotten, signals, a plain layer refused silently, a nested and an empty folder, an opacity drag ended, busy), `LayerContextMenuTests::aFoldersMenuOffersUngroup`, the menu entry and its gate in `CompositorMenusTests`, the shortcut count, Swift's chord and a remap of the entry in `KeyboardShortcutsTests`, the eye pointer's pixels, hot spot and the arrows over the eye in `LayerDragGestureTests`.
- Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike. The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it and when turned 2° at 1:1), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering mask under a preview that did not grow, and on a grown one the old bounds), `FilterSessionTests` and `FinishingFilterTests` Line truncated
- Blending (13.9a: `6dc2219`, `8ab32c9`, `5a8f6ce`, `e6c964b`). Held, proven: clipping stacks and live-mask coverage at device resolution (the twin's `LiveMaskRenderer` has no `bounds`: its group, alpha and coverage surfaces are the painter's device since 4.x, so Swift's `resolution` and `pixelSize` have no twin); an adjustment in any mode, over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`adjust`'s `blended` goes through `LayerRenderer::composite`, which hand-blends every mode QPainter lacks through `HslBlend`, so Swift's `SeparableBlend.blend` has no twin); a clipping stack composited in its base's mode in every mode (`drawComposite` hands `m_stackModes` to `LayerRenderer::composite`, the same path), on the canvas, in export and in Copy Merged alike. Ported: Soft Light by Photoshop's formula, as Core Image computes it (2bs + b²(1 − 2s) up to half, 2b(1 − s) + √b(2s − 1) above), in `SeparableBlend::channel`, and `needsSurface` names Soft Light, so `HslBlend` blends it by hand everywhere a layer, a stack or an adjustment is drawn; `LayerRenderer::compositionMode` loses its Soft Light case (dead now). Compared first: Qt's `CompositionMode_SoftLight` is the PDF's (W3C) formula, which equals Photoshop's below a source of half and above it wherever the backdrop passes 0.25; under that it takes the PDF's cubic for √b and runs up to 17 levels darker (backdrop 5/255, source 1: 19 where Photoshop gives 36). Swift's 0.9 over 0.5 gives 170 under either formula. Known difference: Swift's Core Image is "within 5 levels of Photoshop's"; the twin is Photoshop's own formula, so it may differ from Swift's by those few levels. Tests: `BlendingTests` (Swift's `clippingStacksBlendInTheirBasesMode` for Linear Dodge, Color Dodge and Multiply: Swift's numbers, Copy Merged equal to export, the canvas within a level; adjustments in Difference and Linear Light over the document and Vivid Light and Subtract inside a stack, by the numbers, at full and half opacity, in all three places; Swift's `softLightMatchesPhotoshop` plus a dark backdrop and the lower branch; Swift's `softEdgedLayersBlendTheSame` for Soft Light, Overlay, Multiply and Color Dodge: a radial soft layer over noise, Copy Merged equal to export, the canvas within a level), `BlendModeTests::everyBlendModeMatchesThePdfFormulas` (its Soft Light reference is Photoshop's now; its backdrop of 0.1 under sources past half catches the PDF's cubic (at 0.25 the cubic equals √0.25)), `ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels` (a half-unit stripe stays sharp at a device scale of two, in a stack and through coverage from a source further down). Equivalent mutant: Soft Light's `source <= 0.5` for `< 0.5` (both branches give the backdrop at a half). Judgments: stacks and coverage at device resolution, adjustments in any mode and a stack in its base's mode were held (proven by the tests above); Soft Light ported. Mutants: 5 on Soft Light, 4 caught, one equivalent (above).
- The Metal canvas (13.9b: `cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`). OmaPhoto stays a CPU canvas (the port's decision): Swift's GPU canvas, its `GPUCanvas`, `GPUNoise`, `MetalWarp` and `CanvasLinesOverlay` and the GPU branches of `EditorCanvas`, have no twin; the twin's canvas is the Core Graphics canvas those commits compare against. Ported: Grain and Add Noise anchored to the document (`54dd707`): `LiveMaskRenderer::adjustmentRegion` (Swift's `adjustmentRegion`) maps what the painter shows into the document, and the canvas's `drawLayers` sets it from its centre mapping and zoom, so the smooth path counts noise in frame pixels from the document's corner and Grain in document pixels, as export does, and the crisp path's surface maps to document pixels, so a partial repaint keeps one field; the blur's scale still comes from the unmapped region. This retires 9.5b's and 12.8c's known differences (Grain and noise following the view, a partial repaint's own field); `CanvasDisplayTests::grainFollowsTheViewOnTheCanvas` is gone. Export and `drawLiveComposite` already draw in document pixels and set no region, as Swift's. Tests: `AdjustmentCanvasTests` (Swift's `matchesEveryAdjustment`: every kind through a ramp mask at 80% over a photo and a translucent layer, the canvas within a level of the export over its checkerboard; Swift's `patternsStayWithTheDocument` for Add Noise and Grain: equal to export at 100% and 200%, panned and not, and at 80% a whole-pixel pan moving the pattern with the image and a partial repaint showing the whole's pattern within two levels of resampling), `CanvasEditContractTests` (Swift's `aNewMaskRevealsTheWholeLayer`, upright and turned: a one-pixel mask on a scaled layer changes no covered pixel, the canvas as the export; `matchesWhileMovingPixels`, moving and duplicating on a masked layer at 60% under a soft selection: the preview equals the commit and the export outside the ants, the hole only for a move; `matchesWhileDraggingAGradient` in a selection at 70%: the pending gradient equals its commit outside the overlays, nothing outside the selection; `5708ed3`'s failed fill cancelling with `brushError`, no step, the document unchanged; `matchesALiveMaskOutsideAClippingStack`: a turned source with a layer between, the canvas as the export), `CanvasContractFixtures.h` (the scene, the export over the canvas's checkerboard, a gap that skips the overlays' bands, the ramp mask). Judgments, each Swift contract held unless named above: `cdf8674`: rotation, flips, scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom, sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on pixel layers, filter, Levels and Hue/Saturation previews in the layer's place: held; the CPU paths are the reference and are pinned by `LayerRendererTests` (placement, sampling rules, halvings, overhang), `DownsampleTests`, `BlendModeTests`, `CanvasDisplayTests` (checkerboard, shadow and border, crisp from 200%, the rich scene against the export), `FolderOpacityTests`, `FolderMaskClipTests`, `MaskPlacementTests`, `LevelsCanvasTests`, `HueSaturationCanvasTests`, `VignetteCanvasTests`. `4b04323`: a moved or duplicated selection's preview and commit: held and now proven on a masked, translucent layer (above); the rounded offset is pinned by a move of 5.6 landing at 6 (`EffectsCanvasTests::movingPixelsKeepTheEffects`). Swift's laziness (`PixelMove.applyOffset`, `holed`, `liftedTarget`) only spares the GPU canvas a rebuild per move; the twin rebuilds eagerly in `movePixels`, which realises the tiles before display and commit by construction, and a failure there cancels the move with `brushError` (`PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels`). `5708ed3`, `91cb0b1`: live paint, erase and mask paint, paint past the old bounds under the old mask (`TiledStrokeTests`, `BrushPreviewTests`, `MaskPaintAnywhereTests`), Smudge and Liquify previews through the mask, opacity and placement (`SmearCanvasTests`, `FolderOpacityTests`): held. `GradientEdit.Fill` and `applyFill` are the GPU's laziness; the twin fills in `refreshGradient`, before display and commit by construction, a failure cancelling with `brushError` (now tested), and the commit publishes what was filled. `MetalWarpTests.matchesTheCPU`: the twin's `WarpStroke` is the CPU reference (`SmudgeLiquifyTests`); nothing to port. `93de9e7`: a one-pixel white mask revealing its layer: held (QPainter's smooth draw clamps an image's edge, as Core Graphics draws it; now tested); the live paint grid's extent and edge sampling: held (`TiledStrokeTests`, `DownsampleTests`, `TiledLayerTests`). `clampedToExtent` is GPU-only. `54dd707`: every adjustment kind with alpha, masks and opacLine truncated
- Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors.
- Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. Like the brush, the gradient's refusal while the editor is busy is none, which clears a stale alert where Swift's first guard returns untouched. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal, and the order at each seam that two reasons can share: two layers before a folder, a folder before its hiding, hiding before a mask turned off, an adjustment before an empty selection, a folder's or adjustment's mask past the folder or adjustment; the mask-off and adjustment tests exclude each other, so their order is unreachable; a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the bar's Radius (13.16; Swift's tenth of the brush, 1.5 to 30, at 13.11) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first (Swift's `if let` on the mask and its `?? 0` for a missing document are dead, since the mask's asset is the sample and a stroke needs a document, and left out), softened through `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, in the pieces the brush reaches since 13.16, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheRadius`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeLine truncated
- Masks as Photoshop's (13.12, `b3419ab`): `addMask` with a selection now reveals it (white inside, black outside, the coverage as drawn) and, `revealing` false, hides it (the coverage inverted), one `Reveal Selection` or `Hide Selection` step; the row menu's Reveal All and Hide All call it as before, so Hide All with a selection hides the selection. `LayerMaskMenu` reads Alt from `QGuiApplication::keyboardModifiers()` when clicked, Swift's `NSApp.currentEvent`, so Alt-click adds the opposite, with Swift's help in Alt's words. The session keeps `viewsMaskAlone` (a getter and a setter that notifies on a change, the badge's write; `EditorSession.h`, defined in `EditorSession.cpp` beside Swift's `didSet`) and `maskAloneLayer()` (Swift's computed property: the view asked, the mask targeted, the active layer with a mask); `isMaskSelected` gains Swift's `didSet` as a private `setIsMaskSelected` (declared in `LayerMask+Session.h`, since `EditorSession.h` sits at 499 lines; defined in `EditorSession.cpp`), which every former write of `m_isMaskSelected` now calls, so targeting the pixels, another layer, a deleted mask or a load ends the view. `maskAloneLayer` and `toggleMaskAlone` live in `LayerMask+Alone.cpp`, since `LayerMask.cpp` already held 495 lines. `toggleMaskAlone` targets the mask through `selectLayerTarget` and shows it unless it already showed; where that refuses and leaves another target, so does the toggle, but with the project busy and the same mask already targeted the refusal is silent and the view still flips, as Swift's does. The canvas's `DisplayState` gains `maskAlone` (the mask asset's `ImageIdentity`, whether or not enabled, since the composite may skip it), and `drawLayers` hands off to `drawMaskAlone` (`EditorCanvas+Masks.cpp`, beside `liveFolderMaskClip`, which moved there to keep `EditorCanvas.cpp` under 500 lines): the canvas filled with the mask's edge tone, then a brush stroke or gradient laid into that mask through `LayerRenderer::drawBrushPreview` (the painted mask's own raster when it has one), else the mask at its displayed placement or the layer's transform, gray where it shows; the crisp zoom path draws through `drawLayers` too. The list's mask thumbnail: Alt's release without a drag calls `toggleMaskAlone`, only inside the thumbnail after a move under `startDragDistance` (4.7's rule; Swift's `dragMaskCopy` toggles on any left-up before a 3-point drag) (Shift no longer toggles there, as Swift's), the tooltip names Alt-click, and the targeted ring is white while that mask shows alone (`setTargeted`'s `alone`; Swift's `maskAloneLayer?.id == layerID` is left out as dead: the ring shows only on the active row, whose layer is the one shown alone). `LayerListFocusTests` and `LayerDragGestureTests` expected Alt-Shift to toggle the mask; they now expect Swift's view. `MaskAloneBadge` (in `UI/LayerMaskMenu`, Swift's file) is 26 points tall, 75% black with a 14% white rim, radius 13: the mask glyph at 11, "Layer Mask" 12 px semibold, the layer's name 12 px at 60% white, elided within 220 points, and an `xmark` button (a new `LayerIcon`, Swift's bold `xmark` as two round-capped strokes) with Swift's tooltip in Alt's words and accessible name, which ends the view. `ContentView` keeps one badge, shown while `maskAloneLayer` holds, in a layout nested in the canvas slot (it starts hidden through `synchronize`, which the constructor runs) (bottom, centred, 14 points up), so the room beside and below it stays the canvas's; Qt child widgets take their own clicks, so Swift's AppKit hosting has no twin. Swift's GPU fix (a mask stroke's grid starting at the mask's background) is the twin's behaviour already: `allocateTile` fills a mask tile with `maskBackground`. Tests: `MaskAloneTests` (Swift's three, plus another layer ending the view and the setter's signal, a stroke and a gradient shown as committed, a disabled and a placed mask, the crisp zoom, the badge's words, place, elision and clicks, the list's Alt-click, Shift ignored and the white ring; the view across undo and redo and when the shown mask is targeted again; a pending pixel gradient on the layer and another layer's pending mask gradient kept out of the view while their commits are posted), `SelectionEditTests` (Swift's renamed two and the scaled layer's hiding mask), `LayersPanelTests::theMaskButtonUsesTheSelection` (plain and Alt clicks with and without a selection, the tooltips, the row's Hide All). Mutants: the session 10 + 1 rerun (the active-layer term of `toggleMaskAlone` pinned by a busy refusal with another layer's mask targeted), equivalent: `maskAloneLayer`'s `m_isMaskSelected` and `layer->mask` terms (every path that clears either clears the view through `setIsMaskSelected`; Swift's guards, kept); `drawMaskAlone` 6 + 3 reruns (the placement pinned off the old box), the stroke's `isMask` and layer terms are live, not equivalent: `resolveGradient` only posts the commit, so a pending pixel gradient, or another layer's mask gradieLine truncated
- Quit mid-edit and Scanlines (13.17, Swift 1.4.4). Quit (`1aad384`): `ProjectWorkspace::settlePendingEdits` twins Swift's: on the tab on screen a pending gradient is applied (`commitGradient`), then a pixel move (`finishPixelMove`), then a filter, Hue/Saturation, Levels, Color Range, the Expand/Contract/Feather panel (`setSelectionAmountOperation(nullopt)`) and the colour picker are cancelled (their Cancel, so nothing un-OK'd lands). It takes a completion callback (Swift's `async`), run from the event loop once both commits have landed, and only while the workspace lives (`QPointer`), the tab kept alive in the callbacks as every workspace flow keeps it. `confirmQuit` refuses at once while the workspace manages (Swift's guard moved there from `applicationShouldTerminate`; the twin's window close reaches `confirmQuit` directly, so this guard is what stops a second close mid-quit), applies open text, settles, and only then asks `canSwitch`; a refusal there (a busy project, a commit still running) is a warning on `omaphoto.app` where Swift beeps. Swift's `gradientEdit != nil` and `pixelMove != nil` tests before awaiting are left out: both twins return (posted) when there is nothing to commit. Swift's `finishAdjustmentEditing(commit: false)` is left out as dead: `cancelFilter`, `cancelHueSaturation` and `cancelLevels`, each called before it, call it first, and an adjustment's editor is always one of those three (its request and original are set and cleared with them; while the editor still renders, `finishAdjustmentEditing` does nothing in Swift either, and `canSwitch` refuses the quit). Tests: `QuitPendingEditsTests` (the tab on screen settled and another left as it is; a quit refused while an adjustment's editor is asked for and not yet rendered, then cancelled once open; Swift's `quittingAppliesAPendingGradient` and `quittingCancelsAnOpenDialog`; a pixel move applied as one `Move Pixels` step; Color Range putting the selection back, the selection-amount panel, the colour picker not taking its colour; an adjustment's editor putting the settings back; a workspace gone mid-settle calling nothing; closing the window applying the gradient before the Save question; a busy project refused with the warning, nothing asked or applied; a second quit while the first asks refused unsettled). Scanlines (`9c99853`, `cd2f998`): `DitherStyle::scanlines` ("Scanlines (CRT)") joins the fourth group; `usesPixelSize` (false for ASCII and Scanlines: no chunky pixels, no Pixel Size or Pixel Shape rows) and `drawsMarks` without Scanlines, as Swift's. `DitherSettings` gains `lineSpacing` (2–32, rounded, 4), `glow` (0–100, 35), `dots` (0–100, 0) and `wobble` (0–64 px, unrounded, 0), normalized as Swift's; the kernel's cell is the line spacing for Scanlines. `kernels/DitherPixels.c/.h` are cd2f998's, with one adaptation: Swift's kernel threads through GCD (`dispatch_apply`) and Apple blocks, which GCC cannot compile. `in_bands` keeps Swift's split (one band under 64 items, else 32) but takes a C function and a context; the three block bodies (the tone pass, the scanlines, the glow) are moved word for word into static functions with context structs (`tone_job`, `scan_job`, `glow_job`), and `__block int failed` is a C11 `atomic_int`. The bands run through `dither_bands`, a C hook defined in `Document/Dither.cpp` on `PoolMap::blocking` (QtConcurrent, so a filter worker lends its pool place). The arithmetic is unchanged; this is a forced difference (GCC has no blocks or GCD), agreed with the coordinator. `bandsDrawAsOneBandDoes` holds 32 bands against one: the top rows of a 300-row picture equal a 62-row picture's, which runs as one band in both passes. `glowing` twins Swift's: sigma `lineSpacing × 3 + 3`, shrunk by `max(1, floor(sigma / 4))`, blurred at `sigma / shrink`, scaled back and added through `dither_glow` at `glow / 100 × 2.5`. Core Image clamps to the extent before it shrinks, so the twin pads the image by the blur's reach (three sigmas, a whole number of shrink steps) with its edges repeated, the far sides filled to a whole number of steps so the shrink and the enlargement are both exactly the shrink (`theGlowScalesBackExactlyOffTheStep`), shrinks that with `QImage::scaled`'s smooth filter (an area average, where Core Image samples bilinearly), blurs with the edge repeated (`PixelAdjust::gaussianBlur`), scales back bilinearly and crops the middle; it stays within three levels of light of Swift's first commit's full-size blur (`theGlowIsTheBlurredLightAtItsAmount`, both axes). The sheet adds Line Spacing (px), Glow (%), Dots (%) and Wobble (px) with Swift's help under Scanlines, and Pixel Size and Pixel Shape follow `usesPixelSize`. Tests: `ScanlinesTests` (Swift's four `DitherTests`; the beam's rows for white, 0.35 gray and a striped average; the style's flags and that pixel size, shape and cell size change nothing; the four settings' clamps, the rows' units and ranges read from the sliders, rounding and defaults; TwLine truncated
- Camera Raw curves as Photoshop's (13.18, Swift 1.4.5: `60d9117`): `kernels/AdjustPixels.c/.h` are 60d9117's: the tone curve runs on red, green and blue alike (`toneLut`, Swift's rename of `lumaLut`), and Refine Saturation means what Swift's now means: 0 the curve per channel, below 0 easing toward the colour scaled to the curved brightness (−100 brightness alone), above 0 pushing the curved colour from its luminance by 1 + the amount. `CameraRawCurveSettings::parametric` twins Swift's: unchanged with all four amounts at 0; otherwise 33 anchors, each `bend` of `bend` (shadows and highlights past the outer dividers, then darks and lights either side of the middle one, gamma curves of strength 1.66), run through the same curve as Image › Curves (`point`), so the regions meet without a corner. `bend` is a private static, as Swift's; `lumaTable` is `toneTable`. The twin's normalization of the dividers (5–90, +2 apart, 98 at most) still clamps what a drag writes. The graph's gestures (`UI/CameraRawColorControls+Curve.cpp`, beside the controls' file) are Swift's single `DragGesture`: a left press picks once (`parametricDrag`: within 18 points of the bottom the nearest divider, the first of equals; elsewhere the region whose tone range holds the press, a tone on a divider joining the region above; `pointDrag`: the point nearest by distance within 0.055, chosen; else a new point there, if fewer than 16, inside x 0.01–0.99 and over a hundredth from every point's x, sorted in, stored and chosen), then `continueDrag` runs at the press and on every left-button move: a point's output follows the pointer, an inner point's x stays a hundredth inside its neighbours, an end keeps its x; a divider follows, shadow below dark − 2 and dark below light − 2; a region's amount is its start minus the drag's height over the graph's times 200, within ±100, rounded. Left out as dead, since normalizing does each (`CameraRawCurveSettings::normalized`: amounts, dividers 5–90, +2 apart, 98 at most, and the repair's 0–1 outputs): Swift's 2–98 clamp of a divider, dark's `shadow + 2` floor and light's `dark + 2` floor, and the 0–1 clamps of a dragged or new point's output (a press inside the graph is inside 0–1 anyway). The new point's `x > 0.01` and `x < 0.99` are dead too: the ends sit at 0 and 1, and a point within a hundredth of any x is refused. `bend`'s `lower > 0` and `upper < 1` are dead (no tone lies below 0 or above 1). The release lets go. Qt can lose a release, so every press picks afresh. A double click is Qt's second press: it picks and drags as a press (Swift's drag sees it too), then on the point page removes the nearest inner point by x within 0.04 and drops the choice, and lets go of the drag (Swift removes at the tap's end, which ends the drag). Swift re-picks on each move while a press picked nothing; with the same start and points it picks nothing again, so the twin leaves that out. The chosen point (Swift's `selected`) draws in `Highlight`, the accent, and the readout shows only while it exists, following the drag; another channel drops the choice and the drag (Swift's `onChange`). The graph's help and Refine Saturation's are Swift's new words. Swift's `CameraRawMixerControls` gains the same `Drag` and `selected` state but never reads it: dead, left out. A fix beyond Swift: Swift holds an inner point exactly a hundredth from its neighbours, where the curve's repair (`x > kept.x + 0.01`) drops it (or drops the next point), and the drag's index then names another point; the twin holds it a billionth further inside, so the repair keeps it and the drag stays on it (`aPointIsPickedByDistanceAndKept`, `endPointsStayAndARealDoubleClickRemoves`, both failing before the fix; `CameraRawColorTests` pins the repair's strict `>` at exactly a hundredth, which the fix rests on). A fix beyond Swift: a preset's points drop the choice (TASKS asks for it; Swift's `applyPreset` keeps the index, which then lights whatever point holds it); Custom changes nothing and keeps it. Tests: `CameraRawCurveTests` (Swift's `parametricCurveIsSmooth`, `parametricCurveMatchesPhotoshop`, `curveDeepensColorLikePhotoshop`; each anchor against the bend written apart, and the smooth curve between; each region bending only its range, dividers at the ends bending nothing; the tone table through the point curve; Refine Saturation at 0 per channel through the table, −100 brightness with the hue kept, −50 halfway, +50 half again from luminance), `CameraRawCurveGraphTests` (a double click's 0.04 reach by x, and one in the divider strip moving the divider; regions lifted and lowered from their start, clamped and rounded, picked once by tone; dividers in the strip, nearest and first of equals, kept ordered and within the settings' limits; points by distance, kept through the drag, a new point added and dragged by the same press, ends moving their outputs alone; refusals near an edge, a point's x, at sixteen; the chosen point lit and read Line truncated
- Held keys, field values and resize snapping in the Move tool (13.13: `bca8f13`, `03d692c`, `686d8c7`, `c459f88`): `UI/HeldModifiers` twins Swift's class, a `QObject` singleton (`shared()`) with one signal, `changed()`. An application event filter reads key presses and releases and left-button presses and releases, Swift's `flagsChanged`, `keyDown`, `leftMouseDown` and `leftMouseUp`; a modifier key's own event sets or clears its bit, since Qt's event may not count it yet. Ctrl, Shift, Alt and Meta count (Swift's Command, Shift, Option, Control). Typing counts as none: Swift's `NSText` first responder is a focus widget with `Qt::WA_InputMethodEnabled`, so a `QLineEdit` and the canvas holding open text alike. `applicationStateChanged` stands for the two activation notifications (inactive clears; active reads `queryKeyboardModifiers`), and a `QMenu`'s `Hide` for `NSMenu.didEndTracking`. `TransformInspector` shows Auto Select as `transformAutoSelect != Ctrl` and the lock as `locksTransformRatio != Shift`, and a click while held writes the other way, as Swift's bindings; it synchronizes on `HeldModifiers::changed` too. Tooltips carry Swift's new words, Ctrl for Command. `transformPressLayer` follows Swift's: `autoSelect = transformAutoSelect != Ctrl`; Ctrl picks the layer under the pointer only with Auto Select off or with Shift (Ctrl+Shift adds either way). `TransformEdit.fromFields` sits last; `beginTransform(persistent, fromFields)` sets it, where Swift writes `transformEdit?.fromFields` in place (the twin's edit is read-only from outside). The bar's `change` opens a non-persistent `fromFields` edit; `finish` commits it, one step, when a field's scrub ends (`NumericScrub`'s `onEnd`) or the field loses focus. A focus borrowed by a menu, a popup or another window in front (`ActiveWindowFocusReason`, new: AppKit keeps the first responder when the app goes to the back) finishes nothing and keeps the typing. Escape in a field throws a `fromFields` edit away before handing the canvas the keys. The Swift path assumed: AppKit sends the Cancel button's Escape key equivalent (`configuredNativeShortcut(.escape)`) before the field editor, the only way `686d8c7`'s "Escape still throws the change away" can hold, since the field's own `onExitCommand` would end the focus and so apply the value. That path would also cancel a Ctrl+T edit from a field (and Return apply it); the twin keeps 6.1's reading there, a Ctrl+T edit waiting for the canvas's keys, a known difference held from 6.1. Flip H, Flip V and Sampling open a `fromFields` edit with no finish of their own, as Swift's do: it applies with the next field's finish, a canvas drag or anything that commits. `beginTransformDrag` commits a `fromFields` edit first, after `transformPressLayer` (so it picks nothing, as Swift's `picks`), so the two stay separate steps. Qt moves the focus before a press arrives, where SwiftUI's focus changes after `mouseDown`: a field left for a mouse press (`MouseFocusReason`) finishes from the event loop (a zero `QTimer`), so a typed value then a press over another layer drags the active layer under Auto Select, as in Swift (review 2's finding; `MoveBarPressTests`). `snappedResizePoint` lives in `Document/Crop.cpp` with Swift's: the edges each axis moves snap on their own to `transformSnapTargets` (the file's `nearest`, the first of ties), the nearer alone when proportional, the pointer nudged by one pixel's measured step; an upright layer only; guides set, or cleared when snapping is off, the drag is no resize or the layer turned. `dragTransform` snaps a resize unless Ctrl is held, proportional as `locksTransformRatio != Shift`. Dead or equivalent: `beginDistort` keeps `fromFields` where Swift's rebuild drops it, but a `fromFields` edit never reaches it (the canvas commits it before any handle drag). `snappedResizePoint`'s `before` read from `update(result)` or `update(point)` is equivalent (unlocked axes are independent, and proportional leaves one snap). The edge's tie (`<=`, the low edge) is reachable (the right handle half a pixel from its anchor, a one-pixel box) and held by `aTieTakesTheLowEdge`; the dragged handle's carried place by a drag past the anchor (`aLongDragSnapsTheEdgeItCarries`); Alt's centre resize by `altResizesFromTheCentreAndSnaps`. `QRectF::normalized` was dropped as dead (a resize keeps sizes positive and flips instead). Untested paths: the menu `Hide` and the activation's `queryKeyboardModifiers` (offscreen holds no real keyboard state). Tests: `MoveBarPressTests` (a typed value then a real press on another layer drags the active layer, two steps; Ctrl with a folder active and Auto Select on drags the folder past its box; a value left by a click in another field applies alone, one step), `MovePressFixtures.h` (the canvas fixture the three press files share), `TransformPressTests` (Swift's `commandFlipsAutoSelectWhileHeld` both ways and `commandShiftClickAddsALayerWithAutoSelectOn`, Ctrl outside the active layer, a fieLine truncated
- The Smear's 1.4.2–1.4.3 fixes (13.16, part one: `7164dd4`, `ad9ad7c`, `504129d`, and `095da2f`'s commit half): Liquify is Swift's new GPU stroke on the CPU, as the twin has no Metal: `WarpStroke` keeps the layer as it found it (`m_original`, a copy made in the constructor) and a source offset per pixel (`m_offsets`, two floats each, zero at first); each dab copies the offsets under it to `m_scratch`, reads them bilinearly from behind the brush's travel, less the travel times the weight, writes them back, and draws the pixel afresh from the untouched layer, bilinear, held to its edges, each byte rounded once: `warp_push` of `MetalWarp.swift`, float for float (bytes over 255, mixed, times 255). Swift allocates the two textures on the first push; the twin allocates them with the stroke, before any dab (nothing changes the copy between), so a failure is `ExportError(render)` where `beginWarp` catches it, where Swift's guard returns in silence; Smudge's carried colour, allocated at the first dab inside `beginWarp`, fails the same way, and a dab's scratch that finds no memory skips that dab with a warning on `omaphoto.rendering`, Swift's guard on its scratch texture (review 2: it ran outside any catch). The colour reads keep the shader's `size - 2` as they are: the offsets' read above needs two columns and rows of the dab's area (`ix`, `iy`), which a canvas a pixel wide or tall lacks, so no read leaves the image; the clamps first written here for such canvases were dead and are removed (review 2). Smudge is `ad9ad7c` in both of Swift's paths: dabs every 0.005 of the width (a pixel at least), each dab lays what it carries at weight × strength, and carries what it just left, nothing older. `BrushSettings.blurRadius` (5) is the Blur's own Radius: the sample's sigma follows the Radius (see part two for the merged formula). `BrushControls` shows Radius for the Blur mode alone, after Strength: a scrubbable label (a tenth a point, 0.5–50), a 100-point slider over 0.5–20 in hundredths (past 20 Qt rests it at its end, so Swift's `min(20, …)` is left out), and a 42-point `PickerField` with "px": a readable number applies on Return or leaving, clamped, a number that is not finite takes 5 (Swift's binding), other text goes back, arrows step one (Shift ten) from the session's value, clamped; it prints Swift's `fractionLength(0...1)`, never grouped. `BrushStroke::maxDiameter` is 2100, so `finishWarp`'s tip four wider than a 2000 brush is no longer refused; `finishWarp` appends a dab centre only every twentieth of the brush (the last always), as Swift's; the thinning is `WarpStroke::committedPoints`, so the test reads it directly (`aCommitKeepsATwentiethAndTheLastPoint`). Part two (`095da2f`'s Blur half, on 13.11's grid sampling, cc9bf95 merged into port-d): `BrushStroke::Clone` is 13.11's `(image, placed, inGrid)` plus `render`, a function that makes a part of `image` (Swift's `cloneRender`; Swift keeps it beside `clone`, the twin inside it, so one assignment sets both), and `clone` is private behind `clone()` and `setClone()`, which drops the pieces cut so far (Swift's two `didSet`s). `clonePixels(key, tile)` is Swift's `clonePiece`: the tile mapped into sample pixels through 13.11's chain (grid, `pixelToDocument` unless `inGrid`, then `placed` to the image's pixels, Swift's `scaleX` and `scaleY`), two pixels round, made whole outward (`toAlignedRect`, Swift's `integral`), held to the image; an empty part paints nothing on that tile (the fill is skipped, as Swift's `if let piece`); otherwise the piece is `render(part)` or a copy, cut once per tile key and kept in `m_clonePieces`, and the tile's sample is drawn from it through the same transform shifted by the piece's corner, so every path reads the pixels the whole image would give. A piece that cannot be allocated is `ExportError(render)`, thrown on the calling thread with `publish`'s other copies, where Swift's `cropping` returning nil paints nothing. `blurSample(stroke)` returns the sharp sample in the stroke's grid (13.11's `region`, `fit` and budget unchanged) with `render` blurring a part at `sigma × fit`, padded by the Gaussian's reach (`ceil(3σ)`) and cut back, clear past the sample for pixels (`QImage::copy` fills outside with zeros), its edges repeated for a mask (Swift's `clampedToExtent`), so a piece equals that part of the whole byte for byte (Swift allows one level). The Radius sigma is Swift's `min(min(50, max(0.5, blurRadius)) / perPixel, sidePixels / 2)`. Merge: port-c's direct `clone =` writes became `setClone` (`beginBrush`, `finishWarp`, `cloneSample`'s callers, the tests and `documentClone`), every `Clone` initialiser names `render` (GCC's `-Wmissing-field-initializers`), and port-c's Blur sample tests compare the sharp sample (`framed`) and its softening through `render` (`soft`), with each brush size's old sigma now set as the Radius. Equivalent mutants and untested paths for part two: a piece, a padded copy or a cut-back blur that cannot Line truncated
- 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 `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, 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.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
- `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.
- `Rendering/SeparableBlend` twins Swift's file for the modes QPainter lacks since 1.2.2: `needsSurface` names Linear Burn, Linear Dodge (Add), Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract and Divide, and `channel` is Photoshop's formula for each on one channel (in Vivid Light a zero divisor is infinity, clamped to the end; Divide by black is white, zero over zero included, a choice of the twin's, since neither Photoshop nor Core Image publishes it). Swift reaches Core Image's filters, in sRGB as Color Burn and Dodge have been since 1.2.0 (`121a15f`); Color Burn and Dodge themselves stay QPainter's, which follow the PDF formulas, as do its Hard Light and Exclusion (`BlendModeTests::everyBlendModeMatchesThePdfFormulas` holds all twenty-four modes within 2/255 against formulas written apart). Known difference: Core Image's code is not published, so its edges (Hard Mix exactly at one, a divisor of zero) follow Photoshop's definitions, not measured Core Image. Hard Mix is Photoshop's at full fill, white where backdrop and source reach one together.
- `Rendering/HslBlend` is Linux-only: the modes QPainter lacks (Hue, Saturation, Color and Luminosity, and since 1.2.2 `SeparableBlend`'s eight), blended by hand over a copy of the backdrop with the PDF's compositing, so every path that hands a mode to `HslBlend::handles` (the export, the canvas's surface, live masks, effects) takes the new ones.
- `Rendering/SeparableBlend` twins Swift's file for the modes QPainter lacks since 1.2.2: `needsSurface` names Soft Light (since 13.9a), Linear Burn, Linear Dodge (Add), Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract and Divide, and `channel` is Photoshop's formula for each on one channel (in Vivid Light a zero divisor is infinity, clamped to the end; Divide by black is white, zero over zero included, a choice of the twin's, since neither Photoshop nor Core Image publishes it). Swift reaches Core Image's filters, in sRGB as Color Burn and Dodge have been since 1.2.0 (`121a15f`); Color Burn and Dodge themselves stay QPainter's, which follow the PDF formulas, as do its Hard Light and Exclusion (`BlendModeTests::everyBlendModeMatchesThePdfFormulas` holds all twenty-four modes within 2/255 against formulas written apart). Known difference: Core Image's code is not published, so its edges (Hard Mix exactly at one, a divisor of zero) follow Photoshop's definitions, not measured Core Image. Hard Mix is Photoshop's at full fill, white where backdrop and source reach one together.
- `Rendering/HslBlend` is Linux-only: the modes QPainter lacks (Hue, Saturation, Color and Luminosity, and `SeparableBlend`'s nine: eight since 1.2.2, Soft Light since 13.9a), blended by hand over a copy of the backdrop with the PDF's compositing, so every path that hands a mode to `HslBlend::handles` (the export, the canvas's surface, live masks, effects) takes the new ones.
- 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`.
@@ -171,10 +198,11 @@ Deliberate name breaks: `MetalBrushCoverage` → `BrushCoverage`, `MetalLayerEff
## 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.
- 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); Swift keeps them in `GradientControls` and `BrushControls`, and the twin keeps them with the rail's other icons. The eyedropper's glyph lives in `Rendering/EyedropperIcon`, since the canvas's cursor draws it too. Icons that follow tool state (the ellipse marquee, the polygonal lasso, the Magic tool's object mode, the eraser) take a `ToolIconKind`. `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-noble_amd64.deb`, `omaphoto-resolute_amd64.deb`, `omaphoto.x86_64.rpm`, `omaphoto-x86_64.pkg.tar.zst`; 1.0.0 had one DEB, `omaphoto_amd64.deb`) with `SHA256SUMS`, so `releases/latest/download/<name>` always reaches the newest. `scripts/install/fedora.sh` and `arch.sh` read `/etc/os-release` (`ID` or `ID_LIKE`; Omarchy says `ID_LIKE=arch`) and `ubuntu.sh` its `UBUNTU_CODENAME`, which Ubuntu's derivatives set to their base: each DEB names its release's Qt libraries (24.04's carry a `t64` suffix that 26.04's drop, and Debian 13 has neither), so the release carries one DEB per LTS, built on it (`Dockerfile.dev` takes the base as `BASE`), and the script takes the one whose codename matches, `noble` or `resolute`, refusing any other. Each refuses another system or another machine than x86_64, 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. 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 at the newest release's tag, which GitHub's `releases/latest` redirect names, into the user's profile (`nix profile install`, the name every Nix still takes), so no user builds unreleased work from `main`. 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 DEBs on the dev image over each base, the RPM on Fedora 43, the oldest Fedora still supported, whose Qt symbols a later Fedora keeps, 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, and the workflow's first job refuses a tag that names another.
- Custom cursors (`QCursor` pixmaps) come out garbled on the hardware cursor path with NVIDIA and Hyprland at a fractional scale; theme cursors render correctly (issue #9, hyprwm/Hyprland#16413). README gives the software-cursor setting; the app keeps its cursors and does not guess at drivers (the user's decision, 13.1).
- Distribution (11.4b): releases live on Gitea (git.zacharyzhang.com) under fixed asset names (`omaphoto-noble_amd64.deb`, `omaphoto-resolute_amd64.deb`, `omaphoto-fc43.x86_64.rpm`, `omaphoto-fc44.x86_64.rpm`, `omaphoto-x86_64.pkg.tar.zst`, `omaphoto-x86_64.AppImage` since 1.4.5; 1.0.0 had one DEB, `omaphoto_amd64.deb`, and 1.0.0 to 1.2.3 one RPM, `omaphoto.x86_64.rpm`) with `SHA256SUMS`. Gitea has no `releases/latest/download`, so each script asks the API for the newest release's tag (`/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest`) and downloads from `releases/download/<tag>/<name>`. `scripts/install/arch.sh` reads `/etc/os-release` (`ID` or `ID_LIKE`; Omarchy says `ID_LIKE=arch`), `fedora.sh` its `ID` or `ID_LIKE` and `VERSION_ID`, taking the RPM of release 43 or 44 and refusing any other, since each names its release's LibRaw (`libraw_r.so.23` on 43, `.25` on 44; found in 12.16, when 1.2.3's single RPM from Fedora 43 would not install on 44), and `ubuntu.sh` its `UBUNTU_CODENAME`, which Ubuntu's derivatives set to their base: each DEB names its release's Qt libraries (24.04's carry a `t64` suffix that 26.04's drop, and Debian 13 has neither), so the release carries one DEB per LTS, built on it (`Dockerfile.dev` takes the base as `BASE`), and the script takes the one whose codename matches, `noble` or `resolute`, refusing any other. Each refuses another system or another machine than x86_64, 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. 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 at the newest release's tag (`git+https://…?ref=refs/tags/<tag>`) into the user's profile (`nix profile install`, the name every Nix still takes), so no user builds unreleased work from `main`. The README runs each script after downloading it, so the package manager's questions read the terminal and fish runs it too. `scripts/release.sh vX.Y.Z` (with `GITEA_TOKEN` set, the tag already pushed) builds each package from `git archive` of the tag with the command the distro checks use, the DEBs on the dev image over each base, the RPMs on Fedora 43 and 44 (`scripts/distro/fedora/Dockerfile` takes the release as `RELEASE`, 43 by default), the Arch package through `makepkg` as a user, the AppImage on the noble dev image (`scripts/distro/appimage`, linuxdeploy and its Qt plugin pinned by checksum; `scripts/appimage.sh` adds Qt's Wayland shell, decoration and graphics plugins and libheif's HEVC plugin, and `packaging/appimage/omaphoto.sh`, an AppRun hook, puts the AppImage's `usr/share` first on `XDG_DATA_DIRS` so the model is found, and sets `LIBHEIF_PLUGIN_PATH`), then writes `SHA256SUMS`, creates the release and uploads every file; the version is CMake's `project` version, the PKGBUILD's and the flake's, kept equal by hand, and the script refuses a tag that names another.
- 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.
+16 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.22)
project(omaphoto VERSION 1.3.3 LANGUAGES C CXX)
project(omaphoto VERSION 1.4.5 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -44,7 +44,9 @@ add_library(compositor_core STATIC
src/Document/AdjustmentEditing.cpp
src/Document/LayerAppearance.cpp
src/Document/LayerGroups.cpp
src/Document/LayerGroups+Order.cpp
src/Document/LayerMask.cpp
src/Document/LayerMask+Alone.cpp
src/Document/LayerTransform.cpp
src/Document/LiveLayerMask.cpp
src/Document/PixelAdjust.cpp
@@ -90,15 +92,19 @@ add_library(compositor_core STATIC
src/Document/SelectionEdits.cpp
src/Document/ShapeTool.cpp
src/Document/TypeTool.cpp
src/Document/TypeTool+Runs.cpp
src/Document/ColorRangeSelection.cpp
src/Document/SmudgeLiquify.cpp
src/UI/BlendModePicker.cpp
src/UI/BrushControls.cpp
src/UI/CropControls.cpp
src/UI/CanvasRulers.cpp
src/UI/CanvasSizeSheet.cpp
src/UI/GridSettingsSheet.cpp
src/UI/ImageSizeSheet.cpp
src/UI/TrimSheet.cpp
src/UI/JPEGExportSheet.cpp
src/UI/JPEGExportSheet+Preview.cpp
src/UI/ByteCounts.cpp
src/UI/SliderSnap.cpp
src/UI/GradientControls.cpp
@@ -107,6 +113,7 @@ add_library(compositor_core STATIC
src/UI/CanvasThumbnail.cpp
src/UI/ColorPaletteControls.cpp
src/UI/ColorPickerSheet.cpp
src/UI/ColorPickerSheet+Dialog.cpp
src/UI/CompositorMenus.cpp
src/UI/LayerAppearanceControls.cpp
src/UI/LayerIcons.cpp
@@ -115,6 +122,8 @@ add_library(compositor_core STATIC
src/UI/LayersPanel.cpp
src/UI/LevelsSheet.cpp
src/UI/HueSaturationSheet.cpp
src/UI/SampleButton.cpp
src/UI/ColorRangeSheet.cpp
src/UI/HueSaturationSheet+Spectrum.cpp
src/UI/CurvesControls.cpp
src/UI/NativeLayerList.cpp
@@ -125,9 +134,11 @@ add_library(compositor_core STATIC
src/UI/NativeLayerList+Menu.cpp
src/UI/NavigationToolHeader.cpp
src/UI/NumericScrub.cpp
src/UI/HeldModifiers.cpp
src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp
src/UI/CameraRawColorControls.cpp
src/UI/CameraRawColorControls+Curve.cpp
src/UI/CameraRawColorControls+Grading.cpp
src/UI/CameraRawColorControls+Mixer.cpp
src/UI/CameraRawControls.cpp
@@ -149,6 +160,8 @@ add_library(compositor_core STATIC
src/UI/RawDevelopSheet.cpp
src/UI/OmarchyTheme.cpp
src/UI/ProjectTabs.cpp
src/UI/ProjectTabs+Strip.cpp
src/UI/ProjectTabLayout.cpp
src/UI/ProjectWorkspaceView.cpp
src/UI/ToolHeaderStyle.cpp
src/UI/ToolIcons.cpp
@@ -195,6 +208,7 @@ add_library(compositor_core STATIC
src/Rendering/EditorCanvas+Gradient.cpp
src/Rendering/EditorCanvas+Cursors.cpp
src/Rendering/BrushCursorOverlay.cpp
src/Rendering/EditorCanvas+Masks.cpp
src/Rendering/EditorCanvas+Navigation.cpp
src/Rendering/EditorCanvas+Picking.cpp
src/Rendering/EditorCanvas+Selection.cpp
@@ -279,7 +293,7 @@ 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_PACKAGE_HOMEPAGE_URL "https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto")
set(CPACK_PACKAGING_INSTALL_PREFIX /usr)
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
set(CPACK_DEBIAN_PACKAGE_SECTION graphics)
+34 -13
View File
@@ -6,21 +6,24 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
## What it does
- Layers, folders, masks, clipping masks, blend modes and opacity; whole layers copy between projects
- Selections: marquee, lasso, magic wand and its object mode, Select › Subject, load from a layer, feather, expand and contract
- Brush, eraser, spot healing, clone stamp, smudge, blur and liquify, gradient, shapes, type with colour on selected letters
- Move, transform and distort layers and selections; rulers, guides, a grid and snapping
- Layers, folders, masks, clipping masks, blend modes and opacity; whole layers copy between projects; Ungroup Layers
- Selections: marquee, lasso, magic wand and its object mode, Select › Subject, Select › Color Range, load from a layer, feather, expand and contract
- Brush, eraser, spot healing, clone stamp, smudge, blur and liquify, gradient, shapes, type with colour and fonts on selected letters
- Move, transform and distort layers and selections; rulers, guides, a grid with its own settings, and snapping for layers, selections and shapes
- Levels, Curves, Hue/Saturation, Exposure, Black & White, Color Balance, Gradient Map, Grain, Invert, blur and noise, as edits or as adjustment layers
- Camera Raw Filter, docked beside the canvas
- Camera Raw Filter, docked beside the canvas, with Photoshop's curves
- Layer effects: stroke, drop shadow, outer and inner glow, color overlay, inner shadow
- Filters: Gaussian and motion blur, noise, lens correction, Vignette, Bloom / Glow, Tonal Contrast, Dither, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Crop, trim, canvas size, image size; PNG and JPEG export
- Crop, trim, canvas size, image size; PNG and JPEG export with a zoomable preview; New Canvas presets for screens and social formats
- Imports JPEG, PNG, TIFF, HEIC, SVG, Photoshop files (PSD and PSB) and camera RAW
- Open Recent, and a Keyboard Shortcuts window
- Drag a number's label to scrub its value
- Keep working while a project saves
- Project tabs that drag to reorder; those that do not fit gather in a menu
- A project another app changes on disk reloads in place; unsaved work is never replaced without asking
The [acceptance run](docs/acceptance.md) checks each of these in the app's own window, with screenshots.
### Works with AI agents
AI agents and scripts can build and edit projects directly: a `.comp` is a folder of PNG layers and a manifest, and an open project updates live as it's written. See [Writing OmaPhoto projects](docs/writing-comp-files.md) and the [project format](docs/project-format.md).
@@ -32,21 +35,21 @@ Each script installs the newest release. The Arch, Ubuntu and Fedora scripts dow
Arch and Omarchy:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/arch.sh && bash arch.sh
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/main/scripts/install/arch.sh && bash arch.sh
```
Ubuntu 24.04 or 26.04 LTS and systems built on them (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
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/main/scripts/install/ubuntu.sh && bash ubuntu.sh
```
Each DEB names its Ubuntu release's libraries, so other Ubuntu releases and Debian build from source (below).
Fedora:
Fedora 43 or 44:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/fedora.sh && bash fedora.sh
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/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.
@@ -54,12 +57,30 @@ Fedora's own libheif decodes no HEVC. For HEIC import, add [RPM Fusion](https://
NixOS installs the release's flake into your profile:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/nixos.sh && bash nixos.sh
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/main/scripts/install/nixos.sh && bash nixos.sh
```
Or add `github:ZacharyZhang-NY/OmaPhoto/<tag>`, a release tag, as a flake input and put its `packages.x86_64-linux.default` in your configuration.
Or add `git+https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto?ref=refs/tags/<tag>`, a release tag, 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).
The packages themselves sit on the [releases page](https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/releases). Any other x86_64 system can run the AppImage there, `omaphoto-x86_64.AppImage`: download it, `chmod +x` it and start it.
### Garbled cursors on NVIDIA with Hyprland
On NVIDIA with Hyprland at a fractional scale, the cursors OmaPhoto draws itself, such as those of the selection tools, the wand, the eyedropper, zoom and moving layers, can show garbled or cut off. Theme cursors render correctly. The fault lies in the hardware cursor path (reported upstream as [Hyprland #16413](https://github.com/hyprwm/Hyprland/issues/16413)). Software cursors avoid it.
Current Hyprland and Omarchy read `~/.config/hypr/hyprland.lua`; Omarchy users can put the line in `~/.config/hypr/looknfeel.lua`:
```lua
hl.config({ cursor = { no_hardware_cursors = 1 } })
```
An older setup that reads `~/.config/hypr/hyprland.conf` takes:
```
cursor {
no_hardware_cursors = 1
}
```
## Build from source
+108 -1
View File
@@ -398,7 +398,114 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun.
- 12.16 work: version 1.3.3 in CMakeLists.txt, the PKGBUILD and the flake; `versionupdate.md` says what changed since 1.2.3; README's feature list brought up to date. The full build (warnings as errors) and suite, their first since 12.8r, found two faults: `ProjectExportFailureTests` failed one run in three (the snapshot grew its record list, holding two at its peak; now reserved, with 2,048 layers and 5,120 KB limits, and `aSnapshotHoldsOneRecordList` pins the reserve), and `HueSaturationCanvasTests` read an edit that 12.14d2 now refuses over open text (the test checks the refusal, then the Type tool's double click without a draft). The NixOS check found the flake's test shell without shared-mime-info (no camera RAW type), added. Then 291 of 291 in the dev image, Arch, Fedora, NixOS and Ubuntu 26.04.
- 12.16 codex review: round 1 PASS (version guards, change log, README, both test fixes and their mutants); round 2 PASS (the flake's MIME path, and the installed package finding the database on NixOS and through `nix profile`).
- [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
- 12.16 release run: every job passed and published. The live installs (each script from GitHub in fresh containers) installed and started 1.3.3 on Ubuntu 24.04 and 26.04, Fedora 43 and Arch and refused Debian 13, but Fedora 44 refused the RPM: built on 43, it needs LibRaw's `libraw_r.so.23`, where 44 has `.25` (so 1.2.3's RPM had the same fault). Now one RPM per Fedora release, as one DEB per Ubuntu LTS: the Fedora Dockerfile takes `RELEASE`, the workflow builds `omaphoto-fc43` and `omaphoto-fc44`, `fedora.sh` takes the one for `VERSION_ID`, `distro-check.sh fedora44` passed 291 of 291. The published tag stays: the release gained `omaphoto-fc43.x86_64.rpm` (the CI build, renamed), `omaphoto-fc44.x86_64.rpm` (built from `git archive v1.3.3` with the workflow's command) and new sums, and keeps `omaphoto.x86_64.rpm` for the old script.
- 12.16 round 3: FAIL, one finding: `fedora.sh`'s combined pattern refused a derivative whose `ID_LIKE` ends in `fedora`. Fixed (identity and version checked apart); the reviewer's own probe of eleven identities now passes each. Closed after the third round.
- 12.16 live installs after the fix (`live-installs-1.3.3.log`, `live-nixos-1.3.3.log`): each script from GitHub in a fresh container verified its package, installed it and started 1.3.3 with the bundled runtime on Ubuntu 24.04 (Qt 6.4.2) and 26.04 (6.10.2), Fedora 43 (6.10.3) and 44 (6.11.2) and Arch (6.11.2); Debian 13 was refused; `nixos.sh` resolved `v1.3.3`, built its flake and started 1.3.3 on Qt 6.11.2.
- [x] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
- 11.5 work: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window through one scenario per README feature row (menus, canvas gestures, sheets, the real panels) and check pixels, layers, sizes and files; `docs/acceptance/` holds sixteen screenshots and `docs/acceptance.md` the MVP acceptance table, with the distro checks and live installs of 12.16. The README links the table (the README is the Linux app's own since 11.4b, so its Install and Build sections are the Linux section). The run found a crash: destroying the window while the Keyboard Shortcuts panel held the keys (so quitting with it active) reached the half-destroyed menus; `~CompositorMenus` now stops listening first. 295 of 295 in the dev image.
- 11.5 round 1 (codex): FAIL. A sibling crash: a floating panel's typed field commits as the window goes, and the session's signal reached the window's toolbar and tab strip, already destroyed (colour picker, Levels, Hue/Saturation, a filter); `~ProjectWorkspaceView` now disconnects its session watch first, `WindowTeardownTests` holds the four cases. Also: the reload scenario did not check that the app asked (a bypass mutant passed), the documented command hid an early failure, unchecked lookups and `->` on optionals, the scrub row's screenshot taken after the reload, a stale Tool icons bullet and two table wordings. All fixed; each fix's mutant caught.
- 11.5 round 2: PASS, no findings.
## 13 Compositor 1.4.5 (the user's order, 2026-09-30: port through v1.4.5, release OmaPhoto 1.4.5; issue #9 gets its workaround documented)
Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) through 13.2's second round; from 13.2's third they run on Claude Code, Fable 5.1 at high effort (agent `fable-reviewer`, a split pane to the right; the user's order, 2026-09-30: codex was too slow). Each item names its upstream commits; release, feed and Xcode commits (`ab99b60`, `c28f827`, `4f11bb5`, `0c56c86`, `5809fe9`, `8294fc4`, `9e2894e`, `e2440b4`, `2fbf14f`, `de442f2`, `0d9986f`, `3b50e5d`, `ed9c280`, `0787b60`, `557d2f0`, `3a2798b`, `ec02559`, `2187cbf`, `086f163`, `250578e`, `38ec6f7`), review notes (`9593bf5`, `bc29006`'s note), the 1.3.3 feed (`aba3077`), README's line about the author's own history (`3b3f6ca`) and merges, trials included (`7b22492`, `9d3d1b7`, `359956b`, `5adf0b1`, `0a74187`, `4211695`, `e359885`, `7dec592`, `c8295cc`: none resolves a conflict, `git show --cc` is empty) carry nothing to port.
- [x] 13.0 Survey v1.3.3..v1.4.5 (94 commits) and split them into the items below; the two window-teardown crashes found in 11.5 are fixed on `main` (e94fd73) and ship with 1.4.5.
- 13.0 codex review (gpt-6.1-sol, max): round 1 FAIL (the Metal commits' shared CPU contracts unnamed, ordinary paint at the layer's resolution missing from 13.11, Camera Raw's graph gestures missing from 13.18); round 2 FAIL (more contracts from its inventory, the empty layer's document resolution); round 3 PASS.
- [x] 13.1 Issue #9: custom cursors garbled on NVIDIA + Hyprland at a fractional scale (the driver's hardware cursor plane, hyprwm/Hyprland#16413). README's Install section names the workaround, `cursor { no_hardware_cursors = true }`; the issue gets the answer. No app-side fallback (the user's decision).
- 13.1 review (codex-sol): round 1 FAIL (the config path: Hyprland 0.56 and Omarchy load `hyprland.lua`, the `.conf` block is the older form; an unproven reason for theme cursors; the cursor list not marked as examples); round 2 PASS. The reply posted on the issue.
- [x] 13.2 Version 1.3.4, part one: a font change applies to the selected letters, a mixed selection takes the face the menu shows, the menu says (Multiple) (`0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test), with the manifest and both format documents.
- 13.2 work: `LayerTextFontRun` and `fontRuns` with Swift's model (`fontNameAt` for `fontName(at:)`), the run members moved to `TypeTool+Runs.cpp`, each run's face as a layout format, format version 11, (Multiple) in the Type bar's menu; a fix beyond Swift keeps a folded base face. Tests: `TextFontRunsTests`, `TextFontControlsTests`.
- 13.2 review: round 1 (codex) FAIL: the folded face's parity unstated, and test gaps (overflow, start-of-text inheritance, newlines, an independent bold reference, editor geometry); round 2 (codex) FAIL: test gaps (neighbouring faces, size and tracking in layout, colour with faces, a missing location); round 3 (Fable 5.1) FAIL: test gaps (a negative location, a run starting at the span's end, fractional numbers, an unlisted face's own name). All fixed, each mutant caught; closed after the third round.
- [x] 13.3a Part two: Dither's ASCII as readable text (`2309a85`, the kernel and the sheet).
- 13.3a review (Fable 5.1): round 1 FAIL (test gaps: the face's divisor and the width's rounding at larger sizes, the coverage's mean, the Text Size row's ends, decimals and unit, the picked table over non-square cells); round 2 FAIL (the kernel's averaging window, height and width); round 3 PASS.
- [x] 13.3b Part three: Select › Color Range (`c3e360a`: `ColorRangeSelection`, `WandPixels.c`, the sheet, the canvas's sampling).
- 13.3b review (Fable 5.1): round 1 FAIL (the sheet's native keys unbound; test gaps: the click's rows and floor, stale and foreign results, the selection kept on error, the preview's truncation); round 2 FAIL (OK after an error, the antialiased flag; two dead gate terms noted); round 3 PASS.
- [x] 13.4a Version 1.3.5, part one: Export JPEG and Canvas Size take the app's colour picker (`c580a38`); `908bed9` and `158cd53` judged: Qt signals arrive on the GUI thread, and the digest already hashes a local's bytes.
- 13.4a review (Fable 5.1): round 1 FAIL (the swatch's minimize case, its white ring and radius untested); round 2 PASS.
- [x] 13.4b Part two: Export JPEG's zoomable preview and tidier layout, the View menu's zoom on it, one OK on the import error (`ac6f309`).
- 13.4b review (Fable 5.1): round 1 FAIL (the full-size preview passed Qt's 128 MB decode limit on large canvases, so they could not export; the kept middle's `min` untested); round 2 PASS.
- [x] 13.5a Part three: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`).
- 13.5a review (Fable 5.1): round 1 FAIL (test gaps: rounding, the targets without centres, a path's own box, the drag's reach when zoomed); round 2 FAIL (the nearer of two lines); round 3 FAIL (the nearer line neither first nor lowest). All fixed, each mutant caught; closed after the third round.
- [x] 13.5b Part four: View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`).
- 13.5b review (Fable 5.1): round 1 FAIL (the picker's Cancel turned Light Gray, Light Blue and Green into Custom, since only one side was compared in 8 bits; the drawing's dots, dashes, caps and 4-point threshold loosely tested); round 2 PASS. The CPU cap was lifted to all 32 cores during this item (scripts/mutate.py follows).
- [x] 13.6 Part five: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`).
- 13.6 review (Fable 5.1): round 1 FAIL (four survivors unrecorded; the code judged right against Swift); round 2 PASS.
- [x] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone.
- 13.7 port (agent): tests in `NewCanvasSheetTests` (presets, the check, the dots), `QuickLookPreviewTests` (the store's folder, load ignoring it, the JPEG byte for byte, sizes, the 50-megapixel bound, a failed render, a controller save) and the sync order in `ProjectReplaceTests`. Survivors recorded: the heading's stretch (equivalent); the scale and allocation null checks and the staged folder's `mkdir` check untested. No review yet.
- [x] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`).
- 13.8 port (agent): tests in `SharpCanvasTests`, `PSDAdjustmentTests`, `AdjustmentBandsTests`, `LayerOrderTests`, `PSDReaderTests`, `HueSaturationTests`, `FilterTests`, `FilterSessionTests`, `FinishingFilterTests`, `BlurNoiseAdjustmentTests`, `AdjustmentSurfaceTests`, `HueSaturationFailureTests` (it now fails at the copy). Survivors recorded: Saturation's clamps, the cube cache, the copy's bytewise branch, the bands' guard (equivalent); the mask grid out of memory untested. No review yet.
- [x] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`).
- 13.9a port (agent): Soft Light by Photoshop's formula (Qt's is the PDF's, up to 17 levels darker on dark backdrops); stacks, coverage and adjustments in every mode held and proven. Tests in `BlendingTests`, `BlendModeTests`, `ClippingStackTests`. Survivor recorded: `<= 0.5` for `< 0.5` (equivalent). No review yet.
- [x] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin.
- 13.9b port (agent): Grain and Add Noise anchored to the document on the canvas (`adjustmentRegion`); every other GPU contract judged held, gaps pinned in `AdjustmentCanvasTests` and `CanvasEditContractTests`; the grid over the border hairline left as a known difference. No review yet.
- [x] 13.10 Part three: Shift keeps moved pixels on a line (`60bde4f`); Ctrl+A selects the canvas from the Layers panel (`451281e`).
- 13.10 port (agent): Shift keeps a Ctrl-dragged selection's pixels on the longer axis; Ctrl+A from the Layers panel selects the canvas (`selectAllRows` gone). No review yet.
- [x] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`).
- 13.11 port (agent): `paintRefusal` says why a brush, gradient (a fix beyond Swift) or the Smear refuses; Inverse of everything deselects; Clone Stamp and Blur sample in the stroke's own grid (`Clone` as Swift's image, placed, inGrid; `gridRect`; `blurSample`'s budget); the held judgment holds (`NativeResolutionPaintTests`, no change to the grid or coverage); Cancel and Apply fade in only for a waiting edit; `sizeLabel`. No review yet.
- [x] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`).
- 13.12 port (agent): the mask button reveals the selection, Alt-click hides it; Alt-click on a mask thumbnail shows the mask alone (`viewsMaskAlone`, `toggleMaskAlone`, `drawMaskAlone`, a white ring, `MaskAloneBadge`); `liveFolderMaskClip` moved to `EditorCanvas+Masks.cpp`. No review yet.
- [x] 13.13 Part three: Ctrl flips Auto Select both ways and Shift the aspect lock, live in the bar (`bca8f13`, `03d692c`); Move bar values apply without Apply as one step (`686d8c7`); resize handles snap (`c459f88`).
- 13.13 port (agent): held Ctrl flips Auto Select both ways and Shift the lock, shown in the bar (`HeldModifiers`); Ctrl+Shift adds either way; the bar's typed, stepped and scrubbed values open a `fromFields` edit applied as one step when the field is left or the scrub let go, Escape throwing it away, a canvas drag applying it first; resize handles snap (`snappedResizePoint`), Ctrl free, a turned layer not; tests in `TransformPressTests`, `TransformInspectorTests`, `ScrubbableTransformTests`, `ResizeSnapTests`, `ResizeSnapCanvasTests`. Survivors recorded: `before`'s read. No review yet.
- [x] 13.14 Part four: tabs drag to reorder, the ones that don't fit gather in a menu (`1faf7a0`).
- 13.14 port (agent): tests in `ProjectTabLayoutTests` (Swift's six and the bounds), `ProjectTabDragTests` (drag, slide, stacking, landing, threshold, busy, lost release, a close mid-drag, the overflow pill and its menu), `ProjectWorkspaceTests` (`moveTab`) and `ProjectWorkspaceViewTests`. Survivor recorded: reporting `ended` after a click that never dragged (equivalent but after a lost release); `span`'s empty guard and `contentWidth`'s pill fallback removed as dead. No review yet.
- [x] 13.15 Part five: Layer › Ungroup Layers (Ctrl+Shift+G) and in a folder's context menu (`1318f1e`, `6c3b9a5`); Alt over a mask thumbnail shows the duplicate pointer with an eye (`6955a6f`).
- 13.15 port (agent): tests in `GroupSessionTests` (Swift's three and more), `LayerContextMenuTests`, `CompositorMenusTests`, `KeyboardShortcutsTests` and `LayerDragGestureTests` (the eye pointer). Survivor recorded: the context entry gated by `canEditLayers` (equivalent: the right-click makes the folder active); Swift's validation guard left out as dead. No review yet.
- [x] 13.16 Versions 1.4.2–1.4.3: Liquify keeps pixels sharp (`7164dd4`), Smudge without ghost copies (`ad9ad7c`), the Blur brush's own Radius (`504129d`), Blur, Smudge and Liquify on big canvases (`095da2f`).
- 13.16 port (agent): Liquify on per-pixel offsets from the untouched layer (MetalWarp's `warp_push` on the CPU), Smudge carrying only the last dab's pixels at the strength, dabs a pixel apart; the Blur's own Radius (`blurRadius`, the bar's row), its sigma Swift's `min(min(50, max(0.5, r)) / perPixel, sidePixels / 2)` in 13.11's grid; `blurSample` returns the sharp sample with `render`, blurring only the pieces the brush reaches, each tile's clone piece cut once; `maxDiameter` 2100 and the warp commit's thinned points; tests in `SmudgeLiquifyTests`, `WarpStrokeTests`, `LargeCanvasBrushTests` (Swift's `largestBrushCommits`, `blurPiecesMatchTheWhole`), `BlurBrushTests`, `BlurToolTests`, `BrushControlsTests`, `BrushTests`. Survivors recorded: buffer allocation for every mode. No review yet.
- [x] 13.17 Version 1.4.4: quit works mid-gradient or with a dialog open (`1aad384`); Dither › Scanlines (CRT) on all cores (`9c99853`, `cd2f998`).
- 13.17 port (agent): `settlePendingEdits` applies a pending gradient and pixel move and cancels open dialogs before Quit asks, `confirmQuit` refuses while managing; Scanlines (CRT) with Glow, Dots and Wobble, the kernel cd2f998's with GCD swapped for `dither_bands` on PoolMap (forced difference); tests in `QuitPendingEditsTests` and `ScanlinesTests`. Survivors recorded: the glow pad and edge mode, the band split. No review yet.
- [x] 13.18 Version 1.4.5: Camera Raw curves as Photoshop's (`60d9117`): the smooth parametric curve and the per-channel point curves, Refine Saturation's new meaning (0 the RGB curve, −100 brightness alone, above 0 stronger colour) and its words; the graph: a press picks a point, divider or tonal region once and keeps it through the drag, a region drag lifts or lowers that region, dividers move in the bottom strip and stay ordered, point mode picks by distance, adds and drags a new point at once, keeps inner points ordered and moves the end points' outputs alone, the chosen point lit and the readout following it, a new channel or preset clearing the choice.
- 13.18 port (agent): the fitted parametric curve, the tone curve per channel, Refine Saturation's new meaning and words; the graph's one drag (`CameraRawColorControls+Curve.cpp`); fixes beyond Swift: a preset drops the chosen point, a point dragged onto its neighbour stays and stays dragged; tests in `CameraRawCurveTests` and `CameraRawCurveGraphTests`. Survivor recorded: the all-zero shortcut. No review yet.
- [ ] 13.19 Release OmaPhoto 1.4.5: version, change log, README, AGENTS.md, the acceptance run, distro checks, packages, live installs, the acceptance checklist.
- Status 2026-10-02, stopped for a system reinstall. Main is 19d8737 plus this note, pushed; there are no other branches or worktrees. Items 13.2–13.18 are ported and merged; 13.19 is open. Nothing outside the repository survives the reinstall (`~/.cache/omaphoto-review`, `~/.cache/omaphoto-port`, the `omaphoto-dev` image, `../Compositor-spec`), so this note carries everything.
- Setup after the reinstall: clone https://github.com/robbietilton/Compositor into `Compositor-Ref` (ignored) and check out tag `v1.4.5`; `scripts/dev.sh build` rebuilds the `omaphoto-dev` image from `Dockerfile.dev`.
- Reviews: two of the ten the user allowed are used. Review 1 (13.7, 13.8, 13.9a, 13.9b, 13.14, 13.15) passed 13.8 and failed the rest on test gaps and stale AGENTS lines; review 2 (13.10–13.13, 13.16–13.18) failed on the same kinds. No crash or wrong output was found. Fixed and tested: all of review 1's, and review 2's for 13.17 and 13.18.
- Committed but neither built nor tested (stopped mid-work; two leftover mutants were reverted from it):
- 13.8: `SharpCanvasTests` pins `LayerRenderer::draw`'s `upright` term (a turned layer near 1:1 must filter) (`63e42ac`).
- 13.10–13.12 (`2190a1b`): tests for Shift's upward-dominant step (`SelectionCanvasTests`), `paintRefusal`'s untested seams (folder/hidden, hidden/mask off, mask off/adjustment, adjustment/empty selection; `PaintRefusalTests`), the mask-alone view across undo and redo and through `setIsMaskSelected` writes (`MaskAloneTests`), and AGENTS lines. Still open: the 12.5b bullet's Select All sentence, the 13.11 bullet's `blurSample` sentence and test names at HEAD, its "row in `NativeLayerListTests`" (the scaled row is in `NativeResolutionPaintTests`), the 8.6 bullet's `blurSample` term, `sizeLabel`'s unreachable "ties to even" claim, and 13.12's pending pixel-gradient case (a pixel gradient must not draw into the mask-alone view).
- 13.13 and 13.16 (`70318de`): a typed Move-bar value then a real canvas press (the field finishes from the event loop on `MouseFocusReason`; `MoveBarPressTests`), the folder case, Alt-centre and long-drag resize snapping and the edge tie (`ResizeSnapTests`, `ResizeSnapCanvasTests`, fixture `MovePressFixtures.h`), the dead one-pixel Liquify "fix" removed, a Liquify dab's scratch allocation caught (skips the dab with a warning), the Radius field's `inf`, `WarpStroke::committedPoints` with its test, `softened`'s mask flag removed, and AGENTS and TASKS lines.
- Known failures: at 81894f7 the full suite under `ctest -j16` failed `ColorRangeTests`, `GuideGridTests`, `GridSettingsSheetTests`, `TypeFontPreviewTests` and `GuideCanvasTests`, each of which passes alone: some state is shared between test processes (suspect `QSettings` or `ToolDefaults` keys). Find and isolate it; never loosen a check.
- Next, in order: build; run the commits above and fix them; run the full suite and fix the parallel failures; review 3 over every fix since 81894f7 (13.8 included); then this item: acceptance run, distro checks (arch, fedora, fedora44, nixos, resolute), the `v1.4.5` tag and release, live installs, and the acceptance checklist. The 1.4.5 version, change log, README and acceptance table (results pending) are already in.
- Resumed 2026-10-03 after the reinstall. The user's rule of that day replaces the ten-review allowance: every step below is reviewed by codex (GPT-6.1-Sol, max effort until 13.19g, high from then at the user's word) in a herdr pane before the next, at most three rounds a step. Steps, in order:
- [x] 13.19a The spec checkout is `Compositor-Ref` inside the repository (ignored by git, left out of the flake's source), at `v1.4.5`; AGENTS.md and TASKS.md say so.
- 13.19a review: round 1 FAIL (the flake copied the spec; no step for review 3's scope; the budget unstated; the status said done), fixed; the Docker context finding answered (no Dockerfile copies from it). Round 2 PASS.
- [x] 13.19b Build main in a fresh `omaphoto-dev` image.
- 13.19b review: round 1 PASS.
- [x] 13.19c `63e42ac` (13.8's `upright` pin) built, run, its mutant caught.
- 13.19c work: `SharpCanvasTests` passes; `transform.radians() == 0` set to `true` in `LayerRenderer::draw` and in `TiledLayerRenderer`, each caught by the turned rows (dropping `upright` inside `interpolation` leaves the parameter unused, which `-Werror` refuses).
- 13.19c review: round 1 PASS.
- [x] 13.19d `2190a1b` (13.10–13.12 tests) built and run; its open items closed: the 12.5b, 13.11 and 8.6 bullets, `sizeLabel`'s tie claim, 13.12's pending pixel gradient.
- 13.19d work: `MaskAloneTests`, `PaintRefusalTests` and `SelectionCanvasTests` pass. Mutants: the three reachable `paintRefusal` seams swapped, caught (mask off and adjustment exclude each other, an equivalent swap); the Shift axis test without either `abs`, without both, and `>`, caught; `drawMaskAlone` without its `isMask` or its layer term, caught; `setIsMaskSelected` keeping the view, caught. The 12.5b, 8.6 and 13.11 lines match the code. `sizeLabel`'s tie claim was false (1062.5 over 1000 pixels is exactly 106.25%): ties to even are now tested both ways (ICU's default rounding, which Swift's `.formatted` uses), `round` and `floor(x + 0.5)` caught.
- 13.19d review: round 1 FAIL: the 8.6 bullet's whole-sample blur timing, the 13.11 bullet's typed values as persistent, `->` on optionals in `MaskAloneTests`. Fixed; the same `->` in `CanvasEditContractTests`, `ImageFileDropTests`, `RawDevelopTests` and `SessionRecord.h` went too. Their run found `SelectionSignalTests` stale since 13.11 (Inverse of everything deselects, so the `deselect` after it is silent): the test now says so.
- 13.19d round 2: FAIL: other tests still read optionals through `->` and `*`, through aliases a text scan missed. Found by the compiler: in a throwaway container `std::optional`'s `operator->` and `operator*` were marked deprecated and every target built (`-O0`); 271 sites in 30 test files and headers, each now `.value()`, and the rerun finds none in `tests/` with every unit compiled. The full suite at `-j8` then failed `FilterSessionTests` and `SelectionFeatherTests` alone too: both still called `addMask(false)` to reveal a selection, the meaning 13.12 flipped (`0b0d36a`); now `addMask(true)`. 329 of 329 at `-j8`.
- 13.19d round 3: PASS.
- [x] 13.19e `70318de` (13.13 and 13.16 fixes and tests) built and run.
- 13.19e work: `70318de`'s tests pass. Mutants: the field's deferred finish on a mouse press, both ways, caught; `committedPoints` at twice the spacing, without its last-point term, without `kept`, caught, and its `<=` and its one-pixel floor survived until rows for exactly the spacing and a small brush were added, now caught. `WarpFailureTests` (new, a binary of its own) starves a Liquify dab's scratch (skipped with its warning), Smudge's carried colour and Liquify's offsets (each `ExportError(render)`); each guard's removal caught; the untouched copy's null check is equivalent under starvation (the offsets after it are twice its size). `blurSample` drops Swift's dead `if let` on the mask and `?? 0` for the document. The 13.16 line named `compositor.rendering`; it is `omaphoto.rendering`. 330 of 330 at `-j8`.
- 13.19e review: round 1 FAIL: the starvation tests took any `ExportError` kind; no test made the Move bar's deferred finish run by itself; AGENTS still called allocating Liquify's buffers for every mode equivalent. Fixed: both tests compare the kind (`encode` and `tooLarge` mutants caught), `aValueLeftByMouseForAnotherFieldApplies` clicks Y with no canvas press (dropping the deferred finish caught), the AGENTS line says caught.
- 13.19e round 2: PASS.
- [x] 13.19f The fixes since `81894f7` already tested (`382c9c2` for 13.9a and 13.9b, `6735aad` for 13.7, 13.14 and 13.15, `5c8e18c` for 13.17 and 13.18) reviewed: review 3's scope.
- 13.19f work: the three commits' tests pass. Mutants on their code: the tab strip's release watch (its button test, its start, its ending, its other-tab guard) and the glow's whole steps. The other-tab guard (Swift's `guard reorder?.id == id`) survived; `anotherTabPressedBeforeTheWatchMovesNothing` now catches it. The glow's rounded-up small sides are equivalent (the padded sides are whole steps).
- 13.19f review: round 1 FAIL: `aDoubleClickInTheStripMovesADivider` pressed twice at one place, so a double click that skipped the divider passed. Now the first click and the double click land apart; that mutant is caught.
- 13.19f round 2: PASS.
- [x] 13.19g The full suite under `ctest -j16`; the shared state behind the five parallel failures found and isolated.
- 13.19g work: eight full runs at `-j16` and `-j32` on 32 cores passed 330 of 330; the five failures of `81894f7` did not come back, alone or with the nineteen suites that share `QStandardPaths`' test folder, so their cause is not known (the old runs had a 12-core cap and bwrap). Under a 4-CPU cap at `-j16` a new one failed three of three: `AcceptanceTests::paintingTools`' rectangle made no layer. Diagnostics showed the gradient edit still pending when the drag began, with the project free: `finishRasterCommit` freed the project, then posted the continuation that clears the gradient, so for one turn of the event loop the edit refused every press (Swift's caller resumes from `await` in the same job). The continuation now runs where the commit ends, for the gradient, the pixel move and fills; their callers' callbacks stay posted. Round 1 of review found the same gap one level up, through posted callbacks that carry work: the floating selection installed after the clear, and a filter, Levels, Hue/Saturation or Invert begun after a pending gradient read whatever layer was active by then. With the continuation inline, both run where the commit ends; only early paths post. `CommitContinuationTests` posts a check as the project frees: the gradient gone, the move gone, the floating edit ready, a tool chosen then staying chosen, and each chained command keeping its layer; the two first were red before the fix, posting the continuation again is caught, and posting the gradient's or the fill's continuation is caught by the chained and floating rows. Then 331 of 331 at `-j16` and three of three under the 4-CPU cap.
- 13.19g stress past that: 2 CPUs at `-j32`, sixteen processes a core, failed six and eight suites, every one a time limit (waits of 5 to 20 s, QtTest's 300 s watchdog on `LargeCanvasBrushTests` and `LayerContextMenuEdgeTests`) but `SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError`, whose 512 KB limit then found room; it passes alone at 2 CPUs three of three and under the 4-CPU load, and its cause under that stress is not established. `dev.sh test` and the distro checks run the suite one test at a time.
- 13.19g review: round 1 FAIL (the floating selection and the commands chained after a gradient still had a gap); round 2 PASS.
- [x] 13.19h The acceptance run and its shots.
- 13.19h work: the acceptance run checked none of the seventeen rows new in 1.4.5, though the README says it checks each feature. `AcceptanceNewTests` (Color Range, Grid Settings, snapping; presets, Quick Look, Ungroup, tabs), `AcceptanceEditTests` (fonts and their preview, painting at a layer's resolution, a hidden layer's refusal, Blur's Radius, Liquify, Smudge, masks; Move's held keys and typed width, Soft Light and a Linear Dodge stack on the canvas against the export) and `AcceptanceFinishTests` (Camera Raw's curves, Dither's ASCII and Scanlines, the JPEG preview's keys, Quit with a gradient pending and with Levels open) drive the app's window for each. All six acceptance binaries pass, twice in parallel; the shots are taken again, two of them new (`17-tabs`, `18-mask-alone`); the table says what each new row's run checks, and every row but the distro and package rows reads pass.
- 13.19h review: round 1 FAIL: the font scenario emitted the menu's signals itself, Dither and Blur's Radius were set past their controls, the ASCII check took blocks for letters, the mask-alone view was not looked at, and seven rows said more than their scenarios (my rewrite of the table had not landed). Fixed: the font popup opened, hovered and clicked (`popupRow`, `choose`); Dither's style, characters and text size through its sheet and OK, Camera Raw through OK, the Radius typed in the bar, Ctrl+Shift+G and Ctrl+Z as keys, Color Range's OK, Save As from the menu; ASCII of \"@\" must keep holes (inked share under three quarters) and grow about twice at 28; the mask-alone canvas checked black and white; each row says what its scenario does. The reviewer's mutants (no popup, no style handler, blocks for letters, no mask-alone drawing) and a Radius field that writes nothing are each caught.
- 13.19h round 2: FAIL: the committed Camera Raw shot showed the zero-exposure preview, since the scenario waited for any preview. It now waits for the preview whose ground has brightened; the shot is retaken.
- 13.19h round 3: PASS.
- [x] 13.19i Distro checks: arch, fedora, fedora44, nixos, resolute.
- 13.19i work: first run failed on every newer Qt. Arch (6.11), Fedora 43 and 44, Ubuntu 26.04 (6.10) and NixOS (6.11) found two real bugs in the pickers: Qt 6.10 emits `highlighted` when a closed combo box follows its value, so the font menu started a preview as the caret moved and a later close put back text typed since; and it closes the popup on Escape without `hidePopup`, so Escape kept a font or blend preview. Fixed by previewing only while the popup shows and ending on the popup window's `Hide`, tested on both Qts with mutants caught. Two test fixes: `GridSettingsSheetTests` sets an English default locale (6.10's C locale groups nothing), and Dither's starvation test skips off Qt 6.4 as the other five do.
- 13.19i second run: all five pass, 334 of 334 each; the full suite passes on the dev image.
- 13.19i review: round 1 PASS (the Qt behaviour probed on 6.4.2, 6.10.2 and 6.11.2, six mutants caught).
- 13.19j work: `versionupdate.md` gains 13.19i's blend menu fix; `v1.4.5` tagged on main and pushed; the workflow's run is checked below.
- [ ] 13.19j Tag `v1.4.5`; the release workflow publishes every package.
- 2026-10-11: GitHub suspended the account; the project moved to Gitea (git.zacharyzhang.com), the workflow became `scripts/release.sh`, and releases gain an AppImage.
- [ ] 13.19k Live installs from Gitea in fresh containers.
- [ ] 13.19l The acceptance checklist.
## Skills by phase
+67
View File
@@ -0,0 +1,67 @@
# OmaPhoto 1.4.5 acceptance
The acceptance table for OmaPhoto 1.4.5, the Linux port of Compositor 1.4.5. Each row is a feature from the README. The rows under "New in 1.4.5" cover what changed since 1.3.3. A "pending" result waits for the release.
The acceptance run drives the app's own window, the way a person uses it. It chooses menu entries, drags on the canvas, types into sheets and answers the real file panels. Then it checks concrete results: pixels, layers, sizes and files on disk. The run is six test programs: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` for the table, `AcceptanceNewTests`, `AcceptanceEditTests` and `AcceptanceFinishTests` for the rows new in 1.4.5. They take the screenshots below when `ACCEPTANCE_SHOTS` names a folder.
```sh
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen -v "$PWD:$PWD" -w "$PWD" omaphoto-dev \
sh -c 'cmake -S . -B build && cmake --build build --target AcceptanceTests AcceptanceColorTests AcceptanceProjectTests AcceptanceNewTests AcceptanceEditTests AcceptanceFinishTests &&
for t in AcceptanceTests AcceptanceColorTests AcceptanceProjectTests AcceptanceNewTests AcceptanceEditTests AcceptanceFinishTests; do ACCEPTANCE_SHOTS=$PWD/docs/acceptance build/$t || exit 1; done'
```
The screenshots come from Qt's offscreen platform, so floating panels appear without the window manager's title bars. The run uses the built-in dark theme, except for the last row.
## Table
| Feature | What the run does and checks | Screenshot | Suites that hold the details | Result |
| --- | --- | --- | --- | --- |
| Layers, folders, masks, clipping masks, blend modes, opacity | Imports a photo and paints a layer. Clips it to the photo and sets Multiply at 60%. Masks the photo and groups both in a folder. Undo takes a step back. | [01](acceptance/01-layers.png) | `GroupTests`, `LayerMaskTests`, `LiveMaskTests`, `LayerAppearanceTests`, `LayerRendererTests`, `HistoryTests` | pass |
| Selections | Drags a marquee to within 2 px. The wand takes the flat ground. Select › Subject finds the red disc with the U²-Net model. | [02](acceptance/02-selections.png) | `SelectionTests`, `MagicWandTests`, `SelectSubjectTests`, `SelectionEditTests`, `ObjectSelectionTests`, `SelectionFeatherTests` | pass |
| Painting tools | Drags a gradient that fades to clear, a rectangle shape and a soft white brush stroke. Types "Hello OmaPhoto" on the canvas and colours "Hello" red. That leaves one colour run, committed with Ctrl+Return. | [03](acceptance/03-painting.png) | `BrushTests`, `GradientTests`, `ShapeToolTests`, `TypeToolTests`, `TextColorRunsTests`, `SpotHealingTests`, `CloneStampTests`, `SmudgeLiquifyTests` | pass |
| Move, transform, distort; rulers, guides, grid | Drags the layer with the Move tool. Transform Layer halves it, and Return applies. Rulers, a guide and the grid are shown. | [04](acceptance/04-transform-guides-grid.png) | `TransformSessionTests`, `DistortTests`, `GuideTests`, `FloatingSelectionTests` | pass |
| Adjustments as edits and as layers | Hue/Saturation turns the red disc green. An Invert layer inverts the view and leaves the pixels alone. A Curves layer opens its editor. | [05](acceptance/05-adjustments.png) | `HueSaturationTests`, `LevelsTests`, `CurvesControlsTests`, `AdjustmentLayerTests`, `AdjustmentEditorTests`, `ImageAdjustmentTests` | pass |
| Camera Raw Filter, docked | Opens in the dock beside the canvas. One stop of exposure previews, commits and brightens the ground. | [06](acceptance/06-camera-raw.png) | `CameraRawTests`, `CameraRawSessionTests`, `CameraRawPanelTests`, `CameraRawCanvasTests` | pass |
| Layer effects | A 12 px red stroke rings a shape outside its edge, and a drop shadow's sheet shows. The shape keeps its fill. | [07](acceptance/07-effects.png) | `LayerEffectsTests`, `LayerEffectsKernelTests`, `EffectsPanelTests`, `EffectsCanvasTests`, `InnerGlowTests` | pass |
| Filters | Dither leaves only black and white dots. Remove Background masks the photo around its subject: the centre stays opaque and a corner turns clear. | [08](acceptance/08-filters-dither.png), [09](acceptance/09-remove-background.png) | `DitherTests`, `FilterTests`, `FinishingFilterTests`, `SubjectRemovalTests`, `RemoveBackgroundTests` | pass |
| Crop, trim, canvas size, image size; PNG and JPEG export | Crops to 1000 × 600. Canvas Size widens it to 1400. Trim finds the photo's own 1200 again. Image Size halves it. Exports a PNG through the save panel and a JPEG through its sheet and panel, then reads both back at 600 × 300. | [10](acceptance/10-canvas-size.png), [11](acceptance/11-export-jpeg.png) | `CropTests`, `ImageTrimTests`, `ProjectResizeTests`, `ProjectExportTests`, `JPEGExportTests` | pass |
| Imports | Takes JPEG, PNG, TIFF, HEIC, SVG, a layered Photoshop file (it arrives as a folder) and a camera RAW file, which asks how to develop it. That makes eight layers with no import error. | [12](acceptance/12-raw-develop.png), [13](acceptance/13-imports.png) | `ImageImportTests`, `PSDImportTests`, `RawImporterTests`, `RawDevelopTests`, `SVGImportTests` | pass |
| Open Recent; Keyboard Shortcuts window | Saves a project, and Open Recent lists it. The shortcuts window opens over the editor. | [15](acceptance/15-shortcuts.png) | `OpenRecentTests`, `RecentProjectsCoreTests`, `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests` | pass |
| Scrub a number by its label | Dragging the Opacity title 40 points left lowers the layer's opacity. | [14](acceptance/14-scrub.png) | `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableSheetsTests` | pass |
| Keep working while a project saves | Another layer is added while a save runs. The file holds the state the save began with. | — | `ProjectSaveWhileEditingTests` | pass |
| Reload a project changed on disk | With unsaved edits open, another program renames a layer on disk. The app's alert asks, Revert is chosen, and the saved state shows with the new name. | [15](acceptance/15-shortcuts.png) | `ExternalChangeTests` | pass |
| Follows the Omarchy theme | Starts in the built-in dark. A Catppuccin Latte `colors.toml` written while the app runs retints the window. | [16](acceptance/16-omarchy-light.png) | `OmarchyThemeTests` | pass |
| Builds and tests on each system | The full suite, 334 binaries, passes on Ubuntu 24.04 (Qt 6.4.2), Ubuntu 26.04 (6.10.2), Arch (6.11.2), Fedora 43 and 44, and NixOS (6.11.2). Warnings count as errors. | — | `scripts/distro-check.sh` | pass |
| Packages and install scripts | Each install script runs from Gitea in a fresh container. On Ubuntu 24.04 and 26.04, Fedora 43 and 44 and Arch, it checks its package against `SHA256SUMS`, installs it and starts 1.4.5. On NixOS, `nixos.sh` resolves `v1.4.5`, builds its flake and starts 1.4.5. Debian 13 is refused, as intended. | — | `DesktopIntegrationTests`, `scripts/release.sh` | pending |
## New in 1.4.5
| Feature | What the run checks | Screenshot | Suites that hold the details | Result |
| --- | --- | --- | --- | --- |
| Select › Color Range | Samples the red disc on the canvas; the selection covers the disc, 400 pixels across, and leaves the ground. | — | `ColorRangeTests`, `ColorRangeSheetTests`, `ColorRangeCanvasTests` | pass |
| View › Grid Settings | Types a spacing of 100 in the sheet; Restore Defaults puts the grid back. | — | `GridSettingsSheetTests`, `GuideGridTests` | pass |
| Snapping for selections and shapes | A marquee and a rectangle shape drawn near a guide land on it; with Ctrl they do not. | — | `SnapDrawingCanvasTests`, `TransformSnapTests` | pass |
| New Canvas presets | Chooses Instagram Story from the ⋮ menu; the fields and the check follow, and Create makes 1080 × 1920. | — | `NewCanvasSheetTests` | pass |
| Quick Look preview | A saved project holds `QuickLook/Preview.jpg`, 576 × 1024, and its manifest keeps its four layers. | — | `QuickLookPreviewTests` | pass |
| Ungroup Layers | Ctrl+Shift+G on a folder leaves its layers where the folder stood, in order; Ctrl+Z brings the folder back. | — | `GroupSessionTests`, `LayerContextMenuTests` | pass |
| Project tabs reorder and overflow | Drags a tab past its neighbour; with many tabs, the "more tabs" menu switches to a hidden one. | [17](acceptance/17-tabs.png) | `ProjectTabDragTests`, `ProjectTabLayoutTests` | pass |
| Fonts on selected letters and previews | In the open font menu, the pointer over a face previews it on the selected word and Escape puts it back; a click gives the word that face; Ctrl+Z takes back typing. | — | `TextFontRunsTests`, `TypeFontPreviewTests`, `InlineTextDrawTests` | pass |
| Painting at the layer's resolution | Paints on a layer scaled to 25%: its pixels stay 800 wide and the stroke is 80 of them across. A hidden layer says why it cannot paint. | — | `NativeResolutionPaintTests`, `PaintRefusalTests` | pass |
| Liquify, Smudge and Blur | Liquify moves stripes and leaves few grays; Smudge leaves one fading trail; a Radius of 20 typed in the bar softens wider than 2. | — | `SmudgeLiquifyTests`, `WarpStrokeTests`, `BlurToolTests` | pass |
| Masks as Photoshop's | The mask button reveals the selection, Alt hides it; Alt-click on the mask thumbnail shows the mask alone on the canvas, black where it hides and white past it. | [18](acceptance/18-mask-alone.png) | `MaskAloneTests`, `SelectionEditTests` | pass |
| Move tool keys and values | Held Ctrl flips Auto Select; a typed width applies as one step. | — | `TransformInspectorTests`, `ResizeSnapCanvasTests` | pass |
| Blending and a sharper canvas | Soft Light and a clipping stack in Linear Dodge show on the canvas as exported. | — | `SharpCanvasTests`, `BlendingTests`, `ClippingStackTests` | pass |
| Camera Raw curves | Lifts a region of the parametric curve and adds a point on the point curve, then OK; the ground brightens. | — | `CameraRawCurveTests`, `CameraRawCurveGraphTests` | pass |
| Dither ASCII and Scanlines | In the Dither sheet, ASCII of "@" draws that letter, holes and all, about twice as tall at a text size of 28; Scanlines draws dark gaps between rows. | — | `DitherTests`, `ScanlinesTests` | pass |
| Export JPEG preview | The View keys zoom the preview to 100%, in, and back to Fit. | — | `JPEGPreviewTests`, `JPEGExportSheetTests` | pass |
| Quit mid-edit | Quits with a gradient pending: it is applied before the one question. With Levels open, Levels closes and the window goes. | — | `QuitPendingEditsTests`, `WindowTeardownTests` | pass |
## Known differences from Compositor
`AGENTS.md` lists each place where the port differs from the Swift app, and why. The main ones:
- Remove Background and Select Subject use U²-Net on ONNX Runtime, not Apple's Vision.
- Rendering is CPU only.
- There is no updater.
- A `.comp` project is a folder.
- A Wayland compositor places windows itself.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+5 -3
View File
@@ -1,10 +1,10 @@
# OmaPhoto project format, versions 1–10
# OmaPhoto project format, versions 1–11
OmaPhoto reads and writes [Compositor](https://github.com/robbietilton/Compositor)'s project format; this page follows Compositor's own description.
A `.comp` project is a folder (a document package on macOS) containing `manifest.json` and an `images/` directory of `<layer UUID>.png` assets.
The manifest identifies `com.compositor.project`, version `10` for new saves (versions `1`–`9` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
The manifest identifies `com.compositor.project`, version `11` for new saves (versions `1`–`10` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
Embedded PNGs preserve source pixels and transparency; transforms remain separate. Projects survive moving or deleting imported source photos. Saving writes a hidden sibling folder and syncs it. A first save renames it into place; a later save swaps it with the old project atomically (`renameat2` with `RENAME_EXCHANGE`). Where the file system knows no exchange, the old project is moved aside and the new one moved in, two steps with a moment between them and a warning in the log, and the old copy is kept until the new one is synced. Unsupported versions, invalid metadata, missing assets, unsafe paths, and oversized data are rejected before replacing the live document.
@@ -36,6 +36,8 @@ Version 9 adds three adjustment kinds that sample neighboring pixels: `Gaussian
Version 10 lets a text layer color some of its letters differently: optional `colorRuns` in its `text` metadata (see Editable text). Files declaring 1–9 cannot contain it.
Version 11 lets those letters use different faces too: optional `fontRuns` in the same metadata. Files declaring 1–10 cannot contain it. `colorRuns` stays valid from version 10.
### Additive layer fields
Later fields are optional and not gated on the version, so older readers ignore them and keep the pixels or the linked mask as they were:
@@ -45,7 +47,7 @@ Later fields are optional and not gated on the version, so older readers ignore
### Editable text
Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). Runs are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color.
Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). From version 11, optional `fontRuns` lists letters set in another face than `fontName`: the same `location` and `length`, plus `fontName`. Runs of either kind are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color or face.
### Layer effects
+4 -2
View File
@@ -7,7 +7,7 @@ An OmaPhoto project (`.comp`) is a folder of PNG layer images plus a `manifest.j
1. Open a project in OmaPhoto 1.3 or later (save a new canvas somewhere, e.g. `~/Pictures/demo.comp`), and keep it open.
2. Ask an AI agent that can edit files on your computer (Claude Code, Codex and the like):
> Read docs/writing-comp-files.md in github.com/ZacharyZhang-NY/OmaPhoto, then design a moody night scene in ~/Pictures/demo.comp. Work in steps, one or two layers at a time.
> Read docs/writing-comp-files.md in git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto, then design a moody night scene in ~/Pictures/demo.comp. Work in steps, one or two layers at a time.
3. Watch the canvas. Each time the agent writes the project, OmaPhoto reloads it, usually within half a second.
@@ -28,7 +28,7 @@ A minimal manifest with one full-canvas image layer:
```json
{
"format": "com.compositor.project",
"version": 10,
"version": 11,
"colorSpace": "sRGB",
"documentID": "0C5E7A91-3B2D-4F6A-8E1C-9D0B7A6F5E4D",
"width": 1920,
@@ -83,6 +83,8 @@ To change an existing layer, keep its `id` and overwrite its PNG, then rewrite t
Remove images you no longer reference once the manifest no longer lists them.
A project OmaPhoto saved also has a `QuickLook` folder (`Preview.jpg`), which Finder shows as its Space-bar preview on a Mac. When you change a project, delete that folder, so Finder doesn't preview an out-of-date picture; OmaPhoto writes it again the next time it saves.
## What the open app does
- It reloads about a third of a second after writes stop. Several writes in quick succession arrive as one update, so pause briefly between steps if a viewer should see each one.
+3 -3
View File
@@ -13,11 +13,11 @@
in {
packages.x86_64-linux.default = pkgs.stdenv.mkDerivation {
pname = "omaphoto";
version = "1.3.3";
# dev.sh builds in ./build; the package starts clean.
version = "1.4.5";
# dev.sh's build and the Swift spec stay out.
src = pkgs.lib.cleanSourceWith {
src = self;
filter = path: type: path != "${toString self}/build";
filter = path: type: !(builtins.elem path [ "${toString self}/build" "${toString self}/Compositor-Ref" ]);
};
nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ];
buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ];
+16 -10
View File
@@ -729,7 +729,7 @@ static double point_weight(double h, double s, double l, const float *point) {
}
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
double refineSaturation, const float *mixer, int pointCount, const float *points,
const float *grade, double blending, double balance, int visualize) {
for (size_t y = 0; y < height; ++y) {
@@ -739,16 +739,22 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s
double alpha = p[3];
if (!alpha) continue;
double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
double tone = rec709(r, g, b);
double mapped = lut_at(lumaLut, tone);
scale_luminance(&r, &g, &b, mapped);
if (refineSaturation != 0 && tone > 1e-4) {
double factor = 1 + refineSaturation * (mapped / tone - 1);
double lum = rec709(r, g, b);
r = camera_clamp(lum + (r - lum) * factor);
g = camera_clamp(lum + (g - lum) * factor);
b = camera_clamp(lum + (b - lum) * factor);
// The tone curve works on red, green and blue alike, as Photoshop's does, so contrast brings color strength
// with it. Refine Saturation below zero eases toward changing brightness alone (−100), and above zero adds
// more color.
double curvedR = lut_at(toneLut, r), curvedG = lut_at(toneLut, g), curvedB = lut_at(toneLut, b);
if (refineSaturation < 0) {
double br = r, bg = g, bb = b;
scale_luminance(&br, &bg, &bb, lut_at(toneLut, rec709(r, g, b)));
double k = -refineSaturation;
curvedR += (br - curvedR) * k; curvedG += (bg - curvedG) * k; curvedB += (bb - curvedB) * k;
} else if (refineSaturation > 0) {
double lum = rec709(curvedR, curvedG, curvedB), factor = 1 + refineSaturation;
curvedR = camera_clamp(lum + (curvedR - lum) * factor);
curvedG = camera_clamp(lum + (curvedG - lum) * factor);
curvedB = camera_clamp(lum + (curvedB - lum) * factor);
}
r = curvedR; g = curvedG; b = curvedB;
r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b);
double h, s, l;
rgb_to_hsl(r, g, b, &h, &s, &l);
+2 -2
View File
@@ -48,12 +48,12 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride
// the radii match a full-size render. Grain is applied separately. Alpha is kept.
// Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only.
void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights);
// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256
// Curve, Color Mixer, and Color Grading after the basic grade. `toneLut` and the channel LUTs are 256
// entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is
// 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels
// of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color.
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
double refineSaturation, const float *mixer, int pointCount, const float *points,
const float *grade, double blending, double balance, int visualize);
void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride,
+157 -17
View File
@@ -1,9 +1,30 @@
#include "DitherPixels.h"
#include <math.h>
#include <stdlib.h>
#include <stdatomic.h>
// Linux: GCD's dispatch_apply, run on QtConcurrent by Dither.cpp.
void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context);
static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
typedef void (*band_body)(void *context, size_t start, size_t end);
typedef struct { size_t count, size; band_body body; void *context; } band_job;
static void run_band(void *context, size_t band) {
const band_job *job = context;
size_t count = job->count, size = job->size;
size_t start = band * size, end = start + size < count ? start + size : count;
if (start < end) job->body(job->context, start, end);
}
// Runs `body` over `count` items split into a few runs per core, each a (start, end) range, all at once.
static void in_bands(size_t count, band_body body, void *context) {
size_t bands = count < 64 ? 1 : 32, size = (count + bands - 1) / bands;
band_job job = { count, size, body, context };
dither_bands(bands, run_band, &job);
}
// Density darkens (positive) or lightens as a gamma, so black and white stay put; contrast pivots on mid gray.
static inline float adjust_tone(float v, float gamma, float contrast) {
v = powf(clamp01(v), gamma);
@@ -114,19 +135,17 @@ static inline void write_pixel(uint8_t *px, float r, float g, float b) {
px[2] = (uint8_t)lroundf(clamp01(b) * a * 255.0f);
}
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
size_t count = width * height;
if (!count) return 1;
int planes = p->originalColors ? 3 : 1;
float *tone = malloc(count * sizeof(float) * (size_t)planes);
uint8_t *alpha = malloc(count);
// The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode.
float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL;
if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; }
typedef struct {
uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone, *source; const DitherParams *p;
float gamma, contrast;
} tone_job;
float gamma = exp2f(p->density * 1.5f);
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
for (size_t y = 0; y < height; ++y) {
static void tone_band(void *context, size_t first, size_t last) {
const tone_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count;
uint8_t *alpha = job->alpha; float *tone = job->tone, *source = job->source; const DitherParams *p = job->p;
float gamma = job->gamma, contrast = job->contrast;
for (size_t y = first; y < last; ++y) {
const uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
@@ -147,6 +166,90 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
}
}
}
}
typedef struct {
uint8_t *rgba; size_t stride, width, height, count; int planes; const uint8_t *alpha; const float *tone;
const DitherParams *p; size_t spacing; float middle, dots; const float *screen, *phosphor; atomic_int failed;
} scan_job;
static void scan_band(void *context, size_t firstLine, size_t lastLine) {
scan_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, height = job->height, count = job->count;
int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; const DitherParams *p = job->p;
size_t spacing = job->spacing; float middle = job->middle, dots = job->dots;
const float *screen = job->screen, *phosphor = job->phosphor;
float *scan = malloc(width * sizeof(float) * (size_t)planes);
if (!scan) { atomic_store(&job->failed, 1); return; }
for (size_t line = firstLine; line < lastLine; ++line) {
size_t top = line * spacing;
size_t bottom = top + spacing < height ? top + spacing : height;
// Wobble: each line is pushed sideways, a slow wave down the screen with a quicker one over it, as a
// CRT's picture wavers when its sync drifts.
float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f;
long shift = lroundf(p->wobble * wave);
for (size_t x = 0; x < width; ++x) {
float sum[3] = { 0, 0, 0 }; int n = 0;
long sx = (long)x - shift;
if (sx >= 0 && sx < (long)width)
for (size_t y = top; y < bottom; ++y) {
size_t at = y * width + (size_t)sx;
if (!alpha[at]) continue;
for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at];
++n;
}
for (int c = 0; c < planes; ++c) scan[(size_t)c * width + x] = n ? sum[c] / (float)n : 0;
}
for (size_t y = top; y < bottom; ++y) {
uint8_t *row = rgba + y * stride;
float offset = fabsf((float)(y - top) + 0.5f - middle);
for (size_t x = 0; x < width; ++x) {
if (!alpha[y * width + x]) continue;
// Dots: the line breaks into beads, one every line spacing, each lit in the color at its middle.
float along = fmodf((float)x + 0.5f, (float)spacing) - middle;
long centered = lroundf((float)x - along * dots);
size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered;
float r, g, b, t;
if (p->originalColors) {
r = scan[at]; g = scan[width + at]; b = scan[2 * width + at];
t = 0.2126f * r + 0.7152f * g + 0.0722f * b;
} else {
t = scan[at];
r = screen[0] + (phosphor[0] - screen[0]) * t;
g = screen[1] + (phosphor[1] - screen[1]) * t;
b = screen[2] + (phosphor[2] - screen[2]) * t;
}
// The beam is driven brighter than the picture, making up for the dark screen between lines.
r *= 1.35f; g *= 1.35f; b *= 1.35f;
// Half the beam's height: a thin line in the shadows, most of the way across in the highlights,
// always leaving dark screen between lines.
float beam = middle * (0.2f + 0.5f * sqrtf(clamp01(t)));
float across = along * dots, distance = sqrtf(offset * offset + across * across);
float cover = clamp01(beam - distance + 0.5f);
// Between the lines, the screen: black in Original, else the dark color.
float br = p->originalColors ? 0 : screen[0], bg = p->originalColors ? 0 : screen[1];
float bb = p->originalColors ? 0 : screen[2];
write_pixel(row + x * 4, br + (r - br) * cover, bg + (g - bg) * cover, bb + (b - bb) * cover);
}
}
}
free(scan);
}
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
size_t count = width * height;
if (!count) return 1;
int planes = p->originalColors ? 3 : 1;
float *tone = malloc(count * sizeof(float) * (size_t)planes);
uint8_t *alpha = malloc(count);
// The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode.
float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL;
if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; }
float gamma = exp2f(p->density * 1.5f);
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
tone_job toning = { rgba, stride, width, count, alpha, tone, source, p, gamma, contrast };
in_bands(height, tone_band, &toning);
float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f };
float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f };
@@ -183,6 +286,18 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
}
}
}
} else if (style == DITHER_SCANLINES) {
// A CRT: each line scans the image, its tone along the line the average of the rows it covers. The beam glows
// brighter and blooms thicker where the picture is light, and the screen between lines stays dark.
size_t spacing = (size_t)(p->cell < 2 ? 2 : p->cell);
float middle = (float)spacing / 2, dots = clamp01(p->dots);
// Each line is drawn on its own, so the lines are shared out across the cores.
size_t lines = (height + spacing - 1) / spacing;
const float *screen = dark, *phosphor = light;
scan_job scanning = { rgba, stride, width, height, count, planes, alpha, tone, p, spacing, middle, dots, screen, phosphor, 0 };
in_bands(lines, scan_band, &scanning);
int failed = atomic_load(&scanning.failed);
if (failed) { free(tone); free(alpha); free(source); return 0; }
} else {
// Marks (halftone shapes, patterns, glyphs) cover as much of each spot as the tone calls for. On light, they
// stand for darkness and are drawn in the dark color; light on dark, the reverse.
@@ -195,7 +310,8 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
float cosA = cosf(p->angle), sinA = sinf(p->angle);
float *ink = p->lightOnDark ? light : dark, *paper = p->lightOnDark ? dark : light;
// Glyphs: each cell shares one, picked from the cell's average tone, worked out once per cell.
size_t columns = (width + (size_t)cell - 1) / (size_t)cell, cellRows = (height + (size_t)cell - 1) / (size_t)cell;
size_t gw = (size_t)(p->glyphWidth < 1 ? 1 : p->glyphWidth), gh = (size_t)(p->glyphHeight < 1 ? 1 : p->glyphHeight);
size_t columns = (width + gw - 1) / gw, cellRows = (height + gh - 1) / gh;
int *picked = NULL;
if (style == DITHER_GLYPHS && p->glyphCount > 0) {
picked = malloc(columns * cellRows * sizeof(int));
@@ -203,8 +319,8 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
for (size_t row = 0; row < cellRows; ++row)
for (size_t column = 0; column < columns; ++column) {
float sum = 0; int n = 0;
for (size_t yy = row * (size_t)cell; yy < (row + 1) * (size_t)cell && yy < height; ++yy)
for (size_t xx = column * (size_t)cell; xx < (column + 1) * (size_t)cell && xx < width; ++xx) {
for (size_t yy = row * gh; yy < (row + 1) * gh && yy < height; ++yy)
for (size_t xx = column * gw; xx < (column + 1) * gw && xx < width; ++xx) {
size_t i = yy * width + xx;
if (alpha[i]) { sum += marks[i]; ++n; }
}
@@ -228,8 +344,8 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
if (!alpha[at]) continue;
float amount;
if (picked) {
int glyph = picked[(y / (size_t)cell) * columns + x / (size_t)cell];
amount = p->glyphs[(size_t)glyph * (size_t)(cell * cell) + (y % (size_t)cell) * (size_t)cell + x % (size_t)cell] / 255.0f;
int glyph = picked[(y / gh) * columns + x / gw];
amount = p->glyphs[(size_t)glyph * gw * gh + (y % gh) * gw + x % gw] / 255.0f;
} else if (style == DITHER_PATTERNS) {
float t = marks[at];
float coverage = p->lightOnDark ? t : 1 - t;
@@ -276,3 +392,27 @@ void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int
}
}
}
typedef struct { uint8_t *rgba; const uint8_t *glow; size_t width, stride; float amount; } glow_job;
static void glow_band(void *context, size_t first, size_t last) {
const glow_job *job = context;
uint8_t *rgba = job->rgba; const uint8_t *glow = job->glow; size_t width = job->width, stride = job->stride;
float amount = job->amount;
for (size_t y = first; y < last; ++y) {
uint8_t *row = rgba + y * stride;
const uint8_t *light = glow + y * stride;
for (size_t x = 0; x < width * 4; x += 4) {
float a = row[x + 3];
for (int c = 0; c < 3; ++c) {
float v = (float)row[x + c] + (float)light[x + c] * amount * a / 255.0f;
row[x + c] = (uint8_t)lroundf(v > a ? a : v);
}
}
}
}
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount) {
glow_job glowing = { rgba, glow, width, stride, amount };
in_bands(height, glow_band, &glowing);
}
+13 -4
View File
@@ -8,7 +8,7 @@ enum {
DITHER_ATKINSON, DITHER_FLOYD_STEINBERG,
DITHER_BAYER_2, DITHER_BAYER_4, DITHER_BAYER_8,
DITHER_DOTS, DITHER_LINES, DITHER_DIAMONDS,
DITHER_PATTERNS, DITHER_GLYPHS
DITHER_PATTERNS, DITHER_GLYPHS, DITHER_SCANLINES
};
typedef struct {
@@ -20,7 +20,8 @@ typedef struct {
// −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering.
float density;
float contrast;
// Halftone and glyph cells, in pixels, and the halftone screen's angle in radians.
// Halftone and glyph cells, in pixels (scanlines: the distance between lines), and the halftone screen's angle in
// radians.
int cell;
float angle;
// Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light.
@@ -29,11 +30,17 @@ typedef struct {
int originalColors;
uint8_t dark[3];
uint8_t light[3];
// Glyphs: `glyphCount` coverage maps of `cell` × `cell` bytes (255 is fully inked), from least inked to most,
// with each map's mean coverage (0–1) in `glyphCoverage`.
// Glyphs: `glyphCount` coverage maps of `glyphWidth` × `glyphHeight` bytes (255 is fully inked), from least
// inked to most, with each map's mean coverage (0–1) in `glyphCoverage`. The image is laid out in cells that size,
// like lines of monospaced text.
int glyphWidth;
int glyphHeight;
const uint8_t *glyphs;
const float *glyphCoverage;
int glyphCount;
// Scanlines: how far each line breaks into round dots (0–1), and how many pixels its wobble pushes it sideways.
float dots;
float wobble;
} DitherParams;
// Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully
@@ -42,4 +49,6 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
// Turns each `block` × `block` square of premultiplied RGBA pixels into a round dot in its own color on `gap` (straight
// sRGB), like the lit pixels of a dot-matrix screen. The dot's edge is smoothed and alpha is kept.
void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap);
// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, never past their own alpha.
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount);
#endif
+32
View File
@@ -26,3 +26,35 @@ void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t cou
}
}
}
/// A color lookup through `cube` (`dimension`³ RGBA entries, red varying fastest), blended between the eight nearest
/// entries, on unpremultiplied colors; alpha is kept.
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension) {
const float scale = (dimension - 1) / 255.0f;
const size_t dy = dimension, dz = (size_t)dimension * dimension;
for (size_t i=0; i<count; ++i) {
uint8_t *p = pixels + i*4;
float alpha = p[3];
if (!alpha) continue;
float position[3], fraction[3];
int lo[3];
for (int channel=0; channel<3; ++channel) {
position[channel] = fminf(255, p[channel]*255.0f/alpha) * scale;
lo[channel] = (int)position[channel];
if (lo[channel] > dimension - 2) lo[channel] = dimension - 2;
fraction[channel] = position[channel] - lo[channel];
}
const float *base = cube + ((size_t)lo[0] + lo[1]*dy + lo[2]*dz) * 4;
const size_t sx = 4, sy = dy*4, sz = dz*4;
for (int channel=0; channel<3; ++channel) {
const float *c = base + channel;
float x00 = c[0] + (c[sx] - c[0]) * fraction[0];
float x10 = c[sy] + (c[sy+sx] - c[sy]) * fraction[0];
float x01 = c[sz] + (c[sz+sx] - c[sz]) * fraction[0];
float x11 = c[sz+sy] + (c[sz+sy+sx] - c[sz+sy]) * fraction[0];
float y0 = x00 + (x10 - x00) * fraction[1];
float y1 = x01 + (x11 - x01) * fraction[1];
float result = y0 + (y1 - y0) * fraction[2];
p[channel] = (uint8_t)fminf(alpha, fmaxf(0, roundf(result*alpha)));
}
}
}
+1
View File
@@ -2,3 +2,4 @@
#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);
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension);
+31
View File
@@ -172,3 +172,34 @@ fail:
free(lens);
return -1;
}
static inline int color_near(const int *rgb, const uint8_t *colors, int count, int fuzziness) {
for (int i = 0; i < count; ++i) {
const uint8_t *c = colors + i * 3;
if (abs(rgb[0] - c[0]) <= fuzziness && abs(rgb[1] - c[1]) <= fuzziness && abs(rgb[2] - c[2]) <= fuzziness) return 1;
}
return 0;
}
long color_range_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
const uint8_t *include, int includeCount, const uint8_t *exclude, int excludeCount,
int fuzziness, int invert, uint8_t *mask) {
long count = 0;
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) {
const uint8_t *px = row + x * 4;
int matches = 0;
if (px[3]) {
int rgb[3];
for (int c = 0; c < 3; ++c) rgb[c] = (px[c] * 255 + px[3] / 2) / px[3];
matches = color_near(rgb, include, includeCount, fuzziness) && !color_near(rgb, exclude, excludeCount, fuzziness);
}
if (invert) matches = !matches;
out[x] = matches ? 255 : 0;
count += matches;
}
}
return count;
}
+7
View File
@@ -10,6 +10,13 @@
// `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);
// Select > Color Range over premultiplied RGBA (as above). A pixel matches when every color channel is within
// `fuzziness` of one of the `includeCount` colors in `include` and of none in `exclude` (straight sRGB, 3 bytes
// each). Transparent pixels never match. With `invert`, the pixels that don't match are selected instead. Writes
// 255 for selected, 0 elsewhere, into `mask`, and returns the number selected.
long color_range_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
const uint8_t *include, int includeCount, const uint8_t *exclude, int excludeCount,
int fuzziness, int invert, 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.
+3
View File
@@ -0,0 +1,3 @@
# The AppImage's model, MIME data and HEVC decoder come first.
export XDG_DATA_DIRS="$APPDIR/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
export LIBHEIF_PLUGIN_PATH="$APPDIR/usr/lib/libheif"
+2 -2
View File
@@ -1,10 +1,10 @@
# Built from this checkout: run makepkg in packaging/arch.
pkgname=omaphoto
pkgver=1.3.3
pkgver=1.4.5
pkgrel=1
pkgdesc='Layered image editing for Linux, a port of Compositor'
arch=('x86_64')
url='https://github.com/ZacharyZhang-NY/OmaPhoto'
url='https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto'
license=('MIT')
depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info')
makedepends=('cmake' 'ninja')
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Builds omaphoto-x86_64.AppImage inside the AppImage image.
set -euo pipefail
id=io.github.ZacharyZhang_NY.OmaPhoto
cmake -S . -B build-appimage -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-appimage --target omaphoto
rm -rf AppDir
DESTDIR="$PWD/AppDir" cmake --install build-appimage
# libheif loads its HEVC decoder as a plugin.
mkdir -p AppDir/usr/lib/libheif AppDir/apprun-hooks
cp /usr/lib/x86_64-linux-gnu/libheif/plugins/libheif-libde265.so AppDir/usr/lib/libheif/
cp packaging/appimage/omaphoto.sh AppDir/apprun-hooks/
# Qt's Wayland client needs its shell and decoration plugins.
qtplugins=/usr/lib/x86_64-linux-gnu/qt6/plugins
mkdir -p AppDir/usr/plugins
for dir in wayland-shell-integration wayland-decoration-client wayland-graphics-integration-client; do
cp -r "$qtplugins/$dir" AppDir/usr/plugins/
done
export QMAKE=/usr/lib/qt6/bin/qmake6 APPIMAGE_EXTRACT_AND_RUN=1 OUTPUT=omaphoto-x86_64.AppImage
export EXTRA_PLATFORM_PLUGINS="libqwayland-egl.so;libqwayland-generic.so" EXTRA_QT_MODULES=svg
linuxdeploy --appdir AppDir --plugin qt --output appimage \
--desktop-file "AppDir/usr/share/applications/$id.desktop" \
--icon-file "AppDir/usr/share/icons/hicolor/512x512/apps/$id.png" \
--deploy-deps-only AppDir/usr/lib/libheif \
--deploy-deps-only AppDir/usr/plugins/wayland-shell-integration \
--deploy-deps-only AppDir/usr/plugins/wayland-decoration-client \
--deploy-deps-only AppDir/usr/plugins/wayland-graphics-integration-client
+3 -1
View File
@@ -1,12 +1,14 @@
#!/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|resolute"}
distro=${1:?"usage: distro-check.sh arch|fedora|fedora44|nixos|resolute"}
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/.." && pwd)
# Ubuntu 26.04 is the dev image on another base.
if [ "$distro" = resolute ]; then
docker build -t omaphoto-resolute --build-arg BASE=ubuntu:26.04 -f "$repo/Dockerfile.dev" "$here"
elif [ "$distro" = fedora44 ]; then
docker build -t omaphoto-fedora44 --build-arg RELEASE=44 "$here/distro/fedora"
else
docker build -t "omaphoto-$distro" "$here/distro/$distro"
fi
+10
View File
@@ -0,0 +1,10 @@
# The dev image plus linuxdeploy, pinned by checksum.
ARG DEV=omaphoto-dev
FROM $DEV
RUN curl -fsSL -o /usr/local/bin/linuxdeploy \
https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage \
&& curl -fsSL -o /usr/local/bin/linuxdeploy-plugin-qt \
https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-qt-x86_64.AppImage \
&& echo "c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d /usr/local/bin/linuxdeploy" | sha256sum -c - \
&& echo "15106be885c1c48a021198e7e1e9a48ce9d02a86dd0a1848f00bdbf3c1c92724 /usr/local/bin/linuxdeploy-plugin-qt" | sha256sum -c - \
&& chmod +x /usr/local/bin/linuxdeploy /usr/local/bin/linuxdeploy-plugin-qt
+5 -3
View File
@@ -1,6 +1,8 @@
# Fedora 43, the oldest still supported; HEVC from RPM Fusion.
FROM fedora:43
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-43.noarch.rpm \
# A supported Fedora, 43 unless named; HEVC from RPM Fusion.
ARG RELEASE=43
FROM fedora:${RELEASE}
ARG RELEASE
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-${RELEASE}.noarch.rpm \
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtsvg-devel qt6-qtimageformats libheif-devel LibRaw-devel libheif-freeworld \
fontconfig-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \
&& dnf clean all
+9 -2
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest package on Arch and Omarchy.
# Installs OmaPhoto's newest package on Arch and Omarchy.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
fi
base=$repo/releases/download/$tag
package=omaphoto-x86_64.pkg.tar.zst
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
+17 -5
View File
@@ -1,16 +1,28 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest RPM on Fedora.
# Installs OmaPhoto's newest RPM on Fedora 43 or 44.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
package=omaphoto.x86_64.rpm
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
fi
base=$repo/releases/download/$tag
. /etc/os-release
release=
case " ${ID:-} ${ID_LIKE:-} " in
*" fedora "*) ;;
*" fedora "*) release=${VERSION_ID:-} ;;
esac
# Each RPM names its release's LibRaw.
case "$release" in
43 | 44) ;;
*)
echo "This script is for Fedora; this system is ${PRETTY_NAME:-unknown}." >&2
echo "This script is for Fedora 43 or 44; this system is ${PRETTY_NAME:-unknown}." >&2
exit 1
;;
esac
package=omaphoto-fc${release}.x86_64.rpm
if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's packages are built for x86_64 alone." >&2
exit 1
+7 -10
View File
@@ -9,14 +9,11 @@ if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's flake builds for x86_64 alone." >&2
exit 1
fi
# The newest release's tag, as GitHub redirects to it.
latest=$(curl -fsSLI --proto '=https' -o /dev/null -w '%{url_effective}' https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest)
tag=${latest##*/}
case "$tag" in
v[0-9]*) ;;
*)
echo "No release found at $latest." >&2
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
;;
esac
nix --extra-experimental-features "nix-command flakes" profile install "github:ZacharyZhang-NY/OmaPhoto/$tag"
fi
nix --extra-experimental-features "nix-command flakes" profile install "git+$repo?ref=refs/tags/$tag"
+9 -2
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest DEB on Ubuntu LTS and derivatives.
# Installs OmaPhoto's newest DEB on Ubuntu LTS and derivatives.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
fi
base=$repo/releases/download/$tag
. /etc/os-release
# Each DEB names its LTS's libraries; derivatives name their base.
case "${UBUNTU_CODENAME:-}" in
+4 -2
View File
@@ -23,13 +23,15 @@ 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]
# Cores for docker and the build, shared by parallel workers.
cpus = os.environ.get('OMAPHOTO_CPUS', '32')
def docker(command):
uid, gid = os.getuid(), os.getgid()
runtime = '/run/user/%d' % uid
done = subprocess.run(
['docker', 'run', '--rm', '--init', '--cpus=12', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen',
['docker', 'run', '--rm', '--init', '--cpus=' + cpus, '-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)
@@ -38,7 +40,7 @@ def docker(command):
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 -j12' + wanted)
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build -j' + cpus + wanted)
def test():
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Builds a tag's packages and publishes a Gitea release.
set -euo pipefail
tag=${1:?"usage: GITEA_TOKEN=... scripts/release.sh vX.Y.Z"}
token=${GITEA_TOKEN:?"GITEA_TOKEN must hold a Gitea access token"}
api=https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto
repo=$(cd "$(dirname "$0")/.." && pwd)
cd "$repo"
version=${tag#v}
# The tag must name the version each build file carries.
if ! git show "$tag:CMakeLists.txt" | grep -qF "project(omaphoto VERSION $version "; then
echo "CMakeLists.txt at $tag is not $version" >&2
exit 1
fi
if ! git show "$tag:packaging/arch/PKGBUILD" | grep -qxF "pkgver=$version"; then
echo "The PKGBUILD at $tag is not $version" >&2
exit 1
fi
if ! git show "$tag:flake.nix" | grep -qF "version = \"$version\";"; then
echo "flake.nix at $tag is not $version" >&2
exit 1
fi
out=$repo/release/$tag
rm -rf "$out"
mkdir -p "$out"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
user="$(id -u):$(id -g)"
configure="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"
# Each package builds from a fresh copy of the tag.
fresh() {
rm -rf "$work/src"
mkdir "$work/src"
git archive "$tag" | tar -x -C "$work/src"
}
build() {
docker run --rm -u "$user" -e HOME=/tmp -v "$work/src:/src" -w /src "$1" sh -c "$2"
}
# One DEB per Ubuntu LTS: each names its own libraries.
docker build -t omaphoto-dev --build-arg BASE=ubuntu:24.04 -f Dockerfile.dev .
docker build -t omaphoto-resolute --build-arg BASE=ubuntu:26.04 -f Dockerfile.dev .
for pair in noble:omaphoto-dev resolute:omaphoto-resolute; do
fresh
build "${pair#*:}" "$configure && cd build && cpack -G DEB"
mv "$work"/src/build/omaphoto_*.deb "$out/omaphoto-${pair%%:*}_amd64.deb"
done
# One RPM per Fedora release: each names its own LibRaw.
docker build -t omaphoto-fedora --build-arg RELEASE=43 scripts/distro/fedora
docker build -t omaphoto-fedora44 --build-arg RELEASE=44 scripts/distro/fedora
for pair in 43:omaphoto-fedora 44:omaphoto-fedora44; do
fresh
build "${pair#*:}" "$configure && cd build && cpack -G RPM"
mv "$work"/src/build/omaphoto-*.x86_64.rpm "$out/omaphoto-fc${pair%%:*}.x86_64.rpm"
done
# makepkg refuses root; a user of our id builds.
docker build --pull -t omaphoto-arch scripts/distro/arch
fresh
docker run --rm -v "$work/src:/src" omaphoto-arch sh -c "useradd -u $(id -u) builder && chown -R builder /src && su builder -c 'cd /src/packaging/arch && makepkg -f'"
mv "$work"/src/packaging/arch/omaphoto-[0-9]*-x86_64.pkg.tar.zst "$out/omaphoto-x86_64.pkg.tar.zst"
# The AppImage builds on Ubuntu 24.04, the oldest glibc here.
docker build -t omaphoto-appimage --build-arg DEV=omaphoto-dev scripts/distro/appimage
fresh
build omaphoto-appimage scripts/appimage.sh
mv "$work/src/omaphoto-x86_64.AppImage" "$out/"
names="omaphoto-noble_amd64.deb omaphoto-resolute_amd64.deb omaphoto-fc43.x86_64.rpm omaphoto-fc44.x86_64.rpm omaphoto-x86_64.pkg.tar.zst omaphoto-x86_64.AppImage"
(cd "$out" && sha256sum $names > SHA256SUMS)
# The tag must already be on Gitea.
notes="Install with the script for your system, or run the AppImage: see the README's Install section."
body=$(printf '{"tag_name":"%s","name":"OmaPhoto %s","body":"%s"}' "$tag" "$version" "$notes")
release=$(curl -fsS -X POST -H "Authorization: token $token" -H "Content-Type: application/json" -d "$body" "$api/releases")
id=$(echo "$release" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
for name in $names SHA256SUMS; do
echo "Uploading $name"
curl -fsS -o /dev/null -X POST -H "Authorization: token $token" -F "attachment=@$out/$name" "$api/releases/$id/assets?name=$name"
done
echo "Published https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/releases/tag/$tag"
+11
View File
@@ -1,4 +1,5 @@
#include "ContentView.h"
#include "UI/ColorRangeSheet.h"
#include "UI/LassoControls.h"
#include "UI/EffectsSheet.h"
#include "UI/FilterSheet.h"
@@ -41,6 +42,16 @@ void ContentView::synchronizePanels()
m_selectionAmountPanel.close();
}
}
// Swift's onChange of colorRange == nil.
if (m_colorRangeShown != m_session.colorRange().has_value()) {
m_colorRangeShown = m_session.colorRange().has_value();
if (m_colorRangeShown) {
m_colorRangePanel.onClose = [this] { m_session.cancelColorRange(); };
m_colorRangePanel.show(QStringLiteral("Color Range"), new ColorRangeSheet(m_session));
} else {
m_colorRangePanel.close();
}
}
if (m_filterShown != m_session.filterEdit().has_value()) {
m_filterShown = m_session.filterEdit().has_value();
if (m_filterShown) {
+13 -1
View File
@@ -11,6 +11,7 @@
#include "UI/ShapeControls.h"
#include "UI/TypeControls.h"
#include "UI/LassoControls.h"
#include "UI/LayerMaskMenu.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
#include "UI/NewCanvasSheet.h"
@@ -196,7 +197,8 @@ protected:
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_dock(new QWidget(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_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)),
m_maskAloneBadge(new MaskAloneBadge(session, 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))
{
@@ -240,6 +242,12 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
// The welcome sits over the canvas, as Swift's ZStack.
m_canvasSlot->addWidget(m_canvas, 0, 0);
m_canvasSlot->addWidget(m_dropRing, 0, 0);
// A layout takes no clicks; the canvas keeps the rest.
auto *foot = new QVBoxLayout;
foot->addStretch(1);
foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter);
foot->addSpacing(14);
m_canvasSlot->addLayout(foot, 0, 0);
setAcceptDrops(true);
auto *middle = new QHBoxLayout;
@@ -390,6 +398,10 @@ void ContentView::synchronize()
}
const std::optional<CanvasDocument> &document = m_session.document();
showWelcome(!document);
const std::optional<ImageLayer> alone = m_session.maskAloneLayer();
if (alone)
m_maskAloneBadge->setLayerName(alone->name);
m_maskAloneBadge->setVisible(alone.has_value());
for (QWidget *ruler : m_rulers)
ruler->setVisible(m_session.showsRulers() && document);
m_zoom->setVisible(document.has_value());
+5
View File
@@ -14,6 +14,7 @@
#include <QWidget>
class LayersPanel;
class MaskAloneBadge;
class ToolButton;
class ProjectWorkspace;
class QFileDialog;
@@ -74,6 +75,8 @@ private:
QWidget *const m_dock;
// An accent ring while a drop may land.
QWidget *const m_dropRing;
// Over the canvas's foot while a mask shows alone.
MaskAloneBadge *const m_maskAloneBadge;
// Swift 1.1.7's rulers: the corner, then the two strips.
std::vector<QWidget *> m_rulers;
ProjectWorkspace *m_workspace = nullptr;
@@ -102,5 +105,7 @@ private:
FloatingPanel m_effectsPanel{QStringLiteral("effectsPanel"), *this};
FloatingPanel m_selectionAmountPanel{QStringLiteral("selectionAmountPanel"), *this};
std::optional<SelectionAmountOperation> m_selectionAmountShown;
FloatingPanel m_colorRangePanel{QStringLiteral("colorRangePanel"), *this};
bool m_colorRangeShown = false;
std::optional<LayerEffectSelection> m_effectsShown;
};
+2 -2
View File
@@ -1,4 +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;
// The stroke's pixels or mask, sharp; `render` softens parts.
std::optional<BrushStroke::Clone> blurSample(const BrushStroke &stroke) const;
+61 -35
View File
@@ -2,49 +2,75 @@
#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
namespace {
// `part` padded by `margin`: clear outside, or edges repeated.
QImage padded(const QImage &image, const QRect &part, int margin, bool repeat)
{
const std::optional<ImageLayer> layer = activeLayer();
if (!layer)
const QRect around = part.adjusted(-margin, -margin, margin, margin);
if (!repeat) {
const QImage copy = image.copy(around);
if (copy.isNull())
throw ExportError(ExportError::Kind::render);
return copy;
}
QImage copy = BrushRaster::context(around.width(), around.height(), true);
for (int y = 0; y < around.height(); ++y) {
const uchar *row = image.constScanLine(std::clamp(around.top() + y, 0, image.height() - 1));
uchar *out = copy.scanLine(y);
for (int x = 0; x < around.width(); ++x)
out[x] = row[std::clamp(around.left() + x, 0, image.width() - 1)];
}
return copy;
}
// A piece blurred alone equals that part of the whole.
std::function<QImage(const QRect &)> softened(const QImage &sharp, double sigma, bool mask)
{
return [sharp, sigma, mask](const QRect &part) {
// The pad holds the edges; the blur's rule never reaches.
const int margin = int(std::ceil(3 * sigma));
const QImage piece = PixelAdjust::gaussianBlur(padded(sharp, part, margin, mask), sigma, false).copy(margin, margin, part.width(), part.height());
if (piece.isNull())
throw ExportError(ExportError::Kind::render);
return piece;
};
}
}
// Swift's BlurTool extension: the layer's pixels, or mask, softened.
std::optional<BrushStroke::Clone> EditorSession::blurSample(const BrushStroke &stroke) const
{
const ImageLayer &layer = stroke.layer;
const std::optional<ImportedImage> &asset = stroke.isMask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset;
if (!asset)
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));
// The bar's Radius, carried into the layer's pixels.
const QTransform &map = stroke.pixelToDocument;
const double perPixel = std::max(1e-6, std::sqrt(std::abs(map.m11() * map.m22() - map.m12() * map.m21())));
const double sidePixels = std::max(stroke.sourceRect.width(), stroke.sourceRect.height());
const double sigma = std::min(std::min(50.0, std::max(0.5, m_brushSettings.blurRadius)) / perPixel, sidePixels / 2);
// Room for the blur to spread past the pixels' edges.
const double margin = std::ceil(3 * sigma);
const QRectF region = stroke.sourceRect.adjusted(-margin, -margin, margin, margin);
// A huge layer's sample is made coarser, within several canvases.
const qint64 canvas = qint64(m_document.value().width) * m_document.value().height;
const double budget = double(std::min(DocumentLimits::maxSurfacePixels, std::max<qint64>(16'000'000, 4 * canvas)));
const double fit = std::min(1.0, std::sqrt(budget / (region.width() * region.height())));
const int width = std::max(1, int(std::ceil(region.width() * fit))), height = std::max(1, int(std::ceil(region.height() * fit)));
const QRectF placed(margin * fit, margin * fit, stroke.sourceRect.width() * fit, stroke.sourceRect.height() * fit);
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);
QImage context = BrushRaster::context(width, height, stroke.isMask);
// Past its pixels a mask keeps its edge tone.
if (stroke.isMask)
context.fill(int(std::round(LayerMask::background(asset->thumbnail) * 255)));
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, {});
BrushRaster::draw(asset->image(), placed, painter);
painter.end();
return PixelAdjust::gaussianBlur(context, sigma, false);
// Sharp; only the parts the brush reaches are softened.
return BrushStroke::Clone{context, region, true, softened(context, sigma * fit, stroke.isMask)};
} catch (const ExportError &error) {
qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what();
return std::nullopt;
+8
View File
@@ -58,6 +58,14 @@ LayerTransform BrushStroke::transform(const QRectF &bounds) const
return result;
}
// Offset into grid pixels, turned and scaled as the layer.
QRectF BrushStroke::gridRect(const QRectF &rect, QSizeF offset) const
{
const QTransform toGrid = pixelToDocument.inverted();
const QPointF shift = toGrid.map(QPointF(offset.width(), offset.height())) - toGrid.map(QPointF(0, 0));
return rect.translated(-shift);
}
// Swift's heal: the painted spot rebuilt from nearby texture.
void BrushStroke::heal()
{
+34 -12
View File
@@ -92,7 +92,7 @@ BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const Br
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
if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > maxDiameter
|| !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);
@@ -239,7 +239,7 @@ void BrushStroke::publish(const std::set<qint64> &changed)
tile.context.detach();
if (tile.context.isNull())
throw ExportError(ExportError::Kind::render);
work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(tile)});
work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(key, tile)});
const QRectF rect = pixelToDocument.mapRect(tile.rect).intersected(canvas);
m_dirtyDocumentRect = m_dirtyDocumentRect ? std::optional(m_dirtyDocumentRect->united(rect)) : std::optional(rect);
}
@@ -250,9 +250,11 @@ void BrushStroke::publish(const std::set<qint64> &changed)
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
if (m_clone && (!isMask || isBlur)) {
// Where the sample does not reach, nothing is painted.
if (!item.sample.isNull())
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;
});
@@ -267,17 +269,37 @@ QImage BrushStroke::selectionCoverage(const Tile &tile) const
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
void BrushStroke::setClone(std::optional<Clone> clone)
{
if (!clone)
m_clone = std::move(clone);
m_clonePieces.clear();
}
// The clone as this tile sees it, before the fill.
QImage BrushStroke::clonePixels(qint64 key, const Tile &tile)
{
if (!m_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());
// Tile to sample pixels; each tile's piece is cut once.
const QRectF &placed = m_clone->placed;
const QTransform toWhole = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * (m_clone->inGrid ? QTransform() : pixelToDocument)
* QTransform::fromTranslate(-placed.left(), -placed.top())
* QTransform::fromScale(m_clone->image.width() / placed.width(), m_clone->image.height() / placed.height());
if (!m_clonePieces.contains(key)) {
const QRect part =
toWhole.mapRect(QRectF(0, 0, width, height)).adjusted(-2, -2, 2, 2).toAlignedRect().intersected(m_clone->image.rect());
if (part.isEmpty())
return QImage();
const QImage piece = m_clone->render ? m_clone->render(part) : m_clone->image.copy(part);
if (piece.isNull())
throw ExportError(ExportError::Kind::render);
m_clonePieces.emplace(key, ClonePiece{piece, part.topLeft()});
}
const ClonePiece &piece = m_clonePieces.at(key);
const QTransform toClone = toWhole * QTransform::fromTranslate(-piece.origin.x(), -piece.origin.y());
// Colour even for gray clones: edges carry their coverage.
const QImage &image = clone->image;
const QImage &image = piece.image;
if (toClone.type() > QTransform::TxTranslate) {
// Swift's medium quality: bilinear under a scale or turn.
QImage sample = BrushRaster::context(width, height, false);
+32
View File
@@ -3,6 +3,7 @@
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <cmath>
#include <cstring>
#include <numeric>
#include <stdexcept>
@@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask)
return surface;
}
QImage BrushRaster::copy(const QImage &image)
{
QImage result = context(image.width(), image.height(), false);
if (image.format() != QImage::Format_RGBA8888_Premultiplied) {
QPainter painter(&result);
draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return result;
}
for (int y = 0; y < image.height(); ++y)
std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4);
return result;
}
void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body)
{
const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2;
const qsizetype size = (count + bands - 1) / bands;
try {
std::vector<int> indices(static_cast<size_t>(bands));
std::iota(indices.begin(), indices.end(), 0);
PoolMap::blocking(indices, [&](int band) {
const qsizetype start = band * size;
if (start < count)
body(start, std::min(size, count - start));
});
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
}
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
{
context.save();
+27 -4
View File
@@ -34,6 +34,8 @@ struct BrushSettings {
double opacity = 1;
// 0–100: the brush trails the pointer on this string.
double smoothing = 0;
// Blur: how far it softens, in canvas pixels.
double blurRadius = 5;
// Erase clears the layer's pixels instead of painting on them.
bool erasing = false;
// Spot healing takes nearby pixels instead of the colour.
@@ -44,6 +46,10 @@ struct BrushSettings {
// Shared raster drawing; coverage is copied without color conversion.
namespace BrushRaster {
QImage context(int width, int height, bool mask);
// A colour context holding `image`; its own layout copies bytewise.
QImage copy(const QImage &image);
// Runs `body` over `count` pixels, band by band: start, length.
void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body);
// 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.
@@ -107,12 +113,17 @@ public:
qint64 pixelLimit = DocumentLimits::documentPixelBudget();
// 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.
// Painted through the tip, placed: document or `inGrid` pixels.
struct Clone {
QImage image;
QSizeF offset;
QRectF placed;
bool inGrid;
// Makes a part of `image`: Blur softens piece by piece.
std::function<QImage(const QRect &)> render;
};
std::optional<Clone> clone;
const std::optional<Clone> &clone() const { return m_clone; }
// Swift's didSet: the pieces cut so far go.
void setClone(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.
@@ -120,6 +131,8 @@ public:
// The undo name when the stroke's kind says none.
std::optional<QString> editName;
static constexpr int tileSize = 256;
// Size stops at 2000; the app's own commits run wider.
static constexpr double maxDiameter = 2100;
// Soft-tip deposition rate, shared with the continuous integral.
static double spacingFraction(double hardness);
@@ -144,6 +157,8 @@ public:
QRectF committedBounds() const;
LayerTransform committedTransform() const;
LayerTransform transform(const QRectF &bounds) const;
// `rect` shifted to copy from `offset` document pixels away.
QRectF gridRect(const QRectF &rect, QSizeF offset) const;
// Spot Healing's end: the painted spot rebuilt from around it.
void heal();
PaintSnapshot paintSnapshot() const;
@@ -175,7 +190,8 @@ private:
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;
// Null where the clone does not reach the tile.
QImage clonePixels(qint64 key, const Tile &tile);
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);
@@ -194,5 +210,12 @@ private:
std::map<qint64, QImage> m_coverage;
// Selected pixels cut from the source, in layer pixels.
std::optional<Lifted> m_lifted;
std::optional<Clone> m_clone;
// Each tile's part of the clone, cut once, and where.
struct ClonePiece {
QImage image;
QPoint origin;
};
std::map<qint64, ClonePiece> m_clonePieces;
std::set<qint64> m_moveTiles;
};
+27 -18
View File
@@ -79,25 +79,34 @@ bool CameraRawCurveSettings::adjusts() const
|| !isLinear(green) || !isLinear(blue);
}
double CameraRawCurveSettings::parametric(double tone) const
// Bends tones below `lower` by `low`, above `upper` by `high`.
double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high)
{
const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
double amount, lo, hi;
if (tone < shadow)
amount = shadows, lo = 0, hi = shadow;
else if (tone < dark)
amount = darks, lo = shadow, hi = dark;
else if (tone < light)
amount = lights, lo = dark, hi = light;
else
amount = highlights, lo = light, hi = 1;
const double span = std::max(0.02, hi - lo);
const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
// A tone in its region weighs 0 to 1, unfloored.
return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
// Fitted to Photoshop: Darks −51 dips 0.1 there.
const double strength = 1.66;
if (tone < lower)
return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength));
if (tone > upper) {
const double rest = 1 - upper;
return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength));
}
return tone;
}
std::vector<float> CameraRawCurveSettings::lumaTable() const
double CameraRawCurveSettings::parametric(double tone) const
{
if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0)
return tone;
// Gamma bends, smoothed as Image › Curves smooths.
std::vector<CurvePoint> anchors;
for (int index = 0; index <= 32; ++index) {
const double x = index / 32.0;
anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)});
}
return point(tone, anchors);
}
std::vector<float> CameraRawCurveSettings::toneTable() const
{
std::vector<float> table(256);
for (int index = 0; index < 256; ++index)
@@ -262,10 +271,10 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs
const CameraRawCurveSettings shape = curve.normalized();
const CameraRawMixerSettings mixing = mixer.normalized();
const CameraRawGradingSettings grade = grading.normalized();
const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
gradeFloats = grade.gradeFloats();
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(),
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(),
blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(),
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize);
}
+6 -2
View File
@@ -33,15 +33,19 @@ struct CameraRawCurveSettings {
static std::vector<CurvePoint> strongContrast();
static bool isLinear(const std::vector<CurvePoint> &points);
bool adjusts() const;
// Lifts or lowers the region a tone belongs to.
// Photoshop's parametric curve: gamma bends, smoothed as Curves.
double parametric(double tone) const;
std::vector<float> lumaTable() const;
// Applied to red, green and blue alike.
std::vector<float> toneTable() const;
std::vector<float> channelTable(const std::vector<CurvePoint> &points) const;
CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
// Swift's region(for:): the parametric amount a tone moves.
double &region(double tone);
CameraRawCurveSettings normalized() const;
friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
private:
static double bend(double tone, double lower, double low, double upper, double high);
};
// One picked colour and how far its adjustment reaches.
+2 -2
View File
@@ -6,5 +6,5 @@ public:
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;
// What `stroke` copies, `offset` away: its pixels or the canvas.
std::optional<BrushStroke::Clone> cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const;
+9 -10
View File
@@ -1,7 +1,6 @@
#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.
@@ -40,20 +39,20 @@ std::optional<QPointF> EditorSession::cloneSamplePoint(QPointF point) const
return point + QPointF(m_cloneOffset->width(), m_cloneOffset->height());
}
std::optional<QImage> EditorSession::cloneSample(const CanvasDocument &document) const
std::optional<BrushStroke::Clone> EditorSession::cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const
{
try {
// A painted asset flattens here; the catch covers it too.
if (!m_cloneSettings.sampleAllLayers) {
if (!stroke.layer.asset)
return std::nullopt;
return BrushStroke::Clone{stroke.layer.asset->image(), stroke.gridRect(stroke.sourceRect, offset), true, {}};
}
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, {});
}
drawLiveComposite(document, painter);
painter.end();
return context;
return BrushStroke::Clone{context, QRectF(-offset.width(), -offset.height(), context.width(), context.height()), false, {}};
} catch (const ExportError &error) {
qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what();
return std::nullopt;
+5
View File
@@ -28,6 +28,11 @@ public:
void openEffectColorPicker(LayerEffectKind kind);
// The effect follows the picker's working colour.
void previewEffectColor();
// The picker on a dialog's colour, which hears each change.
void openDialogColorPicker(const QString &title, const PaletteColor &color, std::function<void(const PaletteColor &)> change);
// A dialog covers the canvas: nothing to sample.
bool pickingForDialog() const;
void previewDialogColor() const;
// The open text draft previews the picker's working colour.
void previewTextColor();
// Paints the open text's selected letters, or all of it.
+27
View File
@@ -109,6 +109,8 @@ QString ColorPickerTarget::title() const
return QStringLiteral("Color Picker (Vignette Color)");
if (kind == Kind::dither)
return light ? QStringLiteral("Color Picker (Dither Light Color)") : QStringLiteral("Color Picker (Dither Dark Color)");
if (kind == Kind::dialog)
return QStringLiteral("Color Picker (%1)").arg(dialogTitle);
return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)");
}
@@ -257,6 +259,8 @@ void EditorSession::closeColorPicker(bool commit)
setVignetteColor(commit ? color : picker.original);
} else if (picker.target.kind == ColorPickerTarget::Kind::dither) {
setDitherColor(commit ? color : picker.original, picker.target.light);
} else if (picker.target.kind == ColorPickerTarget::Kind::dialog) {
picker.dialogChange(commit ? color : picker.original);
} else if (picker.target.kind == ColorPickerTarget::Kind::effect) {
const PaletteColor chosen = commit ? color : picker.original;
changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); });
@@ -440,3 +444,26 @@ std::optional<PaletteColor> EditorSession::sampleCompositeColor(QPointF point) c
return std::nullopt;
}
}
void EditorSession::openDialogColorPicker(const QString &title, const PaletteColor &color, std::function<void(const PaletteColor &)> change)
{
if (m_colorPicker)
return;
ColorPickerTarget target{ColorPickerTarget::Kind::dialog};
target.dialogTitle = title;
m_colorPicker.emplace(target, color);
m_colorPicker->dialogChange = std::move(change);
notify();
}
bool EditorSession::pickingForDialog() const
{
return m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::dialog;
}
// The dialog follows the working colour; the session keeps nothing.
void EditorSession::previewDialogColor() const
{
if (pickingForDialog())
m_colorPicker->dialogChange(m_colorPicker->color());
}
+6 -1
View File
@@ -3,6 +3,7 @@
#include <QColor>
#include <QString>
#include <QUuid>
#include <functional>
#include <optional>
// Swift's PaletteColor: an sRGB colour without alpha.
@@ -38,7 +39,7 @@ enum class LayerEffectKind;
// The picker's targets: palette, text, effect, a filter's colour.
struct ColorPickerTarget {
enum class Kind { palette, text, gradientMap, effect, vignette, dither };
enum class Kind { palette, text, gradientMap, effect, vignette, dither, dialog };
Kind kind;
bool background = false;
std::optional<QUuid> draftID = std::nullopt;
@@ -48,6 +49,8 @@ struct ColorPickerTarget {
LayerEffectKind effect{};
// Dither's Two Colors: the light one, else the dark.
bool light = false;
// A dialog's own colour, such as Export JPEG's background.
QString dialogTitle{};
QString title() const;
friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default;
};
@@ -66,5 +69,7 @@ struct ColorPickerState {
LayerTextStyle style;
};
std::optional<EditedText> editedText;
// A dialog's colour: told as it moves, then on closing.
std::function<void(const PaletteColor &)> dialogChange;
PaletteColor color() const { return hsb.rgb().quantized(); }
};
@@ -0,0 +1,15 @@
// Select › Color Range's panel; its selection shows until OK.
const std::optional<ColorRangeEdit> &colorRange() const { return m_colorRange; }
bool canSelectColorRange() const;
void beginColorRange();
// A canvas click: Shift adds, Alt takes away.
void sampleColorRange(QPointF point, bool shift, bool option);
// Matches off the UI thread; a newer change supersedes it.
void updateColorRange();
void commitColorRange();
void cancelColorRange();
// The sheet's and the canvas's writes, Swift's edit fields.
void setColorRangeSampleMode(HueSampleMode mode);
void setColorRangeHeld(std::optional<HueSampleMode> held);
void setColorRangeFuzziness(double fuzziness);
void setColorRangeInvert(bool invert);
+235
View File
@@ -0,0 +1,235 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
extern "C" {
#include "WandPixels.h"
}
// Swift's ColorRangeSelection.swift: the session's half.
namespace {
struct ColorRangeJob {
QImage image;
std::vector<uchar> include;
std::vector<uchar> exclude;
int fuzziness;
bool invert;
};
struct ColorRangeResult {
std::optional<QPainterPath> path;
QImage preview;
std::optional<QString> error;
};
// The straight colour under `point`, over 3 × 3 pixels.
std::optional<std::vector<uchar>> colorAt(const QImage &image, QPointF point)
{
if (!std::isfinite(point.x()) || !std::isfinite(point.y()))
return std::nullopt;
const double fx = std::floor(point.x()), fy = std::floor(point.y());
if (fx < 0 || fy < 0 || fx >= image.width() || fy >= image.height())
return std::nullopt;
const int x = int(fx), y = int(fy);
std::array<int, 4> sums{};
// Past the picture's edge, Swift's context holds clear pixels.
for (int row = y - 1; row <= y + 1; ++row)
for (int column = x - 1; column <= x + 1; ++column) {
if (row < 0 || column < 0 || row >= image.height() || column >= image.width())
continue;
const uchar *pixel = image.constScanLine(row) + column * 4;
for (int channel = 0; channel < 4; ++channel)
sums[size_t(channel)] += pixel[channel];
}
if (sums[3] <= 0)
return std::nullopt;
std::vector<uchar> color(3);
for (int channel = 0; channel < 3; ++channel)
color[size_t(channel)] = uchar(std::min(255, (sums[size_t(channel)] * 255 + sums[3] / 2) / sums[3]));
return color;
}
std::vector<uchar> colorRangeMask(const ColorRangeJob &job)
{
const int width = job.image.width(), height = job.image.height();
try {
std::vector<uchar> mask(size_t(width) * size_t(height));
color_range_mask(job.image.constBits(), size_t(width), size_t(height), size_t(job.image.bytesPerLine()), job.include.data(),
int(job.include.size() / 3), job.exclude.data(), int(job.exclude.size() / 3), job.fuzziness, job.invert ? 1 : 0,
mask.data());
return mask;
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
// The mask shrunk to the panel's preview, twice its size.
QImage preview(const std::vector<uchar> &mask, int width, int height)
{
const double scale = std::min(ColorRangeEdit::previewSize.width() / width, ColorRangeEdit::previewSize.height() / height) * 2;
const int w = std::max(1, int(width * scale)), h = std::max(1, int(height * scale));
QImage full(width, height, QImage::Format_Grayscale8);
if (full.isNull())
throw ExportError(ExportError::Kind::render);
for (int y = 0; y < height; ++y)
std::copy_n(mask.data() + size_t(y) * size_t(width), width, full.scanLine(y));
const QImage shrunk = full.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (shrunk.isNull())
throw ExportError(ExportError::Kind::render);
return shrunk;
}
ColorRangeResult colorRangeResult(const ColorRangeJob &job)
{
ColorRangeResult result;
try {
const std::vector<uchar> mask = colorRangeMask(job);
result.preview = preview(mask, job.image.width(), job.image.height());
if (std::any_of(mask.begin(), mask.end(), [](uchar value) { return value != 0; }))
result.path = MagicWand::outline(mask, job.image.width(), job.image.height());
} catch (const MagicWandError &error) {
result.error = QString::fromUtf8(error.what());
} catch (const ExportError &error) {
result.error = QString::fromUtf8(error.what());
}
return result;
}
}
bool EditorSession::canSelectColorRange() const
{
return canEditSelection();
}
void EditorSession::beginColorRange()
{
if (!canSelectColorRange())
return;
const std::optional<QImage> image = selectionSample(*m_document, true);
if (!image)
return;
m_colorRange.emplace(*image, m_document->selection);
notify();
}
void EditorSession::sampleColorRange(QPointF point, bool shift, bool option)
{
if (!m_colorRange)
return;
ColorRangeEdit &edit = m_colorRange.value();
const std::optional<std::vector<uchar>> color = colorAt(edit.image, point);
if (!color)
return;
switch (option ? HueSampleMode::remove : shift ? HueSampleMode::add : edit.sampleMode) {
case HueSampleMode::replace:
edit.include = *color;
edit.exclude.clear();
break;
case HueSampleMode::add:
edit.include.insert(edit.include.end(), color->begin(), color->end());
break;
case HueSampleMode::remove:
edit.exclude.insert(edit.exclude.end(), color->begin(), color->end());
break;
}
updateColorRange();
}
void EditorSession::updateColorRange()
{
if (!m_colorRange || !m_document)
return;
ColorRangeEdit &edit = m_colorRange.value();
const int generation = ++edit.generation;
if (!edit.hasColors()) {
m_document->selection = edit.original;
edit.preview = QImage();
notify();
return;
}
const ColorRangeJob job{edit.image, edit.include, edit.exclude, int(std::round(edit.fuzziness)), edit.invert};
// A watcher a change, as Swift starts a task each.
auto *watcher = new QFutureWatcher<ColorRangeResult>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, id = edit.id, generation, document = m_document->id] {
watcher->deleteLater();
const ColorRangeResult result = watcher->result();
if (!m_colorRange || m_colorRange->id != id || m_colorRange->generation != generation || !m_document || m_document->id != document)
return;
m_colorRange->error = result.error;
m_colorRange->preview = result.preview;
if (result.error) {
qCWarning(lcApp).noquote() << "Color Range could not select:" << *result.error;
} else {
m_document->selection = result.path ? std::optional(DocumentSelection{*result.path, m_selectionAntialiased}) : std::nullopt;
}
notify();
});
watcher->setFuture(QtConcurrent::run([job] { return colorRangeResult(job); }));
// The colours changed now; their match lands later.
notify();
}
void EditorSession::commitColorRange()
{
if (!m_colorRange)
return;
const std::optional<DocumentSelection> result = m_document->selection;
const ColorRangeEdit edit = std::exchange(m_colorRange, std::nullopt).value();
m_document->selection = edit.original;
resumeFileRequests();
if (edit.hasColors() && !edit.error) {
if (result)
setSelection(result, QStringLiteral("Color Range"));
else
deselect();
}
// Closing is news even when the selection stays.
notify();
}
void EditorSession::cancelColorRange()
{
if (!m_colorRange)
return;
m_document->selection = m_colorRange->original;
m_colorRange.reset();
resumeFileRequests();
notify();
}
void EditorSession::setColorRangeSampleMode(HueSampleMode mode)
{
if (!m_colorRange || m_colorRange->sampleMode == mode)
return;
m_colorRange->sampleMode = mode;
notify();
}
void EditorSession::setColorRangeHeld(std::optional<HueSampleMode> held)
{
if (!m_colorRange || m_colorRange->held == held)
return;
m_colorRange->held = held;
notify();
}
void EditorSession::setColorRangeFuzziness(double fuzziness)
{
// Swift's binding: whole, within the range, changed or nothing.
const double clamped = std::min(ColorRangeEdit::fuzzinessHigh, std::max(ColorRangeEdit::fuzzinessLow, std::round(fuzziness)));
if (!m_colorRange || m_colorRange->fuzziness == clamped)
return;
m_colorRange->fuzziness = clamped;
updateColorRange();
}
void EditorSession::setColorRangeInvert(bool invert)
{
if (!m_colorRange)
return;
m_colorRange->invert = invert;
updateColorRange();
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "Document/HueSaturation.h"
#include "Document/Selection.h"
#include <QImage>
#include <QSizeF>
#include <QUuid>
#include <optional>
#include <vector>
// Select › Color Range: pixels near the clicked colours, anywhere.
class ColorRangeEdit {
public:
static constexpr double fuzzinessLow = 0, fuzzinessHigh = 200;
// The panel's preview fits in this, in points.
static constexpr QSizeF previewSize{292, 200};
ColorRangeEdit(QImage image, std::optional<DocumentSelection> original) : image(std::move(image)), original(std::move(original)) {}
double fuzziness = 40;
bool invert = false;
// The next click: start over, add or take away.
HueSampleMode sampleMode = HueSampleMode::replace;
// Shift (add) or Alt (take away) held, over the mode.
std::optional<HueSampleMode> held;
HueSampleMode effectiveMode() const { return held.value_or(sampleMode); }
// The selection in black and white; null until a colour.
QImage preview;
// Straight sRGB colours, three bytes each.
std::vector<uchar> include;
std::vector<uchar> exclude;
std::optional<QString> error;
// The composite at document size: what colours are matched against.
QImage image;
std::optional<DocumentSelection> original;
int generation = 0;
// Swift's object identity: a result lands on its own edit.
QUuid id = QUuid::createUuid();
bool hasColors() const { return !include.empty(); }
};
+7
View File
@@ -2,7 +2,14 @@
public:
SnapGuides transformSnapTargets(const QSet<QUuid> &moving) const;
LayerTransform snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance);
// A resize handle's pointer, nudged so moved edges snap.
QPointF snappedResizePoint(QPointF point, const TransformDrag &drag, bool proportional, const QSet<QUuid> &moving,
double tolerance, const std::function<LayerTransform(QPointF)> &update);
SnapGuides cropSnapTargets() const;
// A drawn corner, each axis to a target within reach.
QPointF snappedPoint(QPointF point, double tolerance);
// A moved selection's edges or middle; a locked axis stays.
QSizeF snappedSelectionOffset(QSizeF offset, double tolerance, bool horizontal = true, bool vertical = true);
// The Crop tool's frame: the canvas until one is drawn.
std::optional<QRectF> visibleCropRect() const;
std::optional<double> cropRatio() const;
+87
View File
@@ -168,12 +168,99 @@ LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSe
return snapped;
}
// A resize handle's point, nudged onto targets; upright only.
QPointF EditorSession::snappedResizePoint(QPointF point, const TransformDrag &drag, bool proportional, const QSet<QUuid> &moving,
double tolerance, const std::function<LayerTransform(QPointF)> &update)
{
if (!m_snappingEnabled || drag.mode.kind != TransformDrag::Kind::resize || drag.original.radians() != 0) {
snapGuides = {};
return point;
}
const QPointF handle = LayerTransform::handles[size_t(drag.mode.index)];
const SnapGuides targets = transformSnapTargets(moving);
// The dragged handle's place tells its edge apart.
const QPointF at = drag.original.point(handle) + point - drag.start;
const auto edge = [at](const LayerTransform &transform, bool horizontal) {
const QRectF box(transform.origin, transform.size);
if (horizontal)
return std::abs(box.left() - at.x()) <= std::abs(box.right() - at.x()) ? box.left() : box.right();
return std::abs(box.top() - at.y()) <= std::abs(box.bottom() - at.y()) ? box.top() : box.bottom();
};
struct Snap {
bool horizontal;
double target;
};
const LayerTransform draft = update(point);
std::vector<Snap> snaps;
if (handle.x() != 0.5)
if (const std::optional<double> x = nearest(edge(draft, true), targets.xs, tolerance))
snaps.push_back({true, *x});
if (handle.y() != 0.5)
if (const std::optional<double> y = nearest(edge(draft, false), targets.ys, tolerance))
snaps.push_back({false, *y});
// Proportional, the nearer edge snaps; the ratio moves the other.
if (proportional && snaps.size() == 2) {
const auto distance = [&](const Snap &snap) { return std::abs(snap.target - edge(draft, snap.horizontal)); };
snaps = {distance(snaps[1]) < distance(snaps[0]) ? snaps[1] : snaps[0]};
}
// Edges follow the pointer linearly: one pixel measures the step.
QPointF result = point;
for (const Snap &snap : snaps) {
const double before = edge(update(result), snap.horizontal);
const QPointF nudged = result + (snap.horizontal ? QPointF(1, 0) : QPointF(0, 1));
const double perPixel = edge(update(nudged), snap.horizontal) - before;
if (std::abs(perPixel) <= 0.01)
continue;
const double shift = (snap.target - before) / perPixel;
result += snap.horizontal ? QPointF(shift, 0) : QPointF(0, shift);
}
snapGuides = {};
for (const Snap &snap : snaps)
(snap.horizontal ? snapGuides.xs : snapGuides.ys).push_back(snap.target);
return result;
}
// View > Snap To's targets, without the centres.
SnapGuides EditorSession::cropSnapTargets() const
{
return alignmentSnapTargets({}, false);
}
QPointF EditorSession::snappedPoint(QPointF point, double tolerance)
{
if (!m_snappingEnabled) {
snapGuides = {};
return point;
}
const SnapGuides targets = cropSnapTargets();
// The first of the nearest, as Swift's `min`.
const auto nearest = [tolerance](double value, const std::vector<double> &lines) {
std::optional<double> best;
for (const double line : lines)
if (std::abs(line - value) <= tolerance && (!best || std::abs(line - value) < std::abs(*best - value)))
best = line;
return best;
};
const std::optional<double> x = nearest(point.x(), targets.xs), y = nearest(point.y(), targets.ys);
snapGuides = {x ? std::vector<double>{*x} : std::vector<double>{}, y ? std::vector<double>{*y} : std::vector<double>{}};
return QPointF(x.value_or(point.x()), y.value_or(point.y()));
}
QSizeF EditorSession::snappedSelectionOffset(QSizeF offset, double tolerance, bool horizontal, bool vertical)
{
if (!m_snappingEnabled || !m_selectionMoveOrigin) {
snapGuides = {};
return offset;
}
const QSizeF whole(std::round(offset.width()), std::round(offset.height()));
const QRectF box = m_selectionMoveOrigin->path.boundingRect().translated(whole.width(), whole.height());
const SnapGuides targets = cropSnapTargets();
const TransformSnap::Offset snap =
TransformSnap::offset(box, horizontal ? targets.xs : std::vector<double>{}, vertical ? targets.ys : std::vector<double>{}, tolerance);
snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}};
return whole + snap.offset;
}
std::optional<QRectF> EditorSession::visibleCropRect() const
{
if (m_tool != NavigationTool::crop || !m_document)
+83 -18
View File
@@ -1,16 +1,28 @@
#include "Document/Dither.h"
#include "Document/BrushStroke.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Rendering/PoolMap.h"
#include <QFontMetricsF>
#include <QPainter>
#include <QPainterPath>
#include <QTextBoundaryFinder>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <set>
extern "C" {
#include "DitherPixels.h"
}
// The kernel's bands, where Swift's kernel calls dispatch_apply.
extern "C" void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context)
{
std::vector<size_t> indices(bands);
std::iota(indices.begin(), indices.end(), size_t(0));
PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); });
}
// Swift's Dither.swift: the settings, the chunky pixels, the glyphs.
namespace {
// Swift's Characters: the text's graphemes.
@@ -52,6 +64,7 @@ QString rawValue(DitherStyle style)
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
case DitherStyle::ascii: return QStringLiteral("ASCII");
case DitherStyle::scanlines: return QStringLiteral("Scanlines (CRT)");
}
throw std::logic_error("unknown dither style");
}
@@ -79,7 +92,12 @@ bool isHalftone(DitherStyle style)
bool drawsMarks(DitherStyle style)
{
return !hasTones(style);
return !hasTones(style) && style != DitherStyle::scanlines;
}
bool usesPixelSize(DitherStyle style)
{
return style != DitherStyle::ascii && style != DitherStyle::scanlines;
}
QString rawValue(DitherPixelShape shape)
@@ -103,6 +121,11 @@ DitherSettings DitherSettings::normalized() const
DitherSettings result = *this;
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14));
result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4));
result.glow = clamp(glow, 0, 100, 35);
result.dots = clamp(dots, 0, 100, 0);
result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0);
result.angle = clamp(angle, -90, 90, 45);
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
result.diffusion = clamp(diffusion, 0, 100, 100);
@@ -123,7 +146,8 @@ DitherSettings DitherSettings::normalized() const
QImage DitherSettings::apply(const QImage &image) const
{
const DitherSettings settings = normalized();
const int block = int(settings.pixelSize);
// ASCII and Scanlines draw full size: chunks would blur them.
const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1;
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (source.isNull())
throw ExportError(ExportError::Kind::render);
@@ -150,6 +174,8 @@ QImage DitherSettings::apply(const QImage &image) const
}
}
const QImage dithered = settings.dither(working);
if (settings.style == DitherStyle::scanlines && settings.glow > 0)
return settings.glowing(dithered);
if (block <= 1)
return dithered;
// Blown back up without smoothing.
@@ -168,10 +194,45 @@ QImage DitherSettings::apply(const QImage &image) const
return full;
}
// The lines' light blurred wide and added back: phosphor bloom.
QImage DitherSettings::glowing(const QImage &image) const
{
// Blurred small, scaled back up: same light, less work.
const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4));
const int width = image.width(), height = image.height();
// Swift clamps first: edges repeat outward before shrinking.
const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step;
// Far sides fill whole steps: both scales are exact.
const auto whole = [step](int side) { return (side + step - 1) / step * step; };
QImage padded = BrushRaster::context(whole(width + 2 * pad), whole(height + 2 * pad), false);
for (int y = 0; y < padded.height(); ++y) {
const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1));
uchar *out = padded.scanLine(y);
for (int x = 0; x < padded.width(); ++x)
std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4);
}
const int smallWidth = padded.width() / step, smallHeight = padded.height() / step;
const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (small.isNull())
throw ExportError(ExportError::Kind::render);
const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true)
.scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
QImage result = image.copy();
if (blurred.isNull() || result.isNull())
throw ExportError(ExportError::Kind::render);
// The scaled light's own rows, cropped to the image.
QImage bloom = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y)
std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y));
dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5));
return result;
}
QImage DitherSettings::dither(const QImage &image) const
{
const int cell = int(cellSize);
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, cell) : Glyphs();
const int cell = int(style == DitherStyle::scanlines ? lineSpacing : cellSize);
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs();
const bool two = colors == DitherColors::twoColors;
const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0};
const std::array<uint8_t, 3> lightColor = two ? bytes(light) : std::array<uint8_t, 3>{255, 255, 255};
@@ -180,8 +241,9 @@ QImage DitherSettings::dither(const QImage &image) const
DitherParams params{.style = int(style), .levels = int(levels), .diffusion = float(diffusion / 100), .density = float(density / 100),
.contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0,
.originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphs = glyphs.maps.data(),
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())};
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(),
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size()), .dots = float(dots / 100),
.wobble = float(wobble)};
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), &params) == 0;
});
if (failed)
@@ -189,19 +251,23 @@ QImage DitherSettings::dither(const QImage &image) const
return result;
}
DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int cell)
DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int lineHeight)
{
// Swift's bold system monospace, at a whole pixel size.
QFont font(QStringLiteral("monospace"));
font.setWeight(QFont::Bold);
font.setPixelSize(std::max(1, int(std::lround(cell * 1.15))));
font.setPixelSize(std::max(1, int(std::lround(lineHeight / 1.2))));
const QFontMetricsF metrics(font);
const int height = lineHeight, width = std::max(1, int(std::lround(metrics.horizontalAdvance(QStringLiteral("M")))));
// One baseline, mid-line, as a terminal sets it.
const double baseline = height - std::round((height - (metrics.ascent() + metrics.descent())) / 2 + metrics.descent());
std::vector<std::pair<std::vector<uint8_t>, float>> drawn;
// Swift's Set<Character>: canonically equivalent graphemes are one.
std::set<QString> seen;
for (const QString &character : graphemes(characters)) {
if (!seen.insert(character.normalized(QString::NormalizationForm_C)).second)
continue;
QImage map(cell, cell, QImage::Format_Grayscale8);
QImage map(width, height, QImage::Format_Grayscale8);
if (map.isNull())
throw ExportError(ExportError::Kind::render);
map.fill(0);
@@ -209,25 +275,24 @@ DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int cel
path.addText(0, 0, font, character);
// Overlapping outlines stay inked, as text rendering fills them.
path.setFillRule(Qt::WindingFill);
const QRectF bounds = path.boundingRect();
{
// Centred on its ink, as CTLineGetImageBounds centres it.
// Centred on its advance, on the shared baseline.
QPainter painter(&map);
painter.setRenderHint(QPainter::Antialiasing);
painter.translate((cell - bounds.width()) / 2 - bounds.left(), (cell - bounds.height()) / 2 - bounds.top());
painter.translate(std::round((width - metrics.horizontalAdvance(character)) / 2), baseline);
painter.fillPath(path, Qt::white);
}
std::vector<uint8_t> bytes(size_t(cell * cell));
std::vector<uint8_t> bytes(size_t(width * height));
long sum = 0;
for (int y = 0; y < cell; ++y) {
std::copy_n(map.constScanLine(y), cell, bytes.begin() + y * cell);
for (int x = 0; x < cell; ++x)
for (int y = 0; y < height; ++y) {
std::copy_n(map.constScanLine(y), width, bytes.begin() + y * width);
for (int x = 0; x < width; ++x)
sum += map.constScanLine(y)[x];
}
drawn.emplace_back(std::move(bytes), float(sum) / float(255 * cell * cell));
drawn.emplace_back(std::move(bytes), float(sum) / float(255 * width * height));
}
std::stable_sort(drawn.begin(), drawn.end(), [](const auto &a, const auto &b) { return a.second < b.second; });
Glyphs result;
Glyphs result{.maps = {}, .coverage = {}, .width = width, .height = height};
for (const auto &[map, coverage] : drawn) {
result.maps.insert(result.maps.end(), map.begin(), map.end());
result.coverage.push_back(coverage);
+23 -6
View File
@@ -6,10 +6,10 @@
#include <vector>
// Filter › Dither's looks, in the panel's order and DitherPixels.h's.
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii};
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii, scanlines };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii, DitherStyle::scanlines};
QString rawValue(DitherStyle style);
// Swift's groups: diffusion, ordered, halftone, marks.
int ditherGroup(DitherStyle style);
@@ -19,6 +19,8 @@ bool diffuses(DitherStyle style);
bool hasTones(DitherStyle style);
bool isHalftone(DitherStyle style);
bool drawsMarks(DitherStyle style);
// ASCII and Scanlines draw at full resolution, not chunky.
bool usesPixelSize(DitherStyle style);
// A chunky pixel: a square, or a dot on dark.
enum class DitherPixelShape { square, dot };
@@ -33,14 +35,25 @@ QString rawValue(DitherColors colors);
struct DitherSettings {
static constexpr double pixelSizeLow = 1, pixelSizeHigh = 32;
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
static constexpr double textSizeLow = 6, textSizeHigh = 64;
static constexpr double levelsLow = 2, levelsHigh = 8;
static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32;
static constexpr double wobbleLow = 0, wobbleHigh = 64;
static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); }
DitherStyle style = DitherStyle::atkinson;
// Each dithered pixel covers this many layer pixels a side.
double pixelSize = 2;
DitherPixelShape pixelShape = DitherPixelShape::square;
// Halftone and character cells, in dithered pixels.
// Halftone cells, in dithered pixels.
double cellSize = 8;
// ASCII's line height in layer pixels; letters about 0.6 wide.
double textSize = 14;
// Scanlines: the lines' distance apart, in layer pixels.
double lineSpacing = 4;
// Glow and dots 0–100%; wobble sideways in pixels.
double glow = 35;
double dots = 0;
double wobble = 0;
// The halftone screen's angle, in degrees.
double angle = 45;
// Tones per channel; 2 is 1-bit.
@@ -65,9 +78,13 @@ struct DitherSettings {
struct Glyphs {
std::vector<uint8_t> maps;
std::vector<float> coverage;
int width = 1;
int height = 1;
};
static Glyphs glyphs(const QString &characters, int cell);
// A monospaced cell: a line tall, a letter wide.
static Glyphs glyphs(const QString &characters, int lineHeight);
private:
QImage dither(const QImage &image) const;
QImage glowing(const QImage &image) const;
};
+49 -27
View File
@@ -37,6 +37,27 @@ bool EditorSession::canPaint() const
&& (m_isMaskSelected || !layer->adjustment);
}
std::optional<QString> EditorSession::paintRefusal() const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!canEditLayers() || !layer || canPaint())
return std::nullopt;
if (m_selectedLayerIDs.size() > 1)
return QStringLiteral("Several layers are selected. Select just one to paint on it.");
if (layer->isGroup && !m_isMaskSelected)
return QStringLiteral("“%1” is a folder, which has no pixels of its own. Paint on a layer inside it, or on the folder’s mask.").arg(layer->name);
if (!m_document->effectiveVisibleIDs().contains(layer->id))
return QStringLiteral("“%1” is hidden, or inside a hidden folder. Show it to paint on it.").arg(layer->name);
if (m_isMaskSelected && !(layer->mask && layer->mask->isEnabled))
return QStringLiteral("The layer mask is turned off. Shift-click its thumbnail to turn it on, then paint.");
if (!m_isMaskSelected && layer->adjustment)
return QStringLiteral("“%1” is an adjustment layer, with no pixels to paint. Paint on its mask instead.").arg(layer->name);
const std::optional<DocumentSelection> current = selection();
if (current && current->isEmpty())
return QStringLiteral("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere.");
return std::nullopt;
}
// A tiled raster edit of pixels or mask, within budget.
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings, bool growsMask) const
{
@@ -74,28 +95,20 @@ void EditorSession::beginBrush(QPointF point)
}
// 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())
if (!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected))
return;
if (!canPaint()) {
setBrushError(paintRefusal());
return;
}
const ImageLayer layer = activeLayer().value();
std::optional<BrushStroke::Clone> clone;
std::optional<QSizeF> sourceOffset;
if (m_tool == NavigationTool::cloneStamp) {
const std::optional<QSizeF> offset = cloneStrokeOffset(point);
if (!offset) {
sourceOffset = cloneStrokeOffset(point);
if (!sourceOffset) {
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 {
@@ -108,9 +121,21 @@ void EditorSession::beginBrush(QPointF point)
settings.green = settings.red;
settings.blue = settings.red;
}
m_brushStroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
m_brushStroke->clone = clone;
m_brushStroke->isBlur = m_tool == NavigationTool::blur;
std::unique_ptr<BrushStroke> stroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
if (sourceOffset) {
stroke->setClone(cloneSample(m_document.value(), *stroke, *sourceOffset));
if (!stroke->clone())
return;
m_cloneOffset = sourceOffset;
}
// Blur paints a softened copy of the layer, in place.
if (m_tool == NavigationTool::blur) {
stroke->setClone(blurSample(*stroke));
if (!stroke->clone())
return;
}
stroke->isBlur = m_tool == NavigationTool::blur;
m_brushStroke = std::move(stroke);
m_brushStroke->append(point);
m_brushAnchor = point;
m_brushPointer = point;
@@ -254,7 +279,7 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
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.clone() ? QStringLiteral("Clone Stamp")
: stroke.settings.healing ? QStringLiteral("Spot Healing")
: QStringLiteral("Brush Stroke")));
ImageLayer &layer = m_document->layers[index];
@@ -275,14 +300,10 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
// 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();
if (done)
done();
return;
}
setIsProjectBusy(true);
@@ -306,8 +327,9 @@ void EditorSession::finishRasterCommit()
setIsProjectBusy(false);
++m_brushRevision;
notify();
// Swift's caller resumes in the same job, nothing between.
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
pending.done();
};
if (made.failure) {
setBrushError(made.failure);
+2
View File
@@ -1,6 +1,8 @@
// Swift's EditorSession+Brush extension: a stroke from press to commit.
public:
bool canPaint() const;
// Why the target refuses a stroke; none while busy.
std::optional<QString> paintRefusal() const;
std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings(), bool growsMask = false) const;
void beginBrush(QPointF point);
void continueBrush(QPointF point);
+2 -2
View File
@@ -9,8 +9,8 @@
bool EditorSession::canStartProjectOperation() const
{
return !m_selectionAmountOperation && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument
&& !m_showsImporter && !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID;
return !m_selectionAmountOperation && !m_colorRange && !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)
+2 -2
View File
@@ -104,8 +104,8 @@ struct SessionJobs {
FilterSettings settings;
// Camera Raw's scope of the grade itself.
std::optional<CameraRawScope> scope = std::nullopt;
// Its grid's version: a newer grid drops it.
quint64 sourceVersion = 0;
// The grown layer it was made from: it shows there.
std::optional<LayerTransform> placement = std::nullopt;
};
struct FilterMade {
std::optional<ImportedImage> asset;
+2 -1
View File
@@ -75,7 +75,8 @@ struct CanvasDocument {
QSizeF size() const { return QSizeF(width, height); }
static std::optional<int> validDimension(const QString &value);
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
// Drawing order and what shows (LayerOrder), kept between asks.
LayerOrder::Result hierarchy() const;
QSet<QUuid> effectiveVisibleIDs() const;
QHash<QUuid, double> effectiveOpacities() const;
std::vector<ImageLayer> renderLayers() const;
+2 -2
View File
@@ -27,7 +27,7 @@ std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path)
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
setIsMaskSelected(false);
m_cropRect.reset();
m_guideDrag = std::nullopt;
m_transformEdit = std::nullopt;
@@ -78,7 +78,7 @@ void EditorSession::setProjectPath(std::optional<QString> path)
void EditorSession::clearProject()
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
setIsMaskSelected(false);
m_cropRect.reset();
m_transformEdit = std::nullopt;
m_guideDrag = std::nullopt;
+5 -5
View File
@@ -59,7 +59,7 @@ std::optional<LayerTransform> EditorSession::groupTransformBox() const
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)
void EditorSession::beginTransform(bool persistent, bool fromFields)
{
cancelCrop();
const std::optional<ImageLayer> layer = activeLayer();
@@ -73,11 +73,11 @@ void EditorSession::beginTransform(bool persistent)
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};
m_transformEdit = TransformEdit{layer->id, *box, TransformGroup{*box, originals}, false, persistent, std::nullopt, nullptr, fromFields};
} 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};
m_transformEdit = TransformEdit{layer->id, maskAlone ? layer->maskTransform() : layer->transform, std::nullopt, maskAlone, persistent, std::nullopt, nullptr, fromFields};
}
notify();
}
@@ -257,8 +257,8 @@ 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;
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->preparedTransform;
return layer.transform;
}
+20 -5
View File
@@ -35,7 +35,7 @@ void EditorSession::setActiveLayerID(std::optional<QUuid> id)
{
// Another layer: its pixels are the target again.
if (id != m_activeLayerID)
m_isMaskSelected = false;
setIsMaskSelected(false);
m_activeLayerID = id;
m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>();
notify();
@@ -198,8 +198,8 @@ void EditorSession::cycleToolMode()
bool EditorSession::canUseHistory() const
{
return !m_selectionAmountOperation && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument
&& !m_showsImporter && !m_renamingLayerID && !m_importError && !m_transformEdit && !m_levels;
return !m_selectionAmountOperation && !m_colorRange && !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()
@@ -234,7 +234,7 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
m_document = snapshot.document;
setActiveLayerID(snapshot.activeLayerID);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = keepsMaskTarget && active && active->mask;
setIsMaskSelected(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";
@@ -256,7 +256,7 @@ void EditorSession::endEdit()
bool EditorSession::canEditLayers() const
{
return !m_selectionAmountOperation && !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting
return !m_selectionAmountOperation && !m_colorRange && !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;
@@ -381,3 +381,18 @@ void EditorSession::requestCanvasFocus()
++m_canvasFocusRequest;
notify();
}
void EditorSession::setIsMaskSelected(bool value)
{
m_isMaskSelected = value;
if (!value)
m_viewsMaskAlone = false;
}
void EditorSession::setViewsMaskAlone(bool value)
{
if (m_viewsMaskAlone == value)
return;
m_viewsMaskAlone = value;
notify();
}
+12 -11
View File
@@ -2,6 +2,7 @@
#include "Document/BrushStroke.h"
#include "Document/CloneStamp.h"
#include "Document/ColorPalette.h"
#include "Document/ColorRangeSelection.h"
#include "Document/Gradient.h"
#include "Document/Distort.h"
#include "Document/DocumentLimits.h"
@@ -84,20 +85,19 @@ public:
// 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; }
// Alt-click: the targeted mask alone, gray, on the canvas.
bool viewsMaskAlone() const { return m_viewsMaskAlone; }
void setViewsMaskAlone(bool value);
// A blend mode tried on by hovering; nothing is saved.
struct BlendPreview {
QUuid layerID;
@@ -113,7 +113,7 @@ public:
bool transformsAsGroup() const;
std::vector<ImageLayer> groupTransformMembers() const;
std::optional<LayerTransform> groupTransformBox() const;
void beginTransform(bool persistent = true);
void beginTransform(bool persistent = true, bool fromFields = false);
// Alt-drag: the selection's copies move, one step.
void beginDuplicateTransform();
// The Move tool's settings (TransformInspector, Ctrl+H).
@@ -133,7 +133,6 @@ public:
// 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);
@@ -187,7 +186,6 @@ public:
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.
@@ -211,7 +209,6 @@ public:
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.
@@ -222,7 +219,6 @@ public:
// 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);
@@ -282,7 +278,6 @@ public:
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"
@@ -319,6 +314,7 @@ public:
#include "Document/CameraRaw+Session.h"
#include "Document/Guides+Session.h"
#include "Document/ObjectSelection+Session.h"
#include "Document/ColorRangeSelection+Session.h"
signals:
void changed();
@@ -346,7 +342,6 @@ private:
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 = ToolDefaults::boolean(QStringLiteral("autoSelect"), false);
@@ -355,6 +350,7 @@ private:
std::optional<BlendPreview> m_blendPreview;
std::optional<QUuid> m_opacityEditLayerID;
bool m_isMaskSelected = false;
bool m_viewsMaskAlone = false;
std::optional<QUuid> m_activeLayerID;
bool m_isProjectBusy = false;
bool m_showsNewDocument = false;
@@ -367,6 +363,8 @@ private:
bool m_snappingEnabled = true;
// The layout grid starts off; guides show; rulers hide.
bool m_showsGrid = ToolDefaults::boolean(QStringLiteral("grid"), false);
LayoutGrid m_layoutGrid = LayoutGrid::stored();
GridAppearance m_gridAppearance = GridAppearance::stored();
bool m_showsGuides = ToolDefaults::boolean(QStringLiteral("guides"), true);
bool m_showsRulers = ToolDefaults::boolean(QStringLiteral("rulers"), false);
bool m_snapEnabled = ToolDefaults::boolean(QStringLiteral("snap"), true);
@@ -433,6 +431,8 @@ private:
mutable std::map<QUuid, ShapePreview> m_shapeTransformPreviewCache;
std::optional<TextDraft> m_textDraft;
LayerTextStyle m_textDefaults;
// The text's style before the font menu previewed faces.
std::optional<LayerTextStyle> m_fontPreviewOriginal;
std::optional<QPointF> m_cloneSource;
CloneSettings m_cloneSettings;
// The offset aligned strokes keep, fixed as a stroke begins.
@@ -495,4 +495,5 @@ private:
int m_selectionContractAmount = 1;
int m_selectionFeatherAmount = 2;
std::optional<SelectionAmountOperation> m_selectionAmountOperation;
std::optional<ColorRangeEdit> m_colorRange;
};
+13 -17
View File
@@ -80,10 +80,6 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
try {
edit.growForBlur();
edit.preparedPreview.reset();
// A waiting job moves to the grid its version names.
if (edit.pending)
edit.pending = edit.previewJob();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
@@ -111,6 +107,7 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
return;
}
edit.pending = edit.previewJob();
edit.pendingTransform = edit.grownTransform;
renderFilterPreview();
}
@@ -127,22 +124,22 @@ void EditorSession::renderFilterPreview()
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
m_filterPreviewFor = edit.id;
m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered {
m_filterPreview.setFuture(QtConcurrent::run([job, placement = edit.pendingTransform]() -> Filtered {
try {
if (job.kind == FilterKind::cameraRaw) {
auto [image, scope] = CameraRawScope::preview(job);
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version};
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), placement};
}
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version};
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, placement};
} catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const SubjectRemovalError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ExportError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ProjectError &error) {
// Settings past their bounds: Black & White, Color Balance.
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
}
}));
}
@@ -153,18 +150,17 @@ void EditorSession::finishFilterPreview()
{
m_filterRendering = false;
const Filtered result = m_filterPreview.result();
// A cancelled edit takes nothing; an older grid renders again.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) {
m_filterEdit->preparing = false;
notify();
} else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
// A cancelled edit takes nothing.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
FilterEdit &edit = *m_filterEdit;
edit.preparing = false;
edit.previewError = result.failure;
if (result.scope)
edit.rawPanel.scope = result.scope;
// Made before the layer grew, it shows there till replaced.
if (edit.preview || isAutomatic(edit.kind)) {
edit.preparedPreview = result.image;
edit.preparedTransform = result.placement;
edit.preparedSettings = result.settings;
++m_brushRevision;
}
@@ -351,7 +347,7 @@ void EditorSession::finishFilterCommit()
mask.isEnabled = true;
mask.placement = std::nullopt;
m_document->layers[index].mask = mask;
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
finish();
return;
-1
View File
@@ -272,7 +272,6 @@ void FilterEdit::grow(const QRectF &extent)
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
{
const QSize size = source.size();
++previewSourceVersion;
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise, grain and dither preview whole: enlarged, they look coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
+4 -2
View File
@@ -171,10 +171,12 @@ public:
// Add Noise's grain, fixed while the panel is open.
const quint32 seed = QRandomGenerator::global()->generate();
std::optional<QImage> preparedPreview;
// Where the prepared preview sits; none: the layer's own place.
std::optional<LayerTransform> preparedTransform;
// The grown layer `pending` is made from.
std::optional<LayerTransform> pendingTransform;
// The settings the prepared preview was made with.
std::optional<FilterSettings> preparedSettings;
// Moves with each new grid: an older render lands nothing.
quint64 previewSourceVersion = 0;
std::optional<FilterJob> pending;
CameraRawPanel rawPanel;
// Vignette on an empty layer: the canvas it fills.
+6 -1
View File
@@ -35,8 +35,13 @@ void EditorSession::setGradientSettings(const GradientSettings &settings)
void EditorSession::beginGradient(QPointF point)
{
const std::optional<ImageLayer> layer = activeLayer();
if (m_tool != NavigationTool::gradient || !(canPaint() || m_gradientEdit) || !layer)
if (m_tool != NavigationTool::gradient || !layer)
return;
// Swift explains only past this guard, where nothing refuses.
if (!canPaint() && !m_gradientEdit) {
setBrushError(paintRefusal());
return;
}
// A new line on this target replaces the pending one.
if (m_gradientEdit && m_gradientEdit->raster->layer.id == layer->id && m_gradientEdit->raster->isMask == m_isMaskSelected) {
m_gradientEdit->start = point;
+5
View File
@@ -5,6 +5,11 @@ public:
static constexpr double guideHitDistance = 5;
bool showsGrid() const { return m_showsGrid; }
void setShowsGrid(bool shows);
// View > Grid Settings…: the person's, kept across launches.
const LayoutGrid &layoutGrid() const { return m_layoutGrid; }
void setLayoutGrid(const LayoutGrid &grid);
const GridAppearance &gridAppearance() const { return m_gridAppearance; }
void setGridAppearance(const GridAppearance &appearance);
bool showsGuides() const { return m_showsGuides; }
void setShowsGuides(bool shows);
bool showsRulers() const { return m_showsRulers; }
+149 -8
View File
@@ -1,5 +1,6 @@
#include "Document/Guides.h"
#include "Document/EditorSession.h"
#include "Logging.h"
#include <algorithm>
#include <cmath>
@@ -39,19 +40,159 @@ std::optional<CanvasGuide::Axis> guideAxis(const QString &text)
return std::nullopt;
}
std::vector<double> LayoutGrid::lines(double length)
LayoutGrid::LayoutGrid(int spacing, int subdivisions)
: spacing(std::clamp(spacing, spacingLow, spacingHigh)), subdivisions(std::min({std::max(subdivisions, subdivisionLow), subdivisionHigh, this->spacing}))
{
}
// Counted, not summed: uneven steps stay on the majors.
std::vector<double> LayoutGrid::lines(double length) const
{
if (!(length >= 0))
return {0};
const int count = int(std::floor(length / step() + 0.001));
std::vector<double> result;
for (double value = 0; value <= length + 0.001; value += step)
result.push_back(std::round(value));
for (int index = 0; index <= count; ++index)
result.push_back(std::round(index * step()));
return result;
}
bool LayoutGrid::isMajor(double value)
bool LayoutGrid::isMajor(double value) const
{
return std::abs(std::fmod(std::round(value), spacing)) < 0.001;
return std::abs(std::fmod(std::round(value), double(spacing))) < 0.001;
}
LayoutGrid LayoutGrid::stored()
{
return LayoutGrid(ToolDefaults::integer(QStringLiteral("gridSpacing"), 64), ToolDefaults::integer(QStringLiteral("gridSubdivisions"), 8));
}
void LayoutGrid::store() const
{
ToolDefaults::set(spacing, QStringLiteral("gridSpacing"));
ToolDefaults::set(subdivisions, QStringLiteral("gridSubdivisions"));
}
QString rawValue(GridAppearance::Preset preset)
{
using Preset = GridAppearance::Preset;
switch (preset) {
case Preset::lightGray: return QStringLiteral("Light Gray");
case Preset::lightBlue: return QStringLiteral("Light Blue");
case Preset::lightRed: return QStringLiteral("Light Red");
case Preset::green: return QStringLiteral("Green");
case Preset::mediumBlue: return QStringLiteral("Medium Blue");
case Preset::yellow: return QStringLiteral("Yellow");
case Preset::magenta: return QStringLiteral("Magenta");
case Preset::cyan: return QStringLiteral("Cyan");
case Preset::black: return QStringLiteral("Black");
case Preset::custom: return QStringLiteral("Custom");
}
throw std::logic_error("unknown grid preset");
}
QString rawValue(GridAppearance::Style style)
{
switch (style) {
case GridAppearance::Style::lines: return QStringLiteral("Lines");
case GridAppearance::Style::dashedLines: return QStringLiteral("Dashed Lines");
case GridAppearance::Style::dots: return QStringLiteral("Dots");
}
throw std::logic_error("unknown grid style");
}
std::optional<PaletteColor> presetColor(GridAppearance::Preset preset)
{
using Preset = GridAppearance::Preset;
switch (preset) {
case Preset::lightGray: return PaletteColor{0.7, 0.7, 0.7};
case Preset::lightBlue: return PaletteColor{0.29, 0.78, 1};
case Preset::lightRed: return PaletteColor{1, 0.4, 0.4};
case Preset::green: return PaletteColor{0.25, 0.8, 0.25};
case Preset::mediumBlue: return PaletteColor{0.2, 0.4, 1};
case Preset::yellow: return PaletteColor{1, 1, 0};
case Preset::magenta: return PaletteColor{1, 0, 1};
case Preset::cyan: return PaletteColor{0, 1, 1};
case Preset::black: return PaletteColor::black();
case Preset::custom: return std::nullopt;
}
throw std::logic_error("unknown grid preset");
}
std::vector<double> dashes(GridAppearance::Style style)
{
switch (style) {
case GridAppearance::Style::lines: return {};
case GridAppearance::Style::dashedLines: return {4, 3};
case GridAppearance::Style::dots: return {1, 2};
}
throw std::logic_error("unknown grid style");
}
PaletteColor GridAppearance::color() const
{
return presetColor(preset).value_or(customColor);
}
// A grid that is on never disappears: 1% at least.
double GridAppearance::majorAlpha() const
{
return std::clamp(opacity, opacityLow, opacityHigh) / 100.0;
}
namespace {
// A stored name, else the default and a warning.
template <typename Value, typename Values>
Value named(const QString &key, const Values &values, Value fallback)
{
const QString stored = ToolDefaults::text(key);
for (const Value value : values)
if (rawValue(value) == stored)
return value;
if (!stored.isEmpty())
qCWarning(lcApp).noquote() << "ignoring the tool setting" << key << "of" << stored;
return fallback;
}
}
GridAppearance GridAppearance::stored()
{
GridAppearance appearance;
appearance.preset = named(QStringLiteral("gridColor"), allGridPresets, appearance.preset);
const QString custom = ToolDefaults::text(QStringLiteral("gridCustomColor"));
if (const std::optional<PaletteColor> color = PaletteColor::fromHex(custom))
appearance.customColor = *color;
else if (!custom.isEmpty())
qCWarning(lcApp).noquote() << "ignoring the tool setting gridCustomColor of" << custom;
appearance.style = named(QStringLiteral("gridStyle"), allGridStyles, appearance.style);
appearance.opacity = ToolDefaults::integer(QStringLiteral("gridOpacity"), appearance.opacity);
return appearance;
}
void GridAppearance::store() const
{
ToolDefaults::set(rawValue(preset), QStringLiteral("gridColor"));
ToolDefaults::set(customColor.hex(), QStringLiteral("gridCustomColor"));
ToolDefaults::set(rawValue(style), QStringLiteral("gridStyle"));
ToolDefaults::set(opacity, QStringLiteral("gridOpacity"));
}
void EditorSession::setLayoutGrid(const LayoutGrid &grid)
{
if (grid == m_layoutGrid)
return;
m_layoutGrid = grid;
grid.store();
notify();
}
void EditorSession::setGridAppearance(const GridAppearance &appearance)
{
if (appearance == m_gridAppearance)
return;
m_gridAppearance = appearance;
appearance.store();
notify();
}
void EditorSession::setShowsGrid(bool shows)
@@ -274,9 +415,9 @@ SnapGuides EditorSession::alignmentSnapTargets(const QSet<QUuid> &moving, bool i
}
// Hidden extras do not snap, as in Photoshop.
if (m_snapToGrid && m_showsGrid) {
for (double x : LayoutGrid::lines(size.width()))
for (double x : m_layoutGrid.lines(size.width()))
targets.xs.push_back(x);
for (double y : LayoutGrid::lines(size.height()))
for (double y : m_layoutGrid.lines(size.height()))
targets.ys.push_back(y);
}
if (m_snapToGuides && m_showsGuides) {
@@ -295,7 +436,7 @@ double EditorSession::snappedGuidePosition(double position, CanvasGuide::Axis ax
const double length = vertical ? m_document->size().width() : m_document->size().height();
std::vector<double> targets;
if (m_snapToGrid && m_showsGrid)
targets = LayoutGrid::lines(length);
targets = m_layoutGrid.lines(length);
if (m_snapToGuides && m_showsGuides) {
for (const CanvasGuide &guide : displayedGuides()) {
if (guide.axis == axis && guide.id != excluding)
+52 -9
View File
@@ -1,4 +1,6 @@
#pragma once
#include "Document/ColorPalette.h"
#include <array>
#include <QColor>
#include <QString>
#include <QUuid>
@@ -24,15 +26,56 @@ struct CanvasGuide {
QString rawValue(CanvasGuide::Axis axis);
std::optional<CanvasGuide::Axis> guideAxis(const QString &text);
// The non-printing grid: majors every 64 px, eight subdivisions.
namespace LayoutGrid {
inline constexpr double spacing = 64;
inline constexpr int subdivisions = 8;
inline constexpr double step = spacing / subdivisions;
// Every line along a document edge, whole pixels.
std::vector<double> lines(double length);
bool isMajor(double value);
}
// The non-printing grid: majors every `spacing` px, split in parts.
struct LayoutGrid {
static constexpr int spacingLow = 2, spacingHigh = 4096;
static constexpr int subdivisionLow = 1, subdivisionHigh = 64;
// Plain members, Swift's `let`s, so the struct stays assignable.
int spacing;
// Never finer than a pixel.
int subdivisions;
explicit LayoutGrid(int spacing = 64, int subdivisions = 8);
double step() const { return double(spacing) / subdivisions; }
// Every line along an edge from the origin, whole pixels.
std::vector<double> lines(double length) const;
bool isMajor(double value) const;
// The person's grid, kept across projects and launches.
static LayoutGrid stored();
void store() const;
friend bool operator==(const LayoutGrid &, const LayoutGrid &) = default;
};
// How the grid is drawn, after Photoshop's Grid settings.
struct GridAppearance {
enum class Preset { lightGray, lightBlue, lightRed, green, mediumBlue, yellow, magenta, cyan, black, custom };
enum class Style { lines, dashedLines, dots };
static constexpr int opacityLow = 1, opacityHigh = 100;
Preset preset = Preset::lightGray;
// Custom's colour, kept while another preset is chosen.
PaletteColor customColor{0.7, 0.7, 0.7};
Style style = Style::lines;
// The majors' opacity, in percent.
int opacity = 45;
PaletteColor color() const;
double majorAlpha() const;
// A little over half the majors': 28% beside 45%.
double subdivisionAlpha() const { return majorAlpha() * 28 / 45; }
static GridAppearance stored();
void store() const;
friend bool operator==(const GridAppearance &, const GridAppearance &) = default;
};
inline constexpr std::array allGridPresets{GridAppearance::Preset::lightGray, GridAppearance::Preset::lightBlue, GridAppearance::Preset::lightRed,
GridAppearance::Preset::green, GridAppearance::Preset::mediumBlue, GridAppearance::Preset::yellow,
GridAppearance::Preset::magenta, GridAppearance::Preset::cyan, GridAppearance::Preset::black,
GridAppearance::Preset::custom};
inline constexpr std::array allGridStyles{GridAppearance::Style::lines, GridAppearance::Style::dashedLines, GridAppearance::Style::dots};
QString rawValue(GridAppearance::Preset preset);
QString rawValue(GridAppearance::Style style);
// None for Custom, which takes the appearance's own.
std::optional<PaletteColor> presetColor(GridAppearance::Preset preset);
// On and off in screen points; empty is solid.
std::vector<double> dashes(GridAppearance::Style style);
// A create or move under way; the document changes after.
struct GuideDrag {
+62 -59
View File
@@ -1,13 +1,14 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "Document/HueSaturation.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include <QPainter>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <deque>
#include <mutex>
extern "C" {
#include "LevelsPixels.h"
}
namespace {
struct HSL {
@@ -48,38 +49,38 @@ HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness
return {clamp(color.red), clamp(color.green), clamp(color.blue)};
}
// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back.
void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows)
// The last tables built: building costs more than applying.
struct CubeCache {
std::mutex lock;
std::deque<std::pair<HueSaturationSettings, std::vector<float>>> cubes;
};
CubeCache &cubeCache()
{
constexpr int size = HueSaturationFilter::dimension, last = size - 1;
PoolMap::blocking(rows, [&](int y) {
uchar *pixel = bits + y * stride;
for (int x = 0; x < width; ++x, pixel += 4) {
const float alpha = pixel[3];
if (alpha == 0) {
std::fill_n(pixel, 3, uchar(0));
continue;
static CubeCache cache;
return cache;
}
std::vector<float> built(const HueSaturationSettings &settings)
{
const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(settings);
constexpr int dimension = HueSaturationFilter::dimension;
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
float place[3], fraction[3];
int low[3];
for (int channel = 0; channel < 3; ++channel) {
place[channel] = std::min(1.0f, pixel[channel] / alpha) * last;
low[channel] = std::min(int(place[channel]), last - 1);
fraction[channel] = place[channel] - float(low[channel]);
}
float out[3] = {0, 0, 0};
for (int corner = 0; corner < 8; ++corner) {
const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1);
const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1])
* ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]);
const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)];
for (int channel = 0; channel < 3; ++channel)
out[channel] += share * entry[channel];
}
for (int channel = 0; channel < 3; ++channel)
pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha));
}
});
}
return values;
}
}
@@ -104,22 +105,20 @@ std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(c
std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings)
{
const std::vector<HueResponse> response = hueResponse(settings);
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const Color color = adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
CubeCache &cache = cubeCache();
{
const std::lock_guard<std::mutex> held(cache.lock);
for (const auto &[known, values] : cache.cubes) {
if (known == settings)
return values;
}
}
std::vector<float> values = built(settings);
const std::lock_guard<std::mutex> held(cache.lock);
std::erase_if(cache.cubes, [&](const auto &entry) { return entry.first == settings; });
cache.cubes.emplace_front(settings, values);
if (cache.cubes.size() > 8)
cache.cubes.pop_back();
return values;
}
@@ -144,8 +143,7 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
hue = std::fmod(hue + sampled.shift, 360);
if (hue < 0)
hue += 360;
// Multiplied, so neutral grays stay neutral.
saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100)));
saturation = adjustedSaturation(saturation, sampled.saturation);
}
// Toward white above zero, black below, reaching either at 100.
const double amount = std::min(1.0, std::max(-1.0, lightnessAmount));
@@ -153,6 +151,15 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness)));
}
double HueSaturationFilter::adjustedSaturation(double saturation, double amount)
{
const double share = std::min(1.0, std::max(-1.0, amount / 100));
// Multiplied both ways, so neutral grays stay neutral.
if (!(share > 0))
return std::max(0.0, saturation * (1 + share));
return share >= 1 ? (saturation > 0 ? 1 : 0) : std::min(1.0, saturation / (1 - share));
}
double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings)
{
double shift = 0;
@@ -166,23 +173,19 @@ double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &
AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job)
{
const int width = job.image.width(), height = job.image.height();
QImage result = BrushRaster::context(width, height, false);
{
QPainter painter(&result);
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
}
// Across the cores: it runs over the view each frame.
QImage result = BrushRaster::copy(job.image);
std::vector<float> table;
std::vector<int> rows;
try {
table = cube(job.settings);
rows.resize(size_t(height));
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
std::iota(rows.begin(), rows.end(), 0);
applyCube(result.bits(), result.bytesPerLine(), width, table, rows);
// The lookup unpremultiplies around itself.
uchar *const pixels = result.bits();
BrushRaster::inBands(qsizetype(result.width()) * result.height(),
[&](qsizetype start, qsizetype length) { cube_apply(pixels + start * 4, size_t(length), table.data(), dimension); });
// Swift blends over the image drawn again: the same pixels.
if (job.selection)
result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false);
+2
View File
@@ -131,6 +131,8 @@ std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings);
std::vector<float> cube(const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response);
// Photoshop's: down toward gray, up by dividing by what's left.
double adjustedSaturation(double saturation, double amount);
// The hue a spectrum swatch becomes.
double shiftedHue(double hue, const HueSaturationSettings &settings);
}
+2 -1
View File
@@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region,
FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()};
// Swift's region origin, counted in the image's own pixels.
if (region)
job.noiseOrigin = region->topLeft() * (image.width() / region->width());
job.noiseOrigin = QPointF(region->left() * image.width() / std::max(1.0, region->width()),
region->top() * image.height() / std::max(1.0, region->height()));
return PixelFilter::run(job);
}
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
+1 -1
View File
@@ -97,7 +97,7 @@ void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing)
finishEffectsEditing(false);
// Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here.
selectLayer(id);
m_isMaskSelected = false;
setIsMaskSelected(false);
m_effectSelection = selection;
if (editing && m_effectsEditing != selection) {
m_effectsEditingOriginal = m_document->layers[size_t(indexOf(m_document->layers, id))].effects.value_or(LayerEffects());
+108
View File
@@ -0,0 +1,108 @@
#include "Document/EditorSession.h"
#include "Document/LayerGroups.h"
#include <map>
#include <mutex>
#include <stdexcept>
namespace {
using Children = std::map<std::optional<QUuid>, std::vector<const LayerOrder::Node *>>;
void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool shown, LayerOrder::Result &result)
{
if (depth > 64)
return;
const auto found = children.find(parent);
if (found == children.end())
return;
for (const LayerOrder::Node *node : found->second) {
const bool effective = shown && node->isVisible;
result.order.push_back(node->id);
if (effective) {
result.visible.insert(node->id);
if (!node->isGroup)
result.drawn.push_back(node->id);
}
if (node->isGroup)
visit(children, node->id, depth + 1, effective, result);
}
}
// The last nodes asked about and their answer.
struct Known {
std::mutex lock;
std::optional<std::pair<std::vector<LayerOrder::Node>, LayerOrder::Result>> last;
};
Known &known()
{
static Known value;
return value;
}
}
LayerOrder::Result LayerOrder::resolve(const std::vector<ImageLayer> &layers)
{
std::vector<Node> nodes;
nodes.reserve(layers.size());
for (const ImageLayer &layer : layers)
nodes.push_back(Node{layer.id, layer.parentID, layer.isGroup, layer.isVisible});
Known &cache = known();
{
const std::lock_guard<std::mutex> held(cache.lock);
if (cache.last && cache.last->first == nodes)
return cache.last->second;
}
Children children;
for (const Node &node : nodes)
children[node.parentID].push_back(&node);
Result result;
visit(children, std::nullopt, 0, true, result);
const std::lock_guard<std::mutex> held(cache.lock);
cache.last.emplace(std::move(nodes), result);
return result;
}
LayerOrder::Result CanvasDocument::hierarchy() const
{
return LayerOrder::resolve(layers);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
return hierarchy().visible;
}
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
{
// Each layer's opacity and folder, no whole layers copied.
QHash<QUuid, std::pair<double, std::optional<QUuid>>> folders;
for (const ImageLayer &layer : layers) {
if (folders.contains(layer.id))
throw std::logic_error("two layers share an id");
folders.insert(layer.id, {layer.opacity, layer.parentID});
}
QHash<QUuid, double> result;
for (auto entry = folders.cbegin(); entry != folders.cend(); ++entry) {
result.insert(entry.key(), LayerOpacity::effective(entry->first, entry->second, [&folders](QUuid id) {
return folders.contains(id) ? std::optional(folders.value(id)) : std::nullopt;
}));
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
const std::vector<QUuid> order = hierarchy().drawn;
if (order.empty())
return {};
std::map<QUuid, size_t> index;
for (size_t place = 0; place < layers.size(); ++place) {
if (!index.emplace(layers[place].id, place).second)
throw std::logic_error("two layers share an id");
}
std::vector<ImageLayer> result;
result.reserve(order.size());
for (const QUuid id : order)
result.push_back(layers[index.at(id)]);
return result;
}
+3
View File
@@ -7,6 +7,9 @@ public:
void selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary);
void extendSelection(QUuid id);
void groupSelectedLayers();
// The active layer is a folder: something to unwrap.
bool canUngroupLayers() const;
void ungroupLayers();
std::vector<LayerHierarchy::Entry> layerRows() const;
bool canPlaceLayer(QUuid id, std::optional<QUuid> parent) const;
bool placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false);
+42 -51
View File
@@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRe
});
}
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
{
std::map<QUuid, ImageLayer> byID;
for (const ImageLayer &layer : layers) {
if (!byID.emplace(layer.id, layer).second)
throw std::logic_error("two layers share an id");
}
QHash<QUuid, double> result;
for (const ImageLayer &layer : layers)
result.insert(layer.id, layer.effectiveOpacity(byID));
return result;
}
ProjectLayerRecord ImageLayer::hierarchyRecord() const
{
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
@@ -159,40 +146,6 @@ ProjectLayerRecord ImageLayer::hierarchyRecord() const
.text = typed ? std::optional(typed->style) : std::nullopt};
}
std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const
{
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : layers)
records.push_back(layer.hierarchyRecord());
return LayerHierarchy::entries(records);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
QSet<QUuid> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible)
result.insert(entry.layer.id);
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
QHash<QUuid, const ImageLayer *> byID;
for (const ImageLayer &layer : layers) {
if (byID.contains(layer.id))
throw std::logic_error("two layers share an id");
byID.insert(layer.id, &layer);
}
std::vector<ImageLayer> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible && entry.layer.isGroup != true)
result.push_back(*byID.value(entry.layer.id));
}
return result;
}
void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary)
{
dropEffectSelection();
@@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers()
}
std::vector<QUuid> ordered;
std::vector<Chain> chains;
for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) {
if (rootIDs.contains(entry.layer.id)) {
ordered.push_back(entry.layer.id);
chains.push_back(ancestors(entry.layer.id));
for (const QUuid id : m_document->hierarchy().order) {
if (rootIDs.contains(id)) {
ordered.push_back(id);
chains.push_back(ancestors(id));
}
}
// The nearest folder they all share; the root when none.
@@ -317,6 +270,44 @@ void EditorSession::groupSelectedLayers()
endEdit();
}
bool EditorSession::canUngroupLayers() const
{
const std::optional<ImageLayer> active = activeLayer();
return canEditLayers() && active && active->isGroup;
}
// The folder's children take its place, in order.
void EditorSession::ungroupLayers()
{
if (!canUngroupLayers())
return;
const ImageLayer group = activeLayer().value();
std::vector<ImageLayer> children;
for (const ImageLayer &layer : m_document->layers) {
if (layer.parentID == group.id) {
children.push_back(layer);
children.back().parentID = group.parentID;
}
}
std::vector<ImageLayer> layers;
QSet<QUuid> childIDs;
for (const ImageLayer &child : children)
childIDs.insert(child.id);
for (const ImageLayer &layer : m_document->layers) {
if (layer.id == group.id)
layers.insert(layers.end(), children.begin(), children.end());
else if (!childIDs.contains(layer.id))
layers.push_back(layer);
}
releaseDetachedClipping(layers);
finishOpacityEdit();
beginEdit(QStringLiteral("Ungroup Layers"));
m_document->layers = layers;
selectLayers(childIDs, children.empty() ? std::nullopt : std::optional(children.front().id));
m_collapsedGroupIDs.remove(group.id);
endEdit();
}
std::vector<LayerHierarchy::Entry> EditorSession::layerRows() const
{
std::vector<ProjectLayerRecord> records;
+23
View File
@@ -23,3 +23,26 @@ namespace LayerOpacity {
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
double effective(double own, std::optional<QUuid> parent, const Node &folder);
}
struct ImageLayer;
// Swift's LayerOrder: the hierarchy from ids, folders and visibility alone.
namespace LayerOrder {
struct Node {
QUuid id;
std::optional<QUuid> parentID;
bool isGroup;
bool isVisible;
friend bool operator==(const Node &, const Node &) = default;
};
struct Result {
// Every layer and folder, as LayerHierarchy::entries lists them.
std::vector<QUuid> order;
// Visible, in folders that are.
QSet<QUuid> visible;
// The layers that show, folders left out, bottom to top.
std::vector<QUuid> drawn;
};
// Kept until a node changes: the canvas asks each event.
Result resolve(const std::vector<ImageLayer> &layers);
}
+21
View File
@@ -0,0 +1,21 @@
#include "Document/EditorSession.h"
// Swift's mask shown alone, an Alt-click on its thumbnail.
std::optional<ImageLayer> EditorSession::maskAloneLayer() const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!m_viewsMaskAlone || !m_isMaskSelected || !layer || !layer->mask)
return std::nullopt;
return layer;
}
void EditorSession::toggleMaskAlone(QUuid id)
{
const std::optional<ImageLayer> shown = maskAloneLayer();
const bool showing = shown && shown->id == id;
selectLayerTarget(id, true);
if (m_activeLayerID != id || !m_isMaskSelected)
return;
m_viewsMaskAlone = !showing;
notify();
}
+6
View File
@@ -15,6 +15,12 @@ public:
std::optional<QImage> maskDistortPreview(const ImageLayer &layer) const;
// The selection made a mask; without one, a solid mask.
void addMask(bool revealing = true);
// The layer whose mask the canvas shows alone, if any.
std::optional<ImageLayer> maskAloneLayer() const;
// Alt-click on a mask thumbnail: alone, or the composite again.
void toggleMaskAlone(QUuid id);
private:
// Swift's didSet: the pixels targeted end the mask view.
void setIsMaskSelected(bool value);
void commitMaskTransform();
+9 -8
View File
@@ -262,7 +262,7 @@ void EditorSession::selectLayerTarget(QUuid id, bool mask)
resolveGradient();
selectLayer(id);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = mask && active && active->mask;
setIsMaskSelected(mask && active && active->mask);
notify();
}
@@ -286,14 +286,15 @@ void EditorSession::addMask(bool revealing)
// The selection's coverage, soft where it is feathered.
QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height,
BrushRaster::pixelToDocument(active->transform, width, height));
if (revealing)
// Revealing, white inside; hiding, black inside.
if (!revealing)
image.invertPixels();
const LayerMask mask(LayerMask::assetFrom(image));
finishOpacityEdit();
beginEdit(QStringLiteral("Add Mask from Selection"));
beginEdit(revealing ? QStringLiteral("Reveal Selection") : QStringLiteral("Hide Selection"));
m_document->layers[size_t(index)].mask = mask;
m_document->selection = std::nullopt;
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
} catch (const std::runtime_error &error) {
qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what();
@@ -312,7 +313,7 @@ void EditorSession::addLayerMask(bool revealing)
if (layer.id == active->id)
layer.mask = LayerMask::solid(revealing);
}
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
}
@@ -341,7 +342,7 @@ void EditorSession::deleteLayerMask()
if (layer.id == active->id)
layer.mask = std::nullopt;
}
m_isMaskSelected = false;
setIsMaskSelected(false);
endEdit();
}
@@ -371,7 +372,7 @@ void EditorSession::copyMask(QUuid source, QUuid target)
beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask"));
find(target)->mask = mask;
selectLayer(target);
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
}
@@ -392,7 +393,7 @@ std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageL
if (!layer.mask)
return std::nullopt;
// Previewed on a grown layer, the mask keeps its bounds.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return layer.mask->placement.value_or(layer.transform);
if (m_transformEdit && m_transformEdit->group) {
const TransformGroup &group = *m_transformEdit->group;
+2
View File
@@ -71,6 +71,8 @@ struct TransformEdit {
std::optional<Corners> corners;
// Ctrl+T on selected pixels: they float, then merge back.
std::shared_ptr<const FloatingTransform> floating;
// The Move bar's fields: applied once they are done.
bool fromFields = false;
};
// Where a move has just snapped: guides while it lasts.
+5 -3
View File
@@ -104,15 +104,17 @@ QImage LevelsFilter::run(const LevelsJob &job)
{
if (job.settings.isIdentity())
return job.image;
QImage context = drawn(job.image);
QImage context = BrushRaster::copy(job.image);
// On the stack: a heap failure would escape the catch.
std::array<float, 3 * 256> tables;
for (size_t channel = 0; channel < 3; ++channel) {
for (size_t value = 0; value <= 255; ++value)
tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
}
// The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice).
levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data());
// The kernel unpremultiplies edges itself; bands use every core.
uchar *const pixels = context.bits();
BrushRaster::inBands(qsizetype(context.width()) * context.height(),
[&](qsizetype start, qsizetype length) { levels_apply(pixels + start * 4, size_t(length), tables.data()); });
if (job.selection)
return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false);
return context;
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more