diff --git a/AGENTS.md b/AGENTS.md index db7c2e1..e131f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,7 @@ Platform adaptations, each forced by a missing Qt analog: - Typed text as pixels, effects kept on (12.12a, Swift 1.2.11: `fe6cb2b`, `b683acb`): the canvas draws text being typed as the pixels `textImage` makes of the draft's style (remade only when the style changes and let go at the first draw without a draft, as Swift's `drawNewText` asks with no draft, each remake a debug line on `omaphoto.rendering`, a style that cannot be drawn logged and drawn as nothing), where the editor shows it: an edited layer in its place with its opacity and blend mode (its mask left off, as Swift's), new text just above the active layer, or on top when the active layer is not drawn (a folder, a hidden or blank layer, none). The editor's glyphs are clear: it draws its frame, handles, caret, marked text's underline and a selection at 45% of the highlight over those pixels. An underline without a colour of its own, the preedit's or an input method's, takes the text's colour, since clear glyphs would hide it (Swift's `NSTextView` draws marked text itself). `DisplayState` carries the draft's style and shown transform, so typing repaints the whole view, as effects reach past the box; a handle drag at a zoom can move the box a fraction with the style unchanged, so the transform counts on its own. The edited layer's effects are redone as it is typed, synchronously at the preview size (`EffectsPreviewCache::renderNow`, the worker's scaling moved into `scaled`), through its enabled mask or its placed mask clipped over the typed box; until a change is redone the last effects stand in. When the edit ends and the layer holds the text as last typed, the next draw seeds those effects (`handOnDraftEffects`); a cancel of changed text, or other text, seeds nothing. The cache keeps the last three finished results per layer: pixels an undo puts back show their effects at once, a known result clearing any seed; a mask added, removed or replaced on the same pixels keeps the last preview until the new one lands, where it dropped before. A painted mask keeps its layer's effects through the stroke: `LayerEffectsSurface::MaskStroke` holds the stroke's tiles, their map into the surface's grid and the coverage of any region (the old mask, then the tiles over it, as a tile already holds the old mask it covers; Swift's white fill beneath is dead, since past the old mask there are no pixels), and the surface redoes the regions the mapped tiles touch; a placed mask's surface keeps the layer's grid and takes coverage from `placedMaskPreview` (`placedMaskSurface`). Swift's `restoreToolCursor` has no twin: `syncPicking` already sets the cursor both ways, and a widget's cursor cannot be left over from another window. `strokeDirtyRect` moved to `EditorCanvas+Brush.cpp`, the typed text to `EditorCanvas+DraftText.cpp`. Left out as dead: the cache's filter of a result already recent (a request matching one returns before it renders). Equivalent mutant: the mapped tile rect's one-pixel inset (the redo region is widened by the effects' reach). Tests: `TypedTextCanvasTests` (Swift's `textLooksTheSameWhileEditingAndOnceCommitted` at 1.5 and 4, with the box's inside compared since the editor's frame draws on the canvas; placement above the active layer, opacity and a blend mode with and without effects, effects kept and handed on, masks linked and placed, cancelled text, remakes counted and the image let go once editing ends, typed effects redone when a handle moves a placed-mask box a fraction, the whole view repainted when typing or when a handle moves the box a fraction), `MaskEffectsCanvasTests` (linked and placed masks keep effects mid-stroke, a placed mask sixty times finer against a whole render; untouched tiles keep the old mask), `EffectsPreviewCacheTests` (recent results, `renderNow`, the looser keep), `LayerEffectsSurfaceTests` (a mask stroke redone where its tiles map), and the inline editor's tests read the 45% selection through `highlightedOver`. - Masks painted anywhere (12.12b, Swift 1.2.11: `e753f26`, `8d2eade`, `2ffb092`, `a247676`, taken as their net change from 1.2.10): `BrushStroke` takes `growsMask`, which the Brush, the Gradient and fills (`applyPixelEdit`) pass, so on a mask their grid reaches the canvas as a pixel stroke's does; Spot Healing, Clone Stamp and the Smear keep the mask's grid. A solid placed mask (two pixels a side at most) is painted at a pixel per document pixel of its placement rather than grown from its pixel. `maskBackground` (`LayerMask::background` of the old mask's thumbnail, white or black, 1 without a mask) fills each new tile, the committed snapshot past its base and patches (`RasterSnapshot::fill`, drawn where neither reaches, the box of a mask grown by distant tiles holding such corners) and the asynchronous commit's context (`BrushCommit::Input::fill`). A mask stroke's snapshot keeps every touched tile (`committedBounds`). Committed, a mask grown past its old bounds, or placed already, takes the stroke's transform as its placement (`placedMask`, for the snapshot and the raster commit alike), keeping its place on the document while a linked one still moves with its layer. On the canvas an unplaced mask stroke draws in its possibly grown grid (`paintTransform`), a placed one in the layer's place, and a placed mask's preview and surface map through the stroke's `paintTransform`. `CanvasThumbnail::mask` fills past the mask with `LayerMask::background`, so a stroke touching the edge leaves no gray mean; the thumbnail's own `edgeTone` is gone. `8d2eade`'s canvas fix (the distortion's effects asked with the mask as stored) needs no twin: the twin asks for a layer's effects once, with the mask as shown (9.6c), and its y-flip in `LayerEffectsSurface` has no Qt counterpart. The linked mask surface's white fill (12.12a) stays dead: the layer's pixels lie only within the old mask's rect, however far the grid grows. Equivalent mutant: `placedMask`'s `mask.placement` term (a placed mask keeps its placement through `replacing`, and its transform over unchanged bounds is that placement). Tests: `MaskPaintAnywhereTests` (the brush grows a mask and keeps it linked and in place, a hide-all mask's new area hides, the Gradient and a fill grow it, the Smear's blur does not, a solid placed mask's grid (two pixels a side, a box not square, grown and not), the snapshot's and the commit's fill in uncovered corners, a grown stroke drawn in place, a grown placed mask's tiles mapped under effects), `CanvasThumbnailTests` (Swift's edge case), and `MaskEffectsCanvasTests`' finer placed mask through the stroke's transform. - Scrubbable labels (12.12c1, Swift 1.2.11: `2d36d6e`, `680a522`): `UI/NumericScrub` twins Swift's modifier as a `QObject` living as its label's child and filtering its events; `scrubbable` and `unitSuffix(_:scrubValue:)` are its constructor on the label or unit (`Options`: sensitivity, range, optional step, value, set, onStart, onEnd). A left press on an enabled label, a double click's second press included, is taken (a disabled label's presses reach its filter before Qt drops them, so the filter asks), so the label keeps the drag past its edge; the first move off the press begins (Swift's `minimumDistance` of one, reached by any move of whole points), keeps the start value and calls `onStart`; each move sets the start plus the sideways distance (in the label's own points, which stay exact, where Qt's `notify` rounds global ones) times the sensitivity, snapped to the step when positive, then clamped; the left release calls `onEnd`. Swift's Int overload is a step of one. The cursor is the label's own `SizeHorCursor`: Qt keeps a grabbing widget's cursor through the drag and shows a disabled label's parent's, so Swift's hover and restore dance has no twin. Qt can lose a release: the left release is watched application-wide while pressed, and a move without the left button ends the drag (the label tracks the mouse, so such a move arrives). The tool bars scrub as Swift's: the Brush's Size, Hardness, Opacity or Strength and Smoothing, the Gradient's Opacity, the Magic Wand's Tolerance, Object Select's Edge, the Expand, Contract and Feather units (Expand's and Contract's rest with their rows; Feather's amount and unit stay enabled without a selection, its button alone resting, as Swift's, which the twin's amount had missed), the amount sheet's title (writing its input), the Shape's Width and Radius (a scrub writes its field's text over any typing, as a step does: `showValue` on `SelectionAmountField` and the Type bar's `TextStyleField`), the Type bar's size unit, Tracking and Leading (whole numbers), the Move bar's labels (whole numbers in Swift's new ranges: ±30,000, sides 1 to 30,000, Scale 0.1 to 30,000%, rotation ±360; the scrub writes its field's text over any typing, then the value, so an unfocused field shows what the session keeps; another active layer ends each field's drag under way, `TransformValueField::endScrub`, as it ends the opacity's), the zoom's unit (through the field's `step`) and the layer opacity (one opacity drag, `beginOpacityEdit` to `finishOpacityEdit`; another active layer ends a drag under way through `NumericScrub::end`, since the twin keeps the controls where Swift's `.id` remakes them, and later held moves wait for a fresh press). The sheets and panels follow in 12.12c2. `075a71b`'s narrowing fix in `AdjustPixels.c` is taken (the kernels match Swift's); its other changes and `725b934`'s are Swift's concurrency, plist and CI, with nothing to twin. Swift's `emptyCanvasLeavesTheScrubberCursorAloneAndRestoresTrackingForADocument` has no twin: the canvas never sets a global cursor, only its own. Equivalent mutant: removing the application filter when a drag ends (it then sees nothing to end). Tests: `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableTransformTests` (the Move bar, the zoom unit, the layer opacity), `ScrubFixtures.h`. +- Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. Known: a sheet destroyed while one of its fields holds the focus has that field commit into the sheet's destroyed members as Qt moves the focus; the app always hides a panel's content before deleting it, so the focus leaves first, and `ScrubFixtures`' typing helper leaves the field before the sheet goes. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles). - 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 `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/TASKS.md b/TASKS.md index dabb8ad..046265f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -385,7 +385,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.12a Version 1.2.11, part one: text being typed shown as the pixels it will commit to (`fe6cb2b`); layer effects kept on screen while text is edited, masks are painted and steps undone (`b683acb`). Codex review: 3 rounds, PASS. - [x] 12.12b Part two: masks painted anywhere on the canvas (`e753f26`, `2ffb092`), a moved unlinked mask kept in place under effects (`8d2eade`), mask thumbnails' ground white or black as the canvas treats it (`a247676`). Codex review: 1 round; its one finding (two long test comments) fixed. - [x] 12.12c1 Part three: numeric label scrubbing (`2d36d6e`, `680a522`) in the tool bars and the layer opacity; the build-warning fixes (`075a71b`, `725b934`) where they change behaviour. Codex review: 3 rounds. Round 1: focused fields left stale, double-click second presses, Feather's amount wrongly resting, test gaps. Round 2: an opacity scrub outliving its layer, boundary gaps. Round 3: the same for the Move bar. All fixed; closed after the third. -- [ ] 12.12c2 Numeric label scrubbing in the sheets and panels: Canvas Size, Image Size, the colour picker, effects, filters, Hue/Saturation, Levels, Camera Raw. +- [x] 12.12c2 Numeric label scrubbing in the sheets and panels: Canvas Size, Image Size, the colour picker, effects, filters, Hue/Saturation, Levels, Camera Raw. Codex review: 3 rounds. Round 1: every Mixer row scrubbed where Swift scrubs the HSL families alone, and test gaps (Image Size limits, Camera Raw bindings, filter decimals, centimetres). Round 2: test gaps (a held drag across `reshape`, titles enabled again, Alt-held Camera Raw scrubs). Round 3 passed. - [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects. - [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish. - [ ] 12.15 Version 1.3.3: the Dither filter. diff --git a/src/UI/CameraRawColorControls+Mixer.cpp b/src/UI/CameraRawColorControls+Mixer.cpp index 45256ed..c7bd28b 100644 --- a/src/UI/CameraRawColorControls+Mixer.cpp +++ b/src/UI/CameraRawColorControls+Mixer.cpp @@ -162,7 +162,7 @@ void CameraRawMixerControls::rebuildFamilies(int tab) }; row(m_families, {.name = QStringLiteral("family") + name, .title = name, .help = QStringLiteral("%1 of %2.").arg(tabs.at(size_t(tab)), name), - .track = familyTrack(index, tab), .titleWidth = 78, .fixedTitle = true, .fieldWidth = 48}, + .track = familyTrack(index, tab), .titleWidth = 78, .fixedTitle = true, .fieldWidth = 48, .scrub = 1}, [family, index](const CameraRawSettings &settings) { CameraRawSettings copy = settings; return family(copy).at(size_t(index)); diff --git a/src/UI/CameraRawColorControls.cpp b/src/UI/CameraRawColorControls.cpp index cf5d26c..47bd2a0 100644 --- a/src/UI/CameraRawColorControls.cpp +++ b/src/UI/CameraRawColorControls.cpp @@ -219,7 +219,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget * amounts->setContentsMargins(0, 0, 0, 0); amounts->setSpacing(8); const auto amount = [this, amounts](const QString &name, const QString &title, double CameraRawCurveSettings::*key, const QString &help) { - auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .titleWidth = 88, .fixedTitle = true, .fieldWidth = 48}, + auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .titleWidth = 88, .fixedTitle = true, .fieldWidth = 48, .scrub = 1}, [this, key] { return raw().curve.*key; }, [this, key](double value) { update([key, value](CameraRawSettings &settings) { settings.curve.*key = value; }); }, [this, key](double value) { update([key, value](CameraRawSettings &settings) { settings.curve.*key = value; }); }, @@ -258,7 +258,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget * }); m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"), .help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88, - .fixedTitle = true, .fieldWidth = 48}, + .fixedTitle = true, .fieldWidth = 48, .scrub = 1}, [this] { return raw().curve.refineSaturation; }, [this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); }, [this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); }, diff --git a/src/UI/CameraRawControls.cpp b/src/UI/CameraRawControls.cpp index 4f7affd..a4b0e04 100644 --- a/src/UI/CameraRawControls.cpp +++ b/src/UI/CameraRawControls.cpp @@ -268,7 +268,7 @@ void CameraRawControls::slider(QVBoxLayout *column, const QString &name, const Q { const double step = std::pow(10.0, decimals); auto *row = new CameraRawRow( - {.name = name, .title = title, .help = help, .low = low, .high = high, .decimals = decimals, .track = track, .titleResets = true}, + {.name = name, .title = title, .help = help, .low = low, .high = high, .decimals = decimals, .track = track, .titleResets = true, .scrub = 1 / step}, [this, key] { CameraRawSettings settings = raw(); return key(settings); diff --git a/src/UI/CameraRawDetailOpticsControls.cpp b/src/UI/CameraRawDetailOpticsControls.cpp index 39e6416..721a1cc 100644 --- a/src/UI/CameraRawDetailOpticsControls.cpp +++ b/src/UI/CameraRawDetailOpticsControls.cpp @@ -91,7 +91,7 @@ CameraRawDetailControls::CameraRawDetailControls(EditorSession &session, QWidget void CameraRawDetailControls::row(QVBoxLayout *column, const QString &name, const QString &title, double CameraRawDetailSettings::*key, double high, double reset, bool maskingPreview, const QString &help) { - auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .low = 0, .high = high, .titleWidth = CameraRawControls::labelWidth}, + auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .low = 0, .high = high, .titleWidth = CameraRawControls::labelWidth, .scrub = 1}, [this, key] { return raw(m_session).detail.*key; }, [this, key, maskingPreview](double value) { assign(key, std::round(value), maskingPreview); }, [this, key](double value) { assign(key, value, false); }, [this, key, reset] { assign(key, reset, false); }); @@ -194,7 +194,7 @@ void CameraRawOpticsControls::row(QVBoxLayout *column, const QString &name, cons double high, double reset, const QString &help) { const auto set = [this, key](double value) { update([key, value](CameraRawOpticsSettings &optics) { optics.*key = value; }); }; - auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .low = low, .high = high, .titleWidth = CameraRawControls::labelWidth}, + auto *row = new CameraRawRow({.name = name, .title = title, .help = help, .low = low, .high = high, .titleWidth = CameraRawControls::labelWidth, .scrub = 1}, [this, key] { return raw(m_session).optics.*key; }, [set, low](double value) { set(low < 0 ? value : std::round(value)); }, set, [set, reset] { set(reset); }); column->addWidget(row); diff --git a/src/UI/CameraRawGeometryCalibrationControls.cpp b/src/UI/CameraRawGeometryCalibrationControls.cpp index fe2b50c..3baecf3 100644 --- a/src/UI/CameraRawGeometryCalibrationControls.cpp +++ b/src/UI/CameraRawGeometryCalibrationControls.cpp @@ -55,7 +55,7 @@ void write(EditorSession &session, const std::function<void(CameraRawSettings &) CameraRawRow *rounded(const QString &name, const QString &title, const QString &help, double low, double high, std::function<double()> value, std::function<void(double)> set, QWidget *parent) { - return new CameraRawRow({.name = name, .title = title, .help = help, .low = low, .high = high, .titleWidth = CameraRawControls::labelWidth}, + return new CameraRawRow({.name = name, .title = title, .help = help, .low = low, .high = high, .titleWidth = CameraRawControls::labelWidth, .scrub = 1}, std::move(value), [set](double number) { set(std::round(number)); }, set, [set] { set(0); }, parent); } } diff --git a/src/UI/CameraRawRow.cpp b/src/UI/CameraRawRow.cpp index 3238a48..27211f9 100644 --- a/src/UI/CameraRawRow.cpp +++ b/src/UI/CameraRawRow.cpp @@ -1,5 +1,6 @@ #include "UI/CameraRawRow.h" #include "UI/ColorPickerSheet.h" +#include "UI/NumericScrub.h" #include <QEvent> #include <QHBoxLayout> #include <QLocale> @@ -38,6 +39,16 @@ CameraRawRow::CameraRawRow(Spec spec, std::function<double()> value, std::functi row->setSpacing(10); row->addWidget(m_title); row->addWidget(m_slider, 1); + // Before the reset's filter, which runs first and passes on. + if (m_spec.scrub) { + new NumericScrub(m_title, {.sensitivity = *m_spec.scrub, .low = m_spec.low, .high = m_spec.high, .step = std::nullopt, .value = m_value, + .set = [this, type](double value) { + type(value); + if (m_field) + m_field->setModified(false); + synchronize(); + }}); + } if (m_spec.fieldWidth > 0) { m_field = new PickerField([this, type = std::move(type)] { bool number = false; @@ -73,7 +84,8 @@ bool CameraRawRow::eventFilter(QObject *watched, QEvent *event) { if (watched == m_title && event->type() == QEvent::MouseButtonDblClick) { m_reset(); - return true; + // A scrub still takes the second press, as Swift's drag. + return !m_spec.scrub; } return QWidget::eventFilter(watched, event); } diff --git a/src/UI/CameraRawRow.h b/src/UI/CameraRawRow.h index d434726..33f969a 100644 --- a/src/UI/CameraRawRow.h +++ b/src/UI/CameraRawRow.h @@ -2,6 +2,7 @@ #include "UI/CameraRawSlider.h" #include <QWidget> #include <functional> +#include <optional> class PickerField; class QLabel; @@ -25,6 +26,8 @@ public: int fieldWidth = 56; // A double click on the title resets it. bool titleResets = false; + // Swift's scrubbable title: a point's worth, through `type`. + std::optional<double> scrub = std::nullopt; }; // `slide` takes the slider's own value, `type` a typed number. CameraRawRow(Spec spec, std::function<double()> value, std::function<void(double)> slide, std::function<void(double)> type, diff --git a/src/UI/CanvasSizeSheet.cpp b/src/UI/CanvasSizeSheet.cpp index 870dd7b..bbc3985 100644 --- a/src/UI/CanvasSizeSheet.cpp +++ b/src/UI/CanvasSizeSheet.cpp @@ -4,6 +4,7 @@ #include "UI/ByteCounts.h" #include "UI/ColorPaletteControls.h" #include "UI/ColorPickerSheet.h" +#include "UI/NumericScrub.h" #include <QButtonGroup> #include <QCheckBox> #include <QColorDialog> @@ -22,6 +23,38 @@ const std::array<QString, 9> anchorNames = {QStringLiteral("Top left"), QStr QStringLiteral("Middle left"), QStringLiteral("Center"), QStringLiteral("Middle right"), QStringLiteral("Bottom left"), QStringLiteral("Bottom center"), QStringLiteral("Bottom right")}; +// Swift's scrubRange: the pixel limits, shown in the draft's unit. +std::pair<double, double> scrubRange(const CanvasSizeDraft &draft, bool widthAxis) +{ + const double original = double(widthAxis ? draft.originalWidth : draft.originalHeight); + const double other = double(widthAxis ? draft.originalHeight : draft.originalWidth); + const double lower = draft.locked ? std::max(1.0, original / other) : 1.0; + const double upper = draft.locked ? std::min(30000.0, 30000 * original / other) : 30000.0; + const auto displayed = [&](double pixels) { + const double difference = pixels - (draft.relative ? original : 0); + switch (draft.unit) { + case CanvasUnit::pixels: return difference; + case CanvasUnit::percent: return difference / original * 100; + case CanvasUnit::inches: return difference / draft.resolution; + case CanvasUnit::centimeters: return difference / draft.resolution * 2.54; + } + throw std::logic_error("no such unit"); + }; + return {displayed(lower), displayed(upper)}; +} + +// Swift's scrubSensitivity: a point is a pixel in any unit. +double scrubSensitivity(const CanvasSizeDraft &draft, bool widthAxis) +{ + switch (draft.unit) { + case CanvasUnit::pixels: return 1; + case CanvasUnit::percent: return 100 / double(widthAxis ? draft.originalWidth : draft.originalHeight); + case CanvasUnit::inches: return 1 / draft.resolution; + case CanvasUnit::centimeters: return 2.54 / draft.resolution; + } + throw std::logic_error("no such unit"); +} + QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) { auto *label = new QLabel(words, parent); @@ -117,9 +150,12 @@ CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor fo }); fields->addWidget(unitsLabel, 0, 0); fields->addWidget(units, 0, 1); - fields->addWidget(new QLabel(QStringLiteral("Width"), this), 1, 0); + auto *width = new QLabel(QStringLiteral("Width"), this); + auto *height = new QLabel(QStringLiteral("Height"), this); + m_scrubs = {scrub(width, true), scrub(height, false)}; + fields->addWidget(width, 1, 0); fields->addWidget(m_width, 1, 1); - fields->addWidget(new QLabel(QStringLiteral("Height"), this), 2, 0); + fields->addWidget(height, 2, 0); fields->addWidget(m_height, 2, 1); column->addLayout(fields); @@ -240,6 +276,17 @@ PickerField *CanvasSizeSheet::dimension(bool widthAxis) return field; } +NumericScrub *CanvasSizeSheet::scrub(QLabel *title, bool widthAxis) +{ + // Dragged sizes snap to whole units; synchronize sets the limits. + return new NumericScrub(title, {.sensitivity = 1, .low = 0, .high = 0, .step = 1, .value = [this, widthAxis] { return m_draft.displayed(widthAxis); }, + .set = [this, widthAxis](double value) { + m_draft.set(value, widthAxis); + (widthAxis ? m_width : m_height)->setModified(false); + synchronize(); + }}); +} + std::optional<CanvasExtensionColor> CanvasSizeSheet::fill() const { const auto colour = [](PaletteColor value) { return CanvasExtensionColor{value.red, value.green, value.blue}; }; @@ -272,6 +319,8 @@ void CanvasSizeSheet::synchronize() { for (const auto &[field, widthAxis] : {std::pair(m_width, true), std::pair(m_height, false)}) { // A field being typed in keeps its typing. + const auto [low, high] = scrubRange(m_draft, widthAxis); + m_scrubs[widthAxis ? 0 : 1]->reshape(scrubSensitivity(m_draft, widthAxis), low, high); const QString number = shown(m_draft.displayed(widthAxis), field->locale()); if (!field->isModified() && field->text() != number) field->setText(number); diff --git a/src/UI/CanvasSizeSheet.h b/src/UI/CanvasSizeSheet.h index df07db8..aa97e0a 100644 --- a/src/UI/CanvasSizeSheet.h +++ b/src/UI/CanvasSizeSheet.h @@ -6,6 +6,7 @@ #include <array> #include <functional> +class NumericScrub; class PickerField; class QButtonGroup; class QComboBox; @@ -22,6 +23,8 @@ public: private: PickerField *dimension(bool widthAxis); + // Swift's scrubbable Width or Height title. + NumericScrub *scrub(QLabel *title, bool widthAxis); std::optional<CanvasExtensionColor> fill() const; void pickCustomColor(); void synchronize(); @@ -34,6 +37,8 @@ private: PaletteColor m_custom = PaletteColor::white(); PickerField *const m_width; PickerField *const m_height; + // Their limits follow the unit, Relative and Lock. + std::array<NumericScrub *, 2> m_scrubs{}; QLabel *const m_note; QButtonGroup *const m_anchors; QLabel *const m_anchorName; diff --git a/src/UI/ColorPickerSheet.cpp b/src/UI/ColorPickerSheet.cpp index 57a09a4..1cab02b 100644 --- a/src/UI/ColorPickerSheet.cpp +++ b/src/UI/ColorPickerSheet.cpp @@ -1,4 +1,5 @@ #include "UI/ColorPickerSheet.h" +#include "UI/NumericScrub.h" #include "UI/KeyboardShortcuts.h" #include <QFontDatabase> #include <QGridLayout> @@ -205,7 +206,19 @@ ColorPickerSheet::ColorPickerSheet(EditorSession &session, std::function<void(bo fields->setHorizontalSpacing(8); fields->setVerticalSpacing(6); for (int index = 0; index < 3; ++index) { - fields->addWidget(label(QString(QStringLiteral("RGB").at(index)), this), index, 0); + QLabel *name = label(QString(QStringLiteral("RGB").at(index)), this); + // Swift's scrubbable channel: whole levels, replacing typing. + new NumericScrub(name, {.sensitivity = 1, .low = 0, .high = 255, .step = 1, + .value = [this, index] { + const PaletteColor color = m_session.colorPicker().value().color(); + return std::round((index == 0 ? color.red : index == 1 ? color.green : color.blue) * 255); + }, + .set = [this, index](double value) { + setChannel(index, value); + m_channels[size_t(index)]->setModified(false); + synchronize(); + }}); + fields->addWidget(name, index, 0); fields->addWidget(m_channels[size_t(index)], index, 1, Qt::AlignLeft); } m_hex->setObjectName(QStringLiteral("hex")); diff --git a/src/UI/EffectsSheet.cpp b/src/UI/EffectsSheet.cpp index 8fd64ba..c8a52e5 100644 --- a/src/UI/EffectsSheet.cpp +++ b/src/UI/EffectsSheet.cpp @@ -4,6 +4,7 @@ #include "UI/ColorPickerSheet.h" #include <QButtonGroup> #include <QHBoxLayout> +#include "UI/NumericScrub.h" #include <QLabel> #include <QLocale> #include <QPushButton> @@ -195,6 +196,15 @@ void EffectsSheet::slider(const QString &title, std::function<std::optional<doub slider->setRange(0, travel); slider->setFixedWidth(130); label->setBuddy(slider); + // Swift's scrubbable title, over the range typing reaches. + new NumericScrub(label, {.sensitivity = 1, .low = low, .high = typedHigh.value_or(high), .step = std::nullopt, + .value = [this, index] { return m_sliders[index].value(m_session.editingEffects()).value_or(m_sliders[index].low); }, + .set = [this, index](double amount) { + const Slider &at = m_sliders[index]; + apply(at, amount); + at.field->setModified(false); + synchronize(); + }}); connect(slider, &QSlider::valueChanged, this, [this, index](int position) { const Slider &at = m_sliders[index]; apply(at, at.low + (at.high - at.low) * position / travel); diff --git a/src/UI/FilterSheet.cpp b/src/UI/FilterSheet.cpp index 3c211ca..648ac3a 100644 --- a/src/UI/FilterSheet.cpp +++ b/src/UI/FilterSheet.cpp @@ -1,4 +1,5 @@ #include "UI/FilterSheet.h" +#include "UI/NumericScrub.h" #include "UI/KeyboardShortcuts.h" #include "UI/ColorPaletteControls.h" #include "UI/ColorPickerSheet.h" @@ -257,6 +258,17 @@ void FilterSheet::control(const QString &title, std::function<double &(FilterSet auto *label = new QLabel(title, box); label->setMinimumWidth(60); label->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + // Swift's scrubbable title: a point is the field's last decimal. + new NumericScrub(label, {.sensitivity = 1 / std::pow(10.0, decimals), .low = low, .high = high, .step = std::nullopt, + .value = [this, index] { + FilterSettings current = settings(); + return m_controls[index].key(current); + }, + .set = [this, index](double value) { + update([this, index, value](FilterSettings &settings) { m_controls[index].key(settings) = value; }); + m_controls[index].field->setModified(false); + synchronize(); + }}); auto *slider = new QSlider(Qt::Horizontal, box); slider->setObjectName(name + QStringLiteral("Slider")); slider->setRange(0, travel); diff --git a/src/UI/HueSaturationSheet.cpp b/src/UI/HueSaturationSheet.cpp index 35b20bf..03fc737 100644 --- a/src/UI/HueSaturationSheet.cpp +++ b/src/UI/HueSaturationSheet.cpp @@ -2,6 +2,7 @@ #include "UI/KeyboardShortcuts.h" #include "Rendering/EyedropperIcon.h" #include "UI/ColorPickerSheet.h" +#include "UI/NumericScrub.h" #include <QCheckBox> #include <QComboBox> #include <QFrame> @@ -373,6 +374,14 @@ QWidget *HueSaturationSheet::row(size_t index) layout->setSpacing(10); auto *label = new QLabel(title, box); label->setFixedWidth(76); + // Swift's scrubbable title; synchronize sets its range. + m_scrubs[index] = new NumericScrub(label, {.sensitivity = 1, .low = 0, .high = 0, .step = std::nullopt, + .value = [this, index] { return (current(m_session).*rows[index].value)(); }, + .set = [this, index](double value) { + change([&](HueSaturationSettings &settings) { (settings.*rows[index].set)(value); }); + m_fields[index]->setModified(false); + synchronize(); + }}); auto *slider = new QSlider(Qt::Horizontal, box); slider->setObjectName(title.toLower() + QStringLiteral("Slider")); label->setBuddy(slider); @@ -443,6 +452,7 @@ void HueSaturationSheet::synchronize() m_targeting->setChecked(m_session.hueTargeting()); for (size_t index = 0; index < rows.size(); ++index) { const auto [low, high] = bounds(index, settings.colorize); + m_scrubs[index]->reshape(1, low, high); const double value = (settings.*rows[index].value)(); { // The sliders show the session's numbers without writing back. diff --git a/src/UI/HueSaturationSheet.h b/src/UI/HueSaturationSheet.h index 81ca2cb..5758eff 100644 --- a/src/UI/HueSaturationSheet.h +++ b/src/UI/HueSaturationSheet.h @@ -5,6 +5,7 @@ #include <functional> #include <optional> +class NumericScrub; class PickerField; class QCheckBox; class QComboBox; @@ -55,6 +56,8 @@ private: // Hue, Saturation, Lightness; the rows make them. std::array<QSlider *, 3> m_sliders{}; std::array<PickerField *, 3> m_fields{}; + // Each title's scrub; Colorize changes its range. + std::array<NumericScrub *, 3> m_scrubs{}; const std::array<QWidget *, 3> m_rows; SpectrumEditor *const m_spectrum; QCheckBox *const m_invert; diff --git a/src/UI/ImageSizeSheet.cpp b/src/UI/ImageSizeSheet.cpp index 1d0c868..4052085 100644 --- a/src/UI/ImageSizeSheet.cpp +++ b/src/UI/ImageSizeSheet.cpp @@ -2,6 +2,7 @@ #include "Document/DocumentLimits.h" #include "UI/KeyboardShortcuts.h" #include "UI/ColorPickerSheet.h" +#include "UI/NumericScrub.h" #include <QCheckBox> #include <QComboBox> #include <QGridLayout> @@ -53,14 +54,8 @@ ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<voi m_resolutionField(new PickerField([this] { bool number = false; const double typed = m_resolutionField->locale().toDouble(m_resolutionField->text(), &number); - if (m_resolutionField->isModified() && number) { - // Swift's onChange: a physical size keeps, its pixels scale. - const double old = std::exchange(m_resolution, typed); - if (m_resample && physical(m_unit) && old > 0 && typed > 0 && std::isfinite(typed)) { - m_width *= typed / old; - m_height *= typed / old; - } - } + if (m_resolutionField->isModified() && number) + setResolution(typed); m_resolutionField->setModified(false); synchronize(); }, nullptr, this)), @@ -91,9 +86,11 @@ ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<voi }); fields->addWidget(units, 0, 0); fields->addWidget(m_units, 0, 1); - fields->addWidget(new QLabel(QStringLiteral("Width"), this), 1, 0); + m_titles = {new QLabel(QStringLiteral("Width"), this), new QLabel(QStringLiteral("Height"), this)}; + m_scrubs = {scrub(m_titles[0], true), scrub(m_titles[1], false)}; + fields->addWidget(m_titles[0], 1, 0); fields->addWidget(m_widthField, 1, 1); - fields->addWidget(new QLabel(QStringLiteral("Height"), this), 2, 0); + fields->addWidget(m_titles[1], 2, 0); fields->addWidget(m_heightField, 2, 1); column->addLayout(fields); @@ -104,7 +101,15 @@ ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<voi }); column->addWidget(m_lock); auto *resolution = new QHBoxLayout; - resolution->addWidget(new QLabel(QStringLiteral("Resolution"), this)); + auto *resolutionTitle = new QLabel(QStringLiteral("Resolution"), this); + // Swift's scrubbable Resolution: whole pixels per inch. + new NumericScrub(resolutionTitle, {.sensitivity = 1, .low = 1, .high = 9600, .step = 1, .value = [this] { return m_resolution; }, + .set = [this](double value) { + setResolution(value); + m_resolutionField->setModified(false); + synchronize(); + }}); + resolution->addWidget(resolutionTitle); m_resolutionField->setObjectName(QStringLiteral("imageResolution")); m_resolutionField->setPlaceholderText(QStringLiteral("Resolution")); m_resolutionField->setAccessibleName(QStringLiteral("Resolution")); @@ -223,6 +228,61 @@ void ImageSizeSheet::setDimension(double value, bool isWidth) } } +void ImageSizeSheet::setResolution(double value) +{ + const double old = std::exchange(m_resolution, value); + if (m_resample && physical(m_unit) && old > 0 && value > 0 && std::isfinite(value)) { + m_width *= value / old; + m_height *= value / old; + } +} + +NumericScrub *ImageSizeSheet::scrub(QLabel *title, bool isWidth) +{ + // Whole units; synchronize sets the limits and the gate. + return new NumericScrub(title, {.sensitivity = 0, .low = 0, .high = 0, .step = 1, + .value = [this, isWidth] { return display(isWidth ? m_width : m_height, isWidth ? m_originalWidth : m_originalHeight); }, + .set = [this, isWidth](double value) { + setDimension(value, isWidth); + (isWidth ? m_widthField : m_heightField)->setModified(false); + synchronize(); + }}); +} + +bool ImageSizeSheet::canScrubDimensions() const +{ + return !physical(m_unit) || (std::isfinite(m_resolution) && m_resolution > 0); +} + +// Swift's scrubRange: pixel limits, or print sizes without resampling. +std::pair<double, double> ImageSizeSheet::scrubRange(bool isWidth) const +{ + const double pixels = isWidth ? m_width : m_height, other = isWidth ? m_height : m_width; + if (!m_resample) { + const double multiplier = m_unit == QLatin1String("Centimeters") ? 2.54 : 1; + return {pixels * multiplier / 9600, pixels * multiplier}; + } + const double minimum = m_locked ? std::max(1.0, pixels / other) : 1.0; + const double dimensionLimit = m_locked ? std::min(30000.0, 30000 * pixels / other) : 30000.0; + const double areaLimit = m_locked ? std::sqrt(100'000'000 * pixels / other) : 100'000'000 / other; + const double maximum = std::max(minimum, std::min(dimensionLimit, areaLimit)); + const qint64 original = isWidth ? m_originalWidth : m_originalHeight; + return {display(minimum, original), display(maximum, original)}; +} + +double ImageSizeSheet::scrubSensitivity(bool isWidth) const +{ + if (!m_resample) + return m_unit == QLatin1String("Centimeters") ? 0.0254 : 0.01; + if (m_unit == QLatin1String("Percent")) + return 100 / double(isWidth ? m_originalWidth : m_originalHeight); + if (m_unit == QLatin1String("Inches")) + return 1 / m_resolution; + if (m_unit == QLatin1String("Centimeters")) + return 2.54 / m_resolution; + return 1; +} + // Swift's onChange: without resampling the pixels stay the document's. void ImageSizeSheet::setResample(bool enabled) { @@ -252,6 +312,12 @@ void ImageSizeSheet::synchronize() } m_units->setCurrentText(m_unit); for (const auto &[field, isWidth] : {std::pair(m_widthField, true), std::pair(m_heightField, false)}) { + const bool scrubs = canScrubDimensions(); + m_titles[isWidth ? 0 : 1]->setEnabled(scrubs); + if (scrubs) { + const auto [low, high] = scrubRange(isWidth); + m_scrubs[isWidth ? 0 : 1]->reshape(scrubSensitivity(isWidth), low, high); + } // A field being typed in keeps its typing. const QString number = shown(display(isWidth ? m_width : m_height, isWidth ? m_originalWidth : m_originalHeight), field->locale()); if (!field->isModified() && field->text() != number) diff --git a/src/UI/ImageSizeSheet.h b/src/UI/ImageSizeSheet.h index 6572221..923c443 100644 --- a/src/UI/ImageSizeSheet.h +++ b/src/UI/ImageSizeSheet.h @@ -2,8 +2,10 @@ #include "Document/EditorSession+Model.h" #include "IO/ImageResizer.h" #include <QWidget> +#include <array> #include <functional> +class NumericScrub; class PickerField; class QCheckBox; class QComboBox; @@ -21,6 +23,13 @@ private: double display(double pixels, qint64 original) const; PickerField *dimension(bool isWidth); void setDimension(double value, bool isWidth); + // Swift's onChange: a physical size keeps, its pixels scale. + void setResolution(double value); + // Swift's scrubbable Width or Height title and its limits. + NumericScrub *scrub(QLabel *title, bool isWidth); + bool canScrubDimensions() const; + std::pair<double, double> scrubRange(bool isWidth) const; + double scrubSensitivity(bool isWidth) const; void setResample(bool enabled); void synchronize(); @@ -37,6 +46,8 @@ private: QComboBox *const m_units; PickerField *const m_widthField; PickerField *const m_heightField; + std::array<QLabel *, 2> m_titles{}; + std::array<NumericScrub *, 2> m_scrubs{}; QCheckBox *const m_lock; PickerField *const m_resolutionField; QWidget *const m_samplingRow; diff --git a/src/UI/LevelsSheet.cpp b/src/UI/LevelsSheet.cpp index 6134a9e..7ce4d0c 100644 --- a/src/UI/LevelsSheet.cpp +++ b/src/UI/LevelsSheet.cpp @@ -1,4 +1,5 @@ #include "UI/LevelsSheet.h" +#include "UI/NumericScrub.h" #include "UI/KeyboardShortcuts.h" #include "Rendering/EyedropperIcon.h" #include <QEvent> @@ -327,7 +328,24 @@ PickerField *LevelsSheet::field(size_t index) auto *layout = new QVBoxLayout(box); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(5); - layout->addWidget(caption(name, box)); + QLabel *title = caption(name, box); + layout->addWidget(title); + // Swift's scrubbable caption, over gamma's or a tone's range. + const bool gamma = entries[index].decimals > 0; + new NumericScrub(title, {.sensitivity = gamma ? 0.01 : 1, .low = gamma ? 0.1 : 0, .high = gamma ? 9.99 : 255, .step = std::nullopt, + .value = [this, index] { + const std::optional<LevelsEdit> &edit = m_session.levels(); + return edit ? edit->settings.current().*entries[index].value : 0.0; + }, + .set = [this, index](double value) { + change([&](LevelsSettings &settings) { + LevelRange range = settings.current(); + range.*entries[index].value = value; + settings.setCurrent(range); + }); + m_fields[index]->setModified(false); + synchronize(); + }}); // A number in the user's locale applies; other text reverts. auto *entry = new PickerField([this, index] { PickerField &edited = *m_fields[index]; diff --git a/src/UI/NumericScrub.cpp b/src/UI/NumericScrub.cpp index c773ddc..80c5942 100644 --- a/src/UI/NumericScrub.cpp +++ b/src/UI/NumericScrub.cpp @@ -63,6 +63,13 @@ bool NumericScrub::eventFilter(QObject *watched, QEvent *event) return true; } +void NumericScrub::reshape(double sensitivity, double low, double high) +{ + m_options.sensitivity = sensitivity; + m_options.low = low; + m_options.high = high; +} + void NumericScrub::end() { if (!m_press) diff --git a/src/UI/NumericScrub.h b/src/UI/NumericScrub.h index 2a9d9b8..eed78ef 100644 --- a/src/UI/NumericScrub.h +++ b/src/UI/NumericScrub.h @@ -25,13 +25,15 @@ public: NumericScrub(QWidget *label, Options options); // Ends a drag as a release would. void end(); + // New limits, as SwiftUI's next render hands the modifier. + void reshape(double sensitivity, double low, double high); protected: bool eventFilter(QObject *watched, QEvent *event) override; private: QWidget *const m_label; - const Options m_options; + Options m_options; // The press, until the release; Swift's gesture begins there. std::optional<QPointF> m_press; // Swift's startValue: set once the drag moves a point. diff --git a/tests/ScrubFixtures.h b/tests/ScrubFixtures.h index 8e8af6b..dc1eb9e 100644 --- a/tests/ScrubFixtures.h +++ b/tests/ScrubFixtures.h @@ -35,7 +35,7 @@ inline void drag(QWidget &label, double dx, bool release = true) send(label, QEvent::MouseButtonRelease, QPointF(2 + dx, 2), Qt::LeftButton, Qt::NoButton); } -// Types 7, which applies, then scrubs, holding the press. +// Types 7, then scrubs, holding the press. inline QString scrubOverTyping(QLineEdit &field, QWidget &label, double dx) { field.setFocus(); @@ -48,6 +48,8 @@ inline QString scrubOverTyping(QLineEdit &field, QWidget &label, double dx) send(label, QEvent::MouseButtonRelease, QPointF(2 + dx, 2), Qt::LeftButton, Qt::NoButton); if (field.text() != during || !field.hasFocus()) throw std::runtime_error("the release changed the field"); + // Leaves while its sheet lives, as a closing panel does. + field.clearFocus(); return during; } diff --git a/tests/ScrubbableCameraRawTests.cpp b/tests/ScrubbableCameraRawTests.cpp new file mode 100644 index 0000000..21da49c --- /dev/null +++ b/tests/ScrubbableCameraRawTests.cpp @@ -0,0 +1,195 @@ +#include "Document/BrushStroke.h" +#include "ScrubFixtures.h" +#include "UI/CameraRawControls.h" +#include "UI/CameraRawRow.h" +#include <QAbstractButton> + +// Swift's scrubbable Camera Raw titles, row by row. +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); + showActive(*controls); + } + CameraRawSettings raw() const { return session.filterEdit().value().settings.cameraRaw; } + void set(const std::function<void(CameraRawSettings &)> &change) + { + FilterSettings settings = session.filterEdit().value().settings; + change(settings.cameraRaw); + session.updateFilter(settings, true); + } + void panel(const std::function<void(CameraRawPanel &)> &change) + { + CameraRawPanel copy = session.filterEdit().value().rawPanel; + change(copy); + session.setCameraRawPanel(copy); + } + // The title of the row whose slider is `name`. + QLabel &title(const QString &name) const + { + auto *slider = controls->findChild<QSlider *>(name + QStringLiteral("Slider")); + if (!slider) + throw std::runtime_error(name.toStdString()); + return *slider->parentWidget()->findChildren<QLabel *>(Qt::FindDirectChildrenOnly).value(0); + } + bool scrubs(const QString &name) const { return title(name).cursor().shape() == Qt::SizeHorCursor; } +}; + +// Presses twice, the second a double click, then drags. +void doubleClickDrag(QWidget &label, double dx) +{ + send(label, QEvent::MouseButtonPress, QPointF(2, 2), Qt::LeftButton, Qt::LeftButton); + send(label, QEvent::MouseButtonRelease, QPointF(2, 2), Qt::LeftButton, Qt::NoButton); + send(label, QEvent::MouseButtonDblClick, QPointF(2, 2), Qt::LeftButton, Qt::LeftButton); + send(label, QEvent::MouseMove, QPointF(2 + dx, 2), Qt::NoButton, Qt::LeftButton); + send(label, QEvent::MouseButtonRelease, QPointF(2 + dx, 2), Qt::LeftButton, Qt::NoButton); +} +} + +class ScrubbableCameraRawTests : public QObject { + Q_OBJECT +private slots: + void theMainRowsScrubALastDecimalAPoint(); + void aDoubleClickResetsAndStillScrubs(); + void curveAndHslFamilyTitlesScrubColorAndPointTitlesDoNot(); + void detailOpticsGeometryAndCalibrationScrubAUnitAPoint(); + void gradingTitlesStayPassive(); + void altHeldScrubsShowNoPreview(); +}; + +void ScrubbableCameraRawTests::theMainRowsScrubALastDecimalAPoint() +{ + Panel panel; + // Two decimals: a point is a hundredth, unrounded. + drag(panel.title("exposure"), 50.5); + QVERIFY(qAbs(panel.raw().exposure - 0.505) < 1e-9); + drag(panel.title("exposure"), 9000); + QCOMPARE(panel.raw().exposure, 5.0); + drag(panel.title("exposure"), -90000); + QCOMPARE(panel.raw().exposure, -5.0); + drag(panel.title("contrast"), -30.5); + QCOMPARE(panel.raw().contrast, -30.5); + QCOMPARE(panel.raw().exposure, -5.0); + // The typed setter: Custom white balance, no clipping view. + panel.set([](CameraRawSettings &settings) { settings.whiteBalance = CameraRawWhiteBalance::automatic; }); + panel.panel([](CameraRawPanel &raw) { raw.clipping = CameraRawClipping::highlights; }); + drag(panel.title("temperature"), 12); + QCOMPARE(panel.raw().temperature, 12.0); + QVERIFY(panel.raw().whiteBalance == CameraRawWhiteBalance::custom); + QVERIFY(!panel.session.filterEdit().value().rawPanel.clipping); + panel.set([](CameraRawSettings &settings) { settings.whiteBalance = CameraRawWhiteBalance::automatic; }); + drag(panel.title("tint"), -7); + QCOMPARE(panel.raw().tint, -7.0); + QVERIFY(panel.raw().whiteBalance == CameraRawWhiteBalance::custom); + QCOMPARE(scrubOverTyping(find<QLineEdit>(*panel.controls, "contrastField"), panel.title("contrast"), 3), QString("-28")); +} + +void ScrubbableCameraRawTests::aDoubleClickResetsAndStillScrubs() +{ + Panel panel; + drag(panel.title("contrast"), 40); + QCOMPARE(panel.raw().contrast, 40.0); + // The reset runs first; the second press scrubs from zero. + doubleClickDrag(panel.title("contrast"), 12); + QCOMPARE(panel.raw().contrast, 12.0); + drag(panel.title("contrast"), -900); + QCOMPARE(panel.raw().contrast, -100.0); +} + +void ScrubbableCameraRawTests::curveAndHslFamilyTitlesScrubColorAndPointTitlesDoNot() +{ + Panel panel; + drag(panel.title("curveHighlights"), 20.5); + QCOMPARE(panel.raw().curve.highlights, 20.5); + drag(panel.title("curveHighlights"), -900); + QCOMPARE(panel.raw().curve.highlights, -100.0); + drag(panel.title("refineSaturation"), 900); + QCOMPARE(panel.raw().curve.refineSaturation, 100.0); + // HSL families scrub the shown component. + drag(panel.title("familyReds"), 15); + QCOMPARE(panel.raw().mixer.hue[0], 15.0); + panel.panel([](CameraRawPanel &raw) { raw.mixerTab = CameraRawMixerTab::saturation; }); + drag(panel.title("familyOranges"), -900); + QCOMPARE(panel.raw().mixer.saturation[1], -100.0); + QCOMPARE(panel.raw().mixer.hue[1], 0.0); + // Swift's colorSlider and pointSlider keep plain titles. + panel.panel([](CameraRawPanel &raw) { raw.mixerPage = CameraRawMixerPage::color; }); + for (const QString &name : {QStringLiteral("colorHue"), QStringLiteral("colorSaturation"), QStringLiteral("colorLuminance")}) + QVERIFY(!panel.scrubs(name)); + panel.set([](CameraRawSettings &settings) { settings.mixer.points = {CameraRawPointColor{.hue = 30, .saturation = 1, .luminance = 0.5}}; }); + panel.panel([](CameraRawPanel &raw) { raw.mixerPage = CameraRawMixerPage::point; }); + QVERIFY(!panel.scrubs("hueShift")); + QVERIFY(!panel.scrubs("saturationShift")); + drag(panel.title("hueShift"), 30); + QCOMPARE(panel.raw().mixer.points[0].hueShift, 0.0); +} + +void ScrubbableCameraRawTests::detailOpticsGeometryAndCalibrationScrubAUnitAPoint() +{ + Panel panel; + // The typed setter: no sharpen-mask view, unrounded. + panel.panel([](CameraRawPanel &raw) { raw.sharpenMask = true; }); + drag(panel.title("sharpenMasking"), 10.5); + QCOMPARE(panel.raw().detail.sharpenMasking, 10.5); + QVERIFY(!panel.session.filterEdit().value().rawPanel.sharpenMask); + drag(panel.title("sharpenAmount"), 900); + QCOMPARE(panel.raw().detail.sharpenAmount, 150.0); + drag(panel.title("sharpenAmount"), -900); + QCOMPARE(panel.raw().detail.sharpenAmount, 0.0); + drag(panel.title("distortion"), -12.5); + QCOMPARE(panel.raw().optics.distortion, -12.5); + drag(panel.title("lensMidpoint"), 900); + QCOMPARE(panel.raw().optics.vignetteMidpoint, 100.0); + drag(panel.title("rotate"), 900); + QCOMPARE(panel.raw().geometry.rotate, 45.0); + drag(panel.title("vertical"), 7.5); + QCOMPARE(panel.raw().geometry.vertical, 7.5); + drag(panel.title("shadowTint"), -900); + QCOMPARE(panel.raw().calibration.shadowTint, -100.0); + drag(panel.title("redHue"), 3.5); + QCOMPARE(panel.raw().calibration.redHue, 3.5); +} + +void ScrubbableCameraRawTests::gradingTitlesStayPassive() +{ + Panel panel; + QVERIFY(!panel.scrubs("gradeBlending")); + QVERIFY(!panel.scrubs("gradeBalance")); + const double blending = panel.raw().grading.blending; + drag(panel.title("gradeBlending"), 20); + QCOMPARE(panel.raw().grading.blending, blending); +} + +void ScrubbableCameraRawTests::altHeldScrubsShowNoPreview() +{ + Panel panel; + QWindow *window = panel.controls->windowHandle(); + QTest::keyPress(window, Qt::Key_Alt, Qt::AltModifier); + QVERIFY(QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier)); + // The slider shows clipping under Alt; a scrub never does. + drag(panel.title("exposure"), 10.5); + QVERIFY(qAbs(panel.raw().exposure - 0.105) < 1e-9); + QVERIFY(!panel.session.filterEdit().value().rawPanel.clipping); + drag(panel.title("blacks"), -12.5); + QCOMPARE(panel.raw().blacks, -12.5); + QVERIFY(!panel.session.filterEdit().value().rawPanel.clipping); + drag(panel.title("sharpenMasking"), 20.5); + QCOMPARE(panel.raw().detail.sharpenMasking, 20.5); + QVERIFY(!panel.session.filterEdit().value().rawPanel.sharpenMask); + // The slider itself, for contrast, shows the view under Alt. + panel.controls->findChild<QSlider *>(QStringLiteral("exposureSlider"))->setValue(100); + QVERIFY(panel.session.filterEdit().value().rawPanel.clipping.has_value()); + QTest::keyRelease(window, Qt::Key_Alt, Qt::NoModifier); +} + +QTEST_MAIN(ScrubbableCameraRawTests) +#include "ScrubbableCameraRawTests.moc" diff --git a/tests/ScrubbableSheetsTests.cpp b/tests/ScrubbableSheetsTests.cpp new file mode 100644 index 0000000..85a920e --- /dev/null +++ b/tests/ScrubbableSheetsTests.cpp @@ -0,0 +1,383 @@ +#include "Document/BrushStroke.h" +#include "ScrubFixtures.h" +#include "UI/CanvasSizeSheet.h" +#include "UI/ColorPickerSheet.h" +#include "UI/EffectsSheet.h" +#include "UI/FilterSheet.h" +#include "UI/HueSaturationSheet.h" +#include "UI/ImageSizeSheet.h" +#include "UI/LevelsSheet.h" +#include <QCheckBox> +#include <QComboBox> + +// Swift's scrubbable titles in the sheets and panels. +namespace { +// A document with one coloured layer, left active. +void paint(EditorSession &session) +{ + session.createDocument(8, 8); + QImage image = BrushRaster::context(8, 8, false); + image.fill(QColor(120, 160, 200)); + session.insert(ImportedImage(image, image, QStringLiteral("Colour"))); +} + +void choose(QComboBox &box, const QString &text) +{ + const int index = box.findText(text); + box.setCurrentIndex(index); + emit box.activated(index); +} +} + +class ScrubbableSheetsTests : public QObject { + Q_OBJECT +private slots: + void effectsTitlesScrubToTheTypedLimit(); + void hueSaturationTitlesFollowColorize(); + void levelsTitlesScrubTonesAndGamma(); + void filterTitlesMoveALastDecimalAPoint(); + void colorPickerChannelsScrubWholeLevels(); + void filterTitlesKeepEveryDecimalsSensitivity(); + void canvasSizeTitlesScrubInTheirUnit(); + void canvasSizeCentimetresScrubTheirLimits(); + void imageSizeTitlesScrubPixelsPrintSizeAndResolution(); + void imageSizeLimitsFollowUnitsLockAndResolution(); +}; + +void ScrubbableSheetsTests::effectsTitlesScrubToTheTypedLimit() +{ + EditorSession session; + paint(session); + session.addEffect(LayerEffectKind::stroke); + EffectsSheet sheet(session, LayerEffectKind::stroke); + showActive(sheet); + const auto stroke = [&session] { return session.editingEffects().stroke.value(); }; + drag(scrubbed(sheet, "Size"), 10); + QCOMPARE(stroke().size, 14.0); + // Past the slider's end, to what typing reaches. + drag(scrubbed(sheet, "Size"), 9000); + QCOMPARE(stroke().size, 500.0); + drag(scrubbed(sheet, "Size"), -9000); + QCOMPARE(stroke().size, 0.0); + drag(scrubbed(sheet, "Opacity"), -30.5); + QVERIFY(qAbs(stroke().opacity - 0.695) < 1e-12); + QCOMPARE(scrubOverTyping(find<QLineEdit>(sheet, "sizeField"), scrubbed(sheet, "Size"), 3), QString("3")); +} + +void ScrubbableSheetsTests::hueSaturationTitlesFollowColorize() +{ + EditorSession session; + paint(session); + session.beginHueSaturation(); + HueSaturationSheet sheet(session); + showActive(sheet); + const auto settings = [&session] { return session.hueSaturation().value().settings; }; + drag(scrubbed(sheet, "Hue"), 25.5); + QCOMPARE(settings().hue(), 25.5); + drag(scrubbed(sheet, "Hue"), 900); + QCOMPARE(settings().hue(), 180.0); + drag(scrubbed(sheet, "Hue"), -900); + QCOMPARE(settings().hue(), -180.0); + drag(scrubbed(sheet, "Saturation"), -900); + QCOMPARE(settings().saturation(), -100.0); + drag(scrubbed(sheet, "Lightness"), 40); + QCOMPARE(settings().lightness(), 40.0); + // Colorize sets hue and saturation outright: new ranges. + for (QCheckBox *box : sheet.findChildren<QCheckBox *>()) + if (box->text() == "Colorize") + box->click(); + QVERIFY(settings().colorize); + drag(scrubbed(sheet, "Hue"), 900); + QCOMPARE(settings().hue(), 360.0); + drag(scrubbed(sheet, "Saturation"), -900); + QCOMPARE(settings().saturation(), 0.0); + QCOMPARE(scrubOverTyping(find<QLineEdit>(sheet, "lightnessField"), scrubbed(sheet, "Lightness"), 3), QString("3")); +} + +void ScrubbableSheetsTests::levelsTitlesScrubTonesAndGamma() +{ + EditorSession session; + paint(session); + session.beginLevels(); + LevelsSheet sheet(session); + showActive(sheet); + const auto range = [&session] { return session.levels().value().settings.current(); }; + // Gamma moves a hundredth a point, within 0.1 and 9.99. + drag(scrubbed(sheet, "Gamma"), 50); + QVERIFY(qAbs(range().gamma - 1.5) < 1e-12); + drag(scrubbed(sheet, "Gamma"), 9000); + QCOMPARE(range().gamma, 9.99); + drag(scrubbed(sheet, "Gamma"), -9000); + QCOMPARE(range().gamma, 0.1); + drag(scrubbed(sheet, "Input black"), 20); + QCOMPARE(range().black, 20.0); + drag(scrubbed(sheet, "Input black"), -900); + QCOMPARE(range().black, 0.0); + drag(scrubbed(sheet, "Output white"), -30.5); + QCOMPARE(range().outputWhite, 224.5); + drag(scrubbed(sheet, "Output white"), 900); + QCOMPARE(range().outputWhite, 255.0); + QCOMPARE(scrubOverTyping(find<QLineEdit>(sheet, "levelsOutputblack"), scrubbed(sheet, "Output black"), 3), QString("3")); +} + +void ScrubbableSheetsTests::filterTitlesMoveALastDecimalAPoint() +{ + EditorSession session; + paint(session); + session.beginFilter(FilterKind::gaussianBlur); + FilterSheet sheet(session); + showActive(sheet); + const auto radius = [&session] { return session.filterEdit().value().settings.radius; }; + const double start = radius(); + // One decimal: a point is a tenth. + drag(scrubbed(sheet, "Radius"), 10); + QVERIFY(qAbs(radius() - (start + 1)) < 1e-9); + drag(scrubbed(sheet, "Radius"), 90000); + QCOMPARE(radius(), 250.0); + drag(scrubbed(sheet, "Radius"), -90000); + QCOMPARE(radius(), 0.1); + QCOMPARE(scrubOverTyping(find<QLineEdit>(sheet, "radiusField"), scrubbed(sheet, "Radius"), 30), QString("3.1")); +} + +void ScrubbableSheetsTests::filterTitlesKeepEveryDecimalsSensitivity() +{ + EditorSession session; + paint(session); + session.beginFilter(FilterKind::gaussianBlur); + { + FilterSheet sheet(session); + showActive(sheet); + const double start = session.filterEdit().value().settings.radius; + // No snap: a point and a half is 0.15. + drag(scrubbed(sheet, "Radius"), 1.5); + QVERIFY(qAbs(session.filterEdit().value().settings.radius - (start + 0.15)) < 1e-9); + } + session.cancelFilter(); + session.beginFilter(FilterKind::exposure); + { + FilterSheet sheet(session); + showActive(sheet); + const auto exposure = [&session] { return session.filterEdit().value().settings.exposure; }; + drag(scrubbed(sheet, "Exposure"), 50.5); + QVERIFY(qAbs(exposure().exposure - 0.505) < 1e-9); + // Four decimals: fifty points are 0.005. + drag(scrubbed(sheet, "Offset"), 50); + QVERIFY(qAbs(exposure().offset - 0.005) < 1e-12); + const double gamma = exposure().gamma; + drag(scrubbed(sheet, "Gamma"), 25); + QVERIFY(qAbs(exposure().gamma - (gamma + 0.25)) < 1e-9); + drag(scrubbed(sheet, "Gamma"), -90000); + QCOMPARE(exposure().gamma, 0.01); + } + session.cancelFilter(); + session.beginFilter(FilterKind::motionBlur); + FilterSheet sheet(session); + showActive(sheet); + // No decimals: a point is a degree, fractions kept. + const double angle = session.filterEdit().value().settings.angle; + drag(scrubbed(sheet, "Angle"), 15.5); + QCOMPARE(session.filterEdit().value().settings.angle, angle + 15.5); +} + +void ScrubbableSheetsTests::colorPickerChannelsScrubWholeLevels() +{ + EditorSession session; + paint(session); + session.openColorPicker(false); + ColorPickerSheet sheet(session, [](bool) {}); + showActive(sheet); + const auto color = [&session] { return session.colorPicker().value().color(); }; + // Whole levels: 100.6 points lands on 101. + drag(scrubbed(sheet, "R"), 100.6); + QCOMPARE(color().red, 101 / 255.0); + drag(scrubbed(sheet, "G"), 900); + QCOMPARE(color().green, 1.0); + drag(scrubbed(sheet, "B"), 30); + QCOMPARE(std::lround(color().blue * 255), 30L); + drag(scrubbed(sheet, "R"), -900); + QCOMPARE(color().red, 0.0); + QCOMPARE(scrubOverTyping(find<QLineEdit>(sheet, "b"), scrubbed(sheet, "B"), 3), QString("33")); +} + +void ScrubbableSheetsTests::canvasSizeTitlesScrubInTheirUnit() +{ + CanvasDocument document{1200, 800}; + document.resolution = 72; + CanvasSizeSheet sheet(document, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {}); + showActive(sheet); + QLineEdit &width = find<QLineEdit>(sheet, "canvasWidth"); + QLineEdit &height = find<QLineEdit>(sheet, "canvasHeight"); + // A held drag keeps its start through each reshape. + QLabel &title = scrubbed(sheet, "Width"); + drag(title, 10, false); + QCOMPARE(width.text(), QString("1210")); + send(title, QEvent::MouseMove, QPointF(22, 2), Qt::NoButton, Qt::LeftButton); + QCOMPARE(width.text(), QString("1220")); + send(title, QEvent::MouseButtonRelease, QPointF(22, 2), Qt::LeftButton, Qt::NoButton); + drag(title, -9.6); + QCOMPARE(width.text(), QString("1210")); + drag(scrubbed(sheet, "Height"), 90000); + QCOMPARE(height.text(), QString("30000")); + drag(scrubbed(sheet, "Height"), -90000); + QCOMPARE(height.text(), QString("1")); + // A percent is twelve points of a 1,200-pixel side. + choose(find<QComboBox>(sheet, "canvasUnits"), "Percent"); + drag(scrubbed(sheet, "Width"), 12); + QCOMPARE(width.text(), QString("102")); + choose(find<QComboBox>(sheet, "canvasUnits"), "Inches"); + drag(scrubbed(sheet, "Width"), -144); + QCOMPARE(width.text(), QString("15")); + // Relative counts from the current size, locked keeps the ratio. + choose(find<QComboBox>(sheet, "canvasUnits"), "Pixels"); + find<QCheckBox>(sheet, "canvasRelative").click(); + drag(scrubbed(sheet, "Width"), -90000); + QCOMPARE(width.text(), QString("-1199")); + find<QCheckBox>(sheet, "canvasRelative").click(); + find<QCheckBox>(sheet, "canvasLocked").click(); + drag(scrubbed(sheet, "Height"), -90000); + QCOMPARE(height.text(), QString("1")); + drag(scrubbed(sheet, "Width"), -90000); + QCOMPARE(width.text(), QString("1.5")); + drag(scrubbed(sheet, "Height"), 90000); + QCOMPARE(height.text(), QString("20000")); + drag(scrubbed(sheet, "Width"), 90000); + QCOMPARE(width.text(), QString("30000")); + QCOMPARE(height.text(), QString("20000")); + QCOMPARE(scrubOverTyping(find<QLineEdit>(sheet, "canvasWidth"), scrubbed(sheet, "Width"), -2), QString("29998")); +} + +void ScrubbableSheetsTests::canvasSizeCentimetresScrubTheirLimits() +{ + CanvasDocument document{1200, 800}; + document.resolution = 72; + CanvasSizeSheet sheet(document, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {}); + showActive(sheet); + QLineEdit &width = find<QLineEdit>(sheet, "canvasWidth"); + QLineEdit &height = find<QLineEdit>(sheet, "canvasHeight"); + choose(find<QComboBox>(sheet, "canvasUnits"), "Centimeters"); + // A point is 2.54 / 72 cm; 72 add 2.54. + drag(scrubbed(sheet, "Width"), 72); + QCOMPARE(width.text(), QString("45")); + drag(scrubbed(sheet, "Width"), 90000); + QCOMPARE(width.text(), QString("1058.333")); + drag(scrubbed(sheet, "Width"), -90000); + QCOMPARE(width.text(), QString("0.035")); + find<QCheckBox>(sheet, "canvasRelative").click(); + drag(scrubbed(sheet, "Height"), -90000); + QCOMPARE(height.text(), QString("-28.187")); + find<QCheckBox>(sheet, "canvasRelative").click(); + find<QCheckBox>(sheet, "canvasLocked").click(); + drag(scrubbed(sheet, "Height"), 90000); + QCOMPARE(height.text(), QString("705.556")); +} + +void ScrubbableSheetsTests::imageSizeLimitsFollowUnitsLockAndResolution() +{ + { + // Unlocked, a hundred megapixels over a 5,000-pixel height. + CanvasDocument tall{1000, 5000}; + tall.resolution = 72; + ImageSizeSheet sheet(tall, [](std::optional<ImageSizeOptions>) {}); + showActive(sheet); + find<QCheckBox>(sheet, "imageLocked").click(); + drag(scrubbed(sheet, "Width"), 90000); + QCOMPARE(find<QLineEdit>(sheet, "imageWidth").text(), QString("20000")); + QCOMPARE(find<QLineEdit>(sheet, "imageHeight").text(), QString("5000")); + } + CanvasDocument document{1200, 800}; + document.resolution = 72; + ImageSizeSheet sheet(document, [](std::optional<ImageSizeOptions>) {}); + showActive(sheet); + QLineEdit &width = find<QLineEdit>(sheet, "imageWidth"); + QLineEdit &height = find<QLineEdit>(sheet, "imageHeight"); + QComboBox &units = find<QComboBox>(sheet, "imageUnits"); + // A percent is twelve points of 1,200 pixels. + choose(units, "Percent"); + drag(scrubbed(sheet, "Width"), 12); + QCOMPARE(width.text(), QString("101")); + drag(scrubbed(sheet, "Height"), -16); + QCOMPARE(height.text(), QString("99")); + // Resampled inches: 72 points an inch, the pixels following. + choose(units, "Inches"); + drag(scrubbed(sheet, "Width"), 72); + QCOMPARE(width.text(), QString("18")); + QCOMPARE(find<QLabel>(sheet, "imageResult").text(), QString("Result: 1,296 × 864 pixels")); + // A resolution scrub keeps print sizes and scales the pixels. + drag(scrubbed(sheet, "Resolution"), 72); + QCOMPARE(find<QLineEdit>(sheet, "imageResolution").text(), QString("144")); + QCOMPARE(width.text(), QString("18")); + QCOMPARE(find<QLabel>(sheet, "imageResult").text(), QString("Result: 2,592 × 1,728 pixels")); + // Resampled centimetres: 144 points add 2.54 at 144 ppi. + choose(units, "Centimeters"); + drag(scrubbed(sheet, "Width"), 144); + QCOMPARE(width.text(), QString("48")); + choose(units, "Inches"); + drag(scrubbed(sheet, "Width"), 90000); + QCOMPARE(width.text(), QString("85.052")); + choose(units, "Centimeters"); + drag(scrubbed(sheet, "Height"), -90000); + QCOMPARE(height.text(), QString("0.018")); +} + +void ScrubbableSheetsTests::imageSizeTitlesScrubPixelsPrintSizeAndResolution() +{ + CanvasDocument document{1200, 800}; + document.resolution = 72; + ImageSizeSheet sheet(document, [](std::optional<ImageSizeOptions>) {}); + showActive(sheet); + QLineEdit &width = find<QLineEdit>(sheet, "imageWidth"); + QLineEdit &height = find<QLineEdit>(sheet, "imageHeight"); + QLineEdit &resolution = find<QLineEdit>(sheet, "imageResolution"); + // Locked, the height follows; a hundred megapixels at most. + drag(scrubbed(sheet, "Width"), 300); + QCOMPARE(width.text(), QString("1500")); + QCOMPARE(height.text(), QString("1000")); + drag(scrubbed(sheet, "Width"), 90000); + QCOMPARE(width.text(), QString("12247.449")); + drag(scrubbed(sheet, "Height"), -90000); + QCOMPARE(height.text(), QString("1")); + QCOMPARE(width.text(), QString("1.5")); + drag(scrubbed(sheet, "Resolution"), 10.4); + QCOMPARE(resolution.text(), QString("82")); + drag(scrubbed(sheet, "Resolution"), 90000); + QCOMPARE(resolution.text(), QString("9600")); + drag(scrubbed(sheet, "Resolution"), -90000); + QCOMPARE(resolution.text(), QString("1")); + // Without resampling a point is a hundredth of an inch. + find<QCheckBox>(sheet, "imageResample").click(); + QCOMPARE(find<QComboBox>(sheet, "imageUnits").currentText(), QString("Inches")); + QCOMPARE(width.text(), QString("1200")); + drag(scrubbed(sheet, "Width"), -60000); + QCOMPARE(width.text(), QString("600")); + QCOMPARE(resolution.text(), QString("2")); + // Up to one pixel per inch, down to 9,600. + drag(scrubbed(sheet, "Width"), 90000); + QCOMPARE(width.text(), QString("1200")); + QCOMPARE(resolution.text(), QString("1")); + drag(scrubbed(sheet, "Width"), -200000); + QCOMPARE(width.text(), QString("0.125")); + // A point is 0.0254 cm: a hundred make 2.54. + choose(find<QComboBox>(sheet, "imageUnits"), "Centimeters"); + drag(scrubbed(sheet, "Width"), 100); + QCOMPARE(width.text(), QString("3")); + QCOMPARE(resolution.text(), QString("1016")); + QCOMPARE(scrubOverTyping(resolution, scrubbed(sheet, "Resolution"), 4), QString("1020")); + // A resolution of nothing leaves no print size to scrub. + resolution.setFocus(); + resolution.selectAll(); + QTest::keyClicks(&resolution, QStringLiteral("0")); + QTest::keyClick(&resolution, Qt::Key_Return); + QVERIFY(!scrubbed(sheet, "Width").isEnabled()); + QVERIFY(!scrubbed(sheet, "Height").isEnabled()); + // A positive resolution again brings the titles back. + drag(scrubbed(sheet, "Resolution"), 72); + QCOMPARE(resolution.text(), QString("72")); + QVERIFY(scrubbed(sheet, "Width").isEnabled()); + QVERIFY(scrubbed(sheet, "Height").isEnabled()); + drag(scrubbed(sheet, "Height"), -100); + QCOMPARE(height.text(), QString("26")); + QCOMPARE(resolution.text(), QString("78.154")); +} + +QTEST_MAIN(ScrubbableSheetsTests) +#include "ScrubbableSheetsTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 4fbb17b..748379c 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -146,6 +146,8 @@ compositor_test(LayerContextMenuEdgeTests) compositor_test(NavigationToolHeaderTests) compositor_test(NumericScrubTests) compositor_test(ScrubbableLabelsTests) +compositor_test(ScrubbableSheetsTests) +compositor_test(ScrubbableCameraRawTests) compositor_test(ScrubbableTransformTests) compositor_test(NewCanvasSheetTests) compositor_test(OmarchyThemeTests)