diff --git a/AGENTS.md b/AGENTS.md index 41f87ce..c0341cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ Platform adaptations, each forced by a missing Qt analog: - 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. +- The session's brush (8.2): `EditorSession+Brush.cpp` twins Swift's extension as far as the tools exist: `canPaint` with the terms that exist (the adjustment term since 9.5a), `makeRasterEdit` (the budgets, the selection clip; `pixelLimit` is `qint64`, Swift's `Int`, and may run below zero when other layers use more than the budget), `beginBrush` for the Brush (Spot Healing joins with 8.4, the other brush tools with their items; erase and the mask's black or white as Swift sets them), `continueBrush`, `shiftLineStart`, `cancelBrush`, `finishBrushImmediately` (busy, it waits; otherwise it flushes and commits the snapshot), `finishBrush` (Swift's `async` wrapper is a plain call here), `commitPaintSnapshot` (the tiles land at once with their undo entry; a mask that covered the old grid is regrown through `RasterSnapshot::replacing`), `commitRasterEdit` (a completion callback, the render through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ExportError` alone, the UI side keeping Swift's guards and the mask's `isEnabled` as it stands at commit; its continuation runs where the commit ends, not from the event loop, because Swift's caller resumes from `await` in the same job: `commitGradient` cancels the gradient and runs its caller's next step (a filter, Levels, Hue/Saturation or Invert begun on the same layer), a fill or clear hands on to its caller (`beginSelectionTransform` installs the floating edit), and the pixel move clears itself, all before any event lands; only early paths (nothing to commit, a busy project) post, as Swift's `Task` hops (since 13.19g: a press could arrive with the project free and the finished gradient still pending, which refused it; `CommitContinuationTests`)), `usesOpacityKeys`, `typeOpacityDigit` (Swift's uptime is `std::chrono::steady_clock` unless the caller passes a time, as the tests do), `changeBrushHardness`, `changeBrushSize`. `brushStroke` is a `std::unique_ptr` read through a pointer, since a stroke's const members forbid copying; `brushSettings`, `brushMode` (`BrushToolMode` in `Document/SmudgeLiquify.h`, whose smear and liquify arrive with 8.6) and `maskPaintWhite` are getters and setters that notify; `lastBrushPoint` and `pendingOpacityDigit` are private structs. `brushRevision` exists after all, Swift's counter, because the canvas's `DisplayState` must change with every step of a stroke, whose patches it does not hold; a step that changes nothing else repaints the stroke's `dirtyDocumentRect` alone, in view points two points out (`strokeDirtyRect`), as Swift's `setNeedsDisplay` does, and any other change repaints the view. While a folder's mask is painted, the clip for the layers inside comes from the edit's tiles (`liveFolderMaskClip`, Swift's `liveMaskEdit`): `drawBrushPreview` draws the old mask and the patches, nearest, into gray coverage the size of the device, hidden outside the mask's grid, and that multiplies into the folder coverage; the old mask, or a painted mask's base, is halved near the size drawn first, as Swift's `displayImage` does, so a fine mask keeps its shade while painted. The session's private structs (`Decoded`, `Wanded`, `RasterMade` and the rest) live in `EditorSession+Jobs.h` as `SessionJobs` and are aliased in the class, so the header stays under 500 lines. The gates (`canInvert` and `canAdjustColors` among them), `selectLayer`, `selectLayers`, `selectTool` and `selectLayerTarget` refuse while a stroke lasts, `canStartProjectOperation` too, and `importImages` finishes a stroke first, as Swift's do; the tips' parking by tool family arrived with 8.5. `SelectionEdits.cpp` gains `canEditPixels`, `fillSelection` (the palette's colours are black and white until 8.10's `ColorPalette`; on a mask the background is the opposite of `maskPaintWhite`, as Swift's `paletteColor`), `clearSelectedPixels`, `deleteKeyPressed` (its effects term since 9.6a) and `applyPixelEdit`; `cutSelection` sits in `SelectionClipboard.cpp`. `BrushStroke` gains `erasing` in its settings (`BrushRaster::fill` takes `DestinationOut` for it, Swift's blend mode on the context; a mask never erases, so that mode on a gray context is a `logic_error`), `paintCanvas` (the base redrawn, the body under the inverse mapping and a hard canvas clip, then `PixelAdjust::blend` through the selection where Swift clips), `fill` and `clearPixels`; `placedMaskPreview` is declared in `BrushStroke.h` and defined in `LayerMask.cpp`, as Swift's extension, and a preview that cannot be made logs on `omaphoto.rendering` where Swift's `try?` is silent. On the canvas a pixel stroke draws its whole grid placed by `paintTransform` through `TiledLayerRenderer::drawStroke`, a mask stroke through `drawMaskStroke` under the layer's transform, and a mask on its own placement draws the layer through `placedMaskPreview`; the mask's clip takes the stroke's owner as Swift's `base` does. Delete and Backspace without Ctrl, Alt or Meta are `deleteKeyPressed` on the canvas and, 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. - Moving and floating selected pixels (8.2b): `PixelMove` is a class in `Document/SelectionEdits.h`, Swift's `final class`. Its raster is a `std::shared_ptr`, because the canvas draws it while the commit runs, so `commitRasterEdit` takes a `std::shared_ptr` (the brush hands its stroke over); its `offset` starts at zero, since a default `QSizeF` is −1 by −1. `beginPixelMove`, `movePixels`, `finishPixelMove` (a completion callback; the move stays set until the commit lands, so pixels and outline never show at the old place), `cancelPixelMove` and `nudgePixels` (Swift's beep is a warning on `omaphoto.app`) live in `SelectionEdits.cpp`; `displayedSelection` follows a pixel move and a floating edit. `BrushStroke::liftSelection` draws the source into the region, from its tiles when painted, and multiplies the selection's coverage in (`PixelAdjust::coverage` under `DestinationIn`) where Swift clips; `moveLifted` rebuilds each tile from its base, cuts the hole through the same coverage under `DestinationOut` (not for a copy) and places the lifted pixels on whole pixels: a landing between pixels (a scaled or turned layer) is resampled first by its fraction, bilinear and rows in parallel (`BrushRaster::shiftedByFraction`, shared with Clone Stamp), because QPainter snaps an unscaled image to whole pixels (probed on 6.4), where Swift draws it with `.high`. `FloatingSelection.cpp` twins Swift's file: `FloatingTransform` rides in `TransformEdit.floating` (last) as a `std::shared_ptr`, because the edit is copied freely and holds the whole document from before; `canTransformSelection`, `transformCommand`, `beginSelectionTransform` (a completion callback: the outer `Transform Selection` edit opens, `clearSelectedPixels` runs, and its callback inserts the `Floating Selection` layer), `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, and `FloatingMerge::merge`, a namespace with a `Merged` struct for Swift's tuple. The merge writes pixels, transform and mask into the source layer in place, where Swift rebuilds the layer from listed fields: the fields that list leaves out do not exist yet (`TASKS.md`, "Session hooks owed"). `DistortWarp::mapPath` came forward from 6.2's list: it walks the path's elements (Qt stores a quad as a cubic) and keeps the fill rule. `beginPixelMove` and `canTransformSelection` leave out Swift's `pixelMove == nil`, `transformEdit == nil` and `!selection.isEmpty`, which `canPaint` reads already (through `canEditPixels`). `displayedMaskPlacement`'s `floating == nil` term is dead and left out: the floating layer is made without a mask, and the open edit blocks adding one; `beginDistort` edits the edit in place, so `floating` carries over. The session's private structs sit in `SessionJobs`, now a private base struct of `EditorSession` (the aliases went; `MergePlan` and `TransformDuplicate` joined it), so the header stays under 500 lines. On the canvas a Ctrl-press inside the selection with a selection tool lifts and moves its pixels, Ctrl-Alt copies them, under `movePixelsCursor` (the arrow with scissors drawn as paths and haloed, where Swift badges the SF symbol) or the duplicate cursor, kept under Space as every drag keeps its own; `updateCursor` picks the outline and pixel drags' cursors ahead of Space, so `lassoCursor`, its only caller's helper, checks neither (Swift's has other callers); Ctrl-arrows nudge the selected pixels in any tool, Shift ten, never with Alt or Meta (macOS's Option and Control), ahead of the Move tool's arrows; losing focus cancels a pixel drag; `drawOwn` draws the move's raster as a stroke, and a step repaints the whole view, as Swift's (only a brush stroke repaints its rect). The Layer menu's Ctrl+T is `transformCommand`, titled `Transform Selection` while `canTransformSelection` holds and enabled by either gate. `canEditLayers`, `canInvert`, `canAdjustColors` and `ProjectWorkspace::canSwitch` read `pixelMove`. - The brush on the canvas (8.3): `EditorCanvas+Brush.cpp` holds Swift's gestures. A press begins a stroke, Shift drawing on from `shiftLineStart` through the press point; a drag continues it, and Shift holds an axis that the first three pixels from its anchor settle (the press under Shift, else the last pixel reached), the pixel waiting at the anchor until then. Swift clears the axis when Shift is let go and again when a new anchor is set; the first is dead and left out. The release reaches its own point, then `finishBrushImmediately` commits. A busy project holds the stroke: a drag, the release, Escape and a lost focus leave it. A stroke goes on only under a drag with the left button, as Swift's `mouseDragged` (a hover after a busy release paints nothing), and wheels and pinches neither pan nor zoom while it lasts, as Swift's `scrollWheel` and `magnify` refuse. A right-drag with a brush tool, no stroke and no Space, sizes the tip from the press (two view points a pixel, rounded, 1 to 2000), or with Shift sets hardness over 200 points, each putting the other back; the circle stays at the press. `BrushCursorOverlay` is no widget, like `TransformOverlay`: the canvas draws the circle last, white 2.5 under black 1, and while hardness is dragged a dashed inner ring (four points on, three off, given in pen widths as Qt counts dashes); `update` returns the old and new rings three points out, as Swift's `setNeedsDisplay`. Swift's `hardness > 0` is dead (a zero ellipse strokes nothing) and left out. The circle follows the pointer, hover included but not a pan (Swift's `mouseDragged` pans first), hides under Space, on leaving and on a lost focus, and shows with the brush tools alone. The canvas's `event` takes Tab before the focus chain (Shift-Tab stays the chain's) for `cycleToolMode`, which switches the Marquee's and the Lasso's shapes, the Brush's mode and Spot Healing's type (the other tools' modes arrive with them); B and E choose the Brush and its mode; a digit reaches `typeOpacityDigit` only where `usesOpacityKeys` holds, else it goes on to the parent, as Swift's `super.keyDown`; brackets step size and hardness, and the application filter takes them from any control but a text field unless the canvas has focus, so a focused canvas's `keyPressEvent` re-reads the modifiers as Swift's `keyDown` does. The layer list takes Tab and Shift-Tab (Swift's table cycles on both), B, E and digits alike. `BrushControls` is every brush tool's `ToolHeaderBar`: Paint and Erase in an exclusive group, size (1–2000), hardness and opacity as a slider in thousandths beside a whole-percent field (opacity from 1%), the sliders following the session under `QSignalBlocker` so nothing rounded is written back, the fields stepping with Up and Down from the session's exact value as Swift's `arrowSteps` does (a slider's 33.3% steps to 34.3%; `SelectionAmountField` takes Swift's `value` and `change` closures, and the Lasso and Wand fields round in `change` as theirs do, an equivalent mutant since their values are whole), and the mask's black-or-white picker and note while a mask is selected; the colour swatch arrived with 8.10a. `ContentView+Status.cpp` holds `percent`, `iconKind` and `hint`, the eraser's words and icon among them; `iconKind` checks its tool although every consumer ignores a kind that is not its own (an equivalent mutant). `updateCursor` and the modifier helpers moved to `EditorCanvas+Cursors.cpp`. `ContentView`'s destructor stops listening first: `~QWidget` closes a shown window after the members are gone, and the canvas's focus-out then notifies (`cancelBrush` bumps `brushRevision`, as Swift's does). - Brush speed (8.3's gate: 4K at a scale of 2, an 800 px soft brush, median frame at most 8 ms): 6.8–7.4 ms measured, from 47. The soft tip's density comes from a table of 8192 steps over d²/r² from h² to the rim, in radii because hardness alone shapes it, linear between steps; inside h the tip is solid, the table's first value, so a near-hard tip keeps every step across its fade (from 0.9995 on, a table over the whole radius missed the falloff by up to 46 levels). Once the table's steps are finer than float can tell apart ((1 − h²) / 8192 under float's epsilon, from about 0.9995), even that table misses by a few levels: there `tipDensity` evaluates the shader's own formula, float for float, for dabs and segments alike, its solid and empty ends returning at once what the formula yields, so only samples in the thin fade pay for `exp` and `log`. `BrushCoverageTests` holds dabs and strokes on both sides of the switch against the shader's own quadrature, replayed in float. The kernel keeps the shader's order of float operations to the letter (`midpoint ± halfLength · node − projection`, never regrouped): 8192 pixels along a near-hard stroke a regrouping moved a pixel three levels, and a reference written from the twin's own code repeated it. Its one read per lookup is checked at the upper step, so a lost bound aborts. Only a segment passing through the solid core can look up inside it, so the lower clamp runs for those alone (`quadrature`): for every segment it cost 0.3 ms a frame. Bands of 32 rows are the tasks. The halving's bands take their rows, sums and source row from the caller's scratch, so a worker allocates nothing and a failed allocation throws where the caller catches it (QtConcurrent wraps a worker's exception in `QUnhandledException`). `publish` copies each tile and makes its selection coverage on the calling thread, so a failure throws there and a worker's malloc arena never meets a starved allocator, then fills side by side. Pieces compose and halve side by side (`sideBySide`), each halving unthreaded: Qt 6.4 deadlocks on a `blockingMap` inside a map task. The Lanczos colour rows sum four channels in a GCC vector (`vector_size(16)`), each lane as the scalar sum, and the vertical pass runs tap by tap over whole rows; `BrushCoverage.cpp` and `DownsampleCache.cpp` build at `-O3`, where GCC vectorises them. The shadow is skipped when the dirty rect lies inside the document, whose opaque fill covers it. Equivalent mutants: that skip, `fill`'s skip of a zero weight, the colour row's right clamp (it only ever reads padding), the table's interpolation (under a third of a level), its lower clamp at step 1 (the first steps of every fade sit at the density's cap), the clamped quadrature for every segment, the formula for every hardness and `tipDensity`'s two early returns (each only costs time). `BrushPerformanceTests` runs under `BRUSH_BENCHMARK=1`. diff --git a/TASKS.md b/TASKS.md index 29cd48a..d37a702 100644 --- a/TASKS.md +++ b/TASKS.md @@ -467,7 +467,7 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.13 and 13.16 (`70318de`): a typed Move-bar value then a real canvas press (the field finishes from the event loop on `MouseFocusReason`; `MoveBarPressTests`), the folder case, Alt-centre and long-drag resize snapping and the edge tie (`ResizeSnapTests`, `ResizeSnapCanvasTests`, fixture `MovePressFixtures.h`), the dead one-pixel Liquify "fix" removed, a Liquify dab's scratch allocation caught (skips the dab with a warning), the Radius field's `inf`, `WarpStroke::committedPoints` with its test, `softened`'s mask flag removed, and AGENTS and TASKS lines. - Known failures: at 81894f7 the full suite under `ctest -j16` failed `ColorRangeTests`, `GuideGridTests`, `GridSettingsSheetTests`, `TypeFontPreviewTests` and `GuideCanvasTests`, each of which passes alone: some state is shared between test processes (suspect `QSettings` or `ToolDefaults` keys). Find and isolate it; never loosen a check. - Next, in order: build; run the commits above and fix them; run the full suite and fix the parallel failures; review 3 over every fix since 81894f7 (13.8 included); then this item: acceptance run, distro checks (arch, fedora, fedora44, nixos, resolute), the `v1.4.5` tag and release, live installs, and the acceptance checklist. The 1.4.5 version, change log, README and acceptance table (results pending) are already in. -- Resumed 2026-10-03 after the reinstall. The user's rule of that day replaces the ten-review allowance: every step below is reviewed by codex (GPT-6.1-Sol, max) in a herdr pane before the next, at most three rounds a step. Steps, in order: +- Resumed 2026-10-03 after the reinstall. The user's rule of that day replaces the ten-review allowance: every step below is reviewed by codex (GPT-6.1-Sol, max effort until 13.19g, high from then at the user's word) in a herdr pane before the next, at most three rounds a step. Steps, in order: - [x] 13.19a The spec checkout is `Compositor-Ref` inside the repository (ignored by git, left out of the flake's source), at `v1.4.5`; AGENTS.md and TASKS.md say so. - 13.19a review: round 1 FAIL (the flake copied the spec; no step for review 3's scope; the budget unstated; the status said done), fixed; the Docker context finding answered (no Dockerfile copies from it). Round 2 PASS. - [x] 13.19b Build main in a fresh `omaphoto-dev` image. @@ -488,7 +488,10 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.19f work: the three commits' tests pass. Mutants on their code: the tab strip's release watch (its button test, its start, its ending, its other-tab guard) and the glow's whole steps. The other-tab guard (Swift's `guard reorder?.id == id`) survived; `anotherTabPressedBeforeTheWatchMovesNothing` now catches it. The glow's rounded-up small sides are equivalent (the padded sides are whole steps). - 13.19f review: round 1 FAIL: `aDoubleClickInTheStripMovesADivider` pressed twice at one place, so a double click that skipped the divider passed. Now the first click and the double click land apart; that mutant is caught. - 13.19f round 2: PASS. - - [ ] 13.19g The full suite under `ctest -j16`; the shared state behind the five parallel failures found and isolated. + - [x] 13.19g The full suite under `ctest -j16`; the shared state behind the five parallel failures found and isolated. + - 13.19g work: eight full runs at `-j16` and `-j32` on 32 cores passed 330 of 330; the five failures of `81894f7` did not come back, alone or with the nineteen suites that share `QStandardPaths`' test folder, so their cause is not known (the old runs had a 12-core cap and bwrap). Under a 4-CPU cap at `-j16` a new one failed three of three: `AcceptanceTests::paintingTools`' rectangle made no layer. Diagnostics showed the gradient edit still pending when the drag began, with the project free: `finishRasterCommit` freed the project, then posted the continuation that clears the gradient, so for one turn of the event loop the edit refused every press (Swift's caller resumes from `await` in the same job). The continuation now runs where the commit ends, for the gradient, the pixel move and fills; their callers' callbacks stay posted. Round 1 of review found the same gap one level up, through posted callbacks that carry work: the floating selection installed after the clear, and a filter, Levels, Hue/Saturation or Invert begun after a pending gradient read whatever layer was active by then. With the continuation inline, both run where the commit ends; only early paths post. `CommitContinuationTests` posts a check as the project frees: the gradient gone, the move gone, the floating edit ready, a tool chosen then staying chosen, and each chained command keeping its layer; the two first were red before the fix, posting the continuation again is caught, and posting the gradient's or the fill's continuation is caught by the chained and floating rows. Then 331 of 331 at `-j16` and three of three under the 4-CPU cap. + - 13.19g stress past that: 2 CPUs at `-j32`, sixteen processes a core, failed six and eight suites, every one a time limit (waits of 5 to 20 s, QtTest's 300 s watchdog on `LargeCanvasBrushTests` and `LayerContextMenuEdgeTests`) but `SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError`, whose 512 KB limit then found room; it passes alone at 2 CPUs three of three and under the 4-CPU load, and its cause under that stress is not established. `dev.sh test` and the distro checks run the suite one test at a time. + - 13.19g review: round 1 FAIL (the floating selection and the commands chained after a gradient still had a gap); round 2 PASS. - [ ] 13.19h The acceptance run and its shots. - [ ] 13.19i Distro checks: arch, fedora, fedora44, nixos, resolute. - [ ] 13.19j Tag `v1.4.5`; the release workflow publishes every package. diff --git a/src/Document/EditorSession+Brush.cpp b/src/Document/EditorSession+Brush.cpp index 2486ede..7a00200 100644 --- a/src/Document/EditorSession+Brush.cpp +++ b/src/Document/EditorSession+Brush.cpp @@ -300,14 +300,10 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke) // A raster edit assembled off the UI thread; one step. void EditorSession::commitRasterEdit(std::shared_ptr stroke, const QString &name, std::function alsoApply, std::function done) { - // The caller resumes from the event loop, like await. - const auto finish = [this, done] { - if (done) - QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); - }; if (!stroke->committedTransform().isValid()) { setBrushError(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what())); - finish(); + if (done) + done(); return; } setIsProjectBusy(true); @@ -331,8 +327,9 @@ void EditorSession::finishRasterCommit() setIsProjectBusy(false); ++m_brushRevision; notify(); + // Swift's caller resumes in the same job, nothing between. if (pending.done) - QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); + pending.done(); }; if (made.failure) { setBrushError(made.failure); diff --git a/tests/CommitContinuationTests.cpp b/tests/CommitContinuationTests.cpp new file mode 100644 index 0000000..3268eb6 --- /dev/null +++ b/tests/CommitContinuationTests.cpp @@ -0,0 +1,173 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "SelectionFixtures.h" + +// Swift resumes after await in one job: nothing lands between. +namespace { +std::unique_ptr halves() +{ + auto session = std::make_unique(); + session->createDocument(100, 40, true); + QImage image = BrushRaster::context(100, 40, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 50, 40), Qt::red); + painter.fillRect(QRect(50, 0, 50, 40), Qt::blue); + painter.end(); + session->insert(ImportedImage(image, image, "Halves")); + session->deleteLayer(session->document().value().layers[0].id); + return session; +} + +// Runs `look` at the first event after the project frees. +void atTheNextEvent(EditorSession &session, std::function look) +{ + auto busy = std::make_shared(false), armed = std::make_shared(false); + QObject::connect(&session, &EditorSession::changed, &session, [&session, busy, armed, look] { + if (session.isProjectBusy()) + *busy = true; + if (!*busy || session.isProjectBusy() || *armed) + return; + *armed = true; + QTimer::singleShot(0, &session, look); + }); +} + +void pendingGradient(EditorSession &session) +{ + session.selectTool(NavigationTool::gradient); + session.beginGradient(QPointF(0.5, 2)); + session.moveGradient(std::nullopt, QPointF(99.5, 2)); + session.endGradientDrag(); +} + +bool done(const std::function)> &call) +{ + bool finished = false; + call([&] { finished = true; }); + return QTest::qWaitFor([&] { return finished; }, 10000); +} +} + +class CommitContinuationTests : public QObject { + Q_OBJECT +private slots: + void theProjectFreesWithTheGradientGone(); + void theProjectFreesWithTheMoveGone(); + void theFloatingEditIsReadyAtTheNextEvent(); + void aToolChosenAfterTheLiftStaysChosen(); + void aChainedEditKeepsItsLayer_data(); + void aChainedEditKeepsItsLayer(); +}; + +void CommitContinuationTests::theProjectFreesWithTheGradientGone() +{ + const auto session = halves(); + pendingGradient(*session); + std::optional pending, editable; + atTheNextEvent(*session, [&] { + pending = session->gradientEdit().has_value(); + editable = session->canEditLayers(); + }); + QVERIFY(done([&](auto finished) { session->commitGradient(finished); })); + QTRY_VERIFY(pending.has_value()); + QCOMPARE(pending.value(), false); + QCOMPARE(editable.value(), true); +} + +void CommitContinuationTests::theProjectFreesWithTheMoveGone() +{ + const auto session = halves(); + session->applySelection(rectPath(QRectF(0, 0, 50, 40)), SelectionMode::replace, "Select"); + QVERIFY(session->beginPixelMove(false)); + session->movePixels(QSizeF(10, 0)); + std::optional moving, editable; + atTheNextEvent(*session, [&] { + moving = session->pixelMove() != nullptr; + editable = session->canEditLayers(); + }); + QVERIFY(done([&](auto finished) { session->finishPixelMove(finished); })); + QTRY_VERIFY(moving.has_value()); + QCOMPARE(moving.value(), false); + QCOMPARE(editable.value(), true); +} + +void CommitContinuationTests::theFloatingEditIsReadyAtTheNextEvent() +{ + const auto session = halves(); + session->applySelection(rectPath(QRectF(10, 10, 10, 10)), SelectionMode::replace, "Select"); + std::optional floating, editable; + atTheNextEvent(*session, [&] { + floating = session->transformEdit().has_value(); + editable = session->canEditLayers(); + }); + QVERIFY(done([&](auto finished) { session->beginSelectionTransform(finished); })); + QTRY_VERIFY(floating.has_value()); + QCOMPARE(floating.value(), true); + QCOMPARE(editable.value(), false); +} + +void CommitContinuationTests::aToolChosenAfterTheLiftStaysChosen() +{ + const auto session = halves(); + session->applySelection(rectPath(QRectF(10, 10, 10, 10)), SelectionMode::replace, "Select"); + bool chosen = false; + atTheNextEvent(*session, [&] { + session->selectTool(NavigationTool::brush); + chosen = true; + }); + QVERIFY(done([&](auto finished) { session->beginSelectionTransform(finished); })); + QTRY_VERIFY(chosen); + // The choice applied the floating edit and stayed. + QCOMPARE(session->tool(), NavigationTool::brush); + QVERIFY(!session->transformEdit().has_value()); +} + +void CommitContinuationTests::aChainedEditKeepsItsLayer_data() +{ + QTest::addColumn("command"); + QTest::newRow("filter") << 0; + QTest::newRow("levels") << 1; + QTest::newRow("hue") << 2; + QTest::newRow("invert") << 3; +} + +void CommitContinuationTests::aChainedEditKeepsItsLayer() +{ + QFETCH(int, command); + const auto session = halves(); + const QUuid target = session->activeLayerID().value(); + QImage green = BrushRaster::context(100, 40, false); + green.fill(QColor(0, 255, 0)); + session->insert(ImportedImage(green, green, "Other")); + const QUuid other = session->activeLayerID().value(); + session->selectLayer(target); + pendingGradient(*session); + // Another layer chosen once the gradient lands, if allowed. + atTheNextEvent(*session, [&] { + if (session->canEditLayers()) + session->selectLayer(other); + }); + if (command == 0) { + session->beginFilter(FilterKind::gaussianBlur); + QTRY_VERIFY(session->filterEdit().has_value()); + QCOMPARE(session->filterEdit().value().layerID, target); + } else if (command == 1) { + session->beginLevels(); + QTRY_VERIFY(session->levels().has_value()); + QCOMPARE(session->levels().value().layerID, target); + } else if (command == 2) { + session->beginHueSaturation(); + QTRY_VERIFY(session->hueSaturation().has_value()); + QCOMPARE(session->hueSaturation().value().layerID, target); + } else { + QVERIFY(done([&](auto finished) { session->invertPixels(finished); })); + QCOMPARE(session->history.undoName(), QString("Invert")); + QCOMPARE(session->activeLayerID().value(), target); + const ImageLayer kept = session->document().value().layers.back(); + QCOMPARE(kept.id, other); + QCOMPARE(kept.asset.value().image().pixelColor(5, 5), QColor(0, 255, 0)); + } +} + +QTEST_GUILESS_MAIN(CommitContinuationTests) +#include "CommitContinuationTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index c53f45a..1a4c4ec 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -43,6 +43,7 @@ compositor_test(CloneStampCanvasTests) compositor_test(SmudgeLiquifyTests) compositor_test(WarpStrokeTests) compositor_test(WarpFailureTests) +compositor_test(CommitContinuationTests) compositor_test(LargeCanvasBrushTests) compositor_test(BlurBrushTests) compositor_test(BlurToolTests)