23 Commits
Author SHA1 Message Date
雷电芽衣 1904144511 OmaPhoto 1.3.3: version bump, change log, README; steady export failure tests, Hue/Saturation test after 12.14d2, NixOS shell MIME database (12.16)
Release / version (push) Canceled after 0s
Release / deb (ubuntu:24.04, noble) (push) Canceled after 0s
Release / deb (ubuntu:26.04, resolute) (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
2026-09-30 11:32:25 -04:00
雷电芽衣 bc9d43f1e8 Filter › Dither, as Compositor 1.3.3 (12.15) 2026-09-30 07:55:57 -04:00
雷电芽衣 aa5a44e517 Closing the picker gives open text the keys back; colour edits wait for text editing (12.14d2) 2026-09-30 06:16:28 -04:00
雷电芽衣 27a4fe8428 Colour only the selected letters of text being edited; project format version 10 (12.14d1) 2026-09-30 05:15:51 -04:00
雷电芽衣 2fa8d9921d Coloured slider tracks with double-click reset, aligned filter titles; entries release focus before their owners go (12.14c) 2026-09-30 03:32:51 -04:00
雷电芽衣 2183348aa6 Open Recent, print sizes through an invalid resolution, Color Grading under Color (12.14b) 2026-09-30 01:43:23 -04:00
雷电芽衣 94b884a2ef Closing or quitting while text is edited applies it first (12.14a) 2026-09-30 00:25:34 -04:00
雷电芽衣 5ee5aee07b A guide for AI agents writing projects, the project format page, README lines (12.13c) 2026-09-29 23:30:45 -04:00
雷电芽衣 d4c065fa77 Keep editing while a project saves; saves, reloads and opens queue behind one writer (12.13b) 2026-09-29 22:39:49 -04:00
雷电芽衣 838d5b1459 Reload a project changed on disk, fingerprinted by its manifest and image sizes (12.13a) 2026-09-29 20:13:20 -04:00
雷电芽衣 aa253d9715 Scrub numbers by dragging their titles in the sheets and panels (12.12c2) 2026-09-29 17:07:35 -04:00
雷电芽衣 5bc7889387 Scrub numbers by dragging their labels in the tool bars and layer opacity (12.12c1) 2026-09-29 15:06:49 -04:00
雷电芽衣 f335818e53 Masks painted anywhere on the canvas; mask thumbnails' ground white or black (12.12b) 2026-09-29 13:26:01 -04:00
雷电芽衣 136ee65a48 Typed text drawn as its pixels; effects kept while typing, painting masks and undoing (12.12a) 2026-09-29 12:47:36 -04:00
雷电芽衣 8fbb104c48 Clicked text on its baseline, live text on the Move tool's double click, SVG import (12.11c) 2026-09-29 10:55:10 -04:00
雷电芽衣 008493b0a3 Import simple Photoshop text as editable text (12.11b2) 2026-09-29 09:04:50 -04:00
雷电芽衣 a2241601ec Read Photoshop Large Documents, layerless files and oversized layers (12.11b1) 2026-09-29 04:23:27 -04:00
雷电芽衣 8a9c56ee1f The document's own pixel budget, marching ants at screen resolution (12.11a) 2026-09-29 00:19:45 -04:00
雷电芽衣 ffba1753c6 Crop ratios and selection start, Vignette on empty layers, text colour previews (12.10c); mutation runs use 12 cores 2026-09-28 21:03:16 -04:00
雷电芽衣 7a873eb9c7 Whole layers on the clipboard and several duplicates (12.10b) 2026-09-28 19:09:46 -04:00
雷电芽衣 fc076de71d The format version in one place and the layer context menu (12.10a) 2026-09-28 15:28:21 -04:00
雷电芽衣 9ac3b1b81c Stepped keyboard zoom and a steady tab strip (12.9b) 2026-09-28 13:18:10 -04:00
雷电芽衣 fdb62978c5 Finishing filters: Vignette, Bloom / Glow, Tonal Contrast (12.9a) 2026-09-28 10:50:06 -04:00
310 changed files with 20139 additions and 1776 deletions

No files matched your search

+42 -16
View File
@@ -2,6 +2,10 @@
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-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.
## Designing or editing a project
To make or change an image in a `.comp` project, the source is not needed: read [docs/writing-comp-files.md](docs/writing-comp-files.md). It covers the format, the rules that make a project load, and writing it safely while it is open. [docs/project-format.md](docs/project-format.md) describes the whole format.
## Trees ## Trees
- The repository root: the app. C++20, Qt 6 Widgets (floor Qt 6.4), CMake, Qt Test. CPU raster only: QPainter plus the C kernels in `kernels/`, copied unchanged from Compositor's `Compositor/Rendering/*.c` and compiled in place. - 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.
@@ -49,24 +53,24 @@ Platform adaptations, each forced by a missing Qt analog:
- Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; shapes and text set them apart: `installProject` loads both after `CanvasDocument(snapshot)`, `applyDocumentSize` does not, as Swift's rebuild leaves them out. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`. - Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; shapes and text set them apart: `installProject` loads both after `CanvasDocument(snapshot)`, `applyDocumentSize` does not, as Swift's rebuild leaves them out. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`.
- Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files. - Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files.
- Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too. - Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too.
- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer<QWidget>`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; `markSaved()` needs no signal of its own, because every path from a save goes on to free the project, and that notifies. `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it). - `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer<QWidget>`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; a save marks the saved revision before `setProjectPath`, whose signal then shows the saved state (12.13b: the write lands after the project is free again). `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no security scopes; recent projects are 12.14b's. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it).
- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayer` bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor's `?? centre` fallback is dead (the copied layer is always among the copies) and is a checked read instead. - `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayers` (since 12.10b; `copyLayer` hands it one id, and a drop still copies row by row, as Swift's) bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor is the first layer's centre, or with several the centre of the box round every copied layer that is no folder; the `?? centre` fallback is dead (the first id is always among the copies) and is a checked read instead. The copies end up selected, the first active; an id the source tab lacks (two tabs of one project share ids, and the first holding the first id is the source) is left out of the selection, as Swift's `compactMap`.
- `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus; keys in `EditorCanvas+Keys.cpp` since 12.2b). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own. - `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus; keys in `EditorCanvas+Keys.cpp` since 12.2b). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own.
- `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`. - `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`.
- `EditorSession::drawLiveComposite` lives in `LiveLayerMask.cpp`, as in Swift, and draws the document as the export does, in document pixels, through the displayed transforms, placements and blend modes; `LayerMaskSessionTests` compares it with the export on every render. - `EditorSession::drawLiveComposite` lives in `LiveLayerMask.cpp`, as in Swift, and draws the document as the export does, in document pixels, through the displayed transforms, placements and blend modes; `LayerMaskSessionTests` compares it with the export on every render.
- `ContentView` is one project's editor area, a `QWidget` that compares what it shows with what the session holds on every `changed()`; the window around it is `ProjectWorkspaceView` (4.3c), which owns the toolbar that SwiftUI lets `ContentView` declare. `ToolHeaderBar` carries `Q_OBJECT`: Qt 6.11's `findChild` refuses a class without one at compile time. Every tool has a bar of `ToolHeaderStyle::height`, so the canvas never jumps: a tool whose bar has not arrived yet shows an empty `ToolHeaderBar`, the idle and Eyedropper bars are titles alone, Hand and Zoom share one `NavigationToolHeader`, and a bar put in late is shown at once (a layout shows a late child only from the event loop). The welcome is made anew each time the document goes, as Swift's `if document == nil` makes a fresh view, so its fields and its clipboard suggestion start over. A rail button is a `QToolButton` that toggles itself on a click before the session answers, so the click's slot sets every button again from the session, which a refused `selectTool` leaves silent. `NewCanvasSheet` takes Return and Enter from anywhere on the sheet through widget shortcuts, as SwiftUI's `.defaultAction` does: `setDefault` alone reaches only a `QDialog`. Fonts are set in pixels, Swift's points on a display of one: `setPointSize` would grow them by the screen's DPI. Swift's `.secondary` text is the palette's `PlaceholderText`, its orange warning the palette's `BrightText`, which the theme of 4.5 fills; dividers are `Mid`. Labels and hints say Ctrl and Alt for ⌘ and ⌥. The status bar's hint is an eliding label and the one item that gives way when the window is narrow, as SwiftUI truncates it; zoom and size print through the English locale, as Swift's interpolation groups digits (`1,200 × 800 px`). Swift's last branch gives the Eyedropper the Zoom tool's hint; the twin says the same. Hints and icons that follow tool state (marquee and lasso kinds, brush and smear modes, shape kinds) arrive with that state. `NavigationToolHeader` applies on Return and Escape directly and again when focus leaves, where Swift applies only on the loss of focus: a Qt widget in a window that is not active loses no focus. Text that is no number reads as zero through `toDouble`, which the `> 0` test refuses, so no `ok` flag is read; `inf` reads as a number and the viewport refuses it. `NewCanvasSheet::clipboardDimensions` reads a copied picture's size from its header through `QImageReader` (no pixel is decoded), PNG before TIFF as in Swift, sides swapped for a stored quarter turn. The importer is a `QFileDialog` for existing files, and the two alerts are `QMessageBox`es opened over the view; each is taken down again when the session's flag or error is cleared from elsewhere, as a SwiftUI binding would dismiss it. - `ContentView` is one project's editor area, a `QWidget` that compares what it shows with what the session holds on every `changed()`; the window around it is `ProjectWorkspaceView` (4.3c), which owns the toolbar that SwiftUI lets `ContentView` declare. `ToolHeaderBar` carries `Q_OBJECT`: Qt 6.11's `findChild` refuses a class without one at compile time. Every tool has a bar of `ToolHeaderStyle::height`, so the canvas never jumps: a tool whose bar has not arrived yet shows an empty `ToolHeaderBar`, the idle and Eyedropper bars are titles alone, Hand and Zoom share one `NavigationToolHeader`, and a bar put in late is shown at once (a layout shows a late child only from the event loop). The welcome is made anew each time the document goes, as Swift's `if document == nil` makes a fresh view, so its fields and its clipboard suggestion start over. A rail button is a `QToolButton` that toggles itself on a click before the session answers, so the click's slot sets every button again from the session, which a refused `selectTool` leaves silent. `NewCanvasSheet` takes Return and Enter from anywhere on the sheet through widget shortcuts, as SwiftUI's `.defaultAction` does: `setDefault` alone reaches only a `QDialog`. Fonts are set in pixels, Swift's points on a display of one: `setPointSize` would grow them by the screen's DPI. Swift's `.secondary` text is the palette's `PlaceholderText`, its orange warning the palette's `BrightText`, which the theme of 4.5 fills; dividers are `Mid`. Labels and hints say Ctrl and Alt for ⌘ and ⌥. The status bar's hint is an eliding label and the one item that gives way when the window is narrow, as SwiftUI truncates it; zoom and size print through the English locale, as Swift's interpolation groups digits (`1,200 × 800 px`). Swift's last branch gives the Eyedropper the Zoom tool's hint; the twin says the same. Hints and icons that follow tool state (marquee and lasso kinds, brush and smear modes, shape kinds) arrive with that state. `NavigationToolHeader` applies on Return and Escape directly and again when focus leaves, where Swift applies only on the loss of focus: a Qt widget in a window that is not active loses no focus. Text that is no number reads as zero through `toDouble`, which the `> 0` test refuses, so no `ok` flag is read; `inf` reads as a number and the viewport refuses it. `NewCanvasSheet::clipboardDimensions` reads a copied picture's size from its header through `QImageReader` (no pixel is decoded), PNG before TIFF as in Swift, sides swapped for a stored quarter turn. The importer is a `QFileDialog` for existing files, and the two alerts are `QMessageBox`es opened over the view; each is taken down again when the session's flag or error is cleared from elsewhere, as a SwiftUI binding would dismiss it.
- `LayersPanel` twins `LayersPanel.swift`: the heading with its count, `LayerAppearanceControls`, the rows or the empty words, and the footer's `QToolButton`s (new layer, folder, `LayerMaskMenu`, the effects menu since 9.6d, the adjustment menu since 9.5d, delete). Its width is `ContentView`'s `layersPanelWidth`, kept in `QSettings` under Swift's `AppStorage` key; a stored width outside `minimumWidth…maximumWidth` is the default, logged. `PanelResizeEdge` lives in `ContentView.cpp` as in Swift: an 8-point grip with the split cursor over a `Mid` hairline, dragged left to widen, whole points, within range. Tests enable `QStandardPaths::setTestModeEnabled`, which sends `QSettings` to `~/.qttest`. The delete button is Swift's `deleteLayerOrMask` (a chosen effect goes first since 9.6a, then a selected mask, then the selection; since 9.6d its words name a chosen effect first). - `LayersPanel` twins `LayersPanel.swift`: the heading with its count, `LayerAppearanceControls`, the rows or the empty words, and the footer's `QToolButton`s (new layer, folder, `LayerMaskMenu`, the effects menu since 9.6d, the adjustment menu since 9.5d, delete). Its width is `ContentView`'s `layersPanelWidth`, kept in `QSettings` under Swift's `AppStorage` key; a stored width outside `minimumWidth…maximumWidth` is the default, logged. `PanelResizeEdge` lives in `ContentView.cpp` as in Swift: an 8-point grip with the split cursor over a `Mid` hairline, dragged left to widen, whole points, within range. Tests enable `QStandardPaths::setTestModeEnabled`, which sends `QSettings` to `~/.qttest`. The delete button is Swift's `deleteLayerOrMask` (a chosen effect goes first since 9.6a, then a selected mask, then the selection; since 9.6d its words name a chosen effect first).
- `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the cell's `actions()`, named as Swift's menu items. Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's and the mask button's icons; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception. - `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the list's since 12.10a (below). Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's and the mask button's icons; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception.
- Drags in the list (`NativeLayerList+Drag.cpp`): a row pressed and moved past `startDragDistance` starts a `QDrag` carrying the row's id, or its whole selection's, one id a line under `ProjectWorkspace::layerType` (Swift writes one pasteboard item a row); Alt at the press offers `CopyAction` alone under `CanvasView::duplicateCursor`, as the table's source mask does, and the drop then duplicates. `dropTarget` is Swift's `validateDrop`: a folder's middle half takes the drop in, the rest lands above the nearer row edge or below every row, every row must pass `canPlaceLayer` (a copied folder carries its contents since 12.2a); `acceptDrop` is `acceptDrop`, the place worked out once, the order reversed into a folder, the moved rows kept selected, one `Move Layer(s)` or `Duplicate Layer(s)` step. Swift refuses a drag from another table by its source; the twin's drag carries its list's token under `sourceType` and a drop takes only its own (Qt's synthetic drop events carry no `source()`, and two projects opened from one file share their layers' ids, so neither would do). A press arms the drag with its modifiers, on the row or on a thumbnail (a thumbnail press that targets goes on to arm the row, as Swift's `mouseDown` falls through to the table); every left press first drops whatever an earlier press armed, then arms unless it clipped (Swift returns after clipping) or was Ctrl's or Shift's; only a move with the left button held past `startDragDistance` starts the drag. An Alt press on a mask watches the left button's release application-wide (a popup opened mid-press takes it), as the eye does; a release seen on a `QWindow` is not counted, since the window sees the button's own release before the button does, and another button's release leaves the press, as Swift waits for the left one. A plain drag offers Move and Copy with Move preferred, so a project target can take the Copy Swift proposes (a tab, the canvas and the New button set `CopyAction` on a valid enter, move and drop and ignore the rest); the list itself duplicates only when a copy is offered alone (Alt at the press), whatever Ctrl makes Qt propose, and sets Move or Copy itself. A drag held near the viewport's edge scrolls the list by the overshoot into a 16-point margin and keeps scrolling on a timer until the pointer moves back inside or the drag ends (a leave or a drop stops it, so no tick reads a drag that is gone), as the table autoscrolls (Wayland sends no moves while the pointer holds still); `autoscrolling()` says whether the timer runs, for the tests. An Alt click that clips sets the cursor for the next click at once, as Swift's refresh after toggling. `LayerColumn` paints the indicator: a two-point accent line between rows or the accent-filled folder row. A mask's Alt-drag carries its layer's id under `maskType`, read back through `ProjectWorkspace::layerID` (a payload that is no id names no mask, an all-zero id included), and lands on the row under the pointer through `canCopyMask`. `QDrag::exec` needs the platform's drag loop, which `offscreen` has not: `LayerDragGestureTests` defines both `QDrag::exec` overloads in the test executable, where they take libQt6Gui's place (as `ProjectReplaceTests` does with libc), record what the drag offered and return `IgnoreAction`, so presses, thresholds and actions are tested to the call; `LayerDragTests` holds the drops and `LayerDragFixtures.h` what both share. The eye and thumbnail buttons live in `NativeLayerList+Buttons.cpp`. With Alt held the viewport's cursor follows the pointer (`cursorFor`): the clipping cursor over a row's strip (create or release, drawn as paths like the symbols, refused where `canToggleClippingMask` says no), the duplicate cursor over the rest of a row (a folder's strip included, since a folder clips nothing, 12.2a) and over a mask thumbnail, the arrow otherwise and with Ctrl; Alt or Ctrl pressed anywhere, and hover over any row or control (every cell and its children track the mouse), reach an application event filter installed while the list is shown, as Swift's `flagsChanged` monitor and tracking area; the list keeps the last hover point, so a modifier change is judged where the pointer sits (`QCursor::pos` is no witness offscreen), and a hover's own modifiers are the event's. - Drags in the list (`NativeLayerList+Drag.cpp`): a row pressed and moved past `startDragDistance` starts a `QDrag` carrying the row's id, or its whole selection's, one id a line under `ProjectWorkspace::layerType` (Swift writes one pasteboard item a row); Alt at the press offers `CopyAction` alone under `CanvasView::duplicateCursor`, as the table's source mask does, and the drop then duplicates. `dropTarget` is Swift's `validateDrop`: a folder's middle half takes the drop in, the rest lands above the nearer row edge or below every row, every row must pass `canPlaceLayer` (a copied folder carries its contents since 12.2a); `acceptDrop` is `acceptDrop`, the place worked out once, the order reversed into a folder, the moved rows kept selected, one `Move Layer(s)` or `Duplicate Layer(s)` step. Swift refuses a drag from another table by its source; the twin's drag carries its list's token under `sourceType` and a drop takes only its own (Qt's synthetic drop events carry no `source()`, and two projects opened from one file share their layers' ids, so neither would do). A press arms the drag with its modifiers, on the row or on a thumbnail (a thumbnail press that targets goes on to arm the row, as Swift's `mouseDown` falls through to the table); every left press first drops whatever an earlier press armed, then arms unless it clipped (Swift returns after clipping) or was Ctrl's or Shift's; only a move with the left button held past `startDragDistance` starts the drag. An Alt press on a mask watches the left button's release application-wide (a popup opened mid-press takes it), as the eye does; a release seen on a `QWindow` is not counted, since the window sees the button's own release before the button does, and another button's release leaves the press, as Swift waits for the left one. A plain drag offers Move and Copy with Move preferred, so a project target can take the Copy Swift proposes (a tab, the canvas and the New button set `CopyAction` on a valid enter, move and drop and ignore the rest); the list itself duplicates only when a copy is offered alone (Alt at the press), whatever Ctrl makes Qt propose, and sets Move or Copy itself. A drag held near the viewport's edge scrolls the list by the overshoot into a 16-point margin and keeps scrolling on a timer until the pointer moves back inside or the drag ends (a leave or a drop stops it, so no tick reads a drag that is gone), as the table autoscrolls (Wayland sends no moves while the pointer holds still); `autoscrolling()` says whether the timer runs, for the tests. An Alt click that clips sets the cursor for the next click at once, as Swift's refresh after toggling. `LayerColumn` paints the indicator: a two-point accent line between rows or the accent-filled folder row. A mask's Alt-drag carries its layer's id under `maskType`, read back through `ProjectWorkspace::layerID` (a payload that is no id names no mask, an all-zero id included), and lands on the row under the pointer through `canCopyMask`. `QDrag::exec` needs the platform's drag loop, which `offscreen` has not: `LayerDragGestureTests` defines both `QDrag::exec` overloads in the test executable, where they take libQt6Gui's place (as `ProjectReplaceTests` does with libc), record what the drag offered and return `IgnoreAction`, so presses, thresholds and actions are tested to the call; `LayerDragTests` holds the drops and `LayerDragFixtures.h` what both share. The eye and thumbnail buttons live in `NativeLayerList+Buttons.cpp`. With Alt held the viewport's cursor follows the pointer (`cursorFor`): the clipping cursor over a row's strip (create or release, drawn as paths like the symbols, refused where `canToggleClippingMask` says no), the duplicate cursor over the rest of a row (a folder's strip included, since a folder clips nothing, 12.2a) and over a mask thumbnail, the arrow otherwise and with Ctrl; Alt or Ctrl pressed anywhere, and hover over any row or control (every cell and its children track the mouse), reach an application event filter installed while the list is shown, as Swift's `flagsChanged` monitor and tracking area; the list keeps the last hover point, so a modifier change is judged where the pointer sits (`QCursor::pos` is no witness offscreen), and a hover's own modifiers are the event's.
- `EditorSession::duplicateActiveLayer` and `duplicateLayer` (`Document/SelectionClipboard.cpp`, the rest of that extension arrives with 7.4) came forward from 5.1 for the Alt-drag: the copy is the layer with a new id and " copy" after its name, inserted above it, one `Duplicate Layer` step; since 12.2a a folder copies with every layer inside it, new ids throughout, parents and clipping links inside the copy pointing inside it and those outside kept, a collapsed folder's copy collapsed, and nothing past 10,000 layers; `duplicateLayer` selects, duplicates and places the copy inside one transaction, refusing a place `canPlaceLayer` refuses (which reads `canEditLayers`, Swift's first term) and answering false when the limit made no copy. - `EditorSession::duplicateActiveLayer` and `duplicateLayer` (`Document/SelectionClipboard.cpp`, the rest of that extension arrives with 7.4) came forward from 5.1 for the Alt-drag: the copy is the layer with a new id and " copy" after its name, inserted above it (`insertCopy`); since 12.10b `duplicateActiveLayer` is `duplicateLayers` of every selected layer (none inside a selected folder, in document order: `copiedLayerIDs`; Swift's union with the active layer is dead and left out, since `selectLayers` always keeps the active layer selected), one step, where several copies stack together above the topmost original in the panel's order, in its parent, and end up selected, the active one's copy active; since 12.2a a folder copies with every layer inside it, new ids throughout, parents and clipping links inside the copy pointing inside it and those outside kept, a collapsed folder's copy collapsed, and nothing past 10,000 layers; `duplicateLayer` selects, duplicates and places the copy inside one transaction, refusing a place `canPlaceLayer` refuses (which reads `canEditLayers`, Swift's first term) and answering false when the limit made no copy.
- `LayerMerge.cpp`, `LayerFlip.cpp` and `Filters.cpp` twin the Swift files of those names (`Filters.cpp` holds `PixelFilter::trimmed` alone until 9.x). `trimmed` returns a `Trimmed` struct where Swift returns a tuple, and reads the alpha bounds straight from a premultiplied `RGBA8888` image, drawing any other format into one first, where Swift always draws into a context. `mergePlan` is a private `MergePlan` struct. `mergeLayers` renders through `drawLiveComposite` into a document-sized context and takes the trimmed image's own size through `ImageLayer(asset, origin)`, where Swift also assigns `trimmed.transform`: at 1:1 the two are equal by construction. Its insertion index counts the kept layers before the anchor, so Swift's `?? layers.count` and the clamp are dead and left out; the hierarchy check stays (a folder selected together with its own child would put the result in a removed folder), and Swift's beeps are warnings on `omaphoto.rendering`, as is a context that cannot be allocated (Swift returns in silence). `flipLayers` reads the group box and the active layer through `value()`, since `canTransform` has proved both, and drops Swift's `ids.isEmpty` guard for the same reason; `commitTransform` has already run `finishOpacityEdit`, so Swift's second call in `mergeLayers` and `flipLayers` is left out. `LayerTransform::mirrored` is declared in `LayerTransform.h` and defined in `LayerFlip.cpp`, as Swift's extension. `beginDuplicateTransform` keeps a private `TransformDuplicate`; a selected folder carries its selected contents, which are no targets of their own, and since the layer limit can refuse a copy, Swift's `copies.isEmpty` branch (the step closed, the selection back) and its `copy != id` test are kept (12.2a); a cancel removes each copy with its contents and their collapsed marks. `commitTransform` ends the duplicate after its own transaction (`endDuplicateTransform`, Swift's `defer`) on the layer path only: `selectLayers` has just made a copy active, so no mask is selected and the mask path never carries one. `cancelTransform` removes the copies, restores the selection and ends it. The Layer menu's Duplicate Layer entry calls `duplicateActiveLayer`, what Swift's `layerViaCopy` does without a selection (7.4 brings the selection); Merge shows `mergeTitle` and Flip Layer follows `canTransform`. - `LayerMerge.cpp`, `LayerFlip.cpp` and `Filters.cpp` twin the Swift files of those names (`Filters.cpp` holds `PixelFilter::trimmed` alone until 9.x). `trimmed` returns a `Trimmed` struct where Swift returns a tuple, and reads the alpha bounds straight from a premultiplied `RGBA8888` image, drawing any other format into one first, where Swift always draws into a context. `mergePlan` is a private `MergePlan` struct. `mergeLayers` renders through `drawLiveComposite` into a document-sized context and takes the trimmed image's own size through `ImageLayer(asset, origin)`, where Swift also assigns `trimmed.transform`: at 1:1 the two are equal by construction. Its insertion index counts the kept layers before the anchor, so Swift's `?? layers.count` and the clamp are dead and left out; the hierarchy check stays (a folder selected together with its own child would put the result in a removed folder), and Swift's beeps are warnings on `omaphoto.rendering`, as is a context that cannot be allocated (Swift returns in silence). `flipLayers` reads the group box and the active layer through `value()`, since `canTransform` has proved both, and drops Swift's `ids.isEmpty` guard for the same reason; `commitTransform` has already run `finishOpacityEdit`, so Swift's second call in `mergeLayers` and `flipLayers` is left out. `LayerTransform::mirrored` is declared in `LayerTransform.h` and defined in `LayerFlip.cpp`, as Swift's extension. `beginDuplicateTransform` keeps a private `TransformDuplicate`; a selected folder carries its selected contents, which are no targets of their own, and since the layer limit can refuse a copy, Swift's `copies.isEmpty` branch (the step closed, the selection back) is kept (12.2a; the copies are found by the selection since 12.10b); a cancel removes each copy with its contents and their collapsed marks. `commitTransform` ends the duplicate after its own transaction (`endDuplicateTransform`, Swift's `defer`) on the layer path only: `selectLayers` has just made a copy active, so no mask is selected and the mask path never carries one. `cancelTransform` removes the copies, restores the selection and ends it. The Layer menu's Duplicate Layer entry calls `duplicateActiveLayer`, what Swift's `layerViaCopy` does without a selection (7.4 brings the selection); Merge shows `mergeTitle` and Flip Layer follows `canTransform`.
- The Move tool on the canvas (6.1): `TransformOverlay` is no widget. Swift's overlay view exists so that a selection never redraws the pixels; a transparent Qt child would repaint the canvas beneath it anyway, so the canvas draws the handles and guides itself, after its pixels, and `synchronizeDisplay` repaints only the union of the old and the new `drawnRect` (a guide spans the canvas) when the geometry or `snapGuides` changed, leaving its return value to the pixel state. `TransformOverlayGeometry` keeps Swift's handles, rotation grip and `hit`; `showsRotation` and the corner constructor arrive with 6.2. `resizeCursor` maps Swift's frame positions onto Qt's four resize shapes (right, bottom right, bottom, top right). `EditorCanvas+Transform.cpp` holds the drags (`beginTransformDrag`, `dragTransform`, `endTransformDrag`, `cancelTransformDrag`, Swift's `resignFirstResponder`), `transformPressLayer` and `transformCursor`; Ctrl stands for Cmd in picking, Ctrl+Shift extends the selection. The drag's cursor is kept in `m_dragCursor` until the release and outranks Space, where Swift disables the window's cursor rects; hover reaches `updateCursor` through mouse tracking and the last hover point, the application filter reads Alt and Ctrl as it read Alt, and a shown canvas reads both from the keyboard. The release synchronizes the display, so a Ctrl+T edit's guides go without a commit. `moveCursor` (the arrow with Swift's four-way badge) and `rotationCursor` (two arrows round a ring, for the SF symbol) are drawn as paths; the distort cursor arrives with 6.2. `SnapGuides` is a struct where Swift has a tuple, a public member like `viewport`, unannounced; `commitTransform` and `cancelTransform` clear it. `transformSnapTargets` and `snappedMove` live in `Document/Crop.cpp`, Swift's file. `TransformEdit.persistent` sits last, so the positional initialisers keep their order; a drag's edit and a duplicate's are not persistent. `transformAutoSelect`, `showsTransformControls` and `locksTransformRatio` are getters and setters that notify. Escape and Return end a drag with its edit and put the hover cursor back. A press that never moves commits an unchanged edit, which the history drops. The pixel tests hide the controls (`CanvasDisplayTests`, `HighDpiCanvasTests`) and preview through a drag's edit, since Swift's overlay never reaches its `draw` tests. - 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`. - 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. - `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, 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.
- 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 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 Magic Wand (7.3): `WandPixels.c` compiles in place beside the other kernels, and `Document/MagicWand.cpp` wraps it as Swift's `MagicWand` does: `select` draws the image into a premultiplied `RGBA8888` context (the kernel's byte order, rows top down), floors the point, clamps the tolerance and hands the mask to `wand_mask`; `outline` frees the kernel's arrays through `unique_ptr` and builds a `WindingFill` path from its loops. `MagicWandError` carries Swift's two messages (`what()`), `tooDetailed` for the kernel's −2 and `memory` for −1. The session's `magicWand` takes a completion callback and runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, like the invert; the worker catches `MagicWandError` and `ExportError`, and the UI side applies the outline only while the same document is open (its id, as Swift compares), New with no match deselecting, New with a match `setSelection` without the canvas clip (Swift's shortcut), the other modes `applySelection`. `wandSample` draws the composite through `drawLiveComposite` or the active layer's own pixels through `LayerRenderer::draw` at 1:1, a folder or blank layer as clear; a context that cannot be allocated logs and refuses, as Swift's `try?` returns nil. `wandSettings` is a struct with a setter that notifies; `title` and `radius` stand for Swift's computed properties of `WandSampleSize`. The canvas hands a wand press to `magicWand` with the press's mode, and a click inside the selection to `magicWand` in New mode where another tool would deselect. `LassoControls` shows the wand's controls with the wand alone: the tolerance is a `SelectionAmountField` bounded 0–255, the sample size a `QComboBox` of Swift's titles, the source two checkable buttons, Contiguous a `QCheckBox`; `changeWand` rewrites one field of the settings. The mutant that pins `m_thisLayer` checked is equivalent, since the exclusive group's later `m_allLayers` line decides, as with the marquee's kinds.
- The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and counts the clipboard's `dataChanged` signals from the first copy as Swift reads the pasteboard's change count: a paste puts our pixels back in place while the count still matches, else takes another app's image through `QMimeData::imageData`, converted to sRGB first when it carries another colour space (CoreGraphics converts as it draws; `QPainter` copies the numbers), then to the working format, tagged sRGB and centred. A cleared clipboard has no `QMimeData` at all, so `canPaste` checks the pointer. A working copy that cannot be allocated is a warning, as Swift's `try?` beeps there, where a failed Layer via Copy sets `brushError` as Swift's `catch` does. The clipboard is touched only by a copy or a paste, so session tests without one run under `QTEST_GUILESS_MAIN` still; `SelectionClipboardTests` runs under `QTEST_MAIN`. `addPixelLayer`'s shape and text arrived with 8.8 and 8.9a. Swift's `cutSelection`, `PixelMove` and the whole `FloatingSelection` extension need `clearSelectedPixels` and the brush raster, so the cut arrived with 8.2 and the rest with 8.2b, where `canEditLayers` and `ProjectWorkspace::canSwitch` gained their `pixelMove` terms. The Edit menu's Copy and Paste check when chosen, as Swift's (a text field keeps its own, as with Select All); Copy Merged follows `canCopyMerged`; the Layer menu's Ctrl+J is `layerViaCopy`, titled by the selection and enabled as Swift's: `canCopyPixels`, or no selection on an editable layer that is no folder. Swift's beeps are warnings on `omaphoto.app`. - 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. - 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 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), `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
@@ -76,14 +80,14 @@ Platform adaptations, each forced by a missing Qt analog:
- 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. - 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. - Desktop integration (11.2): the app id is `io.github.ZacharyZhang_NY.OmaPhoto` (Compositor's was its bundle identifier, `com.wonderassembly.compositor`), which names the desktop entry, the icon and the MIME package, and which `main` gives Qt as the desktop file name, so a Wayland compositor matches the window to its entry and icon; on X11 Qt 6.4 names the window class after the application (`omaphoto`, `OmaPhoto`), so the entry's `StartupWMClass` is `OmaPhoto`. `packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop` launches `omaphoto %F` (files named at launch reach `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate) and lists Swift's two document types: its projects (`application/x-compositor-project`) and JPEG, PNG, HEIC and TIFF. `io.github.ZacharyZhang_NY.OmaPhoto.xml` declares the project type for shared-mime-info, a folder (`inode/directory`) named `*.comp`, commented "OmaPhoto Project", where Swift says "Compositor Project"; a file manager may still open such a folder as a folder, since a project is a directory on Linux. `cmake --install` lays down the app, the entry, the MIME package, Compositor's app icon (`packaging/icons`) at six hicolor sizes (16 to 512) and U²-Net's licence under `share/licenses/omaphoto`; `OMAPHOTO_MODEL`, a path, installs the model as `share/omaphoto/u2net.onnx` whatever its own name, the file itself when given a link (`file(REAL_PATH)`), where `SubjectRemoval` looks, and packages pass it (11.3). A direct install (no `DESTDIR`) runs `update-mime-database` on its prefix, required, so the project type registers at once; a staged install leaves that to the package's hooks. `DesktopIntegrationTests` reads both files, stages an install and compares the whole file list (the model included when the build was configured with one, read from its cache), and installs directly with a stand-in `update-mime-database` first on the path and any `DESTDIR` of the caller cleared; the call in `main` is not under test.
- Packages (11.3): one CMake install serves every package. It lays down the app, its entry, MIME package, icons and both licences (the app's MIT `LICENSE` and U²-Net's, under `share/licenses/omaphoto`), and `OMAPHOTO_MODEL` adds the model. `OMAPHOTO_BUNDLE_ORT` (off by default) installs ONNX Runtime beside the app in `lib/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. - 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 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|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.
- 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. - 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). - 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`: sigma a tenth of the brush within 1.5 and 30; the layer blurred with clear past the document's edge; a mask on its edge tone, its placed box black under its coverage, blurred with the edge repeated. Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact; it costs 20–110 ms for a 4K document on 32 cores (sigma 1.5 to 30), paid as a Blur stroke begins, since Swift's sample is taken whole. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` and `blurSample` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre).
- The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr<BrushStroke>`, as a pixel move's, because the canvas draws it while the commit renders, and the raster's pointer stands for Swift's `gradientEdit === edit` (a new line on the same target keeps the raster, as Swift keeps the edit). `commitGradient` takes a completion callback, posted on every path; `resolveGradient` posts the commit, Swift's `Task`, so a switch of tool, layer or target applies it once the caller returns; `invertPixels` and `beginFilter` commit it first and call themselves again from its callback, as Swift awaits it. `fillGradient` fills the canvas through `paintCanvas` with a `QLinearGradient`, or a `QRadialGradient` centred on the start with the line as its radius; Qt's pad spread is CoreGraphics' `drawsBeforeStartLocation` and `drawsAfterEndLocation`, and two stops interpolate alike in both. `gradientColors` makes a mask's colours gray from the palette's red, where Swift makes `DeviceGray` colours. `undo` first discards a pending gradient and `canUndo` counts one, as Swift's. The palette's model came forward from 8.10 into `ColorPalette.cpp` (`PaletteColor`, `foregroundColor` with `setForegroundColor` through `setBrushSettings`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`), and `fillSelection` reads it; the Type bar's share of `setPaletteColor` came with 8.9a, the picker and the swatches came with 8.10a. On the canvas (`EditorCanvas+Gradient.cpp`) a press within ten points of the end, then the start, grabs it, else begins a line; Shift turns the dragged end to a multiple of 45° around the other; Escape cancels and Return or Enter applies, after the selection's keys; losing focus drops the drag. `TransformOverlay` draws the line in place of the handles while one is pending, as Swift's: a radial's dashed rim at the line's length, the line black 3 under white 1, each end a white disc with a black rim, a gray ring and its colour. A pending gradient on a folder's mask clips the folder's layers at once through `liveFolderMaskClip`, as a brush stroke does. `GradientControls` is the tool's `ToolHeaderBar`: the shapes as checkable `QToolButton`s in a `QButtonGroup`, `GradientSwatch` (white, gray checks of four points, the colours, clipped to a rounded rect, the rim unclipped as Swift's `strokeBorder` overlay), the style `QComboBox`, Reverse, the slider in thousandths from 1% and the percent field (a `SelectionAmountField`, whose own clamp stands for the binding's), the Mask note in `PlaceholderText`, and Cancel and Apply while pending. Equivalent mutants: the gray branch of `gradientColors` and of `fillSelection` (a mask's palette is black or white), and the swatch's rim clipped too (3/255 at its corner pixels, measured). A press that marks a drag without an edit was equivalent until 8.10b's Alt eyedropper read the drag; a test holds it now. - The 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 Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), `installProject` loads shapes and `applyDocumentSize` does not, as Swift's rebuild leaves them out after a crop or a resize; where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing.
- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passeLine truncated - The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passeLine truncated
- The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view; the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. Each event follows `QWidgetLiLine truncated - The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view (clear glyphs since 12.12a); the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. ELine truncated
- The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (knownLine truncated - The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (knownLine truncated
- The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (Unicode space separators and the tab, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; itLine truncated - The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (Unicode space separators and the tab, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; itLine truncated
- The Eyedropper and picking (8.10b): `EditorCanvas+Picking.cpp` holds Swift's sampling. `palettePicking` is the Eyedropper, or Alt with the Brush, Spot Healing or the Gradient while no stroke runs and no gradient end is dragged; `picking` adds an open picker (the Levels and hue sample modes arrive with 9.1 and 9.3). A press while picking, Space aside, samples at once and keeps the colour it began from; a drag samples on; the release ends it and, with a picker open, hands the picker the keys back (`ColorPickerPanelController::refocus`, which the canvas includes, as Swift's calls it). `sampleColor` samples into the open picker, else sets the foreground through `setForegroundColor` while the palette can be edited (a chosen mask too, as Swift sets `foregroundColor`), and rings the point. Qt can lose a release: a move without the left button, a lost focus or the next press ends a sampling. Picking outranks every tool's press, a double click (the second click is a press, so the inline editor and a polygonal Lasso stand aside while the picker is open, Swift's `hitTest`), a hover with no button held (the eyedropper alone: the brush circle neither moves nor shows, as Swift's `mouseMoved` returns first; a right-button drag still sizes the tip, as Swift's `rightMouseDragged` ignores picking) and the cursor (after the drag cursors, before Space). `syncPicking` ends a sampling when picking begins or ends: `synchronizeDisplay` runs it, as does every modifier change (Swift's monitor), a sampling press before it begins (Alt may first show in the press, and the foreground's signal would otherwise end the new sampling) and every move (Alt let go unseen). `pickingRelease` returns nothing, where Swift returns from `mouseUp`: a sampling press begins no other gesture, so the rest of the release finds nothing to end. `SampleRingOverlay` is no widget, like `BrushCursorOverlay`: 116 points round the pointer, a 24-point ring of gray 0.45 under a 16-point one, the sampled colour on the top half and the original below, each half replacing the clip and one restore dropping it, drawn over the overlays and under the inline editor; `showsSampleRing` (a getter and a setter that notifies) hides it. The Eyedropper's bar is Swift's inline one, built in `ContentView`: the title and a "Sample Ring" `QCheckBox` 16 points on, following the session under a `QSignalBlocker`. `eyedropperCursor` draws the symbol into Swift's 20-point box of a 24-point image, sixteen white offsets of 1.25 and then black; its hot spot is the drawn tip, (5, 19), as Swift's (3, 21) is its symbol's tip. The glyph is `Rendering/EyedropperIcon`, which the rail and the cursor share, as they share `SelectionIcons`. A mounted canvas leaves the keys to an open picker (Swift's `viewDidMoveToWindow`). The application event filter moved, unchanged, beside the modifier helpers in `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. Equivalent mutant: the ring's `setBrush(Qt::NoBrush)` (the painter reaches it with no brush). - The Eyedropper and picking (8.10b): `EditorCanvas+Picking.cpp` holds Swift's sampling. `palettePicking` is the Eyedropper, or Alt with the Brush, Spot Healing or the Gradient while no stroke runs and no gradient end is dragged; `picking` adds an open picker (the Levels and hue sample modes arrive with 9.1 and 9.3). A press while picking, Space aside, samples at once and keeps the colour it began from; a drag samples on; the release ends it and, with a picker open, hands the picker the keys back (`ColorPickerPanelController::refocus`, which the canvas includes, as Swift's calls it). `sampleColor` samples into the open picker, else sets the foreground through `setForegroundColor` while the palette can be edited (a chosen mask too, as Swift sets `foregroundColor`), and rings the point. Qt can lose a release: a move without the left button, a lost focus or the next press ends a sampling. Picking outranks every tool's press, a double click (the second click is a press, so the inline editor and a polygonal Lasso stand aside while the picker is open, Swift's `hitTest`), a hover with no button held (the eyedropper alone: the brush circle neither moves nor shows, as Swift's `mouseMoved` returns first; a right-button drag still sizes the tip, as Swift's `rightMouseDragged` ignores picking) and the cursor (after the drag cursors, before Space). `syncPicking` ends a sampling when picking begins or ends: `synchronizeDisplay` runs it, as does every modifier change (Swift's monitor), a sampling press before it begins (Alt may first show in the press, and the foreground's signal would otherwise end the new sampling) and every move (Alt let go unseen). `pickingRelease` returns nothing, where Swift returns from `mouseUp`: a sampling press begins no other gesture, so the rest of the release finds nothing to end. `SampleRingOverlay` is no widget, like `BrushCursorOverlay`: 116 points round the pointer, a 24-point ring of gray 0.45 under a 16-point one, the sampled colour on the top half and the original below, each half replacing the clip and one restore dropping it, drawn over the overlays and under the inline editor; `showsSampleRing` (a getter and a setter that notifies) hides it. The Eyedropper's bar is Swift's inline one, built in `ContentView`: the title and a "Sample Ring" `QCheckBox` 16 points on, following the session under a `QSignalBlocker`. `eyedropperCursor` draws the symbol into Swift's 20-point box of a 24-point image, sixteen white offsets of 1.25 and then black; its hot spot is the drawn tip, (5, 19), as Swift's (3, 21) is its symbol's tip. The glyph is `Rendering/EyedropperIcon`, which the rail and the cursor share, as they share `SelectionIcons`. A mounted canvas leaves the keys to an open picker (Swift's `viewDidMoveToWindow`). The application event filter moved, unchanged, beside the modifier helpers in `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. Equivalent mutant: the ring's `setBrush(Qt::NoBrush)` (the painter reaches it with no brush).
@@ -91,16 +95,16 @@ Platform adaptations, each forced by a missing Qt analog:
- The Levels sheet (9.1b): `UI/LevelsSheet.h/.cpp` twin Swift's sheet; `Histogram`, `Gradient`, `Track` and `Handle` are private widgets of that file, and the histogram and the tracks follow the session's `changed()` themselves. Each handle is a 22 by 20 widget of its own, a child of the sheet placed over its track, as Swift's `ForEach` makes a view each: a later handle lies on top (white over gamma over black), a press drags only its own, each carries Swift's label, and at either end half of it lies in the sheet's padding, where a child of the track would be clipped. The output gradient is made before the output handles, so they lie on its last row as SwiftUI stacks the later view. A drag reads the pointer along its track, Swift's named coordinate space. The triangle is a path (apex 3.75, base 14.25, 12 wide, Swift's 12-point `triangle.fill`); its half-point gray shadow is a gray rim, since Qt has no blur. The histogram's colours are SwiftUI's gray and, from Apple's HIG for macOS 26 (the Swift target), its dark red, green and blue: (142, 142, 147), (255, 66, 69), (48, 209, 88), (0, 145, 255), data colours like the canvas's grays, whatever the theme. The entries are the picker's `PickerField`s: a number applies on Return or on leaving, as Swift's value binding, other text goes back, and an entry keeps its typing until then, unless its own handle is dragged, which takes no focus and replaces the typing with its value (9.6f); numbers are read and shown in the widget's locale with Swift's decimals, as Swift's `.number` format follows the user's (a German gamma reads `2,5`; no level reaches a thousand, so nothing is grouped). A click on OK takes the focus first, so a typed entry applies before the commit, where AppKit's buttons take no focus and the typing is lost; Return in an entry goes on to OK, the panel's default, as in the picker. The eyedroppers are checkable buttons set from the session, the chosen glyph in the accent and the others in the secondary ink, for Swift's `.tint`, redrawn on `PaletteChange`. The channel picker is named by its label (`setBuddy`): on Linux Qt names a combo box by its label relation, never by `accessibleName`. Alt+P is the Preview box's own shortcut in the panel and the canvas's key when the canvas has the keys. For an adjustment layer's editor the caption says the underlying pixels (9.5d). `ContentView+Panels.cpp` holds `synchronizePanels`, Swift's `onChange(of: levels == nil)`, and since 10.1 the importer and the alerts, because `ContentView.cpp` runs to 500 lines: a `FloatingPanel` named `levelsPanel` shows a new sheet when an edit opens, its close cancelling as Swift's `onClose`, and hides when the edit closes. The canvas draws `previewImage` in the layer's place after a filter's; a press in a sample mode is `sampleLevels` and hands the panel the keys (`FloatingPanel::refocus`); other presses wait for Space, the Hand or the Zoom while Levels is open; Escape, Return, Enter and Alt+P are its keys and every other key but Space passes on; `picking` reads the sample mode, so the eyedropper cursor shows; the application filter's brackets rest. `ContentView::acceptsDrop` needs no term of its own: `canSwitch` reads `canStartProjectOperation`, which reads `levels`. The Image menu's Levels… is Ctrl+L and follows `canAdjustColors` (its `hueSaturation` term arrives with 9.3); Import Images and the New canvas button rest while Levels is open. Undo and Redo are bare, since Levels closes the history, and enabled only while a field has focus, as for a busy project: Swift's bare entries act only on a text view. Equivalent mutants: the histogram's zero-peak guard and its `max(0, …)`, each standing in for the other (with no bin counted, 0/0), its `min(1, …)` (the widget clips a taller bar) and the bars' `+ 0.1` (Qt covers abutting rectangles of one path exactly); the drag's clamp of the tone, white's `max(black + 1, …)` and gamma's `min(0.999, …)` (`normalized` does each); either of a track's move and resize events alone (a layout moves and sizes it together); the canvas's `!preview` before the Levels preview (a filter edit and Levels never overlap); passing an unhandled key on (nothing above the canvas takes keys) and the base `changeEvent` (the sheet paints nothing of its own). Qt sizes the loading note itself: a shown child without a layout is adjusted to its hint. - The Levels sheet (9.1b): `UI/LevelsSheet.h/.cpp` twin Swift's sheet; `Histogram`, `Gradient`, `Track` and `Handle` are private widgets of that file, and the histogram and the tracks follow the session's `changed()` themselves. Each handle is a 22 by 20 widget of its own, a child of the sheet placed over its track, as Swift's `ForEach` makes a view each: a later handle lies on top (white over gamma over black), a press drags only its own, each carries Swift's label, and at either end half of it lies in the sheet's padding, where a child of the track would be clipped. The output gradient is made before the output handles, so they lie on its last row as SwiftUI stacks the later view. A drag reads the pointer along its track, Swift's named coordinate space. The triangle is a path (apex 3.75, base 14.25, 12 wide, Swift's 12-point `triangle.fill`); its half-point gray shadow is a gray rim, since Qt has no blur. The histogram's colours are SwiftUI's gray and, from Apple's HIG for macOS 26 (the Swift target), its dark red, green and blue: (142, 142, 147), (255, 66, 69), (48, 209, 88), (0, 145, 255), data colours like the canvas's grays, whatever the theme. The entries are the picker's `PickerField`s: a number applies on Return or on leaving, as Swift's value binding, other text goes back, and an entry keeps its typing until then, unless its own handle is dragged, which takes no focus and replaces the typing with its value (9.6f); numbers are read and shown in the widget's locale with Swift's decimals, as Swift's `.number` format follows the user's (a German gamma reads `2,5`; no level reaches a thousand, so nothing is grouped). A click on OK takes the focus first, so a typed entry applies before the commit, where AppKit's buttons take no focus and the typing is lost; Return in an entry goes on to OK, the panel's default, as in the picker. The eyedroppers are checkable buttons set from the session, the chosen glyph in the accent and the others in the secondary ink, for Swift's `.tint`, redrawn on `PaletteChange`. The channel picker is named by its label (`setBuddy`): on Linux Qt names a combo box by its label relation, never by `accessibleName`. Alt+P is the Preview box's own shortcut in the panel and the canvas's key when the canvas has the keys. For an adjustment layer's editor the caption says the underlying pixels (9.5d). `ContentView+Panels.cpp` holds `synchronizePanels`, Swift's `onChange(of: levels == nil)`, and since 10.1 the importer and the alerts, because `ContentView.cpp` runs to 500 lines: a `FloatingPanel` named `levelsPanel` shows a new sheet when an edit opens, its close cancelling as Swift's `onClose`, and hides when the edit closes. The canvas draws `previewImage` in the layer's place after a filter's; a press in a sample mode is `sampleLevels` and hands the panel the keys (`FloatingPanel::refocus`); other presses wait for Space, the Hand or the Zoom while Levels is open; Escape, Return, Enter and Alt+P are its keys and every other key but Space passes on; `picking` reads the sample mode, so the eyedropper cursor shows; the application filter's brackets rest. `ContentView::acceptsDrop` needs no term of its own: `canSwitch` reads `canStartProjectOperation`, which reads `levels`. The Image menu's Levels… is Ctrl+L and follows `canAdjustColors` (its `hueSaturation` term arrives with 9.3); Import Images and the New canvas button rest while Levels is open. Undo and Redo are bare, since Levels closes the history, and enabled only while a field has focus, as for a busy project: Swift's bare entries act only on a text view. Equivalent mutants: the histogram's zero-peak guard and its `max(0, …)`, each standing in for the other (with no bin counted, 0/0), its `min(1, …)` (the widget clips a taller bar) and the bars' `+ 0.1` (Qt covers abutting rectangles of one path exactly); the drag's clamp of the tone, white's `max(black + 1, …)` and gamma's `min(0.999, …)` (`normalized` does each); either of a track's move and resize events alone (a layout moves and sizes it together); the canvas's `!preview` before the Levels preview (a filter edit and Levels never overlap); passing an unhandled key on (nothing above the canvas takes keys) and the base `changeEvent` (the sheet paints nothing of its own). Qt sizes the loading note itself: a shown child without a layout is adjusted to its hint.
- Curves and the image adjustments (9.2): `Document/Curves.h/.cpp` and `Document/ImageAdjustments.h/.cpp` twin Swift's files, `UI/CurvesControls.h/.cpp` Swift's view. `AdjustPixels.c` was already compiled for the downsample cache; Curves and Exposure run `levels_apply` from `LevelsPixels.c`, as Swift's do. `CurvesSettings.channels` is a vector of point vectors, as Swift's array, so `isValid` still counts four. `value` finds Swift's `lastIndex(where:)` with a loop; a NaN input takes the first segment at t = 0, as Swift's `max(0, .nan)` gives 0. `ImageAdjustmentPixels::run` hands its body the premultiplied RGBA context `BrushRaster::context` makes and returns that context. `AdjustmentColor` keeps Swift's two initialisers as constructors; the palette's has its callers since 9.4a (a Gradient Map starts from the palette, its picker) and gains 9.5's (new layers), as `normalized` serves the filter's settings since 9.4a and the adjustment's with 9.5; `ends`, which `apply` reads, gains the filter sheet as a reader with 9.4b and returns an `Ends` struct for Swift's tuple. `GrainSettings::apply` takes an optional seed that replaces the stored one and hands the same image back when the amount is 0. Left out as dead: the `isFinite` terms of `AdjustmentColor.isValid` and of the curves' points (the ranges refuse NaN and infinities), and the points' x range (ends at 0 and 255 with x strictly rising keep every x inside). `CurvesControls` takes Swift's binding as a value and a change function and shows the settings on `synchronize`, which each of its own edits calls and its owner will (9.4's filter sheet); a channel shown anew drops the selection and the drag, Swift's `onChange`. The graph (`curvesGraph`) is a private widget 260 tall: black at 35%, white quarter lines at 12%, the curve two wide, 8-point dots with the chosen one in the accent; presses and drags follow Swift's gesture (a point within 14 curve units is taken, else one is added between x 1 and 254, more than a unit from the others, 32 at most; an inner point keeps a unit from its neighbours; the left button alone). Remove point keeps Swift's guard (an inner point that is still there), so a selection another change has left behind removes nothing. The readout (`curvesReadout`) truncates as Swift's `Int(…)`; SwiftUI's `monospacedDigit` has no Qt 6.4 twin (font features arrive in 6.6), so the digits are the font's own. Carried from 9.1b's review: the Type bar's size and tracking read and print through its locale, as Swift's `.number` fields, without group separators, as every field of the twin; leading keeps `Double(text)`'s period, as Swift's. Equivalent mutants: the curves' `2…` lower count (one point cannot hold both ends), `<` for `<=` in the segment search (the curve is continuous at its points), `< 0` for `<= 0` in the inner slope (a zero slope's reciprocal is infinite, so the harmonic mean is zero anyway) and the final 0…255 clamp (Swift's slopes never overshoot a valid curve); Exposure's `max(0, …)` before the gamma and its table's lower clamp (a negative light ends at zero either way), the gradient map's byte clamp (its colours lie in 0…1); the controls' `x > 1` and `x < 254` (a point more than a unit from both ends lies inside them) and Remove's upper bound (Remove rests on the last point). - Curves and the image adjustments (9.2): `Document/Curves.h/.cpp` and `Document/ImageAdjustments.h/.cpp` twin Swift's files, `UI/CurvesControls.h/.cpp` Swift's view. `AdjustPixels.c` was already compiled for the downsample cache; Curves and Exposure run `levels_apply` from `LevelsPixels.c`, as Swift's do. `CurvesSettings.channels` is a vector of point vectors, as Swift's array, so `isValid` still counts four. `value` finds Swift's `lastIndex(where:)` with a loop; a NaN input takes the first segment at t = 0, as Swift's `max(0, .nan)` gives 0. `ImageAdjustmentPixels::run` hands its body the premultiplied RGBA context `BrushRaster::context` makes and returns that context. `AdjustmentColor` keeps Swift's two initialisers as constructors; the palette's has its callers since 9.4a (a Gradient Map starts from the palette, its picker) and gains 9.5's (new layers), as `normalized` serves the filter's settings since 9.4a and the adjustment's with 9.5; `ends`, which `apply` reads, gains the filter sheet as a reader with 9.4b and returns an `Ends` struct for Swift's tuple. `GrainSettings::apply` takes an optional seed that replaces the stored one and hands the same image back when the amount is 0. Left out as dead: the `isFinite` terms of `AdjustmentColor.isValid` and of the curves' points (the ranges refuse NaN and infinities), and the points' x range (ends at 0 and 255 with x strictly rising keep every x inside). `CurvesControls` takes Swift's binding as a value and a change function and shows the settings on `synchronize`, which each of its own edits calls and its owner will (9.4's filter sheet); a channel shown anew drops the selection and the drag, Swift's `onChange`. The graph (`curvesGraph`) is a private widget 260 tall: black at 35%, white quarter lines at 12%, the curve two wide, 8-point dots with the chosen one in the accent; presses and drags follow Swift's gesture (a point within 14 curve units is taken, else one is added between x 1 and 254, more than a unit from the others, 32 at most; an inner point keeps a unit from its neighbours; the left button alone). Remove point keeps Swift's guard (an inner point that is still there), so a selection another change has left behind removes nothing. The readout (`curvesReadout`) truncates as Swift's `Int(…)`; SwiftUI's `monospacedDigit` has no Qt 6.4 twin (font features arrive in 6.6), so the digits are the font's own. Carried from 9.1b's review: the Type bar's size and tracking read and print through its locale, as Swift's `.number` fields, without group separators, as every field of the twin; leading keeps `Double(text)`'s period, as Swift's. Equivalent mutants: the curves' `2…` lower count (one point cannot hold both ends), `<` for `<=` in the segment search (the curve is continuous at its points), `< 0` for `<= 0` in the inner slope (a zero slope's reciprocal is infinite, so the harmonic mean is zero anyway) and the final 0…255 clamp (Swift's slopes never overshoot a valid curve); Exposure's `max(0, …)` before the gamma and its table's lower clamp (a negative light ends at zero either way), the gradient map's byte clamp (its colours lie in 0…1); the controls' `x > 1` and `x < 254` (a point more than a unit from both ends lies inside them) and Remove's upper bound (Remove rests on the last point).
- Hue/Saturation in the session (9.3a): `Document/HueSaturation.h` twins Swift's types. Swift's file runs to 574 lines, so the twin splits it: `HueSaturation.cpp` (ranges, bands, sample modes, settings, the edit), `HueSaturation+Filter.cpp` (the cube) and `HueSaturation+Session.cpp` (the session extension, `canAdjustColors` with it; `HueSaturation+Session.h` its fragment). `ColorRange` and `HueSampleMode` keep Swift's raw values and case order (`allColorRanges`, `colorRanges`, `allHueSampleModes`); `defaultBand` is a free function where Swift has a property, and `help` gives the modes' help; their SF Symbols, the eyedropper and its badges, are the sheet's to draw (9.3b). `HueSaturationSettings` keeps Swift's dictionaries as `std::map`s, so the constructor's entry for its range and the setters' inserts compare as Swift's do; Swift's computed `hue`, `saturation`, `lightness` and `band` are getter and setter pairs. `HueSaturationFilter` is a namespace; `HueResponse` and `Color` stand for Swift's tuples, and `cube` returns the floats Swift packs into `Data`. Core Image's `CIColorCube` becomes a CPU lookup, rows in parallel: each pixel unpremultiplied, read trilinearly from the 33-point cube, premultiplied again and rounded once; Core Image's own precision may differ by a level, and Swift's tests allow eight. A failed table or row allocation is `ExportError(render)`, since a worker's `bad_alloc` would pass its catch; `HueSaturationFailureTests` reaches that net through a picture one pixel wide, whose row list asks as much as its pixels. A selection blends the result over the job's own pixels, as the Levels filter does, where Swift draws them into a context first: the same bytes, since layer pixels are that context's format. `HueSaturationEdit` is a class in a `std::optional`, as `LevelsEdit`, its `id` standing for Swift's identity; its preview source is made as the Levels edit's (to 8000 pixels a side, from a painted layer's tiles without flattening). The session keeps a watcher for previews and one for the commit. Where Swift cancels the preview task (Preview off, nothing to show, OK) the twin forgets whose it is, so that result drops; a result for a closed edit fails the id check, Swift's `hueSaturation === edit`. A run counts as running until its finish has run (`m_hueSaturationRendering`, as Levels' queue), so a failure is reported even when the next job waits. Known difference: Swift's run for a closed edit returns before it looks at the queue, so a job queued behind it waits for the next change; the twin's runs it. Known difference: Swift's task never looks at its cancellation, so a render still running when Preview goes off, or when OK is pressed, lands. A failed render sets `brushError` whichever edit asked, as Swift's `adjustedPixels` does. Known difference: Swift's `beginHueSaturation` resolves a pending gradient without waiting, so its edit holds the pixels from before the gradient and the commit, finding the layer changed, lands nothing; the twin waits for the gradient, as `beginLevels` does. `commitHueSaturation` refuses while a commit runs (Swift has no guard; its second commit would find the layer changed and land nothing), keeps a copy of the edit for the finish (`CommittingHue`), frees the project before the edit goes (Swift's two `defer`s) and names the new asset after the layer; the rebuild drops shape and text. `sampledHue` reads the composite through `PickerHSB`, as Swift's; `beginHueTargeting` takes Swift's `max(by:)`, the first of the strongest claims. `setHueSampleMode` and `setHueTargeting` are the sheet's writes and notify on a change; `endHueTargeting` changes nothing observable and stays silent. Swift's beeps are warnings on `omaphoto.app`. Left out as dead: `beginHueSaturation`'s `document` and `activeLayer` guard (`canAdjustColors` reads both) and the targeting's `?? .reds` (the ranges are never empty). Equivalent mutants: the weight's fallbacks for ramps of no width (no hue lies inside one) and its `<=` at the plateau's end (both sides give 1); `toHSL`'s cap on saturation, `toRGB`'s gray return and clamp (the arithmetic stays within 0 and 1 but for rounding, which later clamps absorb); a clear pixel's fill (its lookup is multiplied by alpha 0); the response's skips of a still range or a zero weight (each adds nothing); the saturation's floor of zero (`toRGB` draws gray at or below it); the amount's clamps and the lightness clamp (a sum past ±100 reaches white or black either way). The hooks: `canEditLayers`, `canContentAwareFill` (so `beginFilter`), `beginLevels` and `ProjectWorkspace::canSwitch` read `hueSaturation`; `canUseHistory`, `canStartProjectOperation`, `selectLayer` and `selectTool` do not, as in Swift. - Hue/Saturation in the session (9.3a): `Document/HueSaturation.h` twins Swift's types. Swift's file runs to 574 lines, so the twin splits it: `HueSaturation.cpp` (ranges, bands, sample modes, settings, the edit), `HueSaturation+Filter.cpp` (the cube) and `HueSaturation+Session.cpp` (the session extension, `canAdjustColors` with it; `HueSaturation+Session.h` its fragment). `ColorRange` and `HueSampleMode` keep Swift's raw values and case order (`allColorRanges`, `colorRanges`, `allHueSampleModes`); `defaultBand` is a free function where Swift has a property, and `help` gives the modes' help; their SF Symbols, the eyedropper and its badges, are the sheet's to draw (9.3b). `HueSaturationSettings` keeps Swift's dictionaries as `std::map`s, so the constructor's entry for its range and the setters' inserts compare as Swift's do; Swift's computed `hue`, `saturation`, `lightness` and `band` are getter and setter pairs. `HueSaturationFilter` is a namespace; `HueResponse` and `Color` stand for Swift's tuples, and `cube` returns the floats Swift packs into `Data`. Core Image's `CIColorCube` becomes a CPU lookup, rows in parallel: each pixel unpremultiplied, read trilinearly from the 33-point cube, premultiplied again and rounded once; Core Image's own precision may differ by a level, and Swift's tests allow eight. A failed table or row allocation is `ExportError(render)`, since a worker's `bad_alloc` would pass its catch; `HueSaturationFailureTests` reaches that net through a picture one pixel wide, whose row list asks as much as its pixels. A selection blends the result over the job's own pixels, as the Levels filter does, where Swift draws them into a context first: the same bytes, since layer pixels are that context's format. `HueSaturationEdit` is a class in a `std::optional`, as `LevelsEdit`, its `id` standing for Swift's identity; its preview source is made as the Levels edit's (to 8000 pixels a side, from a painted layer's tiles without flattening). The session keeps a watcher for previews and one for the commit. Where Swift cancels the preview task (Preview off, nothing to show, OK) the twin forgets whose it is, so that result drops; a result for a closed edit fails the id check, Swift's `hueSaturation === edit`. A run counts as running until its finish has run (`m_hueSaturationRendering`, as Levels' queue), so a failure is reported even when the next job waits. Known difference: Swift's run for a closed edit returns before it looks at the queue, so a job queued behind it waits for the next change; the twin's runs it. Known difference: Swift's task never looks at its cancellation, so a render still running when Preview goes off, or when OK is pressed, lands. A failed render sets `brushError` whichever edit asked, as Swift's `adjustedPixels` does. Known difference: Swift's `beginHueSaturation` resolves a pending gradient without waiting, so its edit holds the pixels from before the gradient and the commit, finding the layer changed, lands nothing; the twin waits for the gradient, as `beginLevels` does. `commitHueSaturation` refuses while a commit runs (Swift has no guard; its second commit would find the layer changed and land nothing), keeps a copy of the edit for the finish (`CommittingHue`), frees the project before the edit goes (Swift's two `defer`s) and names the new asset after the layer; the rebuild drops shape and text. `sampledHue` reads the composite through `PickerHSB`, as Swift's; `beginHueTargeting` takes Swift's `max(by:)`, the first of the strongest claims. `setHueSampleMode` and `setHueTargeting` are the sheet's writes and notify on a change; `endHueTargeting` changes nothing observable and stays silent. Swift's beeps are warnings on `omaphoto.app`. Left out as dead: `beginHueSaturation`'s `document` and `activeLayer` guard (`canAdjustColors` reads both) and the targeting's `?? .reds` (the ranges are never empty). Equivalent mutants: the weight's fallbacks for ramps of no width (no hue lies inside one) and its `<=` at the plateau's end (both sides give 1); `toHSL`'s cap on saturation, `toRGB`'s gray return and clamp (the arithmetic stays within 0 and 1 but for rounding, which later clamps absorb); a clear pixel's fill (its lookup is multiplied by alpha 0); the response's skips of a still range or a zero weight (each adds nothing); the saturation's floor of zero (`toRGB` draws gray at or below it); the amount's clamps and the lightness clamp (a sum past ±100 reaches white or black either way). The hooks: `canEditLayers`, `canContentAwareFill` (so `beginFilter`), `beginLevels` and `ProjectWorkspace::canSwitch` read `hueSaturation`; `canUseHistory`, `canStartProjectOperation`, `selectLayer` and `selectTool` do not, as in Swift.
- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Swift's sliders run freely; the twin's step in hundredths and show the session's value under `QSignalBlocker`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: a text draft and a polygonal outline can both be open under Hue/Saturation (Swift's `canAdjustColors` asks for neither). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held. - The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Since 12.14c the sliders are Camera Raw's coloured ones over a thousand steps, writing whole numbers as Swift's do, and show the session's value through `reshape`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: the Type tool can be chosen and a polygonal outline open under Hue/Saturation, but since 1.3.2 no text draft (`d448625`, 12.14d2). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held.
- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and 100 megapixels at most (`ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, 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). - 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). - 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 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. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` in `CanvasDisplayTests`.
- Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does. - Editing adjustment layers in the 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`. - 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, the filter's, Levels' and Hue/Saturation's commits, a floating merge, a copy between projects and both resizers leave them out. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy. - Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and both resizers leave them out (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy.
- Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, 100 million. Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning). - Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, one surface (200 million since 12.11a). Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning).
- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's. A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too. - Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's (the recent results and `renderNow` since 12.12a). A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too.
- `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels). - `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels).
- The canvas's effects (9.6c): `DisplayState::Layer` carries `effects`, `drawLayers` calls `prepare`, and `synchronizeDisplay` hands a finished stroke's surface to the cache as a seed first (`handOnStrokeSurface`, Swift's opening lines). `drawOwn` keeps Swift's order: the warp, the distorted effects, the distortion's preview, the effects preview, then the stroke, the shape's preview and the colour edits' previews, so a layer with effects shows its effects over its own pixels while Levels, Hue/Saturation or a filter previews it, as Swift's does (`EffectsCanvasTests` pins it). A fix beyond Swift: Swift asks the cache twice a draw, first with the mask unplaced and the stored transform, then with what the layer shows; the first request wins, so a placed mask clips the preview unplaced, and while a transform edit moves such a layer the two requests cancel each other and render without end. The twin asks once, with the shown mask and transform, before the distortion's branches, so the mask is worked out first. The drawing functions lost their `const`: they update the stroke surface and hand the cache a repaint. `drawStroke` moved to `EditorCanvas+Effects.cpp` beside `strokeSurface` and `handOnStrokeSurface`, keeping `EditorCanvas.cpp` under 500 lines. `Distort.cpp` gains `DistortEffectsCache`, both `distortedEffects` (`const` with a mutable cache, as `distortPreview`; a warp that throws leaves the effects off, Swift's `try?`) and `commitDistort`'s seed, the cache cleared after the step as Swift's `defer` clears it. Swift has no tests for any of this: `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests` and `EffectsCanvasTests` pin its code. Equivalent mutants: the pool's thread count (a result lands by its request's id, whatever the order), the destructor's `clear` and every cancelling of a superseded request, the worker's two checks included (the landing's id check drops them; they only spare renders), the matching's `&&` between two absent masks (the sources must match anyway), `!previous` before the seed (a landed result clears its seed), the resizes skipped at full size (a draw at 1:1 copies); in the surface, the reach's floor of one (an overlay reads each pixel alone) and the stroke's `+ 2` (the spread reads `lround(size)`), `inner`'s cut at the bounds (QPainter clips the draw alike), the first region's margin (the reach goes as far) and the window's skip of tiles off its region (it only spares draws); `commitDistort`'s clearing of its cache (keyed by its inputs, the cache only frees memory). `EffectsFailureTests` starves a stroke's first pass through `AddressSpaceLimit`, in a binary of its own: the canvas logs it and shows the last effects. Untested failure paths: `rendered`'s and the landing's tests of a result, and the resize's null check, differ only when a preview fails, and a preview asks too little for `AddressSpaceLimit` to reach a worker's arena. - The canvas's effects (9.6c): `DisplayState::Layer` carries `effects`, `drawLayers` calls `prepare`, and `synchronizeDisplay` hands a finished stroke's surface to the cache as a seed first (`handOnStrokeSurface`, Swift's opening lines). `drawOwn` keeps Swift's order: the warp, the distorted effects, the distortion's preview, the effects preview, then the stroke, the shape's preview and the colour edits' previews, so a layer with effects shows its effects over its own pixels while Levels, Hue/Saturation or a filter previews it, as Swift's does (`EffectsCanvasTests` pins it). A fix beyond Swift: Swift asks the cache twice a draw, first with the mask unplaced and the stored transform, then with what the layer shows; the first request wins, so a placed mask clips the preview unplaced, and while a transform edit moves such a layer the two requests cancel each other and render without end. The twin asks once, with the shown mask and transform, before the distortion's branches, so the mask is worked out first. The drawing functions lost their `const`: they update the stroke surface and hand the cache a repaint. `drawStroke` moved to `EditorCanvas+Effects.cpp` beside `strokeSurface` and `handOnStrokeSurface`, keeping `EditorCanvas.cpp` under 500 lines. `Distort.cpp` gains `DistortEffectsCache`, both `distortedEffects` (`const` with a mutable cache, as `distortPreview`; a warp that throws leaves the effects off, Swift's `try?`) and `commitDistort`'s seed, the cache cleared after the step as Swift's `defer` clears it. Swift has no tests for any of this: `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests` and `EffectsCanvasTests` pin its code. Equivalent mutants: the pool's thread count (a result lands by its request's id, whatever the order), the destructor's `clear` and every cancelling of a superseded request, the worker's two checks included (the landing's id check drops them; they only spare renders), the matching's `&&` between two absent masks (the sources must match anyway), `!previous` before the seed (a landed result clears its seed), the resizes skipped at full size (a draw at 1:1 copies); in the surface, the reach's floor of one (an overlay reads each pixel alone) and the stroke's `+ 2` (the spread reads `lround(size)`), `inner`'s cut at the bounds (QPainter clips the draw alike), the first region's margin (the reach goes as far) and the window's skip of tiles off its region (it only spares draws); `commitDistort`'s clearing of its cache (keyed by its inputs, the cache only frees memory). `EffectsFailureTests` starves a stroke's first pass through `AddressSpaceLimit`, in a binary of its own: the canvas logs it and shows the last effects. Untested failure paths: `rendered`'s and the landing's tests of a result, and the resize's null check, differ only when a preview fails, and a preview asks too little for `AddressSpaceLimit` to reach a worker's arena.
- Layer effects' panel (9.6d): `UI/EffectsSheet.h/.cpp` twin Swift's sheet: 340 points wide with 20 of padding and 16 between rows, the kind's headline (13 pixels, semibold), the stroke's Position as two touching checkable `QToolButton`s in an exclusive `QButtonGroup` (Swift's segmented picker; its hidden label is the box's accessible name), the colour as the Type bar's `SwatchButton` (36 by 18, radius 3, a half-black rim, `<Kind> color` its tooltip and accessible name, black while the effect is missing) opening `openEffectColorPicker`, Swift's `slider` rows (a 64-point title as the slider's buddy, a 130-point slider over a thousand steps of its range that sets the value unrounded, as Swift's continuous binding, a 48-point right-aligned `PickerField` with the title as its accessible name and placeholder, its unit two points after), then Cancel and OK ten points apart, OK the default button and Cancel no auto-default, so Return anywhere means OK, as Swift's `.defaultAction`. A field shows its value whole, ties to even as Swift's `.number` format rounds; it applies a readable finite number on leaving or on Return (which then goes on to OK), clamped to the slider's range as Swift's binding, reverts other text, keeps its typing while other changes arrive, and steps with Up and Down (Shift ten) from the session's value, clamped, the step replacing any typing and showing, as the picker's entries do; a change through the row's own slider (a wheel leaves the keys with the field) replaces it too. Each change is a step, as Swift's `changeEffects`. On every change of the picker's colour the sheet calls `previewEffectColor` (Swift's `onChange`), and it repaints its swatch on every change. While the layer lacks the effect the controls hide, as Swift's `if let effect`. `ContentView::synchronizePanels` first runs Swift's layers check (an edited effect gone from its layer, or the layer gone, closes an effect picker without its colour and clears `effectsEditing` and `effectsEditingOriginal`; a palette's picker stays), then shows the sheet in `effectsPanel`, titled by the kind, whenever `effectsEditing` changes (a new choice, a new sheet), its close button and Escape cancelling, and closes it when the choice clears. The footer's effects button (`sparkles` in `LayerIcons`: three four-pointed stars) is an instant-popup menu of the four kinds, each with an ellipsis, resting while `canEditEffects` does not hold. While an effect is chosen (`selectedEffect`, so a choice an undo took away names the layer again) the trash says `Delete selected effect` and the Layer menu's Delete `Delete <Kind>`. The canvas's left press lets go of a chosen effect before any guard, Swift's first line of `mouseDown`; other buttons keep it. Swift has no tests for the window's effects: `EffectsPanelTests` pins them, and `LayerEffectsSessionTests` the new writers' signals. Equivalent mutants: the fields' `OmitGroupSeparator` (no range reaches a thousand) and OK's explicit `setDefault` (a dialog makes its first auto-default button the default as it shows, and Cancel is none). - Layer effects' panel (9.6d): `UI/EffectsSheet.h/.cpp` twin Swift's sheet: 340 points wide with 20 of padding and 16 between rows, the kind's headline (13 pixels, semibold), the stroke's Position as two touching checkable `QToolButton`s in an exclusive `QButtonGroup` (Swift's segmented picker; its hidden label is the box's accessible name), the colour as the Type bar's `SwatchButton` (36 by 18, radius 3, a half-black rim, `<Kind> color` its tooltip and accessible name, black while the effect is missing) opening `openEffectColorPicker`, Swift's `slider` rows (a 64-point title as the slider's buddy, a 130-point slider over a thousand steps of its range that sets the value unrounded, as Swift's continuous binding, a 48-point right-aligned `PickerField` with the title as its accessible name and placeholder, its unit two points after), then Cancel and OK ten points apart, OK the default button and Cancel no auto-default, so Return anywhere means OK, as Swift's `.defaultAction`. A field shows its value whole, ties to even as Swift's `.number` format rounds; it applies a readable finite number on leaving or on Return (which then goes on to OK), clamped to the slider's range as Swift's binding, reverts other text, keeps its typing while other changes arrive, and steps with Up and Down (Shift ten) from the session's value, clamped, the step replacing any typing and showing, as the picker's entries do; a change through the row's own slider (a wheel leaves the keys with the field) replaces it too. Each change is a step, as Swift's `changeEffects`. On every change of the picker's colour the sheet calls `previewEffectColor` (Swift's `onChange`), and it repaints its swatch on every change. While the layer lacks the effect the controls hide, as Swift's `if let effect`. `ContentView::synchronizePanels` first runs Swift's layers check (an edited effect gone from its layer, or the layer gone, closes an effect picker without its colour and clears `effectsEditing` and `effectsEditingOriginal`; a palette's picker stays), then shows the sheet in `effectsPanel`, titled by the kind, whenever `effectsEditing` changes (a new choice, a new sheet), its close button and Escape cancelling, and closes it when the choice clears. The footer's effects button (`sparkles` in `LayerIcons`: three four-pointed stars) is an instant-popup menu of the four kinds, each with an ellipsis, resting while `canEditEffects` does not hold. While an effect is chosen (`selectedEffect`, so a choice an undo took away names the layer again) the trash says `Delete selected effect` and the Layer menu's Delete `Delete <Kind>`. The canvas's left press lets go of a chosen effect before any guard, Swift's first line of `mouseDown`; other buttons keep it. Swift has no tests for the window's effects: `EffectsPanelTests` pins them, and `LayerEffectsSessionTests` the new writers' signals. Equivalent mutants: the fields' `OmitGroupSeparator` (no range reaches a thousand) and OK's explicit `setDefault` (a dialog makes its first auto-default button the default as it shows, and Cancel is none).
@@ -108,7 +112,7 @@ Platform adaptations, each forced by a missing Qt analog:
- 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 - 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 - 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`. - 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 panel limits sit at 2,560 KB, above the snapshot's peak of 2,300 KB (768 KB and a 1,532 KB mapping, reached before the panel allocates) and one arena growth, below a copying mutant's 3,060 KB peak, measured in 12.8d, because the heap's free chunks count on top of a limit and shift with load; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a 1,532 KB mapping), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of oLine truncated - 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
- 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`. - 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. - 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 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.
@@ -124,12 +128,34 @@ Platform adaptations, each forced by a missing Qt analog:
- 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`, 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`.
- 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). - 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 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`; the track is fixed at construction: Swift's track update in `updateNSView` has no twin, since the rows whose tracks change (12.8b2's mixer) are made anew. `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 (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`. - 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. - 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. 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
- 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`.
- 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`.
- Limits, ants and wheels from Swift 1.2.8–1.2.10 (12.11a: `7dadc88`, `bc865fb`, `8eba077`, `96edd5f`). `Document/DocumentLimits` twins Swift's enum as a namespace: `maxSide` (30,000), `maxSurfacePixels` (200 million, one canvas, export, filter target, layer, mask or generated surface), `documentPixelBudget()` (every layer, and apart every mask, of a document together: a sixteenth of physical memory in bytes, Swift's `physicalMemory / 16`, read once through `sysconf`, between one surface and 800 million) and the megapixels the messages quote; `budgetFor(memory)` is the budget's formula alone, so tests reach every memory size. Swift's `maxSideExtent` and `maxSurfaceExtent` (CGFloat copies) have no twin: C++ compares across types. Each literal Swift replaced is replaced as Swift's diff does it, the surface ceiling or the document budget, and the twin-only ones as their Swift counterparts: the RAW import's remaining pixels and the effects kernel's ceiling. Messages print the numbers (`maxSideText`, English grouping as the status bar). Tests fill budgets with layers that claim pixels they do not hold (`tests/BudgetFixtures.h`: `claimed`, `claiming`), so a boundary costs no memory whatever the machine; where an import or a save would still make the pixels, a boundary is read where it is judged before any pixel: the resizer judges a layer before it looks for its image (a missing image then says the budget passed; a canvas widened in one direction puts a layer on exactly the remainder), the store judges a file by its PNG header on load (`claimingPNG`, a header with a size its pixels never fill) and by the asset's size on save, before flattening it, as Swift reads a lazy `CGImage`'s size unrendered (the twin flattened first until 12.11a; a mask both invalid and oversized is now refused as too large where Swift says invalid). Save and load count through one `checkSize(size, isMask, budgets)`, images and masks apart. Every mask the resizer passes is drawn, so its exact edge is read under `AddressSpaceLimit`: the budget passes and the surface then finds no memory (`ExportError(render)`), one pixel more is refused as too large. The marching ants (`Rendering/TransformOverlay+Ants.cpp`, Swift's level of detail): an outline past 20,000 path elements, drawn below one screen pixel a document pixel, is filled into a gray mask (at the step at least, else as fine as about 40 megapixels allows, Swift's formula and its comment's bound alike; the path's own fill rule, where Swift's are winding and the twin's paths keep theirs), averaged down to its power-of-two step (`SmoothTransformation` for CoreGraphics' medium interpolation), any coverage counted, and traced along its pixels' edges through `MagicWand::outline`, in a `QtConcurrent` job whose watcher the overlay owns: a new source or step replaces the watcher, so an older result drops, and until a step lands the last one stands in, none at first. A trace whose tracer throws (`MagicWandError`) logs on `omaphoto.rendering` where Swift's `try?` is silent. The overlay asks the canvas to repaint the ants when a trace lands (`repaintAnts`); the canvas skips a tick while its last repaint is pending (`m_antsRepaintPending`, cleared as it paints: Swift's `needsDisplay` guard). The grading wheels' direction (`96edd5f`) came with 12.8b2: Qt's conical gradient already runs counterclockwise, and `theWheelsHueRunsCounterclockwise` holds it. Equivalent mutant: the landing's repaint (the ants timer repaints the same rect within a tick while a selection shows). Tests: `DocumentLimitsTests`, `MarchingAntsTests`, and every test that pinned the old ceilings, now read from `DocumentLimits`.
- Photoshop Large Documents, background-only files and oversized layers (12.11b1, Swift 1.2.8–1.2.10: `b8e3cd1`, `e6bcfa1`, `1fcf854`). Swift's `PSDReader.swift` runs to 543 lines, so the twin splits it: `IO/PSD/PSDReader+Records.h/.cpp` hold the cursor, the raw records, their pixels and the cut (`PSDRecords`), `PSDReader.cpp` the header, the assembly and the merged image. A version-2 header is a Large Document (`Header::isPSB`): the layer section's and layer info's lengths, each channel's length and the additional-info keys Swift lists (`psbLargeKeys`) take eight bytes (`Cursor::length`, a length past `qint64` too large), and PackBits rows count in four bytes (`PSDChannelCoder::decode`'s `largeDocument`); another version is `unsupportedVersion`, whose message names no format now (Swift's words, OmaPhoto for Compositor). The import panel offers `*.psb`, a dropped picture prefers the PSB type right after PSD's (`ImageFileDrop`, Swift's order), and the MIME package declares `image/x-photoshop-large-image` (a `*.psb` glob and the version-2 signature as magic) for the desktop entry, as Swift's Info.plist declares the type. A file whose records do not fit the budget together (`fitsBudget`, the check `decodeChannels` makes, summed) has every layer and mask cut to the canvas (`cropToCanvas`, the bounds Swift's `crop` gives, the channels' source bounds kept) and is judged again, refused only if it still does not fit; a cut channel is decoded straight into its part (`PSDCrop`: raw rows copied in part, PackBits rows outside it skipped by their counts, one routine with the whole image as its crop for an uncut channel), and each cut layer's report says so (`PSDRecord::croppedToCanvas`, the note first in its layer's notes). A file with no layer records, Photoshop's background alone, reads as its merged image (`PSDReader::merged`, ImageIO's read for Swift): the header, its ICC profile (resource 1039) attached as Qt reads it and sRGB when it has none, the sides and the remaining budget judged from the 26-byte header before the file is read, its ICC profile (resource 1039) copied only for this read, since a layered read skips it as Swift's does, as Swift asks ImageIO's properties before it decodes (the layered canvas's one-surface ceiling does not apply, as those properties know none), the first three of its channels' planes from the image data section (every channel's row counts precede the rows), opaque; `ImageImporter::decode`'s `flattenedPhotoshop` sends it through the importer's own colour conversion and thumbnail, and when the Photoshop worker finds no records (`PhotoshopRead::layerless`) the session ends the reading first, as Swift does, then decodes the merged image through the ordinary decoder (`decode(url, flattened)`, which also takes the merged reader's `PSDError` and `ExportError`) and inserts the one layer without the conversion sheet. Known difference: ImageIO also reads 16-bit and non-RGB merged images; the twin's reader keeps the layered path's 8-bit RGB rule. The second `fitsBudget` refuses before any pixel is read (decoding the first layer could fail otherwise, and differently), and the crop's `max` cuts an inverted record to nothing, reported. A source's bounds and the cut stay 64-bit (`PSDCrop`, the coder's widths): once a cut makes a source past the side limit acceptable, its width can pass `int`, and Swift's `Int` holds it; PackBits rows decode straight into the crop's columns, so no row the width of the source is held, and the counts table is judged against the data before it is allocated. A PackBits row past the crop writes through a checked `std::span`, so a lost skip aborts. Tests: `PSBImportTests` (Swift's three; the merged image, its profile, budget and surface rule; the session's import at a point), `CropToCanvasImportTests` (Swift's five; the layers summed before any cut, a layer wholly before the left edge), the version and oversized cases in `PSDRoundTripTests` and `PSDReaderLayerTests`, the MIME package in `DesktopIntegrationTests`.
- Photoshop text (12.11b2, Swift 1.2.10: `b8cce6e`, `fa2b342`): `IO/PSD/PSDText.h/.cpp` twin Swift's file, a namespace; Swift's 682 lines split into `PSDText+Engine` (the text engine's PostScript dictionary, a `std::variant` tree read through `walk`, `number`, `boolean`, `string` and `array`) and `PSDText+Descriptor` (Adobe's action descriptors, `Reader` and the item lookups; a rectangle is a normalised `QRectF`, and a side's key is chosen by presence before its type is read, Swift's `??`). A type layer (`TySh`, else `tySh`; 8,000,000 bytes at most) reads as Swift's: version 1, a finite matrix, version 50, the text descriptor; vertical text, shear and uneven scale refuse (the matrix's scale alone maps engine sizes to pixels, never the document's resolution); a warp other than none, faux bold or italic, runs whose twelve-field signature differs from the first, and justification other than left, right or centre each add Swift's note; the words come from `Txt ` (else `Txt`), cleaned of a leading BOM or NUL, trailing NULs and CR line ends, and only when that item is missing from the engine's `EngineDict/Editor/Text`; 1 to 100,000 UTF-16 units. A frame (`bounds`) wider and taller than the letters (`boundingBox`) by more than four makes box text, its corner mapped through the matrix; rectangles read standardised, as `CGRect` reads them. `render` draws through `EditorSession::textImage` and places the anchor (the frame's padded corner, or point text's first baseline at its alignment's x, `TextLines::baseline`, new here) on the document point through the layer transform, a transform that is not valid being `ProjectError(tooLarge)`. The reader parses type first, so a parsed type layer never becomes a live shape or a rasterized vector mask; the builder draws the text (any `std::runtime_error` keeps the pixels with Swift's rasterized note, Swift's `try?`), reports the parser's notes and a missing face, and makes a live text layer. A missing face is one fontconfig does not list by PostScript name (`TextLayout::availableFonts`), where Swift asks `NSFont`; its note says the text was drawn with a substitute font, since Qt draws fontconfig's match where macOS draws the system font. Engine numbers read as Swift's `Double(text)` (whole token, C locale, overflow infinite, through `strtod_l`); a name with a high byte reads empty and an identifier with one refuses, Swift's ASCII decoding; an odd count of UTF-16 bytes in an engine string reads empty, and a lone surrogate in either reader becomes U+FFFD (Qt's decoder keeps it; Swift's strings repair it), a valid pair kept. Fixes beyond Swift: nesting deeper than 512 in either reader refuses the block, where Swift's recursion overflows its stack, and a font index or justification past `Int64` reads as absent (left, with the note), where Swift's `Int()` traps. Left out as dead: `placement`'s finite-scale guard (a scale over 1e-6 from finite numbers), `parse`'s font-size guard (every path clamps it), the inner frame guard (the final `isValid` refuses the same style), `baseline`'s empty sample and no-glyph fallback (the words are never empty, and point text's image holds its first line), the frame's `width > 1` and `height > 1` (each side already passes the letters' by four) and `Double(text)`'s empty token (a number starts with a byte it keeps). Equivalent mutants: `runs.size() > 1` as `> 0` (it only keeps `begin() + 1` off an empty list), `rounded`'s `Int64` bound (past it the cast is undefined, and x86 yields a value no index or justification matches), the string's end after a lone backslash (the dictionary cannot close then) and the retry's `start > 0` (a retry at zero fails again). Tests: `PSDTextTests` (Swift's ten, the builder's notes, rendered pixels and exact transform, the matrix's two per cent, each signature field, a rendered frame's corner, the baseline in ink, frames placed and flipped, the style's fallbacks, a type layer with a shape), `PSDTextReaderTests` (the engine's forms, deep nesting, every descriptor type and cap, the list and reference counts, nesting and names, lone surrogates, colours in one, three and four channels, each channel as a second style, the sources' precedence, rounding, scaled leading, the `Txt` key, negative exponents, both versions and the limits at their edges); `PSDFixture+Text.h` twins `tySh`, and `PSDFixture::data` now takes several additional blocks.
- Text at the click, text on the Move tool's double click, and SVG (12.11c, Swift 1.2.10: `292cb6b`, `072fa08`, `a5721b2`, `0534e00`): a click with the Type tool puts new text's first baseline on the pointer, the box its padding to the left and its first baseline above (`TextLines::baseline(0)` of the style, the laid line's descent, where Swift reads the font's descender: they agree within a hundredth of a pixel); a dragged box sits exactly where it was drawn. Swift's `setHiddenUntilMouseMoves(false)` has no twin: Qt never hides the pointer while typing. The canvas's press is `press(event, clicks)`, Swift's `mouseDown` with its click count: `mousePressEvent` passes one and a double click's fall-through two, so a triple click counts one (the Type tool keeps its own count); with the Move tool a second click on the topmost shown live text under the pointer (a hidden folder hides what it holds) runs `commitTransform`, selects it, opens its editor and synchronizes the inline editor (`beginLiveTextEdit`, in `EditorCanvas+Transform.cpp`), else the press goes on to guides and the transform drag. No transform edit is open there (`canEditLayers`), but `commitTransform` also ends an opacity drag and a blend preview, which `selectLayer` leaves when the text is already active. In the layer list a live text row's size column says "Text · Double-click to edit"; a double click on its thumbnail opens it and on its name renames it, as Swift's merge (`3258c66`) kept, where the branch commit opened text anywhere. Opening existing text puts the caret after it once the editor takes the keys, unless a click already placed it (that merge's step in `InlineTextEditor.synchronize`): the queued focus request moves an untouched caret (0, 0) to the end, for a draft on a layer alone; the editor keeps its canvas as a `CanvasView` to reach itself there. SVG goes by its suffix, `svg` or `svgz` in any case (the importer's filter lists both, the desktop entry `image/svg+xml` and `image/svg+xml-compressed`, which shared-mime-info keeps apart) (Swift's `UTType.svg`), after camera raw and before Photoshop's bytes, through the ordinary decoder with the canvas's size: `ImageImporter::decodeSVG` renders once through Qt SVG's `QSvgRenderer` (a new dependency: `qt6-svg-dev` in the dev image, `qt6-svg`, `qt6-qtsvg-devel` and `qt6.qtsvg` in the distro images, the PKGBUILD and the flake; the DEB and RPM find the library themselves), fitted to the canvas by the smaller ratio, else at the declared size, each side rounded and one pixel at least, refused past the side limit or the remaining budget, tagged sRGB, named without its suffix. Drops need nothing, since a dropped picture of any `image/` type already imports. Known differences: Qt SVG draws SVG Tiny 1.2 where macOS's renderer draws more of SVG 1.1 (filters, masks, some CSS), `defaultSize` truncates a declared size to whole pixels (40.9 by 20.9 draws 40 by 20; a side that truncates to nothing falls back to Qt's bounds, which round, so a declared side under half a pixel refuses and one from half a pixel draws a pixel wide, where Swift draws any positive side a pixel wide at least), and a drawing whose declared size is empty takes its content's bounds, where `NSImage` measures nothing; Swift's `isValid` guard is folded into the size test, since Qt measures a drawing it cannot read as nothing. Tests: Swift's two `TypeToolTests` changes and the baseline in ink (`clickedTextSitsOnItsBaseline`); `LiveTextDoubleClickTests` (zoomed and turned text, a hidden folder, the cleanup, the caret); `SVGImportTests`. Tests that set text up at a known corner use `beginTextAt` (`SessionFixtures.h`), which pins the draft's corner as Swift's clipping test does.
- Typed text as pixels, effects kept on (12.12a, Swift 1.2.11: `fe6cb2b`, `b683acb`): the canvas draws text being typed as the pixels `textImage` makes of the draft's style (remade only when the style changes and let go at the first draw without a draft, as Swift's `drawNewText` asks with no draft, each remake a debug line on `omaphoto.rendering`, a style that cannot be drawn logged and drawn as nothing), where the editor shows it: an edited layer in its place with its opacity and blend mode (its mask left off, as Swift's), new text just above the active layer, or on top when the active layer is not drawn (a folder, a hidden or blank layer, none). The editor's glyphs are clear: it draws its frame, handles, caret, marked text's underline and a selection at 45% of the highlight over those pixels. An underline without a colour of its own, the preedit's or an input method's, takes the text's colour, since clear glyphs would hide it (Swift's `NSTextView` draws marked text itself). `DisplayState` carries the draft's style and shown transform, so typing repaints the whole view, as effects reach past the box; a handle drag at a zoom can move the box a fraction with the style unchanged, so the transform counts on its own. The edited layer's effects are redone as it is typed, synchronously at the preview size (`EffectsPreviewCache::renderNow`, the worker's scaling moved into `scaled`), through its enabled mask or its placed mask clipped over the typed box; until a change is redone the last effects stand in. When the edit ends and the layer holds the text as last typed, the next draw seeds those effects (`handOnDraftEffects`); a cancel of changed text, or other text, seeds nothing. The cache keeps the last three finished results per layer: pixels an undo puts back show their effects at once, a known result clearing any seed; a mask added, removed or replaced on the same pixels keeps the last preview until the new one lands, where it dropped before. A painted mask keeps its layer's effects through the stroke: `LayerEffectsSurface::MaskStroke` holds the stroke's tiles, their map into the surface's grid and the coverage of any region (the old mask, then the tiles over it, as a tile already holds the old mask it covers; Swift's white fill beneath is dead, since past the old mask there are no pixels), and the surface redoes the regions the mapped tiles touch; a placed mask's surface keeps the layer's grid and takes coverage from `placedMaskPreview` (`placedMaskSurface`). Swift's `restoreToolCursor` has no twin: `syncPicking` already sets the cursor both ways, and a widget's cursor cannot be left over from another window. `strokeDirtyRect` moved to `EditorCanvas+Brush.cpp`, the typed text to `EditorCanvas+DraftText.cpp`. Left out as dead: the cache's filter of a result already recent (a request matching one returns before it renders). Equivalent mutant: the mapped tile rect's one-pixel inset (the redo region is widened by the effects' reach). Tests: `TypedTextCanvasTests` (Swift's `textLooksTheSameWhileEditingAndOnceCommitted` at 1.5 and 4, with the box's inside compared since the editor's frame draws on the canvas; placement above the active layer, opacity and a blend mode with and without effects, effects kept and handed on, masks linked and placed, cancelled text, remakes counted and the image let go once editing ends, typed effects redone when a handle moves a placed-mask box a fraction, the whole view repainted when typing or when a handle moves the box a fraction), `MaskEffectsCanvasTests` (linked and placed masks keep effects mid-stroke, a placed mask sixty times finer against a whole render; untouched tiles keep the old mask), `EffectsPreviewCacheTests` (recent results, `renderNow`, the looser keep), `LayerEffectsSurfaceTests` (a mask stroke redone where its tiles map), and the inline editor's tests read the 45% selection through `highlightedOver`.
- Masks painted anywhere (12.12b, Swift 1.2.11: `e753f26`, `8d2eade`, `2ffb092`, `a247676`, taken as their net change from 1.2.10): `BrushStroke` takes `growsMask`, which the Brush, the Gradient and fills (`applyPixelEdit`) pass, so on a mask their grid reaches the canvas as a pixel stroke's does; Spot Healing, Clone Stamp and the Smear keep the mask's grid. A solid placed mask (two pixels a side at most) is painted at a pixel per document pixel of its placement rather than grown from its pixel. `maskBackground` (`LayerMask::background` of the old mask's thumbnail, white or black, 1 without a mask) fills each new tile, the committed snapshot past its base and patches (`RasterSnapshot::fill`, drawn where neither reaches, the box of a mask grown by distant tiles holding such corners) and the asynchronous commit's context (`BrushCommit::Input::fill`). A mask stroke's snapshot keeps every touched tile (`committedBounds`). Committed, a mask grown past its old bounds, or placed already, takes the stroke's transform as its placement (`placedMask`, for the snapshot and the raster commit alike), keeping its place on the document while a linked one still moves with its layer. On the canvas an unplaced mask stroke draws in its possibly grown grid (`paintTransform`), a placed one in the layer's place, and a placed mask's preview and surface map through the stroke's `paintTransform`. `CanvasThumbnail::mask` fills past the mask with `LayerMask::background`, so a stroke touching the edge leaves no gray mean; the thumbnail's own `edgeTone` is gone. `8d2eade`'s canvas fix (the distortion's effects asked with the mask as stored) needs no twin: the twin asks for a layer's effects once, with the mask as shown (9.6c), and its y-flip in `LayerEffectsSurface` has no Qt counterpart. The linked mask surface's white fill (12.12a) stays dead: the layer's pixels lie only within the old mask's rect, however far the grid grows. Equivalent mutant: `placedMask`'s `mask.placement` term (a placed mask keeps its placement through `replacing`, and its transform over unchanged bounds is that placement). Tests: `MaskPaintAnywhereTests` (the brush grows a mask and keeps it linked and in place, a hide-all mask's new area hides, the Gradient and a fill grow it, the Smear's blur does not, a solid placed mask's grid (two pixels a side, a box not square, grown and not), the snapshot's and the commit's fill in uncovered corners, a grown stroke drawn in place, a grown placed mask's tiles mapped under effects), `CanvasThumbnailTests` (Swift's edge case), and `MaskEffectsCanvasTests`' finer placed mask through the stroke's transform.
- Scrubbable labels (12.12c1, Swift 1.2.11: `2d36d6e`, `680a522`): `UI/NumericScrub` twins Swift's modifier as a `QObject` living as its label's child and filtering its events; `scrubbable` and `unitSuffix(_:scrubValue:)` are its constructor on the label or unit (`Options`: sensitivity, range, optional step, value, set, onStart, onEnd). A left press on an enabled label, a double click's second press included, is taken (a disabled label's presses reach its filter before Qt drops them, so the filter asks), so the label keeps the drag past its edge; the first move off the press begins (Swift's `minimumDistance` of one, reached by any move of whole points), keeps the start value and calls `onStart`; each move sets the start plus the sideways distance (in the label's own points, which stay exact, where Qt's `notify` rounds global ones) times the sensitivity, snapped to the step when positive, then clamped; the left release calls `onEnd`. Swift's Int overload is a step of one. The cursor is the label's own `SizeHorCursor`: Qt keeps a grabbing widget's cursor through the drag and shows a disabled label's parent's, so Swift's hover and restore dance has no twin. Qt can lose a release: the left release is watched application-wide while pressed, and a move without the left button ends the drag (the label tracks the mouse, so such a move arrives). The tool bars scrub as Swift's: the Brush's Size, Hardness, Opacity or Strength and Smoothing, the Gradient's Opacity, the Magic Wand's Tolerance, Object Select's Edge, the Expand, Contract and Feather units (Expand's and Contract's rest with their rows; Feather's amount and unit stay enabled without a selection, its button alone resting, as Swift's, which the twin's amount had missed), the amount sheet's title (writing its input), the Shape's Width and Radius (a scrub writes its field's text over any typing, as a step does: `showValue` on `SelectionAmountField` and the Type bar's `TextStyleField`), the Type bar's size unit, Tracking and Leading (whole numbers), the Move bar's labels (whole numbers in Swift's new ranges: ±30,000, sides 1 to 30,000, Scale 0.1 to 30,000%, rotation ±360; the scrub writes its field's text over any typing, then the value, so an unfocused field shows what the session keeps; another active layer ends each field's drag under way, `TransformValueField::endScrub`, as it ends the opacity's), the zoom's unit (through the field's `step`) and the layer opacity (one opacity drag, `beginOpacityEdit` to `finishOpacityEdit`; another active layer ends a drag under way through `NumericScrub::end`, since the twin keeps the controls where Swift's `.id` remakes them, and later held moves wait for a fresh press). The sheets and panels follow in 12.12c2. `075a71b`'s narrowing fix in `AdjustPixels.c` is taken (the kernels match Swift's); its other changes and `725b934`'s are Swift's concurrency, plist and CI, with nothing to twin. Swift's `emptyCanvasLeavesTheScrubberCursorAloneAndRestoresTrackingForADocument` has no twin: the canvas never sets a global cursor, only its own. Equivalent mutant: removing the application filter when a drag ends (it then sees nothing to end). Tests: `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableTransformTests` (the Move bar, the zoom unit, the layer opacity), `ScrubFixtures.h`.
- Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. A sheet destroyed while one of its fields holds the focus used to commit into its destroyed members; 12.14c fixed that (`releaseFocus`), though the app hides a panel's content before deleting it anyway. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles).
- Projects changed on disk (12.13a, Swift 1.3: `ed5ddea`, `cd4061a`, `cba6efc`): `IO/ProjectWatcher` twins Swift's class with a `QFileSystemWatcher` (inotify) on the package folder, its manifest and its images folder, where Swift opens kernel vnode sources: an event restarts a 300 ms single-shot delivery (Swift's coalescing) and re-arms every path from scratch each 100 ms, up to 20 times, until all three are watched (both timers are named children, `delivery` and `rearm`, so tests read them, and precise, since a coarse timer may fire 5% early where Swift's sleeps never end early; so is the controller's recheck); Qt's inotify engine subscribes to no `IN_MOVE_SELF`, where Swift's sources watch `.rename`, so a package exchanged for another one kept aside would pass unseen: the watcher also watches the package's parent folder and counts an event there only when the package path's device and inode differ from the ones it armed on, because a package swapped in (our store's `renameat2` exchange, another app's rename) retires the watched inodes. `IO/ProjectDigest` is a namespace: SHA-256 (`QCryptographicHash`) over the manifest's bytes, then each regular file in `images/` (links and folders skipped, names sorted by UTF-16 units) as its UTF-8 name and its size as a little-endian 64-bit number; a manifest that cannot be opened, or reads short of its size (a failed read, as Swift's `Data(contentsOf:)` throws), or an image gone mid-listing, throws, which the callers read as no digest (Swift's `try?`). `ProjectController+ExternalChanges.cpp` holds Swift's extension, its state `ExternalChangeState` (`ProjectController+ExternalChanges.h`) a public member, as Swift's: the watcher, the known digest, `checking`, `pending`, `saving`, the recheck `QTimer` (single-shot, a child of the controller), `recheckAttempt` and `reloadCount`. Swift's async loop runs as callbacks: each pass takes the pending flag, stops without a path, a document or while our own save writes, computes the digest off the UI thread (a `QFutureWatcher` child of the controller, so an answer dies with it), passes over a digest that matches, backs off at 250 ms × 2ⁿ (n to 7) while the project cannot start an operation, a transform is open or the workspace manages, reloads an unmodified document, and for unsaved work asks Swift's alert (Revert, Keep Mine) over the window, only for the front tab; without a window, or behind another tab, the change waits for `resumeExternalChangeCheck`, which the workspace's `select` calls. Keep Mine remembers the new digest. A reload (`reloadFromDisk`) marks the project busy, loads through the store off the UI thread, lands only on the same path with a document still open, calls `EditorSession::reloadProject`, remembers the digest as loaded and then counts the reload; a package that fails to load leaves the document alone and logs on `omaphoto.io`. `reloadProject` installs the snapshot and puts back the viewport, the collapsed folders that still exist, and the active layer with the selection's surviving members when the active layer still exists, as Swift's; history starts over. An open (the controller's own, which the workspace's tabs use) and a save remember the digest, then watch the package; a save marks `saving` from before its worker starts until the digest of what it wrote is remembered, or, when it fails, until its alert is answered (Swift's `defer`), so its own events pass; a new canvas and a close stop the watch. Swift's decoding from bytes in memory (`cba6efc`) has no twin: `QImageReader` decodes a PNG into memory the image owns, never mapped from the file, so a later save that replaces the file cannot reach an image kept for undo. Equivalent mutants: the try count's reset on a new event (a missing folder's creation is itself an event on the package, which re-arms), and the reload's two log lines. Tests: `ExternalChangeTests` (Swift's six; the revert question both ways, Keep Mine remembering, a background tab asking when brought forward, a busy project backing off then reloading, new canvas and close stopping the watch and its recheck, one check at a time, a check ending without a project or during our save), `ExternalChangeGateTests` (a failed save's guard, a managing workspace, the back-off's intervals, a load landing elsewhere, the reload's whole view and last signal, an unreadable manifest, Save As and open moving the watch, a second change while the question waits), `ExternalChangeFixtures.h`, `ProjectDigestRaceTests` (a binary of its own: `readdir` and `readdir64` take libc's place and remove an image as its folder's listing ends, so the digest's throw is ordered, not raced), `ProjectWatcherTests` (one report a burst, the quiet time from the last event, twenty re-arm tries, each watch speaking alone, an exchange keeping the old package, precise timing, re-arming and giving up, the digest's exact input and order, an unreadable manifest).
- Editing while a project saves (12.13b, Swift 1.3: `940e188`): a save holds the tools only while it captures the document (`prepareSave`, Swift's: the revision, the snapshot, and the Save panel when there is no path yet or for Save As), then reserves the writer and only then frees the project, so no save asked in that signal slips in beside it, and writes in the background; a capture finds no document when Undo took it while the save waited (Swift's `guard let snapshot`), and the save answers false; `DocumentHistory::currentRevision` and `markSaved(QUuid)` twin Swift's, so only the captured version counts as saved: edits made while it writes stay modified, and undoing back to it is unmodified. Swift's `writing` task is `m_writing` with a list of waiters: `finishWriting` runs its callback at once when nothing writes, else when the write lands, each waiter posted to the event loop and passed through `finishWriting` again, so one writer runs at a time where Swift's store actor serialises the disk; `save` waits there before `begin`, so a second save writes what changed since, and `confirmReplacement` waits there first, so a close, a quit, a new canvas or an open never cuts a file short and asks only once the write is whole; an open also waits before its first read, and so does an external reload (12.13a's `reloadFromDisk`), since the twin's store is no actor and could read a package mid-save: a Revert answered while our save writes loads what we wrote. A digest that comes back while our save writes is looked at again once the write lands (pending, through `finishWriting`): after a save it matches what we wrote, after a failed one the change is taken up; Keep Mine remembers its digest only when no save landed while the question was up. A save queued behind a quit that already holds the tools is refused, as Swift's `begin` refuses it. A save that fails frees its waiters after its alert is answered. The refusal of a completed Save As name that exists sits in `prepareSave`, beside the panel. Swift has no tests for this commit: `ProjectSaveWhileEditingTests` pins it (edits while writing, a second save, a new canvas waiting, the panel alone holding the tools, a failed write, the last signal), and, over `tests/SaveHooks.h` (`fsync` slowed and counting overlapping writes, `renameat2` held at a package's swap, `readdir` and `readdir64` held at one folder, the store's loads counted from its log, every hold let go on cleanup), `ProjectSaveOrderTests` (a reopen reading nothing while the write is held, a Revert mid-save, the guard through the save's digest, Canvas Size and Image Size each committed before and after the held write lands, a digest out as our save begins, a failed save keeping an external change in line, a second save waiting through the first save's digest, Keep Mine after a save) and `ProjectSaveQueueTests` ( three saves one at a time, a save asked as the tools free, a quit waiting, a document undone away, a failed write holding its waiters, a reopen, a tab closing, a controller gone mid-write, an export beside a write).
- 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
- 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
- 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
- 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. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `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`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
- `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update). - `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:)`. - 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:)`.
+25 -5
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.22) cmake_minimum_required(VERSION 3.22)
project(omaphoto VERSION 1.2.3 LANGUAGES C CXX) project(omaphoto VERSION 1.3.3 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -11,7 +11,7 @@ endif()
option(OMAPHOTO_WERROR "Treat warnings as errors" OFF) option(OMAPHOTO_WERROR "Treat warnings as errors" OFF)
find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Test) find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Svg Test)
find_package(libheif REQUIRED) find_package(libheif REQUIRED)
find_package(Fontconfig REQUIRED) find_package(Fontconfig REQUIRED)
find_package(onnxruntime REQUIRED) find_package(onnxruntime REQUIRED)
@@ -49,11 +49,14 @@ add_library(compositor_core STATIC
src/Document/LiveLayerMask.cpp src/Document/LiveLayerMask.cpp
src/Document/PixelAdjust.cpp src/Document/PixelAdjust.cpp
src/Document/ProjectWorkspace.cpp src/Document/ProjectWorkspace.cpp
src/Document/ProjectWorkspace+Copy.cpp
src/Document/SelectionClipboard.cpp src/Document/SelectionClipboard.cpp
src/Document/Crop.cpp src/Document/Crop.cpp
src/Document/DocumentLimits.cpp
src/Document/Guides.cpp src/Document/Guides.cpp
src/Document/ToolDefaults.cpp src/Document/ToolDefaults.cpp
src/Document/ObjectSelection.cpp src/Document/ObjectSelection.cpp
src/Document/Dither.cpp
src/Document/Filters.cpp src/Document/Filters.cpp
src/Document/CameraRaw.cpp src/Document/CameraRaw.cpp
src/Document/CameraRaw+Session.cpp src/Document/CameraRaw+Session.cpp
@@ -112,13 +115,16 @@ add_library(compositor_core STATIC
src/UI/LayersPanel.cpp src/UI/LayersPanel.cpp
src/UI/LevelsSheet.cpp src/UI/LevelsSheet.cpp
src/UI/HueSaturationSheet.cpp src/UI/HueSaturationSheet.cpp
src/UI/HueSaturationSheet+Spectrum.cpp
src/UI/CurvesControls.cpp src/UI/CurvesControls.cpp
src/UI/NativeLayerList.cpp src/UI/NativeLayerList.cpp
src/UI/NativeLayerList+Buttons.cpp src/UI/NativeLayerList+Buttons.cpp
src/UI/NativeLayerList+Cell.cpp src/UI/NativeLayerList+Cell.cpp
src/UI/NativeLayerList+Drag.cpp src/UI/NativeLayerList+Drag.cpp
src/UI/NativeLayerList+Effects.cpp src/UI/NativeLayerList+Effects.cpp
src/UI/NativeLayerList+Menu.cpp
src/UI/NavigationToolHeader.cpp src/UI/NavigationToolHeader.cpp
src/UI/NumericScrub.cpp
src/UI/TransformInspector.cpp src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp src/UI/EffectsSheet.cpp
src/UI/CameraRawColorControls.cpp src/UI/CameraRawColorControls.cpp
@@ -132,6 +138,9 @@ add_library(compositor_core STATIC
src/UI/CameraRawSlider.cpp src/UI/CameraRawSlider.cpp
src/UI/FilterSheet.cpp src/UI/FilterSheet.cpp
src/UI/FilterSheet+Adjustments.cpp src/UI/FilterSheet+Adjustments.cpp
src/UI/FilterSheet+Controls.cpp
src/UI/FilterSheet+Dither.cpp
src/UI/FilterSheet+Finishing.cpp
src/UI/FloatingPanel.cpp src/UI/FloatingPanel.cpp
src/UI/KeyboardShortcuts.cpp src/UI/KeyboardShortcuts.cpp
src/UI/KeyboardShortcuts+Sheet.cpp src/UI/KeyboardShortcuts+Sheet.cpp
@@ -151,14 +160,22 @@ add_library(compositor_core STATIC
src/IO/ProjectController.cpp src/IO/ProjectController.cpp
src/IO/ProjectController+Resize.cpp src/IO/ProjectController+Resize.cpp
src/IO/ProjectController+Export.cpp src/IO/ProjectController+Export.cpp
src/IO/ProjectController+ExternalChanges.cpp
src/IO/ImageFileDrop.cpp src/IO/ImageFileDrop.cpp
src/IO/PSD/PSDChannelCoder.cpp src/IO/PSD/PSDChannelCoder.cpp
src/IO/PSD/PSDDocumentBuilder.cpp src/IO/PSD/PSDDocumentBuilder.cpp
src/IO/PSD/PSDReader.cpp src/IO/PSD/PSDReader.cpp
src/IO/PSD/PSDReader+Records.cpp
src/IO/PSD/PSDReader+Adjustments.cpp src/IO/PSD/PSDReader+Adjustments.cpp
src/IO/PSD/PSDText.cpp
src/IO/PSD/PSDText+Descriptor.cpp
src/IO/PSD/PSDText+Engine.cpp
src/IO/PSD/PSDTypes.cpp src/IO/PSD/PSDTypes.cpp
src/IO/PSD/PSDVector.cpp src/IO/PSD/PSDVector.cpp
src/IO/ProjectDigest.cpp
src/IO/ProjectStore.cpp src/IO/ProjectStore.cpp
src/IO/ProjectWatcher.cpp
src/IO/RecentProjects.cpp
src/IO/ProjectStore+Adjustment.cpp src/IO/ProjectStore+Adjustment.cpp
src/IO/ProjectStore+Effects.cpp src/IO/ProjectStore+Effects.cpp
src/IO/ProjectStore+Manifest.cpp src/IO/ProjectStore+Manifest.cpp
@@ -170,6 +187,7 @@ add_library(compositor_core STATIC
src/Rendering/EditorCanvas+CameraRaw.cpp src/Rendering/EditorCanvas+CameraRaw.cpp
src/Rendering/EditorCanvas+Brush.cpp src/Rendering/EditorCanvas+Brush.cpp
src/Rendering/EditorCanvas+Crop.cpp src/Rendering/EditorCanvas+Crop.cpp
src/Rendering/EditorCanvas+DraftText.cpp
src/Rendering/EditorCanvas+Effects.cpp src/Rendering/EditorCanvas+Effects.cpp
src/Rendering/EditorCanvas+Grid.cpp src/Rendering/EditorCanvas+Grid.cpp
src/Rendering/EditorCanvas+Guides.cpp src/Rendering/EditorCanvas+Guides.cpp
@@ -191,6 +209,7 @@ add_library(compositor_core STATIC
src/Rendering/EyedropperIcon.cpp src/Rendering/EyedropperIcon.cpp
src/Rendering/SampleRingOverlay.cpp src/Rendering/SampleRingOverlay.cpp
src/Rendering/TransformOverlay.cpp src/Rendering/TransformOverlay.cpp
src/Rendering/TransformOverlay+Ants.cpp
src/Rendering/HslBlend.cpp src/Rendering/HslBlend.cpp
src/Rendering/SeparableBlend.cpp src/Rendering/SeparableBlend.cpp
src/Rendering/LayerEffectsKernel.cpp src/Rendering/LayerEffectsKernel.cpp
@@ -204,16 +223,17 @@ add_library(compositor_core STATIC
# Shared macOS pixel kernels, always optimized. # Shared macOS pixel kernels, always optimized.
set(OMAPHOTO_KERNELS ${CMAKE_CURRENT_SOURCE_DIR}/kernels) set(OMAPHOTO_KERNELS ${CMAKE_CURRENT_SOURCE_DIR}/kernels)
target_sources(compositor_core PRIVATE ${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c target_sources(compositor_core PRIVATE ${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c)
set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c ${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c
${OMAPHOTO_KERNELS}/DitherPixels.c)
set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c ${OMAPHOTO_KERNELS}/DitherPixels.c
PROPERTIES COMPILE_OPTIONS -O2) PROPERTIES COMPILE_OPTIONS -O2)
# Swift's kernel warns so through 1.3.3, unchanged. # Swift's kernel warns so through 1.3.3, unchanged.
set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_OPTIONS -Wno-unused-parameter -Wno-misleading-indentation) set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_OPTIONS -Wno-unused-parameter -Wno-misleading-indentation)
# The per-pixel loops vectorise only at -O3. # The per-pixel loops vectorise only at -O3.
set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3) set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3)
target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS}) target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS})
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW) target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE Qt6::Svg heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW)
target_compile_options(compositor_core PUBLIC -Wall -Wextra target_compile_options(compositor_core PUBLIC -Wall -Wextra
$<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>) $<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>)
# Empty optionals and bad indexes abort, not pass by luck. # Empty optionals and bad indexes abort, not pass by luck.
+1 -1
View File
@@ -2,7 +2,7 @@ ARG BASE=ubuntu:24.04
FROM $BASE FROM $BASE
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
g++ cmake ninja-build qt6-base-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \ g++ cmake ninja-build qt6-base-dev qt6-svg-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \
qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev \ qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev \
ca-certificates curl dpkg-dev file fakeroot \ ca-certificates curl dpkg-dev file fakeroot \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+18 -9
View File
@@ -6,15 +6,24 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
## What it does ## What it does
- Layers, folders, masks, clipping masks, blend modes and opacity - Layers, folders, masks, clipping masks, blend modes and opacity; whole layers copy between projects
- Selections: marquee, lasso, magic wand, load from a layer, expand and contract - 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 - 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 - Move, transform and distort layers and selections; rulers, guides, a grid and snapping
- Levels, Curves, Hue/Saturation, Exposure, Gradient Map and Grain, as edits or as adjustment layers - Levels, Curves, Hue/Saturation, Exposure, Black & White, Color Balance, Gradient Map, Grain, Invert, blur and noise, as edits or as adjustment layers
- Layer effects: stroke, drop shadow, color overlay, inner shadow - Camera Raw Filter, docked beside the canvas
- Filters: Gaussian and motion blur, noise, lens correction, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline) - Layer effects: stroke, drop shadow, outer and inner glow, color overlay, inner shadow
- Crop, canvas size, image size; PNG and JPEG export - 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)
- Imports JPEG, PNG, TIFF, HEIC, Photoshop files and camera RAW - Crop, trim, canvas size, image size; PNG and JPEG export
- 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
- A project another app changes on disk reloads in place; unsaved work is never replaced without asking
### 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).
## Install ## Install
+29 -8
View File
@@ -369,14 +369,35 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.8r release run: failed on every package: Qt 6.9 and later refuse `findChild` on a class without `Q_OBJECT`, and 12.8b's `ClipButton` and mixer `Swatch` were found that way (Qt 6.4 in the dev image allows it). Both are now found by their Qt base and name; `scripts/distro-check.sh arch` (Qt 6.11) built and passed 248 of 248. The tag moved to the fix, since nothing was published; the arch check now runs before every release tag. - 12.8r release run: failed on every package: Qt 6.9 and later refuse `findChild` on a class without `Q_OBJECT`, and 12.8b's `ClipButton` and mixer `Swatch` were found that way (Qt 6.4 in the dev image allows it). Both are now found by their Qt base and name; `scripts/distro-check.sh arch` (Qt 6.11) built and passed 248 of 248. The tag moved to the fix, since nothing was published; the arch check now runs before every release tag.
- [x] 12.8d A `std::bad_alloc` aborted `ProjectExportFailureTests` under a parallel run (seen in 12.7b and 12.8a; alone it passed ten of ten): the panel tests held a memory limit while dialogs painted, and the panel limits sat at the edge of what the panel and its snapshot need. - [x] 12.8d A `std::bad_alloc` aborted `ProjectExportFailureTests` under a parallel run (seen in 12.7b and 12.8a; alone it passed ten of ten): the panel tests held a memory limit while dialogs painted, and the panel limits sat at the edge of what the panel and its snapshot need.
- 12.8d round 1: PASS. Its notes taken: the snapshot's measured peak named in AGENTS.md, `malloc_trim` in `limited` named equivalent. - 12.8d round 1: PASS. Its notes taken: the snapshot's measured peak named in AGENTS.md, `malloc_trim` in `limited` named equivalent.
- [ ] 12.9 Version 1.2.4: the finishing filters, stepped keyboard zoom, a steady tool rail and tab strip, the project format's version in one place. - [x] 12.9a Version 1.2.4, part one: the finishing filters (Vignette with its colour picker, Bloom / Glow, Tonal Contrast), a preview kept to the grid it was rendered for, effects kept by a filter's commit (`e08dd8c`).
- [ ] 12.10 Versions 1.2.5–1.2.7: the layer context menu without its dropped Layer Effects entry (`5d7adba`), copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews. - 12.9a round 1 (codex): FAIL, two findings: a job waiting behind another edit's worker kept its old grid's image while Preview off grew the grid, so its result landed stretched into the new placement (reproduced); the sheet's test could not tell which setting a control wrote. Fixed: a growth rebuilds a waiting job on the new grid; each row names its member and only that member may move. Each fix's mutant caught.
- [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG). - 12.9a round 2: PASS, no findings.
- [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing. - [x] 12.9b Version 1.2.4, part two: stepped keyboard zoom (`383548c`, `8053fb6`), a steady tool rail and tab strip (`75c4219`). The project format's version in one place is 1.2.5's (`4cc9951`), moved to 12.10.
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects. - 12.9b round 1 (codex): FAIL, a spec mismatch and test gaps: tab titles centred where Swift's are leading; the zoom tolerance, the Meta exclusion, an unsaved long title's elision and the scroll test's unsaved edit were unpinned; and the menu already takes Ctrl with keypad minus, so the filter's zoom-out branch was unreachable in the app. Fixed: `TabTitle` paints from the left, the filter takes Zoom In's plus alone, the key test runs in the app's window; each mutant caught.
- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish. - 12.9b round 2: PASS, no findings.
- [ ] 12.15 Version 1.3.3: the Dither filter. - [x] 12.10a Versions 1.2.5–1.2.7, part one: the project format's version in one place (`4cc9951`); the layer context menu, routed by the list, with its layer actions and validation (`6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`), without the Layer Effects entry (`5d7adba`). Codex review: 3 rounds, PASS.
- [ ] 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. - [x] 12.10b Part two: copying and pasting whole layers within and between projects (`5ef5b0a`, `c873453`); duplicating every selected layer, stacked above the topmost, folders with Ctrl+J (`245db93`, `2f1537f`). Codex review: 3 rounds, the third's finding (a history manager re-setting the current entry) fixed and closed.
- [x] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's. Codex review: 2 rounds, PASS. The tab strip's two commits have no subject here (no fade; the desktop's title bar drags).
- [x] 12.11a Versions 1.2.8–1.2.10, part one: the document's own pixel budget, scaled to the machine's memory, one surface capped (`7dadc88`, `bc865fb`); marching ants from a screen-resolution outline when zoomed out (`8eba077`); Camera Raw's grading wheels drawn in the direction their hue runs (`96edd5f`). Codex review: 3 rounds, PASS.
- [x] 12.11b1 Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`). Codex review: 3 rounds; the third stopped before its report after one finding (a layered read copied the ICC resource), fixed and closed.
- [x] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). Codex review: 3 rounds; the third's findings (test gaps: rounding, scaled leading, the `Txt` key, negative exponents, versions, the limits' edges) fixed and closed.
- [x] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). Codex review: 3 rounds; the third's one finding (a test gap: a stale caret step meeting a replacing draft) fixed and closed.
- [x] 12.12a Version 1.2.11, part one: text being typed shown as the pixels it will commit to (`fe6cb2b`); layer effects kept on screen while text is edited, masks are painted and steps undone (`b683acb`). Codex review: 3 rounds, PASS.
- [x] 12.12b Part two: masks painted anywhere on the canvas (`e753f26`, `2ffb092`), a moved unlinked mask kept in place under effects (`8d2eade`), mask thumbnails' ground white or black as the canvas treats it (`a247676`). Codex review: 1 round; its one finding (two long test comments) fixed.
- [x] 12.12c1 Part three: numeric label scrubbing (`2d36d6e`, `680a522`) in the tool bars and the layer opacity; the build-warning fixes (`075a71b`, `725b934`) where they change behaviour. Codex review: 3 rounds. Round 1: focused fields left stale, double-click second presses, Feather's amount wrongly resting, test gaps. Round 2: an opacity scrub outliving its layer, boundary gaps. Round 3: the same for the Move bar. All fixed; closed after the third.
- [x] 12.12c2 Numeric label scrubbing in the sheets and panels: Canvas Size, Image Size, the colour picker, effects, filters, Hue/Saturation, Levels, Camera Raw. Codex review: 3 rounds. Round 1: every Mixer row scrubbed where Swift scrubs the HSL families alone, and test gaps (Image Size limits, Camera Raw bindings, filter decimals, centimetres). Round 2: test gaps (a held drag across `reshape`, titles enabled again, Alt-held Camera Raw scrubs). Round 3 passed.
- [x] 12.13a Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), images decoded from memory (`cba6efc`). Codex review: 3 rounds. Round 1: a package exchanged and kept aside unseen (Qt watches no IN_MOVE_SELF), a failed manifest read still digested, coarse timers, a failed save's guard released early, and test gaps. Round 2: test gaps (the watch moving, a second change while asking, quiet-time and re-arm counts, an ordered test for a vanished image). Round 3 passed.
- [x] 12.13b Keep editing while a project saves (`940e188`). Codex review: 3 rounds. Round 1: overlapping writes and a quit before a write finished, a queued save crashing after Undo took the document, a reopen reading a package mid-save, and test gaps. Round 2: an external reload reading mid-save, and test gaps. Round 3: a failed save stranding an external change already seen, and test gaps. All fixed; closed after the third.
- [x] 12.13c The guide for AI agents writing projects (`c317306`), README's lines. Codex review: 3 rounds. Round 1: thirteen claims Swift's pages make that the code does not keep (the same-size recipe, replacing a folder, required adjustment fields, folder pass-through, budgets, the save's fallback, text after resizes, fonts, masks, Alt-click) and weak example tests. Round 2: Hue/Saturation's `hsvSettings` precedence, the sampling list, PNG depths, a checked read. Round 3 passed.
- [x] 12.14a Version 1.3.1: quitting and closing while text is edited (`a18d86c`, `afd622e`), the Type tool's I-beam over the canvas (`4a4cf85`, `221d6cc`). Codex review: 3 rounds, all test gaps (quit order and refusal across tabs, the real window's close, content and history, existing and blank drafts, the cursor outside the canvas before and after leaving) and one false equivalence claim; fixed and closed after the third.
- [x] 12.14b Version 1.3.2: File > Open Recent (`73dc0f4`), Image Size keeping print sizes through an invalid resolution (`555417e`), Camera Raw's Color Grading under Color (`7e9afbe`); `d62a132` (always dark) judged against the Omarchy theme (holds: alerts follow the app's palette, which follows Omarchy by design). Codex review: 3 rounds; two display defects (a name's ampersand in the menu, a filename read as markup in the alert), two stale test expectations and one false equivalence claim in round 1, the rest test gaps (eye visibility and toggles by group, note boundaries, remembered resolution across units, ranges and resampling, the submenu's place and every gate, the list across processes); fixed and closed after the third.
- [x] 12.14c Coloured slider tracks with double-click reset in Black & White, Color Balance and Hue/Saturation, filter sliders lined up (`6b709b4`, `c8ed9ba`, `0c969af`). A fix beyond Swift found by its tests: a sheet or Camera Raw group destroyed while an entry held the keys committed into freed members (`releaseFocus`). Codex review: passed in round 3; round 1 found Camera Raw groups still unsafe to destroy, Hue/Saturation's arrow keys stuck on rounding and right double clicks resetting, with test gaps; round 2 an untested repaint and two stale notes.
- [x] 12.14d1 Colouring only the selected letters (`2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): colour runs in the text style, the manifest's version 10, the runs drawn and kept through edits, the palette, picker and Type bar paint the selection, the swatch follows the caret, the selection stays see-through under the picker. Codex review: 3 rounds; round 1 found colours guessed onto the wrong twin letter (now exact steps through every path, undo included) and run glyphs drawn in the clear overlay, with test gaps; round 2 a coloured tail lost across multi-step edits and a preedit over a selection inheriting wrongly; round 3 a leading preedit losing its colour on update or commit and an untested start position; fixed and closed after the third.
- [x] 12.14d2 Sampling keeping a panel's focus and the keys going back to open text (`810c88a`), filters, adjustments and Invert waiting for text editing to finish (`d448625`). Sampling's panel focus is a known difference: the window manager activates the clicked window, and refocusing at the press would end the sampling, so the keys go back at the release. Codex review: 3 rounds, test gaps only (reopened text, empty-layer Vignette, both pickers and the background's, the modal guard, a tool panel keeping its keys, no request without text); fixed and closed after the third.
- [x] 12.15 Version 1.3.3: the Dither filter (`f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `DitherPixels.c`, `DitherSettings` with its styles, chunky pixels and their shape, colours and ASCII glyphs, the filter kind in the Filter menu under Bloom / Glow, full-size previews, the picker's two Dither colours, the sheet's controls and swatches, the busy line shown only for the slow filters; the guide's reload note (`29f0bea`); `e70da06` (a launch to open a file shows its window) judged. Codex review: 3 rounds; round 1 found the Characters field stale after normalization, canonically equal characters drawn twice and a stale test, with test gaps; round 3 found glyph paths filled odd-even (overlapping outlines lost their ink); the rest test gaps; fixed and closed after the third.
- [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. - [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
## Skills by phase ## Skills by phase
+52
View File
@@ -0,0 +1,52 @@
# OmaPhoto project format, versions 1–10
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.
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.
Limits: 30,000 pixels per canvas/image side, a pixel budget for layer images and another as large for masks (a sixteenth of the computer's memory in pixels, between 200 and 800 million: `src/Document/DocumentLimits.cpp`), 200 million pixels for any one surface, 10,000 layers, 4 MiB manifest, 512 MiB per encoded asset. See `src/IO/ProjectStore.cpp` for validation.
Undo history and viewport are session-only. Opening fits the canvas, restores selection, and starts with clean history. Future editable features must extend the schema and round-trip tests. PNG export is a flattened derivative and does not mark project edits saved.
Image Size adds optional `resolution` (pixels/inch, 1–9600). Older manifests without it default to 72. This additive field retains version 1 compatibility. Both PNG and JPEG exports include document resolution metadata. Resampling stores the new layer pixels and bounds; undo retains the prior sources only during the current session.
Version 2 adds optional `parentID` and `isGroup` on layer records. A group has no image file. Root nodes have no parent; children refer to an existing group. Array order defines bottom-to-top sibling order; renderers traverse each group as a contiguous subtree. Visibility is inherited without changing child flags. Cycles, missing/non-group parents, image-bearing groups, and nesting beyond 64 ancestor levels are rejected. Group ancestors permit room for leaf nodes at the deepest level. Group metadata survives image/canvas resizing and cropping. Older app builds reject version 2 rather than misrender grouped documents. Collapse state is not serialized.
Version 3 adds optional per-layer `opacity` (finite 0–1) and `blendMode` (Normal, Multiply, Screen, Overlay, Darken, Lighten, Difference, Color Dodge, Color Burn). Missing fields default to full opacity and Normal. Group records required those defaults until version 8, which lets a folder carry its own opacity; a folder's opacity multiplies into every layer inside it, while its blend mode stays Normal because folders are pass-through. Effects are applied during compositing and retained as metadata when resizing sources. Files declaring older versions cannot contain non-default appearance values.
Version 4 adds optional `maskFile` and `maskEnabled` fields to individual layers. Mask filenames must be `<layer UUID>.mask.png` under `images/`; enabled defaults to true when a mask exists. Records without masks omit both fields. Groups cannot carry masks in this version. Files declaring versions 1–3 cannot contain mask metadata.
Masks store 8-bit grayscale coverage without alpha (white reveals, black hides). Their normalized extent matches the image’s local rectangle, so the same layer transform applies to both. A uniform 1×1 mask is valid and avoids allocating full-resolution pixels before painting. Nonuniform mask pixels and a thumbnail are immutable assets shared by history. Image Size resamples them with the image transform; Canvas Size and Crop preserve their pixels. Mask pixels count toward their own budget, as large as the image budget; per-side and per-file limits also apply to masks. Disabled masks remain embedded and editable but do not affect compositing. Image-versus-mask target selection is session-only and reopens on image pixels.
Version 5 adds optional `maskSourceID`: the UUID of a non-group layer supplying live alpha in document coordinates. It multiplies the target’s alpha alongside its enabled raster mask. Source pixels, transform, opacity, raster mask and upstream live masks contribute coverage; visibility and RGB color do not. Sources remain independent layers. Missing references, self-links, cycles, group endpoints and chains over 256 nodes are rejected. Deletion can bake the live coverage into dependent image pixels (retaining their raster masks) or remove the links, as one undoable operation. Links survive image/canvas resize and crop. Older versions default to no live mask; older app builds reject v5.
UI terminology: these alpha links are clipping masks. Alt-click in a row's clipping strip (Option-click on macOS) assigns the lower sibling’s base or releases the connection. Multiple clipped layers share one base, show indented above it, and release when moved outside the contiguous stack. The underlying `maskSourceID` representation is unchanged.
Version 6 allows `maskFile` and `maskEnabled` on group records. A folder has no image, so its mask covers the folder's own transform rectangle (the canvas size when the folder was created); Image Size resamples it through that transform, and Canvas Size and Crop preserve its pixels, exactly as for layer masks. Groups are pass-through, so an enabled folder mask multiplies the coverage of every descendant layer, together with that layer's own mask and any enclosing folders' masks; clipping-mask coverage is unaffected. Files declaring versions 1–5 cannot give a group a mask, and older app builds reject v6.
Version 7 adds adjustment layers: a layer record with an optional `adjustment` object and no `imageFile`. Adjustment layers cannot be groups and cannot carry `text`; like any layer they take a transform, opacity, blend mode, raster mask and clipping link, and they affect everything composited below them. Folders are pass-through, so an adjustment inside a folder also changes what lies below the folder; only a clipping stack gives its members a backdrop of their own. `kind` is one of `Hue/Saturation`, `Levels`, `Curves`, `Exposure`, `Gradient Map`, `Grain`, `Invert`, `Black & White` and `Color Balance` (version 9 adds three more). Every adjustment, whatever its kind, must carry `hue`, `saturation`, `lightness`, `colorize`, `levels` and `curves`; the other kinds' settings are optional, and a missing one takes that kind's own defaults (not always an identity: Gaussian Blur's radius defaults to 10). The settings: `hue` (±360), `saturation` and `lightness` (±100), `colorize` and `hsvSettings` for Hue/Saturation (`hsvSettings`, when present, wins over the other four); `levels` (four channel ranges, RGB then red, green, blue); `curves` (four channel point lists); `exposureSettings`; `gradientMapSettings` (`shadows`, `highlights`, `reversed`); `grainSettings`; `blackWhiteSettings`; `colorBalanceSettings`. Out-of-range or non-finite values are rejected. Files declaring versions 1–6 cannot contain adjustment records, and older app builds reject v7. See `src/Document/LayerAdjustment.cpp` for the exact ranges.
Version 8 lets a folder carry its own `opacity`, which multiplies into every layer inside it; a folder's blend mode stays Normal because folders are pass-through (files declaring 1–7 require folders at full opacity). It also adds an optional top-level `guides` array of alignment guides, each with `id`, `axis` (`horizontal` or `vertical`) and `position` in document pixels (finite, at most 1,000,000 in magnitude). At most 1,000 guides are stored; files declaring 1–7 cannot contain guides. Guides survive Canvas Size and Crop by offsetting with the canvas.
Version 9 adds three adjustment kinds that sample neighboring pixels: `Gaussian Blur` (`blurRadius`, 0.1–250 document pixels), `Motion Blur` (`motionAngle`, −90 to 90 degrees, and `motionDistance`, 1–2000) and `Add Noise` (`noiseAmount`, 0.1–400, `noiseGaussian`, `noiseMonochromatic` and `noiseSeed`, so the pattern is stable between sessions). Files declaring 1–8 cannot contain these kinds; the earlier adjustment kinds remain valid at version 7 and up.
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.
### 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:
- `maskPlacement` and `maskLinked`: an unlinked mask (`maskLinked` false; missing means linked) keeps its own transform in `maskPlacement`, a document-space rectangle like the layer transform, and no longer follows the layer when it moves. `maskPlacement` requires a `maskFile`; `maskLinked` without a mask is accepted and changes nothing.
- `shape`: a layer made with the Shape tool keeps its style (`kind`, `red`/`green`/`blue`, `cornerRadius` in document pixels, and for lines `lineWidth` plus `start` and `end` as fractions of the layer box) so it redraws cleanly when scaled. Its PNG is still an ordinary raster; once anything else changes those pixels the metadata is dropped.
### 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.
### Layer effects
An optional `effects` record contains independent `stroke`, `shadow`, `colorOverlay`, `innerShadow`, `outerGlow` and `innerGlow` records. Stroke carries a size (0–500 layer pixels), a color, an opacity and an `inside` flag choosing which side of the edge it sits on; drop shadow and inner shadow each carry an angle, a distance, a blur, a color and an opacity; color overlay carries a color and an opacity; outer glow and inner glow each carry a size (0–500 layer pixels), a color and an opacity. Each supports optional `enabled` visibility (missing means visible); hidden effects keep all parameters and remain listed under their layer. Effects, including their visibility, are saved and participate in document undo. Canvas previews run on a serial background worker with a shared pixel budget; exports render the full-resolution effects. A record omitting an effect means that layer does not have it, so older readers see the effects they understand and ignore the rest.
+137
View File
@@ -0,0 +1,137 @@
# Writing OmaPhoto projects (for AI agents and scripts)
An OmaPhoto project (`.comp`) is a folder of PNG layer images plus a `manifest.json`: the same format as [Compositor](https://github.com/robbietilton/Compositor)'s, so a project moves between the two apps. Anything that can write files can build or edit one, and OmaPhoto updates the open canvas as the files change. No plugin or API is involved.
## Try it
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.
3. Watch the canvas. Each time the agent writes the project, OmaPhoto reloads it, usually within half a second.
What you get are ordinary layers: select them, change their opacity or blend mode, paint on their masks, save.
## The project folder
```
Example.comp/
├── manifest.json
└── images/
├── 6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.png a layer's pixels
└── 6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.mask.png its mask (optional)
```
A minimal manifest with one full-canvas image layer:
```json
{
"format": "com.compositor.project",
"version": 10,
"colorSpace": "sRGB",
"documentID": "0C5E7A91-3B2D-4F6A-8E1C-9D0B7A6F5E4D",
"width": 1920,
"height": 1080,
"resolution": 72,
"activeLayerID": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F",
"layers": [
{
"id": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F",
"name": "Background",
"imageFile": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.png",
"isVisible": true,
"isGroup": false,
"opacity": 1,
"blendMode": "Normal",
"transform": {
"origin": [0, 0],
"size": [1920, 1080],
"rotation": 0,
"flipX": false,
"flipY": false,
"sampling": "High quality"
}
}
]
}
```
- `layers` runs **bottom to top**: the last layer draws on top.
- Keep `documentID` as it is when editing an existing project.
- `transform` places the layer in document pixels: `origin` is its top-left corner, `size` its width and height, `rotation` is in degrees, clockwise. The image is stretched to `size`, so a layer can be smaller than the canvas (a cut-out placed with `origin`) or scaled.
- `sampling` is `"High quality"`, `"Smooth"` or `"Nearest"`.
- `opacity` runs from 0 to 1.
## Rules that matter
Break one of these and OmaPhoto refuses the whole file **without any message**: the open canvas just stays as it was. If nothing updates, check these first.
- **Image files are named after their layer.** A layer with `"id": "6F1D…"` must use `"imageFile": "6F1D….png"`, and a mask `"maskFile": "6F1D….mask.png"`, with the ID in uppercase as written in the manifest. One ID per layer, unique in the project.
- **Images are PNGs** in `images/`, at most 8 bits a channel: 16-bit PNGs are refused. Write layer images as 8-bit RGBA (RGB, grayscale and lower-depth grayscale load too, converted); masks are 8-bit grayscale (white shows the layer, black hides it).
- **Blend modes are spelled exactly** as OmaPhoto names them: `Normal`, `Darken`, `Multiply`, `Color Burn`, `Linear Burn`, `Lighten`, `Screen`, `Color Dodge`, `Linear Dodge (Add)`, `Overlay`, `Soft Light`, `Hard Light`, `Vivid Light`, `Linear Light`, `Pin Light`, `Hard Mix`, `Difference`, `Exclusion`, `Subtract`, `Divide`, `Hue`, `Saturation`, `Color`, `Luminosity`.
- **Every `imageFile` and `maskFile` the manifest names is in place**, and the manifest is valid JSON. Blank layers, folders and adjustment layers have no `imageFile`.
## Writing safely while the project is open
OmaPhoto reads the project as soon as it changes, so never leave it half written:
1. Write any new or changed PNGs into `images/` first.
2. Then write the manifest to a temporary file inside the project folder (for example `.manifest.json.tmp`) and rename it over `manifest.json`. A rename is atomic: OmaPhoto sees either the old manifest or the new one, never part of one.
To change an existing layer, keep its `id` and overwrite its PNG, then rewrite the manifest. The layer updates in place, in the same spot in the stack.
Remove images you no longer reference once the manifest no longer lists them.
## 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.
- A reload keeps the zoom, scroll and selection, but clears undo, as reopening a file does.
- If the person has unsaved changes of their own, OmaPhoto asks them to revert to your version or keep theirs, and never replaces their work silently.
- A write that fails to load is ignored until the next change, so a mistake you then fix will still show up.
- Changes are noticed from the manifest's contents and from each image's name and size, not from when files were written. Rewriting a PNG with different pixels changes its size in practice. If you replace an image with one of exactly the same byte size, also make a change to the manifest, such as renaming the layer (any change to its bytes will do, even added whitespace); writing identical manifest bytes back isn't enough.
- Replacing the whole project folder at once is noticed too. A populated folder cannot be renamed over another on Linux; exchange the two instead with `renameat2(RENAME_EXCHANGE)` (Python's `os.rename` cannot), then remove the old one.
## Masks
Add a mask to any layer with `"maskFile": "<id>.mask.png"` and `"maskEnabled": true`. The mask is stretched over the layer's box, so it usually has the same pixel size as the layer's image; a 1×1 mask covers the whole layer with one value. Soft grays give soft edges.
## Adjustment layers
An adjustment layer has an `adjustment` object and no `imageFile`, and it affects everything below it. Every kind carries identity `levels` and `curves` blocks, plus its own settings. A warming Curves layer:
```json
{
"id": "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D",
"name": "Warm Grade",
"isVisible": true,
"isGroup": false,
"opacity": 1,
"blendMode": "Normal",
"transform": { "origin": [0, 0], "size": [1920, 1080], "rotation": 0, "flipX": false, "flipY": false, "sampling": "High quality" },
"adjustment": {
"kind": "Curves",
"hue": 0, "saturation": 0, "lightness": 0, "colorize": false,
"levels": { "channel": "RGB", "ranges": [
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 } ] },
"curves": { "channel": "RGB", "channels": [
[ { "x": 0, "y": 0 }, { "x": 255, "y": 255 } ],
[ { "x": 0, "y": 0 }, { "x": 120, "y": 147 }, { "x": 255, "y": 255 } ],
[ { "x": 0, "y": 0 }, { "x": 100, "y": 114 }, { "x": 255, "y": 255 } ],
[ { "x": 0, "y": 0 }, { "x": 115, "y": 97 }, { "x": 255, "y": 238 } ] ] }
}
}
```
- `ranges` and `channels` run RGB, then red, green, blue. Curve points run from x 0 to x 255, in increasing x.
- `kind` is one of `Hue/Saturation`, `Levels`, `Curves`, `Exposure`, `Gradient Map`, `Grain`, `Invert`, `Black & White`, `Color Balance`, `Gaussian Blur`, `Motion Blur`, `Add Noise`.
- For Hue/Saturation, set `hue`, `saturation` and `lightness` on the adjustment itself when it has no `hsvSettings`. A Hue/Saturation layer saved by OmaPhoto carries `hsvSettings`, which wins over those three: there, edit its Master entry in `hsvSettings.adjustments`, a list of range names each followed by its values (for example `["Master", { "hue": 120, "saturation": 0, "lightness": 0 }]`), and leave the other ranges as they are. Color Balance takes a `colorBalanceSettings` object (`shadowCyanRed`, `shadowMagentaGreen`, `shadowYellowBlue`, and the same for `mid` and `highlight`, each −100 to 100, plus `preserveLuminosity`).
- For the other kinds, the easiest way to get the exact shape is to add one in OmaPhoto, save, and copy it from that project's manifest.
## More
- Folders, text layers, layer effects and everything else the format holds: [project-format.md](project-format.md).
- Limits: canvases up to 30,000 pixels on a side; layers and masks count toward a memory budget that scales with the computer's memory.
+4 -3
View File
@@ -13,14 +13,14 @@
in { in {
packages.x86_64-linux.default = pkgs.stdenv.mkDerivation { packages.x86_64-linux.default = pkgs.stdenv.mkDerivation {
pname = "omaphoto"; pname = "omaphoto";
version = "1.2.3"; version = "1.3.3";
# dev.sh builds in ./build; the package starts clean. # dev.sh builds in ./build; the package starts clean.
src = pkgs.lib.cleanSourceWith { src = pkgs.lib.cleanSourceWith {
src = self; src = self;
filter = path: type: path != "${toString self}/build"; filter = path: type: path != "${toString self}/build";
}; };
nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ]; nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ];
buildInputs = with pkgs; [ qt6.qtbase qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ]; buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ];
cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ]; cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ];
ninjaFlags = [ "omaphoto" ]; ninjaFlags = [ "omaphoto" ];
# Staged: profiles build MIME caches outside the store. # Staged: profiles build MIME caches outside the store.
@@ -39,7 +39,8 @@
# fontconfig's own rules, which NixOS keeps in /etc. # fontconfig's own rules, which NixOS keeps in /etc.
includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ]; includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ];
}; };
XDG_DATA_DIRS = "${pkgs.runCommand "u2net" { } "mkdir -p $out/share/omaphoto && ln -s ${model} $out/share/omaphoto/u2net.onnx"}/share"; # The MIME database too, which NixOS keeps in its profile.
XDG_DATA_DIRS = "${pkgs.runCommand "u2net" { } "mkdir -p $out/share/omaphoto && ln -s ${model} $out/share/omaphoto/u2net.onnx"}/share:${pkgs.shared-mime-info}/share";
}; };
}; };
} }
+98 -8
View File
@@ -408,12 +408,11 @@ static void effects_dehaze(double *r, double *g, double *b, double amount) {
*b = camera_clamp(y2 + (*b - y2) * sat); *b = camera_clamp(y2 + (*b - y2) * sat);
} }
static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y, size_t width, size_t height, /// The vignette's strength at a point `px`, `py` of a `width` × `height` frame (0 at its middle, 1 past its edges).
double amount, double midpoint, double roundness, double feather, double highlights, static double vignette_mask_at(double px, double py, double width, double height,
int style) { double midpoint, double roundness, double feather) {
if (amount == 0 || width == 0 || height == 0) return; double nx = px / width * 2.0 - 1.0;
double nx = ((double)x + 0.5) / (double)width * 2.0 - 1.0; double ny = py / height * 2.0 - 1.0;
double ny = ((double)y + 0.5) / (double)height * 2.0 - 1.0;
double square = fmax(fabs(nx), fabs(ny)); double square = fmax(fabs(nx), fabs(ny));
double circle = hypot(nx, ny) / sqrt(2.0); double circle = hypot(nx, ny) / sqrt(2.0);
double shape = (1.0 - roundness / 100.0) * 0.5; double shape = (1.0 - roundness / 100.0) * 0.5;
@@ -423,7 +422,19 @@ static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y
if (soft < 0.05) soft = 0.05; if (soft < 0.05) soft = 0.05;
double t = (dist - start) / soft; double t = (dist - start) / soft;
t = camera_clamp(t); t = camera_clamp(t);
double mask = t * t * (3.0 - 2.0 * t); return t * t * (3.0 - 2.0 * t);
}
static double vignette_mask(size_t x, size_t y, size_t width, size_t height,
double midpoint, double roundness, double feather) {
return vignette_mask_at((double)x + 0.5, (double)y + 0.5, (double)width, (double)height, midpoint, roundness, feather);
}
static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y, size_t width, size_t height,
double amount, double midpoint, double roundness, double feather, double highlights,
int style) {
if (amount == 0 || width == 0 || height == 0) return;
double mask = vignette_mask(x, y, width, height, midpoint, roundness, feather);
double effect = (amount / 100.0) * mask; double effect = (amount / 100.0) * mask;
// Highlight Priority eases a darkening vignette off bright pixels. The other styles do not. // Highlight Priority eases a darkening vignette off bright pixels. The other styles do not.
if (effect < 0 && style == 0) { if (effect < 0 && style == 0) {
@@ -447,6 +458,47 @@ static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y
} }
} }
void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t stride,
double frameX, double frameY, double frameWidth, double frameHeight, int fillsClear,
double amount, double midpoint, double roundness, double feather,
double highlights, double red, double green, double blue) {
if (!rgba || amount <= 0 || width == 0 || height == 0 || frameWidth <= 0 || frameHeight <= 0) return;
double strength = camera_clamp(amount / 100.0);
red = camera_clamp(red); green = camera_clamp(green); blue = camera_clamp(blue);
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
uint8_t *p = row + x * 4;
if (!p[3] && !fillsClear) continue;
double mask = vignette_mask_at((double)x + 0.5 - frameX, (double)y + 0.5 - frameY, frameWidth, frameHeight,
midpoint, roundness, feather);
if (mask <= 0) continue;
double alpha = p[3] / 255.0;
double r = 0, g = 0, b = 0, bright = 0;
if (p[3]) {
r = fmin(1.0, p[0] / (double)p[3]);
g = fmin(1.0, p[1] / (double)p[3]);
b = fmin(1.0, p[2] / (double)p[3]);
bright = camera_clamp((rec709(r, g, b) - 0.45) / 0.55);
}
double effect = strength * mask * (1.0 - (highlights / 100.0) * bright);
if (!fillsClear) {
// Only the pixels that are there change color; their coverage stays as it was.
write_premultiplied(p, r + (red - r) * effect, g + (green - g) * effect, b + (blue - b) * effect, p[3]);
continue;
}
// The color painted over the pixel at `effect`: an opaque pixel moves toward it, a clear one takes it on.
double out = alpha + effect * (1.0 - alpha);
if (out <= 0) continue;
r = (red * effect + r * alpha * (1.0 - effect)) / out;
g = (green * effect + g * alpha * (1.0 - effect)) / out;
b = (blue * effect + b * alpha * (1.0 - effect)) / out;
p[3] = (uint8_t)fmin(255.0, round(out * 255.0));
write_premultiplied(p, r, g, b, p[3]);
}
}
}
void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights) { void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights) {
if (!shadows && !highlights) return; if (!shadows && !highlights) return;
for (size_t y = 0; y < height; ++y) { for (size_t y = 0; y < height; ++y) {
@@ -581,6 +633,44 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_
free(glowPlane); free(glowPlane);
} }
static double tonal_smooth(double low, double high, double value) {
double t = camera_clamp((value - low) / (high - low));
return t * t * (3.0 - 2.0 * t);
}
void adjust_tonal_contrast(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride, double amount,
double shadows, double midtones, double highlights) {
if (amount <= 0 || (shadows == 0 && midtones == 0 && highlights == 0)) return;
double strength = amount / 50.0;
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
const uint8_t *baseRow = blurred + y * blurredStride;
for (size_t x = 0; x < width; ++x) {
uint8_t *p = row + x * 4;
const uint8_t *base = baseRow + x * 4;
double alpha = p[3];
if (alpha == 0 || base[3] == 0) continue;
double r = fmin(1.0, p[0] / alpha);
double g = fmin(1.0, p[1] / alpha);
double b = fmin(1.0, p[2] / alpha);
double lum = rec709(r, g, b);
double baseLum = rec709(fmin(1.0, base[0] / (double)base[3]),
fmin(1.0, base[1] / (double)base[3]),
fmin(1.0, base[2] / (double)base[3]));
double shadowWeight = 1.0 - tonal_smooth(0.15, 0.5, baseLum);
double highlightWeight = tonal_smooth(0.5, 0.85, baseLum);
double midtoneWeight = 1.0 - shadowWeight - highlightWeight;
double weight = (shadows * shadowWeight + midtones * midtoneWeight +
highlights * highlightWeight) / 100.0;
double detail = lum - baseLum;
double delta = 0.18 * tanh(detail * 6.0) * weight * strength * (4.0 * lum * (1.0 - lum));
write_premultiplied(p, camera_clamp(r + delta), camera_clamp(g + delta),
camera_clamp(b + delta), alpha);
}
}
}
static double lut_at(const float *lut, double value) { static double lut_at(const float *lut, double value) {
double scaled = camera_clamp(value) * 255.0; double scaled = camera_clamp(value) * 255.0;
int lo = (int)scaled; int lo = (int)scaled;
@@ -954,7 +1044,7 @@ static void optics_chromatic(uint8_t *rgba, size_t width, size_t height, size_t
double dx = x + 0.5 - cx, dy = y + 0.5 - cy; double dx = x + 0.5 - cx, dy = y + 0.5 - cy;
double radial = hypot(dx, dy) / maxR; double radial = hypot(dx, dy) / maxR;
double shift = strength * radial * radial * 2.5; double shift = strength * radial * radial * 2.5;
long rx = (long)lround(x - shift), bx = (long)lround(x + shift); int rx = (int)lround(x - shift), bx = (int)lround(x + shift);
const uint8_t *pr = srcRow + clamped_index(rx, width) * 4; const uint8_t *pr = srcRow + clamped_index(rx, width) * 4;
const uint8_t *pb = srcRow + clamped_index(bx, width) * 4; const uint8_t *pb = srcRow + clamped_index(bx, width) * 4;
double g = fmin(1.0, srcRow[x * 4 + 1] / alpha); double g = fmin(1.0, srcRow[x * 4 + 1] / alpha);
+13
View File
@@ -62,6 +62,19 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_
double vignetteAmount, double vignetteMidpoint, double vignetteRoundness, double vignetteAmount, double vignetteMidpoint, double vignetteRoundness,
double vignetteFeather, double vignetteHighlights, int vignetteStyle, double vignetteFeather, double vignetteHighlights, int vignetteStyle,
double scale); double scale);
// Standalone Vignette: blends straight sRGB toward the selected edge color using Camera Raw's
// falloff shape and Highlight Priority. Preserves the source alpha and premultiplied storage.
// The vignette is shaped to the frame (in the image's pixels). With fillsClear it paints transparent pixels too;
// without, it recolors only the pixels that are there.
void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t stride,
double frameX, double frameY, double frameWidth, double frameHeight, int fillsClear,
double amount, double midpoint, double roundness, double feather,
double highlights, double red, double green, double blue);
// Local luminance contrast with independent shadow, midtone, and highlight gains.
// `blurred` is the same premultiplied RGBA image blurred at the chosen detail radius.
void adjust_tonal_contrast(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride, double amount,
double shadows, double midtones, double highlights);
// Manual noise reduction, then sharpening. `scale` maps radius to preview pixels. Applied after the creative grade. // Manual noise reduction, then sharpening. `scale` maps radius to preview pixels. Applied after the creative grade.
void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride, void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride,
double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking, double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking,
+278
View File
@@ -0,0 +1,278 @@
#include "DitherPixels.h"
#include <math.h>
#include <stdlib.h>
static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
// 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);
return clamp01((v - 0.5f) * contrast + 0.5f);
}
// One error-diffusion kernel: neighbors to the right on this row and below, with their weights over `divisor`.
typedef struct { int dx, dy, weight; } Tap;
typedef struct { const Tap *taps; int count; float divisor; } Kernel;
static const Tap atkinson[] = { {1,0,1}, {2,0,1}, {-1,1,1}, {0,1,1}, {1,1,1}, {0,2,1} };
static const Tap floyd[] = { {1,0,7}, {-1,1,3}, {0,1,5}, {1,1,1} };
// Atkinson passes on only six eighths of the error, which is what gives the Mac's crisp, contrasty look.
static Kernel kernel_for(int style) {
return style == DITHER_ATKINSON ? (Kernel){ atkinson, 6, 8 } : (Kernel){ floyd, 4, 16 };
}
static inline float quantize(float v, int levels) {
float steps = (float)(levels - 1);
return roundf(clamp01(v) * steps) / steps;
}
// Diffuses each plane in serpentine order, so the error's drift doesn't streak to one side.
static void diffuse(float *plane, const uint8_t *alpha, size_t width, size_t height, const DitherParams *p) {
Kernel k = kernel_for(p->style);
for (size_t y = 0; y < height; ++y) {
int reverse = (int)(y & 1);
for (size_t i = 0; i < width; ++i) {
size_t x = reverse ? width - 1 - i : i;
size_t at = y * width + x;
if (!alpha[at]) continue;
float old = plane[at], q = quantize(old, p->levels);
plane[at] = q;
float error = (old - q) * p->diffusion / k.divisor;
for (int t = 0; t < k.count; ++t) {
long nx = (long)x + (reverse ? -k.taps[t].dx : k.taps[t].dx), ny = (long)y + k.taps[t].dy;
if (nx < 0 || nx >= (long)width || ny >= (long)height) continue;
plane[(size_t)ny * width + (size_t)nx] += error * (float)k.taps[t].weight;
}
}
}
}
static const uint8_t bayer8[64] = {
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26,
12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25,
15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
};
// The ordered threshold for a pixel, in [0, 1). Smaller Bayer matrices are the top-left corners of the 8 × 8 one,
// rescaled, which is how the recursive construction nests them.
static inline float ordered_threshold(int style, size_t x, size_t y) {
switch (style) {
case DITHER_BAYER_2: { static const uint8_t m[4] = { 0, 2, 3, 1 }; return ((float)m[(y & 1) * 2 + (x & 1)] + 0.5f) / 4; }
case DITHER_BAYER_4: {
static const uint8_t m[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 };
return ((float)m[(y & 3) * 4 + (x & 3)] + 0.5f) / 16;
}
default: return ((float)bayer8[(y & 7) * 8 + (x & 7)] + 0.5f) / 64;
}
}
static inline float ordered(float v, float threshold, int levels) {
float steps = (float)(levels - 1);
float q = floorf(clamp01(v) * steps + threshold);
return (q > steps ? steps : q) / steps;
}
// How much of a halftone cell a point must be covered by before it's marked, for each screen shape. `u` and `v`
// run from −0.5 to 0.5 across the cell; the shapes grow from its middle as coverage rises.
static inline float spot(int style, float u, float v) {
float au = fabsf(u), av = fabsf(v);
switch (style) {
case DITHER_DOTS: return 3.14159265f * (u * u + v * v);
case DITHER_LINES: return av * 2;
default: return au + av;
}
}
// Old Mac fill patterns, 8 × 8, one byte per row with the leftmost pixel in the top bit, from sparsest to fullest.
static const uint8_t patterns[][8] = {
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 },
{ 0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00 },
{ 0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00 },
{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 },
{ 0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22 },
{ 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00 },
{ 0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88 },
{ 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00 },
{ 0x88, 0x55, 0x22, 0x55, 0x88, 0x55, 0x22, 0x55 },
{ 0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08 },
{ 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55 },
{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 },
{ 0x77, 0xAA, 0xDD, 0xAA, 0x77, 0xAA, 0xDD, 0xAA },
{ 0xEE, 0xDD, 0xBB, 0x77, 0xEE, 0xDD, 0xBB, 0x77 },
{ 0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF },
{ 0x7F, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF },
{ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF },
};
static const int patternCount = (int)(sizeof patterns / sizeof patterns[0]);
static inline void write_pixel(uint8_t *px, float r, float g, float b) {
float a = (float)px[3] / 255.0f;
px[0] = (uint8_t)lroundf(clamp01(r) * a * 255.0f);
px[1] = (uint8_t)lroundf(clamp01(g) * a * 255.0f);
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; }
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) {
const uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
size_t at = y * width + x;
alpha[at] = px[3];
float r = 0, g = 0, b = 0;
if (px[3]) {
float scale = 1.0f / (float)px[3];
r = px[0] * scale; g = px[1] * scale; b = px[2] * scale;
}
if (p->originalColors) {
tone[at] = adjust_tone(r, gamma, contrast);
tone[count + at] = adjust_tone(g, gamma, contrast);
tone[2 * count + at] = adjust_tone(b, gamma, contrast);
source[at * 3] = r; source[at * 3 + 1] = g; source[at * 3 + 2] = b;
} else {
tone[at] = adjust_tone(0.2126f * r + 0.7152f * g + 0.0722f * b, gamma, contrast);
}
}
}
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 };
int style = p->style;
int levels = p->levels < 2 ? 2 : p->levels > 16 ? 16 : p->levels;
if (style <= DITHER_BAYER_8) {
// Diffusion and ordered dithering: each plane is quantized to `levels` tones, then mapped to colors.
if (style <= DITHER_FLOYD_STEINBERG) {
DitherParams local = *p;
local.levels = levels;
for (int c = 0; c < planes; ++c) diffuse(tone + (size_t)c * count, alpha, width, height, &local);
} else {
for (int c = 0; c < planes; ++c) {
float *plane = tone + (size_t)c * count;
for (size_t y = 0; y < height; ++y)
for (size_t x = 0; x < width; ++x) {
size_t at = y * width + x;
if (alpha[at]) plane[at] = ordered(plane[at], ordered_threshold(style, x, y), levels);
}
}
}
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
size_t at = y * width + x;
if (!alpha[at]) continue;
if (p->originalColors) {
write_pixel(row + x * 4, tone[at], tone[count + at], tone[2 * count + at]);
} else {
float t = tone[at];
write_pixel(row + x * 4, dark[0] + (light[0] - dark[0]) * t, dark[1] + (light[1] - dark[1]) * t,
dark[2] + (light[2] - dark[2]) * t);
}
}
}
} 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.
float *marks = p->originalColors ? malloc(count * sizeof(float)) : tone;
if (!marks) { free(tone); free(alpha); free(source); return 0; }
if (p->originalColors)
for (size_t i = 0; i < count; ++i)
marks[i] = 0.2126f * tone[i] + 0.7152f * tone[count + i] + 0.0722f * tone[2 * count + i];
int cell = p->cell < 2 ? 2 : p->cell;
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;
int *picked = NULL;
if (style == DITHER_GLYPHS && p->glyphCount > 0) {
picked = malloc(columns * cellRows * sizeof(int));
if (!picked) { if (marks != tone) free(marks); free(tone); free(alpha); free(source); return 0; }
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) {
size_t i = yy * width + xx;
if (alpha[i]) { sum += marks[i]; ++n; }
}
float t = n ? sum / (float)n : 1;
float wanted = (p->lightOnDark ? t : 1 - t) * p->glyphCoverage[p->glyphCount - 1];
int best = 0;
float bestDistance = 2;
for (int g = 0; g < p->glyphCount; ++g) {
float d = fabsf(p->glyphCoverage[g] - wanted);
if (d < bestDistance) { bestDistance = d; best = g; }
}
picked[row * columns + column] = best;
}
}
// Original colors: marks take the pixel's own color, on black (light on dark) or white.
float paperOriginal = p->lightOnDark ? 0.0f : 1.0f;
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
size_t at = y * width + x;
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;
} else if (style == DITHER_PATTERNS) {
float t = marks[at];
float coverage = p->lightOnDark ? t : 1 - t;
int index = (int)lroundf(coverage * (float)(patternCount - 1));
amount = (patterns[index][y & 7] >> (7 - (x & 7))) & 1;
} else {
float fx = (float)x + 0.5f, fy = (float)y + 0.5f;
float u = (fx * cosA + fy * sinA) / (float)cell, v = (-fx * sinA + fy * cosA) / (float)cell;
u -= floorf(u) + 0.5f; v -= floorf(v) + 0.5f;
float t = marks[at];
amount = (p->lightOnDark ? t : 1 - t) > spot(style, u, v) ? 1 : 0;
}
if (p->originalColors) {
const float *s = source + at * 3;
write_pixel(row + x * 4, paperOriginal + (s[0] - paperOriginal) * amount,
paperOriginal + (s[1] - paperOriginal) * amount, paperOriginal + (s[2] - paperOriginal) * amount);
} else {
write_pixel(row + x * 4, paper[0] + (ink[0] - paper[0]) * amount, paper[1] + (ink[1] - paper[1]) * amount,
paper[2] + (ink[2] - paper[2]) * amount);
}
}
}
free(picked);
if (marks != tone) free(marks);
}
free(tone); free(alpha); free(source);
return 1;
}
void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap) {
if (block < 2) return;
float radius = (float)block * 0.42f, middle = (float)block / 2;
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
float dy = (float)(y % (size_t)block) + 0.5f - middle;
for (size_t x = 0; x < width; ++x) {
uint8_t *px = row + x * 4;
if (!px[3]) continue;
float dx = (float)(x % (size_t)block) + 0.5f - middle;
float cover = clamp01(radius - sqrtf(dx * dx + dy * dy) + 0.5f);
if (cover >= 1) continue;
for (int c = 0; c < 3; ++c)
px[c] = (uint8_t)lroundf((float)px[c] * cover + (float)gap[c] * (float)px[3] / 255.0f * (1 - cover));
}
}
}
+45
View File
@@ -0,0 +1,45 @@
#ifndef DitherPixels_h
#define DitherPixels_h
#include <stdint.h>
#include <stddef.h>
// The dither styles, in the order the Filter panel lists them.
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
};
typedef struct {
int style;
// Tones per channel for diffusion and ordered styles, 2–8. Two is pure 1-bit.
int levels;
// How much of each pixel's error diffusion passes on, 0–1.
float diffusion;
// −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.
int cell;
float angle;
// Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light.
int lightOnDark;
// 0: the result is made of `dark` and `light` (straight sRGB). 1: it keeps the image's own colors.
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`.
const uint8_t *glyphs;
const float *glyphCoverage;
int glyphCount;
} DitherParams;
// Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully
// transparent pixels are left alone. Returns 0 if working memory couldn't be had.
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *params);
// 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);
#endif
+2 -2
View File
@@ -1,12 +1,12 @@
# Built from this checkout: run makepkg in packaging/arch. # Built from this checkout: run makepkg in packaging/arch.
pkgname=omaphoto pkgname=omaphoto
pkgver=1.2.3 pkgver=1.3.3
pkgrel=1 pkgrel=1
pkgdesc='Layered image editing for Linux, a port of Compositor' pkgdesc='Layered image editing for Linux, a port of Compositor'
arch=('x86_64') arch=('x86_64')
url='https://github.com/ZacharyZhang-NY/OmaPhoto' url='https://github.com/ZacharyZhang-NY/OmaPhoto'
license=('MIT') license=('MIT')
depends=('qt6-base' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info') depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info')
makedepends=('cmake' 'ninja') makedepends=('cmake' 'ninja')
# ONNX Runtime ships inside: Arch's changes its symbols each release. # ONNX Runtime ships inside: Arch's changes its symbols each release.
source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx' source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx'
@@ -7,5 +7,5 @@ Exec=omaphoto %F
Icon=io.github.ZacharyZhang_NY.OmaPhoto Icon=io.github.ZacharyZhang_NY.OmaPhoto
Terminal=false Terminal=false
Categories=Graphics;2DGraphics;RasterGraphics; Categories=Graphics;2DGraphics;RasterGraphics;
MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-dcraw; MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/svg+xml;image/svg+xml-compressed;image/x-dcraw;
StartupWMClass=OmaPhoto StartupWMClass=OmaPhoto
@@ -5,4 +5,11 @@
<sub-class-of type="inode/directory"/> <sub-class-of type="inode/directory"/>
<glob pattern="*.comp"/> <glob pattern="*.comp"/>
</mime-type> </mime-type>
<mime-type type="image/x-photoshop-large-image">
<comment>Photoshop Large Document</comment>
<glob pattern="*.psb"/>
<magic priority="50">
<match type="string" value="8BPS\x00\x02" offset="0"/>
</magic>
</mime-type>
</mime-info> </mime-info>
+1 -1
View File
@@ -1,6 +1,6 @@
# Arch and Omarchy: Qt and GCC as shipped. # Arch and Omarchy: Qt and GCC as shipped.
FROM archlinux:latest FROM archlinux:latest
RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 libraw \ RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-svg qt6-imageformats libheif libde265 libraw \
fontconfig ttf-dejavu curl \ fontconfig ttf-dejavu curl \
&& pacman -Scc --noconfirm && pacman -Scc --noconfirm
# The ONNX Runtime the packages carry: Microsoft's own build. # The ONNX Runtime the packages carry: Microsoft's own build.
+1 -1
View File
@@ -1,7 +1,7 @@
# Fedora 43, the oldest still supported; HEVC from RPM Fusion. # Fedora 43, the oldest still supported; HEVC from RPM Fusion.
FROM fedora:43 FROM fedora:43
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-43.noarch.rpm \ RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-43.noarch.rpm \
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel LibRaw-devel libheif-freeworld \ && 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 \ fontconfig-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \
&& dnf clean all && dnf clean all
# The ONNX Runtime the packages carry: Microsoft's own build. # The ONNX Runtime the packages carry: Microsoft's own build.
+2 -2
View File
@@ -29,7 +29,7 @@ def docker(command):
uid, gid = os.getuid(), os.getgid() uid, gid = os.getuid(), os.getgid()
runtime = '/run/user/%d' % uid runtime = '/run/user/%d' % uid
done = subprocess.run( done = subprocess.run(
['docker', 'run', '--rm', '--init', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen', ['docker', 'run', '--rm', '--init', '--cpus=12', '-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, '--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], '-v', '%s:%s' % (repo, repo), '-w', repo, 'omaphoto-dev', 'timeout', '600', 'sh', '-c', command],
capture_output=True, text=True) capture_output=True, text=True)
@@ -38,7 +38,7 @@ def docker(command):
def build(): def build():
wanted = ' --target ' + ' '.join(targets) if targets else '' wanted = ' --target ' + ' '.join(targets) if targets else ''
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build' + wanted) return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build -j12' + wanted)
def test(): def test():
+1 -1
View File
@@ -87,7 +87,7 @@ void ContentView::showImporter()
m_importer = new QFileDialog(this); m_importer = new QFileDialog(this);
m_importer->setAttribute(Qt::WA_DeleteOnClose); m_importer->setAttribute(Qt::WA_DeleteOnClose);
m_importer->setFileMode(QFileDialog::ExistingFiles); m_importer->setFileMode(QFileDialog::ExistingFiles);
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd %1)").arg(RawImporter::globs().join(' '))); m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb *.svg *.svgz %1)").arg(RawImporter::globs().join(' ')));
connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) { connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) {
// Ours no longer: the flag must not reject it again. // Ours no longer: the flag must not reject it again.
if (m_importer != panel) if (m_importer != panel)
+6 -3
View File
@@ -131,8 +131,9 @@ PaintSnapshot BrushStroke::paintSnapshot() const
const QRectF placed = rect.translated(tile.rect.topLeft()); const QRectF placed = rect.translated(tile.rect.topLeft());
bounds = bounds ? std::optional(bounds->united(placed)) : std::optional(placed); bounds = bounds ? std::optional(bounds->united(placed)) : std::optional(placed);
} }
const QRectF crop = bounds.value_or(committedBounds()); // A mask keeps every tile touched: past its pixels, grows.
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask); const QRectF crop = isMask ? committedBounds() : bounds.value_or(committedBounds());
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask, maskBackground);
return {ImportedImage(raster, raster->thumbnail(), layer.name), transform(crop), crop}; return {ImportedImage(raster, raster->thumbnail(), layer.name), transform(crop), crop};
} }
@@ -142,7 +143,7 @@ BrushCommit::Input BrushStroke::commitInput() const
std::vector<BrushPatch> shifted; std::vector<BrushPatch> shifted;
for (const BrushPatch &patch : patches()) for (const BrushPatch &patch : patches())
shifted.push_back({patch.rect.translated(-bounds.topLeft()), patch.image}); shifted.push_back({patch.rect.translated(-bounds.topLeft()), patch.image});
return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft())}; return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft()), maskBackground};
} }
ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop) ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop)
@@ -161,6 +162,8 @@ ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &i
BrushCommit::Output BrushCommit::render(const Input &input) BrushCommit::Output BrushCommit::render(const Input &input)
{ {
QImage context = BrushRaster::context(input.width, input.height, input.mask); QImage context = BrushRaster::context(input.width, input.height, input.mask);
if (input.mask)
context.fill(qRound(input.fill * 255));
QPainter painter(&context); QPainter painter(&context);
if (input.source) if (input.source)
drawSource(*input.source, input.sourceRect, painter); drawSource(*input.source, input.sourceRect, painter);
+21 -10
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Rendering/PoolMap.h" #include "Rendering/PoolMap.h"
#include "Document/PixelAdjust.h" #include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
@@ -63,20 +64,26 @@ double BrushStroke::spacingFraction(double hardness)
return hardness >= 1 ? 0.015 : 0.025; return hardness >= 1 ? 0.015 : 0.025;
} }
BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas) BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, bool growsMask)
{ {
// A placed mask paints in its grid; else the layer's. // A placed mask paints in its grid; else the layer's.
const bool placedMask = mask && layer.mask && layer.mask->placement; const bool placedMask = mask && layer.mask && layer.mask->placement;
const LayerTransform base = placedMask ? *layer.mask->placement : layer.transform; const LayerTransform base = placedMask ? *layer.mask->placement : layer.transform;
const int originalWidth = placedMask ? layer.mask->asset.size().width() // A solid placed mask gets a pixel per document pixel.
: layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width())); const bool solidPlaced = placedMask && layer.mask->asset.size().width() <= 2 && layer.mask->asset.size().height() <= 2;
const int originalHeight = placedMask ? layer.mask->asset.size().height() const int originalWidth = solidPlaced ? std::max(1, int(std::round(base.size.width())))
: layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height())); : placedMask ? layer.mask->asset.size().width()
if (originalWidth < 1 || originalWidth > 30'000 || originalHeight < 1 || originalHeight > 30'000) : layer.asset ? layer.asset->size().width()
: int(std::round(layer.size().width()));
const int originalHeight = solidPlaced ? std::max(1, int(std::round(base.size.height())))
: placedMask ? layer.mask->asset.size().height()
: layer.asset ? layer.asset->size().height()
: int(std::round(layer.size().height()));
if (originalWidth < 1 || originalWidth > DocumentLimits::maxSide || originalHeight < 1 || originalHeight > DocumentLimits::maxSide)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight); const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight);
const QRectF originalBounds(0, 0, originalWidth, originalHeight); const QRectF originalBounds(0, 0, originalWidth, originalHeight);
const QRectF extent = mask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas))); const QRectF extent = mask && !growsMask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas)));
// Judged as a double: past int the conversion is undefined. // Judged as a double: past int the conversion is undefined.
if (extent.width() < 1 || extent.width() > 1'000'000'000 || extent.height() < 1 || extent.height() > 1'000'000'000) if (extent.width() < 1 || extent.width() > 1'000'000'000 || extent.height() < 1 || extent.height() > 1'000'000'000)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
@@ -92,14 +99,15 @@ BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const Br
return grid; return grid;
} }
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas) BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas, bool growsMask)
: BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas))) : BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas), growsMask))
{ {
} }
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid) BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid)
: layer(layer), isMask(mask), settings(settings), canvas(canvas), width(grid.width), height(grid.height), sourceRect(grid.sourceRect), : layer(layer), isMask(mask), settings(settings), canvas(canvas), width(grid.width), height(grid.height), sourceRect(grid.sourceRect),
pixelToDocument(grid.pixelToDocument), paintTransform(grid.paintTransform), pixelToDocument(grid.pixelToDocument), paintTransform(grid.paintTransform),
maskBackground(mask && layer.mask ? LayerMask::background(layer.mask->asset.thumbnail) : 1),
m_source(mask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset), m_source(mask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset),
m_paintColor(mask ? QColor::fromRgbF(settings.red, settings.red, settings.red) : QColor::fromRgbF(settings.red, settings.green, settings.blue)) m_paintColor(mask ? QColor::fromRgbF(settings.red, settings.red, settings.red) : QColor::fromRgbF(settings.red, settings.green, settings.blue))
{ {
@@ -301,10 +309,13 @@ void BrushStroke::allocateTile(qint64 key, qint64 x, qint64 y)
return; return;
const QRectF rect(x * tileSize, y * tileSize, std::min<qint64>(tileSize, width - x * tileSize), std::min<qint64>(tileSize, height - y * tileSize)); const QRectF rect(x * tileSize, y * tileSize, std::min<qint64>(tileSize, width - x * tileSize), std::min<qint64>(tileSize, height - y * tileSize));
const QRectF nextBounds = m_allocatedBounds ? m_allocatedBounds->united(rect) : m_source ? sourceRect.united(rect) : rect; const QRectF nextBounds = m_allocatedBounds ? m_allocatedBounds->united(rect) : m_source ? sourceRect.united(rect) : rect;
if (nextBounds.width() > 30'000 || nextBounds.height() > 30'000 || nextBounds.width() * nextBounds.height() > pixelLimit) if (nextBounds.width() > DocumentLimits::maxSide || nextBounds.height() > DocumentLimits::maxSide || nextBounds.width() * nextBounds.height() > pixelLimit)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
m_allocatedBounds = nextBounds; m_allocatedBounds = nextBounds;
QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask); QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask);
// Past the mask's pixels a tile starts as its background.
if (isMask)
context.fill(qRound(maskBackground * 255));
QPainter painter(&context); QPainter painter(&context);
const QRectF initialRect = sourceRect.translated(-rect.topLeft()); const QRectF initialRect = sourceRect.translated(-rect.topLeft());
if (m_source && m_source->raster) { if (m_source && m_source->raster) {
+9 -3
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "Document/EditorSession+Model.h" #include "Document/EditorSession+Model.h"
#include "Document/DocumentLimits.h"
#include "Document/Gradient.h" #include "Document/Gradient.h"
#include "Document/LayerTransform.h" #include "Document/LayerTransform.h"
#include "Document/Selection.h" #include "Document/Selection.h"
@@ -74,6 +75,8 @@ struct Input {
bool mask; bool mask;
QString name; QString name;
QRectF sourceRect; QRectF sourceRect;
// A grown mask where no old pixel or edit reaches.
double fill = 1;
}; };
struct Output { struct Output {
ImportedImage asset; ImportedImage asset;
@@ -86,7 +89,8 @@ Output render(const Input &input);
// Only touched 256 px tiles hold pixels; snapshots copy those. // Only touched 256 px tiles hold pixels; snapshots copy those.
class BrushStroke { class BrushStroke {
public: public:
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas); // `growsMask`: the brush and fills reach the canvas on masks.
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas, bool growsMask = false);
const ImageLayer layer; const ImageLayer layer;
const bool isMask; const bool isMask;
@@ -97,8 +101,10 @@ public:
const QRectF sourceRect; const QRectF sourceRect;
const QTransform pixelToDocument; const QTransform pixelToDocument;
const LayerTransform paintTransform; const LayerTransform paintTransform;
// A mask past its pixels: 1 reveals, 0 hides.
const double maskBackground;
// Swift's Int: the budget left may run below zero. // Swift's Int: the budget left may run below zero.
qint64 pixelLimit = 100'000'000; qint64 pixelLimit = DocumentLimits::documentPixelBudget();
// Limits every edit to the document selection; nil when none. // Limits every edit to the document selection; nil when none.
std::optional<SelectionClip> selectionClip; std::optional<SelectionClip> selectionClip;
// Clone Stamp: a document-sized image, and each point's offset. // Clone Stamp: a document-sized image, and each point's offset.
@@ -162,7 +168,7 @@ private:
QTransform pixelToDocument; QTransform pixelToDocument;
LayerTransform paintTransform; LayerTransform paintTransform;
}; };
static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas); static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, bool growsMask);
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid); BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid);
std::vector<BrushSegment> continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const; std::vector<BrushSegment> continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const;
std::set<qint64> continuousKeys(const std::vector<BrushSegment> &segments) const; std::set<qint64> continuousKeys(const std::vector<BrushSegment> &segments) const;
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/CanvasSize.h" #include "Document/CanvasSize.h"
#include "Document/DocumentLimits.h"
#include <cmath> #include <cmath>
QString rawValue(CanvasUnit unit) QString rawValue(CanvasUnit unit)
@@ -24,7 +25,7 @@ CanvasSizeDraft::CanvasSizeDraft(qint64 width, qint64 height, double resolution)
bool CanvasSizeDraft::valid() const bool CanvasSizeDraft::valid() const
{ {
// NaN and infinity fail both range tests. // NaN and infinity fail both range tests.
return std::round(width) >= 1 && std::round(width) <= 30'000 && std::round(height) >= 1 && std::round(height) <= 30'000; return std::round(width) >= 1 && std::round(width) <= DocumentLimits::maxSide && std::round(height) >= 1 && std::round(height) <= DocumentLimits::maxSide;
} }
double CanvasSizeDraft::displayed(bool widthAxis) const double CanvasSizeDraft::displayed(bool widthAxis) const
+14
View File
@@ -18,10 +18,20 @@ public:
void openGradientMapColorPicker(bool highlights); void openGradientMapColorPicker(bool highlights);
// The Gradient Map follows the picker's working colour. // The Gradient Map follows the picker's working colour.
void previewGradientMapColor(); void previewGradientMapColor();
// The picker on the open Vignette's colour, which follows it.
void openVignetteColorPicker();
void previewVignetteColor();
// The picker on Dither's dark or light colour.
void openDitherColorPicker(bool light);
void previewDitherColor();
// The picker on an effect of the open panel. // The picker on an effect of the open panel.
void openEffectColorPicker(LayerEffectKind kind); void openEffectColorPicker(LayerEffectKind kind);
// The effect follows the picker's working colour. // The effect follows the picker's working colour.
void previewEffectColor(); void previewEffectColor();
// The open text draft previews the picker's working colour.
void previewTextColor();
// Paints the open text's selected letters, or all of it.
void setDraftTextColor(const PaletteColor &color);
// Swift writes the picker's hsb in place; here, the session. // Swift writes the picker's hsb in place; here, the session.
void setColorPickerHSB(const PickerHSB &hsb); void setColorPickerHSB(const PickerHSB &hsb);
void sampleIntoColorPicker(QPointF point); void sampleIntoColorPicker(QPointF point);
@@ -29,4 +39,8 @@ public:
std::optional<PaletteColor> sampleCompositeColor(QPointF point) const; std::optional<PaletteColor> sampleCompositeColor(QPointF point) const;
private: private:
// Puts back the open text's colours from the picker's opening.
void restoreDraftTextColors(const LayerTextStyle &original);
void setGradientMapColor(const PaletteColor &color, bool highlights); void setGradientMapColor(const PaletteColor &color, bool highlights);
void setVignetteColor(const PaletteColor &color);
void setDitherColor(const PaletteColor &color, bool light);
+125 -21
View File
@@ -105,6 +105,10 @@ QString ColorPickerTarget::title() const
return highlights ? QStringLiteral("Color Picker (Gradient Map Highlights)") : QStringLiteral("Color Picker (Gradient Map Shadows)"); return highlights ? QStringLiteral("Color Picker (Gradient Map Highlights)") : QStringLiteral("Color Picker (Gradient Map Shadows)");
if (kind == Kind::effect) if (kind == Kind::effect)
return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect)); return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect));
if (kind == Kind::vignette)
return QStringLiteral("Color Picker (Vignette Color)");
if (kind == Kind::dither)
return light ? QStringLiteral("Color Picker (Dither Light Color)") : QStringLiteral("Color Picker (Dither Dark Color)");
return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)"); return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)");
} }
@@ -154,13 +158,8 @@ void EditorSession::setPaletteColor(const PaletteColor &color, bool background)
} else { } else {
setForegroundColor(color); setForegroundColor(color);
// Open text follows the swatch; drafts live under Type. // Open text follows the swatch; drafts live under Type.
if (m_textDraft) { if (m_textDraft)
changeTextStyle([&color](LayerTextStyle &style) { setDraftTextColor(color);
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
}
} }
} }
@@ -193,7 +192,11 @@ void EditorSession::openColorPicker(bool background)
{ {
if (!canEditPalette() || m_isMaskSelected) if (!canEditPalette() || m_isMaskSelected)
return; return;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::palette, background}, paletteColor(background)); ColorPickerState picker({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
// Open text follows the foreground: it previews, Cancel restores.
if (!background && m_tool == NavigationTool::type && m_textDraft)
picker.editedText = ColorPickerState::EditedText{m_textDraft->id, m_textDraft->style};
m_colorPicker = picker;
notify(); notify();
} }
@@ -201,7 +204,9 @@ PaletteColor EditorSession::typeColor() const
{ {
if (!m_textDraft) if (!m_textDraft)
return foregroundColor(); return foregroundColor();
return {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue}; // The first selected letter, else the one before the caret.
const TextSpan selection = m_textDraft->selection;
return m_textDraft->style.color(selection.length > 0 ? selection.location : std::max<qint64>(0, selection.location - 1));
} }
void EditorSession::openTextColorPicker() void EditorSession::openTextColorPicker()
@@ -209,7 +214,10 @@ void EditorSession::openTextColorPicker()
if (!canEditPalette() || m_colorPicker || m_tool != NavigationTool::type) if (!canEditPalette() || m_colorPicker || m_tool != NavigationTool::type)
return; return;
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt; const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::text, false, draftID}, typeColor()); ColorPickerState picker({ColorPickerTarget::Kind::text, false, draftID}, typeColor());
if (m_textDraft)
picker.editedText = ColorPickerState::EditedText{m_textDraft->id, m_textDraft->style};
m_colorPicker = picker;
notify(); notify();
} }
@@ -223,28 +231,39 @@ void EditorSession::closeColorPicker(bool commit)
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt; const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
if (commit && picker.target.kind == ColorPickerTarget::Kind::palette && !m_isMaskSelected) { if (commit && picker.target.kind == ColorPickerTarget::Kind::palette && !m_isMaskSelected) {
setPaletteColor(color, picker.target.background); setPaletteColor(color, picker.target.background);
} else if (commit && picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) { } else if (!commit && picker.target.kind == ColorPickerTarget::Kind::palette && picker.editedText && m_tool == NavigationTool::type
&& draftID == picker.editedText->draftID) {
restoreDraftTextColors(picker.editedText->style);
} else if (picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
// The text previewed the working colour; Cancel puts it back.
const PaletteColor chosen = commit ? color : picker.original;
if (draftID) { if (draftID) {
changeTextStyle([&color](LayerTextStyle &style) { if (commit)
style.red = color.red; setDraftTextColor(chosen);
style.green = color.green; else if (picker.editedText)
style.blue = color.blue; restoreDraftTextColors(picker.editedText->style);
}); } else if (commit) {
} else { m_textDefaults.red = chosen.red;
m_textDefaults.red = color.red; m_textDefaults.green = chosen.green;
m_textDefaults.green = color.green; m_textDefaults.blue = chosen.blue;
m_textDefaults.blue = color.blue;
} }
// The text colour is the foreground: the swatch follows. // The text colour is the foreground: the swatch follows.
if (!m_isMaskSelected) if (commit && !m_isMaskSelected)
setForegroundColor(color); setForegroundColor(color);
} else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) { } else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) {
// The end previewed the working colour; Cancel puts it back. // The end previewed the working colour; Cancel puts it back.
setGradientMapColor(commit ? color : picker.original, picker.target.highlights); setGradientMapColor(commit ? color : picker.original, picker.target.highlights);
} else if (picker.target.kind == ColorPickerTarget::Kind::vignette) {
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::effect) { } else if (picker.target.kind == ColorPickerTarget::Kind::effect) {
const PaletteColor chosen = commit ? color : picker.original; const PaletteColor chosen = commit ? color : picker.original;
changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); }); changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); });
} }
// Sampling took the keys from open text: they go back.
if (m_textDraft)
++m_canvasFocusRequest;
notify(); notify();
} }
@@ -258,6 +277,43 @@ void EditorSession::openGradientMapColorPicker(bool highlights)
notify(); notify();
} }
void EditorSession::openVignetteColorPicker()
{
// A commit keeps the project busy: no `committing` term.
if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::vignette)
return;
const AdjustmentColor value = m_filterEdit->settings.vignetteColor;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::vignette}, PaletteColor{value.red, value.green, value.blue});
notify();
}
void EditorSession::openDitherColorPicker(bool light)
{
// A commit keeps the project busy: no `committing` term.
if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::dither)
return;
const AdjustmentColor value = light ? m_filterEdit->settings.dither.light : m_filterEdit->settings.dither.dark;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::dither, false, std::nullopt, false, LayerEffectKind{}, light},
PaletteColor{value.red, value.green, value.blue});
notify();
}
void EditorSession::previewDitherColor()
{
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::dither)
setDitherColor(m_colorPicker->color(), m_colorPicker->target.light);
}
// Its picker lives only while its Dither edit is open.
void EditorSession::setDitherColor(const PaletteColor &color, bool light)
{
FilterSettings settings = m_filterEdit.value().settings;
(light ? settings.dither.light : settings.dither.dark) = AdjustmentColor(color);
if (settings == m_filterEdit->settings)
return;
updateFilter(settings, m_filterEdit->preview);
}
void EditorSession::openEffectColorPicker(LayerEffectKind kind) void EditorSession::openEffectColorPicker(LayerEffectKind kind)
{ {
if (!canEditPalette() || m_colorPicker || !m_effectsEditing) if (!canEditPalette() || m_colorPicker || !m_effectsEditing)
@@ -266,6 +322,38 @@ void EditorSession::openEffectColorPicker(LayerEffectKind kind)
notify(); notify();
} }
void EditorSession::previewTextColor()
{
if (!m_colorPicker)
return;
const ColorPickerTarget &target = m_colorPicker->target;
const std::optional<QUuid> draftID = target.kind == ColorPickerTarget::Kind::text ? target.draftID
: target.kind == ColorPickerTarget::Kind::palette && m_colorPicker->editedText
? std::optional(m_colorPicker->editedText->draftID)
: std::nullopt;
if (draftID && m_tool == NavigationTool::type && m_textDraft && m_textDraft->id == *draftID)
setDraftTextColor(m_colorPicker->color());
}
void EditorSession::setDraftTextColor(const PaletteColor &color)
{
if (!m_textDraft)
return;
const TextSpan selection = m_textDraft->selection;
changeTextStyle([&](LayerTextStyle &style) { style.setColor(color, selection); });
}
void EditorSession::restoreDraftTextColors(const LayerTextStyle &original)
{
changeTextStyle([&original](LayerTextStyle &style) {
style.red = original.red;
style.green = original.green;
style.blue = original.blue;
// Retyped since, the runs cannot come back letter for letter.
style.colorRuns = original.content == style.content ? original.colorRuns : std::nullopt;
});
}
void EditorSession::previewEffectColor() void EditorSession::previewEffectColor()
{ {
if (!m_colorPicker || m_colorPicker->target.kind != ColorPickerTarget::Kind::effect) if (!m_colorPicker || m_colorPicker->target.kind != ColorPickerTarget::Kind::effect)
@@ -281,6 +369,22 @@ void EditorSession::previewGradientMapColor()
setGradientMapColor(m_colorPicker->color(), m_colorPicker->target.highlights); setGradientMapColor(m_colorPicker->color(), m_colorPicker->target.highlights);
} }
void EditorSession::previewVignetteColor()
{
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::vignette)
setVignetteColor(m_colorPicker->color());
}
// Its picker lives only while its Vignette edit is open.
void EditorSession::setVignetteColor(const PaletteColor &color)
{
FilterSettings settings = m_filterEdit.value().settings;
settings.vignetteColor = AdjustmentColor(color);
if (settings == m_filterEdit->settings)
return;
updateFilter(settings, m_filterEdit->preview);
}
void EditorSession::setGradientMapColor(const PaletteColor &color, bool highlights) void EditorSession::setGradientMapColor(const PaletteColor &color, bool highlights)
{ {
// An adjustment's editor can close under its picker (9.5c). // An adjustment's editor can close under its picker (9.5c).
+11 -2
View File
@@ -1,4 +1,5 @@
#pragma once #pragma once
#include "Document/TypeTool.h"
#include <QColor> #include <QColor>
#include <QString> #include <QString>
#include <QUuid> #include <QUuid>
@@ -35,9 +36,9 @@ struct PickerHSB {
enum class LayerEffectKind; enum class LayerEffectKind;
// The picker's targets: palette, text, effect, Gradient Map end. // The picker's targets: palette, text, effect, a filter's colour.
struct ColorPickerTarget { struct ColorPickerTarget {
enum class Kind { palette, text, gradientMap, effect }; enum class Kind { palette, text, gradientMap, effect, vignette, dither };
Kind kind; Kind kind;
bool background = false; bool background = false;
std::optional<QUuid> draftID = std::nullopt; std::optional<QUuid> draftID = std::nullopt;
@@ -45,6 +46,8 @@ struct ColorPickerTarget {
bool highlights = false; bool highlights = false;
// An effect's colour: which effect. // An effect's colour: which effect.
LayerEffectKind effect{}; LayerEffectKind effect{};
// Dither's Two Colors: the light one, else the dark.
bool light = false;
QString title() const; QString title() const;
friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default; friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default;
}; };
@@ -57,5 +60,11 @@ struct ColorPickerState {
PickerHSB hsb; PickerHSB hsb;
// Swift's object identity: a new picker opens its panel anew. // Swift's object identity: a new picker opens its panel anew.
QUuid id = QUuid::createUuid(); QUuid id = QUuid::createUuid();
// The picker over open text: that text, its colours then.
struct EditedText {
QUuid draftID;
LayerTextStyle style;
};
std::optional<EditedText> editedText;
PaletteColor color() const { return hsb.rgb().quantized(); } PaletteColor color() const { return hsb.rgb().quantized(); }
}; };
+6 -1
View File
@@ -1,4 +1,5 @@
#include "Document/Crop.h" #include "Document/Crop.h"
#include "Document/DocumentLimits.h"
#include "Document/Distort.h" #include "Document/Distort.h"
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "IO/CanvasResizer.h" #include "IO/CanvasResizer.h"
@@ -19,7 +20,7 @@ QRectF CropGeometry::snapped(const QRectF &rect)
bool CropGeometry::valid(const QRectF &rect) bool CropGeometry::valid(const QRectF &rect)
{ {
const QRectF standard = rect.normalized(); const QRectF standard = rect.normalized();
return standard.width() >= 1 && standard.width() <= 30'000 && standard.height() >= 1 && standard.height() <= 30'000 return standard.width() >= 1 && standard.width() <= DocumentLimits::maxSide && standard.height() >= 1 && standard.height() <= DocumentLimits::maxSide
&& std::abs(standard.left()) <= 1'000'000 && std::abs(standard.top()) <= 1'000'000; && std::abs(standard.left()) <= 1'000'000 && std::abs(standard.top()) <= 1'000'000;
} }
@@ -188,8 +189,12 @@ std::optional<double> EditorSession::cropRatio() const
return 1; return 1;
if (m_cropRatioChoice == QStringLiteral("4:3")) if (m_cropRatioChoice == QStringLiteral("4:3"))
return 4.0 / 3; return 4.0 / 3;
if (m_cropRatioChoice == QStringLiteral("3:4"))
return 3.0 / 4;
if (m_cropRatioChoice == QStringLiteral("16:9")) if (m_cropRatioChoice == QStringLiteral("16:9"))
return 16.0 / 9; return 16.0 / 9;
if (m_cropRatioChoice == QStringLiteral("9:16"))
return 9.0 / 16;
return std::nullopt; return std::nullopt;
} }
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/Distort.h" #include "Document/Distort.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h" #include "Document/LayerEffects+Renderer.h"
@@ -174,7 +175,7 @@ DistortWarp::Warped DistortWarp::warp(const QImage &image, const LayerTransform
if (!isUsable(corners)) if (!isUsable(corners))
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
const QRectF bounds = shapeBounds(corners); const QRectF bounds = shapeBounds(corners);
if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > 30'000 || bounds.height() > 30'000 || bounds.width() * bounds.height() > 100'000'000) if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > DocumentLimits::maxSide || bounds.height() > DocumentLimits::maxSide || bounds.width() * bounds.height() > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
const LayerTransform placed{.origin = bounds.topLeft(), .size = bounds.size(), .sampling = transform.sampling}; const LayerTransform placed{.origin = bounds.topLeft(), .size = bounds.size(), .sampling = transform.sampling};
// A uniform 1 × 1 mask already covers any shape. // A uniform 1 × 1 mask already covers any shape.
+236
View File
@@ -0,0 +1,236 @@
#include "Document/Dither.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include <QPainter>
#include <QPainterPath>
#include <QTextBoundaryFinder>
#include <algorithm>
#include <cmath>
#include <set>
extern "C" {
#include "DitherPixels.h"
}
// Swift's Dither.swift: the settings, the chunky pixels, the glyphs.
namespace {
// Swift's Characters: the text's graphemes.
QStringList graphemes(const QString &text)
{
QStringList found;
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
qsizetype start = 0;
while (finder.toNextBoundary() >= 0) {
found << text.mid(start, finder.position() - start);
start = finder.position();
}
return found;
}
// Swift's Character.isNewline.
bool isNewline(const QString &grapheme)
{
static const QString breaks = QStringLiteral("\n\r\u000B\u000C\u0085

");
return grapheme == QStringLiteral("\r\n") || (grapheme.size() == 1 && breaks.contains(grapheme.front()));
}
std::array<uint8_t, 3> bytes(const AdjustmentColor &color)
{
return {uint8_t(std::lround(color.red * 255)), uint8_t(std::lround(color.green * 255)), uint8_t(std::lround(color.blue * 255))};
}
}
QString rawValue(DitherStyle style)
{
switch (style) {
case DitherStyle::atkinson: return QStringLiteral("Atkinson (Classic Mac)");
case DitherStyle::floydSteinberg: return QStringLiteral("Floyd–Steinberg");
case DitherStyle::bayer2: return QStringLiteral("Bayer 2 × 2");
case DitherStyle::bayer4: return QStringLiteral("Bayer 4 × 4");
case DitherStyle::bayer8: return QStringLiteral("Bayer 8 × 8");
case DitherStyle::dots: return QStringLiteral("Halftone Dots");
case DitherStyle::lines: return QStringLiteral("Halftone Lines");
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
case DitherStyle::ascii: return QStringLiteral("ASCII");
}
throw std::logic_error("unknown dither style");
}
int ditherGroup(DitherStyle style)
{
const int index = int(style);
return index < 2 ? 0 : index < 5 ? 1 : index < 8 ? 2 : 3;
}
bool diffuses(DitherStyle style)
{
return ditherGroup(style) == 0;
}
bool hasTones(DitherStyle style)
{
return ditherGroup(style) < 2;
}
bool isHalftone(DitherStyle style)
{
return ditherGroup(style) == 2;
}
bool drawsMarks(DitherStyle style)
{
return !hasTones(style);
}
QString rawValue(DitherPixelShape shape)
{
return shape == DitherPixelShape::square ? QStringLiteral("Square") : QStringLiteral("Dot");
}
QString rawValue(DitherColors colors)
{
switch (colors) {
case DitherColors::blackWhite: return QStringLiteral("Black & White");
case DitherColors::twoColors: return QStringLiteral("Two Colors");
case DitherColors::original: return QStringLiteral("Original");
}
throw std::logic_error("unknown dither colours");
}
DitherSettings DitherSettings::normalized() const
{
using ImageAdjustmentPixels::clamp;
DitherSettings result = *this;
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
result.angle = clamp(angle, -90, 90, 45);
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
result.diffusion = clamp(diffusion, 0, 100, 100);
result.density = clamp(density, -100, 100, 0);
result.contrast = clamp(contrast, -100, 100, 0);
result.dark = dark.clamped();
result.light = light.clamped();
// Line breaks go; 64 characters at most.
QStringList kept;
for (const QString &grapheme : graphemes(characters)) {
if (!isNewline(grapheme) && kept.size() < 64)
kept << grapheme;
}
result.characters = kept.join(QString());
return result;
}
QImage DitherSettings::apply(const QImage &image) const
{
const DitherSettings settings = normalized();
const int block = int(settings.pixelSize);
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (source.isNull())
throw ExportError(ExportError::Kind::render);
const int width = source.width(), height = source.height();
QImage working = source;
if (block > 1) {
// Chunky pixels: each block's area averaged, then dithered small.
working = BrushRaster::context((width + block - 1) / block, (height + block - 1) / block, false);
const int area = block * block;
for (int y = 0; y < working.height(); ++y) {
uchar *out = working.scanLine(y);
for (int x = 0; x < working.width(); ++x) {
std::array<int, 4> sum{};
for (int sy = y * block; sy < std::min(height, (y + 1) * block); ++sy) {
const uchar *row = source.constScanLine(sy);
for (int sx = x * block; sx < std::min(width, (x + 1) * block); ++sx) {
for (int channel = 0; channel < 4; ++channel)
sum[size_t(channel)] += row[sx * 4 + channel];
}
}
for (int channel = 0; channel < 4; ++channel)
out[x * 4 + channel] = uchar((sum[size_t(channel)] + area / 2) / area);
}
}
}
const QImage dithered = settings.dither(working);
if (block <= 1)
return dithered;
// Blown back up without smoothing.
QImage full = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y) {
const uchar *row = dithered.constScanLine(y / block);
uchar *out = full.scanLine(y);
for (int x = 0; x < width; ++x)
std::copy_n(row + (x / block) * 4, 4, out + x * 4);
}
if (settings.pixelShape == DitherPixelShape::dot) {
// The gaps are the dark colour: black, or picked.
const std::array<uint8_t, 3> gap = settings.colors == DitherColors::twoColors ? bytes(settings.dark) : std::array<uint8_t, 3>{0, 0, 0};
dither_dots(full.bits(), size_t(width), size_t(height), size_t(full.bytesPerLine()), block, gap.data());
}
return full;
}
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 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};
bool failed = false;
const QImage result = ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
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())};
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), &params) == 0;
});
if (failed)
throw ExportError(ExportError::Kind::render);
return result;
}
DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int cell)
{
// 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))));
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);
if (map.isNull())
throw ExportError(ExportError::Kind::render);
map.fill(0);
QPainterPath path;
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.
QPainter painter(&map);
painter.setRenderHint(QPainter::Antialiasing);
painter.translate((cell - bounds.width()) / 2 - bounds.left(), (cell - bounds.height()) / 2 - bounds.top());
painter.fillPath(path, Qt::white);
}
std::vector<uint8_t> bytes(size_t(cell * cell));
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)
sum += map.constScanLine(y)[x];
}
drawn.emplace_back(std::move(bytes), float(sum) / float(255 * cell * cell));
}
std::stable_sort(drawn.begin(), drawn.end(), [](const auto &a, const auto &b) { return a.second < b.second; });
Glyphs result;
for (const auto &[map, coverage] : drawn) {
result.maps.insert(result.maps.end(), map.begin(), map.end());
result.coverage.push_back(coverage);
}
return result;
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include "Document/ImageAdjustments.h"
#include <QImage>
#include <QString>
#include <array>
#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};
QString rawValue(DitherStyle style);
// Swift's groups: diffusion, ordered, halftone, marks.
int ditherGroup(DitherStyle style);
// Each pixel's rounding error passes to its neighbours.
bool diffuses(DitherStyle style);
// Diffusion and ordered styles have tones; the rest draw marks.
bool hasTones(DitherStyle style);
bool isHalftone(DitherStyle style);
bool drawsMarks(DitherStyle style);
// A chunky pixel: a square, or a dot on dark.
enum class DitherPixelShape { square, dot };
inline constexpr std::array allDitherPixelShapes{DitherPixelShape::square, DitherPixelShape::dot};
QString rawValue(DitherPixelShape shape);
enum class DitherColors { blackWhite, twoColors, original };
inline constexpr std::array allDitherColors{DitherColors::blackWhite, DitherColors::twoColors, DitherColors::original};
QString rawValue(DitherColors colors);
// Swift's DitherSettings: the filter's look, in layer pixels.
struct DitherSettings {
static constexpr double pixelSizeLow = 1, pixelSizeHigh = 32;
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
static constexpr double levelsLow = 2, levelsHigh = 8;
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.
double cellSize = 8;
// The halftone screen's angle, in degrees.
double angle = 45;
// Tones per channel; 2 is 1-bit.
double levels = 2;
// How much error passes on, 0–100%.
double diffusion = 100;
// −100…100: more ink or less, flatter or punchier.
double density = 0;
double contrast = 0;
DitherColors colors = DitherColors::blackWhite;
AdjustmentColor dark = AdjustmentColor(0, 0, 0);
AdjustmentColor light = AdjustmentColor(1, 1, 1);
// Marks stand for the light tones, lit on the dark.
bool lightOnDark = true;
// ASCII's characters, sorted by their ink.
QString characters = defaultCharacters();
DitherSettings normalized() const;
QImage apply(const QImage &image) const;
friend bool operator==(const DitherSettings &, const DitherSettings &) = default;
// Each distinct character's coverage map, least ink first.
struct Glyphs {
std::vector<uint8_t> maps;
std::vector<float> coverage;
};
static Glyphs glyphs(const QString &characters, int cell);
private:
QImage dither(const QImage &image) const;
};
+4
View File
@@ -26,6 +26,10 @@ public:
bool isModified() const { return m_revision != m_savedRevision; } bool isModified() const { return m_revision != m_savedRevision; }
int undoCount() const { return int(m_past.size()); } int undoCount() const { return int(m_past.size()); }
void markSaved() { m_savedRevision = m_revision; } void markSaved() { m_savedRevision = m_revision; }
// The document now, for a save that finishes later.
QUuid currentRevision() const { return m_revision; }
// A save of `saved` finished; later edits stay modified.
void markSaved(QUuid saved) { m_savedRevision = saved; }
void reset(); void reset();
// Takes the steps a staged copy of this history recorded. // Takes the steps a staged copy of this history recorded.
void adopt(DocumentHistory &&staged) noexcept; void adopt(DocumentHistory &&staged) noexcept;
+31
View File
@@ -0,0 +1,31 @@
#include "Document/DocumentLimits.h"
#include <QLocale>
#include <algorithm>
#include <unistd.h>
// Swift's physicalMemory / 16, read once as a static let.
qint64 DocumentLimits::documentPixelBudget()
{
static const qint64 budget = budgetFor(qint64(sysconf(_SC_PHYS_PAGES)) * qint64(sysconf(_SC_PAGE_SIZE)));
return budget;
}
qint64 DocumentLimits::budgetFor(qint64 memory)
{
return std::min<qint64>(800'000'000, std::max(maxSurfacePixels, memory / 16));
}
qint64 DocumentLimits::maxSurfaceMegapixels()
{
return maxSurfacePixels / 1'000'000;
}
qint64 DocumentLimits::documentBudgetMegapixels()
{
return documentPixelBudget() / 1'000'000;
}
QString DocumentLimits::maxSideText()
{
return QLocale(QLocale::English).toString(maxSide);
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <QString>
#include <QtGlobal>
// Swift's DocumentLimits: one surface, and a whole document's raster.
namespace DocumentLimits {
// Longest side of any canvas, layer, mask or generated surface.
inline constexpr int maxSide = 30'000;
// Largest single surface; both ceilings stay under maxSide squared.
inline constexpr qint64 maxSurfacePixels = 200'000'000;
// All layers and masks: a quarter of memory, capped.
qint64 documentPixelBudget();
// The budget for this much memory, in bytes.
qint64 budgetFor(qint64 memory);
qint64 maxSurfaceMegapixels();
qint64 documentBudgetMegapixels();
// The side limit as Swift's `formatted()` prints it: 30,000.
QString maxSideText();
}
+19 -7
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "IO/ProjectStore.h" #include "IO/ProjectStore.h"
@@ -37,11 +38,11 @@ bool EditorSession::canPaint() const
} }
// A tiled raster edit of pixels or mask, within budget. // A tiled raster edit of pixels or mask, within budget.
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings) const std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings, bool growsMask) const
{ {
if (!m_document) if (!m_document)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size()); auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size(), growsMask);
qint64 used = 0; qint64 used = 0;
for (const ImageLayer &other : m_document->layers) { for (const ImageLayer &other : m_document->layers) {
if (other.id == layer.id) if (other.id == layer.id)
@@ -50,7 +51,7 @@ std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &lay
if (image) if (image)
used += qint64(image->size().width()) * image->size().height(); used += qint64(image->size().width()) * image->size().height();
} }
stroke->pixelLimit = 100'000'000 - used; stroke->pixelLimit = DocumentLimits::documentPixelBudget() - used;
const std::optional<DocumentSelection> current = selection(); const std::optional<DocumentSelection> current = selection();
if (current) if (current)
stroke->selectionClip = current->clip(m_document->size()); stroke->selectionClip = current->clip(m_document->size());
@@ -60,7 +61,7 @@ std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &lay
if (other.id != layer.id && other.mask) if (other.id != layer.id && other.mask)
maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height(); maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height();
} }
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, 100'000'000 - maskPixels); stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, DocumentLimits::documentPixelBudget() - maskPixels);
} }
return stroke; return stroke;
} }
@@ -107,7 +108,7 @@ void EditorSession::beginBrush(QPointF point)
settings.green = settings.red; settings.green = settings.red;
settings.blue = settings.red; settings.blue = settings.red;
} }
m_brushStroke = makeRasterEdit(layer, settings); m_brushStroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
m_brushStroke->clone = clone; m_brushStroke->clone = clone;
m_brushStroke->isBlur = m_tool == NavigationTool::blur; m_brushStroke->isBlur = m_tool == NavigationTool::blur;
m_brushStroke->append(point); m_brushStroke->append(point);
@@ -224,6 +225,16 @@ void EditorSession::finishBrush()
finishBrushImmediately(); finishBrushImmediately();
} }
// Grown past its layer, or placed already: keeps its place.
LayerMask EditorSession::placedMask(const LayerMask &mask, const ImportedImage &asset, const QRectF &bounds, const LayerTransform &transform,
const BrushStroke &stroke)
{
LayerMask painted = mask.replacing(asset);
if (mask.placement || bounds != stroke.sourceRect)
painted.placement = transform;
return painted;
}
// Immutable tiles land at once, undo entry included. // Immutable tiles land at once, undo entry included.
void EditorSession::commitPaintSnapshot(const BrushStroke &stroke) void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
{ {
@@ -248,7 +259,7 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
: QStringLiteral("Brush Stroke"))); : QStringLiteral("Brush Stroke")));
ImageLayer &layer = m_document->layers[index]; ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) { if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset); layer.mask = current.mask ? placedMask(*current.mask, result.asset, result.bounds, result.transform, stroke) : LayerMask(result.asset);
} else { } else {
layer.asset = result.asset; layer.asset = result.asset;
layer.transform = result.transform; layer.transform = result.transform;
@@ -326,7 +337,8 @@ void EditorSession::finishRasterCommit()
beginEdit(pending.name); beginEdit(pending.name);
ImageLayer &layer = m_document->layers[index]; ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) { if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset); const QRectF bounds = result.pixelBounds.translated(stroke.committedBounds().topLeft());
layer.mask = current.mask ? placedMask(*current.mask, result.asset, bounds, transform, stroke) : LayerMask(result.asset);
} else { } else {
layer.asset = result.asset; layer.asset = result.asset;
layer.transform = transform; layer.transform = transform;
+4 -1
View File
@@ -1,7 +1,7 @@
// Swift's EditorSession+Brush extension: a stroke from press to commit. // Swift's EditorSession+Brush extension: a stroke from press to commit.
public: public:
bool canPaint() const; bool canPaint() const;
std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings()) const; std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings(), bool growsMask = false) const;
void beginBrush(QPointF point); void beginBrush(QPointF point);
void continueBrush(QPointF point); void continueBrush(QPointF point);
// Where a Shift-click's line starts; nil on another target. // Where a Shift-click's line starts; nil on another target.
@@ -22,5 +22,8 @@ public:
private: private:
void finishRasterCommit(); void finishRasterCommit();
// A mask painted: grown or placed, it keeps its place.
static LayerMask placedMask(const LayerMask &mask, const ImportedImage &asset, const QRectF &bounds, const LayerTransform &transform,
const BrushStroke &stroke);
// Where Smoothing lets the brush go; none while slack. // Where Smoothing lets the brush go; none while slack.
std::optional<QPointF> smoothed(QPointF point); std::optional<QPointF> smoothed(QPointF point);
+45 -12
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/ProjectWorkspace.h" #include "Document/ProjectWorkspace.h"
#include "Document/PixelAdjust.h" #include "Document/PixelAdjust.h"
#include "IO/PSD/PSDReader.h" #include "IO/PSD/PSDReader.h"
@@ -99,23 +100,24 @@ void EditorSession::decodeNext()
drainImports(); drainImports();
return; return;
} }
qint64 usedPixels = 0;
if (m_document) {
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height();
}
}
const QUrl url = request.urls[m_importIndex]; const QUrl url = request.urls[m_importIndex];
if (url.isLocalFile() && RawImporter::matches(url.toLocalFile())) { if (url.isLocalFile() && RawImporter::matches(url.toLocalFile())) {
decodeRaw(url.toLocalFile(), 100'000'000 - usedPixels); decodeRaw(url.toLocalFile(), remainingPixels());
return;
}
// An SVG goes by its suffix, before Photoshop's bytes.
if (url.isLocalFile() && ImageImporter::isSVG(url.toLocalFile())) {
decode(url, false);
return; return;
} }
if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) { if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) {
beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import")); beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import"));
m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = 100'000'000 - usedPixels]() -> PhotoshopRead { m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = remainingPixels()]() -> PhotoshopRead {
try { try {
PSDDocument document = ImageImporter::loadPhotoshop(path, remaining); PSDDocument document = ImageImporter::loadPhotoshop(path, remaining);
// No layer records: Photoshop wrote the merged image alone.
if (document.layers.empty())
return {std::nullopt, {}, QString(), true};
std::map<QUuid, ImportedImage> assets = ImageImporter::photoshopAssets(document); std::map<QUuid, ImportedImage> assets = ImageImporter::photoshopAssets(document);
return {std::move(document), std::move(assets), QString()}; return {std::move(document), std::move(assets), QString()};
} catch (const ImageImportError &error) { } catch (const ImageImportError &error) {
@@ -128,13 +130,38 @@ void EditorSession::decodeNext()
})); }));
return; return;
} }
m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded { decode(url, false);
}
qint64 EditorSession::remainingPixels() const
{
qint64 usedPixels = 0;
if (m_document) {
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height();
}
}
return DocumentLimits::documentPixelBudget() - usedPixels;
}
// `flattened`: a layerless Photoshop file, read as its merged image.
void EditorSession::decode(const QUrl &url, bool flattened)
{
const std::optional<QSizeF> fitting = m_document ? std::optional(m_document->size()) : std::nullopt;
m_decoder.setFuture(QtConcurrent::run([url, flattened, fitting, remaining = remainingPixels()]() -> Decoded {
try { try {
if (!url.isLocalFile()) if (!url.isLocalFile())
throw ImageImportError(ImageImportError::Kind::unsupported); throw ImageImportError(ImageImportError::Kind::unsupported);
return {ImageImporter::decode(url.toLocalFile(), remaining), QString()}; if (ImageImporter::isSVG(url.toLocalFile()))
return {ImageImporter::decodeSVG(url.toLocalFile(), fitting, remaining), QString()};
return {ImageImporter::decode(url.toLocalFile(), remaining, flattened), QString()};
} catch (const ImageImportError &error) { } catch (const ImageImportError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()}; return {std::nullopt, url.fileName() + ": " + error.what()};
} catch (const PSDError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()};
} catch (const ExportError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()};
} }
})); }));
} }
@@ -150,7 +177,7 @@ void EditorSession::decodeRaw(const QString &path, qint64 remaining)
}; };
if (!size) if (!size)
return refuse(ImageImportError::Kind::unreadable); return refuse(ImageImportError::Kind::unreadable);
if (size->width() > 30'000 || size->height() > 30'000 || qint64(size->width()) * size->height() > remaining) if (size->width() > DocumentLimits::maxSide || size->height() > DocumentLimits::maxSide || qint64(size->width()) * size->height() > remaining)
return refuse(ImageImportError::Kind::tooLarge); return refuse(ImageImportError::Kind::tooLarge);
developRaw(path, [this, path, name](std::optional<RawDevelopSettings> settings) { developRaw(path, [this, path, name](std::optional<RawDevelopSettings> settings) {
if (!settings) { if (!settings) {
@@ -209,6 +236,12 @@ void EditorSession::finishPhotoshopRead()
m_importIndex += 1; m_importIndex += 1;
decodeNext(); decodeNext();
}; };
// Swift ends the reading first, then reads the merged image.
if (read.layerless) {
endPSDReading();
decode(url, true);
return;
}
// Every image has its asset: making layers cannot fail. // Every image has its asset: making layers cannot fail.
const std::optional<PSDImport> imported = read.document ? std::optional(PSDDocumentBuilder::makeImport(*read.document, read.assets)) : std::nullopt; const std::optional<PSDImport> imported = read.document ? std::optional(PSDDocumentBuilder::makeImport(*read.document, read.assets)) : std::nullopt;
if (!imported) { if (!imported) {
+4
View File
@@ -36,6 +36,8 @@ struct SessionJobs {
std::optional<PSDDocument> document; std::optional<PSDDocument> document;
std::map<QUuid, ImportedImage> assets; std::map<QUuid, ImportedImage> assets;
QString failure; QString failure;
// No layer records: a background alone, read merged next.
bool layerless = false;
}; };
struct Baked { struct Baked {
std::map<QUuid, ImportedImage> images; std::map<QUuid, ImportedImage> images;
@@ -102,6 +104,8 @@ struct SessionJobs {
FilterSettings settings; FilterSettings settings;
// Camera Raw's scope of the grade itself. // Camera Raw's scope of the grade itself.
std::optional<CameraRawScope> scope = std::nullopt; std::optional<CameraRawScope> scope = std::nullopt;
// Its grid's version: a newer grid drops it.
quint64 sourceVersion = 0;
}; };
struct FilterMade { struct FilterMade {
std::optional<ImportedImage> asset; std::optional<ImportedImage> asset;
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession+Model.h" #include "Document/EditorSession+Model.h"
#include "Document/DocumentLimits.h"
#include "IO/ProjectStore.h" #include "IO/ProjectStore.h"
#include <algorithm> #include <algorithm>
@@ -85,7 +86,7 @@ std::optional<int> CanvasDocument::validDimension(const QString &value)
while (last > first && isWhitespace(value[last - 1])) while (last > first && isWhitespace(value[last - 1]))
--last; --last;
const std::optional<int> number = wholeNumber(QStringView(value).mid(first, last - first)); const std::optional<int> number = wholeNumber(QStringView(value).mid(first, last - first));
if (!number || *number < 1 || *number > 30'000) if (!number || *number < 1 || *number > DocumentLimits::maxSide)
return std::nullopt; return std::nullopt;
return number; return number;
} }
+25 -1
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Logging.h" #include "Logging.h"
std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
@@ -8,6 +9,8 @@ std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
ProjectSnapshot snapshot{.manifest = {.resolution = m_document->resolution, .documentID = m_document->id, .width = m_document->width, ProjectSnapshot snapshot{.manifest = {.resolution = m_document->resolution, .documentID = m_document->id, .width = m_document->width,
.height = m_document->height, .activeLayerID = m_activeLayerID, .layers = {}}, .height = m_document->height, .activeLayerID = m_activeLayerID, .layers = {}},
.images = {}}; .images = {}};
// Growing would hold two record lists at once.
snapshot.manifest.layers.reserve(m_document->layers.size());
for (const ImageLayer &layer : m_document->layers) { for (const ImageLayer &layer : m_document->layers) {
if (layer.asset) if (layer.asset)
snapshot.images.insert({layer.id, *layer.asset}); snapshot.images.insert({layer.id, *layer.asset});
@@ -45,6 +48,27 @@ void EditorSession::installProject(const ProjectSnapshot &snapshot, const QStrin
notify(); notify();
} }
void EditorSession::reloadProject(const ProjectSnapshot &snapshot)
{
if (!m_projectPath)
return;
const CanvasViewport kept = viewport;
const QSet<QUuid> collapsed = m_collapsedGroupIDs;
const std::optional<QUuid> active = m_activeLayerID;
const QSet<QUuid> selected = m_selectedLayerIDs;
installProject(snapshot, *m_projectPath);
viewport = kept;
QSet<QUuid> ids;
for (const ProjectLayerRecord &record : snapshot.manifest.layers)
ids.insert(record.id);
m_collapsedGroupIDs = collapsed & ids;
if (active && ids.contains(*active)) {
setActiveLayerID(active);
m_selectedLayerIDs = (selected & ids) | QSet<QUuid>{*active};
}
notify();
}
void EditorSession::setProjectPath(std::optional<QString> path) void EditorSession::setProjectPath(std::optional<QString> path)
{ {
m_projectPath = std::move(path); m_projectPath = std::move(path);
@@ -69,7 +93,7 @@ void EditorSession::clearProject()
void EditorSession::createNewProject(int width, int height) void EditorSession::createNewProject(int width, int height)
{ {
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000) if (m_isProjectBusy || m_isImporting || width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide)
return; return;
clearProject(); clearProject();
createDocument(width, height, true); createDocument(width, height, true);
+2
View File
@@ -3,5 +3,7 @@ public:
std::optional<ProjectSnapshot> projectSnapshot() const; std::optional<ProjectSnapshot> projectSnapshot() const;
// Only a package that loaded and validated whole is installed. // Only a package that loaded and validated whole is installed.
void installProject(const ProjectSnapshot &snapshot, const QString &path); void installProject(const ProjectSnapshot &snapshot, const QString &path);
// Swift's reloadProject: what the package holds now, the view kept.
void reloadProject(const ProjectSnapshot &snapshot);
void clearProject(); void clearProject();
void createNewProject(int width, int height); void createNewProject(int width, int height);
+6 -11
View File
@@ -116,7 +116,6 @@ void EditorSession::beginDuplicateTransform()
QSet<QUuid> carried; QSet<QUuid> carried;
for (const QUuid &id : selection) for (const QUuid &id : selection)
carried.unite(descendantIDs(id)); carried.unite(descendantIDs(id));
// Bottom to top: the copies keep their order.
std::vector<QUuid> targets; std::vector<QUuid> targets;
for (const ImageLayer &layer : m_document->layers) { for (const ImageLayer &layer : m_document->layers) {
if (selection.contains(layer.id) && !carried.contains(layer.id)) if (selection.contains(layer.id) && !carried.contains(layer.id))
@@ -125,21 +124,17 @@ void EditorSession::beginDuplicateTransform()
if (targets.empty()) if (targets.empty())
return; return;
beginEdit(targets.size() > 1 ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer")); beginEdit(targets.size() > 1 ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer"));
std::vector<QUuid> copies; // Stacked as Duplicate Layer stacks them.
for (const QUuid &id : targets) { duplicateLayers(targets);
selectLayer(id); const QSet<QUuid> copies = m_selectedLayerIDs - selection;
duplicateActiveLayer();
if (m_activeLayerID != id)
copies.push_back(m_activeLayerID.value());
}
// Past the layer limit nothing was copied. // Past the layer limit nothing was copied.
if (copies.empty()) { if (copies.isEmpty()) {
endEdit(); endEdit();
selectLayers(selection, primary); selectLayers(selection, primary);
return; return;
} }
m_transformDuplicate = TransformDuplicate{copies, selection, primary}; m_transformDuplicate = TransformDuplicate{std::vector<QUuid>(copies.begin(), copies.end()), selection, primary};
selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back()); selectLayers(copies, m_activeLayerID);
beginTransform(false); beginTransform(false);
} }
+25 -3
View File
@@ -1,4 +1,6 @@
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/Crop.h"
#include "Logging.h" #include "Logging.h"
#include <cmath> #include <cmath>
@@ -146,10 +148,19 @@ void EditorSession::selectTool(NavigationTool value)
m_brushSettings.opacity = parked.opacity; m_brushSettings.opacity = parked.opacity;
} }
m_tool = value; m_tool = value;
// The Crop tool opens on the whole canvas, freely. // The Crop tool opens freely: the selection, else the canvas.
if (value == NavigationTool::crop && !m_cropRect && m_document) { if (value == NavigationTool::crop && !m_cropRect && m_document) {
m_cropRatioChoice = QStringLiteral("Free"); m_cropRatioChoice = QStringLiteral("Free");
m_cropRect = QRectF(QPointF(0, 0), m_document->size()); const QRectF canvas(QPointF(0, 0), m_document->size());
m_cropRect = canvas;
if (m_document->selection && !m_document->selection->isEmpty()) {
// CoreGraphics' `integral`: whole pixels, rounded outwards.
const QRectF box = m_document->selection->path.boundingRect();
const QRectF whole = QRectF(QPointF(std::floor(box.left()), std::floor(box.top())), QPointF(std::ceil(box.right()), std::ceil(box.bottom())))
.intersected(canvas);
if (CropGeometry::valid(whole))
m_cropRect = whole;
}
} }
notify(); notify();
} }
@@ -272,7 +283,7 @@ void EditorSession::insert(const ImportedImage &asset, std::optional<QPointF> ce
void EditorSession::createDocument(int width, int height, bool emptyLayer) void EditorSession::createDocument(int width, int height, bool emptyLayer)
{ {
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000) if (m_isProjectBusy || m_isImporting || width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide)
return; return;
commitTransform(); commitTransform();
beginEdit(QStringLiteral("New Canvas")); beginEdit(QStringLiteral("New Canvas"));
@@ -315,6 +326,17 @@ void EditorSession::zoom(double value, std::optional<QPointF> anchor)
notify(); notify();
} }
void EditorSession::zoomKeyboard(int step)
{
if (!m_document)
return;
const double target = viewport.keyboardZoomTarget(step);
if (target == viewport.zoom())
return;
viewport.setZoom(target, viewport.center(), m_document->size());
notify();
}
void EditorSession::setShowsPixelGrid(bool shows) void EditorSession::setShowsPixelGrid(bool shows)
{ {
m_showsPixelGrid = shows; m_showsPixelGrid = shows;
+6 -5
View File
@@ -4,6 +4,7 @@
#include "Document/ColorPalette.h" #include "Document/ColorPalette.h"
#include "Document/Gradient.h" #include "Document/Gradient.h"
#include "Document/Distort.h" #include "Document/Distort.h"
#include "Document/DocumentLimits.h"
#include "Document/Filters.h" #include "Document/Filters.h"
#include "Document/LevelsAutomatic.h" #include "Document/LevelsAutomatic.h"
#include "Document/FloatingSelection.h" #include "Document/FloatingSelection.h"
@@ -253,6 +254,8 @@ public:
void createDocument(int width, int height, bool emptyLayer = false); void createDocument(int width, int height, bool emptyLayer = false);
void fit(); void fit();
void zoom(double value, std::optional<QPointF> anchor = std::nullopt); void zoom(double value, std::optional<QPointF> anchor = std::nullopt);
// Swift's stepped keyboard zoom about the view's centre.
void zoomKeyboard(int step);
// The View menu's grid, shown from 800%. // The View menu's grid, shown from 800%.
bool showsPixelGrid() const { return m_showsPixelGrid; } bool showsPixelGrid() const { return m_showsPixelGrid; }
void setShowsPixelGrid(bool shows); void setShowsPixelGrid(bool shows);
@@ -274,8 +277,6 @@ public:
int canvasFocusRequest() const { return m_canvasFocusRequest; } int canvasFocusRequest() const { return m_canvasFocusRequest; }
void requestCanvasFocus(); void requestCanvasFocus();
// What Paste puts back in place, until another copy.
const std::optional<PixelClipboard> &pixelClipboard() const { return m_pixelClipboard; }
// What went wrong in a tool; the window shows it. // What went wrong in a tool; the window shows it.
const std::optional<QString> &brushError() const { return m_brushError; } const std::optional<QString> &brushError() const { return m_brushError; }
void setBrushError(std::optional<QString> error); void setBrushError(std::optional<QString> error);
@@ -329,6 +330,8 @@ private:
void updateBusyIndicator(); void updateBusyIndicator();
void drainImports(); void drainImports();
void decodeNext(); void decodeNext();
qint64 remainingPixels() const;
void decode(const QUrl &url, bool flattened);
void finishDecode(); void finishDecode();
void finishPhotoshopRead(); void finishPhotoshopRead();
void endDuplicateTransform(); void endDuplicateTransform();
@@ -479,9 +482,7 @@ private:
std::optional<LayerEffects> m_effectsEditingOriginal; std::optional<LayerEffects> m_effectsEditingOriginal;
std::optional<LayerEffectSelection> m_effectSelection; std::optional<LayerEffectSelection> m_effectSelection;
std::optional<PixelClipboard> m_pixelClipboard; std::optional<PixelClipboard> m_pixelClipboard;
// Swift's pasteboard change count, counted from the first copy. std::optional<CopiedLayer> m_copiedLayer;
int m_clipboardChanges = 0;
bool m_watchingClipboard = false;
std::optional<LassoDraft> m_lassoDraft; std::optional<LassoDraft> m_lassoDraft;
LassoKind m_lassoKind = LassoKind::freehand; LassoKind m_lassoKind = LassoKind::freehand;
LassoKind m_marqueeKind = LassoKind::rectangle; LassoKind m_marqueeKind = LassoKind::rectangle;
+58 -24
View File
@@ -20,8 +20,8 @@ void EditorSession::beginFilter(FilterKind kind)
{ {
if (kind == FilterKind::contentAwareFill && !canContentAwareFill()) if (kind == FilterKind::contentAwareFill && !canContentAwareFill())
return; return;
// Swift beeps; `canAdjustColors` reads the open filter. // Swift beeps; the gates read the open filter.
if (m_hueSaturation || !canAdjustColors()) { if (m_hueSaturation || !(kind == FilterKind::vignette ? canVignette() : canAdjustColors())) {
qCWarning(lcApp) << "a filter cannot open here"; qCWarning(lcApp) << "a filter cannot open here";
return; return;
} }
@@ -33,7 +33,7 @@ void EditorSession::beginFilter(FilterKind kind)
commitTransform(); commitTransform();
cancelCrop(); cancelCrop();
cancelLasso(); cancelLasso();
const ImageLayer layer = activeLayer().value(); ImageLayer layer = activeLayer().value();
const QSizeF size = m_document->size(); const QSizeF size = m_document->size();
const std::optional<DocumentSelection> current = selection(); const std::optional<DocumentSelection> current = selection();
FilterSettings settings = m_filterSettings; FilterSettings settings = m_filterSettings;
@@ -41,14 +41,26 @@ void EditorSession::beginFilter(FilterKind kind)
if (kind == FilterKind::gradientMap) if (kind == FilterKind::gradientMap)
settings.gradientMap = GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)}; settings.gradientMap = GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)};
try { try {
// Content-Aware Fill extends the layer over the selection's reach. // An empty layer has no pixels: Vignette starts them clear.
std::optional<QRectF> area; const bool startedEmpty = !layer.asset;
if (startedEmpty) {
const QImage clear = BrushRaster::context(std::max(1, int(std::round(layer.transform.size.width()))),
std::max(1, int(std::round(layer.transform.size.height()))), false);
layer.asset = ImportedImage(clear, PixelAdjust::thumbnail(clear), layer.name);
}
// Fill reaches the selection; Vignette the canvas it frames.
const QRectF canvas(QPointF(0, 0), size);
const bool fillsCanvas = kind == FilterKind::vignette && startedEmpty;
std::optional<QRectF> area = fillsCanvas ? std::optional(canvas) : std::nullopt;
if (kind == FilterKind::contentAwareFill && current) { if (kind == FilterKind::contentAwareFill && current) {
const QRectF box = current->path.boundingRect().intersected(QRectF(QPointF(0, 0), size)); const QRectF box = current->path.boundingRect().intersected(canvas);
if (!box.isEmpty()) if (!box.isEmpty())
area = box; area = box;
} }
m_filterEdit.emplace(kind, layer, current ? std::optional(current->clip(size)) : std::nullopt, settings, area); m_filterEdit.emplace(kind, layer, current ? std::optional(current->clip(size)) : std::nullopt, settings, area);
if (fillsCanvas)
m_filterEdit->canvas = canvas;
m_filterEdit->startedEmpty = startedEmpty;
updateFilter(m_filterEdit->settings, true); updateFilter(m_filterEdit->settings, true);
} catch (const ProjectError &error) { } catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what())); setBrushError(QString::fromUtf8(error.what()));
@@ -69,6 +81,9 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
try { try {
edit.growForBlur(); edit.growForBlur();
edit.preparedPreview.reset(); edit.preparedPreview.reset();
// A waiting job moves to the grid its version names.
if (edit.pending)
edit.pending = edit.previewJob();
} catch (const ProjectError &error) { } catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what())); setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) { } catch (const ExportError &error) {
@@ -112,22 +127,22 @@ void EditorSession::renderFilterPreview()
const FilterJob job = *std::exchange(edit.pending, std::nullopt); const FilterJob job = *std::exchange(edit.pending, std::nullopt);
m_filterPreviewFor = edit.id; m_filterPreviewFor = edit.id;
m_filterRendering = true; m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job]() -> Filtered { m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered {
try { try {
if (job.kind == FilterKind::cameraRaw) { if (job.kind == FilterKind::cameraRaw) {
auto [image, scope] = CameraRawScope::preview(job); auto [image, scope] = CameraRawScope::preview(job);
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope)}; return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version};
} }
return Filtered{PixelFilter::run(job), std::nullopt, job.settings}; return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version};
} catch (const ContentFillError &error) { } catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
} catch (const SubjectRemovalError &error) { } catch (const SubjectRemovalError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
} catch (const ExportError &error) { } catch (const ExportError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
} catch (const ProjectError &error) { } catch (const ProjectError &error) {
// Settings past their bounds: Black & White, Color Balance. // Settings past their bounds: Black & White, Color Balance.
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
} }
})); }));
} }
@@ -138,8 +153,11 @@ void EditorSession::finishFilterPreview()
{ {
m_filterRendering = false; m_filterRendering = false;
const Filtered result = m_filterPreview.result(); const Filtered result = m_filterPreview.result();
// An edit cancelled meanwhile takes nothing; a new one waited. // A cancelled edit takes nothing; an older grid renders again.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) { 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) {
FilterEdit &edit = *m_filterEdit; FilterEdit &edit = *m_filterEdit;
edit.preparing = false; edit.preparing = false;
edit.previewError = result.failure; edit.previewError = result.failure;
@@ -162,10 +180,18 @@ void EditorSession::finishFilterPreview()
} }
} }
namespace {
// A filter's own colours: their pickers go with its panel.
bool filterColour(ColorPickerTarget::Kind kind)
{
return kind == ColorPickerTarget::Kind::gradientMap || kind == ColorPickerTarget::Kind::vignette || kind == ColorPickerTarget::Kind::dither;
}
}
void EditorSession::cancelFilter() void EditorSession::cancelFilter()
{ {
// A Gradient Map colour being picked goes with the panel. // A filter colour being picked goes with the panel.
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap) if (m_colorPicker && filterColour(m_colorPicker->target.kind))
closeColorPicker(false); closeColorPicker(false);
if (finishAdjustmentEditing(false) || !m_filterEdit || m_filterEdit->committing) if (finishAdjustmentEditing(false) || !m_filterEdit || m_filterEdit->committing)
return; return;
@@ -181,7 +207,7 @@ void EditorSession::commitFilter(std::function<void()> done)
if (done) if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
}; };
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap) if (m_colorPicker && filterColour(m_colorPicker->target.kind))
closeColorPicker(true); closeColorPicker(true);
if (finishAdjustmentEditing(true) || !m_filterEdit || m_filterEdit->committing) { if (finishAdjustmentEditing(true) || !m_filterEdit || m_filterEdit->committing) {
finish(); finish();
@@ -204,6 +230,11 @@ void EditorSession::commitFilter(std::function<void()> done)
// Nothing to change closes as Cancel does, with no step. // Nothing to change closes as Cancel does, with no step.
if ((edit.kind == FilterKind::lensCorrection && edit.settings.distortion == 0) if ((edit.kind == FilterKind::lensCorrection && edit.settings.distortion == 0)
|| (edit.kind == FilterKind::exposure && edit.settings.exposure == ExposureSettings()) || (edit.kind == FilterKind::exposure && edit.settings.exposure == ExposureSettings())
|| (edit.kind == FilterKind::vignette && edit.settings.vignetteAmount == 0)
|| (edit.kind == FilterKind::bloomGlow && edit.settings.bloomAmount == 0)
|| (edit.kind == FilterKind::tonalContrast
&& (edit.settings.tonalAmount == 0
|| (edit.settings.tonalShadows == 0 && edit.settings.tonalMidtones == 0 && edit.settings.tonalHighlights == 0)))
|| (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0) || (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0)
|| (edit.kind == FilterKind::cameraRaw && rendered.cameraRaw.isIdentity())) { || (edit.kind == FilterKind::cameraRaw && rendered.cameraRaw.isIdentity())) {
cancelFilter(); cancelFilter();
@@ -224,13 +255,15 @@ void EditorSession::commitFilter(std::function<void()> done)
return; return;
} }
const std::optional<QImage> cached = isAutomatic(edit.kind) && edit.preparedSettings == edit.settings ? edit.preparedPreview : std::nullopt; const std::optional<QImage> cached = isAutomatic(edit.kind) && edit.preparedSettings == edit.settings ? edit.preparedPreview : std::nullopt;
const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur; const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur || edit.kind == FilterKind::bloomGlow;
// A painted layer flattens in the worker, which catches failures. // A painted layer flattens in the worker, which catches failures.
m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = rendered, m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = rendered,
selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached, selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached,
grown = edit.grownTransform, spreads]() -> FilterMade { grown = edit.grownTransform, spreads, canvas = edit.canvas]() -> FilterMade {
try { try {
QImage image = cached ? *cached : PixelFilter::run(FilterJob{kind, grownImage ? *grownImage : original.image(), settings, 1, selection, mapping, seed}); FilterJob job{kind, grownImage ? *grownImage : original.image(), settings, 1, selection, mapping, seed};
job.canvas = canvas;
QImage image = cached ? *cached : PixelFilter::run(job);
std::optional<LayerTransform> placed = grown; std::optional<LayerTransform> placed = grown;
// A blur is cut back to the pixels it left. // A blur is cut back to the pixels it left.
if (spreads && grown) { if (spreads && grown) {
@@ -305,7 +338,9 @@ void EditorSession::finishFilterCommit()
return; return;
} }
const ImageLayer current = m_document->layers[index]; const ImageLayer current = m_document->layers[index];
if (!current.asset || current.asset->identity() != edit.original.identity() || current.transform != edit.transform) { // An empty layer the filter began on stays empty.
const bool kept = current.asset ? current.asset->identity() == edit.original.identity() : edit.startedEmpty;
if (!kept || current.transform != edit.transform) {
finish(); finish();
return; return;
} }
@@ -339,9 +374,8 @@ void EditorSession::finishFilterCommit()
layer.asset = made.asset; layer.asset = made.asset;
layer.transform = made.transform.value_or(current.transform); layer.transform = made.transform.value_or(current.transform);
layer.mask = mask; layer.mask = mask;
// Swift rebuilds the layer without shape, effects and text. // Swift rebuilds the layer without shape and text; effects stay.
layer.shape = std::nullopt; layer.shape = std::nullopt;
layer.effects = std::nullopt;
layer.text = std::nullopt; layer.text = std::nullopt;
m_document->layers[index] = layer; m_document->layers[index] = layer;
endEdit(); endEdit();
+62 -4
View File
@@ -1,4 +1,5 @@
#include "Document/Filters.h" #include "Document/Filters.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "Document/ContentFill.h" #include "Document/ContentFill.h"
#include "Document/EditorSession+Model.h" #include "Document/EditorSession+Model.h"
@@ -12,6 +13,7 @@
#include <numbers> #include <numbers>
extern "C" { extern "C" {
#include "AdjustPixels.h"
#include "BrushPixels.h" #include "BrushPixels.h"
#include "LensPixels.h" #include "LensPixels.h"
#include "NoisePixels.h" #include "NoisePixels.h"
@@ -23,6 +25,10 @@ QString rawValue(FilterKind kind)
case FilterKind::gaussianBlur: return QStringLiteral("Gaussian Blur"); case FilterKind::gaussianBlur: return QStringLiteral("Gaussian Blur");
case FilterKind::motionBlur: return QStringLiteral("Motion Blur"); case FilterKind::motionBlur: return QStringLiteral("Motion Blur");
case FilterKind::addNoise: return QStringLiteral("Add Noise"); case FilterKind::addNoise: return QStringLiteral("Add Noise");
case FilterKind::vignette: return QStringLiteral("Vignette");
case FilterKind::bloomGlow: return QStringLiteral("Bloom / Glow");
case FilterKind::dither: return QStringLiteral("Dither");
case FilterKind::tonalContrast: return QStringLiteral("Tonal Contrast");
case FilterKind::lensCorrection: return QStringLiteral("Lens Correction"); case FilterKind::lensCorrection: return QStringLiteral("Lens Correction");
case FilterKind::cameraRaw: return QStringLiteral("Camera Raw Filter"); case FilterKind::cameraRaw: return QStringLiteral("Camera Raw Filter");
case FilterKind::removeBackground: return QStringLiteral("Remove Background"); case FilterKind::removeBackground: return QStringLiteral("Remove Background");
@@ -61,6 +67,19 @@ FilterSettings FilterSettings::normalized() const
result.angle = clamp(angle, -90, 90, 0); result.angle = clamp(angle, -90, 90, 0);
result.distance = clamp(distance, 1, 2000, 10); result.distance = clamp(distance, 1, 2000, 10);
result.amount = clamp(amount, 0.1, 400, 10); result.amount = clamp(amount, 0.1, 400, 10);
result.vignetteAmount = clamp(vignetteAmount, 0, 100, 35);
result.vignetteColor = vignetteColor.clamped();
result.vignetteMidpoint = clamp(vignetteMidpoint, 0, 100, 50);
result.vignetteRoundness = clamp(vignetteRoundness, -100, 100, 100);
result.vignetteFeather = clamp(vignetteFeather, 0, 100, 60);
result.vignetteHighlights = clamp(vignetteHighlights, 0, 100, 25);
result.bloomAmount = clamp(bloomAmount, 0, 100, 40);
result.bloomRadius = clamp(bloomRadius, 1, 150, 24);
result.tonalAmount = clamp(tonalAmount, 0, 100, 50);
result.tonalRadius = clamp(tonalRadius, 1, 100, 16);
result.tonalShadows = clamp(tonalShadows, -100, 100, 40);
result.tonalMidtones = clamp(tonalMidtones, -100, 100, 60);
result.tonalHighlights = clamp(tonalHighlights, -100, 100, 30);
result.distortion = clamp(distortion, -100, 100, 0); result.distortion = clamp(distortion, -100, 100, 0);
result.refineEdges = clamp(refineEdges, 0, 40, 12); result.refineEdges = clamp(refineEdges, 0, 40, 12);
result.matteContrast = clamp(matteContrast, 0, 100, 25); result.matteContrast = clamp(matteContrast, 0, 100, 25);
@@ -68,6 +87,7 @@ FilterSettings FilterSettings::normalized() const
result.exposure = exposure.normalized(); result.exposure = exposure.normalized();
result.gradientMap = gradientMap.normalized(); result.gradientMap = gradientMap.normalized();
result.grain = grain.normalized(); result.grain = grain.normalized();
result.dither = dither.normalized();
result.cameraRaw = cameraRaw.normalized(); result.cameraRaw = cameraRaw.normalized();
return result; return result;
} }
@@ -107,6 +127,19 @@ QImage drawn(const QImage &image)
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter); BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return context; return context;
} }
// CIBloom is private: the pixels plus their Gaussian glow, scaled.
QImage bloom(QImage pixels, double sigma, double intensity)
{
const QImage glow = PixelAdjust::gaussianBlur(pixels, sigma, false);
for (int y = 0; y < pixels.height(); ++y) {
uchar *row = pixels.scanLine(y);
const uchar *light = glow.constScanLine(y);
for (int index = 0; index < pixels.width() * 4; ++index)
row[index] = uchar(std::min(255.0, std::round(row[index] + intensity * light[index])));
}
return pixels;
}
} }
QImage PixelFilter::run(const FilterJob &job) QImage PixelFilter::run(const FilterJob &job)
@@ -125,6 +158,7 @@ QImage PixelFilter::run(const FilterJob &job)
break; break;
// Grain sits in layer pixels; the job's seed patterns it. // Grain sits in layer pixels; the job's seed patterns it.
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break; case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
case FilterKind::dither: image = settings.dither.apply(job.image); break;
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break; case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
case FilterKind::contentAwareFill: image = ContentFill::run(job); break; case FilterKind::contentAwareFill: image = ContentFill::run(job); break;
// Unclamped: a blur spreads into the room made for it. // Unclamped: a blur spreads into the room made for it.
@@ -137,6 +171,25 @@ QImage PixelFilter::run(const FilterJob &job)
noise_add_at(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0, noise_add_at(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0,
settings.monochromatic ? 1 : 0, job.seed, int64_t(std::floor(job.noiseOrigin.x())), int64_t(std::floor(job.noiseOrigin.y()))); settings.monochromatic ? 1 : 0, job.seed, int64_t(std::floor(job.noiseOrigin.x())), int64_t(std::floor(job.noiseOrigin.y())));
break; break;
case FilterKind::vignette: {
image = drawn(job.image);
// The canvas in this image's pixels, else the image itself.
const QRectF frame = job.canvas ? job.mapping.inverted().mapRect(*job.canvas) : QRectF(0, 0, width, height);
adjust_colored_vignette(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), frame.x(), frame.y(), frame.width(),
frame.height(), job.canvas ? 1 : 0, settings.vignetteAmount,
settings.vignetteMidpoint, settings.vignetteRoundness, settings.vignetteFeather, settings.vignetteHighlights,
settings.vignetteColor.red, settings.vignetteColor.green, settings.vignetteColor.blue);
break;
}
case FilterKind::bloomGlow: image = bloom(drawn(job.image), settings.bloomRadius * job.scale, settings.bloomAmount / 50); break;
case FilterKind::tonalContrast: {
// The detail's base: the layer softened, clear past its edge.
const QImage base = PixelAdjust::gaussianBlur(drawn(job.image), settings.tonalRadius * job.scale, false);
image = drawn(job.image);
adjust_tonal_contrast(image.bits(), base.constBits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), size_t(base.bytesPerLine()),
settings.tonalAmount, settings.tonalShadows, settings.tonalMidtones, settings.tonalHighlights);
break;
}
case FilterKind::lensCorrection: { case FilterKind::lensCorrection: {
// Relative to the image's size, so previews bend alike. // Relative to the image's size, so previews bend alike.
const QImage source = drawn(job.image); const QImage source = drawn(job.image);
@@ -173,6 +226,7 @@ double FilterEdit::blurMargin(FilterKind kind, const FilterSettings &settings)
switch (kind) { switch (kind) {
case FilterKind::gaussianBlur: return settings.radius * 3 + 2; case FilterKind::gaussianBlur: return settings.radius * 3 + 2;
case FilterKind::motionBlur: return settings.distance / 2 + 2; case FilterKind::motionBlur: return settings.distance / 2 + 2;
case FilterKind::bloomGlow: return settings.bloomRadius * 3 + 2;
default: return 0; default: return 0;
} }
} }
@@ -194,7 +248,7 @@ void FilterEdit::grow(const QRectF &extent)
const QRectF target = integral(bounds.united(extent)); const QRectF target = integral(bounds.united(extent));
if (target == bounds) if (target == bounds)
return; return;
if (target.width() > 30'000 || target.height() > 30'000 || target.width() * target.height() > 100'000'000) if (target.width() > DocumentLimits::maxSide || target.height() > DocumentLimits::maxSide || target.width() * target.height() > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
QImage grown = BrushRaster::context(int(target.width()), int(target.height()), false); QImage grown = BrushRaster::context(int(target.width()), int(target.height()), false);
const QRectF inside = bounds.translated(-target.left(), -target.top()); const QRectF inside = bounds.translated(-target.left(), -target.top());
@@ -218,9 +272,11 @@ void FilterEdit::grow(const QRectF &extent)
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed) void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
{ {
const QSize size = source.size(); const QSize size = source.size();
++previewSourceVersion;
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height()); mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise and grain preview whole: enlarged, grain looks coarse. // Noise, grain and dither preview whole: enlarged, they look coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground; const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
|| kind == FilterKind::removeBackground;
const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height())); const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height()));
if (!(factor < 1)) { if (!(factor < 1)) {
previewSource = source.image(); previewSource = source.image();
@@ -259,7 +315,9 @@ FilterJob FilterEdit::previewJob() const
rawPanel.showsShadowClipping, rawPanel.showsShadowClipping,
rawPanel.showsHighlightClipping, rawPanel.showsHighlightClipping,
rawPanel.pointColorVisualizeIndex(settings.cameraRaw), rawPanel.pointColorVisualizeIndex(settings.cameraRaw),
rawPanel.sharpenMask}; rawPanel.sharpenMask,
{},
canvas};
} }
FilterSettings FilterEdit::renderSettings() const FilterSettings FilterEdit::renderSettings() const
+30 -2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "Document/CameraRaw.h" #include "Document/CameraRaw.h"
#include "Document/Curves.h" #include "Document/Curves.h"
#include "Document/Dither.h"
#include "Document/ImageAdjustments.h" #include "Document/ImageAdjustments.h"
#include "Document/LayerTransform.h" #include "Document/LayerTransform.h"
#include "Document/Selection.h" #include "Document/Selection.h"
@@ -16,8 +17,8 @@ struct ImageLayer;
// The Filter and Image menus' filters. // The Filter and Image menus' filters.
enum class FilterKind { enum class FilterKind {
gaussianBlur, motionBlur, addNoise, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain, blackWhite, gaussianBlur, motionBlur, addNoise, vignette, bloomGlow, dither, tonalContrast, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves,
colorBalance exposure, gradientMap, grain, blackWhite, colorBalance
}; };
// The menu's title, Swift's rawValue. // The menu's title, Swift's rawValue.
QString rawValue(FilterKind kind); QString rawValue(FilterKind kind);
@@ -27,6 +28,8 @@ bool isAutomatic(FilterKind kind);
bool isImageAdjustment(FilterKind kind); bool isImageAdjustment(FilterKind kind);
// Swift's allCases, the menus' order. // Swift's allCases, the menus' order.
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise, inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
FilterKind::vignette, FilterKind::bloomGlow, FilterKind::dither,
FilterKind::tonalContrast,
FilterKind::lensCorrection, FilterKind::cameraRaw, FilterKind::removeBackground, FilterKind::lensCorrection, FilterKind::cameraRaw, FilterKind::removeBackground,
FilterKind::contentAwareFill, FilterKind::contentAwareFill,
FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap, FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap,
@@ -49,6 +52,22 @@ struct FilterSettings {
bool gaussian = false; bool gaussian = false;
// The same noise on every channel: brightness alone. // The same noise on every channel: brightness alone.
bool monochromatic = false; bool monochromatic = false;
// Vignette: the edge colour, its strength and falloff.
double vignetteAmount = 35;
AdjustmentColor vignetteColor = AdjustmentColor(0, 0, 0);
double vignetteMidpoint = 50;
double vignetteRoundness = 100;
double vignetteFeather = 60;
double vignetteHighlights = 25;
// Bloom / Glow: strength, and blur radius in layer pixels.
double bloomAmount = 40;
double bloomRadius = 24;
// Tonal Contrast: one detail radius, a strength per tone.
double tonalAmount = 50;
double tonalRadius = 16;
double tonalShadows = 40;
double tonalMidtones = 60;
double tonalHighlights = 30;
// Positive straightens barrel distortion, negative pincushion. // Positive straightens barrel distortion, negative pincushion.
double distortion = 0; double distortion = 0;
CurvesSettings curves{}; CurvesSettings curves{};
@@ -57,6 +76,7 @@ struct FilterSettings {
GrainSettings grain{}; GrainSettings grain{};
BlackWhiteSettings blackWhite{}; BlackWhiteSettings blackWhite{};
ColorBalanceSettings colorBalance{}; ColorBalanceSettings colorBalance{};
DitherSettings dither{};
CameraRawSettings cameraRaw{}; CameraRawSettings cameraRaw{};
BackgroundQuality backgroundQuality = BackgroundQuality::basic; BackgroundQuality backgroundQuality = BackgroundQuality::basic;
// How far, in pixels, the mask is pulled onto edges. // How far, in pixels, the mask is pulled onto edges.
@@ -89,6 +109,8 @@ struct FilterJob {
bool showsSharpenMask = false; bool showsSharpenMask = false;
// A live adjustment's noise field, fixed in its region's pixels. // A live adjustment's noise field, fixed in its region's pixels.
QPointF noiseOrigin{}; QPointF noiseOrigin{};
// Vignette on an empty layer: the canvas it fills.
std::optional<QRectF> canvas = std::nullopt;
}; };
// Swift's PixelFilter. // Swift's PixelFilter.
@@ -151,8 +173,14 @@ public:
std::optional<QImage> preparedPreview; std::optional<QImage> preparedPreview;
// The settings the prepared preview was made with. // The settings the prepared preview was made with.
std::optional<FilterSettings> preparedSettings; std::optional<FilterSettings> preparedSettings;
// Moves with each new grid: an older render lands nothing.
quint64 previewSourceVersion = 0;
std::optional<FilterJob> pending; std::optional<FilterJob> pending;
CameraRawPanel rawPanel; CameraRawPanel rawPanel;
// Vignette on an empty layer: the canvas it fills.
std::optional<QRectF> canvas;
// The layer had no pixels: the filter began clear.
bool startedEmpty = false;
private: private:
void grow(const QRectF &extent); void grow(const QRectF &extent);
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/FloatingSelection.h" #include "Document/FloatingSelection.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "Document/Distort.h" #include "Document/Distort.h"
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
@@ -168,7 +169,7 @@ FloatingMerge::Merged FloatingMerge::merge(const QImage &pixels, const LayerTran
const QRectF floatingBounds = toPixels.mapRect(BrushRaster::pixelToDocument(transform, pixels.width(), pixels.height()).mapRect(QRectF(pixels.rect()))); const QRectF floatingBounds = toPixels.mapRect(BrushRaster::pixelToDocument(transform, pixels.width(), pixels.height()).mapRect(QRectF(pixels.rect())));
const QRectF original(0, 0, width, height); const QRectF original(0, 0, width, height);
const QRectF extent = integral(original.united(floatingBounds)); const QRectF extent = integral(original.united(floatingBounds));
if (extent.width() > 30'000 || extent.height() > 30'000 || extent.width() * extent.height() > 100'000'000) if (extent.width() > DocumentLimits::maxSide || extent.height() > DocumentLimits::maxSide || extent.width() * extent.height() > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
QImage context = BrushRaster::context(int(extent.width()), int(extent.height()), false); QImage context = BrushRaster::context(int(extent.width()), int(extent.height()), false);
const QRectF placed = original.translated(-extent.topLeft()); const QRectF placed = original.translated(-extent.topLeft());
+1 -1
View File
@@ -48,7 +48,7 @@ void EditorSession::beginGradient(QPointF point)
return; return;
finishOpacityEdit(); finishOpacityEdit();
try { try {
m_gradientEdit = GradientEdit{std::shared_ptr<BrushStroke>(makeRasterEdit(*layer)), point, point}; m_gradientEdit = GradientEdit{std::shared_ptr<BrushStroke>(makeRasterEdit(*layer, BrushSettings(), true)), point, point};
++m_brushRevision; ++m_brushRevision;
notify(); notify();
} catch (const ProjectError &error) { } catch (const ProjectError &error) {
+15 -2
View File
@@ -17,11 +17,24 @@ SessionJobs::Adjusted adjusted(const HueSaturationJob &job)
// Swift's session extension in HueSaturation.swift. // Swift's session extension in HueSaturation.swift.
bool EditorSession::canAdjustColors() const bool EditorSession::canAdjustColors() const
{
return canAdjust(false);
}
// Vignette also paints an empty layer, without pixels yet.
bool EditorSession::canVignette() const
{
const std::optional<ImageLayer> layer = activeLayer();
return canAdjust(!(layer && layer->adjustment));
}
bool EditorSession::canAdjust(bool allowingEmpty) const
{ {
const std::optional<ImageLayer> layer = activeLayer(); const std::optional<ImageLayer> layer = activeLayer();
const std::optional<DocumentSelection> current = selection(); const std::optional<DocumentSelection> current = selection();
return !m_levels && !m_filterEdit && m_document && layer && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_pixelMove && !m_renamingLayerID && !m_showsNewDocument // Open text is drawn by its editor: previews would lie.
&& !m_showsImporter && m_selectedLayerIDs.size() == 1 && !layer->isGroup && !m_isMaskSelected && layer->asset return !m_levels && !m_filterEdit && !m_textDraft && m_document && layer && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_pixelMove && !m_renamingLayerID && !m_showsNewDocument
&& !m_showsImporter && m_selectedLayerIDs.size() == 1 && !layer->isGroup && !m_isMaskSelected && (layer->asset || allowingEmpty)
&& m_document->effectiveVisibleIDs().contains(layer->id) && !(current && current->isEmpty()); && m_document->effectiveVisibleIDs().contains(layer->id) && !(current && current->isEmpty());
} }
+2
View File
@@ -1,6 +1,7 @@
// Swift's HueSaturation extension. // Swift's HueSaturation extension.
public: public:
bool canAdjustColors() const; bool canAdjustColors() const;
bool canVignette() const;
void beginHueSaturation(); void beginHueSaturation();
// Previews coalesce: the newest waits for the running one. // Previews coalesce: the newest waits for the running one.
void updateHueSaturation(const HueSaturationSettings &settings, bool preview); void updateHueSaturation(const HueSaturationSettings &settings, bool preview);
@@ -21,6 +22,7 @@ public:
void setHueTargeting(bool targeting); void setHueTargeting(bool targeting);
private: private:
bool canAdjust(bool allowingEmpty) const;
void renderPendingHuePreview(); void renderPendingHuePreview();
void finishHueSaturationPreview(); void finishHueSaturationPreview();
void finishHueSaturationCommit(); void finishHueSaturationCommit();
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/LayerEffects+Renderer.h" #include "Document/LayerEffects+Renderer.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "Logging.h" #include "Logging.h"
@@ -116,7 +117,7 @@ LayerEffectsRenderer::Rendered LayerEffectsRenderer::render(const QImage &image,
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
const double inset = margin(effects); const double inset = margin(effects);
const qint64 width = image.width() + qint64(inset) * 2, height = image.height() + qint64(inset) * 2; const qint64 width = image.width() + qint64(inset) * 2, height = image.height() + qint64(inset) * 2;
if (width <= 0 || height <= 0 || width * height > 100'000'000) if (width <= 0 || height <= 0 || width * height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
// The pixels with room round them, then the effects. // The pixels with room round them, then the effects.
QImage padded = BrushRaster::context(int(width), int(height), false); QImage padded = BrushRaster::context(int(width), int(height), false);
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/Distort.h" #include "Document/Distort.h"
#include "Document/DocumentLimits.h"
#include "Document/LayerMask.h" #include "Document/LayerMask.h"
#include "Document/PixelAdjust.h" #include "Document/PixelAdjust.h"
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
@@ -280,7 +281,7 @@ void EditorSession::addMask(bool revealing)
const int width = active->asset ? active->asset->size().width() : int(std::round(active->size().width())); const int width = active->asset ? active->asset->size().width() : int(std::round(active->size().width()));
const int height = active->asset ? active->asset->size().height() : int(std::round(active->size().height())); const int height = active->asset ? active->asset->size().height() : int(std::round(active->size().height()));
try { try {
if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000) if (width <= 0 || height <= 0 || qint64(width) * height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
// The selection's coverage, soft where it is feathered. // The selection's coverage, soft where it is feathered.
QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height, QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height,
+203
View File
@@ -0,0 +1,203 @@
#include "Document/ProjectWorkspace.h"
#include "Document/DocumentLimits.h"
#include "Document/LiveLayerMask.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QFutureWatcher>
#include <QtConcurrent>
// Several rows are copied one after another, as Swift awaits.
void ProjectWorkspace::copyNext(std::vector<QUuid> ids, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done)
{
if (ids.empty()) {
finish(done);
return;
}
const QUuid first = ids.front();
ids.erase(ids.begin());
copyLayer(first, destination, point, [self = QPointer(this), ids = std::move(ids), destination, point, done = std::move(done)]() mutable {
if (self)
self->copyNext(std::move(ids), destination, point, std::move(done));
});
}
std::optional<QUuid> ProjectWorkspace::draggedLayerSource(const QMimeData &data) const
{
const std::vector<QUuid> ids = layerIDs(data);
if (ids.empty())
return std::nullopt;
for (const std::shared_ptr<ProjectTab> &each : m_tabs) {
if (each->session.document() && indexOf(each->session.document()->layers, ids.front()) >= 0)
return each->id;
}
return std::nullopt;
}
bool ProjectWorkspace::canReceiveDrag(const QMimeData &data, std::optional<QUuid> destination) const
{
const std::optional<QUuid> source = draggedLayerSource(data);
return !destination || !source || *source != *destination;
}
void ProjectWorkspace::copyLayer(QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done)
{
copyLayers({id}, destination, point, std::move(done));
}
// Several keep their places relative to each other.
void ProjectWorkspace::copyLayers(const std::vector<QUuid> &ids, std::optional<QUuid> destination, std::optional<QPointF> point,
std::function<void()> done)
{
// A null pointer aborts when read; an iterator would not.
std::shared_ptr<ProjectTab> from;
for (const std::shared_ptr<ProjectTab> &each : m_tabs) {
if (!from && !ids.empty() && each->session.document() && indexOf(each->session.document()->layers, ids.front()) >= 0)
from = each;
}
if (!canSwitch() || !from || !from->session.canEditLayers() || destination == std::optional(from->id)) {
finish(done);
return;
}
Copy copy{.ids = ids, .from = from, .target = nullptr, .original = from->session.document().value(), .layers = {}, .included = {}, .point = point};
const ProjectSnapshot snapshot = copy.from->session.projectSnapshot().value();
copy.target = destination ? tab(*destination) : tab(addTab(false).id);
if (!copy.target || (destination && !copy.target->session.canStartProjectOperation())
|| (copy.target->session.document() && !copy.target->session.canEditLayers())) {
finish(done);
return;
}
for (const QUuid &id : ids) {
copy.included.insert(id);
copy.included.unite(copy.from->session.descendantIDs(id));
}
const auto pixels = [](const std::vector<ImageLayer> &layers) {
qint64 total = 0;
for (const ImageLayer &layer : layers)
total += layer.asset ? qint64(layer.asset->size().width()) * layer.asset->size().height() : 0;
return total;
};
for (const ImageLayer &layer : copy.original.layers) {
if (copy.included.contains(layer.id))
copy.layers.push_back(layer);
}
const qint64 used = copy.target->session.document() ? pixels(copy.target->session.document()->layers) : 0;
if (used + pixels(copy.layers) > DocumentLimits::documentPixelBudget()) {
copy.target->session.setImportError(QStringLiteral("The copied layers exceed this project’s %1-megapixel limit.").arg(DocumentLimits::documentBudgetMegapixels()));
finish(done);
return;
}
setManaging(true);
copy.from->session.setIsProjectBusy(true);
copy.target->session.setIsProjectBusy(true);
// A mask from outside the copy goes into the pixels.
std::vector<QUuid> outside;
for (const ImageLayer &layer : copy.layers) {
if (layer.maskSourceID && !copy.included.contains(*layer.maskSourceID))
outside.push_back(layer.id);
}
auto *watcher = new QFutureWatcher<Baked>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, copy, done] {
watcher->deleteLater();
const Baked baked = watcher->result();
if (baked.failure) {
qCWarning(lcApp).noquote() << "cannot copy the layers:" << *baked.failure;
copy.target->session.setImportError(baked.failure);
} else {
place(copy, baked.images);
}
// Managing ends last: its signal sees both projects free.
copy.from->session.setIsProjectBusy(false);
copy.target->session.setIsProjectBusy(false);
setManaging(false);
finish(done);
});
watcher->setFuture(QtConcurrent::run([snapshot, outside]() -> Baked {
Baked result;
try {
for (const QUuid layer : outside) {
if (const std::optional<ImportedImage> image = LiveMaskBaker::bake(snapshot, layer))
result.images.insert({layer, *image});
}
} catch (const ExportError &error) {
result.failure = QString::fromUtf8(error.what());
}
return result;
}));
}
void ProjectWorkspace::place(const Copy &copy, const EditorSession::BakedImages &baked)
{
QHash<QUuid, QUuid> mapping;
for (const ImageLayer &layer : copy.layers)
mapping.insert(layer.id, QUuid::createUuid());
EditorSession &into = copy.target->session;
const QSizeF size = into.document() ? into.document()->size() : copy.original.size();
// One layer anchors on its centre; several, on their box.
QRectF pictured;
for (const ImageLayer &layer : copy.layers) {
if (!layer.isGroup)
pictured = pictured.isNull() ? QRectF(layer.transform.origin, layer.transform.size) : pictured.united(QRectF(layer.transform.origin, layer.transform.size));
}
const QPointF anchor = copy.ids.size() == 1 || pictured.isNull() ? copy.layers[indexOf(copy.layers, copy.ids.front())].transform.center() : pictured.center();
const QPointF shift = copy.point.value_or(QPointF(size.width() / 2, size.height() / 2)) - anchor;
const auto mapped = [&](const std::optional<QUuid> &old) {
return old && mapping.contains(*old) ? std::optional(mapping.value(*old)) : std::nullopt;
};
std::vector<ImageLayer> layers;
for (ImageLayer layer : copy.layers) {
if (baked.contains(layer.id))
layer.asset = baked.at(layer.id);
layer.id = mapping.value(layer.id);
layer.transform.origin += shift;
if (layer.mask && layer.mask->placement)
layer.mask->placement->origin += shift;
// A parent or mask source outside the copy lets go.
layer.parentID = mapped(layer.parentID);
layer.maskSourceID = mapped(layer.maskSourceID);
layers.push_back(layer);
}
// Busy sessions refuse a new canvas: free it first.
into.setIsProjectBusy(false);
into.beginEdit(QStringLiteral("Copy Layers from Project"));
if (!into.document())
into.createDocument(int(size.width()), int(size.height()));
into.m_document->layers.insert(into.m_document->layers.end(), layers.begin(), layers.end());
into.setActiveLayerID(mapping.value(copy.ids.front()));
into.m_selectedLayerIDs.clear();
// A layer the source tab lacks was not copied.
for (const QUuid &id : copy.ids) {
if (mapping.contains(id))
into.m_selectedLayerIDs.insert(mapping.value(id));
}
into.endEdit();
// Announced by the caller, which stops managing next.
m_selectedID = copy.target->id;
}
// Ctrl+V with layers copied whole; false leaves Paste to pixels.
bool ProjectWorkspace::pasteCopiedLayer()
{
std::shared_ptr<ProjectTab> source;
for (const std::shared_ptr<ProjectTab> &each : m_tabs) {
const std::optional<CopiedLayer> &copied = each->session.copiedLayer();
if (!source && copied && EditorSession::clipboardHolds(copied->data))
source = each;
}
if (!source || !source->session.document())
return false;
std::vector<QUuid> ids;
for (const QUuid &id : source->session.copiedLayer()->ids) {
if (indexOf(source->session.document()->layers, id) >= 0)
ids.push_back(id);
}
if (ids.empty())
return false;
if (source->id == m_selectedID) {
if (!source->session.canEditLayers())
return false;
source->session.duplicateLayers(ids, QStringLiteral("Paste"));
return true;
}
copyLayers(ids, m_selectedID);
return true;
}
+12 -154
View File
@@ -1,13 +1,9 @@
#include "Document/ProjectWorkspace.h" #include "Document/ProjectWorkspace.h"
#include "Document/LiveLayerMask.h"
#include "IO/ImageExporter.h"
#include "IO/ImageFileDrop.h" #include "IO/ImageFileDrop.h"
#include "Logging.h" #include "Logging.h"
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QFutureWatcher>
#include <QTimer> #include <QTimer>
#include <QtConcurrent>
const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row"); const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row");
@@ -96,6 +92,7 @@ void ProjectWorkspace::select(QUuid id)
current().session.commitTransform(); current().session.commitTransform();
m_selectedID = id; m_selectedID = id;
current().controller.window = window; current().controller.window = window;
current().controller.resumeExternalChangeCheck();
emit changed(); emit changed();
} }
@@ -213,9 +210,19 @@ std::vector<std::shared_ptr<ProjectTab>> ProjectWorkspace::quitOrder() const
return order; return order;
} }
// Swift's finishTextEditing: open text is applied before asking.
bool ProjectWorkspace::finishTextEditing()
{
for (const std::shared_ptr<ProjectTab> &each : quitOrder()) {
if (each->session.textDraft() && !each->session.finishText())
return false;
}
return true;
}
void ProjectWorkspace::confirmQuit(std::function<void(bool)> done) void ProjectWorkspace::confirmQuit(std::function<void(bool)> done)
{ {
if (!canSwitch()) { if (!finishTextEditing() || !canSwitch()) {
finish(done, false); finish(done, false);
return; return;
} }
@@ -348,152 +355,3 @@ std::vector<QUuid> ProjectWorkspace::layerIDs(const QMimeData &data)
} }
return ids; return ids;
} }
// Several rows are copied one after another, as Swift awaits.
void ProjectWorkspace::copyNext(std::vector<QUuid> ids, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done)
{
if (ids.empty()) {
finish(done);
return;
}
const QUuid first = ids.front();
ids.erase(ids.begin());
copyLayer(first, destination, point, [self = QPointer(this), ids = std::move(ids), destination, point, done = std::move(done)]() mutable {
if (self)
self->copyNext(std::move(ids), destination, point, std::move(done));
});
}
std::optional<QUuid> ProjectWorkspace::draggedLayerSource(const QMimeData &data) const
{
const std::vector<QUuid> ids = layerIDs(data);
if (ids.empty())
return std::nullopt;
for (const std::shared_ptr<ProjectTab> &each : m_tabs) {
if (each->session.document() && indexOf(each->session.document()->layers, ids.front()) >= 0)
return each->id;
}
return std::nullopt;
}
bool ProjectWorkspace::canReceiveDrag(const QMimeData &data, std::optional<QUuid> destination) const
{
const std::optional<QUuid> source = draggedLayerSource(data);
return !destination || !source || *source != *destination;
}
void ProjectWorkspace::copyLayer(QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done)
{
// A null pointer aborts when read; an iterator would not.
std::shared_ptr<ProjectTab> from;
for (const std::shared_ptr<ProjectTab> &each : m_tabs) {
if (!from && each->session.document() && indexOf(each->session.document()->layers, id) >= 0)
from = each;
}
if (!canSwitch() || !from || !from->session.canEditLayers() || destination == std::optional(from->id)) {
finish(done);
return;
}
Copy copy{.id = id, .from = from, .target = nullptr, .original = from->session.document().value(), .layers = {}, .included = {}, .point = point};
const ProjectSnapshot snapshot = copy.from->session.projectSnapshot().value();
copy.target = destination ? tab(*destination) : tab(addTab(false).id);
if (!copy.target || (destination && !copy.target->session.canStartProjectOperation())
|| (copy.target->session.document() && !copy.target->session.canEditLayers())) {
finish(done);
return;
}
copy.included = copy.from->session.descendantIDs(id);
copy.included.insert(id);
const auto pixels = [](const std::vector<ImageLayer> &layers) {
qint64 total = 0;
for (const ImageLayer &layer : layers)
total += layer.asset ? qint64(layer.asset->size().width()) * layer.asset->size().height() : 0;
return total;
};
for (const ImageLayer &layer : copy.original.layers) {
if (copy.included.contains(layer.id))
copy.layers.push_back(layer);
}
const qint64 used = copy.target->session.document() ? pixels(copy.target->session.document()->layers) : 0;
if (used + pixels(copy.layers) > 100'000'000) {
copy.target->session.setImportError(QStringLiteral("The copied layers exceed this project’s 100-megapixel limit."));
finish(done);
return;
}
setManaging(true);
copy.from->session.setIsProjectBusy(true);
copy.target->session.setIsProjectBusy(true);
// A mask from outside the copy goes into the pixels.
std::vector<QUuid> outside;
for (const ImageLayer &layer : copy.layers) {
if (layer.maskSourceID && !copy.included.contains(*layer.maskSourceID))
outside.push_back(layer.id);
}
auto *watcher = new QFutureWatcher<Baked>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, copy, done] {
watcher->deleteLater();
const Baked baked = watcher->result();
if (baked.failure) {
qCWarning(lcApp).noquote() << "cannot copy the layers:" << *baked.failure;
copy.target->session.setImportError(baked.failure);
} else {
place(copy, baked.images);
}
// Managing ends last: its signal sees both projects free.
copy.from->session.setIsProjectBusy(false);
copy.target->session.setIsProjectBusy(false);
setManaging(false);
finish(done);
});
watcher->setFuture(QtConcurrent::run([snapshot, outside]() -> Baked {
Baked result;
try {
for (const QUuid layer : outside) {
if (const std::optional<ImportedImage> image = LiveMaskBaker::bake(snapshot, layer))
result.images.insert({layer, *image});
}
} catch (const ExportError &error) {
result.failure = QString::fromUtf8(error.what());
}
return result;
}));
}
void ProjectWorkspace::place(const Copy &copy, const EditorSession::BakedImages &baked)
{
QHash<QUuid, QUuid> mapping;
for (const ImageLayer &layer : copy.layers)
mapping.insert(layer.id, QUuid::createUuid());
EditorSession &into = copy.target->session;
const QSizeF size = into.document() ? into.document()->size() : copy.original.size();
const QPointF anchor = copy.layers[indexOf(copy.layers, copy.id)].transform.center();
const QPointF shift = copy.point.value_or(QPointF(size.width() / 2, size.height() / 2)) - anchor;
const auto mapped = [&](const std::optional<QUuid> &old) {
return old && mapping.contains(*old) ? std::optional(mapping.value(*old)) : std::nullopt;
};
std::vector<ImageLayer> layers;
for (ImageLayer layer : copy.layers) {
if (baked.contains(layer.id))
layer.asset = baked.at(layer.id);
layer.id = mapping.value(layer.id);
layer.transform.origin += shift;
if (layer.mask && layer.mask->placement)
layer.mask->placement->origin += shift;
// A parent or mask source outside the copy lets go.
layer.parentID = mapped(layer.parentID);
layer.maskSourceID = mapped(layer.maskSourceID);
// Swift's copy lists its fields; effects are not among them.
layer.effects = std::nullopt;
layers.push_back(layer);
}
// Busy sessions refuse a new canvas: free it first.
into.setIsProjectBusy(false);
into.beginEdit(QStringLiteral("Copy Layers from Project"));
if (!into.document())
into.createDocument(int(size.width()), int(size.height()));
into.m_document->layers.insert(into.m_document->layers.end(), layers.begin(), layers.end());
into.setActiveLayerID(mapping.value(copy.id));
into.endEdit();
// Announced by the caller, which stops managing next.
m_selectedID = copy.target->id;
}
+7 -1
View File
@@ -60,17 +60,23 @@ public:
static std::optional<QUuid> layerID(const QString &text); static std::optional<QUuid> layerID(const QString &text);
void copyLayer(QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point = std::nullopt, void copyLayer(QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point = std::nullopt,
std::function<void()> done = {}); std::function<void()> done = {});
// Folders with all they hold, as one step there.
void copyLayers(const std::vector<QUuid> &ids, std::optional<QUuid> destination, std::optional<QPointF> point = std::nullopt,
std::function<void()> done = {});
// Ctrl+V with layers copied whole; false when none.
bool pasteCopiedLayer();
signals: signals:
void changed(); void changed();
private: private:
bool finishTextEditing();
struct Baked { struct Baked {
EditorSession::BakedImages images; EditorSession::BakedImages images;
std::optional<QString> failure; std::optional<QString> failure;
}; };
struct Copy { struct Copy {
QUuid id; std::vector<QUuid> ids;
std::shared_ptr<ProjectTab> from, target; std::shared_ptr<ProjectTab> from, target;
CanvasDocument original; CanvasDocument original;
std::vector<ImageLayer> layers; std::vector<ImageLayer> layers;
+14 -1
View File
@@ -2,11 +2,21 @@
public: public:
// Ctrl+X: copy, then clear the selected pixels. // Ctrl+X: copy, then clear the selected pixels.
void cutSelection(std::function<void()> done = {}); void cutSelection(std::function<void()> done = {});
// A copy above the layer, then placed; never a folder. // What Paste puts back in place, until another copy.
const std::optional<PixelClipboard> &pixelClipboard() const;
// The layers Copy took whole, until another copy.
const std::optional<CopiedLayer> &copiedLayer() const;
// Whether the clipboard still holds this very copy.
static bool clipboardHolds(const QPointer<QMimeData> &data);
// Every selected layer, a folder with all it holds.
void duplicateActiveLayer(); void duplicateActiveLayer();
// Each layer copied as one step; the copies end selected.
void duplicateLayers(const std::vector<QUuid> &ids, const QString &editName = QStringLiteral("Duplicate Layer"));
bool duplicateLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false); bool duplicateLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false);
std::optional<QRectF> selectionCopyRegion() const; std::optional<QRectF> selectionCopyRegion() const;
bool canCopyPixels() const; bool canCopyPixels() const;
// Copy with no selection takes the layers themselves.
bool canCopyLayer() const;
std::optional<CopiedPixels> renderSelectedPixels(const ImageLayer &layer, bool mask) const; std::optional<CopiedPixels> renderSelectedPixels(const ImageLayer &layer, bool mask) const;
bool canCopyMerged() const; bool canCopyMerged() const;
std::optional<CopiedPixels> renderMergedPixels() const; std::optional<CopiedPixels> renderMergedPixels() const;
@@ -22,3 +32,6 @@ public:
private: private:
void store(const CopiedPixels &copied); void store(const CopiedPixels &copied);
std::vector<QUuid> copiedLayerIDs() const;
std::optional<QUuid> insertCopy(QUuid id);
+118 -21
View File
@@ -163,32 +163,60 @@ void EditorSession::copyMergedSelection()
} }
} }
bool EditorSession::canCopyLayer() const
{
return canEditLayers() && activeLayer() && !selection() && !m_isMaskSelected;
}
// Swift's pasteboard type for a layer copied whole.
static const QString copiedLayerType = QStringLiteral("com.compositor.copied-layer");
void EditorSession::copySelection() void EditorSession::copySelection()
{ {
const std::optional<ImageLayer> layer = activeLayer(); if (!canCopyPixels() && !canCopyLayer())
if (!canCopyPixels() || !layer)
return; return;
const ImageLayer layer = activeLayer().value();
if (!canCopyPixels()) {
auto *data = new QMimeData;
data->setData(copiedLayerType, uuidString(layer.id).toUtf8());
QGuiApplication::clipboard()->setMimeData(data);
m_pixelClipboard = std::nullopt;
m_copiedLayer = CopiedLayer{copiedLayerIDs(), data};
return;
}
try { try {
const std::optional<CopiedPixels> copied = renderSelectedPixels(*layer, m_isMaskSelected); const std::optional<CopiedPixels> copied = renderSelectedPixels(layer, m_isMaskSelected);
if (!copied) { if (!copied) {
qCWarning(lcApp) << "nothing to copy"; qCWarning(lcApp) << "nothing to copy";
return; return;
} }
store(*copied); store(*copied);
if (canCopyLayer())
m_copiedLayer = CopiedLayer{copiedLayerIDs(), m_pixelClipboard.value().data};
} catch (const ExportError &error) { } catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what())); setBrushError(QString::fromUtf8(error.what()));
} }
} }
const std::optional<PixelClipboard> &EditorSession::pixelClipboard() const
{
return m_pixelClipboard;
}
const std::optional<CopiedLayer> &EditorSession::copiedLayer() const
{
return m_copiedLayer;
}
// Swift's change count: our own object while we own it.
bool EditorSession::clipboardHolds(const QPointer<QMimeData> &data)
{
return data && QGuiApplication::clipboard()->mimeData() == data.data();
}
// Keeps pixels for Paste; the system clipboard gets PNG. // Keeps pixels for Paste; the system clipboard gets PNG.
void EditorSession::store(const CopiedPixels &copied) void EditorSession::store(const CopiedPixels &copied)
{ {
QClipboard *clipboard = QGuiApplication::clipboard();
// Counted from the first copy: another since makes Paste external.
if (!m_watchingClipboard) {
m_watchingClipboard = true;
connect(clipboard, &QClipboard::dataChanged, this, [this] { ++m_clipboardChanges; });
}
auto *data = new QMimeData; auto *data = new QMimeData;
data->setImageData(copied.image); data->setImageData(copied.image);
QByteArray png; QByteArray png;
@@ -198,8 +226,9 @@ void EditorSession::store(const CopiedPixels &copied)
data->setData(QStringLiteral("image/png"), png); data->setData(QStringLiteral("image/png"), png);
else else
qCWarning(lcApp) << "the copied pixels could not be encoded as PNG:" << writer.errorString(); qCWarning(lcApp) << "the copied pixels could not be encoded as PNG:" << writer.errorString();
clipboard->setMimeData(data); QGuiApplication::clipboard()->setMimeData(data);
m_pixelClipboard = PixelClipboard{copied.image, copied.region.topLeft(), m_clipboardChanges}; m_pixelClipboard = PixelClipboard{copied.image, copied.region.topLeft(), data};
m_copiedLayer = std::nullopt;
} }
void EditorSession::cutSelection(std::function<void()> done) void EditorSession::cutSelection(std::function<void()> done)
@@ -217,7 +246,7 @@ bool EditorSession::canPaste() const
{ {
if (!m_document || !canEditLayers()) if (!m_document || !canEditLayers())
return false; return false;
if (m_pixelClipboard && m_clipboardChanges == m_pixelClipboard->changeCount) if (m_pixelClipboard && clipboardHolds(m_pixelClipboard->data))
return true; return true;
// An empty clipboard has no data object at all. // An empty clipboard has no data object at all.
const QMimeData *data = clipboardData(); const QMimeData *data = clipboardData();
@@ -229,7 +258,7 @@ void EditorSession::paste()
{ {
if (!canPaste()) if (!canPaste())
return; return;
if (m_pixelClipboard && m_clipboardChanges == m_pixelClipboard->changeCount) { if (m_pixelClipboard && clipboardHolds(m_pixelClipboard->data)) {
addPixelLayer(m_pixelClipboard->image, m_pixelClipboard->origin, nextLayerName(), QStringLiteral("Paste")); addPixelLayer(m_pixelClipboard->image, m_pixelClipboard->origin, nextLayerName(), QStringLiteral("Paste"));
return; return;
} }
@@ -253,12 +282,15 @@ void EditorSession::layerViaCopy()
{ {
const std::optional<ImageLayer> layer = activeLayer(); const std::optional<ImageLayer> layer = activeLayer();
const std::optional<DocumentSelection> current = selection(); const std::optional<DocumentSelection> current = selection();
if (!canEditLayers() || !layer || layer->isGroup || (current && current->isEmpty())) if (!canEditLayers() || !layer || (current && current->isEmpty()))
return; return;
// Without a selection it duplicates, folders included.
if (!current) { if (!current) {
duplicateActiveLayer(); duplicateActiveLayer();
return; return;
} }
if (layer->isGroup)
return;
try { try {
const std::optional<CopiedPixels> copied = renderSelectedPixels(*layer, m_isMaskSelected); const std::optional<CopiedPixels> copied = renderSelectedPixels(*layer, m_isMaskSelected);
if (!copied) { if (!copied) {
@@ -314,13 +346,80 @@ QString EditorSession::nextLayerName() const
return QStringLiteral("Layer %1").arg(number); return QStringLiteral("Layer %1").arg(number);
} }
// Selected layers in document order, none inside a selected folder.
std::vector<QUuid> EditorSession::copiedLayerIDs() const
{
if (!m_document)
return {};
// The active layer is always selected: Swift's union is dead.
const QSet<QUuid> &selected = m_selectedLayerIDs;
QSet<QUuid> nested;
for (const QUuid &id : selected)
nested.unite(descendantIDs(id));
std::vector<QUuid> ids;
for (const ImageLayer &layer : m_document->layers) {
if (selected.contains(layer.id) && !nested.contains(layer.id))
ids.push_back(layer.id);
}
return ids;
}
void EditorSession::duplicateActiveLayer() void EditorSession::duplicateActiveLayer()
{ {
const int index = canEditLayers() ? indexOf(m_document->layers, m_activeLayerID) : -1; duplicateLayers(copiedLayerIDs());
if (index < 0) }
// One copy sits above its original; several, above the topmost.
void EditorSession::duplicateLayers(const std::vector<QUuid> &ids, const QString &editName)
{
if (!canEditLayers() || ids.empty())
return; return;
const std::optional<QUuid> active = m_activeLayerID;
beginEdit(editName);
QHash<QUuid, QUuid> copiesOf;
for (const QUuid &id : ids) {
if (const std::optional<QUuid> copy = insertCopy(id))
copiesOf.insert(id, *copy);
}
if (copiesOf.isEmpty()) {
endEdit();
return;
}
if (copiesOf.size() > 1) {
// Panel order, top first, so folders compare as seen.
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : m_document->layers)
records.push_back(layer.hierarchyRecord());
std::vector<QUuid> originals;
for (const LayerHierarchy::Entry &entry : LayerHierarchy::entries(records, true)) {
if (copiesOf.contains(entry.layer.id))
originals.push_back(entry.layer.id);
}
const std::optional<QUuid> parent = m_document->layers[indexOf(m_document->layers, originals.front())].parentID;
QUuid below = originals.front();
for (auto original = originals.rbegin(); original != originals.rend(); ++original) {
if (placeLayer(copiesOf.value(*original), parent, below))
below = copiesOf.value(*original);
}
}
// Else the first copy in order, where Swift takes any.
std::optional<QUuid> lead = active && copiesOf.contains(*active) ? std::optional(copiesOf.value(*active)) : std::nullopt;
for (const QUuid &id : ids) {
if (!lead && copiesOf.contains(id))
lead = copiesOf.value(id);
}
setActiveLayerID(lead.value());
m_selectedLayerIDs = QSet<QUuid>(copiesOf.cbegin(), copiesOf.cend());
endEdit();
}
// Copies the layer and all it holds, just above it.
std::optional<QUuid> EditorSession::insertCopy(QUuid id)
{
const int index = indexOf(m_document->layers, id);
if (index < 0)
return std::nullopt;
// A folder carries its whole tree, links kept inside it. // A folder carries its whole tree, links kept inside it.
const QUuid id = *m_activeLayerID;
QSet<QUuid> included = descendantIDs(id); QSet<QUuid> included = descendantIDs(id);
included.insert(id); included.insert(id);
std::vector<ImageLayer> copies; std::vector<ImageLayer> copies;
@@ -329,7 +428,7 @@ void EditorSession::duplicateActiveLayer()
copies.push_back(layer); copies.push_back(layer);
} }
if (m_document->layers.size() + copies.size() > 10'000) if (m_document->layers.size() + copies.size() > 10'000)
return; return std::nullopt;
QHash<QUuid, QUuid> mapping; QHash<QUuid, QUuid> mapping;
for (const ImageLayer &copy : copies) for (const ImageLayer &copy : copies)
mapping.insert(copy.id, QUuid::createUuid()); mapping.insert(copy.id, QUuid::createUuid());
@@ -344,14 +443,12 @@ void EditorSession::duplicateActiveLayer()
renamed(copy.parentID); renamed(copy.parentID);
renamed(copy.maskSourceID); renamed(copy.maskSourceID);
} }
beginEdit(QStringLiteral("Duplicate Layer"));
m_document->layers.insert(m_document->layers.begin() + index + 1, copies.begin(), copies.end()); m_document->layers.insert(m_document->layers.begin() + index + 1, copies.begin(), copies.end());
for (auto original = mapping.cbegin(); original != mapping.cend(); ++original) { for (auto original = mapping.cbegin(); original != mapping.cend(); ++original) {
if (m_collapsedGroupIDs.contains(original.key())) if (m_collapsedGroupIDs.contains(original.key()))
m_collapsedGroupIDs.insert(original.value()); m_collapsedGroupIDs.insert(original.value());
} }
setActiveLayerID(mapping.value(id)); return mapping.value(id);
endEdit();
} }
// Alt-drag in the Layers panel: a copy placed where dropped. // Alt-drag in the Layers panel: a copy placed where dropped.
+14 -2
View File
@@ -1,14 +1,18 @@
#pragma once #pragma once
#include <QImage> #include <QImage>
#include <QMimeData>
#include <QPointer>
#include <QPointF> #include <QPointF>
#include <QRectF> #include <QRectF>
#include <QUuid>
#include <vector>
// Pixels copied from the canvas, with where they came from. // Pixels copied from the canvas, with where they came from.
struct PixelClipboard { struct PixelClipboard {
QImage image; QImage image;
QPointF origin; QPointF origin;
// The clipboard's changes when written; more means another copy since. // What we put there; another object means another copy.
int changeCount; QPointer<QMimeData> data;
}; };
// Swift's (image, region) tuple: pixels and their canvas bounds. // Swift's (image, region) tuple: pixels and their canvas bounds.
@@ -16,3 +20,11 @@ struct CopiedPixels {
QImage image; QImage image;
QRectF region; QRectF region;
}; };
// Layers Copy took whole: Paste brings them back complete.
struct CopiedLayer {
// Document order; a copied folder's contents come with it.
std::vector<QUuid> ids;
// As PixelClipboard's: another copy since replaces it.
QPointer<QMimeData> data;
};
+5 -3
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/PixelAdjust.h" #include "Document/PixelAdjust.h"
#include "Document/PixelInvert.h" #include "Document/PixelInvert.h"
@@ -11,7 +12,7 @@ namespace {
// A uniform 1×1 mask given the layer's pixel grid. // A uniform 1×1 mask given the layer's pixel grid.
QImage expandedUniformMask(const QImage &image, int width, int height) QImage expandedUniformMask(const QImage &image, int width, int height)
{ {
if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000) if (width <= 0 || height <= 0 || qint64(width) * height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
QImage expanded = BrushRaster::context(width, height, true); QImage expanded = BrushRaster::context(width, height, true);
QPainter painter(&expanded); QPainter painter(&expanded);
@@ -85,7 +86,8 @@ void EditorSession::applyPixelEdit(const ImageLayer &layer, const QString &name,
{ {
finishOpacityEdit(); finishOpacityEdit();
try { try {
std::unique_ptr<BrushStroke> edit = makeRasterEdit(layer); // A fill on a mask covers the canvas too.
std::unique_ptr<BrushStroke> edit = makeRasterEdit(layer, BrushSettings(), true);
paint(*edit); paint(*edit);
if (edit->patches().empty()) { if (edit->patches().empty()) {
if (done) if (done)
@@ -120,7 +122,7 @@ void EditorSession::deleteLayerOrMask()
bool EditorSession::canInvert() const bool EditorSession::canInvert() const
{ {
const std::optional<ImageLayer> layer = activeLayer(); const std::optional<ImageLayer> layer = activeLayer();
if (!m_document || !layer || m_isProjectBusy || m_isImporting || m_brushStroke || m_pixelMove || m_renamingLayerID || m_showsNewDocument || m_showsImporter if (!m_document || m_textDraft || !layer || m_isProjectBusy || m_isImporting || m_brushStroke || m_pixelMove || m_renamingLayerID || m_showsNewDocument || m_showsImporter
|| m_selectedLayerIDs.size() != 1 || (layer->isGroup && !m_isMaskSelected) || !m_document->effectiveVisibleIDs().contains(layer->id)) || m_selectedLayerIDs.size() != 1 || (layer->isGroup && !m_isMaskSelected) || !m_document->effectiveVisibleIDs().contains(layer->id))
return false; return false;
const std::optional<DocumentSelection> current = selection(); const std::optional<DocumentSelection> current = selection();
+1 -1
View File
@@ -1,7 +1,7 @@
// Swift's ShapeTool extension: shapes dragged out as new layers. // Swift's ShapeTool extension: shapes dragged out as new layers.
public: public:
// Pixels one shape layer may hold, an import's budget. // Pixels one shape layer may hold, an import's budget.
static constexpr qint64 maxShapePixels = 100'000'000; static constexpr qint64 maxShapePixels = DocumentLimits::maxSurfacePixels;
ShapeKind shapeKind() const { return m_shapeKind; } ShapeKind shapeKind() const { return m_shapeKind; }
void setShapeKind(ShapeKind kind); void setShapeKind(ShapeKind kind);
// A rectangle's corner radius, in pixels; 0 keeps corners square. // A rectangle's corner radius, in pixels; 0 keeps corners square.
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/PixelAdjust.h" #include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "Logging.h" #include "Logging.h"
@@ -167,7 +168,7 @@ void EditorSession::finishShape()
return; return;
// Swift's Int(): each side truncated before multiplying. // Swift's Int(): each side truncated before multiplying.
if (std::trunc(rect.width()) * std::trunc(rect.height()) > double(maxShapePixels)) { if (std::trunc(rect.width()) * std::trunc(rect.height()) > double(maxShapePixels)) {
setBrushError(QStringLiteral("That shape is too large. A shape can cover up to 100 megapixels.")); setBrushError(QStringLiteral("That shape is too large. A shape can cover up to %1 megapixels.").arg(DocumentLimits::maxSurfaceMegapixels()));
return; return;
} }
// The ends as fractions of the box, for resizing. // The ends as fractions of the box, for resizing.
+106 -11
View File
@@ -1,11 +1,14 @@
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/PixelAdjust.h" #include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "Logging.h" #include "Logging.h"
#include "Rendering/TextLayout.h" #include "Rendering/TextLayout.h"
#include <QPainter> #include <QPainter>
#include <QTextBoundaryFinder> #include <QTextBoundaryFinder>
#include <algorithm>
#include <cmath> #include <cmath>
#include <limits>
#include <stdexcept> #include <stdexcept>
QString rawValue(TextAlignment alignment) QString rawValue(TextAlignment alignment)
@@ -35,7 +38,7 @@ bool LayerTextStyle::boxIsValid() const
if (!boxSize) if (!boxSize)
return true; return true;
const double width = boxSize->width(), height = boxSize->height(); const double width = boxSize->width(), height = boxSize->height();
return width >= 16 && width <= 30'000 && height >= 16 && height <= 30'000 && width * height <= 100'000'000; return width >= 16 && width <= DocumentLimits::maxSide && height >= 16 && height <= DocumentLimits::maxSide && width * height <= DocumentLimits::maxSurfacePixels;
} }
bool LayerTextStyle::isValid() const bool LayerTextStyle::isValid() const
@@ -43,7 +46,94 @@ bool LayerTextStyle::isValid() const
// Ranges refuse NaN and infinities: Swift's isFinite adds nothing. // Ranges refuse NaN and infinities: Swift's isFinite adds nothing.
const auto within = [](double value, double low, double high) { return value >= low && value <= high; }; const auto within = [](double value, double low, double high) { return value >= low && value <= high; };
return content.size() <= 100'000 && boxIsValid() && within(fontSize, 1, 2000) && within(red, 0, 1) && within(green, 0, 1) return content.size() <= 100'000 && boxIsValid() && within(fontSize, 1, 2000) && within(red, 0, 1) && within(green, 0, 1)
&& within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000); && within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000) && colorRunsAreValid();
}
bool LayerTextStyle::colorRunsAreValid() const
{
if (!colorRuns)
return true;
qint64 end = 0;
for (const LayerTextColorRun &run : *colorRuns) {
// Each after the last, not empty, its end within Int.
if (run.location < end || run.length <= 0 || run.location > std::numeric_limits<qint64>::max() - run.length)
return false;
for (const double channel : {run.red, run.green, run.blue}) {
if (!(channel >= 0 && channel <= 1))
return false;
}
end = run.location + run.length;
}
return !colorRuns->empty() && end <= content.size();
}
PaletteColor LayerTextStyle::color(qint64 index) const
{
for (const LayerTextColorRun &run : colorRuns.value_or(std::vector<LayerTextColorRun>())) {
if (run.location <= index && index < run.location + run.length)
return {run.red, run.green, run.blue};
}
return {red, green, blue};
}
void LayerTextStyle::setColor(const PaletteColor &color, TextSpan span)
{
const qint64 count = content.size();
const qint64 start = std::clamp<qint64>(span.location, 0, count);
const qint64 end = std::max(start, std::min(span.location + span.length, count));
if (start == end || (start == 0 && end == count)) {
red = color.red;
green = color.green;
blue = color.blue;
colorRuns = std::nullopt;
return;
}
std::vector<PaletteColor> colors = unitColors();
std::fill(colors.begin() + start, colors.begin() + end, color);
setUnitColors(colors);
}
void LayerTextStyle::replaceCharacters(TextSpan span, qint64 length)
{
if (!colorRuns)
return;
std::vector<PaletteColor> colors = unitColors();
const qint64 count = qint64(colors.size());
const qint64 start = std::clamp<qint64>(span.location, 0, count);
const qint64 end = std::max(start, std::min(span.location + span.length, count));
// New letters take the colour before them, as typing does.
const PaletteColor inherited = start > 0 ? colors[size_t(start - 1)] : end > start ? colors[size_t(start)] : !colors.empty() ? colors.front() : PaletteColor{red, green, blue};
colors.erase(colors.begin() + start, colors.begin() + end);
colors.insert(colors.begin() + start, size_t(std::max<qint64>(0, length)), inherited);
setUnitColors(colors);
}
std::vector<PaletteColor> LayerTextStyle::unitColors() const
{
std::vector<PaletteColor> colors(size_t(content.size()), PaletteColor{red, green, blue});
for (const LayerTextColorRun &run : colorRuns.value_or(std::vector<LayerTextColorRun>())) {
const qint64 from = std::max<qint64>(0, run.location), to = std::min<qint64>(qint64(colors.size()), run.location + run.length);
for (qint64 index = from; index < to; ++index)
colors[size_t(index)] = {run.red, run.green, run.blue};
}
return colors;
}
void LayerTextStyle::setUnitColors(const std::vector<PaletteColor> &colors)
{
const PaletteColor base{red, green, blue};
std::vector<LayerTextColorRun> runs;
for (size_t index = 0; index < colors.size(); ++index) {
const PaletteColor &color = colors[index];
if (color == base)
continue;
if (!runs.empty() && size_t(runs.back().location + runs.back().length) == index
&& PaletteColor{runs.back().red, runs.back().green, runs.back().blue} == color)
runs.back().length += 1;
else
runs.push_back({qint64(index), 1, color.red, color.green, color.blue});
}
colorRuns = runs.empty() ? std::nullopt : std::optional(runs);
} }
std::optional<LayerText> LayerText::loaded(const std::optional<LayerTextStyle> &style, const std::optional<ImportedImage> &image) std::optional<LayerText> LayerText::loaded(const std::optional<LayerTextStyle> &style, const std::optional<ImportedImage> &image)
@@ -96,9 +186,12 @@ void EditorSession::beginText(QPointF point, bool newLayer)
style.boxSize = std::nullopt; style.boxSize = std::nullopt;
} }
m_tool = NavigationTool::type; m_tool = NavigationTool::type;
// A click puts new text's first baseline on the pointer.
const double padding = LayerTextStyle::padding;
const QPointF origin = target ? target->origin() : point - QPointF(padding, padding + TextLines(style, QSizeF(100'000, 100'000)).baseline(0));
setTextDraft(TextDraft{.documentID = m_document->id, .layerID = target ? std::optional(target->id) : std::nullopt, setTextDraft(TextDraft{.documentID = m_document->id, .layerID = target ? std::optional(target->id) : std::nullopt,
.origin = target ? target->origin() : point, .origin = origin,
.transform = target ? std::optional(target->transform) : std::nullopt, .style = style}); .transform = target ? std::optional(target->transform) : std::nullopt, .style = style, .selection = TextSpan()});
} }
void EditorSession::editActiveText() void EditorSession::editActiveText()
@@ -108,7 +201,7 @@ void EditorSession::editActiveText()
return; return;
m_tool = NavigationTool::type; m_tool = NavigationTool::type;
setTextDraft(TextDraft{.documentID = m_document->id, .layerID = layer->id, .origin = layer->origin(), .transform = layer->transform, setTextDraft(TextDraft{.documentID = m_document->id, .layerID = layer->id, .origin = layer->origin(), .transform = layer->transform,
.style = layer->liveText()->style}); .style = layer->liveText()->style, .selection = TextSpan()});
} }
bool EditorSession::applyText(TextDraft draft) bool EditorSession::applyText(TextDraft draft)
@@ -162,6 +255,8 @@ bool EditorSession::applyText(TextDraft draft)
addPixelLayer(image, draft.origin, layerName(draft.style.content), QStringLiteral("New Text Layer"), false, std::nullopt, draft.style); addPixelLayer(image, draft.origin, layerName(draft.style.content), QStringLiteral("New Text Layer"), false, std::nullopt, draft.style);
} }
m_textDefaults = draft.style; m_textDefaults = draft.style;
// The next text starts in one colour.
m_textDefaults.colorRuns = std::nullopt;
requestCanvasFocus(); requestCanvasFocus();
return succeed(); return succeed();
} catch (const ProjectError &error) { } catch (const ProjectError &error) {
@@ -195,12 +290,14 @@ void EditorSession::beginText(const QRectF &rect)
LayerTextStyle style = m_textDefaults; LayerTextStyle style = m_textDefaults;
style.boxSize = QSizeF(std::max(16.0, std::round(rect.width())), std::max(16.0, std::round(rect.height()))); style.boxSize = QSizeF(std::max(16.0, std::round(rect.width())), std::max(16.0, std::round(rect.height())));
if (!style.boxIsValid()) { if (!style.boxIsValid()) {
setBrushError(QStringLiteral("That text box exceeds the 30,000-pixel or 100-megapixel limit.")); setBrushError(QStringLiteral("That text box exceeds the %1-pixel or %2-megapixel limit.").arg(DocumentLimits::maxSideText()).arg(DocumentLimits::maxSurfaceMegapixels()));
return; return;
} }
beginText(rect.topLeft(), true); beginText(rect.topLeft(), true);
if (m_textDraft) { if (m_textDraft) {
TextDraft draft = *m_textDraft; TextDraft draft = *m_textDraft;
// A dragged box is exactly where it was drawn.
draft.origin = rect.topLeft();
draft.style.boxSize = style.boxSize; draft.style.boxSize = style.boxSize;
setTextDraft(draft); setTextDraft(draft);
} }
@@ -215,11 +312,9 @@ bool EditorSession::recolorText(QUuid id, const PaletteColor &color)
return false; return false;
const ImageLayer layer = m_document->layers[size_t(index)]; const ImageLayer layer = m_document->layers[size_t(index)];
LayerTextStyle style = layer.liveText()->style; LayerTextStyle style = layer.liveText()->style;
if (style.red == color.red && style.green == color.green && style.blue == color.blue) if (style.red == color.red && style.green == color.green && style.blue == color.blue && !style.colorRuns)
return true; return true;
style.red = color.red; style.setColor(color, TextSpan());
style.green = color.green;
style.blue = color.blue;
QImage image, thumbnail; QImage image, thumbnail;
try { try {
image = textImage(style); image = textImage(style);
@@ -297,7 +392,7 @@ QImage EditorSession::textImage(const LayerTextStyle &style)
const QSizeF size = textBoxSize(style); const QSizeF size = textBoxSize(style);
const double width = std::ceil(size.width()), height = std::ceil(size.height()); const double width = std::ceil(size.width()), height = std::ceil(size.height());
// A valid style measures sixteen pixels at least. // A valid style measures sixteen pixels at least.
if (!(width <= 30'000 && height <= 30'000 && width * height <= 100'000'000)) if (!(width <= DocumentLimits::maxSide && height <= DocumentLimits::maxSide && width * height <= DocumentLimits::maxSurfacePixels))
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
QImage context = BrushRaster::context(int(width), int(height), false); QImage context = BrushRaster::context(int(width), int(height), false);
const double padding = LayerTextStyle::padding; const double padding = LayerTextStyle::padding;
+35
View File
@@ -6,12 +6,32 @@
#include <QUuid> #include <QUuid>
#include <array> #include <array>
#include <optional> #include <optional>
#include <vector>
enum class TextAlignment { left, center, right }; enum class TextAlignment { left, center, right };
inline constexpr std::array allTextAlignments{TextAlignment::left, TextAlignment::center, TextAlignment::right}; inline constexpr std::array allTextAlignments{TextAlignment::left, TextAlignment::center, TextAlignment::right};
QString rawValue(TextAlignment alignment); QString rawValue(TextAlignment alignment);
std::optional<TextAlignment> textAlignment(const QString &text); std::optional<TextAlignment> textAlignment(const QString &text);
struct PaletteColor;
// Swift's NSRange: UTF-16 units into a text's content.
struct TextSpan {
qint64 location = 0;
qint64 length = 0;
friend bool operator==(const TextSpan &, const TextSpan &) = default;
};
// Letters in a colour of their own, as Swift's LayerTextColorRun.
struct LayerTextColorRun {
qint64 location = 0;
qint64 length = 0;
double red = 0;
double green = 0;
double blue = 0;
friend bool operator==(const LayerTextColorRun &, const LayerTextColorRun &) = default;
};
// What a text layer says and how, in layer pixels. // What a text layer says and how, in layer pixels.
struct LayerTextStyle { struct LayerTextStyle {
QString content = QStringLiteral("Text"); QString content = QStringLiteral("Text");
@@ -27,13 +47,26 @@ struct LayerTextStyle {
double leading = 0; double leading = 0;
// A paragraph's fixed box; none makes point text. // A paragraph's fixed box; none makes point text.
std::optional<QSizeF> boxSize = std::nullopt; std::optional<QSizeF> boxSize = std::nullopt;
// Sorted, apart, within the content; none is one colour.
std::optional<std::vector<LayerTextColorRun>> colorRuns = std::nullopt;
// The gap between the text and its box, either kind. // The gap between the text and its box, either kind.
static constexpr double padding = 12; static constexpr double padding = 12;
double autoLeading() const { return fontSize * 1.2; } double autoLeading() const { return fontSize * 1.2; }
double lineHeight() const { return leading > 0 ? leading : autoLeading(); } double lineHeight() const { return leading > 0 ? leading : autoLeading(); }
bool boxIsValid() const; bool boxIsValid() const;
bool isValid() const; bool isValid() const;
// The colour of the UTF-16 unit at `index`.
PaletteColor color(qint64 index) const;
// Paints `span`; empty or whole, it recolours all the text.
void setColor(const PaletteColor &color, TextSpan span);
// Moves the colours as `span` becomes `length` units.
void replaceCharacters(TextSpan span, qint64 length);
friend bool operator==(const LayerTextStyle &, const LayerTextStyle &) = default; friend bool operator==(const LayerTextStyle &, const LayerTextStyle &) = default;
private:
bool colorRunsAreValid() const;
std::vector<PaletteColor> unitColors() const;
void setUnitColors(const std::vector<PaletteColor> &colors);
}; };
// A text layer: its style and the image it drew. // A text layer: its style and the image it drew.
@@ -52,5 +85,7 @@ struct TextDraft {
QPointF origin; QPointF origin;
std::optional<LayerTransform> transform = std::nullopt; std::optional<LayerTransform> transform = std::nullopt;
LayerTextStyle style; LayerTextStyle style;
// What the editor selects; colour applies to it alone.
TextSpan selection;
friend bool operator==(const TextDraft &, const TextDraft &) = default; friend bool operator==(const TextDraft &, const TextDraft &) = default;
}; };
+3 -2
View File
@@ -1,4 +1,5 @@
#include "IO/CanvasResizer.h" #include "IO/CanvasResizer.h"
#include "Document/DocumentLimits.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "Logging.h" #include "Logging.h"
#include <QPainter> #include <QPainter>
@@ -9,7 +10,7 @@
namespace { namespace {
ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options) ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options)
{ {
if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000 || options.anchor < 0 if (options.width < 1 || options.width > DocumentLimits::maxSide || options.height < 1 || options.height > DocumentLimits::maxSide || options.anchor < 0
|| options.anchor > 8) || options.anchor > 8)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
const ProjectManifest &old = snapshot.manifest; const ProjectManifest &old = snapshot.manifest;
@@ -49,7 +50,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions
qint64 used = 0; qint64 used = 0;
for (const auto &[id, image] : images) for (const auto &[id, image] : images)
used += qint64(image.size().width()) * image.size().height(); used += qint64(image.size().width()) * image.size().height();
if (options.width * options.height > 100'000'000 - used || manifest.layers.size() >= 10'000) if (options.width * options.height > DocumentLimits::documentPixelBudget() - used || manifest.layers.size() >= 10'000)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
const CanvasExtensionColor &color = *options.fill; const CanvasExtensionColor &color = *options.fill;
for (const double part : {color.red, color.green, color.blue}) { for (const double part : {color.red, color.green, color.blue}) {
+4 -3
View File
@@ -1,4 +1,5 @@
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "Document/DocumentLimits.h"
#include "Document/LayerEffects+Renderer.h" #include "Document/LayerEffects+Renderer.h"
#include "Document/LayerGroups.h" #include "Document/LayerGroups.h"
#include "Document/LayerMask.h" #include "Document/LayerMask.h"
@@ -15,11 +16,11 @@
#include <cmath> #include <cmath>
namespace { namespace {
const char *description(ExportError::Kind kind) std::string description(ExportError::Kind kind)
{ {
switch (kind) { switch (kind) {
case ExportError::Kind::tooLarge: case ExportError::Kind::tooLarge:
return "Image export supports canvases up to 100 megapixels and 30,000 pixels per side."; return QStringLiteral("Image export supports canvases up to %1 megapixels and %2 pixels per side.").arg(DocumentLimits::maxSurfaceMegapixels()).arg(DocumentLimits::maxSideText()).toStdString();
case ExportError::Kind::render: case ExportError::Kind::render:
return "The canvas could not be rendered. Try a smaller canvas."; return "The canvas could not be rendered. Try a smaller canvas.";
case ExportError::Kind::encode: case ExportError::Kind::encode:
@@ -50,7 +51,7 @@ ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kin
ExportRaster ImageExporter::render(const ProjectSnapshot &snapshot) ExportRaster ImageExporter::render(const ProjectSnapshot &snapshot)
try { try {
const qint64 width = snapshot.manifest.width, height = snapshot.manifest.height; const qint64 width = snapshot.manifest.width, height = snapshot.manifest.height;
if (width < 1 || width > 30'000 || height < 1 || height > 30'000 || width * height > 100'000'000) if (width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide || width * height > DocumentLimits::maxSurfacePixels)
throw ExportError(ExportError::Kind::tooLarge); throw ExportError(ExportError::Kind::tooLarge);
QImage image(int(width), int(height), QImage::Format_RGBA8888_Premultiplied); QImage image(int(width), int(height), QImage::Format_RGBA8888_Premultiplied);
if (image.isNull()) if (image.isNull())
+2 -2
View File
@@ -8,12 +8,12 @@
#include <algorithm> #include <algorithm>
namespace { namespace {
// Swift's order: PNG, JPEG, HEIC, TIFF, Photoshop, RAW, then any. // Swift's order: PNG, JPEG, HEIC, TIFF, PSD, PSB, RAW, any.
QStringList pictureFormats(const QMimeData &data) QStringList pictureFormats(const QMimeData &data)
{ {
QStringList formats; QStringList formats;
for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff"), for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff"),
QStringLiteral("image/vnd.adobe.photoshop")}) QStringLiteral("image/vnd.adobe.photoshop"), QStringLiteral("image/x-photoshop-large-image")})
if (data.hasFormat(format)) if (data.hasFormat(format))
formats << format; formats << format;
for (const QString &format : data.formats()) for (const QString &format : data.formats())
+70 -29
View File
@@ -1,4 +1,7 @@
#include "IO/ImageImporter.h" #include "IO/ImageImporter.h"
#include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Document/PixelAdjust.h"
#include "IO/PSD/PSDDocumentBuilder.h" #include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDReader.h" #include "IO/PSD/PSDReader.h"
#include "Logging.h" #include "Logging.h"
@@ -7,6 +10,8 @@
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QImageReader> #include <QImageReader>
#include <QPainter>
#include <QSvgRenderer>
#include <QTransform> #include <QTransform>
#include <cmath> #include <cmath>
#include <libheif/heif.h> #include <libheif/heif.h>
@@ -20,7 +25,7 @@ QString description(ImageImportError::Kind kind)
case ImageImportError::Kind::unsupported: case ImageImportError::Kind::unsupported:
return QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file."); return QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file.");
case ImageImportError::Kind::tooLarge: case ImageImportError::Kind::tooLarge:
return QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."); return QStringLiteral("This import exceeds the current %1-megapixel document budget or %2-pixel side limit.").arg(DocumentLimits::documentBudgetMegapixels()).arg(DocumentLimits::maxSideText());
case ImageImportError::Kind::whiteBalance: case ImageImportError::Kind::whiteBalance:
return QStringLiteral("This camera can’t record that white balance. Move Temperature or Tint back toward the shot."); return QStringLiteral("This camera can’t record that white balance. Move Temperature or Tint back toward the shot.");
} }
@@ -37,7 +42,7 @@ void checkSize(const QSize &size, qint64 remainingPixels, const QString &path)
{ {
if (size.width() <= 0 || size.height() <= 0) if (size.width() <= 0 || size.height() <= 0)
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("it states no size")); refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("it states no size"));
if (size.width() > 30'000 || size.height() > 30'000 || qint64(size.width()) * size.height() > remainingPixels) if (size.width() > DocumentLimits::maxSide || size.height() > DocumentLimits::maxSide || qint64(size.width()) * size.height() > remainingPixels)
refuse(ImageImportError::Kind::tooLarge, path, QStringLiteral("%1 x %2 pixels").arg(size.width()).arg(size.height())); refuse(ImageImportError::Kind::tooLarge, path, QStringLiteral("%1 x %2 pixels").arg(size.width()).arg(size.height()));
} }
@@ -147,6 +152,37 @@ QImage decodeHEIC(const QString &path, qint64 remainingPixels)
image.setColorSpace(stated); image.setColorSpace(stated);
return image; return image;
} }
// Into sRGB with the profile still attached, then turned upright.
ImportedImage finish(QImage image, const QString &path, QImageIOHandler::Transformations orientation)
{
image.convertToColorSpace(QColorSpace::SRgb);
image = std::move(image).convertToFormat(QImage::Format_RGBA8888_Premultiplied);
// Qt 6.9 renamed mirrored as flipped; the floor stays 6.4.
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
Qt::Orientations flips;
flips.setFlag(Qt::Horizontal, orientation.testFlag(QImageIOHandler::TransformationMirror));
flips.setFlag(Qt::Vertical, orientation.testFlag(QImageIOHandler::TransformationFlip));
image = std::move(image).flipped(flips);
#else
image = std::move(image).mirrored(orientation.testFlag(QImageIOHandler::TransformationMirror),
orientation.testFlag(QImageIOHandler::TransformationFlip));
#endif
if (orientation.testFlag(QImageIOHandler::TransformationRotate90))
image = image.transformed(QTransform().rotate(90));
if (image.isNull())
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory"));
image.setColorSpace(QColorSpace::SRgb);
// Swift: scale = min(1, 96 / longest), extent rounded outward.
const qint64 longest = std::max(image.width(), image.height());
const QSize small(int((image.width() * 96 + longest - 1) / longest), int((image.height() * 96 + longest - 1) / longest));
const QImage thumbnail = longest <= 96 ? image : image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (thumbnail.isNull())
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory"));
const QString name = QFileInfo(path).completeBaseName();
qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height();
return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name);
}
} }
ImportedImage::ImportedImage(QImage image, QImage thumbnail, QString name) ImportedImage::ImportedImage(QImage image, QImage thumbnail, QString name)
@@ -189,9 +225,12 @@ void ImageImporter::liftAllocationLimit()
Q_UNUSED(lifted) Q_UNUSED(lifted)
} }
ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels) ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels, bool flattenedPhotoshop)
{ {
liftAllocationLimit(); liftAllocationLimit();
// A Photoshop file of a background alone: its merged image.
if (flattenedPhotoshop && PSDReader::matches(path))
return finish(PSDReader::merged(path, remainingPixels), path, QImageIOHandler::TransformationNone);
QFile file(path); QFile file(path);
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
refuse(ImageImportError::Kind::unreadable, path, file.errorString()); refuse(ImageImportError::Kind::unreadable, path, file.errorString());
@@ -218,32 +257,7 @@ ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels)
if (!reader.read(&image)) if (!reader.read(&image))
refuse(ImageImportError::Kind::unreadable, path, reader.errorString()); refuse(ImageImportError::Kind::unreadable, path, reader.errorString());
} }
image.convertToColorSpace(QColorSpace::SRgb); return finish(std::move(image), path, orientation);
image = std::move(image).convertToFormat(QImage::Format_RGBA8888_Premultiplied);
// Qt 6.9 renamed mirrored as flipped; the floor stays 6.4.
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
Qt::Orientations flips;
flips.setFlag(Qt::Horizontal, orientation.testFlag(QImageIOHandler::TransformationMirror));
flips.setFlag(Qt::Vertical, orientation.testFlag(QImageIOHandler::TransformationFlip));
image = std::move(image).flipped(flips);
#else
image = std::move(image).mirrored(orientation.testFlag(QImageIOHandler::TransformationMirror),
orientation.testFlag(QImageIOHandler::TransformationFlip));
#endif
if (orientation.testFlag(QImageIOHandler::TransformationRotate90))
image = image.transformed(QTransform().rotate(90));
if (image.isNull())
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory"));
image.setColorSpace(QColorSpace::SRgb);
// Swift: scale = min(1, 96 / longest), extent rounded outward.
const qint64 longest = std::max(image.width(), image.height());
const QSize small(int((image.width() * 96 + longest - 1) / longest), int((image.height() * 96 + longest - 1) / longest));
const QImage thumbnail = longest <= 96 ? image : image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (thumbnail.isNull())
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory"));
const QString name = QFileInfo(path).completeBaseName();
qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height();
return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name);
} }
PSDDocument ImageImporter::loadPhotoshop(const QString &path, qint64 remainingPixels) PSDDocument ImageImporter::loadPhotoshop(const QString &path, qint64 remainingPixels)
@@ -255,3 +269,30 @@ std::map<QUuid, ImportedImage> ImageImporter::photoshopAssets(const PSDDocument
{ {
return PSDDocumentBuilder::assets(document); return PSDDocumentBuilder::assets(document);
} }
bool ImageImporter::isSVG(const QString &path)
{
const QString suffix = QFileInfo(path).suffix();
return suffix.compare(QLatin1String("svg"), Qt::CaseInsensitive) == 0 || suffix.compare(QLatin1String("svgz"), Qt::CaseInsensitive) == 0;
}
ImportedImage ImageImporter::decodeSVG(const QString &path, std::optional<QSizeF> fitting, qint64 remainingPixels)
{
QSvgRenderer svg(path);
const QSizeF size = svg.defaultSize();
// A drawing Qt cannot read measures nothing.
if (size.width() <= 0 || size.height() <= 0)
throw ImageImportError(ImageImportError::Kind::unreadable);
const double scale = fitting ? std::min(fitting->width() / size.width(), fitting->height() / size.height()) : 1;
const double width = std::max(1.0, std::round(size.width() * scale)), height = std::max(1.0, std::round(size.height() * scale));
if (!(width <= DocumentLimits::maxSide && height <= DocumentLimits::maxSide && width * height <= double(remainingPixels)))
throw ImageImportError(ImageImportError::Kind::tooLarge);
QImage image = BrushRaster::context(int(width), int(height), false);
QPainter painter(&image);
svg.render(&painter, QRectF(0, 0, width, height));
painter.end();
image.setColorSpace(QColorSpace::SRgb);
const QString name = QFileInfo(path).completeBaseName();
qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height();
return ImportedImage(image, PixelAdjust::thumbnail(image), name.isEmpty() ? QFileInfo(path).fileName() : name);
}
+8 -3
View File
@@ -1,4 +1,5 @@
#pragma once #pragma once
#include "Document/DocumentLimits.h"
#include <QImage> #include <QImage>
#include <QString> #include <QString>
#include <QUuid> #include <QUuid>
@@ -47,8 +48,12 @@ public:
namespace ImageImporter { namespace ImageImporter {
// Qt refuses decodes over 128 MB; the app's budgets rule. // Qt refuses decodes over 128 MB; the app's budgets rule.
void liftAllocationLimit(); void liftAllocationLimit();
// JPEG, PNG, TIFF or HEIC: upright, sRGB, premultiplied. // JPEG, PNG, TIFF, HEIC or a layerless PSD: upright sRGB.
ImportedImage decode(const QString &path, qint64 remainingPixels = 100'000'000); ImportedImage decode(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget(), bool flattenedPhotoshop = false);
PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = 100'000'000); PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
std::map<QUuid, ImportedImage> photoshopAssets(const PSDDocument &document); std::map<QUuid, ImportedImage> photoshopAssets(const PSDDocument &document);
// Swift's `UTType.svg` by suffix: `svg` and `svgz`.
bool isSVG(const QString &path);
// Drawn once: fitted to the canvas, else its declared size.
ImportedImage decodeSVG(const QString &path, std::optional<QSizeF> fitting, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
} }
+5 -4
View File
@@ -1,4 +1,5 @@
#include "IO/ImageResizer.h" #include "IO/ImageResizer.h"
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "Document/LayerMask.h" #include "Document/LayerMask.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
@@ -28,7 +29,7 @@ QImage drawn(QImage::Format format, qint64 width, qint64 height, QPointF topLeft
ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions &options) ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions &options)
{ {
// NaN fails both tests of a range. // NaN fails both tests of a range.
if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000 if (options.width < 1 || options.width > DocumentLimits::maxSide || options.height < 1 || options.height > DocumentLimits::maxSide
|| !(options.resolution >= 1 && options.resolution <= 9600)) || !(options.resolution >= 1 && options.resolution <= 9600))
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
const ProjectManifest &old = snapshot.manifest; const ProjectManifest &old = snapshot.manifest;
@@ -38,7 +39,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
manifest.layers = old.layers; manifest.layers = old.layers;
return {.manifest = manifest, .images = snapshot.images, .masks = snapshot.masks}; return {.manifest = manifest, .images = snapshot.images, .masks = snapshot.masks};
} }
if (options.width * options.height > 100'000'000) if (options.width * options.height > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
const double sx = double(options.width) / double(old.width), sy = double(options.height) / double(old.height); const double sx = double(options.width) / double(old.width), sy = double(options.height) / double(old.height);
// Guides scale with the pixels. // Guides scale with the pixels.
@@ -66,7 +67,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
LayerTransform sourceTransform = layer.transform; LayerTransform sourceTransform = layer.transform;
sourceTransform.sampling = options.sampling; sourceTransform.sampling = options.sampling;
if (layer.imageFile) { if (layer.imageFile) {
if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedPixels) if (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > DocumentLimits::documentPixelBudget() - usedPixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
usedPixels += width * height; usedPixels += width * height;
const auto source = snapshot.images.find(layer.id); const auto source = snapshot.images.find(layer.id);
@@ -90,7 +91,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
if (source->second.size() == QSize(1, 1) || layer.maskPlacement) { if (source->second.size() == QSize(1, 1) || layer.maskPlacement) {
masks.insert({layer.id, source->second}); masks.insert({layer.id, source->second});
} else { } else {
if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedMaskPixels) if (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > DocumentLimits::documentPixelBudget() - usedMaskPixels)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
usedMaskPixels += width * height; usedMaskPixels += width * height;
const QImage image = drawn(QImage::Format_Grayscale8, width, height, {left, top}, sx, sy, [&](QPainter &context) { const QImage image = drawn(QImage::Format_Grayscale8, width, height, {left, top}, sx, sy, [&](QPainter &context) {
+106 -31
View File
@@ -2,65 +2,140 @@
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "IO/PSD/PSDTypes.h" #include "IO/PSD/PSDTypes.h"
#include <QColorSpace> #include <QColorSpace>
#include <span>
namespace { namespace {
std::vector<uchar> unpackRLE(int width, int height, const QByteArray &data) struct Reader {
{ const QByteArray &data;
qsizetype offset = 0; qsizetype offset = 0;
const auto next = [&]() -> uchar { uchar next()
{
if (offset >= data.size()) if (offset >= data.size())
throw PSDError(PSDError::Kind::truncated); throw PSDError(PSDError::Kind::truncated);
return uchar(data[offset++]); return uchar(data[offset++]);
};
std::vector<qsizetype> counts(static_cast<size_t>(height));
for (int row = 0; row < height; ++row) {
const uchar hi = next(), lo = next();
counts[size_t(row)] = qsizetype(hi) << 8 | lo;
} }
std::vector<uchar> plane(size_t(width) * size_t(height)); // Two bytes a row, or four in a Large Document.
for (int row = 0; row < height; ++row) { qsizetype count(bool largeDocument)
const qsizetype end = offset + counts[size_t(row)]; {
if (end > data.size()) qsizetype value = 0;
for (int index = 0; index < (largeDocument ? 4 : 2); ++index)
value = value << 8 | next();
return value;
}
};
// The rows' counts, first checked to fit in the data.
std::vector<qsizetype> counts(Reader &reader, qint64 height, bool largeDocument)
{
if (height > (reader.data.size() - reader.offset) / (largeDocument ? 4 : 2))
throw PSDError(PSDError::Kind::truncated);
std::vector<qsizetype> result(static_cast<size_t>(height));
for (qsizetype &count : result)
count = reader.count(largeDocument);
return result;
}
// Only the crop's columns kept: no source-wide row.
std::vector<uchar> unpackRows(Reader &reader, const std::vector<qsizetype> &counts, qint64 width, const PSDCrop &crop)
{
std::vector<uchar> plane(static_cast<size_t>(crop.width * crop.height));
for (size_t index = 0; index < counts.size(); ++index) {
const qint64 y = qint64(index);
const qsizetype end = reader.offset + counts[index];
if (end > reader.data.size())
throw PSDError(PSDError::Kind::truncated); throw PSDError(PSDError::Kind::truncated);
int written = 0; if (y < crop.y || y >= crop.y + crop.height) {
uchar *line = plane.data() + size_t(row) * size_t(width); reader.offset = end;
continue;
}
// Checked: a row past the crop would overrun.
const std::span<uchar> target = std::span(plane).subspan(static_cast<size_t>((y - crop.y) * crop.width), static_cast<size_t>(crop.width));
// What of a run lies in the crop's columns.
const auto keep = [&](qint64 from, qint64 count, const auto &put) {
const qint64 first = std::max(from, crop.x), last = std::min(from + count, crop.x + crop.width);
for (qint64 x = first; x < last; ++x)
target[static_cast<size_t>(x - crop.x)] = put(x - from);
};
qint64 written = 0;
while (written < width) { while (written < width) {
if (offset >= end) if (reader.offset >= end)
throw PSDError(PSDError::Kind::truncated); throw PSDError(PSDError::Kind::truncated);
const int n = qint8(data[offset++]); const int n = qint8(reader.data[reader.offset++]);
if (n >= 0) { if (n >= 0) {
const int count = n + 1; const qint64 count = n + 1;
if (written + count > width || offset + count > end) if (written + count > width || reader.offset + count > end)
throw PSDError(PSDError::Kind::truncated); throw PSDError(PSDError::Kind::truncated);
std::copy_n(data.constData() + offset, count, line + written); const qsizetype at = reader.offset;
offset += count; keep(written, count, [&](qint64 i) { return uchar(reader.data[at + i]); });
reader.offset += count;
written += count; written += count;
} else if (n != -128) { } else if (n != -128) {
const int count = 1 - n; const qint64 count = 1 - n;
if (written + count > width || offset >= end) if (written + count > width || reader.offset >= end)
throw PSDError(PSDError::Kind::truncated); throw PSDError(PSDError::Kind::truncated);
std::fill_n(line + written, count, uchar(data[offset++])); const uchar value = uchar(reader.data[reader.offset++]);
keep(written, count, [&](qint64) { return value; });
written += count; written += count;
} }
} }
offset = end; reader.offset = end;
} }
return plane; return plane;
} }
std::vector<uchar> cropRaw(qint64 width, qint64 height, const QByteArray &data, qsizetype start, const PSDCrop &crop)
{
if (data.size() - start < width * height)
throw PSDError(PSDError::Kind::truncated);
std::vector<uchar> plane(static_cast<size_t>(crop.width * crop.height));
for (qint64 row = 0; row < crop.height; ++row)
std::copy_n(data.constData() + start + (crop.y + row) * width + crop.x, crop.width, plane.data() + row * crop.width);
return plane;
}
} }
std::vector<uchar> PSDChannelCoder::decode(int compression, int width, int height, const QByteArray &data) std::vector<uchar> PSDChannelCoder::decode(int compression, qint64 width, qint64 height, const QByteArray &data, bool largeDocument,
const std::optional<PSDCrop> &crop)
{ {
if (width <= 0 || height <= 0) if (width <= 0 || height <= 0)
return {}; return {};
const qsizetype expected = qsizetype(width) * height; const PSDCrop part = crop.value_or(PSDCrop{0, 0, width, height});
if (part.x < 0 || part.y < 0 || part.width < 0 || part.height < 0 || part.x + part.width > width || part.y + part.height > height)
throw PSDError(PSDError::Kind::truncated);
if (part.width == 0 || part.height == 0)
return {};
switch (compression) { switch (compression) {
case 0: case 0:
if (data.size() < expected) return cropRaw(width, height, data, 0, part);
throw PSDError(PSDError::Kind::truncated); case 1: {
return std::vector<uchar>(data.constData(), data.constData() + expected); Reader reader{data};
case 1: return unpackRows(reader, counts(reader, height, largeDocument), width, part);
return unpackRLE(width, height, data); }
default:
throw PSDError(PSDError::Kind::unsupportedCompression);
}
}
std::vector<std::vector<uchar>> PSDChannelCoder::mergedPlanes(int compression, int width, int height, int channels, int wanted, const QByteArray &data,
bool largeDocument)
{
const PSDCrop whole{0, 0, width, height};
std::vector<std::vector<uchar>> planes;
switch (compression) {
case 0:
for (int channel = 0; channel < wanted; ++channel)
planes.push_back(cropRaw(width, height, data, qsizetype(channel) * width * height, whole));
return planes;
case 1: {
// Every channel's counts come first; only the wanted are unpacked.
Reader reader{data};
std::vector<std::vector<qsizetype>> table;
for (int channel = 0; channel < channels; ++channel)
table.push_back(counts(reader, height, largeDocument));
for (int channel = 0; channel < wanted; ++channel)
planes.push_back(unpackRows(reader, table[size_t(channel)], width, whole));
return planes;
}
default: default:
throw PSDError(PSDError::Kind::unsupportedCompression); throw PSDError(PSDError::Kind::unsupportedCompression);
} }
+15 -1
View File
@@ -1,11 +1,25 @@
#pragma once #pragma once
#include <QByteArray> #include <QByteArray>
#include <QImage> #include <QImage>
#include <optional>
#include <vector> #include <vector>
// What a channel keeps when cut; wide, as sources are.
struct PSDCrop {
qint64 x;
qint64 y;
qint64 width;
qint64 height;
};
// Photoshop's layer channels: raw and PackBits (Adobe's 2019 specification). // Photoshop's layer channels: raw and PackBits (Adobe's 2019 specification).
namespace PSDChannelCoder { namespace PSDChannelCoder {
std::vector<uchar> decode(int compression, int width, int height, const QByteArray &data); // A Large Document counts PackBits rows in four bytes.
std::vector<uchar> decode(int compression, qint64 width, qint64 height, const QByteArray &data, bool largeDocument = false,
const std::optional<PSDCrop> &crop = std::nullopt);
// The merged image's first `wanted` of its `channels` planes.
std::vector<std::vector<uchar>> mergedPlanes(int compression, int width, int height, int channels, int wanted, const QByteArray &data,
bool largeDocument);
// Straight planes into premultiplied RGBA, sRGB. // Straight planes into premultiplied RGBA, sRGB.
QImage rgbaImage(int width, int height, const std::vector<uchar> &red, const std::vector<uchar> &green, QImage rgbaImage(int width, int height, const std::vector<uchar> &red, const std::vector<uchar> &green,
const std::vector<uchar> &blue, const std::vector<uchar> &alpha); const std::vector<uchar> &blue, const std::vector<uchar> &alpha);
+34 -7
View File
@@ -8,11 +8,30 @@ ImportedImage imported(const QImage &image, const QString &name)
return ImportedImage(image, PixelAdjust::thumbnail(image), name); return ImportedImage(image, PixelAdjust::thumbnail(image), name);
} }
std::vector<QString> notes(const PSDRecord &record) // Swift's `try?`: text that cannot be drawn stays pixels.
std::optional<PSDText::Rendered> rendered(const PSDRecord &record)
{
if (!record.text)
return std::nullopt;
try {
return PSDText::render(*record.text);
} catch (const std::runtime_error &) {
return std::nullopt;
}
}
std::vector<QString> notes(const PSDRecord &record, bool typed)
{ {
std::vector<QString> result; std::vector<QString> result;
if (record.kind == PSDLayerKind::text) if (record.kind == PSDLayerKind::text) {
result.push_back(QStringLiteral("Editable Photoshop text becomes pixels and can’t be retyped.")); if (typed) {
result.insert(result.end(), record.text->notes.begin(), record.text->notes.end());
if (const std::optional<QString> missing = PSDText::missingFontNote(record.text->style.fontName))
result.push_back(*missing);
} else {
result.push_back(PSDText::rasterizedNote);
}
}
if (record.kind == PSDLayerKind::smartObject) if (record.kind == PSDLayerKind::smartObject)
result.push_back(QStringLiteral("The smart object was rasterized. Linked contents can’t be edited.")); result.push_back(QStringLiteral("The smart object was rasterized. Linked contents can’t be edited."));
if (record.kind == PSDLayerKind::effects) if (record.kind == PSDLayerKind::effects)
@@ -41,10 +60,14 @@ std::vector<QString> notes(const PSDRecord &record)
return result; return result;
} }
ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map<QUuid, ImportedImage> &assets) ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map<QUuid, ImportedImage> &assets, const std::optional<PSDText::Rendered> &typed)
{ {
ImageLayer layer(record.name, canvas); ImageLayer layer(record.name, canvas);
if (record.image && !record.isGroup && !record.adjustment) { if (typed && !record.adjustment) {
layer = ImageLayer(imported(typed->image, record.name), QPointF());
layer.transform = typed->transform;
layer.text = LayerText{record.text->style, layer.asset->identity()};
} else if (record.image && !record.isGroup && !record.adjustment) {
const ImportedImage asset = assets.contains(record.id) ? assets.at(record.id) : imported(*record.image, record.name); const ImportedImage asset = assets.contains(record.id) ? assets.at(record.id) : imported(*record.image, record.name);
const QSizeF size = record.bounds.width() > 0 && record.bounds.height() > 0 ? record.bounds.size() : QSizeF(record.image->size()); const QSizeF size = record.bounds.width() > 0 && record.bounds.height() > 0 ? record.bounds.size() : QSizeF(record.image->size());
layer = ImageLayer(asset, record.bounds.topLeft()); layer = ImageLayer(asset, record.bounds.topLeft());
@@ -80,11 +103,15 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
std::vector<ImageLayer> layers; std::vector<ImageLayer> layers;
const QSizeF canvas(document.width, document.height); const QSizeF canvas(document.width, document.height);
for (const PSDRecord &record : document.layers) { for (const PSDRecord &record : document.layers) {
for (const QString &note : notes(record)) if (record.croppedToCanvas)
conversions.push_back(PSDConversion{.layerName = record.name,
.message = QStringLiteral("Cropped to the canvas so the file fits in memory. Pixels outside the canvas weren't imported.")});
const std::optional<PSDText::Rendered> typed = rendered(record);
for (const QString &note : notes(record, typed.has_value()))
conversions.push_back(PSDConversion{.layerName = record.name, .message = note}); conversions.push_back(PSDConversion{.layerName = record.name, .message = note});
if (record.kind == PSDLayerKind::adjustment && !record.adjustment) if (record.kind == PSDLayerKind::adjustment && !record.adjustment)
continue; continue;
ImageLayer layer = layerFor(record, canvas, assets); ImageLayer layer = layerFor(record, canvas, assets, typed);
if (record.mask) { if (record.mask) {
try { try {
layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked); layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked);
+294
View File
@@ -0,0 +1,294 @@
#include "IO/PSD/PSDReader+Records.h"
#include "Document/DocumentLimits.h"
#include "IO/ImageImporter.h"
#include <set>
namespace {
// Bytes 0x80–0xFF in Mac OS Roman, as Python's `mac_roman`.
constexpr char16_t macRoman[128] = {
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3,
0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3,
0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8,
0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA,
0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D,
0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02,
0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC,
0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7,
};
QString fromMacRoman(const QByteArray &bytes)
{
QString text;
text.reserve(bytes.size());
for (const char byte : bytes)
text += uchar(byte) < 0x80 ? QChar(uchar(byte)) : QChar(macRoman[uchar(byte) - 0x80]);
return text;
}
std::optional<QString> unicodeName(const QByteArray &data)
{
if (data.size() < 4)
return std::nullopt;
const qint64 count = PSDRecords::u32(data, 0);
if (count <= 0 || data.size() < 4 + count * 2)
return std::nullopt;
QString name(qsizetype(count), Qt::Uninitialized);
for (qint64 i = 0; i < count; ++i)
name[qsizetype(i)] = QChar(quint16(uchar(data[4 + i * 2]) << 8 | uchar(data[5 + i * 2])));
// Swift trims NUL from both ends.
qsizetype from = 0, to = name.size();
while (from < to && name[from] == QChar(0))
++from;
while (to > from && name[to - 1] == QChar(0))
--to;
return name.mid(from, to - from);
}
// Keys whose lengths a Large Document writes in eight bytes.
const std::set<QString> psbLargeKeys{
QStringLiteral("LMsk"), QStringLiteral("Lr16"), QStringLiteral("Lr32"), QStringLiteral("Layr"), QStringLiteral("Mt16"),
QStringLiteral("Mt32"), QStringLiteral("Mtrn"), QStringLiteral("Alph"), QStringLiteral("FMsk"), QStringLiteral("lnk2"),
QStringLiteral("FEid"), QStringLiteral("FXid"), QStringLiteral("PxSD"),
};
// Transparency, red, green, blue and the user mask only.
bool unpacked(int id)
{
return id == -1 || id == 0 || id == 1 || id == 2 || id == -2;
}
// Pixels and mask each within the side and budget.
bool fits(qint64 width, qint64 height, qint64 maskWidth, qint64 maskHeight, bool hasMask, qint64 remainingPixels)
{
const qint64 budget = std::max<qint64>(0, remainingPixels);
if (width > 0 && height > 0 && (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > budget))
return false;
return !(hasMask && maskWidth > 0 && maskHeight > 0
&& (maskWidth > DocumentLimits::maxSide || maskHeight > DocumentLimits::maxSide || maskWidth * maskHeight > budget));
}
// A box's part on the canvas, from its own corner.
PSDCrop crop(qint64 left, qint64 top, qint64 right, qint64 bottom, qint64 canvasWidth, qint64 canvasHeight)
{
const qint64 croppedLeft = std::min(canvasWidth, std::max<qint64>(0, left)), croppedTop = std::min(canvasHeight, std::max<qint64>(0, top));
const qint64 croppedRight = std::max(croppedLeft, std::min(canvasWidth, right)), croppedBottom = std::max(croppedTop, std::min(canvasHeight, bottom));
return PSDCrop{croppedLeft - left, croppedTop - top, croppedRight - croppedLeft, croppedBottom - croppedTop};
}
}
// Swift's `.ascii` decoding: any byte past 127 reads as nothing.
QString PSDRecords::ascii(const QByteArray &bytes)
{
for (const char byte : bytes)
if (uchar(byte) > 127)
return QString();
return QString::fromLatin1(bytes);
}
quint32 PSDRecords::u32(const QByteArray &data, qsizetype at)
{
return quint32(uchar(data[at])) << 24 | quint32(uchar(data[at + 1])) << 16 | quint32(uchar(data[at + 2])) << 8 | uchar(data[at + 3]);
}
void PSDRecords::Cursor::need(qint64 count) const
{
if (offset < 0 || offset > data.size() || count > data.size() - offset)
throw PSDError(PSDError::Kind::truncated);
}
void PSDRecords::Cursor::skip(qint64 count)
{
if (count < 0)
throw PSDError(PSDError::Kind::truncated);
need(count);
offset += count;
}
uchar PSDRecords::Cursor::u8()
{
need(1);
return uchar(data[offset++]);
}
quint16 PSDRecords::Cursor::u16()
{
const quint16 high = u8();
return quint16(high << 8 | u8());
}
quint32 PSDRecords::Cursor::u32()
{
const quint32 high = u16();
return high << 16 | u16();
}
quint64 PSDRecords::Cursor::u64()
{
const quint64 high = u32();
return high << 32 | u32();
}
qint64 PSDRecords::Cursor::length(bool isPSB)
{
const quint64 value = isPSB ? u64() : u32();
if (value > quint64(std::numeric_limits<qint64>::max()))
throw ImageImportError(ImageImportError::Kind::tooLarge);
return qint64(value);
}
QByteArray PSDRecords::Cursor::bytes(qint64 count)
{
need(count);
offset += count;
return data.mid(offset - count, count);
}
PSDRecords::RawLayer PSDRecords::readRecord(Cursor &cursor, bool isPSB)
{
RawLayer layer;
layer.top = cursor.i32();
layer.left = cursor.i32();
layer.bottom = cursor.i32();
layer.right = cursor.i32();
layer.sourceTop = layer.top, layer.sourceLeft = layer.left, layer.sourceBottom = layer.bottom, layer.sourceRight = layer.right;
const int channelCount = cursor.u16();
if (channelCount > 56)
throw ImageImportError(ImageImportError::Kind::tooLarge);
for (int index = 0; index < channelCount; ++index) {
const int id = cursor.i16();
layer.channels.push_back({id, cursor.length(isPSB)});
}
if (cursor.string(4) != QLatin1String("8BIM"))
throw PSDError(PSDError::Kind::truncated);
layer.blendKey = cursor.string(4);
layer.opacity = cursor.u8();
layer.clipping = cursor.u8() != 0;
layer.hidden = (cursor.u8() & 2) != 0;
cursor.skip(1);
const qint64 extraLength = cursor.u32();
const qint64 extraEnd = cursor.offset + extraLength;
const qint64 maskLength = cursor.u32();
const qint64 maskEnd = cursor.offset + maskLength;
if (maskLength >= 20) {
layer.hasMask = true;
layer.maskTop = cursor.i32();
layer.maskLeft = cursor.i32();
layer.maskBottom = cursor.i32();
layer.maskRight = cursor.i32();
layer.sourceMaskTop = layer.maskTop, layer.sourceMaskLeft = layer.maskLeft;
layer.sourceMaskBottom = layer.maskBottom, layer.sourceMaskRight = layer.maskRight;
// The mask's default colour is skipped, as Swift ignores it.
cursor.skip(1);
const uchar flags = cursor.u8();
layer.maskDisabled = (flags & 2) != 0;
layer.maskLinked = (flags & 1) == 0;
layer.maskFromRender = (flags & 8) != 0;
}
cursor.offset = maskEnd;
cursor.skip(cursor.u32());
const int nameCount = cursor.u8();
layer.name = fromMacRoman(cursor.bytes(nameCount));
cursor.skip((4 - ((nameCount + 1) % 4)) % 4);
while (cursor.offset + 12 <= extraEnd) {
const QString signature = cursor.string(4);
if (signature != QLatin1String("8BIM") && signature != QLatin1String("8B64"))
break;
const QString key = cursor.string(4);
qint64 length;
if (signature == QLatin1String("8B64") || (isPSB && psbLargeKeys.contains(key))) {
if (cursor.offset + 8 > extraEnd)
break;
length = cursor.length(true);
} else {
length = cursor.u32();
}
const QByteArray payload = cursor.bytes(length);
if (length % 2 == 1)
cursor.skip(1);
layer.extra[key] = payload;
if (key == QLatin1String("luni"))
if (const std::optional<QString> unicode = unicodeName(payload))
layer.name = *unicode;
if (key == QLatin1String("iOpa") && !payload.isEmpty())
layer.fill = uchar(payload[0]);
if ((key == QLatin1String("lsct") || key == QLatin1String("lsdk")) && payload.size() >= 4)
layer.section = u32(payload, 0);
}
cursor.offset = extraEnd;
return layer;
}
bool PSDRecords::fitsBudget(const std::vector<RawLayer> &layers, qint64 remainingPixels)
{
qint64 usedPixels = 0;
for (const RawLayer &layer : layers) {
const qint64 width = std::max<qint64>(0, layer.right - layer.left), height = std::max<qint64>(0, layer.bottom - layer.top);
const qint64 maskWidth = std::max<qint64>(0, layer.maskRight - layer.maskLeft), maskHeight = std::max<qint64>(0, layer.maskBottom - layer.maskTop);
if (!fits(width, height, maskWidth, maskHeight, layer.hasMask, remainingPixels - usedPixels))
return false;
if (width > 0 && height > 0)
usedPixels += width * height;
}
return true;
}
void PSDRecords::cropToCanvas(RawLayer &layer, qint64 width, qint64 height)
{
const PSDCrop image = crop(layer.left, layer.top, layer.right, layer.bottom, width, height);
if (image.x != 0 || image.y != 0 || image.width != layer.right - layer.left || image.height != layer.bottom - layer.top) {
layer.left += image.x;
layer.top += image.y;
layer.right = layer.left + image.width;
layer.bottom = layer.top + image.height;
layer.imageCrop = image;
layer.cropped = true;
}
if (!layer.hasMask)
return;
const PSDCrop mask = crop(layer.maskLeft, layer.maskTop, layer.maskRight, layer.maskBottom, width, height);
if (mask.x != 0 || mask.y != 0 || mask.width != layer.maskRight - layer.maskLeft || mask.height != layer.maskBottom - layer.maskTop) {
layer.maskLeft += mask.x;
layer.maskTop += mask.y;
layer.maskRight = layer.maskLeft + mask.width;
layer.maskBottom = layer.maskTop + mask.height;
layer.maskCrop = mask;
layer.cropped = true;
}
}
void PSDRecords::decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels, bool isPSB)
{
std::map<int, std::vector<uchar>> planes;
const qint64 width = std::max<qint64>(0, layer.right - layer.left), height = std::max<qint64>(0, layer.bottom - layer.top);
const qint64 maskWidth = std::max<qint64>(0, layer.maskRight - layer.maskLeft), maskHeight = std::max<qint64>(0, layer.maskBottom - layer.maskTop);
if (!fits(width, height, maskWidth, maskHeight, layer.hasMask, remainingPixels))
throw ImageImportError(ImageImportError::Kind::tooLarge);
const qint64 sourceWidth = std::max<qint64>(0, layer.sourceRight - layer.sourceLeft), sourceHeight = std::max<qint64>(0, layer.sourceBottom - layer.sourceTop);
const qint64 sourceMaskWidth = std::max<qint64>(0, layer.sourceMaskRight - layer.sourceMaskLeft);
const qint64 sourceMaskHeight = std::max<qint64>(0, layer.sourceMaskBottom - layer.sourceMaskTop);
for (const Channel &channel : layer.channels) {
const qint64 start = cursor.offset;
if (unpacked(channel.id) && channel.length >= 2) {
const int compression = cursor.u16();
const QByteArray payload = cursor.bytes(channel.length - 2);
const bool isMask = channel.id == -2;
// Written at the source bounds; a cut decodes part.
const qint64 w = isMask ? maskWidth : width, h = isMask ? maskHeight : height;
if (w > 0 && h > 0)
planes[channel.id] = PSDChannelCoder::decode(compression, isMask ? sourceMaskWidth : sourceWidth, isMask ? sourceMaskHeight : sourceHeight,
payload, isPSB, isMask ? layer.maskCrop : layer.imageCrop);
}
cursor.offset = start + std::max<qint64>(0, channel.length);
}
// Each plane decodes to exactly its size: no count check.
if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && planes.contains(-2))
layer.maskImage = PSDChannelCoder::maskImage(int(maskWidth), int(maskHeight), planes.at(-2));
if (width <= 0 || height <= 0)
return;
const size_t count = size_t(width * height);
const auto plane = [&](int id, uchar fill) { return planes.contains(id) ? planes.at(id) : std::vector<uchar>(count, fill); };
layer.image = PSDChannelCoder::rgbaImage(int(width), int(height), plane(0, 0), plane(1, 0), plane(2, 0), plane(-1, 255));
}
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDTypes.h"
#include <QByteArray>
#include <QImage>
#include <QString>
#include <map>
#include <optional>
#include <vector>
// Swift's PSDReader, split: the layer records and pixels.
namespace PSDRecords {
QString ascii(const QByteArray &bytes);
quint32 u32(const QByteArray &data, qsizetype at);
struct Cursor {
const QByteArray &data;
qint64 offset = 0;
void need(qint64 count) const;
void skip(qint64 count);
uchar u8();
quint16 u16();
qint16 i16() { return qint16(u16()); }
quint32 u32();
qint32 i32() { return qint32(u32()); }
quint64 u64();
// A Large Document's lengths take eight bytes.
qint64 length(bool isPSB);
QByteArray bytes(qint64 count);
QString string(qint64 count) { return ascii(bytes(count)); }
};
struct Channel {
int id;
qint64 length;
};
struct RawLayer {
QString name;
qint64 top = 0, left = 0, bottom = 0, right = 0;
// The bounds the channels were written at, before any crop.
qint64 sourceTop = 0, sourceLeft = 0, sourceBottom = 0, sourceRight = 0;
uchar opacity = 255;
uchar fill = 255;
bool clipping = false;
bool hidden = false;
QString blendKey = QStringLiteral("norm");
std::vector<Channel> channels;
std::map<QString, QByteArray> extra;
qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
qint64 sourceMaskTop = 0, sourceMaskLeft = 0, sourceMaskBottom = 0, sourceMaskRight = 0;
bool maskDisabled = false;
bool maskLinked = true;
bool maskFromRender = false;
bool hasMask = false;
std::optional<qint64> section;
std::optional<QImage> image;
std::optional<QImage> maskImage;
std::optional<PSDCrop> imageCrop;
std::optional<PSDCrop> maskCrop;
bool cropped = false;
bool has(std::initializer_list<const char *> keys) const
{
return std::any_of(keys.begin(), keys.end(), [this](const char *key) { return extra.contains(QString::fromLatin1(key)); });
}
};
RawLayer readRecord(Cursor &cursor, bool isPSB);
// Every layer within the side limit and the budget.
bool fitsBudget(const std::vector<RawLayer> &layers, qint64 remainingPixels);
// Cuts a layer and mask to the canvas, marking it.
void cropToCanvas(RawLayer &layer, qint64 width, qint64 height);
void decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels, bool isPSB);
}
+100 -259
View File
@@ -1,252 +1,14 @@
#include "IO/PSD/PSDReader.h" #include "IO/PSD/PSDReader.h"
#include "Document/DocumentLimits.h"
#include "IO/PSD/PSDChannelCoder.h" #include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDReader+Records.h"
#include "IO/PSD/PSDVector.h" #include "IO/PSD/PSDVector.h"
#include "Logging.h" #include "Logging.h"
#include <QColorSpace>
#include <QFile> #include <QFile>
namespace { namespace {
// Bytes 0x80–0xFF in Mac OS Roman, as Python's `mac_roman`. using namespace PSDRecords;
constexpr char16_t macRoman[128] = {
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3,
0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3,
0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8,
0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA,
0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D,
0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02,
0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC,
0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7,
};
QString fromMacRoman(const QByteArray &bytes)
{
QString text;
text.reserve(bytes.size());
for (const char byte : bytes)
text += uchar(byte) < 0x80 ? QChar(uchar(byte)) : QChar(macRoman[uchar(byte) - 0x80]);
return text;
}
// Swift's `.ascii` decoding: any byte past 127 reads as nothing.
QString ascii(const QByteArray &bytes)
{
for (const char byte : bytes)
if (uchar(byte) > 127)
return QString();
return QString::fromLatin1(bytes);
}
struct Cursor {
const QByteArray &data;
qint64 offset = 0;
void need(qint64 count) const
{
if (offset < 0 || offset > data.size() || count > data.size() - offset)
throw PSDError(PSDError::Kind::truncated);
}
void skip(qint64 count)
{
if (count < 0)
throw PSDError(PSDError::Kind::truncated);
need(count);
offset += count;
}
uchar u8()
{
need(1);
return uchar(data[offset++]);
}
quint16 u16()
{
const quint16 high = u8();
return quint16(high << 8 | u8());
}
qint16 i16() { return qint16(u16()); }
quint32 u32()
{
const quint32 high = u16();
return high << 16 | u16();
}
qint32 i32() { return qint32(u32()); }
QByteArray bytes(qint64 count)
{
need(count);
offset += count;
return data.mid(offset - count, count);
}
QString string(qint64 count) { return ascii(bytes(count)); }
};
quint32 u32(const QByteArray &data, qsizetype at)
{
return quint32(uchar(data[at])) << 24 | quint32(uchar(data[at + 1])) << 16 | quint32(uchar(data[at + 2])) << 8 | uchar(data[at + 3]);
}
struct Channel {
int id;
qint64 length;
};
struct RawLayer {
QString name;
qint64 top = 0, left = 0, bottom = 0, right = 0;
uchar opacity = 255;
uchar fill = 255;
bool clipping = false;
bool hidden = false;
QString blendKey = QStringLiteral("norm");
std::vector<Channel> channels;
std::map<QString, QByteArray> extra;
qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
bool maskDisabled = false;
bool maskLinked = true;
bool maskFromRender = false;
bool hasMask = false;
std::optional<qint64> section;
std::optional<QImage> image;
std::optional<QImage> maskImage;
bool has(std::initializer_list<const char *> keys) const
{
return std::any_of(keys.begin(), keys.end(), [this](const char *key) { return extra.contains(QString::fromLatin1(key)); });
}
};
std::optional<QString> unicodeName(const QByteArray &data)
{
if (data.size() < 4)
return std::nullopt;
const qint64 count = u32(data, 0);
if (count <= 0 || data.size() < 4 + count * 2)
return std::nullopt;
QString name(qsizetype(count), Qt::Uninitialized);
for (qint64 i = 0; i < count; ++i)
name[qsizetype(i)] = QChar(quint16(uchar(data[4 + i * 2]) << 8 | uchar(data[5 + i * 2])));
// Swift trims NUL from both ends.
qsizetype from = 0, to = name.size();
while (from < to && name[from] == QChar(0))
++from;
while (to > from && name[to - 1] == QChar(0))
--to;
return name.mid(from, to - from);
}
RawLayer readRecord(Cursor &cursor)
{
RawLayer layer;
layer.top = cursor.i32();
layer.left = cursor.i32();
layer.bottom = cursor.i32();
layer.right = cursor.i32();
const int channelCount = cursor.u16();
if (channelCount > 56)
throw ImageImportError(ImageImportError::Kind::tooLarge);
for (int index = 0; index < channelCount; ++index) {
const int id = cursor.i16();
layer.channels.push_back({id, cursor.u32()});
}
if (cursor.string(4) != QLatin1String("8BIM"))
throw PSDError(PSDError::Kind::truncated);
layer.blendKey = cursor.string(4);
layer.opacity = cursor.u8();
layer.clipping = cursor.u8() != 0;
layer.hidden = (cursor.u8() & 2) != 0;
cursor.skip(1);
const qint64 extraLength = cursor.u32();
const qint64 extraEnd = cursor.offset + extraLength;
const qint64 maskLength = cursor.u32();
const qint64 maskEnd = cursor.offset + maskLength;
if (maskLength >= 20) {
layer.hasMask = true;
layer.maskTop = cursor.i32();
layer.maskLeft = cursor.i32();
layer.maskBottom = cursor.i32();
layer.maskRight = cursor.i32();
// The mask's default colour is skipped, as Swift ignores it.
cursor.skip(1);
const uchar flags = cursor.u8();
layer.maskDisabled = (flags & 2) != 0;
layer.maskLinked = (flags & 1) == 0;
layer.maskFromRender = (flags & 8) != 0;
}
cursor.offset = maskEnd;
cursor.skip(cursor.u32());
const int nameCount = cursor.u8();
layer.name = fromMacRoman(cursor.bytes(nameCount));
cursor.skip((4 - ((nameCount + 1) % 4)) % 4);
while (cursor.offset + 12 <= extraEnd) {
const QString signature = cursor.string(4);
if (signature != QLatin1String("8BIM") && signature != QLatin1String("8B64"))
break;
const QString key = cursor.string(4);
qint64 length;
if (signature == QLatin1String("8B64")) {
if (cursor.offset + 8 > extraEnd)
break;
const quint64 raw = quint64(cursor.u32()) << 32 | cursor.u32();
if (raw > quint64(std::numeric_limits<qint64>::max()))
throw ImageImportError(ImageImportError::Kind::tooLarge);
length = qint64(raw);
} else {
length = cursor.u32();
}
const QByteArray payload = cursor.bytes(length);
if (length % 2 == 1)
cursor.skip(1);
layer.extra[key] = payload;
if (key == QLatin1String("luni"))
if (const std::optional<QString> unicode = unicodeName(payload))
layer.name = *unicode;
if (key == QLatin1String("iOpa") && !payload.isEmpty())
layer.fill = uchar(payload[0]);
if ((key == QLatin1String("lsct") || key == QLatin1String("lsdk")) && payload.size() >= 4)
layer.section = u32(payload, 0);
}
cursor.offset = extraEnd;
return layer;
}
}
namespace {
// Transparency, red, green, blue and the user mask only.
bool unpacked(int id)
{
return id == -1 || id == 0 || id == 1 || id == 2 || id == -2;
}
void decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels)
{
std::map<int, std::vector<uchar>> planes;
const qint64 width = std::max<qint64>(0, layer.right - layer.left), height = std::max<qint64>(0, layer.bottom - layer.top);
const qint64 maskWidth = std::max<qint64>(0, layer.maskRight - layer.maskLeft), maskHeight = std::max<qint64>(0, layer.maskBottom - layer.maskTop);
const qint64 budget = std::max<qint64>(0, remainingPixels);
if (width > 0 && height > 0 && (width > 30'000 || height > 30'000 || width * height > budget))
throw ImageImportError(ImageImportError::Kind::tooLarge);
if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && (maskWidth > 30'000 || maskHeight > 30'000 || maskWidth * maskHeight > budget))
throw ImageImportError(ImageImportError::Kind::tooLarge);
for (const Channel &channel : layer.channels) {
const qint64 start = cursor.offset;
if (unpacked(channel.id) && channel.length >= 2) {
const int compression = cursor.u16();
const QByteArray payload = cursor.bytes(channel.length - 2);
const bool isMask = channel.id == -2;
const qint64 w = isMask ? maskWidth : width, h = isMask ? maskHeight : height;
if (w > 0 && h > 0)
planes[channel.id] = PSDChannelCoder::decode(compression, int(w), int(h), payload);
}
cursor.offset = start + std::max<qint64>(0, channel.length);
}
// Each plane decodes to exactly its size: no count check.
if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && planes.contains(-2))
layer.maskImage = PSDChannelCoder::maskImage(int(maskWidth), int(maskHeight), planes.at(-2));
if (width <= 0 || height <= 0)
return;
const size_t count = size_t(width * height);
const auto plane = [&](int id, uchar fill) { return planes.contains(id) ? planes.at(id) : std::vector<uchar>(count, fill); };
layer.image = PSDChannelCoder::rgbaImage(int(width), int(height), plane(0, 0), plane(1, 0), plane(2, 0), plane(-1, 255));
}
PSDLayerKind kindOf(const RawLayer &layer, bool isGroup) PSDLayerKind kindOf(const RawLayer &layer, bool isGroup)
{ {
@@ -294,11 +56,14 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
record.blendKey = isGroup && passes ? QStringLiteral("pass") : layer.blendKey; record.blendKey = isGroup && passes ? QStringLiteral("pass") : layer.blendKey;
record.clipping = layer.clipping; record.clipping = layer.clipping;
record.kind = kindOf(layer, isGroup); record.kind = kindOf(layer, isGroup);
record.croppedToCanvas = layer.cropped;
const bool hasEffects = record.kind == PSDLayerKind::effects || layer.has({"lfx2", "lrFX", "lmfx"}); const bool hasEffects = record.kind == PSDLayerKind::effects || layer.has({"lfx2", "lrFX", "lmfx"});
record.opacity = hasEffects && layer.fill != 255 ? layer.opacity / 255.0 : (layer.opacity / 255.0) * (layer.fill / 255.0); record.opacity = hasEffects && layer.fill != 255 ? layer.opacity / 255.0 : (layer.opacity / 255.0) * (layer.fill / 255.0);
record.bounds = isGroup ? QRectF(QPointF(0, 0), canvas) : QRectF(layer.left, layer.top, std::max<qint64>(0, layer.right - layer.left), std::max<qint64>(0, layer.bottom - layer.top)); record.bounds = isGroup ? QRectF(QPointF(0, 0), canvas) : QRectF(layer.left, layer.top, std::max<qint64>(0, layer.right - layer.left), std::max<qint64>(0, layer.bottom - layer.top));
record.image = isGroup ? std::nullopt : layer.image; record.image = isGroup ? std::nullopt : layer.image;
std::optional<PSDVector::Live> live = isGroup ? std::nullopt : PSDVector::live(layer.extra, canvas, remaining); if (record.kind == PSDLayerKind::text)
record.text = PSDText::parse(layer.extra);
std::optional<PSDVector::Live> live = isGroup || record.text ? std::nullopt : PSDVector::live(layer.extra, canvas, remaining);
if (live) { if (live) {
record.image = live->image; record.image = live->image;
record.bounds = live->bounds; record.bounds = live->bounds;
@@ -306,7 +71,7 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
record.shapeNotes = live->notes; record.shapeNotes = live->notes;
record.kind = PSDLayerKind::vector; record.kind = PSDLayerKind::vector;
remaining = std::max<qint64>(0, remaining - qint64(live->image.width()) * live->image.height()); remaining = std::max<qint64>(0, remaining - qint64(live->image.width()) * live->image.height());
} else if (!record.image && !isGroup) { } else if (!record.text && !record.image && !isGroup) {
if (const std::optional<PSDVector::Raster> raster = PSDVector::raster(layer.extra, canvas, remaining)) { if (const std::optional<PSDVector::Raster> raster = PSDVector::raster(layer.extra, canvas, remaining)) {
record.image = raster->image; record.image = raster->image;
record.bounds = raster->bounds; record.bounds = raster->bounds;
@@ -328,18 +93,32 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
return result; return result;
} }
PSDDocument parse(const QByteArray &data, qint64 remainingPixels) // What precedes the layers: the canvas, its channels, its profile.
struct Header {
bool isPSB;
int channels;
qint64 width;
qint64 height;
double resolution = 72;
QByteArray profile;
};
// A layered canvas stays one surface; merged, the budget.
Header readHeader(Cursor &cursor, bool surface)
{ {
Cursor cursor{data};
if (cursor.string(4) != QLatin1String("8BPS")) if (cursor.string(4) != QLatin1String("8BPS"))
throw ImageImportError(ImageImportError::Kind::unreadable); throw ImageImportError(ImageImportError::Kind::unreadable);
if (cursor.u16() != 1) // Version 2 is a Large Document, some lengths wider.
const int version = cursor.u16();
if (version != 1 && version != 2)
throw PSDError(PSDError::Kind::unsupportedVersion); throw PSDError(PSDError::Kind::unsupportedVersion);
cursor.skip(6); cursor.skip(6);
cursor.u16(); Header header{version == 2, cursor.u16(), 0, 0, 72, {}};
const qint64 canvasHeight = cursor.u32(), canvasWidth = cursor.u32(); header.height = cursor.u32();
header.width = cursor.u32();
const int depth = cursor.u16(), mode = cursor.u16(); const int depth = cursor.u16(), mode = cursor.u16();
if (canvasWidth < 1 || canvasWidth > 30'000 || canvasHeight < 1 || canvasHeight > 30'000 || canvasWidth * canvasHeight > 100'000'000) if (header.width < 1 || header.width > DocumentLimits::maxSide || header.height < 1 || header.height > DocumentLimits::maxSide
|| (surface && header.width * header.height > DocumentLimits::maxSurfacePixels))
throw ImageImportError(ImageImportError::Kind::tooLarge); throw ImageImportError(ImageImportError::Kind::tooLarge);
if (depth != 8) if (depth != 8)
throw PSDError(PSDError::Kind::unsupportedDepth); throw PSDError(PSDError::Kind::unsupportedDepth);
@@ -348,7 +127,6 @@ PSDDocument parse(const QByteArray &data, qint64 remainingPixels)
cursor.skip(cursor.u32()); cursor.skip(cursor.u32());
const qint64 resourcesLength = cursor.u32(); const qint64 resourcesLength = cursor.u32();
const qint64 resourcesEnd = cursor.offset + resourcesLength; const qint64 resourcesEnd = cursor.offset + resourcesLength;
double resolution = 72;
while (cursor.offset + 12 <= resourcesEnd) { while (cursor.offset + 12 <= resourcesEnd) {
if (cursor.string(4) != QLatin1String("8BIM")) if (cursor.string(4) != QLatin1String("8BIM"))
break; break;
@@ -360,35 +138,75 @@ PSDDocument parse(const QByteArray &data, qint64 remainingPixels)
const qint64 length = cursor.u32(); const qint64 length = cursor.u32();
const qint64 dataStart = cursor.offset; const qint64 dataStart = cursor.offset;
if (id == 1005 && length >= 4) { if (id == 1005 && length >= 4) {
resolution = cursor.u32() / 65536.0; header.resolution = cursor.u32() / 65536.0;
// A fixed-point number is finite; below one reads as 72. // A fixed-point number is finite; below one reads as 72.
resolution = std::min(9600.0, resolution < 1 ? 72 : resolution); header.resolution = std::min(9600.0, header.resolution < 1 ? 72 : header.resolution);
} }
// Only the merged image reads the profile, as Swift's.
if (id == 1039 && !surface)
header.profile = cursor.bytes(length);
cursor.offset = dataStart + length; cursor.offset = dataStart + length;
if (length % 2 == 1) if (length % 2 == 1)
cursor.skip(1); cursor.skip(1);
} }
cursor.offset = resourcesEnd; cursor.offset = resourcesEnd;
const qint64 layerSection = cursor.u32(); return header;
const PSDDocument empty{int(canvasWidth), int(canvasHeight), resolution, {}}; }
PSDDocument parse(const QByteArray &data, qint64 remainingPixels)
{
Cursor cursor{data};
const Header header = readHeader(cursor, true);
const qint64 layerSection = cursor.length(header.isPSB);
const PSDDocument empty{int(header.width), int(header.height), header.resolution, {}};
if (layerSection < 4) if (layerSection < 4)
return empty; return empty;
// The layer info length is skipped, as in Swift. // The layer info length is skipped, as in Swift.
cursor.u32(); cursor.length(header.isPSB);
const qint64 count = std::abs(qint64(cursor.i16())); const qint64 count = std::abs(qint64(cursor.i16()));
if (count > 10'000) if (count > 10'000)
throw ImageImportError(ImageImportError::Kind::tooLarge); throw ImageImportError(ImageImportError::Kind::tooLarge);
std::vector<RawLayer> raw; std::vector<RawLayer> raw;
raw.reserve(size_t(count)); raw.reserve(size_t(count));
for (qint64 index = 0; index < count; ++index) for (qint64 index = 0; index < count; ++index)
raw.push_back(readRecord(cursor)); raw.push_back(readRecord(cursor, header.isPSB));
// Too large whole: cut to the canvas, judged again.
if (!fitsBudget(raw, remainingPixels)) {
for (RawLayer &layer : raw)
cropToCanvas(layer, header.width, header.height);
if (!fitsBudget(raw, remainingPixels))
throw ImageImportError(ImageImportError::Kind::tooLarge);
}
qint64 usedPixels = 0; qint64 usedPixels = 0;
for (RawLayer &layer : raw) { for (RawLayer &layer : raw) {
decodeChannels(cursor, layer, remainingPixels - usedPixels); decodeChannels(cursor, layer, remainingPixels - usedPixels, header.isPSB);
if (layer.image) if (layer.image)
usedPixels += qint64(layer.image->width()) * layer.image->height(); usedPixels += qint64(layer.image->width()) * layer.image->height();
} }
return PSDDocument{int(canvasWidth), int(canvasHeight), resolution, assemble(raw, QSizeF(canvasWidth, canvasHeight), remainingPixels - usedPixels)}; return PSDDocument{int(header.width), int(header.height), header.resolution,
assemble(raw, QSizeF(header.width, header.height), remainingPixels - usedPixels)};
}
// The merged image, as ImageIO reads a layerless file.
QImage mergedImage(const QByteArray &data, qint64 remainingPixels)
{
Cursor cursor{data};
const Header header = readHeader(cursor, false);
if (header.width * header.height > remainingPixels)
throw ImageImportError(ImageImportError::Kind::tooLarge);
if (header.channels < 3)
throw PSDError(PSDError::Kind::unsupportedColorMode);
cursor.skip(cursor.length(header.isPSB));
const int compression = cursor.u16();
const QByteArray planes = data.mid(cursor.offset);
// Opaque: a file of a background alone keeps no transparency.
const std::vector<std::vector<uchar>> decoded = PSDChannelCoder::mergedPlanes(compression, int(header.width), int(header.height), header.channels, 3, planes, header.isPSB);
QImage image = PSDChannelCoder::rgbaImage(int(header.width), int(header.height), decoded[0], decoded[1], decoded[2],
std::vector<uchar>(size_t(header.width * header.height), 255));
// Its own profile, else sRGB, as ImageIO reads it.
const QColorSpace profile = QColorSpace::fromIccProfile(header.profile);
image.setColorSpace(profile.isValid() ? profile : QColorSpace(QColorSpace::SRgb));
return image;
} }
} }
@@ -427,6 +245,29 @@ PSDDocument PSDReader::read(const QString &path, qint64 remainingPixels)
return read(bytes, remainingPixels); return read(bytes, remainingPixels);
} }
QImage PSDReader::merged(const QString &path, qint64 remainingPixels)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
qCWarning(lcIO).noquote() << "cannot open the Photoshop file" << path << file.errorString();
throw ImageImportError(ImageImportError::Kind::unreadable);
}
// Swift asks the size first: the header, before the file.
const QByteArray head = file.peek(26);
Cursor cursor{head};
cursor.skip(12);
cursor.u16();
const qint64 height = cursor.u32(), width = cursor.u32();
if (width > DocumentLimits::maxSide || height > DocumentLimits::maxSide || width * height > remainingPixels)
throw ImageImportError(ImageImportError::Kind::tooLarge);
const QByteArray bytes = file.readAll();
if (file.error() != QFileDevice::NoError) {
qCWarning(lcIO).noquote() << "cannot read the Photoshop file" << path << file.errorString();
throw ImageImportError(ImageImportError::Kind::unreadable);
}
return mergedImage(bytes, remainingPixels);
}
PSDDocument PSDReader::read(const QByteArray &data, qint64 remainingPixels) PSDDocument PSDReader::read(const QByteArray &data, qint64 remainingPixels)
{ {
try { try {
+6 -2
View File
@@ -1,6 +1,8 @@
#pragma once #pragma once
#include "Document/DocumentLimits.h"
#include "IO/PSD/PSDTypes.h" #include "IO/PSD/PSDTypes.h"
#include <QByteArray> #include <QByteArray>
#include <QImage>
#include <QString> #include <QString>
#include <map> #include <map>
#include <set> #include <set>
@@ -9,8 +11,10 @@
namespace PSDReader { namespace PSDReader {
bool matches(const QString &path); bool matches(const QString &path);
bool matches(const QByteArray &data); bool matches(const QByteArray &data);
PSDDocument read(const QString &path, qint64 remainingPixels = 100'000'000); PSDDocument read(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
PSDDocument read(const QByteArray &data, qint64 remainingPixels = 100'000'000); PSDDocument read(const QByteArray &data, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
// A file with no layers: its merged image, profile attached.
QImage merged(const QString &path, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
extern const std::set<QString> adjustmentKeys; extern const std::set<QString> adjustmentKeys;
} }
+264
View File
@@ -0,0 +1,264 @@
#include "IO/PSD/PSDText+Descriptor.h"
#include <QStringDecoder>
#include <QtEndian>
#include <bit>
#include <cmath>
namespace PSDDescriptor {
namespace {
// Nesting past this reads as broken, where Swift's stack overflows.
constexpr int maxDepth = 512;
// Swift's ASCII decoding: a high byte fails.
std::optional<QString> ascii(const QByteArray &raw)
{
for (const char byte : raw)
if (uchar(byte) >= 0x80)
return std::nullopt;
return QString::fromLatin1(raw);
}
template <typename T>
const T *find(const Items &items, const QString &key)
{
return items.contains(key) ? std::get_if<T>(&items.at(key).value) : nullptr;
}
// Swift's `??`: the key present decides, then its type.
std::optional<double> side(const Items &items, const QString &name)
{
const double *found = find<double>(items, items.contains(name) ? name : name.trimmed());
return found ? std::optional(*found) : std::nullopt;
}
}
std::optional<QString> string(const Items &items, const QString &key)
{
const QString *found = find<QString>(items, key);
return found ? std::optional(*found) : std::nullopt;
}
std::optional<QString> enumeration(const Items &items, const QString &key)
{
const Enumeration *found = find<Enumeration>(items, key);
return found ? std::optional(found->name) : std::nullopt;
}
std::optional<QByteArray> data(const Items &items, const QString &key)
{
const QByteArray *found = find<QByteArray>(items, key);
return found ? std::optional(*found) : std::nullopt;
}
QString utf16(const QByteArray &raw)
{
QString text = QStringDecoder(QStringConverter::Utf16BE, QStringConverter::Flag::ConvertInitialBom).decode(raw);
for (qsizetype index = 0; index < text.size(); ++index) {
if (text[index].isHighSurrogate() && index + 1 < text.size() && text[index + 1].isLowSurrogate())
++index;
else if (text[index].isSurrogate())
text[index] = QChar::ReplacementCharacter;
}
return text;
}
std::optional<QRectF> rect(const Items &items, const QString &key)
{
const Items *box = find<Items>(items, key);
if (!box)
return std::nullopt;
const std::optional<double> left = side(*box, QStringLiteral("Left")), top = side(*box, QStringLiteral("Top ")),
right = side(*box, QStringLiteral("Rght")), bottom = side(*box, QStringLiteral("Btom"));
if (!left || !top || !right || !bottom)
return std::nullopt;
if (!std::isfinite(*left) || !std::isfinite(*top) || !std::isfinite(*right) || !std::isfinite(*bottom))
return std::nullopt;
return QRectF(QPointF(*left, *top), QPointF(*right, *bottom)).normalized();
}
std::optional<Items> Reader::descriptor(bool versioned)
{
if (versioned && u32() != 16u)
return std::nullopt;
if (!unicode() || !identifier())
return std::nullopt;
const std::optional<quint32> count = u32();
if (!count || *count > 10'000)
return std::nullopt;
Items result;
for (quint32 index = 0; index < *count; ++index) {
const std::optional<QString> key = identifier();
const std::optional<QString> type = key ? fourCC() : std::nullopt;
std::optional<Value> item = type ? value(*type) : std::nullopt;
if (!item)
return std::nullopt;
result.insert_or_assign(*key, std::move(*item));
}
return result;
}
std::optional<Value> Reader::value(const QString &type)
{
if (depth >= maxDepth)
return std::nullopt;
++depth;
std::optional<Value> result = read(type);
--depth;
return result;
}
std::optional<Value> Reader::read(const QString &type)
{
if (type == QLatin1String("doub")) {
const std::optional<double> number = f64();
return number ? std::optional(Value{*number}) : std::nullopt;
}
if (type == QLatin1String("UntF")) {
const std::optional<double> number = fourCC() ? f64() : std::nullopt;
return number ? std::optional(Value{*number}) : std::nullopt;
}
if (type == QLatin1String("long")) {
const std::optional<qint32> number = i32();
return number ? std::optional(Value{double(*number)}) : std::nullopt;
}
if (type == QLatin1String("comp")) {
const std::optional<QByteArray> raw = bytes(8);
return raw ? std::optional(Value{double(qFromBigEndian<qint64>(raw->constData()))}) : std::nullopt;
}
if (type == QLatin1String("bool"))
return u8() ? std::optional(Value{0.0}) : std::nullopt;
if (type == QLatin1String("TEXT")) {
const std::optional<QString> text = unicode();
return text ? std::optional(Value{*text}) : std::nullopt;
}
if (type == QLatin1String("enum")) {
const std::optional<QString> name = identifier() ? identifier() : std::nullopt;
return name ? std::optional(Value{Enumeration{*name}}) : std::nullopt;
}
if (type == QLatin1String("tdta")) {
const std::optional<quint32> length = u32();
const std::optional<QByteArray> raw = length && *length <= 8'000'000 ? bytes(*length) : std::nullopt;
return raw ? std::optional(Value{*raw}) : std::nullopt;
}
if (type == QLatin1String("Objc") || type == QLatin1String("GlbO")) {
std::optional<Items> nested = descriptor(false);
return nested ? std::optional(Value{std::move(*nested)}) : std::nullopt;
}
if (type == QLatin1String("VlLs")) {
const std::optional<quint32> count = u32();
if (!count || *count > 10'000)
return std::nullopt;
std::vector<Value> list;
for (quint32 index = 0; index < *count; ++index) {
const std::optional<QString> itemType = fourCC();
std::optional<Value> item = itemType ? value(*itemType) : std::nullopt;
if (!item)
return std::nullopt;
list.push_back(std::move(*item));
}
return Value{std::move(list)};
}
if (type == QLatin1String("alis")) {
const std::optional<quint32> length = u32();
return length && *length <= 8'000'000 && bytes(*length) ? std::optional(Value{0.0}) : std::nullopt;
}
if (type == QLatin1String("obj "))
return reference() ? std::optional(Value{0.0}) : std::nullopt;
if (type == QLatin1String("type") || type == QLatin1String("GlbC"))
return unicode() && identifier() ? std::optional(Value{0.0}) : std::nullopt;
return std::nullopt;
}
bool Reader::reference()
{
const std::optional<quint32> count = u32();
if (!count || *count > 10'000)
return false;
for (quint32 index = 0; index < *count; ++index) {
const std::optional<QString> form = fourCC();
if (!form)
return false;
bool read = false;
if (*form == QLatin1String("prop"))
read = unicode() && identifier() && identifier();
else if (*form == QLatin1String("Clss"))
read = unicode() && identifier();
else if (*form == QLatin1String("Enmr"))
read = unicode() && identifier() && identifier() && identifier();
else if (*form == QLatin1String("rele"))
read = unicode() && identifier() && i32();
else if (*form == QLatin1String("Idnt") || *form == QLatin1String("indx"))
read = bool(i32());
else if (*form == QLatin1String("name"))
read = bool(unicode());
if (!read)
return false;
}
return true;
}
std::optional<QString> Reader::unicode()
{
const std::optional<quint32> count = u32();
const std::optional<QByteArray> raw = count && *count <= 1'000'000 ? bytes(qint64(*count) * 2) : std::nullopt;
if (!raw)
return std::nullopt;
return utf16(*raw);
}
std::optional<QString> Reader::identifier()
{
const std::optional<quint32> length = u32();
if (!length)
return std::nullopt;
if (*length == 0)
return fourCC();
const std::optional<QByteArray> raw = *length <= 10'000 ? bytes(*length) : std::nullopt;
return raw ? ascii(*raw) : std::nullopt;
}
std::optional<QString> Reader::fourCC()
{
const std::optional<QByteArray> raw = bytes(4);
return raw ? ascii(*raw) : std::nullopt;
}
std::optional<QByteArray> Reader::bytes(qint64 count)
{
if (count < 0 || count > remaining())
return std::nullopt;
const QByteArray slice = data.mid(offset, count);
offset += count;
return slice;
}
std::optional<uchar> Reader::u8()
{
const std::optional<QByteArray> raw = bytes(1);
return raw ? std::optional(uchar(raw->at(0))) : std::nullopt;
}
std::optional<quint16> Reader::u16()
{
const std::optional<QByteArray> raw = bytes(2);
return raw ? std::optional(qFromBigEndian<quint16>(raw->constData())) : std::nullopt;
}
std::optional<quint32> Reader::u32()
{
const std::optional<QByteArray> raw = bytes(4);
return raw ? std::optional(qFromBigEndian<quint32>(raw->constData())) : std::nullopt;
}
std::optional<qint32> Reader::i32()
{
const std::optional<quint32> value = u32();
return value ? std::optional(qint32(*value)) : std::nullopt;
}
std::optional<double> Reader::f64()
{
const std::optional<QByteArray> raw = bytes(8);
return raw ? std::optional(std::bit_cast<double>(qFromBigEndian<quint64>(raw->constData()))) : std::nullopt;
}
}
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <QByteArray>
#include <QRectF>
#include <QString>
#include <map>
#include <optional>
#include <variant>
#include <vector>
// Adobe's action descriptors, as far as a type layer needs.
namespace PSDDescriptor {
struct Value;
using Items = std::map<QString, Value>;
struct Enumeration {
QString name;
};
struct Value {
std::variant<QString, double, Enumeration, QByteArray, Items, std::vector<Value>> value;
};
std::optional<QString> string(const Items &items, const QString &key);
std::optional<QString> enumeration(const Items &items, const QString &key);
std::optional<QByteArray> data(const Items &items, const QString &key);
// Standardised, as CGRect's reads are.
std::optional<QRectF> rect(const Items &items, const QString &key);
// Big-endian UTF-16, a lone surrogate read as U+FFFD.
QString utf16(const QByteArray &raw);
// Big-endian reads over a block; each fails past its end.
struct Reader {
const QByteArray &data;
qsizetype offset = 0;
int depth = 0;
qsizetype remaining() const { return data.size() - offset; }
std::optional<Items> descriptor(bool versioned);
std::optional<QByteArray> bytes(qint64 count);
std::optional<uchar> u8();
std::optional<quint16> u16();
std::optional<quint32> u32();
std::optional<qint32> i32();
std::optional<double> f64();
private:
std::optional<Value> value(const QString &type);
std::optional<Value> read(const QString &type);
bool reference();
std::optional<QString> unicode();
std::optional<QString> identifier();
std::optional<QString> fourCC();
};
}
+323
View File
@@ -0,0 +1,323 @@
#include "IO/PSD/PSDText+Engine.h"
#include "IO/PSD/PSDText+Descriptor.h"
#include <clocale>
#include <cstdlib>
#include <locale.h>
namespace PSDTextEngine {
namespace {
// Nesting past this reads as broken, where Swift's stack overflows.
constexpr int maxDepth = 512;
bool isDelimiter(uchar byte)
{
return byte <= 0x20 || byte == '/' || byte == '<' || byte == '>' || byte == '[' || byte == ']' || byte == '(' || byte == ')';
}
bool isDigit(uchar byte)
{
return byte >= '0' && byte <= '9';
}
std::optional<uchar> hex(uchar byte)
{
if (isDigit(byte))
return uchar(byte - '0');
if (byte >= 'a' && byte <= 'f')
return uchar(byte - 'a' + 10);
if (byte >= 'A' && byte <= 'F')
return uchar(byte - 'A' + 10);
return std::nullopt;
}
// Swift's `Double(text)`: whole text, C numbers, overflow infinite.
std::optional<double> toDouble(const QByteArray &text)
{
static const locale_t c = newlocale(LC_ALL_MASK, "C", locale_t(nullptr));
char *end = nullptr;
const double value = strtod_l(text.constData(), &end, c);
if (end != text.constData() + text.size())
return std::nullopt;
return value;
}
QString decoded(const QByteArray &raw)
{
if (raw.size() >= 2 && uchar(raw[0]) == 0xFE && uchar(raw[1]) == 0xFF) {
// Foundation reads no odd count of UTF-16 bytes.
if (raw.size() % 2 == 1)
return QString();
return PSDDescriptor::utf16(raw.mid(2));
}
return QString::fromLatin1(raw);
}
struct Cursor {
const QByteArray &bytes;
qsizetype index = 0;
int depth = 0;
std::optional<uchar> peek(qsizetype ahead = 0) const
{
const qsizetype at = index + ahead;
return at < bytes.size() ? std::optional(uchar(bytes[at])) : std::nullopt;
}
bool take(const char *token)
{
const QByteArray encoded(token);
if (bytes.mid(index, encoded.size()) != encoded)
return false;
index += encoded.size();
return true;
}
bool takeWord(const char *word)
{
const QByteArray encoded(word);
if (bytes.mid(index, encoded.size()) != encoded)
return false;
const qsizetype after = index + encoded.size();
if (after < bytes.size() && !isDelimiter(uchar(bytes[after])))
return false;
index = after;
return true;
}
void skipWhitespace()
{
while (peek() && (*peek() <= 0x20 || *peek() == '%')) {
if (*peek() == '%') {
while (peek() && *peek() != '\n' && *peek() != '\r')
++index;
} else {
++index;
}
}
}
// Swift's ASCII decoding: a high byte leaves the name empty.
QString readToken()
{
const qsizetype start = index;
while (peek() && !isDelimiter(*peek()))
++index;
const QByteArray token = bytes.mid(start, index - start);
for (const char byte : token)
if (uchar(byte) >= 0x80)
return QString();
return QString::fromLatin1(token);
}
std::optional<Engine> value()
{
if (depth >= maxDepth)
return std::nullopt;
++depth;
std::optional<Engine> result = parseValue();
--depth;
return result;
}
std::optional<Engine> parseValue()
{
skipWhitespace();
const std::optional<uchar> byte = peek();
if (!byte)
return std::nullopt;
if (*byte == '<')
return peek(1) == uchar('<') ? parseDictionary() : parseHex();
if (*byte == '[')
return parseArray();
if (*byte == '(')
return parseString();
if (*byte == '/') {
++index;
return Engine{readToken()};
}
if (*byte == '-' || *byte == '+' || *byte == '.' || isDigit(*byte)) {
const std::optional<double> parsed = parseNumber();
return parsed ? std::optional(Engine{*parsed}) : std::nullopt;
}
if (takeWord("true"))
return Engine{true};
if (takeWord("false"))
return Engine{false};
if (takeWord("null"))
return Engine{QString()};
return std::nullopt;
}
std::optional<Engine> parseDictionary()
{
take("<<");
Dictionary items;
while (true) {
skipWhitespace();
if (!peek() || *peek() == '>')
break;
if (*peek() != '/')
return std::nullopt;
++index;
const QString key = readToken();
std::optional<Engine> item = value();
if (!item)
return std::nullopt;
items.insert_or_assign(key, std::move(*item));
}
if (!take(">>"))
return std::nullopt;
return Engine{std::move(items)};
}
std::optional<Engine> parseArray()
{
take("[");
Array items;
while (true) {
skipWhitespace();
if (!peek() || *peek() == ']')
break;
std::optional<Engine> item = value();
if (!item)
return std::nullopt;
items.push_back(std::move(*item));
}
if (!take("]"))
return std::nullopt;
return Engine{std::move(items)};
}
void digits()
{
while (peek() && isDigit(*peek()))
++index;
}
std::optional<double> parseNumber()
{
const qsizetype start = index;
if (peek() == uchar('+') || peek() == uchar('-'))
++index;
digits();
if (peek() == uchar('.')) {
++index;
digits();
}
if (peek() == uchar('e') || peek() == uchar('E')) {
++index;
if (peek() == uchar('+') || peek() == uchar('-'))
++index;
digits();
}
return toDouble(bytes.mid(start, index - start));
}
std::optional<Engine> parseString()
{
take("(");
QByteArray raw;
while (peek()) {
const uchar byte = *peek();
++index;
if (byte == ')')
break;
if (byte != '\\') {
raw.append(char(byte));
continue;
}
const std::optional<uchar> escaped = peek();
if (!escaped)
return std::nullopt;
++index;
if (*escaped == 'n') {
raw.append('\n');
} else if (*escaped == 'r') {
raw.append('\r');
} else if (*escaped == 't') {
raw.append('\t');
} else if (*escaped >= '0' && *escaped <= '7') {
int octal = *escaped - '0';
for (int count = 0; count < 2 && peek() && *peek() >= '0' && *peek() <= '7'; ++count)
octal = octal * 8 + (bytes[index++] - '0');
raw.append(char(octal & 0xFF));
} else if (*escaped != '\n' && *escaped != '\r') {
raw.append(char(*escaped));
}
}
return Engine{decoded(raw)};
}
std::optional<Engine> parseHex()
{
take("<");
std::vector<uchar> nibbles;
while (peek() && *peek() != '>') {
if (const std::optional<uchar> nibble = hex(*peek()))
nibbles.push_back(*nibble);
++index;
}
if (!take(">"))
return std::nullopt;
QByteArray raw;
for (size_t at = 0; at + 1 < nibbles.size(); at += 2)
raw.append(char(nibbles[at] << 4 | nibbles[at + 1]));
return Engine{decoded(raw)};
}
};
std::optional<Engine> dictionary(const QByteArray &data, qsizetype start)
{
Cursor cursor{data, start};
std::optional<Engine> value = cursor.value();
if (!value || !std::holds_alternative<Dictionary>(value->value))
return std::nullopt;
return value;
}
}
std::optional<Engine> parse(const QByteArray &data)
{
if (std::optional<Engine> found = dictionary(data, 0))
return found;
const qsizetype start = data.indexOf("<<");
if (start <= 0)
return std::nullopt;
return dictionary(data, start);
}
const Engine *walk(const Engine *value, std::initializer_list<const char *> keys)
{
for (const char *key : keys) {
const Dictionary *items = value ? std::get_if<Dictionary>(&value->value) : nullptr;
if (!items || !items->contains(QString::fromLatin1(key)))
return nullptr;
value = &items->at(QString::fromLatin1(key));
}
return value;
}
std::optional<double> number(const Engine *value)
{
const double *found = value ? std::get_if<double>(&value->value) : nullptr;
return found ? std::optional(*found) : std::nullopt;
}
std::optional<bool> boolean(const Engine *value)
{
const bool *found = value ? std::get_if<bool>(&value->value) : nullptr;
return found ? std::optional(*found) : std::nullopt;
}
std::optional<QString> string(const Engine *value)
{
const QString *found = value ? std::get_if<QString>(&value->value) : nullptr;
return found ? std::optional(*found) : std::nullopt;
}
Array array(const Engine *value)
{
const Array *found = value ? std::get_if<Array>(&value->value) : nullptr;
return found ? *found : Array();
}
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <QByteArray>
#include <QString>
#include <map>
#include <optional>
#include <variant>
#include <vector>
// Photoshop's text-engine dictionary, a small PostScript subset.
namespace PSDTextEngine {
struct Engine;
using Dictionary = std::map<QString, Engine>;
using Array = std::vector<Engine>;
struct Engine {
std::variant<double, bool, QString, Dictionary, Array> value;
};
std::optional<Engine> parse(const QByteArray &data);
// Swift's `walk`: nested dictionary keys, else none.
const Engine *walk(const Engine *value, std::initializer_list<const char *> keys);
std::optional<double> number(const Engine *value);
std::optional<bool> boolean(const Engine *value);
std::optional<QString> string(const Engine *value);
// An array's items; anything else reads empty.
Array array(const Engine *value);
}
+287
View File
@@ -0,0 +1,287 @@
#include "IO/PSD/PSDText.h"
#include "Document/EditorSession.h"
#include "IO/PSD/PSDText+Descriptor.h"
#include "IO/PSD/PSDText+Engine.h"
#include "IO/ProjectStore.h"
#include "Rendering/TextLayout.h"
#include <cmath>
#include <numbers>
namespace PSDText {
namespace {
using namespace PSDTextEngine;
// Uniform scale, a turn and a vertical flip; no shear.
struct Placement {
double pixelScale;
double rotation;
bool flipY;
QTransform map;
};
// The matrix alone maps engine sizes to pixels.
std::optional<Placement> placement(double xx, double xy, double yx, double yy, double tx, double ty)
{
const double scaleX = std::hypot(xx, yx);
if (!(scaleX > 1e-6))
return std::nullopt;
const double cosR = xx / scaleX, sinR = yx / scaleX;
const double localX = cosR * xy + sinR * yy;
const double localY = -sinR * xy + cosR * yy;
const double scaleY = std::abs(localY);
if (!(scaleY > 1e-6))
return std::nullopt;
const double largest = std::max(scaleX, scaleY);
if (!(std::abs(localX) <= 0.02 * largest && std::abs(scaleX - scaleY) <= 0.02 * largest))
return std::nullopt;
const double ySign = localY < 0 ? -1.0 : 1.0;
// Swift's exx, eyx, exy and eyy, then the offset.
const QTransform map(cosR * scaleX, sinR * scaleX, -sinR * scaleX * ySign, cosR * scaleX * ySign, tx, ty);
return Placement{scaleX, std::atan2(sinR, cosR) * 180 / std::numbers::pi, localY < 0, map};
}
// Swift's `Int(x.rounded())`, with no trap on a wild value.
std::optional<qint64> rounded(double value)
{
const double whole = std::round(value);
if (!(std::abs(whole) < 9e18))
return std::nullopt;
return qint64(whole);
}
double unit(double value)
{
return value > 1 ? std::min(255.0, std::max(0.0, value)) / 255 : std::min(1.0, std::max(0.0, value));
}
std::array<double, 3> color(const std::vector<double> &values)
{
if (values.size() >= 4)
return {unit(values[1]), unit(values[2]), unit(values[3])};
if (values.size() == 3)
return {unit(values[0]), unit(values[1]), unit(values[2])};
if (!values.empty())
return {unit(values[0]), unit(values[0]), unit(values[0])};
return {0, 0, 0};
}
std::vector<double> channels(const Engine *data)
{
std::vector<double> result;
for (const Engine &value : array(walk(data, {"FillColor", "Values"})))
if (const std::optional<double> channel = number(&value))
result.push_back(*channel);
return result;
}
struct Signature {
double font = 0, size = 0, tracking = 0, leading = 0;
bool autoLeading = true;
double horizontalScale = 1, verticalScale = 1;
bool bold = false, italic = false;
std::array<double, 3> rgb{};
friend bool operator==(const Signature &, const Signature &) = default;
};
Signature signature(const Engine &run)
{
const Engine *sheet = walk(&run, {"StyleSheet", "StyleSheetData"});
const Engine *data = sheet ? sheet : &run;
return Signature{.font = number(walk(data, {"Font"})).value_or(0),
.size = number(walk(data, {"FontSize"})).value_or(0),
.tracking = number(walk(data, {"Tracking"})).value_or(0),
.leading = number(walk(data, {"Leading"})).value_or(0),
.autoLeading = boolean(walk(data, {"AutoLeading"})).value_or(true),
.horizontalScale = number(walk(data, {"HorizontalScale"})).value_or(1),
.verticalScale = number(walk(data, {"VerticalScale"})).value_or(1),
.bold = boolean(walk(data, {"FauxBold"})).value_or(false),
.italic = boolean(walk(data, {"FauxItalic"})).value_or(false),
.rgb = color(channels(data))};
}
void applyStyle(LayerTextStyle &style, const Engine &engine, double pixelScale, std::vector<QString> &notes)
{
const Array runs = array(walk(&engine, {"EngineDict", "StyleRun", "RunArray"}));
const Engine &first = runs.empty() ? engine : runs.front();
// Swift falls back on the run array, where nothing reads.
const Engine *sheet = walk(&first, {"StyleSheet", "StyleSheetData"});
const Engine *data = sheet ? sheet : walk(&engine, {"EngineDict", "StyleRun", "RunArray"});
if (!data)
data = &first;
const double points = number(walk(data, {"FontSize"})).value_or(12);
if (!(std::isfinite(points) && points > 0))
return;
style.fontSize = std::min(2000.0, std::max(1.0, points * pixelScale));
const Array fonts = array(walk(&engine, {"ResourceDict", "FontSet"}));
const std::optional<qint64> index = rounded(number(walk(data, {"Font"})).value_or(0));
if (index && *index >= 0 && *index < qint64(fonts.size())) {
const QString name = string(walk(&fonts[size_t(*index)], {"Name"})).value_or(QString());
if (!name.isEmpty())
style.fontName = name;
}
if (!array(walk(data, {"FillColor", "Values"})).empty()) {
const std::array<double, 3> rgb = color(channels(data));
style.red = rgb[0];
style.green = rgb[1];
style.blue = rgb[2];
}
const double tracking = number(walk(data, {"Tracking"})).value_or(0);
if (std::isfinite(tracking))
style.tracking = std::min(1000.0, std::max(-100.0, tracking * style.fontSize / 1000));
const bool automatic = boolean(walk(data, {"AutoLeading"})).value_or(true);
const std::optional<double> leading = number(walk(data, {"Leading"}));
if (!automatic && leading && std::isfinite(*leading) && *leading > 0)
style.leading = std::min(5000.0, std::max(0.0, *leading * pixelScale));
if (boolean(walk(data, {"FauxBold"})) == true || boolean(walk(data, {"FauxItalic"})) == true)
notes.push_back(fauxNote);
const Signature firstSignature = signature(first);
if (runs.size() > 1 && std::any_of(runs.begin() + 1, runs.end(), [&](const Engine &run) { return signature(run) != firstSignature; }))
notes.push_back(firstStyleNote);
const Array paragraphs = array(walk(&engine, {"EngineDict", "ParagraphRun", "RunArray"}));
const Engine &paragraph = paragraphs.empty() ? engine : paragraphs.front();
const std::optional<qint64> justification = rounded(number(walk(&paragraph, {"ParagraphSheet", "Properties", "Justification"})).value_or(0));
if (justification == 1) {
style.alignment = TextAlignment::right;
} else if (justification == 2) {
style.alignment = TextAlignment::center;
} else {
style.alignment = TextAlignment::left;
if (justification != 0)
notes.push_back(justifyNote);
}
}
std::optional<QString> cleaned(std::optional<QString> text)
{
if (!text)
return std::nullopt;
while (!text->isEmpty() && (text->front() == QChar(0xFEFF) || text->front() == QChar(0)))
text->remove(0, 1);
while (!text->isEmpty() && text->back() == QChar(0))
text->chop(1);
text->replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
text->replace(QChar('\r'), QChar('\n'));
return text;
}
double horizontalAnchor(const LayerTextStyle &style, double width)
{
switch (style.alignment) {
case TextAlignment::left:
return LayerTextStyle::padding;
case TextAlignment::center:
return width / 2;
case TextAlignment::right:
return width - LayerTextStyle::padding;
}
Q_UNREACHABLE();
}
// The first line's baseline, laid as `textImage` lays it.
double baseline(const LayerTextStyle &style, QSizeF image)
{
const double padding = LayerTextStyle::padding;
const TextLines lines(style, QSizeF(std::max(1.0, image.width() - 2 * padding), std::max(1.0, image.height() - 2 * padding)));
return padding + lines.baseline(0);
}
// As `pixelToDocument`: flip, then a clockwise turn about the centre.
LayerTransform layerTransform(QSizeF image, QPointF imageAnchor, QPointF documentAnchor, double rotation, bool flipY)
{
QPointF local(imageAnchor.x() - image.width() / 2, imageAnchor.y() - image.height() / 2);
if (flipY)
local.setY(-local.y());
const double radians = rotation * std::numbers::pi / 180;
const QPointF turned(local.x() * std::cos(radians) - local.y() * std::sin(radians), local.x() * std::sin(radians) + local.y() * std::cos(radians));
const QPointF centre = documentAnchor - turned;
LayerTransform transform{QPointF(centre.x() - image.width() / 2, centre.y() - image.height() / 2), image};
transform.rotation = rotation;
transform.flipY = flipY;
return transform;
}
}
std::optional<QString> missingFontNote(const QString &name)
{
if (TextLayout::availableFonts().contains(name))
return std::nullopt;
return QStringLiteral("The font “%1” isn’t installed, so the text was drawn with a substitute font.").arg(name);
}
std::optional<Source> parse(const std::map<QString, QByteArray> &extra)
{
const QString key = extra.contains(QStringLiteral("TySh")) ? QStringLiteral("TySh") : QStringLiteral("tySh");
if (!extra.contains(key) || extra.at(key).size() > 8'000'000)
return std::nullopt;
PSDDescriptor::Reader reader{extra.at(key)};
if (reader.u16() != 1u)
return std::nullopt;
std::array<double, 6> matrix{};
for (double &value : matrix) {
const std::optional<double> read = reader.f64();
if (!read || !std::isfinite(*read))
return std::nullopt;
value = *read;
}
if (reader.u16() != 50u)
return std::nullopt;
const std::optional<PSDDescriptor::Items> text = reader.descriptor(true);
if (!text || PSDDescriptor::enumeration(*text, QStringLiteral("Ornt")) == QStringLiteral("Vrtc"))
return std::nullopt;
const std::optional<Placement> placed = placement(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
if (!placed)
return std::nullopt;
std::vector<QString> notes;
if (reader.remaining() >= 2 && reader.u16() == 1u) {
const std::optional<PSDDescriptor::Items> warp = reader.descriptor(true);
const std::optional<QString> style = warp ? PSDDescriptor::enumeration(*warp, QStringLiteral("warpStyle")) : std::nullopt;
if (style && *style != QLatin1String("warpNone") && *style != QLatin1String("none"))
notes.push_back(warpNote);
}
const std::optional<QByteArray> engineData = PSDDescriptor::data(*text, QStringLiteral("EngineData"));
const std::optional<Engine> engine = engineData ? PSDTextEngine::parse(*engineData) : std::nullopt;
std::optional<QString> typed = PSDDescriptor::string(*text, QStringLiteral("Txt "));
if (!typed)
typed = PSDDescriptor::string(*text, QStringLiteral("Txt"));
std::optional<QString> content = cleaned(typed);
if (!content && engine)
content = cleaned(string(walk(&*engine, {"EngineDict", "Editor", "Text"})));
if (!content || content->isEmpty() || content->size() > 100'000)
return std::nullopt;
LayerTextStyle style;
style.content = *content;
if (engine)
applyStyle(style, *engine, placed->pixelScale, notes);
else
style.fontSize = std::min(2000.0, std::max(1.0, 12 * placed->pixelScale));
QPointF anchor(matrix[4], matrix[5]);
bool anchorIsFrame = false;
const std::optional<QRectF> bounds = PSDDescriptor::rect(*text, QStringLiteral("bounds"));
const std::optional<QRectF> glyphs = PSDDescriptor::rect(*text, QStringLiteral("boundingBox"));
if (bounds && glyphs && bounds->width() > glyphs->width() + 4 && bounds->height() > glyphs->height() + 4) {
const double pad = LayerTextStyle::padding;
style.boxSize = QSizeF(bounds->width() * placed->pixelScale + pad * 2, bounds->height() * placed->pixelScale + pad * 2);
anchor = placed->map.map(bounds->topLeft());
anchorIsFrame = true;
}
if (!style.isValid())
return std::nullopt;
return Source{style, notes, anchor, placed->rotation, placed->flipY, anchorIsFrame};
}
Rendered render(const Source &source)
{
const QImage image = EditorSession::textImage(source.style);
const QSizeF size = image.size();
const QPointF anchor = source.anchorIsFrame ? QPointF(LayerTextStyle::padding, LayerTextStyle::padding)
: QPointF(horizontalAnchor(source.style, size.width()), baseline(source.style, size));
const LayerTransform transform = layerTransform(size, anchor, source.documentAnchor, source.rotation, source.flipY);
if (!transform.isValid())
throw ProjectError(ProjectError::Kind::tooLarge);
return Rendered{image, transform};
}
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "Document/TypeTool.h"
#include <QByteArray>
#include <QImage>
#include <map>
#include <optional>
#include <vector>
// Photoshop 6 type (`TySh`) read into the text model.
namespace PSDText {
struct Source {
LayerTextStyle style;
std::vector<QString> notes;
// The document point the image's anchor lands on.
QPointF documentAnchor;
double rotation = 0;
bool flipY = false;
// The paragraph frame's corner, else point text's baseline.
bool anchorIsFrame = false;
};
struct Rendered {
QImage image;
LayerTransform transform;
};
inline const QString rasterizedNote = QStringLiteral("Editable Photoshop text becomes pixels and can’t be retyped.");
inline const QString firstStyleNote = QStringLiteral("Only the first text style was kept.");
inline const QString warpNote = QStringLiteral("The Photoshop text warp was omitted.");
inline const QString fauxNote = QStringLiteral("Faux bold or faux italic was omitted.");
inline const QString justifyNote = QStringLiteral("Full justification was imported as left alignment.");
std::optional<QString> missingFontNote(const QString &name);
std::optional<Source> parse(const std::map<QString, QByteArray> &extra);
Rendered render(const Source &source);
}
+1 -1
View File
@@ -8,7 +8,7 @@ QString description(PSDError::Kind kind)
case PSDError::Kind::truncated: case PSDError::Kind::truncated:
return QStringLiteral("The Photoshop file could not be read. It may be damaged or incomplete."); return QStringLiteral("The Photoshop file could not be read. It may be damaged or incomplete.");
case PSDError::Kind::unsupportedVersion: case PSDError::Kind::unsupportedVersion:
return QStringLiteral("Large Document (.psb) Photoshop files aren’t supported."); return QStringLiteral("This Photoshop file uses a format version OmaPhoto can’t read.");
case PSDError::Kind::unsupportedColorMode: case PSDError::Kind::unsupportedColorMode:
case PSDError::Kind::unsupportedDepth: case PSDError::Kind::unsupportedDepth:
return QStringLiteral("Only 8-bit RGB Photoshop files can be imported."); return QStringLiteral("Only 8-bit RGB Photoshop files can be imported.");
+5
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "Document/EditorSession+Model.h" #include "Document/EditorSession+Model.h"
#include "IO/PSD/PSDText.h"
#include <QImage> #include <QImage>
#include <QRectF> #include <QRectF>
#include <QString> #include <QString>
@@ -43,6 +44,10 @@ struct PSDRecord {
PSDLayerKind kind = PSDLayerKind::raster; PSDLayerKind kind = PSDLayerKind::raster;
std::optional<LayerShapeStyle> shape; std::optional<LayerShapeStyle> shape;
std::vector<QString> shapeNotes; std::vector<QString> shapeNotes;
// Photoshop type the text model can hold.
std::optional<PSDText::Source> text;
// Cut to the canvas so the file fits the budget.
bool croppedToCanvas = false;
std::optional<LayerBlendMode> blendMode() const; std::optional<LayerBlendMode> blendMode() const;
}; };
+4 -3
View File
@@ -1,4 +1,5 @@
#include "IO/PSD/PSDVector.h" #include "IO/PSD/PSDVector.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h" #include "Document/BrushStroke.h"
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include <QPainter> #include <QPainter>
@@ -76,10 +77,10 @@ QRectF integral(const QRectF &box)
return QRectF(left, top, std::ceil(box.right()) - left, std::ceil(box.bottom()) - top); return QRectF(left, top, std::ceil(box.right()) - left, std::ceil(box.bottom()) - top);
} }
// Within 30,000 a side and the pixel budget, else tooLarge. // Within the side limit and the pixel budget, else tooLarge.
QSize pixelSize(QSizeF size, qint64 remainingPixels) QSize pixelSize(QSizeF size, qint64 remainingPixels)
{ {
if (std::abs(size.width()) > 30'000 || std::abs(size.height()) > 30'000) if (std::abs(size.width()) > DocumentLimits::maxSide || std::abs(size.height()) > DocumentLimits::maxSide)
throw ImageImportError(ImageImportError::Kind::tooLarge); throw ImageImportError(ImageImportError::Kind::tooLarge);
const qint64 budget = std::min(EditorSession::maxShapePixels, std::max<qint64>(0, remainingPixels)); const qint64 budget = std::min(EditorSession::maxShapePixels, std::max<qint64>(0, remainingPixels));
// Boxes are whole here, so the whole pixels decide alone. // Boxes are whole here, so the whole pixels decide alone.
@@ -214,7 +215,7 @@ std::optional<PSDVector::Raster> PSDVector::raster(const Extra &extra, QSizeF ca
return std::nullopt; return std::nullopt;
if (!std::isfinite(strokeWidth)) if (!std::isfinite(strokeWidth))
return std::nullopt; return std::nullopt;
if (strokeEnabled && !(strokeWidth >= 0 && strokeWidth <= 30'000)) if (strokeEnabled && !(strokeWidth >= 0 && strokeWidth <= DocumentLimits::maxSide))
throw ImageImportError(ImageImportError::Kind::tooLarge); throw ImageImportError(ImageImportError::Kind::tooLarge);
QRectF box = outline->boundingRect(); QRectF box = outline->boundingRect();
if (strokeEnabled) { if (strokeEnabled) {
+3 -2
View File
@@ -1,4 +1,5 @@
#pragma once #pragma once
#include "Document/DocumentLimits.h"
#include "Document/ShapeTool.h" #include "Document/ShapeTool.h"
#include <QByteArray> #include <QByteArray>
#include <QImage> #include <QImage>
@@ -23,8 +24,8 @@ struct Live {
using Extra = std::map<QString, QByteArray>; using Extra = std::map<QString, QByteArray>;
std::optional<Live> live(const Extra &extra, QSizeF canvas, qint64 remainingPixels = 100'000'000); std::optional<Live> live(const Extra &extra, QSizeF canvas, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
std::optional<Raster> raster(const Extra &extra, QSizeF canvas, qint64 remainingPixels = 100'000'000); std::optional<Raster> raster(const Extra &extra, QSizeF canvas, qint64 remainingPixels = DocumentLimits::documentPixelBudget());
std::optional<QPainterPath> path(const QByteArray &data, QSizeF canvas); std::optional<QPainterPath> path(const QByteArray &data, QSizeF canvas);
struct RGB { struct RGB {
double r; double r;
@@ -0,0 +1,180 @@
#include "Document/ProjectWorkspace.h"
#include "IO/ProjectController.h"
#include "IO/ProjectDigest.h"
#include "Logging.h"
#include <QFileInfo>
#include <QFutureWatcher>
#include <QMessageBox>
#include <QPushButton>
#include <QTimer>
#include <QtConcurrent>
namespace {
// Off the UI thread; a half-written package gives none.
QFuture<std::optional<QByteArray>> digestOf(const QString &path)
{
return QtConcurrent::run([path]() -> std::optional<QByteArray> {
try {
return ProjectDigest::compute(path);
} catch (const std::runtime_error &error) {
qCDebug(lcIO) << "no digest for" << path << error.what();
return std::nullopt;
}
});
}
}
void ProjectController::watchProject(const QString &path)
{
if (externalChanges.watcher && externalChanges.watcher->path == path)
return;
externalChanges.watcher = std::make_unique<ProjectWatcher>(path, [this] { noteExternalChange(); });
}
void ProjectController::stopWatchingProject()
{
externalChanges.watcher.reset();
externalChanges.knownDigest.reset();
externalChanges.recheck->stop();
externalChanges.pending = false;
}
void ProjectController::rememberProjectDigest(const QString &path, std::function<void()> then)
{
auto *watcher = new QFutureWatcher<std::optional<QByteArray>>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, then] {
watcher->deleteLater();
externalChanges.knownDigest = watcher->result();
then();
});
watcher->setFuture(digestOf(path));
}
void ProjectController::resumeExternalChangeCheck()
{
if (externalChanges.pending)
noteExternalChange();
}
void ProjectController::noteExternalChange()
{
externalChanges.pending = true;
if (externalChanges.checking)
return;
externalChanges.checking = true;
checkExternalChange();
}
// Swift's loop: each pass takes one pending change, else stops.
void ProjectController::checkExternalChange()
{
if (!externalChanges.pending) {
externalChanges.checking = false;
return;
}
externalChanges.pending = false;
const std::optional<QString> path = session.projectPath();
if (!path || !session.document() || externalChanges.saving) {
externalChanges.checking = false;
return;
}
// Content, not dates: sync clients touch metadata alone.
auto *watcher = new QFutureWatcher<std::optional<QByteArray>>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, path = *path] {
watcher->deleteLater();
const std::optional<QByteArray> digest = watcher->result();
// Our save began meanwhile: look again once it lands.
if (externalChanges.saving) {
externalChanges.pending = true;
externalChanges.checking = false;
finishWriting([this] { resumeExternalChangeCheck(); });
return;
}
if (!digest || digest == externalChanges.knownDigest) {
checkExternalChange();
return;
}
// An edit in progress finishes before the document goes.
if (!session.canStartProjectOperation() || session.transformEdit() || (workspace && workspace->isManaging())) {
externalChanges.checking = false;
scheduleRecheck();
return;
}
if (!session.isModified()) {
reloadFromDisk(path, [this] { checkExternalChange(); });
return;
}
if (!window || !isFrontmost()) {
externalChanges.pending = true;
externalChanges.checking = false;
return;
}
const int askedAtSave = m_saveGeneration;
askToRevert([this, path, digest, askedAtSave](bool revert) {
if (!revert) {
// A save while asking remembered what it wrote.
if (m_saveGeneration == askedAtSave)
externalChanges.knownDigest = digest;
checkExternalChange();
return;
}
reloadFromDisk(path, [this] { checkExternalChange(); });
});
});
watcher->setFuture(digestOf(*path));
}
bool ProjectController::isFrontmost() const
{
return !workspace || &workspace->current().controller == this;
}
// Backs off while busy: a long operation is not polled.
void ProjectController::scheduleRecheck()
{
externalChanges.pending = true;
const int attempt = externalChanges.recheckAttempt;
externalChanges.recheckAttempt = std::min(attempt + 1, 7);
externalChanges.recheck->start(250 * (1 << attempt));
}
void ProjectController::reloadFromDisk(const QString &path, std::function<void()> then)
{
externalChanges.recheckAttempt = 0;
session.setIsProjectBusy(true);
// Swift's store is an actor: no read mid-write.
finishWriting([this, path, then] {
// A package that fails to load leaves the document alone.
load(path, [this, path, then](const Loaded &loaded) {
if (!loaded.snapshot || session.projectPath() != path || !session.document()) {
if (!loaded.snapshot)
qCInfo(lcIO).noquote() << "the changed package did not load yet:" << loaded.failure;
session.setIsProjectBusy(false);
then();
return;
}
session.reloadProject(*loaded.snapshot);
// As loaded, not first seen: it may have changed.
rememberProjectDigest(path, [this, path, then] {
externalChanges.reloadCount += 1;
qCInfo(lcIO).noquote() << "reloaded" << path << "after it changed on disk";
session.setIsProjectBusy(false);
then();
});
});
});
}
void ProjectController::askToRevert(std::function<void(bool)> then)
{
const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled");
auto *alert = new QMessageBox(window);
alert->setAttribute(Qt::WA_DeleteOnClose);
alert->setIcon(QMessageBox::Warning);
alert->setText(QStringLiteral("“%1” was changed on disk.").arg(name));
alert->setInformativeText(QStringLiteral("Another app changed this project. You can revert to the version on disk, losing your unsaved changes, or keep what you have."));
const QPushButton *revert = alert->addButton(QStringLiteral("Revert"), QMessageBox::AcceptRole);
alert->addButton(QStringLiteral("Keep Mine"), QMessageBox::RejectRole);
connect(alert, &QDialog::finished, this, [alert, revert, then] { then(alert->clickedButton() == revert); });
alert->open();
}
@@ -0,0 +1,22 @@
#pragma once
#include "IO/ProjectWatcher.h"
#include <QByteArray>
#include <memory>
#include <optional>
class QTimer;
// Swift's ExternalChangeState: the watch's bookkeeping, per controller.
struct ExternalChangeState {
std::unique_ptr<ProjectWatcher> watcher;
// The package as last read or written.
std::optional<QByteArray> knownDigest;
bool checking = false;
bool pending = false;
// Our own save is writing: its events are ours.
bool saving = false;
QTimer *recheck = nullptr;
int recheckAttempt = 0;
// Reloads because the package changed on disk; tests read it.
int reloadCount = 0;
};
+159 -60
View File
@@ -1,12 +1,15 @@
#include "IO/ProjectController.h" #include "IO/ProjectController.h"
#include "IO/RecentProjects.h"
#include "Document/ProjectWorkspace.h" #include "Document/ProjectWorkspace.h"
#include "IO/ProjectStore.h" #include "IO/ProjectStore.h"
#include "Logging.h" #include "Logging.h"
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QFutureWatcher> #include <QFutureWatcher>
#include <QLabel>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QTimer>
#include <QtConcurrent> #include <QtConcurrent>
bool ProjectController::isProject(const QUrl &url) bool ProjectController::isProject(const QUrl &url)
@@ -24,7 +27,13 @@ bool ProjectController::samePlace(const std::optional<QString> &path, const QStr
return path && resolved(*path) == resolved(other); return path && resolved(*path) == resolved(other);
} }
ProjectController::ProjectController(EditorSession &session) : session(session) {} ProjectController::ProjectController(EditorSession &session) : session(session)
{
externalChanges.recheck = new QTimer(this);
externalChanges.recheck->setSingleShot(true);
externalChanges.recheck->setTimerType(Qt::PreciseTimer);
connect(externalChanges.recheck, &QTimer::timeout, this, &ProjectController::noteExternalChange);
}
bool ProjectController::canStart() const bool ProjectController::canStart() const
{ {
@@ -61,25 +70,54 @@ void ProjectController::finish(const std::function<void(bool)> &done, bool value
void ProjectController::save(bool asNew, std::function<void(bool)> done) void ProjectController::save(bool asNew, std::function<void(bool)> done)
{ {
if (!session.document() || !begin()) { if (!session.document()) {
finish(done, false); finish(done, false);
return; return;
} }
saveCurrent(asNew, [this, done](bool saved) { // A save still writing finishes; this one saves what changed.
session.setIsProjectBusy(false); finishWriting([this, asNew, done] {
finish(done, saved); if (!begin()) {
finish(done, false);
return;
}
prepareSave(asNew, [this, done](std::optional<Prepared> prepared) {
// The writer is reserved before the tools go free.
if (prepared)
write(*prepared, [this, done](bool saved) { finish(done, saved); });
session.setIsProjectBusy(false);
if (!prepared)
finish(done, false);
});
}); });
} }
void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then) void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
{ {
const std::optional<ProjectSnapshot> snapshot = session.projectSnapshot(); if (!session.document()) {
if (!snapshot) {
then(true); then(true);
return; return;
} }
prepareSave(asNew, [this, then](std::optional<Prepared> prepared) {
if (!prepared) {
then(false);
return;
}
write(*prepared, then);
});
}
void ProjectController::prepareSave(bool asNew, std::function<void(std::optional<Prepared>)> then)
{
const QUuid revision = session.history.currentRevision();
// Undo may have taken the document while this waited.
const std::optional<ProjectSnapshot> captured = session.projectSnapshot();
if (!captured) {
then(std::nullopt);
return;
}
const ProjectSnapshot snapshot = *captured;
if (!asNew && session.projectPath()) { if (!asNew && session.projectPath()) {
write(*snapshot, *session.projectPath(), then); then(Prepared{snapshot, *session.projectPath(), revision});
return; return;
} }
auto *panel = new QFileDialog(window, asNew ? QStringLiteral("Save Project As") : QStringLiteral("Save Project")); auto *panel = new QFileDialog(window, asNew ? QStringLiteral("Save Project As") : QStringLiteral("Save Project"));
@@ -87,15 +125,15 @@ void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
panel->setAcceptMode(QFileDialog::AcceptSave); panel->setAcceptMode(QFileDialog::AcceptSave);
panel->setDefaultSuffix(QStringLiteral("comp")); panel->setDefaultSuffix(QStringLiteral("comp"));
panel->selectFile(session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled.comp")); panel->selectFile(session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled.comp"));
connect(panel, &QDialog::finished, this, [this, panel, snapshot = *snapshot, then](int result) { connect(panel, &QDialog::finished, this, [this, panel, snapshot, revision, then](int result) {
if (result != QDialog::Accepted) { if (result != QDialog::Accepted) {
then(false); then(std::nullopt);
return; return;
} }
// The panel only suggests the suffix; a project needs it. // The panel only suggests the suffix; a project needs it.
const QString chosen = panel->selectedFiles().value(0); const QString chosen = panel->selectedFiles().value(0);
if (isProject(QUrl::fromLocalFile(chosen))) { if (isProject(QUrl::fromLocalFile(chosen))) {
write(snapshot, chosen, then); then(Prepared{snapshot, chosen, revision});
return; return;
} }
// The panel never saw this name: nothing there is replaced. // The panel never saw this name: nothing there is replaced.
@@ -103,27 +141,60 @@ void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
// A link that leads nowhere is still in the way. // A link that leads nowhere is still in the way.
if (renamed.exists() || renamed.isSymLink()) { if (renamed.exists() || renamed.isSymLink()) {
showError(QStringLiteral("Couldn’t save the project"), showError(QStringLiteral("Couldn’t save the project"),
QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()), [then] { then(false); }); QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()), [then] { then(std::nullopt); });
return; return;
} }
write(snapshot, renamed.filePath(), then); then(Prepared{snapshot, renamed.filePath(), revision});
}); });
panel->open(); panel->open();
} }
void ProjectController::write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then) void ProjectController::finishWriting(std::function<void()> then)
{ {
if (!m_writing) {
then();
return;
}
m_writeWaiters.push_back(std::move(then));
}
// Writes a captured document; only that version counts as saved.
void ProjectController::write(const Prepared &prepared, std::function<void(bool)> then)
{
m_writing = true;
// Waiters go on from the event loop once it lands.
const auto landed = [this, then](bool saved) {
m_writing = false;
// Each looks again: an earlier one may be writing.
for (std::function<void()> &waiter : std::exchange(m_writeWaiters, {}))
QMetaObject::invokeMethod(this, [this, waiter = std::move(waiter)] { finishWriting(waiter); }, Qt::QueuedConnection);
then(saved);
};
// Our own save changes the package: its events are ours.
externalChanges.saving = true;
const QString destination = prepared.destination;
const ProjectSnapshot snapshot = prepared.snapshot;
auto *watcher = new QFutureWatcher<std::optional<QString>>(this); auto *watcher = new QFutureWatcher<std::optional<QString>>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, then] { connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, revision = prepared.revision, landed] {
watcher->deleteLater(); watcher->deleteLater();
if (const std::optional<QString> failure = watcher->result()) { if (const std::optional<QString> failure = watcher->result()) {
showError(QStringLiteral("Couldn’t save the project"), *failure, [then] { then(false); }); // Swift's defer: the guard lasts until the alert is answered.
showError(QStringLiteral("Couldn’t save the project"), *failure, [this, landed] {
externalChanges.saving = false;
landed(false);
});
return; return;
} }
// Marked first: the path's signal shows the saved state.
session.history.markSaved(revision);
session.setProjectPath(destination); session.setProjectPath(destination);
session.history.markSaved();
m_saveGeneration += 1; m_saveGeneration += 1;
then(true); RecentProjects::shared().note(destination);
rememberProjectDigest(destination, [this, destination, landed] {
watchProject(destination);
externalChanges.saving = false;
landed(true);
});
}); });
// The store blocks on the disk: off the UI thread. // The store blocks on the disk: off the UI thread.
watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> { watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> {
@@ -173,31 +244,48 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo
// A folder's trailing slash would leave it without a name. // A folder's trailing slash would leave it without a name.
while (source.endsWith(QLatin1Char('/'))) while (source.endsWith(QLatin1Char('/')))
source.chop(1); source.chop(1);
// Validated first: a corrupt project never discards the live document. // A listed project deleted since: name it, not its manifest.
load(source, [this, end, refuse, source](const Loaded &first) { if (!QFileInfo::exists(source)) {
if (!first.snapshot) { RecentProjects::shared().refresh();
refuse(first.failure); refuse(QStringLiteral("The file “%1” couldn’t be opened because there is no such file.").arg(QFileInfo(source).fileName()));
return; return;
} }
const int previousSave = m_saveGeneration; // A write in flight lands first: no read mid-save.
confirmReplacement([this, end, refuse, source, first, previousSave](bool proceed) { finishWriting([this, end, refuse, source] {
if (!proceed) { // Validated first: a corrupt project never discards the live document.
end(false); load(source, [this, end, refuse, source](const Loaded &first) {
if (!first.snapshot) {
refuse(first.failure);
return; return;
} }
// Saving in the confirmation can replace the opened file. const int previousSave = m_saveGeneration;
if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) { confirmReplacement([this, end, refuse, source, first, previousSave](bool proceed) {
session.installProject(*first.snapshot, source); if (!proceed) {
end(true); end(false);
return;
}
load(source, [this, end, refuse, source](const Loaded &again) {
if (!again.snapshot) {
refuse(again.failure);
return; return;
} }
session.installProject(*again.snapshot, source); // Saving in the confirmation can replace the opened file.
end(true); if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) {
session.installProject(*first.snapshot, source);
RecentProjects::shared().note(source);
rememberProjectDigest(source, [this, source, end] {
watchProject(source);
end(true);
});
return;
}
load(source, [this, end, refuse, source](const Loaded &again) {
if (!again.snapshot) {
refuse(again.failure);
return;
}
session.installProject(*again.snapshot, source);
RecentProjects::shared().note(source);
rememberProjectDigest(source, [this, source, end] {
watchProject(source);
end(true);
});
});
}); });
}); });
}); });
@@ -230,8 +318,10 @@ void ProjectController::newCanvas()
return; return;
confirmReplacement([this](bool proceed) { confirmReplacement([this](bool proceed) {
session.setIsProjectBusy(false); session.setIsProjectBusy(false);
if (proceed) if (!proceed)
session.clearProject(); return;
session.clearProject();
stopWatchingProject();
}); });
} }
@@ -248,6 +338,7 @@ void ProjectController::close(QWidget *closing)
if (!proceed) if (!proceed)
return; return;
session.clearProject(); session.clearProject();
stopWatchingProject();
if (closing) if (closing)
closing->close(); closing->close();
}); });
@@ -267,26 +358,29 @@ void ProjectController::confirmQuit(std::function<void(bool)> done)
void ProjectController::confirmReplacement(std::function<void(bool)> then) void ProjectController::confirmReplacement(std::function<void(bool)> then)
{ {
if (!session.isModified() || !session.document()) { // A save still writing finishes: no file is cut short.
then(true); finishWriting([this, then] {
return; if (!session.isModified() || !session.document()) {
} then(true);
const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled"); return;
auto *alert = new QMessageBox(window); }
alert->setAttribute(Qt::WA_DeleteOnClose); const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled");
alert->setIcon(QMessageBox::Warning); auto *alert = new QMessageBox(window);
alert->setText(QStringLiteral("Save changes to %1?").arg(name)); alert->setAttribute(Qt::WA_DeleteOnClose);
alert->setInformativeText(QStringLiteral("Your changes will be lost if you don’t save them.")); alert->setIcon(QMessageBox::Warning);
const QPushButton *save = alert->addButton(QStringLiteral("Save"), QMessageBox::AcceptRole); alert->setText(QStringLiteral("Save changes to %1?").arg(name));
alert->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole); alert->setInformativeText(QStringLiteral("Your changes will be lost if you don’t save them."));
const QPushButton *discard = alert->addButton(QStringLiteral("Don’t Save"), QMessageBox::DestructiveRole); const QPushButton *save = alert->addButton(QStringLiteral("Save"), QMessageBox::AcceptRole);
connect(alert, &QDialog::finished, this, [this, alert, save, discard, then] { alert->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole);
if (alert->clickedButton() == save) const QPushButton *discard = alert->addButton(QStringLiteral("Don’t Save"), QMessageBox::DestructiveRole);
saveCurrent(false, then); connect(alert, &QDialog::finished, this, [this, alert, save, discard, then] {
else if (alert->clickedButton() == save)
then(alert->clickedButton() == discard); saveCurrent(false, then);
else
then(alert->clickedButton() == discard);
});
alert->open();
}); });
alert->open();
} }
void ProjectController::showError(const QString &title, const QString &message, std::function<void()> then) void ProjectController::showError(const QString &title, const QString &message, std::function<void()> then)
@@ -297,6 +391,11 @@ void ProjectController::showError(const QString &title, const QString &message,
alert->setIcon(QMessageBox::Warning); alert->setIcon(QMessageBox::Warning);
alert->setText(title); alert->setText(title);
alert->setInformativeText(message); alert->setInformativeText(message);
// A file's name is read literally, never as markup.
auto *words = alert->findChild<QLabel *>(QStringLiteral("qt_msgbox_informativelabel"));
if (!words)
throw std::logic_error("the alert has no informative label");
words->setTextFormat(Qt::PlainText);
alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole); alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole);
connect(alert, &QDialog::finished, this, std::move(then)); connect(alert, &QDialog::finished, this, std::move(then));
alert->open(); alert->open();
+30 -1
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "IO/ProjectController+ExternalChanges.h"
#include <QList> #include <QList>
#include <QPointer> #include <QPointer>
#include <QUrl> #include <QUrl>
@@ -44,6 +45,15 @@ public:
void exportPNG(std::function<void()> done = {}); void exportPNG(std::function<void()> done = {});
void exportJPEG(std::function<void()> done = {}); void exportJPEG(std::function<void()> done = {});
// Swift's ProjectController+ExternalChanges: the package kept in step.
ExternalChangeState externalChanges;
void watchProject(const QString &path);
void stopWatchingProject();
// Remembers the package as it is now, then calls `then`.
void rememberProjectDigest(const QString &path, std::function<void()> then);
// Its tab came to the front: ask a held question.
void resumeExternalChangeCheck();
private: private:
struct Incoming { struct Incoming {
QList<QUrl> urls; QList<QUrl> urls;
@@ -60,10 +70,20 @@ private:
QString failure; QString failure;
}; };
struct Prepared {
ProjectSnapshot snapshot;
QString destination;
QUuid revision;
};
bool begin(); bool begin();
void finish(const std::function<void(bool)> &done, bool value); void finish(const std::function<void(bool)> &done, bool value);
void saveCurrent(bool asNew, std::function<void(bool)> then); void saveCurrent(bool asNew, std::function<void(bool)> then);
void write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then); // Swift's prepareSave: the document now, and where it goes.
void prepareSave(bool asNew, std::function<void(std::optional<Prepared>)> then);
void write(const Prepared &prepared, std::function<void(bool)> then);
// A save still writing finishes first; then `then` runs.
void finishWriting(std::function<void()> then);
void load(const QString &path, std::function<void(Loaded)> then); void load(const QString &path, std::function<void(Loaded)> then);
void confirmReplacement(std::function<void(bool)> then); void confirmReplacement(std::function<void(bool)> then);
void showError(const QString &title, const QString &message, std::function<void()> then); void showError(const QString &title, const QString &message, std::function<void()> then);
@@ -78,8 +98,17 @@ private:
void drainIncoming(); void drainIncoming();
void handle(const Incoming &request); void handle(const Incoming &request);
std::optional<QUuid> ownTab() const; std::optional<QUuid> ownTab() const;
void noteExternalChange();
void checkExternalChange();
bool isFrontmost() const;
void scheduleRecheck();
void reloadFromDisk(const QString &path, std::function<void()> then);
void askToRevert(std::function<void(bool)> then);
int m_saveGeneration = 0; int m_saveGeneration = 0;
// Swift's `writing`: a save writes in the background.
bool m_writing = false;
std::vector<std::function<void()>> m_writeWaiters;
std::deque<Incoming> m_incoming; std::deque<Incoming> m_incoming;
bool m_processing = false; bool m_processing = false;
}; };
+37
View File
@@ -0,0 +1,37 @@
#include "IO/ProjectDigest.h"
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QtEndian>
#include <algorithm>
#include <stdexcept>
QByteArray ProjectDigest::compute(const QString &path)
{
QCryptographicHash hasher(QCryptographicHash::Sha256);
QFile manifest(path + QStringLiteral("/manifest.json"));
if (!manifest.open(QIODevice::ReadOnly))
throw std::runtime_error(manifest.errorString().toStdString());
// A failed read comes up short; Swift's Data(contentsOf:) throws.
const QByteArray bytes = manifest.readAll();
if (bytes.size() != manifest.size())
throw std::runtime_error("the manifest could not be read whole");
hasher.addData(bytes);
// Assets are not read: a save would wait on each.
const QDir images(path + QStringLiteral("/images"));
QStringList names = images.entryList(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot, QDir::Unsorted);
std::sort(names.begin(), names.end());
for (const QString &name : names) {
const QFileInfo info(images.filePath(name));
// Gone mid-listing: half written, as Swift's resourceValues throws.
if (!info.exists() && !info.isSymLink())
throw std::runtime_error("an image went while the package was read");
if (info.isSymLink() || !info.isFile())
continue;
hasher.addData(name.toUtf8());
const quint64 size = qToLittleEndian(quint64(info.size()));
hasher.addData(QByteArrayView(reinterpret_cast<const char *>(&size), sizeof size));
}
return hasher.result();
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <QByteArray>
#include <QString>
// Swift's ProjectDigest: the manifest, and each image's name and size.
namespace ProjectDigest {
// Throws std::runtime_error when the manifest cannot be read.
QByteArray compute(const QString &path);
}
+26 -2
View File
@@ -102,7 +102,25 @@ QSizeF size(const QJsonValue &value)
return {width, height}; return {width, height};
} }
// Synthesized Codable skips defaults: all but the box required. // A run's every key is required, as synthesized Codable asks.
LayerTextColorRun colorRun(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
return {integer(object.value("location")), integer(object.value("length")), number(object.value("red")), number(object.value("green")),
number(object.value("blue"))};
}
std::vector<LayerTextColorRun> colorRuns(const QJsonValue &value)
{
if (!value.isArray())
refuse();
std::vector<LayerTextColorRun> runs;
for (const QJsonValue &each : value.toArray())
runs.push_back(colorRun(each));
return runs;
}
// Synthesized Codable: all required but the box and runs.
LayerTextStyle textStyle(const QJsonValue &value) LayerTextStyle textStyle(const QJsonValue &value)
{ {
const QJsonObject object = value.toObject(); const QJsonObject object = value.toObject();
@@ -112,7 +130,7 @@ LayerTextStyle textStyle(const QJsonValue &value)
return {.content = string(object.value("content")), .fontName = string(object.value("fontName")), .fontSize = number(object.value("fontSize")), return {.content = string(object.value("content")), .fontName = string(object.value("fontName")), .fontSize = number(object.value("fontSize")),
.red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")), .red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")),
.alignment = *alignment, .tracking = number(object.value("tracking")), .leading = number(object.value("leading")), .alignment = *alignment, .tracking = number(object.value("tracking")), .leading = number(object.value("leading")),
.boxSize = optional(object, "boxSize", size)}; .boxSize = optional(object, "boxSize", size), .colorRuns = optional(object, "colorRuns", colorRuns)};
} }
CanvasGuide guide(const QJsonValue &value) CanvasGuide guide(const QJsonValue &value)
@@ -176,6 +194,12 @@ QJsonObject encoded(const LayerTextStyle &text)
{"blue", text.blue}, {"alignment", rawValue(text.alignment)}, {"tracking", text.tracking}, {"leading", text.leading}}; {"blue", text.blue}, {"alignment", rawValue(text.alignment)}, {"tracking", text.tracking}, {"leading", text.leading}};
if (text.boxSize) if (text.boxSize)
object.insert("boxSize", QJsonArray{text.boxSize->width(), text.boxSize->height()}); object.insert("boxSize", QJsonArray{text.boxSize->width(), text.boxSize->height()});
if (text.colorRuns) {
QJsonArray runs;
for (const LayerTextColorRun &run : *text.colorRuns)
runs.append(QJsonObject{{"location", run.location}, {"length", run.length}, {"red", run.red}, {"green", run.green}, {"blue", run.blue}});
object.insert("colorRuns", runs);
}
return object; return object;
} }
+28 -16
View File
@@ -1,4 +1,5 @@
#include "IO/ProjectStore.h" #include "IO/ProjectStore.h"
#include "Document/DocumentLimits.h"
#include "Document/LayerGroups.h" #include "Document/LayerGroups.h"
#include "Document/LayerMask.h" #include "Document/LayerMask.h"
#include "Document/LiveLayerMask.h" #include "Document/LiveLayerMask.h"
@@ -29,7 +30,7 @@ QString description(ProjectError::Kind kind)
case ProjectError::Kind::missingImage: case ProjectError::Kind::missingImage:
return QStringLiteral("An image inside the project is missing or damaged. The current document has not been replaced."); return QStringLiteral("An image inside the project is missing or damaged. The current document has not been replaced.");
case ProjectError::Kind::tooLarge: case ProjectError::Kind::tooLarge:
return QStringLiteral("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit."); return QStringLiteral("This project exceeds the supported canvas, layer, file-size, or %1-megapixel document limit.").arg(DocumentLimits::documentBudgetMegapixels());
case ProjectError::Kind::encode: case ProjectError::Kind::encode:
return QStringLiteral("An image could not be saved. The previous project has not been replaced."); return QStringLiteral("An image could not be saved. The previous project has not been replaced.");
} }
@@ -62,18 +63,18 @@ void validate(const ProjectManifest &manifest)
{ {
if (manifest.format != QLatin1String("com.compositor.project")) if (manifest.format != QLatin1String("com.compositor.project"))
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
if (manifest.version < 1 || manifest.version > 9) if (!ProjectManifest::supports(manifest.version))
throw ProjectError::unsupportedVersion(manifest.version); throw ProjectError::unsupportedVersion(manifest.version);
if (manifest.colorSpace != QLatin1String("sRGB")) if (manifest.colorSpace != QLatin1String("sRGB"))
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
// The range refuses NaN and infinity as well. // The range refuses NaN and infinity as well.
if (manifest.resolution && !(*manifest.resolution >= 1 && *manifest.resolution <= 9600)) if (manifest.resolution && !(*manifest.resolution >= 1 && *manifest.resolution <= 9600))
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
if (manifest.width < 1 || manifest.width > 30'000 || manifest.height < 1 || manifest.height > 30'000 || manifest.layers.size() > 10'000) if (manifest.width < 1 || manifest.width > DocumentLimits::maxSide || manifest.height < 1 || manifest.height > DocumentLimits::maxSide || manifest.layers.size() > 10'000)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
for (const ProjectLayerRecord &layer : manifest.layers) { for (const ProjectLayerRecord &layer : manifest.layers) {
// Text needs a valid style and pixels to show it. // Text needs a valid style, pixels; runs from 10.
if (layer.text && (!layer.text->isValid() || !layer.imageFile)) if (layer.text && (!layer.text->isValid() || !layer.imageFile || (layer.text->colorRuns && manifest.version < 10)))
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
// Version 7's adjustments hold settings, never pixels or children. // Version 7's adjustments hold settings, never pixels or children.
if (layer.adjustment && (manifest.version < 7 || layer.isGroup == true || layer.imageFile || !layer.adjustment->isValid())) if (layer.adjustment && (manifest.version < 7 || layer.isGroup == true || layer.imageFile || !layer.adjustment->isValid()))
@@ -116,11 +117,18 @@ void validate(const ProjectManifest &manifest)
validateGuides(manifest); validateGuides(manifest);
} }
// Images share 100 million pixels; masks share another. // Images share the document budget; masks share another.
void checkSize(QSize size, qint64 &used) struct Budgets {
qint64 images = 0;
qint64 masks = 0;
};
// Save and load count alike: one rule for both.
void checkSize(QSize size, bool isMask, Budgets &budgets)
{ {
qint64 &used = isMask ? budgets.masks : budgets.images;
const qint64 pixels = qint64(size.width()) * size.height(); const qint64 pixels = qint64(size.width()) * size.height();
if (size.width() < 1 || size.width() > 30'000 || size.height() < 1 || size.height() > 30'000 || pixels > 100'000'000 - used) if (size.width() < 1 || size.width() > DocumentLimits::maxSide || size.height() < 1 || size.height() > DocumentLimits::maxSide || pixels > DocumentLimits::documentPixelBudget() - used)
throw ProjectError(ProjectError::Kind::tooLarge); throw ProjectError(ProjectError::Kind::tooLarge);
used += pixels; used += pixels;
} }
@@ -225,7 +233,7 @@ void replace(const QString &staged, const QString &destination, const QString &p
} }
// The pixels and a thumbnail of at most 96 pixels. // The pixels and a thumbnail of at most 96 pixels.
ImportedImage asset(const QString &file, bool isMask, const QString &name, qint64 &used) ImportedImage asset(const QString &file, bool isMask, const QString &name, Budgets &used)
{ {
ImageImporter::liftAllocationLimit(); ImageImporter::liftAllocationLimit();
QImageReader reader(file); QImageReader reader(file);
@@ -234,7 +242,7 @@ ImportedImage asset(const QString &file, bool isMask, const QString &name, qint6
|| stored == QImage::Format_Grayscale16; || stored == QImage::Format_Grayscale16;
if (reader.format() != "png" || !reader.size().isValid() || deep) if (reader.format() != "png" || !reader.size().isValid() || deep)
throw ProjectError(ProjectError::Kind::missingImage); throw ProjectError(ProjectError::Kind::missingImage);
checkSize(reader.size(), used); checkSize(reader.size(), isMask, used);
QImage image = reader.read(); QImage image = reader.read();
if (!isMask) if (!isMask)
image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
@@ -256,7 +264,10 @@ ProjectError::ProjectError(Kind kind) : ProjectError(kind, std::nullopt, descrip
ProjectError ProjectError::unsupportedVersion(qint64 version) ProjectError ProjectError::unsupportedVersion(qint64 version)
{ {
return ProjectError(Kind::version, version, return ProjectError(Kind::version, version,
QStringLiteral("This project uses format version %1. This app supports versions 1–9.").arg(version)); QStringLiteral("This project uses format version %1. This app supports versions %2–%3.")
.arg(version)
.arg(ProjectManifest::supported.first)
.arg(ProjectManifest::supported.second));
} }
ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description) ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description)
@@ -268,7 +279,7 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
{ {
validate(snapshot.manifest); validate(snapshot.manifest);
std::vector<std::pair<QString, QByteArray>> files; std::vector<std::pair<QString, QByteArray>> files;
qint64 pixels = 0, maskPixels = 0; Budgets budgets;
for (const ProjectLayerRecord &layer : snapshot.manifest.layers) { for (const ProjectLayerRecord &layer : snapshot.manifest.layers) {
for (const bool isMask : {false, true}) { for (const bool isMask : {false, true}) {
const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile; const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile;
@@ -278,10 +289,11 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
const auto found = assets.find(layer.id); const auto found = assets.find(layer.id);
if (found == assets.end()) if (found == assets.end())
throw ProjectError(ProjectError::Kind::missingImage); throw ProjectError(ProjectError::Kind::missingImage);
// Swift's lazy image has its size unrendered: judged first.
checkSize(found->second.size(), isMask, budgets);
const QImage image = found->second.image(); const QImage image = found->second.image();
if (isMask && !LayerMask::isValid(image)) if (isMask && !LayerMask::isValid(image))
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
checkSize(image.size(), isMask ? maskPixels : pixels);
files.push_back({*filename, png(image)}); files.push_back({*filename, png(image)});
} }
} }
@@ -332,12 +344,12 @@ ProjectSnapshot ProjectStore::load(const QString &path)
const auto [format, version] = ProjectManifest::header(metadata); const auto [format, version] = ProjectManifest::header(metadata);
if (format != QLatin1String("com.compositor.project")) if (format != QLatin1String("com.compositor.project"))
throw ProjectError(ProjectError::Kind::invalid); throw ProjectError(ProjectError::Kind::invalid);
if (version < 1 || version > 9) if (!ProjectManifest::supports(version))
throw ProjectError::unsupportedVersion(version); throw ProjectError::unsupportedVersion(version);
const ProjectManifest manifest = ProjectManifest::decoded(metadata); const ProjectManifest manifest = ProjectManifest::decoded(metadata);
validate(manifest); validate(manifest);
ProjectSnapshot snapshot{.manifest = manifest, .images = {}, .masks = {}}; ProjectSnapshot snapshot{.manifest = manifest, .images = {}, .masks = {}};
qint64 pixels = 0, maskPixels = 0; Budgets budgets;
for (const ProjectLayerRecord &layer : manifest.layers) { for (const ProjectLayerRecord &layer : manifest.layers) {
for (const bool isMask : {false, true}) { for (const bool isMask : {false, true}) {
const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile; const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile;
@@ -345,7 +357,7 @@ ProjectSnapshot ProjectStore::load(const QString &path)
continue; continue;
const QString file = path + QLatin1String("/images/") + *filename; const QString file = path + QLatin1String("/images/") + *filename;
checkFile(file, path, assetBytes, ProjectError::Kind::missingImage); checkFile(file, path, assetBytes, ProjectError::Kind::missingImage);
(isMask ? snapshot.masks : snapshot.images).insert({layer.id, asset(file, isMask, layer.name, isMask ? maskPixels : pixels)}); (isMask ? snapshot.masks : snapshot.images).insert({layer.id, asset(file, isMask, layer.name, budgets)});
} }
} }
qCInfo(lcIO) << "loaded" << path << "with" << manifest.layers.size() << "layers and" << snapshot.images.size() + snapshot.masks.size() << "images"; qCInfo(lcIO) << "loaded" << path << "with" << manifest.layers.size() << "layers and" << snapshot.images.size() + snapshot.masks.size() << "images";
+5 -1
View File
@@ -50,9 +50,13 @@ struct ProjectLayerRecord {
QString uuidString(const QUuid &id); QString uuidString(const QUuid &id);
struct ProjectManifest { struct ProjectManifest {
// Swift's current and supported: what saves write, what loads accept.
static constexpr qint64 current = 10;
static constexpr std::pair<qint64, qint64> supported{1, current};
static bool supports(qint64 version) { return version >= supported.first && version <= supported.second; }
QString format = QStringLiteral("com.compositor.project"); QString format = QStringLiteral("com.compositor.project");
// Swift's Int: a wild number must reach validation whole. // Swift's Int: a wild number must reach validation whole.
qint64 version = 9; qint64 version = current;
QString colorSpace = QStringLiteral("sRGB"); QString colorSpace = QStringLiteral("sRGB");
// Version-1 projects carry none: 72 pixels per inch. // Version-1 projects carry none: 72 pixels per inch.
std::optional<double> resolution = std::nullopt; std::optional<double> resolution = std::nullopt;
+66
View File
@@ -0,0 +1,66 @@
#include "IO/ProjectWatcher.h"
#include <QFile>
#include <QFileInfo>
#include <sys/stat.h>
ProjectWatcher::ProjectWatcher(const QString &path, std::function<void()> onChange, QObject *parent)
: QObject(parent), path(path), m_onChange(std::move(onChange)), m_delivery(new QTimer(this)), m_rearm(new QTimer(this))
{
m_delivery->setObjectName(QStringLiteral("delivery"));
m_delivery->setSingleShot(true);
// Swift's sleeps never end early; a coarse timer may.
m_delivery->setTimerType(Qt::PreciseTimer);
m_delivery->setInterval(coalescing);
connect(m_delivery, &QTimer::timeout, this, [this] { m_onChange(); });
// A swapped-in package retires the watched inodes: arm by path.
m_rearm->setObjectName(QStringLiteral("rearm"));
m_rearm->setInterval(100);
m_rearm->setTimerType(Qt::PreciseTimer);
connect(m_rearm, &QTimer::timeout, this, [this] {
arm();
m_rearmTries += 1;
if (m_watcher.files().size() + m_watcher.directories().size() == watchedPaths().size() || m_rearmTries >= 20)
m_rearm->stop();
});
connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, &ProjectWatcher::noteEvent);
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &ProjectWatcher::noteEvent);
// The parent's other entries are not ours: only another package.
connect(&m_parent, &QFileSystemWatcher::directoryChanged, this, [this] {
if (identity() != m_identity)
noteEvent();
});
m_parent.addPath(QFileInfo(path).absolutePath());
arm();
}
QStringList ProjectWatcher::watchedPaths() const
{
return {path, path + QStringLiteral("/manifest.json"), path + QStringLiteral("/images")};
}
std::optional<std::pair<dev_t, ino_t>> ProjectWatcher::identity() const
{
struct stat status;
if (::stat(QFile::encodeName(path).constData(), &status) != 0)
return std::nullopt;
return std::pair(status.st_dev, status.st_ino);
}
void ProjectWatcher::arm()
{
m_identity = identity();
const QStringList watched = m_watcher.files() + m_watcher.directories();
if (!watched.isEmpty())
m_watcher.removePaths(watched);
for (const QString &each : watchedPaths()) {
if (QFileInfo::exists(each))
m_watcher.addPath(each);
}
}
void ProjectWatcher::noteEvent()
{
m_rearmTries = 0;
m_rearm->start();
m_delivery->start();
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <QFileSystemWatcher>
#include <QObject>
#include <QTimer>
#include <functional>
#include <optional>
#include <sys/types.h>
// Swift's ProjectWatcher: tells its owner the package changed on disk.
class ProjectWatcher : public QObject {
public:
ProjectWatcher(const QString &path, std::function<void()> onChange, QObject *parent = nullptr);
const QString path;
// Quiet time before reporting, so one save reports once.
static constexpr int coalescing = 300;
private:
QStringList watchedPaths() const;
void arm();
void noteEvent();
// The package folder's device and inode, when it exists.
std::optional<std::pair<dev_t, ino_t>> identity() const;
const std::function<void()> m_onChange;
QFileSystemWatcher m_watcher;
// Qt watches no IN_MOVE_SELF: a renamed package shows above.
QFileSystemWatcher m_parent;
std::optional<std::pair<dev_t, ino_t>> m_identity;
// Named children, so tests read whether they run.
QTimer *const m_delivery;
QTimer *const m_rearm;
int m_rearmTries = 0;
};
+66
View File
@@ -0,0 +1,66 @@
#include "IO/RecentProjects.h"
#include <QFileInfo>
#include <QGuiApplication>
#include <QSettings>
namespace {
const QString key = QStringLiteral("recentProjects");
// One place however it was reached, as Finder keeps it.
QString resolved(const QString &path)
{
const QString canonical = QFileInfo(path).canonicalFilePath();
return canonical.isEmpty() ? QFileInfo(path).absoluteFilePath() : canonical;
}
}
RecentProjects &RecentProjects::shared()
{
static RecentProjects recent;
return recent;
}
RecentProjects::RecentProjects()
{
refresh();
// Checked again on activation; a GUI-less run has none.
if (auto *app = qobject_cast<QGuiApplication *>(QCoreApplication::instance())) {
connect(app, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) {
if (state == Qt::ApplicationActive)
refresh();
});
}
}
void RecentProjects::note(const QString &path)
{
const QString place = resolved(path);
QStringList stored = QSettings().value(key).toStringList();
stored.removeAll(place);
stored.prepend(place);
store(stored.mid(0, limit));
}
void RecentProjects::clear()
{
store({});
}
void RecentProjects::refresh()
{
QStringList existing;
for (const QString &path : QSettings().value(key).toStringList()) {
if (QFileInfo::exists(path))
existing << path;
}
if (existing == m_paths)
return;
m_paths = existing;
emit changed();
}
void RecentProjects::store(const QStringList &paths)
{
QSettings().setValue(key, paths);
refresh();
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <QObject>
#include <QStringList>
// Swift's RecentProjects: newest first, kept in QSettings.
class RecentProjects : public QObject {
Q_OBJECT
public:
static RecentProjects &shared();
// At most this many, as macOS keeps by default.
static constexpr int limit = 10;
// The projects that still exist, newest first.
const QStringList &paths() const { return m_paths; }
void note(const QString &path);
void clear();
// Drops the projects since moved or deleted.
void refresh();
signals:
void changed();
private:
RecentProjects();
void store(const QStringList &paths);
QStringList m_paths;
};
+2 -1
View File
@@ -1,10 +1,11 @@
#pragma once #pragma once
#include "Document/DocumentLimits.h"
#include <QPainter> #include <QPainter>
#include <functional> #include <functional>
// Draws through an image surface over what the painter shows. // Draws through an image surface over what the painter shows.
namespace AdjustmentSurface { namespace AdjustmentSurface {
inline constexpr qint64 pixelBudget = 100'000'000; inline constexpr qint64 pixelBudget = DocumentLimits::maxSurfacePixels;
// Padding, in painter units, is drawn past what shows. // Padding, in painter units, is drawn past what shows.
void draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding = 0, void draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding = 0,
qint64 pixelBudget = AdjustmentSurface::pixelBudget); qint64 pixelBudget = AdjustmentSurface::pixelBudget);
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more