diff --git a/AGENTS.md b/AGENTS.md index 690d8cf..08ff064 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Platform adaptations, each forced by a missing Qt analog: - `EditorSession::drawLiveComposite` lives in `LiveLayerMask.cpp`, as in Swift, and draws the document as the export does, in document pixels, through the displayed transforms, placements and blend modes; `LayerMaskSessionTests` compares it with the export on every render. - `ContentView` is one project's editor area, a `QWidget` that compares what it shows with what the session holds on every `changed()`; the window around it is `ProjectWorkspaceView` (4.3c), which owns the toolbar that SwiftUI lets `ContentView` declare. `ToolHeaderBar` carries `Q_OBJECT`: Qt 6.11's `findChild` refuses a class without one at compile time. Every tool has a bar of `ToolHeaderStyle::height`, so the canvas never jumps: a tool whose bar has not arrived yet shows an empty `ToolHeaderBar`, the idle and Eyedropper bars are titles alone, Hand and Zoom share one `NavigationToolHeader`, and a bar put in late is shown at once (a layout shows a late child only from the event loop). The welcome is made anew each time the document goes, as Swift's `if document == nil` makes a fresh view, so its fields and its clipboard suggestion start over. A rail button is a `QToolButton` that toggles itself on a click before the session answers, so the click's slot sets every button again from the session, which a refused `selectTool` leaves silent. `NewCanvasSheet` takes Return and Enter from anywhere on the sheet through widget shortcuts, as SwiftUI's `.defaultAction` does: `setDefault` alone reaches only a `QDialog`. Fonts are set in pixels, Swift's points on a display of one: `setPointSize` would grow them by the screen's DPI. Swift's `.secondary` text is the palette's `PlaceholderText`, its orange warning the palette's `BrightText`, which the theme of 4.5 fills; dividers are `Mid`. Labels and hints say Ctrl and Alt for ⌘ and ⌥. The status bar's hint is an eliding label and the one item that gives way when the window is narrow, as SwiftUI truncates it; zoom and size print through the English locale, as Swift's interpolation groups digits (`1,200 × 800 px`). Swift's last branch gives the Eyedropper the Zoom tool's hint; the twin says the same. Hints and icons that follow tool state (marquee and lasso kinds, brush and smear modes, shape kinds) arrive with that state. `NavigationToolHeader` applies on Return and Escape directly and again when focus leaves, where Swift applies only on the loss of focus: a Qt widget in a window that is not active loses no focus. Text that is no number reads as zero through `toDouble`, which the `> 0` test refuses, so no `ok` flag is read; `inf` reads as a number and the viewport refuses it. `NewCanvasSheet::clipboardDimensions` reads a copied picture's size from its header through `QImageReader` (no pixel is decoded), PNG before TIFF as in Swift, sides swapped for a stored quarter turn. The importer is a `QFileDialog` for existing files, and the two alerts are `QMessageBox`es opened over the view; each is taken down again when the session's flag or error is cleared from elsewhere, as a SwiftUI binding would dismiss it. - `LayersPanel` twins `LayersPanel.swift`: the heading with its count, `LayerAppearanceControls`, the rows or the empty words, and the footer's `QToolButton`s (new layer, folder, `LayerMaskMenu`, the effects menu since 9.6d, the adjustment menu since 9.5d, delete). Its width is `ContentView`'s `layersPanelWidth`, kept in `QSettings` under Swift's `AppStorage` key; a stored width outside `minimumWidth…maximumWidth` is the default, logged. `PanelResizeEdge` lives in `ContentView.cpp` as in Swift: an 8-point grip with the split cursor over a `Mid` hairline, dragged left to widen, whole points, within range. Tests enable `QStandardPaths::setTestModeEnabled`, which sends `QSettings` to `~/.qttest`. The delete button is Swift's `deleteLayerOrMask` (a chosen effect goes first since 9.6a, then a selected mask, then the selection; since 9.6d its words name a chosen effect first). -- `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the cell's `actions()`, named as Swift's menu items. Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's and the mask button's icons; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception. +- `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the list's since 12.10a (below). Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's and the mask button's icons; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception. - Drags in the list (`NativeLayerList+Drag.cpp`): a row pressed and moved past `startDragDistance` starts a `QDrag` carrying the row's id, or its whole selection's, one id a line under `ProjectWorkspace::layerType` (Swift writes one pasteboard item a row); Alt at the press offers `CopyAction` alone under `CanvasView::duplicateCursor`, as the table's source mask does, and the drop then duplicates. `dropTarget` is Swift's `validateDrop`: a folder's middle half takes the drop in, the rest lands above the nearer row edge or below every row, every row must pass `canPlaceLayer` (a copied folder carries its contents since 12.2a); `acceptDrop` is `acceptDrop`, the place worked out once, the order reversed into a folder, the moved rows kept selected, one `Move Layer(s)` or `Duplicate Layer(s)` step. Swift refuses a drag from another table by its source; the twin's drag carries its list's token under `sourceType` and a drop takes only its own (Qt's synthetic drop events carry no `source()`, and two projects opened from one file share their layers' ids, so neither would do). A press arms the drag with its modifiers, on the row or on a thumbnail (a thumbnail press that targets goes on to arm the row, as Swift's `mouseDown` falls through to the table); every left press first drops whatever an earlier press armed, then arms unless it clipped (Swift returns after clipping) or was Ctrl's or Shift's; only a move with the left button held past `startDragDistance` starts the drag. An Alt press on a mask watches the left button's release application-wide (a popup opened mid-press takes it), as the eye does; a release seen on a `QWindow` is not counted, since the window sees the button's own release before the button does, and another button's release leaves the press, as Swift waits for the left one. A plain drag offers Move and Copy with Move preferred, so a project target can take the Copy Swift proposes (a tab, the canvas and the New button set `CopyAction` on a valid enter, move and drop and ignore the rest); the list itself duplicates only when a copy is offered alone (Alt at the press), whatever Ctrl makes Qt propose, and sets Move or Copy itself. A drag held near the viewport's edge scrolls the list by the overshoot into a 16-point margin and keeps scrolling on a timer until the pointer moves back inside or the drag ends (a leave or a drop stops it, so no tick reads a drag that is gone), as the table autoscrolls (Wayland sends no moves while the pointer holds still); `autoscrolling()` says whether the timer runs, for the tests. An Alt click that clips sets the cursor for the next click at once, as Swift's refresh after toggling. `LayerColumn` paints the indicator: a two-point accent line between rows or the accent-filled folder row. A mask's Alt-drag carries its layer's id under `maskType`, read back through `ProjectWorkspace::layerID` (a payload that is no id names no mask, an all-zero id included), and lands on the row under the pointer through `canCopyMask`. `QDrag::exec` needs the platform's drag loop, which `offscreen` has not: `LayerDragGestureTests` defines both `QDrag::exec` overloads in the test executable, where they take libQt6Gui's place (as `ProjectReplaceTests` does with libc), record what the drag offered and return `IgnoreAction`, so presses, thresholds and actions are tested to the call; `LayerDragTests` holds the drops and `LayerDragFixtures.h` what both share. The eye and thumbnail buttons live in `NativeLayerList+Buttons.cpp`. With Alt held the viewport's cursor follows the pointer (`cursorFor`): the clipping cursor over a row's strip (create or release, drawn as paths like the symbols, refused where `canToggleClippingMask` says no), the duplicate cursor over the rest of a row (a folder's strip included, since a folder clips nothing, 12.2a) and over a mask thumbnail, the arrow otherwise and with Ctrl; Alt or Ctrl pressed anywhere, and hover over any row or control (every cell and its children track the mouse), reach an application event filter installed while the list is shown, as Swift's `flagsChanged` monitor and tracking area; the list keeps the last hover point, so a modifier change is judged where the pointer sits (`QCursor::pos` is no witness offscreen), and a hover's own modifiers are the event's. - `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`. @@ -130,6 +130,7 @@ Platform adaptations, each forced by a missing Qt analog: - Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmentTests` (the model, manifest, store, seeds, the editor, the filters at a scale, the noise field, a scaled target, the canvas's zoom and a partial repaint's halo at 100%, 150% and 400%), `ImageTrimTests` (Swift's six, tolerance, the sheet, the controller through its sheet, a failure's alert), `InnerGlowTests` (Swift's, but the parity test, and the order), the new kinds in `LayerAdjustmentTests`, `AdjustmentEditorTests`, `AdjustmentLayerTests` and `AdjustmentPanelTests`, the glow in `LayerEffectsKernelTests`' replay, `LayerEffectsSurfaceTests`, `EffectsPreviewCacheTests`, `LayerEffectsTests` and `EffectsPanelTests`, version 9 across the store's tests, Trim in `CompositorMenusTests`. - The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`. - Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). A new front tab scrolls into view, posted as Swift posts its reveal, once the row is laid out; any other change keeps the offset, which Qt clamps, where the strip used to follow every layout (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`. +- The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu. - 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 0f4e35e..bbd6c4d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,6 +118,7 @@ add_library(compositor_core STATIC src/UI/NativeLayerList+Cell.cpp src/UI/NativeLayerList+Drag.cpp src/UI/NativeLayerList+Effects.cpp + src/UI/NativeLayerList+Menu.cpp src/UI/NavigationToolHeader.cpp src/UI/TransformInspector.cpp src/UI/EffectsSheet.cpp diff --git a/TASKS.md b/TASKS.md index fb1e633..638a6a8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -375,7 +375,9 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.9b Version 1.2.4, part two: stepped keyboard zoom (`383548c`, `8053fb6`), a steady tool rail and tab strip (`75c4219`). The project format's version in one place is 1.2.5's (`4cc9951`), moved to 12.10. - 12.9b round 1 (codex): FAIL, a spec mismatch and test gaps: tab titles centred where Swift's are leading; the zoom tolerance, the Meta exclusion, an unsaved long title's elision and the scroll test's unsaved edit were unpinned; and the menu already takes Ctrl with keypad minus, so the filter's zoom-out branch was unreachable in the app. Fixed: `TabTitle` paints from the left, the filter takes Zoom In's plus alone, the key test runs in the app's window; each mutant caught. - 12.9b round 2: PASS, no findings. -- [ ] 12.10 Versions 1.2.5–1.2.7: the project format's version in one place (`4cc9951`), the layer context menu without its dropped Layer Effects entry (`5d7adba`), copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews. +- [x] 12.10a Versions 1.2.5–1.2.7, part one: the project format's version in one place (`4cc9951`); the layer context menu, routed by the list, with its layer actions and validation (`6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`), without the Layer Effects entry (`5d7adba`). Codex review: 3 rounds, PASS. +- [ ] 12.10b Part two: copying and pasting whole layers within and between projects (`5ef5b0a`, `c873453`); duplicating every selected layer, stacked above the topmost, folders with Ctrl+J (`245db93`, `2f1537f`). +- [ ] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's. - [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG). - [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing. - [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects. diff --git a/src/IO/ProjectStore.cpp b/src/IO/ProjectStore.cpp index 6f70b28..84bc70c 100644 --- a/src/IO/ProjectStore.cpp +++ b/src/IO/ProjectStore.cpp @@ -62,7 +62,7 @@ void validate(const ProjectManifest &manifest) { if (manifest.format != QLatin1String("com.compositor.project")) throw ProjectError(ProjectError::Kind::invalid); - if (manifest.version < 1 || manifest.version > 9) + if (!ProjectManifest::supports(manifest.version)) throw ProjectError::unsupportedVersion(manifest.version); if (manifest.colorSpace != QLatin1String("sRGB")) throw ProjectError(ProjectError::Kind::invalid); @@ -256,7 +256,10 @@ ProjectError::ProjectError(Kind kind) : ProjectError(kind, std::nullopt, descrip ProjectError ProjectError::unsupportedVersion(qint64 version) { return ProjectError(Kind::version, version, - QStringLiteral("This project uses format version %1. This app supports versions 1–9.").arg(version)); + QStringLiteral("This project uses format version %1. This app supports versions %2–%3.") + .arg(version) + .arg(ProjectManifest::supported.first) + .arg(ProjectManifest::supported.second)); } ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description) @@ -332,7 +335,7 @@ ProjectSnapshot ProjectStore::load(const QString &path) const auto [format, version] = ProjectManifest::header(metadata); if (format != QLatin1String("com.compositor.project")) throw ProjectError(ProjectError::Kind::invalid); - if (version < 1 || version > 9) + if (!ProjectManifest::supports(version)) throw ProjectError::unsupportedVersion(version); const ProjectManifest manifest = ProjectManifest::decoded(metadata); validate(manifest); diff --git a/src/IO/ProjectStore.h b/src/IO/ProjectStore.h index 026560a..5ea9ce9 100644 --- a/src/IO/ProjectStore.h +++ b/src/IO/ProjectStore.h @@ -50,9 +50,13 @@ struct ProjectLayerRecord { QString uuidString(const QUuid &id); struct ProjectManifest { + // Swift's current and supported: what saves write, what loads accept. + static constexpr qint64 current = 9; + static constexpr std::pair<qint64, qint64> supported{1, current}; + static bool supports(qint64 version) { return version >= supported.first && version <= supported.second; } QString format = QStringLiteral("com.compositor.project"); // Swift's Int: a wild number must reach validation whole. - qint64 version = 9; + qint64 version = current; QString colorSpace = QStringLiteral("sRGB"); // Version-1 projects carry none: 72 pixels per inch. std::optional<double> resolution = std::nullopt; diff --git a/src/UI/NativeLayerList+Cell.cpp b/src/UI/NativeLayerList+Cell.cpp index 0812c60..c6fe09a 100644 --- a/src/UI/NativeLayerList+Cell.cpp +++ b/src/UI/NativeLayerList+Cell.cpp @@ -57,52 +57,6 @@ LayerCell::LayerCell(NativeLayerList &list) m_editor->setFont(name); m_editor->hide(); m_editor->installEventFilter(this); - menuAction(QStringLiteral("renameLayer"), QStringLiteral("Rename…"), [this] { - if (!m_list.session().canEditLayers()) - return; - m_list.session().setActiveLayerID(m_layerID); - m_list.session().setRenamingLayerID(m_layerID); - }); - menuAction(QStringLiteral("toggleVisibility"), QStringLiteral("Hide/Show Layer"), [this] { m_list.session().toggleLayerVisibility(m_layerID); }); - // With a selection, white hides it, black shows it alone. - menuAction(QStringLiteral("addWhiteMask"), QStringLiteral("Add White Mask"), [this] { - m_list.session().selectLayerTarget(m_layerID, false); - m_list.session().addMask(); - }); - menuAction(QStringLiteral("addBlackMask"), QStringLiteral("Add Black Mask"), [this] { - m_list.session().selectLayerTarget(m_layerID, false); - m_list.session().addMask(false); - }); - menuAction(QStringLiteral("toggleMask"), QStringLiteral("Enable/Disable Mask"), [this] { - m_list.session().selectLayerTarget(m_layerID, false); - m_list.session().toggleLayerMask(); - }); - menuAction(QStringLiteral("deleteMask"), QStringLiteral("Delete Mask"), [this] { - m_list.session().selectLayerTarget(m_layerID, false); - m_list.session().deleteLayerMask(); - }); - menuAction(QStringLiteral("releaseClippingMask"), QStringLiteral("Release Clipping Mask"), [this] { m_list.session().removeLiveMask(m_layerID); }); - menuAction(QStringLiteral("moveOut"), QStringLiteral("Move Out of Folder"), [this] { - m_list.session().selectLayer(m_layerID); - m_list.session().moveActiveLayerOutOfGroup(); - }); - menuAction(QStringLiteral("deleteLayer"), QStringLiteral("Delete Layer / Folder"), [this] { - // Part of a multi-selection, the whole selection goes. - EditorSession &session = m_list.session(); - if (session.selectedLayerIDs().size() > 1 && session.selectedLayerIDs().contains(m_layerID)) - session.deleteSelectedLayers(); - else - session.deleteLayer(m_layerID); - }); -} - -QAction *LayerCell::menuAction(const QString &name, const QString &title, const std::function<void()> &run) -{ - auto *action = new QAction(title, this); - action->setObjectName(name); - connect(action, &QAction::triggered, this, run); - addAction(action); - return action; } void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool visible) @@ -315,9 +269,10 @@ void LayerCell::mouseDoubleClickEvent(QMouseEvent *event) session.setRenamingLayerID(m_layerID); } +// The list routes the press and builds the menu. void LayerCell::contextMenuEvent(QContextMenuEvent *event) { - QMenu::exec(actions(), event->globalPos(), nullptr, this); + m_list.menuFor(*this, event->pos())->exec(event->globalPos()); } void LayerCell::beginRenaming() diff --git a/src/UI/NativeLayerList+Menu.cpp b/src/UI/NativeLayerList+Menu.cpp new file mode 100644 index 0000000..6c7bdd0 --- /dev/null +++ b/src/UI/NativeLayerList+Menu.cpp @@ -0,0 +1,101 @@ +#include "UI/NativeLayerList.h" +#include <QMenu> + +// Swift's table-level context menu (1.2.5): entries and validation. +std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id) +{ + EditorSession &session = m_session; + if (session.selectedLayerIDs().isEmpty()) + session.selectLayer(id); + auto menu = std::make_unique<QMenu>(this); + // A refused selection leaves no layer: the gate rests then. + const std::optional<ImageLayer> active = session.activeLayer(); + const bool editable = session.canEditLayers(); + const bool mask = active && active->mask; + const auto entry = [&](QMenu &into, const char *name, const QString &title, bool enabled, std::function<void()> run) { + QAction *action = into.addAction(title, this, std::move(run)); + action->setObjectName(QString::fromLatin1(name)); + action->setEnabled(enabled); + }; + // Swift's actions read the active layer when chosen. + const auto onActive = [&session](std::function<void(QUuid)> run) { + return [&session, run] { + if (const std::optional<QUuid> activeID = session.activeLayerID()) + run(*activeID); + }; + }; + entry(*menu, "duplicateLayer", QStringLiteral("Duplicate Layer"), editable, [&session] { session.duplicateActiveLayer(); }); + entry(*menu, "renameLayer", QStringLiteral("Rename…"), editable && session.selectedLayerIDs().size() == 1, onActive([&session](QUuid activeID) { + if (session.canEditLayers()) + session.setRenamingLayerID(activeID); + })); + // A chosen mask exists: Swift's `mask != nil` is dead. + const QString deleteTitle = session.isMaskSelected() ? QStringLiteral("Delete Mask") + : session.selectedLayerIDs().size() > 1 ? QStringLiteral("Delete Selected Layers") + : QStringLiteral("Delete Layer"); + entry(*menu, "deleteLayer", deleteTitle, editable, [&session] { session.deleteLayerOrMask(); }); + menu->addSeparator(); + entry(*menu, "clippingMask", active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask"), + active && session.canToggleClippingMask(active->id), onActive([&session](QUuid activeID) { session.toggleClippingMask(activeID); })); + entry(*menu, "groupLayers", QStringLiteral("Group Selected Layers"), + session.canEditLayers() && session.document() && session.document()->layers.size() < 10'000 && !session.selectedLayerIDs().isEmpty(), + [&session] { session.groupSelectedLayers(); }); + entry(*menu, "moveOut", QStringLiteral("Move Out of Folder"), session.canEditLayers() && active && active->parentID, + [&session] { session.moveActiveLayerOutOfGroup(); }); + entry(*menu, "mergeLayers", session.mergeTitle(), session.canMergeLayers(), [&session] { session.mergeLayers(); }); + menu->addSeparator(); + // The mask entries target the layer's pixels first, as Swift's. + const bool masking = session.canEditMask(); + const auto onPixels = [&session, onActive](std::function<void()> then) { + return onActive([&session, then](QUuid activeID) { + session.selectLayerTarget(activeID, false); + then(); + }); + }; + QMenu *add = menu->addMenu(QStringLiteral("Add Mask")); + add->menuAction()->setObjectName(QStringLiteral("addMask")); + add->menuAction()->setEnabled(masking && !mask); + entry(*add, "addWhiteMask", QStringLiteral("Reveal All (White)"), masking && !mask, onPixels([&session] { session.addMask(true); })); + entry(*add, "addBlackMask", QStringLiteral("Hide All (Black)"), masking && !mask, onPixels([&session] { session.addMask(false); })); + entry(*menu, "toggleMask", mask && !active->mask->isEnabled ? QStringLiteral("Enable Mask") : QStringLiteral("Disable Mask"), masking && mask, + onPixels([&session] { session.toggleLayerMask(); })); + entry(*menu, "deleteMask", QStringLiteral("Delete Mask"), masking && mask, onPixels([&session] { session.deleteLayerMask(); })); + entry(*menu, "linkMask", mask && !active->mask->isLinked ? QStringLiteral("Link Mask") : QStringLiteral("Unlink Mask"), + session.canEditLayers() && mask && !active->isGroup && !active->adjustment, + onActive([&session](QUuid activeID) { session.toggleMaskLink(activeID); })); + menu->addSeparator(); + entry(*menu, "layerVisibility", active && !active->isVisible ? QStringLiteral("Show Layer") : QStringLiteral("Hide Layer"), editable, + onActive([&session](QUuid activeID) { session.toggleLayerVisibility(activeID); })); + return menu; +} + +// Swift's menu(for:): the press chooses what the menu acts on. +std::unique_ptr<QMenu> NativeLayerList::menuFor(LayerCell &cell, QPoint cellPoint) +{ + // Swift's targetLayer: the session's calls below may rebuild the rows. + const QUuid id = cell.layerID(); + // Swift's table selects no row while an effect is chosen. + const bool rowSelected = !m_session.selectedEffect() && m_session.selectedLayerIDs().contains(id); + QWidget *under = cell.childAt(cellPoint); + // On an effect's row: that effect is chosen, rows untouched. + for (QWidget *each = under; each && each != &cell; each = each->parentWidget()) { + if (auto *effect = qobject_cast<LayerEffectRow *>(each)) { + effect->select(false); + return contextMenu(id); + } + } + // Each call below drops a chosen effect itself. + const auto *thumbnail = qobject_cast<LayerThumbnailButton *>(under); + const bool maskThumbnail = thumbnail && thumbnail->isMaskTarget, layerThumbnail = thumbnail && !thumbnail->isMaskTarget; + if (rowSelected) { + if (maskThumbnail || layerThumbnail) + m_session.selectLayerTarget(id, maskThumbnail); + else + m_session.selectLayers(QSet<QUuid>(m_session.selectedLayerIDs()), id); + } else { + m_session.selectLayers({id}, id); + focusList(); + m_session.selectLayerTarget(id, maskThumbnail); + } + return contextMenu(id); +} diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h index 437cf73..33028f8 100644 --- a/src/UI/NativeLayerList.h +++ b/src/UI/NativeLayerList.h @@ -12,6 +12,7 @@ #include <memory> class NativeLayerList; +class QMenu; // A layer's eye: a press toggles, a drag swipes rows. class EyeSwipeButton : public QToolButton { @@ -145,7 +146,6 @@ protected: private: void relayout(); void endRenaming(bool keeping); - QAction *menuAction(const QString &name, const QString &title, const std::function<void()> &run); NativeLayerList &m_list; EyeSwipeButton *const m_eye; @@ -226,6 +226,9 @@ public: // Photoshop's strip along the bottom of a row. static bool isClippingZone(const LayerCell &cell, QPoint cellPoint); void focusList(); + // Swift's contextMenu(for:) and menu(for:): entries, then the press's routing. + std::unique_ptr<QMenu> contextMenu(QUuid id); + std::unique_ptr<QMenu> menuFor(LayerCell &cell, QPoint cellPoint); // What a drag of this row carries: its selection. std::unique_ptr<QMimeData> dragData(const LayerCell &cell) const; void startDrag(LayerCell &cell, Qt::KeyboardModifiers modifiers); diff --git a/tests/LayerContextMenuEdgeTests.cpp b/tests/LayerContextMenuEdgeTests.cpp new file mode 100644 index 0000000..aeb371b --- /dev/null +++ b/tests/LayerContextMenuEdgeTests.cpp @@ -0,0 +1,175 @@ +#include "LayerContextMenuFixtures.h" +#include "ProjectFixtures.h" +#include "SessionFixtures.h" +#include <QLineEdit> + +// The menu's gates at their edges, and its routing. +class LayerContextMenuEdgeTests : public QObject { + Q_OBJECT +private slots: + void aRefusedSelectionLeavesTheEntriesAtRest(); + void actionsReadTheActiveLayerWhenChosen(); + void aChosenEffectsLayerRowCountsAsUnselected(); + void theGatesHoldAtTheirEdges(); + void theMenuTargetsThePressedRowThroughARebuild(); + void aStaleEffectLeavesItsRowSelected(); + void anEntryWithNoActiveLayerDoesNothing(); +}; + +void LayerContextMenuEdgeTests::aRefusedSelectionLeavesTheEntriesAtRest() +{ + // Levels open, nothing selected, then an effect's row. + const auto session = paintedPair(); + Shown shown(*session); + session->beginLevels(); + QVERIFY(session->levels()); + session->selectLayers({}, std::nullopt); + QVERIFY(session->selectedLayerIDs().isEmpty() && !session->activeLayerID()); + auto *effect = shown.row(0).findChild<LayerEffectRow *>(); + QVERIFY(effect); + const std::unique_ptr<QMenu> menu = shown.list.menuFor(shown.row(0), effect->mapTo(&shown.row(0), effect->rect().center())); + QVERIFY(!session->activeLayerID()); + for (const char *name : {"duplicateLayer", "renameLayer", "deleteLayer", "clippingMask", "groupLayers", "moveOut", "addMask", "toggleMask", + "deleteMask", "linkMask", "layerVisibility"}) + QVERIFY2(!item(*menu, name).isEnabled(), name); + session->cancelLevels(); +} + +void LayerContextMenuEdgeTests::actionsReadTheActiveLayerWhenChosen() +{ + const auto session = threeLayers(); + Shown shown(*session); + const QUuid a = shown.row(0).layerID(), b = shown.row(1).layerID(); + // Built on A; B is active when chosen. + std::unique_ptr<QMenu> menu = shown.menu(0); + session->selectLayer(b); + item(*menu, "layerVisibility").trigger(); + QVERIFY(layerWith(*session, a).isVisible && !layerWith(*session, b).isVisible); + menu = shown.menu(0); + session->selectLayer(b); + item(*menu, "addWhiteMask").trigger(); + QVERIFY(!layerWith(*session, a).mask && layerWith(*session, b).mask && session->activeLayerID() == b); + menu = shown.menu(0); + session->selectLayer(b); + item(*menu, "clippingMask").trigger(); + QVERIFY(!layerWith(*session, a).maskSourceID && layerWith(*session, b).maskSourceID); + // Rename asks the gate again, as Swift's. + menu = shown.menu(0); + session->setIsProjectBusy(true); + item(*menu, "renameLayer").trigger(); + QVERIFY(!session->renamingLayerID()); + session->setIsProjectBusy(false); +} + +void LayerContextMenuEdgeTests::aChosenEffectsLayerRowCountsAsUnselected() +{ + // Swift's table shows no row while an effect is chosen. + const auto session = paintedPair(); + Shown shown(*session); + const QUuid top = shown.row(0).layerID(); + session->selectEffect(LayerEffectKind::stroke, top, false); + QVERIFY(session->selectedLayerIDs().contains(top) && session->effectSelection()); + QLineEdit field; + field.show(); + field.activateWindow(); + field.setFocus(); + QTRY_VERIFY(field.hasFocus()); + shown.list.activateWindow(); + QVERIFY(shown.menu(0)); + QTRY_VERIFY(shown.list.hasFocus()); + QVERIFY(!session->effectSelection() && session->selectedLayerIDs() == QSet<QUuid>{top}); +} + +void LayerContextMenuEdgeTests::theGatesHoldAtTheirEdges() +{ + // Nothing selected: the menu selects its row first. + const auto session = threeLayers(); + Shown shown(*session); + const QUuid a = shown.row(0).layerID(), b = shown.row(1).layerID(); + session->selectLayers({}, std::nullopt); + QVERIFY(shown.list.contextMenu(a)); + QCOMPARE(session->selectedLayerIDs(), QSet<QUuid>{a}); + // Two layers chosen: no mask entry. + session->selectLayers({a, b}, a); + std::unique_ptr<QMenu> menu = shown.list.contextMenu(a); + for (const char *name : {"addMask", "addWhiteMask", "addBlackMask"}) + QVERIFY2(!item(*menu, name).isEnabled(), name); + // A folder's mask never links. + session->groupSelectedLayers(); + session->addMask(true); + QVERIFY(session->activeLayer().value().isGroup && session->activeLayer().value().mask); + menu = shown.list.contextMenu(session->activeLayerID().value()); + QVERIFY(!item(*menu, "linkMask").isEnabled() && item(*menu, "deleteMask").isEnabled()); + // Grouping stops at ten thousand layers. + ProjectSnapshot crowded = twoLayers(); + while (crowded.manifest.layers.size() < 9'999) + crowded.manifest.layers.push_back({.id = QUuid::createUuid(), .name = "n", .isVisible = true, .transform = {.origin = {0, 0}, .size = {1, 1}}, + .imageFile = std::nullopt}); + EditorSession packed; + packed.installProject(crowded, QString()); + NativeLayerList list(packed); + const QUuid last = packed.document().value().layers.back().id; + packed.selectLayer(last); + QVERIFY(item(*list.contextMenu(last), "groupLayers").isEnabled()); + packed.addBlankLayer(); + QCOMPARE(packed.document().value().layers.size(), size_t(10'000)); + QVERIFY(!item(*list.contextMenu(packed.activeLayerID().value()), "groupLayers").isEnabled()); +} + +void LayerContextMenuEdgeTests::theMenuTargetsThePressedRowThroughARebuild() +{ + // Committing new text rebuilds the rows under the press. + const auto session = threeLayers(); + Shown shown(*session); + const QUuid pressed = shown.row(1).layerID(); + session->selectTool(NavigationTool::type); + session->beginText(QPointF(10, 10), true); + session->changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("new text"); }); + QVERIFY(session->textDraft()); + QPointer<LayerCell> cell = &shown.row(1); + const std::unique_ptr<QMenu> menu = shown.list.menuFor(*cell, QPoint(cell->width() - 20, 20)); + QVERIFY(!cell && !session->textDraft()); + QCOMPARE(session->document().value().layers.size(), size_t(4)); + QCOMPARE(session->activeLayerID().value(), pressed); + QCOMPARE(session->selectedLayerIDs(), QSet<QUuid>{pressed}); + item(*menu, "layerVisibility").trigger(); + QVERIFY(!layerWith(*session, pressed).isVisible); +} + +void LayerContextMenuEdgeTests::aStaleEffectLeavesItsRowSelected() +{ + // Undo takes the effect; its row shows selected again. + const auto session = paintedPair(); + Shown shown(*session); + const QUuid top = shown.row(0).layerID(); + session->selectEffect(LayerEffectKind::stroke, top, false); + session->undo(); + QVERIFY(session->effectSelection() && !session->selectedEffect()); + session->setRenamingLayerID(top); + auto *field = shown.row(0).findChild<QLineEdit *>(); + QTRY_VERIFY(field && field->hasFocus()); + field->setText(QStringLiteral("Rename pending")); + const std::unique_ptr<QMenu> menu = shown.menu(0); + QVERIFY(field->hasFocus()); + QCOMPARE(session->renamingLayerID(), std::optional(top)); + QCOMPARE(layerWith(*session, top).name, QString("Red")); + QVERIFY(!item(*menu, "duplicateLayer").isEnabled()); +} + +void LayerContextMenuEdgeTests::anEntryWithNoActiveLayerDoesNothing() +{ + const auto session = threeLayers(); + Shown shown(*session); + std::unique_ptr<QMenu> menu = shown.menu(0); + QVERIFY(item(*menu, "layerVisibility").isEnabled()); + session->selectLayers({}, std::nullopt); + QVERIFY(!session->activeLayerID()); + const int steps = session->history.undoCount(); + item(*menu, "layerVisibility").trigger(); + for (const ImageLayer &layer : session->document().value().layers) + QVERIFY(layer.isVisible); + QCOMPARE(session->history.undoCount(), steps); +} + +QTEST_MAIN(LayerContextMenuEdgeTests) +#include "LayerContextMenuEdgeTests.moc" diff --git a/tests/LayerContextMenuFixtures.h b/tests/LayerContextMenuFixtures.h new file mode 100644 index 0000000..b47cf75 --- /dev/null +++ b/tests/LayerContextMenuFixtures.h @@ -0,0 +1,63 @@ +#pragma once +#include "UI/NativeLayerList.h" +#include <QMenu> +#include <QtTest> +#include <memory> +#include <stdexcept> + +// What the context menu tests share. +namespace { +// Two red pixel layers, the top one with a stroke. +inline std::unique_ptr<EditorSession> paintedPair() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(8, 8); + for (int layer = 0; layer < 2; ++layer) { + QImage pixels(8, 8, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::red); + session->insert(ImportedImage(pixels, pixels, QStringLiteral("Red"))); + } + session->setEffects(LayerEffects{.stroke = StrokeEffect()}); + return session; +} + +// Swift's sessionWithThreeLayers: three blank layers, 800 by 600. +inline std::unique_ptr<EditorSession> threeLayers() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(800, 600); + for (int layer = 0; layer < 3; ++layer) + session->addBlankLayer(); + return session; +} + +struct Shown { + NativeLayerList list; + explicit Shown(EditorSession &session) : list(session) + { + list.resize(252, 400); + list.show(); + if (!QTest::qWaitForWindowActive(&list)) + throw std::runtime_error("the list never became active"); + } + LayerCell &row(int index) { return *list.cells().at(size_t(index)); } + // A press on the row's name, away from every control. + std::unique_ptr<QMenu> menu(int index) { return list.menuFor(row(index), QPoint(row(index).width() - 20, 20)); } +}; + +inline QAction &item(QMenu &menu, const char *name) +{ + auto *found = menu.findChild<QAction *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; +} + +inline QStringList titles(const QMenu &menu) +{ + QStringList result; + for (const QAction *action : menu.actions()) + result << (action->isSeparator() ? QStringLiteral("-") : action->text()); + return result; +} +} diff --git a/tests/LayerContextMenuTests.cpp b/tests/LayerContextMenuTests.cpp new file mode 100644 index 0000000..4866771 --- /dev/null +++ b/tests/LayerContextMenuTests.cpp @@ -0,0 +1,316 @@ +#include "LayerContextMenuFixtures.h" +#include "SessionFixtures.h" +#include <QApplication> +#include <QMenu> +#include <QTimer> +#include <QtTest> + +// Swift's layer context menu tests (1.2.5), on the list. + +class LayerContextMenuTests : public QObject { + Q_OBJECT +private slots: + void testContextMenuContainsCoreLayerActions(); + void testRightClickOnUnselectedLayerSelectsIt(); + void testRightClickInsideMultiSelectionPreservesSelection(); + void testRightClickOutsideRowsReturnsNoMenu(); + void testContextMenuDuplicateUsesExistingDuplicateOperation(); + void testContextMenuMaskActionsFollowLayerState(); + void testContextMenuShowsCreateOrReleaseClippingAction(); + void testContextMenuUsesExistingMergeTitleAndAction(); + void testContextMenuActionsPreserveUndoRedo(); + void entriesFollowTheSelectionAndTheGates(); + void thumbnailsAndEffectsChooseWhatTheMenuTargets(); + void testContextMenuDuplicateFolderPreservesHierarchy(); +}; + +void LayerContextMenuTests::testContextMenuContainsCoreLayerActions() +{ + const auto session = threeLayers(); + Shown shown(*session); + const std::unique_ptr<QMenu> menu = shown.menu(0); + // Swift's order, the Layer Effects entry left out (5d7adba). + QCOMPARE(titles(*menu), (QStringList{"Duplicate Layer", "Rename…", "Delete Layer", "-", "Create Clipping Mask", "Group Selected Layers", + "Move Out of Folder", "Merge Down", "-", "Add Mask", "Disable Mask", "Delete Mask", "Unlink Mask", "-", + "Hide Layer"})); + QMenu *add = item(*menu, "addMask").menu(); + QVERIFY(add); + QCOMPARE(titles(*add), (QStringList{"Reveal All (White)", "Hide All (Black)"})); +} + +void LayerContextMenuTests::testRightClickOnUnselectedLayerSelectsIt() +{ + const auto session = threeLayers(); + Shown shown(*session); + const QUuid a = shown.row(0).layerID(), b = shown.row(1).layerID(); + session->selectLayers({a}, a); + QVERIFY(shown.menu(1)); + QCOMPARE(session->selectedLayerIDs(), QSet<QUuid>{b}); + QCOMPARE(session->activeLayerID(), std::optional(b)); + // The list takes the keys, as Swift's first responder. + QTRY_VERIFY(shown.list.hasFocus()); + // A real right-click shows the menu, for the row pressed. + QStringList shownTitles; + QTimer::singleShot(0, [&shownTitles] { + auto *popup = qobject_cast<QMenu *>(QApplication::activePopupWidget()); + if (popup) { + shownTitles = titles(*popup); + popup->close(); + } + }); + LayerCell &third = shown.row(2); + QContextMenuEvent click(QContextMenuEvent::Mouse, QPoint(third.width() - 20, 20), third.mapToGlobal(QPoint(third.width() - 20, 20))); + QApplication::sendEvent(&third, &click); + QCOMPARE(shownTitles.value(0), QString("Duplicate Layer")); + QCOMPARE(session->activeLayerID(), std::optional(third.layerID())); +} + +void LayerContextMenuTests::testRightClickInsideMultiSelectionPreservesSelection() +{ + const auto session = threeLayers(); + Shown shown(*session); + const QSet<QUuid> all{shown.row(0).layerID(), shown.row(1).layerID(), shown.row(2).layerID()}; + session->selectLayers(all, shown.row(0).layerID()); + QVERIFY(shown.menu(1)); + QCOMPARE(session->selectedLayerIDs(), all); + QCOMPARE(session->activeLayerID(), std::optional(shown.row(1).layerID())); +} + +void LayerContextMenuTests::testRightClickOutsideRowsReturnsNoMenu() +{ + const auto session = threeLayers(); + Shown shown(*session); + const QSet<QUuid> before = session->selectedLayerIDs(); + // Below every row: no menu shows, the rows stay. + QContextMenuEvent below(QContextMenuEvent::Mouse, QPoint(100, 380), shown.list.viewport()->mapToGlobal(QPoint(100, 380))); + QApplication::sendEvent(shown.list.viewport(), &below); + QVERIFY(!QApplication::activePopupWidget()); + QCOMPARE(session->selectedLayerIDs(), before); +} + +void LayerContextMenuTests::testContextMenuDuplicateUsesExistingDuplicateOperation() +{ + const auto session = threeLayers(); + Shown shown(*session); + const ImageLayer target = session->activeLayer().value(); + const size_t count = session->document().value().layers.size(); + const int steps = session->history.undoCount(); + item(*shown.menu(0), "duplicateLayer").trigger(); + const std::vector<ImageLayer> &layers = session->document().value().layers; + QCOMPARE(layers.size(), count + 1); + QVERIFY(std::any_of(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return layer.id == target.id; })); + const ImageLayer copy = session->activeLayer().value(); + QVERIFY(copy.id != target.id); + QCOMPARE(copy.name, target.name + " copy"); + QCOMPARE(session->history.undoCount(), steps + 1); + QCOMPARE(session->history.undoName(), QString("Duplicate Layer")); +} + +void LayerContextMenuTests::testContextMenuMaskActionsFollowLayerState() +{ + const auto session = threeLayers(); + Shown shown(*session); + std::unique_ptr<QMenu> menu = shown.menu(0); + QVERIFY(item(*menu, "addMask").isEnabled() && !item(*menu, "deleteMask").isEnabled() && !item(*menu, "toggleMask").isEnabled()); + item(*menu, "addWhiteMask").trigger(); + QVERIFY(session->activeLayer().value().mask && session->activeLayer().value().mask.value().isEnabled); + menu = shown.menu(0); + QVERIFY(!item(*menu, "addMask").isEnabled() && !item(*menu, "addWhiteMask").isEnabled() && !item(*menu, "addBlackMask").isEnabled()); + QVERIFY(item(*menu, "toggleMask").isEnabled() && item(*menu, "deleteMask").isEnabled()); + QCOMPARE(item(*menu, "toggleMask").text(), QString("Disable Mask")); + item(*menu, "toggleMask").trigger(); + QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + menu = shown.menu(0); + QCOMPARE(item(*menu, "toggleMask").text(), QString("Enable Mask")); + // Link follows the mask; the entries target the pixels first. + QCOMPARE(item(*menu, "linkMask").text(), QString("Unlink Mask")); + item(*menu, "linkMask").trigger(); + QVERIFY(!session->activeLayer().value().mask.value().isLinked); + menu = shown.menu(0); + QCOMPARE(item(*menu, "linkMask").text(), QString("Link Mask")); + // With the mask chosen, an entry targets the pixels again. + session->selectLayerTarget(session->activeLayerID().value(), true); + item(*menu, "toggleMask").trigger(); + QVERIFY(session->activeLayer().value().mask.value().isEnabled && !session->isMaskSelected()); + session->selectLayerTarget(session->activeLayerID().value(), true); + item(*menu, "deleteMask").trigger(); + QVERIFY(!session->activeLayer().value().mask && !session->isMaskSelected()); + // Black hides all. + item(*shown.menu(0), "addBlackMask").trigger(); + QCOMPARE(session->activeLayer().value().mask.value().asset.image().pixelColor(0, 0), QColor(0, 0, 0)); +} + +void LayerContextMenuTests::testContextMenuShowsCreateOrReleaseClippingAction() +{ + const auto session = threeLayers(); + Shown shown(*session); + std::unique_ptr<QMenu> menu = shown.menu(0); + QCOMPARE(item(*menu, "clippingMask").text(), QString("Create Clipping Mask")); + QVERIFY(item(*menu, "clippingMask").isEnabled()); + item(*menu, "clippingMask").trigger(); + QVERIFY(session->activeLayer().value().maskSourceID); + menu = shown.menu(0); + QCOMPARE(item(*menu, "clippingMask").text(), QString("Release Clipping Mask")); + QVERIFY(item(*menu, "clippingMask").isEnabled()); + item(*menu, "clippingMask").trigger(); + QVERIFY(!session->activeLayer().value().maskSourceID); + // The bottom layer has nothing to clip to. + QVERIFY(!item(*shown.menu(2), "clippingMask").isEnabled()); +} + +void LayerContextMenuTests::testContextMenuUsesExistingMergeTitleAndAction() +{ + const auto session = threeLayers(); + Shown shown(*session); + std::unique_ptr<QMenu> menu = shown.menu(0); + QCOMPARE(item(*menu, "mergeLayers").text(), QString("Merge Down")); + QCOMPARE(item(*menu, "mergeLayers").isEnabled(), session->canMergeLayers()); + session->selectLayers({shown.row(0).layerID(), shown.row(1).layerID()}, shown.row(0).layerID()); + menu = shown.menu(0); + QCOMPARE(item(*menu, "mergeLayers").text(), QString("Merge Layers")); + QVERIFY(item(*menu, "mergeLayers").isEnabled() && session->canMergeLayers()); + const size_t before = session->document().value().layers.size(); + item(*menu, "mergeLayers").trigger(); + QCOMPARE(session->document().value().layers.size(), before - 1); +} + +void LayerContextMenuTests::testContextMenuActionsPreserveUndoRedo() +{ + const auto session = threeLayers(); + Shown shown(*session); + const std::vector<ImageLayer> initial = session->document().value().layers; + item(*shown.menu(0), "duplicateLayer").trigger(); + QCOMPARE(session->document().value().layers.size(), size_t(4)); + session->undo(); + QVERIFY(session->document().value().layers == initial); + session->redo(); + QCOMPARE(session->document().value().layers.size(), size_t(4)); + session->undo(); + item(*shown.menu(0), "addWhiteMask").trigger(); + QVERIFY(session->activeLayer().value().mask); + session->undo(); + QVERIFY(!session->activeLayer().value().mask); + const bool wasVisible = session->activeLayer().value().isVisible; + QCOMPARE(item(*shown.menu(0), "layerVisibility").text(), QString("Hide Layer")); + item(*shown.menu(0), "layerVisibility").trigger(); + QCOMPARE(session->activeLayer().value().isVisible, !wasVisible); + QCOMPARE(item(*shown.menu(0), "layerVisibility").text(), QString("Show Layer")); + session->undo(); + QCOMPARE(session->activeLayer().value().isVisible, wasVisible); +} + +void LayerContextMenuTests::entriesFollowTheSelectionAndTheGates() +{ + const auto session = threeLayers(); + Shown shown(*session); + // Rename for one layer; Delete names a selection or mask. + std::unique_ptr<QMenu> menu = shown.menu(0); + QVERIFY(item(*menu, "renameLayer").isEnabled() && item(*menu, "deleteLayer").text() == "Delete Layer"); + item(*menu, "renameLayer").trigger(); + QCOMPARE(session->renamingLayerID(), std::optional(shown.row(0).layerID())); + session->setRenamingLayerID(std::nullopt); + session->selectLayers({shown.row(0).layerID(), shown.row(1).layerID()}, shown.row(0).layerID()); + menu = shown.menu(1); + QVERIFY(!item(*menu, "renameLayer").isEnabled() && item(*menu, "deleteLayer").text() == "Delete Selected Layers"); + item(*menu, "deleteLayer").trigger(); + QCOMPARE(session->document().value().layers.size(), size_t(1)); + // A chosen mask names Delete Mask. + item(*shown.menu(0), "addWhiteMask").trigger(); + session->selectLayerTarget(shown.row(0).layerID(), true); + menu = shown.list.contextMenu(shown.row(0).layerID()); + QCOMPARE(item(*menu, "deleteLayer").text(), QString("Delete Mask")); + // Group, then Move Out for the layer inside. + QVERIFY(item(*menu, "groupLayers").isEnabled() && !item(*menu, "moveOut").isEnabled()); + const QUuid child = shown.row(0).layerID(); + item(*menu, "groupLayers").trigger(); + QVERIFY(layerWith(*session, child).parentID.has_value()); + session->selectLayer(child); + menu = shown.list.contextMenu(child); + QVERIFY(item(*menu, "moveOut").isEnabled()); + item(*menu, "moveOut").trigger(); + QVERIFY(!session->activeLayer().value().parentID); + // While a project is busy, the layer entries rest. + session->groupSelectedLayers(); + session->selectLayer(child); + session->addBlankLayer(); + const QUuid blank = session->activeLayerID().value(); + menu = shown.list.contextMenu(blank); + QVERIFY(item(*menu, "moveOut").isEnabled() && item(*menu, "mergeLayers").isEnabled()); + session->setIsProjectBusy(true); + menu = shown.list.contextMenu(blank); + for (const char *name : {"duplicateLayer", "renameLayer", "deleteLayer", "groupLayers", "moveOut", "mergeLayers", "linkMask", "layerVisibility"}) + QVERIFY2(!item(*menu, name).isEnabled(), name); + session->setIsProjectBusy(false); + // An adjustment's mask never links, as Swift's. + session->addAdjustment(AdjustmentKind::levels); + session->setAdjustmentEditingID(std::nullopt); + session->addMask(true); + menu = shown.list.contextMenu(session->activeLayerID().value()); + QVERIFY(session->activeLayer().value().mask && !item(*menu, "linkMask").isEnabled() && item(*menu, "deleteMask").isEnabled()); +} + +void LayerContextMenuTests::thumbnailsAndEffectsChooseWhatTheMenuTargets() +{ + const auto session = threeLayers(); + session->addMask(true); + Shown shown(*session); + const QUuid top = shown.row(0).layerID(); + const auto at = [&](const char *name) { + auto *button = shown.row(0).findChild<QWidget *>(QString::fromLatin1(name)); + return button->mapTo(&shown.row(0), button->rect().center()); + }; + // A selected row's mask thumbnail targets its mask; pixels, pixels. + shown.list.menuFor(shown.row(0), at("maskThumbnail")); + QVERIFY(session->isMaskSelected()); + shown.list.menuFor(shown.row(0), at("layerThumbnail")); + QVERIFY(!session->isMaskSelected()); + // An unselected row's mask: it alone, its mask. + session->selectLayer(shown.row(1).layerID()); + shown.list.menuFor(shown.row(0), at("maskThumbnail")); + QVERIFY(session->selectedLayerIDs() == QSet<QUuid>{top} && session->isMaskSelected()); + // An effect's row chooses that effect; effects need pixels. + EditorSession painted; + painted.createDocument(8, 8); + for (int layer = 0; layer < 2; ++layer) { + QImage pixels(8, 8, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::red); + painted.insert(ImportedImage(pixels, pixels, QStringLiteral("Red"))); + } + painted.setEffects(LayerEffects{.stroke = StrokeEffect()}); + Shown both(painted); + painted.selectLayer(both.row(1).layerID()); + auto *effect = both.row(0).findChild<LayerEffectRow *>(); + QVERIFY(effect); + QVERIFY(both.list.menuFor(both.row(0), effect->mapTo(&both.row(0), effect->rect().center()))); + QVERIFY(painted.effectSelection() && painted.effectSelection().value().kind == LayerEffectKind::stroke); + QCOMPARE(painted.effectSelection().value().layerID, both.row(0).layerID()); + // Anywhere else drops the chosen effect. + both.menu(1); + QVERIFY(!painted.effectSelection()); +} + +void LayerContextMenuTests::testContextMenuDuplicateFolderPreservesHierarchy() +{ + const auto session = threeLayers(); + QSet<QUuid> all; + for (const ImageLayer &layer : session->document().value().layers) + all.insert(layer.id); + session->selectLayers(all, session->activeLayerID()); + session->groupSelectedLayers(); + const QUuid folder = session->activeLayerID().value(); + Shown shown(*session); + std::unique_ptr<QMenu> menu = shown.list.contextMenu(folder); + QVERIFY(item(*menu, "duplicateLayer").isEnabled()); + const size_t children = session->document().value().layers.size() - 1; + item(*menu, "duplicateLayer").trigger(); + const ImageLayer copy = session->activeLayer().value(); + QVERIFY(copy.id != folder && copy.isGroup); + size_t copied = 0; + for (const ImageLayer &layer : session->document().value().layers) + copied += layer.parentID == copy.id; + QCOMPARE(copied, children); + QCOMPARE(session->document().value().layers.size(), 2 * children + 2); +} + +QTEST_MAIN(LayerContextMenuTests) +#include "LayerContextMenuTests.moc" diff --git a/tests/LayersPanelTests.cpp b/tests/LayersPanelTests.cpp index c8b9aa9..1b1bb01 100644 --- a/tests/LayersPanelTests.cpp +++ b/tests/LayersPanelTests.cpp @@ -6,6 +6,7 @@ #include <QSettings> #include <QStackedWidget> #include <QToolButton> +#include <QMenu> #include <QtTest> #include "SessionFixtures.h" @@ -200,13 +201,8 @@ void LayersPanelTests::theMaskButtonUsesTheSelection() // The row's menu: black shows only the selection. session.undo(); QVERIFY(session.selection().has_value()); - QAction *black = nullptr; - for (QAction *action : panel.list().cells().at(0)->actions()) { - if (action->objectName() == "addBlackMask") - black = action; - } - QVERIFY(black); - black->trigger(); + LayerCell &cell = *panel.list().cells().at(0); + panel.list().menuFor(cell, QPoint(cell.width() - 20, 20))->findChild<QAction *>("addBlackMask")->trigger(); QCOMPARE(session.history.undoName(), QString("Add Mask from Selection")); QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 255); QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 0); diff --git a/tests/NativeLayerListTests.cpp b/tests/NativeLayerListTests.cpp index 0b21494..63642b6 100644 --- a/tests/NativeLayerListTests.cpp +++ b/tests/NativeLayerListTests.cpp @@ -1,6 +1,7 @@ #include "UI/NativeLayerList.h" #include "SelectionFixtures.h" #include <QAction> +#include <QMenu> #include <QtTest> // The layer rows: what they show, select, rename and key. @@ -261,7 +262,10 @@ void NativeLayerListTests::disclosureLinkAndTheMenuReachTheSession() QCOMPARE(shown.list.cells().size(), size_t(3)); // The green row's menu acts on green, whatever is active. session.selectLayer(blue); - const auto trigger = [&](int row, const char *name) { shown.row(row).findChild<QAction *>(QString::fromLatin1(name))->trigger(); }; + const auto trigger = [&](int row, const char *name) { + LayerCell &cell = shown.row(row); + shown.list.menuFor(cell, QPoint(cell.width() - 20, 20))->findChild<QAction *>(QString::fromLatin1(name))->trigger(); + }; trigger(1, "addBlackMask"); QVERIFY(layerWith(session, green).mask.has_value()); QCOMPARE(layerWith(session, green).mask.value().asset.image().pixelColor(0, 0), QColor(0, 0, 0)); @@ -274,7 +278,7 @@ void NativeLayerListTests::disclosureLinkAndTheMenuReachTheSession() QVERIFY(!layerWith(session, green).mask.has_value()); trigger(1, "addWhiteMask"); QCOMPARE(layerWith(session, green).mask.value().asset.image().pixelColor(0, 0), QColor(255, 255, 255)); - trigger(1, "toggleVisibility"); + trigger(1, "layerVisibility"); QVERIFY(!layerWith(session, green).isVisible); QCOMPARE(layerWith(session, green).parentID, std::optional(folder)); trigger(1, "moveOut"); diff --git a/tests/ProjectTests.cpp b/tests/ProjectTests.cpp index 0ca8f97..b7f8e58 100644 --- a/tests/ProjectTests.cpp +++ b/tests/ProjectTests.cpp @@ -8,6 +8,7 @@ class ProjectTests : public QObject { Q_OBJECT private slots: + void theCurrentFormatVersionIsOneTheReaderAccepts(); void projectRoundTripSurvivesPackageMove(); void masksAndLargeImagesRoundTrip(); void overwriteReplacesPackageAndDropsRemovedAssets(); @@ -20,6 +21,16 @@ private slots: void savesAndLoadsAreLogged(); }; +// Saves write current; loads take supported: they must agree. +void ProjectTests::theCurrentFormatVersionIsOneTheReaderAccepts() +{ + QVERIFY(ProjectManifest::supports(ProjectManifest::current)); + QVERIFY((ProjectManifest::supported == std::pair<qint64, qint64>(1, 9))); + QCOMPARE(ProjectManifest{}.version, ProjectManifest::current); + QVERIFY(!ProjectManifest::supports(0) && !ProjectManifest::supports(ProjectManifest::current + 1)); + QCOMPARE(QString(ProjectError::unsupportedVersion(10).what()), QString("This project uses format version 10. This app supports versions 1–9.")); +} + void ProjectTests::projectRoundTripSurvivesPackageMove() { QTemporaryDir root; diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 26663b0..cb8a54e 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -138,6 +138,8 @@ compositor_test(MaskTracingTests) compositor_test(MaskTransformTests) compositor_test(ProjectManifestTests) compositor_test(NativeLayerListTests) +compositor_test(LayerContextMenuTests) +compositor_test(LayerContextMenuEdgeTests) compositor_test(NavigationToolHeaderTests) compositor_test(NewCanvasSheetTests) compositor_test(OmarchyThemeTests)