diff --git a/AGENTS.md b/AGENTS.md
index 9158953..9d624a3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -124,7 +124,8 @@ 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, and Swift's track update in `updateNSView` arrives with its first changing caller, the mixer's families (12.8b2). `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`; 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'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.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to
"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2fa4394..bdde3b6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -119,7 +119,13 @@ add_library(compositor_core STATIC
src/UI/NavigationToolHeader.cpp
src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp
+ src/UI/CameraRawColorControls.cpp
+ src/UI/CameraRawColorControls+Grading.cpp
+ src/UI/CameraRawColorControls+Mixer.cpp
src/UI/CameraRawControls.cpp
+ src/UI/CameraRawDetailOpticsControls.cpp
+ src/UI/CameraRawGeometryCalibrationControls.cpp
+ src/UI/CameraRawRow.cpp
src/UI/CameraRawControls+Basic.cpp
src/UI/CameraRawSlider.cpp
src/UI/FilterSheet.cpp
diff --git a/TASKS.md b/TASKS.md
index 6b90dda..0b9114f 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -355,7 +355,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.8e: the cause, from a backtrace under load: Qt 6.4's `QHash` insert (`findOrInsert`, `noexcept`) terminates when it cannot allocate, and the JPEG worker's render filled a `QHash` in `LiveMaskGraph::validate`. The render path's hash containers are `std::map` and `std::set` now; `RenderStarvationTests` fails each allocation in turn. Round 1: PASS, its note taken (bake's workers catch `ExportError` alone).
- [x] 12.8p Review PR #8 (a self-contained AppImage workflow from a contributor) and say whether it can merge (the user's request, 2026-09-27).
- 12.8p: built the workflow's AppDir locally on Ubuntu 24.04. Not mergeable: `AppRun` is a bare symlink, so the bundled model under `$APPDIR/usr/share` is never on `XDG_DATA_DIRS` and Remove Background finds no model; libheif's HEVC plugin is not bundled; only xcb is deployed; its tag runs clash with `release.yml`; its tools are unpinned. Changes requested on the PR, with the user's approval.
-- [ ] 12.8b2 Version 1.2.3, part three: Curve, Color Mixer, Color Grading (its wheels turning the way their hue runs, 1.2.9's `96edd5f`), Detail, Optics, Geometry and Calibration in the panel, and the Filter menu's Camera Raw entry.
+- [x] 12.8b2 Version 1.2.3, part three: Curve, Color Mixer, Color Grading (its wheels turning the way their hue runs, 1.2.9's `96edd5f`), Detail, Optics, Geometry and Calibration in the panel, and the Filter menu's Camera Raw entry.
+- 12.8b2 round 1: FAIL: two classes named `CurveGraph` shared a vtable (renamed); a real double click re-added the point; the wheel's double click reset outside the circle; 79 of 81 mutants lived. All fixed, with row tables and drawing tests.
+- 12.8b2 round 2: FAIL, residue: words, captions, buddies, picker states, page visibility, the wheel's lower half and rim, the curve's line, repaints, the selected point. All pinned.
+- 12.8b2 round 3: FAIL: the AGENTS.md wheel sentence was wrong; the widgets' following of the model and two guards were unpinned. Fixed after the round and closed, as the rule allows; the last mutants caught.
- [ ] 12.8c Version 1.2.3, part four: Inner Glow, Image Trim, blur and noise adjustment layers, the layer context menu without 1.2.5's dropped Layer Effects entry (`5d7adba`).
- [ ] 12.8r Release OmaPhoto 1.2.3 once 12.8b2 and 12.8c are in (the user's decision, 2026-09-27): the GitHub release, and `versionupdate.md` with what changed since 1.0.1.
- [x] 12.8d A `std::bad_alloc` aborted `ProjectExportFailureTests` under a parallel run (seen in 12.7b and 12.8a; alone it passed ten of ten): the panel tests held a memory limit while dialogs painted, and the panel limits sat at the edge of what the panel and its snapshot need.
diff --git a/src/UI/CameraRawColorControls+Grading.cpp b/src/UI/CameraRawColorControls+Grading.cpp
new file mode 100644
index 0000000..ba3aa01
--- /dev/null
+++ b/src/UI/CameraRawColorControls+Grading.cpp
@@ -0,0 +1,240 @@
+#include "UI/CameraRawColorControls.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+CameraRawGradeWheel &wheelOf(CameraRawSettings &settings, int key)
+{
+ CameraRawGradingSettings &grading = settings.grading;
+ return key == 0 ? grading.shadows : key == 1 ? grading.midtones : key == 2 ? grading.highlights : grading.global;
+}
+
+// Swift's GradeWheel: angle sets hue, distance saturation.
+class GradeWheel : public QWidget {
+public:
+ GradeWheel(std::function value, std::function set, std::function reset, QWidget *parent)
+ : QWidget(parent), m_value(std::move(value)), m_set(std::move(set)), m_reset(std::move(reset))
+ {
+ setFixedSize(86, 86);
+ setToolTip(QStringLiteral("Drag to set hue and saturation. Double-click to reset this wheel."));
+ }
+
+protected:
+ void paintEvent(QPaintEvent *) override
+ {
+ QPainter painter(this);
+ painter.setRenderHint(QPainter::Antialiasing);
+ const double side = std::min(width(), height()), radius = side / 2 - 6;
+ const QPointF centre(side / 2, side / 2);
+ // Hue runs counterclockwise from red, as Qt's cone does.
+ QConicalGradient hues(centre, 0);
+ for (int degrees = 0; degrees <= 360; degrees += 30)
+ hues.setColorAt(degrees / 360.0, QColor::fromHsvF(float(degrees % 360) / 360, 1, 1));
+ painter.setOpacity(0.85);
+ painter.setPen(Qt::NoPen);
+ painter.setBrush(hues);
+ painter.drawEllipse(centre, side / 2, side / 2);
+ painter.setOpacity(1);
+ painter.setBrush(Qt::NoBrush);
+ painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.8f), 1));
+ painter.drawEllipse(centre, side / 2 - 0.5, side / 2 - 0.5);
+ const CameraRawGradeWheel wheel = m_value();
+ const double angle = wheel.hue * std::numbers::pi / 180, distance = wheel.saturation / 100 * radius;
+ painter.setPen(Qt::NoPen);
+ painter.setBrush(Qt::white);
+ painter.drawEllipse(centre + QPointF(std::cos(angle) * distance, -std::sin(angle) * distance), 5, 5);
+ }
+
+ // Swift's contentShape: presses land inside the circle alone.
+ bool inside(QPointF at) const
+ {
+ const double side = std::min(width(), height());
+ return QLineF(QPointF(side / 2, side / 2), at).length() <= side / 2;
+ }
+
+ void mousePressEvent(QMouseEvent *event) override
+ {
+ m_dragging = event->button() == Qt::LeftButton && inside(event->position());
+ if (m_dragging)
+ drag(event->position());
+ }
+
+ void mouseMoveEvent(QMouseEvent *event) override
+ {
+ if (m_dragging && event->buttons().testFlag(Qt::LeftButton))
+ drag(event->position());
+ }
+
+ void mouseReleaseEvent(QMouseEvent *) override { m_dragging = false; }
+
+ void mouseDoubleClickEvent(QMouseEvent *event) override
+ {
+ if (event->button() == Qt::LeftButton && inside(event->position()))
+ m_reset();
+ }
+
+private:
+ void drag(QPointF at)
+ {
+ const double side = std::min(width(), height()), radius = side / 2 - 6;
+ const double dx = at.x() - side / 2, dy = side / 2 - at.y();
+ double degrees = std::atan2(dy, dx) * 180 / std::numbers::pi;
+ if (degrees < 0)
+ degrees += 360;
+ m_set(degrees, std::min(100.0, std::hypot(dx, dy) / radius * 100));
+ }
+
+ const std::function m_value;
+ const std::function m_set;
+ const std::function m_reset;
+ bool m_dragging = false;
+};
+
+const std::array pages{QStringLiteral("Three-Way"), QStringLiteral("Shadows"), QStringLiteral("Midtones"), QStringLiteral("Highlights"),
+ QStringLiteral("Global")};
+
+QLabel *small(const QString &text, QWidget *parent)
+{
+ auto *label = new QLabel(text, parent);
+ QFont font = label->font();
+ font.setPixelSize(10);
+ label->setFont(font);
+ return label;
+}
+}
+
+CameraRawGradingControls::CameraRawGradingControls(EditorSession &session, QWidget *parent)
+ : QWidget(parent), m_session(session), m_page(new QComboBox(this)), m_threeWay(new QWidget(this)), m_single(new QWidget(this))
+{
+ auto *column = new QVBoxLayout(this);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(8);
+ // Five segments want more room than the panel: a menu.
+ m_page->setObjectName(QStringLiteral("gradePage"));
+ for (const QString &page : pages)
+ m_page->addItem(page);
+ m_page->setToolTip(QStringLiteral("Three-Way shows shadows, midtones, and highlights. The other choices show one wheel."));
+ m_page->setSizeAdjustPolicy(QComboBox::AdjustToContents);
+ auto *hidden = new QLabel(QStringLiteral("Grading"), this);
+ hidden->setBuddy(m_page);
+ hidden->hide();
+ connect(m_page, &QComboBox::activated, this, [this](int page) {
+ if (!m_session.filterEdit())
+ return;
+ CameraRawPanel raw = m_session.filterEdit()->rawPanel;
+ raw.gradePage = CameraRawGradePage(page);
+ m_session.setCameraRawPanel(raw);
+ });
+ column->addWidget(m_page, 0, Qt::AlignLeft);
+ auto *three = new QHBoxLayout(m_threeWay);
+ three->setContentsMargins(0, 0, 0, 0);
+ three->setSpacing(30);
+ for (int key = 0; key < 3; ++key)
+ three->addWidget(wheel(pages.at(size_t(key) + 1), key));
+ three->addStretch(1);
+ column->addWidget(m_threeWay);
+ auto *single = new QHBoxLayout(m_single);
+ single->setContentsMargins(0, 0, 0, 0);
+ for (int key = 0; key < 4; ++key)
+ single->addWidget(wheel(pages.at(size_t(key) + 1), key));
+ single->addStretch(1);
+ column->addWidget(m_single);
+ const auto slider = [this, column](const QString &name, const QString &title, double low, double high, double reset, const QString &help,
+ double CameraRawGradingSettings::*key) {
+ const auto set = [this, key](double value) { update([key, value](CameraRawSettings &settings) { settings.grading.*key = value; }); };
+ auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .low = low, .high = high, .titleWidth = 78, .fixedTitle = true, .fieldWidth = 0},
+ [this, key] { return raw().grading.*key; }, set, set, [set, reset] { set(reset); }, this);
+ column->addWidget(row);
+ m_rows.push_back(row);
+ };
+ slider(QStringLiteral("gradeBlending"), QStringLiteral("Blending"), 0, 100, 50, QStringLiteral("Controls how much the three tonal wheels overlap."),
+ &CameraRawGradingSettings::blending);
+ slider(QStringLiteral("gradeBalance"), QStringLiteral("Balance"), -100, 100, 0, QStringLiteral("Shifts the wheels toward shadows or highlights."),
+ &CameraRawGradingSettings::balance);
+ connect(&m_session, &EditorSession::changed, this, &CameraRawGradingControls::synchronize);
+ synchronize();
+}
+
+// Swift's wheel: its title, the wheel, the readout and brightness.
+QWidget *CameraRawGradingControls::wheel(const QString &title, int key)
+{
+ auto *box = new QWidget;
+ box->setObjectName(QStringLiteral("gradeWheel") + title);
+ auto *column = new QVBoxLayout(box);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(4);
+ QLabel *heading = small(title, box);
+ heading->setToolTip(QStringLiteral("Drag inside the wheel. Angle sets hue, distance sets saturation."));
+ column->addWidget(heading, 0, Qt::AlignHCenter);
+ column->addWidget(new GradeWheel([this, key] {
+ CameraRawSettings settings = raw();
+ return wheelOf(settings, key);
+ },
+ [this, key](double hue, double saturation) {
+ update([key, hue, saturation](CameraRawSettings &settings) {
+ wheelOf(settings, key).hue = hue;
+ wheelOf(settings, key).saturation = saturation;
+ });
+ },
+ [this, key] {
+ update([key](CameraRawSettings &settings) {
+ wheelOf(settings, key).hue = 0;
+ wheelOf(settings, key).saturation = 0;
+ });
+ },
+ box),
+ 0, Qt::AlignHCenter);
+ QLabel *readout = small(QString(), box);
+ readout->setObjectName(QStringLiteral("gradeReadout"));
+ readout->setToolTip(QStringLiteral("Hue and saturation of this wheel."));
+ column->addWidget(readout, 0, Qt::AlignHCenter);
+ const auto set = [this, key](double value) { update([key, value](CameraRawSettings &settings) { wheelOf(settings, key).luminance = value; }); };
+ // Three columns fit when each slider takes 96.
+ auto *luminance = new CameraRawSlider(-100, 100, {}, QStringLiteral("Brightness added by this wheel."), set, [set] { set(0); }, box);
+ luminance->setObjectName(QStringLiteral("gradeLuminance"));
+ luminance->setFixedWidth(96);
+ column->addWidget(luminance, 0, Qt::AlignHCenter);
+ m_wheels.push_back({key, box});
+ return box;
+}
+
+CameraRawSettings CameraRawGradingControls::raw() const
+{
+ return m_session.filterEdit() ? m_session.filterEdit()->settings.cameraRaw : CameraRawSettings();
+}
+
+void CameraRawGradingControls::update(const std::function &change)
+{
+ FilterSettings settings = m_session.filterEdit() ? m_session.filterEdit()->settings : FilterSettings();
+ change(settings.cameraRaw);
+ m_session.updateFilter(settings, m_session.filterEdit() ? m_session.filterEdit()->preview : true);
+}
+
+void CameraRawGradingControls::synchronize()
+{
+ const CameraRawGradePage page = m_session.filterEdit() ? m_session.filterEdit()->rawPanel.gradePage : CameraRawGradePage::threeWay;
+ {
+ const QSignalBlocker quiet(m_page);
+ m_page->setCurrentIndex(int(page));
+ }
+ m_threeWay->setVisible(page == CameraRawGradePage::threeWay);
+ m_single->setVisible(page != CameraRawGradePage::threeWay);
+ CameraRawSettings settings = raw();
+ for (const auto &[key, box] : m_wheels) {
+ // A single page shows the wheel it names.
+ if (box->parentWidget() == m_single)
+ box->setVisible(int(page) == key + 1);
+ const CameraRawGradeWheel &wheel = wheelOf(settings, key);
+ box->findChild(QStringLiteral("gradeReadout"))->setText(QStringLiteral("%1° %2").arg(std::lround(wheel.hue)).arg(std::lround(wheel.saturation)));
+ box->findChild(QStringLiteral("gradeLuminance"))->display(wheel.luminance);
+ box->update();
+ }
+ for (CameraRawRow *row : m_rows)
+ row->synchronize();
+}
diff --git a/src/UI/CameraRawColorControls+Mixer.cpp b/src/UI/CameraRawColorControls+Mixer.cpp
new file mode 100644
index 0000000..7cc6216
--- /dev/null
+++ b/src/UI/CameraRawColorControls+Mixer.cpp
@@ -0,0 +1,316 @@
+#include "Rendering/EyedropperIcon.h"
+#include "UI/CameraRawColorControls.h"
+#include "UI/LayerIcons.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+const std::array tabs{QStringLiteral("Hue"), QStringLiteral("Saturation"), QStringLiteral("Luminance")};
+
+QColor hsb(double degrees, double saturation, double brightness)
+{
+ return QColor::fromHsvF(float(std::fmod(degrees, 360) / 360), float(saturation), float(brightness));
+}
+
+// Swift's familyTrack: the family's hue, strength or lightness.
+CameraRawSliderTrack familyTrack(int index, int tab)
+{
+ const double hue = CameraRawMixerSettings::centers.at(size_t(index));
+ using Kind = CameraRawSliderTrack::Kind;
+ return {tab == 1 ? Kind::saturation : tab == 2 ? Kind::luminance : Kind::hue, hue};
+}
+
+// Swift's swatch: a filled circle, ringed white when chosen.
+class Swatch : public QAbstractButton {
+public:
+ Swatch(double side, QWidget *parent) : QAbstractButton(parent), m_side(side) { setFixedSize(int(side) + 4, int(side) + 4); }
+ QColor colour;
+ bool chosen = false;
+
+protected:
+ void paintEvent(QPaintEvent *) override
+ {
+ QPainter painter(this);
+ painter.setRenderHint(QPainter::Antialiasing);
+ const QRectF circle(2, 2, m_side, m_side);
+ painter.setPen(Qt::NoPen);
+ painter.setBrush(colour);
+ painter.drawEllipse(circle);
+ if (chosen) {
+ painter.setPen(QPen(Qt::white, 2));
+ painter.setBrush(Qt::NoBrush);
+ painter.drawEllipse(circle);
+ }
+ }
+
+private:
+ const double m_side;
+};
+
+void clear(QWidget *box)
+{
+ for (QWidget *child : box->findChildren(QString(), Qt::FindDirectChildrenOnly))
+ delete child;
+}
+}
+
+CameraRawMixerControls::CameraRawMixerControls(EditorSession &session, QWidget *parent)
+ : QWidget(parent), m_session(session), m_hsl(new QWidget(this)), m_families(new QWidget(m_hsl)), m_color(new QWidget(this)),
+ m_colorRows(new QWidget(m_color)), m_point(new QWidget(this)), m_sampler(new QToolButton(m_point)), m_picked(new QWidget(m_point)),
+ m_pointControls(new QWidget(m_point)), m_pointRows(new QWidget(m_pointControls)),
+ m_visualize(new QCheckBox(QStringLiteral("Visualize Range"), m_pointControls)), m_target(new QPushButton(QStringLiteral("Targeted Adjustment"), this))
+{
+ auto *column = new QVBoxLayout(this);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(8);
+ m_page = cameraRawSegments({QStringLiteral("HSL"), QStringLiteral("Color"), QStringLiteral("Point Color")}, QStringLiteral("mixerPage"),
+ QStringLiteral("HSL lists every color. Color edits one family. Point Color adjusts a color you pick."), column,
+ [this](int page) { panel([page](CameraRawPanel &raw) { raw.mixerPage = CameraRawMixerPage(page); }); });
+ auto *hsl = new QVBoxLayout(m_hsl);
+ hsl->setContentsMargins(0, 0, 0, 0);
+ hsl->setSpacing(8);
+ m_tab = cameraRawSegments({tabs[0], tabs[1], tabs[2]}, QStringLiteral("mixerTab"),
+ QStringLiteral("Hue shifts the color, Saturation its strength, and Luminance its brightness."), hsl,
+ [this](int tab) { panel([tab](CameraRawPanel &raw) { raw.mixerTab = CameraRawMixerTab(tab); }); });
+ new QVBoxLayout(m_families);
+ m_families->layout()->setContentsMargins(0, 0, 0, 0);
+ m_families->layout()->setSpacing(8);
+ hsl->addWidget(m_families);
+ column->addWidget(m_hsl);
+ auto *color = new QVBoxLayout(m_color);
+ color->setContentsMargins(0, 0, 0, 0);
+ color->setSpacing(8);
+ auto *swatches = new QHBoxLayout;
+ for (int index = 0; index < 8; ++index) {
+ auto *swatch = new Swatch(18, m_color);
+ swatch->setObjectName(QStringLiteral("mixerSwatch%1").arg(index));
+ swatch->colour = hsb(CameraRawMixerSettings::centers.at(size_t(index)), 0.8, 0.9);
+ swatch->setToolTip(QStringLiteral("Edit %1.").arg(CameraRawMixerSettings::names.at(size_t(index))));
+ connect(swatch, &QAbstractButton::clicked, this, [this, index] { panel([index](CameraRawPanel &raw) { raw.mixerSwatch = index; }); });
+ swatches->addWidget(swatch);
+ m_swatches.push_back(swatch);
+ }
+ swatches->addStretch(1);
+ color->addLayout(swatches);
+ new QVBoxLayout(m_colorRows);
+ m_colorRows->layout()->setContentsMargins(0, 0, 0, 0);
+ m_colorRows->layout()->setSpacing(8);
+ color->addWidget(m_colorRows);
+ column->addWidget(m_color);
+ auto *point = new QVBoxLayout(m_point);
+ point->setContentsMargins(0, 0, 0, 0);
+ point->setSpacing(8);
+ auto *pickRow = new QHBoxLayout;
+ m_sampler->setObjectName(QStringLiteral("pointColorSampler"));
+ m_sampler->setAutoRaise(true);
+ m_sampler->setToolTip(QStringLiteral("Click the picture to save a color. Up to eight colors."));
+ connect(m_sampler, &QToolButton::clicked, this, [this] { panel([](CameraRawPanel &raw) { raw.samplesPointColor = !raw.samplesPointColor; }); });
+ pickRow->addWidget(m_sampler);
+ new QHBoxLayout(m_picked);
+ m_picked->layout()->setContentsMargins(0, 0, 0, 0);
+ pickRow->addWidget(m_picked);
+ pickRow->addStretch(1);
+ point->addLayout(pickRow);
+ auto *controls = new QVBoxLayout(m_pointControls);
+ controls->setContentsMargins(0, 0, 0, 0);
+ controls->setSpacing(8);
+ new QVBoxLayout(m_pointRows);
+ m_pointRows->layout()->setContentsMargins(0, 0, 0, 0);
+ m_pointRows->layout()->setSpacing(8);
+ controls->addWidget(m_pointRows);
+ m_visualize->setObjectName(QStringLiteral("visualizeRange"));
+ m_visualize->setToolTip(QStringLiteral("Dims the picture outside this color's range. It is not kept when you press OK."));
+ connect(m_visualize, &QAbstractButton::clicked, this, [this](bool on) { updatePoint([on](CameraRawPointColor &color) { color.visualize = on; }); });
+ controls->addWidget(m_visualize);
+ point->addWidget(m_pointControls);
+ column->addWidget(m_point);
+ m_target->setObjectName(QStringLiteral("mixerTargeted"));
+ m_target->setCheckable(true);
+ m_target->setAutoDefault(false);
+ m_target->setToolTip(QStringLiteral("Drag a color in the picture. Nearby color families move together."));
+ connect(m_target, &QPushButton::clicked, this, [this] {
+ panel([](CameraRawPanel &raw) {
+ raw.targetsCurve = false;
+ raw.targetsMixer = !raw.targetsMixer;
+ });
+ });
+ column->addWidget(m_target, 0, Qt::AlignLeft);
+ connect(&m_session, &EditorSession::changed, this, &CameraRawMixerControls::synchronize);
+ synchronize();
+}
+
+void CameraRawMixerControls::row(QWidget *box, CameraRawRow::Spec spec, std::function value,
+ std::function write, double reset)
+{
+ const auto set = [this, write](double number) { update([write, number](CameraRawSettings &settings) { write(settings, number); }); };
+ box->layout()->addWidget(new CameraRawRow(std::move(spec), [this, value] { return value(raw()); }, set, set, [set, reset] { set(reset); }, box));
+}
+
+void CameraRawMixerControls::rebuildFamilies(int tab)
+{
+ clear(m_families);
+ for (int index = 0; index < 8; ++index) {
+ const QString &name = CameraRawMixerSettings::names.at(size_t(index));
+ const auto family = [tab](CameraRawSettings &settings) -> std::array & {
+ return tab == 1 ? settings.mixer.saturation : tab == 2 ? settings.mixer.luminance : settings.mixer.hue;
+ };
+ row(m_families,
+ {.name = QStringLiteral("family") + name, .title = name, .help = QStringLiteral("%1 of %2.").arg(tabs.at(size_t(tab)), name),
+ .track = familyTrack(index, tab), .titleWidth = 78, .fixedTitle = true, .fieldWidth = 48},
+ [family, index](const CameraRawSettings &settings) {
+ CameraRawSettings copy = settings;
+ return family(copy).at(size_t(index));
+ },
+ [family, index](CameraRawSettings &settings, double value) { family(settings).at(size_t(index)) = value; }, 0);
+ }
+}
+
+void CameraRawMixerControls::rebuildColor(int swatch)
+{
+ clear(m_colorRows);
+ const std::array help{QStringLiteral("Shifts the selected color family around the wheel."),
+ QStringLiteral("Makes the selected color family stronger or quieter."),
+ QStringLiteral("Makes the selected color family lighter or darker.")};
+ for (int tab = 0; tab < 3; ++tab) {
+ const auto family = [tab](CameraRawSettings &settings) -> std::array & {
+ return tab == 1 ? settings.mixer.saturation : tab == 2 ? settings.mixer.luminance : settings.mixer.hue;
+ };
+ row(m_colorRows,
+ {.name = QStringLiteral("color") + tabs.at(size_t(tab)), .title = tabs.at(size_t(tab)), .help = help.at(size_t(tab)),
+ .track = familyTrack(swatch, tab), .titleWidth = 88, .fixedTitle = true, .fieldWidth = 0},
+ [family, swatch](const CameraRawSettings &settings) {
+ CameraRawSettings copy = settings;
+ return family(copy).at(size_t(swatch));
+ },
+ [family, swatch](CameraRawSettings &settings, double value) { family(settings).at(size_t(swatch)) = value; }, 0);
+ }
+}
+
+void CameraRawMixerControls::rebuildPoint(int index, double hue)
+{
+ clear(m_pointRows);
+ using Kind = CameraRawSliderTrack::Kind;
+ const auto point = [this, index](const QString &name, const QString &title, const QString &help, double CameraRawPointColor::*key, double low,
+ double high, double reset, CameraRawSliderTrack track) {
+ row(m_pointRows,
+ {.name = name, .title = title, .help = help, .low = low, .high = high, .track = track, .titleWidth = 110, .fixedTitle = true, .fieldWidth = 0},
+ [index, key](const CameraRawSettings &settings) {
+ const std::vector &points = settings.mixer.points;
+ return size_t(index) < points.size() ? points[size_t(index)].*key : 0.0;
+ },
+ [index, key](CameraRawSettings &settings, double value) {
+ if (size_t(index) < settings.mixer.points.size())
+ settings.mixer.points[size_t(index)].*key = value;
+ },
+ reset);
+ };
+ point(QStringLiteral("hueShift"), QStringLiteral("Hue Shift"), QStringLiteral("Shifts the picked color around the color wheel."),
+ &CameraRawPointColor::hueShift, -100, 100, 0, {Kind::hue, hue});
+ point(QStringLiteral("saturationShift"), QStringLiteral("Saturation Shift"), QStringLiteral("Makes the picked color stronger or quieter."),
+ &CameraRawPointColor::saturationShift, -100, 100, 0, {Kind::saturation, hue});
+ point(QStringLiteral("luminanceShift"), QStringLiteral("Luminance Shift"), QStringLiteral("Makes the picked color lighter or darker."),
+ &CameraRawPointColor::luminanceShift, -100, 100, 0, {Kind::luminance, hue});
+ point(QStringLiteral("hueRange"), QStringLiteral("Hue Range"), QStringLiteral("How far in hue the adjustment reaches."), &CameraRawPointColor::hueRange,
+ 5, 180, 30, {});
+ point(QStringLiteral("saturationRange"), QStringLiteral("Saturation Range"), QStringLiteral("How far in saturation the adjustment reaches."),
+ &CameraRawPointColor::saturationRange, 0.05, 1, 0.4, {});
+ point(QStringLiteral("luminanceRange"), QStringLiteral("Luminance Range"), QStringLiteral("How far in brightness the adjustment reaches."),
+ &CameraRawPointColor::luminanceRange, 0.05, 1, 0.4, {});
+}
+
+CameraRawSettings CameraRawMixerControls::raw() const
+{
+ return m_session.filterEdit() ? m_session.filterEdit()->settings.cameraRaw : CameraRawSettings();
+}
+
+void CameraRawMixerControls::update(const std::function &change)
+{
+ FilterSettings settings = m_session.filterEdit() ? m_session.filterEdit()->settings : FilterSettings();
+ change(settings.cameraRaw);
+ m_session.updateFilter(settings, m_session.filterEdit() ? m_session.filterEdit()->preview : true);
+}
+
+// Swift's updatePoint: the chosen point, when it still exists.
+void CameraRawMixerControls::updatePoint(const std::function &change)
+{
+ const int index = m_session.filterEdit() ? m_session.filterEdit()->rawPanel.pointIndex : 0;
+ update([index, &change](CameraRawSettings &settings) {
+ if (index >= 0 && size_t(index) < settings.mixer.points.size())
+ change(settings.mixer.points[size_t(index)]);
+ });
+}
+
+void CameraRawMixerControls::panel(const std::function &change)
+{
+ if (!m_session.filterEdit())
+ return;
+ CameraRawPanel raw = m_session.filterEdit()->rawPanel;
+ change(raw);
+ m_session.setCameraRawPanel(raw);
+}
+
+void CameraRawMixerControls::synchronize()
+{
+ const std::optional &edit = m_session.filterEdit();
+ const CameraRawPanel shown = edit ? edit->rawPanel : CameraRawPanel();
+ const CameraRawSettings settings = raw();
+ m_page->button(int(shown.mixerPage))->setChecked(true);
+ m_tab->button(int(shown.mixerTab))->setChecked(true);
+ m_hsl->setVisible(shown.mixerPage == CameraRawMixerPage::hsl);
+ m_color->setVisible(shown.mixerPage == CameraRawMixerPage::color);
+ m_point->setVisible(shown.mixerPage == CameraRawMixerPage::point);
+ if (m_shownTab != int(shown.mixerTab)) {
+ m_shownTab = int(shown.mixerTab);
+ rebuildFamilies(*m_shownTab);
+ }
+ const int swatch = std::min(7, shown.mixerSwatch);
+ if (m_shownSwatch != swatch) {
+ m_shownSwatch = swatch;
+ rebuildColor(swatch);
+ }
+ for (size_t index = 0; index < m_swatches.size(); ++index) {
+ static_cast(m_swatches[index])->chosen = int(index) == shown.mixerSwatch;
+ m_swatches[index]->update();
+ }
+ const std::vector &points = settings.mixer.points;
+ if (m_shownPicked != points.size()) {
+ m_shownPicked = points.size();
+ clear(m_picked);
+ for (size_t index = 0; index < points.size(); ++index) {
+ auto *pick = new Swatch(16, m_picked);
+ pick->setObjectName(QStringLiteral("pickedColor%1").arg(index));
+ pick->setToolTip(QStringLiteral("Select this picked color."));
+ connect(pick, &QAbstractButton::clicked, this, [this, index] { panel([index](CameraRawPanel &raw) { raw.pointIndex = int(index); }); });
+ m_picked->layout()->addWidget(pick);
+ // Shown now: a layout shows late children only later.
+ pick->show();
+ }
+ }
+ for (size_t index = 0; index < points.size(); ++index) {
+ auto *pick = m_picked->findChild(QStringLiteral("pickedColor%1").arg(index));
+ pick->colour = hsb(points[index].hue, points[index].saturation, points[index].luminance);
+ pick->chosen = int(index) == shown.pointIndex;
+ pick->update();
+ }
+ const bool chosen = shown.pointIndex >= 0 && size_t(shown.pointIndex) < points.size();
+ m_pointControls->setVisible(chosen);
+ if (chosen && m_shownPoint != std::pair(shown.pointIndex, points[size_t(shown.pointIndex)].hue)) {
+ m_shownPoint = std::pair(shown.pointIndex, points[size_t(shown.pointIndex)].hue);
+ rebuildPoint(shown.pointIndex, m_shownPoint->second);
+ }
+ {
+ const QSignalBlocker quiet(m_visualize);
+ m_visualize->setChecked(chosen && points[size_t(shown.pointIndex)].visualize);
+ }
+ m_sampler->setIcon(EyedropperIcon::icon(palette().color(shown.samplesPointColor ? QPalette::Highlight : QPalette::PlaceholderText), devicePixelRatioF()));
+ for (CameraRawRow *each : findChildren())
+ each->synchronize();
+ m_target->setChecked(shown.targetsMixer);
+ m_target->setIcon(QIcon(LayerIcons::pixmap(LayerIcon::scope, 14, palette().color(QPalette::ButtonText), devicePixelRatioF())));
+}
diff --git a/src/UI/CameraRawColorControls.cpp b/src/UI/CameraRawColorControls.cpp
new file mode 100644
index 0000000..cf5d26c
--- /dev/null
+++ b/src/UI/CameraRawColorControls.cpp
@@ -0,0 +1,355 @@
+#include "UI/CameraRawColorControls.h"
+#include "UI/CameraRawRow.h"
+#include "UI/LayerIcons.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+QButtonGroup *cameraRawSegments(const QStringList &titles, const QString &name, const QString &help, QVBoxLayout *column, std::function choose)
+{
+ auto *box = new QWidget;
+ auto *row = new QHBoxLayout(box);
+ row->setContentsMargins(0, 0, 0, 0);
+ row->setSpacing(0);
+ auto *group = new QButtonGroup(box);
+ group->setExclusive(true);
+ for (int index = 0; index < titles.size(); ++index) {
+ auto *button = new QToolButton(box);
+ button->setText(titles[index]);
+ button->setCheckable(true);
+ button->setToolTip(help);
+ button->setObjectName(name + QString::number(index));
+ button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+ group->addButton(button, index);
+ row->addWidget(button);
+ }
+ QObject::connect(group, &QButtonGroup::idClicked, box, std::move(choose));
+ column->addWidget(box);
+ return group;
+}
+
+// Swift's curve graph: diagonal, curve, dividers or points.
+class CameraRawCurveGraph : public QWidget {
+public:
+ explicit CameraRawCurveGraph(CameraRawCurveControls &owner) : QWidget(&owner), m_owner(owner)
+ {
+ setObjectName(QStringLiteral("cameraRawCurveGraph"));
+ setFixedHeight(150);
+ }
+
+protected:
+ bool parametric() const
+ {
+ return !m_owner.m_session.filterEdit() || m_owner.m_session.filterEdit()->rawPanel.curvePage == CameraRawCurvePage::parametric;
+ }
+
+ void paintEvent(QPaintEvent *) override
+ {
+ QPainter painter(this);
+ painter.setRenderHint(QPainter::Antialiasing);
+ QPainterPath frame;
+ frame.addRoundedRect(QRectF(rect()), 4, 4);
+ painter.setClipPath(frame);
+ painter.fillRect(rect(), QColor::fromRgbF(0, 0, 0, 0.35f));
+ const double w = width(), h = height();
+ painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.25f), 1));
+ painter.drawLine(QPointF(0, h), QPointF(w, 0));
+ const CameraRawCurveSettings curve = m_owner.raw().curve;
+ std::vector samples;
+ if (parametric()) {
+ for (int index = 0; index < 64; ++index)
+ samples.push_back(curve.parametric(index / 63.0));
+ } else {
+ for (const float value : curve.channelTable(m_owner.currentPoints()))
+ samples.push_back(value);
+ }
+ QPainterPath line;
+ for (size_t index = 0; index < samples.size(); ++index) {
+ const QPointF point(double(index) / double(std::max(1, samples.size() - 1)) * w, (1 - samples[index]) * h);
+ if (index == 0)
+ line.moveTo(point);
+ else
+ line.lineTo(point);
+ }
+ painter.strokePath(line, QPen(Qt::white, 1.5));
+ if (parametric()) {
+ painter.setPen(QPen(Qt::white, 3));
+ for (const double split : {curve.shadowSplit, curve.darkSplit, curve.lightSplit})
+ painter.drawLine(QPointF(split / 100 * w, h - 8), QPointF(split / 100 * w, h));
+ return;
+ }
+ painter.setPen(Qt::NoPen);
+ painter.setBrush(Qt::white);
+ for (const CurvePoint &point : m_owner.currentPoints())
+ painter.drawEllipse(QRectF(point.x * w - 4, (1 - point.y) * h - 4, 8, 8));
+ }
+
+ // Swift's two drags: still, a press adds; moved, it drags.
+ void mousePressEvent(QMouseEvent *event) override
+ {
+ if (event->button() == Qt::LeftButton)
+ m_press = event->position();
+ }
+
+ void mouseMoveEvent(QMouseEvent *event) override
+ {
+ if (!m_press || !event->buttons().testFlag(Qt::LeftButton))
+ return;
+ if (!m_moved && QLineF(*m_press, event->position()).length() < 2)
+ return;
+ m_moved = true;
+ if (parametric())
+ moveDivider(event->position().x());
+ else
+ movePoint(event->position());
+ }
+
+ void mouseReleaseEvent(QMouseEvent *event) override
+ {
+ if (event->button() == Qt::LeftButton && m_press && !m_moved && event->position() == *m_press && !parametric())
+ addPoint(event->position());
+ m_press.reset();
+ m_moved = false;
+ }
+
+ // The double click's own release must add nothing back.
+ void mouseDoubleClickEvent(QMouseEvent *event) override
+ {
+ if (event->button() == Qt::LeftButton && !parametric())
+ removePoint(event->position());
+ }
+
+private:
+ void moveDivider(double x)
+ {
+ const double value = std::min(98.0, std::max(2.0, x / std::max(double(width()), 1.0) * 100));
+ const CameraRawCurveSettings curve = m_owner.raw().curve;
+ const std::array splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
+ size_t nearest = 0;
+ for (size_t index = 1; index < 3; ++index)
+ if (std::abs(splits[index] - value) < std::abs(splits[nearest] - value))
+ nearest = index;
+ m_owner.update([nearest, value](CameraRawSettings &settings) {
+ (nearest == 0 ? settings.curve.shadowSplit : nearest == 1 ? settings.curve.darkSplit : settings.curve.lightSplit) = value;
+ });
+ }
+
+ void addPoint(QPointF at)
+ {
+ std::vector points = m_owner.currentPoints();
+ points.push_back({std::min(0.99, std::max(0.01, at.x() / width())), std::min(1.0, std::max(0.0, 1 - at.y() / height()))});
+ m_owner.store(points);
+ }
+
+ // The nearest inner point by x; ends stay.
+ std::optional nearestInner(const std::vector &points, double x) const
+ {
+ std::optional nearest;
+ for (size_t index = 1; index + 1 < points.size(); ++index)
+ if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
+ nearest = index;
+ return nearest;
+ }
+
+ void movePoint(QPointF at)
+ {
+ std::vector points = m_owner.currentPoints();
+ const double x = at.x() / width();
+ const std::optional index = nearestInner(points, x);
+ if (!index)
+ return;
+ points[*index] = {std::min(0.98, std::max(0.02, x)), std::min(1.0, std::max(0.0, 1 - at.y() / height()))};
+ m_owner.store(points);
+ }
+
+ void removePoint(QPointF at)
+ {
+ std::vector points = m_owner.currentPoints();
+ const double x = at.x() / width();
+ const std::optional index = nearestInner(points, x);
+ if (!index || !(std::abs(points[*index].x - x) < 0.04))
+ return;
+ points.erase(points.begin() + std::ptrdiff_t(*index));
+ m_owner.store(points);
+ }
+
+ CameraRawCurveControls &m_owner;
+ std::optional m_press;
+ bool m_moved = false;
+};
+
+namespace {
+QLabel *caption(const QString &text, int pixels)
+{
+ auto *label = new QLabel(text);
+ QFont font = label->font();
+ font.setPixelSize(pixels);
+ label->setFont(font);
+ label->setForegroundRole(QPalette::PlaceholderText);
+ return label;
+}
+}
+
+CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *parent)
+ : QWidget(parent), m_session(session), m_graph(new CameraRawCurveGraph(*this)), m_amounts(new QWidget(this)), m_points(new QWidget(this)),
+ m_selected(caption(QString(), 10)), m_preset(new QComboBox(m_points)), m_target(new QPushButton(QStringLiteral("Targeted Adjustment"), this))
+{
+ auto *column = new QVBoxLayout(this);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(8);
+ m_page = cameraRawSegments({QStringLiteral("Parametric"), QStringLiteral("Point")}, QStringLiteral("curvePage"),
+ QStringLiteral("Parametric lifts tonal regions. Point places anchors on the curve."), column,
+ [this](int page) { panel([page](CameraRawPanel &raw) { raw.curvePage = CameraRawCurvePage(page); }); });
+ m_channelBox = new QWidget(this);
+ auto *channelColumn = new QVBoxLayout(m_channelBox);
+ channelColumn->setContentsMargins(0, 0, 0, 0);
+ m_channel = cameraRawSegments({QStringLiteral("RGB"), QStringLiteral("Red"), QStringLiteral("Green"), QStringLiteral("Blue")},
+ QStringLiteral("pointChannel"), QStringLiteral("RGB changes brightness. Red, green, and blue also shift the color."),
+ channelColumn, [this](int channel) { panel([channel](CameraRawPanel &raw) { raw.pointChannel = CameraRawPointChannel(channel); }); });
+ column->addWidget(m_channelBox);
+ column->addWidget(m_graph);
+ auto *amounts = new QVBoxLayout(m_amounts);
+ amounts->setContentsMargins(0, 0, 0, 0);
+ amounts->setSpacing(8);
+ const auto amount = [this, amounts](const QString &name, const QString &title, double CameraRawCurveSettings::*key, const QString &help) {
+ auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .titleWidth = 88, .fixedTitle = true, .fieldWidth = 48},
+ [this, key] { return raw().curve.*key; },
+ [this, key](double value) { update([key, value](CameraRawSettings &settings) { settings.curve.*key = value; }); },
+ [this, key](double value) { update([key, value](CameraRawSettings &settings) { settings.curve.*key = value; }); },
+ [this, key] { update([key](CameraRawSettings &settings) { settings.curve.*key = 0; }); });
+ amounts->addWidget(row);
+ m_rows.push_back(row);
+ };
+ amount(QStringLiteral("curveHighlights"), QStringLiteral("Highlights"), &CameraRawCurveSettings::highlights, QStringLiteral("Lifts or lowers the brightest tones."));
+ amount(QStringLiteral("curveLights"), QStringLiteral("Lights"), &CameraRawCurveSettings::lights, QStringLiteral("Lifts or lowers the light tones."));
+ amount(QStringLiteral("curveDarks"), QStringLiteral("Darks"), &CameraRawCurveSettings::darks, QStringLiteral("Lifts or lowers the dark tones."));
+ amount(QStringLiteral("curveShadows"), QStringLiteral("Shadows"), &CameraRawCurveSettings::shadows, QStringLiteral("Lifts or lowers the darkest tones."));
+ column->addWidget(m_amounts);
+ auto *points = new QVBoxLayout(m_points);
+ points->setContentsMargins(0, 0, 0, 0);
+ points->setSpacing(8);
+ m_selected->setObjectName(QStringLiteral("curveSelectedPoint"));
+ m_selected->setToolTip(QStringLiteral("Input and output of the selected curve point."));
+ points->addWidget(m_selected);
+ m_preset->setObjectName(QStringLiteral("curvePreset"));
+ m_preset->addItems({QStringLiteral("Custom"), QStringLiteral("Linear"), QStringLiteral("Medium Contrast"), QStringLiteral("Strong Contrast")});
+ m_preset->setToolTip(QStringLiteral("Replaces this curve with a straight line or a contrast curve."));
+ auto *presetRow = new QHBoxLayout;
+ auto *presetTitle = new QLabel(QStringLiteral("Preset"), m_points);
+ presetTitle->setBuddy(m_preset);
+ presetRow->addWidget(presetTitle);
+ presetRow->addWidget(m_preset);
+ presetRow->addStretch(1);
+ points->addLayout(presetRow);
+ connect(m_preset, &QComboBox::activated, this, [this](int preset) {
+ if (preset == 1)
+ store(CameraRawCurveSettings::linear());
+ else if (preset == 2)
+ store(CameraRawCurveSettings::mediumContrast());
+ else if (preset == 3)
+ store(CameraRawCurveSettings::strongContrast());
+ });
+ m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"),
+ .help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88,
+ .fixedTitle = true, .fieldWidth = 48},
+ [this] { return raw().curve.refineSaturation; },
+ [this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); },
+ [this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); },
+ [this] { update([](CameraRawSettings &settings) { settings.curve.refineSaturation = 0; }); });
+ points->addWidget(m_refine);
+ m_rows.push_back(m_refine);
+ column->addWidget(m_points);
+ m_target->setObjectName(QStringLiteral("curveTargeted"));
+ m_target->setCheckable(true);
+ m_target->setAutoDefault(false);
+ m_target->setToolTip(QStringLiteral("Drag on the picture to move the curve for the tone under the pointer."));
+ connect(m_target, &QPushButton::clicked, this, [this] {
+ panel([](CameraRawPanel &raw) {
+ raw.targetsMixer = false;
+ raw.targetsCurve = !raw.targetsCurve;
+ });
+ });
+ column->addWidget(m_target, 0, Qt::AlignLeft);
+ connect(&m_session, &EditorSession::changed, this, &CameraRawCurveControls::synchronize);
+ synchronize();
+}
+
+CameraRawSettings CameraRawCurveControls::raw() const
+{
+ return m_session.filterEdit() ? m_session.filterEdit()->settings.cameraRaw : CameraRawSettings();
+}
+
+std::vector CameraRawCurveControls::currentPoints() const
+{
+ const CameraRawCurveSettings curve = raw().curve;
+ switch (m_session.filterEdit() ? m_session.filterEdit()->rawPanel.pointChannel : CameraRawPointChannel::rgb) {
+ case CameraRawPointChannel::rgb: return curve.rgb;
+ case CameraRawPointChannel::red: return curve.red;
+ case CameraRawPointChannel::green: return curve.green;
+ case CameraRawPointChannel::blue: return curve.blue;
+ }
+ throw std::logic_error("unknown point channel");
+}
+
+void CameraRawCurveControls::update(const std::function &change)
+{
+ FilterSettings settings = m_session.filterEdit() ? m_session.filterEdit()->settings : FilterSettings();
+ change(settings.cameraRaw);
+ m_session.updateFilter(settings, m_session.filterEdit() ? m_session.filterEdit()->preview : true);
+}
+
+void CameraRawCurveControls::store(const std::vector &points)
+{
+ const CameraRawPointChannel channel = m_session.filterEdit() ? m_session.filterEdit()->rawPanel.pointChannel : CameraRawPointChannel::rgb;
+ update([&points, channel](CameraRawSettings &settings) {
+ (channel == CameraRawPointChannel::rgb ? settings.curve.rgb
+ : channel == CameraRawPointChannel::red ? settings.curve.red
+ : channel == CameraRawPointChannel::green ? settings.curve.green
+ : settings.curve.blue) = points;
+ });
+}
+
+void CameraRawCurveControls::panel(const std::function &change)
+{
+ if (!m_session.filterEdit())
+ return;
+ CameraRawPanel raw = m_session.filterEdit()->rawPanel;
+ change(raw);
+ m_session.setCameraRawPanel(raw);
+}
+
+void CameraRawCurveControls::synchronize()
+{
+ const std::optional &edit = m_session.filterEdit();
+ const bool point = edit && edit->rawPanel.curvePage == CameraRawCurvePage::point;
+ m_page->button(point ? 1 : 0)->setChecked(true);
+ m_channelBox->setVisible(point);
+ const CameraRawPointChannel channel = edit ? edit->rawPanel.pointChannel : CameraRawPointChannel::rgb;
+ m_channel->button(int(channel))->setChecked(true);
+ m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click the curve to add one. Double-click a point to remove it.")
+ : QStringLiteral("Drag a divider to change which tones the neighboring sliders affect."));
+ m_graph->update();
+ m_amounts->setVisible(!point);
+ m_points->setVisible(point);
+ const std::vector points = currentPoints();
+ // Swift's selectedPoint: the last inner point, else the last.
+ const CurvePoint selected = points.size() > 2 ? points[points.size() - 2] : points.back();
+ m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(selected.x * 255)).arg(std::lround(selected.y * 255)));
+ const QSignalBlocker quiet(m_preset);
+ m_preset->setCurrentIndex(points == CameraRawCurveSettings::linear() ? 1
+ : points == CameraRawCurveSettings::mediumContrast() ? 2
+ : points == CameraRawCurveSettings::strongContrast() ? 3
+ : 0);
+ m_refine->setVisible(channel == CameraRawPointChannel::rgb);
+ for (CameraRawRow *row : m_rows)
+ row->synchronize();
+ m_target->setChecked(edit && edit->rawPanel.targetsCurve);
+ m_target->setIcon(QIcon(LayerIcons::pixmap(LayerIcon::scope, 14, palette().color(QPalette::ButtonText), devicePixelRatioF())));
+}
diff --git a/src/UI/CameraRawColorControls.h b/src/UI/CameraRawColorControls.h
new file mode 100644
index 0000000..4d3cd29
--- /dev/null
+++ b/src/UI/CameraRawColorControls.h
@@ -0,0 +1,105 @@
+#pragma once
+#include "Document/EditorSession.h"
+#include "UI/CameraRawRow.h"
+#include
+
+class QAbstractButton;
+class QButtonGroup;
+class QComboBox;
+class QLabel;
+class QPushButton;
+class QToolButton;
+class QVBoxLayout;
+
+// Swift's segmented pickers: touching checkable buttons, one chosen.
+QButtonGroup *cameraRawSegments(const QStringList &titles, const QString &name, const QString &help, QVBoxLayout *column, std::function choose);
+
+// Swift's CameraRawCurveControls: parametric regions or point curves.
+class CameraRawCurveControls : public QWidget {
+ Q_OBJECT
+public:
+ explicit CameraRawCurveControls(EditorSession &session, QWidget *parent = nullptr);
+ void synchronize();
+
+private:
+ CameraRawSettings raw() const;
+ std::vector currentPoints() const;
+ void update(const std::function &change);
+ void store(const std::vector &points);
+ void panel(const std::function &change);
+ friend class CameraRawCurveGraph;
+
+ EditorSession &m_session;
+ QButtonGroup *m_page;
+ QWidget *m_channelBox;
+ QButtonGroup *m_channel;
+ QWidget *const m_graph;
+ QWidget *const m_amounts;
+ QWidget *const m_points;
+ QLabel *const m_selected;
+ QComboBox *const m_preset;
+ CameraRawRow *m_refine = nullptr;
+ std::vector m_rows;
+ QPushButton *const m_target;
+};
+
+// Swift's CameraRawMixerControls: HSL, one family, or picked colours.
+class CameraRawMixerControls : public QWidget {
+ Q_OBJECT
+public:
+ explicit CameraRawMixerControls(EditorSession &session, QWidget *parent = nullptr);
+ void synchronize();
+
+private:
+ CameraRawSettings raw() const;
+ void update(const std::function &change);
+ void updatePoint(const std::function &change);
+ void panel(const std::function &change);
+ // Rows following a tab, a swatch or a point.
+ void rebuildFamilies(int tab);
+ void rebuildColor(int swatch);
+ void rebuildPoint(int index, double hue);
+ void row(QWidget *box, CameraRawRow::Spec spec, std::function value,
+ std::function write, double reset);
+
+ EditorSession &m_session;
+ QButtonGroup *m_page;
+ QButtonGroup *m_tab;
+ QWidget *const m_hsl;
+ QWidget *const m_families;
+ QWidget *const m_color;
+ QWidget *const m_colorRows;
+ std::vector m_swatches;
+ QWidget *const m_point;
+ QToolButton *const m_sampler;
+ QWidget *const m_picked;
+ QWidget *const m_pointControls;
+ QWidget *const m_pointRows;
+ QAbstractButton *const m_visualize;
+ QPushButton *const m_target;
+ // What the rebuilt rows were made for.
+ std::optional m_shownTab;
+ std::optional m_shownSwatch;
+ std::optional> m_shownPoint;
+ size_t m_shownPicked = 0;
+};
+
+// Swift's CameraRawGradingControls: the wheels, Blending, Balance.
+class CameraRawGradingControls : public QWidget {
+ Q_OBJECT
+public:
+ explicit CameraRawGradingControls(EditorSession &session, QWidget *parent = nullptr);
+ void synchronize();
+
+private:
+ CameraRawSettings raw() const;
+ void update(const std::function &change);
+ QWidget *wheel(const QString &title, int key);
+
+ EditorSession &m_session;
+ QComboBox *const m_page;
+ QWidget *const m_threeWay;
+ QWidget *const m_single;
+ std::vector> m_wheels;
+ std::vector m_rows;
+};
diff --git a/src/UI/CameraRawControls.cpp b/src/UI/CameraRawControls.cpp
index 650022b..61bbf59 100644
--- a/src/UI/CameraRawControls.cpp
+++ b/src/UI/CameraRawControls.cpp
@@ -1,5 +1,8 @@
#include "UI/CameraRawControls.h"
-#include "UI/ColorPickerSheet.h"
+#include "UI/CameraRawColorControls.h"
+#include "UI/CameraRawDetailOpticsControls.h"
+#include "UI/CameraRawGeometryCalibrationControls.h"
+#include "UI/CameraRawRow.h"
#include "UI/LayerIcons.h"
#include "Rendering/EyedropperIcon.h"
#include
@@ -51,20 +54,6 @@ bool adjusts(const CameraRawSettings &raw, int section)
throw std::logic_error("unknown Camera Raw section");
}
-// 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;
-}
-
// Swift's graph: the three ribbons, or the vectorscope's cells.
class ScopeGraph : public QWidget {
public:
@@ -206,10 +195,13 @@ CameraRawControls::CameraRawControls(EditorSession &session, QWidget *parent)
light(group(Section::light, groups));
color(group(Section::color, groups));
effects(group(Section::effects, groups));
- // Curve to Calibration fill their groups with 12.8b2.
- for (const Section section : {Section::curve, Section::colorMixer, Section::colorGrading, Section::detail, Section::optics, Section::geometry,
- Section::calibration})
- group(section, groups);
+ group(Section::curve, groups)->addWidget(new CameraRawCurveControls(session));
+ group(Section::colorMixer, groups)->addWidget(new CameraRawMixerControls(session));
+ group(Section::colorGrading, groups)->addWidget(new CameraRawGradingControls(session));
+ group(Section::detail, groups)->addWidget(new CameraRawDetailControls(session));
+ group(Section::optics, groups)->addWidget(new CameraRawOpticsControls(session));
+ group(Section::geometry, groups)->addWidget(new CameraRawGeometryControls(session));
+ group(Section::calibration, groups)->addWidget(new CameraRawCalibrationControls(session));
groups->addStretch(1);
auto *scroll = new QScrollArea(this);
scroll->setObjectName(QStringLiteral("cameraRawGroups"));
@@ -274,43 +266,17 @@ void CameraRawControls::slider(QVBoxLayout *column, const QString &name, const Q
double low, double high, int decimals, std::optional clipping, const QString &help,
CameraRawSliderTrack track, double reset)
{
- auto *box = new QWidget;
- auto *label = new QLabel(title, box);
- label->setMinimumWidth(labelWidth);
- label->setToolTip(help);
const double step = std::pow(10.0, decimals);
- auto *slider = new CameraRawSlider(
- low, high, track, help, [this, key, step, clipping](double value) { assign(key, std::round(value * step) / step, clipping); },
- [this, key, reset] { assign(key, reset, std::nullopt); }, box);
- slider->setObjectName(name + QStringLiteral("Slider"));
- const size_t index = m_rows.size();
- auto *field = new PickerField([this, index] {
- PickerField &edited = *m_rows[index].field;
- bool number = false;
- const double typed = edited.locale().toDouble(edited.text(), &number);
- if (edited.isModified() && number && std::isfinite(typed))
- assign(m_rows[index].key, typed, std::nullopt);
- edited.setModified(false);
- synchronize();
- }, nullptr, box);
- field->setObjectName(name + QStringLiteral("Field"));
- field->setAccessibleName(title);
- field->setPlaceholderText(title);
- field->setToolTip(help);
- field->setAlignment(Qt::AlignRight);
- field->setFixedWidth(56);
- // Swift's double click on the title resets the slider.
- label->setProperty("resetRow", qulonglong(index));
- label->setProperty("resetValue", reset);
- auto *row = new QHBoxLayout(box);
- row->setContentsMargins(0, 0, 0, 0);
- row->setSpacing(10);
- row->addWidget(label);
- row->addWidget(slider, 1);
- row->addWidget(field);
- box->setAccessibleName(title);
- column->addWidget(box);
- m_rows.push_back(Row{std::move(key), decimals, slider, field});
+ auto *row = new CameraRawRow(
+ {.name = name, .title = title, .help = help, .low = low, .high = high, .decimals = decimals, .track = track, .titleResets = true},
+ [this, key] {
+ CameraRawSettings settings = raw();
+ return key(settings);
+ },
+ [this, key, step, clipping](double value) { assign(key, std::round(value * step) / step, clipping); },
+ [this, key](double typed) { assign(key, typed, std::nullopt); }, [this, key, reset] { assign(key, reset, std::nullopt); });
+ column->addWidget(row);
+ m_rows.push_back(row);
}
QLabel *CameraRawControls::subheadline(const QString &title, QVBoxLayout *column)
@@ -372,11 +338,6 @@ CameraRawSettings CameraRawControls::raw() const
bool CameraRawControls::eventFilter(QObject *watched, QEvent *event)
{
- if (event->type() == QEvent::MouseButtonDblClick && watched->property("resetRow").isValid()) {
- const Row &row = m_rows.at(size_t(watched->property("resetRow").toULongLong()));
- assign(row.key, watched->property("resetValue").toDouble(), std::nullopt);
- return true;
- }
// Alt let go ends the clipping and sharpen-mask views.
if (event->type() == QEvent::KeyRelease && static_cast(event)->key() == Qt::Key_Alt && !static_cast(event)->isAutoRepeat()
&& m_session.filterEdit()) {
@@ -409,13 +370,8 @@ void CameraRawControls::synchronize()
group.eye->setToolTip((shown ? QStringLiteral("Hide %1 in the preview") : QStringLiteral("Show %1 in the preview")).arg(name));
group.eye->setAccessibleName((shown ? QStringLiteral("Hide %1") : QStringLiteral("Show %1")).arg(name));
}
- for (Row &row : m_rows) {
- CameraRawSettings copy = settings;
- const double value = row.key(copy);
- row.slider->display(value);
- if (!(row.field->hasFocus() && row.field->isModified()))
- row.field->setText(shown(value, row.decimals, row.field->locale()));
- }
+ for (CameraRawRow *row : m_rows)
+ row->synchronize();
const std::optional> readout = edit ? edit->rawPanel.readout : std::nullopt;
m_readout->setText(readout ? QStringLiteral("R %1 G %2 B %3").arg((*readout)[0]).arg((*readout)[1]).arg((*readout)[2])
: QStringLiteral("R — G — B —"));
diff --git a/src/UI/CameraRawControls.h b/src/UI/CameraRawControls.h
index 64c7f35..7c6133d 100644
--- a/src/UI/CameraRawControls.h
+++ b/src/UI/CameraRawControls.h
@@ -3,7 +3,7 @@
#include "UI/CameraRawSlider.h"
#include
-class PickerField;
+class CameraRawRow;
class QComboBox;
class QLabel;
class QToolButton;
@@ -30,12 +30,6 @@ private:
QToolButton *eye;
QWidget *body;
};
- struct Row {
- std::function key;
- int decimals;
- CameraRawSlider *slider;
- PickerField *field;
- };
QVBoxLayout *group(Section section, QVBoxLayout *column);
// Swift's slider: a title, the slider and an exact field.
void slider(QVBoxLayout *column, const QString &name, const QString &title, std::function key, double low, double high, int decimals,
@@ -54,7 +48,7 @@ private:
QWidget *const m_scope;
QLabel *const m_readout;
std::vector m_groups;
- std::vector m_rows;
+ std::vector m_rows;
QComboBox *m_whiteBalance = nullptr;
QToolButton *m_whiteBalanceSampler = nullptr;
QLabel *m_whiteBalanceHint = nullptr;
diff --git a/src/UI/CameraRawDetailOpticsControls.cpp b/src/UI/CameraRawDetailOpticsControls.cpp
new file mode 100644
index 0000000..39e6416
--- /dev/null
+++ b/src/UI/CameraRawDetailOpticsControls.cpp
@@ -0,0 +1,254 @@
+#include "UI/CameraRawDetailOpticsControls.h"
+#include "Rendering/EyedropperIcon.h"
+#include "UI/CameraRawControls.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+QLabel *text(const QString &words, int pixels, bool secondary)
+{
+ auto *label = new QLabel(words);
+ QFont font = label->font();
+ font.setPixelSize(pixels);
+ label->setFont(font);
+ label->setWordWrap(true);
+ if (secondary)
+ label->setForegroundRole(QPalette::PlaceholderText);
+ return label;
+}
+
+// Swift's `.opacity(0.45).disabled(…)` over a group of rows.
+QWidget *group(QVBoxLayout *column)
+{
+ auto *box = new QWidget;
+ auto *rows = new QVBoxLayout(box);
+ rows->setContentsMargins(0, 0, 0, 0);
+ rows->setSpacing(8);
+ box->setGraphicsEffect(new QGraphicsOpacityEffect(box));
+ column->addWidget(box);
+ return box;
+}
+
+void dim(QWidget *box, bool active)
+{
+ box->setEnabled(active);
+ static_cast(box->graphicsEffect())->setOpacity(active ? 1 : 0.45);
+}
+
+void update(EditorSession &session, const std::function &change)
+{
+ FilterSettings settings = session.filterEdit() ? session.filterEdit()->settings : FilterSettings();
+ change(settings.cameraRaw);
+ session.updateFilter(settings, session.filterEdit() ? session.filterEdit()->preview : true);
+}
+
+CameraRawSettings raw(const EditorSession &session)
+{
+ return session.filterEdit() ? session.filterEdit()->settings.cameraRaw : CameraRawSettings();
+}
+}
+
+CameraRawDetailControls::CameraRawDetailControls(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session)
+{
+ auto *column = new QVBoxLayout(this);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(8);
+ column->addWidget(text(QStringLiteral("Sharpening"), 11, false));
+ row(column, QStringLiteral("sharpenAmount"), QStringLiteral("Amount"), &CameraRawDetailSettings::sharpenAmount, 150, 0, false,
+ QStringLiteral("Controls how strong the sharpening is."));
+ row(column, QStringLiteral("sharpenRadius"), QStringLiteral("Radius"), &CameraRawDetailSettings::sharpenRadius, 100, 10, false,
+ QStringLiteral("How far from each edge the sharpening reaches, in pixels."));
+ row(column, QStringLiteral("sharpenDetail"), QStringLiteral("Detail"), &CameraRawDetailSettings::sharpenDetail, 100, 25, false,
+ QStringLiteral("Emphasizes fine texture over broader edges."));
+ row(column, QStringLiteral("sharpenMasking"), QStringLiteral("Masking"), &CameraRawDetailSettings::sharpenMasking, 100, 0, true,
+ QStringLiteral("Limits sharpening to stronger edges. Hold Alt to see the mask."));
+ column->addWidget(text(QStringLiteral("Noise Reduction"), 11, false));
+ row(column, QStringLiteral("noiseLuminance"), QStringLiteral("Luminance"), &CameraRawDetailSettings::noiseLuminance, 100, 0, false,
+ QStringLiteral("Smooths grain and noise in brightness."));
+ m_luminanceDetail = group(column);
+ auto *luminance = static_cast(m_luminanceDetail->layout());
+ row(luminance, QStringLiteral("noiseLuminanceDetail"), QStringLiteral("Luminance Detail"), &CameraRawDetailSettings::noiseLuminanceDetail, 100, 50,
+ false, QStringLiteral("Preserves fine texture while luminance noise is reduced."));
+ row(luminance, QStringLiteral("noiseLuminanceContrast"), QStringLiteral("Luminance Contrast"), &CameraRawDetailSettings::noiseLuminanceContrast, 100, 0,
+ false, QStringLiteral("Keeps local contrast after luminance smoothing."));
+ row(column, QStringLiteral("noiseColor"), QStringLiteral("Color"), &CameraRawDetailSettings::noiseColor, 100, 0, false,
+ QStringLiteral("Smooths colored speckles."));
+ m_colorDetail = group(column);
+ auto *color = static_cast(m_colorDetail->layout());
+ row(color, QStringLiteral("noiseColorDetail"), QStringLiteral("Color Detail"), &CameraRawDetailSettings::noiseColorDetail, 100, 50, false,
+ QStringLiteral("Preserves colored edges while color noise is reduced."));
+ row(color, QStringLiteral("noiseColorSmoothness"), QStringLiteral("Color Smoothness"), &CameraRawDetailSettings::noiseColorSmoothness, 100, 50, false,
+ QStringLiteral("Makes the color smoothing softer or tighter."));
+ connect(&m_session, &EditorSession::changed, this, &CameraRawDetailControls::synchronize);
+ synchronize();
+}
+
+void CameraRawDetailControls::row(QVBoxLayout *column, const QString &name, const QString &title, double CameraRawDetailSettings::*key,
+ double high, double reset, bool maskingPreview, const QString &help)
+{
+ auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .low = 0, .high = high, .titleWidth = CameraRawControls::labelWidth},
+ [this, key] { return raw(m_session).detail.*key; },
+ [this, key, maskingPreview](double value) { assign(key, std::round(value), maskingPreview); },
+ [this, key](double value) { assign(key, value, false); }, [this, key, reset] { assign(key, reset, false); });
+ column->addWidget(row);
+ m_rows.push_back(row);
+}
+
+// Swift's assignDetail: the sharpen mask shows while Alt is held.
+void CameraRawDetailControls::assign(double CameraRawDetailSettings::*key, double value, bool maskingPreview)
+{
+ if (!m_session.filterEdit())
+ return;
+ CameraRawPanel panel = m_session.filterEdit()->rawPanel;
+ panel.sharpenMask = maskingPreview && QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier);
+ m_session.setCameraRawPanel(panel);
+ ::update(m_session, [key, value](CameraRawSettings &settings) { settings.detail.*key = value; });
+}
+
+void CameraRawDetailControls::synchronize()
+{
+ const CameraRawDetailSettings detail = raw(m_session).detail;
+ dim(m_luminanceDetail, detail.noiseLuminance > 0);
+ dim(m_colorDetail, detail.noiseColor > 0);
+ for (CameraRawRow *row : m_rows)
+ row->synchronize();
+}
+
+CameraRawOpticsControls::CameraRawOpticsControls(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session)
+{
+ auto *column = new QVBoxLayout(this);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(8);
+ const auto toggle = [this, column](const QString &name, const QString &title, const QString &help, bool CameraRawOpticsSettings::*key) {
+ auto *box = new QCheckBox(title, this);
+ box->setObjectName(name);
+ box->setToolTip(help);
+ connect(box, &QCheckBox::clicked, this, [this, key](bool on) { update([key, on](CameraRawOpticsSettings &optics) { optics.*key = on; }); });
+ column->addWidget(box);
+ return box;
+ };
+ m_chromatic = toggle(QStringLiteral("removeChromaticAberration"), QStringLiteral("Remove Chromatic Aberration"),
+ QStringLiteral("Pulls red and blue fringes apart toward the center to reduce color edging."),
+ &CameraRawOpticsSettings::removeChromaticAberration);
+ m_profile = toggle(QStringLiteral("enableLensProfile"), QStringLiteral("Enable Lens Profile Corrections"),
+ QStringLiteral("Applies generic profile strength when camera metadata is not available."), &CameraRawOpticsSettings::enableLensProfile);
+ m_profileRows = new QWidget(this);
+ auto *profile = new QVBoxLayout(m_profileRows);
+ profile->setContentsMargins(0, 0, 0, 0);
+ profile->setSpacing(8);
+ profile->addWidget(text(QStringLiteral("No lens metadata on this layer. Profile sliders set generic correction strength."), 10, true));
+ row(profile, QStringLiteral("profileDistortion"), QStringLiteral("Distortion"), &CameraRawOpticsSettings::profileDistortion, 0, 100, 100,
+ QStringLiteral("How much of the profile distortion correction is applied."));
+ row(profile, QStringLiteral("profileVignetting"), QStringLiteral("Vignetting"), &CameraRawOpticsSettings::profileVignetting, 0, 100, 100,
+ QStringLiteral("How much of the profile vignetting correction is applied."));
+ column->addWidget(m_profileRows);
+ column->addWidget(text(QStringLiteral("Manual"), 11, false));
+ row(column, QStringLiteral("distortion"), QStringLiteral("Distortion"), &CameraRawOpticsSettings::distortion, -100, 100, 0,
+ QStringLiteral("Straightens barrel or pincushion bending."));
+ auto *defringe = new QHBoxLayout;
+ defringe->setSpacing(10);
+ auto *title = new QLabel(QStringLiteral("Defringe"), this);
+ title->setMinimumWidth(CameraRawControls::labelWidth);
+ title->setToolTip(QStringLiteral("Click a purple or green fringe to set its hue range."));
+ m_defringe = new QToolButton(this);
+ m_defringe->setObjectName(QStringLiteral("defringeSampler"));
+ m_defringe->setAutoRaise(true);
+ m_defringe->setToolTip(QStringLiteral("Click a purple or green fringe to set its hue range."));
+ connect(m_defringe, &QToolButton::clicked, this, [this] {
+ if (!m_session.filterEdit())
+ return;
+ CameraRawPanel panel = m_session.filterEdit()->rawPanel;
+ panel.samplesDefringe = !panel.samplesDefringe;
+ m_session.setCameraRawPanel(panel);
+ });
+ defringe->addWidget(title);
+ defringe->addWidget(m_defringe);
+ defringe->addStretch(1);
+ column->addLayout(defringe);
+ m_defringeHint = text(QStringLiteral("Click the fringe on the layer. Click the eyedropper again to stop."), 10, true);
+ m_defringeHint->setObjectName(QStringLiteral("defringeHint"));
+ column->addWidget(m_defringeHint);
+ row(column, QStringLiteral("purpleAmount"), QStringLiteral("Purple Amount"), &CameraRawOpticsSettings::purpleAmount, 0, 100, 0,
+ QStringLiteral("Weakens purple fringes inside the purple hue range."));
+ column->addWidget(hueRange(QStringLiteral("purpleHue"), QStringLiteral("Purple Hue"), &CameraRawOpticsSettings::purpleHueLow,
+ &CameraRawOpticsSettings::purpleHueHigh, 270, 310, QStringLiteral("Hue range where purple defringe runs.")));
+ row(column, QStringLiteral("greenAmount"), QStringLiteral("Green Amount"), &CameraRawOpticsSettings::greenAmount, 0, 100, 0,
+ QStringLiteral("Weakens green fringes inside the green hue range."));
+ column->addWidget(hueRange(QStringLiteral("greenHue"), QStringLiteral("Green Hue"), &CameraRawOpticsSettings::greenHueLow,
+ &CameraRawOpticsSettings::greenHueHigh, 60, 120, QStringLiteral("Hue range where green defringe runs.")));
+ row(column, QStringLiteral("lensVignetting"), QStringLiteral("Vignetting"), &CameraRawOpticsSettings::vignetteAmount, -100, 100, 0,
+ QStringLiteral("Brightens or darkens the corners to counter lens falloff."));
+ row(column, QStringLiteral("lensMidpoint"), QStringLiteral("Midpoint"), &CameraRawOpticsSettings::vignetteMidpoint, 0, 100, 50,
+ QStringLiteral("Moves the vignette correction inward or outward."));
+ connect(&m_session, &EditorSession::changed, this, &CameraRawOpticsControls::synchronize);
+ synchronize();
+}
+
+// Swift's opticsSlider: ranges from zero round the slider's value.
+void CameraRawOpticsControls::row(QVBoxLayout *column, const QString &name, const QString &title, double CameraRawOpticsSettings::*key, double low,
+ double high, double reset, const QString &help)
+{
+ const auto set = [this, key](double value) { update([key, value](CameraRawOpticsSettings &optics) { optics.*key = value; }); };
+ auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .low = low, .high = high, .titleWidth = CameraRawControls::labelWidth},
+ [this, key] { return raw(m_session).optics.*key; },
+ [set, low](double value) { set(low < 0 ? value : std::round(value)); }, set, [set, reset] { set(reset); });
+ column->addWidget(row);
+ m_rows.push_back(row);
+}
+
+// Swift's hueRange: Low and High, inset past the titles.
+QWidget *CameraRawOpticsControls::hueRange(const QString &name, const QString &title, double CameraRawOpticsSettings::*low,
+ double CameraRawOpticsSettings::*high, double lowReset, double highReset, const QString &help)
+{
+ auto *box = new QWidget;
+ auto *column = new QVBoxLayout(box);
+ column->setContentsMargins(CameraRawControls::labelWidth + 10, 0, 0, 0);
+ column->setSpacing(4);
+ QLabel *heading = text(title, 10, true);
+ heading->setToolTip(help);
+ column->addWidget(heading);
+ auto *row = new QHBoxLayout;
+ row->setSpacing(8);
+ for (const bool start : {true, false}) {
+ double CameraRawOpticsSettings::*key = start ? low : high;
+ const QString words = start ? QStringLiteral("Start of the hue range, in degrees.") : QStringLiteral("End of the hue range, in degrees.");
+ QLabel *end = text(start ? QStringLiteral("Low") : QStringLiteral("High"), 10, false);
+ end->setToolTip(words);
+ const double reset = start ? lowReset : highReset;
+ auto *slider = new CameraRawSlider(
+ 0, 360, {}, words, [this, key](double value) { update([key, value](CameraRawOpticsSettings &optics) { optics.*key = std::round(value); }); },
+ [this, key, reset] { update([key, reset](CameraRawOpticsSettings &optics) { optics.*key = reset; }); }, box);
+ slider->setObjectName(name + (start ? QStringLiteral("Low") : QStringLiteral("High")));
+ row->addWidget(end);
+ row->addWidget(slider, 1);
+ m_hueSliders.push_back({slider, key});
+ }
+ column->addLayout(row);
+ return box;
+}
+
+void CameraRawOpticsControls::update(const std::function &change)
+{
+ ::update(m_session, [&change](CameraRawSettings &settings) { change(settings.optics); });
+}
+
+void CameraRawOpticsControls::synchronize()
+{
+ const CameraRawOpticsSettings optics = raw(m_session).optics;
+ const QSignalBlocker chromatic(m_chromatic), profile(m_profile);
+ m_chromatic->setChecked(optics.removeChromaticAberration);
+ m_profile->setChecked(optics.enableLensProfile);
+ m_profileRows->setVisible(optics.enableLensProfile);
+ const bool sampling = m_session.filterEdit() && m_session.filterEdit()->rawPanel.samplesDefringe;
+ m_defringe->setIcon(EyedropperIcon::icon(palette().color(sampling ? QPalette::Highlight : QPalette::PlaceholderText), devicePixelRatioF()));
+ m_defringeHint->setVisible(sampling);
+ for (CameraRawRow *row : m_rows)
+ row->synchronize();
+ for (const auto &[slider, key] : m_hueSliders)
+ slider->display(optics.*key);
+}
diff --git a/src/UI/CameraRawDetailOpticsControls.h b/src/UI/CameraRawDetailOpticsControls.h
new file mode 100644
index 0000000..2b25285
--- /dev/null
+++ b/src/UI/CameraRawDetailOpticsControls.h
@@ -0,0 +1,52 @@
+#pragma once
+#include "Document/EditorSession.h"
+#include "UI/CameraRawRow.h"
+#include
+
+class QAbstractButton;
+class QLabel;
+class QToolButton;
+class QVBoxLayout;
+
+// Swift's CameraRawDetailControls: sharpening and noise reduction.
+class CameraRawDetailControls : public QWidget {
+ Q_OBJECT
+public:
+ explicit CameraRawDetailControls(EditorSession &session, QWidget *parent = nullptr);
+ void synchronize();
+
+private:
+ // Swift's sharpenSlider; Masking shows its mask while Alt is held.
+ void row(QVBoxLayout *column, const QString &name, const QString &title, double CameraRawDetailSettings::*key, double high, double reset,
+ bool maskingPreview, const QString &help);
+ void assign(double CameraRawDetailSettings::*key, double value, bool maskingPreview);
+
+ EditorSession &m_session;
+ std::vector m_rows;
+ QWidget *m_luminanceDetail = nullptr;
+ QWidget *m_colorDetail = nullptr;
+};
+
+// Swift's CameraRawOpticsControls: profile, distortion, defringe, vignetting.
+class CameraRawOpticsControls : public QWidget {
+ Q_OBJECT
+public:
+ explicit CameraRawOpticsControls(EditorSession &session, QWidget *parent = nullptr);
+ void synchronize();
+
+private:
+ void row(QVBoxLayout *column, const QString &name, const QString &title, double CameraRawOpticsSettings::*key, double low, double high, double reset,
+ const QString &help);
+ QWidget *hueRange(const QString &name, const QString &title, double CameraRawOpticsSettings::*low, double CameraRawOpticsSettings::*high,
+ double lowReset, double highReset, const QString &help);
+ void update(const std::function &change);
+
+ EditorSession &m_session;
+ std::vector m_rows;
+ std::vector> m_hueSliders;
+ QAbstractButton *m_chromatic = nullptr;
+ QAbstractButton *m_profile = nullptr;
+ QWidget *m_profileRows = nullptr;
+ QToolButton *m_defringe = nullptr;
+ QLabel *m_defringeHint = nullptr;
+};
diff --git a/src/UI/CameraRawGeometryCalibrationControls.cpp b/src/UI/CameraRawGeometryCalibrationControls.cpp
new file mode 100644
index 0000000..fe2b50c
--- /dev/null
+++ b/src/UI/CameraRawGeometryCalibrationControls.cpp
@@ -0,0 +1,215 @@
+#include "UI/CameraRawGeometryCalibrationControls.h"
+#include "UI/CameraRawColorControls.h"
+#include "UI/CameraRawControls.h"
+#include "UI/LayerIcons.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+QLabel *text(const QString &words, int pixels, bool secondary)
+{
+ auto *label = new QLabel(words);
+ QFont font = label->font();
+ font.setPixelSize(pixels);
+ label->setFont(font);
+ label->setWordWrap(true);
+ if (secondary)
+ label->setForegroundRole(QPalette::PlaceholderText);
+ return label;
+}
+
+// Swift's labelled Picker: its title, then the menu.
+QComboBox *picker(const QString &title, const QStringList &choices, const QString &help, QWidget *parent, QVBoxLayout *column)
+{
+ auto *menu = new QComboBox(parent);
+ menu->addItems(choices);
+ menu->setToolTip(help);
+ auto *label = new QLabel(title, parent);
+ label->setBuddy(menu);
+ auto *row = new QHBoxLayout;
+ row->addWidget(label);
+ row->addWidget(menu);
+ row->addStretch(1);
+ column->addLayout(row);
+ return menu;
+}
+
+CameraRawSettings raw(const EditorSession &session)
+{
+ return session.filterEdit() ? session.filterEdit()->settings.cameraRaw : CameraRawSettings();
+}
+
+void write(EditorSession &session, const std::function &change)
+{
+ FilterSettings settings = session.filterEdit() ? session.filterEdit()->settings : FilterSettings();
+ change(settings.cameraRaw);
+ session.updateFilter(settings, session.filterEdit() ? session.filterEdit()->preview : true);
+}
+
+// Swift's Geometry and Calibration rows: the slider rounds.
+CameraRawRow *rounded(const QString &name, const QString &title, const QString &help, double low, double high, std::function value,
+ std::function set, QWidget *parent)
+{
+ return new CameraRawRow({.name = name, .title = title, .help = help, .low = low, .high = high, .titleWidth = CameraRawControls::labelWidth},
+ std::move(value), [set](double number) { set(std::round(number)); }, set, [set] { set(0); }, parent);
+}
+}
+
+CameraRawGeometryControls::CameraRawGeometryControls(EditorSession &session, QWidget *parent)
+ : QWidget(parent), m_session(session), m_guided(new QWidget(this)), m_draw(new QPushButton(QStringLiteral("Draw Guides"), m_guided)),
+ m_drawHint(text(QStringLiteral("Drag on the layer to place a guide. Draw at least two lines."), 10, true)),
+ m_clear(new QPushButton(QStringLiteral("Clear Guides"), m_guided)),
+ m_constrain(new QCheckBox(QStringLiteral("Constrain Crop"), this))
+{
+ auto *column = new QVBoxLayout(this);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(8);
+ column->addWidget(text(QStringLiteral("Upright"), 11, false));
+ m_upright = cameraRawSegments({QStringLiteral("Off"), QStringLiteral("Guided")}, QStringLiteral("upright"),
+ QStringLiteral("Off leaves the picture as it is. Guided straightens from lines you draw on the picture."), column, [this](int mode) {
+ update([mode](CameraRawGeometrySettings &geometry) { geometry.upright = CameraRawUprightMode(mode); });
+ if (CameraRawUprightMode(mode) != CameraRawUprightMode::guided && m_session.filterEdit()) {
+ CameraRawPanel panel = m_session.filterEdit()->rawPanel;
+ panel.drawingGeometryGuide = false;
+ m_session.setCameraRawPanel(panel);
+ }
+ });
+ auto *guided = new QVBoxLayout(m_guided);
+ guided->setContentsMargins(0, 0, 0, 0);
+ guided->setSpacing(8);
+ m_draw->setObjectName(QStringLiteral("drawGuides"));
+ m_draw->setCheckable(true);
+ m_draw->setAutoDefault(false);
+ m_draw->setToolTip(QStringLiteral("Draw two or more lines on the preview that should be level or vertical."));
+ connect(m_draw, &QPushButton::clicked, this, [this] {
+ if (!m_session.filterEdit())
+ return;
+ CameraRawPanel panel = m_session.filterEdit()->rawPanel;
+ panel.drawingGeometryGuide = !panel.drawingGeometryGuide;
+ m_session.setCameraRawPanel(panel);
+ });
+ guided->addWidget(m_draw, 0, Qt::AlignLeft);
+ m_drawHint->setObjectName(QStringLiteral("drawGuidesHint"));
+ guided->addWidget(m_drawHint);
+ m_clear->setObjectName(QStringLiteral("clearGuides"));
+ m_clear->setAutoDefault(false);
+ m_clear->setToolTip(QStringLiteral("Remove every guide line."));
+ connect(m_clear, &QPushButton::clicked, this, [this] { update([](CameraRawGeometrySettings &geometry) { geometry.guides.clear(); }); });
+ guided->addWidget(m_clear, 0, Qt::AlignLeft);
+ column->addWidget(m_guided);
+ m_projection = picker(QStringLiteral("Projection"), {QStringLiteral("Perspective"), QStringLiteral("Rectilinear")},
+ QStringLiteral("Perspective allows stronger keystone. Rectilinear keeps the warp gentler."), this, column);
+ m_projection->setObjectName(QStringLiteral("projection"));
+ connect(m_projection, &QComboBox::activated, this,
+ [this](int index) { update([index](CameraRawGeometrySettings &geometry) { geometry.projection = CameraRawProjection(index); }); });
+ const auto slider = [this, column](const QString &name, const QString &title, double CameraRawGeometrySettings::*key, double low, double high,
+ const QString &help) {
+ auto *row = rounded(name, title, help, low, high, [this, key] { return raw(m_session).geometry.*key; },
+ [this, key](double value) { update([key, value](CameraRawGeometrySettings &geometry) { geometry.*key = value; }); }, this);
+ column->addWidget(row);
+ m_rows.push_back(row);
+ };
+ slider(QStringLiteral("vertical"), QStringLiteral("Vertical"), &CameraRawGeometrySettings::vertical, -100, 100,
+ QStringLiteral("Straightens vertical lines toward the center."));
+ slider(QStringLiteral("horizontal"), QStringLiteral("Horizontal"), &CameraRawGeometrySettings::horizontal, -100, 100,
+ QStringLiteral("Straightens horizontal lines toward the center."));
+ slider(QStringLiteral("rotate"), QStringLiteral("Rotate"), &CameraRawGeometrySettings::rotate, -45, 45, QStringLiteral("Rotates the picture around its center."));
+ slider(QStringLiteral("aspect"), QStringLiteral("Aspect"), &CameraRawGeometrySettings::aspect, -100, 100, QStringLiteral("Stretches width relative to height."));
+ slider(QStringLiteral("scale"), QStringLiteral("Scale"), &CameraRawGeometrySettings::scale, -100, 100,
+ QStringLiteral("Zooms the transformed picture within the frame."));
+ slider(QStringLiteral("offsetX"), QStringLiteral("Offset X"), &CameraRawGeometrySettings::offsetX, -100, 100, QStringLiteral("Moves the picture left or right."));
+ slider(QStringLiteral("offsetY"), QStringLiteral("Offset Y"), &CameraRawGeometrySettings::offsetY, -100, 100, QStringLiteral("Moves the picture up or down."));
+ m_constrain->setObjectName(QStringLiteral("constrainCrop"));
+ m_constrain->setToolTip(QStringLiteral("Crops empty edges after the transform and fits the result back into the frame."));
+ connect(m_constrain, &QAbstractButton::clicked, this, [this](bool on) { update([on](CameraRawGeometrySettings &geometry) { geometry.constrainCrop = on; }); });
+ column->addWidget(m_constrain);
+ connect(&m_session, &EditorSession::changed, this, &CameraRawGeometryControls::synchronize);
+ synchronize();
+}
+
+void CameraRawGeometryControls::update(const std::function &change)
+{
+ write(m_session, [&change](CameraRawSettings &settings) { change(settings.geometry); });
+}
+
+void CameraRawGeometryControls::synchronize()
+{
+ const CameraRawGeometrySettings geometry = raw(m_session).geometry;
+ const bool drawing = m_session.filterEdit() && m_session.filterEdit()->rawPanel.drawingGeometryGuide;
+ m_upright->button(int(geometry.upright))->setChecked(true);
+ m_guided->setVisible(geometry.upright == CameraRawUprightMode::guided);
+ m_draw->setChecked(drawing);
+ m_draw->setIcon(QIcon(LayerIcons::pixmap(LayerIcon::lineDiagonal, 14, palette().color(QPalette::ButtonText), devicePixelRatioF())));
+ m_drawHint->setVisible(drawing);
+ m_clear->setVisible(!geometry.guides.empty());
+ const QSignalBlocker projection(m_projection), constrain(m_constrain);
+ m_projection->setCurrentIndex(int(geometry.projection));
+ m_constrain->setChecked(geometry.constrainCrop);
+ for (CameraRawRow *row : m_rows)
+ row->synchronize();
+}
+
+CameraRawCalibrationControls::CameraRawCalibrationControls(EditorSession &session, QWidget *parent)
+ : QWidget(parent), m_session(session), m_process(new QComboBox(this)), m_summary(text(QString(), 10, true))
+{
+ auto *column = new QVBoxLayout(this);
+ column->setContentsMargins(0, 0, 0, 0);
+ column->setSpacing(8);
+ for (int version = 0; version < 6; ++version)
+ m_process->addItem(rawValue(CameraRawProcessVersion(version)));
+ m_process->setObjectName(QStringLiteral("processVersion"));
+ m_process->setToolTip(QStringLiteral("Chooses how strongly the calibration sliders below are applied. Version 6 is the current default."));
+ auto *label = new QLabel(QStringLiteral("Process"), this);
+ label->setBuddy(m_process);
+ auto *processRow = new QHBoxLayout;
+ processRow->addWidget(label);
+ processRow->addWidget(m_process);
+ processRow->addStretch(1);
+ column->addLayout(processRow);
+ connect(m_process, &QComboBox::activated, this, [this](int version) {
+ write(m_session, [version](CameraRawSettings &settings) { settings.calibration.process = CameraRawProcessVersion(version); });
+ });
+ m_summary->setObjectName(QStringLiteral("processSummary"));
+ column->addWidget(m_summary);
+ const auto slider = [this, column](const QString &name, const QString &title, double CameraRawCalibrationSettings::*key, const QString &help) {
+ auto *row = rounded(name, title, help, -100, 100, [this, key] { return raw(m_session).calibration.*key; },
+ [this, key](double value) { write(m_session, [key, value](CameraRawSettings &settings) { settings.calibration.*key = value; }); },
+ this);
+ column->addWidget(row);
+ m_rows.push_back(row);
+ };
+ column->addWidget(text(QStringLiteral("Shadows"), 11, false));
+ slider(QStringLiteral("shadowTint"), QStringLiteral("Tint"), &CameraRawCalibrationSettings::shadowTint, QStringLiteral("Adds green or magenta to the darkest tones."));
+ column->addWidget(text(QStringLiteral("Red Primary"), 11, false));
+ slider(QStringLiteral("redHue"), QStringLiteral("Hue"), &CameraRawCalibrationSettings::redHue, QStringLiteral("Shifts how red is interpreted."));
+ slider(QStringLiteral("redSaturation"), QStringLiteral("Saturation"), &CameraRawCalibrationSettings::redSaturation,
+ QStringLiteral("Strengthens or weakens the red primary."));
+ column->addWidget(text(QStringLiteral("Green Primary"), 11, false));
+ slider(QStringLiteral("greenHue"), QStringLiteral("Hue"), &CameraRawCalibrationSettings::greenHue, QStringLiteral("Shifts how green is interpreted."));
+ slider(QStringLiteral("greenSaturation"), QStringLiteral("Saturation"), &CameraRawCalibrationSettings::greenSaturation,
+ QStringLiteral("Strengthens or weakens the green primary."));
+ column->addWidget(text(QStringLiteral("Blue Primary"), 11, false));
+ slider(QStringLiteral("blueHue"), QStringLiteral("Hue"), &CameraRawCalibrationSettings::blueHue, QStringLiteral("Shifts how blue is interpreted."));
+ slider(QStringLiteral("blueSaturation"), QStringLiteral("Saturation"), &CameraRawCalibrationSettings::blueSaturation,
+ QStringLiteral("Strengthens or weakens the blue primary."));
+ connect(&m_session, &EditorSession::changed, this, &CameraRawCalibrationControls::synchronize);
+ synchronize();
+}
+
+void CameraRawCalibrationControls::synchronize()
+{
+ const CameraRawCalibrationSettings calibration = raw(m_session).calibration;
+ {
+ const QSignalBlocker quiet(m_process);
+ m_process->setCurrentIndex(int(calibration.process));
+ }
+ m_summary->setText(summary(calibration.process));
+ m_summary->setToolTip(summary(calibration.process));
+ for (CameraRawRow *row : m_rows)
+ row->synchronize();
+}
diff --git a/src/UI/CameraRawGeometryCalibrationControls.h b/src/UI/CameraRawGeometryCalibrationControls.h
new file mode 100644
index 0000000..ac03e47
--- /dev/null
+++ b/src/UI/CameraRawGeometryCalibrationControls.h
@@ -0,0 +1,45 @@
+#pragma once
+#include "Document/EditorSession.h"
+#include "UI/CameraRawRow.h"
+#include
+
+class QAbstractButton;
+class QButtonGroup;
+class QComboBox;
+class QLabel;
+class QPushButton;
+
+// Swift's CameraRawGeometryControls: Upright, guides, the transform.
+class CameraRawGeometryControls : public QWidget {
+ Q_OBJECT
+public:
+ explicit CameraRawGeometryControls(EditorSession &session, QWidget *parent = nullptr);
+ void synchronize();
+
+private:
+ void update(const std::function &change);
+
+ EditorSession &m_session;
+ QButtonGroup *m_upright = nullptr;
+ QWidget *const m_guided;
+ QPushButton *const m_draw;
+ QLabel *const m_drawHint;
+ QPushButton *const m_clear;
+ QComboBox *m_projection = nullptr;
+ QAbstractButton *const m_constrain;
+ std::vector m_rows;
+};
+
+// Swift's CameraRawCalibrationControls: the process and the primaries.
+class CameraRawCalibrationControls : public QWidget {
+ Q_OBJECT
+public:
+ explicit CameraRawCalibrationControls(EditorSession &session, QWidget *parent = nullptr);
+ void synchronize();
+
+private:
+ EditorSession &m_session;
+ QComboBox *const m_process;
+ QLabel *const m_summary;
+ std::vector m_rows;
+};
diff --git a/src/UI/CameraRawRow.cpp b/src/UI/CameraRawRow.cpp
new file mode 100644
index 0000000..3238a48
--- /dev/null
+++ b/src/UI/CameraRawRow.cpp
@@ -0,0 +1,79 @@
+#include "UI/CameraRawRow.h"
+#include "UI/ColorPickerSheet.h"
+#include
+#include
+#include
+#include
+#include
+
+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;
+}
+}
+
+CameraRawRow::CameraRawRow(Spec spec, std::function value, std::function slide, std::function type,
+ std::function reset, QWidget *parent)
+ : QWidget(parent), m_spec(std::move(spec)), m_value(std::move(value)), m_reset(std::move(reset)), m_title(new QLabel(m_spec.title, this)),
+ m_slider(new CameraRawSlider(m_spec.low, m_spec.high, m_spec.track, m_spec.help, std::move(slide), m_reset, this))
+{
+ if (m_spec.fixedTitle)
+ m_title->setFixedWidth(m_spec.titleWidth);
+ else
+ m_title->setMinimumWidth(m_spec.titleWidth);
+ m_title->setToolTip(m_spec.help);
+ m_slider->setObjectName(m_spec.name + QStringLiteral("Slider"));
+ auto *row = new QHBoxLayout(this);
+ row->setContentsMargins(0, 0, 0, 0);
+ row->setSpacing(10);
+ row->addWidget(m_title);
+ row->addWidget(m_slider, 1);
+ if (m_spec.fieldWidth > 0) {
+ m_field = new PickerField([this, type = std::move(type)] {
+ bool number = false;
+ const double typed = m_field->locale().toDouble(m_field->text(), &number);
+ if (m_field->isModified() && number && std::isfinite(typed))
+ type(typed);
+ m_field->setModified(false);
+ synchronize();
+ }, nullptr, this);
+ m_field->setObjectName(m_spec.name + QStringLiteral("Field"));
+ m_field->setAccessibleName(m_spec.title);
+ m_field->setPlaceholderText(m_spec.title);
+ m_field->setToolTip(m_spec.help);
+ m_field->setAlignment(Qt::AlignRight);
+ m_field->setFixedWidth(m_spec.fieldWidth);
+ row->addWidget(m_field);
+ }
+ if (m_spec.titleResets)
+ m_title->installEventFilter(this);
+ setAccessibleName(m_spec.title);
+}
+
+void CameraRawRow::synchronize()
+{
+ const double value = m_value();
+ m_slider->display(value);
+ if (m_field && !(m_field->hasFocus() && m_field->isModified()))
+ m_field->setText(shown(value, m_spec.decimals, m_field->locale()));
+}
+
+// Swift's double click on the title resets the slider.
+bool CameraRawRow::eventFilter(QObject *watched, QEvent *event)
+{
+ if (watched == m_title && event->type() == QEvent::MouseButtonDblClick) {
+ m_reset();
+ return true;
+ }
+ return QWidget::eventFilter(watched, event);
+}
diff --git a/src/UI/CameraRawRow.h b/src/UI/CameraRawRow.h
new file mode 100644
index 0000000..d434726
--- /dev/null
+++ b/src/UI/CameraRawRow.h
@@ -0,0 +1,45 @@
+#pragma once
+#include "UI/CameraRawSlider.h"
+#include
+#include
+
+class PickerField;
+class QLabel;
+
+// Swift's Camera Raw rows: title, slider, exact field.
+class CameraRawRow : public QWidget {
+ Q_OBJECT
+public:
+ struct Spec {
+ QString name;
+ QString title;
+ QString help;
+ double low = -100;
+ double high = 100;
+ int decimals = 0;
+ CameraRawSliderTrack track = {};
+ // The title's width: at least this, or exactly when fixed.
+ int titleWidth = 96;
+ bool fixedTitle = false;
+ // No field at zero.
+ int fieldWidth = 56;
+ // A double click on the title resets it.
+ bool titleResets = false;
+ };
+ // `slide` takes the slider's own value, `type` a typed number.
+ CameraRawRow(Spec spec, std::function value, std::function slide, std::function type,
+ std::function reset, QWidget *parent = nullptr);
+ // Shows the value; a focused field keeps its typing.
+ void synchronize();
+
+protected:
+ bool eventFilter(QObject *watched, QEvent *event) override;
+
+private:
+ const Spec m_spec;
+ const std::function m_value;
+ const std::function m_reset;
+ QLabel *const m_title;
+ CameraRawSlider *const m_slider;
+ PickerField *m_field = nullptr;
+};
diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp
index 3d1baae..3b21dfd 100644
--- a/src/UI/CompositorMenus.cpp
+++ b/src/UI/CompositorMenus.cpp
@@ -215,8 +215,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
QMenu *filter = bar.addMenu(QStringLiteral("Fil&ter"));
for (const FilterKind kind : allFilterKinds) {
- // Camera Raw's entry arrives with its panel (12.8b).
- if (kind != FilterKind::contentAwareFill && kind != FilterKind::cameraRaw && !isImageAdjustment(kind))
+ if (kind != FilterKind::contentAwareFill && !isImageAdjustment(kind))
add(filter, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
}
@@ -358,7 +357,7 @@ void CompositorMenus::synchronize()
action(QString::fromLatin1(name))->setEnabled(s.canModifySelection());
action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
for (const FilterKind kind : allFilterKinds) {
- if (kind != FilterKind::contentAwareFill && kind != FilterKind::cameraRaw)
+ if (kind != FilterKind::contentAwareFill)
action(filterName(kind))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
}
action(QStringLiteral("hueSaturation"))->setEnabled(s.canAdjustColors());
diff --git a/src/UI/LayerIcons.cpp b/src/UI/LayerIcons.cpp
index f65fbf6..890b5ad 100644
--- a/src/UI/LayerIcons.cpp
+++ b/src/UI/LayerIcons.cpp
@@ -208,6 +208,16 @@ void sparkles(QPainter &painter)
star(QPointF(14, 4.5), 3);
star(QPointF(14.5, 14), 2);
}
+
+// Swift's scope: a ring with four ticks toward its middle.
+void scope(QPainter &painter)
+{
+ painter.drawEllipse(QPointF(9, 9), 6, 6);
+ painter.drawLine(QPointF(9, 1.5), QPointF(9, 6));
+ painter.drawLine(QPointF(9, 12), QPointF(9, 16.5));
+ painter.drawLine(QPointF(1.5, 9), QPointF(6, 9));
+ painter.drawLine(QPointF(12, 9), QPointF(16.5, 9));
+}
}
void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double side, const QColor &colour)
@@ -243,6 +253,8 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
case LayerIcon::rightHalfCircle: rightHalfCircle(painter); break;
case LayerIcon::hatchedCircle: hatchedCircle(painter); break;
case LayerIcon::axes: axes(painter); break;
+ case LayerIcon::scope: scope(painter); break;
+ case LayerIcon::lineDiagonal: painter.drawLine(QPointF(3, 15), QPointF(15, 3)); break;
}
painter.restore();
}
diff --git a/src/UI/LayerIcons.h b/src/UI/LayerIcons.h
index 77ca5f5..286a614 100644
--- a/src/UI/LayerIcons.h
+++ b/src/UI/LayerIcons.h
@@ -7,7 +7,8 @@
// The layers panel's icons, drawn: SF Symbols have no twin.
enum class LayerIcon {
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
- halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes
+ halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes,
+ scope, lineDiagonal
};
namespace LayerIcons {
diff --git a/tests/CameraRawCurveMixerTests.cpp b/tests/CameraRawCurveMixerTests.cpp
new file mode 100644
index 0000000..a631439
--- /dev/null
+++ b/tests/CameraRawCurveMixerTests.cpp
@@ -0,0 +1,260 @@
+#include "Document/BrushStroke.h"
+#include "Document/EditorSession.h"
+#include "UI/CameraRawColorControls.h"
+#include "UI/LayerIcons.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Swift's Curve and Color Mixer groups.
+namespace {
+// The knob's middle, where a double click resets.
+QPoint knob(const CameraRawSlider &slider)
+{
+ QStyleOptionSlider option;
+ option.initFrom(&slider);
+ option.orientation = Qt::Horizontal;
+ option.minimum = slider.minimum();
+ option.maximum = slider.maximum();
+ option.sliderPosition = slider.sliderPosition();
+ option.sliderValue = slider.value();
+ return slider.style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderHandle, &slider).center();
+}
+
+template struct Group {
+ EditorSession session;
+ std::unique_ptr controls;
+ Group()
+ {
+ session.createDocument(8, 8);
+ QImage image = BrushRaster::context(8, 8, false);
+ image.fill(QColor(200, 120, 60));
+ session.insert(ImportedImage(image, image, QStringLiteral("Warm")));
+ session.beginFilter(FilterKind::cameraRaw);
+ controls = std::make_unique(session);
+ controls->resize(374, 600);
+ controls->show();
+ }
+ template Widget &child(const QString &name) const
+ {
+ auto *found = controls->template findChild(name);
+ if (!found)
+ throw std::runtime_error(name.toStdString());
+ return *found;
+ }
+ const CameraRawSettings &raw() const { return session.filterEdit().value().settings.cameraRaw; }
+ const CameraRawPanel &panel() const { return session.filterEdit().value().rawPanel; }
+ void set(const std::function &change)
+ {
+ FilterSettings settings = session.filterEdit().value().settings;
+ change(settings.cameraRaw);
+ session.updateFilter(settings, true);
+ }
+};
+
+void press(QWidget &widget, QPoint at)
+{
+ QTest::mousePress(&widget, Qt::LeftButton, {}, at);
+}
+
+void drag(QWidget &widget, QPoint to)
+{
+ QMouseEvent move(QEvent::MouseMove, to, widget.mapToGlobal(to), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
+ QApplication::sendEvent(&widget, &move);
+}
+}
+
+class CameraRawCurveMixerTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theParametricPageMovesRegionsAndDividers();
+ void thePointPageAddsMovesAndRemovesPoints();
+ void presetsAndRefineSaturation();
+ void targetedAdjustmentArmsOneAtATime();
+ void hslRowsWriteTheirTabsFamilies();
+ void theColorPageEditsTheChosenFamily();
+ void pointColorsArePickedChosenAndShifted();
+};
+
+void CameraRawCurveMixerTests::theParametricPageMovesRegionsAndDividers()
+{
+ Group group;
+ QVERIFY(group.child(QStringLiteral("curvePage0")).isChecked() && !group.child(QStringLiteral("curveSelectedPoint")).isVisible());
+ for (const auto &[name, member] : {std::pair("curveHighlights", &CameraRawCurveSettings::highlights), std::pair("curveLights", &CameraRawCurveSettings::lights),
+ std::pair("curveDarks", &CameraRawCurveSettings::darks), std::pair("curveShadows", &CameraRawCurveSettings::shadows)}) {
+ auto &slider = group.child(QString::fromLatin1(name) + QStringLiteral("Slider"));
+ slider.setValue(800);
+ QCOMPARE(group.raw().curve.*member, 60.0);
+ // A double click on the knob resets it.
+ QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider));
+ QCOMPARE(group.raw().curve.*member, 0.0);
+ }
+ // A drag moves the nearest divider, within 2 and 98.
+ auto &graph = group.child(QStringLiteral("cameraRawCurveGraph"));
+ press(graph, QPoint(int(graph.width() * 0.3), 75));
+ drag(graph, QPoint(int(graph.width() * 0.35), 75));
+ QVERIFY(std::abs(group.raw().curve.shadowSplit - 35) < 1 && group.raw().curve.darkSplit == 50);
+ drag(graph, QPoint(graph.width() + 40, 75));
+ QVERIFY(group.raw().curve.lightSplit == 98 || group.raw().curve.shadowSplit == 98 || group.raw().curve.darkSplit == 98);
+ QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(graph.width() + 40, 75));
+ // A double click here leaves the point curve's points.
+ group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
+ QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(graph.width() / 2, graph.height() / 2));
+ QCOMPARE(group.raw().curve.rgb.size(), size_t(3));
+}
+
+void CameraRawCurveMixerTests::thePointPageAddsMovesAndRemovesPoints()
+{
+ Group group;
+ group.child(QStringLiteral("curvePage1")).click();
+ QVERIFY(group.panel().curvePage == CameraRawCurvePage::point);
+ group.child(QStringLiteral("pointChannel1")).click();
+ QVERIFY(group.panel().pointChannel == CameraRawPointChannel::red);
+ auto &graph = group.child(QStringLiteral("cameraRawCurveGraph"));
+ const int w = graph.width(), h = graph.height();
+ // A still click adds a point where it lands.
+ QTest::mouseClick(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 4));
+ QCOMPARE(group.raw().curve.red.size(), size_t(3));
+ QVERIFY(std::abs(group.raw().curve.red[1].x - 0.5) < 0.01 && std::abs(group.raw().curve.red[1].y - 0.75) < 0.01);
+ QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
+ QCOMPARE(group.child(QStringLiteral("curveSelectedPoint")).text(), QString("In %1 Out %2").arg(std::lround(group.raw().curve.red[1].x * 255))
+ .arg(std::lround(group.raw().curve.red[1].y * 255)));
+ // A drag moves the nearest inner point.
+ press(graph, QPoint(w / 2, h / 4));
+ drag(graph, QPoint(int(w * 0.6), h / 2));
+ QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(int(w * 0.6), h / 2));
+ QCOMPARE(group.raw().curve.red.size(), size_t(3));
+ QVERIFY(std::abs(group.raw().curve.red[1].x - 0.6) < 0.01 && std::abs(group.raw().curve.red[1].y - 0.5) < 0.01);
+ // A double click far off removes nothing; near, it does.
+ QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(int(w * 0.1), h / 2));
+ QCOMPARE(group.raw().curve.red.size(), size_t(3));
+ const size_t before = group.raw().curve.red.size();
+ QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(int(w * 0.6), h / 2));
+ QCOMPARE(group.raw().curve.red.size(), before - 1);
+}
+
+void CameraRawCurveMixerTests::presetsAndRefineSaturation()
+{
+ Group group;
+ group.child(QStringLiteral("curvePage1")).click();
+ auto &preset = group.child(QStringLiteral("curvePreset"));
+ QCOMPARE(preset.currentText(), QString("Linear"));
+ emit preset.activated(3);
+ QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::strongContrast());
+ QCOMPARE(preset.currentText(), QString("Strong Contrast"));
+ emit preset.activated(2);
+ QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::mediumContrast());
+ emit preset.activated(1);
+ QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
+ group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.4, 0.3}, {1, 1}}; });
+ QCOMPARE(preset.currentText(), QString("Custom"));
+ // Custom changes nothing; Refine Saturation shows for RGB alone.
+ emit preset.activated(0);
+ QCOMPARE(group.raw().curve.rgb.size(), size_t(3));
+ auto &refine = group.child(QStringLiteral("refineSaturationSlider"));
+ QVERIFY(refine.isVisible());
+ refine.setValue(750);
+ QCOMPARE(group.raw().curve.refineSaturation, 50.0);
+ group.child(QStringLiteral("pointChannel2")).click();
+ QVERIFY(!refine.isVisible());
+}
+
+void CameraRawCurveMixerTests::targetedAdjustmentArmsOneAtATime()
+{
+ Group curve;
+ CameraRawMixerControls mixer(curve.session);
+ auto &curveTarget = curve.child(QStringLiteral("curveTargeted"));
+ auto *mixerTarget = mixer.findChild(QStringLiteral("mixerTargeted"));
+ curveTarget.click();
+ QVERIFY(curve.panel().targetsCurve && curveTarget.isChecked());
+ mixerTarget->click();
+ QVERIFY(curve.panel().targetsMixer && !curve.panel().targetsCurve && !curveTarget.isChecked() && mixerTarget->isChecked());
+ mixerTarget->click();
+ QVERIFY(!curve.panel().targetsMixer);
+ mixerTarget->click();
+ curveTarget.click();
+ QVERIFY(curve.panel().targetsCurve && !curve.panel().targetsMixer);
+ // Both carry Swift's scope glyph.
+ const qreal ratio = curveTarget.devicePixelRatioF();
+ const QImage scope = LayerIcons::pixmap(LayerIcon::scope, 14, curveTarget.palette().color(QPalette::ButtonText), ratio).toImage();
+ QVERIFY(curveTarget.icon().pixmap(QSize(14, 14), ratio).toImage() == scope && mixerTarget->icon().pixmap(QSize(14, 14), ratio).toImage() == scope);
+}
+
+void CameraRawCurveMixerTests::hslRowsWriteTheirTabsFamilies()
+{
+ Group group;
+ QVERIFY(group.child(QStringLiteral("mixerPage0")).isChecked());
+ for (int tab = 0; tab < 3; ++tab) {
+ group.child(QStringLiteral("mixerTab%1").arg(tab)).click();
+ for (int index = 0; index < 8; ++index) {
+ const QString name = CameraRawMixerSettings::names.at(size_t(index));
+ auto &slider = group.child(QStringLiteral("family") + name + QStringLiteral("Slider"));
+ QCOMPARE(slider.toolTip(), QStringLiteral("%1 of %2.").arg(tab == 0 ? "Hue" : tab == 1 ? "Saturation" : "Luminance", name));
+ slider.setValue(600 + index * 10);
+ const std::array &family = tab == 0 ? group.raw().mixer.hue : tab == 1 ? group.raw().mixer.saturation : group.raw().mixer.luminance;
+ QVERIFY(std::abs(family.at(size_t(index)) - (20 + index * 2)) < 1e-9);
+ }
+ }
+ // Each tab's rows show its own track: saturation starts gray.
+ const QImage drawn = group.child(QStringLiteral("familyGreensSlider")).grab().toImage();
+ group.child(QStringLiteral("mixerTab1")).click();
+ const QImage gray = group.child(QStringLiteral("familyGreensSlider")).grab().toImage();
+ QVERIFY(drawn != gray);
+ const QColor start = gray.pixelColor(10, gray.height() / 2);
+ QVERIFY(std::abs(start.red() - start.green()) < 12);
+}
+
+void CameraRawCurveMixerTests::theColorPageEditsTheChosenFamily()
+{
+ Group group;
+ group.child(QStringLiteral("mixerPage1")).click();
+ QVERIFY(group.panel().mixerPage == CameraRawMixerPage::color);
+ group.child(QStringLiteral("mixerSwatch5")).click();
+ QCOMPARE(group.panel().mixerSwatch, 5);
+ group.child(QStringLiteral("colorLuminanceSlider")).setValue(250);
+ QCOMPARE(group.raw().mixer.luminance[5], -50.0);
+ QCOMPARE(group.raw().mixer.luminance[4], 0.0);
+ group.child(QStringLiteral("colorHueSlider")).setValue(750);
+ QCOMPARE(group.raw().mixer.hue[5], 50.0);
+ QVERIFY(!group.controls->findChild(QStringLiteral("colorHueField")));
+}
+
+void CameraRawCurveMixerTests::pointColorsArePickedChosenAndShifted()
+{
+ Group group;
+ group.child(QStringLiteral("mixerPage2")).click();
+ group.child(QStringLiteral("pointColorSampler")).click();
+ QVERIFY(group.panel().samplesPointColor);
+ QVERIFY(!group.child(QStringLiteral("visualizeRange")).isVisible());
+ group.set([](CameraRawSettings &settings) {
+ settings.mixer.points = {CameraRawPointColor{.hue = 30, .saturation = 1, .luminance = 0.5},
+ CameraRawPointColor{.hue = 200, .saturation = 0.5, .luminance = 0.5}};
+ });
+ QVERIFY(group.child(QStringLiteral("pickedColor1")).isVisible());
+ group.child(QStringLiteral("pickedColor1")).click();
+ QCOMPARE(group.panel().pointIndex, 1);
+ group.child(QStringLiteral("hueShiftSlider")).setValue(750);
+ QCOMPARE(group.raw().mixer.points[1].hueShift, 50.0);
+ QCOMPARE(group.raw().mixer.points[0].hueShift, 0.0);
+ auto &hueRange = group.child(QStringLiteral("hueRangeSlider"));
+ hueRange.setValue(1000);
+ QCOMPARE(group.raw().mixer.points[1].hueRange, 180.0);
+ QTest::mouseDClick(&hueRange, Qt::LeftButton, {}, knob(hueRange));
+ QCOMPARE(group.raw().mixer.points[1].hueRange, 30.0);
+ auto &range = group.child(QStringLiteral("saturationRangeSlider"));
+ QTest::mouseDClick(&range, Qt::LeftButton, {}, knob(range));
+ group.child(QStringLiteral("saturationRangeSlider")).setValue(0);
+ QCOMPARE(group.raw().mixer.points[1].saturationRange, 0.05);
+ group.child(QStringLiteral("visualizeRange")).click();
+ QVERIFY(group.raw().mixer.points[1].visualize && !group.raw().mixer.points[0].visualize);
+ group.child(QStringLiteral("visualizeRange")).click();
+ QVERIFY(!group.raw().mixer.points[1].visualize);
+}
+
+QTEST_MAIN(CameraRawCurveMixerTests)
+#include "CameraRawCurveMixerTests.moc"
diff --git a/tests/CameraRawGradingLensTests.cpp b/tests/CameraRawGradingLensTests.cpp
new file mode 100644
index 0000000..a7dc299
--- /dev/null
+++ b/tests/CameraRawGradingLensTests.cpp
@@ -0,0 +1,271 @@
+#include "Document/BrushStroke.h"
+#include "Document/EditorSession.h"
+#include "UI/CameraRawColorControls.h"
+#include "UI/CameraRawControls.h"
+#include "UI/CameraRawDetailOpticsControls.h"
+#include "UI/CameraRawGeometryCalibrationControls.h"
+#include "UI/FloatingPanel.h"
+#include "UI/LayerIcons.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Swift's Color Grading, Detail, Optics, Geometry and Calibration groups.
+namespace {
+// The knob's middle, where a double click resets.
+QPoint knob(const CameraRawSlider &slider)
+{
+ QStyleOptionSlider option;
+ option.initFrom(&slider);
+ option.orientation = Qt::Horizontal;
+ option.minimum = slider.minimum();
+ option.maximum = slider.maximum();
+ option.sliderPosition = slider.sliderPosition();
+ option.sliderValue = slider.value();
+ return slider.style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderHandle, &slider).center();
+}
+
+template struct Group {
+ EditorSession session;
+ std::unique_ptr controls;
+ Group()
+ {
+ session.createDocument(8, 8);
+ QImage image = BrushRaster::context(8, 8, false);
+ image.fill(QColor(200, 120, 60));
+ session.insert(ImportedImage(image, image, QStringLiteral("Warm")));
+ session.beginFilter(FilterKind::cameraRaw);
+ controls = std::make_unique(session);
+ controls->resize(374, 700);
+ controls->show();
+ }
+ template Widget &child(const QString &name) const
+ {
+ auto *found = controls->template findChild(name);
+ if (!found)
+ throw std::runtime_error(name.toStdString());
+ return *found;
+ }
+ const CameraRawSettings &raw() const { return session.filterEdit().value().settings.cameraRaw; }
+ const CameraRawPanel &panel() const { return session.filterEdit().value().rawPanel; }
+ void set(const std::function &change)
+ {
+ FilterSettings settings = session.filterEdit().value().settings;
+ change(settings.cameraRaw);
+ session.updateFilter(settings, true);
+ }
+};
+}
+
+class CameraRawGradingLensTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theWheelsSetHueAndSaturationAndResetOnADoubleClick();
+ void theWheelsHueRunsCounterclockwise();
+ void onePageShowsOneWheelAndTheGradingFitsTheDock();
+ void detailRoundsItsRowsAndDimsIdleNoiseDetail();
+ void opticsTogglesProfileDefringeAndHueRanges();
+ void geometryUprightGuidesAndRoundedRows();
+ void calibrationChoosesItsProcessAndPrimaries();
+};
+
+void CameraRawGradingLensTests::theWheelsSetHueAndSaturationAndResetOnADoubleClick()
+{
+ Group group;
+ auto *box = group.controls->findChild(QStringLiteral("gradeWheelMidtones"));
+ QWidget *wheel = nullptr;
+ for (QWidget *child : box->findChildren())
+ if (child->size() == QSize(86, 86))
+ wheel = child;
+ QVERIFY(wheel);
+ // Straight up is hue 90; at the rim, 100.
+ QTest::mousePress(wheel, Qt::LeftButton, {}, QPoint(43, 6));
+ QTest::mouseRelease(wheel, Qt::LeftButton, {}, QPoint(43, 6));
+ QVERIFY(std::abs(group.raw().grading.midtones.hue - 90) < 0.5 && group.raw().grading.midtones.saturation == 100);
+ QVERIFY(group.raw().grading.shadows.hue == 0);
+ QCOMPARE(box->findChild(QStringLiteral("gradeReadout"))->text(), QString("90° 100"));
+ // The left rim, 37 points out: hue 180, saturation 100.
+ QTest::mousePress(wheel, Qt::LeftButton, {}, QPoint(43 - 37, 43));
+ QVERIFY(std::abs(group.raw().grading.midtones.hue - 180) < 0.5 && group.raw().grading.midtones.saturation == 100);
+ QTest::mouseRelease(wheel, Qt::LeftButton, {}, QPoint(6, 43));
+ // A press outside the circle does nothing.
+ QTest::mousePress(wheel, Qt::LeftButton, {}, QPoint(1, 1));
+ QVERIFY(std::abs(group.raw().grading.midtones.hue - 180) < 0.5);
+ QTest::mouseRelease(wheel, Qt::LeftButton, {}, QPoint(1, 1));
+ QTest::mouseDClick(wheel, Qt::LeftButton, {}, QPoint(43, 43));
+ QVERIFY(group.raw().grading.midtones.hue == 0 && group.raw().grading.midtones.saturation == 0);
+ auto *luminance = box->findChild(QStringLiteral("gradeLuminance"));
+ luminance->setValue(750);
+ QCOMPARE(group.raw().grading.midtones.luminance, 50.0);
+ QCOMPARE(luminance->width(), 96);
+ group.child(QStringLiteral("gradeBlendingSlider")).setValue(200);
+ QCOMPARE(group.raw().grading.blending, 20.0);
+ auto &balance = group.child(QStringLiteral("gradeBalanceSlider"));
+ balance.setValue(250);
+ QCOMPARE(group.raw().grading.balance, -50.0);
+ QTest::mouseDClick(&balance, Qt::LeftButton, {}, knob(balance));
+ QCOMPARE(group.raw().grading.balance, 0.0);
+}
+
+// 1.2.9's 96edd5f: the rainbow runs the way the hue does.
+void CameraRawGradingLensTests::theWheelsHueRunsCounterclockwise()
+{
+ Group group;
+ auto *box = group.controls->findChild(QStringLiteral("gradeWheelShadows"));
+ QWidget *wheel = nullptr;
+ for (QWidget *child : box->findChildren())
+ if (child->size() == QSize(86, 86))
+ wheel = child;
+ const QImage drawn = wheel->grab().toImage();
+ const auto hueAt = [&drawn](double degrees) {
+ const double radians = degrees * std::numbers::pi / 180;
+ return drawn.pixelColor(int(43 + std::cos(radians) * 30), int(43 - std::sin(radians) * 30)).hsvHueF() * 360;
+ };
+ // Dragged to 120, the wheel shows green; 240, blue.
+ QVERIFY2(std::abs(hueAt(120) - 120) < 20, qPrintable(QString::number(hueAt(120))));
+ QVERIFY2(std::abs(hueAt(240) - 240) < 20, qPrintable(QString::number(hueAt(240))));
+ QVERIFY2(std::abs(hueAt(60) - 60) < 20, qPrintable(QString::number(hueAt(60))));
+}
+
+void CameraRawGradingLensTests::onePageShowsOneWheelAndTheGradingFitsTheDock()
+{
+ Group group;
+ auto &page = group.child(QStringLiteral("gradePage"));
+ QCOMPARE(page.count(), 5);
+ const auto shown = [&group] {
+ QStringList names;
+ for (QWidget *box : group.controls->findChildren())
+ if (box->objectName().startsWith(QLatin1String("gradeWheel")) && box->isVisible())
+ names << box->objectName();
+ names.sort();
+ return names;
+ };
+ QCOMPARE(shown(), (QStringList{"gradeWheelHighlights", "gradeWheelMidtones", "gradeWheelShadows"}));
+ emit page.activated(4);
+ QVERIFY(group.panel().gradePage == CameraRawGradePage::global);
+ QCOMPARE(shown(), QStringList{"gradeWheelGlobal"});
+ emit page.activated(2);
+ QCOMPARE(shown(), QStringList{"gradeWheelMidtones"});
+ // Swift's theDockedPanelFitsTheGradingWheels: padding 24 a side, inset 18.
+ CameraRawGradingControls grading(group.session);
+ QVERIFY2(grading.sizeHint().width() <= FloatingPanel::dockedWidth - 48 - 18, qPrintable(QString::number(grading.sizeHint().width())));
+}
+
+void CameraRawGradingLensTests::detailRoundsItsRowsAndDimsIdleNoiseDetail()
+{
+ Group group;
+ auto &amount = group.child(QStringLiteral("sharpenAmountSlider"));
+ amount.setValue(333);
+ QCOMPARE(group.raw().detail.sharpenAmount, 50.0);
+ auto &radius = group.child(QStringLiteral("sharpenRadiusSlider"));
+ QTest::mouseDClick(&radius, Qt::LeftButton, {}, knob(radius));
+ QCOMPARE(group.raw().detail.sharpenRadius, 10.0);
+ auto &detail = group.child(QStringLiteral("noiseLuminanceDetailSlider"));
+ QVERIFY(!detail.isEnabled());
+ group.child(QStringLiteral("noiseLuminanceSlider")).setValue(400);
+ QVERIFY(detail.isEnabled() && !group.child(QStringLiteral("noiseColorDetailSlider")).isEnabled());
+ // Alt on Masking shows the mask; other rows clear it.
+ QWindow *window = group.controls->windowHandle();
+ QTest::keyPress(window, Qt::Key_Alt, Qt::AltModifier);
+ group.child(QStringLiteral("sharpenMaskingSlider")).setValue(500);
+ QVERIFY(group.panel().sharpenMask && group.raw().detail.sharpenMasking == 50);
+ amount.setValue(400);
+ QVERIFY(!group.panel().sharpenMask);
+ QTest::keyRelease(window, Qt::Key_Alt);
+ group.child(QStringLiteral("sharpenMaskingSlider")).setValue(600);
+ QVERIFY(!group.panel().sharpenMask);
+}
+
+void CameraRawGradingLensTests::opticsTogglesProfileDefringeAndHueRanges()
+{
+ Group group;
+ auto &profile = group.child(QStringLiteral("profileDistortionSlider"));
+ QVERIFY(!profile.isVisible());
+ group.child(QStringLiteral("enableLensProfile")).click();
+ QVERIFY(group.raw().optics.enableLensProfile && profile.isVisible());
+ group.child(QStringLiteral("removeChromaticAberration")).click();
+ QVERIFY(group.raw().optics.removeChromaticAberration);
+ // A range from zero rounds; a signed range does not.
+ group.child(QStringLiteral("purpleAmountSlider")).setValue(333);
+ QCOMPARE(group.raw().optics.purpleAmount, 33.0);
+ group.child(QStringLiteral("distortionSlider")).setValue(333);
+ QVERIFY(std::abs(group.raw().optics.distortion - -33.4) < 1e-9);
+ auto &midpoint = group.child(QStringLiteral("lensMidpointSlider"));
+ midpoint.setValue(0);
+ QTest::mouseDClick(&midpoint, Qt::LeftButton, {}, knob(midpoint));
+ QCOMPARE(group.raw().optics.vignetteMidpoint, 50.0);
+ // Hue sliders round, and reset to their own ends.
+ auto &low = group.child