From 56af936a7bef0d3640298c5845f440994c0508b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 00:22:02 -0400 Subject: [PATCH] Camera Raw panel docked beside the canvas: scope, Light, Color, Effects, canvas tools (12.8b1) --- AGENTS.md | 1 + CMakeLists.txt | 4 + TASKS.md | 11 +- src/ContentView+Panels.cpp | 3 +- src/ContentView.cpp | 8 +- src/ContentView.h | 2 + src/Rendering/EditorCanvas+CameraRaw.cpp | 73 ++++ src/Rendering/EditorCanvas+Navigation.cpp | 9 + src/Rendering/EditorCanvas+Picking.cpp | 6 +- src/Rendering/EditorCanvas.h | 5 + src/Rendering/EyedropperIcon.cpp | 13 + src/Rendering/EyedropperIcon.h | 3 + src/UI/CameraRawControls+Basic.cpp | 163 ++++++++ src/UI/CameraRawControls.cpp | 445 ++++++++++++++++++++++ src/UI/CameraRawControls.h | 63 +++ src/UI/CameraRawSlider.cpp | 99 +++++ src/UI/CameraRawSlider.h | 39 ++ src/UI/FilterSheet.cpp | 8 +- src/UI/FloatingPanel.cpp | 72 +++- src/UI/FloatingPanel.h | 9 +- src/UI/LevelsSheet.cpp | 16 +- tests/CameraRawCanvasTests.cpp | 224 +++++++++++ tests/CameraRawPanelTests.cpp | 409 ++++++++++++++++++++ tests/CameraRawSliderTests.cpp | 117 ++++++ tests/FloatingPanelTests.cpp | 23 ++ tests/Tests.cmake | 3 + 26 files changed, 1794 insertions(+), 34 deletions(-) create mode 100644 src/Rendering/EditorCanvas+CameraRaw.cpp create mode 100644 src/UI/CameraRawControls+Basic.cpp create mode 100644 src/UI/CameraRawControls.cpp create mode 100644 src/UI/CameraRawControls.h create mode 100644 src/UI/CameraRawSlider.cpp create mode 100644 src/UI/CameraRawSlider.h create mode 100644 tests/CameraRawCanvasTests.cpp create mode 100644 tests/CameraRawPanelTests.cpp create mode 100644 tests/CameraRawSliderTests.cpp diff --git a/AGENTS.md b/AGENTS.md index bec8166..205117c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,7 @@ Platform adaptations, each forced by a missing Qt analog: - Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, unchanged through 1.3.3), so a base or an adjustment in one of them draws as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`. - Black & White, Color Balance and Invert (12.7b, Swift 1.2.2): `kernels/AdjustPixels.c/.h` are v1.2.2's, with `adjust_black_white` and `adjust_color_balance`, run through `ImageAdjustmentPixels::run` on one thread as Swift's. `BlackWhiteSettings` and `ColorBalanceSettings` keep Swift's fields, defaults and bounds (the `isFinite` terms left out, as the bounds refuse NaN and infinities); `apply` throws `ProjectError(invalid)` past them and Color Balance hands an untouched image back. Swift's `FilterSettings.normalized` does not clamp either, so a number typed past a slider's range fails the preview with that error and a commit sets `brushError` with no step, as in Swift: the filter's two workers catch `ProjectError` beside their other errors, where it would otherwise escape as `QUnhandledException` and abort. `FilterKind` gains both after Grain (`isImageAdjustment`), `AdjustmentKind` Invert, Black & White and Color Balance in Swift's order, with `isEditable` (all but Invert) and `filterKind`; `LayerAdjustment` gains the two optionals with Swift's computed getter and setter pairs, validity and `apply` (Invert through `PixelInvert::run`, whole image, colour only); the manifest's `blackWhiteSettings` and `colorBalanceSettings` are Swift's synthesized `Codable`, every key required, left out while absent. `addAdjustment` opens the editor for editable kinds alone: an Invert layer applies at once. The editors carry both settings through `beginAdjustmentEditing` and `editedAdjustment`. A fix beyond Swift: Swift's Edit Adjustment… stays enabled on an Invert layer, whose editor switch breaks after the original is kept and the transaction opened, so nothing ever closes them and the session stays refused; the twin's entry rests for a kind that is not editable, and the two switches throw a `logic_error` for Invert, which nothing reaches. As Swift's row, an Invert layer says "Adjustment · Double-click to edit", yet a double click on its glyph renames it (`m_editableAdjustment`, Swift's `isEditable` test). The Image menu lists Black & White… and Color Balance… before Exposure…, the Layer menu's New Adjustment Layer entries take an ellipsis when editable, and a menu doubles the ampersand of "Black & White" (a lone one is a mnemonic in Qt), in the kinds' entries and in Undo and Redo's step names alike; object names drop it (`blackWhite`, `newBlackWhiteAdjustment`). `LayerIcons` draws Swift's three new symbols as paths: `circle.righthalf.filled`, `circle.filled.pattern.diagonalline.rectangle` (a rounded frame, diagonal lines, a filled circle) and `scale.3d` (three axes with arrowheads). The filter sheet's two kinds live in `UI/FilterSheet+Adjustments.cpp` (the sheet's file neared 500 lines): the six families and Tint's Hue and Saturation, shown while Tint is on (Swift's `if`, the rows `m_shownWhen` holds with Advanced's), and Color Balance's three headlines (13 pixels, semibold, Swift's `.headline`, as the effects sheet's) over three rows each and Preserve Luminosity; each `flag` is Swift's `Toggle` with its help as the tooltip. `NativeLayerList` stops listening in its destructor, as `ContentView` does: closing a list window hides it first, and a rename's focus-out then commits into a list half destroyed. Tests: Swift's `invertAppliesWithoutAnEditor` in `AdjustmentLayerTests`, its editor test over the editable kinds in `AdjustmentEditorTests` and `FloatingPanelTests`, `ColorAdjustmentTests` (the kernels against Photoshop's defaults and a tint worked in HSL, the tones of Color Balance and Preserve Luminosity, bounds, every manifest key, the sheet), the kinds, keys and menus in `FilterModelTests`, `LayerAdjustmentTests`, `AdjustmentPanelTests`, `CompositorMenusTests` and `SelectMenuTests`. `ProjectExportFailureTests`' two panel limits rose from 2,048 KB: the thousand-layer snapshot grew by the two optionals of every record (2,560 KB since 12.8d). - The Camera Raw filter's model (12.8a, Swift 1.2.3): `kernels/AdjustPixels.c/.h` are v1.2.3's, with the Camera Raw kernels and Grain's particle size (`59b5040`); that kernel warns under `-Wunused-parameter` and `-Wmisleading-indentation` through 1.3.3 (both lines date from `9982fad`; `075a71b` fixes another), so CMake silences those two for that file alone. `Document/CameraRaw.h/.cpp`, `CameraRawColor`, `CameraRawDetailOptics` and `CameraRawGeometryCalibration` twin Swift's four files; Swift's `CameraRaw.swift` runs to 638 lines, so its session extension is `CameraRaw+Session.cpp` with `CameraRaw+Session.h`. The enums keep Swift's raw values and order; `CameraRawClipping` keeps Swift's bits (highlights 1, shadows 2); `Balance` and `Gains` are structs for Swift's tuples; `region` returns a reference where Swift writes through a key path. `CameraRawDrag` sits in `CameraRaw.h`, where Swift's sits in `CameraRawColor.swift`: it holds the whole settings, which C++ needs complete. Swift's panel state sits on `FilterEdit` (the shown groups, pages, swatch, point index, the tools, the guide draft, clipping, sharpen mask, the scope and its mode, the readout); the twin gathers it in `CameraRawPanel`, `FilterEdit::rawPanel`, written through `setCameraRawPanel`, which notifies while an edit is open. `renderSettings` is Swift's settings with the hidden groups left out (`applying`); the commit renders and keeps those, so a hidden group stays out next time, and an identity grade closes as Cancel does. `previewJob` carries the panel's views; the preview's worker runs `CameraRawScope::preview`, whose scope lands on the panel. A fix beyond Swift: Swift's `preview` drops the point colour's view (it runs the job itself only for clipping or the sharpen mask, through 1.3.3), so Visualize never shows; the twin runs the job for it too. Geometry's warp is `QTransform::quadToQuad` from the image's rect to Swift's corners flipped to y down, drawn smooth and antialiased; a warp Qt cannot solve is a `logic_error`. Constrain Crop fits the frame inside `brush_alpha_bounds`; a crop whose copy finds no memory is `ExportError(render)`, where Swift's `cropping` returning nil falls back to the uncropped warp. Left out as dead: `apply`'s `isValid` guard and with it `isValid` (settings arrive normalized, and nothing else asks), `nudged`'s guard for an empty channel (`normalized` repairs every curve to two points; `at` throws if that breaks), the vectorscope's wrap of a negative hue (its angle's cosine and sine turn round alike), the parametric curve's `max(0, weight)` (a tone inside its region weighs 0 to 1), each group's `applying(shows:)` (no caller), and the defringe eyedropper's own `normalized` (`updateFilter` normalizes). Equivalent mutants: `tone < shadow` against `<=` (the regions meet with equal weight), the defringe tie's `<` against `<=`, the mixer's `weights > 0` against `>= 0` (a zero weight adds nothing), the identity commit's cancel (the history drops an unchanged step), and Auto's `committing` term (a committing edit refuses `updateFilter`, and the landing asks again). The Filter menu and the sheet leave Camera Raw out until its panel (12.8b). Tests: `CameraRawTests`, `CameraRawSessionTests` (Swift's session tests; the preview's views and scope), `CameraRawToolsTests`, `CameraRawRulesTests`, `CameraRawColorTests`, `CameraRawLensTests` (the kernel's calibration called directly), `CameraRawWiringTests` (Effects, Optics and Detail against their kernels called with the header's order, a clip view on an untouched grade, Auto in linear light, every eye's fields, Offset, Aspect and Constrain Crop against placed pictures, the scope's channels and hues, the curve, mixer and grading call with its floats written out, the sharpen-mask view, each lens flag alone, Grain's scale, a dull colour's reach, Rotate, Scale and Vertical against placed pictures), `CameraRawFixtures.h`, Grain's size in `ImageAdjustmentTests`, the kind in `FilterModelTests`. +- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; the track is fixed at construction, 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`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a49da5..2fa4394 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,6 +119,9 @@ add_library(compositor_core STATIC src/UI/NavigationToolHeader.cpp src/UI/TransformInspector.cpp src/UI/EffectsSheet.cpp + src/UI/CameraRawControls.cpp + src/UI/CameraRawControls+Basic.cpp + src/UI/CameraRawSlider.cpp src/UI/FilterSheet.cpp src/UI/FilterSheet+Adjustments.cpp src/UI/FloatingPanel.cpp @@ -156,6 +159,7 @@ add_library(compositor_core STATIC src/Rendering/DownsampleCache.cpp src/Rendering/EffectsPreviewCache.cpp src/Rendering/EditorCanvas.cpp + src/Rendering/EditorCanvas+CameraRaw.cpp src/Rendering/EditorCanvas+Brush.cpp src/Rendering/EditorCanvas+Crop.cpp src/Rendering/EditorCanvas+Effects.cpp diff --git a/TASKS.md b/TASKS.md index c8e2cfd..9a77f72 100644 --- a/TASKS.md +++ b/TASKS.md @@ -348,8 +348,15 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - 12.8a round 2: FAIL, one finding: the curve, mixer and grading call, the sharpen-mask view and the lens flags were not held against their kernels; each is now, with the floats written out by hand, and the minor constants (Grain's scale, a dull colour's reach, Rotate, Scale, Vertical, a green sample's tone) with them. - 12.8a round 3: PASS. Its three minor gaps (Horizontal's factor, the sharpen mask's scale, calibration's green and blue fields) pinned after the round (`mut-12.8a-r6.log`). - [x] 12.8a Version 1.2.3, part one: the Camera Raw filter's model and pixel kernels. -- [ ] 12.8b Version 1.2.3, part two: the Camera Raw panel beside the canvas (Swift's docked placement in `FloatingPanel.swift`), its grading wheels turning the way their hue runs (1.2.9's `96edd5f`), the Filter menu's entry, and the canvas half of the tools in `EditorCanvas.swift` (the readout on hover and its clearing on leaving, the three eyedroppers and the guide's press, each refocusing the panel, the guide and targeted drags, their release, `picking`'s four terms). -- [ ] 12.8c Version 1.2.3, part three: Inner Glow, Image Trim, blur and noise adjustment layers, the layer context menu without 1.2.5's dropped Layer Effects entry (`5d7adba`). +- [x] 12.8b1 Version 1.2.3, part two: the Camera Raw panel docked beside the canvas (Swift's docked placement in `FloatingPanel.swift`), `CameraRawSlider` (coloured tracks, a double click on the knob resetting), the histogram and vectorscope with the clipping buttons and the readout, the ten sections with their eyes, Light, Color and Effects, and the canvas half of the tools in `EditorCanvas.swift` (the readout on hover and its clearing on leaving, the three eyedroppers and the guide's press, each refocusing the panel, the guide and targeted drags, their release, `picking`'s four terms). +- 12.8b1 round 1: FAIL, four findings: the rows' titles, help and two clipping views, the scope's drawing and the glyphs were unpinned; help said Option and Control-click; a stray `friend` and `setTrack` without a caller; AGENTS.md lacked the equivalents and the dock's height. All fixed, with the minor notes (buddy, `isHidden`, SwiftUI's dark colours, auto-repeat, the docked panel's deletion). +- 12.8b1 round 2: PASS. Its notes taken: every row's decimals and plain track pinned, the lit highlight triangle, the knob's two-point ring, a redundant label filter gone, two consequences named in AGENTS.md. +- [ ] 12.8e `ProjectExportFailureTests::aJPEGRenderThatFindsNoMemoryIsExplained` still aborts with `std::bad_alloc` under a full parallel run after 12.8d (once in 12.8b1's run; that test's main thread waits while the JPEG worker runs under the limit): catch a backtrace, fix the cause. +- [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. +- [ ] 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. - 12.8d round 1: PASS. Its notes taken: the snapshot's measured peak named in AGENTS.md, `malloc_trim` in `limited` named equivalent. - [ ] 12.9 Version 1.2.4: the finishing filters, stepped keyboard zoom, a steady tool rail and tab strip, the project format's version in one place. diff --git a/src/ContentView+Panels.cpp b/src/ContentView+Panels.cpp index 96bfa6d..2d58413 100644 --- a/src/ContentView+Panels.cpp +++ b/src/ContentView+Panels.cpp @@ -45,7 +45,8 @@ void ContentView::synchronizePanels() m_filterShown = m_session.filterEdit().has_value(); if (m_filterShown) { m_filterPanel.onClose = [this] { m_session.cancelFilter(); }; - m_filterPanel.show(rawValue(m_session.filterEdit()->kind), new FilterSheet(m_session)); + const FilterKind kind = m_session.filterEdit()->kind; + m_filterPanel.show(rawValue(kind), new FilterSheet(m_session), kind == FilterKind::cameraRaw ? m_dock : nullptr); } else { m_filterPanel.close(); } diff --git a/src/ContentView.cpp b/src/ContentView.cpp index d4e1f87..f10ade0 100644 --- a/src/ContentView.cpp +++ b/src/ContentView.cpp @@ -196,7 +196,7 @@ protected: ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent) : QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout), - m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))), + m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))), m_activity(statusText(new ElidedLabel(this))), m_spinner(new QProgressBar(this)) { @@ -261,6 +261,12 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW m_layersPanel->setFixedWidth(int(layersPanelWidth())); middle->addWidget(new PanelResizeEdge(*m_layersPanel, this)); middle->addWidget(m_layersPanel); + m_dock->setObjectName(QStringLiteral("panelDock")); + m_dock->setFixedWidth(FloatingPanel::dockedWidth); + auto *docked = new QVBoxLayout(m_dock); + docked->setContentsMargins(0, 0, 0, 0); + m_dock->hide(); + middle->addWidget(m_dock); m_zoom->setObjectName(QStringLiteral("zoomStatus")); m_zoom->setFixedWidth(62); diff --git a/src/ContentView.h b/src/ContentView.h index 103796d..55d694a 100644 --- a/src/ContentView.h +++ b/src/ContentView.h @@ -70,6 +70,8 @@ private: QGridLayout *const m_canvasSlot; CanvasView *const m_canvas; LayersPanel *const m_layersPanel; + // Swift's docked panel: a slot at the window's right. + QWidget *const m_dock; // An accent ring while a drop may land. QWidget *const m_dropRing; // Swift 1.1.7's rulers: the corner, then the two strips. diff --git a/src/Rendering/EditorCanvas+CameraRaw.cpp b/src/Rendering/EditorCanvas+CameraRaw.cpp new file mode 100644 index 0000000..218c8f2 --- /dev/null +++ b/src/Rendering/EditorCanvas+CameraRaw.cpp @@ -0,0 +1,73 @@ +#include "Rendering/EditorCanvas.h" +#include "UI/FloatingPanel.h" + +// Swift's Camera Raw branches of mouseDown, mouseDragged and mouseUp. +bool CanvasView::cameraRawPress(QPointF point) +{ + const std::optional<FilterEdit> &edit = m_session.filterEdit(); + if (!edit || m_spaceHeld) + return false; + const QPointF document = m_session.viewport.documentPoint(point, m_session.document()->size()); + const CameraRawPanel &panel = edit->rawPanel; + // Each eyedropper samples and hands the panel the keys back. + if (panel.samplesWhiteBalance || panel.samplesPointColor || panel.samplesDefringe) { + if (panel.samplesWhiteBalance) + m_session.sampleCameraRawWhiteBalance(document); + else if (panel.samplesPointColor) + m_session.sampleCameraRawPointColor(document); + else + m_session.sampleCameraRawDefringe(document); + FloatingPanel::refocus(QStringLiteral("filterPanel")); + return true; + } + if (panel.drawingGeometryGuide) { + m_session.beginCameraRawGeometryGuide(document); + return true; + } + if (panel.targetsCurve || panel.targetsMixer) { + m_session.beginCameraRawDrag(document); + return true; + } + return false; +} + +// No left button: the release was lost, so it ends. +bool CanvasView::cameraRawMove(QPointF point, Qt::MouseButtons buttons) +{ + const std::optional<FilterEdit> &edit = m_session.filterEdit(); + if (!edit || !m_session.document() || !(edit->rawPanel.guideDraft || edit->rawPanel.drag)) + return false; + if (!buttons.testFlag(Qt::LeftButton)) { + cameraRawRelease(); + return false; + } + const QPointF document = m_session.viewport.documentPoint(point, m_session.document()->size()); + if (edit->rawPanel.drawingGeometryGuide && edit->rawPanel.guideDraft) + m_session.continueCameraRawGeometryGuide(document); + else if (edit->rawPanel.drag) + m_session.dragCameraRaw(document); + return true; +} + +void CanvasView::cameraRawRelease() +{ + if (!m_session.filterEdit()) + return; + if (m_session.filterEdit()->rawPanel.guideDraft) + m_session.commitCameraRawGeometryGuide(); + if (m_session.filterEdit() && m_session.filterEdit()->rawPanel.drag) { + CameraRawPanel panel = m_session.filterEdit()->rawPanel; + panel.drag.reset(); + m_session.setCameraRawPanel(panel); + } +} + +// Swift's mouseExited: the readout goes with the pointer. +void CanvasView::clearCameraRawReadout() +{ + if (!m_session.filterEdit() || !m_session.filterEdit()->rawPanel.readout) + return; + CameraRawPanel panel = m_session.filterEdit()->rawPanel; + panel.readout.reset(); + m_session.setCameraRawPanel(panel); +} diff --git a/src/Rendering/EditorCanvas+Navigation.cpp b/src/Rendering/EditorCanvas+Navigation.cpp index 7161a2f..de74eab 100644 --- a/src/Rendering/EditorCanvas+Navigation.cpp +++ b/src/Rendering/EditorCanvas+Navigation.cpp @@ -139,6 +139,8 @@ void CanvasView::mousePressEvent(QMouseEvent *event) return; const QPointF point = event->position(); m_hover = point; + if (cameraRawPress(point)) + return; // A Levels eyedropper sets its point; the panel keeps keys. if (m_session.levels() && m_session.levels()->sampleMode && !m_spaceHeld) { m_session.sampleLevels(m_session.viewport.documentPoint(point, m_session.document()->size())); @@ -206,6 +208,9 @@ void CanvasView::mouseMoveEvent(QMouseEvent *event) const QPointF point = event->position(); m_hover = point; readModifiers(event->modifiers()); + // Swift's mouseMoved: the readout follows a hover alone. + if (!event->buttons() && m_session.document()) + m_session.updateCameraRawReadout(m_session.viewport.documentPoint(point, m_session.document()->size())); if (m_middlePanPoint && event->buttons().testFlag(Qt::MiddleButton)) { m_session.viewport.translate(QSizeF(point.x() - m_middlePanPoint->x(), point.y() - m_middlePanPoint->y())); m_middlePanPoint = point; @@ -229,6 +234,8 @@ void CanvasView::mouseMoveEvent(QMouseEvent *event) return; if (m_inlineTextEditor && !held) m_inlineTextEditor->release(); + if (cameraRawMove(point, event->buttons())) + return; if (pickingMove(point, event->buttons()) || targetingMove(point, event->buttons(), event->modifiers()) || cropMove(point, event->buttons(), event->modifiers()) || guideMove(point, event->buttons())) return; @@ -287,6 +294,7 @@ void CanvasView::mouseReleaseEvent(QMouseEvent *event) QWidget::mouseReleaseEvent(event); return; } + cameraRawRelease(); if (m_textBoxAnchor) { finishTextGesture(); return; @@ -363,6 +371,7 @@ void CanvasView::leaveEvent(QEvent *event) { m_brushPointer = std::nullopt; updateBrushCursor(); + clearCameraRawReadout(); QWidget::leaveEvent(event); } diff --git a/src/Rendering/EditorCanvas+Picking.cpp b/src/Rendering/EditorCanvas+Picking.cpp index b8e2125..79c2273 100644 --- a/src/Rendering/EditorCanvas+Picking.cpp +++ b/src/Rendering/EditorCanvas+Picking.cpp @@ -13,7 +13,11 @@ bool CanvasView::palettePicking() const bool CanvasView::picking() const { - return palettePicking() || m_session.colorPicker() || m_session.hueSampleMode() || (m_session.levels() && m_session.levels()->sampleMode); + const std::optional<FilterEdit> &edit = m_session.filterEdit(); + return palettePicking() || m_session.colorPicker() || m_session.hueSampleMode() || (m_session.levels() && m_session.levels()->sampleMode) + || (edit + && (edit->rawPanel.samplesWhiteBalance || edit->rawPanel.samplesPointColor || edit->rawPanel.samplesDefringe + || edit->rawPanel.drawingGeometryGuide)); } // Picking or targeting begins or ends: a sampling goes. diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index 8b6d6f8..2a85195 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -242,6 +242,11 @@ private: bool beginGuideDrag(QPointF point); bool guideMove(QPointF point, Qt::MouseButtons buttons); void endGuideDrag(QPointF point); + // Camera Raw's tools on the canvas (EditorCanvas+CameraRaw.cpp). + bool cameraRawPress(QPointF point); + bool cameraRawMove(QPointF point, Qt::MouseButtons buttons); + void cameraRawRelease(); + void clearCameraRawReadout(); // Swift's targeted drag: right raises; Ctrl drags the hue. bool targetingMove(QPointF point, Qt::MouseButtons buttons, Qt::KeyboardModifiers modifiers); void targetingRelease(); diff --git a/src/Rendering/EyedropperIcon.cpp b/src/Rendering/EyedropperIcon.cpp index 7186259..07a8859 100644 --- a/src/Rendering/EyedropperIcon.cpp +++ b/src/Rendering/EyedropperIcon.cpp @@ -31,3 +31,16 @@ void EyedropperIcon::paint(QPainter &painter) painter.drawPath(tube); painter.restore(); } + +QIcon EyedropperIcon::icon(const QColor &ink, double ratio) +{ + QPixmap pixmap(QSize(14, 14) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.scale(14.0 / 18, 14.0 / 18); + painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + paint(painter); + return QIcon(pixmap); +} diff --git a/src/Rendering/EyedropperIcon.h b/src/Rendering/EyedropperIcon.h index 5775722..8de3b4f 100644 --- a/src/Rendering/EyedropperIcon.h +++ b/src/Rendering/EyedropperIcon.h @@ -1,4 +1,5 @@ #pragma once +#include <QIcon> #include <QPainter> #include <QPointF> @@ -8,4 +9,6 @@ namespace EyedropperIcon { QPointF tip(); // Draws with the painter's pen, as the rail's icons. void paint(QPainter &painter); +// The glyph in one ink, 14 points, for buttons. +QIcon icon(const QColor &ink, double ratio); } diff --git a/src/UI/CameraRawControls+Basic.cpp b/src/UI/CameraRawControls+Basic.cpp new file mode 100644 index 0000000..7b52ad3 --- /dev/null +++ b/src/UI/CameraRawControls+Basic.cpp @@ -0,0 +1,163 @@ +#include "UI/CameraRawControls.h" +#include "Rendering/EyedropperIcon.h" +#include <QComboBox> +#include <QHBoxLayout> +#include <QLabel> +#include <QToolButton> +#include <QVBoxLayout> + +namespace { +using Key = std::function<double &(CameraRawSettings &)>; + +Key field(double CameraRawSettings::*member) +{ + return [member](CameraRawSettings &settings) -> double & { return settings.*member; }; +} + +constexpr CameraRawClipping highlights = CameraRawClipping::highlights, shadows = CameraRawClipping::shadows; + +// Swift's labelled Picker: its title, then the menu. +QComboBox *picker(const QString &title, const QStringList &choices, const QString &help, QVBoxLayout *column) +{ + auto *box = new QWidget; + auto *label = new QLabel(title, box); + auto *menu = new QComboBox(box); + menu->addItems(choices); + menu->setToolTip(help); + label->setBuddy(menu); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->addWidget(label); + row->addWidget(menu); + row->addStretch(1); + column->addWidget(box); + return menu; +} + +// Swift's `.padding(.leading, 16)` around a group of rows. +QVBoxLayout *indented(QVBoxLayout *column) +{ + auto *box = new QWidget; + auto *rows = new QVBoxLayout(box); + rows->setContentsMargins(16, 0, 0, 0); + rows->setSpacing(8); + column->addWidget(box); + return rows; +} +} + +void CameraRawControls::light(QVBoxLayout *column) +{ + slider(column, QStringLiteral("exposure"), QStringLiteral("Exposure"), field(&CameraRawSettings::exposure), CameraRawSettings::exposureLow, CameraRawSettings::exposureHigh, 2, + highlights, QStringLiteral("Brightens or darkens the whole picture, in stops of light. Hold Alt to see clipped highlights.")); + slider(column, QStringLiteral("contrast"), QStringLiteral("Contrast"), field(&CameraRawSettings::contrast), -100, 100, 0, std::nullopt, + QStringLiteral("Makes light and dark tones more or less different, mostly around the middle.")); + slider(column, QStringLiteral("highlights"), QStringLiteral("Highlights"), field(&CameraRawSettings::highlights), -100, 100, 0, highlights, + QStringLiteral("Adjusts the bright parts of the picture. Hold Alt to see clipped highlights.")); + slider(column, QStringLiteral("shadows"), QStringLiteral("Shadows"), field(&CameraRawSettings::shadows), -100, 100, 0, shadows, + QStringLiteral("Adjusts the dark parts of the picture. Hold Alt to see clipped shadows.")); + slider(column, QStringLiteral("whites"), QStringLiteral("Whites"), field(&CameraRawSettings::whites), -100, 100, 0, highlights, + QStringLiteral("Sets the brightest point. Hold Alt to see clipped highlights.")); + slider(column, QStringLiteral("blacks"), QStringLiteral("Blacks"), field(&CameraRawSettings::blacks), -100, 100, 0, shadows, + QStringLiteral("Sets the darkest point. Hold Alt to see clipped shadows.")); +} + +void CameraRawControls::color(QVBoxLayout *column) +{ + const QString help = QStringLiteral("Auto balances the average color. Custom follows Temperature and Tint."); + auto *box = new QWidget; + auto *label = new QLabel(QStringLiteral("White Balance"), box); + label->setMinimumWidth(labelWidth); + label->setToolTip(help); + m_whiteBalance = new QComboBox(box); + m_whiteBalance->setObjectName(QStringLiteral("whiteBalance")); + m_whiteBalance->addItems({QStringLiteral("Custom"), QStringLiteral("Auto")}); + m_whiteBalance->setToolTip(help); + label->setBuddy(m_whiteBalance); + connect(m_whiteBalance, &QComboBox::activated, this, [this](int index) { + if (CameraRawWhiteBalance(index) == CameraRawWhiteBalance::automatic) { + m_session.applyCameraRawAutoWhiteBalance(); + return; + } + update([](CameraRawSettings &settings) { settings.whiteBalance = CameraRawWhiteBalance::custom; }); + }); + m_whiteBalanceSampler = new QToolButton(box); + m_whiteBalanceSampler->setObjectName(QStringLiteral("whiteBalanceSelector")); + m_whiteBalanceSampler->setAutoRaise(true); + m_whiteBalanceSampler->setToolTip(QStringLiteral("Click a pixel that should be neutral.")); + m_whiteBalanceSampler->setAccessibleName(QStringLiteral("White Balance Selector")); + connect(m_whiteBalanceSampler, &QToolButton::clicked, this, + [this] { panel([](CameraRawPanel &raw) { raw.samplesWhiteBalance = !raw.samplesWhiteBalance; }); }); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(10); + row->addWidget(label); + row->addWidget(m_whiteBalance, 1); + row->addWidget(m_whiteBalanceSampler); + column->addWidget(box); + m_whiteBalanceHint = new QLabel(QStringLiteral("Click the original layer. Click the eyedropper again to stop.")); + m_whiteBalanceHint->setObjectName(QStringLiteral("whiteBalanceHint")); + m_whiteBalanceHint->setWordWrap(true); + m_whiteBalanceHint->setForegroundRole(QPalette::PlaceholderText); + QFont caption = m_whiteBalanceHint->font(); + caption.setPixelSize(10); + m_whiteBalanceHint->setFont(caption); + column->addWidget(m_whiteBalanceHint); + using Kind = CameraRawSliderTrack::Kind; + slider(column, QStringLiteral("temperature"), QStringLiteral("Temperature"), field(&CameraRawSettings::temperature), -100, 100, 0, std::nullopt, + QStringLiteral("Shifts the picture from blue to yellow."), {Kind::temperature}); + slider(column, QStringLiteral("tint"), QStringLiteral("Tint"), field(&CameraRawSettings::tint), -100, 100, 0, std::nullopt, + QStringLiteral("Shifts the picture from green to mauve."), {Kind::tint}); + slider(column, QStringLiteral("vibrance"), QStringLiteral("Vibrance"), field(&CameraRawSettings::vibrance), -100, 100, 0, std::nullopt, + QStringLiteral("Strengthens quiet colors more than colors that are already strong, and protects skin tones."), {Kind::chroma}); + slider(column, QStringLiteral("saturation"), QStringLiteral("Saturation"), field(&CameraRawSettings::saturation), -100, 100, 0, std::nullopt, + QStringLiteral("Strengthens or weakens every color by the same amount."), {Kind::chroma}); +} + +void CameraRawControls::effects(QVBoxLayout *column) +{ + slider(column, QStringLiteral("texture"), QStringLiteral("Texture"), field(&CameraRawSettings::texture), -100, 100, 0, std::nullopt, QStringLiteral("Adds or softens small detail.")); + slider(column, QStringLiteral("clarity"), QStringLiteral("Clarity"), field(&CameraRawSettings::clarity), -100, 100, 0, std::nullopt, + QStringLiteral("Adds or softens contrast along broader shapes.")); + slider(column, QStringLiteral("dehaze"), QStringLiteral("Dehaze"), field(&CameraRawSettings::dehaze), -100, 100, 0, std::nullopt, + QStringLiteral("Clears haze when raised, and adds haze when lowered.")); + subheadline(QStringLiteral("Glow"), column); + slider(column, QStringLiteral("glow"), QStringLiteral("Glow"), field(&CameraRawSettings::glow), 0, 100, 0, std::nullopt, QStringLiteral("Spreads a glow from the bright areas.")); + m_glowStyle = picker(QStringLiteral("Style"), {QStringLiteral("Diffusion"), QStringLiteral("Bloom"), QStringLiteral("Halation")}, + QStringLiteral("Diffusion is soft and wide, Bloom is tighter, and Halation is a red fringe."), column); + m_glowStyle->setObjectName(QStringLiteral("glowStyle")); + connect(m_glowStyle, &QComboBox::activated, this, + [this](int index) { update([index](CameraRawSettings &settings) { settings.glowStyle = CameraRawGlowStyle(index); }); }); + QVBoxLayout *glow = indented(column); + slider(glow, QStringLiteral("glowRange"), QStringLiteral("Range"), field(&CameraRawSettings::glowRange), -100, 100, 0, std::nullopt, + QStringLiteral("Chooses how bright an area must be to glow. Has no effect until Glow is raised.")); + slider(glow, QStringLiteral("glowSpread"), QStringLiteral("Spread"), field(&CameraRawSettings::glowSpread), -100, 100, 0, std::nullopt, + QStringLiteral("Sets how far the glow reaches. Has no effect until Glow is raised.")); + slider(glow, QStringLiteral("glowWarmth"), QStringLiteral("Warmth"), field(&CameraRawSettings::glowWarmth), -100, 100, 0, std::nullopt, + QStringLiteral("Shifts the glow from cool to warm. Halation stays red. Has no effect until Glow is raised.")); + subheadline(QStringLiteral("Vignette"), column); + slider(column, QStringLiteral("vignetteAmount"), QStringLiteral("Amount"), field(&CameraRawSettings::vignetteAmount), -100, 100, 0, std::nullopt, + QStringLiteral("Darkens or lightens the edges. The center does not change.")); + m_vignetteStyle = picker(QStringLiteral("Style"), {QStringLiteral("Highlight Priority"), QStringLiteral("Color Priority"), QStringLiteral("Paint Overlay")}, + QStringLiteral("Highlight Priority protects bright edges. Color Priority also reduces color. Paint Overlay covers the edges evenly."), + column); + m_vignetteStyle->setObjectName(QStringLiteral("vignetteStyle")); + connect(m_vignetteStyle, &QComboBox::activated, this, + [this](int index) { update([index](CameraRawSettings &settings) { settings.vignetteStyle = CameraRawVignetteStyle(index); }); }); + QVBoxLayout *vignette = indented(column); + slider(vignette, QStringLiteral("vignetteMidpoint"), QStringLiteral("Midpoint"), field(&CameraRawSettings::vignetteMidpoint), 0, 100, 0, std::nullopt, + QStringLiteral("Sets where the vignette begins, from the center outward."), {}, 50); + slider(vignette, QStringLiteral("vignetteRoundness"), QStringLiteral("Roundness"), field(&CameraRawSettings::vignetteRoundness), -100, 100, 0, std::nullopt, + QStringLiteral("Makes the vignette rounder or more square.")); + slider(vignette, QStringLiteral("vignetteFeather"), QStringLiteral("Feather"), field(&CameraRawSettings::vignetteFeather), 0, 100, 0, std::nullopt, + QStringLiteral("Softens the edge of the vignette."), {}, 50); + slider(vignette, QStringLiteral("vignetteHighlights"), QStringLiteral("Highlights"), field(&CameraRawSettings::vignetteHighlights), 0, 100, 0, std::nullopt, + QStringLiteral("Protects bright pixels while a dark vignette is applied. Used by Highlight Priority.")); + subheadline(QStringLiteral("Grain"), column); + slider(column, QStringLiteral("grainAmount"), QStringLiteral("Amount"), field(&CameraRawSettings::grainAmount), 0, 100, 0, std::nullopt, + QStringLiteral("Adds film grain, strongest in the middle tones.")); + slider(column, QStringLiteral("grainSize"), QStringLiteral("Size"), field(&CameraRawSettings::grainSize), 0, 100, 0, std::nullopt, QStringLiteral("Makes the grain coarser or finer."), + {}, 25); + slider(column, QStringLiteral("grainRoughness"), QStringLiteral("Roughness"), field(&CameraRawSettings::grainRoughness), 0, 100, 0, std::nullopt, + QStringLiteral("Makes the grain smoother or more uneven."), {}, 50); +} diff --git a/src/UI/CameraRawControls.cpp b/src/UI/CameraRawControls.cpp new file mode 100644 index 0000000..650022b --- /dev/null +++ b/src/UI/CameraRawControls.cpp @@ -0,0 +1,445 @@ +#include "UI/CameraRawControls.h" +#include "UI/ColorPickerSheet.h" +#include "UI/LayerIcons.h" +#include "Rendering/EyedropperIcon.h" +#include <QApplication> +#include <QComboBox> +#include <QContextMenuEvent> +#include <QHBoxLayout> +#include <QKeyEvent> +#include <QLabel> +#include <QMenu> +#include <QPainter> +#include <QPainterPath> +#include <QScrollArea> +#include <QToolButton> +#include <QVBoxLayout> + +namespace { +QString title(int section) +{ + static const std::array<QString, 10> titles{QStringLiteral("Light"), QStringLiteral("Color"), QStringLiteral("Effects"), + QStringLiteral("Curve"), QStringLiteral("Color Mixer"), QStringLiteral("Color Grading"), + QStringLiteral("Detail"), QStringLiteral("Optics"), QStringLiteral("Geometry"), + QStringLiteral("Calibration")}; + return titles.at(size_t(section)); +} + +// The group's eye in CameraRawGroups, by section. +bool &shows(CameraRawGroups &groups, int section) +{ + const std::array fields{&CameraRawGroups::light, &CameraRawGroups::color, &CameraRawGroups::effects, &CameraRawGroups::curve, + &CameraRawGroups::mixer, &CameraRawGroups::grading, &CameraRawGroups::detail, &CameraRawGroups::optics, + &CameraRawGroups::geometry, &CameraRawGroups::calibration}; + return groups.*fields.at(size_t(section)); +} + +bool adjusts(const CameraRawSettings &raw, int section) +{ + switch (section) { + case 0: return raw.adjustsLight(); + case 1: return raw.adjustsColor(); + case 2: return raw.adjustsEffects(); + case 3: return raw.curve.adjusts(); + case 4: return raw.mixer.adjusts(); + case 5: return raw.grading.adjusts(); + case 6: return raw.detail.adjusts(); + case 7: return raw.optics.adjusts(); + case 8: return raw.geometry.adjusts(); + case 9: return raw.calibration.adjusts(); + } + 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: + ScopeGraph(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session) { setFixedHeight(110); } + +protected: + 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 std::optional<FilterEdit> &edit = m_session.filterEdit(); + if (!edit || !edit->rawPanel.scope) + return; + const CameraRawScope &scope = *edit->rawPanel.scope; + const QSizeF size = QSizeF(this->size()); + if (edit->rawPanel.scopeMode == CameraRawScopeMode::histogram) { + const double peak = scope.peak(); + if (!(peak > 0)) + return; + // SwiftUI's red, green and blue, as the Levels histogram. + ribbon(painter, scope.red, QColor(255, 66, 69), peak, size); + ribbon(painter, scope.green, QColor(48, 209, 88), peak, size); + ribbon(painter, scope.blue, QColor(0, 145, 255), peak, size); + return; + } + const double peak = *std::max_element(scope.vectorscope.begin(), scope.vectorscope.end()); + if (!(peak > 0)) + return; + const double cell = size.width() / CameraRawScope::scopeSide; + for (size_t index = 0; index < scope.vectorscope.size(); ++index) { + if (!(scope.vectorscope[index] > 0)) + continue; + const int column = int(index) % CameraRawScope::scopeSide, row = int(index) / CameraRawScope::scopeSide; + const double amount = std::min(1.0, scope.vectorscope[index] / peak); + painter.fillRect(QRectF(column * cell, size.height() - (row + 1) * cell, cell + 0.2, cell + 0.2), + QColor::fromRgbF(1, 1, 1, float(0.15 + 0.85 * amount))); + } + } + + void contextMenuEvent(QContextMenuEvent *event) override + { + QMenu menu(this); + menu.addAction(QStringLiteral("Histogram"), this, [this] { mode(CameraRawScopeMode::histogram); }); + menu.addAction(QStringLiteral("Vectorscope"), this, [this] { mode(CameraRawScopeMode::vectorscope); }); + menu.exec(event->globalPos()); + } + +private: + static void ribbon(QPainter &painter, const std::array<double, 256> &bins, QColor colour, double peak, QSizeF size) + { + QPainterPath path(QPointF(0, size.height())); + for (size_t index = 0; index < bins.size(); ++index) { + const double height = size.height() * std::min(1.0, std::max(0.0, bins[index] / peak)); + path.lineTo(double(index) * size.width() / double(bins.size()), size.height() - height); + } + path.lineTo(size.width(), size.height()); + path.closeSubpath(); + colour.setAlphaF(0.55f); + painter.fillPath(path, colour); + } + + void mode(CameraRawScopeMode mode) + { + if (!m_session.filterEdit()) + return; + CameraRawPanel panel = m_session.filterEdit()->rawPanel; + panel.scopeMode = mode; + m_session.setCameraRawPanel(panel); + } + + EditorSession &m_session; +}; + +// Swift's clip button: a small triangle, lit when on. +class ClipButton : public QToolButton { +public: + ClipButton(bool shadows, QWidget *parent) : QToolButton(parent), m_shadows(shadows) + { + setFixedSize(14, 14); + setAutoRaise(true); + setObjectName(shadows ? QStringLiteral("shadowClipping") : QStringLiteral("highlightClipping")); + setToolTip(shadows ? QStringLiteral("Show clipped shadows in blue on the preview.") : QStringLiteral("Show clipped highlights in red on the preview.")); + setAccessibleName(shadows ? QStringLiteral("Shadow Clipping Indicator") : QStringLiteral("Highlight Clipping Indicator")); + } + bool on = false; + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(on ? (m_shadows ? QColor(0, 145, 255) : QColor(255, 66, 69)) : QColor::fromRgbF(1, 1, 1, 0.55f)); + painter.drawPolygon(QPolygonF({QPointF(7, 3), QPointF(12, 11), QPointF(2, 11)})); + } + +private: + const bool m_shadows; +}; +} + +CameraRawControls::CameraRawControls(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_scope(new ScopeGraph(session, this)), m_readout(new QLabel(this)) +{ + m_scope->setObjectName(QStringLiteral("cameraRawScope")); + m_readout->setObjectName(QStringLiteral("cameraRawReadout")); + m_readout->setToolTip(QStringLiteral("Red, green, and blue of the pixel under the pointer.")); + m_readout->setForegroundRole(QPalette::PlaceholderText); + QFont caption = m_readout->font(); + caption.setPixelSize(10); + m_readout->setFont(caption); + auto *clips = new QHBoxLayout(m_scope); + clips->setContentsMargins(4, 4, 4, 4); + for (const bool shadows : {true, false}) { + auto *button = new ClipButton(shadows, m_scope); + connect(button, &QToolButton::clicked, this, [this, shadows] { + panel([shadows](CameraRawPanel &raw) { + bool &shown = shadows ? raw.showsShadowClipping : raw.showsHighlightClipping; + shown = !shown; + }); + m_session.updateFilter(m_session.filterEdit().value().settings, m_session.filterEdit()->preview); + }); + clips->addWidget(button); + if (shadows) + clips->addStretch(1); + } + auto *scopeColumn = new QVBoxLayout; + scopeColumn->setSpacing(4); + scopeColumn->addWidget(m_scope); + scopeColumn->addWidget(m_readout); + auto *content = new QWidget; + auto *groups = new QVBoxLayout(content); + groups->setContentsMargins(0, 0, 0, 0); + groups->setSpacing(12); + 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); + groups->addStretch(1); + auto *scroll = new QScrollArea(this); + scroll->setObjectName(QStringLiteral("cameraRawGroups")); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setWidget(content); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(0, 0, 0, 0); + column->setSpacing(10); + column->addLayout(scopeColumn); + column->addWidget(scroll, 1); + qApp->installEventFilter(this); + connect(&m_session, &EditorSession::changed, this, &CameraRawControls::synchronize); + synchronize(); +} + +QVBoxLayout *CameraRawControls::group(Section section, QVBoxLayout *column) +{ + const QString name = title(int(section)); + auto *box = new QWidget; + auto *header = new QHBoxLayout; + header->setSpacing(6); + auto *disclosure = new QToolButton(box); + disclosure->setObjectName(QString(name).remove(QLatin1Char(' ')) + QStringLiteral("Section")); + disclosure->setText(name); + disclosure->setAccessibleName(name); + disclosure->setAutoRaise(true); + disclosure->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + QFont headline = disclosure->font(); + headline.setPixelSize(13); + headline.setWeight(QFont::DemiBold); + disclosure->setFont(headline); + auto *eye = new QToolButton(box); + eye->setObjectName(QString(name).remove(QLatin1Char(' ')) + QStringLiteral("Eye")); + eye->setAutoRaise(true); + connect(eye, &QToolButton::clicked, this, [this, section] { toggleShown(section); }); + header->addWidget(disclosure); + header->addStretch(1); + header->addWidget(eye); + auto *body = new QWidget(box); + auto *rows = new QVBoxLayout(body); + rows->setContentsMargins(18, 0, 0, 0); + rows->setSpacing(8); + // Light and Color open first, Swift's `expanded`. + body->setVisible(section == Section::light || section == Section::color); + connect(disclosure, &QToolButton::clicked, this, [this, body] { + body->setVisible(body->isHidden()); + synchronize(); + }); + auto *stack = new QVBoxLayout(box); + stack->setContentsMargins(0, 0, 0, 0); + stack->setSpacing(8); + stack->addLayout(header); + stack->addWidget(body); + column->addWidget(box); + m_groups.push_back(Group{section, disclosure, eye, body}); + return rows; +} + +void CameraRawControls::slider(QVBoxLayout *column, const QString &name, const QString &title, std::function<double &(CameraRawSettings &)> key, + double low, double high, int decimals, std::optional<CameraRawClipping> 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}); +} + +QLabel *CameraRawControls::subheadline(const QString &title, QVBoxLayout *column) +{ + auto *label = new QLabel(title); + QFont font = label->font(); + font.setPixelSize(11); + label->setFont(font); + column->addWidget(label); + return label; +} + +// Swift's assign: Alt on a Light slider shows its clipping. +void CameraRawControls::assign(const std::function<double &(CameraRawSettings &)> &key, double value, std::optional<CameraRawClipping> clipping) +{ + if (!m_session.filterEdit()) + return; + const bool showClipping = clipping && QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier); + panel([&](CameraRawPanel &raw) { raw.clipping = showClipping ? clipping : std::nullopt; }); + update([&](CameraRawSettings &settings) { + double &field = key(settings); + field = value; + if (&field == &settings.temperature || &field == &settings.tint) + settings.whiteBalance = CameraRawWhiteBalance::custom; + }); +} + +void CameraRawControls::update(const std::function<void(CameraRawSettings &)> &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 CameraRawControls::panel(const std::function<void(CameraRawPanel &)> &change) +{ + if (!m_session.filterEdit()) + return; + CameraRawPanel raw = m_session.filterEdit()->rawPanel; + change(raw); + m_session.setCameraRawPanel(raw); +} + +void CameraRawControls::toggleShown(Section section) +{ + if (!m_session.filterEdit()) + return; + panel([section](CameraRawPanel &raw) { + bool &shown = shows(raw.shows, int(section)); + shown = !shown; + }); + m_session.updateFilter(m_session.filterEdit()->settings, m_session.filterEdit()->preview); +} + +CameraRawSettings CameraRawControls::raw() const +{ + return m_session.filterEdit() ? m_session.filterEdit()->settings.cameraRaw : CameraRawSettings(); +} + +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<QKeyEvent *>(event)->key() == Qt::Key_Alt && !static_cast<QKeyEvent *>(event)->isAutoRepeat() + && m_session.filterEdit()) { + const CameraRawPanel &raw = m_session.filterEdit()->rawPanel; + if (raw.clipping || raw.sharpenMask) { + panel([](CameraRawPanel &shown) { + shown.clipping = std::nullopt; + shown.sharpenMask = false; + }); + m_session.updateFilter(m_session.filterEdit()->settings, m_session.filterEdit()->preview); + } + } + return QWidget::eventFilter(watched, event); +} + +void CameraRawControls::synchronize() +{ + const CameraRawSettings settings = raw(); + const std::optional<FilterEdit> &edit = m_session.filterEdit(); + const qreal ratio = devicePixelRatioF(); + const QColor ink = palette().color(QPalette::WindowText), secondary = palette().color(QPalette::PlaceholderText); + for (const Group &group : m_groups) { + group.disclosure->setIcon(QIcon(LayerIcons::pixmap(!group.body->isHidden() ? LayerIcon::chevronDown : LayerIcon::chevronRight, 12, ink, ratio))); + const bool adjusted = adjusts(settings, int(group.section)); + group.eye->setVisible(adjusted); + CameraRawGroups groups = edit ? edit->rawPanel.shows : CameraRawGroups(); + const bool shown = shows(groups, int(group.section)); + const QString name = title(int(group.section)); + group.eye->setIcon(QIcon(LayerIcons::pixmap(shown ? LayerIcon::eye : LayerIcon::eyeSlash, 14, ink, ratio))); + 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())); + } + const std::optional<std::array<int, 3>> 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 —")); + const bool histogram = !edit || edit->rawPanel.scopeMode == CameraRawScopeMode::histogram; + m_scope->setToolTip(histogram ? QStringLiteral("Tones from black on the left to white on the right: blacks, shadows, midtones, highlights, whites. " + "Right-click to show the vectorscope.") + : QStringLiteral("Hue around the wheel, saturation outward from the center. Right-click to show the histogram.")); + m_scope->setAccessibleName(histogram ? QStringLiteral("RGB histogram") : QStringLiteral("Vectorscope")); + for (auto *clip : m_scope->findChildren<ClipButton *>()) { + const bool shadows = clip->objectName() == QLatin1String("shadowClipping"); + clip->on = edit && (shadows ? edit->rawPanel.showsShadowClipping : edit->rawPanel.showsHighlightClipping); + clip->update(); + } + m_scope->update(); + if (m_whiteBalance) { + const QSignalBlocker quiet(m_whiteBalance); + m_whiteBalance->setCurrentIndex(int(settings.whiteBalance)); + const bool sampling = edit && edit->rawPanel.samplesWhiteBalance; + m_whiteBalanceSampler->setIcon(EyedropperIcon::icon(sampling ? palette().color(QPalette::Highlight) : secondary, ratio)); + m_whiteBalanceHint->setVisible(sampling); + } + if (m_glowStyle) { + const QSignalBlocker glow(m_glowStyle), vignette(m_vignetteStyle); + m_glowStyle->setCurrentIndex(int(settings.glowStyle)); + m_vignetteStyle->setCurrentIndex(int(settings.vignetteStyle)); + } +} diff --git a/src/UI/CameraRawControls.h b/src/UI/CameraRawControls.h new file mode 100644 index 0000000..64c7f35 --- /dev/null +++ b/src/UI/CameraRawControls.h @@ -0,0 +1,63 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/CameraRawSlider.h" +#include <QWidget> + +class PickerField; +class QComboBox; +class QLabel; +class QToolButton; +class QVBoxLayout; + +// Swift's CameraRawControls: the scope, then the ten groups. +class CameraRawControls : public QWidget { + Q_OBJECT +public: + explicit CameraRawControls(EditorSession &session, QWidget *parent = nullptr); + // Shows the edit as it now stands. + void synchronize(); + static constexpr int labelWidth = 96; + +protected: + // Swift's option monitor: Alt let go ends the views. + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + enum class Section { light, color, effects, curve, colorMixer, colorGrading, detail, optics, geometry, calibration }; + struct Group { + Section section; + QToolButton *disclosure; + QToolButton *eye; + QWidget *body; + }; + struct Row { + std::function<double &(CameraRawSettings &)> 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<double &(CameraRawSettings &)> key, double low, double high, int decimals, + std::optional<CameraRawClipping> clipping, const QString &help, CameraRawSliderTrack track = {}, double reset = 0); + QLabel *subheadline(const QString &title, QVBoxLayout *column); + void light(QVBoxLayout *column); + void color(QVBoxLayout *column); + void effects(QVBoxLayout *column); + void assign(const std::function<double &(CameraRawSettings &)> &key, double value, std::optional<CameraRawClipping> clipping); + void update(const std::function<void(CameraRawSettings &)> &change); + void panel(const std::function<void(CameraRawPanel &)> &change); + void toggleShown(Section section); + CameraRawSettings raw() const; + + EditorSession &m_session; + QWidget *const m_scope; + QLabel *const m_readout; + std::vector<Group> m_groups; + std::vector<Row> m_rows; + QComboBox *m_whiteBalance = nullptr; + QToolButton *m_whiteBalanceSampler = nullptr; + QLabel *m_whiteBalanceHint = nullptr; + QComboBox *m_glowStyle = nullptr; + QComboBox *m_vignetteStyle = nullptr; +}; diff --git a/src/UI/CameraRawSlider.cpp b/src/UI/CameraRawSlider.cpp new file mode 100644 index 0000000..95a75be --- /dev/null +++ b/src/UI/CameraRawSlider.cpp @@ -0,0 +1,99 @@ +#include "UI/CameraRawSlider.h" +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <QStyleOptionSlider> +#include <cmath> + +namespace { +// Thousandths of the range, as the filter sheet's sliders. +constexpr int travel = 1000; + +QColor hsb(double degrees, double saturation, double brightness) +{ + double turns = degrees / 360; + turns -= std::floor(turns); + return QColor::fromHsvF(float(turns), float(saturation), float(brightness)); +} +} + +std::optional<std::pair<QColor, QColor>> CameraRawSliderTrack::colors() const +{ + switch (kind) { + case Kind::plain: return std::nullopt; + case Kind::temperature: return std::pair(QColor::fromRgbF(0.22f, 0.46f, 0.95f), QColor::fromRgbF(0.98f, 0.82f, 0.18f)); + case Kind::tint: return std::pair(QColor::fromRgbF(0.28f, 0.70f, 0.34f), QColor::fromRgbF(0.70f, 0.40f, 0.64f)); + case Kind::chroma: return std::pair(QColor::fromRgbF(0.62f, 0.62f, 0.64f), QColor::fromRgbF(0.86f, 0.18f, 0.20f)); + case Kind::hue: return std::pair(hsb(degrees - 50, 0.85, 0.9), hsb(degrees + 50, 0.85, 0.9)); + case Kind::saturation: return std::pair(QColor::fromRgbF(0.55f, 0.55f, 0.56f), hsb(degrees, 0.9, 0.9)); + case Kind::luminance: return std::pair(hsb(degrees, 0.55, 0.18), hsb(degrees, 0.35, 0.95)); + } + throw std::logic_error("unknown slider track"); +} + +CameraRawSlider::CameraRawSlider(double low, double high, CameraRawSliderTrack track, const QString &help, std::function<void(double)> change, + std::function<void()> reset, QWidget *parent) + : QSlider(Qt::Horizontal, parent), m_low(low), m_high(high), m_track(track), m_change(std::move(change)), m_reset(std::move(reset)) +{ + setRange(0, travel); + setMinimumWidth(60); + setFixedHeight(22); + setToolTip(help); + setAccessibleName(help); + connect(this, &QSlider::valueChanged, this, [this] { m_change(shown()); }); +} + +void CameraRawSlider::display(double value) +{ + // Swift's isTrackingValue: a drag keeps its own knob. + if (isSliderDown()) + return; + const QSignalBlocker quiet(this); + setValue(int(std::lround((value - m_low) / (m_high - m_low) * travel))); +} + +double CameraRawSlider::shown() const +{ + return m_low + (m_high - m_low) * value() / travel; +} + +bool CameraRawSlider::isOnKnob(QPoint point) const +{ + QStyleOptionSlider option; + initStyleOption(&option); + return style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderHandle, this).adjusted(-2, -2, 2, 2).contains(point); +} + +// Swift's GradientSliderCell: the whole track shows the colours. +void CameraRawSlider::paintEvent(QPaintEvent *event) +{ + const std::optional<std::pair<QColor, QColor>> colors = m_track.colors(); + if (!colors) { + QSlider::paintEvent(event); + return; + } + QStyleOptionSlider option; + initStyleOption(&option); + const QRect groove = style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderGroove, this); + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const double height = 4; + const QRectF bar(groove.left(), groove.center().y() + 0.5 - height / 2, groove.width(), height); + QLinearGradient gradient(bar.topLeft(), bar.topRight()); + gradient.setColorAt(0, colors->first); + gradient.setColorAt(1, colors->second); + QPainterPath shape; + shape.addRoundedRect(bar, height / 2, height / 2); + painter.fillPath(shape, gradient); + option.subControls = QStyle::SC_SliderHandle; + style()->drawComplexControl(QStyle::CC_Slider, &option, &painter, this); +} + +void CameraRawSlider::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton && isOnKnob(event->position().toPoint())) { + m_reset(); + return; + } + QSlider::mouseDoubleClickEvent(event); +} diff --git a/src/UI/CameraRawSlider.h b/src/UI/CameraRawSlider.h new file mode 100644 index 0000000..be9e8cd --- /dev/null +++ b/src/UI/CameraRawSlider.h @@ -0,0 +1,39 @@ +#pragma once +#include <QColor> +#include <QSlider> +#include <functional> +#include <optional> +#include <utility> + +// Swift's CameraRawSliderTrack: a coloured track, or the system's. +struct CameraRawSliderTrack { + enum class Kind { plain, temperature, tint, chroma, hue, saturation, luminance }; + Kind kind = Kind::plain; + // A family's centre, for hue, saturation and luminance. + double degrees = 0; + // Left and right colours; none keeps the system track. + std::optional<std::pair<QColor, QColor>> colors() const; +}; + +// Swift's CameraRawSlider: a double click on the knob resets. +class CameraRawSlider : public QSlider { + Q_OBJECT +public: + CameraRawSlider(double low, double high, CameraRawSliderTrack track, const QString &help, std::function<void(double)> change, + std::function<void()> reset, QWidget *parent = nullptr); + // Shows a value, unless a drag holds the knob. + void display(double value); + double shown() const; + bool isOnKnob(QPoint point) const; + +protected: + void paintEvent(QPaintEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + +private: + const double m_low; + const double m_high; + const CameraRawSliderTrack m_track; + const std::function<void(double)> m_change; + const std::function<void()> m_reset; +}; diff --git a/src/UI/FilterSheet.cpp b/src/UI/FilterSheet.cpp index 42424bd..65b4736 100644 --- a/src/UI/FilterSheet.cpp +++ b/src/UI/FilterSheet.cpp @@ -2,7 +2,9 @@ #include "UI/KeyboardShortcuts.h" #include "UI/ColorPaletteControls.h" #include "UI/ColorPickerSheet.h" +#include "UI/CameraRawControls.h" #include "UI/CurvesControls.h" +#include "UI/FloatingPanel.h" #include <QButtonGroup> #include <QCheckBox> #include <QFrame> @@ -147,7 +149,8 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent) m_spinner(new QProgressBar(this)), m_activity(words(QString(), true, this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_ok(new QPushButton(QStringLiteral("OK"), this)) { - setFixedWidth(380); + const bool cameraRaw = m_session.filterEdit() && m_session.filterEdit()->kind == FilterKind::cameraRaw; + setFixedWidth(cameraRaw ? FloatingPanel::dockedWidth : 380); m_column->setContentsMargins(24, 24, 24, 24); m_column->setSpacing(16); const auto key = [](double FilterSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.*field; }; }; @@ -182,8 +185,7 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent) break; case FilterKind::removeBackground: background(); break; case FilterKind::blackWhite: blackWhite(); break; - // CameraRawControls arrive with the panel (12.8b). - case FilterKind::cameraRaw: break; + case FilterKind::cameraRaw: m_column->addWidget(new CameraRawControls(m_session, this), 1); break; case FilterKind::colorBalance: colorBalance(); break; case FilterKind::contentAwareFill: m_column->addWidget(words(QStringLiteral("Fill the selection using surrounding pixels from this layer."), false, this)); diff --git a/src/UI/FloatingPanel.cpp b/src/UI/FloatingPanel.cpp index 41f2658..6c0d9a4 100644 --- a/src/UI/FloatingPanel.cpp +++ b/src/UI/FloatingPanel.cpp @@ -4,6 +4,9 @@ #include <QCloseEvent> #include <QDialog> #include <QHash> +#include <QLabel> +#include <QStyle> +#include <QToolButton> #include <QVBoxLayout> namespace { @@ -39,7 +42,8 @@ protected: void moveEvent(QMoveEvent *event) override { QDialog::moveEvent(event); - if (isVisible()) + // A docked frame is the window's: never remembered. + if (isVisible() && isWindow()) m_owner.remember(); } @@ -52,22 +56,22 @@ FloatingPanel::FloatingPanel(const QString &name, QWidget &owner) : m_name(name) FloatingPanel::~FloatingPanel() { delete m_panel; + delete m_docked; } -void FloatingPanel::show(const QString &title, QWidget *content) +void FloatingPanel::show(const QString &title, QWidget *content, QWidget *dock) { + if (dock) { + showDocked(title, content, *dock); + return; + } if (!m_panel) { m_panel = new PanelWindow(*this, m_owner.window()); m_panel->setObjectName(m_name); } const bool wasVisible = m_panel->isVisible(); m_panel->setWindowTitle(title); - // Later: a signal from the old content may still run. - if (m_content) { - m_content->hide(); - m_content->deleteLater(); - } - m_content = content; + place(content); m_panel->layout()->addWidget(content); // Shown now: layouts show late children from the event loop. content->show(); @@ -86,16 +90,61 @@ void FloatingPanel::show(const QString &title, QWidget *content) remember(); } +// Wayland lets no client place a window: a slot instead. +void FloatingPanel::showDocked(const QString &title, QWidget *content, QWidget &dock) +{ + if (!m_docked) { + m_docked = new PanelWindow(*this, &dock); + m_docked->setWindowFlags(Qt::Widget); + m_docked->setObjectName(m_name); + m_docked->layout()->setSizeConstraint(QLayout::SetDefaultConstraint); + auto *header = new QWidget(m_docked); + auto *row = new QHBoxLayout(header); + row->setContentsMargins(12, 8, 8, 0); + auto *heading = new QLabel(header); + heading->setObjectName(QStringLiteral("dockedTitle")); + auto *closer = new QToolButton(header); + closer->setObjectName(QStringLiteral("dockedClose")); + closer->setIcon(closer->style()->standardIcon(QStyle::SP_TitleBarCloseButton)); + closer->setAccessibleName(QStringLiteral("Close")); + closer->setAutoRaise(true); + QObject::connect(closer, &QToolButton::clicked, m_docked, &QWidget::close); + row->addWidget(heading, 1); + row->addWidget(closer); + m_docked->layout()->addWidget(header); + dock.layout()->addWidget(m_docked); + } + m_docked->findChild<QLabel *>(QStringLiteral("dockedTitle"))->setText(title); + place(content); + m_docked->layout()->addWidget(content); + content->show(); + m_docked->show(); + dock.show(); + m_docked->setFocus(); +} + +// Later: a signal from the old content may still run. +void FloatingPanel::place(QWidget *content) +{ + if (m_content) { + m_content->hide(); + m_content->deleteLater(); + } + m_content = content; +} + // Moves were remembered as they came; hide sends no close. void FloatingPanel::close() { if (m_panel) m_panel->hide(); + if (m_docked) + m_docked->parentWidget()->hide(); } bool FloatingPanel::isVisible() const { - return m_panel && m_panel->isVisible(); + return (m_panel && m_panel->isVisible()) || (m_docked && m_docked->isVisible()); } void FloatingPanel::refocus(const QString &name) @@ -103,6 +152,11 @@ void FloatingPanel::refocus(const QString &name) for (QWidget *widget : QApplication::topLevelWidgets()) { if (widget->objectName() == name && widget->isVisible()) widget->activateWindow(); + // A docked panel takes the keys inside its window. + for (QDialog *docked : widget->findChildren<QDialog *>(name)) { + if (docked->isVisible() && !docked->isWindow()) + docked->setFocus(); + } } } diff --git a/src/UI/FloatingPanel.h b/src/UI/FloatingPanel.h index 25db039..609884d 100644 --- a/src/UI/FloatingPanel.h +++ b/src/UI/FloatingPanel.h @@ -13,13 +13,15 @@ public: ~FloatingPanel(); FloatingPanel(const FloatingPanel &) = delete; FloatingPanel &operator=(const FloatingPanel &) = delete; - // New content and title; a shown panel keeps its place. - void show(const QString &title, QWidget *content); + // New content and title; a dock slot takes it docked. + void show(const QString &title, QWidget *content, QWidget *dock = nullptr); // Hides the panel without reporting a close. void close(); bool isVisible() const; // Its close button and Escape report here, as Cancel. std::function<void()> onClose; + // Swift's docked width: Camera Raw's grading wheels fit it. + static constexpr int dockedWidth = 440; // Hands the keys back to a shown panel, after clicks. static void refocus(const QString &name); @@ -27,9 +29,12 @@ private: friend class PanelWindow; void remember(); void closed(); + void place(QWidget *content); + void showDocked(const QString &title, QWidget *content, QWidget &dock); const QString m_name; QWidget &m_owner; QPointer<QDialog> m_panel; + QPointer<QWidget> m_docked; QPointer<QWidget> m_content; }; diff --git a/src/UI/LevelsSheet.cpp b/src/UI/LevelsSheet.cpp index 051d176..6134a9e 100644 --- a/src/UI/LevelsSheet.cpp +++ b/src/UI/LevelsSheet.cpp @@ -213,20 +213,6 @@ void dragHandle(LevelRange &range, bool output, size_t index, double tone) range.gamma = std::log(std::min(0.999, std::max(0.001, (x - range.black) / (range.white - range.black)))) / std::log(0.5); } -// The eyedropper glyph in one ink, 14 points. -QIcon eyedropper(const QColor &ink, double ratio) -{ - QPixmap pixmap(QSize(14, 14) * ratio); - pixmap.setDevicePixelRatio(ratio); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing); - painter.scale(14.0 / 18, 14.0 / 18); - painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); - EyedropperIcon::paint(painter); - return QIcon(pixmap); -} - // Swift's HStack: spread by Spacers, or packed to the left. QHBoxLayout *row(std::initializer_list<QWidget *> widgets, bool spread) { @@ -411,7 +397,7 @@ void LevelsSheet::synchronize() for (size_t index = 0; index < 3; ++index) { const bool chosen = edit->sampleMode == allLevelsSamples[index]; m_samples[index]->setChecked(chosen); - m_samples[index]->setIcon(eyedropper(palette().color(chosen ? QPalette::Highlight : QPalette::PlaceholderText), devicePixelRatioF())); + m_samples[index]->setIcon(EyedropperIcon::icon(palette().color(chosen ? QPalette::Highlight : QPalette::PlaceholderText), devicePixelRatioF())); m_autos[index]->setEnabled(edit->histogramReady); } m_sampleHint->setVisible(edit->sampleMode.has_value()); diff --git a/tests/CameraRawCanvasTests.cpp b/tests/CameraRawCanvasTests.cpp new file mode 100644 index 0000000..d7f317f --- /dev/null +++ b/tests/CameraRawCanvasTests.cpp @@ -0,0 +1,224 @@ +#include "MenuFixtures.h" +#include "SelectionCanvasFixtures.h" +#include "UI/CameraRawControls.h" +#include "UI/FilterSheet.h" +#include <QLabel> +#include <QToolButton> + +// Camera Raw's tools on the canvas, and its docked panel. +namespace { +struct Raw : Canvas { + Raw() + { + QImage image(400, 300, QImage::Format_RGBA8888_Premultiplied); + image.fill(QColor(200, 120, 60)); + for (int y = 0; y < 300; ++y) + for (int x = 200; x < 400; ++x) + image.setPixelColor(x, y, QColor(60, 180, 90)); + session.insert(ImportedImage(image, image, QStringLiteral("Two"))); + session.beginFilter(FilterKind::cameraRaw); + canvas->synchronizeDisplay(); + } + const CameraRawPanel &panel() const { return session.filterEdit().value().rawPanel; } + const CameraRawSettings &raw() const { return session.filterEdit().value().settings.cameraRaw; } + void arm(const std::function<void(CameraRawPanel &)> &change) + { + CameraRawPanel copy = panel(); + change(copy); + session.setCameraRawPanel(copy); + canvas->synchronizeDisplay(); + } +}; + +QWidget *topLevel(const QString &name) +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == name && widget->isVisible()) + return widget; + } + return nullptr; +} +} + +class CameraRawCanvasTests : public QObject { + Q_OBJECT +private slots: + void aHoverReadsThePixelAndLeavingClearsIt(); + void eachEyedropperSamplesAndThePressGoesNoFurther(); + void aGuideIsDrawnByDragging(); + void aTargetedDragEndsOnReleaseOrWhenTheReleaseIsLost(); + void samplingToolsShowTheEyedropperAndSpacePans(); + void thePanelDocksInTheWindowAndCancelsLikeAPanel(); +}; + +void CameraRawCanvasTests::aHoverReadsThePixelAndLeavingClearsIt() +{ + Raw raw; + raw.hover(QPointF(50, 50)); + QCOMPARE(raw.panel().readout, std::optional(std::array{200, 120, 60})); + raw.hover(QPointF(300, 50)); + QCOMPARE(raw.panel().readout, std::optional(std::array{60, 180, 90})); + // A drag reads nothing: Swift's mouseMoved alone reads. + raw.move(QPointF(50, 50)); + QCOMPARE(raw.panel().readout, std::optional(std::array{60, 180, 90})); + QEvent leave(QEvent::Leave); + QApplication::sendEvent(raw.canvas, &leave); + QVERIFY(!raw.panel().readout); + // Another filter reads nothing. + raw.session.cancelFilter(); + raw.session.beginFilter(FilterKind::gaussianBlur); + raw.hover(QPointF(50, 50)); + QVERIFY(!raw.session.filterEdit().value().rawPanel.readout); +} + +void CameraRawCanvasTests::eachEyedropperSamplesAndThePressGoesNoFurther() +{ + Raw raw; + raw.arm([](CameraRawPanel &panel) { panel.samplesWhiteBalance = true; }); + raw.click(QPointF(50, 50)); + QVERIFY(raw.raw().temperature != 0 && !raw.session.lassoDraft() && !raw.session.selection()); + raw.arm([](CameraRawPanel &panel) { + panel.samplesWhiteBalance = false; + panel.samplesPointColor = true; + }); + raw.click(QPointF(300, 50)); + QCOMPARE(raw.raw().mixer.points.size(), size_t(1)); + QVERIFY(std::abs(raw.raw().mixer.points[0].hue - 135) < 1); + raw.arm([](CameraRawPanel &panel) { + panel.samplesPointColor = false; + panel.samplesDefringe = true; + }); + raw.click(QPointF(300, 50)); + QCOMPARE(raw.raw().optics.greenAmount, 50.0); + QVERIFY(!raw.session.selection()); +} + +void CameraRawCanvasTests::aGuideIsDrawnByDragging() +{ + Raw raw; + raw.arm([](CameraRawPanel &panel) { panel.drawingGeometryGuide = true; }); + raw.press(QPointF(40, 30)); + QVERIFY(raw.panel().guideDraft); + raw.move(QPointF(360, 60)); + raw.release(QPointF(360, 60)); + QVERIFY(!raw.panel().guideDraft); + QCOMPARE(raw.raw().geometry.guides.size(), size_t(1)); + const CameraRawGeometryGuide guide = raw.raw().geometry.guides[0]; + QVERIFY(std::abs(guide.startX - 0.1) < 1e-6 && std::abs(guide.startY - 0.1) < 1e-6); + QVERIFY(std::abs(guide.endX - 0.9) < 1e-6 && std::abs(guide.endY - 0.2) < 1e-6); + QVERIFY(raw.raw().geometry.upright == CameraRawUprightMode::guided); + // A lost release: the next move without the button commits. + raw.press(QPointF(100, 30)); + raw.move(QPointF(100, 270)); + raw.hover(QPointF(120, 270)); + QVERIFY(!raw.panel().guideDraft); + QCOMPARE(raw.raw().geometry.guides.size(), size_t(2)); +} + +void CameraRawCanvasTests::aTargetedDragEndsOnReleaseOrWhenTheReleaseIsLost() +{ + Raw raw; + raw.arm([](CameraRawPanel &panel) { panel.targetsCurve = true; }); + raw.press(QPointF(50, 150)); + QVERIFY(raw.panel().drag); + raw.move(QPointF(50, 50)); + QCOMPARE(raw.raw().curve.lights, 35.0); + raw.release(QPointF(50, 50)); + QVERIFY(!raw.panel().drag); + // A move after the release changes nothing. + raw.move(QPointF(50, 0)); + QCOMPARE(raw.raw().curve.lights, 35.0); + raw.press(QPointF(50, 150)); + raw.hover(QPointF(50, 100)); + QVERIFY(!raw.panel().drag); + QCOMPARE(raw.raw().curve.lights, 35.0); +} + +void CameraRawCanvasTests::samplingToolsShowTheEyedropperAndSpacePans() +{ + Raw raw; + const QCursor eyedropper = CanvasView::eyedropperCursor(raw.canvas->devicePixelRatio()); + raw.hover(QPointF(50, 50)); + QVERIFY(!raw.shows(eyedropper)); + for (const auto &change : std::vector<std::function<void(CameraRawPanel &)>>{ + [](CameraRawPanel &panel) { panel.samplesWhiteBalance = true; }, [](CameraRawPanel &panel) { panel.samplesPointColor = true; }, + [](CameraRawPanel &panel) { panel.samplesDefringe = true; }, [](CameraRawPanel &panel) { panel.drawingGeometryGuide = true; }}) { + raw.arm([](CameraRawPanel &panel) { panel = CameraRawPanel(); }); + raw.hover(QPointF(50, 50)); + QVERIFY(!raw.shows(eyedropper)); + raw.arm(change); + raw.hover(QPointF(52, 50)); + QVERIFY(raw.shows(eyedropper)); + } + // With Space held a press pans and samples nothing. + raw.arm([](CameraRawPanel &panel) { + panel = CameraRawPanel(); + panel.samplesWhiteBalance = true; + }); + raw.canvas->setFocus(); + QTest::keyPress(raw.canvas, Qt::Key_Space); + raw.press(QPointF(50, 50)); + raw.release(QPointF(50, 50)); + QTest::keyRelease(raw.canvas, Qt::Key_Space); + QCOMPARE(raw.raw().temperature, 0.0); +} + +void CameraRawCanvasTests::thePanelDocksInTheWindowAndCancelsLikeAPanel() +{ + Bar bar; + bar.window.resize(1200, 800); + bar.window.show(); + QVERIFY(QTest::qWaitForWindowActive(&bar.window)); + bar.session().createDocument(8, 8, true); + QImage image(8, 8, QImage::Format_RGBA8888_Premultiplied); + image.fill(QColor(200, 120, 60)); + bar.session().insert(ImportedImage(image, image, QStringLiteral("Warm"))); + // A floating filter panel, moved, then closed. + bar.session().beginFilter(FilterKind::gaussianBlur); + QWidget *floating = topLevel(QStringLiteral("filterPanel")); + QVERIFY(floating); + floating->move(40, 60); + const QPoint parked = floating->pos(); + bar.session().cancelFilter(); + // Camera Raw fills the window's right slot instead. + bar.session().beginFilter(FilterKind::cameraRaw); + auto *dock = bar.window.findChild<QWidget *>(QStringLiteral("panelDock")); + QVERIFY(dock && dock->isVisible() && !topLevel(QStringLiteral("filterPanel"))); + QCOMPARE(dock->width(), 440); + auto *docked = dock->findChild<QWidget *>(QStringLiteral("filterPanel")); + QVERIFY(docked && !docked->isWindow() && docked->findChild<CameraRawControls *>()); + QCOMPARE(docked->findChild<QLabel *>(QStringLiteral("dockedTitle"))->text(), QString("Camera Raw Filter")); + QTRY_VERIFY(docked->height() > 600); + // Shown, it takes the keys, Swift's makeKeyAndOrderFront. + QVERIFY(docked->hasFocus()); + // At the right edge; a taller window, a taller panel. + QCOMPARE(dock->mapTo(&bar.window, dock->rect().topRight()).x(), bar.window.centralWidget()->mapTo(&bar.window, bar.window.centralWidget()->rect().topRight()).x()); + const int height = docked->height(); + bar.window.resize(1200, 900); + QTRY_COMPARE(docked->height(), height + 100); + // A sample hands the keys back to the docked panel. + auto *canvas = bar.window.findChild<CanvasView *>(); + CameraRawPanel panel = bar.session().filterEdit().value().rawPanel; + panel.samplesWhiteBalance = true; + bar.session().setCameraRawPanel(panel); + canvas->setFocus(); + const QPointF at = bar.session().viewport.viewPoint(QPointF(4, 4), QSizeF(8, 8)); + QTest::mouseClick(canvas, Qt::LeftButton, {}, at.toPoint()); + QVERIFY(docked->isAncestorOf(QApplication::focusWidget()) || QApplication::focusWidget() == docked); + // Its close button cancels; Escape inside it too. + docked->findChild<QToolButton *>(QStringLiteral("dockedClose"))->click(); + QVERIFY(!bar.session().filterEdit() && !dock->isVisible()); + bar.session().beginFilter(FilterKind::cameraRaw); + QVERIFY(dock->isVisible()); + docked->setFocus(); + QTest::keyClick(docked, Qt::Key_Escape); + QVERIFY(!bar.session().filterEdit() && !dock->isVisible()); + // The floating filters reopen where they were left. + bar.session().beginFilter(FilterKind::gaussianBlur); + QCOMPARE(topLevel(QStringLiteral("filterPanel"))->pos(), parked); + QVERIFY(!dock->isVisible()); + bar.session().cancelFilter(); +} + +QTEST_MAIN(CameraRawCanvasTests) +#include "CameraRawCanvasTests.moc" diff --git a/tests/CameraRawPanelTests.cpp b/tests/CameraRawPanelTests.cpp new file mode 100644 index 0000000..fa01f7c --- /dev/null +++ b/tests/CameraRawPanelTests.cpp @@ -0,0 +1,409 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "UI/CameraRawControls.h" +#include "UI/ColorPickerSheet.h" +#include "UI/LayerIcons.h" +#include <QComboBox> +#include <QLabel> +#include <QMenu> +#include <QScrollArea> +#include <QToolButton> +#include <QtTest> + +// The panel's scope, groups, Light, Color and Effects. +namespace { +struct Panel { + EditorSession session; + std::unique_ptr<CameraRawControls> controls; + Panel() + { + 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<CameraRawControls>(session); + controls->resize(392, 700); + controls->show(); + } + template <typename Widget> Widget &child(const QString &name) const + { + auto *found = controls->findChild<Widget *>(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 arm(const std::function<void(CameraRawPanel &)> &change) + { + CameraRawPanel copy = panel(); + change(copy); + session.setCameraRawPanel(copy); + } +}; + +struct Field { + const char *name; + double CameraRawSettings::*member; + double low; + double high; + double reset; + const char *title; + std::optional<CameraRawClipping> clipping; + const char *help; +}; + +constexpr auto H = CameraRawClipping::highlights, S = CameraRawClipping::shadows; +const std::optional<CameraRawClipping> none; + +// Swift's rows: range, reset, title, clipping view and help. +const std::vector<Field> fields{ + {"exposure", &CameraRawSettings::exposure, -5, 5, 0, "Exposure", H, + "Brightens or darkens the whole picture, in stops of light. Hold Alt to see clipped highlights."}, + {"contrast", &CameraRawSettings::contrast, -100, 100, 0, "Contrast", none, + "Makes light and dark tones more or less different, mostly around the middle."}, + {"highlights", &CameraRawSettings::highlights, -100, 100, 0, "Highlights", H, + "Adjusts the bright parts of the picture. Hold Alt to see clipped highlights."}, + {"shadows", &CameraRawSettings::shadows, -100, 100, 0, "Shadows", S, "Adjusts the dark parts of the picture. Hold Alt to see clipped shadows."}, + {"whites", &CameraRawSettings::whites, -100, 100, 0, "Whites", H, "Sets the brightest point. Hold Alt to see clipped highlights."}, + {"blacks", &CameraRawSettings::blacks, -100, 100, 0, "Blacks", S, "Sets the darkest point. Hold Alt to see clipped shadows."}, + {"temperature", &CameraRawSettings::temperature, -100, 100, 0, "Temperature", none, "Shifts the picture from blue to yellow."}, + {"tint", &CameraRawSettings::tint, -100, 100, 0, "Tint", none, "Shifts the picture from green to mauve."}, + {"vibrance", &CameraRawSettings::vibrance, -100, 100, 0, "Vibrance", none, + "Strengthens quiet colors more than colors that are already strong, and protects skin tones."}, + {"saturation", &CameraRawSettings::saturation, -100, 100, 0, "Saturation", none, "Strengthens or weakens every color by the same amount."}, + {"texture", &CameraRawSettings::texture, -100, 100, 0, "Texture", none, "Adds or softens small detail."}, + {"clarity", &CameraRawSettings::clarity, -100, 100, 0, "Clarity", none, "Adds or softens contrast along broader shapes."}, + {"dehaze", &CameraRawSettings::dehaze, -100, 100, 0, "Dehaze", none, "Clears haze when raised, and adds haze when lowered."}, + {"glow", &CameraRawSettings::glow, 0, 100, 0, "Glow", none, "Spreads a glow from the bright areas."}, + {"glowRange", &CameraRawSettings::glowRange, -100, 100, 0, "Range", none, + "Chooses how bright an area must be to glow. Has no effect until Glow is raised."}, + {"glowSpread", &CameraRawSettings::glowSpread, -100, 100, 0, "Spread", none, + "Sets how far the glow reaches. Has no effect until Glow is raised."}, + {"glowWarmth", &CameraRawSettings::glowWarmth, -100, 100, 0, "Warmth", none, + "Shifts the glow from cool to warm. Halation stays red. Has no effect until Glow is raised."}, + {"vignetteAmount", &CameraRawSettings::vignetteAmount, -100, 100, 0, "Amount", none, + "Darkens or lightens the edges. The center does not change."}, + {"vignetteMidpoint", &CameraRawSettings::vignetteMidpoint, 0, 100, 50, "Midpoint", none, + "Sets where the vignette begins, from the center outward."}, + {"vignetteRoundness", &CameraRawSettings::vignetteRoundness, -100, 100, 0, "Roundness", none, "Makes the vignette rounder or more square."}, + {"vignetteFeather", &CameraRawSettings::vignetteFeather, 0, 100, 50, "Feather", none, "Softens the edge of the vignette."}, + {"vignetteHighlights", &CameraRawSettings::vignetteHighlights, 0, 100, 0, "Highlights", none, + "Protects bright pixels while a dark vignette is applied. Used by Highlight Priority."}, + {"grainAmount", &CameraRawSettings::grainAmount, 0, 100, 0, "Amount", none, "Adds film grain, strongest in the middle tones."}, + {"grainSize", &CameraRawSettings::grainSize, 0, 100, 25, "Size", none, "Makes the grain coarser or finer."}, + {"grainRoughness", &CameraRawSettings::grainRoughness, 0, 100, 50, "Roughness", none, "Makes the grain smoother or more uneven."}, +}; +} + +class CameraRawPanelTests : public QObject { + Q_OBJECT +private slots: + void everySliderWritesItsFieldAndResets(); + void aTypedNumberAppliesAndIsHeldInRange(); + void altOnALightSliderShowsItsClippingUntilLetGo(); + void eachGroupOpensAndItsEyeHidesItFromThePreview(); + void theScopeSwitchesAndItsTrianglesShowClipping(); + void theReadoutNamesThePixel(); + void whiteBalanceAutoCustomAndTheEyedropper(); + void glowAndVignetteStylesAreWritten(); + void everyRowCarriesSwiftsTitleHelpAndClipping(); + void theScopeDrawsItsRibbonsAndItsCells(); + void theGroupsDrawTheirChevronsAndEyes(); +}; + +void CameraRawPanelTests::everySliderWritesItsFieldAndResets() +{ + Panel panel; + for (const Field &field : fields) { + auto &slider = panel.child<CameraRawSlider>(QString::fromLatin1(field.name) + QStringLiteral("Slider")); + slider.setValue(750); + const double expected = field.low + (field.high - field.low) * 0.75; + QVERIFY2(panel.raw().*field.member == expected, field.name); + QCOMPARE(panel.child<PickerField>(QString::fromLatin1(field.name) + QStringLiteral("Field")).text(), + panel.controls->locale().toString(expected, 'f', field.low == -5 ? 1 : 0)); + // A double click on its title resets it. + auto *label = slider.parentWidget()->findChild<QLabel *>(); + QTest::mouseDClick(label, Qt::LeftButton); + QVERIFY2(panel.raw().*field.member == field.reset, field.name); + } + // Exposure shows two decimals, every other row whole numbers. + FilterSettings fractional = panel.session.filterEdit().value().settings; + for (const Field &field : fields) + fractional.cameraRaw.*field.member = field.low + (field.high - field.low) * 0.063; + panel.session.updateFilter(fractional, true); + for (const Field &field : fields) { + const double value = field.low + (field.high - field.low) * 0.063; + QCOMPARE(panel.child<PickerField>(QString::fromLatin1(field.name) + QStringLiteral("Field")).text(), + panel.controls->locale().toString(value, 'f', field.low == -5 ? 2 : 0)); + } + // Four rows colour their tracks; the rest stay gray. + for (const Field &field : fields) { + const QString name = QString::fromLatin1(field.name); + if (name == QLatin1String("temperature") || name == QLatin1String("tint") || name == QLatin1String("vibrance") || name == QLatin1String("saturation")) + continue; + const QImage drawn = panel.child<CameraRawSlider>(name + QStringLiteral("Slider")).grab().toImage(); + const QColor end = drawn.pixelColor(drawn.width() - 4, drawn.height() / 2); + QVERIFY2(std::abs(end.red() - end.green()) < 12 && std::abs(end.green() - end.blue()) < 12, field.name); + } + // Temperature and Tint make the balance Custom. + FilterSettings automatic = panel.session.filterEdit().value().settings; + automatic.cameraRaw.whiteBalance = CameraRawWhiteBalance::automatic; + panel.session.updateFilter(automatic, true); + panel.child<CameraRawSlider>(QStringLiteral("tintSlider")).setValue(600); + QVERIFY(panel.raw().whiteBalance == CameraRawWhiteBalance::custom); + // Each colour slider paints its own track: blue, green, gray. + const auto left = [&panel](const char *name) { + const QImage drawn = panel.child<CameraRawSlider>(QString::fromLatin1(name)).grab().toImage(); + return drawn.pixelColor(4, drawn.height() / 2); + }; + QVERIFY(left("temperatureSlider").blue() > left("temperatureSlider").red() + 100); + QVERIFY(left("tintSlider").green() > left("tintSlider").red() + 60); + QVERIFY(std::abs(left("vibranceSlider").red() - left("vibranceSlider").green()) < 8); + QVERIFY(std::abs(left("saturationSlider").red() - left("saturationSlider").blue()) < 8); +} + +void CameraRawPanelTests::aTypedNumberAppliesAndIsHeldInRange() +{ + Panel panel; + auto &field = panel.child<PickerField>(QStringLiteral("exposureField")); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, QStringLiteral("1.25")); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(panel.raw().exposure, 1.25); + QCOMPARE(panel.child<CameraRawSlider>(QStringLiteral("exposureSlider")).shown(), 1.25); + field.selectAll(); + QTest::keyClicks(&field, QStringLiteral("9")); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(panel.raw().exposure, 5.0); + // Text that is no number goes back. + field.selectAll(); + QTest::keyClicks(&field, QStringLiteral("x")); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(field.text(), QString("5")); +} + +void CameraRawPanelTests::altOnALightSliderShowsItsClippingUntilLetGo() +{ + Panel panel; + QWindow *window = panel.controls->windowHandle(); + QTest::keyPress(window, Qt::Key_Alt, Qt::AltModifier); + panel.child<CameraRawSlider>(QStringLiteral("whitesSlider")).setValue(700); + QCOMPARE(panel.panel().clipping, std::optional(CameraRawClipping::highlights)); + panel.child<CameraRawSlider>(QStringLiteral("blacksSlider")).setValue(300); + QCOMPARE(panel.panel().clipping, std::optional(CameraRawClipping::shadows)); + // Contrast has no clipping view. + panel.child<CameraRawSlider>(QStringLiteral("contrastSlider")).setValue(700); + QVERIFY(!panel.panel().clipping); + panel.child<CameraRawSlider>(QStringLiteral("exposureSlider")).setValue(700); + QVERIFY(panel.panel().clipping); + panel.arm([](CameraRawPanel &raw) { raw.sharpenMask = true; }); + // A repeated release is no release: the views stay. + QKeyEvent repeat(QEvent::KeyRelease, Qt::Key_Alt, Qt::NoModifier, QString(), true); + QApplication::sendEvent(panel.controls.get(), &repeat); + QVERIFY(panel.panel().clipping && panel.panel().sharpenMask); + QTest::keyRelease(window, Qt::Key_Alt); + QVERIFY(!panel.panel().clipping && !panel.panel().sharpenMask); + // Without Alt a Light slider shows the grade. + panel.child<CameraRawSlider>(QStringLiteral("whitesSlider")).setValue(800); + QVERIFY(!panel.panel().clipping); +} + +void CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview() +{ + Panel panel; + const QStringList groups{"Light", "Color", "Effects", "Curve", "ColorMixer", "ColorGrading", "Detail", "Optics", "Geometry", "Calibration"}; + for (const QString &group : groups) { + auto &disclosure = panel.child<QToolButton>(group + QStringLiteral("Section")); + auto *body = disclosure.parentWidget()->findChildren<QWidget *>(QString(), Qt::FindDirectChildrenOnly).last(); + const bool open = group == QLatin1String("Light") || group == QLatin1String("Color"); + QCOMPARE(body->isVisible(), open); + disclosure.click(); + QCOMPARE(body->isVisible(), !open); + // An eye shows only while the group adjusts. + QVERIFY(!panel.child<QToolButton>(group + QStringLiteral("Eye")).isVisible()); + } + panel.child<CameraRawSlider>(QStringLiteral("exposureSlider")).setValue(700); + auto &eye = panel.child<QToolButton>(QStringLiteral("LightEye")); + QVERIFY(eye.isVisible()); + QCOMPARE(eye.toolTip(), QString("Hide Light in the preview")); + QCOMPARE(eye.accessibleName(), QString("Hide Light")); + eye.click(); + QVERIFY(!panel.panel().shows.light && panel.panel().shows.color); + QCOMPARE(eye.toolTip(), QString("Show Light in the preview")); + QVERIFY(!panel.session.filterEdit().value().previewJob().settings.cameraRaw.adjustsLight()); + eye.click(); + QVERIFY(panel.panel().shows.light); +} + +void CameraRawPanelTests::theScopeSwitchesAndItsTrianglesShowClipping() +{ + Panel panel; + auto &scope = panel.child<QWidget>(QStringLiteral("cameraRawScope")); + QCOMPARE(scope.accessibleName(), QString("RGB histogram")); + QTimer::singleShot(0, [] { + auto *menu = qobject_cast<QMenu *>(QApplication::activePopupWidget()); + menu->actions().at(1)->trigger(); + menu->close(); + }); + QContextMenuEvent event(QContextMenuEvent::Mouse, QPoint(10, 10), scope.mapToGlobal(QPoint(10, 10))); + QApplication::sendEvent(&scope, &event); + QVERIFY(panel.panel().scopeMode == CameraRawScopeMode::vectorscope); + QCOMPARE(scope.accessibleName(), QString("Vectorscope")); + QVERIFY(scope.toolTip().startsWith("Hue around the wheel")); + panel.child<QToolButton>(QStringLiteral("shadowClipping")).click(); + QVERIFY(panel.panel().showsShadowClipping && !panel.panel().showsHighlightClipping); + panel.child<QToolButton>(QStringLiteral("highlightClipping")).click(); + QVERIFY(panel.panel().showsHighlightClipping); + // Lit, the shadows' triangle is blue. + const QImage lit = panel.child<QToolButton>(QStringLiteral("shadowClipping")).grab().toImage(); + QCOMPARE(lit.pixelColor(7, 9), QColor(0, 145, 255)); + QCOMPARE(panel.child<QToolButton>(QStringLiteral("highlightClipping")).grab().toImage().pixelColor(7, 9), QColor(255, 66, 69)); + QTRY_VERIFY(!panel.session.filterEdit().value().preparing); + QVERIFY(panel.session.filterEdit().value().previewJob().showsShadowClipping); +} + +void CameraRawPanelTests::theReadoutNamesThePixel() +{ + Panel panel; + auto &readout = panel.child<QLabel>(QStringLiteral("cameraRawReadout")); + QCOMPARE(readout.text(), QString("R — G — B —")); + panel.session.updateCameraRawReadout(QPointF(2, 2)); + QCOMPARE(readout.text(), QString("R 200 G 120 B 60")); +} + +void CameraRawPanelTests::whiteBalanceAutoCustomAndTheEyedropper() +{ + Panel panel; + auto &balance = panel.child<QComboBox>(QStringLiteral("whiteBalance")); + QCOMPARE(balance.currentText(), QString("Custom")); + balance.setCurrentIndex(1); + emit balance.activated(1); + QVERIFY(panel.raw().whiteBalance == CameraRawWhiteBalance::automatic); + QTRY_VERIFY(panel.raw().temperature < 0); + QCOMPARE(balance.currentText(), QString("Auto")); + emit balance.activated(0); + QVERIFY(panel.raw().whiteBalance == CameraRawWhiteBalance::custom && panel.raw().temperature < 0); + auto &sampler = panel.child<QToolButton>(QStringLiteral("whiteBalanceSelector")); + auto &hint = panel.child<QLabel>(QStringLiteral("whiteBalanceHint")); + QVERIFY(!hint.isVisible()); + sampler.click(); + QVERIFY(panel.panel().samplesWhiteBalance && hint.isVisible()); + sampler.click(); + QVERIFY(!panel.panel().samplesWhiteBalance && !hint.isVisible()); +} + +void CameraRawPanelTests::glowAndVignetteStylesAreWritten() +{ + Panel panel; + auto &glow = panel.child<QComboBox>(QStringLiteral("glowStyle")); + glow.setCurrentIndex(2); + emit glow.activated(2); + QVERIFY(panel.raw().glowStyle == CameraRawGlowStyle::halation); + auto &vignette = panel.child<QComboBox>(QStringLiteral("vignetteStyle")); + vignette.setCurrentIndex(1); + emit vignette.activated(1); + QVERIFY(panel.raw().vignetteStyle == CameraRawVignetteStyle::colorPriority); + // Settings written elsewhere show in both menus. + FilterSettings settings = panel.session.filterEdit().value().settings; + settings.cameraRaw.glowStyle = CameraRawGlowStyle::bloom; + settings.cameraRaw.vignetteStyle = CameraRawVignetteStyle::paintOverlay; + panel.session.updateFilter(settings, true); + QCOMPARE(glow.currentText(), QString("Bloom")); + QCOMPARE(vignette.currentText(), QString("Paint Overlay")); +} + +void CameraRawPanelTests::everyRowCarriesSwiftsTitleHelpAndClipping() +{ + Panel panel; + QWindow *window = panel.controls->windowHandle(); + QTest::keyPress(window, Qt::Key_Alt, Qt::AltModifier); + for (const Field &field : fields) { + auto &slider = panel.child<CameraRawSlider>(QString::fromLatin1(field.name) + QStringLiteral("Slider")); + QCOMPARE(slider.parentWidget()->findChild<QLabel *>()->text(), QString::fromUtf8(field.title)); + QCOMPARE(slider.toolTip(), QString::fromUtf8(field.help)); + QCOMPARE(panel.child<PickerField>(QString::fromLatin1(field.name) + QStringLiteral("Field")).toolTip(), QString::fromUtf8(field.help)); + slider.setValue(slider.value() == 900 ? 100 : 900); + QVERIFY2(panel.panel().clipping == field.clipping, field.name); + } + QTest::keyRelease(window, Qt::Key_Alt); + // Exposure shows two decimals, the rest whole numbers. + FilterSettings settings = panel.session.filterEdit().value().settings; + settings.cameraRaw.exposure = 1.256; + settings.cameraRaw.contrast = 12.6; + panel.session.updateFilter(settings, true); + QCOMPARE(panel.child<PickerField>(QStringLiteral("exposureField")).text(), panel.controls->locale().toString(1.26, 'f', 2)); + QCOMPARE(panel.child<PickerField>(QStringLiteral("contrastField")).text(), QString("13")); +} + +void CameraRawPanelTests::theScopeDrawsItsRibbonsAndItsCells() +{ + Panel panel; + // A gray ramp fills every bin: three full-height ribbons. + QImage ramp = BrushRaster::context(256, 4, false); + for (int y = 0; y < 4; ++y) + for (int x = 0; x < 256; ++x) + ramp.setPixelColor(x, y, QColor(x, x, x)); + panel.session.cancelFilter(); + panel.session.insert(ImportedImage(ramp, ramp, QStringLiteral("Ramp"))); + panel.session.beginFilter(FilterKind::cameraRaw); + panel.controls = std::make_unique<CameraRawControls>(panel.session); + panel.controls->resize(392, 700); + panel.controls->show(); + panel.session.updateFilter(panel.session.filterEdit().value().settings, true); + QTRY_VERIFY(panel.panel().scope); + auto &scope = panel.child<QWidget>(QStringLiteral("cameraRawScope")); + QImage drawn = scope.grab().toImage(); + // Black at 35% over the window, each ribbon at 55%. + const QColor window = panel.controls->palette().color(QPalette::Window); + const QColor ground(int(std::lround(window.red() * 0.65)), int(std::lround(window.green() * 0.65)), int(std::lround(window.blue() * 0.65))); + const auto over = [](QColor top, QColor below) { + return QColor(int(std::lround(top.red() * 0.55 + below.red() * 0.45)), int(std::lround(top.green() * 0.55 + below.green() * 0.45)), + int(std::lround(top.blue() * 0.55 + below.blue() * 0.45))); + }; + const QColor stacked = over(QColor(0, 145, 255), over(QColor(48, 209, 88), over(QColor(255, 66, 69), ground))); + for (const QPoint at : {QPoint(drawn.width() / 2, drawn.height() - 4), QPoint(drawn.width() / 3, 6)}) { + const QColor seen = drawn.pixelColor(at); + QVERIFY2(std::abs(seen.red() - stacked.red()) <= 3 && std::abs(seen.green() - stacked.green()) <= 3 && std::abs(seen.blue() - stacked.blue()) <= 3, + qPrintable(seen.name() + QStringLiteral(" against ") + stacked.name())); + } + // Blue lights its cell; rows past 18 fall off. + QImage red = BrushRaster::context(8, 8, false); + red.fill(QColor(0, 0, 255)); + panel.session.cancelFilter(); + panel.session.insert(ImportedImage(red, red, QStringLiteral("Blue"))); + panel.session.beginFilter(FilterKind::cameraRaw); + panel.session.updateFilter(panel.session.filterEdit().value().settings, true); + QTRY_VERIFY(panel.panel().scope); + // Hue 240 at full reach: column 16, row 5. + panel.arm([](CameraRawPanel &raw) { raw.scopeMode = CameraRawScopeMode::vectorscope; }); + drawn = scope.grab().toImage(); + const double cell = double(drawn.width()) / CameraRawScope::scopeSide; + const QColor lit = drawn.pixelColor(int((16 + 0.5) * cell), int(drawn.height() - (5 + 0.5) * cell)); + QVERIFY2(lit.red() > 240 && lit.green() > 240 && lit.blue() > 240, qPrintable(lit.name())); +} + +void CameraRawPanelTests::theGroupsDrawTheirChevronsAndEyes() +{ + Panel panel; + const qreal ratio = panel.controls->devicePixelRatioF(); + const QColor ink = panel.controls->palette().color(QPalette::WindowText); + const auto glyph = [ratio, &ink](LayerIcon icon, double side) { return LayerIcons::pixmap(icon, side, ink, ratio).toImage(); }; + const auto shows = [&](const QString &button, LayerIcon icon, double side) { + const QToolButton &shown = panel.child<QToolButton>(button); + return shown.icon().pixmap(QSize(int(side), int(side)), ratio).toImage() == glyph(icon, side); + }; + QVERIFY(shows(QStringLiteral("LightSection"), LayerIcon::chevronDown, 12)); + QVERIFY(shows(QStringLiteral("EffectsSection"), LayerIcon::chevronRight, 12)); + panel.child<QToolButton>(QStringLiteral("EffectsSection")).click(); + QVERIFY(shows(QStringLiteral("EffectsSection"), LayerIcon::chevronDown, 12)); + panel.child<CameraRawSlider>(QStringLiteral("exposureSlider")).setValue(700); + QVERIFY(shows(QStringLiteral("LightEye"), LayerIcon::eye, 14)); + panel.child<QToolButton>(QStringLiteral("LightEye")).click(); + QVERIFY(shows(QStringLiteral("LightEye"), LayerIcon::eyeSlash, 14)); +} + +QTEST_MAIN(CameraRawPanelTests) +#include "CameraRawPanelTests.moc" diff --git a/tests/CameraRawSliderTests.cpp b/tests/CameraRawSliderTests.cpp new file mode 100644 index 0000000..b7431f1 --- /dev/null +++ b/tests/CameraRawSliderTests.cpp @@ -0,0 +1,117 @@ +#include "UI/CameraRawSlider.h" +#include "UI/SliderSnap.h" +#include <QStyleOptionSlider> +#include <QtTest> + +// Swift's CameraRawSliderTests. +namespace { +struct Probe { + double changed = std::nan(""); + int resets = 0; + CameraRawSlider slider; + explicit Probe(CameraRawSliderTrack track = {}) + : slider(-100, 100, track, QStringLiteral("Help"), [this](double value) { changed = value; }, [this] { ++resets; }) + { + slider.resize(200, 24); + slider.show(); + } + QRect knob() const + { + 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); + } +}; +} + +class CameraRawSliderTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { SliderSnap::install(); } + void colorTracksRunFromTheCoolOrMutedEndToTheWarmOrStrongEnd(); + void temperatureBarIsBlueOnTheLeftAndADoubleClickHitsOnlyTheKnob(); + void trackClickValueMatchesTheClickedPosition(); + void aFamilysTracksCentreOnItsHue(); + void aDragKeepsItsKnob(); +}; + +void CameraRawSliderTests::colorTracksRunFromTheCoolOrMutedEndToTheWarmOrStrongEnd() +{ + using Kind = CameraRawSliderTrack::Kind; + const auto [cool, warm] = CameraRawSliderTrack{Kind::temperature}.colors().value(); + QVERIFY(cool.blueF() > warm.blueF()); + const auto [green, mauve] = CameraRawSliderTrack{Kind::tint}.colors().value(); + QVERIFY(green.greenF() > mauve.greenF()); + const auto [gray, red] = CameraRawSliderTrack{Kind::chroma}.colors().value(); + QVERIFY(std::abs(gray.redF() - gray.greenF()) < 0.05); + QVERIFY(red.redF() > red.greenF() + 0.4); + QVERIFY(!CameraRawSliderTrack{Kind::plain}.colors()); +} + +void CameraRawSliderTests::temperatureBarIsBlueOnTheLeftAndADoubleClickHitsOnlyTheKnob() +{ + Probe probe({CameraRawSliderTrack::Kind::temperature}); + probe.slider.display(0); + const QImage drawn = probe.slider.grab().toImage(); + const QColor left = drawn.pixelColor(8, drawn.height() / 2), right = drawn.pixelColor(drawn.width() - 8, drawn.height() / 2); + QVERIFY2(left.blue() > left.red(), qPrintable(left.name())); + QVERIFY2(right.red() + right.green() > right.blue() + 100, qPrintable(right.name())); + probe.slider.display(40); + const QRect knob = probe.knob(); + QVERIFY(probe.slider.isOnKnob(knob.center())); + QVERIFY(!probe.slider.isOnKnob(QPoint(2, knob.center().y()))); + // Swift's two points round the knob count as the knob. + QVERIFY(probe.slider.isOnKnob(QPoint(knob.right() + 2, knob.center().y()))); + QVERIFY(!probe.slider.isOnKnob(QPoint(knob.right() + 4, knob.center().y()))); + // A double click resets on the knob, not the track. + QTest::mouseDClick(&probe.slider, Qt::LeftButton, {}, knob.center()); + QCOMPARE(probe.resets, 1); + QTest::mouseDClick(&probe.slider, Qt::LeftButton, {}, QPoint(2, knob.center().y())); + QCOMPARE(probe.resets, 1); +} + +void CameraRawSliderTests::trackClickValueMatchesTheClickedPosition() +{ + for (const auto &[x, expected] : {std::pair(0, -100.0), std::pair(100, 0.0), std::pair(199, 100.0)}) { + Probe probe; + probe.slider.display(x < 100 ? 50 : -50); + QTest::mousePress(&probe.slider, Qt::LeftButton, {}, QPoint(x, 12)); + QVERIFY2(std::abs(probe.changed - expected) <= 2, qPrintable(QString::number(probe.changed))); + QTest::mouseRelease(&probe.slider, Qt::LeftButton, {}, QPoint(x, 12)); + } +} + +void CameraRawSliderTests::aFamilysTracksCentreOnItsHue() +{ + using Kind = CameraRawSliderTrack::Kind; + // Hue runs 50 degrees either side; red wraps below zero. + const auto [below, above] = CameraRawSliderTrack{Kind::hue, 0}.colors().value(); + QVERIFY(std::abs(below.hueF() * 360 - 310) < 1 && std::abs(above.hueF() * 360 - 50) < 1); + const auto [muted, strong] = CameraRawSliderTrack{Kind::saturation, 240}.colors().value(); + QVERIFY(muted.hsvSaturationF() < 0.05 && std::abs(strong.hueF() * 360 - 240) < 1); + const auto [dark, light] = CameraRawSliderTrack{Kind::luminance, 120}.colors().value(); + QVERIFY(dark.valueF() < 0.2 && light.valueF() > 0.9 && std::abs(dark.hueF() * 360 - 120) < 1); +} + +void CameraRawSliderTests::aDragKeepsItsKnob() +{ + Probe probe; + probe.slider.display(20); + QCOMPARE(probe.slider.shown(), 20.0); + probe.slider.setSliderDown(true); + probe.slider.display(-60); + QCOMPARE(probe.slider.shown(), 20.0); + probe.slider.setSliderDown(false); + probe.slider.display(-60); + QCOMPARE(probe.slider.shown(), -60.0); + // Shown values write nothing back. + QVERIFY(std::isnan(probe.changed)); +} + +QTEST_MAIN(CameraRawSliderTests) +#include "CameraRawSliderTests.moc" diff --git a/tests/FloatingPanelTests.cpp b/tests/FloatingPanelTests.cpp index e5c5952..608f750 100644 --- a/tests/FloatingPanelTests.cpp +++ b/tests/FloatingPanelTests.cpp @@ -6,6 +6,7 @@ #include "UI/LevelsSheet.h" #include <QApplication> #include <QLabel> +#include <QVBoxLayout> #include <QWindow> #include <QtTest> @@ -32,6 +33,7 @@ private slots: void aPanelOpensOverTheCanvasThenWhereItWasLeft(); void itsCloseButtonAndEscapeCancel(); void aPanelRemembersWhereItIsMovedAndCentresWithoutACanvas(); + void dockedPlacementLeavesTheSavedFilterPosition(); }; void FloatingPanelTests::colorPickerPanelSurvivesALayoutPass() @@ -283,5 +285,26 @@ void FloatingPanelTests::aPanelRemembersWhereItIsMovedAndCentresWithoutACanvas() QVERIFY(std::abs(window->pos().y() + window->height() / 2 - middle.y()) <= 1); } +// Swift's test: a docked frame never sets where panels reopen. +void FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition() +{ + Shown shown; + auto *slot = new QWidget(&shown.window); + auto *layout = new QVBoxLayout(slot); + slot->setGeometry(250, 0, 150, 300); + FloatingPanel panel(QStringLiteral("testDockedPanel"), shown.window); + panel.show(QStringLiteral("Docked"), new QLabel(QStringLiteral("Docked")), slot); + QVERIFY(!visiblePanel(QStringLiteral("testDockedPanel")) && panel.isVisible()); + // The docked panel moves inside its slot; nothing is kept. + layout->setContentsMargins(20, 20, 0, 0); + layout->activate(); + panel.close(); + QVERIFY(!slot->isVisible() && !panel.isVisible()); + panel.show(QStringLiteral("Floating"), new QLabel(QStringLiteral("Floating"))); + QWidget *window = visiblePanel(QStringLiteral("testDockedPanel")); + const QPoint middle = shown.canvas->mapToGlobal(shown.canvas->rect().center()); + QVERIFY(std::abs(window->pos().x() + window->width() / 2 - middle.x()) <= 1); +} + QTEST_MAIN(FloatingPanelTests) #include "FloatingPanelTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 6783e35..447959d 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -214,6 +214,9 @@ compositor_test(CameraRawColorTests) compositor_test(CameraRawLensTests) target_include_directories(CameraRawLensTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(CameraRawWiringTests) +compositor_test(CameraRawSliderTests) +compositor_test(CameraRawPanelTests) +compositor_test(CameraRawCanvasTests) target_include_directories(CameraRawWiringTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(LayerEffectsRendererTests) compositor_test(LayerFlipTests)