12.2a: folders duplicate with their contents, folder opacity, format version 8, View > Snap (Compositor 1.1.5–1.1.6)
This commit is contained in:
1 parent
ee34c338d4
commit
f1b2c65c1e
52 files changed
+909
-239
No files matched your search
@@ -57,9 +57,9 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- `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.
|
||||
- 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, a copy with a folder among the rows is refused, every row must pass `canPlaceLayer`; `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 that is no folder 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; `duplicateLayer` selects, duplicates and places the copy inside one transaction, refusing folders and a place `canPlaceLayer` refuses.
|
||||
- `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`; every target yields a copy (`duplicateActiveLayer` refuses nothing a target passes), so Swift's `copies.isEmpty` branch and its `copy != id` test are left out. `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`.
|
||||
- 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`.
|
||||
- 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`.
|
||||
- `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.
|
||||
@@ -82,7 +82,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`: sigma a tenth of the brush within 1.5 and 30; the layer blurred with clear past the document's edge; a mask on its edge tone, its placed box black under its coverage, blurred with the edge repeated. Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact; it costs 20–110 ms for a 4K document on 32 cores (sigma 1.5 to 30), paid as a Blur stroke begins, since Swift's sample is taken whole. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` and `blurSample` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre).
|
||||
- The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr<BrushStroke>`, as a pixel move's, because the canvas draws it while the commit renders, and the raster's pointer stands for Swift's `gradientEdit === edit` (a new line on the same target keeps the raster, as Swift keeps the edit). `commitGradient` takes a completion callback, posted on every path; `resolveGradient` posts the commit, Swift's `Task`, so a switch of tool, layer or target applies it once the caller returns; `invertPixels` and `beginFilter` commit it first and call themselves again from its callback, as Swift awaits it. `fillGradient` fills the canvas through `paintCanvas` with a `QLinearGradient`, or a `QRadialGradient` centred on the start with the line as its radius; Qt's pad spread is CoreGraphics' `drawsBeforeStartLocation` and `drawsAfterEndLocation`, and two stops interpolate alike in both. `gradientColors` makes a mask's colours gray from the palette's red, where Swift makes `DeviceGray` colours. `undo` first discards a pending gradient and `canUndo` counts one, as Swift's. The palette's model came forward from 8.10 into `ColorPalette.cpp` (`PaletteColor`, `foregroundColor` with `setForegroundColor` through `setBrushSettings`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`), and `fillSelection` reads it; the Type bar's share of `setPaletteColor` came with 8.9a, the picker and the swatches came with 8.10a. On the canvas (`EditorCanvas+Gradient.cpp`) a press within ten points of the end, then the start, grabs it, else begins a line; Shift turns the dragged end to a multiple of 45° around the other; Escape cancels and Return or Enter applies, after the selection's keys; losing focus drops the drag. `TransformOverlay` draws the line in place of the handles while one is pending, as Swift's: a radial's dashed rim at the line's length, the line black 3 under white 1, each end a white disc with a black rim, a gray ring and its colour. A pending gradient on a folder's mask clips the folder's layers at once through `liveFolderMaskClip`, as a brush stroke does. `GradientControls` is the tool's `ToolHeaderBar`: the shapes as checkable `QToolButton`s in a `QButtonGroup`, `GradientSwatch` (white, gray checks of four points, the colours, clipped to a rounded rect, the rim unclipped as Swift's `strokeBorder` overlay), the style `QComboBox`, Reverse, the slider in thousandths from 1% and the percent field (a `SelectionAmountField`, whose own clamp stands for the binding's), the Mask note in `PlaceholderText`, and Cancel and Apply while pending. Equivalent mutants: the gray branch of `gradientColors` and of `fillSelection` (a mask's palette is black or white), and the swatch's rim clipped too (3/255 at its corner pixels, measured). A press that marks a drag without an edit was equivalent until 8.10b's Alt eyedropper read the drag; a test holds it now.
|
||||
- The Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), `installProject` loads shapes and `applyDocumentSize` does not, as Swift's rebuild leaves them out after a crop or a resize; where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing.
|
||||
- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passeLine truncated
|
||||
- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passeLine truncated
|
||||
- The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view; the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. Each event follows `QWidgetLiLine truncated
|
||||
- The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (knownLine truncated
|
||||
- The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (Unicode space separators and the tab, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; itLine truncated
|
||||
@@ -112,6 +112,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-<UUID>` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`.
|
||||
- Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not.
|
||||
- Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files.
|
||||
- Folders, opacity and Snap (12.2a, Swift 1.1.5–1.1.6): a folder takes an opacity that multiplies into each layer inside it, never composited as a unit, since folders pass through. `LayerOpacity::effective` (in `LayerGroups.h`) walks up to 64 folders, a lookup standing for Swift's closure; `ImageLayer::effectiveOpacity`, `ProjectLayerRecord::effectiveOpacity` (declared in `ProjectStore.h`, defined in `LayerGroups.cpp`, as Swift's extension) and `CanvasDocument::effectiveOpacities` twin Swift's, the last refusing a repeated id with a `logic_error` where Swift's dictionary traps. Every drawing path takes it: the export, `drawLiveComposite`, the canvas (its every draw, adjustments included, and `DisplayState`, so a folder's change redraws) and the live-mask baker's sources, whose records are pointers, so it calls `LayerOpacity::effective` itself. `canEditOpacity` is one layer, a folder included; `canEditAppearance` stays for blending, so `LayerAppearanceControls` rests with `canEditOpacity` and its Blend caption and picker with `canEditAppearance`; `setSelectedLayersOpacity` dims selected folders too. The format moves to version 8 here (Swift's guides arrive under it in 12.3; until then a version-8 project's `guides` key is ignored as unknown), because 1.2.0's store rule (`d9b3f9e`) lets a folder dim from version 8 on, its blend mode always normal. View ▸ Snap is `snappingEnabled`, a getter and a setter that clears the guides and notifies (Swift's `didSet`); `snappedMove` and the crop drag read it. The transform box draws its accent alone. The effects sheet's sliders span Swift's new ranges (Size 0–20, Distance 0–100, the inner shadow's 0–50, Blur 0–100) while typing and steps reach the effect's own limits (500, 5,000, 500), clamped by `apply`; `QSlider` pins a larger value at its end by itself, so Swift's clamp of the thumb has no twin; the swatch takes a white ring inside a black rim, where SwiftUI's `contentShape` has no need of a twin (a Qt button takes its whole rect). The layer list takes T for Type, as 1.1.6's table does. Equivalent mutant: `canEditOpacity`'s `activeLayer()` term (one selected layer is the active one). `drawPixelGrid` moved to `EditorCanvas+Grid.cpp`, keeping the canvas file under 500 lines. Tests: `FolderOpacityTests` (the walk, the export, the composite, adjustments, effects, the canvas in each way it draws, the baker, Swift's `aDimmedFolderSavesAndReopens`), folder duplication in `LayerDuplicateTests` (moved out of `EditorSessionTests` at 500 lines), `TransformTests` and `LayerDragTests`, the controls in `LayerAppearanceControlsTests`, Snap in `TransformSnapTests`, `CropCanvasTests` and `ViewMenuTests` (the View menu tests, moved out of `CompositorMenusTests`), the ranges and swatch in `EffectsPanelTests`.
|
||||
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
|
||||
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
|
||||
- `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.
|
||||
|
||||
@@ -134,6 +134,7 @@ add_library(compositor_core STATIC
|
||||
src/Rendering/EditorCanvas+Brush.cpp
|
||||
src/Rendering/EditorCanvas+Crop.cpp
|
||||
src/Rendering/EditorCanvas+Effects.cpp
|
||||
src/Rendering/EditorCanvas+Grid.cpp
|
||||
src/Rendering/EditorCanvas+Gradient.cpp
|
||||
src/Rendering/EditorCanvas+Cursors.cpp
|
||||
src/Rendering/BrushCursorOverlay.cpp
|
||||
@@ -330,6 +331,7 @@ compositor_test(CanvasThumbnailTests)
|
||||
compositor_test(ClippingStackTests)
|
||||
compositor_test(CompositorTests)
|
||||
compositor_test(CompositorMenusTests)
|
||||
compositor_test(ViewMenuTests)
|
||||
compositor_test(CompositorUITestsLaunchTests)
|
||||
compositor_test(ContentViewTests)
|
||||
compositor_test(ContentViewStatusTests)
|
||||
@@ -338,6 +340,7 @@ compositor_test(DownsampleTests)
|
||||
compositor_test(DrawCoverageTests)
|
||||
compositor_test(EditorSessionSignalTests)
|
||||
compositor_test(EditorSessionTests)
|
||||
compositor_test(LayerDuplicateTests)
|
||||
compositor_test(ExportTests)
|
||||
compositor_test(FolderMaskClipTests)
|
||||
compositor_test(EditorCanvasTests)
|
||||
@@ -352,6 +355,7 @@ compositor_test(ImageSizeTests)
|
||||
compositor_test(JPEGExportTests)
|
||||
compositor_test(LayerAppearanceControlsTests)
|
||||
compositor_test(LayerAppearanceTests)
|
||||
compositor_test(FolderOpacityTests)
|
||||
compositor_test(LayerMaskEditTests)
|
||||
compositor_test(LayerMaskSessionTests)
|
||||
compositor_test(LayerMaskTests)
|
||||
|
||||
@@ -301,7 +301,11 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
|
||||
- 12.1 work: feather (the coverage blurred at sigma feather / 2, edges repeated; the bounds, clip, copy region and masks from a selection grow by twice the feather), the Select menu's Expand…, Contract… and Feather… through the amount sheet, the bar's Feather, Select ▸ Subject on U²-Net, Soft Light, `TypeFontPicker`, the inline editor's measure cache and `setTextDraft`'s narrower resume. Evidence: 209 of 209 in the dev image, lint clean; mutants over Selection, the sheet and bar, Select Subject, the menus, the font picker, the mask from a selection, the copy region, `whitePixels` and the renderer: every one caught but the two equivalent ones AGENTS.md names; dead terms removed (`canSelectSubject`'s document and busy, the sheet's `trimmed`, the picker's name test).
|
||||
- 12.1 round 1: FAIL, two findings. Choosing the face already shown restyled, opening live text for editing, where Swift's `choose` writes nothing (the picker returns on the shown face; `theFaceShownChangesNothing`, its mutant caught); AGENTS.md named `LayerRendererTests` for the blend formulas (`BlendModeTests`).
|
||||
- 12.1 round 2: PASS, no findings.
|
||||
- [ ] 12.2 Versions 1.1.5–1.1.6: folder duplication and the refined layer controls, folder opacity with 1.2.0's store rule for a dimmed folder (`d9b3f9e`), the keyboard shortcuts window.
|
||||
- [x] 12.2a Versions 1.1.5–1.1.6, part one: folders duplicated with their contents (Alt-drags on the canvas and in the list; the Layer menu takes folders in 12.10, as 1.2.5 does), the layer list's T key, the effects sheet's wider typed ranges and swatch rims, the text row's words, no clipping strip on a folder, the transform box without its dark line; folder opacity (effective opacity in every drawing path, `canEditOpacity`) with 1.2.0's store rule for a dimmed folder (`d9b3f9e`), the format moving to version 8 here since that rule reads it (guides arrive under that version in 12.3); the View menu's Snap.
|
||||
- 12.2a work: folders duplicate with their contents (canvas Alt-drag, list; the menu still refuses a folder, as 1.1.6), no clipping strip on folders, the text row's "Text", the accent-only transform box, the effects sheet's slider and typed ranges and swatch rims; folder opacity through every drawing path with `canEditOpacity`, the format at version 8 with `d9b3f9e`'s rule; View ▸ Snap. Evidence: every test green in the dev image, lint clean; mutants over opacity, the drawing paths, the gates and controls, the store, duplication, the transform duplicate, the list, the sheet, Snap, the crop, the menu, the overlay and the cell: all caught; dead terms left out (`duplicateLayer`'s `canEditLayers`, the thumb's clamp).
|
||||
- 12.2a round 1: FAIL, five findings. The layer list lacked 1.1.6's T for Type (added, tested in `NativeLayerListTests`); a stale comment on `beginDuplicateTransform` said folders just move (it copies roots with their contents); TASKS credited the Layer menu with folder copies, which arrive with 1.2.5 in 12.10 (words fixed); the 10,000-layer limit was tested past its edge only (three copies onto 9,997 now reach 10,000); the baker's folder walk was tested one folder deep (a folder around the folder now dims the source to 63). Each fix's mutant is caught; the equivalent `activeLayer()` term of `canEditOpacity` is named in AGENTS.md.
|
||||
- 12.2a round 2: PASS, no findings.
|
||||
- [ ] 12.2b Version 1.1.6, part two: the keyboard shortcuts window, remapped shortcuts on the menus, the canvas, the layer list, the sheets' buttons and open text; Space released by its physical key.
|
||||
- [ ] 12.3 Version 1.1.7: rulers, guides, the layout grid and Snap To.
|
||||
- [ ] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region).
|
||||
- [ ] 12.5a Version 1.2.0, part one: Photoshop PSD import (the reader, channel coder, vectors, document builder, the conversion report sheet).
|
||||
|
||||
@@ -168,6 +168,10 @@ SnapGuides EditorSession::transformSnapTargets(const QSet<QUuid> &moving) const
|
||||
// `draft` nudged onto a nearby edge or middle, within `tolerance`.
|
||||
LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance)
|
||||
{
|
||||
if (!m_snappingEnabled) {
|
||||
snapGuides = {};
|
||||
return draft;
|
||||
}
|
||||
const SnapGuides targets = transformSnapTargets(moving);
|
||||
const TransformSnap::Offset snap = TransformSnap::offset(boxAround(draft), targets.xs, targets.ys, tolerance);
|
||||
snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}};
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
#include "Document/TypeTool.h"
|
||||
#include "IO/ImageImporter.h"
|
||||
#include <QString>
|
||||
#include <QHash>
|
||||
#include <QUuid>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
@@ -40,6 +42,8 @@ struct ImageLayer {
|
||||
QPointF origin() const { return transform.origin; }
|
||||
QSizeF size() const { return transform.size; }
|
||||
ProjectLayerRecord hierarchyRecord() const;
|
||||
// Drawn opacity, folders included (LayerOpacity).
|
||||
double effectiveOpacity(const std::map<QUuid, ImageLayer> &byID) const;
|
||||
LayerTransform maskTransform() const;
|
||||
// The shape this layer still is: none once repainted otherwise.
|
||||
std::optional<LayerShape> liveShape() const;
|
||||
@@ -70,6 +74,7 @@ struct CanvasDocument {
|
||||
static std::optional<int> validDimension(const QString &value);
|
||||
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
|
||||
QSet<QUuid> effectiveVisibleIDs() const;
|
||||
QHash<QUuid, double> effectiveOpacities() const;
|
||||
std::vector<ImageLayer> renderLayers() const;
|
||||
friend bool operator==(const CanvasDocument &lhs, const CanvasDocument &rhs) = default;
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ void EditorSession::setLocksTransformRatio(bool locks)
|
||||
notify();
|
||||
}
|
||||
|
||||
// A folder has no pixels to copy: it just moves.
|
||||
// Alt-drag copies selected roots with their contents.
|
||||
void EditorSession::beginDuplicateTransform()
|
||||
{
|
||||
if (m_transformDuplicate || !m_activeLayerID)
|
||||
@@ -110,10 +110,14 @@ void EditorSession::beginDuplicateTransform()
|
||||
if (!canTransform())
|
||||
return;
|
||||
const QSet<QUuid> selection = m_selectedLayerIDs;
|
||||
// A selected folder carries its selected contents along.
|
||||
QSet<QUuid> carried;
|
||||
for (const QUuid &id : selection)
|
||||
carried.unite(descendantIDs(id));
|
||||
// Bottom to top: the copies keep their order.
|
||||
std::vector<QUuid> targets;
|
||||
for (const ImageLayer &layer : m_document->layers) {
|
||||
if (selection.contains(layer.id) && !layer.isGroup)
|
||||
if (selection.contains(layer.id) && !carried.contains(layer.id))
|
||||
targets.push_back(layer.id);
|
||||
}
|
||||
if (targets.empty())
|
||||
@@ -123,7 +127,14 @@ void EditorSession::beginDuplicateTransform()
|
||||
for (const QUuid &id : targets) {
|
||||
selectLayer(id);
|
||||
duplicateActiveLayer();
|
||||
copies.push_back(m_activeLayerID.value());
|
||||
if (m_activeLayerID != id)
|
||||
copies.push_back(m_activeLayerID.value());
|
||||
}
|
||||
// Past the layer limit nothing was copied.
|
||||
if (copies.empty()) {
|
||||
endEdit();
|
||||
selectLayers(selection, primary);
|
||||
return;
|
||||
}
|
||||
m_transformDuplicate = TransformDuplicate{copies, selection, primary};
|
||||
selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back());
|
||||
@@ -206,9 +217,11 @@ void EditorSession::cancelTransform()
|
||||
if (m_transformDuplicate) {
|
||||
// The copies go and the old selection returns.
|
||||
const TransformDuplicate duplicate = *m_transformDuplicate;
|
||||
std::erase_if(m_document->layers, [&](const ImageLayer &layer) {
|
||||
return std::find(duplicate.copies.begin(), duplicate.copies.end(), layer.id) != duplicate.copies.end();
|
||||
});
|
||||
QSet<QUuid> removed(duplicate.copies.begin(), duplicate.copies.end());
|
||||
for (const QUuid © : duplicate.copies)
|
||||
removed.unite(descendantIDs(copy));
|
||||
std::erase_if(m_document->layers, [&](const ImageLayer &layer) { return removed.contains(layer.id); });
|
||||
m_collapsedGroupIDs.subtract(removed);
|
||||
selectLayers(duplicate.source, duplicate.primary);
|
||||
endDuplicateTransform();
|
||||
}
|
||||
|
||||
@@ -318,6 +318,14 @@ void EditorSession::setShowsPixelGrid(bool shows)
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setSnappingEnabled(bool enabled)
|
||||
{
|
||||
m_snappingEnabled = enabled;
|
||||
if (!enabled)
|
||||
snapGuides = {};
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setCropRect(std::optional<QRectF> rect)
|
||||
{
|
||||
if (std::exchange(m_cropRect, rect) != rect)
|
||||
|
||||
@@ -238,6 +238,9 @@ public:
|
||||
// The View menu's grid, shown from 800%.
|
||||
bool showsPixelGrid() const { return m_showsPixelGrid; }
|
||||
void setShowsPixelGrid(bool shows);
|
||||
// View > Snap: moves and crops line up with edges.
|
||||
bool snappingEnabled() const { return m_snappingEnabled; }
|
||||
void setSnappingEnabled(bool enabled);
|
||||
// The Eyedropper's ring: the sampled colour over the original.
|
||||
bool showsSampleRing() const { return m_showsSampleRing; }
|
||||
void setShowsSampleRing(bool shows);
|
||||
@@ -336,6 +339,7 @@ private:
|
||||
std::optional<QUuid> m_renamingLayerID;
|
||||
std::optional<QString> m_projectPath;
|
||||
bool m_showsPixelGrid = true;
|
||||
bool m_snappingEnabled = true;
|
||||
bool m_showsSampleRing = true;
|
||||
std::optional<QRectF> m_cropRect;
|
||||
QString m_cropRatioChoice = QStringLiteral("Free");
|
||||
|
||||
@@ -3,6 +3,8 @@ public:
|
||||
LayerBlendMode displayedBlendMode(const ImageLayer &layer) const;
|
||||
void previewBlendMode(std::optional<LayerBlendMode> mode, std::optional<QUuid> id);
|
||||
bool canEditAppearance() const;
|
||||
// A folder dims what it holds; blending stays per layer.
|
||||
bool canEditOpacity() const;
|
||||
// A slider drag: many values, one undo step.
|
||||
void beginOpacityEdit();
|
||||
void finishOpacityEdit();
|
||||
|
||||
@@ -54,9 +54,14 @@ bool EditorSession::canEditAppearance() const
|
||||
return canEditLayers() && m_selectedLayerIDs.size() == 1 && active && !active->isGroup;
|
||||
}
|
||||
|
||||
bool EditorSession::canEditOpacity() const
|
||||
{
|
||||
return canEditLayers() && m_selectedLayerIDs.size() == 1 && activeLayer();
|
||||
}
|
||||
|
||||
void EditorSession::beginOpacityEdit()
|
||||
{
|
||||
if (!canEditAppearance() || m_opacityEditLayerID)
|
||||
if (!canEditOpacity() || m_opacityEditLayerID)
|
||||
return;
|
||||
beginEdit(QStringLiteral("Layer Opacity"));
|
||||
m_opacityEditLayerID = m_activeLayerID;
|
||||
@@ -72,7 +77,7 @@ void EditorSession::finishOpacityEdit()
|
||||
|
||||
void EditorSession::setLayerOpacity(double opacity)
|
||||
{
|
||||
if (!std::isfinite(opacity) || !canEditAppearance())
|
||||
if (!std::isfinite(opacity) || !canEditOpacity())
|
||||
return;
|
||||
const QUuid id = m_opacityEditLayerID.value_or(*m_activeLayerID);
|
||||
const auto layer = std::find_if(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &each) { return each.id == id; });
|
||||
@@ -88,9 +93,9 @@ void EditorSession::setSelectedLayersOpacity(double opacity)
|
||||
{
|
||||
if (!std::isfinite(opacity) || !canEditLayers())
|
||||
return;
|
||||
// Folders have no opacity of their own.
|
||||
// A selected folder takes it too, dimming what it holds.
|
||||
const double value = std::clamp(opacity, 0.0, 1.0);
|
||||
const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && !layer.isGroup && layer.opacity != value; };
|
||||
const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && layer.opacity != value; };
|
||||
if (std::none_of(m_document->layers.begin(), m_document->layers.end(), changes))
|
||||
return;
|
||||
finishOpacityEdit();
|
||||
|
||||
@@ -86,6 +86,54 @@ void LayerHierarchy::validate(const std::vector<ProjectLayerRecord> &layers)
|
||||
}
|
||||
}
|
||||
|
||||
double LayerOpacity::effective(double own, std::optional<QUuid> parent, const Node &folder)
|
||||
{
|
||||
double opacity = own;
|
||||
std::optional<QUuid> id = parent;
|
||||
// Past 64 folders the tree is wrong: Swift stops there.
|
||||
for (int depth = 0; id && depth < 64; ++depth) {
|
||||
const std::optional<std::pair<double, std::optional<QUuid>>> node = folder(*id);
|
||||
if (!node)
|
||||
break;
|
||||
opacity *= node->first;
|
||||
id = node->second;
|
||||
}
|
||||
return opacity;
|
||||
}
|
||||
|
||||
double ImageLayer::effectiveOpacity(const std::map<QUuid, ImageLayer> &byID) const
|
||||
{
|
||||
return LayerOpacity::effective(opacity, parentID, [&byID](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
|
||||
if (!byID.contains(id))
|
||||
return std::nullopt;
|
||||
const ImageLayer &node = byID.at(id);
|
||||
return std::pair(node.opacity, node.parentID);
|
||||
});
|
||||
}
|
||||
|
||||
double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRecord> &byID) const
|
||||
{
|
||||
return LayerOpacity::effective(opacity.value_or(1), parentID, [&byID](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
|
||||
if (!byID.contains(id))
|
||||
return std::nullopt;
|
||||
const ProjectLayerRecord &node = byID.at(id);
|
||||
return std::pair(node.opacity.value_or(1), node.parentID);
|
||||
});
|
||||
}
|
||||
|
||||
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
|
||||
{
|
||||
std::map<QUuid, ImageLayer> byID;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (!byID.emplace(layer.id, layer).second)
|
||||
throw std::logic_error("two layers share an id");
|
||||
}
|
||||
QHash<QUuid, double> result;
|
||||
for (const ImageLayer &layer : layers)
|
||||
result.insert(layer.id, layer.effectiveOpacity(byID));
|
||||
return result;
|
||||
}
|
||||
|
||||
ProjectLayerRecord ImageLayer::hierarchyRecord() const
|
||||
{
|
||||
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "IO/ProjectStore.h"
|
||||
#include <QSet>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
namespace LayerHierarchy {
|
||||
@@ -15,3 +16,10 @@ std::vector<Entry> entries(const std::vector<ProjectLayerRecord> &layers, bool t
|
||||
std::vector<ProjectLayerRecord> visibleLayers(const std::vector<ProjectLayerRecord> &layers);
|
||||
void validate(const std::vector<ProjectLayerRecord> &layers);
|
||||
}
|
||||
|
||||
// A folder's opacity multiplies into each layer inside it.
|
||||
namespace LayerOpacity {
|
||||
// A layer's opacity and parent, or none when unknown.
|
||||
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
|
||||
double effective(double own, std::optional<QUuid> parent, const Node &folder);
|
||||
}
|
||||
@@ -42,14 +42,14 @@ void EditorSession::drawLiveComposite(const CanvasDocument &document, QPainter &
|
||||
if (const auto effects = LayerEffectsRenderer::cached(image, mask, layer.effects)) {
|
||||
const LayerTransform grown = LayerEffectsRenderer::placed(transform, effects->image, effects->inset);
|
||||
LayerRenderer::draw(effects->image, grown, grown.center(), target,
|
||||
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .clip = clip});
|
||||
{.opacity = layer.effectiveOpacity(records), .blendMode = displayedBlendMode(layer), .clip = clip});
|
||||
return;
|
||||
}
|
||||
LayerRenderer::draw(image, transform, transform.center(), target,
|
||||
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip});
|
||||
{.opacity = layer.effectiveOpacity(records), .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip});
|
||||
});
|
||||
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).effectiveOpacity(records); };
|
||||
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
|
||||
const ImageLayer &layer = records.at(id);
|
||||
if (const std::optional<QImage> image = layer.mask ? layer.mask->enabledImage() : std::nullopt)
|
||||
@@ -116,6 +116,14 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
|
||||
throw std::logic_error("a snapshot to bake repeats a layer id");
|
||||
records.insert(layer.id, &layer);
|
||||
}
|
||||
// A source dims with its folders, as exported.
|
||||
const auto folded = [&records](const ProjectLayerRecord &layer) {
|
||||
return LayerOpacity::effective(layer.opacity.value_or(1), layer.parentID, [&records](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
|
||||
if (!records.contains(id))
|
||||
return std::nullopt;
|
||||
return std::pair(records.value(id)->opacity.value_or(1), records.value(id)->parentID);
|
||||
});
|
||||
};
|
||||
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.value(id)->maskSourceID : std::nullopt; },
|
||||
[&](QUuid id, QPainter &context, const QImage &clip) {
|
||||
// `at` is checked: end() would read wild memory.
|
||||
@@ -135,7 +143,7 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
|
||||
context.save();
|
||||
context.setTransform(inverse, true);
|
||||
LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), context,
|
||||
{.opacity = layer.opacity.value_or(1), .mask = shown.value_or(QImage()), .clip = clip});
|
||||
{.opacity = folded(layer), .mask = shown.value_or(QImage()), .clip = clip});
|
||||
context.restore();
|
||||
});
|
||||
{
|
||||
|
||||
@@ -317,28 +317,54 @@ QString EditorSession::nextLayerName() const
|
||||
void EditorSession::duplicateActiveLayer()
|
||||
{
|
||||
const int index = canEditLayers() ? indexOf(m_document->layers, m_activeLayerID) : -1;
|
||||
if (index < 0 || m_document->layers[index].isGroup)
|
||||
if (index < 0)
|
||||
return;
|
||||
ImageLayer copy = m_document->layers[index];
|
||||
copy.id = QUuid::createUuid();
|
||||
copy.name = copy.name + QStringLiteral(" copy");
|
||||
// A folder carries its whole tree, links kept inside it.
|
||||
const QUuid id = *m_activeLayerID;
|
||||
QSet<QUuid> included = descendantIDs(id);
|
||||
included.insert(id);
|
||||
std::vector<ImageLayer> copies;
|
||||
for (const ImageLayer &layer : m_document->layers) {
|
||||
if (included.contains(layer.id))
|
||||
copies.push_back(layer);
|
||||
}
|
||||
if (m_document->layers.size() + copies.size() > 10'000)
|
||||
return;
|
||||
QHash<QUuid, QUuid> mapping;
|
||||
for (const ImageLayer © : copies)
|
||||
mapping.insert(copy.id, QUuid::createUuid());
|
||||
const auto renamed = [&mapping](std::optional<QUuid> &link) {
|
||||
if (link && mapping.contains(*link))
|
||||
link = mapping.value(*link);
|
||||
};
|
||||
for (ImageLayer © : copies) {
|
||||
if (copy.id == id)
|
||||
copy.name += QStringLiteral(" copy");
|
||||
copy.id = mapping.value(copy.id);
|
||||
renamed(copy.parentID);
|
||||
renamed(copy.maskSourceID);
|
||||
}
|
||||
beginEdit(QStringLiteral("Duplicate Layer"));
|
||||
m_document->layers.insert(m_document->layers.begin() + index + 1, copy);
|
||||
setActiveLayerID(copy.id);
|
||||
m_document->layers.insert(m_document->layers.begin() + index + 1, copies.begin(), copies.end());
|
||||
for (auto original = mapping.cbegin(); original != mapping.cend(); ++original) {
|
||||
if (m_collapsedGroupIDs.contains(original.key()))
|
||||
m_collapsedGroupIDs.insert(original.value());
|
||||
}
|
||||
setActiveLayerID(mapping.value(id));
|
||||
endEdit();
|
||||
}
|
||||
|
||||
// Alt-drag in the Layers panel: a copy placed where dropped.
|
||||
bool EditorSession::duplicateLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above, bool atBottom)
|
||||
{
|
||||
const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1;
|
||||
if (index < 0 || m_document->layers[index].isGroup || !canPlaceLayer(id, parent))
|
||||
// canPlaceLayer reads canEditLayers, Swift's first term.
|
||||
if (!canPlaceLayer(id, parent))
|
||||
return false;
|
||||
beginEdit(QStringLiteral("Duplicate Layer"));
|
||||
selectLayer(id);
|
||||
duplicateActiveLayer();
|
||||
// The copy is active: nothing above refuses it.
|
||||
const bool placed = placeLayer(*m_activeLayerID, parent, above, atBottom);
|
||||
// Past the layer limit no copy was made.
|
||||
const bool placed = m_activeLayerID != id && placeLayer(*m_activeLayerID, parent, above, atBottom);
|
||||
endEdit();
|
||||
return placed;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ try {
|
||||
const std::optional<LayerMask> mask = snapshot.mask(layer);
|
||||
const std::optional<QImage> shown =
|
||||
mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt;
|
||||
const double opacity = layer.opacity.value_or(1);
|
||||
const double opacity = layer.effectiveOpacity(records);
|
||||
const LayerBlendMode mode = layer.blendMode.value_or(LayerBlendMode::normal);
|
||||
// With effects the mask is in their image already.
|
||||
if (const auto effects = LayerEffectsRenderer::cached(pixels, shown, layer.effects)) {
|
||||
@@ -87,7 +87,7 @@ try {
|
||||
{.opacity = opacity, .blendMode = mode, .mask = shown.value_or(QImage()), .clip = clip});
|
||||
});
|
||||
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity.value_or(1); };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).effectiveOpacity(records); };
|
||||
live.adjustmentClip = [&](QUuid id, const QPainter &context, QImage &coverage) {
|
||||
const ProjectLayerRecord &layer = records.at(id);
|
||||
const std::optional<LayerMask> mask = snapshot.mask(layer);
|
||||
|
||||
@@ -42,7 +42,7 @@ void validate(const ProjectManifest &manifest)
|
||||
{
|
||||
if (manifest.format != QLatin1String("com.compositor.project"))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
if (manifest.version < 1 || manifest.version > 7)
|
||||
if (manifest.version < 1 || manifest.version > 8)
|
||||
throw ProjectError::unsupportedVersion(manifest.version);
|
||||
if (manifest.colorSpace != QLatin1String("sRGB"))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
@@ -64,9 +64,11 @@ void validate(const ProjectManifest &manifest)
|
||||
|| (manifest.version >= (group ? 6 : 4) && *layer.maskFile == uuidString(layer.id) + QLatin1String(".mask.png"));
|
||||
const bool placementAllowed = !layer.maskPlacement || (layer.maskPlacement->isValid() && layer.maskFile);
|
||||
const double opacity = layer.opacity.value_or(1);
|
||||
const bool plain = opacity == 1 && layer.blendMode.value_or(LayerBlendMode::normal) == LayerBlendMode::normal;
|
||||
const bool normal = layer.blendMode.value_or(LayerBlendMode::normal) == LayerBlendMode::normal;
|
||||
const bool plain = opacity == 1 && normal;
|
||||
// Folders dim from version 8; they always pass through.
|
||||
if (!maskAllowed || (layer.maskEnabled && !layer.maskFile) || !placementAllowed || !std::isfinite(opacity) || opacity < 0
|
||||
|| opacity > 1 || (manifest.version < 3 && !plain) || (group && !plain))
|
||||
|| opacity > 1 || (manifest.version < 3 && !plain) || (group && !(normal && (manifest.version >= 8 || opacity == 1))))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
}
|
||||
LayerHierarchy::validate(manifest.layers);
|
||||
@@ -228,7 +230,7 @@ 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–7.").arg(version));
|
||||
QStringLiteral("This project uses format version %1. This app supports versions 1–8.").arg(version));
|
||||
}
|
||||
|
||||
ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description)
|
||||
@@ -304,7 +306,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 > 7)
|
||||
if (version < 1 || version > 8)
|
||||
throw ProjectError::unsupportedVersion(version);
|
||||
const ProjectManifest manifest = ProjectManifest::decoded(metadata);
|
||||
validate(manifest);
|
||||
|
||||
@@ -40,6 +40,9 @@ struct ProjectLayerRecord {
|
||||
// What the layer draws round itself; its pixels stay.
|
||||
std::optional<LayerEffects> effects = std::nullopt;
|
||||
std::optional<LayerTextStyle> text = std::nullopt;
|
||||
|
||||
// Drawn opacity, folders included; defined in LayerGroups.cpp.
|
||||
double effectiveOpacity(const std::map<QUuid, ProjectLayerRecord> &byID) const;
|
||||
};
|
||||
|
||||
// A UUID as Swift writes it: upper case, no braces.
|
||||
@@ -48,7 +51,7 @@ QString uuidString(const QUuid &id);
|
||||
struct ProjectManifest {
|
||||
QString format = QStringLiteral("com.compositor.project");
|
||||
// Swift's Int: a wild number must reach validation whole.
|
||||
qint64 version = 7;
|
||||
qint64 version = 8;
|
||||
QString colorSpace = QStringLiteral("sRGB");
|
||||
// Version-1 projects carry none: 72 pixels per inch.
|
||||
std::optional<double> resolution = std::nullopt;
|
||||
|
||||
@@ -81,7 +81,7 @@ void CanvasView::dragCrop(QPointF point, Qt::KeyboardModifiers modifiers)
|
||||
const std::optional<double> ratio = m_session.cropRatio();
|
||||
const CropDrag &drag = m_cropDrag.value();
|
||||
QRectF next = drag.updated(pixel, ratio, symmetric);
|
||||
if (!modifiers.testFlag(Qt::ControlModifier))
|
||||
if (m_session.snappingEnabled() && !modifiers.testFlag(Qt::ControlModifier))
|
||||
next = m_cropSnap.value().apply(next, drag, pixel, ratio, symmetric);
|
||||
if (CropGeometry::valid(next))
|
||||
m_session.setCropRect(next);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include <QPainterPath>
|
||||
#include <cmath>
|
||||
|
||||
// One-screen-pixel lines on document pixel boundaries, over the image only.
|
||||
void CanvasView::drawPixelGrid(const QRectF &view, const CanvasDocument &document, QPainter &context) const
|
||||
{
|
||||
const CanvasViewport &viewport = m_session.viewport;
|
||||
const double points = viewport.pointsPerPixel();
|
||||
const QRectF canvas(viewport.viewPoint(QPointF(0, 0), document.size()), QSizeF(document.width * points, document.height * points));
|
||||
const QRectF area = view.intersected(canvas);
|
||||
if (area.isEmpty())
|
||||
return;
|
||||
const QPointF first = viewport.documentPoint(area.topLeft(), document.size());
|
||||
const QPointF last = viewport.documentPoint(area.bottomRight(), document.size());
|
||||
const double hairline = 1 / viewport.backingScale;
|
||||
// Crossings fill once, as CoreGraphics fills the union.
|
||||
QPainterPath path;
|
||||
path.setFillRule(Qt::WindingFill);
|
||||
for (int column = int(std::ceil(first.x())); column <= int(std::floor(last.x())); ++column) {
|
||||
const double x = viewport.viewPoint(QPointF(column, 0), document.size()).x();
|
||||
path.addRect(QRectF(x - hairline / 2, area.top(), hairline, area.height()));
|
||||
}
|
||||
for (int row = int(std::ceil(first.y())); row <= int(std::floor(last.y())); ++row) {
|
||||
const double y = viewport.viewPoint(QPointF(0, row), document.size()).y();
|
||||
path.addRect(QRectF(area.left(), y - hairline / 2, area.width(), hairline));
|
||||
}
|
||||
context.save();
|
||||
context.setRenderHint(QPainter::Antialiasing, true);
|
||||
context.fillPath(path, QColor::fromRgbF(0.55, 0.55, 0.55, 0.45));
|
||||
context.restore();
|
||||
}
|
||||
@@ -81,6 +81,8 @@ CanvasView::DisplayState CanvasView::displayState() const
|
||||
[](const ImageLayer &layer) { return layer.maskSourceID.has_value(); });
|
||||
const std::vector<ImageLayer> rendered = liveMasks ? std::vector<ImageLayer>() : document->renderLayers();
|
||||
const QSet<QUuid> visible = document->effectiveVisibleIDs();
|
||||
// A folder's opacity reaches the canvas through its layers.
|
||||
const QHash<QUuid, double> opacities = document->effectiveOpacities();
|
||||
for (const ImageLayer &layer : liveMasks ? document->layers : rendered) {
|
||||
// A blank layer counts while a draft sits on it.
|
||||
if (!layer.asset && !layer.adjustment && !(m_session.shapeDraft() && layer.id == m_session.activeLayerID()))
|
||||
@@ -88,7 +90,7 @@ CanvasView::DisplayState CanvasView::displayState() const
|
||||
state.layers.push_back({.id = layer.id, .transform = m_session.displayedTransform(layer),
|
||||
.imageID = layer.asset ? std::optional(layer.asset->identity()) : std::nullopt,
|
||||
.maskID = enabledMaskID(layer.mask), .maskSourceID = layer.maskSourceID, .parentID = layer.parentID,
|
||||
.visible = visible.contains(layer.id), .opacity = layer.opacity,
|
||||
.visible = visible.contains(layer.id), .opacity = opacities.value(layer.id),
|
||||
.blendMode = m_session.displayedBlendMode(layer), .maskPlacement = m_session.displayedMaskPlacement(layer),
|
||||
.adjustment = layer.adjustment, .effects = layer.effects});
|
||||
}
|
||||
@@ -308,6 +310,8 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
|
||||
if (!byID.contains(id) || (m_session.textDraft() && m_session.textDraft().value().layerID == id))
|
||||
return;
|
||||
const ImageLayer &layer = byID.at(id);
|
||||
// Its folders dim it with everything inside them.
|
||||
const double opacity = layer.effectiveOpacity(byID);
|
||||
// A stroke or pixel move stands in for the pixels.
|
||||
const PixelMove *move = m_session.pixelMove();
|
||||
const std::optional<GradientEdit> &gradient = m_session.gradientEdit();
|
||||
@@ -323,7 +327,7 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
|
||||
const std::optional<QImage> mask = layer.mask ? layer.mask->clipImage(layer.maskTransform(), canvas, warp->width, warp->height, 2048)
|
||||
: std::nullopt;
|
||||
LayerRenderer::draw(warp->image(), canvas, center(canvas.center()), target,
|
||||
{.scale = scale, .opacity = layer.opacity, .blendMode = m_session.displayedBlendMode(layer),
|
||||
{.scale = scale, .opacity = opacity, .blendMode = m_session.displayedBlendMode(layer),
|
||||
.mask = mask.value_or(QImage()), .clip = clip});
|
||||
return;
|
||||
}
|
||||
@@ -344,12 +348,12 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
|
||||
const QSize size = owner.asset ? owner.asset->size() : QSize(int(std::round(base.size.width())), int(std::round(base.size.height())));
|
||||
mask = layer.mask->clipImage(placement, base, size.width(), size.height(), m_session.transformEdit() ? std::min(2048.0, steady) : steady);
|
||||
}
|
||||
const LayerRenderer::Options options{.scale = scale, .opacity = layer.opacity, .blendMode = m_session.displayedBlendMode(layer),
|
||||
const LayerRenderer::Options options{.scale = scale, .opacity = opacity, .blendMode = m_session.displayedBlendMode(layer),
|
||||
.mask = mask.value_or(QImage()), .clip = clip};
|
||||
// Swift asks twice; once, with what shows, is one request.
|
||||
const std::optional<EffectsPreviewCache::Result> effects =
|
||||
stroke ? std::nullopt : m_session.effectsPreviews.preview(layer, mask, transform, placement, repaint);
|
||||
const LayerRenderer::Options bare{.scale = scale, .opacity = layer.opacity, .blendMode = options.blendMode, .clip = clip};
|
||||
const LayerRenderer::Options bare{.scale = scale, .opacity = opacity, .blendMode = options.blendMode, .clip = clip};
|
||||
// A pending distortion warps the effects along with the layer.
|
||||
if (const auto warped = effects ? m_session.distortedEffects(layer, effects->image, effects->inset) : std::nullopt) {
|
||||
LayerRenderer::draw(warped->image, warped->transform, center(warped->transform.center()), target, bare);
|
||||
@@ -409,7 +413,7 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
|
||||
};
|
||||
LiveMaskRenderer live([&](QUuid id) { return byID.contains(id) ? byID.at(id).maskSourceID : std::nullopt; }, drawOwnWithDraft);
|
||||
live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).opacity; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); };
|
||||
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
|
||||
const ImageLayer &layer = byID.at(id);
|
||||
if (!layer.mask || !layer.mask->isEnabled)
|
||||
@@ -468,32 +472,3 @@ void CanvasView::drawDocumentPixels(const QRectF &view, const QRectF &pixels, co
|
||||
context.drawImage(target, raster);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
// One-screen-pixel lines on document pixel boundaries, over the image only.
|
||||
void CanvasView::drawPixelGrid(const QRectF &view, const CanvasDocument &document, QPainter &context) const
|
||||
{
|
||||
const CanvasViewport &viewport = m_session.viewport;
|
||||
const double points = viewport.pointsPerPixel();
|
||||
const QRectF canvas(viewport.viewPoint(QPointF(0, 0), document.size()), QSizeF(document.width * points, document.height * points));
|
||||
const QRectF area = view.intersected(canvas);
|
||||
if (area.isEmpty())
|
||||
return;
|
||||
const QPointF first = viewport.documentPoint(area.topLeft(), document.size());
|
||||
const QPointF last = viewport.documentPoint(area.bottomRight(), document.size());
|
||||
const double hairline = 1 / viewport.backingScale;
|
||||
// Crossings fill once, as CoreGraphics fills the union.
|
||||
QPainterPath path;
|
||||
path.setFillRule(Qt::WindingFill);
|
||||
for (int column = int(std::ceil(first.x())); column <= int(std::floor(last.x())); ++column) {
|
||||
const double x = viewport.viewPoint(QPointF(column, 0), document.size()).x();
|
||||
path.addRect(QRectF(x - hairline / 2, area.top(), hairline, area.height()));
|
||||
}
|
||||
for (int row = int(std::ceil(first.y())); row <= int(std::floor(last.y())); ++row) {
|
||||
const double y = viewport.viewPoint(QPointF(0, row), document.size()).y();
|
||||
path.addRect(QRectF(area.left(), y - hairline / 2, area.width(), hairline));
|
||||
}
|
||||
context.save();
|
||||
context.setRenderHint(QPainter::Antialiasing, true);
|
||||
context.fillPath(path, QColor::fromRgbF(0.55, 0.55, 0.55, 0.45));
|
||||
context.restore();
|
||||
}
|
||||
@@ -348,8 +348,7 @@ void TransformOverlay::drawTransformHandles(QPainter &context, const QPalette &p
|
||||
context.save();
|
||||
context.setRenderHint(QPainter::Antialiasing, true);
|
||||
context.setBrush(Qt::NoBrush);
|
||||
context.setPen(QPen(QColor(0, 0, 0, 179), 3));
|
||||
context.drawPath(path);
|
||||
// The accent alone: a dark line read as a halo.
|
||||
context.setPen(QPen(accent, 1));
|
||||
context.drawPath(path);
|
||||
context.setBrush(Qt::white);
|
||||
|
||||
@@ -118,6 +118,8 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
add(view, QStringLiteral("zoomOut"), QStringLiteral("Zoom Out"), QKeySequence(Qt::CTRL | Qt::Key_Minus), [this] { session().zoom(session().viewport.zoom() / 1.25); });
|
||||
add(view, QStringLiteral("pixelGrid"), QStringLiteral("Pixel Grid (800% and above)"), QKeySequence(), [this] { session().setShowsPixelGrid(!session().showsPixelGrid()); })
|
||||
->setCheckable(true);
|
||||
add(view, QStringLiteral("snap"), QStringLiteral("Snap"), QKeySequence(), [this] { session().setSnappingEnabled(!session().snappingEnabled()); })
|
||||
->setCheckable(true);
|
||||
// Ctrl+H: the Move tool's box; the desktop hides apps.
|
||||
add(view, QStringLiteral("transformControls"), QStringLiteral("Show Transform Controls"), QKeySequence(Qt::CTRL | Qt::Key_H),
|
||||
[this] { session().setShowsTransformControls(!session().showsTransformControls()); })
|
||||
@@ -275,6 +277,7 @@ void CompositorMenus::synchronize()
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
action(QString::fromLatin1(name))->setEnabled(drawn);
|
||||
action(QStringLiteral("pixelGrid"))->setChecked(s.showsPixelGrid());
|
||||
action(QStringLiteral("snap"))->setChecked(s.snappingEnabled());
|
||||
action(QStringLiteral("transformControls"))->setChecked(s.showsTransformControls());
|
||||
action(QStringLiteral("transformControls"))->setEnabled(s.tool() == NavigationTool::move && s.document().has_value());
|
||||
const bool selected = s.selection().has_value();
|
||||
|
||||
+11
-10
@@ -53,7 +53,7 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
|
||||
header(QStringLiteral("Stroke"), true);
|
||||
colour(true);
|
||||
slider(QStringLiteral("Size"), [](const LayerEffects &e) { return e.stroke ? std::optional(e.stroke->size) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.stroke->size = value; }, 1, StrokeEffect::maxSize, QStringLiteral("px"));
|
||||
[](LayerEffects &e, double value) { e.stroke->size = value; }, 0, 20, QStringLiteral("px"), StrokeEffect::maxSize);
|
||||
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.stroke); },
|
||||
[](LayerEffects &e, double value) { e.stroke->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
|
||||
break;
|
||||
@@ -64,9 +64,9 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
|
||||
slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->angle) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.shadow->angle = value; }, -180, 180, QStringLiteral("°"));
|
||||
slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->distance) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.shadow->distance = value; }, 0, 300, QStringLiteral("px"));
|
||||
[](LayerEffects &e, double value) { e.shadow->distance = value; }, 0, 100, QStringLiteral("px"), 5000);
|
||||
slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->blur) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.shadow->blur = value; }, 0, 300, QStringLiteral("px"));
|
||||
[](LayerEffects &e, double value) { e.shadow->blur = value; }, 0, 100, QStringLiteral("px"), 500);
|
||||
break;
|
||||
case LayerEffectKind::colorOverlay:
|
||||
header(QStringLiteral("Color Overlay"), false);
|
||||
@@ -80,9 +80,9 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
|
||||
slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->angle) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.innerShadow->angle = value; }, -180, 180, QStringLiteral("°"));
|
||||
slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->distance) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.innerShadow->distance = value; }, 0, 300, QStringLiteral("px"));
|
||||
[](LayerEffects &e, double value) { e.innerShadow->distance = value; }, 0, 50, QStringLiteral("px"), 5000);
|
||||
slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->blur) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.innerShadow->blur = value; }, 0, 300, QStringLiteral("px"));
|
||||
[](LayerEffects &e, double value) { e.innerShadow->blur = value; }, 0, 100, QStringLiteral("px"), 500);
|
||||
break;
|
||||
}
|
||||
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
|
||||
@@ -139,7 +139,7 @@ void EffectsSheet::header(const QString &title, bool position)
|
||||
// The effect's colour, opened in the app's own picker.
|
||||
void EffectsSheet::colour(bool labelled)
|
||||
{
|
||||
auto *swatch = new SwatchButton([this] { return m_session.editingEffects().color(m_kind).value_or(PaletteColor::black()); }, 3, 0, 0.5, this);
|
||||
auto *swatch = new SwatchButton([this] { return m_session.editingEffects().color(m_kind).value_or(PaletteColor::black()); }, 3, 1, 1, this);
|
||||
swatch->setObjectName(QStringLiteral("effectColor"));
|
||||
swatch->setFixedSize(36, 18);
|
||||
swatch->setToolTip(rawValue(m_kind) + QStringLiteral(" color"));
|
||||
@@ -162,7 +162,8 @@ void EffectsSheet::colour(bool labelled)
|
||||
}
|
||||
|
||||
void EffectsSheet::slider(const QString &title, std::function<std::optional<double>(const LayerEffects &)> value,
|
||||
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit)
|
||||
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit,
|
||||
std::optional<double> typedHigh)
|
||||
{
|
||||
const size_t index = m_sliders.size();
|
||||
const QString name = title.toLower();
|
||||
@@ -215,12 +216,12 @@ void EffectsSheet::slider(const QString &title, std::function<std::optional<doub
|
||||
layout->addLayout(suffixed);
|
||||
m_column->addWidget(row);
|
||||
m_controls.push_back(row);
|
||||
m_sliders.push_back(Slider{std::move(value), std::move(change), low, high, slider, field});
|
||||
m_sliders.push_back(Slider{std::move(value), std::move(change), low, high, typedHigh.value_or(high), slider, field});
|
||||
}
|
||||
|
||||
void EffectsSheet::apply(const Slider &control, double value)
|
||||
{
|
||||
const double clamped = std::clamp(value, control.low, control.high);
|
||||
const double clamped = std::clamp(value, control.low, control.typedHigh);
|
||||
m_session.changeEffects([&control, clamped](LayerEffects &effects) { control.change(effects, clamped); });
|
||||
}
|
||||
|
||||
@@ -244,7 +245,7 @@ void EffectsSheet::synchronize()
|
||||
if (!value)
|
||||
continue;
|
||||
{
|
||||
// The slider shows the session's number without writing back.
|
||||
// QSlider pins a larger typed value at its end.
|
||||
const QSignalBlocker quiet(control.slider);
|
||||
control.slider->setValue(int(std::lround((*value - control.low) / (control.high - control.low) * travel)));
|
||||
}
|
||||
|
||||
@@ -22,14 +22,17 @@ private:
|
||||
std::function<void(LayerEffects &, double)> change;
|
||||
double low;
|
||||
double high;
|
||||
// What a field or step may reach, past the slider.
|
||||
double typedHigh;
|
||||
QSlider *slider;
|
||||
PickerField *field;
|
||||
};
|
||||
void header(const QString &title, bool position);
|
||||
void colour(bool labelled);
|
||||
void slider(const QString &title, std::function<std::optional<double>(const LayerEffects &)> value,
|
||||
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit);
|
||||
// A value clamped to the slider's range, as Swift's binding.
|
||||
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit,
|
||||
std::optional<double> typedHigh = std::nullopt);
|
||||
// A value clamped to the typed range, as Swift's setAmount.
|
||||
void apply(const Slider &control, double value);
|
||||
void synchronize();
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ LayerAppearanceControls::LayerAppearanceControls(EditorSession &session, QWidget
|
||||
column->setContentsMargins(12, 12, 12, 12);
|
||||
column->setSpacing(8);
|
||||
auto *blend = new QHBoxLayout;
|
||||
blend->addWidget(caption(QStringLiteral("Blend"), this));
|
||||
m_blendCaption = caption(QStringLiteral("Blend"), this);
|
||||
blend->addWidget(m_blendCaption);
|
||||
blend->addWidget(m_picker, 1);
|
||||
column->addLayout(blend);
|
||||
auto *opacity = new QHBoxLayout;
|
||||
@@ -74,7 +75,9 @@ LayerAppearanceControls::~LayerAppearanceControls()
|
||||
|
||||
void LayerAppearanceControls::synchronize()
|
||||
{
|
||||
setEnabled(m_session.canEditAppearance());
|
||||
// A folder takes an opacity; blending rests with the picker.
|
||||
setEnabled(m_session.canEditOpacity());
|
||||
m_blendCaption->setEnabled(m_session.canEditAppearance());
|
||||
const std::optional<ImageLayer> active = m_session.activeLayer();
|
||||
const double opacity = active ? active->opacity : 1;
|
||||
m_syncing = true;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QWidget>
|
||||
|
||||
class BlendModePicker;
|
||||
class QLabel;
|
||||
|
||||
// The active layer's blend mode and opacity.
|
||||
class LayerAppearanceControls : public QWidget {
|
||||
@@ -25,6 +26,7 @@ private:
|
||||
|
||||
EditorSession &m_session;
|
||||
BlendModePicker *const m_picker;
|
||||
QLabel *m_blendCaption = nullptr;
|
||||
QSlider *const m_slider;
|
||||
QLineEdit *const m_percentage;
|
||||
// The layer the controls were last made for.
|
||||
|
||||
@@ -157,7 +157,7 @@ void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool
|
||||
// A pending rename closes the edits: rows never move.
|
||||
if (!m_renaming)
|
||||
m_name->setText((layer.maskSourceID ? QStringLiteral("↳ ") : QString()) + layer.name);
|
||||
m_dimensions->setText(m_editableText ? QStringLiteral("Text · Double-click to edit")
|
||||
m_dimensions->setText(m_editableText ? QStringLiteral("Text")
|
||||
: m_isAdjustment ? QStringLiteral("Adjustment · Double-click to edit")
|
||||
: layer.isGroup ? QStringLiteral("Folder")
|
||||
: QStringLiteral("%1 × %2 px").arg(std::lround(layer.size().width())).arg(std::lround(layer.size().height())));
|
||||
|
||||
@@ -127,11 +127,6 @@ std::optional<LayerDropTarget> NativeLayerList::dropTarget(const QMimeData &data
|
||||
return std::nullopt;
|
||||
// Swift duplicates when the source offers a copy alone (Alt).
|
||||
const bool copying = offered == Qt::CopyAction;
|
||||
// Folders are not duplicated: a copy with one is refused.
|
||||
for (const QUuid &id : ids) {
|
||||
if (copying && m_rows[size_t(indexOf(m_rows, id))].isGroup)
|
||||
return std::nullopt;
|
||||
}
|
||||
const QPoint inColumn = m_column->mapFrom(viewport(), viewport()->mapFrom(this, listPoint));
|
||||
int row = int(m_rows.size());
|
||||
bool onRow = false;
|
||||
@@ -326,7 +321,7 @@ QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modif
|
||||
if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell))
|
||||
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
|
||||
if (!isClippingZone(cell, inCell))
|
||||
return editable && !layer.isGroup ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
|
||||
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
|
||||
if (!m_session.canToggleClippingMask(layer.id))
|
||||
return QCursor(Qt::ArrowCursor);
|
||||
return clippingCursor(layer.maskSourceID.has_value(), ratio);
|
||||
|
||||
@@ -91,7 +91,8 @@ int NativeLayerList::rowAt(QPoint listPoint) const
|
||||
|
||||
bool NativeLayerList::isClippingZone(const LayerCell &cell, QPoint cellPoint)
|
||||
{
|
||||
return cellPoint.y() >= cell.height() - 10;
|
||||
// A folder clips nothing, so it has no strip.
|
||||
return !cell.isGroup() && cellPoint.y() >= cell.height() - 10;
|
||||
}
|
||||
|
||||
bool NativeLayerList::clickRow(LayerCell &cell, Qt::KeyboardModifiers modifiers, QPoint cellPoint)
|
||||
@@ -177,7 +178,7 @@ void NativeLayerList::keyPressEvent(QKeyEvent *event)
|
||||
{Qt::Key_Z, NavigationTool::zoom}, {Qt::Key_G, NavigationTool::gradient}, {Qt::Key_L, NavigationTool::lasso}, {Qt::Key_M, NavigationTool::marquee},
|
||||
{Qt::Key_W, NavigationTool::wand}, {Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp},
|
||||
{Qt::Key_U, NavigationTool::shape}, {Qt::Key_R, NavigationTool::blur}, {Qt::Key_I, NavigationTool::eyedropper},
|
||||
{Qt::Key_C, NavigationTool::crop}};
|
||||
{Qt::Key_C, NavigationTool::crop}, {Qt::Key_T, NavigationTool::type}};
|
||||
if (plain && (key == Qt::Key_Backspace || key == Qt::Key_Delete)) {
|
||||
m_session.deleteKeyPressed();
|
||||
return;
|
||||
|
||||
@@ -124,6 +124,7 @@ public:
|
||||
void beginRenaming();
|
||||
bool isOnControl(QPoint point) const;
|
||||
bool isRenaming() const { return m_renaming; }
|
||||
bool isGroup() const { return m_isGroup; }
|
||||
QUuid layerID() const { return m_layerID; }
|
||||
LayerThumbnailButton &thumbnail() { return *m_thumbnail; }
|
||||
LayerThumbnailButton &maskThumbnail() { return *m_maskThumbnail; }
|
||||
|
||||
@@ -15,8 +15,6 @@ private slots:
|
||||
void fileEntriesFollowTheControllersGate();
|
||||
void undoAndRedoCarryTheirNamesAndKeepAFieldsOwn();
|
||||
void theMenuBarBorrowsFocusAndTheFieldKeepsUndo();
|
||||
void viewEntriesZoomTheFrontSession();
|
||||
void thePixelGridEntryTogglesTheSession();
|
||||
void layerEntriesFollowTheActiveLayer();
|
||||
void duplicateMergeAndFlipEntriesActOnTheLayers();
|
||||
void transformEntriesFollowTheMoveTool();
|
||||
@@ -32,7 +30,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
{"newCanvas", "Ctrl+N"}, {"openProject", "Ctrl+O"}, {"importImages", ""}, {"save", "Ctrl+S"}, {"saveAs", "Ctrl+Shift+S"},
|
||||
{"exportPNG", "Ctrl+Shift+E"}, {"exportJPEG", "Ctrl+Alt+Shift+S"},
|
||||
{"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"},
|
||||
{"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"},
|
||||
{"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"},
|
||||
{"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"},
|
||||
{"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["},
|
||||
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
|
||||
@@ -53,7 +51,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"}));
|
||||
QCOMPARE(menus[0]->actions().size(), 11);
|
||||
QCOMPARE(menus[1]->actions().size(), 12);
|
||||
QCOMPARE(menus[2]->actions().size(), 6);
|
||||
QCOMPARE(menus[2]->actions().size(), 7);
|
||||
QCOMPARE(menus[3]->actions().size(), 10);
|
||||
QCOMPARE(menus[4]->actions().size(), 13);
|
||||
QCOMPARE(menus[5]->actions().size(), 5);
|
||||
@@ -226,43 +224,6 @@ void CompositorMenusTests::theMenuBarBorrowsFocusAndTheFieldKeepsUndo()
|
||||
QTRY_COMPARE(undo.text(), QString("Undo New Blank Layer"));
|
||||
}
|
||||
|
||||
void CompositorMenusTests::viewEntriesZoomTheFrontSession()
|
||||
{
|
||||
Bar bar;
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
QVERIFY(!bar.action(name).isEnabled());
|
||||
bar.session().createDocument(400, 300);
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
QVERIFY(bar.action(name).isEnabled());
|
||||
bar.session().zoom(0.5);
|
||||
bar.action("actualPixels").trigger();
|
||||
QCOMPARE(bar.session().viewport.zoom(), 1.0);
|
||||
bar.action("zoomIn").trigger();
|
||||
QCOMPARE(bar.session().viewport.zoom(), 1.25);
|
||||
bar.action("zoomOut").trigger();
|
||||
bar.action("zoomOut").trigger();
|
||||
QCOMPARE(bar.session().viewport.zoom(), 0.8);
|
||||
bar.action("fit").trigger();
|
||||
QVERIFY(bar.session().viewport.followsFit());
|
||||
}
|
||||
|
||||
void CompositorMenusTests::thePixelGridEntryTogglesTheSession()
|
||||
{
|
||||
Bar bar;
|
||||
QAction &grid = bar.action("pixelGrid");
|
||||
QVERIFY(grid.isCheckable() && grid.isChecked() && grid.isEnabled());
|
||||
QCOMPARE(grid.text(), QString("Pixel Grid (800% and above)"));
|
||||
grid.trigger();
|
||||
QVERIFY(!bar.session().showsPixelGrid() && !grid.isChecked());
|
||||
grid.trigger();
|
||||
QVERIFY(bar.session().showsPixelGrid() && grid.isChecked());
|
||||
// The session's own change reaches the mark.
|
||||
bar.session().setShowsPixelGrid(false);
|
||||
QVERIFY(!grid.isChecked());
|
||||
bar.workspace.newCanvas();
|
||||
QVERIFY(grid.isChecked() && grid.isEnabled());
|
||||
}
|
||||
|
||||
void CompositorMenusTests::layerEntriesFollowTheActiveLayer()
|
||||
{
|
||||
Bar bar;
|
||||
|
||||
@@ -199,6 +199,11 @@ void CropCanvasTests::snappingReachesEightViewPoints()
|
||||
const QPointF from = shown.session.viewport.viewPoint(QPointF(100, 50), size), to = shown.session.viewport.viewPoint(QPointF(390, 170), size);
|
||||
shown.drag(from, to);
|
||||
QCOMPARE(shown.frame(), std::optional(QRectF(100, 50, 300, 120)));
|
||||
// Snap off: the edges stay where the pointer puts them.
|
||||
shown.session.setSnappingEnabled(false);
|
||||
shown.session.setCropRect(std::nullopt);
|
||||
shown.drag(from, to);
|
||||
QCOMPARE(shown.frame(), std::optional(QRectF(100, 50, 290, 120)));
|
||||
}
|
||||
|
||||
void CropCanvasTests::theCursorShowsWhatAPressTakes()
|
||||
|
||||
@@ -43,7 +43,6 @@ private slots:
|
||||
void foldingAFolderTakesTheSelectionFromItsContents();
|
||||
void deletingAFolderTakesItsContents();
|
||||
void movingStaysAmongSiblings();
|
||||
void duplicatingALayerByDraggingPlacesTheCopyAsOneStep();
|
||||
void aNewCanvasCanStartWithALayer();
|
||||
void sessionChangesAreLogged();
|
||||
};
|
||||
@@ -445,50 +444,5 @@ void EditorSessionTests::sessionChangesAreLogged()
|
||||
}
|
||||
|
||||
// Alt-dragging a row drops a duplicate, one step.
|
||||
void EditorSessionTests::duplicatingALayerByDraggingPlacesTheCopyAsOneStep()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(800, 600);
|
||||
for (int each = 0; each < 3; ++each)
|
||||
session.addBlankLayer();
|
||||
const std::vector<LayerHierarchy::Entry> rows = session.layerRows();
|
||||
const ProjectLayerRecord top = rows.front().layer, bottom = rows.back().layer;
|
||||
const std::optional<CanvasDocument> before = session.document();
|
||||
const int count = session.history.undoCount();
|
||||
QVERIFY(session.duplicateLayer(bottom.id, std::nullopt, top.id));
|
||||
const std::vector<LayerHierarchy::Entry> after = session.layerRows();
|
||||
QCOMPARE(after.size(), size_t(4));
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Duplicate Layer"));
|
||||
QCOMPARE(after.front().layer.name, bottom.name + " copy");
|
||||
QCOMPARE(after.front().layer.id, session.activeLayerID());
|
||||
QVERIFY(indexOf(session.document().value().layers, bottom.id) >= 0);
|
||||
session.undo();
|
||||
QCOMPARE(session.document(), before);
|
||||
// Folders are not duplicated this way; neither is a stranger.
|
||||
session.addGroup();
|
||||
const QUuid folder = session.activeLayerID().value();
|
||||
QVERIFY(!session.duplicateLayer(folder, std::nullopt, std::nullopt, true));
|
||||
QVERIFY(!session.duplicateLayer(QUuid::createUuid(), std::nullopt));
|
||||
QVERIFY(!session.duplicateLayer(bottom.id, bottom.id));
|
||||
QCOMPARE(session.layerRows().size(), size_t(4));
|
||||
// Into a folder, at its top.
|
||||
QVERIFY(session.duplicateLayer(bottom.id, folder));
|
||||
QCOMPARE(layerWith(session, session.activeLayerID().value()).parentID, std::optional(folder));
|
||||
session.setIsImporting(true);
|
||||
QVERIFY(!session.duplicateLayer(bottom.id, std::nullopt));
|
||||
session.setIsImporting(false);
|
||||
// The copy sits right above; a folder makes none.
|
||||
session.selectLayer(top.id);
|
||||
session.duplicateActiveLayer();
|
||||
const int original = indexOf(session.document().value().layers, top.id);
|
||||
QCOMPARE(session.document().value().layers[original + 1].name, top.name + " copy");
|
||||
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[original + 1].id));
|
||||
const size_t kept = session.document().value().layers.size();
|
||||
session.selectLayer(folder);
|
||||
session.duplicateActiveLayer();
|
||||
QCOMPARE(session.document().value().layers.size(), kept);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(EditorSessionTests)
|
||||
#include "EditorSessionTests.moc"
|
||||
+24
-10
@@ -147,7 +147,8 @@ void EffectsPanelTests::theSheetEditsItsEffect()
|
||||
QCOMPARE(size.text(), QString("4"));
|
||||
type(size, "12");
|
||||
QCOMPARE(editor.effects().stroke->size, 12.0);
|
||||
QCOMPARE(slider.value(), 22);
|
||||
// The slider spans 0 to 20; typing reaches 500.
|
||||
QCOMPARE(slider.value(), 600);
|
||||
// No finite number changes nothing; the field goes back.
|
||||
type(size, "abc");
|
||||
QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12");
|
||||
@@ -158,6 +159,8 @@ void EffectsPanelTests::theSheetEditsItsEffect()
|
||||
QCOMPARE(editor.effects().stroke->size, 13.0);
|
||||
QTest::keyClick(&size, Qt::Key_Up, Qt::ShiftModifier);
|
||||
QCOMPARE(editor.effects().stroke->size, 23.0);
|
||||
// Past its end the thumb rests there, writing nothing back.
|
||||
QCOMPARE(slider.value(), 1000);
|
||||
// A step replaces pending typing, even where it clamps.
|
||||
typing(size, "12");
|
||||
QTest::keyClick(&size, Qt::Key_Up);
|
||||
@@ -172,12 +175,12 @@ void EffectsPanelTests::theSheetEditsItsEffect()
|
||||
size.clearFocus();
|
||||
QCOMPARE(editor.effects().stroke->size, 500.0);
|
||||
// The slider sets any value; the field rounds, ties even.
|
||||
slider.setValue(500);
|
||||
QVERIFY(editor.effects().stroke->size == 250.5 && size.text() == "250");
|
||||
slider.setValue(525);
|
||||
QVERIFY(editor.effects().stroke->size == 10.5 && size.text() == "10");
|
||||
// Leaving an untouched field keeps the exact value.
|
||||
size.setFocus();
|
||||
size.clearFocus();
|
||||
QCOMPARE(editor.effects().stroke->size, 250.5);
|
||||
QCOMPARE(editor.effects().stroke->size, 10.5);
|
||||
// Typing waits while other changes arrive.
|
||||
auto &opacity = find<QLineEdit>(sheet, "opacityField");
|
||||
QCOMPARE(opacity.text(), QString("100"));
|
||||
@@ -206,6 +209,11 @@ void EffectsPanelTests::theSheetEditsItsEffect()
|
||||
editor.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0}));
|
||||
QCOMPARE(editor.effects().stroke->color(), (PaletteColor{1, 0, 0}));
|
||||
QTRY_VERIFY(paints > 0);
|
||||
// A black rim, a white ring inside, then the colour.
|
||||
const QImage shot = swatch.grab().toImage();
|
||||
QVERIFY2(shot.pixelColor(0, 9) == QColor(Qt::black), qPrintable(shot.pixelColor(0, 9).name()));
|
||||
QVERIFY2(shot.pixelColor(1, 9) == QColor(Qt::white), qPrintable(shot.pixelColor(1, 9).name()));
|
||||
QCOMPARE(shot.pixelColor(18, 9), QColor(Qt::red));
|
||||
editor.session.closeColorPicker(true);
|
||||
// Return commits, then goes on to OK, keeping everything.
|
||||
typing(size, "7");
|
||||
@@ -227,7 +235,7 @@ void EffectsPanelTests::aWheelOnTheSliderReplacesTheTyping()
|
||||
QWheelEvent wheel(QPointF(10, 5), slider.mapToGlobal(QPointF(10, 5)), QPoint(), QPoint(0, -120), Qt::NoButton, Qt::NoModifier,
|
||||
Qt::NoScrollPhase, false);
|
||||
QApplication::sendEvent(&slider, &wheel);
|
||||
const double wheeled = 1 + 499.0 * (6 - QApplication::wheelScrollLines()) / 1000;
|
||||
const double wheeled = 20.0 * (200 - QApplication::wheelScrollLines()) / 1000;
|
||||
QCOMPARE(editor.effects().stroke->size, wheeled);
|
||||
QVERIFY(size.hasFocus() && size.text() == QString::number(std::lround(wheeled)) && !size.isModified());
|
||||
// Return then keeps the slider's value, going on to OK.
|
||||
@@ -276,11 +284,13 @@ void EffectsPanelTests::eachKindHasSwiftsControls_data()
|
||||
QTest::addColumn<LayerEffectKind>("kind");
|
||||
QTest::addColumn<QString>("headline");
|
||||
QTest::addColumn<QStringList>("fields");
|
||||
const QStringList shadow{"Opacity:0:100", "Angle:-180:180", "Distance:0:300", "Blur:0:300"};
|
||||
QTest::newRow("stroke") << LayerEffectKind::stroke << QString("Stroke") << QStringList{"Size:1:500", "Opacity:0:100"};
|
||||
// Title, typed floor and ceiling, then the slider's.
|
||||
const QStringList shadow{"Opacity:0:100:100", "Angle:-180:180:180", "Distance:0:5000:100", "Blur:0:500:100"};
|
||||
QTest::newRow("stroke") << LayerEffectKind::stroke << QString("Stroke") << QStringList{"Size:0:500:20", "Opacity:0:100:100"};
|
||||
QTest::newRow("shadow") << LayerEffectKind::shadow << QString("Drop Shadow") << shadow;
|
||||
QTest::newRow("overlay") << LayerEffectKind::colorOverlay << QString("Color Overlay") << QStringList{"Opacity:0:100"};
|
||||
QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow") << shadow;
|
||||
QTest::newRow("overlay") << LayerEffectKind::colorOverlay << QString("Color Overlay") << QStringList{"Opacity:0:100:100"};
|
||||
QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow")
|
||||
<< QStringList{"Opacity:0:100:100", "Angle:-180:180:180", "Distance:0:5000:50", "Blur:0:500:100"};
|
||||
}
|
||||
|
||||
void EffectsPanelTests::eachKindHasSwiftsControls()
|
||||
@@ -312,6 +322,10 @@ void EffectsPanelTests::eachKindHasSwiftsControls()
|
||||
QCOMPARE(field.text(), parts[2]);
|
||||
type(field, "-99999");
|
||||
QCOMPARE(field.text(), parts[1]);
|
||||
// The slider's end sets its range's top.
|
||||
auto &slider = *sheet.findChild<QSlider *>(parts[0].toLower() + "Slider");
|
||||
slider.setValue(1000);
|
||||
QCOMPARE(field.text(), parts[3]);
|
||||
}
|
||||
QCOMPARE(names, titles);
|
||||
}
|
||||
@@ -342,7 +356,7 @@ void EffectsPanelTests::cancelAndEscapePutTheEffectBack()
|
||||
editor.session.selectEffect(LayerEffectKind::shadow, editor.layer, true);
|
||||
QCOMPARE(find<QLineEdit>(*editor.sheet(), "distanceField").text(), QString("20"));
|
||||
find<QSlider>(*editor.sheet(), "distanceSlider").setValue(400);
|
||||
QCOMPARE(editor.effects().shadow->distance, 120.0);
|
||||
QCOMPARE(editor.effects().shadow->distance, 40.0);
|
||||
find<QPushButton>(*editor.sheet(), "effectsCancel").click();
|
||||
QVERIFY(editor.effects().shadow->distance == 20 && !editor.session.effectsEditing());
|
||||
// A new overlay's panel: Escape takes the overlay away again.
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#include "Document/LiveLayerMask.h"
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "RenderFixtures.h"
|
||||
#include "SessionFixtures.h"
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
// A folder's opacity dims each layer inside it (Swift 1.1.6).
|
||||
namespace {
|
||||
ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name)
|
||||
{
|
||||
const QImage image = solid(width, height, premultiplied);
|
||||
return ImportedImage(image, image, name);
|
||||
}
|
||||
|
||||
// The layer wrapped in a folder, the folder left active.
|
||||
QUuid wrap(EditorSession &session, QUuid id)
|
||||
{
|
||||
session.selectLayers({id}, id);
|
||||
session.groupSelectedLayers();
|
||||
return session.activeLayerID().value();
|
||||
}
|
||||
|
||||
void dim(EditorSession &session, QUuid id, double opacity)
|
||||
{
|
||||
session.selectLayer(id);
|
||||
session.setLayerOpacity(opacity);
|
||||
}
|
||||
|
||||
QImage exported(const EditorSession &session)
|
||||
{
|
||||
return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
}
|
||||
|
||||
int alpha(const QImage &image, int x, int y)
|
||||
{
|
||||
return image.constScanLine(y)[x * 4 + 3];
|
||||
}
|
||||
|
||||
std::optional<std::pair<double, std::optional<QUuid>>> none(QUuid)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
class FolderOpacityTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void opacitiesMultiplyUpTheTree();
|
||||
void theExportAndTheCompositeDimInsideAFolder();
|
||||
void anAdjustmentDimsWithItsFolder();
|
||||
void effectsDimWithTheirFolder();
|
||||
void theCanvasShowsAndFollowsAFoldersOpacity();
|
||||
void theCanvasDimsEveryWayALayerDraws_data();
|
||||
void theCanvasDimsEveryWayALayerDraws();
|
||||
void aClippingSourceBakesDimmed();
|
||||
void aDimmedFolderSavesAndReopens();
|
||||
};
|
||||
|
||||
void FolderOpacityTests::opacitiesMultiplyUpTheTree()
|
||||
{
|
||||
const QUuid inner = QUuid::createUuid(), outer = QUuid::createUuid();
|
||||
const auto tree = [&](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
|
||||
if (id == inner)
|
||||
return std::pair(0.5, std::optional(outer));
|
||||
if (id == outer)
|
||||
return std::pair(0.8, std::optional<QUuid>());
|
||||
return std::nullopt;
|
||||
};
|
||||
QCOMPARE(LayerOpacity::effective(0.5, inner, tree), 0.2);
|
||||
QCOMPARE(LayerOpacity::effective(0.5, std::nullopt, tree), 0.5);
|
||||
// A parent nobody knows ends the walk.
|
||||
QCOMPARE(LayerOpacity::effective(0.5, QUuid::createUuid(), none), 0.5);
|
||||
// A loop stops after 64 folders, as Swift's does.
|
||||
const QUuid loop = QUuid::createUuid();
|
||||
const auto ring = [&](QUuid) -> std::optional<std::pair<double, std::optional<QUuid>>> { return std::pair(0.9, std::optional(loop)); };
|
||||
QCOMPARE(LayerOpacity::effective(1, loop, ring), std::pow(0.9, 64));
|
||||
// A document that repeats an id is a bug.
|
||||
CanvasDocument twice(4, 4);
|
||||
twice.layers = {ImageLayer(QStringLiteral("One"), QSizeF(4, 4))};
|
||||
twice.layers.push_back(twice.layers.front());
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, twice.effectiveOpacities());
|
||||
// The document's map and each record's agree.
|
||||
EditorSession session;
|
||||
session.createDocument(4, 4);
|
||||
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
|
||||
const QUuid red = session.activeLayerID().value();
|
||||
dim(session, red, 0.5);
|
||||
const QUuid folder = wrap(session, red);
|
||||
const QUuid top = wrap(session, folder);
|
||||
dim(session, folder, 0.5);
|
||||
dim(session, top, 0.8);
|
||||
const QHash<QUuid, double> opacities = session.document().value().effectiveOpacities();
|
||||
QCOMPARE(opacities.value(red), 0.2);
|
||||
QCOMPARE(opacities.value(folder), 0.4);
|
||||
QCOMPARE(opacities.value(top), 0.8);
|
||||
std::map<QUuid, ProjectLayerRecord> records;
|
||||
const ProjectSnapshot snapshot = session.projectSnapshot().value();
|
||||
for (const ProjectLayerRecord &record : snapshot.manifest.layers)
|
||||
records.emplace(record.id, record);
|
||||
QCOMPARE(records.at(red).effectiveOpacity(records), 0.2);
|
||||
// The layer itself still reads its own opacity.
|
||||
QCOMPARE(layerWith(session, red).opacity, 0.5);
|
||||
}
|
||||
|
||||
void FolderOpacityTests::theExportAndTheCompositeDimInsideAFolder()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(4, 4);
|
||||
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
|
||||
const QUuid red = session.activeLayerID().value();
|
||||
const QUuid folder = wrap(session, red);
|
||||
QCOMPARE(alpha(exported(session), 1, 1), 255);
|
||||
dim(session, folder, 0.5);
|
||||
QCOMPARE(alpha(exported(session), 1, 1), 127);
|
||||
// Nested, each folder multiplies in.
|
||||
const QUuid top = wrap(session, folder);
|
||||
dim(session, top, 0.5);
|
||||
QCOMPARE(alpha(exported(session), 1, 1), 63);
|
||||
QImage composite = BrushRaster::context(4, 4, false);
|
||||
QPainter painter(&composite);
|
||||
session.drawLiveComposite(session.document().value(), painter);
|
||||
painter.end();
|
||||
QCOMPARE(alpha(composite, 1, 1), 63);
|
||||
}
|
||||
|
||||
void FolderOpacityTests::anAdjustmentDimsWithItsFolder()
|
||||
{
|
||||
// Half an adjustment equals a whole one, half folded.
|
||||
const auto scene = [](bool folded) {
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(4, 4);
|
||||
session->insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
|
||||
session->addAdjustment(AdjustmentKind::gradientMap);
|
||||
session->setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid adjustment = session->activeLayerID().value();
|
||||
if (folded)
|
||||
dim(*session, wrap(*session, adjustment), 0.5);
|
||||
else
|
||||
dim(*session, adjustment, 0.5);
|
||||
QImage composite = BrushRaster::context(4, 4, false);
|
||||
QPainter painter(&composite);
|
||||
session->drawLiveComposite(session->document().value(), painter);
|
||||
painter.end();
|
||||
return std::pair(exported(*session), composite);
|
||||
};
|
||||
const auto half = scene(false), folded = scene(true);
|
||||
QCOMPARE(folded.first, half.first);
|
||||
QCOMPARE(folded.second, half.second);
|
||||
QVERIFY(half.first.pixelColor(1, 1) != QColor(Qt::red) && half.second.pixelColor(1, 1) != QColor(Qt::red));
|
||||
}
|
||||
|
||||
void FolderOpacityTests::effectsDimWithTheirFolder()
|
||||
{
|
||||
// A stroked layer: half alone, or whole, half folded.
|
||||
const auto scene = [](bool folded) {
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(20, 20);
|
||||
session->insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red"));
|
||||
const QUuid red = session->activeLayerID().value();
|
||||
LayerEffects effects;
|
||||
effects.stroke = StrokeEffect();
|
||||
session->setEffects(effects, red);
|
||||
if (folded)
|
||||
dim(*session, wrap(*session, red), 0.5);
|
||||
else
|
||||
dim(*session, red, 0.5);
|
||||
QImage composite = BrushRaster::context(20, 20, false);
|
||||
QPainter painter(&composite);
|
||||
session->drawLiveComposite(session->document().value(), painter);
|
||||
painter.end();
|
||||
return std::pair(exported(*session), composite);
|
||||
};
|
||||
const auto half = scene(false), folded = scene(true);
|
||||
QCOMPARE(folded.first, half.first);
|
||||
QCOMPARE(folded.second, half.second);
|
||||
QVERIFY(alpha(half.first, 10, 10) == 127 && alpha(half.first, 4, 10) > 0);
|
||||
}
|
||||
|
||||
void FolderOpacityTests::theCanvasShowsAndFollowsAFoldersOpacity()
|
||||
{
|
||||
EditorSession session;
|
||||
CanvasView canvas(session);
|
||||
session.createDocument(20, 20);
|
||||
session.setShowsTransformControls(false);
|
||||
canvas.resize(40, 40);
|
||||
session.viewport.resize(QSizeF(40, 40), 1, QSizeF(20, 20));
|
||||
session.zoom(1);
|
||||
// White below, so canvas and export both show opaque.
|
||||
session.insert(filled(20, 20, qRgba(255, 255, 255, 255), "White"));
|
||||
session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red"));
|
||||
const QUuid folder = wrap(session, session.activeLayerID().value());
|
||||
canvas.synchronizeDisplay();
|
||||
const QPoint middle = session.viewport.viewPoint(QPointF(10, 10), QSizeF(20, 20)).toPoint();
|
||||
QCOMPARE(canvas.grab().toImage().pixelColor(middle), QColor(Qt::red));
|
||||
// A folder holds no pixels, yet its opacity redraws.
|
||||
dim(session, folder, 0.5);
|
||||
QVERIFY(canvas.synchronizeDisplay());
|
||||
const QColor shown = canvas.grab().toImage().pixelColor(middle);
|
||||
const QColor expected = exported(session).pixelColor(10, 10);
|
||||
QVERIFY2(std::abs(shown.green() - expected.green()) <= 1 && std::abs(expected.green() - 128) <= 1 && shown.red() == 255,
|
||||
qPrintable(shown.name() + expected.name()));
|
||||
}
|
||||
|
||||
void FolderOpacityTests::aClippingSourceBakesDimmed()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(4, 4);
|
||||
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Base"));
|
||||
const QUuid base = session.activeLayerID().value();
|
||||
session.insert(filled(4, 4, qRgba(0, 255, 0, 255), "Clipped"));
|
||||
const QUuid clipped = session.activeLayerID().value();
|
||||
session.toggleClippingMask(clipped);
|
||||
session.selectLayers({base, clipped}, clipped);
|
||||
session.groupSelectedLayers();
|
||||
const QUuid folder = session.activeLayerID().value();
|
||||
const QImage whole = LiveMaskBaker::bake(session.projectSnapshot().value(), clipped).value().image();
|
||||
QCOMPARE(qAlpha(whole.pixel(1, 1)), 255);
|
||||
dim(session, folder, 0.5);
|
||||
const QImage dimmed = LiveMaskBaker::bake(session.projectSnapshot().value(), clipped).value().image();
|
||||
QCOMPARE(qAlpha(dimmed.pixel(1, 1)), 127);
|
||||
// A folder around the folder dims the source again.
|
||||
dim(session, wrap(session, folder), 0.5);
|
||||
const QImage twice = LiveMaskBaker::bake(session.projectSnapshot().value(), clipped).value().image();
|
||||
QCOMPARE(qAlpha(twice.pixel(1, 1)), 63);
|
||||
}
|
||||
|
||||
void FolderOpacityTests::aDimmedFolderSavesAndReopens()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
EditorSession session;
|
||||
session.createDocument(4, 4);
|
||||
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
|
||||
const QUuid folder = wrap(session, session.activeLayerID().value());
|
||||
dim(session, folder, 0.5);
|
||||
QCOMPARE(layerWith(session, folder).opacity, 0.5);
|
||||
const QString path = root.filePath("Dimmed.comp");
|
||||
ProjectStore::save(session.projectSnapshot().value(), path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(8));
|
||||
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
|
||||
if (record.isGroup == true)
|
||||
QCOMPARE(record.opacity, std::optional(0.5));
|
||||
}
|
||||
}
|
||||
|
||||
void FolderOpacityTests::theCanvasDimsEveryWayALayerDraws_data()
|
||||
{
|
||||
QTest::addColumn<QString>("way");
|
||||
QTest::newRow("adjustment") << "adjustment";
|
||||
QTest::newRow("effects") << "effects";
|
||||
QTest::newRow("warp") << "warp";
|
||||
}
|
||||
|
||||
void FolderOpacityTests::theCanvasDimsEveryWayALayerDraws()
|
||||
{
|
||||
QFETCH(QString, way);
|
||||
// The canvas, half alone against whole in a half folder.
|
||||
const auto scene = [&way](bool folded) {
|
||||
EditorSession session;
|
||||
CanvasView canvas(session);
|
||||
session.createDocument(20, 20);
|
||||
session.setShowsTransformControls(false);
|
||||
canvas.resize(40, 40);
|
||||
session.viewport.resize(QSizeF(40, 40), 1, QSizeF(20, 20));
|
||||
session.zoom(1);
|
||||
session.insert(filled(20, 20, qRgba(255, 255, 255, 255), "White"));
|
||||
session.insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red"));
|
||||
QUuid target = session.activeLayerID().value();
|
||||
if (way == "adjustment") {
|
||||
session.addAdjustment(AdjustmentKind::gradientMap);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
target = session.activeLayerID().value();
|
||||
} else if (way == "effects") {
|
||||
LayerEffects effects;
|
||||
effects.stroke = StrokeEffect();
|
||||
session.setEffects(effects, target);
|
||||
}
|
||||
dim(session, folded ? wrap(session, target) : target, 0.5);
|
||||
if (way == "warp") {
|
||||
session.selectLayer(target);
|
||||
session.selectTool(NavigationTool::blur);
|
||||
session.beginBrush(QPointF(10, 10));
|
||||
if (!session.warpStroke())
|
||||
throw std::runtime_error("no warp began");
|
||||
}
|
||||
canvas.synchronizeDisplay();
|
||||
if (way == "effects" && !QTest::qWaitFor([&] { canvas.grab(); return session.effectsPreviews.rendered(target).has_value(); }))
|
||||
throw std::runtime_error("the effects never rendered");
|
||||
return canvas.grab().toImage();
|
||||
};
|
||||
const QImage half = scene(false), folded = scene(true);
|
||||
QCOMPARE(folded, half);
|
||||
const QPoint inside(20, 20);
|
||||
QVERIFY2(half.pixelColor(inside) != QColor(Qt::red) && half.pixelColor(inside) != QColor(Qt::white), qPrintable(half.pixelColor(inside).name()));
|
||||
}
|
||||
|
||||
QTEST_MAIN(FolderOpacityTests)
|
||||
#include "FolderOpacityTests.moc"
|
||||
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
|
||||
session.addBlankLayer();
|
||||
const QUuid child = session.activeLayerID().value();
|
||||
const ProjectSnapshot snapshot = session.projectSnapshot().value();
|
||||
QCOMPARE(snapshot.manifest.version, qint64(7));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(8));
|
||||
const auto record = [](const ProjectSnapshot &from, QUuid id) {
|
||||
for (const ProjectLayerRecord &layer : from.manifest.layers) {
|
||||
if (layer.id == id)
|
||||
|
||||
@@ -261,7 +261,7 @@ void GroupTests::projectErrorsCarryTheirMessages()
|
||||
const ProjectError version = ProjectError::unsupportedVersion(9);
|
||||
QCOMPARE(version.kind, ProjectError::Kind::version);
|
||||
QCOMPARE(version.version, std::optional<int>(9));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 9. This app supports versions 1–7."));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 9. This app supports versions 1–8."));
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
|
||||
|
||||
const ProjectError missingImage(ProjectError::Kind::missingImage);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "UI/LayerAppearanceControls.h"
|
||||
#include "UI/BlendModePicker.h"
|
||||
#include <QLabel>
|
||||
#include <QtTest>
|
||||
|
||||
// The opacity slider and field, one undo step a drag.
|
||||
@@ -52,6 +53,21 @@ void LayerAppearanceControlsTests::theControlsFollowTheActiveLayerAndItsGate()
|
||||
QVERIFY(controls.widget.findChild<BlendModePicker *>() != nullptr);
|
||||
controls.session.selectLayers({controls.session.document().value().layers.front().id, controls.session.document().value().layers.back().id}, std::nullopt);
|
||||
QVERIFY(!controls.widget.isEnabled());
|
||||
// A folder: its opacity edits, its blend mode rests.
|
||||
controls.session.addGroup();
|
||||
QVERIFY(controls.widget.isEnabled() && controls.slider.isEnabled() && controls.field.isEnabled());
|
||||
const auto blendRow = [&] {
|
||||
for (QLabel *label : controls.widget.findChildren<QLabel *>()) {
|
||||
if (label->text() == "Blend")
|
||||
return label;
|
||||
}
|
||||
throw std::runtime_error("no Blend caption");
|
||||
};
|
||||
QVERIFY(!controls.widget.findChild<BlendModePicker *>()->isEnabled() && !blendRow()->isEnabled());
|
||||
controls.slider.setValue(500);
|
||||
QCOMPARE(controls.opacity(), 0.5);
|
||||
controls.session.selectLayer(controls.session.document().value().layers.front().id);
|
||||
QVERIFY(controls.widget.findChild<BlendModePicker *>()->isEnabled() && blendRow()->isEnabled());
|
||||
}
|
||||
|
||||
void LayerAppearanceControlsTests::aSliderDragIsOneUndoStep()
|
||||
|
||||
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
|
||||
const QString path = folder.filePath("Appearance.comp");
|
||||
ProjectStore::save(snapshot, path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(7));
|
||||
QCOMPARE(loaded.manifest.version, qint64(8));
|
||||
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
|
||||
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
|
||||
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
|
||||
@@ -253,7 +253,8 @@ void LayerAppearanceTests::selectedLayersTakeOneOpacityInOneStep()
|
||||
session.setSelectedLayersOpacity(0.5);
|
||||
QCOMPARE(layerWith(session, first).opacity, 0.5);
|
||||
QCOMPARE(layerWith(session, second).opacity, 0.5);
|
||||
QCOMPARE(layerWith(session, folder).opacity, 1.0);
|
||||
// A selected folder takes it too.
|
||||
QCOMPARE(layerWith(session, folder).opacity, 0.5);
|
||||
QCOMPARE(layerWith(session, apart).opacity, 1.0);
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Layer Opacity"));
|
||||
@@ -326,13 +327,27 @@ void LayerAppearanceTests::onlyOnePixelLayerHasAnAppearanceToEdit()
|
||||
QVERIFY(session.canEditAppearance());
|
||||
session.addGroup();
|
||||
const QUuid folder = session.activeLayerID().value();
|
||||
// A folder takes an opacity; blending stays per layer.
|
||||
QVERIFY(!session.canEditAppearance());
|
||||
QVERIFY(session.canEditOpacity());
|
||||
session.setLayerBlendMode(LayerBlendMode::screen);
|
||||
session.setLayerOpacity(0.5);
|
||||
session.previewBlendMode(LayerBlendMode::screen, folder);
|
||||
QVERIFY(!session.blendPreview().has_value());
|
||||
QCOMPARE(layerWith(session, folder).blendMode, LayerBlendMode::normal);
|
||||
QCOMPARE(layerWith(session, folder).opacity, 1.0);
|
||||
session.setLayerOpacity(0.5);
|
||||
QCOMPARE(layerWith(session, folder).opacity, 0.5);
|
||||
QCOMPARE(session.history.undoName(), QString("Layer Opacity"));
|
||||
session.beginOpacityEdit();
|
||||
session.setLayerOpacity(0.25);
|
||||
session.setLayerOpacity(0.2);
|
||||
session.finishOpacityEdit();
|
||||
QCOMPARE(layerWith(session, folder).opacity, 0.2);
|
||||
session.undo();
|
||||
QCOMPARE(layerWith(session, folder).opacity, 0.5);
|
||||
// Two layers selected: neither gate holds.
|
||||
session.selectLayers({folder, session.document().value().layers.front().id}, folder);
|
||||
QVERIFY(!session.canEditOpacity());
|
||||
session.selectLayer(folder);
|
||||
session.addBlankLayer();
|
||||
QVERIFY(session.canEditAppearance());
|
||||
session.setShowsImporter(true);
|
||||
@@ -441,10 +456,9 @@ void LayerAppearanceTests::moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo()
|
||||
const int count = session.history.undoCount();
|
||||
session.typeOpacityDigit(5, 10);
|
||||
for (const ImageLayer &layer : session.document().value().layers) {
|
||||
if (layer.id == first || layer.id == second)
|
||||
// The folder takes it too, since 1.1.6.
|
||||
if (layer.id == first || layer.id == second || layer.id == folder)
|
||||
QCOMPARE(layer.opacity, 0.5);
|
||||
if (layer.id == folder)
|
||||
QCOMPARE(layer.opacity, 1.0);
|
||||
}
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
}
|
||||
|
||||
@@ -65,13 +65,15 @@ void LayerDragGestureTests::altShowsTheClippingAndDuplicateCursors()
|
||||
QVERIFY(image(CanvasView::loadSelectionCursor(1)).pixelColor(11, 8).lightness() > 128);
|
||||
QVERIFY(image(CanvasView::loadSelectionCursor(1)).pixelColor(21, 18).alpha() > 0);
|
||||
QCOMPARE(image(CanvasView::loadSelectionCursor(1)).pixelColor(21, 21).alpha(), 0);
|
||||
// The bottom row clips to nothing; a folder duplicates not.
|
||||
// The bottom row clips nothing; a folder has no strip.
|
||||
const QPoint lastStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4));
|
||||
QCOMPARE(shown.list.cursorFor(lastStrip, Qt::AltModifier).shape(), Qt::ArrowCursor);
|
||||
session->selectLayer(shown.id(1));
|
||||
session->addGroup();
|
||||
const QPoint folderBody = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, 20));
|
||||
QCOMPARE(shown.list.cursorFor(folderBody, Qt::AltModifier).shape(), Qt::ArrowCursor);
|
||||
QCOMPARE(image(shown.list.cursorFor(folderBody, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio)));
|
||||
const QPoint folderStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4));
|
||||
QCOMPARE(image(shown.list.cursorFor(folderStrip, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio)));
|
||||
// A mask thumbnail copies, or loads with Ctrl; busy, nothing.
|
||||
session->selectLayer(shown.id(0));
|
||||
session->addLayerMask();
|
||||
|
||||
@@ -125,11 +125,25 @@ void LayerDragTests::anAltDragDropsDuplicates()
|
||||
QCOMPARE(session->history.undoName(), QString("Duplicate Layers"));
|
||||
session->undo();
|
||||
QCOMPARE(shown.names().size(), size_t(3));
|
||||
// A folder among the dragged rows: no copy at all.
|
||||
session->selectLayer(shown.id(2));
|
||||
// A folder among the dragged rows copies with its contents.
|
||||
const QUuid held = shown.id(2);
|
||||
session->selectLayer(held);
|
||||
session->addGroup();
|
||||
QVERIFY(!shown.drop(shown.rows({0, 2}), Qt::CopyAction, shown.below()));
|
||||
QVERIFY(session->placeLayer(held, session->activeLayerID().value()));
|
||||
QCOMPARE(shown.names().size(), size_t(4));
|
||||
QVERIFY(session->document().value().layers[size_t(indexOf(session->document().value().layers, shown.id(2)))].isGroup);
|
||||
QVERIFY(shown.drop(shown.rows({0, 2}), Qt::CopyAction, shown.below()));
|
||||
QCOMPARE(session->history.undoName(), QString("Duplicate Layers"));
|
||||
const std::vector<ImageLayer> &layers = session->document().value().layers;
|
||||
QCOMPARE(int(layers.size()), 7);
|
||||
std::vector<QUuid> folders;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (layer.isGroup)
|
||||
folders.push_back(layer.id);
|
||||
}
|
||||
QCOMPARE(int(folders.size()), 2);
|
||||
for (const QUuid &folder : folders)
|
||||
QCOMPARE(int(session->descendantIDs(folder).size()), 1);
|
||||
}
|
||||
|
||||
void LayerDragTests::aMaskDragCopiesTheMaskOntoTheRowUnderIt()
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "SessionFixtures.h"
|
||||
#include <QtTest>
|
||||
|
||||
// Duplicating layers and folders in the session.
|
||||
class LayerDuplicateTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void duplicatingALayerByDraggingPlacesTheCopyAsOneStep();
|
||||
void aFolderCopyKeepsLinksInsideAndStopsAtTheLimit();
|
||||
};
|
||||
|
||||
void LayerDuplicateTests::duplicatingALayerByDraggingPlacesTheCopyAsOneStep()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(800, 600);
|
||||
for (int each = 0; each < 3; ++each)
|
||||
session.addBlankLayer();
|
||||
const std::vector<LayerHierarchy::Entry> rows = session.layerRows();
|
||||
const ProjectLayerRecord top = rows.front().layer, bottom = rows.back().layer;
|
||||
const std::optional<CanvasDocument> before = session.document();
|
||||
const int count = session.history.undoCount();
|
||||
QVERIFY(session.duplicateLayer(bottom.id, std::nullopt, top.id));
|
||||
const std::vector<LayerHierarchy::Entry> after = session.layerRows();
|
||||
QCOMPARE(after.size(), size_t(4));
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Duplicate Layer"));
|
||||
QCOMPARE(after.front().layer.name, bottom.name + " copy");
|
||||
QCOMPARE(after.front().layer.id, session.activeLayerID());
|
||||
QVERIFY(indexOf(session.document().value().layers, bottom.id) >= 0);
|
||||
session.undo();
|
||||
QCOMPARE(session.document(), before);
|
||||
session.addGroup();
|
||||
const QUuid folder = session.activeLayerID().value();
|
||||
// A stranger or a place inside itself is refused.
|
||||
QVERIFY(!session.duplicateLayer(QUuid::createUuid(), std::nullopt));
|
||||
QVERIFY(!session.duplicateLayer(bottom.id, bottom.id));
|
||||
QVERIFY(!session.duplicateLayer(folder, folder));
|
||||
QCOMPARE(session.layerRows().size(), size_t(4));
|
||||
// Into a folder, at its top.
|
||||
QVERIFY(session.duplicateLayer(bottom.id, folder));
|
||||
const QUuid inside = session.activeLayerID().value();
|
||||
QCOMPARE(layerWith(session, inside).parentID, std::optional(folder));
|
||||
session.setIsImporting(true);
|
||||
QVERIFY(!session.duplicateLayer(bottom.id, std::nullopt));
|
||||
session.setIsImporting(false);
|
||||
// A folder copies with what it holds, at the bottom.
|
||||
session.toggleGroupExpansion(folder);
|
||||
const int layers = int(session.document().value().layers.size());
|
||||
QVERIFY(session.duplicateLayer(folder, std::nullopt, std::nullopt, true));
|
||||
const ImageLayer copy = layerWith(session, session.activeLayerID().value());
|
||||
QVERIFY(copy.isGroup && copy.id != folder && !copy.parentID);
|
||||
QCOMPARE(copy.name, layerWith(session, folder).name + " copy");
|
||||
const QSet<QUuid> held = session.descendantIDs(copy.id);
|
||||
QCOMPARE(int(held.size()), 1);
|
||||
const ImageLayer child = layerWith(session, *held.begin());
|
||||
// The child keeps its name and moves to the copy.
|
||||
QVERIFY(child.id != inside && child.name == layerWith(session, inside).name && child.parentID == copy.id);
|
||||
QVERIFY(session.collapsedGroupIDs().contains(copy.id));
|
||||
QCOMPARE(int(session.document().value().layers.size()), layers + 2);
|
||||
QCOMPARE(session.layerRows().back().layer.id, copy.id);
|
||||
session.undo();
|
||||
QCOMPARE(int(session.document().value().layers.size()), layers);
|
||||
// The copy sits right above its source.
|
||||
session.selectLayer(top.id);
|
||||
session.duplicateActiveLayer();
|
||||
const int original = indexOf(session.document().value().layers, top.id);
|
||||
QCOMPARE(session.document().value().layers[original + 1].name, top.name + " copy");
|
||||
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[original + 1].id));
|
||||
}
|
||||
|
||||
void LayerDuplicateTests::aFolderCopyKeepsLinksInsideAndStopsAtTheLimit()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 40, true);
|
||||
const QUuid base = session.activeLayerID().value();
|
||||
QImage white(10, 10, QImage::Format_RGBA8888_Premultiplied);
|
||||
white.fill(Qt::white);
|
||||
session.insert(ImportedImage(white, white, QStringLiteral("White")));
|
||||
const QUuid clipped = session.activeLayerID().value();
|
||||
QVERIFY(session.canToggleClippingMask(clipped));
|
||||
session.toggleClippingMask(clipped);
|
||||
session.selectLayers({base, clipped}, clipped);
|
||||
session.groupSelectedLayers();
|
||||
const QUuid folder = session.activeLayerID().value();
|
||||
session.duplicateActiveLayer();
|
||||
const QUuid copy = session.activeLayerID().value();
|
||||
QVERIFY(copy != folder);
|
||||
// Links inside the copy point inside the copy.
|
||||
std::optional<QUuid> copiedBase, copiedClip;
|
||||
for (const QUuid &id : session.descendantIDs(copy)) {
|
||||
const ImageLayer layer = layerWith(session, id);
|
||||
(layer.maskSourceID ? copiedClip : copiedBase) = id;
|
||||
}
|
||||
QCOMPARE(layerWith(session, copiedClip.value()).maskSourceID, copiedBase);
|
||||
QVERIFY(copiedBase != base);
|
||||
QCOMPARE(layerWith(session, clipped).maskSourceID, std::optional(base));
|
||||
// A copy alone keeps links and a parent outside it.
|
||||
session.selectLayer(clipped);
|
||||
session.duplicateActiveLayer();
|
||||
const ImageLayer lone = layerWith(session, session.activeLayerID().value());
|
||||
QVERIFY(lone.id != clipped && lone.maskSourceID == base && lone.parentID == folder);
|
||||
session.undo();
|
||||
// Past 10,000 layers nothing is copied or recorded.
|
||||
ProjectSnapshot snapshot = session.projectSnapshot().value();
|
||||
// Three copies into 9,997 reach 10,000 exactly: allowed.
|
||||
while (snapshot.manifest.layers.size() < 9'997)
|
||||
snapshot.manifest.layers.push_back(ProjectLayerRecord{.id = QUuid::createUuid(), .name = "Blank", .isVisible = true,
|
||||
.transform = {.origin = {0, 0}, .size = {40, 40}}, .imageFile = std::nullopt});
|
||||
session.installProject(snapshot, QString());
|
||||
session.selectLayer(folder);
|
||||
session.duplicateActiveLayer();
|
||||
QVERIFY(session.activeLayerID() != folder);
|
||||
QCOMPARE(int(session.document().value().layers.size()), 10'000);
|
||||
// Past it nothing is copied or recorded.
|
||||
session.selectLayer(folder);
|
||||
const int steps = session.history.undoCount();
|
||||
session.duplicateActiveLayer();
|
||||
QCOMPARE(session.activeLayerID(), std::optional(folder));
|
||||
QCOMPARE(int(session.document().value().layers.size()), 10'000);
|
||||
QCOMPARE(session.history.undoCount(), steps);
|
||||
QVERIFY(!session.duplicateLayer(folder, std::nullopt, std::nullopt, true));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(LayerDuplicateTests)
|
||||
#include "LayerDuplicateTests.moc"
|
||||
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
|
||||
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
|
||||
ProjectStore::save(session->projectSnapshot().value(), path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(7));
|
||||
QCOMPARE(loaded.manifest.version, qint64(8));
|
||||
session->installProject(loaded, path);
|
||||
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
|
||||
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
|
||||
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
|
||||
const ProjectSnapshot snapshot = session->projectSnapshot().value();
|
||||
ProjectStore::save(snapshot, path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QVERIFY(loaded.manifest.version == 7 && loaded.masks.count(folder) == 1);
|
||||
QVERIFY(loaded.manifest.version == 8 && loaded.masks.count(folder) == 1);
|
||||
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList<int>{255, 0, 128, 255}));
|
||||
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
|
||||
QCOMPARE(alphas(ImageExporter::render(resized).image),
|
||||
|
||||
@@ -350,6 +350,9 @@ void NativeLayerListTests::layerListToolKeysAndTransformNudge()
|
||||
shown.list.setFocus();
|
||||
QTest::keyClick(&shown.list, Qt::Key_H);
|
||||
QCOMPARE(session.tool(), NavigationTool::hand);
|
||||
// T chooses Type, since Swift 1.1.6.
|
||||
QTest::keyClick(&shown.list, Qt::Key_T);
|
||||
QCOMPARE(session.tool(), NavigationTool::type);
|
||||
QTest::keyClick(&shown.list, Qt::Key_V);
|
||||
QCOMPARE(session.tool(), NavigationTool::move);
|
||||
session.beginTransform();
|
||||
|
||||
@@ -309,10 +309,10 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
|
||||
QTest::addColumn<std::optional<ProjectError::Kind>>("refused");
|
||||
const auto invalid = std::optional(ProjectError::Kind::invalid), tooLarge = std::optional(ProjectError::Kind::tooLarge);
|
||||
const std::optional<ProjectError::Kind> fine;
|
||||
for (int version = 1; version <= 7; ++version)
|
||||
for (int version = 1; version <= 8; ++version)
|
||||
QTest::addRow("plain at version %d", version) << "" << version << fine;
|
||||
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("version 8") << "" << 8 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("version 9") << "" << 9 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("another format") << "format" << 7 << invalid;
|
||||
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
|
||||
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
|
||||
@@ -349,7 +349,9 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
|
||||
QTest::newRow("an empty folder at version 1") << "folderEmpty" << 1 << invalid;
|
||||
QTest::newRow("an empty folder at version 2") << "folderEmpty" << 2 << fine;
|
||||
QTest::newRow("a folder that blends") << "folderBlend" << 7 << invalid;
|
||||
QTest::newRow("a folder that fades") << "folderOpacity" << 7 << invalid;
|
||||
QTest::newRow("a folder that fades before version 8") << "folderOpacity" << 7 << invalid;
|
||||
QTest::newRow("a folder that fades at version 8") << "folderOpacity" << 8 << fine;
|
||||
QTest::newRow("a folder that blends at version 8") << "folderBlend" << 8 << invalid;
|
||||
QTest::newRow("a folder with pixels") << "folderPixels" << 7 << invalid;
|
||||
QTest::newRow("a folder mask before version 6") << "folderMask" << 5 << invalid;
|
||||
QTest::newRow("a folder mask at version 6") << "folderMask" << 6 << fine;
|
||||
|
||||
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
|
||||
const ProjectSnapshot again = session.projectSnapshot().value();
|
||||
// The same manifest to the byte; pixels by identity.
|
||||
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
|
||||
QCOMPARE(again.manifest.version, qint64(7));
|
||||
QCOMPARE(again.manifest.version, qint64(8));
|
||||
QCOMPARE(int(again.images.size()), 2);
|
||||
QCOMPARE(int(again.masks.size()), 2);
|
||||
for (const auto &[id, image] : snapshot.images)
|
||||
|
||||
@@ -167,7 +167,7 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
};
|
||||
// A version out of range is named whatever else fails.
|
||||
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
|
||||
for (const qint64 strange : {qint64(42), qint64(8), qint64(0)}) {
|
||||
for (const qint64 strange : {qint64(42), qint64(9), qint64(0)}) {
|
||||
try {
|
||||
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
|
||||
ProjectStore::load(path);
|
||||
@@ -182,12 +182,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
|
||||
// Another format is invalid whatever version it claims.
|
||||
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||||
// Swift's Int holds 64 bits, each one: no double between.
|
||||
const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min();
|
||||
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 7", "\"version\": " + QByteArray::number(wild)));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": " + QByteArray::number(wild)));
|
||||
ProjectStore::load(path);
|
||||
QFAIL("a wild version opened");
|
||||
} catch (const ProjectError &error) {
|
||||
@@ -197,19 +197,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
}
|
||||
}
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
// Declared: Qt rounds a token just past Int64's minimum.
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 7", "\"version\": -9223372036854775809"));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": -9223372036854775809"));
|
||||
ProjectStore::load(path);
|
||||
QFAIL("a version below every integer opened");
|
||||
} catch (const ProjectError &error) {
|
||||
QCOMPARE(error.version, std::optional(least));
|
||||
}
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
|
||||
|
||||
@@ -168,9 +168,12 @@ void TransformOverlayTests::handlesAndGuidesArePaintedWhereTheyBelong()
|
||||
QCOMPARE(shot.pixelColor(190, 140), QColor(Qt::white));
|
||||
QCOMPARE(shot.pixelColor(210, 160), QColor(Qt::white));
|
||||
QCOMPARE(shot.pixelColor(200, 112), QColor(Qt::white));
|
||||
// The accent outline runs between the handles, over the red.
|
||||
QVERIFY(shot.pixelColor(190, 145) != QColor(Qt::red));
|
||||
QVERIFY(shot.pixelColor(190, 145).blue() > shot.pixelColor(190, 145).red());
|
||||
// The accent alone runs between handles, half over red.
|
||||
const QColor accent = canvas.palette().color(QPalette::Highlight);
|
||||
const QColor edge = shot.pixelColor(190, 145);
|
||||
QVERIFY2(std::abs(edge.red() - (accent.red() + 255) / 2) <= 2 && std::abs(edge.green() - accent.green() / 2) <= 2
|
||||
&& std::abs(edge.blue() - accent.blue() / 2) <= 2,
|
||||
qPrintable(edge.name()));
|
||||
session.setShowsTransformControls(false);
|
||||
QVERIFY(overlay.drawnRect(canvas.rect()).isEmpty());
|
||||
shot = canvas.grab().toImage();
|
||||
|
||||
@@ -73,6 +73,18 @@ void TransformSnapTests::snappedMoveNudgesWithinToleranceAndKeepsTheGuides()
|
||||
const LayerTransform near{.origin = {77, 20}, .size = {20, 10}};
|
||||
QCOMPARE(session.snappedMove(near, {a}, 2).origin, QPointF(75, 20));
|
||||
QCOMPARE(session.snappedMove(near, {a, b}, 2).origin, QPointF(77, 20));
|
||||
// View > Snap off: every draft stays put, guides gone.
|
||||
const LayerTransform snapped = session.snappedMove(draft, {a}, 5);
|
||||
QVERIFY(snapped != draft && !session.snapGuides.xs.empty());
|
||||
QSignalSpy changes(&session, &EditorSession::changed);
|
||||
session.setSnappingEnabled(false);
|
||||
QVERIFY(!session.snappingEnabled() && changes.count() == 1);
|
||||
QCOMPARE(session.snapGuides, SnapGuides{});
|
||||
session.snapGuides = SnapGuides{{1}, {2}};
|
||||
QCOMPARE(session.snappedMove(draft, {a}, 5), draft);
|
||||
QCOMPARE(session.snapGuides, SnapGuides{});
|
||||
session.setSnappingEnabled(true);
|
||||
QCOMPARE(session.snappedMove(draft, {a}, 5), snapped);
|
||||
}
|
||||
|
||||
void TransformSnapTests::dragsEditsAreNotPersistentAndEndsClearTheGuides()
|
||||
|
||||
+38
-12
@@ -24,7 +24,7 @@ private slots:
|
||||
void moveRotateAndShiftConstraints();
|
||||
void previewCommitCancelAndUndoPreserveSources();
|
||||
void duplicateTransformPreservesOriginalAndSupportsUndoAndCancel();
|
||||
void duplicateTransformCopiesEveryPixelLayerButNoFolder();
|
||||
void duplicateTransformCopiesLayersAndFoldersWithTheirContents();
|
||||
void scalePercentSetsBothSidesAboutTheCenter();
|
||||
void noOpInvalidValuesAndSwitchingTools();
|
||||
void rotatedHitTesting();
|
||||
@@ -373,7 +373,7 @@ void TransformTests::duplicateTransformPreservesOriginalAndSupportsUndoAndCancel
|
||||
QCOMPARE(int(session.document().value().layers.size()), 1);
|
||||
}
|
||||
|
||||
void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
|
||||
void TransformTests::duplicateTransformCopiesLayersAndFoldersWithTheirContents()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(400, 200);
|
||||
@@ -383,16 +383,16 @@ void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
|
||||
const QUuid second = session.activeLayerID().value();
|
||||
session.addGroup();
|
||||
const QUuid folder = session.activeLayerID().value();
|
||||
// A folder has no pixels to copy; layers do.
|
||||
// Every selected root copies, an empty folder too.
|
||||
session.selectLayers({first, second, folder}, first);
|
||||
session.beginDuplicateTransform();
|
||||
QCOMPARE(int(session.document().value().layers.size()), 5);
|
||||
QCOMPARE(session.selectedLayerIDs().size(), 2);
|
||||
QVERIFY(!session.selectedLayerIDs().contains(first) && !session.selectedLayerIDs().contains(second));
|
||||
QCOMPARE(int(session.document().value().layers.size()), 6);
|
||||
QCOMPARE(session.selectedLayerIDs().size(), 3);
|
||||
QVERIFY(!session.selectedLayerIDs().contains(first) && !session.selectedLayerIDs().contains(folder));
|
||||
// The topmost copy leads; copies sit above their sources.
|
||||
QCOMPARE(session.document().value().layers[1].name, QString("Painted layer copy"));
|
||||
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[3].id));
|
||||
QVERIFY(session.transformsAsGroup());
|
||||
QCOMPARE(session.document().value().layers[5].name, QString("Folder 1 copy"));
|
||||
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[5].id));
|
||||
session.cancelTransform();
|
||||
QCOMPARE(int(session.document().value().layers.size()), 3);
|
||||
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second, folder}));
|
||||
@@ -402,13 +402,21 @@ void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
|
||||
session.commitTransform();
|
||||
QCOMPARE(session.history.undoName(), QString("Duplicate Layers"));
|
||||
QCOMPARE(int(session.document().value().layers.size()), 5);
|
||||
// A folder alone has no pixels: nothing begins.
|
||||
// A folder carries its contents; a selected child rides along.
|
||||
QVERIFY(session.placeLayer(second, folder));
|
||||
session.selectLayer(folder);
|
||||
QVERIFY(session.canTransform());
|
||||
session.toggleGroupExpansion(folder);
|
||||
session.selectLayers({folder, second}, folder);
|
||||
session.beginDuplicateTransform();
|
||||
QVERIFY(!session.transformEdit().has_value());
|
||||
QCOMPARE(int(session.document().value().layers.size()), 7);
|
||||
const QUuid copy = session.activeLayerID().value();
|
||||
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{copy});
|
||||
const std::vector<ImageLayer> &shown = session.document().value().layers;
|
||||
QVERIFY(shown[size_t(indexOf(shown, copy))].isGroup && session.descendantIDs(copy).size() == 1);
|
||||
QVERIFY(session.collapsedGroupIDs().contains(copy));
|
||||
session.cancelTransform();
|
||||
QCOMPARE(int(session.document().value().layers.size()), 5);
|
||||
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder});
|
||||
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{folder, second}));
|
||||
QVERIFY(session.canUndo());
|
||||
session.setIsProjectBusy(true);
|
||||
session.selectLayer(first);
|
||||
@@ -417,6 +425,24 @@ void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
|
||||
session.setIsProjectBusy(false);
|
||||
QVERIFY(session.canUndo());
|
||||
QCOMPARE(int(session.document().value().layers.size()), 5);
|
||||
// Past 10,000 layers nothing copies and nothing is recorded.
|
||||
ProjectSnapshot full = session.projectSnapshot().value();
|
||||
while (full.manifest.layers.size() < 10'000)
|
||||
full.manifest.layers.push_back(ProjectLayerRecord{.id = QUuid::createUuid(), .name = "Blank", .isVisible = true,
|
||||
.transform = {.origin = {0, 0}, .size = {40, 40}}, .imageFile = std::nullopt});
|
||||
session.installProject(full, QString());
|
||||
session.selectLayers({first, second}, second);
|
||||
const int steps = session.history.undoCount();
|
||||
session.beginDuplicateTransform();
|
||||
QVERIFY(!session.transformEdit().has_value());
|
||||
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second}));
|
||||
QCOMPARE(session.activeLayerID(), std::optional(second));
|
||||
QCOMPARE(session.history.undoCount(), steps);
|
||||
// No transaction stays open: the next edit steps.
|
||||
session.selectLayer(first);
|
||||
session.setLayerOpacity(0.3);
|
||||
QVERIFY(session.canUndo());
|
||||
QCOMPARE(session.history.undoCount(), steps + 1);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TransformTests)
|
||||
|
||||
@@ -404,7 +404,7 @@ void TypeControlsTests::aTextRowShowsAndOpensItsText()
|
||||
QVERIFY(QTest::qWaitForWindowActive(&list));
|
||||
LayerCell &textRow = *list.cells().at(0), &blankRow = *list.cells().at(1);
|
||||
const auto dimensions = [](LayerCell &row) { return row.findChild<QLabel *>("layerDimensions")->text(); };
|
||||
QCOMPARE(dimensions(textRow), QString("Text · Double-click to edit"));
|
||||
QCOMPARE(dimensions(textRow), QString("Text"));
|
||||
QCOMPARE(dimensions(blankRow), QString("400 × 300 px"));
|
||||
QCOMPARE(textRow.thumbnail().toolTip(), QString("Editable text layer"));
|
||||
QCOMPARE(textRow.thumbnail().accessibleName(), QString("Select text: Row"));
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#include "MenuFixtures.h"
|
||||
|
||||
// The View menu: zoom, the pixel grid and Snap.
|
||||
class ViewMenuTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void viewEntriesZoomTheFrontSession();
|
||||
void thePixelGridEntryTogglesTheSession();
|
||||
};
|
||||
|
||||
void ViewMenuTests::viewEntriesZoomTheFrontSession()
|
||||
{
|
||||
Bar bar;
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
QVERIFY(!bar.action(name).isEnabled());
|
||||
bar.session().createDocument(400, 300);
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
QVERIFY(bar.action(name).isEnabled());
|
||||
bar.session().zoom(0.5);
|
||||
bar.action("actualPixels").trigger();
|
||||
QCOMPARE(bar.session().viewport.zoom(), 1.0);
|
||||
bar.action("zoomIn").trigger();
|
||||
QCOMPARE(bar.session().viewport.zoom(), 1.25);
|
||||
bar.action("zoomOut").trigger();
|
||||
bar.action("zoomOut").trigger();
|
||||
QCOMPARE(bar.session().viewport.zoom(), 0.8);
|
||||
bar.action("fit").trigger();
|
||||
QVERIFY(bar.session().viewport.followsFit());
|
||||
}
|
||||
|
||||
void ViewMenuTests::thePixelGridEntryTogglesTheSession()
|
||||
{
|
||||
Bar bar;
|
||||
QAction &grid = bar.action("pixelGrid");
|
||||
QVERIFY(grid.isCheckable() && grid.isChecked() && grid.isEnabled());
|
||||
QCOMPARE(grid.text(), QString("Pixel Grid (800% and above)"));
|
||||
grid.trigger();
|
||||
QVERIFY(!bar.session().showsPixelGrid() && !grid.isChecked());
|
||||
grid.trigger();
|
||||
QVERIFY(bar.session().showsPixelGrid() && grid.isChecked());
|
||||
// The session's own change reaches the mark.
|
||||
bar.session().setShowsPixelGrid(false);
|
||||
QVERIFY(!grid.isChecked());
|
||||
bar.workspace.newCanvas();
|
||||
QVERIFY(grid.isChecked() && grid.isEnabled());
|
||||
// Snap, beneath it, follows the session the same way.
|
||||
QAction &snap = bar.action("snap");
|
||||
QVERIFY(snap.isCheckable() && snap.isChecked() && snap.isEnabled() && snap.text() == "Snap");
|
||||
snap.trigger();
|
||||
QVERIFY(!bar.session().snappingEnabled() && !snap.isChecked());
|
||||
snap.trigger();
|
||||
QVERIFY(bar.session().snappingEnabled() && snap.isChecked());
|
||||
bar.session().setSnappingEnabled(false);
|
||||
QVERIFY(!snap.isChecked());
|
||||
}
|
||||
|
||||
QTEST_MAIN(ViewMenuTests)
|
||||
#include "ViewMenuTests.moc"
|
||||
Reference in new issue
Block a user