diff --git a/AGENTS.md b/AGENTS.md index 304e9f5..97e405e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ Platform adaptations, each forced by a missing Qt analog: - The Levels sheet (9.1b): `UI/LevelsSheet.h/.cpp` twin Swift's sheet; `Histogram`, `Gradient`, `Track` and `Handle` are private widgets of that file, and the histogram and the tracks follow the session's `changed()` themselves. Each handle is a 22 by 20 widget of its own, a child of the sheet placed over its track, as Swift's `ForEach` makes a view each: a later handle lies on top (white over gamma over black), a press drags only its own, each carries Swift's label, and at either end half of it lies in the sheet's padding, where a child of the track would be clipped. The output gradient is made before the output handles, so they lie on its last row as SwiftUI stacks the later view. A drag reads the pointer along its track, Swift's named coordinate space. The triangle is a path (apex 3.75, base 14.25, 12 wide, Swift's 12-point `triangle.fill`); its half-point gray shadow is a gray rim, since Qt has no blur. The histogram's colours are SwiftUI's gray and, from Apple's HIG for macOS 26 (the Swift target), its dark red, green and blue: (142, 142, 147), (255, 66, 69), (48, 209, 88), (0, 145, 255), data colours like the canvas's grays, whatever the theme. The entries are the picker's `PickerField`s: a number applies on Return or on leaving, as Swift's value binding, other text goes back, and an entry keeps its typing until then, unless its own handle is dragged, which takes no focus and replaces the typing with its value (9.6f); numbers are read and shown in the widget's locale with Swift's decimals, as Swift's `.number` format follows the user's (a German gamma reads `2,5`; no level reaches a thousand, so nothing is grouped). A click on OK takes the focus first, so a typed entry applies before the commit, where AppKit's buttons take no focus and the typing is lost; Return in an entry goes on to OK, the panel's default, as in the picker. The eyedroppers are checkable buttons set from the session, the chosen glyph in the accent and the others in the secondary ink, for Swift's `.tint`, redrawn on `PaletteChange`. The channel picker is named by its label (`setBuddy`): on Linux Qt names a combo box by its label relation, never by `accessibleName`. Alt+P is the Preview box's own shortcut in the panel and the canvas's key when the canvas has the keys. For an adjustment layer's editor the caption says the underlying pixels (9.5d). `ContentView+Panels.cpp` holds `synchronizePanels`, Swift's `onChange(of: levels == nil)`, and since 10.1 the importer and the alerts, because `ContentView.cpp` runs to 500 lines: a `FloatingPanel` named `levelsPanel` shows a new sheet when an edit opens, its close cancelling as Swift's `onClose`, and hides when the edit closes. The canvas draws `previewImage` in the layer's place after a filter's; a press in a sample mode is `sampleLevels` and hands the panel the keys (`FloatingPanel::refocus`); other presses wait for Space, the Hand or the Zoom while Levels is open; Escape, Return, Enter and Alt+P are its keys and every other key but Space passes on; `picking` reads the sample mode, so the eyedropper cursor shows; the application filter's brackets rest. `ContentView::acceptsDrop` needs no term of its own: `canSwitch` reads `canStartProjectOperation`, which reads `levels`. The Image menu's Levels… is Ctrl+L and follows `canAdjustColors` (its `hueSaturation` term arrives with 9.3); Import Images and the New canvas button rest while Levels is open. Undo and Redo are bare, since Levels closes the history, and enabled only while a field has focus, as for a busy project: Swift's bare entries act only on a text view. Equivalent mutants: the histogram's zero-peak guard and its `max(0, …)`, each standing in for the other (with no bin counted, 0/0), its `min(1, …)` (the widget clips a taller bar) and the bars' `+ 0.1` (Qt covers abutting rectangles of one path exactly); the drag's clamp of the tone, white's `max(black + 1, …)` and gamma's `min(0.999, …)` (`normalized` does each); either of a track's move and resize events alone (a layout moves and sizes it together); the canvas's `!preview` before the Levels preview (a filter edit and Levels never overlap); passing an unhandled key on (nothing above the canvas takes keys) and the base `changeEvent` (the sheet paints nothing of its own). Qt sizes the loading note itself: a shown child without a layout is adjusted to its hint. - Curves and the image adjustments (9.2): `Document/Curves.h/.cpp` and `Document/ImageAdjustments.h/.cpp` twin Swift's files, `UI/CurvesControls.h/.cpp` Swift's view. `AdjustPixels.c` was already compiled for the downsample cache; Curves and Exposure run `levels_apply` from `LevelsPixels.c`, as Swift's do. `CurvesSettings.channels` is a vector of point vectors, as Swift's array, so `isValid` still counts four. `value` finds Swift's `lastIndex(where:)` with a loop; a NaN input takes the first segment at t = 0, as Swift's `max(0, .nan)` gives 0. `ImageAdjustmentPixels::run` hands its body the premultiplied RGBA context `BrushRaster::context` makes and returns that context. `AdjustmentColor` keeps Swift's two initialisers as constructors; the palette's has its callers since 9.4a (a Gradient Map starts from the palette, its picker) and gains 9.5's (new layers), as `normalized` serves the filter's settings since 9.4a and the adjustment's with 9.5; `ends`, which `apply` reads, gains the filter sheet as a reader with 9.4b and returns an `Ends` struct for Swift's tuple. `GrainSettings::apply` takes an optional seed that replaces the stored one and hands the same image back when the amount is 0. Left out as dead: the `isFinite` terms of `AdjustmentColor.isValid` and of the curves' points (the ranges refuse NaN and infinities), and the points' x range (ends at 0 and 255 with x strictly rising keep every x inside). `CurvesControls` takes Swift's binding as a value and a change function and shows the settings on `synchronize`, which each of its own edits calls and its owner will (9.4's filter sheet); a channel shown anew drops the selection and the drag, Swift's `onChange`. The graph (`curvesGraph`) is a private widget 260 tall: black at 35%, white quarter lines at 12%, the curve two wide, 8-point dots with the chosen one in the accent; presses and drags follow Swift's gesture (a point within 14 curve units is taken, else one is added between x 1 and 254, more than a unit from the others, 32 at most; an inner point keeps a unit from its neighbours; the left button alone). Remove point keeps Swift's guard (an inner point that is still there), so a selection another change has left behind removes nothing. The readout (`curvesReadout`) truncates as Swift's `Int(…)`; SwiftUI's `monospacedDigit` has no Qt 6.4 twin (font features arrive in 6.6), so the digits are the font's own. Carried from 9.1b's review: the Type bar's size and tracking read and print through its locale, as Swift's `.number` fields, without group separators, as every field of the twin; leading keeps `Double(text)`'s period, as Swift's. Equivalent mutants: the curves' `2…` lower count (one point cannot hold both ends), `<` for `<=` in the segment search (the curve is continuous at its points), `< 0` for `<= 0` in the inner slope (a zero slope's reciprocal is infinite, so the harmonic mean is zero anyway) and the final 0…255 clamp (Swift's slopes never overshoot a valid curve); Exposure's `max(0, …)` before the gamma and its table's lower clamp (a negative light ends at zero either way), the gradient map's byte clamp (its colours lie in 0…1); the controls' `x > 1` and `x < 254` (a point more than a unit from both ends lies inside them) and Remove's upper bound (Remove rests on the last point). - Hue/Saturation in the session (9.3a): `Document/HueSaturation.h` twins Swift's types. Swift's file runs to 574 lines, so the twin splits it: `HueSaturation.cpp` (ranges, bands, sample modes, settings, the edit), `HueSaturation+Filter.cpp` (the cube) and `HueSaturation+Session.cpp` (the session extension, `canAdjustColors` with it; `HueSaturation+Session.h` its fragment). `ColorRange` and `HueSampleMode` keep Swift's raw values and case order (`allColorRanges`, `colorRanges`, `allHueSampleModes`); `defaultBand` is a free function where Swift has a property, and `help` gives the modes' help; their SF Symbols, the eyedropper and its badges, are the sheet's to draw (9.3b). `HueSaturationSettings` keeps Swift's dictionaries as `std::map`s, so the constructor's entry for its range and the setters' inserts compare as Swift's do; Swift's computed `hue`, `saturation`, `lightness` and `band` are getter and setter pairs. `HueSaturationFilter` is a namespace; `HueResponse` and `Color` stand for Swift's tuples, and `cube` returns the floats Swift packs into `Data`. Core Image's `CIColorCube` becomes a CPU lookup, rows in parallel: each pixel unpremultiplied, read trilinearly from the 33-point cube, premultiplied again and rounded once; Core Image's own precision may differ by a level, and Swift's tests allow eight. A failed table or row allocation is `ExportError(render)`, since a worker's `bad_alloc` would pass its catch; `HueSaturationFailureTests` reaches that net through a picture one pixel wide, whose row list asks as much as its pixels. A selection blends the result over the job's own pixels, as the Levels filter does, where Swift draws them into a context first: the same bytes, since layer pixels are that context's format. `HueSaturationEdit` is a class in a `std::optional`, as `LevelsEdit`, its `id` standing for Swift's identity; its preview source is made as the Levels edit's (to 8000 pixels a side, from a painted layer's tiles without flattening). The session keeps a watcher for previews and one for the commit. Where Swift cancels the preview task (Preview off, nothing to show, OK) the twin forgets whose it is, so that result drops; a result for a closed edit fails the id check, Swift's `hueSaturation === edit`. A run counts as running until its finish has run (`m_hueSaturationRendering`, as Levels' queue), so a failure is reported even when the next job waits. Known difference: Swift's run for a closed edit returns before it looks at the queue, so a job queued behind it waits for the next change; the twin's runs it. Known difference: Swift's task never looks at its cancellation, so a render still running when Preview goes off, or when OK is pressed, lands. A failed render sets `brushError` whichever edit asked, as Swift's `adjustedPixels` does. Known difference: Swift's `beginHueSaturation` resolves a pending gradient without waiting, so its edit holds the pixels from before the gradient and the commit, finding the layer changed, lands nothing; the twin waits for the gradient, as `beginLevels` does. `commitHueSaturation` refuses while a commit runs (Swift has no guard; its second commit would find the layer changed and land nothing), keeps a copy of the edit for the finish (`CommittingHue`), frees the project before the edit goes (Swift's two `defer`s) and names the new asset after the layer; the rebuild drops shape and text. `sampledHue` reads the composite through `PickerHSB`, as Swift's; `beginHueTargeting` takes Swift's `max(by:)`, the first of the strongest claims. `setHueSampleMode` and `setHueTargeting` are the sheet's writes and notify on a change; `endHueTargeting` changes nothing observable and stays silent. Swift's beeps are warnings on `omaphoto.app`. Left out as dead: `beginHueSaturation`'s `document` and `activeLayer` guard (`canAdjustColors` reads both) and the targeting's `?? .reds` (the ranges are never empty). Equivalent mutants: the weight's fallbacks for ramps of no width (no hue lies inside one) and its `<=` at the plateau's end (both sides give 1); `toHSL`'s cap on saturation, `toRGB`'s gray return and clamp (the arithmetic stays within 0 and 1 but for rounding, which later clamps absorb); a clear pixel's fill (its lookup is multiplied by alpha 0); the response's skips of a still range or a zero weight (each adds nothing); the saturation's floor of zero (`toRGB` draws gray at or below it); the amount's clamps and the lightness clamp (a sum past ±100 reaches white or black either way). The hooks: `canEditLayers`, `canContentAwareFill` (so `beginFilter`), `beginLevels` and `ProjectWorkspace::canSwitch` read `hueSaturation`; `canUseHistory`, `canStartProjectOperation`, `selectLayer` and `selectTool` do not, as in Swift. -- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Swift's sliders run freely; the twin's step in hundredths and show the session's value under `QSignalBlocker`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: a text draft and a polygonal outline can both be open under Hue/Saturation (Swift's `canAdjustColors` asks for neither). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held. +- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Since 12.14c the sliders are Camera Raw's coloured ones over a thousand steps, writing whole numbers as Swift's do, and show the session's value through `reshape`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: a text draft and a polygonal outline can both be open under Hue/Saturation (Swift's `canAdjustColors` asks for neither). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held. - 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`. @@ -128,7 +128,7 @@ Platform adaptations, each forced by a missing Qt analog: - Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, unchanged through 1.3.3), so a base or an adjustment in one of them draws as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`. - Black & White, Color Balance and Invert (12.7b, Swift 1.2.2): `kernels/AdjustPixels.c/.h` are v1.2.2's, with `adjust_black_white` and `adjust_color_balance`, run through `ImageAdjustmentPixels::run` on one thread as Swift's. `BlackWhiteSettings` and `ColorBalanceSettings` keep Swift's fields, defaults and bounds (the `isFinite` terms left out, as the bounds refuse NaN and infinities); `apply` throws `ProjectError(invalid)` past them and Color Balance hands an untouched image back. Swift's `FilterSettings.normalized` does not clamp either, so a number typed past a slider's range fails the preview with that error and a commit sets `brushError` with no step, as in Swift: the filter's two workers catch `ProjectError` beside their other errors, where it would otherwise escape as `QUnhandledException` and abort. `FilterKind` gains both after Grain (`isImageAdjustment`), `AdjustmentKind` Invert, Black & White and Color Balance in Swift's order, with `isEditable` (all but Invert) and `filterKind`; `LayerAdjustment` gains the two optionals with Swift's computed getter and setter pairs, validity and `apply` (Invert through `PixelInvert::run`, whole image, colour only); the manifest's `blackWhiteSettings` and `colorBalanceSettings` are Swift's synthesized `Codable`, every key required, left out while absent. `addAdjustment` opens the editor for editable kinds alone: an Invert layer applies at once. The editors carry both settings through `beginAdjustmentEditing` and `editedAdjustment`. A fix beyond Swift: Swift's Edit Adjustment… stays enabled on an Invert layer, whose editor switch breaks after the original is kept and the transaction opened, so nothing ever closes them and the session stays refused; the twin's entry rests for a kind that is not editable, and the two switches throw a `logic_error` for Invert, which nothing reaches. As Swift's row, an Invert layer says "Adjustment · Double-click to edit", yet a double click on its glyph renames it (`m_editableAdjustment`, Swift's `isEditable` test). The Image menu lists Black & White… and Color Balance… before Exposure…, the Layer menu's New Adjustment Layer entries take an ellipsis when editable, and a menu doubles the ampersand of "Black & White" (a lone one is a mnemonic in Qt), in the kinds' entries and in Undo and Redo's step names alike; object names drop it (`blackWhite`, `newBlackWhiteAdjustment`). `LayerIcons` draws Swift's three new symbols as paths: `circle.righthalf.filled`, `circle.filled.pattern.diagonalline.rectangle` (a rounded frame, diagonal lines, a filled circle) and `scale.3d` (three axes with arrowheads). The filter sheet's two kinds live in `UI/FilterSheet+Adjustments.cpp` (the sheet's file neared 500 lines): the six families and Tint's Hue and Saturation, shown while Tint is on (Swift's `if`, the rows `m_shownWhen` holds with Advanced's), and Color Balance's three headlines (13 pixels, semibold, Swift's `.headline`, as the effects sheet's) over three rows each and Preserve Luminosity; each `flag` is Swift's `Toggle` with its help as the tooltip. `NativeLayerList` stops listening in its destructor, as `ContentView` does: closing a list window hides it first, and a rename's focus-out then commits into a list half destroyed. Tests: Swift's `invertAppliesWithoutAnEditor` in `AdjustmentLayerTests`, its editor test over the editable kinds in `AdjustmentEditorTests` and `FloatingPanelTests`, `ColorAdjustmentTests` (the kernels against Photoshop's defaults and a tint worked in HSL, the tones of Color Balance and Preserve Luminosity, bounds, every manifest key, the sheet), the kinds, keys and menus in `FilterModelTests`, `LayerAdjustmentTests`, `AdjustmentPanelTests`, `CompositorMenusTests` and `SelectMenuTests`. `ProjectExportFailureTests`' two panel limits rose from 2,048 KB: the thousand-layer snapshot grew by the two optionals of every record (2,560 KB since 12.8d). - The Camera Raw filter's model (12.8a, Swift 1.2.3): `kernels/AdjustPixels.c/.h` are v1.2.3's, with the Camera Raw kernels and Grain's particle size (`59b5040`); that kernel warns under `-Wunused-parameter` and `-Wmisleading-indentation` through 1.3.3 (both lines date from `9982fad`; `075a71b` fixes another), so CMake silences those two for that file alone. `Document/CameraRaw.h/.cpp`, `CameraRawColor`, `CameraRawDetailOptics` and `CameraRawGeometryCalibration` twin Swift's four files; Swift's `CameraRaw.swift` runs to 638 lines, so its session extension is `CameraRaw+Session.cpp` with `CameraRaw+Session.h`. The enums keep Swift's raw values and order; `CameraRawClipping` keeps Swift's bits (highlights 1, shadows 2); `Balance` and `Gains` are structs for Swift's tuples; `region` returns a reference where Swift writes through a key path. `CameraRawDrag` sits in `CameraRaw.h`, where Swift's sits in `CameraRawColor.swift`: it holds the whole settings, which C++ needs complete. Swift's panel state sits on `FilterEdit` (the shown groups, pages, swatch, point index, the tools, the guide draft, clipping, sharpen mask, the scope and its mode, the readout); the twin gathers it in `CameraRawPanel`, `FilterEdit::rawPanel`, written through `setCameraRawPanel`, which notifies while an edit is open. `renderSettings` is Swift's settings with the hidden groups left out (`applying`); the commit renders and keeps those, so a hidden group stays out next time, and an identity grade closes as Cancel does. `previewJob` carries the panel's views; the preview's worker runs `CameraRawScope::preview`, whose scope lands on the panel. A fix beyond Swift: Swift's `preview` drops the point colour's view (it runs the job itself only for clipping or the sharpen mask, through 1.3.3), so Visualize never shows; the twin runs the job for it too. Geometry's warp is `QTransform::quadToQuad` from the image's rect to Swift's corners flipped to y down, drawn smooth and antialiased; a warp Qt cannot solve is a `logic_error`. Constrain Crop fits the frame inside `brush_alpha_bounds`; a crop whose copy finds no memory is `ExportError(render)`, where Swift's `cropping` returning nil falls back to the uncropped warp. Left out as dead: `apply`'s `isValid` guard and with it `isValid` (settings arrive normalized, and nothing else asks), `nudged`'s guard for an empty channel (`normalized` repairs every curve to two points; `at` throws if that breaks), the vectorscope's wrap of a negative hue (its angle's cosine and sine turn round alike), the parametric curve's `max(0, weight)` (a tone inside its region weighs 0 to 1), each group's `applying(shows:)` (no caller), and the defringe eyedropper's own `normalized` (`updateFilter` normalizes). Equivalent mutants: `tone < shadow` against `<=` (the regions meet with equal weight), the defringe tie's `<` against `<=`, the mixer's `weights > 0` against `>= 0` (a zero weight adds nothing), the identity commit's cancel (the history drops an unchanged step), and Auto's `committing` term (a committing edit refuses `updateFilter`, and the landing asks again). The Filter menu and the sheet leave Camera Raw out until its panel (12.8b). Tests: `CameraRawTests`, `CameraRawSessionTests` (Swift's session tests; the preview's views and scope), `CameraRawToolsTests`, `CameraRawRulesTests`, `CameraRawColorTests`, `CameraRawLensTests` (the kernel's calibration called directly), `CameraRawWiringTests` (Effects, Optics and Detail against their kernels called with the header's order, a clip view on an untouched grade, Auto in linear light, every eye's fields, Offset, Aspect and Constrain Crop against placed pictures, the scope's channels and hues, the curve, mixer and grading call with its floats written out, the sharpen-mask view, each lens flag alone, Grain's scale, a dull colour's reach, Rotate, Scale and Vertical against placed pictures), `CameraRawFixtures.h`, Grain's size in `ImageAdjustmentTests`, the kind in `FilterModelTests`. -- 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`; the track is fixed at construction: Swift's track update in `updateNSView` has no twin, since the rows whose tracks change (12.8b2's mixer) are made anew. `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 (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 of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmentTests` (the model, manifest, store, seeds, the editor, the filters at a scale, the noise field, a scaled target, the canvas's zoom and a partial repaint's halo at 100%, 150% and 400%), `ImageTrimTests` (Swift's six, tolerance, the sheet, the controller through its sheet, a failure's alert), `InnerGlowTests` (Swift's, but the parity test, and the order), the new kinds in `LayerAdjustmentTests`, `AdjustmentEditorTests`, `AdjustmentLayerTests` and `AdjustmentPanelTests`, the glow in `LayerEffectsKernelTests`' replay, `LayerEffectsSurfaceTests`, `EffectsPreviewCacheTests`, `LayerEffectsTests` and `EffectsPanelTests`, version 9 across the store's tests, Trim in `CompositorMenusTests`. @@ -144,12 +144,13 @@ Platform adaptations, each forced by a missing Qt analog: - Typed text as pixels, effects kept on (12.12a, Swift 1.2.11: `fe6cb2b`, `b683acb`): the canvas draws text being typed as the pixels `textImage` makes of the draft's style (remade only when the style changes and let go at the first draw without a draft, as Swift's `drawNewText` asks with no draft, each remake a debug line on `omaphoto.rendering`, a style that cannot be drawn logged and drawn as nothing), where the editor shows it: an edited layer in its place with its opacity and blend mode (its mask left off, as Swift's), new text just above the active layer, or on top when the active layer is not drawn (a folder, a hidden or blank layer, none). The editor's glyphs are clear: it draws its frame, handles, caret, marked text's underline and a selection at 45% of the highlight over those pixels. An underline without a colour of its own, the preedit's or an input method's, takes the text's colour, since clear glyphs would hide it (Swift's `NSTextView` draws marked text itself). `DisplayState` carries the draft's style and shown transform, so typing repaints the whole view, as effects reach past the box; a handle drag at a zoom can move the box a fraction with the style unchanged, so the transform counts on its own. The edited layer's effects are redone as it is typed, synchronously at the preview size (`EffectsPreviewCache::renderNow`, the worker's scaling moved into `scaled`), through its enabled mask or its placed mask clipped over the typed box; until a change is redone the last effects stand in. When the edit ends and the layer holds the text as last typed, the next draw seeds those effects (`handOnDraftEffects`); a cancel of changed text, or other text, seeds nothing. The cache keeps the last three finished results per layer: pixels an undo puts back show their effects at once, a known result clearing any seed; a mask added, removed or replaced on the same pixels keeps the last preview until the new one lands, where it dropped before. A painted mask keeps its layer's effects through the stroke: `LayerEffectsSurface::MaskStroke` holds the stroke's tiles, their map into the surface's grid and the coverage of any region (the old mask, then the tiles over it, as a tile already holds the old mask it covers; Swift's white fill beneath is dead, since past the old mask there are no pixels), and the surface redoes the regions the mapped tiles touch; a placed mask's surface keeps the layer's grid and takes coverage from `placedMaskPreview` (`placedMaskSurface`). Swift's `restoreToolCursor` has no twin: `syncPicking` already sets the cursor both ways, and a widget's cursor cannot be left over from another window. `strokeDirtyRect` moved to `EditorCanvas+Brush.cpp`, the typed text to `EditorCanvas+DraftText.cpp`. Left out as dead: the cache's filter of a result already recent (a request matching one returns before it renders). Equivalent mutant: the mapped tile rect's one-pixel inset (the redo region is widened by the effects' reach). Tests: `TypedTextCanvasTests` (Swift's `textLooksTheSameWhileEditingAndOnceCommitted` at 1.5 and 4, with the box's inside compared since the editor's frame draws on the canvas; placement above the active layer, opacity and a blend mode with and without effects, effects kept and handed on, masks linked and placed, cancelled text, remakes counted and the image let go once editing ends, typed effects redone when a handle moves a placed-mask box a fraction, the whole view repainted when typing or when a handle moves the box a fraction), `MaskEffectsCanvasTests` (linked and placed masks keep effects mid-stroke, a placed mask sixty times finer against a whole render; untouched tiles keep the old mask), `EffectsPreviewCacheTests` (recent results, `renderNow`, the looser keep), `LayerEffectsSurfaceTests` (a mask stroke redone where its tiles map), and the inline editor's tests read the 45% selection through `highlightedOver`. - Masks painted anywhere (12.12b, Swift 1.2.11: `e753f26`, `8d2eade`, `2ffb092`, `a247676`, taken as their net change from 1.2.10): `BrushStroke` takes `growsMask`, which the Brush, the Gradient and fills (`applyPixelEdit`) pass, so on a mask their grid reaches the canvas as a pixel stroke's does; Spot Healing, Clone Stamp and the Smear keep the mask's grid. A solid placed mask (two pixels a side at most) is painted at a pixel per document pixel of its placement rather than grown from its pixel. `maskBackground` (`LayerMask::background` of the old mask's thumbnail, white or black, 1 without a mask) fills each new tile, the committed snapshot past its base and patches (`RasterSnapshot::fill`, drawn where neither reaches, the box of a mask grown by distant tiles holding such corners) and the asynchronous commit's context (`BrushCommit::Input::fill`). A mask stroke's snapshot keeps every touched tile (`committedBounds`). Committed, a mask grown past its old bounds, or placed already, takes the stroke's transform as its placement (`placedMask`, for the snapshot and the raster commit alike), keeping its place on the document while a linked one still moves with its layer. On the canvas an unplaced mask stroke draws in its possibly grown grid (`paintTransform`), a placed one in the layer's place, and a placed mask's preview and surface map through the stroke's `paintTransform`. `CanvasThumbnail::mask` fills past the mask with `LayerMask::background`, so a stroke touching the edge leaves no gray mean; the thumbnail's own `edgeTone` is gone. `8d2eade`'s canvas fix (the distortion's effects asked with the mask as stored) needs no twin: the twin asks for a layer's effects once, with the mask as shown (9.6c), and its y-flip in `LayerEffectsSurface` has no Qt counterpart. The linked mask surface's white fill (12.12a) stays dead: the layer's pixels lie only within the old mask's rect, however far the grid grows. Equivalent mutant: `placedMask`'s `mask.placement` term (a placed mask keeps its placement through `replacing`, and its transform over unchanged bounds is that placement). Tests: `MaskPaintAnywhereTests` (the brush grows a mask and keeps it linked and in place, a hide-all mask's new area hides, the Gradient and a fill grow it, the Smear's blur does not, a solid placed mask's grid (two pixels a side, a box not square, grown and not), the snapshot's and the commit's fill in uncovered corners, a grown stroke drawn in place, a grown placed mask's tiles mapped under effects), `CanvasThumbnailTests` (Swift's edge case), and `MaskEffectsCanvasTests`' finer placed mask through the stroke's transform. - Scrubbable labels (12.12c1, Swift 1.2.11: `2d36d6e`, `680a522`): `UI/NumericScrub` twins Swift's modifier as a `QObject` living as its label's child and filtering its events; `scrubbable` and `unitSuffix(_:scrubValue:)` are its constructor on the label or unit (`Options`: sensitivity, range, optional step, value, set, onStart, onEnd). A left press on an enabled label, a double click's second press included, is taken (a disabled label's presses reach its filter before Qt drops them, so the filter asks), so the label keeps the drag past its edge; the first move off the press begins (Swift's `minimumDistance` of one, reached by any move of whole points), keeps the start value and calls `onStart`; each move sets the start plus the sideways distance (in the label's own points, which stay exact, where Qt's `notify` rounds global ones) times the sensitivity, snapped to the step when positive, then clamped; the left release calls `onEnd`. Swift's Int overload is a step of one. The cursor is the label's own `SizeHorCursor`: Qt keeps a grabbing widget's cursor through the drag and shows a disabled label's parent's, so Swift's hover and restore dance has no twin. Qt can lose a release: the left release is watched application-wide while pressed, and a move without the left button ends the drag (the label tracks the mouse, so such a move arrives). The tool bars scrub as Swift's: the Brush's Size, Hardness, Opacity or Strength and Smoothing, the Gradient's Opacity, the Magic Wand's Tolerance, Object Select's Edge, the Expand, Contract and Feather units (Expand's and Contract's rest with their rows; Feather's amount and unit stay enabled without a selection, its button alone resting, as Swift's, which the twin's amount had missed), the amount sheet's title (writing its input), the Shape's Width and Radius (a scrub writes its field's text over any typing, as a step does: `showValue` on `SelectionAmountField` and the Type bar's `TextStyleField`), the Type bar's size unit, Tracking and Leading (whole numbers), the Move bar's labels (whole numbers in Swift's new ranges: ±30,000, sides 1 to 30,000, Scale 0.1 to 30,000%, rotation ±360; the scrub writes its field's text over any typing, then the value, so an unfocused field shows what the session keeps; another active layer ends each field's drag under way, `TransformValueField::endScrub`, as it ends the opacity's), the zoom's unit (through the field's `step`) and the layer opacity (one opacity drag, `beginOpacityEdit` to `finishOpacityEdit`; another active layer ends a drag under way through `NumericScrub::end`, since the twin keeps the controls where Swift's `.id` remakes them, and later held moves wait for a fresh press). The sheets and panels follow in 12.12c2. `075a71b`'s narrowing fix in `AdjustPixels.c` is taken (the kernels match Swift's); its other changes and `725b934`'s are Swift's concurrency, plist and CI, with nothing to twin. Swift's `emptyCanvasLeavesTheScrubberCursorAloneAndRestoresTrackingForADocument` has no twin: the canvas never sets a global cursor, only its own. Equivalent mutant: removing the application filter when a drag ends (it then sees nothing to end). Tests: `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableTransformTests` (the Move bar, the zoom unit, the layer opacity), `ScrubFixtures.h`. -- Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. Known: a sheet destroyed while one of its fields holds the focus has that field commit into the sheet's destroyed members as Qt moves the focus; the app always hides a panel's content before deleting it, so the focus leaves first, and `ScrubFixtures`' typing helper leaves the field before the sheet goes. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles). +- Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. A sheet destroyed while one of its fields holds the focus used to commit into its destroyed members; 12.14c fixed that (`releaseFocus`), though the app hides a panel's content before deleting it anyway. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles). - Projects changed on disk (12.13a, Swift 1.3: `ed5ddea`, `cd4061a`, `cba6efc`): `IO/ProjectWatcher` twins Swift's class with a `QFileSystemWatcher` (inotify) on the package folder, its manifest and its images folder, where Swift opens kernel vnode sources: an event restarts a 300 ms single-shot delivery (Swift's coalescing) and re-arms every path from scratch each 100 ms, up to 20 times, until all three are watched (both timers are named children, `delivery` and `rearm`, so tests read them, and precise, since a coarse timer may fire 5% early where Swift's sleeps never end early; so is the controller's recheck); Qt's inotify engine subscribes to no `IN_MOVE_SELF`, where Swift's sources watch `.rename`, so a package exchanged for another one kept aside would pass unseen: the watcher also watches the package's parent folder and counts an event there only when the package path's device and inode differ from the ones it armed on, because a package swapped in (our store's `renameat2` exchange, another app's rename) retires the watched inodes. `IO/ProjectDigest` is a namespace: SHA-256 (`QCryptographicHash`) over the manifest's bytes, then each regular file in `images/` (links and folders skipped, names sorted by UTF-16 units) as its UTF-8 name and its size as a little-endian 64-bit number; a manifest that cannot be opened, or reads short of its size (a failed read, as Swift's `Data(contentsOf:)` throws), or an image gone mid-listing, throws, which the callers read as no digest (Swift's `try?`). `ProjectController+ExternalChanges.cpp` holds Swift's extension, its state `ExternalChangeState` (`ProjectController+ExternalChanges.h`) a public member, as Swift's: the watcher, the known digest, `checking`, `pending`, `saving`, the recheck `QTimer` (single-shot, a child of the controller), `recheckAttempt` and `reloadCount`. Swift's async loop runs as callbacks: each pass takes the pending flag, stops without a path, a document or while our own save writes, computes the digest off the UI thread (a `QFutureWatcher` child of the controller, so an answer dies with it), passes over a digest that matches, backs off at 250 ms × 2ⁿ (n to 7) while the project cannot start an operation, a transform is open or the workspace manages, reloads an unmodified document, and for unsaved work asks Swift's alert (Revert, Keep Mine) over the window, only for the front tab; without a window, or behind another tab, the change waits for `resumeExternalChangeCheck`, which the workspace's `select` calls. Keep Mine remembers the new digest. A reload (`reloadFromDisk`) marks the project busy, loads through the store off the UI thread, lands only on the same path with a document still open, calls `EditorSession::reloadProject`, remembers the digest as loaded and then counts the reload; a package that fails to load leaves the document alone and logs on `omaphoto.io`. `reloadProject` installs the snapshot and puts back the viewport, the collapsed folders that still exist, and the active layer with the selection's surviving members when the active layer still exists, as Swift's; history starts over. An open (the controller's own, which the workspace's tabs use) and a save remember the digest, then watch the package; a save marks `saving` from before its worker starts until the digest of what it wrote is remembered, or, when it fails, until its alert is answered (Swift's `defer`), so its own events pass; a new canvas and a close stop the watch. Swift's decoding from bytes in memory (`cba6efc`) has no twin: `QImageReader` decodes a PNG into memory the image owns, never mapped from the file, so a later save that replaces the file cannot reach an image kept for undo. Equivalent mutants: the try count's reset on a new event (a missing folder's creation is itself an event on the package, which re-arms), and the reload's two log lines. Tests: `ExternalChangeTests` (Swift's six; the revert question both ways, Keep Mine remembering, a background tab asking when brought forward, a busy project backing off then reloading, new canvas and close stopping the watch and its recheck, one check at a time, a check ending without a project or during our save), `ExternalChangeGateTests` (a failed save's guard, a managing workspace, the back-off's intervals, a load landing elsewhere, the reload's whole view and last signal, an unreadable manifest, Save As and open moving the watch, a second change while the question waits), `ExternalChangeFixtures.h`, `ProjectDigestRaceTests` (a binary of its own: `readdir` and `readdir64` take libc's place and remove an image as its folder's listing ends, so the digest's throw is ordered, not raced), `ProjectWatcherTests` (one report a burst, the quiet time from the last event, twenty re-arm tries, each watch speaking alone, an exchange keeping the old package, precise timing, re-arming and giving up, the digest's exact input and order, an unreadable manifest). - Editing while a project saves (12.13b, Swift 1.3: `940e188`): a save holds the tools only while it captures the document (`prepareSave`, Swift's: the revision, the snapshot, and the Save panel when there is no path yet or for Save As), then reserves the writer and only then frees the project, so no save asked in that signal slips in beside it, and writes in the background; a capture finds no document when Undo took it while the save waited (Swift's `guard let snapshot`), and the save answers false; `DocumentHistory::currentRevision` and `markSaved(QUuid)` twin Swift's, so only the captured version counts as saved: edits made while it writes stay modified, and undoing back to it is unmodified. Swift's `writing` task is `m_writing` with a list of waiters: `finishWriting` runs its callback at once when nothing writes, else when the write lands, each waiter posted to the event loop and passed through `finishWriting` again, so one writer runs at a time where Swift's store actor serialises the disk; `save` waits there before `begin`, so a second save writes what changed since, and `confirmReplacement` waits there first, so a close, a quit, a new canvas or an open never cuts a file short and asks only once the write is whole; an open also waits before its first read, and so does an external reload (12.13a's `reloadFromDisk`), since the twin's store is no actor and could read a package mid-save: a Revert answered while our save writes loads what we wrote. A digest that comes back while our save writes is looked at again once the write lands (pending, through `finishWriting`): after a save it matches what we wrote, after a failed one the change is taken up; Keep Mine remembers its digest only when no save landed while the question was up. A save queued behind a quit that already holds the tools is refused, as Swift's `begin` refuses it. A save that fails frees its waiters after its alert is answered. The refusal of a completed Save As name that exists sits in `prepareSave`, beside the panel. Swift has no tests for this commit: `ProjectSaveWhileEditingTests` pins it (edits while writing, a second save, a new canvas waiting, the panel alone holding the tools, a failed write, the last signal), and, over `tests/SaveHooks.h` (`fsync` slowed and counting overlapping writes, `renameat2` held at a package's swap, `readdir` and `readdir64` held at one folder, the store's loads counted from its log, every hold let go on cleanup), `ProjectSaveOrderTests` (a reopen reading nothing while the write is held, a Revert mid-save, the guard through the save's digest, Canvas Size and Image Size each committed before and after the held write lands, a digest out as our save begins, a failed save keeping an external change in line, a second save waiting through the first save's digest, Keep Mine after a save) and `ProjectSaveQueueTests` ( three saves one at a time, a save asked as the tools free, a quit waiting, a document undone away, a failed write holding its waiters, a reopen, a tab closing, a controller gone mid-write, an export beside a write). - The guide for agents (12.13c, Swift 1.3: `c317306`): `docs/writing-comp-files.md` twins Swift's guide and `docs/project-format.md` Swift's format page, both adapted to Linux (a project is a folder, the example lives under `~/Pictures`, the save swaps folders with `renameat2`, a replaced project folder is noticed too, sources named by the twin's files). Swift's top-level `AGENTS.md` for agents becomes the section at the head of this file. README gains Swift's lines (scrubbing, saving while editing, projects changed on disk, working with AI agents). The format page's claims follow the twin, where Swift's page is out of date: the pixel budgets scale with memory, the save's exchange has a two-step fallback, only `maskPlacement` needs a mask, the common adjustment fields are required and later kinds default to their own values, folders pass adjustments through, text becomes pixels after Canvas Size, Image Size and Crop, and a missing face may fall to another family. `WritingProjectsGuideTests` reads the guide itself: the minimal manifest loads with the fields it shows (visibility, opacity, blend, placement, the image named after its layer), the Curves layer's points are pinned and its rendered pixel is the background through those curves, removing any common adjustment field refuses the project, a folder passes an Invert through, `hsvSettings` wins over the legacy Hue/Saturation fields (the guide says where to edit a saved one), and the guide's lists of blend modes, adjustment kinds and samplings equal the twin's `rawValue`s. The guide asks for 8-bit RGBA layers and accepts what the store accepts: RGB, grayscale and lower-depth grayscale layers, never 16 bits. `ExternalChangeTests::theGuidesSameSizeRecipeReloads` runs the same-size recipe against a watched project. - Quitting while text is edited (12.14a, Swift 1.3.1: `a18d86c`, `afd622e`, `4a4cf85`, `221d6cc`): `ProjectWorkspace::confirmQuit` first applies every tab's open text in quit order (`finishTextEditing`, Swift's), so closing the window (the twin's quit) no longer stops at `canSwitch`, which an open draft refuses; text that cannot be applied (a busy project) stops the quit with the draft kept. Swift's `applicationShouldTerminate` guard has no twin: closing the window reaches `confirmQuit` directly. Swift's cursor commits (the arrow restored off the canvas, the I-beam kept across it while text is open) need no code here: a Qt widget owns its cursor, so leaving the canvas shows the next widget's, and the canvas already shows the Type tool's I-beam away from the box. Tests: `QuitWhileEditingTextTests` (Swift's two through the real window's close: the text applied as one `New Text Layer` step before the question, Cancel keeping the window, then Don't Save; every tab in quit order with its own content and its undo; a refused draft in the middle of three tabs stopping the quit where it stands, the tab before applied, the tab after untouched, nothing asked; edited existing text as one `Edit Text` step on the same layer; an unchanged existing draft and a blank new one closing with no step and no question), `InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas` (the I-beam away from the box, after a resize drag, no application-wide cursor on leaving, and neither the window nor a sibling taking the I-beam). - Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`. +- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped ". Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arrow keys walking every slider in both modes without dropping typing, resets keeping Preview off, a range under Colorize keeping its tracks, other buttons and a resting sheet refused, a reset that scrubs on, the sliders' thousand steps), `CameraRawSliderTests::aReshapeLeavesADragAlone`, `CameraRawSliderTests` (two-colour tracks read through the list), `FilterControlsTests` (titles one width, sixty at least). - 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e1514a..5af307a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,6 +114,7 @@ add_library(compositor_core STATIC src/UI/LayersPanel.cpp src/UI/LevelsSheet.cpp src/UI/HueSaturationSheet.cpp + src/UI/HueSaturationSheet+Spectrum.cpp src/UI/CurvesControls.cpp src/UI/NativeLayerList.cpp src/UI/NativeLayerList+Buttons.cpp @@ -136,6 +137,7 @@ add_library(compositor_core STATIC src/UI/CameraRawSlider.cpp src/UI/FilterSheet.cpp src/UI/FilterSheet+Adjustments.cpp + src/UI/FilterSheet+Controls.cpp src/UI/FilterSheet+Finishing.cpp src/UI/FloatingPanel.cpp src/UI/KeyboardShortcuts.cpp diff --git a/TASKS.md b/TASKS.md index 0c3795f..f799a2a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -391,7 +391,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.13c The guide for AI agents writing projects (`c317306`), README's lines. Codex review: 3 rounds. Round 1: thirteen claims Swift's pages make that the code does not keep (the same-size recipe, replacing a folder, required adjustment fields, folder pass-through, budgets, the save's fallback, text after resizes, fonts, masks, Alt-click) and weak example tests. Round 2: Hue/Saturation's `hsvSettings` precedence, the sampling list, PNG depths, a checked read. Round 3 passed. - [x] 12.14a Version 1.3.1: quitting and closing while text is edited (`a18d86c`, `afd622e`), the Type tool's I-beam over the canvas (`4a4cf85`, `221d6cc`). Codex review: 3 rounds, all test gaps (quit order and refusal across tabs, the real window's close, content and history, existing and blank drafts, the cursor outside the canvas before and after leaving) and one false equivalence claim; fixed and closed after the third. - [x] 12.14b Version 1.3.2: File > Open Recent (`73dc0f4`), Image Size keeping print sizes through an invalid resolution (`555417e`), Camera Raw's Color Grading under Color (`7e9afbe`); `d62a132` (always dark) judged against the Omarchy theme (holds: alerts follow the app's palette, which follows Omarchy by design). Codex review: 3 rounds; two display defects (a name's ampersand in the menu, a filename read as markup in the alert), two stale test expectations and one false equivalence claim in round 1, the rest test gaps (eye visibility and toggles by group, note boundaries, remembered resolution across units, ranges and resampling, the submenu's place and every gate, the list across processes); fixed and closed after the third. -- [ ] 12.14c Coloured slider tracks with double-click reset in Black & White, Color Balance and Hue/Saturation, filter sliders lined up (`6b709b4`, `c8ed9ba`, `0c969af`). +- [x] 12.14c Coloured slider tracks with double-click reset in Black & White, Color Balance and Hue/Saturation, filter sliders lined up (`6b709b4`, `c8ed9ba`, `0c969af`). A fix beyond Swift found by its tests: a sheet or Camera Raw group destroyed while an entry held the keys committed into freed members (`releaseFocus`). Codex review: passed in round 3; round 1 found Camera Raw groups still unsafe to destroy, Hue/Saturation's arrow keys stuck on rounding and right double clicks resetting, with test gaps; round 2 an untested repaint and two stale notes. - [ ] 12.14d Colouring only the selected letters (`2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`), sampling keeping a panel's focus (`810c88a`), filters waiting for text editing to finish (`d448625`). - [ ] 12.15 Version 1.3.3: the Dither filter. - [ ] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun. diff --git a/src/UI/CameraRawColorControls+Mixer.cpp b/src/UI/CameraRawColorControls+Mixer.cpp index c7bd28b..69b9184 100644 --- a/src/UI/CameraRawColorControls+Mixer.cpp +++ b/src/UI/CameraRawColorControls+Mixer.cpp @@ -1,4 +1,5 @@ #include "Rendering/EyedropperIcon.h" +#include "UI/ColorPickerSheet.h" #include "UI/CameraRawColorControls.h" #include "UI/LayerIcons.h" #include <QAbstractButton> @@ -315,3 +316,8 @@ void CameraRawMixerControls::synchronize() m_target->setChecked(shown.targetsMixer); m_target->setIcon(QIcon(LayerIcons::pixmap(LayerIcon::scope, 14, palette().color(QPalette::ButtonText), devicePixelRatioF()))); } + +CameraRawMixerControls::~CameraRawMixerControls() +{ + releaseFocus(*this); +} diff --git a/src/UI/CameraRawColorControls.cpp b/src/UI/CameraRawColorControls.cpp index 47bd2a0..2645c14 100644 --- a/src/UI/CameraRawColorControls.cpp +++ b/src/UI/CameraRawColorControls.cpp @@ -1,4 +1,5 @@ #include "UI/CameraRawColorControls.h" +#include "UI/ColorPickerSheet.h" #include "UI/CameraRawRow.h" #include "UI/LayerIcons.h" #include <QButtonGroup> @@ -353,3 +354,8 @@ void CameraRawCurveControls::synchronize() m_target->setChecked(edit && edit->rawPanel.targetsCurve); m_target->setIcon(QIcon(LayerIcons::pixmap(LayerIcon::scope, 14, palette().color(QPalette::ButtonText), devicePixelRatioF()))); } + +CameraRawCurveControls::~CameraRawCurveControls() +{ + releaseFocus(*this); +} diff --git a/src/UI/CameraRawColorControls.h b/src/UI/CameraRawColorControls.h index 4d3cd29..404c4c4 100644 --- a/src/UI/CameraRawColorControls.h +++ b/src/UI/CameraRawColorControls.h @@ -19,6 +19,7 @@ class CameraRawCurveControls : public QWidget { Q_OBJECT public: explicit CameraRawCurveControls(EditorSession &session, QWidget *parent = nullptr); + ~CameraRawCurveControls() override; void synchronize(); private: @@ -48,6 +49,7 @@ class CameraRawMixerControls : public QWidget { Q_OBJECT public: explicit CameraRawMixerControls(EditorSession &session, QWidget *parent = nullptr); + ~CameraRawMixerControls() override; void synchronize(); private: diff --git a/src/UI/CameraRawControls.cpp b/src/UI/CameraRawControls.cpp index c90f041..8fb53ea 100644 --- a/src/UI/CameraRawControls.cpp +++ b/src/UI/CameraRawControls.cpp @@ -1,4 +1,5 @@ #include "UI/CameraRawControls.h" +#include "UI/ColorPickerSheet.h" #include "UI/CameraRawColorControls.h" #include "UI/CameraRawDetailOpticsControls.h" #include "UI/CameraRawGeometryCalibrationControls.h" @@ -400,3 +401,8 @@ void CameraRawControls::synchronize() m_vignetteStyle->setCurrentIndex(int(settings.vignetteStyle)); } } + +CameraRawControls::~CameraRawControls() +{ + releaseFocus(*this); +} diff --git a/src/UI/CameraRawControls.h b/src/UI/CameraRawControls.h index 7145f7f..5c932a7 100644 --- a/src/UI/CameraRawControls.h +++ b/src/UI/CameraRawControls.h @@ -14,6 +14,7 @@ class CameraRawControls : public QWidget { Q_OBJECT public: explicit CameraRawControls(EditorSession &session, QWidget *parent = nullptr); + ~CameraRawControls() override; // Shows the edit as it now stands. void synchronize(); static constexpr int labelWidth = 96; diff --git a/src/UI/CameraRawDetailOpticsControls.cpp b/src/UI/CameraRawDetailOpticsControls.cpp index 721a1cc..df241ab 100644 --- a/src/UI/CameraRawDetailOpticsControls.cpp +++ b/src/UI/CameraRawDetailOpticsControls.cpp @@ -1,4 +1,5 @@ #include "UI/CameraRawDetailOpticsControls.h" +#include "UI/ColorPickerSheet.h" #include "Rendering/EyedropperIcon.h" #include "UI/CameraRawControls.h" #include <QCheckBox> @@ -252,3 +253,13 @@ void CameraRawOpticsControls::synchronize() for (const auto &[slider, key] : m_hueSliders) slider->display(optics.*key); } + +CameraRawDetailControls::~CameraRawDetailControls() +{ + releaseFocus(*this); +} + +CameraRawOpticsControls::~CameraRawOpticsControls() +{ + releaseFocus(*this); +} diff --git a/src/UI/CameraRawDetailOpticsControls.h b/src/UI/CameraRawDetailOpticsControls.h index 2b25285..e6d4714 100644 --- a/src/UI/CameraRawDetailOpticsControls.h +++ b/src/UI/CameraRawDetailOpticsControls.h @@ -13,6 +13,7 @@ class CameraRawDetailControls : public QWidget { Q_OBJECT public: explicit CameraRawDetailControls(EditorSession &session, QWidget *parent = nullptr); + ~CameraRawDetailControls() override; void synchronize(); private: @@ -32,6 +33,7 @@ class CameraRawOpticsControls : public QWidget { Q_OBJECT public: explicit CameraRawOpticsControls(EditorSession &session, QWidget *parent = nullptr); + ~CameraRawOpticsControls() override; void synchronize(); private: diff --git a/src/UI/CameraRawGeometryCalibrationControls.cpp b/src/UI/CameraRawGeometryCalibrationControls.cpp index 3baecf3..9fd4bd0 100644 --- a/src/UI/CameraRawGeometryCalibrationControls.cpp +++ b/src/UI/CameraRawGeometryCalibrationControls.cpp @@ -1,4 +1,5 @@ #include "UI/CameraRawGeometryCalibrationControls.h" +#include "UI/ColorPickerSheet.h" #include "UI/CameraRawColorControls.h" #include "UI/CameraRawControls.h" #include "UI/LayerIcons.h" @@ -213,3 +214,13 @@ void CameraRawCalibrationControls::synchronize() for (CameraRawRow *row : m_rows) row->synchronize(); } + +CameraRawGeometryControls::~CameraRawGeometryControls() +{ + releaseFocus(*this); +} + +CameraRawCalibrationControls::~CameraRawCalibrationControls() +{ + releaseFocus(*this); +} diff --git a/src/UI/CameraRawGeometryCalibrationControls.h b/src/UI/CameraRawGeometryCalibrationControls.h index ac03e47..7ee33e6 100644 --- a/src/UI/CameraRawGeometryCalibrationControls.h +++ b/src/UI/CameraRawGeometryCalibrationControls.h @@ -14,6 +14,7 @@ class CameraRawGeometryControls : public QWidget { Q_OBJECT public: explicit CameraRawGeometryControls(EditorSession &session, QWidget *parent = nullptr); + ~CameraRawGeometryControls() override; void synchronize(); private: @@ -35,6 +36,7 @@ class CameraRawCalibrationControls : public QWidget { Q_OBJECT public: explicit CameraRawCalibrationControls(EditorSession &session, QWidget *parent = nullptr); + ~CameraRawCalibrationControls() override; void synchronize(); private: diff --git a/src/UI/CameraRawRow.cpp b/src/UI/CameraRawRow.cpp index 27211f9..0416d41 100644 --- a/src/UI/CameraRawRow.cpp +++ b/src/UI/CameraRawRow.cpp @@ -89,3 +89,8 @@ bool CameraRawRow::eventFilter(QObject *watched, QEvent *event) } return QWidget::eventFilter(watched, event); } + +CameraRawRow::~CameraRawRow() +{ + releaseFocus(*this); +} diff --git a/src/UI/CameraRawRow.h b/src/UI/CameraRawRow.h index 33f969a..0f1f3e4 100644 --- a/src/UI/CameraRawRow.h +++ b/src/UI/CameraRawRow.h @@ -32,6 +32,7 @@ public: // `slide` takes the slider's own value, `type` a typed number. CameraRawRow(Spec spec, std::function<double()> value, std::function<void(double)> slide, std::function<void(double)> type, std::function<void()> reset, QWidget *parent = nullptr); + ~CameraRawRow() override; // Shows the value; a focused field keeps its typing. void synchronize(); diff --git a/src/UI/CameraRawSlider.cpp b/src/UI/CameraRawSlider.cpp index 95a75be..d0abcb9 100644 --- a/src/UI/CameraRawSlider.cpp +++ b/src/UI/CameraRawSlider.cpp @@ -17,16 +17,24 @@ QColor hsb(double degrees, double saturation, double brightness) } } -std::optional<std::pair<QColor, QColor>> CameraRawSliderTrack::colors() const +std::optional<std::vector<QColor>> CameraRawSliderTrack::colors() const { switch (kind) { case Kind::plain: return std::nullopt; - case Kind::temperature: return std::pair(QColor::fromRgbF(0.22f, 0.46f, 0.95f), QColor::fromRgbF(0.98f, 0.82f, 0.18f)); - case Kind::tint: return std::pair(QColor::fromRgbF(0.28f, 0.70f, 0.34f), QColor::fromRgbF(0.70f, 0.40f, 0.64f)); - case Kind::chroma: return std::pair(QColor::fromRgbF(0.62f, 0.62f, 0.64f), QColor::fromRgbF(0.86f, 0.18f, 0.20f)); - case Kind::hue: return std::pair(hsb(degrees - 50, 0.85, 0.9), hsb(degrees + 50, 0.85, 0.9)); - case Kind::saturation: return std::pair(QColor::fromRgbF(0.55f, 0.55f, 0.56f), hsb(degrees, 0.9, 0.9)); - case Kind::luminance: return std::pair(hsb(degrees, 0.55, 0.18), hsb(degrees, 0.35, 0.95)); + case Kind::temperature: return std::vector{QColor::fromRgbF(0.22f, 0.46f, 0.95f), QColor::fromRgbF(0.98f, 0.82f, 0.18f)}; + case Kind::tint: return std::vector{QColor::fromRgbF(0.28f, 0.70f, 0.34f), QColor::fromRgbF(0.70f, 0.40f, 0.64f)}; + case Kind::chroma: return std::vector{QColor::fromRgbF(0.62f, 0.62f, 0.64f), QColor::fromRgbF(0.86f, 0.18f, 0.20f)}; + case Kind::hue: return std::vector{hsb(degrees - 50, 0.85, 0.9), hsb(degrees + 50, 0.85, 0.9)}; + case Kind::saturation: return std::vector{QColor::fromRgbF(0.55f, 0.55f, 0.56f), hsb(degrees, 0.9, 0.9)}; + case Kind::luminance: return std::vector{hsb(degrees, 0.55, 0.18), hsb(degrees, 0.35, 0.95)}; + case Kind::opposing: return std::vector{from, to}; + case Kind::spectrum: { + // Every 30 degrees round the circle, that hue centred. + std::vector<QColor> circle; + for (int offset = -180; offset <= 180; offset += 30) + circle.push_back(hsb(degrees + offset, 0.85, 0.9)); + return circle; + } } throw std::logic_error("unknown slider track"); } @@ -52,6 +60,15 @@ void CameraRawSlider::display(double value) setValue(int(std::lround((value - m_low) / (m_high - m_low) * travel))); } +void CameraRawSlider::reshape(double low, double high, CameraRawSliderTrack track, double value) +{ + m_low = low; + m_high = high; + m_track = track; + display(value); + update(); +} + double CameraRawSlider::shown() const { return m_low + (m_high - m_low) * value() / travel; @@ -67,7 +84,7 @@ bool CameraRawSlider::isOnKnob(QPoint point) const // Swift's GradientSliderCell: the whole track shows the colours. void CameraRawSlider::paintEvent(QPaintEvent *event) { - const std::optional<std::pair<QColor, QColor>> colors = m_track.colors(); + const std::optional<std::vector<QColor>> colors = m_track.colors(); if (!colors) { QSlider::paintEvent(event); return; @@ -80,8 +97,8 @@ void CameraRawSlider::paintEvent(QPaintEvent *event) const double height = 4; const QRectF bar(groove.left(), groove.center().y() + 0.5 - height / 2, groove.width(), height); QLinearGradient gradient(bar.topLeft(), bar.topRight()); - gradient.setColorAt(0, colors->first); - gradient.setColorAt(1, colors->second); + for (size_t index = 0; index < colors->size(); ++index) + gradient.setColorAt(double(index) / double(colors->size() - 1), colors->at(index)); QPainterPath shape; shape.addRoundedRect(bar, height / 2, height / 2); painter.fillPath(shape, gradient); diff --git a/src/UI/CameraRawSlider.h b/src/UI/CameraRawSlider.h index be9e8cd..ee38c3e 100644 --- a/src/UI/CameraRawSlider.h +++ b/src/UI/CameraRawSlider.h @@ -4,15 +4,19 @@ #include <functional> #include <optional> #include <utility> +#include <vector> // Swift's CameraRawSliderTrack: a coloured track, or the system's. struct CameraRawSliderTrack { - enum class Kind { plain, temperature, tint, chroma, hue, saturation, luminance }; + enum class Kind { plain, temperature, tint, chroma, hue, saturation, luminance, opposing, spectrum }; Kind kind = Kind::plain; - // A family's centre, for hue, saturation and luminance. + // A family's centre, for hue, saturation, luminance and spectrum. double degrees = 0; - // Left and right colours; none keeps the system track. - std::optional<std::pair<QColor, QColor>> colors() const; + // Opposing's two ends, as Color Balance's Cyan / Red. + QColor from = {}; + QColor to = {}; + // Left-to-right colours, evenly spaced; none keeps the system track. + std::optional<std::vector<QColor>> colors() const; }; // Swift's CameraRawSlider: a double click on the knob resets. @@ -23,7 +27,10 @@ public: std::function<void()> reset, QWidget *parent = nullptr); // Shows a value, unless a drag holds the knob. void display(double value); + // Swift's updateNSView: new bounds and track, the value shown again. + void reshape(double low, double high, CameraRawSliderTrack track, double value); double shown() const; + const CameraRawSliderTrack &track() const { return m_track; } bool isOnKnob(QPoint point) const; protected: @@ -31,9 +38,9 @@ protected: void mouseDoubleClickEvent(QMouseEvent *event) override; private: - const double m_low; - const double m_high; - const CameraRawSliderTrack m_track; + double m_low; + double m_high; + CameraRawSliderTrack m_track; const std::function<void(double)> m_change; const std::function<void()> m_reset; }; diff --git a/src/UI/CanvasSizeSheet.cpp b/src/UI/CanvasSizeSheet.cpp index bbc3985..212e6dd 100644 --- a/src/UI/CanvasSizeSheet.cpp +++ b/src/UI/CanvasSizeSheet.cpp @@ -343,3 +343,8 @@ void CanvasSizeSheet::synchronize() m_customRow->setVisible(m_extension->currentIndex() == 5); m_ok->setEnabled(valid); } + +CanvasSizeSheet::~CanvasSizeSheet() +{ + releaseFocus(*this); +} diff --git a/src/UI/CanvasSizeSheet.h b/src/UI/CanvasSizeSheet.h index aa97e0a..0baa60b 100644 --- a/src/UI/CanvasSizeSheet.h +++ b/src/UI/CanvasSizeSheet.h @@ -20,6 +20,7 @@ class CanvasSizeSheet : public QWidget { public: CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background, std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent = nullptr); + ~CanvasSizeSheet() override; private: PickerField *dimension(bool widthAxis); diff --git a/src/UI/ColorPickerSheet.cpp b/src/UI/ColorPickerSheet.cpp index 1cab02b..39bd2c6 100644 --- a/src/UI/ColorPickerSheet.cpp +++ b/src/UI/ColorPickerSheet.cpp @@ -151,6 +151,13 @@ void PickerField::keyPressEvent(QKeyEvent *event) QLineEdit::keyPressEvent(event); } +void releaseFocus(QWidget &owner) +{ + // Destroyed focused, it would commit into freed members. + if (QWidget *focus = owner.focusWidget(); focus && focus->hasFocus()) + focus->clearFocus(); +} + void PickerField::focusOutEvent(QFocusEvent *event) { // A menu or popup borrows the focus: the typing stays. @@ -376,3 +383,8 @@ void ColorPickerPanelController::refocus() { FloatingPanel::refocus(identifier()); } + +ColorPickerSheet::~ColorPickerSheet() +{ + releaseFocus(*this); +} diff --git a/src/UI/ColorPickerSheet.h b/src/UI/ColorPickerSheet.h index 4cd4d6f..58f7009 100644 --- a/src/UI/ColorPickerSheet.h +++ b/src/UI/ColorPickerSheet.h @@ -22,11 +22,15 @@ private: const std::function<void(int)> m_step; }; +// An owner's destructor: its focused entry commits while members live. +void releaseFocus(QWidget &owner); + // Swift's ColorPickerSheet: field, hue strip, preview, entry. class ColorPickerSheet : public QWidget { Q_OBJECT public: ColorPickerSheet(EditorSession &session, std::function<void(bool)> finish, QWidget *parent = nullptr); + ~ColorPickerSheet() override; protected: void paintEvent(QPaintEvent *event) override; diff --git a/src/UI/EffectsSheet.cpp b/src/UI/EffectsSheet.cpp index c8a52e5..60f8507 100644 --- a/src/UI/EffectsSheet.cpp +++ b/src/UI/EffectsSheet.cpp @@ -282,3 +282,8 @@ void EffectsSheet::synchronize() control.field->setText(text); } } + +EffectsSheet::~EffectsSheet() +{ + releaseFocus(*this); +} diff --git a/src/UI/EffectsSheet.h b/src/UI/EffectsSheet.h index 227413b..9c84695 100644 --- a/src/UI/EffectsSheet.h +++ b/src/UI/EffectsSheet.h @@ -14,6 +14,7 @@ class EffectsSheet : public QWidget { Q_OBJECT public: EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget *parent = nullptr); + ~EffectsSheet() override; private: // Swift's slider: a title, a slider, a whole-number field. diff --git a/src/UI/FilterSheet+Adjustments.cpp b/src/UI/FilterSheet+Adjustments.cpp index 1a2e396..57aa3b1 100644 --- a/src/UI/FilterSheet+Adjustments.cpp +++ b/src/UI/FilterSheet+Adjustments.cpp @@ -8,19 +8,23 @@ void FilterSheet::blackWhite() { // Each slider: how bright that family of colours becomes. - const std::vector<std::pair<QString, double BlackWhiteSettings::*>> families{ - {QStringLiteral("Reds"), &BlackWhiteSettings::reds}, {QStringLiteral("Yellows"), &BlackWhiteSettings::yellows}, - {QStringLiteral("Greens"), &BlackWhiteSettings::greens}, {QStringLiteral("Cyans"), &BlackWhiteSettings::cyans}, - {QStringLiteral("Blues"), &BlackWhiteSettings::blues}, {QStringLiteral("Magentas"), &BlackWhiteSettings::magentas}}; - for (const auto &[title, field] : families) + using Kind = CameraRawSliderTrack::Kind; + const std::vector<std::tuple<QString, double BlackWhiteSettings::*, double>> families{ + {QStringLiteral("Reds"), &BlackWhiteSettings::reds, 0}, {QStringLiteral("Yellows"), &BlackWhiteSettings::yellows, 60}, + {QStringLiteral("Greens"), &BlackWhiteSettings::greens, 120}, {QStringLiteral("Cyans"), &BlackWhiteSettings::cyans, 180}, + {QStringLiteral("Blues"), &BlackWhiteSettings::blues, 240}, {QStringLiteral("Magentas"), &BlackWhiteSettings::magentas, 300}}; + for (const auto &[title, field, degrees] : families) control(title, [field](FilterSettings &settings) -> double & { return settings.blackWhite.*field; }, BlackWhiteSettings::low, - BlackWhiteSettings::high, QStringLiteral("%"), 0, false); + BlackWhiteSettings::high, QStringLiteral("%"), 0, false, + [degrees](const FilterSettings &) { return CameraRawSliderTrack{Kind::luminance, degrees}; }); flag(QStringLiteral("Tint"), QStringLiteral("Color the result while keeping its tones, for a sepia or a cyanotype"), [](FilterSettings &settings) -> bool & { return settings.blackWhite.tint; }); control(QStringLiteral("Hue"), [](FilterSettings &settings) -> double & { return settings.blackWhite.tintHue; }, 0, 360, QStringLiteral("°"), 0, - false); + false, [](const FilterSettings &) { return CameraRawSliderTrack{}; }); + // Saturation runs from gray to the tint's own hue. control(QStringLiteral("Saturation"), [](FilterSettings &settings) -> double & { return settings.blackWhite.tintSaturation; }, 0, 100, - QStringLiteral("%"), 0, false); + QStringLiteral("%"), 0, false, + [](const FilterSettings &settings) { return CameraRawSliderTrack{Kind::saturation, settings.blackWhite.tintHue}; }); for (size_t index = m_controls.size() - 2; index < m_controls.size(); ++index) m_shownWhen.emplace_back(m_controls[index].slider->parentWidget(), [](const FilterSettings &settings) { return settings.blackWhite.tint; }); } @@ -34,11 +38,12 @@ void FilterSheet::colorBalance() {QStringLiteral("Highlights"), {&ColorBalanceSettings::highlightCyanRed, &ColorBalanceSettings::highlightMagentaGreen, &ColorBalanceSettings::highlightYellowBlue}}}; const std::array<QString, 3> pairs{QStringLiteral("Cyan / Red"), QStringLiteral("Magenta / Green"), QStringLiteral("Yellow / Blue")}; + static constexpr std::array tracks{&FilterSheet::cyanRedTrack, &FilterSheet::magentaGreenTrack, &FilterSheet::yellowBlueTrack}; for (const auto &[range, fields] : ranges) { headline(range); for (size_t pair = 0; pair < 3; ++pair) control(pairs[pair], [field = fields[pair]](FilterSettings &settings) -> double & { return settings.colorBalance.*field; }, - ColorBalanceSettings::low, ColorBalanceSettings::high, QString(), 0, false); + ColorBalanceSettings::low, ColorBalanceSettings::high, QString(), 0, false, [pair](const FilterSettings &) { return tracks[pair](); }); } flag(QStringLiteral("Preserve Luminosity"), QStringLiteral("Put each pixel's brightness back afterwards, so only the color moves"), [](FilterSettings &settings) -> bool & { return settings.colorBalance.preserveLuminosity; }); diff --git a/src/UI/FilterSheet+Controls.cpp b/src/UI/FilterSheet+Controls.cpp new file mode 100644 index 0000000..4f4897c --- /dev/null +++ b/src/UI/FilterSheet+Controls.cpp @@ -0,0 +1,188 @@ +#include "UI/FilterSheet.h" +#include "UI/ColorPickerSheet.h" +#include "UI/NumericScrub.h" +#include <QEvent> +#include <QHBoxLayout> +#include <QLabel> +#include <QLocale> +#include <QMouseEvent> +#include <QSlider> +#include <QVBoxLayout> +#include <cmath> + +// Swift's `control`: title, slider, exact field; coloured tracks reset. +namespace { +// Swift's `.number`: at most `decimals` places, none trailing. +QString shown(double value, int decimals, QLocale locale) +{ + locale.setNumberOptions(QLocale::OmitGroupSeparator); + QString text = locale.toString(value, 'f', decimals); + if (decimals == 0) + return text; + while (text.endsWith(locale.zeroDigit())) + text.chop(locale.zeroDigit().size()); + if (text.endsWith(locale.decimalPoint())) + text.chop(locale.decimalPoint().size()); + return text; +} + +CameraRawSliderTrack opposing(QColor from, QColor to) +{ + return {.kind = CameraRawSliderTrack::Kind::opposing, .from = from, .to = to}; +} +} + +FilterSettings FilterSheet::resetting(const std::function<double &(FilterSettings &)> &key, FilterSettings settings) +{ + FilterSettings defaults; + key(settings) = key(defaults); + return settings; +} + +CameraRawSliderTrack FilterSheet::cyanRedTrack() +{ + return opposing(QColor::fromRgbF(0.10f, 0.72f, 0.80f), QColor::fromRgbF(0.86f, 0.18f, 0.20f)); +} + +CameraRawSliderTrack FilterSheet::magentaGreenTrack() +{ + return opposing(QColor::fromRgbF(0.80f, 0.22f, 0.70f), QColor::fromRgbF(0.24f, 0.70f, 0.30f)); +} + +CameraRawSliderTrack FilterSheet::yellowBlueTrack() +{ + return opposing(QColor::fromRgbF(0.95f, 0.82f, 0.18f), QColor::fromRgbF(0.22f, 0.40f, 0.92f)); +} + +void FilterSheet::control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit, + int decimals, bool logarithmic, std::function<CameraRawSliderTrack(const FilterSettings &)> track) +{ + if (track && logarithmic) + throw std::logic_error("a coloured track is linear"); + QString name = title; + name.remove(QLatin1Char(' ')); + name[0] = name[0].toLower(); + const size_t index = m_controls.size(); + auto *box = new QWidget(this); + auto *label = new QLabel(title, box); + // Swift's scrubbable title: a point is the field's last decimal. + new NumericScrub(label, {.sensitivity = 1 / std::pow(10.0, decimals), .low = low, .high = high, .step = std::nullopt, + .value = [this, index] { + FilterSettings current = settings(); + return m_controls[index].key(current); + }, + .set = [this, index](double value) { + update([this, index, value](FilterSettings &settings) { m_controls[index].key(settings) = value; }); + m_controls[index].field->setModified(false); + synchronize(); + }}); + // Swift rounds what the slider sets to the field's decimals. + const auto slide = [this, index](double value) { + const Control &at = m_controls[index]; + const double step = std::pow(10.0, at.decimals), rounded = std::round(value * step) / step; + // Unchanged, the thumb stays, so arrow steps add up. + FilterSettings current = settings(); + if (at.key(current) == rounded) + return; + update([&at, rounded](FilterSettings &settings) { at.key(settings) = rounded; }); + // Its value replaces the field's typing. + at.field->setModified(false); + synchronize(); + }; + QSlider *slider = nullptr; + if (track) { + slider = new CameraRawSlider(low, high, track(settings()), title + QStringLiteral(". Double-click to reset."), slide, [this, index] { reset(index); }, + box); + // After the scrub's filter, so this one runs first. + label->installEventFilter(this); + } else { + slider = new QSlider(Qt::Horizontal, box); + slider->setRange(0, travel); + connect(slider, &QSlider::valueChanged, this, [this, index, slide](int position) { + const Control &at = m_controls[index]; + const double from = at.logarithmic ? std::log(at.low) : at.low, to = at.logarithmic ? std::log(at.high) : at.high; + const double value = from + (to - from) * position / travel; + slide(at.logarithmic ? std::exp(value) : value); + }); + } + slider->setObjectName(name + QStringLiteral("Slider")); + label->setBuddy(slider); + // A readable number applies as typed; normalizing clamps it. + auto *field = new PickerField([this, index] { + PickerField &edited = *m_controls[index].field; + bool number = false; + const double typed = edited.locale().toDouble(edited.text(), &number); + if (edited.isModified() && number && std::isfinite(typed)) + update([this, index, typed](FilterSettings &settings) { m_controls[index].key(settings) = typed; }); + edited.setModified(false); + synchronize(); + }, nullptr, box); + field->setObjectName(name + QStringLiteral("Field")); + field->setAccessibleName(title); + field->setPlaceholderText(title); + field->setAlignment(Qt::AlignRight); + field->setFixedWidth(56); + auto *suffixed = new QHBoxLayout; + suffixed->setSpacing(2); + suffixed->addWidget(field); + suffixed->addWidget(new QLabel(unit, box)); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(10); + row->addWidget(label); + row->addWidget(slider, 1); + row->addLayout(suffixed); + m_column->addWidget(box); + m_controls.push_back(Control{std::move(key), low, high, decimals, logarithmic, slider, field, label, std::move(track)}); +} + +void FilterSheet::showControl(Control &control, const FilterSettings &settings) +{ + FilterSettings copy = settings; + const double value = control.key(copy); + if (control.track) { + // Swift's updateNSView: the track follows the settings. + static_cast<CameraRawSlider *>(control.slider)->reshape(control.low, control.high, control.track(settings), value); + } else { + const double from = control.logarithmic ? std::log(control.low) : control.low, to = control.logarithmic ? std::log(control.high) : control.high; + const double at = control.logarithmic ? std::log(value) : value; + // The slider shows the session's number without writing back. + const QSignalBlocker quiet(control.slider); + control.slider->setValue(int(std::lround((at - from) / (to - from) * travel))); + } + // A field being typed in keeps its typing. + const QString text = shown(value, control.decimals, control.field->locale()); + if (!control.field->isModified() && control.field->text() != text) + control.field->setText(text); +} + +void FilterSheet::alignTitles() +{ + int widest = 60; + for (const Control &control : m_controls) { + if (!control.slider->parentWidget()->isHidden()) + widest = std::max(widest, control.title->sizeHint().width()); + } + for (const Control &control : m_controls) + control.title->setFixedWidth(widest); +} + +void FilterSheet::reset(size_t index) +{ + update([this, index](FilterSettings &settings) { settings = resetting(m_controls[index].key, settings); }); + m_controls[index].field->setModified(false); + synchronize(); +} + +// Swift's double click on a coloured slider's title resets it. +bool FilterSheet::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::MouseButtonDblClick && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton) { + for (size_t index = 0; index < m_controls.size(); ++index) { + if (m_controls[index].title == watched && m_controls[index].track && m_controls[index].title->isEnabled()) + reset(index); + } + } + // The scrub still takes the second press, as Swift's drag. + return QWidget::eventFilter(watched, event); +} diff --git a/src/UI/FilterSheet.cpp b/src/UI/FilterSheet.cpp index 648ac3a..c873fae 100644 --- a/src/UI/FilterSheet.cpp +++ b/src/UI/FilterSheet.cpp @@ -21,9 +21,6 @@ #include <cmath> namespace { -// A slider's travel: thousandths of its range. -constexpr int travel = 1000; - QLabel *words(const QString &text, bool callout, QWidget *parent) { auto *label = new QLabel(text, parent); @@ -39,20 +36,6 @@ QLabel *words(const QString &text, bool callout, QWidget *parent) return label; } -// Swift's `.number`: at most `decimals` places, none trailing. -QString shown(double value, int decimals, QLocale locale) -{ - locale.setNumberOptions(QLocale::OmitGroupSeparator); - QString text = locale.toString(value, 'f', decimals); - if (decimals == 0) - return text; - while (text.endsWith(locale.zeroDigit())) - text.chop(locale.zeroDigit().size()); - if (text.endsWith(locale.decimalPoint())) - text.chop(locale.decimalPoint().size()); - return text; -} - QColor colour(const AdjustmentColor &value) { return QColor::fromRgbF(float(value.red), float(value.green), float(value.blue)); @@ -247,76 +230,6 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent) synchronize(); } -void FilterSheet::control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit, - int decimals, bool logarithmic) -{ - QString name = title; - name.remove(QLatin1Char(' ')); - name[0] = name[0].toLower(); - const size_t index = m_controls.size(); - auto *box = new QWidget(this); - auto *label = new QLabel(title, box); - label->setMinimumWidth(60); - label->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - // Swift's scrubbable title: a point is the field's last decimal. - new NumericScrub(label, {.sensitivity = 1 / std::pow(10.0, decimals), .low = low, .high = high, .step = std::nullopt, - .value = [this, index] { - FilterSettings current = settings(); - return m_controls[index].key(current); - }, - .set = [this, index](double value) { - update([this, index, value](FilterSettings &settings) { m_controls[index].key(settings) = value; }); - m_controls[index].field->setModified(false); - synchronize(); - }}); - auto *slider = new QSlider(Qt::Horizontal, box); - slider->setObjectName(name + QStringLiteral("Slider")); - slider->setRange(0, travel); - label->setBuddy(slider); - // Swift rounds what the slider sets to the field's decimals. - connect(slider, &QSlider::valueChanged, this, [this, index](int position) { - const Control &at = m_controls[index]; - const double from = at.logarithmic ? std::log(at.low) : at.low, to = at.logarithmic ? std::log(at.high) : at.high; - const double value = from + (to - from) * position / travel, step = std::pow(10.0, at.decimals); - const double rounded = std::round((at.logarithmic ? std::exp(value) : value) * step) / step; - // Unchanged, the thumb stays, so arrow steps add up. - FilterSettings current = settings(); - if (at.key(current) == rounded) - return; - update([&at, rounded](FilterSettings &settings) { at.key(settings) = rounded; }); - // Its value replaces the field's typing. - at.field->setModified(false); - synchronize(); - }); - // A readable number applies as typed; normalizing clamps it. - auto *field = new PickerField([this, index] { - PickerField &edited = *m_controls[index].field; - bool number = false; - const double typed = edited.locale().toDouble(edited.text(), &number); - if (edited.isModified() && number && std::isfinite(typed)) - update([this, index, typed](FilterSettings &settings) { m_controls[index].key(settings) = typed; }); - edited.setModified(false); - synchronize(); - }, nullptr, box); - field->setObjectName(name + QStringLiteral("Field")); - field->setAccessibleName(title); - field->setPlaceholderText(title); - field->setAlignment(Qt::AlignRight); - field->setFixedWidth(56); - auto *suffixed = new QHBoxLayout; - suffixed->setSpacing(2); - suffixed->addWidget(field); - suffixed->addWidget(new QLabel(unit, box)); - auto *row = new QHBoxLayout(box); - row->setContentsMargins(0, 0, 0, 0); - row->setSpacing(10); - row->addWidget(label); - row->addWidget(slider, 1); - row->addLayout(suffixed); - m_column->addWidget(box); - m_controls.push_back(Control{std::move(key), low, high, decimals, logarithmic, slider, field}); -} - void FilterSheet::noise() { auto *box = new QWidget(this); @@ -408,21 +321,8 @@ void FilterSheet::synchronize() if (m_vignetteSwatch) m_vignetteSwatch->update(); const FilterEdit &edit = m_session.filterEdit().value(); - for (Control &control : m_controls) { - FilterSettings copy = edit.settings; - const double value = control.key(copy); - const double from = control.logarithmic ? std::log(control.low) : control.low, to = control.logarithmic ? std::log(control.high) : control.high; - const double at = control.logarithmic ? std::log(value) : value; - { - // The slider shows the session's number without writing back. - const QSignalBlocker quiet(control.slider); - control.slider->setValue(int(std::lround((at - from) / (to - from) * travel))); - } - // A field being typed in keeps its typing. - const QString text = shown(value, control.decimals, control.field->locale()); - if (!control.field->isModified() && control.field->text() != text) - control.field->setText(text); - } + for (Control &control : m_controls) + showControl(control, edit.settings); if (m_curves) m_curves->synchronize(); if (m_gradientMap) @@ -439,6 +339,7 @@ void FilterSheet::synchronize() } for (const auto &[row, shows] : m_shownWhen) row->setVisible(shows(edit.settings)); + alignTitles(); m_preview->setChecked(edit.preview); m_error->setText(edit.previewError.value_or(QString())); m_error->setVisible(edit.previewError.has_value()); @@ -452,3 +353,8 @@ void FilterSheet::synchronize() m_ok->setEnabled(!(isAutomatic(edit.kind) && (edit.preparing || edit.previewError))); setEnabled(!edit.committing); } + +FilterSheet::~FilterSheet() +{ + releaseFocus(*this); +} diff --git a/src/UI/FilterSheet.h b/src/UI/FilterSheet.h index 8c38088..c4b76e0 100644 --- a/src/UI/FilterSheet.h +++ b/src/UI/FilterSheet.h @@ -1,5 +1,6 @@ #pragma once #include "Document/EditorSession.h" +#include "UI/CameraRawSlider.h" #include <QWidget> class CurvesControls; @@ -38,6 +39,16 @@ class FilterSheet : public QWidget { Q_OBJECT public: explicit FilterSheet(EditorSession &session, QWidget *parent = nullptr); + ~FilterSheet() override; + // Swift's resetting: that setting back to the filter's default. + static FilterSettings resetting(const std::function<double &(FilterSettings &)> &key, FilterSettings settings); + // Color Balance's tracks, each colour to its opposite. + static CameraRawSliderTrack cyanRedTrack(); + static CameraRawSliderTrack magentaGreenTrack(); + static CameraRawSliderTrack yellowBlueTrack(); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; private: // Swift's control: a slider, an exact field and its unit. @@ -49,9 +60,18 @@ private: bool logarithmic; QSlider *slider; PickerField *field; + QLabel *title; + // A coloured track, as Camera Raw's; a double click resets. + std::function<CameraRawSliderTrack(const FilterSettings &)> track; }; + // A slider's travel: thousandths of its range. + static constexpr int travel = 1000; void control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit, - int decimals, bool logarithmic); + int decimals, bool logarithmic, std::function<CameraRawSliderTrack(const FilterSettings &)> track = {}); + void showControl(Control &control, const FilterSettings &settings); + // Swift's LabelWidthKey: every shown title as wide as the widest. + void alignTitles(); + void reset(size_t index); void noise(); // Vignette's colour and rows, Bloom / Glow's, Tonal Contrast's. void finishing(FilterKind kind); diff --git a/src/UI/HueSaturationSheet+Spectrum.cpp b/src/UI/HueSaturationSheet+Spectrum.cpp new file mode 100644 index 0000000..0784cbb --- /dev/null +++ b/src/UI/HueSaturationSheet+Spectrum.cpp @@ -0,0 +1,141 @@ +#include "UI/HueSaturationSheet.h" +#include <QLabel> +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <QVBoxLayout> +#include <algorithm> +#include <cmath> + +// Swift's SpectrumEditor: the bars, their handles and the readout. +namespace { +// Swift's 72 hue slices, as they are or adjusted. +class Spectrum : public QWidget { +public: + Spectrum(std::function<HueSaturationSettings()> value, bool after, QWidget *parent) : QWidget(parent), m_value(std::move(value)), m_after(after) + { + setFixedHeight(16); + } + +protected: + void paintEvent(QPaintEvent *) override + { + constexpr int slices = 72; + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QPainterPath rounded; + rounded.addRoundedRect(QRectF(rect()), 3, 3); + painter.setClipPath(rounded); + const HueSaturationSettings settings = m_value(); + const double width = double(this->width()) / slices; + for (int slice = 0; slice < slices; ++slice) { + const double hue = double(slice) / slices * 360; + const double shown = m_after ? HueSaturationFilter::shiftedHue(hue, settings) : hue; + painter.fillRect(QRectF(slice * width, 0, width + 0.5, height()), QColor::fromHsvF(float(shown / 360), 1, 1)); + } + } + +private: + const std::function<HueSaturationSettings()> m_value; + const bool m_after; +}; + +// Swift's handles: shoulders as blocks, the core's ends as bars. +class SpectrumHandles : public QWidget { +public: + SpectrumHandles(std::function<HueSaturationSettings()> value, std::function<void(double, double, bool)> drag, QWidget *parent) + : QWidget(parent), m_value(std::move(value)), m_drag(std::move(drag)) + { + setFixedHeight(12); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const std::array<double, 4> handles = m_value().band().handles(); + for (size_t index = 0; index < handles.size(); ++index) { + const double x = handles[index] / 360 * width(); + const bool inner = index == 1 || index == 2; + painter.fillRect(inner ? QRectF(x - 1, 0, 2, height()) : QRectF(x - 3.5, height() / 2.0 - 2.5, 7, 5), palette().color(QPalette::WindowText)); + } + } + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) + m_drag(event->position().x(), width(), true); + } + void mouseMoveEvent(QMouseEvent *event) override + { + if (event->buttons().testFlag(Qt::LeftButton)) + m_drag(event->position().x(), width(), false); + } + +private: + const std::function<HueSaturationSettings()> m_value; + const std::function<void(double, double, bool)> m_drag; +}; + +// The nearest handle round the circle; ties take the first. +int nearestHandle(const HueBand &band, double degrees) +{ + const std::array<double, 4> handles = band.handles(); + std::array<double, 4> distances{}; + for (size_t index = 0; index < handles.size(); ++index) { + const double gap = std::fmod(std::abs(handles[index] - degrees), 360); + distances[index] = std::min(gap, 360 - gap); + } + return int(std::min_element(distances.begin(), distances.end()) - distances.begin()); +} +} + +SpectrumEditor::SpectrumEditor(std::function<HueSaturationSettings()> value, std::function<void(const HueSaturationSettings &)> change, QWidget *parent) + : QWidget(parent), m_value(std::move(value)), m_change(std::move(change)), + m_handles(new SpectrumHandles(m_value, [this](double x, double width, bool pressed) { drag(x, width, pressed); }, this)), + m_after(new Spectrum(m_value, true, this)), m_readout(new QLabel(this)) +{ + auto *before = new Spectrum(m_value, false, this); + before->setObjectName(QStringLiteral("spectrumBefore")); + m_handles->setObjectName(QStringLiteral("spectrumHandles")); + m_after->setObjectName(QStringLiteral("spectrumAfter")); + m_readout->setObjectName(QStringLiteral("spectrumReadout")); + // Swift's .caption in the secondary ink. + QFont small = m_readout->font(); + small.setPixelSize(10); + m_readout->setFont(small); + m_readout->setForegroundRole(QPalette::PlaceholderText); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(0, 0, 0, 0); + column->setSpacing(5); + column->addWidget(before); + column->addWidget(m_handles); + column->addWidget(m_after); + column->addWidget(m_readout, 0, Qt::AlignHCenter); + synchronize(); +} + +void SpectrumEditor::synchronize() +{ + QStringList degrees; + for (const double handle : m_value().band().handles()) + degrees << QStringLiteral("%1°").arg(std::lround(handle)); + m_readout->setText(degrees.join(QStringLiteral(" "))); + m_handles->update(); + m_after->update(); +} + +void SpectrumEditor::drag(double x, double width, bool pressed) +{ + // A press starts afresh: Qt can lose a release. + if (pressed) + m_dragging.reset(); + const double degrees = std::clamp(x, 0.0, width) / width * 360; + HueSaturationSettings settings = m_value(); + HueBand band = settings.band(); + const int index = m_dragging.value_or(nearestHandle(band, degrees)); + m_dragging = index; + band.setHandle(index, degrees); + settings.setBand(band); + m_change(settings); +} diff --git a/src/UI/HueSaturationSheet.cpp b/src/UI/HueSaturationSheet.cpp index 03fc737..b5cc4ab 100644 --- a/src/UI/HueSaturationSheet.cpp +++ b/src/UI/HueSaturationSheet.cpp @@ -12,7 +12,6 @@ #include <QPainter> #include <QPainterPath> #include <QPushButton> -#include <QSlider> #include <QToolButton> #include <QVBoxLayout> #include <algorithm> @@ -30,9 +29,6 @@ constexpr std::array<Row, 3> rows{{{"Hue", "°", &HueSaturationSettings::hue, &H {"Saturation", "", &HueSaturationSettings::saturation, &HueSaturationSettings::setSaturation}, {"Lightness", "", &HueSaturationSettings::lightness, &HueSaturationSettings::setLightness}}}; -// Sliders move in hundredths, as Swift's run freely. -constexpr double sliderScale = 100; - // Swift's ranges: Colorize sets hue and saturation outright. std::pair<double, double> bounds(size_t index, bool colorize) { @@ -43,92 +39,29 @@ std::pair<double, double> bounds(size_t index, bool colorize) return {-100.0, 100.0}; } +// Swift's tracks: hue circle, gray to colour, black to white. +CameraRawSliderTrack track(size_t index, const HueSaturationSettings &settings) +{ + using Kind = CameraRawSliderTrack::Kind; + // The middle of the chosen range; Master centres on red. + const auto found = std::find(colorRanges.begin(), colorRanges.end(), settings.range); + const double rangeHue = found == colorRanges.end() ? 0 : double(found - colorRanges.begin()) * 60; + if (index == 0) + return {Kind::spectrum, settings.colorize ? 180 : rangeHue}; + if (index == 1) { + if (settings.colorize) + return {Kind::saturation, settings.hue()}; + return settings.range == ColorRange::master ? CameraRawSliderTrack{Kind::chroma} : CameraRawSliderTrack{Kind::saturation, rangeHue}; + } + return {.kind = Kind::opposing, .from = Qt::black, .to = Qt::white}; +} + // Swift's `current`: the open edit's settings, else the defaults. HueSaturationSettings current(const EditorSession &session) { return session.hueSaturation() ? session.hueSaturation()->settings : HueSaturationSettings(); } -// Swift's 72 hue slices, as they are or adjusted. -class Spectrum : public QWidget { -public: - Spectrum(std::function<HueSaturationSettings()> value, bool after, QWidget *parent) : QWidget(parent), m_value(std::move(value)), m_after(after) - { - setFixedHeight(16); - } - -protected: - void paintEvent(QPaintEvent *) override - { - constexpr int slices = 72; - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - QPainterPath rounded; - rounded.addRoundedRect(QRectF(rect()), 3, 3); - painter.setClipPath(rounded); - const HueSaturationSettings settings = m_value(); - const double width = double(this->width()) / slices; - for (int slice = 0; slice < slices; ++slice) { - const double hue = double(slice) / slices * 360; - const double shown = m_after ? HueSaturationFilter::shiftedHue(hue, settings) : hue; - painter.fillRect(QRectF(slice * width, 0, width + 0.5, height()), QColor::fromHsvF(float(shown / 360), 1, 1)); - } - } - -private: - const std::function<HueSaturationSettings()> m_value; - const bool m_after; -}; - -// Swift's handles: shoulders as blocks, the core's ends as bars. -class SpectrumHandles : public QWidget { -public: - SpectrumHandles(std::function<HueSaturationSettings()> value, std::function<void(double, double, bool)> drag, QWidget *parent) - : QWidget(parent), m_value(std::move(value)), m_drag(std::move(drag)) - { - setFixedHeight(12); - } - -protected: - void paintEvent(QPaintEvent *) override - { - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - const std::array<double, 4> handles = m_value().band().handles(); - for (size_t index = 0; index < handles.size(); ++index) { - const double x = handles[index] / 360 * width(); - const bool inner = index == 1 || index == 2; - painter.fillRect(inner ? QRectF(x - 1, 0, 2, height()) : QRectF(x - 3.5, height() / 2.0 - 2.5, 7, 5), palette().color(QPalette::WindowText)); - } - } - void mousePressEvent(QMouseEvent *event) override - { - if (event->button() == Qt::LeftButton) - m_drag(event->position().x(), width(), true); - } - void mouseMoveEvent(QMouseEvent *event) override - { - if (event->buttons().testFlag(Qt::LeftButton)) - m_drag(event->position().x(), width(), false); - } - -private: - const std::function<HueSaturationSettings()> m_value; - const std::function<void(double, double, bool)> m_drag; -}; - -// The nearest handle round the circle; ties take the first. -int nearestHandle(const HueBand &band, double degrees) -{ - const std::array<double, 4> handles = band.handles(); - std::array<double, 4> distances{}; - for (size_t index = 0; index < handles.size(); ++index) { - const double gap = std::fmod(std::abs(handles[index] - degrees), 360); - distances[index] = std::min(gap, 360 - gap); - } - return int(std::min_element(distances.begin(), distances.end()) - distances.begin()); -} - // Swift's plain sampling buttons: a glyph, tinted when chosen. class SampleButton : public QToolButton { public: @@ -216,56 +149,6 @@ QFrame *line(QFrame::Shape shape, QWidget *parent) } } -SpectrumEditor::SpectrumEditor(std::function<HueSaturationSettings()> value, std::function<void(const HueSaturationSettings &)> change, QWidget *parent) - : QWidget(parent), m_value(std::move(value)), m_change(std::move(change)), - m_handles(new SpectrumHandles(m_value, [this](double x, double width, bool pressed) { drag(x, width, pressed); }, this)), - m_after(new Spectrum(m_value, true, this)), m_readout(new QLabel(this)) -{ - auto *before = new Spectrum(m_value, false, this); - before->setObjectName(QStringLiteral("spectrumBefore")); - m_handles->setObjectName(QStringLiteral("spectrumHandles")); - m_after->setObjectName(QStringLiteral("spectrumAfter")); - m_readout->setObjectName(QStringLiteral("spectrumReadout")); - // Swift's .caption in the secondary ink. - QFont small = m_readout->font(); - small.setPixelSize(10); - m_readout->setFont(small); - m_readout->setForegroundRole(QPalette::PlaceholderText); - auto *column = new QVBoxLayout(this); - column->setContentsMargins(0, 0, 0, 0); - column->setSpacing(5); - column->addWidget(before); - column->addWidget(m_handles); - column->addWidget(m_after); - column->addWidget(m_readout, 0, Qt::AlignHCenter); - synchronize(); -} - -void SpectrumEditor::synchronize() -{ - QStringList degrees; - for (const double handle : m_value().band().handles()) - degrees << QStringLiteral("%1°").arg(std::lround(handle)); - m_readout->setText(degrees.join(QStringLiteral(" "))); - m_handles->update(); - m_after->update(); -} - -void SpectrumEditor::drag(double x, double width, bool pressed) -{ - // A press starts afresh: Qt can lose a release. - if (pressed) - m_dragging.reset(); - const double degrees = std::clamp(x, 0.0, width) / width * 360; - HueSaturationSettings settings = m_value(); - HueBand band = settings.band(); - const int index = m_dragging.value_or(nearestHandle(band, degrees)); - m_dragging = index; - band.setHandle(index, degrees); - settings.setBand(band); - m_change(settings); -} - HueSaturationSheet::HueSaturationSheet(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session), m_range(new QComboBox(this)), m_samples{sampleButton(HueSampleMode::replace, this), sampleButton(HueSampleMode::add, this), sampleButton(HueSampleMode::remove, this)}, @@ -382,15 +265,25 @@ QWidget *HueSaturationSheet::row(size_t index) m_fields[index]->setModified(false); synchronize(); }}); - auto *slider = new QSlider(Qt::Horizontal, box); + const auto [low, high] = bounds(index, current(m_session).colorize); + auto *slider = new CameraRawSlider( + low, high, track(index, current(m_session)), title + QStringLiteral(". Double-click to reset."), + [this, index](double value) { + // Swift sets whole numbers; unchanged, the knob keeps its travel. + const double rounded = std::round(value); + if ((current(m_session).*rows[index].value)() == rounded) + return; + change([&](HueSaturationSettings &settings) { (settings.*rows[index].set)(rounded); }); + // Its value replaces the field's typing. + m_fields[index]->setModified(false); + synchronize(); + }, + [this, index] { reset(index); }, box); slider->setObjectName(title.toLower() + QStringLiteral("Slider")); label->setBuddy(slider); - connect(slider, &QSlider::valueChanged, this, [this, index](int value) { - change([&](HueSaturationSettings &settings) { (settings.*rows[index].set)(value / sliderScale); }); - // Its value replaces the field's typing. - m_fields[index]->setModified(false); - synchronize(); - }); + // After the scrub's filter, so this one runs first. + label->installEventFilter(this); + m_titles[index] = label; // A readable number applies as typed, as Swift's value binding. auto *field = new PickerField([this, index] { PickerField &edited = *m_fields[index]; @@ -426,6 +319,29 @@ QWidget *HueSaturationSheet::row(size_t index) return box; } +void HueSaturationSheet::reset(size_t index) +{ + change([index](HueSaturationSettings &settings) { + const HueSaturationSettings start = settings.colorize ? HueSaturationSettings::colorizeStart() : HueSaturationSettings(); + (settings.*rows[index].set)((start.*rows[index].value)()); + }); + m_fields[index]->setModified(false); + synchronize(); +} + +// Swift's double click on a title resets its slider. +bool HueSaturationSheet::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::MouseButtonDblClick && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton) { + for (size_t index = 0; index < m_titles.size(); ++index) { + if (m_titles[index] == watched && m_titles[index]->isEnabled()) + reset(index); + } + } + // The scrub still takes the second press, as Swift's drag. + return QWidget::eventFilter(watched, event); +} + void HueSaturationSheet::change(const std::function<void(HueSaturationSettings &)> &edit) { HueSaturationSettings settings = current(m_session); @@ -454,12 +370,8 @@ void HueSaturationSheet::synchronize() const auto [low, high] = bounds(index, settings.colorize); m_scrubs[index]->reshape(1, low, high); const double value = (settings.*rows[index].value)(); - { - // The sliders show the session's numbers without writing back. - const QSignalBlocker quiet(m_sliders[index]); - m_sliders[index]->setRange(int(low * sliderScale), int(high * sliderScale)); - m_sliders[index]->setValue(int(std::lround(std::clamp(value, low, high) * sliderScale))); - } + // Swift's updateNSView: range and track follow the settings. + m_sliders[index]->reshape(low, high, track(index, settings), std::clamp(value, low, high)); // Ungrouped digits; a field being typed in keeps its typing. QLocale locale = m_fields[index]->locale(); locale.setNumberOptions(QLocale::OmitGroupSeparator); @@ -476,3 +388,8 @@ void HueSaturationSheet::synchronize() // An adjustment layer's editor ignores the selection. m_limited->setVisible(!m_session.adjustmentOriginal() && m_session.selection()); } + +HueSaturationSheet::~HueSaturationSheet() +{ + releaseFocus(*this); +} diff --git a/src/UI/HueSaturationSheet.h b/src/UI/HueSaturationSheet.h index 5758eff..498708f 100644 --- a/src/UI/HueSaturationSheet.h +++ b/src/UI/HueSaturationSheet.h @@ -1,5 +1,6 @@ #pragma once #include "Document/EditorSession.h" +#include "UI/CameraRawSlider.h" #include <QWidget> #include <array> #include <functional> @@ -40,12 +41,18 @@ class HueSaturationSheet : public QWidget { Q_OBJECT public: explicit HueSaturationSheet(EditorSession &session, QWidget *parent = nullptr); + ~HueSaturationSheet() override; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; private: // Swift's settings binding: changed, then handed to the session. void change(const std::function<void(HueSaturationSettings &)> &edit); // Swift's slider row: title, slider, field and unit. QWidget *row(size_t index); + // Swift's resetValues: that row back to Colorize's start or none. + void reset(size_t index); void synchronize(); EditorSession &m_session; @@ -54,7 +61,8 @@ private: QWidget *const m_divider; QToolButton *const m_targeting; // Hue, Saturation, Lightness; the rows make them. - std::array<QSlider *, 3> m_sliders{}; + std::array<CameraRawSlider *, 3> m_sliders{}; + std::array<QLabel *, 3> m_titles{}; std::array<PickerField *, 3> m_fields{}; // Each title's scrub; Colorize changes its range. std::array<NumericScrub *, 3> m_scrubs{}; diff --git a/src/UI/ImageSizeSheet.cpp b/src/UI/ImageSizeSheet.cpp index 10c9137..526eec5 100644 --- a/src/UI/ImageSizeSheet.cpp +++ b/src/UI/ImageSizeSheet.cpp @@ -343,3 +343,8 @@ void ImageSizeSheet::synchronize() m_result->setForegroundRole(isValid ? QPalette::PlaceholderText : QPalette::BrightText); m_resize->setEnabled(isValid); } + +ImageSizeSheet::~ImageSizeSheet() +{ + releaseFocus(*this); +} diff --git a/src/UI/ImageSizeSheet.h b/src/UI/ImageSizeSheet.h index 5fbb0aa..33fdb36 100644 --- a/src/UI/ImageSizeSheet.h +++ b/src/UI/ImageSizeSheet.h @@ -17,6 +17,7 @@ class ImageSizeSheet : public QWidget { Q_OBJECT public: ImageSizeSheet(const CanvasDocument &document, std::function<void(std::optional<ImageSizeOptions>)> finish, QWidget *parent = nullptr); + ~ImageSizeSheet() override; private: bool valid() const; diff --git a/src/UI/LevelsSheet.cpp b/src/UI/LevelsSheet.cpp index 7ce4d0c..5832fdf 100644 --- a/src/UI/LevelsSheet.cpp +++ b/src/UI/LevelsSheet.cpp @@ -429,3 +429,8 @@ void LevelsSheet::synchronize() m_spinner->setVisible(edit->committing); setEnabled(!edit->committing); } + +LevelsSheet::~LevelsSheet() +{ + releaseFocus(*this); +} diff --git a/src/UI/LevelsSheet.h b/src/UI/LevelsSheet.h index 51689cc..2c524a1 100644 --- a/src/UI/LevelsSheet.h +++ b/src/UI/LevelsSheet.h @@ -15,6 +15,7 @@ class LevelsSheet : public QWidget { Q_OBJECT public: explicit LevelsSheet(EditorSession &session, QWidget *parent = nullptr); + ~LevelsSheet() override; protected: // A new theme recolours the eyedroppers' ink. diff --git a/tests/CameraRawSliderTests.cpp b/tests/CameraRawSliderTests.cpp index b7431f1..2626a3b 100644 --- a/tests/CameraRawSliderTests.cpp +++ b/tests/CameraRawSliderTests.cpp @@ -5,6 +5,15 @@ // Swift's CameraRawSliderTests. namespace { +// A two-colour track's ends. +std::pair<QColor, QColor> ends(const CameraRawSliderTrack &track) +{ + const std::vector<QColor> colors = track.colors().value(); + if (colors.size() != 2) + throw std::runtime_error("not a two-colour track"); + return {colors[0], colors[1]}; +} + struct Probe { double changed = std::nan(""); int resets = 0; @@ -38,16 +47,17 @@ private slots: void trackClickValueMatchesTheClickedPosition(); void aFamilysTracksCentreOnItsHue(); void aDragKeepsItsKnob(); + void aReshapeLeavesADragAlone(); }; void CameraRawSliderTests::colorTracksRunFromTheCoolOrMutedEndToTheWarmOrStrongEnd() { using Kind = CameraRawSliderTrack::Kind; - const auto [cool, warm] = CameraRawSliderTrack{Kind::temperature}.colors().value(); + const auto [cool, warm] = ends(CameraRawSliderTrack{Kind::temperature}); QVERIFY(cool.blueF() > warm.blueF()); - const auto [green, mauve] = CameraRawSliderTrack{Kind::tint}.colors().value(); + const auto [green, mauve] = ends(CameraRawSliderTrack{Kind::tint}); QVERIFY(green.greenF() > mauve.greenF()); - const auto [gray, red] = CameraRawSliderTrack{Kind::chroma}.colors().value(); + const auto [gray, red] = ends(CameraRawSliderTrack{Kind::chroma}); QVERIFY(std::abs(gray.redF() - gray.greenF()) < 0.05); QVERIFY(red.redF() > red.greenF() + 0.4); QVERIFY(!CameraRawSliderTrack{Kind::plain}.colors()); @@ -90,11 +100,11 @@ void CameraRawSliderTests::aFamilysTracksCentreOnItsHue() { using Kind = CameraRawSliderTrack::Kind; // Hue runs 50 degrees either side; red wraps below zero. - const auto [below, above] = CameraRawSliderTrack{Kind::hue, 0}.colors().value(); + const auto [below, above] = ends(CameraRawSliderTrack{Kind::hue, 0}); QVERIFY(std::abs(below.hueF() * 360 - 310) < 1 && std::abs(above.hueF() * 360 - 50) < 1); - const auto [muted, strong] = CameraRawSliderTrack{Kind::saturation, 240}.colors().value(); + const auto [muted, strong] = ends(CameraRawSliderTrack{Kind::saturation, 240}); QVERIFY(muted.hsvSaturationF() < 0.05 && std::abs(strong.hueF() * 360 - 240) < 1); - const auto [dark, light] = CameraRawSliderTrack{Kind::luminance, 120}.colors().value(); + const auto [dark, light] = ends(CameraRawSliderTrack{Kind::luminance, 120}); QVERIFY(dark.valueF() < 0.2 && light.valueF() > 0.9 && std::abs(dark.hueF() * 360 - 120) < 1); } @@ -113,5 +123,24 @@ void CameraRawSliderTests::aDragKeepsItsKnob() QVERIFY(std::isnan(probe.changed)); } +void CameraRawSliderTests::aReshapeLeavesADragAlone() +{ + using Kind = CameraRawSliderTrack::Kind; + Probe probe; + probe.slider.display(20); + probe.slider.setSliderDown(true); + // Mid-drag: the new track shows, the knob and value stay. + probe.slider.reshape(-50, 50, CameraRawSliderTrack{Kind::spectrum, 90}, 30); + QVERIFY(probe.slider.isSliderDown()); + QCOMPARE(probe.slider.value(), 600); + QCOMPARE(probe.slider.track().kind, Kind::spectrum); + QVERIFY(std::isnan(probe.changed)); + probe.slider.setSliderDown(false); + // Let go, new bounds place the value anew. + probe.slider.reshape(0, 100, CameraRawSliderTrack{Kind::chroma}, 25); + QCOMPARE(probe.slider.value(), 250); + QCOMPARE(probe.slider.shown(), 25.0); +} + QTEST_MAIN(CameraRawSliderTests) #include "CameraRawSliderTests.moc" diff --git a/tests/ColoredSliderTests.cpp b/tests/ColoredSliderTests.cpp new file mode 100644 index 0000000..800a11b --- /dev/null +++ b/tests/ColoredSliderTests.cpp @@ -0,0 +1,440 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "UI/FilterSheet.h" +#include "UI/SliderSnap.h" +#include <QApplication> +#include <QLabel> +#include <QMouseEvent> +#include <QLayout> +#include <QLineEdit> +#include <QtTest> + +// Swift 1.3.2's coloured filter sliders: tracks, resets, aligned titles. +namespace { +using Kind = CameraRawSliderTrack::Kind; + +QImage solid(QColor colour) +{ + QImage image(4, 4, QImage::Format_RGBA8888_Premultiplied); + image.fill(colour); + return image; +} + +struct Sheet { + EditorSession session; + std::unique_ptr<FilterSheet> sheet; + explicit Sheet(FilterKind kind) + { + session.createDocument(4, 4); + const QImage image = solid(QColor::fromRgbF(0.8f, 0.3f, 0.2f)); + session.insert(ImportedImage(image, image, QStringLiteral("Color"))); + session.beginFilter(kind); + sheet = std::make_unique<FilterSheet>(session); + sheet->show(); + } + const FilterSettings &settings() const { return session.filterEdit().value().settings; } + void set(const std::function<void(FilterSettings &)> &change, bool preview = true) + { + FilterSettings changed = settings(); + change(changed); + session.updateFilter(changed, preview); + } + // The slider a title names, and that title. + QSlider &slider(const QString &title) const + { + for (QLabel *label : sheet->findChildren<QLabel *>()) { + if (label->text() == title && label->buddy()) + return *static_cast<QSlider *>(label->buddy()); + } + throw std::runtime_error("no slider titled " + title.toStdString()); + } + QLabel &title(const QString &text) const + { + for (QLabel *label : sheet->findChildren<QLabel *>()) { + if (label->text() == text && label->buddy()) + return *label; + } + throw std::runtime_error("no title " + text.toStdString()); + } + CameraRawSlider &coloured(const QString &title) const + { + auto *found = qobject_cast<CameraRawSlider *>(&slider(title)); + if (!found) + throw std::runtime_error("no coloured slider titled " + title.toStdString()); + return *found; + } +}; + +// Swift's Color Balance ends, written out. +const std::array<std::vector<QColor>, 3> swiftEnds{ + std::vector{QColor::fromRgbF(0.10f, 0.72f, 0.80f), QColor::fromRgbF(0.86f, 0.18f, 0.20f)}, + std::vector{QColor::fromRgbF(0.80f, 0.22f, 0.70f), QColor::fromRgbF(0.24f, 0.70f, 0.30f)}, + std::vector{QColor::fromRgbF(0.95f, 0.82f, 0.18f), QColor::fromRgbF(0.22f, 0.40f, 0.92f)}}; + +void send(QWidget &widget, QEvent::Type type, QPointF at, Qt::MouseButton button, Qt::MouseButtons buttons) +{ + QMouseEvent event(type, at, widget.mapToGlobal(at), button, buttons, Qt::NoModifier); + QApplication::sendEvent(&widget, &event); +} + +// Counts the paints a widget receives. +class PaintCount : public QObject { +public: + explicit PaintCount(QWidget &widget) { widget.installEventFilter(this); } + int paints = 0; + +protected: + bool eventFilter(QObject *, QEvent *event) override + { + paints += event->type() == QEvent::Paint; + return false; + } +}; + +// A point on the knob, found along the slider's middle. +QPoint knob(const CameraRawSlider &slider) +{ + for (int x = 0; x < slider.width(); ++x) { + const QPoint point(x, slider.height() / 2); + if (slider.isOnKnob(point)) + return point; + } + throw std::runtime_error("no knob"); +} + +QList<QColor> pixels(const QImage &image) +{ + QList<QColor> colours; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + colours << image.pixelColor(x, y); + return colours; +} + +// Shown rows' titles: the widest's width, sixty at least. +void checkAligned(const Sheet &shown, const QStringList &visible, const QStringList &hidden) +{ + int widest = 60; + for (const QString &text : visible) + widest = std::max(widest, shown.title(text).sizeHint().width()); + for (const QString &text : visible + hidden) { + QCOMPARE(shown.title(text).minimumWidth(), widest); + QCOMPARE(shown.title(text).maximumWidth(), widest); + } +} +} + +class ColoredSliderTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { SliderSnap::install(); } + void colorBalanceTracksRunFromEachColorToItsOpposite(); + void theSpectrumCentresOnItsHue(); + void resettingAColoredFilterSliderRestoresTheDefault(); + void blackAndWhiteAndColorBalanceSlidersAreColoured(); + void aDoubleClickResetsItsOwnSliderOnly(); + void titlesLineUpToTheWidestShown(); + void rowsLayOutAsSwifts(); + void aResetReplacesTheFieldsTyping(); + void theTrackPaintsEveryStopInPlace(); + void aTitleResetsOnlyForALeftDoubleClickWhileEnabled(); + void aResetThenScrubsFromTheDefault(); + void colorBalanceResetsEachOfItsNineRows(); + void aNewTrackRepaintsTheSlider(); +}; + +void ColoredSliderTests::colorBalanceTracksRunFromEachColorToItsOpposite() +{ + const std::vector<QColor> cyanRed = FilterSheet::cyanRedTrack().colors().value(); + QVERIFY(cyanRed[0].blueF() > cyanRed[0].redF() && cyanRed[1].redF() > cyanRed[1].blueF()); + const std::vector<QColor> magentaGreen = FilterSheet::magentaGreenTrack().colors().value(); + QVERIFY(magentaGreen[0].redF() > magentaGreen[0].greenF() && magentaGreen[1].greenF() > magentaGreen[1].redF()); + const std::vector<QColor> yellowBlue = FilterSheet::yellowBlueTrack().colors().value(); + QVERIFY(yellowBlue[0].greenF() > yellowBlue[0].blueF() && yellowBlue[1].blueF() > yellowBlue[1].greenF()); + const QColor greens = CameraRawSliderTrack{Kind::luminance, 120}.colors().value().back(); + QVERIFY(greens.greenF() > greens.redF() && greens.greenF() > greens.blueF()); + // Swift's exact ends. + QVERIFY(cyanRed == swiftEnds[0] && magentaGreen == swiftEnds[1] && yellowBlue == swiftEnds[2]); +} + +void ColoredSliderTests::theSpectrumCentresOnItsHue() +{ + // Thirteen stops every 30 degrees, the hue in the middle. + const std::vector<QColor> circle = CameraRawSliderTrack{Kind::spectrum, 60}.colors().value(); + QCOMPARE(int(circle.size()), 13); + for (size_t index = 0; index < circle.size(); ++index) { + const double hue = std::fmod(60 - 180 + 30.0 * double(index) + 720, 360); + QVERIFY2(std::abs(circle[index].hsvHueF() * 360 - hue) < 0.5 || std::abs(circle[index].hsvHueF() * 360 - hue) > 359.5, qPrintable(QString::number(index))); + QVERIFY(std::abs(circle[index].hsvSaturationF() - 0.85) < 0.01 && std::abs(circle[index].valueF() - 0.9) < 0.01); + } + // Black to white, as Lightness runs. + const std::vector<QColor> ends = CameraRawSliderTrack{.kind = Kind::opposing, .from = Qt::black, .to = Qt::white}.colors().value(); + QVERIFY(ends == (std::vector<QColor>{Qt::black, Qt::white})); +} + +void ColoredSliderTests::resettingAColoredFilterSliderRestoresTheDefault() +{ + EditorSession session; + session.createDocument(4, 4); + const QImage base = solid(QColor::fromRgbF(0.8f, 0.3f, 0.2f)); + session.insert(ImportedImage(base, base, QStringLiteral("Color"))); + session.beginFilter(FilterKind::blackWhite); + FilterSettings settings = session.filterEdit().value().settings; + settings.blackWhite.reds = 250; + settings.blackWhite.blues = -100; + session.updateFilter(settings, true); + settings = FilterSheet::resetting([](FilterSettings &each) -> double & { return each.blackWhite.reds; }, settings); + QCOMPARE(settings.blackWhite.reds, BlackWhiteSettings().reds); + // Only the double-clicked slider resets. + QCOMPARE(settings.blackWhite.blues, -100.0); + session.updateFilter(FilterSheet::resetting([](FilterSettings &each) -> double & { return each.blackWhite.blues; }, settings), true); + QVERIFY(session.filterEdit().value().settings.blackWhite == BlackWhiteSettings()); + QVERIFY(session.filterEdit().value().preview); + bool done = false; + session.commitFilter([&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(pixels(session.activeLayer().value().asset.value().image()), pixels(BlackWhiteSettings().apply(base))); + session.undo(); + + session.beginFilter(FilterKind::colorBalance); + FilterSettings balance = session.filterEdit().value().settings; + balance.colorBalance.midCyanRed = -80; + session.updateFilter(balance, false); + session.updateFilter(FilterSheet::resetting([](FilterSettings &each) -> double & { return each.colorBalance.midCyanRed; }, balance), false); + QVERIFY(session.filterEdit().value().settings.colorBalance == ColorBalanceSettings()); + // A reset leaves Preview off. + QVERIFY(!session.filterEdit().value().preview); + session.cancelFilter(); + QCOMPARE(pixels(session.activeLayer().value().asset.value().image()), pixels(base)); +} + +void ColoredSliderTests::blackAndWhiteAndColorBalanceSlidersAreColoured() +{ + Sheet blackWhite(FilterKind::blackWhite); + const std::pair<const char *, double> families[] = {{"Reds", 0}, {"Yellows", 60}, {"Greens", 120}, {"Cyans", 180}, {"Blues", 240}, {"Magentas", 300}}; + for (const auto &[title, degrees] : families) { + const CameraRawSlider &slider = blackWhite.coloured(QString::fromLatin1(title)); + QVERIFY(slider.track().kind == Kind::luminance && slider.track().degrees == degrees); + QCOMPARE(slider.toolTip(), QString::fromLatin1(title) + QStringLiteral(". Double-click to reset.")); + } + // Tint's Hue is plain; Saturation follows the tint's hue. + QCOMPARE(blackWhite.coloured(QStringLiteral("Hue")).track().kind, Kind::plain); + QVERIFY(blackWhite.coloured(QStringLiteral("Saturation")).track().kind == Kind::saturation + && blackWhite.coloured(QStringLiteral("Saturation")).track().degrees == 40); + blackWhite.set([](FilterSettings &settings) { settings.blackWhite.tintHue = 200; }); + QCOMPARE(blackWhite.coloured(QStringLiteral("Saturation")).track().degrees, 200.0); + // Its knob sits at 40, between -200 and 300. + QCOMPARE(blackWhite.coloured(QStringLiteral("Reds")).shown(), 40.0); + // Dragged, the value lands whole. + blackWhite.coloured(QStringLiteral("Reds")).setValue(501); + QCOMPARE(blackWhite.settings().blackWhite.reds, 51.0); + + Sheet balance(FilterKind::colorBalance); + const QList<CameraRawSlider *> sliders = balance.sheet->findChildren<CameraRawSlider *>(); + QCOMPARE(sliders.size(), 9); + for (int index = 0; index < 9; ++index) + QVERIFY(sliders[index]->track().kind == Kind::opposing && sliders[index]->track().colors().value() == swiftEnds[size_t(index % 3)]); + // Other filters keep the plain slider. + Sheet blur(FilterKind::gaussianBlur); + QVERIFY(!qobject_cast<CameraRawSlider *>(&blur.slider(QStringLiteral("Radius")))); +} + +void ColoredSliderTests::aDoubleClickResetsItsOwnSliderOnly() +{ + Sheet shown(FilterKind::blackWhite); + QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get())); + shown.set([](FilterSettings &settings) { + settings.blackWhite.reds = 250; + settings.blackWhite.blues = -100; + settings.blackWhite.tint = true; + settings.blackWhite.tintHue = 90; + }, false); + // On the title: that row alone, Preview left off. + QTest::mouseDClick(&shown.title(QStringLiteral("Reds")), Qt::LeftButton); + QVERIFY(shown.settings().blackWhite.reds == 40 && shown.settings().blackWhite.blues == -100); + QVERIFY(!shown.session.filterEdit().value().preview); + QCOMPARE(shown.sheet->findChild<QLineEdit *>(QStringLiteral("redsField"))->text(), QString("40")); + // On the knob. + CameraRawSlider &blues = shown.coloured(QStringLiteral("Blues")); + QTest::mouseDClick(&blues, Qt::LeftButton, {}, knob(blues)); + QCOMPARE(shown.settings().blackWhite.blues, 20.0); + // The plain Hue resets too. + QTest::mouseDClick(&shown.title(QStringLiteral("Hue")), Qt::LeftButton); + QCOMPARE(shown.settings().blackWhite.tintHue, 40.0); + // A plain filter's title keeps its value. + Sheet blur(FilterKind::gaussianBlur); + blur.set([](FilterSettings &settings) { settings.radius = 25; }); + QTest::mouseDClick(&blur.title(QStringLiteral("Radius")), Qt::LeftButton); + QCOMPARE(blur.settings().radius, 25.0); +} + +void ColoredSliderTests::titlesLineUpToTheWidestShown() +{ + const QStringList pairs{"Cyan / Red", "Magenta / Green", "Yellow / Blue"}; + Sheet balance(FilterKind::colorBalance); + checkAligned(balance, pairs, {}); + QVERIFY(balance.title(QStringLiteral("Magenta / Green")).width() > 60); + // Tint's rows count only while shown. + Sheet blackWhite(FilterKind::blackWhite); + const QStringList families{"Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"}; + checkAligned(blackWhite, families, {"Hue", "Saturation"}); + blackWhite.set([](FilterSettings &settings) { settings.blackWhite.tint = true; }); + checkAligned(blackWhite, families + QStringList{"Hue", "Saturation"}, {}); + // Hidden again, they stop counting. + blackWhite.set([](FilterSettings &settings) { settings.blackWhite.tint = false; }); + checkAligned(blackWhite, families, {"Hue", "Saturation"}); + // A lone short title keeps sixty. + Sheet blur(FilterKind::gaussianBlur); + QCOMPARE(blur.title(QStringLiteral("Radius")).width(), 60); +} + +void ColoredSliderTests::rowsLayOutAsSwifts() +{ + // Title, slider 10 on, field 10 on, unit 2 past. + for (const auto &[kind, text, field] : {std::tuple(FilterKind::gaussianBlur, "Radius", "radiusField"), std::tuple(FilterKind::blackWhite, "Reds", "redsField")}) { + Sheet shown(kind); + QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get())); + QLabel &title = shown.title(QString::fromLatin1(text)); + QSlider &slider = shown.slider(QString::fromLatin1(text)); + QLineEdit &entry = *shown.sheet->findChild<QLineEdit *>(QString::fromLatin1(field)); + QWidget *box = title.parentWidget(); + QCOMPARE(title.x(), 0); + QCOMPARE(slider.x() - title.geometry().right() - 1, 10); + QCOMPARE(entry.x() - slider.geometry().right() - 1, 10); + QLabel *unit = box->findChildren<QLabel *>().last(); + QCOMPARE(unit->x() - entry.geometry().right() - 1, 2); + QCOMPARE(unit->geometry().right() + 1, box->width()); + QCOMPARE(box->height(), box->sizeHint().height()); + // The row sits in the sheet's column, among the rest. + QVERIFY(shown.sheet->layout()->indexOf(box) >= 0); + } +} + +void ColoredSliderTests::aResetReplacesTheFieldsTyping() +{ + Sheet shown(FilterKind::blackWhite); + QVERIFY(QTest::qWaitForWindowActive(shown.sheet.get())); + shown.set([](FilterSettings &settings) { settings.blackWhite.reds = 250; }); + auto &field = *shown.sheet->findChild<QLineEdit *>(QStringLiteral("redsField")); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, QStringLiteral("7")); + QVERIFY(field.isModified()); + QTest::mouseDClick(&shown.title(QStringLiteral("Reds")), Qt::LeftButton); + QVERIFY(!field.isModified()); + QCOMPARE(field.text(), QString("40")); + // Leaving the field applies nothing more. + shown.sheet->findChild<QLineEdit *>(QStringLiteral("bluesField"))->setFocus(); + QCOMPARE(shown.settings().blackWhite.reds, 40.0); +} + +void ColoredSliderTests::theTrackPaintsEveryStopInPlace() +{ + // Round red: violet a quarter in, red in the middle. + CameraRawSlider slider(-100, 100, CameraRawSliderTrack{Kind::spectrum, 0}, QStringLiteral("Probe"), [](double) {}, [] {}); + slider.resize(400, 22); + slider.display(100); + const QImage painted = slider.grab().toImage(); + const auto hueAt = [&](double share) { return painted.pixelColor(int(share * painted.width()), painted.height() / 2).hsvHueF() * 360; }; + QVERIFY2(std::abs(hueAt(0.25) - 270) < 25, qPrintable(QString::number(hueAt(0.25)))); + const double middle = hueAt(0.5); + QVERIFY2(middle < 20 || middle > 340, qPrintable(QString::number(middle))); + QVERIFY2(std::abs(hueAt(0.75) - 90) < 25, qPrintable(QString::number(hueAt(0.75)))); +} + +void ColoredSliderTests::aTitleResetsOnlyForALeftDoubleClickWhileEnabled() +{ + Sheet shown(FilterKind::blackWhite); + QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get())); + shown.set([](FilterSettings &settings) { settings.blackWhite.reds = 250; }); + for (const Qt::MouseButton button : {Qt::RightButton, Qt::MiddleButton}) { + QTest::mouseDClick(&shown.title(QStringLiteral("Reds")), button); + QCOMPARE(shown.settings().blackWhite.reds, 250.0); + } + // A resting sheet resets nothing. + shown.sheet->setEnabled(false); + send(shown.title(QStringLiteral("Reds")), QEvent::MouseButtonDblClick, QPointF(2, 2), Qt::LeftButton, Qt::LeftButton); + QCOMPARE(shown.settings().blackWhite.reds, 250.0); + shown.sheet->setEnabled(true); + QTest::mouseDClick(&shown.title(QStringLiteral("Reds")), Qt::LeftButton); + QCOMPARE(shown.settings().blackWhite.reds, 40.0); +} + +void ColoredSliderTests::aResetThenScrubsFromTheDefault() +{ + // A real double click: the second press goes on scrubbing. + Sheet shown(FilterKind::blackWhite); + QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get())); + shown.set([](FilterSettings &settings) { settings.blackWhite.reds = 250; }); + QLabel &title = shown.title(QStringLiteral("Reds")); + send(title, QEvent::MouseButtonPress, QPointF(2, 2), Qt::LeftButton, Qt::LeftButton); + send(title, QEvent::MouseButtonRelease, QPointF(2, 2), Qt::LeftButton, Qt::NoButton); + send(title, QEvent::MouseButtonDblClick, QPointF(2, 2), Qt::LeftButton, Qt::LeftButton); + QCOMPARE(shown.settings().blackWhite.reds, 40.0); + send(title, QEvent::MouseMove, QPointF(14, 2), Qt::NoButton, Qt::LeftButton); + send(title, QEvent::MouseButtonRelease, QPointF(14, 2), Qt::LeftButton, Qt::NoButton); + QCOMPARE(shown.settings().blackWhite.reds, 52.0); +} + +void ColoredSliderTests::colorBalanceResetsEachOfItsNineRows() +{ + Sheet shown(FilterKind::colorBalance); + QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get())); + const std::array<double ColorBalanceSettings::*, 9> fields{ + &ColorBalanceSettings::shadowCyanRed, &ColorBalanceSettings::shadowMagentaGreen, &ColorBalanceSettings::shadowYellowBlue, + &ColorBalanceSettings::midCyanRed, &ColorBalanceSettings::midMagentaGreen, &ColorBalanceSettings::midYellowBlue, + &ColorBalanceSettings::highlightCyanRed, &ColorBalanceSettings::highlightMagentaGreen, &ColorBalanceSettings::highlightYellowBlue}; + const QList<CameraRawSlider *> sliders = shown.sheet->findChildren<CameraRawSlider *>(); + QCOMPARE(sliders.size(), 9); + for (size_t index = 0; index < fields.size(); ++index) { + shown.set([&fields](FilterSettings &settings) { + for (size_t each = 0; each < fields.size(); ++each) + settings.colorBalance.*fields[each] = double(each + 1) * 10; + }, false); + CameraRawSlider &slider = *sliders[qsizetype(index)]; + // Titles on even rows, knobs on odd ones. + if (index % 2 == 0) { + QLabel *title = nullptr; + for (QLabel *label : shown.sheet->findChildren<QLabel *>()) + title = label->buddy() == &slider ? label : title; + QTest::mouseDClick(title, Qt::LeftButton); + // The double click's own release ends its scrub. + send(*title, QEvent::MouseButtonRelease, QPointF(2, 2), Qt::LeftButton, Qt::NoButton); + } else { + QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider)); + } + for (size_t each = 0; each < fields.size(); ++each) + QCOMPARE(shown.settings().colorBalance.*fields[each], each == index ? 0.0 : double(each + 1) * 10); + QVERIFY(!shown.session.filterEdit().value().preview); + } +} + +void ColoredSliderTests::aNewTrackRepaintsTheSlider() +{ + // Swift's needsDisplay: the knob stays, the gradient changes. + CameraRawSlider slider(-100, 100, CameraRawSliderTrack{Kind::saturation, 0}, QStringLiteral("Probe"), [](double) {}, [] {}); + slider.resize(200, 22); + slider.display(20); + slider.show(); + QVERIFY(QTest::qWaitForWindowExposed(&slider)); + QTest::qWait(50); + PaintCount count(slider); + slider.reshape(-100, 100, CameraRawSliderTrack{Kind::saturation, 200}, 20); + QTRY_COMPARE(count.paints, 1); + // Mid-drag too. + slider.setSliderDown(true); + slider.reshape(-100, 100, CameraRawSliderTrack{Kind::spectrum, 90}, 60); + QTRY_COMPARE(count.paints, 2); + slider.setSliderDown(false); + // In a sheet: Tint's saturation follows its hue. + Sheet shown(FilterKind::blackWhite); + shown.set([](FilterSettings &settings) { settings.blackWhite.tint = true; }); + QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get())); + QTest::qWait(50); + PaintCount saturation(shown.coloured(QStringLiteral("Saturation"))); + shown.set([](FilterSettings &settings) { settings.blackWhite.tintHue = 200; }); + QTRY_VERIFY(saturation.paints >= 1); +} + +QTEST_MAIN(ColoredSliderTests) +#include "ColoredSliderTests.moc" diff --git a/tests/FilterControlsTests.cpp b/tests/FilterControlsTests.cpp index 3b39a0f..204d2d7 100644 --- a/tests/FilterControlsTests.cpp +++ b/tests/FilterControlsTests.cpp @@ -101,11 +101,11 @@ void FilterControlsTests::eachKindShowsSwiftsControls() QVERIFY(field.alignment() & Qt::AlignRight); QVERIFY(!field.accessibleName().isEmpty() && field.placeholderText() == field.accessibleName()); QSlider &slider = sheet.child<QSlider>(qPrintable(name + QStringLiteral("Slider"))); - // Swift's title: sixty points at least, naming the slider. + // Swift's title: the widest title's width, sixty at least. QLabel *title = nullptr; for (QLabel *each : slider.parentWidget()->findChildren<QLabel *>()) title = each->buddy() == &slider ? each : title; - QVERIFY(title && title->text() == field.accessibleName() && title->minimumWidth() == 60); + QVERIFY(title && title->text() == field.accessibleName() && title->minimumWidth() >= 60 && title->maximumWidth() == title->minimumWidth()); } QCOMPARE(sheet.sheet->findChild<CurvesControls *>() != nullptr, kind == FilterKind::curves); QCOMPARE(sheet.sheet->findChild<GradientMapControls *>() != nullptr, kind == FilterKind::gradientMap); diff --git a/tests/HueSaturationSheetTests.cpp b/tests/HueSaturationSheetTests.cpp index 22b6ada..fa85ba5 100644 --- a/tests/HueSaturationSheetTests.cpp +++ b/tests/HueSaturationSheetTests.cpp @@ -11,6 +11,13 @@ private slots: void samplersAndTargetingToggle(); void theControlsFollowTheSession(); void togglesResetAndTheButtons(); + void tracksFollowTheRangeAndColorize(); + void aDoubleClickResetsToNoneOrColorizesStart(); + void aResetReplacesTheFieldsTyping(); + void arrowKeysWalkEverySlider(); + void aResetKeepsPreviewOffInBothModes(); + void aRangeUnderColorizeKeepsItsTracks(); + void aTitleResetsOnALeftDoubleClickThenScrubs(); }; void HueSaturationSheetTests::theSheetIsLaidOutAsSwifts() @@ -41,11 +48,12 @@ void HueSaturationSheetTests::theSheetIsLaidOutAsSwifts() if (frame->frameShape() == QFrame::VLine) QVERIFY(frame->isHidden()); } - const std::pair<const char *, int> rows[] = {{"Hue", 18000}, {"Saturation", 10000}, {"Lightness", 10000}}; - for (const auto &[title, reach] : rows) { + for (const char *title : {"Hue", "Saturation", "Lightness"}) { const QString lower = QString::fromLatin1(title).toLower(); - QSlider &slider = *shown.sheet->findChild<QSlider *>(lower + QStringLiteral("Slider")); - QVERIFY(slider.minimum() == -reach && slider.maximum() == reach && slider.value() == 0); + // Swift's coloured slider: centred, its help naming the reset. + CameraRawSlider &slider = *shown.sheet->findChild<CameraRawSlider *>(lower + QStringLiteral("Slider")); + QVERIFY(slider.minimum() == 0 && slider.maximum() == 1000 && slider.value() == 500 && slider.shown() == 0); + QCOMPARE(slider.toolTip(), QString(title) + QStringLiteral(". Double-click to reset.")); QCOMPARE(labelled(slider), QString(title)); QLineEdit &field = *shown.sheet->findChild<QLineEdit *>(lower + QStringLiteral("Field")); QCOMPARE(field.text(), QString("0")); @@ -127,10 +135,11 @@ void HueSaturationSheetTests::aRangeShowsItsSpectrumAndSamplers() QVERIFY(shown.settings() == HueSaturationSettings::colorizeStart()); QVERIFY(!range.isEnabled()); QVERIFY(!shown.child<QToolButton>("hueTargeting").isVisible() && !shown.child<QWidget>("hueSpectrum").isVisible()); - QSlider &hue = *shown.sheet->findChild<QSlider *>(QStringLiteral("hueSlider")); - QSlider &saturation = *shown.sheet->findChild<QSlider *>(QStringLiteral("saturationSlider")); - QVERIFY(hue.minimum() == 0 && hue.maximum() == 36000); - QVERIFY(saturation.minimum() == 0 && saturation.maximum() == 10000 && saturation.value() == 2500); + CameraRawSlider &hue = *shown.sheet->findChild<CameraRawSlider *>(QStringLiteral("hueSlider")); + CameraRawSlider &saturation = *shown.sheet->findChild<CameraRawSlider *>(QStringLiteral("saturationSlider")); + // Both ranges now start at zero. + QVERIFY(hue.value() == 0 && hue.shown() == 0); + QVERIFY(saturation.value() == 250 && saturation.shown() == 25); QCOMPARE(shown.child<QLineEdit>("saturationField").text(), QString("25")); // Off again: everything back to the start. shown.titled<QCheckBox>(QStringLiteral("Colorize")).click(); @@ -145,10 +154,11 @@ void HueSaturationSheetTests::slidersAndFieldsWriteTheirValue() { Sheet shown; QVERIFY(shown.show()); - QSlider &hue = *shown.sheet->findChild<QSlider *>(QStringLiteral("hueSlider")); - QSlider &saturationSlider = *shown.sheet->findChild<QSlider *>(QStringLiteral("saturationSlider")); - hue.setValue(3060); - QCOMPARE(shown.settings().hue(), 30.6); + CameraRawSlider &hue = *shown.sheet->findChild<CameraRawSlider *>(QStringLiteral("hueSlider")); + CameraRawSlider &saturationSlider = *shown.sheet->findChild<CameraRawSlider *>(QStringLiteral("saturationSlider")); + // 30.6 along the track: Swift sets the whole number. + hue.setValue(585); + QCOMPARE(shown.settings().hue(), 31.0); QLineEdit &hueField = shown.child<QLineEdit>("hueField"); QCOMPARE(hueField.text(), QString("31")); // Visited without typing, the shown whole number stays unread. @@ -157,27 +167,27 @@ void HueSaturationSheetTests::slidersAndFieldsWriteTheirValue() QLineEdit &saturation = shown.child<QLineEdit>("saturationField"); saturation.setFocus(); QTRY_VERIFY(saturation.hasFocus()); - QCOMPARE(shown.settings().hue(), 30.6); + QCOMPARE(shown.settings().hue(), 31.0); // Typed and left, a number applies unclamped. type(saturation, QStringLiteral("250")); hueField.setFocus(); QCOMPARE(shown.settings().saturation(), 250.0); - QCOMPARE(saturationSlider.value(), 10000); + QCOMPARE(saturationSlider.value(), 1000); QCOMPARE(saturation.text(), QString("250")); // Applied, the field follows the session again. - saturationSlider.setValue(-2000); + saturationSlider.setValue(400); QCOMPARE(saturation.text(), QString("-20")); // What is no finite number reverts. for (const char *text : {"abc", "inf", "nan"}) { type(hueField, QString::fromLatin1(text)); saturation.setFocus(); - QCOMPARE(shown.settings().hue(), 30.6); + QCOMPARE(shown.settings().hue(), 31.0); QCOMPARE(hueField.text(), QString("31")); } // Past any int, the slider rests at its end. type(saturation, QStringLiteral("1e300")); hueField.setFocus(); - QCOMPARE(saturationSlider.value(), 10000); + QCOMPARE(saturationSlider.value(), 1000); // Sliders show Colorize's range without writing. HueSaturationSettings colour = HueSaturationSettings::colorizeStart(); colour.setHue(-40); @@ -187,12 +197,12 @@ void HueSaturationSheetTests::slidersAndFieldsWriteTheirValue() QCOMPARE(hueField.text(), QString("-40")); // Typing waits through other values; its own slider replaces it. type(hueField, QStringLiteral("4")); - saturationSlider.setValue(1000); + saturationSlider.setValue(100); QVERIFY(hueField.text() == "4" && hueField.isModified()); - hue.setValue(1000); - QVERIFY(hueField.text() == "10" && !hueField.isModified() && hueField.hasFocus()); + hue.setValue(250); + QVERIFY(hueField.text() == "90" && !hueField.isModified() && hueField.hasFocus()); saturation.setFocus(); - QCOMPARE(shown.settings().hue(), 10.0); + QCOMPARE(shown.settings().hue(), 90.0); } void HueSaturationSheetTests::fieldsClampOnReturnAndPrintWhole() @@ -273,7 +283,7 @@ void HueSaturationSheetTests::togglesResetAndTheButtons() preview.click(); QVERIFY(!shown.session.hueSaturation().value().preview); // Off, the controls keep Preview off. - shown.sheet->findChild<QSlider *>(QStringLiteral("lightnessSlider"))->setValue(500); + shown.sheet->findChild<QSlider *>(QStringLiteral("lightnessSlider"))->setValue(525); QVERIFY(!shown.session.hueSaturation().value().preview && shown.settings().lightness() == 5); preview.click(); QVERIFY(shown.session.hueSaturation().value().preview); @@ -302,5 +312,162 @@ void HueSaturationSheetTests::togglesResetAndTheButtons() QCOMPARE(shown.session.history.undoCount(), steps); } +void HueSaturationSheetTests::tracksFollowTheRangeAndColorize() +{ + using Kind = CameraRawSliderTrack::Kind; + Sheet shown; + const auto track = [&](const char *name) { return shown.child<CameraRawSlider>(name).track(); }; + // Master: circle round red, gray to red, black to white. + QVERIFY(track("hueSlider").kind == Kind::spectrum && track("hueSlider").degrees == 0); + QCOMPARE(track("saturationSlider").kind, Kind::chroma); + QVERIFY(track("lightnessSlider").kind == Kind::opposing && track("lightnessSlider").colors() == (std::vector<QColor>{Qt::black, Qt::white})); + // A range centres both on its own hue. + shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::cyans)); + QVERIFY(track("hueSlider").kind == Kind::spectrum && track("hueSlider").degrees == 180); + QVERIFY(track("saturationSlider").kind == Kind::saturation && track("saturationSlider").degrees == 180); + shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::magentas)); + QCOMPARE(track("hueSlider").degrees, 300.0); + // Colorize: red to red; saturation towards the chosen hue. + shown.set(HueSaturationSettings(200, 25, 0, true)); + QVERIFY(track("hueSlider").kind == Kind::spectrum && track("hueSlider").degrees == 180); + QVERIFY(track("saturationSlider").kind == Kind::saturation && track("saturationSlider").degrees == 200); + // Its knob at 200 of 0 to 360. + QCOMPARE(shown.child<CameraRawSlider>("hueSlider").value(), 556); +} + +void HueSaturationSheetTests::aDoubleClickResetsToNoneOrColorizesStart() +{ + Sheet shown; + QVERIFY(shown.show()); + const auto title = [&](const char *text) -> QLabel & { return shown.titled<QLabel>(QString::fromLatin1(text)); }; + shown.set(HueSaturationSettings(30, -40, 20, false, ColorRange::reds)); + // The title: that value alone back to none. + QTest::mouseDClick(&title("Hue"), Qt::LeftButton); + QVERIFY(shown.settings().hue() == 0 && shown.settings().saturation() == -40 && shown.settings().lightness() == 20); + // The knob. + CameraRawSlider &lightness = shown.child<CameraRawSlider>("lightnessSlider"); + QPoint knob; + for (int x = 0; x < lightness.width() && knob.isNull(); ++x) + knob = lightness.isOnKnob(QPoint(x, lightness.height() / 2)) ? QPoint(x, lightness.height() / 2) : knob; + QTest::mouseDClick(&lightness, Qt::LeftButton, {}, knob); + QVERIFY(shown.settings().lightness() == 0 && shown.settings().saturation() == -40); + // Under Colorize, back to its start: saturation 25. + shown.set(HueSaturationSettings(200, 70, 10, true)); + QTest::mouseDClick(&title("Saturation"), Qt::LeftButton); + QVERIFY(shown.settings().saturation() == 25 && shown.settings().hue() == 200 && shown.settings().lightness() == 10); + QCOMPARE(shown.child<QLineEdit>("saturationField").text(), QString("25")); +} + +void HueSaturationSheetTests::aResetReplacesTheFieldsTyping() +{ + Sheet shown; + QVERIFY(shown.show()); + shown.set(HueSaturationSettings(200, 70, 10, true)); + QLineEdit &hueField = shown.child<QLineEdit>("hueField"); + hueField.setFocus(); + QTRY_VERIFY(hueField.hasFocus()); + hueField.selectAll(); + QTest::keyClicks(&hueField, QStringLiteral("77")); + QTest::mouseDClick(&shown.titled<QLabel>(QStringLiteral("Hue")), Qt::LeftButton); + QCOMPARE(shown.settings().hue(), 0.0); + QCOMPARE(hueField.text(), QString("0")); + QVERIFY(!hueField.isModified()); +} + +void HueSaturationSheetTests::arrowKeysWalkEverySlider() +{ + // A hundred steps: whole values, the knob keeping its travel. + const auto walk = [](Sheet &shown, const char *name, Qt::Key key) { + CameraRawSlider &slider = shown.child<CameraRawSlider>(name); + slider.setFocus(); + for (int step = 0; step < 100; ++step) + QTest::keyClick(&slider, key); + }; + const std::tuple<bool, const char *, Qt::Key, double> cases[] = { + {false, "hueSlider", Qt::Key_Right, 53}, {false, "hueSlider", Qt::Key_Left, -53}, {false, "saturationSlider", Qt::Key_Right, 33}, + {false, "lightnessSlider", Qt::Key_Left, -33}, {true, "hueSlider", Qt::Key_Right, 53}, {true, "saturationSlider", Qt::Key_Right, 45}, + {true, "saturationSlider", Qt::Key_Left, 9}, {true, "lightnessSlider", Qt::Key_Right, 33}}; + for (const auto &[colorize, name, key, expected] : cases) { + Sheet shown; + QVERIFY(shown.show()); + shown.set(colorize ? HueSaturationSettings::colorizeStart() : HueSaturationSettings()); + walk(shown, name, key); + const HueSaturationSettings &now = shown.settings(); + const double value = QByteArray(name) == "hueSlider" ? now.hue() : QByteArray(name) == "saturationSlider" ? now.saturation() : now.lightness(); + QVERIFY2(value == expected, qPrintable(QStringLiteral("%1 %2: %3").arg(QString::fromLatin1(name)).arg(colorize).arg(value))); + } + // An unchanged step leaves the field's typing. + Sheet shown; + QVERIFY(shown.show()); + QLineEdit &field = shown.child<QLineEdit>("hueField"); + field.setFocus(); + QTRY_VERIFY(field.hasFocus()); + field.selectAll(); + QTest::keyClicks(&field, QStringLiteral("4")); + QTest::keyClick(&shown.child<CameraRawSlider>("hueSlider"), Qt::Key_Right); + QVERIFY(field.isModified() && field.text() == "4" && shown.settings().hue() == 0); +} + +void HueSaturationSheetTests::aResetKeepsPreviewOffInBothModes() +{ + for (const bool colorize : {false, true}) { + Sheet shown; + QVERIFY(shown.show()); + shown.session.updateHueSaturation(HueSaturationSettings(90, 60, 30, colorize), false); + QTest::mouseDClick(&shown.titled<QLabel>(QStringLiteral("Saturation")), Qt::LeftButton); + QCOMPARE(shown.settings().saturation(), colorize ? 25.0 : 0.0); + QVERIFY(shown.settings().hue() == 90 && shown.settings().lightness() == 30); + QCOMPARE(shown.child<QLineEdit>("saturationField").text(), colorize ? QString("25") : QString("0")); + QVERIFY(!shown.session.hueSaturation().value().preview); + CameraRawSlider &lightness = shown.child<CameraRawSlider>("lightnessSlider"); + QPoint knob; + for (int x = 0; x < lightness.width() && knob.isNull(); ++x) + knob = lightness.isOnKnob(QPoint(x, lightness.height() / 2)) ? QPoint(x, lightness.height() / 2) : knob; + QTest::mouseDClick(&lightness, Qt::LeftButton, {}, knob); + QVERIFY(shown.settings().lightness() == 0 && shown.settings().hue() == 90); + QVERIFY(!shown.session.hueSaturation().value().preview); + } +} + +void HueSaturationSheetTests::aRangeUnderColorizeKeepsItsTracks() +{ + using Kind = CameraRawSliderTrack::Kind; + Sheet shown; + shown.set(HueSaturationSettings(200, 25, 0, true, ColorRange::greens)); + CameraRawSlider &hue = shown.child<CameraRawSlider>("hueSlider"); + CameraRawSlider &saturation = shown.child<CameraRawSlider>("saturationSlider"); + // Red to red, towards the chosen hue, Colorize's bounds. + QVERIFY(hue.track().kind == Kind::spectrum && hue.track().degrees == 180); + QVERIFY(saturation.track().kind == Kind::saturation && saturation.track().degrees == 200); + QVERIFY(hue.value() == 556 && saturation.value() == 250); +} + +void HueSaturationSheetTests::aTitleResetsOnALeftDoubleClickThenScrubs() +{ + Sheet shown; + QVERIFY(shown.show()); + shown.set(HueSaturationSettings(30, 0, 0, false)); + QLabel &title = shown.titled<QLabel>(QStringLiteral("Hue")); + const auto send = [&title](QEvent::Type type, double x, Qt::MouseButton button, Qt::MouseButtons buttons) { + QMouseEvent event(type, QPointF(x, 2), title.mapToGlobal(QPointF(x, 2)), button, buttons, Qt::NoModifier); + QApplication::sendEvent(&title, &event); + }; + // Other buttons and a resting sheet reset nothing. + for (const Qt::MouseButton button : {Qt::RightButton, Qt::MiddleButton}) + send(QEvent::MouseButtonDblClick, 2, button, button); + shown.sheet->setEnabled(false); + send(QEvent::MouseButtonDblClick, 2, Qt::LeftButton, Qt::LeftButton); + shown.sheet->setEnabled(true); + QCOMPARE(shown.settings().hue(), 30.0); + // A real double click resets; its second press scrubs. + send(QEvent::MouseButtonPress, 2, Qt::LeftButton, Qt::LeftButton); + send(QEvent::MouseButtonRelease, 2, Qt::LeftButton, Qt::NoButton); + send(QEvent::MouseButtonDblClick, 2, Qt::LeftButton, Qt::LeftButton); + QCOMPARE(shown.settings().hue(), 0.0); + send(QEvent::MouseMove, 14, Qt::NoButton, Qt::LeftButton); + send(QEvent::MouseButtonRelease, 14, Qt::LeftButton, Qt::NoButton); + QCOMPARE(shown.settings().hue(), 12.0); +} + QTEST_MAIN(HueSaturationSheetTests) #include "HueSaturationSheetTests.moc" diff --git a/tests/SheetTeardownTests.cpp b/tests/SheetTeardownTests.cpp new file mode 100644 index 0000000..ed74f51 --- /dev/null +++ b/tests/SheetTeardownTests.cpp @@ -0,0 +1,133 @@ +#include "Document/BrushStroke.h" +#include "ScrubFixtures.h" +#include "UI/CameraRawColorControls.h" +#include "UI/CameraRawControls.h" +#include "UI/CameraRawDetailOpticsControls.h" +#include "UI/CameraRawGeometryCalibrationControls.h" +#include "UI/CameraRawRow.h" +#include "UI/CanvasSizeSheet.h" +#include "UI/ColorPickerSheet.h" +#include "UI/EffectsSheet.h" +#include "UI/FilterSheet.h" +#include "UI/HueSaturationSheet.h" +#include "UI/ImageSizeSheet.h" +#include "UI/LevelsSheet.h" + +// A sheet destroyed while its entry holds keys and typing. +namespace { +void paint(EditorSession &session) +{ + session.createDocument(8, 8); + QImage image = BrushRaster::context(8, 8, false); + image.fill(QColor(120, 160, 200)); + session.insert(ImportedImage(image, image, QStringLiteral("Colour"))); +} + +// Names the owner's class as its entry loses the keys. +class FocusWitness : public QObject { +public: + FocusWitness(QWidget &owner, QWidget &field) : m_owner(owner) { field.installEventFilter(this); } + QString seen; + +protected: + bool eventFilter(QObject *, QEvent *event) override + { + if (event->type() == QEvent::FocusOut && seen.isEmpty()) + seen = QString::fromLatin1(m_owner.metaObject()->className()); + return false; + } + +private: + QWidget &m_owner; +}; + + // Past its destructor, the owner reads as QWidget. +void typeThenDestroy(std::unique_ptr<QWidget> owner, const QString &typed) +{ + showActive(*owner); + PickerField *field = owner->findChildren<PickerField *>().value(0); + if (!field) + throw std::runtime_error(std::string("no entry in ") + owner->metaObject()->className()); + field->setFocus(); + if (!QTest::qWaitFor([field] { return field->hasFocus(); })) + throw std::runtime_error("the entry never took the keys"); + field->selectAll(); + QTest::keyClicks(field, typed); + if (!field->isModified()) + throw std::runtime_error("the typing did not land"); + const QString name = QString::fromLatin1(owner->metaObject()->className()); + FocusWitness witness(*owner, *field); + owner.reset(); + // Past its own destructor, the owner would read as QWidget. + if (witness.seen != name) + throw std::runtime_error("the entry committed into " + witness.seen.toStdString() + ", not " + name.toStdString()); +} +} + +class SheetTeardownTests : public QObject { + Q_OBJECT +private slots: + void theColourSheetsCommitAsTheyGo(); + void theDocumentSheetsGoQuietly(); + void aCameraRawRowGoesQuietly(); + void theCameraRawGroupsGoWhole(); +}; + +void SheetTeardownTests::theColourSheetsCommitAsTheyGo() +{ + // Each commits its typing as hiding would, members alive. + EditorSession session; + paint(session); + session.beginHueSaturation(); + typeThenDestroy(std::make_unique<HueSaturationSheet>(session), QStringLiteral("12")); + QCOMPARE(session.hueSaturation().value().settings.hue(), 12.0); + session.cancelHueSaturation(); + session.beginFilter(FilterKind::gaussianBlur); + typeThenDestroy(std::make_unique<FilterSheet>(session), QStringLiteral("7")); + QCOMPARE(session.filterEdit().value().settings.radius, 7.0); + session.cancelFilter(); + session.beginLevels(); + typeThenDestroy(std::make_unique<LevelsSheet>(session), QStringLiteral("9")); + QCOMPARE(session.levels().value().settings.current().black, 9.0); + session.cancelLevels(); + session.addEffect(LayerEffectKind::stroke); + typeThenDestroy(std::make_unique<EffectsSheet>(session, LayerEffectKind::stroke), QStringLiteral("5")); + QCOMPARE(session.editingEffects().stroke.value().size, 5.0); + session.openColorPicker(false); + typeThenDestroy(std::make_unique<ColorPickerSheet>(session, [](bool) {}), QStringLiteral("10")); + QCOMPARE(session.colorPicker().value().color().red, 10.0 / 255); +} + +void SheetTeardownTests::theDocumentSheetsGoQuietly() +{ + const CanvasDocument canvas{120, 80}; + typeThenDestroy(std::make_unique<ImageSizeSheet>(canvas, [](std::optional<ImageSizeOptions>) {}), QStringLiteral("60")); + typeThenDestroy(std::make_unique<CanvasSizeSheet>(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {}), + QStringLiteral("60")); +} + +void SheetTeardownTests::aCameraRawRowGoesQuietly() +{ + double typed = 0; + typeThenDestroy(std::make_unique<CameraRawRow>(CameraRawRow::Spec{.name = QStringLiteral("probe"), .title = QStringLiteral("Probe"), .help = QString()}, + [] { return 0.0; }, [](double) {}, [&typed](double value) { typed = value; }, [] {}), + QStringLiteral("30")); + QCOMPARE(typed, 30.0); +} + +void SheetTeardownTests::theCameraRawGroupsGoWhole() +{ + EditorSession session; + paint(session); + session.beginFilter(FilterKind::cameraRaw); + typeThenDestroy(std::make_unique<CameraRawControls>(session), QStringLiteral("1")); + typeThenDestroy(std::make_unique<CameraRawCurveControls>(session), QStringLiteral("10")); + typeThenDestroy(std::make_unique<CameraRawMixerControls>(session), QStringLiteral("10")); + typeThenDestroy(std::make_unique<CameraRawDetailControls>(session), QStringLiteral("10")); + typeThenDestroy(std::make_unique<CameraRawOpticsControls>(session), QStringLiteral("10")); + typeThenDestroy(std::make_unique<CameraRawGeometryControls>(session), QStringLiteral("10")); + typeThenDestroy(std::make_unique<CameraRawCalibrationControls>(session), QStringLiteral("10")); +} + +QTEST_MAIN(SheetTeardownTests) +#include "SheetTeardownTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index d415fd0..f8d74b0 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -167,6 +167,8 @@ compositor_test(ProjectReplaceTests) compositor_test(ProjectWorkspaceFlowTests) compositor_test(QuitWhileEditingTextTests) compositor_test(OpenRecentTests) +compositor_test(ColoredSliderTests) +compositor_test(SheetTeardownTests) compositor_test(RecentProjectsCoreTests) compositor_test(ProjectWorkspaceSignalTests) compositor_test(ProjectWorkspaceTests)