diff --git a/AGENTS.md b/AGENTS.md index 08ff064..b5e3e99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Platform adaptations, each forced by a missing Qt analog: - Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files. - Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too. - `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; `markSaved()` needs no signal of its own, because every path from a save goes on to free the project, and that notifies. `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it). -- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayer` bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor's `?? centre` fallback is dead (the copied layer is always among the copies) and is a checked read instead. +- `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. - `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. @@ -58,15 +58,15 @@ Platform adaptations, each forced by a missing Qt analog: - `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 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. -- `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. -- `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`. +- `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) 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. - Distort (6.2): `DistortWarp` keeps Swift's tests of usability and convexity, `homography` (a projective `QTransform` where Swift returns a closure, Qt dividing by the third row as Swift by `w`), `warp`, `warpTrimmed`, `carried` and `warpMask`. Core Image's perspective filter is `QTransform::quadToQuad` on the image's pixel rect and a `drawImage` under it, antialiased and smooth; a folded shape is two triangles under their affine maps, each a hard device-pixel region of the pixels whose centres the triangle holds, built per scanline from each edge's ends ordered by y, the left bound inclusive, so the halves partition the diagonal and meet a fractional shared edge with one x (a path clip turns soft once the image draws antialiased, and `QRegion`'s integer polygons round the vertices), copied through `BrushRaster::draw` without interpolation, as Swift's. In `distortPreview` a mask warp that throws is caught on its own, as Swift's `try?`: a linked mask covering its layer then draws none, a placed one falls back, with a folded shape, to its stored placement clipped over the warp. Its `Warped` and `Trimmed` structs stand for Swift's tuples; `mapPath` arrived with 8.2b and `distortedEffects` arrived with 9.6c. Swift's `DistortTests` agree since 1.2.1 (`7111885`): a bow tie is usable and folds as two triangles, not convex, and a collapsed corner is refused by both tests; the preview takes a folded shape. `TransformEdit.corners` sits last, after `persistent`. The session's `distortPreview` and `maskDistortPreview` are `const` with mutable caches (`DistortPreviewCache` by layer, `MaskDistortPreviewCache`), keyed by `ImageIdentity` where Swift compares `CGImage` identity, because the canvas reads them from a const session; a warp that throws leaves a cached empty result, as Swift's `try?`. `commitDistort` catches `ProjectError` and `ExportError` into `brushError` as Swift catches its errors; `commitTransform` runs it after the mask path, before the plain one, and ends a duplicate after it. `commitMaskTransform` takes the corners path with `Distort Layer Mask`, `displayedMaskPlacement` its corners cases, `nudgeLayer` moves the corners with the draft. The overlay's corners constructor sets `showsRotation` false, `hit` skips the grip then, and the drawing leaves the stem and disc out; `geometry` returns the corners' geometry for a group or the edited layer. The canvas's `DisplayState` carries the edit's corners, so a moved corner repaints; `drawOwn` draws `distortPreview` first and takes `maskDistortPreview` for the mask. A Ctrl-drag on a handle begins the distortion and drags in `distort` mode with the white `distortCursor`; the cursor shows it over a handle under Ctrl or once distorted. The inspector's numbers rest while corners are set, as Swift's `.disabled`. - `TransformInspector` is the Move tool's `ToolHeaderBar`: Auto Select and Show Controls as `QCheckBox`es, the numbers in a `QScrollArea` without bars (Swift's horizontal `ScrollView`, which takes the row's spare room and keeps its content at its own size: the row's end stretch rests, the content ends in a stretch of its own), the ratio lock a checkable `QToolButton` with the panel's link icon, the sampling a `QComboBox`, Flip H and V and Cancel and Apply as `QPushButton`s. `TransformValueField` applies as typed (Swift's `isFinite` test is left out: `previewTransform` refuses every transform an infinite number makes), shows the session's number while unfocused and puts it back on `FocusOut`, steps with Up and Down (Shift ten) writing the number it applied, keeps its typing while a menu or popup borrows the focus (`MenuBarFocusReason`, `PopupFocusReason`), and on Return or Escape hands the canvas the keys as Swift's `onSubmit` and `onExitCommand`; the pending edit waits there for the canvas's Return or Escape. From any other control of the bar, Return applies and Escape cancels, Swift's default and cancel actions. The lock's icon is redrawn on `PaletteChange`, as the panel's. Swift's `.id(activeLayerID)` remake is `synchronize` clearing any focus of its own when the active layer changes. `resizeBox` is Swift's `resize`, whose name would hide `QWidget::resize`; its `number >= 1` guard and the scale's `number > 0` are left out: a side under a pixel is a box `previewTransform` refuses. The Layer menu's Transform Layer entry calls `beginTransform` (Swift's `transformCommand` does the same without a selection; 7.4 brings the selection) and the View menu's Show Transform Controls is checkable under Ctrl+H, enabled in the Move tool with a document. - Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, then a focused layer list, which selects every row with the top one primary, as `NSTableView`'s `selectAll(_:)` does in Swift's chain (`NativeLayerList::selectAllRows`), and only then the canvas. The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes. - The Marquee and the Lasso on the canvas (7.2): Swift's lasso gestures live in `EditorCanvas+Selection.cpp`, the selection cursors in `EditorCanvas+Cursors.cpp`. The canvas tracks Shift as it tracks Alt and Ctrl (the application filter sees the key from any window, as Swift's `flagsChanged` monitor), and a key change reshapes a Marquee draft and the cursor's badge at once. A press with a selection tool is `lassoMouseDown`; a move is `moveSelectionTool` (a hover moves a polygonal draft's rubber band and refreshes the held keys, as Swift's `mouseMoved`); a release is `releaseSelectionTool`; the draft's keys and the outline's nudges are `selectionKey`, ahead of every other key; losing focus is `endSelectionGestures`. A double click closes a polygonal outline from `mouseDoubleClickEvent`, since Qt delivers the second press there (its first press adds the corner, as Swift's first `mouseDown` does), never while Space pans. A held M or L's repeats are swallowed, as Swift ignores them, so a tool chosen meanwhile stays. An outline drag keeps `moveSelectionCursor` under Space, as the transform drag keeps its own. The ants are a 120 ms `QTimer` stepping `TransformOverlay::antsPhase`, repainting `selectionRect` alone (the outline's own rect, not the handles beside it); `drawnRect` spans the handles, the outline and the draft, the last two stroked antialiased with butt caps (`Qt::FlatCap`, as CoreGraphics dashes: Qt's square caps would stretch each dash by the pen's width) as CoreGraphics strokes them (a 1-point line on whole coordinates blurs over two rows at 1×, on macOS too); `synchronizeDisplay` repaints the overlay when the displayed selection or draft changes, refreshes the overlay's cached rect with every full repaint (a pan moves the outline) and refreshes the cursor when a kind or the displayed mode changes. The two timers are named children of the canvas (`antsTimer`, `marqueeAutoscroll`), as the session's busy timer is, so tests read whether they run. Autoscroll is a 16 ms `QTimer` with Swift's speed curve; each step translates the viewport, drags the draft or the outline again under the still pointer, notifies and synchronizes. `moveSelectionCursor`, `selectionCursor` and `wandCursor` are drawn as paths: the crosshair, the icon haloed by eight white offsets and the badge, and the wand with Swift's own strokes (hot spot 7, 7 at the sparkle, the stick down right, the badge at 17, 5), since `QCursor::pixmap` is empty for a shape cursor; the icons are `Rendering/SelectionIcons`, which the rail's `ToolIcons` and the cursors share, so nothing in `Rendering/` includes `UI/`. `SelectionIcon` is that header's enum, not nested in `CanvasView`. Ctrl over the selection shows the move pointer until 7.4 brings the scissors; the wand's click and cursor are 7.3's. `pressMarqueeKey` and `pressLassoKey` choose their tools and nothing more, as Swift's functions do: Swift's tests once expected a second press to switch the kind, which no Swift code does (its `cycleToolMode` on Tab does, 8.x); since 1.2.0 (`b5cb4fa`) they test the code's rule, as the twin does (`…AndKeepsTheShapeLastSet`), and the twin's tooltips name no key. `LassoControls` is the selection tools' `ToolHeaderBar`: Swift's segmented pickers are checkable `QToolButton`s in a `QButtonGroup`, the amounts are `SelectionAmountField`s (a `QLineEdit` taking digits alone, every typed value clamped to 1–500 as Swift's binding clamps it, stepped by arrows within the bounds, Return and Escape handing the canvas the keys, a menu's borrowed focus keeping the typing as `TransformValueField` does), the wand's controls arrive with 7.3. `ContentView::hint` takes a `ToolIconKind` for the ellipse Marquee's and the polygonal Lasso's words, and the rail's buttons repaint with the kind. - The Magic Wand (7.3): `WandPixels.c` compiles in place beside the other kernels, and `Document/MagicWand.cpp` wraps it as Swift's `MagicWand` does: `select` draws the image into a premultiplied `RGBA8888` context (the kernel's byte order, rows top down), floors the point, clamps the tolerance and hands the mask to `wand_mask`; `outline` frees the kernel's arrays through `unique_ptr` and builds a `WindingFill` path from its loops. `MagicWandError` carries Swift's two messages (`what()`), `tooDetailed` for the kernel's −2 and `memory` for −1. The session's `magicWand` takes a completion callback and runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, like the invert; the worker catches `MagicWandError` and `ExportError`, and the UI side applies the outline only while the same document is open (its id, as Swift compares), New with no match deselecting, New with a match `setSelection` without the canvas clip (Swift's shortcut), the other modes `applySelection`. `wandSample` draws the composite through `drawLiveComposite` or the active layer's own pixels through `LayerRenderer::draw` at 1:1, a folder or blank layer as clear; a context that cannot be allocated logs and refuses, as Swift's `try?` returns nil. `wandSettings` is a struct with a setter that notifies; `title` and `radius` stand for Swift's computed properties of `WandSampleSize`. The canvas hands a wand press to `magicWand` with the press's mode, and a click inside the selection to `magicWand` in New mode where another tool would deselect. `LassoControls` shows the wand's controls with the wand alone: the tolerance is a `SelectionAmountField` bounded 0–255, the sample size a `QComboBox` of Swift's titles, the source two checkable buttons, Contiguous a `QCheckBox`; `changeWand` rewrites one field of the settings. The mutant that pins `m_thisLayer` checked is equivalent, since the exclusive group's later `m_allLayers` line decides, as with the marquee's kinds. -- The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and 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. - 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 halves of Swift's `BrushTests`, `RasterSnapshotSessionTests` the five session cases, `BrushCanvasTests` the two `canvasShift` cases and the canvas's Delete key. @@ -98,7 +98,7 @@ Platform adaptations, each forced by a missing Qt analog: - Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` in `CanvasDisplayTests`. - Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does. - Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`. -- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. 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 on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's. A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too. - `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels). @@ -131,6 +131,7 @@ Platform adaptations, each forced by a missing Qt analog: - 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`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index bbd6c4d..eca7dd7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ add_library(compositor_core STATIC src/Document/LiveLayerMask.cpp src/Document/PixelAdjust.cpp src/Document/ProjectWorkspace.cpp + src/Document/ProjectWorkspace+Copy.cpp src/Document/SelectionClipboard.cpp src/Document/Crop.cpp src/Document/Guides.cpp diff --git a/TASKS.md b/TASKS.md index 638a6a8..85ed6db 100644 --- a/TASKS.md +++ b/TASKS.md @@ -376,7 +376,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - 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.9b round 2: PASS, no findings. - [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.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`). +- [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. - [ ] 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. - [ ] 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.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. diff --git a/src/Document/EditorSession+Transform.cpp b/src/Document/EditorSession+Transform.cpp index ffa27ea..5f55454 100644 --- a/src/Document/EditorSession+Transform.cpp +++ b/src/Document/EditorSession+Transform.cpp @@ -116,7 +116,6 @@ void EditorSession::beginDuplicateTransform() QSet<QUuid> carried; for (const QUuid &id : selection) carried.unite(descendantIDs(id)); - // Bottom to top: the copies keep their order. std::vector<QUuid> targets; for (const ImageLayer &layer : m_document->layers) { if (selection.contains(layer.id) && !carried.contains(layer.id)) @@ -125,21 +124,17 @@ void EditorSession::beginDuplicateTransform() if (targets.empty()) return; beginEdit(targets.size() > 1 ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer")); - std::vector<QUuid> copies; - for (const QUuid &id : targets) { - selectLayer(id); - duplicateActiveLayer(); - if (m_activeLayerID != id) - copies.push_back(m_activeLayerID.value()); - } + // Stacked as Duplicate Layer stacks them. + duplicateLayers(targets); + const QSet<QUuid> copies = m_selectedLayerIDs - selection; // Past the layer limit nothing was copied. - if (copies.empty()) { + if (copies.isEmpty()) { endEdit(); selectLayers(selection, primary); return; } - m_transformDuplicate = TransformDuplicate{copies, selection, primary}; - selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back()); + m_transformDuplicate = TransformDuplicate{std::vector<QUuid>(copies.begin(), copies.end()), selection, primary}; + selectLayers(copies, m_activeLayerID); beginTransform(false); } diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 38ad4d3..5ec8084 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -276,8 +276,6 @@ public: int canvasFocusRequest() const { return m_canvasFocusRequest; } void requestCanvasFocus(); - // What Paste puts back in place, until another copy. - const std::optional<PixelClipboard> &pixelClipboard() const { return m_pixelClipboard; } // What went wrong in a tool; the window shows it. const std::optional<QString> &brushError() const { return m_brushError; } void setBrushError(std::optional<QString> error); @@ -481,9 +479,7 @@ private: std::optional<LayerEffects> m_effectsEditingOriginal; std::optional<LayerEffectSelection> m_effectSelection; std::optional<PixelClipboard> m_pixelClipboard; - // Swift's pasteboard change count, counted from the first copy. - int m_clipboardChanges = 0; - bool m_watchingClipboard = false; + std::optional<CopiedLayer> m_copiedLayer; std::optional<LassoDraft> m_lassoDraft; LassoKind m_lassoKind = LassoKind::freehand; LassoKind m_marqueeKind = LassoKind::rectangle; diff --git a/src/Document/ProjectWorkspace+Copy.cpp b/src/Document/ProjectWorkspace+Copy.cpp new file mode 100644 index 0000000..d0310ab --- /dev/null +++ b/src/Document/ProjectWorkspace+Copy.cpp @@ -0,0 +1,202 @@ +#include "Document/ProjectWorkspace.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) > 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 ©, 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; +} diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 5a0c1a7..688ff38 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -1,13 +1,9 @@ #include "Document/ProjectWorkspace.h" -#include "Document/LiveLayerMask.h" -#include "IO/ImageExporter.h" #include "IO/ImageFileDrop.h" #include "Logging.h" #include <QFileDialog> #include <QFileInfo> -#include <QFutureWatcher> #include <QTimer> -#include <QtConcurrent> const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row"); @@ -348,152 +344,3 @@ std::vector<QUuid> ProjectWorkspace::layerIDs(const QMimeData &data) } 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 ©, 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; -} diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h index 8e485f3..34f104b 100644 --- a/src/Document/ProjectWorkspace.h +++ b/src/Document/ProjectWorkspace.h @@ -60,6 +60,11 @@ public: static std::optional<QUuid> layerID(const QString &text); void copyLayer(QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point = std::nullopt, 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: void changed(); @@ -70,7 +75,7 @@ private: std::optional<QString> failure; }; struct Copy { - QUuid id; + std::vector<QUuid> ids; std::shared_ptr<ProjectTab> from, target; CanvasDocument original; std::vector<ImageLayer> layers; diff --git a/src/Document/SelectionClipboard+Session.h b/src/Document/SelectionClipboard+Session.h index fc93eaa..502da77 100644 --- a/src/Document/SelectionClipboard+Session.h +++ b/src/Document/SelectionClipboard+Session.h @@ -2,11 +2,21 @@ public: // Ctrl+X: copy, then clear the selected pixels. 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(); + // 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); std::optional<QRectF> selectionCopyRegion() 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; bool canCopyMerged() const; std::optional<CopiedPixels> renderMergedPixels() const; @@ -22,3 +32,6 @@ public: private: void store(const CopiedPixels &copied); + + std::vector<QUuid> copiedLayerIDs() const; + std::optional<QUuid> insertCopy(QUuid id); diff --git a/src/Document/SelectionClipboard.cpp b/src/Document/SelectionClipboard.cpp index b93ece3..74723b3 100644 --- a/src/Document/SelectionClipboard.cpp +++ b/src/Document/SelectionClipboard.cpp @@ -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() { - const std::optional<ImageLayer> layer = activeLayer(); - if (!canCopyPixels() || !layer) + if (!canCopyPixels() && !canCopyLayer()) 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 { - const std::optional<CopiedPixels> copied = renderSelectedPixels(*layer, m_isMaskSelected); + const std::optional<CopiedPixels> copied = renderSelectedPixels(layer, m_isMaskSelected); if (!copied) { qCWarning(lcApp) << "nothing to copy"; return; } store(*copied); + if (canCopyLayer()) + m_copiedLayer = CopiedLayer{copiedLayerIDs(), m_pixelClipboard.value().data}; } catch (const ExportError &error) { 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. 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; data->setImageData(copied.image); QByteArray png; @@ -198,8 +226,9 @@ void EditorSession::store(const CopiedPixels &copied) data->setData(QStringLiteral("image/png"), png); else qCWarning(lcApp) << "the copied pixels could not be encoded as PNG:" << writer.errorString(); - clipboard->setMimeData(data); - m_pixelClipboard = PixelClipboard{copied.image, copied.region.topLeft(), m_clipboardChanges}; + QGuiApplication::clipboard()->setMimeData(data); + m_pixelClipboard = PixelClipboard{copied.image, copied.region.topLeft(), data}; + m_copiedLayer = std::nullopt; } void EditorSession::cutSelection(std::function<void()> done) @@ -217,7 +246,7 @@ bool EditorSession::canPaste() const { if (!m_document || !canEditLayers()) return false; - if (m_pixelClipboard && m_clipboardChanges == m_pixelClipboard->changeCount) + if (m_pixelClipboard && clipboardHolds(m_pixelClipboard->data)) return true; // An empty clipboard has no data object at all. const QMimeData *data = clipboardData(); @@ -229,7 +258,7 @@ void EditorSession::paste() { if (!canPaste()) 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")); return; } @@ -253,12 +282,15 @@ void EditorSession::layerViaCopy() { const std::optional<ImageLayer> layer = activeLayer(); const std::optional<DocumentSelection> current = selection(); - if (!canEditLayers() || !layer || layer->isGroup || (current && current->isEmpty())) + if (!canEditLayers() || !layer || (current && current->isEmpty())) return; + // Without a selection it duplicates, folders included. if (!current) { duplicateActiveLayer(); return; } + if (layer->isGroup) + return; try { const std::optional<CopiedPixels> copied = renderSelectedPixels(*layer, m_isMaskSelected); if (!copied) { @@ -314,13 +346,80 @@ QString EditorSession::nextLayerName() const 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() { - const int index = canEditLayers() ? indexOf(m_document->layers, m_activeLayerID) : -1; - if (index < 0) + duplicateLayers(copiedLayerIDs()); +} + +// 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; + 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. - const QUuid id = *m_activeLayerID; QSet<QUuid> included = descendantIDs(id); included.insert(id); std::vector<ImageLayer> copies; @@ -329,7 +428,7 @@ void EditorSession::duplicateActiveLayer() copies.push_back(layer); } if (m_document->layers.size() + copies.size() > 10'000) - return; + return std::nullopt; QHash<QUuid, QUuid> mapping; for (const ImageLayer © : copies) mapping.insert(copy.id, QUuid::createUuid()); @@ -344,14 +443,12 @@ void EditorSession::duplicateActiveLayer() renamed(copy.parentID); renamed(copy.maskSourceID); } - beginEdit(QStringLiteral("Duplicate Layer")); m_document->layers.insert(m_document->layers.begin() + index + 1, copies.begin(), copies.end()); for (auto original = mapping.cbegin(); original != mapping.cend(); ++original) { if (m_collapsedGroupIDs.contains(original.key())) m_collapsedGroupIDs.insert(original.value()); } - setActiveLayerID(mapping.value(id)); - endEdit(); + return mapping.value(id); } // Alt-drag in the Layers panel: a copy placed where dropped. diff --git a/src/Document/SelectionClipboard.h b/src/Document/SelectionClipboard.h index 4f4da4e..d2adfd7 100644 --- a/src/Document/SelectionClipboard.h +++ b/src/Document/SelectionClipboard.h @@ -1,14 +1,18 @@ #pragma once #include <QImage> +#include <QMimeData> +#include <QPointer> #include <QPointF> #include <QRectF> +#include <QUuid> +#include <vector> // Pixels copied from the canvas, with where they came from. struct PixelClipboard { QImage image; QPointF origin; - // The clipboard's changes when written; more means another copy since. - int changeCount; + // What we put there; another object means another copy. + QPointer<QMimeData> data; }; // Swift's (image, region) tuple: pixels and their canvas bounds. @@ -16,3 +20,11 @@ struct CopiedPixels { QImage image; 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; +}; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index 141fa15..dfe08f5 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -82,7 +82,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi m_field->copy(); else if (InlineTextEditor *text = typedText()) text->copy(); - else if (session().canCopyPixels()) + else if (session().canCopyPixels() || session().canCopyLayer()) session().copySelection(); else qCWarning(lcApp) << "nothing to copy"; @@ -93,6 +93,8 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi m_field->paste(); else if (InlineTextEditor *text = typedText()) text->paste(); + else if (m_workspace.pasteCopiedLayer()) + return; else if (session().canPaste()) session().paste(); else @@ -395,7 +397,7 @@ void CompositorMenus::synchronize() action(QStringLiteral("fillBackground"))->setEnabled(s.canEditPixels()); action(QStringLiteral("clearSelectionPixels"))->setEnabled(s.selection().has_value() && s.canEditPixels()); action(QStringLiteral("layerViaCopy"))->setText(s.selection() ? QStringLiteral("Layer via Copy") : QStringLiteral("Duplicate Layer")); - action(QStringLiteral("layerViaCopy"))->setEnabled(s.canCopyPixels() || (!s.selection() && s.canEditLayers() && active && !active->isGroup)); + action(QStringLiteral("layerViaCopy"))->setEnabled(s.canCopyPixels() || (!s.selection() && s.canEditLayers() && active)); action(QStringLiteral("mergeLayers"))->setText(s.mergeTitle()); action(QStringLiteral("mergeLayers"))->setEnabled(s.canMergeLayers()); action(QStringLiteral("flipHorizontal"))->setEnabled(s.canTransform()); diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 382fb04..0806e4c 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -320,8 +320,12 @@ void CompositorMenusTests::duplicateMergeAndFlipEntriesActOnTheLayers() QCOMPARE(int(bar.session().document().value().layers.size()), 2); QCOMPARE(bar.session().history.undoName(), QString("Merge Layers")); const QUuid merged = bar.session().activeLayerID().value(); + // A folder duplicates without a selection, copies nothing with one. bar.session().addGroup(); + QVERIFY(bar.action("layerViaCopy").isEnabled()); + bar.session().selectAll(); QVERIFY(!bar.action("layerViaCopy").isEnabled()); + bar.session().deselect(); bar.session().selectLayer(merged); QVERIFY(bar.action("layerViaCopy").isEnabled()); bar.session().setIsProjectBusy(true); diff --git a/tests/LayerClipboardTests.cpp b/tests/LayerClipboardTests.cpp new file mode 100644 index 0000000..61bcae7 --- /dev/null +++ b/tests/LayerClipboardTests.cpp @@ -0,0 +1,364 @@ +#include "Document/ProjectWorkspace.h" +#include "MenuFixtures.h" +#include "SelectionFixtures.h" +#include "SessionFixtures.h" +#include <QClipboard> +#include <QMimeData> +#include <QtTest> + +// Swift 1.2.5: whole layers copied, pasted and duplicated. +namespace { +ImportedImage square(const QString &name, int side = 2) +{ + QImage image(side, side, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + return ImportedImage(image, QImage(), name); +} + +// Three red layers, A under B under C. +QUuid fill(EditorSession &session, std::vector<QUuid> &ids) +{ + session.createDocument(20, 20); + for (const char *name : {"A", "B", "C"}) { + session.insert(square(QString::fromLatin1(name)), QPointF(4 + 6 * ids.size(), 5)); + ids.push_back(session.activeLayerID().value()); + } + return ids.front(); +} + +// A clipboard history's replay: every format, written anew. +QMimeData *saved() +{ + auto *copy = new QMimeData; + const QMimeData *data = QGuiApplication::clipboard()->mimeData(); + for (const QString &format : data->formats()) + copy->setData(format, data->data(format)); + // Offscreen reads no image from PNG bytes; a desktop does. + copy->setImageData(data->imageData()); + return copy; +} + +// Bottom to top, as the document lists them. +QString names(const EditorSession &session) +{ + QStringList result; + for (const ImageLayer &layer : session.document().value().layers) + result << layer.name; + return result.join(QLatin1Char(',')); +} +} + +class LayerClipboardTests : public QObject { + Q_OBJECT +private slots: + void copyWithoutASelectionTakesTheLayersWhole(); + void pasteDuplicatesInTheSameProject(); + void anotherCopyOrAGoneLayerEndsTheLayerPaste(); + void pasteBringsTheLayersIntoAnotherProject(); + void severalDuplicatesStackAboveTheTopmost(); + void foldersLeaveTheAnchorToThePixels(); + void theEditMenuCopiesAndPastesAFolder(); + void clipboardNoticesKeepTheCopy(); + void aRestoredOldCopyStaysExpired(); + void theLatestCopyAnywhereWins(); + void aLayerTheSourceTabLacksIsNotSelected(); +}; + +void LayerClipboardTests::copyWithoutASelectionTakesTheLayersWhole() +{ + EditorSession session; + std::vector<QUuid> ids; + fill(session, ids); + session.selectLayers({}, std::nullopt); + QVERIFY(!session.canCopyLayer()); + session.selectLayers({ids[2], ids[0]}, ids[2]); + QVERIFY(session.canCopyLayer()); + session.copySelection(); + // Pixels too: a layer's copy also serves other apps. + QCOMPARE(session.copiedLayer().value().ids, (std::vector<QUuid>{ids[0], ids[2]})); + QVERIFY(session.pixelClipboard() && QGuiApplication::clipboard()->mimeData()->hasImage()); + // A folder has no pixels: its id alone goes out. + session.selectLayers({ids[1]}, ids[1]); + session.groupSelectedLayers(); + const QUuid folder = session.activeLayerID().value(); + session.selectLayers({folder, ids[1]}, folder); + session.copySelection(); + QCOMPARE(session.copiedLayer().value().ids, std::vector<QUuid>{folder}); + QVERIFY(!session.pixelClipboard()); + QCOMPARE(QGuiApplication::clipboard()->mimeData()->data("com.compositor.copied-layer"), uuidString(folder).toUtf8()); + // A selection or a chosen mask copies pixels, not layers. + session.selectLayer(ids[0]); + session.selectAll(); + QVERIFY(!session.canCopyLayer() && session.canCopyPixels()); + session.copySelection(); + QVERIFY(!session.copiedLayer() && session.pixelClipboard()); + session.deselect(); + session.addMask(true); + QVERIFY(session.isMaskSelected() && !session.canCopyLayer()); + session.selectLayerTarget(ids[0], false); + QVERIFY(session.canCopyLayer()); + session.setIsProjectBusy(true); + QVERIFY(!session.canCopyLayer()); +} + +void LayerClipboardTests::pasteDuplicatesInTheSameProject() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + std::vector<QUuid> ids; + fill(session, ids); + session.selectLayers({ids[0], ids[1]}, ids[0]); + session.copySelection(); + const int steps = session.history.undoCount(); + QVERIFY(workspace.pasteCopiedLayer()); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Paste")); + // Stacked above the topmost original, in their order. + QCOMPARE(names(session), QString("A,B,A copy,B copy,C")); + const std::vector<ImageLayer> layers = session.document().value().layers; + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{layers[2].id, layers[3].id})); + QCOMPARE(session.activeLayerID().value(), layers[2].id); + // With C active, the first copied layer's copy leads. + session.undo(); + session.selectLayer(ids[2]); + QVERIFY(workspace.pasteCopiedLayer()); + QCOMPARE(session.activeLayer().value().name, QString("A copy")); + session.undo(); + QVERIFY(workspace.pasteCopiedLayer()); + // A busy project refuses; the paste goes on to pixels. + session.setIsProjectBusy(true); + QVERIFY(!workspace.pasteCopiedLayer()); + session.setIsProjectBusy(false); + QVERIFY(workspace.pasteCopiedLayer()); + QCOMPARE(session.document().value().layers.size(), size_t(7)); +} + +void LayerClipboardTests::anotherCopyOrAGoneLayerEndsTheLayerPaste() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + std::vector<QUuid> ids; + fill(session, ids); + session.selectLayers({ids[0], ids[1]}, ids[0]); + session.copySelection(); + // A layer gone since stays out; all gone, nothing pastes. + session.selectLayer(ids[0]); + session.deleteLayerOrMask(); + QVERIFY(workspace.pasteCopiedLayer()); + QCOMPARE(names(session), QString("B,B copy,C")); + session.selectLayer(ids[1]); + session.deleteLayerOrMask(); + QVERIFY(!workspace.pasteCopiedLayer()); + // Another app's copy ends it too. + session.selectLayer(ids[2]); + session.copySelection(); + QVERIFY(session.copiedLayer()); + QGuiApplication::clipboard()->setText(QStringLiteral("elsewhere")); + QVERIFY(!workspace.pasteCopiedLayer()); + QCOMPARE(session.document().value().layers.size(), size_t(2)); +} + +void LayerClipboardTests::pasteBringsTheLayersIntoAnotherProject() +{ + ProjectWorkspace workspace; + EditorSession &from = workspace.current().session; + std::vector<QUuid> ids; + fill(from, ids); + from.selectLayer(ids[2]); + LayerEffects effects; + effects.stroke = StrokeEffect(); + from.setEffects(effects); + from.selectLayers({ids[0], ids[2]}, ids[2]); + from.copySelection(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(40, 30); + QCOMPARE(workspace.selectedID(), target.id); + QVERIFY(workspace.pasteCopiedLayer()); + QTRY_COMPARE(target.session.document().value().layers.size(), size_t(2)); + QCOMPARE(target.session.history.undoName(), QString("Copy Layers from Project")); + const std::vector<ImageLayer> layers = target.session.document().value().layers; + QCOMPARE(names(target.session), QString("A,C")); + // A and C keep their gap, centred on the canvas. + QCOMPARE(layers[0].transform.origin, QPointF(13, 14)); + QCOMPARE(layers[1].transform.origin, QPointF(25, 14)); + QVERIFY(layers[1].effects == effects); + QCOMPARE(target.session.activeLayerID().value(), layers[0].id); + QCOMPARE(target.session.selectedLayerIDs(), (QSet<QUuid>{layers[0].id, layers[1].id})); + QCOMPARE(from.document().value().layers.size(), size_t(3)); +} + +void LayerClipboardTests::foldersLeaveTheAnchorToThePixels() +{ + ProjectWorkspace workspace; + EditorSession &from = workspace.current().session; + from.createDocument(20, 20); + from.insert(square("A"), QPointF(4, 5)); + const QUuid a = from.activeLayerID().value(); + from.addGroup(); + const QUuid first = from.activeLayerID().value(); + from.addGroup(); + const QUuid second = from.activeLayerID().value(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(40, 30); + const auto copy = [&](const std::vector<QUuid> &ids) { + bool done = false; + workspace.copyLayers(ids, target.id, std::nullopt, [&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 10'000); + }; + // A folder spans the canvas; only A's box counts. + QVERIFY(copy({a, first})); + QCOMPARE(target.session.document().value().layers.front().transform.origin, QPointF(19, 14)); + // Folders alone: the first one's centre. + target.session.undo(); + QVERIFY(copy({first, second})); + QCOMPARE(target.session.document().value().layers.front().transform.origin, QPointF(10, 5)); +} + +void LayerClipboardTests::severalDuplicatesStackAboveTheTopmost() +{ + EditorSession session; + std::vector<QUuid> ids; + fill(session, ids); + // One copy sits just above its original. + session.selectLayer(ids[0]); + session.duplicateActiveLayer(); + QCOMPARE(names(session), QString("A,A copy,B,C")); + session.undo(); + // C goes into a folder; A's copy joins C's there. + session.selectLayer(ids[2]); + session.groupSelectedLayers(); + const QUuid folder = session.activeLayerID().value(); + // C is active, not first: its copy leads. + session.selectLayers({ids[0], ids[2]}, ids[2]); + const int steps = session.history.undoCount(); + session.duplicateActiveLayer(); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Duplicate Layer")); + QCOMPARE(names(session), QString("A,B,Folder 1,C,A copy,C copy")); + const std::vector<ImageLayer> layers = session.document().value().layers; + QVERIFY(layers[4].parentID == folder && layers[5].parentID == folder); + QCOMPARE(session.activeLayerID().value(), layers[5].id); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{layers[4].id, layers[5].id})); + session.undo(); + QCOMPARE(names(session), QString("A,B,Folder 1,C")); +} + +void LayerClipboardTests::theEditMenuCopiesAndPastesAFolder() +{ + Bar bar; + bar.session().createDocument(20, 20); + bar.session().insert(square("A")); + bar.session().groupSelectedLayers(); + bar.action("copy").trigger(); + bar.action("paste").trigger(); + // The folder's copy holds a copy of A. + QCOMPARE(names(bar.session()), QString("Folder 1,Folder 1 copy,A,A")); + const std::vector<ImageLayer> layers = bar.session().document().value().layers; + QVERIFY(layers[2].parentID == layers[1].id && layers[3].parentID == layers[0].id); + QCOMPARE(bar.session().history.undoName(), QString("Paste")); +} + +void LayerClipboardTests::clipboardNoticesKeepTheCopy() +{ + // Wayland announces one copy twice; neither ends it. + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + std::vector<QUuid> ids; + fill(session, ids); + session.selectLayer(ids[0]); + session.copySelection(); + emit QGuiApplication::clipboard()->dataChanged(); + emit QGuiApplication::clipboard()->dataChanged(); + QVERIFY(workspace.pasteCopiedLayer()); + QCOMPARE(names(session), QString("A,A copy,B,C")); + // Pixels copied under a selection go back in place too. + session.selectLayer(ids[1]); + session.applySelection(rectPath(QRectF(9, 4, 2, 2)), SelectionMode::replace, QStringLiteral("Select")); + session.copySelection(); + emit QGuiApplication::clipboard()->dataChanged(); + QVERIFY(!workspace.pasteCopiedLayer() && session.canPaste()); + session.paste(); + QCOMPARE(session.activeLayer().value().transform.origin, QPointF(9, 4)); +} + +void LayerClipboardTests::aRestoredOldCopyStaysExpired() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + std::vector<QUuid> ids; + fill(session, ids); + // Another app sets the same entry again: theirs now. + session.selectLayer(ids[0]); + session.copySelection(); + QGuiApplication::clipboard()->setMimeData(saved()); + QVERIFY(!workspace.pasteCopiedLayer()); + // A layer copy, replaced by text, then restored from history. + session.copySelection(); + QMimeData *old = saved(); + QGuiApplication::clipboard()->setText(QStringLiteral("elsewhere")); + QGuiApplication::clipboard()->setMimeData(old); + QVERIFY(!EditorSession::clipboardHolds(session.copiedLayer().value().data)); + QVERIFY(!workspace.pasteCopiedLayer()); + // Its pixels come back as another app's: centred, canvas-sized. + session.paste(); + QCOMPARE(names(session), QString("A,Layer 1,B,C")); + QCOMPARE(session.activeLayer().value().size(), QSizeF(20, 20)); + // Selected pixels replayed so land centred, not in place. + session.selectLayer(ids[1]); + session.applySelection(rectPath(QRectF(9, 4, 2, 2)), SelectionMode::replace, QStringLiteral("Select")); + session.copySelection(); + old = saved(); + QGuiApplication::clipboard()->setText(QStringLiteral("elsewhere")); + QGuiApplication::clipboard()->setMimeData(old); + session.paste(); + QCOMPARE(session.activeLayer().value().transform.origin, QPointF(9, 9)); +} + +void LayerClipboardTests::theLatestCopyAnywhereWins() +{ + ProjectWorkspace workspace; + EditorSession &first = workspace.current().session; + first.createDocument(20, 20); + first.addGroup(); + first.copySelection(); + EditorSession &second = workspace.addTab().session; + second.createDocument(20, 20); + second.addGroup(); + second.addGroup(); + second.copySelection(); + // The second copy ends the first's at once. + QVERIFY(!EditorSession::clipboardHolds(first.copiedLayer().value().data)); + QVERIFY(EditorSession::clipboardHolds(second.copiedLayer().value().data)); + // A live object elsewhere is no clipboard of ours. + QMimeData elsewhere; + QVERIFY(!EditorSession::clipboardHolds(QPointer<QMimeData>(&elsewhere))); + // The second tab's folder is the one pasted. + QVERIFY(workspace.pasteCopiedLayer()); + QCOMPARE(names(second), QString("Folder 1,Folder 2,Folder 2 copy")); + QCOMPARE(names(first), QString("Folder 1")); +} + +void LayerClipboardTests::aLayerTheSourceTabLacksIsNotSelected() +{ + // Two tabs of one project share ids; the first wins. + ProjectWorkspace workspace; + EditorSession &first = workspace.current().session; + first.createDocument(20, 20); + first.insert(square("Shared")); + const QUuid shared = first.activeLayerID().value(); + ProjectTab &second = workspace.addTab(); + second.session.installProject(first.projectSnapshot().value(), QString("twin.comp")); + second.session.insert(square("Own")); + second.session.selectLayers({shared, second.session.activeLayerID().value()}, shared); + second.session.copySelection(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(20, 20); + QVERIFY(workspace.pasteCopiedLayer()); + QTRY_COMPARE(names(target.session), QString("Shared")); + const QUuid copy = target.session.document().value().layers.front().id; + QCOMPARE(target.session.selectedLayerIDs(), QSet<QUuid>{copy}); + QCOMPARE(target.session.activeLayerID().value(), copy); +} + +QTEST_MAIN(LayerClipboardTests) +#include "LayerClipboardTests.moc" diff --git a/tests/ProjectCopyTests.cpp b/tests/ProjectCopyTests.cpp index 06279fe..e122b20 100644 --- a/tests/ProjectCopyTests.cpp +++ b/tests/ProjectCopyTests.cpp @@ -49,7 +49,7 @@ private slots: void aFolderTravelsWithItsContentsAndItsLinks(); void aMaskFromOutsideTheCopyIsBakedIn(); void anAdjustmentLetsGoOfItsBaseUnbaked(); - void effectsStayBehindAsSwiftsListLeavesThem(); + void effectsTravelWithTheCopy(); void theCopyLandsAtThePointOrTheCentre(); void aNewTabGetsTheSourcesCanvasInOneStep(); void bothProjectsAreBusyWhileTheCopyBakes(); @@ -229,7 +229,7 @@ void ProjectCopyTests::anAdjustmentLetsGoOfItsBaseUnbaked() QVERIFY(!copy.maskSourceID && !copy.asset && copy.adjustment == LayerAdjustment{AdjustmentKind::curves}); } -void ProjectCopyTests::effectsStayBehindAsSwiftsListLeavesThem() +void ProjectCopyTests::effectsTravelWithTheCopy() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); @@ -243,7 +243,7 @@ void ProjectCopyTests::effectsStayBehindAsSwiftsListLeavesThem() target.session.createDocument(2, 2); QVERIFY(copied(workspace, framed, target.id)); const ImageLayer copy = target.session.document().value().layers.front(); - QVERIFY(copy.name == QString("Framed") && !copy.effects); + QVERIFY(copy.name == QString("Framed") && copy.effects == effects); QVERIFY(layerWith(from.session, framed).effects == effects); } diff --git a/tests/SelectionClipboardTests.cpp b/tests/SelectionClipboardTests.cpp index 33496b0..338cffe 100644 --- a/tests/SelectionClipboardTests.cpp +++ b/tests/SelectionClipboardTests.cpp @@ -127,15 +127,31 @@ void SelectionClipboardTests::layerViaCopyCopiesTheSelectionOrDuplicatesTheLayer QCOMPARE(session->history.undoName(), QString("Duplicate Layer")); QCOMPARE(session->activeLayer().value().name, source.name + " copy"); QVERIFY(session->activeLayer().value().asset.value().identity() == source.asset.value().identity()); - // An empty selection and a folder make no layer. - const int count = session->history.undoCount(); + // A folder duplicates; a folder under a selection copies nothing. session->addGroup(); + const QUuid folder = session->activeLayerID().value(); session->layerViaCopy(); - session->selectLayer(source.id); + QCOMPARE(session->history.undoName(), QString("Duplicate Layer")); + QCOMPARE(session->activeLayer().value().name, QString("Folder 1 copy")); + session->selectLayer(folder); select(*session, QRectF(0, 0, 10, 10)); + const int count = session->history.undoCount(); + session->layerViaCopy(); + QCOMPARE(session->history.undoCount(), count); + // Its chosen mask has pixels, yet Swift copies none. + session->deselect(); + session->addMask(true); + select(*session, QRectF(0, 0, 10, 10)); + QVERIFY(session->isMaskSelected() && session->canCopyPixels()); + const int masked = session->history.undoCount(); + session->layerViaCopy(); + QCOMPARE(session->history.undoCount(), masked); + const int later = masked - count; + // An empty selection copies nothing either. + session->selectLayer(source.id); session->applySelection(rectPath(QRectF(0, 0, 100, 40)), SelectionMode::subtract, "Subtract"); session->layerViaCopy(); - QCOMPARE(session->history.undoCount(), count + 3); + QCOMPARE(session->history.undoCount(), count + later + 1); } void SelectionClipboardTests::lassoShapedSelectionCopiesAndPastes() diff --git a/tests/Tests.cmake b/tests/Tests.cmake index cb8a54e..bddb1d1 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -250,6 +250,7 @@ compositor_test(LassoControlsTests) compositor_test(SelectionAmountSheetTests) compositor_test(MagicWandTests) compositor_test(SelectionClipboardTests) +compositor_test(LayerClipboardTests) compositor_test(SmartEditTests) compositor_test(FilterSheetTests) compositor_test(FilterControlsTests) diff --git a/tests/TransformTests.cpp b/tests/TransformTests.cpp index 14e440f..1d95a13 100644 --- a/tests/TransformTests.cpp +++ b/tests/TransformTests.cpp @@ -389,10 +389,11 @@ void TransformTests::duplicateTransformCopiesLayersAndFoldersWithTheirContents() QCOMPARE(int(session.document().value().layers.size()), 6); QCOMPARE(session.selectedLayerIDs().size(), 3); QVERIFY(!session.selectedLayerIDs().contains(first) && !session.selectedLayerIDs().contains(folder)); - // The topmost copy leads; copies sit above their sources. - QCOMPARE(session.document().value().layers[1].name, QString("Painted layer copy")); + // The copies stack above the topmost source; the primary's leads. + QCOMPARE(session.document().value().layers[2].id, folder); + QCOMPARE(session.document().value().layers[3].name, QString("Painted layer copy")); QCOMPARE(session.document().value().layers[5].name, QString("Folder 1 copy")); - QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[5].id)); + QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[3].id)); session.cancelTransform(); QCOMPARE(int(session.document().value().layers.size()), 3); QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second, folder}));