diff --git a/AGENTS.md b/AGENTS.md index c37c123..736ba5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Platform adaptations, each forced by a missing Qt analog: - `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`. - The Move tool on the canvas (6.1): `TransformOverlay` is no widget. Swift's overlay view exists so that a selection never redraws the pixels; a transparent Qt child would repaint the canvas beneath it anyway, so the canvas draws the handles and guides itself, after its pixels, and `synchronizeDisplay` repaints only the union of the old and the new `drawnRect` (a guide spans the canvas) when the geometry or `snapGuides` changed, leaving its return value to the pixel state. `TransformOverlayGeometry` keeps Swift's handles, rotation grip and `hit`; `showsRotation` and the corner constructor arrive with 6.2. `resizeCursor` maps Swift's frame positions onto Qt's four resize shapes (right, bottom right, bottom, top right). `EditorCanvas+Transform.cpp` holds the drags (`beginTransformDrag`, `dragTransform`, `endTransformDrag`, `cancelTransformDrag`, Swift's `resignFirstResponder`), `transformPressLayer` and `transformCursor`; Ctrl stands for Cmd in picking, Ctrl+Shift extends the selection. The drag's cursor is kept in `m_dragCursor` until the release and outranks Space, where Swift disables the window's cursor rects; hover reaches `updateCursor` through mouse tracking and the last hover point, the application filter reads Alt and Ctrl as it read Alt, and a shown canvas reads both from the keyboard. The release synchronizes the display, so a Ctrl+T edit's guides go without a commit. `moveCursor` (the arrow with Swift's four-way badge) and `rotationCursor` (two arrows round a ring, for the SF symbol) are drawn as paths; the distort cursor arrives with 6.2. `SnapGuides` is a struct where Swift has a tuple, a public member like `viewport`, unannounced; `commitTransform` and `cancelTransform` clear it. `transformSnapTargets` and `snappedMove` live in `Document/Crop.cpp`, Swift's file. `TransformEdit.persistent` sits last, so the positional initialisers keep their order; a drag's edit and a duplicate's are not persistent. `transformAutoSelect`, `showsTransformControls` and `locksTransformRatio` are getters and setters that notify. Escape and Return end a drag with its edit and put the hover cursor back. A press that never moves commits an unchanged edit, which the history drops. The pixel tests hide the controls (`CanvasDisplayTests`, `HighDpiCanvasTests`) and preview through a drag's edit, since Swift's overlay never reaches its `draw` tests. -- Distort (6.2): `DistortWarp` keeps Swift's tests of usability and convexity, `homography` (a projective `QTransform` where Swift returns a closure, Qt dividing by the third row as Swift by `w`), `warp`, `warpTrimmed`, `carried` and `warpMask`. Core Image's perspective filter is `QTransform::quadToQuad` on the image's pixel rect and a `drawImage` under it, antialiased and smooth; a folded shape is two triangles under their affine maps, each a hard device-pixel region of the pixels whose centres the triangle holds, built per scanline from each edge's ends ordered by y, the left bound inclusive, so the halves partition the diagonal and meet a fractional shared edge with one x (a path clip turns soft once the image draws antialiased, and `QRegion`'s integer polygons round the vertices), copied through `BrushRaster::draw` without interpolation, as Swift's. In `distortPreview` a mask warp that throws is caught on its own, as Swift's `try?`: a linked mask covering its layer then draws none, a placed one falls back, with a folded shape, to its stored placement clipped over the warp. Its `Warped` and `Trimmed` structs stand for Swift's tuples; `mapPath` arrived with 8.2b and `distortedEffects` arrived with 9.6c. Swift's `DistortTests` call a bow tie unusable, but the source folds one as two triangles (both halves have area), so the twin's tests use a collapsed corner for the refused shape, as the source refuses it. `TransformEdit.corners` sits last, after `persistent`. The session's `distortPreview` and `maskDistortPreview` are `const` with mutable caches (`DistortPreviewCache` by layer, `MaskDistortPreviewCache`), keyed by `ImageIdentity` where Swift compares `CGImage` identity, because the canvas reads them from a const session; a warp that throws leaves a cached empty result, as Swift's `try?`. `commitDistort` catches `ProjectError` and `ExportError` into `brushError` as Swift catches its errors; `commitTransform` runs it after the mask path, before the plain one, and ends a duplicate after it. `commitMaskTransform` takes the corners path with `Distort Layer Mask`, `displayedMaskPlacement` its corners cases, `nudgeLayer` moves the corners with the draft. The overlay's corners constructor sets `showsRotation` false, `hit` skips the grip then, and the drawing leaves the stem and disc out; `geometry` returns the corners' geometry for a group or the edited layer. The canvas's `DisplayState` carries the edit's corners, so a moved corner repaints; `drawOwn` draws `distortPreview` first and takes `maskDistortPreview` for the mask. A Ctrl-drag on a handle begins the distortion and drags in `distort` mode with the white `distortCursor`; the cursor shows it over a handle under Ctrl or once distorted. The inspector's numbers rest while corners are set, as Swift's `.disabled`. +- 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. @@ -120,6 +120,7 @@ Platform adaptations, each forced by a missing Qt analog: - Photoshop import (12.5a, Swift 1.2.0): `src/IO/PSD/` twins Swift's `IO/PSD` folder. `PSDTypes` holds `PSDError` (Swift's five cases and words), `PSDConversion`, `PSDRecord`, `PSDDocument` and `fromPSD` (Swift's `LayerBlendMode.fromPSD`); Swift's `PSDLayerKind.other` is never assigned, so the case and its note are left out. `PSDChannelCoder` decodes raw and PackBits planes (a no-op run skipped, every overrun `truncated`) and builds `RGBA8888_Premultiplied` pixels tagged sRGB, rounding as Swift's `(c × a + 127) / 255`, and `Grayscale8` masks; a surface that cannot be allocated is `ExportError(render)` through `BrushRaster::context`, where Swift says `truncated`. `PSDReader` reads Swift's fields in Swift's order over a `Cursor` whose bounds check cannot overflow (an 8B64 length near `qint64`'s top reads as truncated, where Swift's `offset + count` traps), maps the file where it can and reads it where it cannot (a pipe, a mount without `mmap`), as `mappedIfSafe` does (a file that cannot be opened or read is `unreadable`, where Swift passes on the Cocoa error), logs every refusal on `omaphoto.io` and rethrows it. Pascal names are Mac OS Roman through a table of the 128 high code points taken from Python's `mac_roman` (Qt decodes it only through ICU); keys are ASCII or nothing, as Swift's `.ascii`. `PSDAdjustments::parse` (in `PSDReader+Adjustments.cpp`) reads Levels, Curves and Hue/Saturation as Swift does. `PSDVector` draws vector masks with `QPainterPath` (tight `boundingRect`, a path empty only without elements as a `CGPath`), fills by nonzero winding and strokes with `Qt::SvgMiterJoin` at 10, SVG's miter rule being CoreGraphics' (probed: kept to a ratio of 9.57, bevelled from 10.43; `Qt::MiterJoin` measures from the join point and clips instead); `integral` stays in doubles, so a wild box reaches the size check instead of an overflowing `QRect`. `PSDDocumentBuilder` makes the layers and conversions; a shape's identity is its asset's. `ImageImporter::loadPhotoshop` and `photoshopAssets` run on the import's worker, `makeImport` on the UI thread. In the session (`EditorSession+Imports.cpp`) a file whose first bytes are `8BPS` takes the Photoshop path: `beginPSDReading` shows the sheet while a `QFutureWatcher` reads it, `finishPSDReading` fills it or takes it away, `finishConversion` answers from the event loop (a resumed task), Cancel while reading drops the file silently, a failure joins the import's failures, and a confirmed file lands through `insertPhotoshop` (a new canvas at the file's size and resolution, or one folder named for the file inside the active folder, centred on a drop point, 10,000 layers at most). A request of Photoshop files alone is one `Import Photoshop File` step. Fixes beyond Swift: `insertPhotoshop` validates the hierarchy first, so folders nested past its depth refuse the file (`truncated`), and a folder clipped to the layer below takes no link but the base note, where Swift links it; either way Swift admits a document its store refuses. Left out as dead: the gates' `showsConversionSheet` terms and its `didSet` (the sheet shows only while importing, which the gates refuse already), Swift's test hook `confirmConversions`, `confirmPSDConversions` and `NativeLayerList.moveLayer` (no caller), the request's `id`, the builder's folder and adjustment checks on a clipping base (only pixel layers become bases), the reader's `layerSectionEnd`, the default mask colour, the reader's plane-size checks (a plane decodes to its size), resolution's finiteness test and its floor, the vector code's finiteness tests on boxes built from finite numbers and the origination's notes (never set), Curves' two length checks past the first, `pixelSize`'s area in doubles (its boxes are whole, so the whole pixels decide alone), the session's catch around `makeImport` (every image arrives with its asset, so nothing there allocates), and the new folder's removal from the collapsed set (a fresh id is in no set). `UI/PSDConversionSheet` is Swift's sheet in a window-modal `QDialog` (`conversionSheet`) that `ContentView` shows from the session's request: the title at 17 pixels bold, the words, a spinner while reading or the list of layer names over their notes, Cancel and the default confirm button, resting while reading; Escape and the close button cancel, and a sheet the session takes away answers nothing; `open()` makes the dialog window-modal. The request shown is kept, so an unrelated change keeps the content, and replaced content hides at once and is deleted later. The importer's filter and the desktop entry take `.psd` (`image/vnd.adobe.photoshop`); a dropped picture is copied as `Dropped.` into a folder of its own, as Swift 1.2.0 keeps a dropped file's name. Equivalent mutants: the cursor's own bounds refusal and `skip`'s (every read past the end meets another refusal: a short signature, a short plane), PackBits' count and row-end checks (the branches refuse the same bytes; they spare reading past the buffer), the `reserve` calls, `painter.end()` before the raster returns, the drop copy's `close()` before its static removal, and the logs. Untested failure path: a read that fails after the file opened (an I/O error; Qt 6.4 refuses to open a directory). Tests: `PSDRoundTripTests` and `PSDVectorTests` (Swift's, through `tests/PSDFixture.h`, Swift's writer, and `PSDVectorFixtures.h`), `PSDReaderTests` and `PSDReaderLayerTests` (the reader's edges, every cut before the last layer byte, budgets and vector layers read whole, built by `PSDReaderFixtures.h` and `PSDVectorBuilders.h`), `PSDImportTests` (the session and the sheet), and the drop, importer, message and desktop checks in their files. - Outer Glow and brush Smoothing (12.5b, Swift 1.2.0): `OuterGlowEffect` joins the effects with Swift's defaults (20 pixels, white, 75%) and bounds (0–500, 0–1), `LayerEffectKind::outerGlow` ("Outer Glow") last, so the footer's menu, the list's rows and every helper take it; the manifest's `outerGlow` is Swift's `Codable` (`size`, `opacity`, the colour, `enabled` only while set). `LayerEffectsKernel` blurs the shape's coverage at sigma size ÷ 2, Metal's glow buffer (a size under 0.02 copies it, as Swift's zero shift does), and composes it over the drop shadow and under an outside stroke, clamped to glow × (1 − shape) × opacity; the renderer's margin takes three sizes, the stroke surface's reach three sizes and two. Swift's CPU `outerGlowCoverage` serves machines without Metal and is dead here, as the other CPU coverages are (9.6b), so Swift's CPU and Metal parity test has no twin. The preview cache keeps the last preview while one effect is hidden or shown on the same pixels (Swift's `dbe82b0`). A fix beyond Swift: a reduced preview scales the glow's size, as it scales the inner shadow's lengths (9.6c). The sheet shows the glow's swatch, Size (0–100 on the slider, 500 typed, px) and Opacity. Smoothing is Swift's string: `BrushSettings::smoothing` (0–100); while the Brush paints (either mode) with smoothing, the brush (`m_brushAnchor`) moves only once the pointer (`m_brushPointer`) pulls further than smoothing ÷ the viewport's zoom, and then by the excess along the string; the release paints on to the pointer; a cancel drops both. The other brush tools follow the pointer. Equivalent mutants: the press's pointer and the cancel's two resets (each makes the others' work redundant), `>` at the string's exact length (a move of zero), the release's tool and smoothing terms (otherwise it appends the last point again), and the surface's glow reach between the blur's three sigmas and Swift's three sizes. `BrushControls` shows Smoothing for the Brush alone: a 100-point slider in tenths and a 42-point `SelectionAmountField` of 0–100 with Swift's help. The rest of 1.2.0 is held already (TASKS.md 12.0, each note now saying Swift agrees) or not code (`NativeLayerList.moveLayer` has no caller; the spelling touches shader comments), but for two follow-ups of `90ee29f` and `7c4512a`: Select All never rests and reaches a focused layer list (7.1's note), and the invert test holds no wall clock. Tests: `OuterGlowTests` (Swift's, but the parity test), the glow in `LayerEffectsKernelTests`' shader replay, `LayerEffectsSurfaceTests`' rows and `EffectsPreviewCacheTests`' scaling, the kinds, manifest and menu in their files; `BrushSmoothingTests` and the bar in `BrushControlsTests`. - Camera RAW (12.6a, Swift 1.2.1): Core Image's RAW filter has no Linux twin, so `IO/RawImporter` develops through LibRaw (the user's decision), its thread-safe `libraw_r` found by pkg-config; `Dockerfile.dev` installs `libraw-dev`, the PKGBUILD and the flake depend on `libraw`, the distro images carry it. A RAW is any file whose extension names a type inheriting `image/x-dcraw` in the MIME database (`matches`, `isRawType` for a dropped picture, `globs` for the importer's filter), Swift's `UTType.rawImage`. `RawDevelopSettings` keeps Swift's fields, `isAsShot` and `reset`; `RawDevelopRequest` is Swift's tuple. White balance: `planckian` is Kim et al.'s cubic (1667–25000 K), `whitePoint` moves off the locus along its greener uv normal by tint ÷ 3000, `temperatureOf` inverts it by a mired search and golden sections: Adobe's scale and sign (the DNG SDK's `kTintScale`), so a positive tint is a greener white and turns the picture magenta, and D65 reads about +10, as Lightroom's Daylight. Known difference: CIRAWFilter's temperature and tint follow Apple's own model; these follow Adobe's convention on CIE 1960 uv. `asShot` reads the camera's multipliers (`cam_mul`, else dcraw's daylight `pre_mul`) and turns the neutral into xy through LibRaw's `rgb_cam` (a DNG's `cam_xyz` stays zero) with `pre_mul` put back; `develop` works back from the chosen white to camera multipliers (`user_mul`, the second green the first's), reading the camera's `pre_mul`, `rgb_cam` and size from unpack's copy (`rawdata`), since a process overwrites `color` with its own multipliers and a half-size one halves `sizes`; a white the matrix maps to a response at or below zero is refused, logged, where LibRaw would drop the balance in silence; the refusal is `ImageImportError(whiteBalance)`, a kind of Linux's own with words of its own, since `CIRAWFilter` takes every white and real cameras' matrices reach it inside the sliders (the warm and green corner). It takes LibRaw's 16-bit sRGB at a power of one (linear), with neither auto-brightening nor the maximum's adjustment (`adjust_maximum_thr` 0, so a frame near white keeps its level), clipped at white in camera space (`highlight` 0, so a clipped photosite stays neutral: unclipped, the matrix turns it magenta), where LibRaw divides by the smallest multiplier; the twin multiplies the smallest back, so the balance is anchored on green, as Core Image's, where dcraw alone brightens the frame as the temperature moves. Known difference: where a multiplier falls below green's (the slider's far ends), the frame's white falls to that multiplier. It makes the look itself, rows side by side: exposure multiplies linear light by 2^EV, clamped, then Boost mixes sRGB's curve (0) with LibRaw's BT.709 curve (1). Known difference: Apple's Boost curve is undisclosed; at 1 this is LibRaw's default curve. The result is opaque `RGBA8888_Premultiplied` tagged sRGB, oriented by LibRaw. Only three-colour sensors develop; a four-colour one is `unsupported` (dcraw's matrices hold no fourth to invert). A preview past its limit decodes at half size, then fits smoothly. `Queue` is Swift's actor: one develop at a time under a mutex, the unpacked frame kept for the previews of one file, a full develop always fresh, `release` letting go; Swift keys the cache by the limit too, since its filter's scale is fixed when made, where LibRaw halves per develop. LibRaw's refusals are `unreadable`, logged with its message on `omaphoto.io`; a surface that cannot be made is `ExportError(render)`. In the session (`EditorSession+Imports.cpp`) a RAW goes before the Photoshop check: `pixelSize` (unreadable when it fails) and Swift's budget (`tooLarge` past 30,000 a side or the pixels left), then `developRaw` shows the sheet with the camera's settings (the defaults when they cannot be read) and keeps the answer, `finishRawDevelop` clears the request, releases the queue on a worker and answers from the event loop (a resumed task): nothing skips the file, settings develop the whole frame on the import's worker, which catches `ImageImportError` and `ExportError`, into a layer named for the file. `rawDevelop` alone stands for Swift's `rawDevelop` and `showsRawDevelop`, which are set and cleared together; the latter's `didSet` is dead (the sheet shows only while importing, which holds file requests anyway), and so is the test hook `confirmRawDevelop`. `UI/RawDevelopSheet` is Swift's sheet in a window-modal `QDialog` (`rawDevelopSheet`) that `ContentView` shows while the request stands, its close button and Escape cancelling and a sheet the session takes away answering nothing: the title at 17 pixels bold, the 560 by 340 preview on 35% black rounded 6 with a spinner while a develop runs, four rows (a 90-point title as the buddy of a 300-point slider, the value 80 points wide in `PlaceholderText`: Exposure in hundredths, Temperature and Tint whole, Boost in thousandths, printed as Swift's `%.Nf`), Reset (resting while as shot), Cancel and the default Import. The first preview develops at once, each change after 60 ms (Swift's `task(id:)`), at 800 pixels through the queue on a worker; a newer change drops the older's result, and a failed preview keeps the last one, logged. Left out as dead: LibRaw's size check after identification (`adjust_sizes_info_only` fails only when called before), the second green's multiplier (LibRaw copies the first's), the lower clamp of unsigned samples, and a check of the output's colours and bits (three colours and 16 bits are asked for). Equivalent mutants: a response of exactly zero refused as below zero (a double lands on it only by chance), Import's explicit default (a dialog makes its first auto-default button the default) and the fit at exactly the limit (the scale keeps the size). Untested: a stale preview dropped while a newer one waits is ordered by the test through the wait timer alone. A dropped picture takes a RAW type after Photoshop's; the importer's filter and the desktop entry (`image/x-dcraw`) take RAW. Tests: `RawImporterTests` (matching, the locus and its round trips, the camera's balance, curves, exposure, colour, orientation, the preview's limit, the queue's cache), `RawDevelopTests` (the session, refusals and budgets, a develop that fails, the sheet), a tiny DNG writer in `DNGFixture.h`, the drop order in `ImageFileDropTests`, the filter in `ContentViewTests`, the entry in `DesktopIntegrationTests`. `tests/Tests.cmake` holds the test binaries, keeping `CMakeLists.txt` under 500 lines. +- Middle-button pan, tool defaults, Auto Select and the icon (12.6b, Swift 1.2.1): `Document/ToolDefaults` twins Swift's enum as a namespace: `boolean` (Swift's `bool`, a C++ keyword) and `set` keep Auto Select, the transform controls, the pixel grid, the layout grid, guides, rulers, Snap, the four Snap To switches and Lock Guides in `QSettings` under Swift's `tool.` keys, read by the session's members as they are made and written by each setter, and by the two places that show guides by themselves (`beginGuideCreation`, `addGuide`), as Swift's `didSet` sees those writes. Swift returns the defaults under XCTest; Qt has no such flag, and most test binaries run outside `QStandardPaths`' test mode, so the twin keeps the defaults until `ToolDefaults::enable()`, which `main` calls first (not under test, as the theme's construction is not). The settings file keeps text, so a stored value reads as `true` or `false`; anything else is ignored with a warning on `omaphoto.app`, where Swift's `as? Bool` is silent. The canvas's middle button (Swift's `otherMouseDown` for button 2) pans from any tool while a document is open, on a drag point of its own (`m_middlePanPoint`), so a left drag under way goes on beside it: a move with both buttons pans, then gives the left its turn (so a Hand drag pans twice), and a right-button tip drag waits for the middle's release. Known difference: macOS sends one drag event a motion, so which button Swift's canvas follows then is AppKit's choice. The press hides the brush circle and shows the closed hand, which outranks every other cursor; the release puts the tool's cursor and circle back. Qt can lose a release: a move without the middle button ends the pan. Unlike Swift's `mouseDown`, the press takes no focus. `transformPressLayer` gains Swift's foreground rule: with Auto Select, a press inside the active layer's box goes to the layer under the pointer when that is painted above it (a background holding every press no longer hides the layer on top); `above` reads both layers through checked lookups, since the pointer's layer and the visible active one are both drawn, where Swift guards each index. Swift's `underPointer != active.id` is left out as dead (`above` refuses a layer against itself); equivalent mutant: `>=` in `above` (only the active layer equals itself, and `selectLayer` of the active layer changes nothing). The reshaped app icon (`45ab8fd`) replaces `packaging/icons`. Tests: `ToolDefaultsTests` (the defaults until enabled, every toggle across sessions and as text, guides shown by a guide, a strange value), `theMiddleButtonPansFromAnyTool` in `CanvasNavigationTests`, Swift's `autoSelectPicksForegroundLayerStackedOnSelectedBackground` and a mask box past its layer in `TransformPressTests`, whose older Auto Select test now expects the layer above. - 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 74e2b62..a7ae2b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,7 @@ add_library(compositor_core STATIC src/Document/SelectionClipboard.cpp src/Document/Crop.cpp src/Document/Guides.cpp + src/Document/ToolDefaults.cpp src/Document/ObjectSelection.cpp src/Document/Filters.cpp src/Document/GuidedMatte.cpp diff --git a/TASKS.md b/TASKS.md index f742212..dc0e214 100644 --- a/TASKS.md +++ b/TASKS.md @@ -334,7 +334,9 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - 12.6a round 1: FAIL, seven findings. The cached LibRaw read its own last multipliers and halved size (the camera's now come from unpack's copy, `rawdata`); the tint's sign was the inverse of Adobe's (positive is a greener white now, D65 reads +9.5); `adjust_maximum_thr` stretched frames near white (off); a white the camera cannot see was dropped by LibRaw in silence (refused); dcraw's normalization by the smallest multiplier moved brightness with temperature (anchored on green); a test comment was wrong. - 12.6a round 2: FAIL, two findings. Unclipped highlights (round 1's anchor through `highlight` 1) turned magenta on real cameras: LibRaw clips again and the twin multiplies the smallest multiplier back, a clipped frame tested white; the refused white said Swift's words for a wrong file type: it is `ImageImportError(whiteBalance)` with words of its own. - 12.6a round 3: PASS. One wording note taken (the frame's white at the slider's ends). -- [ ] 12.6b Version 1.2.1, part two: middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`. +- [x] 12.6b Version 1.2.1, part two: middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`. +- 12.6b round 1: FAIL, two findings: the middle pan's per-move point and the Auto Select branch's `underPointer` guard were held by no test (both tested now); the cosmetic notes taken (test order, the distort test's Swift name, the double pan named). A parallel run showed `SelectionCanvasTests` pressing before a nudge's move had ended; it waits for the move now. +- 12.6b round 2: PASS. - [ ] 12.7 Version 1.2.2: Photoshop's remaining blend modes in its groups; Black & White, Color Balance and Invert adjustments. - [ ] 12.8a Version 1.2.3, part one: the Camera Raw filter's model and pixel kernels. - [ ] 12.8b Version 1.2.3, part two: the Camera Raw panel beside the canvas, its grading wheels turning the way their hue runs (1.2.9's `96edd5f`). diff --git a/packaging/icons/app-icon-128.png b/packaging/icons/app-icon-128.png index 191b590..f3ed513 100644 Binary files a/packaging/icons/app-icon-128.png and b/packaging/icons/app-icon-128.png differ diff --git a/packaging/icons/app-icon-16.png b/packaging/icons/app-icon-16.png index d1afb9f..eeea530 100644 Binary files a/packaging/icons/app-icon-16.png and b/packaging/icons/app-icon-16.png differ diff --git a/packaging/icons/app-icon-256.png b/packaging/icons/app-icon-256.png index cc0805a..de82a5e 100644 Binary files a/packaging/icons/app-icon-256.png and b/packaging/icons/app-icon-256.png differ diff --git a/packaging/icons/app-icon-32.png b/packaging/icons/app-icon-32.png index 7aeb5ee..9cec0a7 100644 Binary files a/packaging/icons/app-icon-32.png and b/packaging/icons/app-icon-32.png differ diff --git a/packaging/icons/app-icon-512.png b/packaging/icons/app-icon-512.png index 3cf9665..0c1c47e 100644 Binary files a/packaging/icons/app-icon-512.png and b/packaging/icons/app-icon-512.png differ diff --git a/packaging/icons/app-icon-64.png b/packaging/icons/app-icon-64.png index 69d537c..f076c66 100644 Binary files a/packaging/icons/app-icon-64.png and b/packaging/icons/app-icon-64.png differ diff --git a/src/CompositorApp.cpp b/src/CompositorApp.cpp index 33322a6..1f4294a 100644 --- a/src/CompositorApp.cpp +++ b/src/CompositorApp.cpp @@ -1,4 +1,5 @@ #include "Document/ProjectWorkspace.h" +#include "Document/ToolDefaults.h" #include "Logging.h" #include "UI/OmarchyTheme.h" #include "UI/ProjectWorkspaceView.h" @@ -11,6 +12,8 @@ int main(int argc, char **argv) qSetMessagePattern(QStringLiteral("%{time yyyy-MM-dd hh:mm:ss.zzz} %{type} %{category}: %{message}")); QApplication application(argc, argv); QApplication::setApplicationName(QStringLiteral("OmaPhoto")); + // Tool toggles outlive the run, as Swift's user defaults. + ToolDefaults::enable(); QApplication::setApplicationVersion(QStringLiteral(OMAPHOTO_VERSION)); // The desktop entry's name: icons and windows find each other. QGuiApplication::setDesktopFileName(QStringLiteral("io.github.ZacharyZhang_NY.OmaPhoto")); diff --git a/src/Document/EditorSession+Transform.cpp b/src/Document/EditorSession+Transform.cpp index 48b4d5e..ffa27ea 100644 --- a/src/Document/EditorSession+Transform.cpp +++ b/src/Document/EditorSession+Transform.cpp @@ -85,12 +85,14 @@ void EditorSession::beginTransform(bool persistent) void EditorSession::setTransformAutoSelect(bool picks) { m_transformAutoSelect = picks; + ToolDefaults::set(picks, QStringLiteral("autoSelect")); notify(); } void EditorSession::setShowsTransformControls(bool shows) { m_showsTransformControls = shows; + ToolDefaults::set(shows, QStringLiteral("transformControls")); notify(); } diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index 4de17fb..6454386 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -318,6 +318,7 @@ void EditorSession::zoom(double value, std::optional<QPointF> anchor) void EditorSession::setShowsPixelGrid(bool shows) { m_showsPixelGrid = shows; + ToolDefaults::set(shows, QStringLiteral("pixelGrid")); notify(); } diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index babad1c..b6c869e 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -12,6 +12,7 @@ #include "Document/SelectionClipboard.h" #include "Document/SelectionEdits.h" #include "Document/SmudgeLiquify.h" +#include "Document/ToolDefaults.h" #include "Document/DocumentHistory.h" #include "Document/EditorSession+Jobs.h" #include "Document/EditorSession+Model.h" @@ -344,8 +345,8 @@ private: std::optional<TransformEdit> m_transformEdit; std::optional<TransformDuplicate> m_transformDuplicate; - bool m_transformAutoSelect = false; - bool m_showsTransformControls = true; + bool m_transformAutoSelect = ToolDefaults::boolean(QStringLiteral("autoSelect"), false); + bool m_showsTransformControls = ToolDefaults::boolean(QStringLiteral("transformControls"), true); bool m_locksTransformRatio = true; std::optional<BlendPreview> m_blendPreview; std::optional<QUuid> m_opacityEditLayerID; @@ -358,18 +359,18 @@ private: std::optional<QString> m_importError; std::optional<QUuid> m_renamingLayerID; std::optional<QString> m_projectPath; - bool m_showsPixelGrid = true; + bool m_showsPixelGrid = ToolDefaults::boolean(QStringLiteral("pixelGrid"), true); bool m_snappingEnabled = true; // The layout grid starts off; guides show; rulers hide. - bool m_showsGrid = false; - bool m_showsGuides = true; - bool m_showsRulers = false; - bool m_snapEnabled = true; - bool m_snapToGuides = true; - bool m_snapToGrid = false; - bool m_snapToLayers = true; - bool m_snapToDocumentBounds = true; - bool m_locksGuides = false; + bool m_showsGrid = ToolDefaults::boolean(QStringLiteral("grid"), false); + bool m_showsGuides = ToolDefaults::boolean(QStringLiteral("guides"), true); + bool m_showsRulers = ToolDefaults::boolean(QStringLiteral("rulers"), false); + bool m_snapEnabled = ToolDefaults::boolean(QStringLiteral("snap"), true); + bool m_snapToGuides = ToolDefaults::boolean(QStringLiteral("snapGuides"), true); + bool m_snapToGrid = ToolDefaults::boolean(QStringLiteral("snapGrid"), false); + bool m_snapToLayers = ToolDefaults::boolean(QStringLiteral("snapLayers"), true); + bool m_snapToDocumentBounds = ToolDefaults::boolean(QStringLiteral("snapBounds"), true); + bool m_locksGuides = ToolDefaults::boolean(QStringLiteral("lockGuides"), false); std::optional<GuideDrag> m_guideDrag; bool m_showsSampleRing = true; std::optional<QRectF> m_cropRect; diff --git a/src/Document/Guides.cpp b/src/Document/Guides.cpp index 7e339d0..26a7246 100644 --- a/src/Document/Guides.cpp +++ b/src/Document/Guides.cpp @@ -57,54 +57,63 @@ bool LayoutGrid::isMajor(double value) void EditorSession::setShowsGrid(bool shows) { m_showsGrid = shows; + ToolDefaults::set(shows, QStringLiteral("grid")); notify(); } void EditorSession::setShowsGuides(bool shows) { m_showsGuides = shows; + ToolDefaults::set(shows, QStringLiteral("guides")); notify(); } void EditorSession::setShowsRulers(bool shows) { m_showsRulers = shows; + ToolDefaults::set(shows, QStringLiteral("rulers")); notify(); } void EditorSession::setSnapEnabled(bool enabled) { m_snapEnabled = enabled; + ToolDefaults::set(enabled, QStringLiteral("snap")); notify(); } void EditorSession::setSnapToGuides(bool snaps) { m_snapToGuides = snaps; + ToolDefaults::set(snaps, QStringLiteral("snapGuides")); notify(); } void EditorSession::setSnapToGrid(bool snaps) { m_snapToGrid = snaps; + ToolDefaults::set(snaps, QStringLiteral("snapGrid")); notify(); } void EditorSession::setSnapToLayers(bool snaps) { m_snapToLayers = snaps; + ToolDefaults::set(snaps, QStringLiteral("snapLayers")); notify(); } void EditorSession::setSnapToDocumentBounds(bool snaps) { m_snapToDocumentBounds = snaps; + ToolDefaults::set(snaps, QStringLiteral("snapBounds")); notify(); } void EditorSession::setLocksGuides(bool locks) { m_locksGuides = locks; + ToolDefaults::set(locks, QStringLiteral("lockGuides")); notify(); } @@ -155,6 +164,7 @@ void EditorSession::beginGuideCreation(CanvasGuide::Axis axis, double position) if (!canEditGuides()) return; m_showsGuides = true; + ToolDefaults::set(true, QStringLiteral("guides")); m_guideDrag = GuideDrag{QUuid::createUuid(), axis, snappedGuidePosition(position, axis, std::nullopt), true, std::nullopt}; notify(); } @@ -225,6 +235,7 @@ void EditorSession::addGuide(const CanvasGuide &guide) if (!canEditGuides()) return; m_showsGuides = true; + ToolDefaults::set(true, QStringLiteral("guides")); beginEdit(QStringLiteral("New Guide")); m_document->guides.push_back(guide); endEdit(); diff --git a/src/Document/ToolDefaults.cpp b/src/Document/ToolDefaults.cpp new file mode 100644 index 0000000..d2a6b76 --- /dev/null +++ b/src/Document/ToolDefaults.cpp @@ -0,0 +1,33 @@ +#include "Document/ToolDefaults.h" +#include "Logging.h" +#include <QSettings> + +namespace { +const QString prefix = QStringLiteral("tool."); +// Swift's XCTest check: only the app keeps the toggles. +bool enabled = false; +} + +void ToolDefaults::enable() +{ + enabled = true; +} + +bool ToolDefaults::boolean(const QString &key, bool fallback) +{ + if (!enabled) + return fallback; + // The settings file keeps text; this run's writes are booleans. + const QString stored = QSettings().value(prefix + key).toString(); + if (stored == QLatin1String("true") || stored == QLatin1String("false")) + return stored == QLatin1String("true"); + if (!stored.isEmpty()) + qCWarning(lcApp).noquote() << "ignoring the tool setting" << key << "of" << stored; + return fallback; +} + +void ToolDefaults::set(bool value, const QString &key) +{ + if (enabled) + QSettings().setValue(prefix + key, value); +} diff --git a/src/Document/ToolDefaults.h b/src/Document/ToolDefaults.h new file mode 100644 index 0000000..c5b6d5d --- /dev/null +++ b/src/Document/ToolDefaults.h @@ -0,0 +1,11 @@ +#pragma once +#include <QString> + +// Swift's ToolDefaults: toggles kept for the person, across launches. +namespace ToolDefaults { +// The app turns them on; tests keep the compiled defaults. +void enable(); +// The stored toggle, else the fallback. +bool boolean(const QString &key, bool fallback); +void set(bool value, const QString &key); +} diff --git a/src/Rendering/EditorCanvas+Brush.cpp b/src/Rendering/EditorCanvas+Brush.cpp index 29ade5d..49ae056 100644 --- a/src/Rendering/EditorCanvas+Brush.cpp +++ b/src/Rendering/EditorCanvas+Brush.cpp @@ -114,7 +114,7 @@ void CanvasView::endBrushTipDrag(QPointF point) // The circle at the pointer, a stroke's own size. void CanvasView::updateBrushCursor() { - const bool shows = isBrushTool(m_session.tool()) && !m_spaceHeld && !picking(); + const bool shows = isBrushTool(m_session.tool()) && !m_spaceHeld && !picking() && !m_middlePanPoint; const double diameter = m_session.brushStroke() ? m_session.brushStroke()->settings.diameter : m_session.brushSettings().diameter; // Clone Stamp marks its source; between strokes, previews a click. std::optional<QPointF> sample; diff --git a/src/Rendering/EditorCanvas+Cursors.cpp b/src/Rendering/EditorCanvas+Cursors.cpp index 488aac4..7c82992 100644 --- a/src/Rendering/EditorCanvas+Cursors.cpp +++ b/src/Rendering/EditorCanvas+Cursors.cpp @@ -250,8 +250,10 @@ QCursor CanvasView::pixelDragCursor(bool duplicate, double ratio) void CanvasView::updateCursor() { const NavigationTool tool = m_session.tool(); - // A transform, outline or pixel drag keeps its cursor. - if (m_dragCursor) + // A middle-button pan outranks every drag; another keeps its cursor. + if (m_middlePanPoint) + setCursor(Qt::ClosedHandCursor); + else if (m_dragCursor) setCursor(*m_dragCursor); else if (m_selectionDragStart) setCursor(moveSelectionCursor(devicePixelRatio())); diff --git a/src/Rendering/EditorCanvas+Navigation.cpp b/src/Rendering/EditorCanvas+Navigation.cpp index 2662911..7161a2f 100644 --- a/src/Rendering/EditorCanvas+Navigation.cpp +++ b/src/Rendering/EditorCanvas+Navigation.cpp @@ -114,6 +114,13 @@ void CanvasView::wheelEvent(QWheelEvent *event) void CanvasView::mousePressEvent(QMouseEvent *event) { + // The middle button pans in any tool, beside the left. + if (event->button() == Qt::MiddleButton && m_session.document()) { + m_middlePanPoint = event->position(); + updateBrushCursor(); + updateCursor(); + return; + } if (event->button() == Qt::RightButton && isBrushTool(m_session.tool()) && !m_session.brushStroke() && !m_session.warpStroke() && !m_spaceHeld) { beginBrushTipDrag(event->position(), event->modifiers()); return; @@ -199,6 +206,16 @@ void CanvasView::mouseMoveEvent(QMouseEvent *event) const QPointF point = event->position(); m_hover = point; readModifiers(event->modifiers()); + if (m_middlePanPoint && event->buttons().testFlag(Qt::MiddleButton)) { + m_session.viewport.translate(QSizeF(point.x() - m_middlePanPoint->x(), point.y() - m_middlePanPoint->y())); + m_middlePanPoint = point; + m_session.notify(); + if (!event->buttons().testFlag(Qt::LeftButton)) + return; + } else if (m_middlePanPoint) { + // Qt can lose a release: the pan ends here. + endMiddlePan(); + } // Without the left button a text gesture's release was lost. const bool held = event->buttons().testFlag(Qt::LeftButton); if (m_textBoxAnchor) { @@ -258,6 +275,10 @@ void CanvasView::mouseMoveEvent(QMouseEvent *event) void CanvasView::mouseReleaseEvent(QMouseEvent *event) { + if (event->button() == Qt::MiddleButton && m_middlePanPoint) { + endMiddlePan(); + return; + } if (event->button() == Qt::RightButton && m_brushTipDrag) { endBrushTipDrag(event->position()); return; @@ -344,3 +365,11 @@ void CanvasView::leaveEvent(QEvent *event) updateBrushCursor(); QWidget::leaveEvent(event); } + +// The tool's own cursor and circle come back at once. +void CanvasView::endMiddlePan() +{ + m_middlePanPoint.reset(); + updateCursor(); + updateBrushCursor(); +} diff --git a/src/Rendering/EditorCanvas+Transform.cpp b/src/Rendering/EditorCanvas+Transform.cpp index 6827b75..6e99288 100644 --- a/src/Rendering/EditorCanvas+Transform.cpp +++ b/src/Rendering/EditorCanvas+Transform.cpp @@ -6,6 +6,19 @@ #include <numbers> namespace { +// Whether `upper` is painted above `lower`: later is higher. +bool above(const std::vector<ImageLayer> &layers, QUuid upper, QUuid lower) +{ + const auto index = [&](QUuid id) -> std::optional<size_t> { + for (size_t at = 0; at < layers.size(); ++at) + if (layers[at].id == id) + return at; + return std::nullopt; + }; + // Both are drawn: the pointer's layer, the visible active one. + return index(upper).value() > index(lower).value(); +} + // Four arrows about `center`, each `reach` long, traced clockwise. QPainterPath fourArrowPath(QPointF center, double reach, double shaft, double head, double headLength) { @@ -149,8 +162,12 @@ std::optional<CanvasView::PressTarget> CanvasView::transformPressLayer(QPointF p if ((box && box->contains(pixel)) || !(picks && m_session.transformAutoSelect()) || !underPointer) return PressTarget{*m_session.activeLayerID(), false}; } - if (active && m_session.editedTransform(*active).contains(pixel)) + if (active && m_session.editedTransform(*active).contains(pixel)) { + // Auto Select prefers a layer above, as over a background. + if (picks && m_session.transformAutoSelect() && underPointer && above(rendered, *underPointer, active->id)) + return PressTarget{*underPointer, true}; return PressTarget{active->id, false}; + } if (picks && (m_session.transformAutoSelect() || control) && underPointer) return PressTarget{*underPointer, true}; if (active) diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index cdbdf26..8b6d6f8 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -208,6 +208,7 @@ private: void dragBrushTip(QPointF point, Qt::KeyboardModifiers modifiers); void endBrushTipDrag(QPointF point); void updateBrushCursor(); + void endMiddlePan(); // Clone Stamp's preview: one click's coverage, and the source there. QImage cloneTip(double diameter, double hardness); QImage clonePreview(QPointF center, double diameter, const CanvasDocument &document); @@ -268,6 +269,8 @@ private: std::optional<int> m_panKey; bool m_optionHeld = false; std::optional<QPointF> m_lastDragPoint; + // Where a middle-button pan last was, its own drag point. + std::optional<QPointF> m_middlePanPoint; std::optional<ZoomDrag> m_zoomDrag; bool m_shiftHeld = false; // Document point where a drag of the outline began. diff --git a/tests/CanvasNavigationTests.cpp b/tests/CanvasNavigationTests.cpp index fafb25d..906bc1d 100644 --- a/tests/CanvasNavigationTests.cpp +++ b/tests/CanvasNavigationTests.cpp @@ -9,6 +9,7 @@ private slots: void theHandToolAndSpaceDragTheView(); void theWheelPansAndZoomsWithAModifier(); void aPinchZoomsAboutThePointer(); + void theMiddleButtonPansFromAnyTool(); }; void CanvasNavigationTests::theZoomToolClicksAndDrags() @@ -167,5 +168,66 @@ void CanvasNavigationTests::aPinchZoomsAboutThePointer() QCOMPARE(shown.session.viewport.zoom(), 1.5); } + +void CanvasNavigationTests::theMiddleButtonPansFromAnyTool() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + session.selectTool(NavigationTool::brush); + canvas.synchronizeDisplay(); + const auto move = [&](QPointF to, Qt::MouseButtons buttons) { + QMouseEvent event(QEvent::MouseMove, to, to, canvas.mapToGlobal(to.toPoint()), Qt::NoButton, buttons, Qt::NoModifier); + QApplication::sendEvent(&canvas, &event); + }; + move(QPointF(100, 100), Qt::NoButton); + const Qt::CursorShape brush = canvas.cursor().shape(); + QVERIFY(canvas.brushCursor().circle().has_value()); + const QSizeF pan = session.viewport.pan; + QSignalSpy changes(&session, &EditorSession::changed); + // The circle hides and nothing paints; the view follows. + QTest::mousePress(&canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(100, 100)); + QCOMPARE(canvas.cursor().shape(), Qt::ClosedHandCursor); + QVERIFY(!canvas.brushCursor().circle().has_value()); + move(QPointF(130, 120), Qt::MiddleButton); + QCOMPARE(session.viewport.pan, pan + QSizeF(30, 20)); + QCOMPARE(changes.count(), 1); + move(QPointF(150, 150), Qt::MiddleButton); + QCOMPARE(session.viewport.pan, pan + QSizeF(50, 50)); + move(QPointF(130, 120), Qt::MiddleButton); + QCOMPARE(session.viewport.pan, pan + QSizeF(30, 20)); + QVERIFY(!session.brushStroke() && !canvas.brushCursor().circle().has_value()); + QCOMPARE(canvas.cursor().shape(), Qt::ClosedHandCursor); + QTest::mouseRelease(&canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(130, 120)); + QCOMPARE(canvas.cursor().shape(), brush); + QVERIFY(canvas.brushCursor().circle().has_value()); + // Beside a Hand drag each button keeps its own point. + session.selectTool(NavigationTool::hand); + canvas.synchronizeDisplay(); + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(50, 50)); + QTest::mousePress(&canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(200, 200)); + move(QPointF(60, 50), Qt::LeftButton | Qt::MiddleButton); + QCOMPARE(session.viewport.pan, pan + QSizeF(30, 20) + QSizeF(10, 0) + QSizeF(-140, -150)); + QTest::mouseRelease(&canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(60, 50)); + QCOMPARE(canvas.cursor().shape(), Qt::ClosedHandCursor); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(60, 50)); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + // A lost release: a buttonless move ends the pan. + const QSizeF held = session.viewport.pan; + QTest::mousePress(&canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(50, 50)); + move(QPointF(70, 70), Qt::NoButton); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + move(QPointF(90, 90), Qt::MiddleButton); + QCOMPARE(session.viewport.pan, held); + // Without a document the middle button does nothing. + Shown empty(std::nullopt); + empty.settle(); + const Qt::CursorShape before = empty.canvas->cursor().shape(); + QTest::mousePress(empty.canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(50, 50)); + QCOMPARE(empty.canvas->cursor().shape(), before); +} + QTEST_MAIN(CanvasNavigationTests) #include "CanvasNavigationTests.moc" diff --git a/tests/DistortTests.cpp b/tests/DistortTests.cpp index 07e7fe7..f5a18e4 100644 --- a/tests/DistortTests.cpp +++ b/tests/DistortTests.cpp @@ -38,7 +38,7 @@ class DistortTests : public QObject { Q_OBJECT private slots: void cornersRunClockwiseFromTheTopLeft(); - void perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused(); + void perspectiveMappingHitsTheCornersAndDegenerateShapesAreRefused(); void distortingWarpsTheLayerIntoTheShapeAsOneUndoStep(); void distortedLayerIsTrimmedToItsVisiblePixels(); void foldedShapesWarpAsTwoTrianglesAndFlipsSwapCorners(); @@ -61,7 +61,7 @@ void DistortTests::cornersRunClockwiseFromTheTopLeft() QVERIFY(!near(corners[1], corners[0])); } -void DistortTests::perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused() +void DistortTests::perspectiveMappingHitsTheCornersAndDegenerateShapesAreRefused() { const QTransform map = DistortWarp::homography(shape); const Corners unit = {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)}; @@ -108,6 +108,9 @@ void DistortTests::distortingWarpsTheLayerIntoTheShapeAsOneUndoStep() // A collapsed corner: ignored. session.previewCorners({shape[0], shape[0], shape[2], shape[3]}); QCOMPARE(session.transformEdit().value().corners.value()[1], QPointF(30, 10)); + // A folded shape is a distortion of its own. + session.previewCorners({shape[0], shape[2], shape[1], shape[3]}); + QCOMPARE(session.transformEdit().value().corners.value()[1], shape[2]); session.previewCorners(shape); QCOMPARE(session.transformEdit().value().corners.value(), shape); const int count = session.history.undoCount(); diff --git a/tests/SelectionCanvasTests.cpp b/tests/SelectionCanvasTests.cpp index 1da93ee..b434946 100644 --- a/tests/SelectionCanvasTests.cpp +++ b/tests/SelectionCanvasTests.cpp @@ -293,6 +293,8 @@ void SelectionCanvasTests::aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem() QCOMPARE(session.history.undoCount(), count + 2); // Ctrl-Alt copies; losing the keys mid-drag cancels. session.selectTool(NavigationTool::marquee); + // The nudge's move ends a turn after its step lands. + QTRY_VERIFY(!session.pixelMove()); shown.press(QPointF(270, 40), Qt::ControlModifier | Qt::AltModifier); QVERIFY(session.pixelMove() && session.pixelMove()->duplicate); shown.move(QPointF(300, 40), Qt::ControlModifier | Qt::AltModifier); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 1ff0c1f..4cb8a99 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -204,6 +204,7 @@ compositor_test(PSDReaderLayerTests) compositor_test(PSDImportTests) compositor_test(RawImporterTests) compositor_test(RawDevelopTests) +compositor_test(ToolDefaultsTests) compositor_test(LayerEffectsRendererTests) compositor_test(LayerFlipTests) compositor_test(LayerMergeTests) diff --git a/tests/ToolDefaultsTests.cpp b/tests/ToolDefaultsTests.cpp new file mode 100644 index 0000000..5123fd3 --- /dev/null +++ b/tests/ToolDefaultsTests.cpp @@ -0,0 +1,123 @@ +#include "Document/EditorSession.h" +#include "Document/ToolDefaults.h" +#include <QRegularExpression> +#include <QSettings> +#include <QTemporaryDir> +#include <QtTest> + +// Swift's ToolDefaults: toggles that outlive the session and the run. +namespace { +struct Toggle { + QString key; + bool fallback; + std::function<bool(const EditorSession &)> read; + std::function<void(EditorSession &, bool)> write; +}; + +std::vector<Toggle> toggles() +{ + return { + {"autoSelect", false, &EditorSession::transformAutoSelect, &EditorSession::setTransformAutoSelect}, + {"transformControls", true, &EditorSession::showsTransformControls, &EditorSession::setShowsTransformControls}, + {"pixelGrid", true, &EditorSession::showsPixelGrid, &EditorSession::setShowsPixelGrid}, + {"grid", false, &EditorSession::showsGrid, &EditorSession::setShowsGrid}, + {"guides", true, &EditorSession::showsGuides, &EditorSession::setShowsGuides}, + {"rulers", false, &EditorSession::showsRulers, &EditorSession::setShowsRulers}, + {"snap", true, &EditorSession::snapEnabled, &EditorSession::setSnapEnabled}, + {"snapGuides", true, &EditorSession::snapToGuides, &EditorSession::setSnapToGuides}, + {"snapGrid", false, &EditorSession::snapToGrid, &EditorSession::setSnapToGrid}, + {"snapLayers", true, &EditorSession::snapToLayers, &EditorSession::setSnapToLayers}, + {"snapBounds", true, &EditorSession::snapToDocumentBounds, &EditorSession::setSnapToDocumentBounds}, + {"lockGuides", false, &EditorSession::locksGuides, &EditorSession::setLocksGuides}, + }; +} +} + +class ToolDefaultsTests : public QObject { + Q_OBJECT +private slots: + void initTestCase(); + void untilTheAppTurnsThemOnTheDefaultsHold(); + void everyToggleOutlivesItsSession(); + void showingAGuideRemembersGuidesShown(); + void aStrangeValueIsIgnoredAndSaid(); + +private: + QTemporaryDir m_folder; +}; + +void ToolDefaultsTests::initTestCase() +{ + // Settings land in a folder of the test's own. + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, m_folder.path()); +} + +void ToolDefaultsTests::untilTheAppTurnsThemOnTheDefaultsHold() +{ + QSettings().setValue("tool.grid", true); + QVERIFY(!ToolDefaults::boolean("grid", false)); + ToolDefaults::set(false, "rulers"); + QVERIFY(!QSettings().contains("tool.rulers")); + EditorSession session; + QVERIFY(!session.showsGrid()); + session.setShowsRulers(true); + QVERIFY(!QSettings().contains("tool.rulers")); + QSettings().remove("tool.grid"); +} + +void ToolDefaultsTests::everyToggleOutlivesItsSession() +{ + ToolDefaults::enable(); + // An unset toggle is no strange value. + QTest::failOnWarning(QRegularExpression("ignoring the tool setting")); + for (const Toggle &toggle : toggles()) { + QVERIFY(!QSettings().contains("tool." + toggle.key)); + QCOMPARE(toggle.read(EditorSession()), toggle.fallback); + { + EditorSession session; + toggle.write(session, !toggle.fallback); + } + QCOMPARE(QSettings().value("tool." + toggle.key).toBool(), !toggle.fallback); + QVERIFY2(toggle.read(EditorSession()) == !toggle.fallback, qPrintable(toggle.key)); + // The next run reads text, as the file keeps it. + QSettings().setValue("tool." + toggle.key, !toggle.fallback ? "true" : "false"); + QCOMPARE(ToolDefaults::boolean(toggle.key, toggle.fallback), !toggle.fallback); + QSettings().remove("tool." + toggle.key); + } +} + +void ToolDefaultsTests::showingAGuideRemembersGuidesShown() +{ + ToolDefaults::enable(); + EditorSession session; + session.createDocument(100, 100); + for (const bool adding : {true, false}) { + session.setShowsGuides(false); + QCOMPARE(QSettings().value("tool.guides").toString(), QString("false")); + if (adding) + session.addGuide(CanvasGuide{QUuid::createUuid(), CanvasGuide::Axis::vertical, 40}); + else + session.beginGuideCreation(CanvasGuide::Axis::horizontal, 20); + QVERIFY(session.showsGuides()); + QCOMPARE(QSettings().value("tool.guides").toString(), QString("true")); + QVERIFY(EditorSession().showsGuides()); + } + QSettings().remove("tool.guides"); +} + +void ToolDefaultsTests::aStrangeValueIsIgnoredAndSaid() +{ + ToolDefaults::enable(); + QSettings().setValue("tool.grid", "maybe"); + QTest::ignoreMessage(QtWarningMsg, "ignoring the tool setting grid of maybe"); + QVERIFY(!ToolDefaults::boolean("grid", false)); + QTest::ignoreMessage(QtWarningMsg, "ignoring the tool setting grid of maybe"); + QVERIFY(ToolDefaults::boolean("grid", true)); + QSettings().setValue("tool.grid", 1); + QTest::ignoreMessage(QtWarningMsg, "ignoring the tool setting grid of 1"); + QVERIFY(!ToolDefaults::boolean("grid", false)); + QSettings().remove("tool.grid"); +} + +QTEST_GUILESS_MAIN(ToolDefaultsTests) +#include "ToolDefaultsTests.moc" diff --git a/tests/TransformPressTests.cpp b/tests/TransformPressTests.cpp index 9232391..2783add 100644 --- a/tests/TransformPressTests.cpp +++ b/tests/TransformPressTests.cpp @@ -47,6 +47,7 @@ private slots: void handlesResizeAndTheGripRotates(); void movesSnapToTheCanvasAndOtherLayersUnlessCtrl(); void autoSelectCanBeDisabledAndCtrlClickOverridesIt(); + void autoSelectPicksTheForegroundOverASelectedBackground(); void escapeMidDragRestoresAndAPersistentEditWaitsForReturn(); void losingTheKeysOrTheWheelMidDrag(); void aSelectionDragsAsOneBox(); @@ -190,16 +191,21 @@ void TransformPressTests::autoSelectCanBeDisabledAndCtrlClickOverridesIt() session.setTransformAutoSelect(true); shown.drag(QPointF(20, 250), QPointF(33, 250)); QCOMPARE(shown.origin(), QPointF(163, 100)); - // Inside the active layer's box, it drags, whatever lies above. + // Inside the active layer's box a layer above wins, auto-selecting. session.insert(filled(100, 100, qRgba(0, 255, 0, 255), "Green"), QPointF(220, 120)); + const QUuid green = session.activeLayerID().value(); session.selectLayer(shown.red); shown.click(QPointF(213, 150)); + QCOMPARE(session.activeLayerID(), std::optional(green)); + session.selectLayer(shown.red); + session.setTransformAutoSelect(false); + shown.click(QPointF(213, 150)); QCOMPARE(session.activeLayerID(), std::optional(shown.red)); // Ctrl picks what lies under the pointer, active included. - const QUuid green = session.activeLayerID() == shown.red ? session.document().value().layers.back().id : shown.red; shown.click(QPointF(213, 150), Qt::ControlModifier); QCOMPARE(session.activeLayerID(), std::optional(green)); session.selectLayer(shown.red); + session.setTransformAutoSelect(true); // A selection's box drags them all; outside it auto-select picks. session.insert(filled(100, 100, qRgba(255, 255, 0, 255), "Yellow"), QPointF(60, 260)); const QUuid yellow = session.activeLayerID().value(); @@ -211,6 +217,60 @@ void TransformPressTests::autoSelectCanBeDisabledAndCtrlClickOverridesIt() QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{yellow}); } +void TransformPressTests::autoSelectPicksTheForegroundOverASelectedBackground() +{ + Shown shown(QSize(400, 300), QSize(400, 300)); + shown.settle(); + EditorSession &session = shown.session; + session.zoom(1); + session.insert(filled(400, 300, qRgba(0, 0, 255, 255), "Sky")); + const QUuid background = session.activeLayerID().value(); + session.insert(filled(80, 60, qRgba(255, 0, 0, 255), "Kite"), QPointF(190, 80)); + const QUuid foreground = session.activeLayerID().value(); + session.selectTool(NavigationTool::move); + shown.canvas->synchronizeDisplay(); + const auto click = [&](QPoint at) { + QTest::mouseClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, at); + session.commitTransform(); + }; + // Swift's test: the background holds every press; the kite wins. + session.selectLayer(background); + session.setTransformAutoSelect(true); + click(QPoint(180, 70)); + QCOMPARE(session.activeLayerID(), std::optional(foreground)); + click(QPoint(20, 20)); + QCOMPARE(session.activeLayerID(), std::optional(background)); + click(QPoint(180, 70)); + QCOMPARE(session.activeLayerID(), std::optional(foreground)); + session.selectLayer(background); + session.setTransformAutoSelect(false); + click(QPoint(180, 70)); + QCOMPARE(session.activeLayerID(), std::optional(background)); + // A Ctrl+T edit keeps its layer; a plain press picks. + session.setTransformAutoSelect(true); + session.beginTransform(); + QTest::mouseClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(180, 70)); + QCOMPARE(session.activeLayerID(), std::optional(background)); + session.cancelTransform(); + session.setTransformAutoSelect(false); + // A wide mask box keeps its layer over lower ones. + session.selectLayer(foreground); + session.addMask(); + session.toggleMaskLink(foreground); + session.selectLayerTarget(foreground, true); + QVERIFY(session.transformTargetsMask()); + session.beginTransform(); + session.previewTransform(LayerTransform(QPointF(40, 20), QSizeF(300, 200))); + session.commitTransform(); + session.setTransformAutoSelect(true); + click(QPoint(60, 40)); + QCOMPARE(session.activeLayerID(), std::optional(foreground)); + // Over empty canvas nothing lies under the pointer. + session.toggleLayerVisibility(background); + click(QPoint(60, 40)); + QCOMPARE(session.activeLayerID(), std::optional(foreground)); +} + void TransformPressTests::escapeMidDragRestoresAndAPersistentEditWaitsForReturn() { Canvas shown;