Merge branch 'main' into port-d

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 18:38:20 -04:00
commit 7c6265af77
141 files changed
+5822 -682

No files matched your search

+16 -5
View File
@@ -68,7 +68,7 @@ Platform adaptations, each forced by a missing Qt analog:
- The Move tool on the canvas (6.1): `TransformOverlay` is no widget. Swift's overlay view exists so that a selection never redraws the pixels; a transparent Qt child would repaint the canvas beneath it anyway, so the canvas draws the handles and guides itself, after its pixels, and `synchronizeDisplay` repaints only the union of the old and the new `drawnRect` (a guide spans the canvas) when the geometry or `snapGuides` changed, leaving its return value to the pixel state. `TransformOverlayGeometry` keeps Swift's handles, rotation grip and `hit`; `showsRotation` and the corner constructor arrive with 6.2. `resizeCursor` maps Swift's frame positions onto Qt's four resize shapes (right, bottom right, bottom, top right). `EditorCanvas+Transform.cpp` holds the drags (`beginTransformDrag`, `dragTransform`, `endTransformDrag`, `cancelTransformDrag`, Swift's `resignFirstResponder`), `transformPressLayer` and `transformCursor`; Ctrl stands for Cmd in picking, Ctrl+Shift extends the selection. The drag's cursor is kept in `m_dragCursor` until the release and outranks Space, where Swift disables the window's cursor rects; hover reaches `updateCursor` through mouse tracking and the last hover point, the application filter reads Alt and Ctrl as it read Alt, and a shown canvas reads both from the keyboard. The release synchronizes the display, so a Ctrl+T edit's guides go without a commit. `moveCursor` (the arrow with Swift's four-way badge) and `rotationCursor` (two arrows round a ring, for the SF symbol) are drawn as paths; the distort cursor arrives with 6.2. `SnapGuides` is a struct where Swift has a tuple, a public member like `viewport`, unannounced; `commitTransform` and `cancelTransform` clear it. `transformSnapTargets` and `snappedMove` live in `Document/Crop.cpp`, Swift's file. `TransformEdit.persistent` sits last, so the positional initialisers keep their order; a drag's edit and a duplicate's are not persistent. `transformAutoSelect`, `showsTransformControls` and `locksTransformRatio` are getters and setters that notify. Escape and Return end a drag with its edit and put the hover cursor back. A press that never moves commits an unchanged edit, which the history drops. The pixel tests hide the controls (`CanvasDisplayTests`, `HighDpiCanvasTests`) and preview through a drag's edit, since Swift's overlay never reaches its `draw` tests.
- Distort (6.2): `DistortWarp` keeps Swift's tests of usability and convexity, `homography` (a projective `QTransform` where Swift returns a closure, Qt dividing by the third row as Swift by `w`), `warp`, `warpTrimmed`, `carried` and `warpMask`. Core Image's perspective filter is `QTransform::quadToQuad` on the image's pixel rect and a `drawImage` under it, antialiased and smooth; a folded shape is two triangles under their affine maps, each a hard device-pixel region of the pixels whose centres the triangle holds, built per scanline from each edge's ends ordered by y, the left bound inclusive, so the halves partition the diagonal and meet a fractional shared edge with one x (a path clip turns soft once the image draws antialiased, and `QRegion`'s integer polygons round the vertices), copied through `BrushRaster::draw` without interpolation, as Swift's. In `distortPreview` a mask warp that throws is caught on its own, as Swift's `try?`: a linked mask covering its layer then draws none, a placed one falls back, with a folded shape, to its stored placement clipped over the warp. Its `Warped` and `Trimmed` structs stand for Swift's tuples; `mapPath` arrived with 8.2b and `distortedEffects` arrived with 9.6c. Swift's `DistortTests` agree since 1.2.1 (`7111885`): a bow tie is usable and folds as two triangles, not convex, and a collapsed corner is refused by both tests; the preview takes a folded shape. `TransformEdit.corners` sits last, after `persistent`. The session's `distortPreview` and `maskDistortPreview` are `const` with mutable caches (`DistortPreviewCache` by layer, `MaskDistortPreviewCache`), keyed by `ImageIdentity` where Swift compares `CGImage` identity, because the canvas reads them from a const session; a warp that throws leaves a cached empty result, as Swift's `try?`. `commitDistort` catches `ProjectError` and `ExportError` into `brushError` as Swift catches its errors; `commitTransform` runs it after the mask path, before the plain one, and ends a duplicate after it. `commitMaskTransform` takes the corners path with `Distort Layer Mask`, `displayedMaskPlacement` its corners cases, `nudgeLayer` moves the corners with the draft. The overlay's corners constructor sets `showsRotation` false, `hit` skips the grip then, and the drawing leaves the stem and disc out; `geometry` returns the corners' geometry for a group or the edited layer. The canvas's `DisplayState` carries the edit's corners, so a moved corner repaints; `drawOwn` draws `distortPreview` first and takes `maskDistortPreview` for the mask. A Ctrl-drag on a handle begins the distortion and drags in `distort` mode with the white `distortCursor`; the cursor shows it over a handle under Ctrl or once distorted. The inspector's numbers rest while corners are set, as Swift's `.disabled`.
- `TransformInspector` is the Move tool's `ToolHeaderBar`: Auto Select and Show Controls as `QCheckBox`es, the numbers in a `QScrollArea` without bars (Swift's horizontal `ScrollView`, which takes the row's spare room and keeps its content at its own size: the row's end stretch rests, the content ends in a stretch of its own), the ratio lock a checkable `QToolButton` with the panel's link icon, the sampling a `QComboBox`, Flip H and V and Cancel and Apply as `QPushButton`s. `TransformValueField` applies as typed (Swift's `isFinite` test is left out: `previewTransform` refuses every transform an infinite number makes), shows the session's number while unfocused and puts it back on `FocusOut`, steps with Up and Down (Shift ten) writing the number it applied, keeps its typing while a menu or popup borrows the focus (`MenuBarFocusReason`, `PopupFocusReason`), and on Return or Escape hands the canvas the keys as Swift's `onSubmit` and `onExitCommand`; the pending edit waits there for the canvas's Return or Escape. From any other control of the bar, Return applies and Escape cancels, Swift's default and cancel actions. The lock's icon is redrawn on `PaletteChange`, as the panel's. Swift's `.id(activeLayerID)` remake is `synchronize` clearing any focus of its own when the active layer changes. `resizeBox` is Swift's `resize`, whose name would hide `QWidget::resize`; its `number >= 1` guard and the scale's `number > 0` are left out: a side under a pixel is a box `previewTransform` refuses. The Layer menu's Transform Layer entry calls `beginTransform` (Swift's `transformCommand` does the same without a selection; 7.4 brings the selection) and the View menu's Show Transform Controls is checkable under Ctrl+H, enabled in the Move tool with a document.
- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, then a focused layer list, which selects every row with the top one primary, as `NSTableView`'s `selectAll(_:)` does in Swift's chain (`NativeLayerList::selectAllRows`), and only then the canvas. The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes.
- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, and then the canvas, a focused layer list included: Swift's table hands Select All to the canvas since 1.4 (`451281e`), so the list's own Select All of every row (12.5b) is gone. The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes.
- The Marquee and the Lasso on the canvas (7.2): Swift's lasso gestures live in `EditorCanvas+Selection.cpp`, the selection cursors in `EditorCanvas+Cursors.cpp`. The canvas tracks Shift as it tracks Alt and Ctrl (the application filter sees the key from any window, as Swift's `flagsChanged` monitor), and a key change reshapes a Marquee draft and the cursor's badge at once. A press with a selection tool is `lassoMouseDown`; a move is `moveSelectionTool` (a hover moves a polygonal draft's rubber band and refreshes the held keys, as Swift's `mouseMoved`); a release is `releaseSelectionTool`; the draft's keys and the outline's nudges are `selectionKey`, ahead of every other key; losing focus is `endSelectionGestures`. A double click closes a polygonal outline from `mouseDoubleClickEvent`, since Qt delivers the second press there (its first press adds the corner, as Swift's first `mouseDown` does), never while Space pans. A held M or L's repeats are swallowed, as Swift ignores them, so a tool chosen meanwhile stays. An outline drag keeps `moveSelectionCursor` under Space, as the transform drag keeps its own. The ants are a 120 ms `QTimer` stepping `TransformOverlay::antsPhase`, repainting `selectionRect` alone (the outline's own rect, not the handles beside it); `drawnRect` spans the handles, the outline and the draft, the last two stroked antialiased with butt caps (`Qt::FlatCap`, as CoreGraphics dashes: Qt's square caps would stretch each dash by the pen's width) as CoreGraphics strokes them (a 1-point line on whole coordinates blurs over two rows at 1×, on macOS too); `synchronizeDisplay` repaints the overlay when the displayed selection or draft changes, refreshes the overlay's cached rect with every full repaint (a pan moves the outline) and refreshes the cursor when a kind or the displayed mode changes. The two timers are named children of the canvas (`antsTimer`, `marqueeAutoscroll`), as the session's busy timer is, so tests read whether they run. Autoscroll is a 16 ms `QTimer` with Swift's speed curve; each step translates the viewport, drags the draft or the outline again under the still pointer, notifies and synchronizes. `moveSelectionCursor`, `selectionCursor` and `wandCursor` are drawn as paths: the crosshair, the icon haloed by eight white offsets and the badge, and the wand with Swift's own strokes (hot spot 7, 7 at the sparkle, the stick down right, the badge at 17, 5), since `QCursor::pixmap` is empty for a shape cursor; the icons are `Rendering/SelectionIcons`, which the rail's `ToolIcons` and the cursors share, so nothing in `Rendering/` includes `UI/`. `SelectionIcon` is that header's enum, not nested in `CanvasView`. Ctrl over the selection shows the move pointer until 7.4 brings the scissors; the wand's click and cursor are 7.3's. `pressMarqueeKey` and `pressLassoKey` choose their tools and nothing more, as Swift's functions do: Swift's tests once expected a second press to switch the kind, which no Swift code does (its `cycleToolMode` on Tab does, 8.x); since 1.2.0 (`b5cb4fa`) they test the code's rule, as the twin does (`…AndKeepsTheShapeLastSet`), and the twin's tooltips name no key. `LassoControls` is the selection tools' `ToolHeaderBar`: Swift's segmented pickers are checkable `QToolButton`s in a `QButtonGroup`, the amounts are `SelectionAmountField`s (a `QLineEdit` taking digits alone, every typed value clamped to 1–500 as Swift's binding clamps it, stepped by arrows within the bounds, Return and Escape handing the canvas the keys, a menu's borrowed focus keeping the typing as `TransformValueField` does), the wand's controls arrive with 7.3. `ContentView::hint` takes a `ToolIconKind` for the ellipse Marquee's and the polygonal Lasso's words, and the rail's buttons repaint with the kind.
- The Magic Wand (7.3): `WandPixels.c` compiles in place beside the other kernels, and `Document/MagicWand.cpp` wraps it as Swift's `MagicWand` does: `select` draws the image into a premultiplied `RGBA8888` context (the kernel's byte order, rows top down), floors the point, clamps the tolerance and hands the mask to `wand_mask`; `outline` frees the kernel's arrays through `unique_ptr` and builds a `WindingFill` path from its loops. `MagicWandError` carries Swift's two messages (`what()`), `tooDetailed` for the kernel's −2 and `memory` for −1. The session's `magicWand` takes a completion callback and runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, like the invert; the worker catches `MagicWandError` and `ExportError`, and the UI side applies the outline only while the same document is open (its id, as Swift compares), New with no match deselecting, New with a match `setSelection` without the canvas clip (Swift's shortcut), the other modes `applySelection`. `wandSample` draws the composite through `drawLiveComposite` or the active layer's own pixels through `LayerRenderer::draw` at 1:1, a folder or blank layer as clear; a context that cannot be allocated logs and refuses, as Swift's `try?` returns nil. `wandSettings` is a struct with a setter that notifies; `title` and `radius` stand for Swift's computed properties of `WandSampleSize`. The canvas hands a wand press to `magicWand` with the press's mode, and a click inside the selection to `magicWand` in New mode where another tool would deselect. `LassoControls` shows the wand's controls with the wand alone: the tolerance is a `SelectionAmountField` bounded 0–255, the sample size a `QComboBox` of Swift's titles, the source two checkable buttons, Contiguous a `QCheckBox`; `changeWand` rewrites one field of the settings. The mutant that pins `m_thisLayer` checked is equivalent, since the exclusive group's later `m_allLayers` line decides, as with the marquee's kinds.
- The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and keeps a `QPointer` to the `QMimeData` it hands the clipboard where Swift reads the pasteboard's change count (since 12.10b; Qt's `dataChanged` is no count, since Wayland announces one copy twice): while this app owns the clipboard Qt returns that very object (offscreen, X11's client clipboard, Wayland's selection source), any other copy, a history manager's restore of the same bytes included, is another object, and a replaced object is deleted, which nulls the pointer, so a copy once replaced never counts again, as a change count never returns; a paste puts our pixels back in place while the clipboard still holds that object (`clipboardHolds`), else takes another app's image through `QMimeData::imageData`, converted to sRGB first when it carries another colour space (CoreGraphics converts as it draws; `QPainter` copies the numbers), then to the working format, tagged sRGB and centred. A cleared clipboard has no `QMimeData` at all, so `canPaste` checks the pointer. A working copy that cannot be allocated is a warning, as Swift's `try?` beeps there, where a failed Layer via Copy sets `brushError` as Swift's `catch` does. The clipboard is touched only by a copy or a paste, so session tests without one run under `QTEST_GUILESS_MAIN` still; `SelectionClipboardTests` runs under `QTEST_MAIN`. `addPixelLayer`'s shape and text arrived with 8.8 and 8.9a. Swift's `cutSelection`, `PixelMove` and the whole `FloatingSelection` extension need `clearSelectedPixels` and the brush raster, so the cut arrived with 8.2 and the rest with 8.2b, where `canEditLayers` and `ProjectWorkspace::canSwitch` gained their `pixelMove` terms. The Edit menu's Copy and Paste check when chosen, as Swift's (a text field keeps its own, as with Select All); Copy Merged follows `canCopyMerged`; the Layer menu's Ctrl+J is `layerViaCopy`, titled by the selection and enabled as Swift's: `canCopyPixels`, or no selection on an editable layer (a folder too since 12.10b). Swift's beeps are warnings on `omaphoto.app`.
@@ -101,7 +101,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and one surface at most (`DocumentLimits`, 200 megapixels since 12.11a; `ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, which grows and trims, as Swift's has since 1.2.0, `7c4512a`), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again).
- The filter sheet (9.4b): `UI/FilterSheet.h/.cpp` twin Swift's file, `FilterSheet` and `GradientMapControls`. The sheet is the content of a `FloatingPanel` named `filterPanel`, which `ContentView::synchronizePanels` shows with the edit, titled by its kind, and closes with it, its close button and Escape cancelling (Swift's `onChange(of: filterEdit == nil)`). The kind's controls are made once, since an edit's kind never changes. Swift's `control` is a title (sixty points at least, the slider's buddy), a slider over a thousand steps of its range, or of the range's logarithm where Swift's is logarithmic, the value rounded to the field's decimals as Swift's binding rounds it, and a 56-point `PickerField` with its unit two points after (Swift's `unitSuffix`), which applies a readable finite number on Return or on leaving (normalizing clamps it), gives up its typing when its own slider changes the value (a wheel leaves the keys with the field; a notch that rounds to the same value changes nothing; 9.6f), and shows Swift's `.fractionLength(0...decimals)`: at most that many decimals, none trailing, in the field's locale, digits never grouped, as in every field of the twin. The sliders show the session's number under a `QSignalBlocker`, so a dragged thumb sits at the rounded value, as Swift's binding shows it; a step that rounds to the setting it already holds changes nothing, so the thumb keeps its place and arrow steps add up. Add Noise's distribution is two checkable buttons in a `QButtonGroup` beside its label (Swift's segmented picker), Monochromatic a `QCheckBox`; Lens Correction's note is callout-sized in `PlaceholderText`; Curves hosts `CurvesControls` through the settings. `GradientMapControls` draws Swift's gradient bar (20 high, rounded 4, a 35% black rim inside), the ends as the palette's `SwatchButton`s (24 points, radius 6, a white ring of 1.5 inside a black rim) with their titles, help and accessible names, and Reverse; a swatch opens the picker on its end, and on every change of the picker's colour the sheet calls `previewGradientMapColor` (Swift's `onChange`), which ignores a palette picker. The controls wait for the sheet's first synchronize, which runs once they are built. `settings` and `update` keep Swift's fallbacks for a sheet whose edit has gone: the defaults, Preview on. The Image menu gains Curves… (Ctrl+M) before Levels…, and Exposure…, Gradient Map… and Grain… after Hue/Saturation…; the Filter menu (`Fil&ter`, between Image and Layer, as Swift declares it) lists `allFilterKinds` but the fill and the image adjustments (`isImageAdjustment`), Remove Background among them since 9.7; every entry rests while colours cannot be adjusted or Hue/Saturation is open, as Swift's. Tests: `FilterSheetTests` (7.5's, moved to the panel), `FilterControlsTests` (each kind's controls, the sliders' travel, the fields, the Gradient Map's swatches, bar and repaint), the menus' in `SelectMenuTests` and `CompositorMenusTests`. Equivalent mutants: Swift's `?? .gaussianBlur` for the kind (the panel makes a sheet only for an open edit) and the memory of the picker's colour (`previewGradientMapColor` with an unchanged colour changes nothing).
- Adjustment layers in the document and the store (9.5a): `Document/LayerAdjustment.h/.cpp` twin Swift's file, `LayerAdjustment+Session.h` its session half. `AdjustmentKind` keeps Swift's raw values (`rawValue`, and `adjustmentKind(text)` for the manifest) and case order (`allAdjustmentKinds`); `filterKind` came with 9.5c, its reader; `symbol` names an SF Symbol, whose glyph the panel draws (9.5d). `LayerAdjustment` keeps Swift's fields: the legacy Hue/Saturation four, the optional `hsvSettings`, Levels and Curves, and the optional Exposure, Gradient Map and Grain, whose computed properties are getter and setter pairs; `resolvedHSV` falls back on the legacy four. `isValid` keeps Swift's bounds (hue within 360 either way, the rest within 100, each range's values alike, every band's handles finite, Levels as normalized, then every kind's own rules whatever the kind) and leaves out the `isFinite` terms the bounds already refuse and Levels' count (the twin holds four ranges). `apply` arrives with 9.5b, its reader. `ImageLayer.adjustment` joins equality and travels wherever Swift lists it: the record (`hierarchyRecord`, so the snapshot), `ImageLayer(record, …)` (so an install and a resize's rebuild), both resizers and duplicates. A copy between projects needs no term of its own: the baker bakes nothing for a layer without pixels, and the placement unlinks it, where Swift skips the bake. The manifest's `adjustment` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Adjustment.cpp` over the readers the manifest shares (`ManifestJson` in `IO/ProjectStore+Json.h`): every key required but the four optionals, which stay out when absent; a `ColorRange`-keyed dictionary is an array of key and value in turn, since Swift's `ColorRange` declares no `CodingKeyRepresentable` (a range Swift does not name refuses it, and so does an odd count, whose last key has no value to read, so Swift's count check has no twin; a later pair wins, as a dictionary assigns); the seed is a `UInt32`. The store refuses an adjustment before version 7, on a folder, with pixels or invalid; Swift's text rule's adjustment term is dead, since text needs pixels. `LiveMaskGraph::validate` refuses clipping to an adjustment layer, so `canLinkMask` refuses one as a source; `canPaint` refuses an adjustment layer's pixels, not its mask. `addAdjustment` puts a blank layer named by its kind just above the active one (into an active folder, uncollapsed; Swift's `addBlankLayer` alone lifts a layer above the folder's contents), as one `New … Adjustment` step, a Gradient Map running from the foreground to the background and each Grain with a seed of its own, 10,000 layers at most, then asks for its editor (Swift's `adjustmentEditingID`, 9.5c). `updateAdjustment` writes valid settings alone, with no step of its own, and moves `brushRevision`. Tests: `LayerAdjustmentTests` (kinds, validity, the optionals, the manifest's keys and refusals, the store's and the graph's rules, adding, updating, travel), `AdjustmentLayerTests` (Swift's `legacyHSVStillDecodesAndRenders`; the rest arrive with 9.5b and 9.5c), Swift's `settingsSaveAndOlderAdjustmentsStillOpen` in `ImageAdjustmentTests`, and an unbaked copy in `ProjectCopyTests`.
- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` in `CanvasDisplayTests`.
- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes` and `anAdjustmentsMaskClipsItOnTheCanvas` in `CanvasDisplayTests`.
- Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does.
- Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`.
- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and both resizers leave them out (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy.
@@ -133,9 +133,9 @@ Platform adaptations, each forced by a missing Qt analog:
- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; Camera Raw's own rows keep the track they are made with, since the rows whose tracks change (12.8b2's mixer) are made anew; since 12.14c `reshape` is Swift's track and bounds update in `updateNSView`, for the filter and Hue/Saturation sheets. `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`.
- The Camera Raw panel's other groups (12.8b2, Swift 1.2.3): `UI/CameraRawColorControls.h/.cpp` (Curve), `+Mixer.cpp` and `+Grading.cpp`, `UI/CameraRawDetailOpticsControls` and `UI/CameraRawGeometryCalibrationControls` twin Swift's three files, each view a `QWidget` over the session, synchronized on `changed()`. `UI/CameraRawRow` is Linux-only: the row each Swift view builds for itself (`sharpenSlider`, `opticsSlider`, `geometrySlider`, `calibrationSlider`, the curve's and mixer's sliders, 12.8b1's `slider`): a title of at least or exactly a width, `CameraRawSlider`, a field or none, a double click on the title resetting where Swift's main groups do; each view rounds the slider's value as its Swift twin does (Detail to whole numbers, Optics when its range starts at zero, Geometry and Calibration always, the rest not). Swift's segmented pickers are `cameraRawSegments`, touching checkable buttons in an exclusive `QButtonGroup`. The curve graph: a still press and release adds a point, a drag past two points moves the nearest divider or inner point, a double click removes the nearest inner point within 0.04 and its trailing release adds nothing, so the net is Swift's: SwiftUI adds on each click, `repair` drops the second as a duplicate and the double tap removes one. The graph is `CameraRawCurveGraph`: a class defined in a `.cpp` outside an anonymous namespace is global, and the Curves sheet's `CurveGraph` would share its vtable across the link. The mixer's rows whose help or track follow its tab, swatch or chosen point are made anew when those change, as SwiftUI renders them anew; a picked colour's swatch is shown at once, since a layout shows late children only later. The grading wheel fills a `QConicalGradient`, which runs counterclockwise from the right as the hue does (1.2.9's `96edd5f`), takes presses and double clicks inside its circle alone (Swift's `contentShape`), and Swift's `theDockedPanelFitsTheGradingWheels` holds; its stops are 96edd5f's own, pure HSV hues every 30 degrees (v1.2.3's six SwiftUI system colours predate that fix). Detail's idle noise rows are disabled under a 45% `QGraphicsOpacityEffect`, Swift's `.opacity(0.45).disabled`. Targeted Adjustment and Draw Guides are checkable push buttons, checked while armed, where Swift tints them (known difference); their SF Symbols, `scope` and `line.diagonal`, are `LayerIcons` paths. The Filter menu lists Camera Raw Filter… after Lens Correction, as Swift's. Known differences: a mixer family's field still being typed in loses its typing when the tab changes (the row is made anew), where SwiftUI's field would commit into the new tab; Equivalent mutants: the wheel's saturation cap (`normalized` clamps it), the picked swatches made anew only when their count changes (it spares rebuilds; Qt guards a swatch deleted in its own click) and the page menu's size policy (layout). The point-colour eyedropper is flat like the other two, where Swift leaves it bordered (cosmetic). Cosmetic, Swift's values and unpinned: the swatches' 18 points, Detail's idle 45%, the hue ranges' inset (title width and 10), the rows' spacing of 10 and the 11-pixel subheadlines. Tests: `CameraRawRowTable.h` walked by `CameraRawGroupRowsTests` (every row against Swift's values), `CameraRawGroupDrawTests` (drawing, pages, gestures, the panel through `FilterSheet`), `CameraRawGroupWordsTests` (every text, captions, buddies, the pickers' states, the mixer's pages, the wheel's lower half and rim, the curve's line, repaint and selected point), `CameraRawCurveMixerTests`, `CameraRawGradingLensTests`, the menu's entries in `CompositorMenusTests`.
- Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held.
- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return Line truncated
- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmeLine truncated
- The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`.
- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). A new front tab scrolls into view, posted as Swift posts its reveal, once the row is laid out; any other change keeps the offset, which Qt clamps, where the strip used to follow every layout (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`.
- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). Since 13.14 the strip no longer scrolls: tabs that do not fit gather under the overflow pill and the front tab is always shown (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`.
- The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu.
- Whole layers on the clipboard and several duplicates (12.10b, Swift 1.2.5: `5ef5b0a`, `c873453`, `245db93`, `2f1537f`). `CopiedLayer` (in `Document/SelectionClipboard.h`) is the session's `copiedLayer`: Copy with no selection and no chosen mask (`canCopyLayer`) takes the selected layers whole; a layer with pixels still copies them as well, for other apps, and a folder or adjustment puts only its id on the clipboard under Swift's `com.compositor.copied-layer`, dropping the pixel copy. Any copy of pixels drops it; it is judged as a pixel copy is (`clipboardHolds`), so a copy anywhere since, in this app or another, ends it for good, a history entry set again included, and Wayland's second announcement of the same copy does not. The copies' active one is the active layer's copy, else the first id's with a copy in order (Swift's `values.first` is any). `ProjectWorkspace::pasteCopiedLayer` finds the tab whose copy still stands, keeps the ids its document still holds, and duplicates them in place as one `Paste` step, refused while that project cannot edit its layers, or copies them into the front tab through `copyLayers`; false leaves Paste to pixels. The Edit menu's Copy runs with `canCopyPixels` or `canCopyLayer`, and Paste asks the workspace first, a text field and open text before both. Ctrl+J without a selection duplicates a folder too (`layerViaCopy`), and its entry is enabled for any active layer; a folder under a selection copies nothing. `beginDuplicateTransform` stacks its copies through `duplicateLayers`, the copies being what is selected after it that was not before, the active copy leading. The copy between projects moved to `ProjectWorkspace+Copy.cpp`, since the workspace's file reached 500 lines. Tests: `LayerClipboardTests` (Swift has none), the updated folder cases in `SelectionClipboardTests`, `CompositorMenusTests` and `TransformTests`, and `effectsTravelWithTheCopy` in `ProjectCopyTests`.
- Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`.
@@ -164,9 +164,20 @@ Platform adaptations, each forced by a missing Qt analog:
- Snapping while drawing (13.5a, Swift 1.3.5: `d6e3e93`, `8b1369a`): `snappedPoint` and `snappedSelectionOffset` live in `Crop.cpp`, Swift's file. A drawn point moves each axis to the nearest crop target within reach (the first of the nearest, reach inclusive) and shows the lines met; a moved selection snaps its rounded box's edges or middle through `TransformSnap::offset`, an axis Shift holds left out; with snapping off, or no move begun, the input comes back and the lines go. The canvas's `snappedCorner` snaps a Marquee's start (a Lasso's never), a Marquee's dragged corner, a shape's start and dragged corner, ten view points' reach, and the selection drag; Ctrl, Swift's Control as the crop and the Move tool read it, draws and moves freely and clears the lines. Every release clears them, as Swift's `mouseUp`. Tests: Swift's `drawnPointsSnapToTheSnapToTargets` in `GuideTests`; a moved selection's offsets (rounding, a locked axis, a turned ellipse's own box) and the gestures at 100% and 200% in `SnapDrawingCanvasTests`.
- Grid Settings (13.5b, Swift 1.3.5: `1c819d0`, `db5eafa`): `LayoutGrid` is Swift's struct: spacing 2–4096 and subdivisions 1–64, never finer than a pixel, clamped as it is made, plain members where Swift has `let`, so it stays assignable (as `CanvasSizeDraft`); its lines are counted from the origin, so an uneven step stays on the majors. `GridAppearance` holds Photoshop's presets by Swift's raw values (`allGridPresets`, `rawValue`, `presetColor`, none for Custom), the styles (`allGridStyles`, `dashes` in screen points), Custom's colour and the majors' opacity (1% at least when drawn; subdivisions at 28/45 of it). Both are the person's: `ToolDefaults` gains `integer`, `text` (empty when nothing is stored) and their `set`s, and the session reads `LayoutGrid::stored()` and `GridAppearance::stored()` as its members are made, under Swift's keys (`gridSpacing`, `gridSubdivisions`, `gridColor`, `gridCustomColor` as hex, `gridStyle`, `gridOpacity`); a stored value that cannot be read is warned of on `omaphoto.app` and left at its default, where Swift falls back in silence. `setLayoutGrid` and `setGridAppearance` store and notify, silent when unchanged. Snap To Grid, the guide snapping and the overlay read the session's grid; the overlay draws the majors in the chosen colour, opacity and dashes (counted in pen widths for Qt) and the dotted subdivisions in that colour, and `GuidesShown` carries both, so a change repaints. `ProjectController::gridSettings` lives in `ProjectController+Resize.cpp` beside the other sheets, since the controller's own file would pass 500 lines: refused without a window or while a modal sheet is up (Swift's `attachedSheet`), it shows the grid, opens `GridSettingsSheet` in the window-modal `sheet` titled Grid, previews every change through the session, and on its answer puts back the grid's visibility and either keeps the settings or restores them; nothing is undone or saved, and the project is not busy. `UI/GridSettingsSheet` twins Swift's: the title, Color (a preset menu and the `DialogColorSwatch` "Grid Color"), Style, Opacity (a half-point scrub, a slider and a 48-point field with "%", stepped by arrows, clamped), a rule, "Gridline every" with "pixels" (a point's scrub) and Subdivisions (a fifth of a point's), the note (the step to two places, grouped as Swift's `formatted()`, or the limits in `BrightText`), Cancel, Restore Defaults (Custom's colour kept) and the default OK, resting while invalid; 360 wide, 24 in, 18 apart. A field takes any whole number, as Swift's binding, so OK rests until the grid is valid, and previews only valid grids. A picked colour becomes Custom's, remembering the preset it left so picking that colour again returns to it; a preset chosen from the menu ends that. A fix beyond Swift: the picker reports 8-bit colours, so Swift's comparisons miss presets that are no byte (Light Gray's 0.7): opening the picker on Light Gray turns the grid Custom at once. The twin compares both sides in the picker's 8 bits, since its Cancel hands back the exact original, so opening and Cancel both keep a preset. Left out as dead: Swift's reset of the pick in its return branch (returning to a preset ends the pick through the preset's own change, in Swift too) and `ToolDefaults::text`'s fallback (Swift's callers pass ""). Untested: the destructor's `releaseFocus` and picker close (without them the swatch's hide writes into members already gone; no sanitizer runs). Tests: `GuideGridTests` (Swift's four grid tests, the settings kept), `GridSettingsSheetTests`, the drawing in `GuideCanvasTests`, the flow in `ProjectResizeTests`, the entry in `ViewMenuTests` and `CompositorMenusTests`.
- Typing's undo and font previews (13.6, Swift 1.3.5: `ee68ba9`, `1bd1df4`). Swift's first commit lets the text view answer Undo and Redo itself, since the window's history took them; the twin's editor has answered them since 8.9b (the keys win `ShortcutOverride`, the menus reach the focused canvas's editor), so only Swift's case is added as a test: typing in a new box and Ctrl+Z through the real menus clears it in one step. The session's `previewFont`, `keepFontPreview` and `endFontPreview` twin Swift's: open text only, each face tried from the style as it was before the first (`m_fontPreviewOriginal`, Swift's `@ObservationIgnored` member) on the draft's selection, refused when invalid or unchanged, kept on a choice, put back on a close without one. `TypeFontPicker` draws each face's name in that face through `Qt::FontRole`, which the combo's menu delegate honours, at the menu's pixel size; a face that cannot draw the letters of its own name (`QRawFont`, the face itself and not fontconfig's fallbacks) keeps the menu's font, as Swift's symbol fonts do. Which faces can is worked out once on a worker when the first picker is made (Swift's `prepareStyledNames`), joined when the menu loads, a name still missing worked out then. The picker takes Swift's preview closure at construction: a highlighted face shows (the `(Multiple)` row does not), and every close posts the revert to the event loop, since Qt reports a choice after `hidePopup`; a choice keeps the preview first, which empties the original, so the revert finds nothing. Swift's `chose` flag does the same work twice and is left out as dead. Equivalent mutants: each preview built from the draft rather than the original (the selection holds still while the menu is open), `previewFont`'s validity term, Swift's too (`setFont` keeps a valid style valid), the worker's start-once guard and its join when the menu loads (without either the same answers are worked out again, costing only time), and the letters-only test of a face's name (every installed name in the dev image is letters and hyphens DejaVu covers); untested in the dev image: a face that cannot draw its own name (only DejaVu is installed). Tests: `TypeFontPreviewTests`, the Ctrl+Z case in `InlineTextDrawTests`.
- New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose sizes print as the fields' exact text, else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `compositor.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change. The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`.
- Tabs reorder and overflow (13.14, Swift `1faf7a0`): `UI/ProjectTabLayout.h/.cpp` twin Swift's file: `projectTabSpacing`, `ProjectTabSlot`, `ProjectTabPillSlot`, `ProjectTabOverflow` (`contentWidth()` for Swift's computed property), `projectTabOverflowLabel` and `projectTabOverflow`, widths a `std::map` read through `at` (every tab has one), the pill's width a `std::function`. Left out as dead: `span`'s empty guard (never asked of no tabs) and `contentWidth`'s pill fallback (a pill always has a tab after it). `ProjectWorkspace::moveTab` is Swift's, clamped, and emits `changed()` only when a tab moved. `ProjectTabStrip` is now a plain `QWidget` that takes the toolbar's free width (the spacer after it went; its size hint is every tab in a row, its minimum none) and places its children by hand from `projectTabOverflow` over its own width: the overflow pill at x 0 and the visible tabs at y 3, 28 high, each as wide as its button's size hint (so the unsaved dot's extra width counts, as before), hidden tabs hidden; it lays out again on `resizeEvent`, on the workspace's signal and on each tab's session signal (a title or a dot changes widths; a hidden button posts no layout request). The QScrollArea, its offset and `showFront` are gone, as Swift's scroller and reveal are; the selected tab is always shown by the pinning. `OverflowTabsPill` (a `QToolButton`, `projectTabsOverflow`) paints an inactive tab's capsule, the label "N more tabs" at 12 pixels medium and a drawn `chevron.down`, the label and chevron at half opacity while the workspace cannot switch (the capsule stays, as Swift's `.opacity` sits before its background); tooltip and accessible name are the label; its width is Swift's `projectTabOverflowPillWidth` (the label's ceiling plus 11, 4, 10, 11). Its press opens a `QMenu` (`projectTabsOverflowMenu`, deleted on close) four points below it, as `popUp(positioning:)` does, listing the hidden tabs in order, "• " before an unsaved one, a choice selecting; while the workspace cannot switch no menu opens. The reorder twins Swift's: `ProjectTabButton` watches its title button's left press, move and release in global coordinates (Swift's `.global` space) and reports `TabDragPhase::changed` once the press has moved three points and `ended` on the release; the strip's `Reorder` is `TabReorderState` (others, widths, compacted x, start, origin, translation, target), `nearestSlot`, `renderX`, `makeReorder` and `commitReorder` as Swift's (a neighbour closed mid-drag has no place, as Swift's `if let`s). A drag begins only while the workspace can switch and across at least three points, selects its tab first and raises it over the others (Swift's `zIndex`); it commits on release only if the workspace can still switch; the others ease to their places through a 150 ms `QPropertyAnimation` of `pos` (`OutQuad` for SwiftUI's `easeOut`), the dragged tab following the pointer at once, and every tab eases home when it ends. Qt can lose a release: a drag whose tab closes is dropped on the workspace's signal (Swift's timer drops it too), and a drag on another tab drops a stale one first (Swift's timer would have cleared it within 0.1 s). Known difference: a lost release on the same tab leaves its stale state for that tab's next drag, which then measures from the old origin. Known difference: Swift's empty title bar beside the tabs drags the window (`TitleBarDragView`); Qt's toolbar is no title bar, so the strip's empty part does nothing. `NewTabDropSlot` has no twin, as before. Equivalent mutant: reporting `ended` after a click that never dragged (Swift's gesture never ends one); it differs only after a lost release, where it would drop another tab's stale drag a click sooner. Tests: `ProjectTabLayoutTests` (Swift's six, the bounds at exactly fitting, a lone wide pinned tab, the pill's width asked by count), `ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments` (Swift's, plus its signal), `ProjectTabDragTests` (following the pointer, clamping, the gap, the slide's duration, curve and end, stacking, landing left and right and in place, a small or upward move as a click, a busy project at the press and at the drop, a tab closed mid-drag, a lost release giving way to the next drag, a three-point move and a move under another button, the overflow pill's label, place and width, the menu's entries, place and choice, pinning, the busy pill), and `ProjectWorkspaceViewTests` (overflow in the window; the strip ends before the zoom buttons). `ProjectTabStripTests::onlyANewFrontTabScrollsIntoView` went with the scrolling.
- Ungroup Layers and the mask pointer (13.15, Swift `1318f1e`, `6c3b9a5`, `6955a6f`): `EditorSession::canUngroupLayers` and `ungroupLayers` sit in `LayerGroups.cpp` (declared in `LayerGroups+Session.h`), as Swift's extension: the folder's direct children, their parent now the folder's own, take its place in the list in their order; `releaseDetachedClipping` then frees clips that no longer sit on their base; `finishOpacityEdit` ends a drag first; one `Ungroup Layers` step selects the children with the lowest active (none for an empty folder) and forgets the folder's collapse. Swift's `LayerHierarchy.validate` guard is dead and left out: unwrapping a folder only shortens parent chains, so nothing it refuses can appear; its `document` guard is `canEditLayers`'. The Layer menu gains Ungroup Layers (Ctrl+Shift+G) after Group Selected Layers, enabled by `canUngroupLayers`, and `ShortcutDefinition::all` Swift's "Ungroup Layers" entry (g, Cmd+Shift), so it can be remapped; the list's context menu shows it after Group Selected Layers for a right-clicked folder alone (Swift reads the clicked row, not the active layer), enabled by `canUngroupLayers`. `NativeLayerList::showMaskCursor` twins Swift's `showMaskCursor`: the duplicate pointer drawn over an eye in a 7.5 by 5.5 box at the hot spot plus (15, 18), haloed by sixteen white copies one point out, the image widened to hold it (29 by 32 at 1×), the hot spot the pointer's; Swift's `eye.fill` is drawn as paths (an almond of two quadratic curves, a ring cut round a filled pupil). Over a mask thumbnail with Alt, `cursorFor` shows it whatever `canEditLayers` says, as Swift's now does (13.12 brings the Alt-click that shows the mask alone). Equivalent mutant: the context entry enabled by `canEditLayers` (the right-click makes the folder active, so `canUngroupLayers` says the same). Tests: `GroupSessionTests` (Swift's three ungroup tests; collapse forgotten, signals, a plain layer refused silently, a nested and an empty folder, an opacity drag ended, busy), `LayerContextMenuTests::aFoldersMenuOffersUngroup`, the menu entry and its gate in `CompositorMenusTests`, the shortcut count in `KeyboardShortcutsTests`, the eye pointer's pixels and hot spot in `LayerDragGestureTests`.
- Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike. The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering mask under a preview that did not grow, and on a grown one the old bounds), `FilterSessionTests` and `FinishingFilterTests` (an older grid's preview shLine truncated
- Blending (13.9a: `6dc2219`, `8ab32c9`, `5a8f6ce`, `e6c964b`). Held, proven: clipping stacks and live-mask coverage at device resolution (the twin's `LiveMaskRenderer` has no `bounds`: its group, alpha and coverage surfaces are the painter's device since 4.x, so Swift's `resolution` and `pixelSize` have no twin); an adjustment in any mode, over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`adjust`'s `blended` goes through `LayerRenderer::composite`, which hand-blends every mode QPainter lacks through `HslBlend`, so Swift's `SeparableBlend.blend` has no twin); a clipping stack composited in its base's mode in every mode (`drawComposite` hands `m_stackModes` to `LayerRenderer::composite`, the same path), on the canvas, in export and in Copy Merged alike. Ported: Soft Light by Photoshop's formula, as Core Image computes it (2bs + b²(1 − 2s) up to half, 2b(1 − s) + √b(2s − 1) above), in `SeparableBlend::channel`, and `needsSurface` names Soft Light, so `HslBlend` blends it by hand everywhere a layer, a stack or an adjustment is drawn; `LayerRenderer::compositionMode` loses its Soft Light case (dead now). Compared first: Qt's `CompositionMode_SoftLight` is the PDF's (W3C) formula, which equals Photoshop's below a source of half and above it wherever the backdrop passes 0.25; under that it takes the PDF's cubic for √b and runs up to 17 levels darker (backdrop 5/255, source 1: 19 where Photoshop gives 36). Swift's 0.9 over 0.5 gives 170 under either formula. Known difference: Swift's Core Image is "within 5 levels of Photoshop's"; the twin is Photoshop's own formula, so it may differ from Swift's by those few levels. Tests: `BlendingTests` (Swift's `clippingStacksBlendInTheirBasesMode` for Linear Dodge, Color Dodge and Multiply: Swift's numbers, Copy Merged equal to export, the canvas within a level; adjustments in Difference and Linear Light over the document and Vivid Light and Subtract inside a stack, by the numbers, at full and half opacity, in all three places; Swift's `softLightMatchesPhotoshop` plus a dark backdrop and the lower branch; Swift's `softEdgedLayersBlendTheSame` for Soft Light, Overlay, Multiply and Color Dodge: a radial soft layer over noise, Copy Merged equal to export, the canvas within a level), `BlendModeTests::everyBlendModeMatchesThePdfFormulas` (its Soft Light reference is Photoshop's now; its backdrops of 0.1 and 0.25 under sources past half catch the PDF's cubic), `ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels` (a half-unit stripe stays sharp at a device scale of two, in a stack and through coverage from a source further down). Equivalent mutant: Soft Light's `source <= 0.5` for `< 0.5` (both branches give the backdrop at a half). Judgments: stacks and coverage at device resolution, adjustments in any mode and a stack in its base's mode were held (proven by the tests above); Soft Light ported. Mutants: 5 on Soft Light, 4 caught, one equivalent (above).
- The Metal canvas (13.9b: `cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`). OmaPhoto stays a CPU canvas (the port's decision): Swift's GPU canvas, its `GPUCanvas`, `GPUNoise`, `MetalWarp` and `CanvasLinesOverlay` and the GPU branches of `EditorCanvas`, have no twin; the twin's canvas is the Core Graphics canvas those commits compare against. Ported: Grain and Add Noise anchored to the document (`54dd707`): `LiveMaskRenderer::adjustmentRegion` (Swift's `adjustmentRegion`) maps what the painter shows into the document, and the canvas's `drawLayers` sets it from its centre mapping and zoom, so the smooth path counts noise in frame pixels from the document's corner and Grain in document pixels, as export does, and the crisp path's surface maps to document pixels, so a partial repaint keeps one field; the blur's scale still comes from the unmapped region. This retires 9.5b's and 12.8c's known differences (Grain and noise following the view, a partial repaint's own field); `CanvasDisplayTests::grainFollowsTheViewOnTheCanvas` is gone. Export and `drawLiveComposite` already draw in document pixels and set no region, as Swift's. Tests: `AdjustmentCanvasTests` (Swift's `matchesEveryAdjustment`: every kind through a ramp mask at 80% over a photo and a translucent layer, the canvas within a level of the export over its checkerboard; Swift's `patternsStayWithTheDocument` for Add Noise and Grain: equal to export at 100% and 200%, panned and not, and at 80% a whole-pixel pan moving the pattern with the image and a partial repaint showing the whole's pattern within two levels of resampling), `CanvasEditContractTests` (Swift's `aNewMaskRevealsTheWholeLayer`, upright and turned: a one-pixel mask on a scaled layer changes no covered pixel, the canvas as the export; `matchesWhileMovingPixels`, moving and duplicating on a masked layer at 60% under a soft selection: the preview equals the commit and the export outside the ants, the hole only for a move; `matchesWhileDraggingAGradient` in a selection at 70%: the pending gradient equals its commit outside the overlays, nothing outside the selection; `5708ed3`'s failed fill cancelling with `brushError`, no step, the document unchanged; `matchesALiveMaskOutsideAClippingStack`: a turned source with a layer between, the canvas as the export), `CanvasContractFixtures.h` (the scene, the export over the canvas's checkerboard, a gap that skips the overlays' bands, the ramp mask). `cdf8674`: rotation, flips, scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom, sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on pixel layers, filter, Levels and Hue/Saturation previews in the layer's place: held; the CPU paths are the reference and are pinned by `LayerRendererTests` (placement, sampling rules, halvings, overhang), `DownsampleTests`, `BlendModeTests`, `CanvasDisplayTests` (checkerboard, shadow and border, crisp from 200%, the rich scene against the export), `FolderOpacityTests`, `FolderMaskClipTests`, `MaskPlacementTests`, `LevelsCanvasTests`, `HueSaturationCanvasTests`, `VignetteCanvasTests`. `4b04323`: a moved or duplicated selection's preview and commit: held and now proven on a masked, translucent layer (above). Swift's laziness (`PixelMove.applyOffset`, `holed`, `liftedTarget`) only spares the GPU canvas a rebuild per move; the twin rebuilds eagerly in `movePixels`, which realises the tiles before display and commit by construction, and a failure there cancels the move with `brushError` (`PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels`). `5708ed3`, `91cb0b1`: live paint, erase and mask paint, paint past the old bounds under the old mask (`TiledStrokeTests`, `BrushPreviewTests`, `MaskPaintAnywhereTests`), Smudge and Liquify previews through the mask, opacity and placement (`SmearCanvasTests`, `FolderOpacityTests`): held. `GradientEdit.Fill` and `applyFill` are the GPU's laziness; the twin fills in `refreshGradient`, before display and commit by construction, a failure cancelling with `brushError` (now tested), and the commit publishes what was filled. `MetalWarpTests.matchesTheCPU`: the twin's `WarpStroke` is the CPU reference (`SmudgeLiquifyTests`); nothing to port. `93de9e7`: a one-pixel white mask revealing its layer: held (QPainter's smooth draw clamps an image's edge, as Core Graphics draws it; now tested); the live paint grid's extent and edge sampling: held (`TiledStrokeTests`, `DownsampleTests`, `TiledLayerTests`). `clampedToExtent` is GPU-only. `54dd707`: every adjustment kind with alpha, masks and opacity, blurs at the drawn scale and direction, adjustment results equal to the CPU reference, deterministic seeds: held (the canvas runs the CPU filters themselves; now Line truncated
- Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors.
- Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal and its order: two layers before a folder, a folder's mask turned off before the folder, an adjustment's mask before the adjustment, a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the canvas's tenth of the brush (1.5 to 30) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first, then `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheBrushSize`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeCoarser`: 16 M and four canvases; `aMaskSampleKeepsItsEdgeTone`), the tests' document clones through `documentClone` (`BrushFixtures.h`). Cancel and Apply, and a scaled layer's row (13.11, `67cc31e`): `TransformInspector` puts Cancel and Apply in a row of their own (`pendingTransform`, spaced 12) that shows only while the edit waits (`transformEdit()->persistent`: typed values, Ctrl+T, a distortion begun so); a handle drag's edit leaves it unseen. Swift's `.opacity(0)` keeps the buttons in the layout and their keys alive, so the row keeps its place under a `QGraphicsOpacityEffect` faLine truncated
- Masks as Photoshop's (13.12, `b3419ab`): `addMask` with a selection now reveals it (white inside, black outside, the coverage as drawn) and, `revealing` false, hides it (the coverage inverted), one `Reveal Selection` or `Hide Selection` step; the row menu's Reveal All and Hide All call it as before, so Hide All with a selection hides the selection. `LayerMaskMenu` reads Alt from `QGuiApplication::keyboardModifiers()` when clicked, Swift's `NSApp.currentEvent`, so Alt-click adds the opposite, with Swift's help in Alt's words. The session keeps `viewsMaskAlone` (a getter and a setter that notifies on a change, the badge's write; `EditorSession.h`, defined in `EditorSession.cpp` beside Swift's `didSet`) and `maskAloneLayer()` (Swift's computed property: the view asked, the mask targeted, the active layer with a mask); `isMaskSelected` gains Swift's `didSet` as a private `setIsMaskSelected` (declared in `LayerMask+Session.h`, since `EditorSession.h` sits at 499 lines; defined in `EditorSession.cpp`), which every former write of `m_isMaskSelected` now calls, so targeting the pixels, another layer, a deleted mask or a load ends the view. `maskAloneLayer` and `toggleMaskAlone` live in `LayerMask+Alone.cpp`, since `LayerMask.cpp` already held 494 lines. `toggleMaskAlone` targets the mask through `selectLayerTarget` and shows it unless it already showed, refusing where that refuses. The canvas's `DisplayState` gains `maskAlone` (the mask asset's `ImageIdentity`, whether or not enabled, since the composite may skip it), and `drawLayers` hands off to `drawMaskAlone` (`EditorCanvas+Masks.cpp`, beside `liveFolderMaskClip`, which moved there to keep `EditorCanvas.cpp` under 500 lines): the canvas filled with the mask's edge tone, then a brush stroke or gradient laid into that mask through `LayerRenderer::drawBrushPreview` (the painted mask's own raster when it has one), else the mask at its displayed placement or the layer's transform, gray where it shows; the crisp zoom path draws through `drawLayers` too. The list's mask thumbnail: Alt's release without a drag calls `toggleMaskAlone` (Shift no longer toggles there, as Swift's), the tooltip names Alt-click, and the targeted ring is white while that mask shows alone (`setTargeted`'s `alone`; Swift's `maskAloneLayer?.id == layerID` is left out as dead: the ring shows only on the active row, whose layer is the one shown alone). `LayerListFocusTests` and `LayerDragGestureTests` expected Alt-Shift to toggle the mask; they now expect Swift's view. `MaskAloneBadge` (in `UI/LayerMaskMenu`, Swift's file) is 26 points tall, 75% black with a 14% white rim, radius 13: the mask glyph at 11, "Layer Mask" 12 px semibold, the layer's name 12 px at 60% white, elided within 220 points, and an `xmark` button (a new `LayerIcon`, Swift's bold `xmark` as two round-capped strokes) with Swift's tooltip in Alt's words and accessible name, which ends the view. `ContentView` keeps one badge, shown while `maskAloneLayer` holds, in a layout nested in the canvas slot (it starts hidden through `synchronize`, which the constructor runs) (bottom, centred, 14 points up), so the room beside and below it stays the canvas's; Qt child widgets take their own clicks, so Swift's AppKit hosting has no twin. Swift's GPU fix (a mask stroke's grid starting at the mask's background) is the twin's behaviour already: `allocateTile` fills a mask tile with `maskBackground`. Tests: `MaskAloneTests` (Swift's three, plus another layer ending the view and the setter's signal, a stroke and a gradient shown as committed, a disabled and a placed mask, the crisp zoom, the badge's words, place, elision and clicks, the list's Alt-click, Shift ignored and the white ring), `SelectionEditTests` (Swift's renamed two and the scaled layer's hiding mask), `LayersPanelTests::theMaskButtonUsesTheSelection` (plain and Alt clicks with and without a selection, the tooltips, the row's Hide All). Mutants: the session 10 + 1 rerun (the active-layer term of `toggleMaskAlone` pinned by a busy refusal with another layer's mask targeted), equivalent: `maskAloneLayer`'s `m_isMaskSelected` and `layer->mask` terms (every path that clears either clears the view through `setIsMaskSelected`; Swift's guards, kept); `drawMaskAlone` 6 + 3 reruns (the placement pinned off the old box), equivalent: the stroke's `isMask` and layer terms (a stroke while a mask shows alone is always that mask's: the view needs the mask targeted, and every target switch commits a pending gradient); the canvas branch and state 2; the list 4 + 2 (a release off the thumbnail pinned); the button and badge 5 + 3 (margins and spacing pinned); `ContentView` 5 (the constructor's `hide` was dead and is gone). All caught otherwise.
- Quit mid-edit and Scanlines (13.17, Swift 1.4.4). Quit (`1aad384`): `ProjectWorkspace::settlePendingEdits` twins Swift's: on the tab on screen a pending gradient is applied (`commitGradient`), then a pixel move (`finishPixelMove`), then a filter, Hue/Saturation, Levels, Color Range, the Expand/Contract/Feather panel (`setSelectionAmountOperation(nullopt)`) and the colour picker are cancelled (their Cancel, so nothing un-OK'd lands). It takes a completion callback (Swift's `async`), run from the event loop once both commits have landed, and only while the workspace lives (`QPointer`), the tab kept alive in the callbacks as every workspace flow keeps it. `confirmQuit` refuses at once while the workspace manages (Swift's guard moved there from `applicationShouldTerminate`; the twin's window close reaches `confirmQuit` directly, so this guard is what stops a second close mid-quit), applies open text, settles, and only then asks `canSwitch`; a refusal there (a busy project, a commit still running) is a warning on `compositor.app` where Swift beeps. Swift's `gradientEdit != nil` and `pixelMove != nil` tests before awaiting are left out: both twins return (posted) when there is nothing to commit. Swift's `finishAdjustmentEditing(commit: false)` is left out as dead: `cancelFilter`, `cancelHueSaturation` and `cancelLevels`, each called before it, call it first, and an adjustment's editor is always one of those three (its request and original are set and cleared with them; while the editor still renders, `finishAdjustmentEditing` does nothing in Swift either, and `canSwitch` refuses the quit). Tests: `QuitPendingEditsTests` (Swift's `quittingAppliesAPendingGradient` and `quittingCancelsAnOpenDialog`; a pixel move applied as one `Move Pixels` step; Color Range putting the selection back, the selection-amount panel, the colour picker not taking its colour; an adjustment's editor putting the settings back; a workspace gone mid-settle calling nothing; closing the window applying the gradient before the Save question; a busy project refused with the warning, nothing asked or applied; a second quit while the first asks refused unsettled). Scanlines (`9c99853`, `cd2f998`): `DitherStyle::scanlines` ("Scanlines (CRT)") joins the fourth group; `usesPixelSize` (false for ASCII and Scanlines: no chunky pixels, no Pixel Size or Pixel Shape rows) and `drawsMarks` without Scanlines, as Swift's. `DitherSettings` gains `lineSpacing` (2–32, rounded, 4), `glow` (0–100, 35), `dots` (0–100, 0) and `wobble` (0–64 px, unrounded, 0), normalized as Swift's; the kernel's cell is the line spacing for Scanlines. `kernels/DitherPixels.c/.h` are cd2f998's, with one adaptation: Swift's kernel threads through GCD (`dispatch_apply`) and Apple blocks, which GCC cannot compile. `in_bands` keeps Swift's split (one band under 64 items, else 32) but takes a C function and a context; the three block bodies (the tone pass, the scanlines, the glow) are moved word for word into static functions with context structs (`tone_job`, `scan_job`, `glow_job`), and `__block int failed` is a C11 `atomic_int`. The bands run through `dither_bands`, a C hook defined in `Document/Dither.cpp` on `PoolMap::blocking` (QtConcurrent, so a filter worker lends its pool place). The arithmetic is unchanged; this is a forced difference (GCC has no blocks or GCD), agreed with the coordinator. `bandsDrawAsOneBandDoes` holds 32 bands against one: the top rows of a 300-row picture equal a 62-row picture's, which runs as one band in both passes. `glowing` twins Swift's: sigma `lineSpacing × 3 + 3`, shrunk by `max(1, floor(sigma / 4))`, blurred at `sigma / shrink`, scaled back and added through `dither_glow` at `glow / 100 × 2.5`. Core Image clamps to the extent before it shrinks, so the twin pads the image by the blur's reach (three sigmas, a whole number of shrink steps) with its edges repeated, shrinks that with `QImage::scaled`'s smooth filter (an area average, where Core Image samples bilinearly), blurs with the edge repeated (`PixelAdjust::gaussianBlur`), scales back bilinearly and crops the middle; it stays within three levels of light of Swift's first commit's full-size blur (`theGlowIsTheBlurredLightAtItsAmount`, both axes). The sheet adds Line Spacing (px), Glow (%), Dots (%) and Wobble (px) with Swift's help under Scanlines, and Pixel Size and Pixel Shape follow `usesPixelSize`. Tests: `ScanlinesTests` (Swift's four `DitherTests`; the beam's rows for white, 0.35 gray and a striped average; the style's flags and that pixel size, shape and cell size change nothing; the four settings' clamps, rounding and defaults; Two Colors' screen and Original's black; clear pixels; every band of tall images, 32 bands and a short last one; the glow against a full-size Gaussian at 20% and 100%, its fade, and alpha; the sheet's rows, help, slider ends and fields), `DitherTests` and `DitherSheetTests` (the eleventh style in names, groups and the menu). Equivalent mutants: a pad of onLine truncated
- Camera Raw curves as Photoshop's (13.18, Swift 1.4.5: `60d9117`): `kernels/AdjustPixels.c/.h` are 60d9117's: the tone curve runs on red, green and blue alike (`toneLut`, Swift's rename of `lumaLut`), and Refine Saturation means what Swift's now means: 0 the curve per channel, below 0 easing toward the colour scaled to the curved brightness (−100 brightness alone), above 0 pushing the curved colour from its luminance by 1 + the amount. `CameraRawCurveSettings::parametric` twins Swift's: unchanged with all four amounts at 0; otherwise 33 anchors, each `bend` of `bend` (shadows and highlights past the outer dividers, then darks and lights either side of the middle one, gamma curves of strength 1.66), run through the same curve as Image › Curves (`point`), so the regions meet without a corner. `bend` is a private static, as Swift's; `lumaTable` is `toneTable`. The twin's normalization of the dividers (5–90, +2 apart, 98 at most) still clamps what a drag writes. The graph's gestures (`UI/CameraRawColorControls+Curve.cpp`, beside the file that would pass 500 lines) are Swift's single `DragGesture`: a left press picks once (`parametricDrag`: within 18 points of the bottom the nearest divider, the first of equals; elsewhere the region whose tone range holds the press, a tone on a divider joining the region above; `pointDrag`: the point nearest by distance within 0.055, chosen; else a new point there, if fewer than 16, inside x 0.01–0.99 and over a hundredth from every point's x, sorted in, stored and chosen), then `continueDrag` runs at the press and on every left-button move: a point's output follows the pointer, an inner point's x stays a hundredth inside its neighbours, an end keeps its x; a divider follows, shadow below dark − 2 and dark below light − 2; a region's amount is its start minus the drag's height over the graph's times 200, within ±100, rounded. Left out as dead, since normalizing does each (`CameraRawCurveSettings::normalized`: amounts, dividers 5–90, +2 apart, 98 at most, and the repair's 0–1 outputs): Swift's 2–98 clamp of a divider, dark's `shadow + 2` floor and light's `dark + 2` floor, and the 0–1 clamps of a dragged or new point's output (a press inside the graph is inside 0–1 anyway). The new point's `x > 0.01` and `x < 0.99` are dead too: the ends sit at 0 and 1, and a point within a hundredth of any x is refused. Swift's explicit refresh after the press is dead: every press writes the settings, which announce. `bend`'s `lower > 0` and `upper < 1` are dead (no tone lies below 0 or above 1). The release lets go. Qt can lose a release, so every press picks afresh. A double click is Qt's second press: it picks and drags as a press (Swift's drag sees it too), then on the point page removes the nearest inner point by x within 0.04 and drops the choice, and lets go of the drag (Swift removes at the tap's end, which ends the drag). Swift re-picks on each move while a press picked nothing; with the same start and points it picks nothing again, so the twin leaves that out. The chosen point (Swift's `selected`) draws in `Highlight`, the accent, and the readout shows only while it exists, following the drag; another channel drops the choice and the drag (Swift's `onChange`). The graph's help and Refine Saturation's are Swift's new words. Swift's `CameraRawMixerControls` gains the same `Drag` and `selected` state but never reads it: dead, left out. A fix beyond Swift: Swift holds an inner point exactly a hundredth from its neighbours, where the curve's repair (`x > kept.x + 0.01`) drops it (or drops the next point), and the drag's index then names another point; the twin holds it a billionth further inside, so the repair keeps it and the drag stays on it (`aPointIsPickedByDistanceAndKept`, `endPointsStayAndARealDoubleClickRemoves`, both failing before the fix). A fix beyond Swift: a preset's points drop the choice (TASKS asks for it; Swift's `applyPreset` keeps the index, which then lights whatever point holds it); Custom changes nothing and keeps it. Tests: `CameraRawCurveTests` (Swift's `parametricCurveIsSmooth`, `parametricCurveMatchesPhotoshop`, `curveDeepensColorLikePhotoshop`; each anchor against the bend written apart, and the smooth curve between; each region bending only its range, dividers at the ends bending nothing; the tone table through the point curve; Refine Saturation at 0 per channel through the table, −100 brightness with the hue kept, −50 halfway, +50 half again from luminance), `CameraRawCurveGraphTests` (regions lifted and lowered from their start, clamped and rounded, picked once by tone; dividers in the strip, nearest and first of equals, kept ordered and within the settings' limits; points by distance, kept through the drag, a new point added and dragged by the same press, ends moving their outputs alone; refusals near an edge, a point's x, at sixteen; the chosen point lit and read out as it moves, ends and new points included; a channel or preset Line truncated
- 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 `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `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.
- `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` (which takes the toolbar's free width) 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 plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (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.
- `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update).
- The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set<int>` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`.
- `LayerMask::clipImage` returns nil when the placed grid cannot be rendered, as Swift's `try?` does, and logs a warning on `omaphoto.rendering` so the loss is visible.
+6
View File
@@ -44,7 +44,9 @@ add_library(compositor_core STATIC
src/Document/AdjustmentEditing.cpp
src/Document/LayerAppearance.cpp
src/Document/LayerGroups.cpp
src/Document/LayerGroups+Order.cpp
src/Document/LayerMask.cpp
src/Document/LayerMask+Alone.cpp
src/Document/LayerTransform.cpp
src/Document/LiveLayerMask.cpp
src/Document/PixelAdjust.cpp
@@ -136,6 +138,7 @@ add_library(compositor_core STATIC
src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp
src/UI/CameraRawColorControls.cpp
src/UI/CameraRawColorControls+Curve.cpp
src/UI/CameraRawColorControls+Grading.cpp
src/UI/CameraRawColorControls+Mixer.cpp
src/UI/CameraRawControls.cpp
@@ -157,6 +160,8 @@ add_library(compositor_core STATIC
src/UI/RawDevelopSheet.cpp
src/UI/OmarchyTheme.cpp
src/UI/ProjectTabs.cpp
src/UI/ProjectTabs+Strip.cpp
src/UI/ProjectTabLayout.cpp
src/UI/ProjectWorkspaceView.cpp
src/UI/ToolHeaderStyle.cpp
src/UI/ToolIcons.cpp
@@ -203,6 +208,7 @@ add_library(compositor_core STATIC
src/Rendering/EditorCanvas+Gradient.cpp
src/Rendering/EditorCanvas+Cursors.cpp
src/Rendering/BrushCursorOverlay.cpp
src/Rendering/EditorCanvas+Masks.cpp
src/Rendering/EditorCanvas+Navigation.cpp
src/Rendering/EditorCanvas+Picking.cpp
src/Rendering/EditorCanvas+Selection.cpp
+22 -11
View File
@@ -431,19 +431,30 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`)
- 13.5b review (Fable 5.1): round 1 FAIL (the picker's Cancel turned Light Gray, Light Blue and Green into Custom, since only one side was compared in 8 bits; the drawing's dots, dashes, caps and 4-point threshold loosely tested); round 2 PASS. The CPU cap was lifted to all 32 cores during this item (scripts/mutate.py follows).
- [x] 13.6 Part five: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`).
- 13.6 review (Fable 5.1): round 1 FAIL (four survivors unrecorded; the code judged right against Swift); round 2 PASS.
- [ ] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone.
- [ ] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`).
- [ ] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`).
- [ ] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin.
- [ ] 13.10 Part three: Shift keeps moved pixels on a line (`60bde4f`); Ctrl+A selects the canvas from the Layers panel (`451281e`).
- [ ] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`).
- [ ] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`).
- [x] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone.
- 13.7 port (agent): tests in `NewCanvasSheetTests` (presets, the check, the dots), `QuickLookPreviewTests` (the store's folder, load ignoring it, the JPEG byte for byte, sizes, the 50-megapixel bound, a failed render, a controller save) and the sync order in `ProjectReplaceTests`. Survivors recorded: the heading's stretch (equivalent); the scale and allocation null checks untested. No review yet.
- [x] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`).
- 13.8 port (agent): tests in `SharpCanvasTests`, `PSDAdjustmentTests`, `AdjustmentBandsTests`, `LayerOrderTests`, `PSDReaderTests`, `HueSaturationTests`, `FilterTests`, `FilterSessionTests`, `FinishingFilterTests`, `BlurNoiseAdjustmentTests`, `AdjustmentSurfaceTests`, `HueSaturationFailureTests` (it now fails at the copy). Survivors recorded: Saturation's clamps, the cube cache, the copy's bytewise branch, the bands' guard (equivalent); the mask grid out of memory untested. No review yet.
- [x] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`).
- 13.9a port (agent): Soft Light by Photoshop's formula (Qt's is the PDF's, up to 17 levels darker on dark backdrops); stacks, coverage and adjustments in every mode held and proven. Tests in `BlendingTests`, `BlendModeTests`, `ClippingStackTests`. Survivor recorded: `<= 0.5` for `< 0.5` (equivalent). No review yet.
- [x] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin.
- 13.9b port (agent): Grain and Add Noise anchored to the document on the canvas (`adjustmentRegion`); every other GPU contract judged held, gaps pinned in `AdjustmentCanvasTests` and `CanvasEditContractTests`; the grid over the border hairline left as a known difference. No review yet.
- [x] 13.10 Part three: Shift keeps moved pixels on a line (`60bde4f`); Ctrl+A selects the canvas from the Layers panel (`451281e`).
- 13.10 port (agent): Shift keeps a Ctrl-dragged selection's pixels on the longer axis; Ctrl+A from the Layers panel selects the canvas (`selectAllRows` gone). No review yet.
- [x] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`).
- 13.11 port (agent): `paintRefusal` says why a brush, gradient (a fix beyond Swift) or the Smear refuses; Inverse of everything deselects; Clone Stamp and Blur sample in the stroke's own grid (`Clone` as Swift's image, placed, inGrid; `gridRect`; `blurSample`'s budget); the held judgment holds (`NativeResolutionPaintTests`, no change to the grid or coverage); Cancel and Apply fade in only for a waiting edit; `sizeLabel`. No review yet.
- [x] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`).
- 13.12 port (agent): the mask button reveals the selection, Alt-click hides it; Alt-click on a mask thumbnail shows the mask alone (`viewsMaskAlone`, `toggleMaskAlone`, `drawMaskAlone`, a white ring, `MaskAloneBadge`); `liveFolderMaskClip` moved to `EditorCanvas+Masks.cpp`. No review yet.
- [ ] 13.13 Part three: Ctrl flips Auto Select both ways and Shift the aspect lock, live in the bar (`bca8f13`, `03d692c`); Move bar values apply without Apply as one step (`686d8c7`); resize handles snap (`c459f88`).
- [ ] 13.14 Part four: tabs drag to reorder, the ones that don't fit gather in a menu (`1faf7a0`).
- [ ] 13.15 Part five: Layer › Ungroup Layers (Ctrl+Shift+G) and in a folder's context menu (`1318f1e`, `6c3b9a5`); Alt over a mask thumbnail shows the duplicate pointer with an eye (`6955a6f`).
- [x] 13.14 Part four: tabs drag to reorder, the ones that don't fit gather in a menu (`1faf7a0`).
- 13.14 port (agent): tests in `ProjectTabLayoutTests` (Swift's six and the bounds), `ProjectTabDragTests` (drag, slide, stacking, landing, threshold, busy, lost release, a close mid-drag, the overflow pill and its menu), `ProjectWorkspaceTests` (`moveTab`) and `ProjectWorkspaceViewTests`. Survivor recorded: reporting `ended` after a click that never dragged (equivalent but after a lost release); `span`'s empty guard and `contentWidth`'s pill fallback removed as dead. No review yet.
- [x] 13.15 Part five: Layer › Ungroup Layers (Ctrl+Shift+G) and in a folder's context menu (`1318f1e`, `6c3b9a5`); Alt over a mask thumbnail shows the duplicate pointer with an eye (`6955a6f`).
- 13.15 port (agent): tests in `GroupSessionTests` (Swift's three and more), `LayerContextMenuTests`, `CompositorMenusTests`, `KeyboardShortcutsTests` and `LayerDragGestureTests` (the eye pointer). Survivor recorded: the context entry gated by `canEditLayers` (equivalent: the right-click makes the folder active); Swift's validation guard left out as dead. No review yet.
- [ ] 13.16 Versions 1.4.2–1.4.3: Liquify keeps pixels sharp (`7164dd4`), Smudge without ghost copies (`ad9ad7c`), the Blur brush's own Radius (`504129d`), Blur, Smudge and Liquify on big canvases (`095da2f`).
- [ ] 13.17 Version 1.4.4: quit works mid-gradient or with a dialog open (`1aad384`); Dither › Scanlines (CRT) on all cores (`9c99853`, `cd2f998`).
- [ ] 13.18 Version 1.4.5: Camera Raw curves as Photoshop's (`60d9117`): the smooth parametric curve and the per-channel point curves, Refine Saturation's new meaning (0 the RGB curve, −100 brightness alone, above 0 stronger colour) and its words; the graph: a press picks a point, divider or tonal region once and keeps it through the drag, a region drag lifts or lowers that region, dividers move in the bottom strip and stay ordered, point mode picks by distance, adds and drags a new point at once, keeps inner points ordered and moves the end points' outputs alone, the chosen point lit and the readout following it, a new channel or preset clearing the choice.
- [x] 13.17 Version 1.4.4: quit works mid-gradient or with a dialog open (`1aad384`); Dither › Scanlines (CRT) on all cores (`9c99853`, `cd2f998`).
- 13.17 port (agent): `settlePendingEdits` applies a pending gradient and pixel move and cancels open dialogs before Quit asks, `confirmQuit` refuses while managing; Scanlines (CRT) with Glow, Dots and Wobble, the kernel cd2f998's with GCD swapped for `dither_bands` on PoolMap (forced difference); tests in `QuitPendingEditsTests` and `ScanlinesTests`. Survivors recorded: the glow pad and edge mode, the band split. No review yet.
- [x] 13.18 Version 1.4.5: Camera Raw curves as Photoshop's (`60d9117`): the smooth parametric curve and the per-channel point curves, Refine Saturation's new meaning (0 the RGB curve, −100 brightness alone, above 0 stronger colour) and its words; the graph: a press picks a point, divider or tonal region once and keeps it through the drag, a region drag lifts or lowers that region, dividers move in the bottom strip and stay ordered, point mode picks by distance, adds and drags a new point at once, keeps inner points ordered and moves the end points' outputs alone, the chosen point lit and the readout following it, a new channel or preset clearing the choice.
- 13.18 port (agent): the fitted parametric curve, the tone curve per channel, Refine Saturation's new meaning and words; the graph's one drag (`CameraRawColorControls+Curve.cpp`); fixes beyond Swift: a preset drops the chosen point, a point dragged onto its neighbour stays and stays dragged; tests in `CameraRawCurveTests` and `CameraRawCurveGraphTests`. Survivor recorded: the all-zero shortcut. No review yet.
- [ ] 13.19 Release OmaPhoto 1.4.5: version, change log, README, AGENTS.md, the acceptance run, distro checks, packages, live installs, the acceptance checklist.
## Skills by phase
+2
View File
@@ -83,6 +83,8 @@ To change an existing layer, keep its `id` and overwrite its PNG, then rewrite t
Remove images you no longer reference once the manifest no longer lists them.
A project OmaPhoto saved also has a `QuickLook` folder (`Preview.jpg`), which Finder shows as its Space-bar preview on a Mac. When you change a project, delete that folder, so Finder doesn't preview an out-of-date picture; OmaPhoto writes it again the next time it saves.
## What the open app does
- It reloads about a third of a second after writes stop. Several writes in quick succession arrive as one update, so pause briefly between steps if a viewer should see each one.
+16 -10
View File
@@ -729,7 +729,7 @@ static double point_weight(double h, double s, double l, const float *point) {
}
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
double refineSaturation, const float *mixer, int pointCount, const float *points,
const float *grade, double blending, double balance, int visualize) {
for (size_t y = 0; y < height; ++y) {
@@ -739,16 +739,22 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s
double alpha = p[3];
if (!alpha) continue;
double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
double tone = rec709(r, g, b);
double mapped = lut_at(lumaLut, tone);
scale_luminance(&r, &g, &b, mapped);
if (refineSaturation != 0 && tone > 1e-4) {
double factor = 1 + refineSaturation * (mapped / tone - 1);
double lum = rec709(r, g, b);
r = camera_clamp(lum + (r - lum) * factor);
g = camera_clamp(lum + (g - lum) * factor);
b = camera_clamp(lum + (b - lum) * factor);
// The tone curve works on red, green and blue alike, as Photoshop's does, so contrast brings color strength
// with it. Refine Saturation below zero eases toward changing brightness alone (−100), and above zero adds
// more color.
double curvedR = lut_at(toneLut, r), curvedG = lut_at(toneLut, g), curvedB = lut_at(toneLut, b);
if (refineSaturation < 0) {
double br = r, bg = g, bb = b;
scale_luminance(&br, &bg, &bb, lut_at(toneLut, rec709(r, g, b)));
double k = -refineSaturation;
curvedR += (br - curvedR) * k; curvedG += (bg - curvedG) * k; curvedB += (bb - curvedB) * k;
} else if (refineSaturation > 0) {
double lum = rec709(curvedR, curvedG, curvedB), factor = 1 + refineSaturation;
curvedR = camera_clamp(lum + (curvedR - lum) * factor);
curvedG = camera_clamp(lum + (curvedG - lum) * factor);
curvedB = camera_clamp(lum + (curvedB - lum) * factor);
}
r = curvedR; g = curvedG; b = curvedB;
r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b);
double h, s, l;
rgb_to_hsl(r, g, b, &h, &s, &l);
+2 -2
View File
@@ -48,12 +48,12 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride
// the radii match a full-size render. Grain is applied separately. Alpha is kept.
// Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only.
void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights);
// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256
// Curve, Color Mixer, and Color Grading after the basic grade. `toneLut` and the channel LUTs are 256
// entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is
// 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels
// of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color.
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
double refineSaturation, const float *mixer, int pointCount, const float *points,
const float *grade, double blending, double balance, int visualize);
void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride,
+151 -12
View File
@@ -1,9 +1,30 @@
#include "DitherPixels.h"
#include <math.h>
#include <stdlib.h>
#include <stdatomic.h>
// Linux: GCD's dispatch_apply, run on QtConcurrent by Document/Dither.cpp. Blocks become functions with a context.
void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context);
static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
typedef void (*band_body)(void *context, size_t start, size_t end);
typedef struct { size_t count, size; band_body body; void *context; } band_job;
static void run_band(void *context, size_t band) {
const band_job *job = context;
size_t count = job->count, size = job->size;
size_t start = band * size, end = start + size < count ? start + size : count;
if (start < end) job->body(job->context, start, end);
}
// Runs `body` over `count` items split into a few runs per core, each a (start, end) range, all at once.
static void in_bands(size_t count, band_body body, void *context) {
size_t bands = count < 64 ? 1 : 32, size = (count + bands - 1) / bands;
band_job job = { count, size, body, context };
dither_bands(bands, run_band, &job);
}
// Density darkens (positive) or lightens as a gamma, so black and white stay put; contrast pivots on mid gray.
static inline float adjust_tone(float v, float gamma, float contrast) {
v = powf(clamp01(v), gamma);
@@ -114,19 +135,17 @@ static inline void write_pixel(uint8_t *px, float r, float g, float b) {
px[2] = (uint8_t)lroundf(clamp01(b) * a * 255.0f);
}
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
size_t count = width * height;
if (!count) return 1;
int planes = p->originalColors ? 3 : 1;
float *tone = malloc(count * sizeof(float) * (size_t)planes);
uint8_t *alpha = malloc(count);
// The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode.
float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL;
if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; }
typedef struct {
uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone, *source; const DitherParams *p;
float gamma, contrast;
} tone_job;
float gamma = exp2f(p->density * 1.5f);
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
for (size_t y = 0; y < height; ++y) {
static void tone_band(void *context, size_t first, size_t last) {
const tone_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count;
uint8_t *alpha = job->alpha; float *tone = job->tone, *source = job->source; const DitherParams *p = job->p;
float gamma = job->gamma, contrast = job->contrast;
for (size_t y = first; y < last; ++y) {
const uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
@@ -147,6 +166,90 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
}
}
}
}
typedef struct {
uint8_t *rgba; size_t stride, width, height, count; int planes; const uint8_t *alpha; const float *tone;
const DitherParams *p; size_t spacing; float middle, dots; const float *screen, *phosphor; atomic_int failed;
} scan_job;
static void scan_band(void *context, size_t firstLine, size_t lastLine) {
scan_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, height = job->height, count = job->count;
int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; const DitherParams *p = job->p;
size_t spacing = job->spacing; float middle = job->middle, dots = job->dots;
const float *screen = job->screen, *phosphor = job->phosphor;
float *scan = malloc(width * sizeof(float) * (size_t)planes);
if (!scan) { atomic_store(&job->failed, 1); return; }
for (size_t line = firstLine; line < lastLine; ++line) {
size_t top = line * spacing;
size_t bottom = top + spacing < height ? top + spacing : height;
// Wobble: each line is pushed sideways, a slow wave down the screen with a quicker one over it, as a
// CRT's picture wavers when its sync drifts.
float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f;
long shift = lroundf(p->wobble * wave);
for (size_t x = 0; x < width; ++x) {
float sum[3] = { 0, 0, 0 }; int n = 0;
long sx = (long)x - shift;
if (sx >= 0 && sx < (long)width)
for (size_t y = top; y < bottom; ++y) {
size_t at = y * width + (size_t)sx;
if (!alpha[at]) continue;
for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at];
++n;
}
for (int c = 0; c < planes; ++c) scan[(size_t)c * width + x] = n ? sum[c] / (float)n : 0;
}
for (size_t y = top; y < bottom; ++y) {
uint8_t *row = rgba + y * stride;
float offset = fabsf((float)(y - top) + 0.5f - middle);
for (size_t x = 0; x < width; ++x) {
if (!alpha[y * width + x]) continue;
// Dots: the line breaks into beads, one every line spacing, each lit in the color at its middle.
float along = fmodf((float)x + 0.5f, (float)spacing) - middle;
long centered = lroundf((float)x - along * dots);
size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered;
float r, g, b, t;
if (p->originalColors) {
r = scan[at]; g = scan[width + at]; b = scan[2 * width + at];
t = 0.2126f * r + 0.7152f * g + 0.0722f * b;
} else {
t = scan[at];
r = screen[0] + (phosphor[0] - screen[0]) * t;
g = screen[1] + (phosphor[1] - screen[1]) * t;
b = screen[2] + (phosphor[2] - screen[2]) * t;
}
// The beam is driven brighter than the picture, making up for the dark screen between lines.
r *= 1.35f; g *= 1.35f; b *= 1.35f;
// Half the beam's height: a thin line in the shadows, most of the way across in the highlights,
// always leaving dark screen between lines.
float beam = middle * (0.2f + 0.5f * sqrtf(clamp01(t)));
float across = along * dots, distance = sqrtf(offset * offset + across * across);
float cover = clamp01(beam - distance + 0.5f);
// Between the lines, the screen: black in Original, else the dark color.
float br = p->originalColors ? 0 : screen[0], bg = p->originalColors ? 0 : screen[1];
float bb = p->originalColors ? 0 : screen[2];
write_pixel(row + x * 4, br + (r - br) * cover, bg + (g - bg) * cover, bb + (b - bb) * cover);
}
}
}
free(scan);
}
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
size_t count = width * height;
if (!count) return 1;
int planes = p->originalColors ? 3 : 1;
float *tone = malloc(count * sizeof(float) * (size_t)planes);
uint8_t *alpha = malloc(count);
// The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode.
float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL;
if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; }
float gamma = exp2f(p->density * 1.5f);
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
tone_job toning = { rgba, stride, width, count, alpha, tone, source, p, gamma, contrast };
in_bands(height, tone_band, &toning);
float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f };
float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f };
@@ -183,6 +286,18 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
}
}
}
} else if (style == DITHER_SCANLINES) {
// A CRT: each line scans the image, its tone along the line the average of the rows it covers. The beam glows
// brighter and blooms thicker where the picture is light, and the screen between lines stays dark.
size_t spacing = (size_t)(p->cell < 2 ? 2 : p->cell);
float middle = (float)spacing / 2, dots = clamp01(p->dots);
// Each line is drawn on its own, so the lines are shared out across the cores.
size_t lines = (height + spacing - 1) / spacing;
const float *screen = dark, *phosphor = light;
scan_job scanning = { rgba, stride, width, height, count, planes, alpha, tone, p, spacing, middle, dots, screen, phosphor, 0 };
in_bands(lines, scan_band, &scanning);
int failed = atomic_load(&scanning.failed);
if (failed) { free(tone); free(alpha); free(source); return 0; }
} else {
// Marks (halftone shapes, patterns, glyphs) cover as much of each spot as the tone calls for. On light, they
// stand for darkness and are drawn in the dark color; light on dark, the reverse.
@@ -277,3 +392,27 @@ void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int
}
}
}
typedef struct { uint8_t *rgba; const uint8_t *glow; size_t width, stride; float amount; } glow_job;
static void glow_band(void *context, size_t first, size_t last) {
const glow_job *job = context;
uint8_t *rgba = job->rgba; const uint8_t *glow = job->glow; size_t width = job->width, stride = job->stride;
float amount = job->amount;
for (size_t y = first; y < last; ++y) {
uint8_t *row = rgba + y * stride;
const uint8_t *light = glow + y * stride;
for (size_t x = 0; x < width * 4; x += 4) {
float a = row[x + 3];
for (int c = 0; c < 3; ++c) {
float v = (float)row[x + c] + (float)light[x + c] * amount * a / 255.0f;
row[x + c] = (uint8_t)lroundf(v > a ? a : v);
}
}
}
}
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount) {
glow_job glowing = { rgba, glow, width, stride, amount };
in_bands(height, glow_band, &glowing);
}
+8 -2
View File
@@ -8,7 +8,7 @@ enum {
DITHER_ATKINSON, DITHER_FLOYD_STEINBERG,
DITHER_BAYER_2, DITHER_BAYER_4, DITHER_BAYER_8,
DITHER_DOTS, DITHER_LINES, DITHER_DIAMONDS,
DITHER_PATTERNS, DITHER_GLYPHS
DITHER_PATTERNS, DITHER_GLYPHS, DITHER_SCANLINES
};
typedef struct {
@@ -20,7 +20,8 @@ typedef struct {
// −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering.
float density;
float contrast;
// Halftone and glyph cells, in pixels, and the halftone screen's angle in radians.
// Halftone and glyph cells, in pixels (scanlines: the distance between lines), and the halftone screen's angle in
// radians.
int cell;
float angle;
// Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light.
@@ -37,6 +38,9 @@ typedef struct {
const uint8_t *glyphs;
const float *glyphCoverage;
int glyphCount;
// Scanlines: how far each line breaks into round dots (0–1), and how many pixels its wobble pushes it sideways.
float dots;
float wobble;
} DitherParams;
// Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully
@@ -45,4 +49,6 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
// Turns each `block` × `block` square of premultiplied RGBA pixels into a round dot in its own color on `gap` (straight
// sRGB), like the lit pixels of a dot-matrix screen. The dot's edge is smoothed and alpha is kept.
void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap);
// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, never past their own alpha.
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount);
#endif
+32
View File
@@ -26,3 +26,35 @@ void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t cou
}
}
}
/// A color lookup through `cube` (`dimension`³ RGBA entries, red varying fastest), blended between the eight nearest
/// entries, on unpremultiplied colors; alpha is kept.
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension) {
const float scale = (dimension - 1) / 255.0f;
const size_t dy = dimension, dz = (size_t)dimension * dimension;
for (size_t i=0; i<count; ++i) {
uint8_t *p = pixels + i*4;
float alpha = p[3];
if (!alpha) continue;
float position[3], fraction[3];
int lo[3];
for (int channel=0; channel<3; ++channel) {
position[channel] = fminf(255, p[channel]*255.0f/alpha) * scale;
lo[channel] = (int)position[channel];
if (lo[channel] > dimension - 2) lo[channel] = dimension - 2;
fraction[channel] = position[channel] - lo[channel];
}
const float *base = cube + ((size_t)lo[0] + lo[1]*dy + lo[2]*dz) * 4;
const size_t sx = 4, sy = dy*4, sz = dz*4;
for (int channel=0; channel<3; ++channel) {
const float *c = base + channel;
float x00 = c[0] + (c[sx] - c[0]) * fraction[0];
float x10 = c[sy] + (c[sy+sx] - c[sy]) * fraction[0];
float x01 = c[sz] + (c[sz+sx] - c[sz]) * fraction[0];
float x11 = c[sz+sy] + (c[sz+sy+sx] - c[sz+sy]) * fraction[0];
float y0 = x00 + (x10 - x00) * fraction[1];
float y1 = x01 + (x11 - x01) * fraction[1];
float result = y0 + (y1 - y0) * fraction[2];
p[channel] = (uint8_t)fminf(alpha, fmaxf(0, roundf(result*alpha)));
}
}
}
+1
View File
@@ -2,3 +2,4 @@
#include <stddef.h>
void levels_apply(uint8_t *pixels, size_t count, const float *tables);
void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins);
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension);
+13 -1
View File
@@ -11,6 +11,7 @@
#include "UI/ShapeControls.h"
#include "UI/TypeControls.h"
#include "UI/LassoControls.h"
#include "UI/LayerMaskMenu.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
#include "UI/NewCanvasSheet.h"
@@ -196,7 +197,8 @@ protected:
ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent)
: QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout),
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)),
m_maskAloneBadge(new MaskAloneBadge(session, this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
m_activity(statusText(new ElidedLabel(this))),
m_spinner(new QProgressBar(this))
{
@@ -240,6 +242,12 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
// The welcome sits over the canvas, as Swift's ZStack.
m_canvasSlot->addWidget(m_canvas, 0, 0);
m_canvasSlot->addWidget(m_dropRing, 0, 0);
// A layout takes no clicks; the canvas keeps the rest.
auto *foot = new QVBoxLayout;
foot->addStretch(1);
foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter);
foot->addSpacing(14);
m_canvasSlot->addLayout(foot, 0, 0);
setAcceptDrops(true);
auto *middle = new QHBoxLayout;
@@ -390,6 +398,10 @@ void ContentView::synchronize()
}
const std::optional<CanvasDocument> &document = m_session.document();
showWelcome(!document);
const std::optional<ImageLayer> alone = m_session.maskAloneLayer();
if (alone)
m_maskAloneBadge->setLayerName(alone->name);
m_maskAloneBadge->setVisible(alone.has_value());
for (QWidget *ruler : m_rulers)
ruler->setVisible(m_session.showsRulers() && document);
m_zoom->setVisible(document.has_value());
+3
View File
@@ -14,6 +14,7 @@
#include <QWidget>
class LayersPanel;
class MaskAloneBadge;
class ToolButton;
class ProjectWorkspace;
class QFileDialog;
@@ -74,6 +75,8 @@ private:
QWidget *const m_dock;
// An accent ring while a drop may land.
QWidget *const m_dropRing;
// Over the canvas's foot while a mask shows alone.
MaskAloneBadge *const m_maskAloneBadge;
// Swift 1.1.7's rulers: the corner, then the two strips.
std::vector<QWidget *> m_rulers;
ProjectWorkspace *m_workspace = nullptr;
+32
View File
@@ -3,6 +3,7 @@
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <cmath>
#include <cstring>
#include <numeric>
#include <stdexcept>
@@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask)
return surface;
}
QImage BrushRaster::copy(const QImage &image)
{
QImage result = context(image.width(), image.height(), false);
if (image.format() != QImage::Format_RGBA8888_Premultiplied) {
QPainter painter(&result);
draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return result;
}
for (int y = 0; y < image.height(); ++y)
std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4);
return result;
}
void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body)
{
const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2;
const qsizetype size = (count + bands - 1) / bands;
try {
std::vector<int> indices(static_cast<size_t>(bands));
std::iota(indices.begin(), indices.end(), 0);
PoolMap::blocking(indices, [&](int band) {
const qsizetype start = band * size;
if (start < count)
body(start, std::min(size, count - start));
});
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
}
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
{
context.save();
+4
View File
@@ -46,6 +46,10 @@ struct BrushSettings {
// Shared raster drawing; coverage is copied without color conversion.
namespace BrushRaster {
QImage context(int width, int height, bool mask);
// A colour context holding `image`; its own layout copies bytewise.
QImage copy(const QImage &image);
// Runs `body` over `count` pixels, band by band: start, length.
void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body);
// Copies `image`, or its `source` part, into `rect`.
void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF());
// A solid colour through gray coverage, and a clip, 1:1.
+27 -18
View File
@@ -79,25 +79,34 @@ bool CameraRawCurveSettings::adjusts() const
|| !isLinear(green) || !isLinear(blue);
}
double CameraRawCurveSettings::parametric(double tone) const
// Bends tones below `lower` by `low`, above `upper` by `high`.
double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high)
{
const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
double amount, lo, hi;
if (tone < shadow)
amount = shadows, lo = 0, hi = shadow;
else if (tone < dark)
amount = darks, lo = shadow, hi = dark;
else if (tone < light)
amount = lights, lo = dark, hi = light;
else
amount = highlights, lo = light, hi = 1;
const double span = std::max(0.02, hi - lo);
const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
// A tone in its region weighs 0 to 1, unfloored.
return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
// Fitted to Photoshop: Darks −51 dips 0.1 there.
const double strength = 1.66;
if (tone < lower)
return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength));
if (tone > upper) {
const double rest = 1 - upper;
return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength));
}
return tone;
}
std::vector<float> CameraRawCurveSettings::lumaTable() const
double CameraRawCurveSettings::parametric(double tone) const
{
if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0)
return tone;
// Gamma bends, smoothed as Image › Curves smooths.
std::vector<CurvePoint> anchors;
for (int index = 0; index <= 32; ++index) {
const double x = index / 32.0;
anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)});
}
return point(tone, anchors);
}
std::vector<float> CameraRawCurveSettings::toneTable() const
{
std::vector<float> table(256);
for (int index = 0; index < 256; ++index)
@@ -262,10 +271,10 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs
const CameraRawCurveSettings shape = curve.normalized();
const CameraRawMixerSettings mixing = mixer.normalized();
const CameraRawGradingSettings grade = grading.normalized();
const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
gradeFloats = grade.gradeFloats();
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(),
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(),
blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(),
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize);
}
+6 -2
View File
@@ -33,15 +33,19 @@ struct CameraRawCurveSettings {
static std::vector<CurvePoint> strongContrast();
static bool isLinear(const std::vector<CurvePoint> &points);
bool adjusts() const;
// Lifts or lowers the region a tone belongs to.
// Photoshop's parametric curve: gamma bends, smoothed as Curves.
double parametric(double tone) const;
std::vector<float> lumaTable() const;
// Applied to red, green and blue alike.
std::vector<float> toneTable() const;
std::vector<float> channelTable(const std::vector<CurvePoint> &points) const;
CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
// Swift's region(for:): the parametric amount a tone moves.
double &region(double tone);
CameraRawCurveSettings normalized() const;
friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
private:
static double bend(double tone, double lower, double low, double upper, double high);
};
// One picked colour and how far its adjustment reaches.
+62 -5
View File
@@ -1,17 +1,28 @@
#include "Document/Dither.h"
#include "Document/BrushStroke.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Rendering/PoolMap.h"
#include <QFontMetricsF>
#include <QPainter>
#include <QPainterPath>
#include <QTextBoundaryFinder>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <set>
extern "C" {
#include "DitherPixels.h"
}
// The kernel's bands, where Swift's kernel calls dispatch_apply.
extern "C" void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context)
{
std::vector<size_t> indices(bands);
std::iota(indices.begin(), indices.end(), size_t(0));
PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); });
}
// Swift's Dither.swift: the settings, the chunky pixels, the glyphs.
namespace {
// Swift's Characters: the text's graphemes.
@@ -53,6 +64,7 @@ QString rawValue(DitherStyle style)
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
case DitherStyle::ascii: return QStringLiteral("ASCII");
case DitherStyle::scanlines: return QStringLiteral("Scanlines (CRT)");
}
throw std::logic_error("unknown dither style");
}
@@ -80,7 +92,12 @@ bool isHalftone(DitherStyle style)
bool drawsMarks(DitherStyle style)
{
return !hasTones(style);
return !hasTones(style) && style != DitherStyle::scanlines;
}
bool usesPixelSize(DitherStyle style)
{
return style != DitherStyle::ascii && style != DitherStyle::scanlines;
}
QString rawValue(DitherPixelShape shape)
@@ -105,6 +122,10 @@ DitherSettings DitherSettings::normalized() const
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14));
result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4));
result.glow = clamp(glow, 0, 100, 35);
result.dots = clamp(dots, 0, 100, 0);
result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0);
result.angle = clamp(angle, -90, 90, 45);
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
result.diffusion = clamp(diffusion, 0, 100, 100);
@@ -125,8 +146,8 @@ DitherSettings DitherSettings::normalized() const
QImage DitherSettings::apply(const QImage &image) const
{
const DitherSettings settings = normalized();
// ASCII draws at full resolution: chunks would blur its letters.
const int block = settings.style == DitherStyle::ascii ? 1 : int(settings.pixelSize);
// ASCII and Scanlines draw full size: chunks would blur them.
const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1;
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (source.isNull())
throw ExportError(ExportError::Kind::render);
@@ -153,6 +174,8 @@ QImage DitherSettings::apply(const QImage &image) const
}
}
const QImage dithered = settings.dither(working);
if (settings.style == DitherStyle::scanlines && settings.glow > 0)
return settings.glowing(dithered);
if (block <= 1)
return dithered;
// Blown back up without smoothing.
@@ -171,9 +194,42 @@ QImage DitherSettings::apply(const QImage &image) const
return full;
}
// The lines' light blurred wide and added back: phosphor bloom.
QImage DitherSettings::glowing(const QImage &image) const
{
// Blurred small, scaled back up: same light, less work.
const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4));
const int width = image.width(), height = image.height();
// Swift clamps first: edges repeat outward before shrinking.
const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step;
QImage padded = BrushRaster::context(width + 2 * pad, height + 2 * pad, false);
for (int y = 0; y < padded.height(); ++y) {
const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1));
uchar *out = padded.scanLine(y);
for (int x = 0; x < padded.width(); ++x)
std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4);
}
const int smallWidth = (padded.width() + step - 1) / step, smallHeight = (padded.height() + step - 1) / step;
const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (small.isNull())
throw ExportError(ExportError::Kind::render);
const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true)
.scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
QImage result = image.copy();
if (blurred.isNull() || result.isNull())
throw ExportError(ExportError::Kind::render);
// The scaled light's own rows, cropped to the image.
QImage bloom = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y)
std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y));
dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5));
return result;
}
QImage DitherSettings::dither(const QImage &image) const
{
const int cell = int(cellSize);
const int cell = int(style == DitherStyle::scanlines ? lineSpacing : cellSize);
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs();
const bool two = colors == DitherColors::twoColors;
const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0};
@@ -184,7 +240,8 @@ QImage DitherSettings::dither(const QImage &image) const
.contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0,
.originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(),
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())};
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size()), .dots = float(dots / 100),
.wobble = float(wobble)};
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), &params) == 0;
});
if (failed)
+15 -4
View File
@@ -6,10 +6,10 @@
#include <vector>
// Filter › Dither's looks, in the panel's order and DitherPixels.h's.
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii};
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii, scanlines };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii, DitherStyle::scanlines};
QString rawValue(DitherStyle style);
// Swift's groups: diffusion, ordered, halftone, marks.
int ditherGroup(DitherStyle style);
@@ -19,6 +19,8 @@ bool diffuses(DitherStyle style);
bool hasTones(DitherStyle style);
bool isHalftone(DitherStyle style);
bool drawsMarks(DitherStyle style);
// ASCII and Scanlines draw at full resolution, not chunky.
bool usesPixelSize(DitherStyle style);
// A chunky pixel: a square, or a dot on dark.
enum class DitherPixelShape { square, dot };
@@ -35,6 +37,8 @@ struct DitherSettings {
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
static constexpr double textSizeLow = 6, textSizeHigh = 64;
static constexpr double levelsLow = 2, levelsHigh = 8;
static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32;
static constexpr double wobbleLow = 0, wobbleHigh = 64;
static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); }
DitherStyle style = DitherStyle::atkinson;
// Each dithered pixel covers this many layer pixels a side.
@@ -44,6 +48,12 @@ struct DitherSettings {
double cellSize = 8;
// ASCII's line height in layer pixels; letters about 0.6 wide.
double textSize = 14;
// Scanlines: the lines' distance apart, in layer pixels.
double lineSpacing = 4;
// Glow and dots 0–100%; wobble sideways in pixels.
double glow = 35;
double dots = 0;
double wobble = 0;
// The halftone screen's angle, in degrees.
double angle = 45;
// Tones per channel; 2 is 1-bit.
@@ -76,4 +86,5 @@ struct DitherSettings {
private:
QImage dither(const QImage &image) const;
QImage glowing(const QImage &image) const;
};
+2 -2
View File
@@ -104,8 +104,8 @@ struct SessionJobs {
FilterSettings settings;
// Camera Raw's scope of the grade itself.
std::optional<CameraRawScope> scope = std::nullopt;
// Its grid's version: a newer grid drops it.
quint64 sourceVersion = 0;
// The grown layer it was made from: it shows there.
std::optional<LayerTransform> placement = std::nullopt;
};
struct FilterMade {
std::optional<ImportedImage> asset;
+2 -1
View File
@@ -75,7 +75,8 @@ struct CanvasDocument {
QSizeF size() const { return QSizeF(width, height); }
static std::optional<int> validDimension(const QString &value);
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
// Drawing order and what shows (LayerOrder), kept between asks.
LayerOrder::Result hierarchy() const;
QSet<QUuid> effectiveVisibleIDs() const;
QHash<QUuid, double> effectiveOpacities() const;
std::vector<ImageLayer> renderLayers() const;
+2 -2
View File
@@ -27,7 +27,7 @@ std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path)
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
setIsMaskSelected(false);
m_cropRect.reset();
m_guideDrag = std::nullopt;
m_transformEdit = std::nullopt;
@@ -78,7 +78,7 @@ void EditorSession::setProjectPath(std::optional<QString> path)
void EditorSession::clearProject()
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
setIsMaskSelected(false);
m_cropRect.reset();
m_transformEdit = std::nullopt;
m_guideDrag = std::nullopt;
+2 -2
View File
@@ -257,8 +257,8 @@ LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const
if (const std::optional<LayerTransform> pending = pendingTransform(layer))
return *pending;
// Content-Aware Fill past the edge previews on the grown layer.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->grownTransform;
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->preparedTransform;
return layer.transform;
}
+17 -2
View File
@@ -35,7 +35,7 @@ void EditorSession::setActiveLayerID(std::optional<QUuid> id)
{
// Another layer: its pixels are the target again.
if (id != m_activeLayerID)
m_isMaskSelected = false;
setIsMaskSelected(false);
m_activeLayerID = id;
m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>();
notify();
@@ -234,7 +234,7 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
m_document = snapshot.document;
setActiveLayerID(snapshot.activeLayerID);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = keepsMaskTarget && active && active->mask;
setIsMaskSelected(keepsMaskTarget && active && active->mask);
if (changedCanvas && m_document)
viewport.fit(m_document->size());
qCInfo(lcApp) << "restored a history snapshot of" << (m_document ? int(m_document->layers.size()) : 0) << "layers";
@@ -381,3 +381,18 @@ void EditorSession::requestCanvasFocus()
++m_canvasFocusRequest;
notify();
}
void EditorSession::setIsMaskSelected(bool value)
{
m_isMaskSelected = value;
if (!value)
m_viewsMaskAlone = false;
}
void EditorSession::setViewsMaskAlone(bool value)
{
if (m_viewsMaskAlone == value)
return;
m_viewsMaskAlone = value;
notify();
}
+4 -3
View File
@@ -93,10 +93,11 @@ public:
void selectTool(NavigationTool value);
// Tab: the current tool's next mode.
void cycleToolMode();
// Whether the mask thumbnail, not the layer's, is the target.
bool isMaskSelected() const { return m_isMaskSelected; }
// Alt-click: the targeted mask alone, gray, on the canvas.
bool viewsMaskAlone() const { return m_viewsMaskAlone; }
void setViewsMaskAlone(bool value);
// A blend mode tried on by hovering; nothing is saved.
struct BlendPreview {
QUuid layerID;
@@ -132,7 +133,6 @@ public:
// An unlinked mask, selected, transforms without its layer.
bool transformTargetsMask() const;
void nudgeLayer(double dx, double dy);
const std::optional<LassoDraft> &lassoDraft() const { return m_lassoDraft; }
LassoKind lassoKind() const { return m_lassoKind; }
void setLassoKind(LassoKind kind);
@@ -350,6 +350,7 @@ private:
std::optional<BlendPreview> m_blendPreview;
std::optional<QUuid> m_opacityEditLayerID;
bool m_isMaskSelected = false;
bool m_viewsMaskAlone = false;
std::optional<QUuid> m_activeLayerID;
bool m_isProjectBusy = false;
bool m_showsNewDocument = false;
+13 -17
View File
@@ -80,10 +80,6 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
try {
edit.growForBlur();
edit.preparedPreview.reset();
// A waiting job moves to the grid its version names.
if (edit.pending)
edit.pending = edit.previewJob();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
@@ -111,6 +107,7 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
return;
}
edit.pending = edit.previewJob();
edit.pendingTransform = edit.grownTransform;
renderFilterPreview();
}
@@ -127,22 +124,22 @@ void EditorSession::renderFilterPreview()
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
m_filterPreviewFor = edit.id;
m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered {
m_filterPreview.setFuture(QtConcurrent::run([job, placement = edit.pendingTransform]() -> Filtered {
try {
if (job.kind == FilterKind::cameraRaw) {
auto [image, scope] = CameraRawScope::preview(job);
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version};
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), placement};
}
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version};
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, placement};
} catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const SubjectRemovalError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ExportError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ProjectError &error) {
// Settings past their bounds: Black & White, Color Balance.
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
}
}));
}
@@ -153,18 +150,17 @@ void EditorSession::finishFilterPreview()
{
m_filterRendering = false;
const Filtered result = m_filterPreview.result();
// A cancelled edit takes nothing; an older grid renders again.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) {
m_filterEdit->preparing = false;
notify();
} else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
// A cancelled edit takes nothing.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
FilterEdit &edit = *m_filterEdit;
edit.preparing = false;
edit.previewError = result.failure;
if (result.scope)
edit.rawPanel.scope = result.scope;
// Made before the layer grew, it shows there till replaced.
if (edit.preview || isAutomatic(edit.kind)) {
edit.preparedPreview = result.image;
edit.preparedTransform = result.placement;
edit.preparedSettings = result.settings;
++m_brushRevision;
}
@@ -351,7 +347,7 @@ void EditorSession::finishFilterCommit()
mask.isEnabled = true;
mask.placement = std::nullopt;
m_document->layers[index].mask = mask;
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
finish();
return;
-1
View File
@@ -272,7 +272,6 @@ void FilterEdit::grow(const QRectF &extent)
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
{
const QSize size = source.size();
++previewSourceVersion;
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise, grain and dither preview whole: enlarged, they look coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
+4 -2
View File
@@ -171,10 +171,12 @@ public:
// Add Noise's grain, fixed while the panel is open.
const quint32 seed = QRandomGenerator::global()->generate();
std::optional<QImage> preparedPreview;
// Where the prepared preview sits; none: the layer's own place.
std::optional<LayerTransform> preparedTransform;
// The grown layer `pending` is made from.
std::optional<LayerTransform> pendingTransform;
// The settings the prepared preview was made with.
std::optional<FilterSettings> preparedSettings;
// Moves with each new grid: an older render lands nothing.
quint64 previewSourceVersion = 0;
std::optional<FilterJob> pending;
CameraRawPanel rawPanel;
// Vignette on an empty layer: the canvas it fills.
+62 -59
View File
@@ -1,13 +1,14 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "Document/HueSaturation.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include <QPainter>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <deque>
#include <mutex>
extern "C" {
#include "LevelsPixels.h"
}
namespace {
struct HSL {
@@ -48,38 +49,38 @@ HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness
return {clamp(color.red), clamp(color.green), clamp(color.blue)};
}
// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back.
void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows)
// The last tables built: building costs more than applying.
struct CubeCache {
std::mutex lock;
std::deque<std::pair<HueSaturationSettings, std::vector<float>>> cubes;
};
CubeCache &cubeCache()
{
constexpr int size = HueSaturationFilter::dimension, last = size - 1;
PoolMap::blocking(rows, [&](int y) {
uchar *pixel = bits + y * stride;
for (int x = 0; x < width; ++x, pixel += 4) {
const float alpha = pixel[3];
if (alpha == 0) {
std::fill_n(pixel, 3, uchar(0));
continue;
static CubeCache cache;
return cache;
}
std::vector<float> built(const HueSaturationSettings &settings)
{
const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(settings);
constexpr int dimension = HueSaturationFilter::dimension;
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
float place[3], fraction[3];
int low[3];
for (int channel = 0; channel < 3; ++channel) {
place[channel] = std::min(1.0f, pixel[channel] / alpha) * last;
low[channel] = std::min(int(place[channel]), last - 1);
fraction[channel] = place[channel] - float(low[channel]);
}
float out[3] = {0, 0, 0};
for (int corner = 0; corner < 8; ++corner) {
const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1);
const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1])
* ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]);
const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)];
for (int channel = 0; channel < 3; ++channel)
out[channel] += share * entry[channel];
}
for (int channel = 0; channel < 3; ++channel)
pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha));
}
});
}
return values;
}
}
@@ -104,22 +105,20 @@ std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(c
std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings)
{
const std::vector<HueResponse> response = hueResponse(settings);
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const Color color = adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
CubeCache &cache = cubeCache();
{
const std::lock_guard<std::mutex> held(cache.lock);
for (const auto &[known, values] : cache.cubes) {
if (known == settings)
return values;
}
}
std::vector<float> values = built(settings);
const std::lock_guard<std::mutex> held(cache.lock);
std::erase_if(cache.cubes, [&](const auto &entry) { return entry.first == settings; });
cache.cubes.emplace_front(settings, values);
if (cache.cubes.size() > 8)
cache.cubes.pop_back();
return values;
}
@@ -144,8 +143,7 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
hue = std::fmod(hue + sampled.shift, 360);
if (hue < 0)
hue += 360;
// Multiplied, so neutral grays stay neutral.
saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100)));
saturation = adjustedSaturation(saturation, sampled.saturation);
}
// Toward white above zero, black below, reaching either at 100.
const double amount = std::min(1.0, std::max(-1.0, lightnessAmount));
@@ -153,6 +151,15 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness)));
}
double HueSaturationFilter::adjustedSaturation(double saturation, double amount)
{
const double share = std::min(1.0, std::max(-1.0, amount / 100));
// Multiplied both ways, so neutral grays stay neutral.
if (!(share > 0))
return std::max(0.0, saturation * (1 + share));
return share >= 1 ? (saturation > 0 ? 1 : 0) : std::min(1.0, saturation / (1 - share));
}
double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings)
{
double shift = 0;
@@ -166,23 +173,19 @@ double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &
AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job)
{
const int width = job.image.width(), height = job.image.height();
QImage result = BrushRaster::context(width, height, false);
{
QPainter painter(&result);
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
}
// Across the cores: it runs over the view each frame.
QImage result = BrushRaster::copy(job.image);
std::vector<float> table;
std::vector<int> rows;
try {
table = cube(job.settings);
rows.resize(size_t(height));
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
std::iota(rows.begin(), rows.end(), 0);
applyCube(result.bits(), result.bytesPerLine(), width, table, rows);
// The lookup unpremultiplies around itself.
uchar *const pixels = result.bits();
BrushRaster::inBands(qsizetype(result.width()) * result.height(),
[&](qsizetype start, qsizetype length) { cube_apply(pixels + start * 4, size_t(length), table.data(), dimension); });
// Swift blends over the image drawn again: the same pixels.
if (job.selection)
result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false);
+2
View File
@@ -131,6 +131,8 @@ std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings);
std::vector<float> cube(const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response);
// Photoshop's: down toward gray, up by dividing by what's left.
double adjustedSaturation(double saturation, double amount);
// The hue a spectrum swatch becomes.
double shiftedHue(double hue, const HueSaturationSettings &settings);
}
+2 -1
View File
@@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region,
FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()};
// Swift's region origin, counted in the image's own pixels.
if (region)
job.noiseOrigin = region->topLeft() * (image.width() / region->width());
job.noiseOrigin = QPointF(region->left() * image.width() / std::max(1.0, region->width()),
region->top() * image.height() / std::max(1.0, region->height()));
return PixelFilter::run(job);
}
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
+1 -1
View File
@@ -97,7 +97,7 @@ void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing)
finishEffectsEditing(false);
// Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here.
selectLayer(id);
m_isMaskSelected = false;
setIsMaskSelected(false);
m_effectSelection = selection;
if (editing && m_effectsEditing != selection) {
m_effectsEditingOriginal = m_document->layers[size_t(indexOf(m_document->layers, id))].effects.value_or(LayerEffects());
+108
View File
@@ -0,0 +1,108 @@
#include "Document/EditorSession.h"
#include "Document/LayerGroups.h"
#include <map>
#include <mutex>
#include <stdexcept>
namespace {
using Children = std::map<std::optional<QUuid>, std::vector<const LayerOrder::Node *>>;
void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool shown, LayerOrder::Result &result)
{
if (depth > 64)
return;
const auto found = children.find(parent);
if (found == children.end())
return;
for (const LayerOrder::Node *node : found->second) {
const bool effective = shown && node->isVisible;
result.order.push_back(node->id);
if (effective) {
result.visible.insert(node->id);
if (!node->isGroup)
result.drawn.push_back(node->id);
}
if (node->isGroup)
visit(children, node->id, depth + 1, effective, result);
}
}
// The last nodes asked about and their answer.
struct Known {
std::mutex lock;
std::optional<std::pair<std::vector<LayerOrder::Node>, LayerOrder::Result>> last;
};
Known &known()
{
static Known value;
return value;
}
}
LayerOrder::Result LayerOrder::resolve(const std::vector<ImageLayer> &layers)
{
std::vector<Node> nodes;
nodes.reserve(layers.size());
for (const ImageLayer &layer : layers)
nodes.push_back(Node{layer.id, layer.parentID, layer.isGroup, layer.isVisible});
Known &cache = known();
{
const std::lock_guard<std::mutex> held(cache.lock);
if (cache.last && cache.last->first == nodes)
return cache.last->second;
}
Children children;
for (const Node &node : nodes)
children[node.parentID].push_back(&node);
Result result;
visit(children, std::nullopt, 0, true, result);
const std::lock_guard<std::mutex> held(cache.lock);
cache.last.emplace(std::move(nodes), result);
return result;
}
LayerOrder::Result CanvasDocument::hierarchy() const
{
return LayerOrder::resolve(layers);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
return hierarchy().visible;
}
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
{
// Each layer's opacity and folder, no whole layers copied.
QHash<QUuid, std::pair<double, std::optional<QUuid>>> folders;
for (const ImageLayer &layer : layers) {
if (folders.contains(layer.id))
throw std::logic_error("two layers share an id");
folders.insert(layer.id, {layer.opacity, layer.parentID});
}
QHash<QUuid, double> result;
for (auto entry = folders.cbegin(); entry != folders.cend(); ++entry) {
result.insert(entry.key(), LayerOpacity::effective(entry->first, entry->second, [&folders](QUuid id) {
return folders.contains(id) ? std::optional(folders.value(id)) : std::nullopt;
}));
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
const std::vector<QUuid> order = hierarchy().drawn;
if (order.empty())
return {};
std::map<QUuid, size_t> index;
for (size_t place = 0; place < layers.size(); ++place) {
if (!index.emplace(layers[place].id, place).second)
throw std::logic_error("two layers share an id");
}
std::vector<ImageLayer> result;
result.reserve(order.size());
for (const QUuid id : order)
result.push_back(layers[index.at(id)]);
return result;
}
+3
View File
@@ -7,6 +7,9 @@ public:
void selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary);
void extendSelection(QUuid id);
void groupSelectedLayers();
// The active layer is a folder: something to unwrap.
bool canUngroupLayers() const;
void ungroupLayers();
std::vector<LayerHierarchy::Entry> layerRows() const;
bool canPlaceLayer(QUuid id, std::optional<QUuid> parent) const;
bool placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false);
+42 -51
View File
@@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRe
});
}
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();
@@ -159,40 +146,6 @@ ProjectLayerRecord ImageLayer::hierarchyRecord() const
.text = typed ? std::optional(typed->style) : std::nullopt};
}
std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const
{
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : layers)
records.push_back(layer.hierarchyRecord());
return LayerHierarchy::entries(records);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
QSet<QUuid> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible)
result.insert(entry.layer.id);
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
QHash<QUuid, const ImageLayer *> byID;
for (const ImageLayer &layer : layers) {
if (byID.contains(layer.id))
throw std::logic_error("two layers share an id");
byID.insert(layer.id, &layer);
}
std::vector<ImageLayer> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible && entry.layer.isGroup != true)
result.push_back(*byID.value(entry.layer.id));
}
return result;
}
void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary)
{
dropEffectSelection();
@@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers()
}
std::vector<QUuid> ordered;
std::vector<Chain> chains;
for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) {
if (rootIDs.contains(entry.layer.id)) {
ordered.push_back(entry.layer.id);
chains.push_back(ancestors(entry.layer.id));
for (const QUuid id : m_document->hierarchy().order) {
if (rootIDs.contains(id)) {
ordered.push_back(id);
chains.push_back(ancestors(id));
}
}
// The nearest folder they all share; the root when none.
@@ -317,6 +270,44 @@ void EditorSession::groupSelectedLayers()
endEdit();
}
bool EditorSession::canUngroupLayers() const
{
const std::optional<ImageLayer> active = activeLayer();
return canEditLayers() && active && active->isGroup;
}
// The folder's children take its place, in order.
void EditorSession::ungroupLayers()
{
if (!canUngroupLayers())
return;
const ImageLayer group = activeLayer().value();
std::vector<ImageLayer> children;
for (const ImageLayer &layer : m_document->layers) {
if (layer.parentID == group.id) {
children.push_back(layer);
children.back().parentID = group.parentID;
}
}
std::vector<ImageLayer> layers;
QSet<QUuid> childIDs;
for (const ImageLayer &child : children)
childIDs.insert(child.id);
for (const ImageLayer &layer : m_document->layers) {
if (layer.id == group.id)
layers.insert(layers.end(), children.begin(), children.end());
else if (!childIDs.contains(layer.id))
layers.push_back(layer);
}
releaseDetachedClipping(layers);
finishOpacityEdit();
beginEdit(QStringLiteral("Ungroup Layers"));
m_document->layers = layers;
selectLayers(childIDs, children.empty() ? std::nullopt : std::optional(children.front().id));
m_collapsedGroupIDs.remove(group.id);
endEdit();
}
std::vector<LayerHierarchy::Entry> EditorSession::layerRows() const
{
std::vector<ProjectLayerRecord> records;
+23
View File
@@ -23,3 +23,26 @@ namespace LayerOpacity {
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
double effective(double own, std::optional<QUuid> parent, const Node &folder);
}
struct ImageLayer;
// Swift's LayerOrder: the hierarchy from ids, folders and visibility alone.
namespace LayerOrder {
struct Node {
QUuid id;
std::optional<QUuid> parentID;
bool isGroup;
bool isVisible;
friend bool operator==(const Node &, const Node &) = default;
};
struct Result {
// Every layer and folder, as LayerHierarchy::entries lists them.
std::vector<QUuid> order;
// Visible, in folders that are.
QSet<QUuid> visible;
// The layers that show, folders left out, bottom to top.
std::vector<QUuid> drawn;
};
// Kept until a node changes: the canvas asks each event.
Result resolve(const std::vector<ImageLayer> &layers);
}
+21
View File
@@ -0,0 +1,21 @@
#include "Document/EditorSession.h"
// Swift's mask shown alone, an Alt-click on its thumbnail.
std::optional<ImageLayer> EditorSession::maskAloneLayer() const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!m_viewsMaskAlone || !m_isMaskSelected || !layer || !layer->mask)
return std::nullopt;
return layer;
}
void EditorSession::toggleMaskAlone(QUuid id)
{
const std::optional<ImageLayer> shown = maskAloneLayer();
const bool showing = shown && shown->id == id;
selectLayerTarget(id, true);
if (m_activeLayerID != id || !m_isMaskSelected)
return;
m_viewsMaskAlone = !showing;
notify();
}
+6
View File
@@ -15,6 +15,12 @@ public:
std::optional<QImage> maskDistortPreview(const ImageLayer &layer) const;
// The selection made a mask; without one, a solid mask.
void addMask(bool revealing = true);
// The layer whose mask the canvas shows alone, if any.
std::optional<ImageLayer> maskAloneLayer() const;
// Alt-click on a mask thumbnail: alone, or the composite again.
void toggleMaskAlone(QUuid id);
private:
// Swift's didSet: the pixels targeted end the mask view.
void setIsMaskSelected(bool value);
void commitMaskTransform();
+9 -8
View File
@@ -262,7 +262,7 @@ void EditorSession::selectLayerTarget(QUuid id, bool mask)
resolveGradient();
selectLayer(id);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = mask && active && active->mask;
setIsMaskSelected(mask && active && active->mask);
notify();
}
@@ -286,14 +286,15 @@ void EditorSession::addMask(bool revealing)
// The selection's coverage, soft where it is feathered.
QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height,
BrushRaster::pixelToDocument(active->transform, width, height));
if (revealing)
// Revealing, white inside; hiding, black inside.
if (!revealing)
image.invertPixels();
const LayerMask mask(LayerMask::assetFrom(image));
finishOpacityEdit();
beginEdit(QStringLiteral("Add Mask from Selection"));
beginEdit(revealing ? QStringLiteral("Reveal Selection") : QStringLiteral("Hide Selection"));
m_document->layers[size_t(index)].mask = mask;
m_document->selection = std::nullopt;
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
} catch (const std::runtime_error &error) {
qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what();
@@ -312,7 +313,7 @@ void EditorSession::addLayerMask(bool revealing)
if (layer.id == active->id)
layer.mask = LayerMask::solid(revealing);
}
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
}
@@ -341,7 +342,7 @@ void EditorSession::deleteLayerMask()
if (layer.id == active->id)
layer.mask = std::nullopt;
}
m_isMaskSelected = false;
setIsMaskSelected(false);
endEdit();
}
@@ -371,7 +372,7 @@ void EditorSession::copyMask(QUuid source, QUuid target)
beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask"));
find(target)->mask = mask;
selectLayer(target);
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
}
@@ -392,7 +393,7 @@ std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageL
if (!layer.mask)
return std::nullopt;
// Previewed on a grown layer, the mask keeps its bounds.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return layer.mask->placement.value_or(layer.transform);
if (m_transformEdit && m_transformEdit->group) {
const TransformGroup &group = *m_transformEdit->group;
+5 -3
View File
@@ -104,15 +104,17 @@ QImage LevelsFilter::run(const LevelsJob &job)
{
if (job.settings.isIdentity())
return job.image;
QImage context = drawn(job.image);
QImage context = BrushRaster::copy(job.image);
// On the stack: a heap failure would escape the catch.
std::array<float, 3 * 256> tables;
for (size_t channel = 0; channel < 3; ++channel) {
for (size_t value = 0; value <= 255; ++value)
tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
}
// The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice).
levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data());
// The kernel unpremultiplies edges itself; bands use every core.
uchar *const pixels = context.bits();
BrushRaster::inBands(qsizetype(context.width()) * context.height(),
[&](qsizetype start, qsizetype length) { levels_apply(pixels + start * 4, size_t(length), tables.data()); });
if (job.selection)
return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false);
return context;
+44 -3
View File
@@ -4,6 +4,7 @@
#include <QFileDialog>
#include <QFileInfo>
#include <QTimer>
#include <algorithm>
const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row");
@@ -96,6 +97,20 @@ void ProjectWorkspace::select(QUuid id)
emit changed();
}
void ProjectWorkspace::moveTab(QUuid id, int index)
{
const auto from = std::ranges::find_if(m_tabs, [id](const std::shared_ptr<ProjectTab> &tab) { return tab->id == id; });
if (from == m_tabs.end())
return;
const auto target = std::clamp<std::ptrdiff_t>(index, 0, std::ptrdiff_t(m_tabs.size()) - 1);
if (target == from - m_tabs.begin())
return;
const std::shared_ptr<ProjectTab> tab = *from;
m_tabs.erase(from);
m_tabs.insert(m_tabs.begin() + target, tab);
emit changed();
}
void ProjectWorkspace::newCanvas()
{
if (!canSwitch())
@@ -220,14 +235,40 @@ bool ProjectWorkspace::finishTextEditing()
return true;
}
void ProjectWorkspace::settlePendingEdits(std::function<void()> done)
{
// Canvas edits are applied; dialogs cancelled, as their Cancel.
const std::shared_ptr<ProjectTab> settled = tab(current().id);
settled->session.commitGradient([self = QPointer<ProjectWorkspace>(this), settled, done] {
settled->session.finishPixelMove([self, settled, done] {
EditorSession &session = settled->session;
session.cancelFilter();
session.cancelHueSaturation();
session.cancelLevels();
session.cancelColorRange();
session.setSelectionAmountOperation(std::nullopt);
session.closeColorPicker(false);
if (self)
done();
});
});
}
void ProjectWorkspace::confirmQuit(std::function<void(bool)> done)
{
if (!finishTextEditing() || !canSwitch()) {
if (m_isManaging || !finishTextEditing()) {
finish(done, false);
return;
}
setManaging(true);
askNext(quitOrder(), 0, std::move(done));
settlePendingEdits([this, done] {
if (!canSwitch()) {
qCWarning(lcApp) << "Quit refused: the project is still busy";
finish(done, false);
return;
}
setManaging(true);
askNext(quitOrder(), 0, done);
});
}
void ProjectWorkspace::askNext(std::vector<std::shared_ptr<ProjectTab>> order, size_t index, std::function<void(bool)> done)
+4
View File
@@ -36,12 +36,16 @@ public:
bool canSwitch() const;
ProjectTab &addTab(bool reuseEmpty = true);
void select(QUuid id);
// A dragged tab lands at `index`, clamped; chrome, never undone.
void moveTab(QUuid id, int index);
void newCanvas();
// Each `done` runs from the event loop, never inline.
void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {});
void close(QUuid id, std::function<void()> done = {});
void removeTab(QUuid id);
std::vector<std::shared_ptr<ProjectTab>> quitOrder() const;
// Applies canvas edits and cancels dialogs before a quit.
void settlePendingEdits(std::function<void()> done);
void confirmQuit(std::function<void(bool)> done);
void closeWindow(QWidget *closing);
void receive(const QList<QUrl> &urls, std::optional<QUuid> destination = std::nullopt,
+33
View File
@@ -45,6 +45,24 @@ QByteArray encode(QImage image, const char *format, int quality, double resoluti
throw ExportError(ExportError::Kind::encode);
return data;
}
QByteArray scaledJPEG(const QImage &image, double longSide)
try {
const double scale = std::min(1.0, longSide / std::max(image.width(), image.height()));
const QSize size(std::max(1, int(std::round(image.width() * scale))), std::max(1, int(std::round(image.height() * scale))));
const QImage scaled = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
QImage flattened(size, QImage::Format_RGB888);
if (scaled.isNull() || flattened.isNull())
throw ExportError(ExportError::Kind::render);
flattened.fill(Qt::white);
{
QPainter painter(&flattened);
painter.drawImage(QRectF(flattened.rect()), scaled);
}
return encode(flattened, "jpeg", 80, 72);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {}
@@ -118,6 +136,21 @@ try {
throw ExportError(ExportError::Kind::render);
}
std::optional<QuickLookImages> ImageExporter::quickLookImages(const ProjectSnapshot &snapshot)
{
if (snapshot.manifest.width * snapshot.manifest.height > 50'000'000) {
qCInfo(lcIO) << "no Quick Look preview for" << snapshot.manifest.width << "x" << snapshot.manifest.height;
return std::nullopt;
}
try {
return QuickLookImages{scaledJPEG(render(snapshot).image, 1024)};
} catch (const std::runtime_error &error) {
// Swift's try? saves without one; the log says why.
qCWarning(lcIO) << "no Quick Look preview:" << error.what();
return std::nullopt;
}
}
QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot)
{
const ExportRaster raster = render(snapshot);
+3
View File
@@ -3,6 +3,7 @@
#include <QByteArray>
#include <QImage>
#include <functional>
#include <optional>
#include <stdexcept>
class ExportError : public std::runtime_error {
@@ -39,6 +40,8 @@ struct JPEGResult {
namespace ImageExporter {
ExportRaster render(const ProjectSnapshot &snapshot);
// Flattened on white, 1024 a side; none past 50 megapixels.
std::optional<QuickLookImages> quickLookImages(const ProjectSnapshot &snapshot);
QByteArray pngData(const ProjectSnapshot &snapshot);
// Stops at Swift's three points once `cancelled` says so.
JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled = {});
+33 -1
View File
@@ -1,6 +1,10 @@
#include "IO/PSD/PSDDocumentBuilder.h"
#include "Document/PixelAdjust.h"
#include "IO/ProjectStore.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
namespace {
ImportedImage imported(const QImage &image, const QString &name)
@@ -114,7 +118,10 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
ImageLayer layer = layerFor(record, canvas, assets, typed);
if (record.mask) {
try {
layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked);
const std::optional<QImage> placed = maskOnLayerGrid(*record.mask, record, layer, canvas);
if (!placed)
throw ExportError(ExportError::Kind::render);
layer.mask = LayerMask(LayerMask::assetFrom(*placed), record.maskEnabled, std::nullopt, record.maskLinked);
} catch (const std::runtime_error &) {
// Swift's `try?`: a mask that fails is reported, not fatal.
conversions.push_back(PSDConversion{.layerName = record.name,
@@ -147,3 +154,28 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
}
return PSDImport{document.width, document.height, document.resolution, std::move(layers), std::move(conversions)};
}
std::optional<QImage> PSDDocumentBuilder::maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas)
{
const QSizeF grid = layer.asset ? QSizeF(layer.asset->size()) : canvas;
const QRectF placed(layer.transform.origin, layer.transform.size);
if (grid.width() < 1 || grid.height() < 1 || !(placed.width() > 0) || !(placed.height() > 0) || !(record.maskBounds.width() > 0)
|| !(record.maskBounds.height() > 0))
return patch;
const double scaleX = grid.width() / placed.width(), scaleY = grid.height() / placed.height();
const QRectF rect((record.maskBounds.left() - placed.left()) * scaleX, (record.maskBounds.top() - placed.top()) * scaleY,
record.maskBounds.width() * scaleX, record.maskBounds.height() * scaleY);
const int width = int(grid.width()), height = int(grid.height());
// Already the layer's grid: nothing to place.
if (rect.toAlignedRect() == QRect(0, 0, width, height) && patch.size() == QSize(width, height))
return patch;
QImage context(width, height, QImage::Format_Grayscale8);
if (context.isNull()) {
qCWarning(lcIO) << "a PSD layer mask could not be placed on its layer:" << QSize(width, height);
return std::nullopt;
}
context.fill(uint(record.maskDefault));
QPainter painter(&context);
BrushRaster::draw(patch, rect, painter);
return context;
}
+2
View File
@@ -15,4 +15,6 @@ namespace PSDDocumentBuilder {
// Each pixel record's asset and thumbnail, off the UI thread.
std::map<QUuid, ImportedImage> assets(const PSDDocument &document);
PSDImport makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets = {});
// The stored patch on the layer's grid; none without memory.
std::optional<QImage> maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas);
}
+25 -11
View File
@@ -1,6 +1,7 @@
#include "IO/PSD/PSDReader.h"
#include <QtEndian>
#include <algorithm>
#include <cmath>
namespace {
quint16 u16(const QByteArray &data, qsizetype at)
@@ -12,21 +13,23 @@ qint16 i16(const QByteArray &data, qsizetype at)
{
return qFromBigEndian<qint16>(data.constData() + at);
}
}
std::optional<LayerAdjustment> levels(const QByteArray &data)
std::optional<LayerAdjustment> PSDAdjustments::levels(const QByteArray &data)
{
if (data.size() < 292)
return std::nullopt;
LevelsSettings settings;
for (int channel = 0; channel < 4; ++channel) {
const qsizetype base = 2 + channel * 10;
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 256, double(u16(data, base + 2)),
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 100, double(u16(data, base + 2)),
double(u16(data, base + 4)), double(u16(data, base + 6))}
.normalized();
}
return LayerAdjustment{.kind = AdjustmentKind::levels, .levels = settings};
}
namespace {
std::optional<LayerAdjustment> curves(const QByteArray &data)
{
if (data.size() < 5)
@@ -66,22 +69,33 @@ std::optional<LayerAdjustment> curves(const QByteArray &data)
return std::nullopt;
return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings};
}
}
std::optional<LayerAdjustment> hue(const QByteArray &data)
std::optional<LayerAdjustment> PSDAdjustments::hue(const QByteArray &data)
{
if (data.size() < 4)
if (data.size() < 16)
return std::nullopt;
HueSaturationSettings settings(0, 0, 0, data[2] != 0);
qsizetype at = 4;
for (const ColorRange range : allColorRanges) {
if (at + 6 > data.size())
const bool colorize = data[2] != 0;
HueSaturationSettings settings(0, 0, 0, colorize);
const auto values = [&data](qsizetype at) { return RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))}; };
// Colorize has values of its own; else the Master's.
settings.adjustments[ColorRange::master] = values(colorize ? 4 : 10);
if (colorize)
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
qsizetype at = 16;
for (const ColorRange range : colorRanges) {
if (at + 14 > data.size())
break;
settings.adjustments[range] = RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))};
at += 6;
const auto degrees = [&data](qsizetype from) {
const double value = std::fmod(double(i16(data, from)), 360);
return value < 0 ? value + 360 : value;
};
settings.bands[range] = HueBand{degrees(at), degrees(at + 2), degrees(at + 4), degrees(at + 6)};
settings.adjustments[range] = values(at + 8);
at += 14;
}
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
}
}
std::optional<LayerAdjustment> PSDAdjustments::parse(const std::map<QString, QByteArray> &extra)
{
+1 -2
View File
@@ -180,8 +180,7 @@ PSDRecords::RawLayer PSDRecords::readRecord(Cursor &cursor, bool isPSB)
layer.maskRight = cursor.i32();
layer.sourceMaskTop = layer.maskTop, layer.sourceMaskLeft = layer.maskLeft;
layer.sourceMaskBottom = layer.maskBottom, layer.sourceMaskRight = layer.maskRight;
// The mask's default colour is skipped, as Swift ignores it.
cursor.skip(1);
layer.maskDefault = cursor.u8();
const uchar flags = cursor.u8();
layer.maskDisabled = (flags & 2) != 0;
layer.maskLinked = (flags & 1) == 0;
+2
View File
@@ -50,6 +50,8 @@ struct RawLayer {
std::map<QString, QByteArray> extra;
qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
qint64 sourceMaskTop = 0, sourceMaskLeft = 0, sourceMaskBottom = 0, sourceMaskRight = 0;
// The mask's value outside its stored bounds.
uchar maskDefault = 255;
bool maskDisabled = false;
bool maskLinked = true;
bool maskFromRender = false;
+2
View File
@@ -80,6 +80,8 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
}
}
record.mask = layer.maskFromRender ? std::nullopt : layer.maskImage;
record.maskBounds = QRectF(layer.maskLeft, layer.maskTop, layer.maskRight - layer.maskLeft, layer.maskBottom - layer.maskTop);
record.maskDefault = layer.maskDefault;
record.maskEnabled = !layer.maskDisabled;
record.maskLinked = layer.maskLinked;
if (!isGroup)
+4
View File
@@ -21,4 +21,8 @@ extern const std::set<QString> adjustmentKeys;
// Levels, Curves and Hue/Saturation layers, as Swift reads them.
namespace PSDAdjustments {
std::optional<LayerAdjustment> parse(const std::map<QString, QByteArray> &extra);
// 'levl': each channel's levels, gamma in hundredths.
std::optional<LayerAdjustment> levels(const QByteArray &data);
// 'hue2': Colorize or the Master, then each range's values.
std::optional<LayerAdjustment> hue(const QByteArray &data);
}
+3
View File
@@ -38,6 +38,9 @@ struct PSDRecord {
QRectF bounds;
std::optional<QImage> image;
std::optional<QImage> mask;
// Where `mask` sits, and Photoshop's value everywhere else.
QRectF maskBounds;
uchar maskDefault = 255;
bool maskEnabled = true;
bool maskLinked = true;
std::optional<LayerAdjustment> adjustment;
+2 -1
View File
@@ -1,6 +1,7 @@
#include "IO/ProjectController.h"
#include "IO/RecentProjects.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include <QFileDialog>
@@ -199,7 +200,7 @@ void ProjectController::write(const Prepared &prepared, std::function<void(bool)
// The store blocks on the disk: off the UI thread.
watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> {
try {
ProjectStore::save(snapshot, destination);
ProjectStore::save(snapshot, destination, ImageExporter::quickLookImages(snapshot));
return std::nullopt;
} catch (const std::runtime_error &error) {
return QString::fromUtf8(error.what());
+8 -1
View File
@@ -276,7 +276,7 @@ ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QStri
{
}
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path, const std::optional<QuickLookImages> &quickLook)
{
validate(snapshot.manifest);
std::vector<std::pair<QString, QByteArray>> files;
@@ -323,6 +323,13 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
for (const auto &[filename, data] : files)
writeFile(staged + QLatin1String("/images/") + filename, data);
syncDirectory(staged + QLatin1String("/images"));
// Quick Look's Space-bar preview; loading ignores it.
if (quickLook) {
if (!QDir().mkdir(staged + QLatin1String("/QuickLook")))
fail(QStringLiteral("could not create"), staged + QLatin1String("/QuickLook"), QStringLiteral("the folder cannot be made"));
writeFile(staged + QLatin1String("/QuickLook/Preview.jpg"), quickLook->preview);
syncDirectory(staged + QLatin1String("/QuickLook"));
}
syncDirectory(staged);
replace(staged, destination.absoluteFilePath(), destination.absolutePath(), staging.keep);
qCInfo(lcIO) << "saved" << destination.absoluteFilePath() << "with" << snapshot.manifest.layers.size() << "layers and" << files.size() << "images";
+6 -1
View File
@@ -97,9 +97,14 @@ private:
ProjectError(Kind kind, std::optional<qint64> version, const QString &description);
};
// Swift's, in ImageExporter.swift; here, as the exporter includes the store.
struct QuickLookImages {
QByteArray preview;
};
// A .comp project: a directory holding manifest.json and images/.
namespace ProjectStore {
// Replaces the project in one step or leaves it untouched.
void save(const ProjectSnapshot &snapshot, const QString &path);
void save(const ProjectSnapshot &snapshot, const QString &path, const std::optional<QuickLookImages> &quickLook = std::nullopt);
ProjectSnapshot load(const QString &path);
}
+12 -3
View File
@@ -1,6 +1,7 @@
#include "Rendering/AdjustmentSurface.h"
#include "Document/BrushStroke.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <QPaintEngine>
void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding, qint64 pixelBudget)
@@ -15,11 +16,18 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
// Spatial adjustments read a halo; the clip keeps what shows.
const QRectF shown = toDevice.inverted().mapRect(QRectF(output));
const QRect area = padding > 0 ? toDevice.mapRect(shown.adjusted(-padding, -padding, padding, padding)).toAlignedRect() : output;
if (qint64(area.width()) * area.height() > pixelBudget) {
// Device pixels; past the budget, painter units.
double scale = 1;
QSize size = area.size();
if (qint64(size.width()) * size.height() > pixelBudget) {
scale = 1 / LayerRenderer::deviceScale(context);
size = QSize(std::max(1, qRound(area.width() * scale)), std::max(1, qRound(area.height() * scale)));
}
if (qint64(size.width()) * size.height() > pixelBudget) {
qCWarning(lcRendering) << "an adjustment surface passes its pixel budget:" << area.size();
return;
}
QImage surface(area.size(), QImage::Format_RGBA8888_Premultiplied);
QImage surface(size, QImage::Format_RGBA8888_Premultiplied);
if (surface.isNull()) {
qCWarning(lcRendering) << "an adjustment surface could not be allocated:" << area.size();
return;
@@ -28,12 +36,13 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
{
// The body draws in the painter's own coordinates.
QPainter painter(&surface);
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()));
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()) * QTransform::fromScale(scale, scale));
body(painter);
}
// Drawn back under the painter's own opacity, mode and clip.
context.save();
context.setWorldTransform(context.deviceTransform().inverted() * context.worldTransform());
context.setRenderHint(QPainter::SmoothPixmapTransform, scale != 1);
context.drawImage(QRectF(area), surface);
context.restore();
}
+65
View File
@@ -0,0 +1,65 @@
#include "Rendering/EditorCanvas.h"
#include "IO/ImageExporter.h"
#include "Rendering/DownsampleCache.h"
#include "Rendering/LayerRenderer.h"
#include "Rendering/RasterSnapshot.h"
#include <QPainter>
// Swift's drawMaskAlone: gray across the canvas, edge tone past it.
void CanvasView::drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center &center, QPainter &context)
{
const LayerMask &mask = layer.mask.value();
const int tone = qRound(LayerMask::background(mask.asset.thumbnail) * 255);
context.fillRect(QRectF(center(QPointF(0, 0)), document.size() * scale), QColor(tone, tone, tone));
const std::optional<GradientEdit> &gradient = m_session.gradientEdit();
const BrushStroke *stroke = m_session.brushStroke() ? m_session.brushStroke() : gradient ? gradient->raster.get() : nullptr;
// A stroke or gradient laid into it shows at once.
if (stroke && stroke->isMask && stroke->layer.id == layer.id) {
const LayerTransform &placed = stroke->paintTransform;
const std::shared_ptr<const RasterSnapshot> &raster = mask.asset.raster;
LayerRenderer::drawBrushPreview(raster ? QImage() : mask.asset.image(), placed, center(placed.center()), context, {.scale = scale},
{.patches = stroke->patches(), .pixelWidth = stroke->width, .pixelHeight = stroke->height,
.paintingMask = false, .sourceRect = stroke->sourceRect, .raster = raster});
return;
}
// Where a transform drag shows it, as the composite.
const LayerTransform placed = m_session.displayedMaskPlacement(layer).value_or(m_session.displayedTransform(layer));
LayerRenderer::draw(mask.asset.image(), placed, center(placed.center()), context, {.scale = scale});
}
// The folder's live mask, multiplied into the coverage.
FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, double scale, const Center &center) const
{
return [&edit, scale, center](const QPainter &painter, QImage &coverage) {
QImage live(coverage.size(), QImage::Format_Grayscale8);
if (live.isNull())
throw ExportError(ExportError::Kind::render);
// Outside the mask's bounds stays hidden, as when committed.
live.fill(0);
LayerTransform transform = edit.paintTransform;
transform.sampling = LayerSampling::nearest;
const std::optional<ImportedImage> &base = edit.layer.mask ? std::optional(edit.layer.mask->asset) : std::nullopt;
{
QPainter drawing(&live);
drawing.setTransform(painter.deviceTransform());
// Swift's displayImage: halved near the size drawn.
const double device = LayerRenderer::deviceScale(drawing);
const auto shown = [device](const QImage &image, double width) {
return DownsampleCache::shared().imageDrawnAt(image, width * device / std::max(1, image.width()));
};
const std::shared_ptr<const RasterSnapshot> raster = base ? base->raster : nullptr;
const QImage image = base && !raster ? shown(base->image(), transform.size.width() * scale) : QImage();
const QImage rasterBase = raster && !raster->base.isNull()
? shown(raster->base, transform.size.width() * scale * raster->baseRect.width() / std::max(1, raster->width))
: QImage();
LayerRenderer::drawBrushPreview(image, transform, center(transform.center()), drawing, {.scale = scale},
{.patches = edit.patches(), .pixelWidth = edit.width, .pixelHeight = edit.height, .paintingMask = false,
.sourceRect = edit.sourceRect, .raster = raster, .rasterBase = rasterBase});
}
QPainter multiplying(&coverage);
if (!multiplying.isActive())
throw ExportError(ExportError::Kind::render);
multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn);
multiplying.drawImage(QRectF(coverage.rect()), BrushRaster::alphaView(live));
};
}
+10 -38
View File
@@ -85,7 +85,8 @@ CanvasView::DisplayState CanvasView::displayState() const
.layers = {},
.folderMasks = {},
.textStyle = m_session.textDraft() ? std::optional(m_session.textDraft()->style) : std::nullopt,
.textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt};
.textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt,
.maskAlone = m_session.maskAloneLayer() ? std::optional(m_session.maskAloneLayer()->mask->asset.identity()) : std::nullopt};
if (!document)
return state;
// A hidden source still clips: live masks count every layer.
@@ -260,45 +261,12 @@ void CanvasView::draw(QPainter &context, const QRectF &dirty)
context.restore();
}
// The folder's live mask, multiplied into the coverage.
FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, double scale, const Center &center) const
{
return [&edit, scale, center](const QPainter &painter, QImage &coverage) {
QImage live(coverage.size(), QImage::Format_Grayscale8);
if (live.isNull())
throw ExportError(ExportError::Kind::render);
// Outside the mask's bounds stays hidden, as when committed.
live.fill(0);
LayerTransform transform = edit.paintTransform;
transform.sampling = LayerSampling::nearest;
const std::optional<ImportedImage> &base = edit.layer.mask ? std::optional(edit.layer.mask->asset) : std::nullopt;
{
QPainter drawing(&live);
drawing.setTransform(painter.deviceTransform());
// Swift's displayImage: halved near the size drawn.
const double device = LayerRenderer::deviceScale(drawing);
const auto shown = [device](const QImage &image, double width) {
return DownsampleCache::shared().imageDrawnAt(image, width * device / std::max(1, image.width()));
};
const std::shared_ptr<const RasterSnapshot> raster = base ? base->raster : nullptr;
const QImage image = base && !raster ? shown(base->image(), transform.size.width() * scale) : QImage();
const QImage rasterBase = raster && !raster->base.isNull()
? shown(raster->base, transform.size.width() * scale * raster->baseRect.width() / std::max(1, raster->width))
: QImage();
LayerRenderer::drawBrushPreview(image, transform, center(transform.center()), drawing, {.scale = scale},
{.patches = edit.patches(), .pixelWidth = edit.width, .pixelHeight = edit.height, .paintingMask = false,
.sourceRect = edit.sourceRect, .raster = raster, .rasterBase = rasterBase});
}
QPainter multiplying(&coverage);
if (!multiplying.isActive())
throw ExportError(ExportError::Kind::render);
multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn);
multiplying.drawImage(QRectF(coverage.rect()), BrushRaster::alphaView(live));
};
}
void CanvasView::drawLayers(const CanvasDocument &document, double scale, const Center &center, QPainter &context)
{
if (const std::optional<ImageLayer> alone = m_session.maskAloneLayer()) {
drawMaskAlone(*alone, document, scale, center, context);
return;
}
handOnDraftEffects(document);
m_session.effectsPreviews.prepare(document.layers);
std::map<QUuid, ImageLayer> byID;
@@ -430,6 +398,10 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); };
live.adjustmentScale = scale;
const QPointF corner = center(QPointF(0, 0));
live.adjustmentRegion = [corner, scale](const QRectF &rect) {
return QRectF((rect.left() - corner.x()) / scale, (rect.top() - corner.y()) / scale, rect.width() / scale, rect.height() / scale);
};
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
const ImageLayer &layer = byID.at(id);
if (!layer.mask || !layer.mask->isEnabled)
+4
View File
@@ -122,6 +122,8 @@ private:
// Typed text, which the canvas draws as pixels.
std::optional<LayerTextStyle> textStyle;
std::optional<LayerTransform> textTransform;
// The mask shown alone, though the composite may skip it.
std::optional<ImageIdentity> maskAlone;
friend bool operator==(const DisplayState &, const DisplayState &) = default;
};
// A Zoom press: drags zoom about it, clicks step.
@@ -146,6 +148,8 @@ private:
void drawStroke(const BrushStroke &stroke, const ImageLayer &layer, const LayerTransform &transform, const std::optional<QImage> &mask,
const LayerRenderer::Options &options, double scale, const Center &center, QPainter &target);
void drawLayers(const CanvasDocument &document, double scale, const Center &center, QPainter &context);
// Alt-click's view: the mask in gray, strokes included.
void drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center &center, QPainter &context);
// Typed text as pixels, where the editor shows it.
struct DraftText {
QImage image;
+3 -5
View File
@@ -20,8 +20,6 @@ QPainter::CompositionMode compositionMode(LayerBlendMode mode)
return QPainter::CompositionMode_Screen;
case LayerBlendMode::overlay:
return QPainter::CompositionMode_Overlay;
case LayerBlendMode::softLight:
return QPainter::CompositionMode_SoftLight;
case LayerBlendMode::darken:
return QPainter::CompositionMode_Darken;
case LayerBlendMode::lighten:
@@ -56,7 +54,7 @@ void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, Q
const double device = deviceScale(context);
const Reduced source = reduced(image, width, device, transform.sampling);
const InterpolationQuality quality = interpolation(
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level));
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level), transform.radians() == 0);
const QRectF bounds(-width / 2, -height / 2, width, height);
QTransform placement;
placement.translate(center.x(), center.y());
@@ -294,9 +292,9 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
context.restore();
}
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor)
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor, bool upright)
{
if (sampling == LayerSampling::nearest)
if (sampling == LayerSampling::nearest || (upright && std::abs(finalFactor - 1) < 0.001))
return InterpolationQuality::none;
return finalFactor <= 1 ? InterpolationQuality::low : quality(sampling);
}
+2 -1
View File
@@ -57,7 +57,8 @@ void drawBrushPreview(const QImage &image, const LayerTransform &transform, QPoi
void composite(QPainter &context, const QTransform &placement, const QRectF &extent, LayerSampling sampling,
InterpolationQuality quality, const Options &options, const QRectF &maskBounds,
const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {});
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor);
// An upright layer drawn pixel for pixel copies straight across.
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor, bool upright = false);
Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling);
QRectF coverage(const Reduced &reduced, const QRectF &bounds);
double deviceScale(const QPainter &context);
+4 -3
View File
@@ -208,8 +208,9 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
qCWarning(lcRendering) << "an adjustment passes its pixel budget:" << device->size();
return;
}
// The document the device shows, where Grain's pattern sits.
const QRectF region = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
// What the device shows; Grain and Noise keep the document.
const QRectF shown = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
const QRectF region = adjustmentRegion ? adjustmentRegion(shown) : shown;
try {
// The kernels' byte order, one image pixel a device pixel.
QImage original = device->convertToFormat(QImage::Format_RGBA8888_Premultiplied).copy();
@@ -217,7 +218,7 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
throw ExportError(ExportError::Kind::render);
original.setDevicePixelRatio(1);
// Device pixels a document pixel, where Swift's surface counts points.
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / region.width());
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / shown.width());
const auto found = m_modes.find(id);
const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal;
if (mode != LayerBlendMode::normal)
+2
View File
@@ -31,6 +31,8 @@ public:
std::function<void(QUuid, const QPainter &, QImage &coverage)> adjustmentClip = [](QUuid, const QPainter &, QImage &) {};
// Painter units a document pixel: the canvas's zoom.
double adjustmentScale = 1;
// The document a painter region shows: the canvas maps it.
std::function<QRectF(const QRectF &)> adjustmentRegion;
private:
// The pixels beneath, adjusted and mixed back through coverage.
+6
View File
@@ -1,5 +1,6 @@
#include "Rendering/SeparableBlend.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace {
@@ -22,6 +23,7 @@ float dodge(float backdrop, float source)
bool SeparableBlend::needsSurface(LayerBlendMode mode)
{
switch (mode) {
case LayerBlendMode::softLight:
case LayerBlendMode::linearBurn:
case LayerBlendMode::linearDodge:
case LayerBlendMode::vividLight:
@@ -39,6 +41,10 @@ bool SeparableBlend::needsSurface(LayerBlendMode mode)
float SeparableBlend::channel(LayerBlendMode mode, float backdrop, float source)
{
switch (mode) {
case LayerBlendMode::softLight:
// Photoshop's, as Core Image's: the PDF's strays 17 levels.
return source <= 0.5f ? 2 * backdrop * source + backdrop * backdrop * (1 - 2 * source)
: 2 * backdrop * (1 - source) + std::sqrt(backdrop) * (2 * source - 1);
case LayerBlendMode::linearBurn:
return std::max(0.0f, backdrop + source - 1);
case LayerBlendMode::linearDodge:
+1 -1
View File
@@ -1,7 +1,7 @@
#pragma once
#include "Document/LayerAppearance.h"
// Swift's SeparableBlend: channel-by-channel modes QPainter lacks.
// Swift's SeparableBlend: channel-by-channel modes QPainter lacks or differs on.
namespace SeparableBlend {
// Blended by hand against a surface, Swift's `needsSurface`.
bool needsSurface(LayerBlendMode mode);
+2 -1
View File
@@ -150,7 +150,8 @@ void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRe
return;
const double device = LayerRenderer::deviceScale(context);
const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth);
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level));
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
transform.radians() == 0);
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
+117
View File
@@ -0,0 +1,117 @@
#include "UI/CameraRawColorControls.h"
#include <algorithm>
#include <array>
#include <cmath>
// Swift's curve graph drags, picked once at the press.
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::parametricDrag(QPointF at, QSizeF size) const
{
const CameraRawCurveSettings curve = raw().curve;
const double tone = at.x() / std::max(size.width(), 1.0) * 100;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
// Along the bottom, the nearest divider, the first of equals.
if (at.y() > size.height() - 18) {
size_t nearest = 0;
for (size_t index = 1; index < splits.size(); ++index)
if (std::abs(splits[index] - tone) < std::abs(splits[nearest] - tone))
nearest = index;
return Drag{.kind = Drag::Kind::divider, .index = nearest, .from = at};
}
double CameraRawCurveSettings::*key = tone < splits[0] ? &CameraRawCurveSettings::shadows
: tone < splits[1] ? &CameraRawCurveSettings::darks
: tone < splits[2] ? &CameraRawCurveSettings::lights
: &CameraRawCurveSettings::highlights;
return Drag{.kind = Drag::Kind::region, .key = key, .start = curve.*key, .from = at};
}
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::pointDrag(QPointF at, QSizeF size)
{
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
std::vector<CurvePoint> points = currentPoints();
// On a point, that point; the first of equals.
std::optional<size_t> near;
for (size_t index = 0; index < points.size(); ++index)
if (!near || std::hypot(points[index].x - x, points[index].y - y) < std::hypot(points[*near].x - x, points[*near].y - y))
near = index;
if (near && std::hypot(points[*near].x - x, points[*near].y - y) < 0.055) {
m_selectedPoint = near;
return Drag{.kind = Drag::Kind::point, .index = *near, .from = at};
}
// Else a new point; the ends bound its x.
if (points.size() >= 16 || !std::all_of(points.begin(), points.end(), [x](const CurvePoint &each) { return std::abs(each.x - x) > 0.01; }))
return std::nullopt;
const CurvePoint point{x, y};
points.push_back(point);
std::stable_sort(points.begin(), points.end(), [](const CurvePoint &left, const CurvePoint &right) { return left.x < right.x; });
store(points);
const size_t index = size_t(std::find(points.begin(), points.end(), point) - points.begin());
m_selectedPoint = index;
return Drag{.kind = Drag::Kind::point, .index = index, .from = at};
}
void CameraRawCurveControls::beginDrag(QPointF at, QSizeF size)
{
// Qt may lose a release: every press picks afresh.
const bool parametric = !m_session.filterEdit() || m_session.filterEdit()->rawPanel.curvePage == CameraRawCurvePage::parametric;
m_drag = parametric ? parametricDrag(at, size) : pointDrag(at, size);
continueDrag(at, size);
}
void CameraRawCurveControls::continueDrag(QPointF at, QSizeF size)
{
if (!m_drag)
return;
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
const Drag drag = *m_drag;
switch (drag.kind) {
case Drag::Kind::point: {
std::vector<CurvePoint> points = currentPoints();
if (drag.index >= points.size())
return;
points[drag.index].y = y;
// Ends stay ends; the rest keep their order.
if (drag.index > 0 && drag.index < points.size() - 1) {
// Fix beyond Swift: just inside, so the repair keeps it.
const double inside = 1e-9;
points[drag.index].x = std::min(points[drag.index + 1].x - 0.01 - inside, std::max(points[drag.index - 1].x + 0.01 + inside, x));
}
store(points);
return;
}
case Drag::Kind::divider: {
const double value = x * 100;
update([&drag, value](CameraRawSettings &settings) {
// Below the next divider; normalizing does the rest.
CameraRawCurveSettings &curve = settings.curve;
if (drag.index == 0)
curve.shadowSplit = std::min(value, curve.darkSplit - 2);
else if (drag.index == 1)
curve.darkSplit = std::min(curve.lightSplit - 2, value);
else
curve.lightSplit = value;
});
return;
}
case Drag::Kind::region: {
const double amount = std::min(100.0, std::max(-100.0, drag.start - (at.y() - drag.from.y()) / std::max(size.height(), 1.0) * 200));
update([&drag, amount](CameraRawSettings &settings) { settings.curve.*drag.key = std::round(amount); });
return;
}
}
}
void CameraRawCurveControls::removePoint(QPointF at, QSizeF size)
{
std::vector<CurvePoint> points = currentPoints();
const double x = at.x() / size.width();
// The nearest inner point by x, within 0.04; ends stay.
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
if (!nearest || !(std::abs(points[*nearest].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*nearest));
m_selectedPoint.reset();
store(points);
}
+35 -82
View File
@@ -87,103 +87,45 @@ protected:
return;
}
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::white);
for (const CurvePoint &point : m_owner.currentPoints())
painter.drawEllipse(QRectF(point.x * w - 4, (1 - point.y) * h - 4, 8, 8));
const std::vector<CurvePoint> points = m_owner.currentPoints();
for (size_t index = 0; index < points.size(); ++index) {
painter.setBrush(m_owner.m_selectedPoint == index ? palette().color(QPalette::Highlight) : QColor(Qt::white));
painter.drawEllipse(QRectF(points[index].x * w - 4, (1 - points[index].y) * h - 4, 8, 8));
}
}
// Swift's two drags: still, a press adds; moved, it drags.
// Swift's one drag: picked at the press, kept until release.
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_press = event->position();
m_owner.beginDrag(event->position(), size());
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (!m_press || !event->buttons().testFlag(Qt::LeftButton))
return;
if (!m_moved && QLineF(*m_press, event->position()).length() < 2)
return;
m_moved = true;
if (parametric())
moveDivider(event->position().x());
else
movePoint(event->position());
if (event->buttons().testFlag(Qt::LeftButton))
m_owner.continueDrag(event->position(), size());
}
void mouseReleaseEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && m_press && !m_moved && event->position() == *m_press && !parametric())
addPoint(event->position());
m_press.reset();
m_moved = false;
if (event->button() == Qt::LeftButton)
m_owner.m_drag.reset();
}
// The double click's own release must add nothing back.
// The second press drags too; then the point goes.
void mouseDoubleClickEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && !parametric())
removePoint(event->position());
if (event->button() != Qt::LeftButton)
return;
m_owner.beginDrag(event->position(), size());
if (!parametric())
m_owner.removePoint(event->position(), size());
m_owner.m_drag.reset();
}
private:
void moveDivider(double x)
{
const double value = std::min(98.0, std::max(2.0, x / std::max(double(width()), 1.0) * 100));
const CameraRawCurveSettings curve = m_owner.raw().curve;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
size_t nearest = 0;
for (size_t index = 1; index < 3; ++index)
if (std::abs(splits[index] - value) < std::abs(splits[nearest] - value))
nearest = index;
m_owner.update([nearest, value](CameraRawSettings &settings) {
(nearest == 0 ? settings.curve.shadowSplit : nearest == 1 ? settings.curve.darkSplit : settings.curve.lightSplit) = value;
});
}
void addPoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
points.push_back({std::min(0.99, std::max(0.01, at.x() / width())), std::min(1.0, std::max(0.0, 1 - at.y() / height()))});
m_owner.store(points);
}
// The nearest inner point by x; ends stay.
std::optional<size_t> nearestInner(const std::vector<CurvePoint> &points, double x) const
{
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
return nearest;
}
void movePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index)
return;
points[*index] = {std::min(0.98, std::max(0.02, x)), std::min(1.0, std::max(0.0, 1 - at.y() / height()))};
m_owner.store(points);
}
void removePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index || !(std::abs(points[*index].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*index));
m_owner.store(points);
}
CameraRawCurveControls &m_owner;
std::optional<QPointF> m_press;
bool m_moved = false;
};
namespace {
@@ -250,6 +192,9 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
presetRow->addStretch(1);
points->addLayout(presetRow);
connect(m_preset, &QComboBox::activated, this, [this](int preset) {
// A fix beyond Swift: a preset's points drop the choice.
if (preset != 0)
m_selectedPoint.reset();
if (preset == 1)
store(CameraRawCurveSettings::linear());
else if (preset == 2)
@@ -258,7 +203,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
store(CameraRawCurveSettings::strongContrast());
});
m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"),
.help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88,
.help = QStringLiteral("How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color."), .titleWidth = 88,
.fixedTitle = true, .fieldWidth = 48, .scrub = 1},
[this] { return raw().curve.refineSaturation; },
[this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); },
@@ -334,15 +279,23 @@ void CameraRawCurveControls::synchronize()
m_channelBox->setVisible(point);
const CameraRawPointChannel channel = edit ? edit->rawPanel.pointChannel : CameraRawPointChannel::rgb;
m_channel->button(int(channel))->setChecked(true);
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click the curve to add one. Double-click a point to remove it.")
: QStringLiteral("Drag a divider to change which tones the neighboring sliders affect."));
// Swift's onChange: another channel drops the choice and drag.
if (channel != m_shownChannel) {
m_shownChannel = channel;
m_selectedPoint.reset();
m_drag.reset();
}
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click to add one. Double-click a point to remove it.")
: QStringLiteral("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
m_graph->update();
m_amounts->setVisible(!point);
m_points->setVisible(point);
const std::vector<CurvePoint> points = currentPoints();
// Swift's selectedPoint: the last inner point, else the last.
const CurvePoint selected = points.size() > 2 ? points[points.size() - 2] : points.back();
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(selected.x * 255)).arg(std::lround(selected.y * 255)));
// Swift's selectedPoint: the chosen index, while it exists.
const bool chosen = m_selectedPoint && *m_selectedPoint < points.size();
m_selected->setVisible(chosen);
if (chosen)
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(points[*m_selectedPoint].x * 255)).arg(std::lround(points[*m_selectedPoint].y * 255)));
const QSignalBlocker quiet(m_preset);
m_preset->setCurrentIndex(points == CameraRawCurveSettings::linear() ? 1
: points == CameraRawCurveSettings::mediumContrast() ? 2
+16
View File
@@ -28,9 +28,25 @@ private:
void update(const std::function<void(CameraRawSettings &)> &change);
void store(const std::vector<CurvePoint> &points);
void panel(const std::function<void(CameraRawPanel &)> &change);
// Swift's Drag: a point, divider, or region.
struct Drag {
enum class Kind { point, divider, region } kind;
size_t index = 0;
double CameraRawCurveSettings::*key = nullptr;
double start = 0;
QPointF from;
};
std::optional<Drag> parametricDrag(QPointF at, QSizeF size) const;
std::optional<Drag> pointDrag(QPointF at, QSizeF size);
void beginDrag(QPointF at, QSizeF size);
void continueDrag(QPointF at, QSizeF size);
void removePoint(QPointF at, QSizeF size);
friend class CameraRawCurveGraph;
EditorSession &m_session;
std::optional<Drag> m_drag;
std::optional<size_t> m_selectedPoint;
CameraRawPointChannel m_shownChannel = CameraRawPointChannel::rgb;
QButtonGroup *m_page;
QWidget *m_channelBox;
QButtonGroup *m_channel;
+2
View File
@@ -243,6 +243,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
});
layer->addSeparator();
add(layer, QStringLiteral("groupLayers"), QStringLiteral("Group Selected Layers"), QKeySequence(Qt::CTRL | Qt::Key_G), [this] { session().groupSelectedLayers(); });
add(layer, QStringLiteral("ungroupLayers"), QStringLiteral("Ungroup Layers"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_G), [this] { session().ungroupLayers(); });
add(layer, QStringLiteral("moveOutOfFolder"), QStringLiteral("Move Out of Folder"), QKeySequence(), [this] { session().moveActiveLayerOutOfGroup(); });
add(layer, QStringLiteral("newBlankLayer"), QStringLiteral("New Blank Layer"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N), [this] { session().addBlankLayer(); });
add(layer, QStringLiteral("renameLayer"), QStringLiteral("Rename Layer…"), QKeySequence(), [this] { session().setRenamingLayerID(session().activeLayerID()); });
@@ -395,6 +396,7 @@ void CompositorMenus::synchronize()
action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask"));
action(QStringLiteral("clippingMask"))->setEnabled(s.activeLayerID() && s.canToggleClippingMask(*s.activeLayerID()));
action(QStringLiteral("groupLayers"))->setEnabled(s.canEditLayers());
action(QStringLiteral("ungroupLayers"))->setEnabled(s.canUngroupLayers());
action(QStringLiteral("moveOutOfFolder"))->setEnabled(s.canEditLayers() && active && active->parentID);
action(QStringLiteral("newBlankLayer"))->setEnabled(s.canEditLayers());
action(QStringLiteral("renameLayer"))->setEnabled(s.canEditLayers() && active);
+17 -2
View File
@@ -48,10 +48,25 @@ void FilterSheet::dither()
});
control(QStringLiteral("Pixel Size"), key(&DitherSettings::pixelSize), DitherSettings::pixelSizeLow, DitherSettings::pixelSizeHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("Make each dithered pixel this many pixels across, for a chunky old-screen look"));
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style != DitherStyle::ascii; });
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return usesPixelSize(settings.dither.style); });
control(QStringLiteral("Text Size"), key(&DitherSettings::textSize), DitherSettings::textSizeLow, DitherSettings::textSizeHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("The height of each line of characters"));
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; });
// Scanlines' four rows.
const auto scanlines = [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::scanlines; };
control(QStringLiteral("Line Spacing"), key(&DitherSettings::lineSpacing), DitherSettings::lineSpacingLow, DitherSettings::lineSpacingHigh, QStringLiteral("px"), 0,
false);
box()->setToolTip(QStringLiteral("How far apart the screen's lines are"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Glow"), key(&DitherSettings::glow), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Light blooming around the lines, like a CRT's phosphors"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Dots"), key(&DitherSettings::dots), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Break the lines into glowing beads"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Wobble"), key(&DitherSettings::wobble), DitherSettings::wobbleLow, DitherSettings::wobbleHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("Make the lines waver sideways down the screen, like a CRT losing sync"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Cell Size"), key(&DitherSettings::cellSize), DitherSettings::cellSizeLow, DitherSettings::cellSizeHigh, QStringLiteral("px"), 0, false);
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); });
control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false);
@@ -133,7 +148,7 @@ void FilterSheet::dither()
const QSignalBlocker quiet(shape);
shape->setCurrentIndex(int(settings.dither.pixelShape));
});
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && settings.dither.style != DitherStyle::ascii; });
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && usesPixelSize(settings.dither.style); });
flag(QStringLiteral("Light on Dark"), QStringLiteral("Draw the marks for the light tones on the dark color, like a glowing screen"),
[](FilterSettings &settings) -> bool & { return settings.dither.lightOnDark; });
m_shownWhen.emplace_back(m_flags.back().first, [](const FilterSettings &settings) { return drawsMarks(settings.dither.style); });
+1 -1
View File
@@ -134,7 +134,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
entry("Levels", "l", 1, true), entry("Hue/Saturation", "u", 1, true), entry("Invert Pixels / Mask", "i", 1, true),
entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true),
entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true),
entry("Group Layers", "g", 1, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
entry("Group Layers", "g", 1, true), entry("Ungroup Layers", "g", 9, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true), entry("Show Grid", "'", 1, true),
entry("Show Guides", ";", 1, true), entry("Show Rulers", "r", 1, true), entry("Snap", ";", 9, true), entry("Lock Guides", ";", 3, true)};
const std::vector<std::pair<const char *, QString>> tools{
+6
View File
@@ -313,6 +313,12 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
case LayerIcon::dottedCircle: dottedCircle(painter); break;
case LayerIcon::plusMagnifyingGlass: magnifyingGlass(painter, true); break;
case LayerIcon::minusMagnifyingGlass: magnifyingGlass(painter, false); break;
// Swift's xmark at bold weight.
case LayerIcon::xmark:
painter.setPen(QPen(colour, 2.6, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(QPointF(4, 4), QPointF(14, 14));
painter.drawLine(QPointF(14, 4), QPointF(4, 14));
break;
}
painter.restore();
}
+1 -1
View File
@@ -8,7 +8,7 @@
enum class LayerIcon {
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes,
scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass
scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass, xmark
};
namespace LayerIcons {
+58 -2
View File
@@ -1,6 +1,9 @@
#include "UI/LayerMaskMenu.h"
#include "UI/LayerIcons.h"
#include <QEvent>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QPainter>
LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolButton(parent), m_session(session)
{
@@ -8,7 +11,8 @@ LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolBut
setAccessibleName(QStringLiteral("Add layer mask"));
setAutoRaise(true);
setFixedSize(32, 40);
connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(); });
// Swift reads Option from the event that clicked.
connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(!QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier)); });
connect(&m_session, &EditorSession::changed, this, &LayerMaskMenu::synchronize);
synchronize();
}
@@ -17,7 +21,8 @@ void LayerMaskMenu::synchronize()
{
const std::optional<ImageLayer> active = m_session.activeLayer();
setEnabled(m_session.canEditMask() && !(active && active->mask));
setToolTip(m_session.selection() ? QStringLiteral("Add layer mask (the selection becomes black)") : QStringLiteral("Add layer mask"));
setToolTip(m_session.selection() ? QStringLiteral("Add layer mask revealing the selection (Alt-click to hide it)")
: QStringLiteral("Add layer mask (Alt-click for a black mask)"));
setIcon(LayerIcons::pixmap(LayerIcon::addMask, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio()));
}
@@ -27,3 +32,54 @@ void LayerMaskMenu::changeEvent(QEvent *event)
if (event->type() == QEvent::PaletteChange)
synchronize();
}
MaskAloneBadge::MaskAloneBadge(EditorSession &session, QWidget *parent) : QWidget(parent), m_name(new QLabel(this))
{
setObjectName(QStringLiteral("maskAloneBadge"));
setFixedHeight(26);
const auto font = [](QLabel *label, bool semibold, double alpha) {
QFont face = label->font();
face.setPixelSize(12);
face.setWeight(semibold ? QFont::DemiBold : QFont::Normal);
label->setFont(face);
QPalette colours = label->palette();
colours.setColor(QPalette::WindowText, QColor::fromRgbF(1, 1, 1, alpha));
label->setPalette(colours);
};
auto *icon = new QLabel(this);
icon->setPixmap(LayerIcons::pixmap(LayerIcon::addMask, 11, Qt::white, devicePixelRatioF()));
auto *title = new QLabel(QStringLiteral("Layer Mask"), this);
title->setObjectName(QStringLiteral("maskAloneTitle"));
font(title, true, 1);
m_name->setObjectName(QStringLiteral("maskAloneName"));
m_name->setTextFormat(Qt::PlainText);
m_name->setMaximumWidth(220);
font(m_name, false, 0.6);
auto *close = new QToolButton(this);
close->setObjectName(QStringLiteral("maskAloneClose"));
close->setAutoRaise(true);
close->setIcon(LayerIcons::pixmap(LayerIcon::xmark, 9, Qt::white, devicePixelRatioF()));
close->setToolTip(QStringLiteral("Show the image again (or Alt-click the mask thumbnail)"));
close->setAccessibleName(QStringLiteral("Stop viewing the mask"));
connect(close, &QToolButton::clicked, this, [&session] { session.setViewsMaskAlone(false); });
auto *row = new QHBoxLayout(this);
row->setContentsMargins(11, 0, 8, 0);
row->setSpacing(7);
for (QWidget *widget : std::initializer_list<QWidget *>{icon, title, m_name, close})
row->addWidget(widget);
}
// Swift's byTruncatingTail within 220 points.
void MaskAloneBadge::setLayerName(const QString &name)
{
m_name->setText(m_name->fontMetrics().elidedText(name, Qt::ElideRight, 220));
}
void MaskAloneBadge::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.14), 1));
painter.setBrush(QColor::fromRgbF(0, 0, 0, 0.75));
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 13, 13);
}
+16 -1
View File
@@ -1,8 +1,9 @@
#pragma once
#include "Document/EditorSession.h"
#include <QLabel>
#include <QToolButton>
// One click adds a mask: white, or the selection black.
// One click adds a mask revealing the selection; Alt, hiding.
class LayerMaskMenu : public QToolButton {
Q_OBJECT
public:
@@ -16,3 +17,17 @@ private:
EditorSession &m_session;
};
// While a mask shows alone: whose, and a way back.
class MaskAloneBadge : public QWidget {
Q_OBJECT
public:
explicit MaskAloneBadge(EditorSession &session, QWidget *parent = nullptr);
void setLayerName(const QString &name);
protected:
void paintEvent(QPaintEvent *event) override;
private:
QLabel *const m_name;
};
+6 -8
View File
@@ -70,9 +70,10 @@ LayerThumbnailButton::LayerThumbnailButton(NativeLayerList &list, bool maskTarge
setAutoRaise(true);
}
void LayerThumbnailButton::setTargeted(bool targeted)
void LayerThumbnailButton::setTargeted(bool targeted, bool alone)
{
m_targeted = targeted;
m_alone = alone;
QWidget::update();
}
@@ -155,12 +156,9 @@ void LayerThumbnailButton::mouseReleaseEvent(QMouseEvent *event)
if (!m_altMaskPress || event->button() != Qt::LeftButton)
return;
endAltMaskPress();
// Released where pressed: a click, not a drag; Shift toggles.
if (!rect().contains(event->position().toPoint()))
return;
m_list.session().selectLayerTarget(layerID, true);
if (event->modifiers().testFlag(Qt::ShiftModifier))
m_list.session().toggleLayerMask();
// Released where pressed: the mask alone, or the image again.
if (rect().contains(event->position().toPoint()))
m_list.session().toggleMaskAlone(layerID);
}
// With a modifier the second press is the button's again.
@@ -179,7 +177,7 @@ void LayerThumbnailButton::paintEvent(QPaintEvent *)
if (!m_targeted)
return;
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(palette().color(QPalette::Highlight), 2));
painter.setPen(QPen(m_alone ? QColor(Qt::white) : palette().color(QPalette::Highlight), 2));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 3, 3);
}
+2 -2
View File
@@ -31,7 +31,7 @@ LayerCell::LayerCell(NativeLayerList &list)
connect(m_disclosure, &QToolButton::clicked, this, [this] { m_list.session().toggleGroupExpansion(m_layerID); });
m_thumbnail->setObjectName(QStringLiteral("layerThumbnail"));
m_maskThumbnail->setObjectName(QStringLiteral("maskThumbnail"));
m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
m_link->setObjectName(QStringLiteral("maskLink"));
m_link->setAutoRaise(true);
m_link->setFixedSize(9, 20);
@@ -159,7 +159,7 @@ void LayerCell::updateTarget()
const bool active = session.activeLayerID() == m_layerID && session.selectedLayerIDs().size() == 1;
const bool mask = session.isMaskSelected();
m_thumbnail->setTargeted(active && !mask);
m_maskThumbnail->setTargeted(active && mask);
m_maskThumbnail->setTargeted(active && mask, active && session.maskAloneLayer());
QWidget::update();
}
+34 -2
View File
@@ -13,6 +13,7 @@
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <cmath>
const QString NativeLayerList::maskType = QStringLiteral("com.compositor.layer-mask");
const QString NativeLayerList::sourceType = QStringLiteral("com.compositor.layer-list");
@@ -299,6 +300,37 @@ QCursor NativeLayerList::clippingCursor(bool releasing, double ratio)
return QCursor(pixmap, 3, 3);
}
// Swift's eye.fill: an almond, a ring round the pupil.
QCursor NativeLayerList::showMaskCursor(double ratio)
{
const QCursor base = CanvasView::duplicateCursor(ratio);
const QPointF hotSpot = base.hotSpot();
const QRectF eye(hotSpot + QPointF(15, 18), QSizeF(7.5, 5.5));
const QSizeF baseSize = base.pixmap().deviceIndependentSize();
const QSize size(int(std::ceil(std::max(baseSize.width(), eye.right() + 2))), int(std::ceil(std::max(baseSize.height(), eye.bottom() + 2))));
QPixmap pixmap(size * ratio);
pixmap.setDevicePixelRatio(ratio);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
QPainterPath shape;
shape.setFillRule(Qt::OddEvenFill);
shape.moveTo(eye.left(), eye.center().y());
shape.quadTo(eye.center().x(), eye.top() - eye.height() / 2, eye.right(), eye.center().y());
shape.quadTo(eye.center().x(), eye.bottom() + eye.height() / 2, eye.left(), eye.center().y());
shape.addEllipse(eye.center(), 1.9, 1.9);
shape.addEllipse(eye.center(), 1.1, 1.1);
// The eye first, haloed, so the arrows sit in front.
for (int step = 0; step < 16; ++step) {
const double angle = step * M_PI / 8;
painter.fillPath(shape.translated(std::cos(angle), std::sin(angle)), Qt::white);
}
painter.fillPath(shape, Qt::black);
painter.drawPixmap(QPointF(0, 0), base.pixmap());
painter.end();
return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y()));
}
QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const
{
const int row = rowAt(listPoint);
@@ -317,9 +349,9 @@ QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modif
return QCursor(Qt::ArrowCursor);
}
const bool editable = m_session.canEditLayers();
// A mask copies, the row duplicates, the strip clips.
// A mask shows alone, the row duplicates, the strip clips.
if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell))
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
return showMaskCursor(ratio);
if (!isClippingZone(cell, inCell))
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
if (!m_session.canToggleClippingMask(layer.id))
+4
View File
@@ -1,5 +1,6 @@
#include "UI/NativeLayerList.h"
#include <QMenu>
#include <algorithm>
// Swift's table-level context menu (1.2.5): entries and validation.
std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id)
@@ -40,6 +41,9 @@ std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id)
entry(*menu, "groupLayers", QStringLiteral("Group Selected Layers"),
session.canEditLayers() && session.document() && session.document()->layers.size() < 10'000 && !session.selectedLayerIDs().isEmpty(),
[&session] { session.groupSelectedLayers(); });
// A folder right-clicked can be ungrouped.
if (std::ranges::any_of(session.document()->layers, [id](const ImageLayer &layer) { return layer.id == id && layer.isGroup; }))
entry(*menu, "ungroupLayers", QStringLiteral("Ungroup Layers"), session.canUngroupLayers(), [&session] { session.ungroupLayers(); });
entry(*menu, "moveOut", QStringLiteral("Move Out of Folder"), session.canEditLayers() && active && active->parentID,
[&session] { session.moveActiveLayerOutOfGroup(); });
entry(*menu, "mergeLayers", session.mergeTitle(), session.canMergeLayers(), [&session] { session.mergeLayers(); });
+5 -2
View File
@@ -43,8 +43,8 @@ public:
LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent);
QUuid layerID;
const bool isMaskTarget;
// An accent ring while the target is selected.
void setTargeted(bool targeted);
// An accent ring while targeted; white while shown alone.
void setTargeted(bool targeted, bool alone = false);
bool isTargeted() const { return m_targeted; }
~LayerThumbnailButton() override;
@@ -63,6 +63,7 @@ private:
NativeLayerList &m_list;
bool m_targeted = false;
bool m_alone = false;
// Alt on a mask: chosen on release, or dragged.
std::optional<QPoint> m_altMaskPress;
};
@@ -240,6 +241,8 @@ public:
// The cursor Alt asks for here: clip, duplicate, arrow.
QCursor cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const;
static QCursor clippingCursor(bool releasing, double ratio);
// Alt over a mask: the duplicate pointer, an eye behind.
static QCursor showMaskCursor(double ratio);
protected:
bool event(QEvent *event) override;
+95 -4
View File
@@ -5,9 +5,13 @@
#include <QBuffer>
#include <QClipboard>
#include <QGridLayout>
#include <QActionGroup>
#include <QImageReader>
#include <QMenu>
#include <QPainter>
#include <QShortcut>
#include <QVBoxLayout>
#include <algorithm>
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
@@ -20,12 +24,58 @@ QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::C
label->setForegroundRole(role);
return label;
}
// Swift's More button: three dots drawn exactly, flush right.
class MoreButton : public QToolButton {
public:
using QToolButton::QToolButton;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(palette().color(QPalette::WindowText));
const double dot = 2.5, top = (height() - 5 * dot) / 2;
for (int index = 0; index < 3; ++index)
painter.drawEllipse(QRectF(width() - dot, top + 2 * dot * index, dot, dot));
}
};
}
const std::vector<std::vector<CanvasPreset>> CanvasPreset::groups = {
{
{QStringLiteral("4K"), 3840, 2160},
{QStringLiteral("1440p"), 2560, 1440},
{QStringLiteral("1080p"), 1920, 1080},
},
{
{QStringLiteral("iPhone 18 Pro"), 1206, 2622},
{QStringLiteral("iPhone 18 Pro Max"), 1320, 2868},
{QStringLiteral("MacBook Pro 14\""), 3024, 1964},
{QStringLiteral("MacBook Pro 16\""), 3456, 2234},
{QStringLiteral("Studio Display"), 5120, 2880},
},
{
{QStringLiteral("Instagram Square"), 1080, 1080},
{QStringLiteral("Instagram Portrait"), 1080, 1350},
{QStringLiteral("Instagram Story"), 1080, 1920},
{QStringLiteral("YouTube Thumb"), 1080, 608},
},
};
const std::vector<CanvasPreset> CanvasPreset::all = [] {
std::vector<CanvasPreset> presets;
for (const std::vector<CanvasPreset> &group : groups)
presets.insert(presets.end(), group.begin(), group.end());
return presets;
}();
NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent)
: QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)),
m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
m_create(new QPushButton(QStringLiteral("Create canvas"), this))
m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this))
{
// Swift's fields stretch the sheet to its full 500 points.
setFixedWidth(500);
@@ -62,17 +112,46 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
auto *column = new QVBoxLayout(this);
column->setContentsMargins(28, 28, 28, 28);
column->setSpacing(24);
auto *heading = new QVBoxLayout;
heading->setSpacing(6);
auto *heading = new QHBoxLayout;
heading->addWidget(text(QStringLiteral("New canvas"), 17, QFont::DemiBold, QPalette::WindowText, this));
heading->addWidget(text(QStringLiteral("A blank space for your next composition."), 13, QFont::Normal, QPalette::PlaceholderText, this));
heading->addStretch(1);
heading->addWidget(m_presets);
column->addLayout(heading);
column->addLayout(fields);
column->addWidget(m_note);
column->addLayout(buttons);
m_presets->setObjectName(QStringLiteral("presetSizes"));
m_presets->setFixedSize(28, 28);
m_presets->setPopupMode(QToolButton::InstantPopup);
m_presets->setToolTip(QStringLiteral("Preset sizes for screens and common formats"));
m_presets->setAccessibleName(QStringLiteral("Preset sizes"));
auto *menu = new QMenu(m_presets);
auto *choices = new QActionGroup(menu);
// Custom changes nothing; the match stays checked.
QAction *custom = choices->addAction(menu->addAction(QStringLiteral("Custom")));
custom->setCheckable(true);
custom->setData(-1);
connect(custom, &QAction::triggered, this, &NewCanvasSheet::checkPreset);
int index = 0;
for (const std::vector<CanvasPreset> &group : CanvasPreset::groups) {
menu->addSeparator();
for (const CanvasPreset &preset : group) {
QAction *action = choices->addAction(menu->addAction(preset.title));
action->setCheckable(true);
action->setData(index++);
connect(action, &QAction::triggered, this, [this, preset] {
m_width->setText(QString::number(preset.width));
m_height->setText(QString::number(preset.height));
});
}
}
m_presets->setMenu(menu);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
// Return anywhere on the sheet is the default action.
std::vector<QShortcut *> returns;
for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter})
@@ -90,6 +169,18 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
connect(&m_session, &EditorSession::changed, this, &NewCanvasSheet::synchronize);
synchronize();
validate();
checkPreset();
}
void NewCanvasSheet::checkPreset()
{
const auto matched = std::ranges::find_if(CanvasPreset::all, [this](const CanvasPreset &preset) {
return QString::number(preset.width) == m_width->text() && QString::number(preset.height) == m_height->text();
});
const QVariant index(matched == CanvasPreset::all.end() ? -1 : int(matched - CanvasPreset::all.begin()));
for (QAction *action : m_presets->menu()->actions())
if (action->data() == index)
action->setChecked(true);
}
void NewCanvasSheet::synchronize()
+15
View File
@@ -4,8 +4,20 @@
#include <QLineEdit>
#include <QMimeData>
#include <QPushButton>
#include <QToolButton>
#include <QWidget>
#include <functional>
#include <vector>
// New Canvas sizes in pixels, upright as held.
struct CanvasPreset {
QString title;
int width;
int height;
// Resolutions, Apple screens, then social formats; the menu divides them.
static const std::vector<std::vector<CanvasPreset>> groups;
static const std::vector<CanvasPreset> all;
};
// The welcome: a new canvas, a project or an image.
class NewCanvasSheet : public QWidget {
@@ -23,6 +35,8 @@ private:
void synchronize();
void validate();
void create();
// Checks the preset the fields match, else Custom.
void checkPreset();
EditorSession &m_session;
const std::function<void(int, int)> m_onCreate;
@@ -30,5 +44,6 @@ private:
QLineEdit *const m_height;
QLabel *const m_note;
QPushButton *const m_create;
QToolButton *const m_presets;
bool m_suggestedClipboardSize = false;
};
+58
View File
@@ -0,0 +1,58 @@
#include "UI/ProjectTabLayout.h"
#include <algorithm>
double ProjectTabOverflow::contentWidth() const
{
// A pill always has a tab after it.
return visible.empty() ? 0 : visible.back().x + visible.back().width;
}
QString projectTabOverflowLabel(int hiddenCount)
{
return hiddenCount == 1 ? QStringLiteral("1 more tab") : QStringLiteral("%1 more tabs").arg(hiddenCount);
}
ProjectTabOverflow projectTabOverflow(const std::vector<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
double availableWidth, const std::function<double(int)> &pillWidth)
{
if (order.empty())
return {};
const auto span = [&](auto first, auto last) {
double total = 0;
for (auto id = first; id != last; ++id)
total += widths.at(*id);
// Never asked of no tabs.
return total + projectTabSpacing * double(std::distance(first, last) - 1);
};
const auto place = [&](const std::vector<QUuid> &ids, double x) {
std::vector<ProjectTabSlot> placed;
for (const QUuid &id : ids) {
placed.push_back({id, x, widths.at(id)});
x += widths.at(id) + projectTabSpacing;
}
return placed;
};
// Not yet measured, or everything fits: no pill.
if (availableWidth <= 0 || span(order.begin(), order.end()) <= availableWidth)
return {place(order, 0), {}, std::nullopt};
qsizetype shown = qsizetype(order.size());
while (shown > 1) {
const qsizetype hidden = qsizetype(order.size()) - shown;
if (pillWidth(int(hidden)) + projectTabSpacing + span(order.end() - shown, order.end()) <= availableWidth)
break;
shown -= 1;
}
std::vector<QUuid> visibleIDs(order.end() - shown, order.end()), hiddenIDs(order.begin(), order.end() - shown);
if (const auto selected = std::ranges::find(hiddenIDs, selectedID); selected != hiddenIDs.end()) {
hiddenIDs.erase(selected);
hiddenIDs.push_back(visibleIDs[0]);
visibleIDs[0] = selectedID;
// A wider selected tab: the tabs after it make room.
while (visibleIDs.size() > 1 && pillWidth(int(hiddenIDs.size())) + projectTabSpacing + span(visibleIDs.begin(), visibleIDs.end()) > availableWidth) {
hiddenIDs.push_back(visibleIDs[1]);
visibleIDs.erase(visibleIDs.begin() + 1);
}
}
const double pill = pillWidth(int(hiddenIDs.size()));
return {place(visibleIDs, pill + projectTabSpacing), hiddenIDs, ProjectTabPillSlot{0, pill}};
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <QString>
#include <QUuid>
#include <functional>
#include <map>
#include <optional>
#include <vector>
// Between pills, and after the overflow pill.
inline constexpr double projectTabSpacing = 6;
// A tab's place in the strip.
struct ProjectTabSlot {
QUuid id;
double x;
double width;
bool operator==(const ProjectTabSlot &) const = default;
};
// The overflow pill's place: it is no tab.
struct ProjectTabPillSlot {
double x;
double width;
bool operator==(const ProjectTabPillSlot &) const = default;
};
// What the strip draws: tabs that fit, the rest.
struct ProjectTabOverflow {
std::vector<ProjectTabSlot> visible;
std::vector<QUuid> hiddenIDs;
std::optional<ProjectTabPillSlot> pill;
double contentWidth() const;
bool operator==(const ProjectTabOverflow &) const = default;
};
// "N more tabs", singular for one.
QString projectTabOverflowLabel(int hiddenCount);
// Drops tabs from the front until the rest fit.
ProjectTabOverflow projectTabOverflow(const std::vector<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
double availableWidth, const std::function<double(int)> &pillWidth);
+208
View File
@@ -0,0 +1,208 @@
#include "UI/ProjectTabs.h"
#include <QPropertyAnimation>
#include <algorithm>
#include <cmath>
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
: QWidget(parent), m_workspace(workspace), m_pill(new OverflowTabsPill(workspace, this))
{
setObjectName(QStringLiteral("projectTabs"));
setAccessibleName(QStringLiteral("Project tabs"));
setFixedHeight(34);
// The toolbar's free width; the toolbar shows through.
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize);
synchronize();
}
// Every tab in a row, as if unmeasured.
QSize ProjectTabStrip::sizeHint() const
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
return {int(std::ceil(projectTabOverflow(order, widths(), m_workspace.selectedID(), 0, &OverflowTabsPill::pillWidth).contentWidth())), 34};
}
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
{
return QList<ProjectTabButton *>(m_buttons.begin(), m_buttons.end());
}
void ProjectTabStrip::synchronize()
{
// Buttons follow the tabs: kept where the tab stays.
std::vector<ProjectTabButton *> kept;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) {
const auto found = std::ranges::find(m_buttons, tab, &ProjectTabButton::tab);
ProjectTabButton *button = nullptr;
if (found == m_buttons.end()) {
const QUuid id = tab->id;
button = new ProjectTabButton(m_workspace, tab, [this, id](TabDragPhase phase, double translation) { handleReorder(id, phase, translation); }, this);
// A title or dot changes the widths.
connect(&tab->session, &EditorSession::changed, button, [this] { layOut(); });
} else {
button = *found;
m_buttons.erase(found);
}
button->synchronize();
kept.push_back(button);
}
for (ProjectTabButton *gone : m_buttons)
delete gone;
m_buttons = std::move(kept);
// A drag whose tab closed under it is over.
if (m_reorder && !m_workspace.tab(m_reorder->id))
m_reorder.reset();
layOut();
}
std::map<QUuid, double> ProjectTabStrip::widths() const
{
std::map<QUuid, double> result;
for (const ProjectTabButton *button : m_buttons)
result.insert({button->tab->id, button->sizeHint().width()});
return result;
}
ProjectTabOverflow ProjectTabStrip::overflow() const
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
return projectTabOverflow(order, widths(), m_workspace.selectedID(), width(), &OverflowTabsPill::pillWidth);
}
void ProjectTabStrip::layOut(bool animated)
{
const ProjectTabOverflow layout = overflow();
m_pill->setVisible(layout.pill.has_value());
if (layout.pill) {
m_pill->setHiddenIDs(layout.hiddenIDs);
m_pill->setGeometry(int(layout.pill->x), 3, int(layout.pill->width), 28);
}
for (ProjectTabButton *button : m_buttons) {
const auto slot = std::ranges::find(layout.visible, button->tab->id, &ProjectTabSlot::id);
button->setVisible(slot != layout.visible.end());
if (slot == layout.visible.end())
continue;
button->resize(int(slot->width), 28);
const QPoint target(int(std::round(renderX(*slot, layout.contentWidth()))), 3);
// The dragged tab follows the pointer; the others ease aside.
auto *slide = button->findChild<QPropertyAnimation *>(QString(), Qt::FindDirectChildrenOnly);
const bool eases = animated && !(m_reorder && m_reorder->id == button->tab->id);
if (slide && (!eases || slide->endValue() != QVariant(target)))
delete std::exchange(slide, nullptr);
if (!eases)
button->move(target);
else if (!slide && button->pos() != target) {
slide = new QPropertyAnimation(button, "pos", button);
slide->setDuration(150);
slide->setEasingCurve(QEasingCurve::OutQuad);
slide->setEndValue(target);
slide->start(QAbstractAnimation::DeleteWhenStopped);
}
}
updateGeometry();
}
double ProjectTabStrip::renderX(const ProjectTabSlot &slot, double contentWidth) const
{
if (!m_reorder)
return slot.x;
if (slot.id == m_reorder->id)
return std::min(std::max(m_reorder->originX + m_reorder->translation, m_reorder->startX), std::max(m_reorder->startX, contentWidth - slot.width));
const auto index = std::ranges::find(m_reorder->others, slot.id);
if (index == m_reorder->others.end())
return slot.x;
const double x = m_reorder->compactedX.at(slot.id);
return index - m_reorder->others.begin() >= m_reorder->targetIndex ? x + m_reorder->widths.at(m_reorder->id) + projectTabSpacing : x;
}
// The gap nearest the dragged tab: where it lands.
int ProjectTabStrip::Reorder::nearestSlot() const
{
const double x = originX + translation;
std::vector<double> gaps;
for (const QUuid &other : others)
gaps.push_back(compactedX.at(other));
gaps.push_back(others.empty() ? startX : compactedX.at(others.back()) + widths.at(others.back()) + projectTabSpacing);
return int(std::ranges::min_element(gaps, {}, [x](double slot) { return std::abs(slot - x); }) - gaps.begin());
}
void ProjectTabStrip::handleReorder(QUuid id, TabDragPhase phase, double translation)
{
if (phase == TabDragPhase::changed) {
// Another tab's drag lost its release, as Swift's timer finds.
if (m_reorder && m_reorder->id != id)
m_reorder.reset();
if (!m_reorder) {
if (!m_workspace.canSwitch() || std::abs(translation) < 3)
return;
// Dragging a tab selects it, as in Safari.
m_workspace.select(id);
m_reorder = makeReorder(id);
// Above the tabs it crosses.
for (ProjectTabButton *button : m_buttons) {
if (button->tab->id == id)
button->raise();
}
}
m_reorder->translation = translation;
m_reorder->targetIndex = m_reorder->nearestSlot();
layOut(true);
return;
}
// Busy by the drop: the tabs go back.
if (m_reorder && m_reorder->id == id && m_workspace.canSwitch())
commitReorder(*m_reorder);
m_reorder.reset();
layOut(true);
}
ProjectTabStrip::Reorder ProjectTabStrip::makeReorder(QUuid id) const
{
const ProjectTabOverflow layout = overflow();
Reorder state{.id = id, .others = {}, .widths = widths(), .compactedX = {}, .startX = layout.visible.empty() ? 0 : layout.visible.front().x,
.originX = 0};
double x = state.startX;
state.originX = state.startX;
for (const ProjectTabSlot &slot : layout.visible) {
if (slot.id == id) {
state.originX = slot.x;
continue;
}
state.others.push_back(slot.id);
state.compactedX.insert({slot.id, x});
x += state.widths.at(slot.id) + projectTabSpacing;
}
return state;
}
// Only the tabs visible as the drag began ever move.
void ProjectTabStrip::commitReorder(const Reorder &state)
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
// A tab closed mid-drag has no place.
const auto position = [&order](QUuid id) -> std::optional<qsizetype> {
const auto found = std::ranges::find(order, id);
return found == order.end() ? std::nullopt : std::optional(found - order.begin());
};
const qsizetype from = position(state.id).value();
qsizetype target = from;
if (const auto neighbor = state.targetIndex < int(state.others.size()) ? position(state.others[size_t(state.targetIndex)]) : std::nullopt)
target = *neighbor;
else if (const auto last = state.others.empty() ? std::nullopt : position(state.others.back()))
target = *last + 1;
if (from < target)
target -= 1;
m_workspace.moveTab(state.id, int(target));
}
void ProjectTabStrip::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
layOut();
}
+87 -71
View File
@@ -6,11 +6,12 @@
#include <QEvent>
#include <QFontMetricsF>
#include <QHBoxLayout>
#include <QMenu>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QPainterPath>
#include <QStyleOptionToolButton>
#include <QStylePainter>
#include <QTimer>
namespace {
QFont tabFont(bool active)
@@ -47,8 +48,10 @@ int labelWidth(const QString &title, bool active, bool modified)
}
}
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent)
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new TabTitle(this)), m_close(new QToolButton(this))
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
QWidget *parent)
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_onReorder(std::move(onReorder)), m_select(new TabTitle(this)),
m_close(new QToolButton(this))
{
// 28 high: the strip's 34 less its margins.
m_select->setObjectName(QStringLiteral("selectTab"));
@@ -65,6 +68,7 @@ ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<
row->addWidget(m_select);
row->addWidget(m_close);
setAcceptDrops(true);
m_select->installEventFilter(this);
connect(m_select, &QToolButton::clicked, this, [this] { m_workspace.select(this->tab->id); });
connect(m_close, &QToolButton::clicked, this, [this] { m_workspace.close(this->tab->id); });
// The strip syncs every button on the workspace's news.
@@ -93,6 +97,26 @@ void ProjectTabButton::synchronize()
setProperty("active", active);
}
// A press moved three points reorders; measured in the window.
bool ProjectTabButton::eventFilter(QObject *watched, QEvent *event)
{
const auto *mouse = static_cast<QMouseEvent *>(event);
if (event->type() == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton) {
m_press = mouse->globalPosition();
m_dragging = false;
} else if (event->type() == QEvent::MouseMove && m_press && mouse->buttons().testFlag(Qt::LeftButton)) {
m_dragging = m_dragging || QLineF(*m_press, mouse->globalPosition()).length() >= 3;
if (m_dragging)
m_onReorder(TabDragPhase::changed, mouse->globalPosition().x() - m_press->x());
} else if (event->type() == QEvent::MouseButtonRelease && mouse->button() == Qt::LeftButton && m_press) {
const double translation = mouse->globalPosition().x() - m_press->x();
m_press.reset();
if (std::exchange(m_dragging, false))
m_onReorder(TabDragPhase::ended, translation);
}
return QWidget::eventFilter(watched, event);
}
// Another tab's layer, unless Alt duplicates it, or files.
bool ProjectTabButton::acceptsDrop(const QMimeData &data) const
{
@@ -237,82 +261,74 @@ void NewCanvasButton::dropEvent(QDropEvent *event)
m_workspace.receiveProviders(*event->mimeData(), std::nullopt);
}
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
: QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this))
OverflowTabsPill::OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace)
{
setObjectName(QStringLiteral("projectTabs"));
setAccessibleName(QStringLiteral("Project tabs"));
setFixedHeight(34);
setFrameShape(QFrame::NoFrame);
// The toolbar shows through; a stylesheet would freeze the palette.
setAutoFillBackground(false);
viewport()->setAutoFillBackground(false);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto *row = new QHBoxLayout(m_row);
row->setContentsMargins(0, 3, 0, 3);
row->setSpacing(6);
row->addStretch(1);
setWidget(m_row);
// setWidget turns the row's fill on; the toolbar shows through.
m_row->setAutoFillBackground(false);
m_row->installEventFilter(this);
connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize);
synchronize();
setObjectName(QStringLiteral("projectTabsOverflow"));
setFocusPolicy(Qt::NoFocus);
QFont font;
font.setPixelSize(12);
font.setWeight(QFont::Medium);
setFont(font);
connect(this, &QToolButton::pressed, this, &OverflowTabsPill::showMenu);
}
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
double OverflowTabsPill::pillWidth(int hiddenCount)
{
QList<ProjectTabButton *> result;
for (int index = 0; index < m_row->layout()->count(); ++index) {
if (auto *button = qobject_cast<ProjectTabButton *>(m_row->layout()->itemAt(index)->widget()))
result << button;
}
return result;
QFont font;
font.setPixelSize(12);
font.setWeight(QFont::Medium);
// Leading, gap before the chevron, chevron, trailing.
return std::ceil(QFontMetricsF(font).horizontalAdvance(projectTabOverflowLabel(hiddenCount))) + 11 + 4 + 10 + 11;
}
void ProjectTabStrip::synchronize()
void OverflowTabsPill::setHiddenIDs(std::vector<QUuid> hiddenIDs)
{
// Buttons follow the tabs: kept where the tab stays.
auto *row = static_cast<QHBoxLayout *>(m_row->layout());
QList<ProjectTabButton *> shown = buttons();
int position = 0;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) {
const auto kept = std::find_if(shown.begin(), shown.end(), [&](ProjectTabButton *button) { return button->tab == tab; });
ProjectTabButton *button = kept == shown.end() ? new ProjectTabButton(m_workspace, tab, m_row) : *kept;
if (kept != shown.end())
shown.erase(kept);
row->removeWidget(button);
row->insertWidget(position, button);
button->show();
button->synchronize();
position += 1;
}
for (ProjectTabButton *gone : shown)
delete gone;
// The row is as wide as its tabs, never squeezed.
m_row->resize(m_row->sizeHint());
showFront();
m_hiddenIDs = std::move(hiddenIDs);
const QString label = projectTabOverflowLabel(int(m_hiddenIDs.size()));
setText(label);
setToolTip(label);
setAccessibleName(label);
update();
}
bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event)
// Below the pill: hidden tabs in order, unsaved marked.
void OverflowTabsPill::showMenu()
{
// A title changed size: the row takes its new width.
if (event->type() == QEvent::LayoutRequest)
m_row->resize(m_row->sizeHint());
return QScrollArea::eventFilter(watched, event);
}
// Only a new front tab scrolls into view.
void ProjectTabStrip::showFront()
{
if (std::exchange(m_shownFront, m_workspace.selectedID()) == m_workspace.selectedID())
setDown(false);
if (!m_workspace.canSwitch())
return;
// Posted, as Swift's: the row's layout request runs first.
QTimer::singleShot(0, this, [this] {
for (ProjectTabButton *button : buttons()) {
if (button->tab->id == m_workspace.selectedID())
ensureWidgetVisible(button, 0, 0);
}
});
auto *menu = new QMenu(this);
menu->setObjectName(QStringLiteral("projectTabsOverflowMenu"));
menu->setAttribute(Qt::WA_DeleteOnClose);
for (const QUuid &id : m_hiddenIDs) {
const std::shared_ptr<ProjectTab> tab = m_workspace.tab(id);
const QString title = (tab->session.isModified() ? QStringLiteral("• ") : QString()) + tab->title();
connect(menu->addAction(title), &QAction::triggered, this, [this, id] { m_workspace.select(id); });
}
menu->popup(mapToGlobal(QPoint(0, height() + 4)));
}
void OverflowTabsPill::paintEvent(QPaintEvent *)
{
// An inactive tab's capsule; the label dims while busy.
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QColor fill = palette().color(QPalette::WindowText), edge = fill;
fill.setAlphaF(0.035);
edge.setAlphaF(0.08);
painter.setPen(QPen(edge, 1));
painter.setBrush(fill);
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 14, 14);
painter.setOpacity(m_workspace.canSwitch() ? 1 : 0.5);
painter.setPen(palette().color(QPalette::ButtonText));
painter.drawText(QRectF(11, 0, width() - 36, height()), Qt::AlignLeft | Qt::AlignVCenter, text());
// Swift's chevron.down, nine points, medium.
QPainterPath chevron;
const double left = width() - 21, middle = height() / 2.0;
chevron.moveTo(left + 1, middle - 2);
chevron.lineTo(left + 5, middle + 2);
chevron.lineTo(left + 9, middle - 2);
painter.setPen(QPen(palette().color(QPalette::ButtonText), 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
painter.drawPath(chevron);
}
+61 -9
View File
@@ -1,14 +1,18 @@
#pragma once
#include "Document/ProjectWorkspace.h"
#include <QScrollArea>
#include "UI/ProjectTabLayout.h"
#include <QToolButton>
#include <QWidget>
// Swift's TabDragPhase: a reorder drag goes on, or ends.
enum class TabDragPhase { changed, ended };
// One tab: its title, the unsaved dot, its close button.
class ProjectTabButton : public QWidget {
Q_OBJECT
public:
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent = nullptr);
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
QWidget *parent = nullptr);
const std::shared_ptr<ProjectTab> tab;
// What the session and the workspace hold, shown.
@@ -16,6 +20,7 @@ public:
bool isActive() const;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
@@ -27,9 +32,13 @@ private:
void setTargeted(bool targeted);
ProjectWorkspace &m_workspace;
const std::function<void(TabDragPhase, double)> m_onReorder;
QToolButton *const m_select;
QToolButton *const m_close;
bool m_targeted = false;
// Swift's DragGesture: the press, in the window's space.
std::optional<QPointF> m_press;
bool m_dragging = false;
};
// Swift's NewProjectDropTarget: the New canvas button takes drops.
@@ -53,23 +62,66 @@ private:
bool m_targeted = false;
};
// The row of tabs, one a project, scrolling when long.
class ProjectTabStrip : public QScrollArea {
// Swift's OverflowTabsPill: the tabs that do not fit, a menu.
class OverflowTabsPill : public QToolButton {
Q_OBJECT
public:
explicit OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent = nullptr);
// Swift's projectTabOverflowPillWidth.
static double pillWidth(int hiddenCount);
void setHiddenIDs(std::vector<QUuid> hiddenIDs);
protected:
void paintEvent(QPaintEvent *event) override;
private:
void showMenu();
ProjectWorkspace &m_workspace;
std::vector<QUuid> m_hiddenIDs;
};
// The row of tabs; the rest gather in a menu.
class ProjectTabStrip : public QWidget {
Q_OBJECT
public:
explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr);
QList<ProjectTabButton *> buttons() const;
OverflowTabsPill *pill() const { return m_pill; }
QSize sizeHint() const override;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private:
// Swift's TabReorderState, taken once as the drag starts.
struct Reorder {
QUuid id;
std::vector<QUuid> others;
std::map<QUuid, double> widths;
std::map<QUuid, double> compactedX;
double startX;
double originX;
double translation = 0;
int targetIndex = 0;
int nearestSlot() const;
};
void synchronize();
void showFront();
std::map<QUuid, double> widths() const;
ProjectTabOverflow overflow() const;
// Places the pill and the tabs; others slide when `animated`.
void layOut(bool animated = false);
double renderX(const ProjectTabSlot &slot, double contentWidth) const;
void handleReorder(QUuid id, TabDragPhase phase, double translation);
Reorder makeReorder(QUuid id) const;
void commitReorder(const Reorder &state);
ProjectWorkspace &m_workspace;
QWidget *const m_row;
// Swift's lastSelectedID: only a new front tab scrolls.
std::optional<QUuid> m_shownFront;
OverflowTabsPill *const m_pill;
std::vector<ProjectTabButton *> m_buttons;
std::optional<Reorder> m_reorder;
};
+1 -4
View File
@@ -26,11 +26,8 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget
auto *newButton = new NewCanvasButton(m_workspace, m_toolbar);
newButton->setDefaultAction(m_newCanvas);
m_toolbar->addWidget(newButton);
m_toolbar->addWidget(m_tabs);
// The strip takes the toolbar's free width; zooms stay right.
auto *spacer = new QWidget(m_toolbar);
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_toolbar->addWidget(spacer);
m_toolbar->addWidget(m_tabs);
m_fit = action(m_toolbar, QStringLiteral("Fit"), QStringLiteral("Fit canvas in window (Ctrl+0)"), QStringLiteral("fitCanvas"));
m_actualPixels = action(m_toolbar, QStringLiteral("100%"), QStringLiteral("Actual pixels (Ctrl+1)"), QStringLiteral("actualPixels"));
m_zoomIn = action(m_toolbar, QStringLiteral("+"), QStringLiteral("Zoom in (Ctrl++)"), QStringLiteral("zoomIn"));
+124
View File
@@ -0,0 +1,124 @@
#include "Document/BrushStroke.h"
#include "Document/HueSaturation.h"
#include "Document/Levels.h"
#include <QRandomGenerator>
#include <QtTest>
#include <mutex>
extern "C" {
#include "LevelsPixels.h"
}
// Compositor 1.4: Levels and Hue/Saturation in bands across the cores.
namespace {
// Premultiplied noise, some of it partly clear.
QImage noise(int width, int height)
{
QImage image = BrushRaster::context(width, height, false);
QRandomGenerator random(7);
for (int y = 0; y < height; ++y) {
uchar *row = image.scanLine(y);
for (int x = 0; x < width; ++x) {
const uchar alpha = uchar(random.bounded(256));
for (int channel = 0; channel < 3; ++channel)
row[x * 4 + channel] = uchar(random.bounded(alpha + 1));
row[x * 4 + 3] = alpha;
}
}
return image;
}
std::vector<uchar> bytes(const QImage &image)
{
std::vector<uchar> result;
for (int y = 0; y < image.height(); ++y)
result.insert(result.end(), image.constScanLine(y), image.constScanLine(y) + image.width() * 4);
return result;
}
}
class AdjustmentBandsTests : public QObject {
Q_OBJECT
private slots:
void bandsCoverEveryPixelOnce();
void aCopyKeepsTheBytes();
void levelsInBandsMatchesOnePass();
void hueSaturationInBandsMatchesOnePass();
void cachedTablesFollowTheirSettings();
};
void AdjustmentBandsTests::bandsCoverEveryPixelOnce()
{
for (const qsizetype count : {qsizetype(10), qsizetype(249'999), qsizetype(250'007), qsizetype(1'000'003)}) {
std::vector<int> marks(size_t(count), 0);
std::mutex lock;
int calls = 0;
BrushRaster::inBands(count, [&](qsizetype start, qsizetype length) {
QVERIFY(length > 0);
for (qsizetype index = start; index < start + length; ++index)
++marks[size_t(index)];
const std::lock_guard<std::mutex> held(lock);
++calls;
});
QVERIFY(std::all_of(marks.begin(), marks.end(), [](int mark) { return mark == 1; }));
// Small counts run as one band.
QCOMPARE(calls > 1, count >= 250'000);
}
}
void AdjustmentBandsTests::aCopyKeepsTheBytes()
{
const QImage source = noise(37, 11);
const QImage copied = BrushRaster::copy(source);
QCOMPARE(copied.format(), QImage::Format_RGBA8888_Premultiplied);
QCOMPARE(bytes(copied), bytes(source));
QVERIFY(copied.constBits() != source.constBits());
// Another layout is drawn into one.
QImage argb(3, 1, QImage::Format_ARGB32);
argb.fill(qRgba(255, 0, 0, 255));
QCOMPARE(bytes(BrushRaster::copy(argb)), (std::vector<uchar>{255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255}));
}
void AdjustmentBandsTests::levelsInBandsMatchesOnePass()
{
const QImage source = noise(701, 401);
LevelsSettings settings;
settings.ranges[0] = LevelRange{.black = 20, .gamma = 1.7, .white = 230, .outputBlack = 10, .outputWhite = 240};
std::array<float, 3 * 256> tables;
for (size_t channel = 0; channel < 3; ++channel) {
for (size_t value = 0; value <= 255; ++value)
tables[channel * 256 + value] = float(settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
}
QImage reference = source.copy();
levels_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), tables.data());
const QImage banded = LevelsFilter::run(LevelsJob{source, settings, std::nullopt, QTransform()});
QCOMPARE(bytes(banded), bytes(reference));
QVERIFY(bytes(banded) != bytes(source));
}
void AdjustmentBandsTests::hueSaturationInBandsMatchesOnePass()
{
const QImage source = noise(701, 401);
const HueSaturationSettings settings(120, 40, -10);
const std::vector<float> cube = HueSaturationFilter::cube(settings);
QImage reference = source.copy();
cube_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), cube.data(), HueSaturationFilter::dimension);
const QImage banded = HueSaturationFilter::run(HueSaturationJob{source, settings, std::nullopt, QTransform(), false}).image;
QCOMPARE(bytes(banded), bytes(reference));
QVERIFY(bytes(banded) != bytes(source));
}
void AdjustmentBandsTests::cachedTablesFollowTheirSettings()
{
// More settings than the cache holds, asked twice over.
std::vector<std::vector<float>> first;
for (int hue = 0; hue < 10; ++hue)
first.push_back(HueSaturationFilter::cube(HueSaturationSettings(hue * 30)));
for (int round = 0; round < 2; ++round) {
for (int hue = 9; hue >= 0; --hue)
QVERIFY(HueSaturationFilter::cube(HueSaturationSettings(hue * 30)) == first[size_t(hue)]);
}
QVERIFY(first[0] != first[1]);
}
QTEST_GUILESS_MAIN(AdjustmentBandsTests)
#include "AdjustmentBandsTests.moc"
+123
View File
@@ -0,0 +1,123 @@
#include "CanvasContractFixtures.h"
// Compositor 1.4's GPU canvas contracts on the CPU canvas: adjustments.
namespace {
// A canvas over a photo and a smaller, translucent layer.
struct Scene : ContractScene {
Scene(int width, int height, int canvasWidth, int canvasHeight) : ContractScene(width, height, canvasWidth, canvasHeight)
{
const QImage photo = noise(width, height, 3);
session.insert(ImportedImage(photo, photo, QStringLiteral("Photo")));
const QImage top = noise(width / 2, height / 2, 5, 160);
session.insert(ImportedImage(top, top, QStringLiteral("Top")));
}
// An adjustment layer of this kind on top, at 80%.
QUuid adjust(const LayerAdjustment &adjustment)
{
session.addAdjustment(adjustment.kind);
session.setAdjustmentEditingID(std::nullopt);
const QUuid id = session.activeLayerID().value();
session.updateAdjustment(id, adjustment);
session.setLayerOpacity(0.8);
return id;
}
};
LayerAdjustment visible(AdjustmentKind kind)
{
LayerAdjustment adjustment{kind};
switch (kind) {
case AdjustmentKind::hsv: adjustment.hsvSettings = HueSaturationSettings(120, 30); break;
case AdjustmentKind::levels: adjustment.levels.ranges[0].outputWhite = 160; break;
case AdjustmentKind::curves: adjustment.curves.channels[0] = {{0, 20}, {120, 170}, {255, 235}}; break;
case AdjustmentKind::exposure: adjustment.setExposure(ExposureSettings{.exposure = 0.8, .gamma = 1.2}); break;
case AdjustmentKind::colorBalance: adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 40}); break;
case AdjustmentKind::gaussianBlur: adjustment.setGaussianRadius(6); break;
case AdjustmentKind::motionBlur:
adjustment.setResolvedMotionDistance(30);
adjustment.setResolvedMotionAngle(30);
break;
case AdjustmentKind::addNoise:
adjustment.setResolvedNoiseAmount(25);
adjustment.setResolvedNoiseSeed(7);
break;
case AdjustmentKind::grain: adjustment.setGrain(GrainSettings{.amount = 60, .size = 3, .seed = 11}); break;
default: break;
}
return adjustment;
}
}
class AdjustmentCanvasTests : public QObject {
Q_OBJECT
private slots:
void everyAdjustmentShowsAsTheExport_data();
void everyAdjustmentShowsAsTheExport();
void patternsStayWithTheDocument_data();
void patternsStayWithTheDocument();
};
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport_data()
{
QTest::addColumn<AdjustmentKind>("kind");
for (const AdjustmentKind kind : allAdjustmentKinds)
QTest::newRow(qPrintable(rawValue(kind))) << kind;
}
// Swift's matchesEveryAdjustment: a mask, 80%, one to one.
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport()
{
QFETCH(AdjustmentKind, kind);
Scene scene(60, 50, 60, 50);
const QImage before = scene.exported();
const QUuid id = scene.adjust(visible(kind));
rewrite(scene.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, rampMask(60, 50)); });
scene.session.zoom(1);
QVERIFY(scene.exported() != before);
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
}
void AdjustmentCanvasTests::patternsStayWithTheDocument_data()
{
QTest::addColumn<AdjustmentKind>("kind");
QTest::newRow("addNoise") << AdjustmentKind::addNoise;
QTest::newRow("grain") << AdjustmentKind::grain;
}
// Swift's patternsStayWithTheDocument: wherever it sits, however zoomed.
void AdjustmentCanvasTests::patternsStayWithTheDocument()
{
QFETCH(AdjustmentKind, kind);
Scene scene(60, 50, 140, 120);
scene.adjust(visible(kind));
// One to one, and the crisp path's document pixels.
for (const double zoom : {1.0, 2.0}) {
scene.session.zoom(zoom);
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1: %2").arg(zoom).arg(scene.gapToExport())));
scene.session.viewport.translate(QSizeF(3, -2));
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1 panned: %2").arg(zoom).arg(scene.gapToExport())));
}
// Reduced: a whole-pixel pan moves the pattern with the image.
scene.session.zoom(0.8);
const QImage before = scene.shot();
const QPoint corner = scene.origin().toPoint();
scene.session.viewport.translate(QSizeF(5, 3));
const QImage after = scene.shot();
QCOMPARE(scene.origin().toPoint(), corner + QPoint(5, 3));
int worst = 0;
for (int y = corner.y() + 2; y < corner.y() + 36; ++y) {
for (int x = corner.x() + 2; x < corner.x() + 44; ++x) {
const QRgb was = before.pixel(x, y), now = after.pixel(x + 5, y + 3);
worst = std::max({worst, std::abs(qRed(was) - qRed(now)), std::abs(qGreen(was) - qGreen(now)), std::abs(qBlue(was) - qBlue(now))});
}
}
QVERIFY2(worst <= 1, qPrintable(QString::number(worst)));
// A partial repaint shows the whole's pattern, resampled alike.
const QRect part(corner + QPoint(18, 12), QSize(20, 15));
const QImage piece = scene.canvas.grab(part).toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY(ContractScene::gap(piece, after.copy(part)) <= 2);
}
QTEST_MAIN(AdjustmentCanvasTests)
#include "AdjustmentCanvasTests.moc"
+21 -2
View File
@@ -107,12 +107,31 @@ void AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn()
{
QImage surface = BrushRaster::context(8, 8, false);
QPainter painter(&surface);
// The budget counts device pixels: 64 here, not 16 units.
// 64 device pixels; past that 16 units; past that, none.
painter.scale(2, 2);
bool called = false;
QTest::ignoreMessage(QtWarningMsg, "an adjustment surface passes its pixel budget: QSize(8, 8)");
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 63);
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 15);
QVERIFY(!called);
QSize allocated;
AdjustmentSurface::draw(painter, [&](QPainter &target) {
allocated = QSize(target.device()->width(), target.device()->height());
target.fillRect(QRectF(0, 0, 2, 4), Qt::blue);
target.fillRect(QRectF(2, 0, 2, 4), Qt::green);
}, 0, 63);
QCOMPARE(allocated, QSize(4, 4));
// A device pixel at a quarter: one pixel, not none.
painter.save();
painter.scale(2, 2);
painter.setClipRect(QRectF(0, 0, 0.25, 1));
allocated = QSize();
AdjustmentSurface::draw(painter, [&](QPainter &target) { allocated = QSize(target.device()->width(), target.device()->height()); }, 0, 3);
painter.restore();
QCOMPARE(allocated, QSize(1, 1));
// Enlarged smoothly: the seam between blue and green blends.
QCOMPARE(surface.pixel(0, 0), qRgba(0, 0, 255, 255));
QCOMPARE(surface.pixel(7, 0), qRgba(0, 255, 0, 255));
QVERIFY(qBlue(surface.pixel(4, 0)) > 0 && qBlue(surface.pixel(4, 0)) < 255);
AdjustmentSurface::draw(painter, [&](QPainter &target) {
called = true;
target.fillRect(QRectF(0, 0, 4, 4), Qt::red);
+3 -4
View File
@@ -70,11 +70,10 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source)
case LayerBlendMode::multiply: result[channel] = b * s; break;
case LayerBlendMode::screen: result[channel] = b + s - b * s; break;
case LayerBlendMode::overlay: result[channel] = hardLight(s, b); break;
case LayerBlendMode::softLight: {
const double d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : std::sqrt(b);
result[channel] = s <= 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (d - b);
// Photoshop's, as Core Image computes it (Compositor 1.4).
case LayerBlendMode::softLight:
result[channel] = s <= 0.5 ? 2 * b * s + b * b * (1 - 2 * s) : 2 * b * (1 - s) + std::sqrt(b) * (2 * s - 1);
break;
}
case LayerBlendMode::darken: result[channel] = std::min(b, s); break;
case LayerBlendMode::lighten: result[channel] = std::max(b, s); break;
case LayerBlendMode::difference: result[channel] = std::abs(b - s); break;
+220
View File
@@ -0,0 +1,220 @@
#include "Rendering/EditorCanvas.h"
#include "IO/ImageExporter.h"
#include "RenderFixtures.h"
#include <QPainter>
#include <QRadialGradient>
#include <QtTest>
// Compositor 1.4's blending: stacks, adjustments in a mode, Soft Light.
namespace {
ImportedImage filled(int width, int height, QColor colour, const QString &name)
{
const QImage image = solid(width, height, colour.rgba());
return ImportedImage(image, image, name);
}
// A canvas at 1:1 over the document.
struct Scene {
EditorSession session;
CanvasView canvas{session};
Scene(int width, int height)
{
session.createDocument(width, height);
session.setShowsTransformControls(false);
canvas.resize(width + 40, height + 40);
session.viewport.resize(QSizeF(width + 40, height + 40), 1, QSizeF(width, height));
session.zoom(1);
}
QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); }
// Copy Merged of the whole canvas.
QImage merged()
{
session.selectAll();
const QImage image = session.renderMergedPixels().value().image.convertToFormat(QImage::Format_ARGB32);
session.deselect();
return image;
}
// The worst channel gap to the export, inside the border.
int canvasGap()
{
const QImage expected = exported(), shot = canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32);
const QPointF origin = session.viewport.viewPoint(QPointF(0, 0), session.document().value().size());
int worst = 0;
for (int y = 1; y < expected.height() - 1; ++y) {
for (int x = 1; x < expected.width() - 1; ++x) {
const QRgb wanted = expected.pixel(x, y), shown = shot.pixel(int(origin.x() + x + 0.5), int(origin.y() + y + 0.5));
worst = std::max({worst, std::abs(qRed(wanted) - qRed(shown)), std::abs(qGreen(wanted) - qGreen(shown)),
std::abs(qBlue(wanted) - qBlue(shown))});
}
}
return worst;
}
void mode(LayerBlendMode mode)
{
session.setLayerBlendMode(mode);
}
};
std::array<int, 3> rgb(QRgb pixel)
{
return {qRed(pixel), qGreen(pixel), qBlue(pixel)};
}
bool near(QRgb pixel, std::array<int, 3> expected, int tolerance)
{
const std::array<int, 3> actual = rgb(pixel);
for (int channel = 0; channel < 3; ++channel) {
if (std::abs(actual[size_t(channel)] - expected[size_t(channel)]) > tolerance)
return false;
}
return true;
}
QString text(QRgb pixel)
{
return QStringLiteral("(%1, %2, %3)").arg(qRed(pixel)).arg(qGreen(pixel)).arg(qBlue(pixel));
}
}
class BlendingTests : public QObject {
Q_OBJECT
private slots:
void clippingStacksBlendInTheirBasesMode_data();
void clippingStacksBlendInTheirBasesMode();
void adjustmentsBlendInAnyMode_data();
void adjustmentsBlendInAnyMode();
void softLightMatchesPhotoshop();
void softEdgedLayersBlendTheSame_data();
void softEdgedLayersBlendTheSame();
};
void BlendingTests::clippingStacksBlendInTheirBasesMode_data()
{
QTest::addColumn<LayerBlendMode>("mode");
QTest::newRow("linearDodge") << LayerBlendMode::linearDodge;
QTest::newRow("colorDodge") << LayerBlendMode::colorDodge;
QTest::newRow("multiply") << LayerBlendMode::multiply;
}
// Swift's: the stack takes its base's mode wherever drawn.
void BlendingTests::clippingStacksBlendInTheirBasesMode()
{
QFETCH(LayerBlendMode, mode);
Scene scene(100, 100);
EditorSession &session = scene.session;
session.insert(filled(100, 100, QColor::fromRgbF(0.4f, 0.2f, 0.1f), QStringLiteral("Base")));
session.insert(filled(100, 100, QColor::fromRgbF(0.3f, 0.3f, 0.3f), QStringLiteral("Blended")));
const QUuid blended = session.activeLayerID().value();
scene.mode(mode);
session.insert(filled(50, 50, QColor::fromRgbF(0.2f, 0.05f, 0), QStringLiteral("Clipped")), QPointF(25, 25));
QVERIFY(session.linkMask(blended, session.activeLayerID().value()));
const QImage exported = scene.exported(), merged = scene.merged();
// Under the clipped layer, and the stack's base alone.
for (const QPoint point : {QPoint(20, 20), QPoint(80, 80)})
QCOMPARE(text(merged.pixel(point)), text(exported.pixel(point)));
if (mode == LayerBlendMode::linearDodge) {
QVERIFY2(near(exported.pixel(20, 20), {153, 64, 26}, 1), qPrintable(text(exported.pixel(20, 20))));
QVERIFY2(near(exported.pixel(80, 80), {179, 128, 102}, 1), qPrintable(text(exported.pixel(80, 80))));
}
QVERIFY(text(exported.pixel(20, 20)) != text(exported.pixel(80, 80)));
QVERIFY(scene.canvasGap() <= 1);
}
void BlendingTests::adjustmentsBlendInAnyMode_data()
{
QTest::addColumn<LayerBlendMode>("mode");
QTest::addColumn<bool>("clipped");
QTest::addColumn<QList<int>>("expected");
// (0.2, 0.4, 0.6) inverted is (0.8, 0.6, 0.4).
QTest::newRow("difference") << LayerBlendMode::difference << false << QList<int>{153, 51, 51};
QTest::newRow("linearLight") << LayerBlendMode::linearLight << false << QList<int>{204, 153, 102};
QTest::newRow("vividLight in a stack") << LayerBlendMode::vividLight << true << QList<int>{128, 128, 128};
QTest::newRow("subtract in a stack") << LayerBlendMode::subtract << true << QList<int>{0, 0, 51};
}
// Blended at full coverage, alpha restored, then mixed by opacity.
void BlendingTests::adjustmentsBlendInAnyMode()
{
QFETCH(LayerBlendMode, mode);
QFETCH(bool, clipped);
QFETCH(QList<int>, expected);
Scene scene(40, 30);
EditorSession &session = scene.session;
session.insert(filled(40, 30, QColor::fromRgbF(0.2f, 0.4f, 0.6f), QStringLiteral("Photo")));
const QUuid photo = session.activeLayerID().value();
session.addAdjustment(AdjustmentKind::invert);
session.setAdjustmentEditingID(std::nullopt);
const QUuid adjustment = session.activeLayerID().value();
scene.mode(mode);
if (clipped)
QVERIFY(session.linkMask(photo, adjustment));
const QImage exported = scene.exported();
const QRgb shown = exported.pixel(10, 10);
QVERIFY2(near(shown, {expected[0], expected[1], expected[2]}, 1), qPrintable(text(shown)));
QCOMPARE(qAlpha(shown), 255);
QCOMPARE(text(scene.merged().pixel(10, 10)), text(shown));
QVERIFY(scene.canvasGap() <= 1);
// At half opacity the result mixes halfway back.
session.selectLayer(adjustment);
session.setLayerOpacity(0.5);
const QRgb half = scene.exported().pixel(10, 10);
QVERIFY2(near(half, {(expected[0] + 51) / 2, (expected[1] + 102) / 2, (expected[2] + 153) / 2}, 1), qPrintable(text(half)));
}
// Photoshop's: 2·b·(1 − s) + √b·(2s − 1) above half.
void BlendingTests::softLightMatchesPhotoshop()
{
const auto blended = [](double backdrop, double source) {
Scene scene(10, 10);
scene.session.insert(filled(10, 10, QColor::fromRgbF(float(backdrop), float(backdrop), float(backdrop)), QStringLiteral("Base")));
scene.session.insert(filled(10, 10, QColor::fromRgbF(float(source), float(source), float(source)), QStringLiteral("Soft")));
scene.mode(LayerBlendMode::softLight);
const int exported = qRed(scene.exported().pixel(5, 5));
if (qRed(scene.merged().pixel(5, 5)) != exported || scene.canvasGap() > 1)
return -1;
return exported;
};
// 2·0.5·0.1 + √0.5·0.8 = 0.666: 170.
QCOMPARE(blended(0.5, 0.9), 170);
// Dark: √(5/255) is 0.140, so 36; the PDF gives 19.
QCOMPARE(blended(5 / 255.0, 1), 36);
// Below half, 2bs + b²(1 − 2s): 0.1375 here.
QCOMPARE(blended(0.25, 0.2), 35);
}
void BlendingTests::softEdgedLayersBlendTheSame_data()
{
QTest::addColumn<LayerBlendMode>("mode");
QTest::newRow("softLight") << LayerBlendMode::softLight;
QTest::newRow("overlay") << LayerBlendMode::overlay;
QTest::newRow("multiply") << LayerBlendMode::multiply;
QTest::newRow("colorDodge") << LayerBlendMode::colorDodge;
}
// A soft brush's layer, mostly partly clear, alike everywhere.
void BlendingTests::softEdgedLayersBlendTheSame()
{
QFETCH(LayerBlendMode, mode);
Scene scene(60, 50);
const QImage photo = noise(60, 50, 4);
scene.session.insert(ImportedImage(photo, photo, QStringLiteral("Photo")));
QImage soft(28, 18, QImage::Format_RGBA8888_Premultiplied);
soft.fill(Qt::transparent);
{
QPainter painter(&soft);
QRadialGradient gradient(QPointF(14, 9), 11);
gradient.setColorAt(0.3, QColor::fromRgbF(1, 0.05f, 0, 1));
gradient.setColorAt(1, QColor::fromRgbF(1, 0.05f, 0, 0));
painter.fillRect(soft.rect(), gradient);
}
scene.session.insert(ImportedImage(soft, soft, QStringLiteral("Paint")), QPointF(30, 23));
scene.mode(mode);
const QImage exported = scene.exported();
// Partly clear pixels blend, as do the opaque ones.
QVERIFY(exported.pixel(30, 23) != photo.pixel(30, 23) && exported.pixel(23, 23) != photo.pixel(23, 23));
QCOMPARE(scene.merged(), exported);
QVERIFY(scene.canvasGap() <= 1);
}
QTEST_MAIN(BlendingTests)
#include "BlendingTests.moc"
+8
View File
@@ -295,6 +295,14 @@ void BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions()
QImage expected = gray.copy();
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 40);
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 4)), expected);
// Each axis by its own scale (Compositor 1.4).
expected = gray.copy();
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 10);
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 16)), expected);
// A region under a unit wide counts as one.
expected = gray.copy();
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 8, 16);
QCOMPARE(noise.apply(gray, QRectF(1, 2, 0.5, 0.5)), expected);
// A negative origin floors, as Swift rounds down.
expected = gray.copy();
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, -11, -21);
+2 -11
View File
@@ -29,7 +29,7 @@ class CameraRawColorTests : public QObject {
Q_OBJECT
private slots:
void curvesAreRepairedIntoOrder();
void theParametricCurveLiftsItsRegion();
void aToneFindsItsRegion();
void aNudgeMovesTheFirstNearestPointWithinRange();
void splitsAndAmountsStayInTheirRanges();
void mixerFamiliesOverlapRoundTheWheel();
@@ -56,17 +56,8 @@ void CameraRawColorTests::curvesAreRepairedIntoOrder()
QCOMPARE(table[100], float(100 / 255.0));
}
void CameraRawColorTests::theParametricCurveLiftsItsRegion()
void CameraRawColorTests::aToneFindsItsRegion()
{
CameraRawCurveSettings curve;
curve.shadows = 100;
// The region's middle takes the whole 22%.
QVERIFY(std::abs(curve.parametric(0.125) - 0.345) < 1e-12);
QCOMPARE(curve.parametric(0.375), 0.375);
curve.highlights = -100;
curve.lightSplit = 100;
// A region of no width still takes a fiftieth's span.
QVERIFY(std::abs(curve.parametric(1) - 0.78) < 1e-12);
// Region picks the amount a tone moves.
CameraRawCurveSettings regions;
regions.region(0.1) = 1;
+403
View File
@@ -0,0 +1,403 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "UI/CameraRawColorControls.h"
#include <QComboBox>
#include <QLabel>
#include <QToolButton>
#include <QtTest>
// Swift 1.4.5: the Camera Raw curve graph's one drag.
namespace {
struct Graph {
EditorSession session;
std::unique_ptr<CameraRawCurveControls> controls;
QWidget *graph = nullptr;
explicit Graph(bool point = false)
{
session.createDocument(8, 8);
QImage image = BrushRaster::context(8, 8, false);
image.fill(QColor(200, 120, 60));
session.insert(ImportedImage(image, image, QStringLiteral("Warm")));
session.beginFilter(FilterKind::cameraRaw);
controls = std::make_unique<CameraRawCurveControls>(session);
controls->resize(374, 600);
controls->show();
graph = controls->findChild<QWidget *>(QStringLiteral("cameraRawCurveGraph"));
if (point)
controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
}
const CameraRawCurveSettings &curve() const { return session.filterEdit().value().settings.cameraRaw.curve; }
void set(const std::function<void(CameraRawCurveSettings &)> &change)
{
FilterSettings settings = session.filterEdit().value().settings;
change(settings.cameraRaw.curve);
session.updateFilter(settings, true);
}
// A curve position in the graph's own points.
QPoint at(double x, double y) const { return QPoint(int(std::lround(x * graph->width())), int(std::lround((1 - y) * graph->height()))); }
void press(QPoint where, Qt::MouseButton button = Qt::LeftButton) const { QTest::mousePress(graph, button, {}, where); }
void move(QPoint where, Qt::MouseButtons buttons = Qt::LeftButton) const
{
QMouseEvent event(QEvent::MouseMove, where, graph->mapToGlobal(where), Qt::NoButton, buttons, Qt::NoModifier);
QApplication::sendEvent(graph, &event);
}
void release(QPoint where) const { QTest::mouseRelease(graph, Qt::LeftButton, {}, where); }
QLabel &readout() const { return *controls->findChild<QLabel *>(QStringLiteral("curveSelectedPoint")); }
QColor dot(double x, double y) const { return graph->grab().toImage().pixelColor(at(x, y)); }
};
}
class CameraRawCurveGraphTests : public QObject {
Q_OBJECT
private slots:
void aRegionDragLiftsOrLowersThePressedRegion();
void theRegionIsPickedOnceByItsTone();
void dividersMoveInTheBottomStripAndStayOrdered();
void aPointIsPickedByDistanceAndKept();
void aPressElsewhereAddsAndDragsAPoint();
void addingIsRefusedNearAnEdgeAPointOrSixteen();
void theChosenPointIsLitAndReadOut();
void aNewChannelOrPresetDropsTheChoice();
void aDoubleClickRemovesAnInnerPointAndTheChoice();
void onlyTheLeftButtonDragsAndAPressStartsAfresh();
void theGraphSaysWhatADragDoes();
};
void CameraRawCurveGraphTests::aRegionDragLiftsOrLowersThePressedRegion()
{
Graph graph;
const int h = graph.graph->height();
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 10; });
// Up lifts: a quarter height adds 50.
graph.press(QPoint(int(graph.graph->width() * 0.6), 100));
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 - h / 4));
QCOMPARE(graph.curve().lights, std::round(10 + double(h / 4) / h * 200));
// Down lowers, clamped at −100, rounded to whole.
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 + h));
QCOMPARE(graph.curve().lights, -100.0);
graph.move(QPoint(10, 100 + 3));
QCOMPARE(graph.curve().lights, std::round(10 - 3.0 / h * 200));
graph.release(QPoint(10, 103));
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().highlights == 0);
// Far up, +100 at most.
graph.press(QPoint(int(graph.graph->width() * 0.9), 120));
graph.move(QPoint(int(graph.graph->width() * 0.9), 120 - 4 * h));
QCOMPARE(graph.curve().highlights, 100.0);
}
void CameraRawCurveGraphTests::theRegionIsPickedOnceByItsTone()
{
Graph graph;
const int w = graph.graph->width();
graph.set([](CameraRawCurveSettings &curve) { curve.shadowSplit = 20; });
// A tone on a divider joins the region above.
const std::pair<double, double CameraRawCurveSettings::*> regions[] = {
{0.1, &CameraRawCurveSettings::shadows}, {0.2, &CameraRawCurveSettings::darks}, {0.5, &CameraRawCurveSettings::lights}, {0.75, &CameraRawCurveSettings::highlights}};
for (const auto &[tone, key] : regions) {
const CameraRawCurveSettings before = graph.curve();
graph.press(QPoint(int(std::lround(tone * w)), 60));
graph.move(QPoint(int(std::lround(tone * w)), 50));
graph.release(QPoint(int(std::lround(tone * w)), 50));
CameraRawCurveSettings expected = before;
expected.*key = std::round(10.0 / graph.graph->height() * 200);
QVERIFY2(graph.curve() == expected, qPrintable(QString::number(tone)));
}
// At tone 50 exactly, each divider hands it upward.
QCOMPARE(w % 2, 0);
const std::tuple<std::array<double, 3>, double CameraRawCurveSettings::*> onDividers[] = {
{{50, 60, 80}, &CameraRawCurveSettings::darks}, {{20, 50, 80}, &CameraRawCurveSettings::lights}, {{20, 30, 50}, &CameraRawCurveSettings::highlights}};
for (const auto &[splits, key] : onDividers) {
graph.set([&splits](CameraRawCurveSettings &curve) {
curve = CameraRawCurveSettings();
curve.shadowSplit = splits[0];
curve.darkSplit = splits[1];
curve.lightSplit = splits[2];
});
graph.press(QPoint(w / 2, 60));
graph.move(QPoint(w / 2, 50));
graph.release(QPoint(w / 2, 50));
QVERIFY(graph.curve().*key > 0);
}
// Kept through the drag: across another region, the same one.
graph.set([](CameraRawCurveSettings &curve) { curve.shadows = curve.darks = curve.lights = curve.highlights = 0; });
graph.press(QPoint(int(0.1 * w), 60));
graph.move(QPoint(int(0.9 * w), 40));
QVERIFY(graph.curve().shadows > 0 && graph.curve().highlights == 0);
}
void CameraRawCurveGraphTests::dividersMoveInTheBottomStripAndStayOrdered()
{
Graph graph;
const int w = graph.graph->width(), h = graph.graph->height();
// The strip is the bottom 18 points.
graph.press(QPoint(int(0.5 * w), h - 18));
graph.move(QPoint(int(0.6 * w), h - 30));
graph.release(QPoint(int(0.6 * w), h - 30));
QCOMPARE(graph.curve().darkSplit, 50.0);
QVERIFY(graph.curve().lights > 0);
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 0; });
// Each divider, the nearest to the press, between its neighbours.
const auto dragged = [&](double from, double to) {
graph.press(QPoint(int(std::lround(from * w)), h - 17));
graph.move(QPoint(int(std::lround(to * w)), 10));
graph.release(QPoint(int(std::lround(to * w)), 10));
};
dragged(0.3, 0.9);
QCOMPARE(graph.curve().shadowSplit, 48.0);
// Past 2 and 98 the settings' own limits hold.
dragged(0.47, 0.0);
QCOMPARE(graph.curve().shadowSplit, 5.0);
dragged(0.55, 0.9);
QCOMPARE(graph.curve().darkSplit, 73.0);
dragged(0.73, 0.01);
QCOMPARE(graph.curve().darkSplit, 7.0);
dragged(0.74, 0.0);
QCOMPARE(graph.curve().lightSplit, 9.0);
dragged(0.095, 1.2);
QCOMPARE(graph.curve().lightSplit, 98.0);
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().lights == 0 && graph.curve().highlights == 0);
// At tone 50 exactly, between 40 and 60: the first.
QCOMPARE(w % 2, 0);
graph.set([](CameraRawCurveSettings &curve) {
curve.shadowSplit = 40;
curve.darkSplit = 60;
curve.lightSplit = 80;
});
graph.press(QPoint(w / 2, h - 5));
graph.move(QPoint(int(0.45 * w), h - 5));
QVERIFY(std::abs(graph.curve().shadowSplit - 45) < 0.5 && graph.curve().darkSplit == 60);
}
void CameraRawCurveGraphTests::aPointIsPickedByDistanceAndKept()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.36, 0.5}, {1, 1}}; });
// Nearer by distance, not by x: the second.
graph.press(graph.at(0.33, 0.48));
graph.move(graph.at(0.33, 0.6));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(std::abs(graph.curve().rgb[2].y - 0.6) < 0.01 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
// Kept as it passes x 0.99, held before the end.
graph.move(graph.at(1.2, 0.2));
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-6 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
// Fix beyond Swift: onto its neighbour it stays, still dragged.
graph.move(graph.at(0.1, 0.2));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.31) < 1e-6 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
graph.move(graph.at(0.6, 0.7));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.6) < 0.003 && graph.curve().rgb[3] == (CurvePoint{1, 1}));
graph.release(graph.at(0.6, 0.7));
// Onto the next point: a hundredth short; both stay.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.5, 0.5}, {1, 1}}; });
graph.press(graph.at(0.3, 0.3));
graph.move(graph.at(0.8, 0.3));
graph.release(graph.at(0.8, 0.3));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-6 && graph.curve().rgb[2] == (CurvePoint{0.5, 0.5}));
// 0.05 away takes the point; 0.06 away adds one.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
graph.press(graph.at(0.53, 0.54));
graph.release(graph.at(0.53, 0.54));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.53) < 0.003);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
graph.press(graph.at(0.54, 0.545));
graph.release(graph.at(0.54, 0.545));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.5, 0.5}));
}
void CameraRawCurveGraphTests::aPressElsewhereAddsAndDragsAPoint()
{
Graph graph(true);
graph.press(graph.at(0.25, 0.6));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.25) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.6) < 0.01);
// The same press drags it on, in order.
graph.move(graph.at(0.4, 0.7));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.7) < 0.01);
graph.release(graph.at(0.4, 0.7));
// Added below, sorted in at its x.
graph.press(graph.at(0.8, 0.95));
graph.move(graph.at(0.7, 1.4));
graph.release(graph.at(0.7, 1.4));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.7) < 0.01 && graph.curve().rgb[2].y == 1);
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01);
// The ends move their output alone.
graph.press(graph.at(1, 1));
graph.move(graph.at(0.6, 0.8));
graph.release(graph.at(0.6, 0.8));
QVERIFY(graph.curve().rgb.back().x == 1 && std::abs(graph.curve().rgb.back().y - 0.8) < 0.01);
graph.press(graph.at(0, 0));
graph.move(graph.at(0.3, -0.5));
graph.release(graph.at(0.3, -0.5));
QVERIFY(graph.curve().rgb.front() == (CurvePoint{0, 0}));
}
void CameraRawCurveGraphTests::addingIsRefusedNearAnEdgeAPointOrSixteen()
{
Graph graph(true);
// Too near an end's x, or a point's.
const int w = graph.graph->width();
graph.press(QPoint(2, 20));
graph.move(QPoint(40, 30));
graph.release(QPoint(40, 30));
graph.press(QPoint(w - 2, 140));
graph.release(QPoint(w - 2, 140));
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.2}, {1, 1}}; });
graph.press(graph.at(0.505, 0.9));
graph.move(graph.at(0.6, 0.9));
graph.release(graph.at(0.6, 0.9));
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.2}, {1, 1}}));
QVERIFY(!graph.readout().isVisible());
// Sixteen points take no seventeenth.
graph.set([](CameraRawCurveSettings &curve) {
curve.rgb = {{0, 0}};
for (int index = 1; index < 15; ++index)
curve.rgb.push_back({index * 0.06, index * 0.06});
curve.rgb.push_back({1, 1});
});
QCOMPARE(graph.curve().rgb.size(), size_t(16));
graph.press(graph.at(0.93, 0.2));
graph.release(graph.at(0.93, 0.2));
QCOMPARE(graph.curve().rgb.size(), size_t(16));
// Fifteen take a sixteenth.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb.erase(curve.rgb.begin() + 1); });
graph.press(graph.at(0.93, 0.2));
graph.release(graph.at(0.93, 0.2));
QCOMPARE(graph.curve().rgb.size(), size_t(16));
}
void CameraRawCurveGraphTests::theChosenPointIsLitAndReadOut()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.4}, {0.7, 0.6}, {1, 1}}; });
const QColor accent = graph.graph->palette().color(QPalette::Highlight);
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
QVERIFY(!graph.readout().isVisible());
const auto shown = [&graph](size_t index) {
const CurvePoint point = graph.curve().rgb[index];
return QStringLiteral("In %1 Out %2").arg(std::lround(point.x * 255)).arg(std::lround(point.y * 255));
};
// The press takes the point to the pointer.
graph.press(graph.at(0.3, 0.4));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.3) < 0.003 && std::abs(graph.curve().rgb[1].y - 0.4) < 0.007);
QCOMPARE(graph.dot(0.3, 0.4), accent);
QCOMPARE(graph.dot(0.7, 0.6), QColor(Qt::white));
QCOMPARE(graph.readout().text(), shown(1));
// The readout follows the drag.
graph.move(graph.at(0.4, 0.5));
QCOMPARE(graph.readout().text(), shown(1));
QVERIFY(graph.readout().text().startsWith("In 102 Out 12"));
graph.release(graph.at(0.4, 0.5));
// An end point is chosen too.
graph.press(graph.at(1, 1));
graph.release(graph.at(1, 1));
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
QCOMPARE(graph.graph->grab().toImage().pixelColor(graph.graph->width() - 2, 2), accent);
// A new point is chosen as it is made.
graph.press(graph.at(0.85, 0.2));
graph.release(graph.at(0.85, 0.2));
QCOMPARE(graph.readout().text(), shown(3));
QVERIFY(std::abs(graph.curve().rgb[3].x - 0.85) < 0.003);
QCOMPARE(graph.dot(0.85, 0.2), accent);
// A choice past the points changed elsewhere hides.
graph.press(graph.at(1, 1));
graph.release(graph.at(1, 1));
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = CameraRawCurveSettings::linear(); });
QVERIFY(!graph.readout().isVisible());
}
void CameraRawCurveGraphTests::aNewChannelOrPresetDropsTheChoice()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = curve.red = {{0, 0}, {0.3, 0.4}, {1, 1}}; });
graph.press(graph.at(0.3, 0.4));
QVERIFY(graph.readout().isVisible());
// Another channel: no choice, and the held drag lets go.
graph.controls->findChild<QToolButton *>(QStringLiteral("pointChannel1"))->click();
QVERIFY(!graph.readout().isVisible());
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
graph.move(graph.at(0.3, 0.9));
QVERIFY(graph.curve().red == (std::vector<CurvePoint>{{0, 0}, {0.3, 0.4}, {1, 1}}));
graph.release(graph.at(0.3, 0.9));
// A preset's points drop it too; Custom keeps it.
graph.press(graph.at(0.3, 0.4));
graph.release(graph.at(0.3, 0.4));
auto &preset = *graph.controls->findChild<QComboBox *>(QStringLiteral("curvePreset"));
emit preset.activated(0);
graph.set([](CameraRawCurveSettings &curve) { curve.refineSaturation = 10; });
QVERIFY(graph.readout().isVisible());
emit preset.activated(2);
QVERIFY(graph.curve().red == CameraRawCurveSettings::mediumContrast());
QVERIFY(!graph.readout().isVisible());
QCOMPARE(graph.dot(0.25, 0.18), QColor(Qt::white));
}
void CameraRawCurveGraphTests::aDoubleClickRemovesAnInnerPointAndTheChoice()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.5, 0.5)));
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
QVERIFY(!graph.readout().isVisible());
// On empty ground: one click adds, two remove.
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.4, 0.8)));
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
// A move after it drags nothing.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.6, 0.6}, {1, 1}}; });
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(0.3, 0.3));
graph.move(graph.at(0.5, 0.9));
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.6, 0.6}, {1, 1}}));
// An end point stays, though chosen.
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(1, 1));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
}
void CameraRawCurveGraphTests::onlyTheLeftButtonDragsAndAPressStartsAfresh()
{
Graph graph(true);
graph.press(graph.at(0.5, 0.8), Qt::RightButton);
graph.move(graph.at(0.5, 0.2), Qt::RightButton);
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mouseDClick(graph.graph, Qt::RightButton, {}, graph.at(0.5, 0.5));
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.5}, {1, 1}}));
// A move without the left button drags nothing.
graph.press(graph.at(0.5, 0.5));
graph.move(graph.at(0.5, 0.9), Qt::RightButton);
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.5) < 0.01);
graph.release(graph.at(0.5, 0.5));
// A lost release: the next press picks its own target.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.7, 0.7}, {1, 1}}; });
graph.press(graph.at(0.3, 0.3));
graph.press(graph.at(0.7, 0.7));
graph.move(graph.at(0.7, 0.9));
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.3) < 0.01 && std::abs(graph.curve().rgb[2].y - 0.9) < 0.01);
// A release lets go: moves then change nothing.
graph.release(graph.at(0.7, 0.9));
const std::vector<CurvePoint> held = graph.curve().rgb;
graph.move(graph.at(0.5, 0.1));
QVERIFY(graph.curve().rgb == held);
// Parametric too: a release ends the region.
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage0"))->click();
graph.press(QPoint(30, 80));
graph.release(QPoint(30, 80));
graph.move(QPoint(30, 10));
QCOMPARE(graph.curve().shadows, 0.0);
}
void CameraRawCurveGraphTests::theGraphSaysWhatADragDoes()
{
Graph graph;
QCOMPARE(graph.graph->toolTip(), QString("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
QCOMPARE(graph.graph->toolTip(), QString("Drag a point. Click to add one. Double-click a point to remove it."));
}
QTEST_MAIN(CameraRawCurveGraphTests)
#include "CameraRawCurveGraphTests.moc"
+10 -4
View File
@@ -94,14 +94,20 @@ void CameraRawCurveMixerTests::theParametricPageMovesRegionsAndDividers()
QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider));
QCOMPARE(group.raw().curve.*member, 0.0);
}
// A drag moves the nearest divider, within 2 and 98.
// Along the bottom: the nearest divider, kept ordered.
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
press(graph, QPoint(int(graph.width() * 0.3), 75));
drag(graph, QPoint(int(graph.width() * 0.35), 75));
const int h = graph.height();
press(graph, QPoint(int(graph.width() * 0.3), h - 5));
drag(graph, QPoint(int(graph.width() * 0.35), h - 5));
QVERIFY(std::abs(group.raw().curve.shadowSplit - 35) < 1 && group.raw().curve.darkSplit == 50);
drag(graph, QPoint(graph.width() + 40, 75));
QVERIFY(group.raw().curve.lightSplit == 98 || group.raw().curve.shadowSplit == 98 || group.raw().curve.darkSplit == 98);
QCOMPARE(group.raw().curve.shadowSplit, 48.0);
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(graph.width() + 40, 75));
// Above it a drag lifts the region under the press.
press(graph, QPoint(int(graph.width() * 0.3), 75));
drag(graph, QPoint(int(graph.width() * 0.9), 75 - h / 4));
QCOMPARE(group.raw().curve.shadows, std::round(double(h / 4) / h * 200));
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(int(graph.width() * 0.9), 75 - h / 4));
// A double click here leaves the point curve's points.
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(graph.width() / 2, graph.height() / 2));
+213
View File
@@ -0,0 +1,213 @@
#include "CameraRawFixtures.h"
#include <QtTest>
#include <cmath>
// Swift 1.4.5: Camera Raw's curves as Photoshop's, the model.
using namespace CameraRawFixtures;
namespace {
// Swift's bend, written out apart from the code.
double bent(double tone, double lower, double low, double upper, double high)
{
if (tone < lower && lower > 0)
return lower * std::pow(tone / lower, std::pow(2.0, -low / 100 * 1.66));
if (tone > upper && upper < 1)
return 1 - (1 - upper) * std::pow((1 - tone) / (1 - upper), std::pow(2.0, high / 100 * 1.66));
return tone;
}
// The kernel's table lookup: linear between entries.
double looked(const std::vector<float> &table, double value)
{
const double at = value * 255;
const int below = int(std::floor(at));
if (below >= 255)
return table[255];
return table[size_t(below)] + (table[size_t(below + 1)] - table[size_t(below)]) * (at - below);
}
double luminance(const std::array<int, 4> &pixel)
{
return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2];
}
}
class CameraRawCurveTests : public QObject {
Q_OBJECT
private slots:
void parametricCurveIsSmooth();
void parametricCurveMatchesPhotoshop();
void curveDeepensColorLikePhotoshop();
void theCurvePassesThroughEachBentAnchor();
void eachRegionBendsOnlyItsRange();
void theToneTableRunsThroughThePointCurve();
void refineSaturationEasesOrStrengthens();
};
void CameraRawCurveTests::parametricCurveIsSmooth()
{
CameraRawCurveSettings curve;
QCOMPARE(curve.parametric(0.3), 0.3);
for (const std::array<double, 4> &amounts : {std::array{100.0, 0.0, 0.0, 0.0}, {0, 100, -100, 0}, {-100, 50, 100, -60}}) {
curve.shadows = amounts[0];
curve.darks = amounts[1];
curve.lights = amounts[2];
curve.highlights = amounts[3];
std::vector<double> samples;
for (int index = 0; index <= 200; ++index)
samples.push_back(curve.parametric(index / 200.0));
QCOMPARE(samples.front(), 0.0);
QCOMPARE(samples.back(), 1.0);
for (size_t index = 1; index < samples.size(); ++index)
QVERIFY(samples[index] >= samples[index - 1] - 1e-9);
// Finer sampling halves the slope's jumps; a corner keeps them.
const auto jump = [&curve](int steps) {
std::vector<double> slopes;
double last = curve.parametric(0);
for (int index = 1; index <= steps; ++index) {
const double value = curve.parametric(double(index) / steps);
slopes.push_back((value - last) * steps);
last = value;
}
double worst = 0;
for (size_t index = 1; index < slopes.size(); ++index)
worst = std::max(worst, std::abs(slopes[index] - slopes[index - 1]));
return worst;
};
QVERIFY(jump(400) < jump(200) * 0.7);
}
curve = CameraRawCurveSettings();
curve.darks = 100;
const double before = curve.parametric(0.4);
curve.darkSplit = 70;
QVERIFY(curve.parametric(0.4) > before);
}
void CameraRawCurveTests::parametricCurveMatchesPhotoshop()
{
CameraRawCurveSettings curve;
curve.darks = -51;
curve.lights = 59;
const std::pair<double, double> photoshop[] = {{0.093, 0.011}, {0.192, 0.089}, {0.267, 0.174}, {0.367, 0.310}, {0.491, 0.498},
{0.616, 0.698}, {0.690, 0.804}, {0.765, 0.886}, {0.840, 0.947}, {0.915, 0.982}};
for (const auto &[tone, expected] : photoshop)
QVERIFY2(std::abs(curve.parametric(tone) - expected) < 0.035, qPrintable(QString::number(curve.parametric(tone))));
}
void CameraRawCurveTests::curveDeepensColorLikePhotoshop()
{
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
CameraRawSettings settings;
settings.curve.darks = -51;
settings.curve.lights = 59;
const std::array<int, 4> curved = pixels(settings.apply(orange))[0];
QVERIFY2(curved[0] > 230 && curved[1] < 80, qPrintable(QString("%1 %2").arg(curved[0]).arg(curved[1])));
settings.curve.refineSaturation = -100;
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
QVERIFY(double(brightness[1]) / double(brightness[0]) > 0.35);
}
void CameraRawCurveTests::theCurvePassesThroughEachBentAnchor()
{
CameraRawCurveSettings curve;
curve.shadows = 40;
curve.darks = -51;
curve.lights = 59;
curve.highlights = -30;
curve.shadowSplit = 20;
curve.darkSplit = 45;
curve.lightSplit = 80;
// Shadows and highlights first, then darks and lights.
for (int index = 0; index <= 32; ++index) {
const double x = index / 32.0;
const double expected = bent(bent(x, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59);
QVERIFY2(std::abs(curve.parametric(x) - expected) < 1e-9, qPrintable(QString::number(x)));
}
// Between anchors the smooth curve, not a straight line.
const double middle = (bent(bent(1 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59) + bent(bent(2 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59)) / 2;
QVERIFY(std::abs(curve.parametric(1.5 / 32) - middle) > 1e-6);
}
void CameraRawCurveTests::eachRegionBendsOnlyItsRange()
{
// Shadows and highlights bend past their dividers alone.
CameraRawCurveSettings shadows;
shadows.shadows = 100;
QVERIFY(shadows.parametric(4 / 32.0) > 4 / 32.0 + 0.05);
QVERIFY(std::abs(shadows.parametric(0.25) - 0.25) < 1e-9);
QVERIFY(std::abs(shadows.parametric(0.5) - 0.5) < 1e-9);
CameraRawCurveSettings highlights;
highlights.highlights = -100;
QVERIFY(highlights.parametric(28 / 32.0) < 28 / 32.0 - 0.05);
QVERIFY(std::abs(highlights.parametric(0.75) - 0.75) < 1e-9);
// Darks cover all below the middle; lights all above it.
CameraRawCurveSettings darks;
darks.darks = 100;
QVERIFY(darks.parametric(4 / 32.0) > 4 / 32.0 + 0.02 && darks.parametric(12 / 32.0) > 12 / 32.0 + 0.05);
QVERIFY(std::abs(darks.parametric(0.5) - 0.5) < 1e-9 && std::abs(darks.parametric(0.75) - 0.75) < 1e-9);
CameraRawCurveSettings lights;
lights.lights = -100;
QVERIFY(lights.parametric(20 / 32.0) < 20 / 32.0 - 0.05 && lights.parametric(28 / 32.0) < 28 / 32.0 - 0.02);
QVERIFY(std::abs(lights.parametric(0.25) - 0.25) < 1e-9);
// A divider at an end bends nothing past it.
CameraRawCurveSettings ends;
ends.shadows = 100;
ends.highlights = 100;
ends.shadowSplit = 0;
ends.lightSplit = 100;
for (int index = 0; index <= 32; ++index)
QVERIFY(std::abs(ends.parametric(index / 32.0) - index / 32.0) < 1e-9);
}
void CameraRawCurveTests::theToneTableRunsThroughThePointCurve()
{
CameraRawCurveSettings curve;
curve.lights = 59;
curve.rgb = {{0, 0}, {0.5, 0.6}, {1, 1}};
const std::vector<float> table = curve.toneTable();
QCOMPARE(table.size(), size_t(256));
CameraRawCurveSettings point;
point.rgb = curve.rgb;
// Without amounts, the point curve alone: 0.5 to 0.6.
QVERIFY(std::abs(point.toneTable()[128] - 0.6) < 0.005);
for (const int index : {0, 64, 128, 200, 255}) {
const double expected = point.toneTable()[size_t(std::lround(curve.parametric(index / 255.0) * 255))];
QVERIFY2(std::abs(table[size_t(index)] - expected) < 0.01, qPrintable(QString::number(index)));
}
QCOMPARE(table[255], 1.0f);
}
void CameraRawCurveTests::refineSaturationEasesOrStrengthens()
{
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
CameraRawSettings settings;
settings.curve.darks = -51;
settings.curve.lights = 59;
// At zero each channel runs through the tone curve alone.
const std::vector<float> table = settings.curve.toneTable();
const std::array<int, 4> source = pixels(orange)[0], curved = pixels(settings.apply(orange))[0];
for (int channel = 0; channel < 3; ++channel)
QVERIFY2(std::abs(curved[size_t(channel)] - looked(table, source[size_t(channel)] / 255.0) * 255) <= 1, qPrintable(QString::number(channel)));
// −100: the colour scaled to the curved brightness, hue kept.
settings.curve.refineSaturation = -100;
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
const double mapped = looked(table, luminance(source) / 255) * 255;
QVERIFY(std::abs(luminance(brightness) - mapped) <= 1.5);
QVERIFY(std::abs(double(brightness[1]) / brightness[0] - double(source[1]) / source[0]) < 0.02);
// −50: halfway between the two.
settings.curve.refineSaturation = -50;
const std::array<int, 4> half = pixels(settings.apply(orange))[0];
for (int channel = 0; channel < 3; ++channel)
QVERIFY(std::abs(half[size_t(channel)] - (curved[size_t(channel)] + brightness[size_t(channel)]) / 2.0) <= 1.5);
// +50: half again as far from luminance.
settings.curve.refineSaturation = 50;
const std::array<int, 4> stronger = pixels(settings.apply(orange))[0];
const double lum = luminance(curved);
for (int channel = 0; channel < 3; ++channel) {
const double expected = std::clamp(lum + (curved[size_t(channel)] - lum) * 1.5, 0.0, 255.0);
QVERIFY2(std::abs(stronger[size_t(channel)] - expected) <= 1.5, qPrintable(QString("%1 %2").arg(stronger[size_t(channel)]).arg(expected)));
}
}
QTEST_MAIN(CameraRawCurveTests)
#include "CameraRawCurveTests.moc"
+6 -5
View File
@@ -89,7 +89,7 @@ void CameraRawGroupDrawTests::theCurvePagesShowTheirOwnControls()
QCOMPARE(preset.toolTip(), QString("Replaces this curve with a straight line or a contrast curve."));
QCOMPARE(channel.toolTip(), QString("RGB changes brightness. Red, green, and blue also shift the color."));
QCOMPARE(group.child<QWidget>(QStringLiteral("cameraRawCurveGraph")).toolTip(),
QString("Drag a point. Click the curve to add one. Double-click a point to remove it."));
QString("Drag a point. Click to add one. Double-click a point to remove it."));
QCOMPARE(group.child<QWidget>(QStringLiteral("curveSelectedPoint")).toolTip(), QString("Input and output of the selected curve point."));
QCOMPARE(group.child<QAbstractButton>(QStringLiteral("curveTargeted")).toolTip(),
QString("Drag on the picture to move the curve for the tone under the pointer."));
@@ -126,20 +126,21 @@ void CameraRawGroupDrawTests::endPointsStayAndARealDoubleClickRemoves()
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
const int w = graph.width(), h = graph.height();
// With no inner point a drag moves nothing.
// An end point keeps its x; its output follows.
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(2, h - 2));
QMouseEvent move(QEvent::MouseMove, QPointF(40, 40), graph.mapToGlobal(QPointF(40, 40)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&graph, &move);
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(40, 40));
QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
// An inner point is held within 0.02 and 0.98.
QCOMPARE(group.raw().curve.rgb.size(), size_t(2));
QVERIFY(group.raw().curve.rgb[0].x == 0 && std::abs(group.raw().curve.rgb[0].y - (1 - 40.0 / h)) < 1e-9 && group.raw().curve.rgb[1] == (CurvePoint{1, 1}));
// Past the start it stays a hundredth in.
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 2));
QMouseEvent far(QEvent::MouseMove, QPointF(-50, h / 2), graph.mapToGlobal(QPointF(-50, h / 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&graph, &far);
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(-50, h / 2));
QCOMPARE(group.raw().curve.rgb.size(), size_t(3));
QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.02) < 1e-9 && group.raw().curve.rgb.front().x == 0 && group.raw().curve.rgb.back().x == 1);
QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.01) < 1e-6);
// A real double click: press, release, double click, release.
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
doubleClick(graph, QPoint(w / 2, h / 2));
+1 -1
View File
@@ -83,7 +83,7 @@ void CameraRawGroupRowsTests::curveRows()
checkRow(*group.controls, group.session, row);
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
checkRow(*group.controls, group.session,
{"refineSaturation", "Refine Saturation", "How much the RGB curve also changes color strength. Zero keeps it to brightness.", -100, 100, 0, false,
{"refineSaturation", "Refine Saturation", "How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color.", -100, 100, 0, false,
48, 88, true, false, curve(&CameraRawCurveSettings::refineSaturation)});
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more