Scrub numbers by dragging their labels in the tool bars and layer opacity (12.12c1)

This commit is contained in:
雷电芽衣 committed 2026-09-29 15:06:49 -04:00
1 parent f335818e53
commit 5bc7889387
25 files changed
+1126 -32

No files matched your search

+1
View File
@@ -139,6 +139,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Text at the click, text on the Move tool's double click, and SVG (12.11c, Swift 1.2.10: `292cb6b`, `072fa08`, `a5721b2`, `0534e00`): a click with the Type tool puts new text's first baseline on the pointer, the box its padding to the left and its first baseline above (`TextLines::baseline(0)` of the style, the laid line's descent, where Swift reads the font's descender: they agree within a hundredth of a pixel); a dragged box sits exactly where it was drawn. Swift's `setHiddenUntilMouseMoves(false)` has no twin: Qt never hides the pointer while typing. The canvas's press is `press(event, clicks)`, Swift's `mouseDown` with its click count: `mousePressEvent` passes one and a double click's fall-through two, so a triple click counts one (the Type tool keeps its own count); with the Move tool a second click on the topmost shown live text under the pointer (a hidden folder hides what it holds) runs `commitTransform`, selects it, opens its editor and synchronizes the inline editor (`beginLiveTextEdit`, in `EditorCanvas+Transform.cpp`), else the press goes on to guides and the transform drag. No transform edit is open there (`canEditLayers`), but `commitTransform` also ends an opacity drag and a blend preview, which `selectLayer` leaves when the text is already active. In the layer list a live text row's size column says "Text · Double-click to edit"; a double click on its thumbnail opens it and on its name renames it, as Swift's merge (`3258c66`) kept, where the branch commit opened text anywhere. Opening existing text puts the caret after it once the editor takes the keys, unless a click already placed it (that merge's step in `InlineTextEditor.synchronize`): the queued focus request moves an untouched caret (0, 0) to the end, for a draft on a layer alone; the editor keeps its canvas as a `CanvasView` to reach itself there. SVG goes by its suffix, `svg` or `svgz` in any case (the importer's filter lists both, the desktop entry `image/svg+xml` and `image/svg+xml-compressed`, which shared-mime-info keeps apart) (Swift's `UTType.svg`), after camera raw and before Photoshop's bytes, through the ordinary decoder with the canvas's size: `ImageImporter::decodeSVG` renders once through Qt SVG's `QSvgRenderer` (a new dependency: `qt6-svg-dev` in the dev image, `qt6-svg`, `qt6-qtsvg-devel` and `qt6.qtsvg` in the distro images, the PKGBUILD and the flake; the DEB and RPM find the library themselves), fitted to the canvas by the smaller ratio, else at the declared size, each side rounded and one pixel at least, refused past the side limit or the remaining budget, tagged sRGB, named without its suffix. Drops need nothing, since a dropped picture of any `image/` type already imports. Known differences: Qt SVG draws SVG Tiny 1.2 where macOS's renderer draws more of SVG 1.1 (filters, masks, some CSS), `defaultSize` truncates a declared size to whole pixels (40.9 by 20.9 draws 40 by 20; a side that truncates to nothing falls back to Qt's bounds, which round, so a declared side under half a pixel refuses and one from half a pixel draws a pixel wide, where Swift draws any positive side a pixel wide at least), and a drawing whose declared size is empty takes its content's bounds, where `NSImage` measures nothing; Swift's `isValid` guard is folded into the size test, since Qt measures a drawing it cannot read as nothing. Tests: Swift's two `TypeToolTests` changes and the baseline in ink (`clickedTextSitsOnItsBaseline`); `LiveTextDoubleClickTests` (zoomed and turned text, a hidden folder, the cleanup, the caret); `SVGImportTests`. Tests that set text up at a known corner use `beginTextAt` (`SessionFixtures.h`), which pins the draft's corner as Swift's clipping test does.
- 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`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
+1
View File
@@ -122,6 +122,7 @@ add_library(compositor_core STATIC
src/UI/NativeLayerList+Effects.cpp
src/UI/NativeLayerList+Menu.cpp
src/UI/NavigationToolHeader.cpp
src/UI/NumericScrub.cpp
src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp
src/UI/CameraRawColorControls.cpp
+2 -1
View File
@@ -384,7 +384,8 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). Codex review: 3 rounds; the third's one finding (a test gap: a stale caret step meeting a replacing draft) fixed and closed.
- [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.
- [ ] 12.12c Part three: numeric label scrubbing (`2d36d6e`, `680a522`); the build-warning fixes (`075a71b`, `725b934`) where they change behaviour.
- [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.
- [ ] 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.
+1 -1
View File
@@ -1044,7 +1044,7 @@ static void optics_chromatic(uint8_t *rgba, size_t width, size_t height, size_t
double dx = x + 0.5 - cx, dy = y + 0.5 - cy;
double radial = hypot(dx, dy) / maxR;
double shift = strength * radial * radial * 2.5;
long rx = (long)lround(x - shift), bx = (long)lround(x + shift);
int rx = (int)lround(x - shift), bx = (int)lround(x + shift);
const uint8_t *pr = srcRow + clamped_index(rx, width) * 4;
const uint8_t *pb = srcRow + clamped_index(bx, width) * 4;
double g = fmin(1.0, srcRow[x * 4 + 1] / alpha);
+16 -1
View File
@@ -2,6 +2,7 @@
#include "Document/EditorSession.h"
#include "UI/ColorPaletteControls.h"
#include "UI/LassoControls.h"
#include "UI/NumericScrub.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
@@ -95,13 +96,27 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent)
m_colour->setToolTip(QStringLiteral("Foreground color"));
m_colour->setAccessibleName(QStringLiteral("Foreground color"));
connect(m_colour, &QAbstractButton::clicked, this, [this] { m_session.openColorPicker(false); });
// Swift's scrubbable labels: a drag sideways sets the value.
QLabel *size = label(QStringLiteral("Size"), this);
QLabel *hardness = label(QStringLiteral("Hardness"), this);
const auto scrub = [this](QLabel *name, SelectionAmountField *shown, double sensitivity, double low, double high, double BrushSettings::*key) {
new NumericScrub(name, {.sensitivity = sensitivity, .low = low, .high = high, .step = std::nullopt,
.value = [this, key] { return m_session.brushSettings().*key; }, .set = [this, shown, key](double value) {
changeBrush(m_session, [&](BrushSettings &brush) { brush.*key = value; });
shown->showValue();
}});
};
scrub(size, m_size, 1, 1, 2000, &BrushSettings::diameter);
scrub(hardness, m_hardness, 0.01, 0, 1, &BrushSettings::hardness);
scrub(m_opacityLabel, m_opacity, 0.01, 0.01, 1, &BrushSettings::opacity);
scrub(m_smoothingLabel, m_smoothing, 1, 0, 100, &BrushSettings::smoothing);
std::vector<QWidget *> widgets{m_paint, m_erase};
for (QAbstractButton *mode : m_blurModes->buttons())
widgets.push_back(mode);
for (QAbstractButton *type : m_healingTypes->buttons())
widgets.push_back(type);
widgets.insert(widgets.end(), {m_aligned, m_thisLayer, m_allLayers});
widgets.insert(widgets.end(), {label(QStringLiteral("Size"), this), m_size, label(QStringLiteral("px"), this), label(QStringLiteral("Hardness"), this),
widgets.insert(widgets.end(), {size, m_size, label(QStringLiteral("px"), this), hardness,
m_hardnessSlider, m_hardness, label(QStringLiteral("%"), this), m_opacityLabel, m_opacitySlider, m_opacity,
label(QStringLiteral("%"), this), m_smoothingLabel, m_smoothingSlider, m_smoothing, m_paintLabel, m_maskPaint, m_colourLabel, m_colour});
for (QWidget *widget : widgets)
+9 -1
View File
@@ -1,6 +1,7 @@
#include "UI/GradientControls.h"
#include "Document/EditorSession.h"
#include "UI/LassoControls.h"
#include "UI/NumericScrub.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
@@ -87,7 +88,14 @@ GradientControls::GradientControls(EditorSession &session, QWidget *parent)
m_apply->setObjectName(QStringLiteral("gradientApply"));
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelGradient(); });
connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitGradient(); });
for (QWidget *widget : std::initializer_list<QWidget *>{m_linear, m_radial, m_swatch, m_style, m_reverse, new QLabel(QStringLiteral("Opacity"), this),
auto *opacity = new QLabel(QStringLiteral("Opacity"), this);
new NumericScrub(opacity, {.sensitivity = 0.01, .low = 0.01, .high = 1, .step = std::nullopt,
.value = [this] { return m_session.gradientSettings().opacity; },
.set = [this](double value) {
changeGradient(m_session, [value](GradientSettings &gradient) { gradient.opacity = value; });
m_opacity->showValue();
}});
for (QWidget *widget : std::initializer_list<QWidget *>{m_linear, m_radial, m_swatch, m_style, m_reverse, opacity,
m_opacitySlider, m_opacity, new QLabel(QStringLiteral("%"), this)})
row->insertWidget(row->count() - 1, widget);
for (QWidget *widget : std::initializer_list<QWidget *>{m_mask, m_cancel, m_apply})
+31 -7
View File
@@ -2,6 +2,7 @@
#include "UI/KeyboardShortcuts.h"
#include <QBoxLayout>
#include <QFrame>
#include "UI/NumericScrub.h"
#include <QLabel>
#include <QSignalBlocker>
#include <QRegularExpressionValidator>
@@ -36,7 +37,7 @@ void SelectionAmountField::sync(int amount)
setText(QString::number(amount));
}
void SelectionAmountField::apply()
void SelectionAmountField::showValue()
{
setText(QString::number(m_amount));
}
@@ -45,7 +46,7 @@ void SelectionAmountField::keyPressEvent(QKeyEvent *event)
{
// Return and Escape hand the canvas the keys.
if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Escape) {
apply();
showValue();
clearFocus();
m_session.requestCanvasFocus();
return;
@@ -64,7 +65,7 @@ void SelectionAmountField::focusOutEvent(QFocusEvent *event)
{
m_borrowed = event->reason() == Qt::MenuBarFocusReason || event->reason() == Qt::PopupFocusReason;
if (!m_borrowed)
apply();
showValue();
QLineEdit::focusOutEvent(event);
}
@@ -115,12 +116,15 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent)
m_antialias(new QCheckBox(QStringLiteral("Anti-alias"), this)), m_expand(new QPushButton(QStringLiteral("Expand"), this)),
m_expandAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionExpandAmount()); },
[this](double amount) { m_session.setSelectionExpandAmount(int(std::lround(amount))); }, this)),
m_expandUnit(new QLabel(QStringLiteral("px"), this)),
m_contract(new QPushButton(QStringLiteral("Contract"), this)),
m_contractAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionContractAmount()); },
[this](double amount) { m_session.setSelectionContractAmount(int(std::lround(amount))); }, this)),
m_contractUnit(new QLabel(QStringLiteral("px"), this)),
m_feather(new QPushButton(QStringLiteral("Feather"), this)),
m_featherAmount(new SelectionAmountField(session, 1, 250, [this] { return double(m_session.selectionFeatherAmount()); },
[this](double amount) { m_session.setSelectionFeatherAmount(int(std::lround(amount))); }, this)),
m_featherUnit(new QLabel(QStringLiteral("px"), this)),
m_empty(new QLabel(QStringLiteral("Empty selection"), this)), m_deselect(new QPushButton(QStringLiteral("Deselect"), this))
{
m_toleranceLabel->setFont(ToolHeaderStyle::controlFont());
@@ -169,13 +173,31 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent)
m_empty->setForegroundRole(QPalette::PlaceholderText);
m_deselect->setObjectName(QStringLiteral("deselect"));
connect(m_deselect, &QPushButton::clicked, this, [this] { m_session.deselect(); });
// Swift's Int scrub rounds: a step of one.
const auto scrub = [](QWidget *label, SelectionAmountField *shown, double low, double high, std::function<double()> value,
std::function<void(double)> set) {
new NumericScrub(label, {.sensitivity = 1, .low = low, .high = high, .step = 1, .value = std::move(value),
.set = [shown, set = std::move(set)](double amount) {
set(amount);
shown->showValue();
}});
};
scrub(m_toleranceLabel, m_tolerance, 0, 255, [this] { return double(m_session.wandSettings().tolerance); },
[this](double tolerance) { changeWand([tolerance](WandSettings &wand) { wand.tolerance = int(tolerance); }); });
scrub(m_edgeLabel, m_edge, -10, 10, [this] { return double(m_session.objectSelectionSettings().edgeOffset); },
[this](double edge) { changeObject([edge](ObjectSelectionSettings &object) { object.edgeOffset = int(edge); }); });
scrub(m_expandUnit, m_expandAmount, 1, 500, [this] { return double(m_session.selectionExpandAmount()); },
[this](double amount) { m_session.setSelectionExpandAmount(int(amount)); });
scrub(m_contractUnit, m_contractAmount, 1, 500, [this] { return double(m_session.selectionContractAmount()); },
[this](double amount) { m_session.setSelectionContractAmount(int(amount)); });
scrub(m_featherUnit, m_featherAmount, 1, 250, [this] { return double(m_session.selectionFeatherAmount()); },
[this](double amount) { m_session.setSelectionFeatherAmount(int(amount)); });
// Swift's `unitSuffix`: a unit sits by each field.
for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_wandMode, m_objectMode, m_freehand, m_polygonal, m_replace, m_add,
m_subtract, m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous,
m_objectThisLayer, m_objectAllLayers, m_edgeLabel, m_edge, m_edgeUnit, m_antialias,
divider, m_expand, m_expandAmount, new QLabel(QStringLiteral("px"), this), m_contract, m_contractAmount,
new QLabel(QStringLiteral("px"), this), m_feather, m_featherAmount,
new QLabel(QStringLiteral("px"), this)})
divider, m_expand, m_expandAmount, m_expandUnit, m_contract, m_contractAmount,
m_contractUnit, m_feather, m_featherAmount, m_featherUnit})
row->insertWidget(row->count() - 1, widget);
row->addWidget(m_empty);
row->addWidget(m_deselect);
@@ -257,7 +279,7 @@ void LassoControls::synchronize()
if (m_antialias->isChecked() != m_session.selectionAntialiased())
m_antialias->setChecked(m_session.selectionAntialiased());
const bool modifies = m_session.canModifySelection();
for (QWidget *widget : std::initializer_list<QWidget *>{m_expand, m_expandAmount, m_contract, m_contractAmount, m_feather, m_featherAmount})
for (QWidget *widget : std::initializer_list<QWidget *>{m_expand, m_expandAmount, m_expandUnit, m_contract, m_contractAmount, m_contractUnit, m_feather})
widget->setEnabled(modifies);
m_expandAmount->sync(m_session.selectionExpandAmount());
m_contractAmount->sync(m_session.selectionContractAmount());
@@ -286,6 +308,8 @@ SelectionAmountSheet::SelectionAmountSheet(EditorSession &session, SelectionAmou
auto *title = new QLabel(QStringLiteral("Amount"), this);
title->setMinimumWidth(60);
title->setBuddy(m_slider);
new NumericScrub(title, {.sensitivity = 1, .low = 1, .high = double(m_maximum), .step = 1, .value = [this] { return double(amount().value_or(1)); },
.set = [this](double value) { m_input->setText(QString::number(int(value))); }});
m_slider->setObjectName(QStringLiteral("amountSlider"));
m_slider->setRange(1, m_maximum);
m_input->setObjectName(QStringLiteral("amountField"));
+6 -2
View File
@@ -18,14 +18,14 @@ public:
SelectionAmountField(EditorSession &session, int low, int high, std::function<double()> value, std::function<void(double)> change,
QWidget *parent = nullptr);
void sync(int amount);
// The session's number, over any typing: a step or scrub.
void showValue();
protected:
void keyPressEvent(QKeyEvent *event) override;
void focusOutEvent(QFocusEvent *event) override;
private:
void apply();
EditorSession &m_session;
const int m_low;
const int m_high;
@@ -80,10 +80,14 @@ private:
QCheckBox *const m_antialias;
QPushButton *const m_expand;
SelectionAmountField *const m_expandAmount;
// The units scrub their amounts, Swift's `unitSuffix`.
QLabel *const m_expandUnit;
QPushButton *const m_contract;
SelectionAmountField *const m_contractAmount;
QLabel *const m_contractUnit;
QPushButton *const m_feather;
SelectionAmountField *const m_featherAmount;
QLabel *const m_featherUnit;
QLabel *const m_empty;
QPushButton *const m_deselect;
};
+13 -1
View File
@@ -1,5 +1,6 @@
#include "UI/LayerAppearanceControls.h"
#include "UI/BlendModePicker.h"
#include "UI/NumericScrub.h"
#include <QApplication>
#include <QHBoxLayout>
#include <QKeyEvent>
@@ -34,7 +35,16 @@ LayerAppearanceControls::LayerAppearanceControls(EditorSession &session, QWidget
column->addLayout(blend);
auto *opacity = new QHBoxLayout;
opacity->setSpacing(6);
opacity->addWidget(caption(QStringLiteral("Opacity"), this));
QLabel *name = caption(QStringLiteral("Opacity"), this);
// A scrub is one opacity drag, as the slider's.
m_scrub = new NumericScrub(name, {.sensitivity = 1, .low = 0, .high = 100, .step = std::nullopt,
.value = [this] {
const std::optional<ImageLayer> active = m_session.activeLayer();
return (active ? active->opacity : 1) * 100;
},
.set = [this](double percent) { step(percent); }, .onStart = [this] { m_session.beginOpacityEdit(); },
.onEnd = [this] { m_session.finishOpacityEdit(); }});
opacity->addWidget(name);
// A thousandth a step; a drag is one undo step.
m_slider->setRange(0, 1000);
m_slider->setObjectName(QStringLiteral("opacitySlider"));
@@ -87,6 +97,8 @@ void LayerAppearanceControls::synchronize()
if (m_layerID != m_session.activeLayerID()) {
// Leaving applies to the old layer only; it is gone.
m_percentage->clearFocus();
// Swift's `.id` drops a scrub under way.
m_scrub->end();
m_layerID = m_session.activeLayerID();
sync();
} else if (!m_percentage->hasFocus()) {
+3
View File
@@ -5,6 +5,7 @@
#include <QWidget>
class BlendModePicker;
class NumericScrub;
class QLabel;
// The active layer's blend mode and opacity.
@@ -29,6 +30,8 @@ private:
QLabel *m_blendCaption = nullptr;
QSlider *const m_slider;
QLineEdit *const m_percentage;
// The Opacity label's scrub, ended when another layer is active.
NumericScrub *m_scrub = nullptr;
// The layer the controls were last made for.
std::optional<QUuid> m_layerID;
bool m_syncing = false;
+4
View File
@@ -1,4 +1,5 @@
#include "UI/NavigationToolHeader.h"
#include "UI/NumericScrub.h"
#include <QKeyEvent>
#include <QRegularExpression>
#include <cmath>
@@ -12,6 +13,9 @@ NavigationToolHeader::NavigationToolHeader(EditorSession &session, QWidget *pare
m_zoom->setFixedWidth(72);
m_zoom->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
m_zoom->installEventFilter(this);
// Swift's scrubbable unit: a drag sideways zooms.
new NumericScrub(m_unit, {.sensitivity = 1, .low = 0.1, .high = 3200, .step = std::nullopt,
.value = [this] { return m_session.viewport.zoom() * 100; }, .set = [this](double percent) { step(percent); }});
// The unit sits tight against its field: one value.
auto *value = new QHBoxLayout;
value->setSpacing(2);
+76
View File
@@ -0,0 +1,76 @@
#include "UI/NumericScrub.h"
#include <QApplication>
#include <QMouseEvent>
#include <QWidget>
#include <algorithm>
#include <cmath>
NumericScrub::NumericScrub(QWidget *label, Options options) : QObject(label), m_label(label), m_options(std::move(options))
{
// A disabled label shows its parent's cursor, as Qt does.
label->setCursor(Qt::SizeHorCursor);
// Buttonless moves must arrive to end a lost release.
label->setMouseTracking(true);
label->installEventFilter(this);
}
bool NumericScrub::eventFilter(QObject *watched, QEvent *event)
{
const QEvent::Type type = event->type();
// A double click's second press is a press too.
const bool press = type == QEvent::MouseButtonPress || type == QEvent::MouseButtonDblClick;
if (!press && type != QEvent::MouseMove && type != QEvent::MouseButtonRelease)
return false;
const auto *mouse = static_cast<QMouseEvent *>(event);
if (watched != m_label) {
// A popup opened mid-drag takes the release.
if (type == QEvent::MouseButtonRelease && mouse->button() == Qt::LeftButton)
end();
return false;
}
if (press) {
// Filters see a disabled label's presses before Qt drops them.
if (mouse->button() != Qt::LeftButton || !m_label->isEnabled())
return false;
// The label holds the grab: its own points stay exact.
m_press = mouse->position();
qApp->installEventFilter(this);
return true;
}
if (!m_press)
return false;
if (type == QEvent::MouseButtonRelease) {
if (mouse->button() == Qt::LeftButton)
end();
return true;
}
// Qt can lose a release: a buttonless move ends.
if (!mouse->buttons().testFlag(Qt::LeftButton)) {
end();
return true;
}
const QPointF translation = mouse->position() - *m_press;
if (!m_start) {
if (std::hypot(translation.x(), translation.y()) < 1)
return true;
m_start = m_options.value();
m_options.onStart();
}
double proposed = *m_start + translation.x() * m_options.sensitivity;
if (m_options.step && *m_options.step > 0)
proposed = std::round(proposed / *m_options.step) * *m_options.step;
m_options.set(std::min(m_options.high, std::max(m_options.low, proposed)));
return true;
}
void NumericScrub::end()
{
if (!m_press)
return;
qApp->removeEventFilter(this);
m_press.reset();
if (m_start) {
m_start.reset();
m_options.onEnd();
}
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <QObject>
#include <QPointF>
#include <functional>
#include <optional>
class QWidget;
// Swift's NumericScrub: a label dragged sideways changes its value.
class NumericScrub : public QObject {
public:
struct Options {
double sensitivity;
double low;
double high;
// Dragged values snap to multiples of this, when set.
std::optional<double> step;
std::function<double()> value;
std::function<void(double)> set;
std::function<void()> onStart = [] {};
std::function<void()> onEnd = [] {};
};
// Lives as the label's child and filters its events.
NumericScrub(QWidget *label, Options options);
// Ends a drag as a release would.
void end();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
QWidget *const m_label;
const 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.
std::optional<double> m_start;
};
+15 -4
View File
@@ -16,10 +16,14 @@ ShapeControls::ShapeControls(EditorSession &session, QWidget *parent)
m_widthSlider(new QSlider(Qt::Horizontal, this)),
m_widthField(new SelectionAmountField(session, 1, 5000, [this] { return m_session.shapeLineWidth(); },
[this](double width) { m_session.setShapeLineWidth(width); }, this)),
m_width(amount(QStringLiteral("Width"), m_widthSlider, m_widthField)), m_radiusSlider(new QSlider(Qt::Horizontal, this)),
m_width(amount(QStringLiteral("Width"), m_widthSlider, m_widthField,
{.sensitivity = 1, .low = 1, .high = 5000, .step = std::nullopt, .value = [this] { return m_session.shapeLineWidth(); },
.set = [this](double width) { m_session.setShapeLineWidth(width); }})), m_radiusSlider(new QSlider(Qt::Horizontal, this)),
m_radiusField(new SelectionAmountField(session, 0, 5000, [this] { return m_session.shapeCornerRadius(); },
[this](double radius) { m_session.setShapeCornerRadius(radius); }, this)),
m_radius(amount(QStringLiteral("Radius"), m_radiusSlider, m_radiusField)),
m_radius(amount(QStringLiteral("Radius"), m_radiusSlider, m_radiusField,
{.sensitivity = 1, .low = 0, .high = 5000, .step = std::nullopt, .value = [this] { return m_session.shapeCornerRadius(); },
.set = [this](double radius) { m_session.setShapeCornerRadius(radius); }})),
m_fill(new SwatchButton([&session] { return session.foregroundColor(); }, 3, 0, 0.5, this))
{
m_widthSlider->setObjectName(QStringLiteral("shapeWidthSlider"));
@@ -65,7 +69,7 @@ QToolButton *ShapeControls::kind(ShapeKind value)
return button;
}
QWidget *ShapeControls::amount(const QString &name, QSlider *slider, SelectionAmountField *field)
QWidget *ShapeControls::amount(const QString &name, QSlider *slider, SelectionAmountField *field, NumericScrub::Options scrub)
{
auto *group = new QWidget(this);
auto *layout = new QHBoxLayout(group);
@@ -73,7 +77,14 @@ QWidget *ShapeControls::amount(const QString &name, QSlider *slider, SelectionAm
layout->setSpacing(6);
slider->setFixedWidth(100);
field->setFixedWidth(48);
for (QWidget *widget : std::initializer_list<QWidget *>{new QLabel(name, group), slider, field, new QLabel(QStringLiteral("px"), group)})
auto *label = new QLabel(name, group);
// A scrub shows its value over the field's typing.
scrub.set = [field, set = std::move(scrub.set)](double value) {
set(value);
field->showValue();
};
new NumericScrub(label, std::move(scrub));
for (QWidget *widget : std::initializer_list<QWidget *>{label, slider, field, new QLabel(QStringLiteral("px"), group)})
layout->addWidget(widget);
return group;
}
+2 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "Document/ShapeTool.h"
#include "UI/NumericScrub.h"
#include "UI/ToolHeaderStyle.h"
#include <array>
@@ -19,7 +20,7 @@ public:
private:
QToolButton *kind(ShapeKind value);
// A label, slider, field and "px", as Swift's HStack.
QWidget *amount(const QString &name, QSlider *slider, SelectionAmountField *field);
QWidget *amount(const QString &name, QSlider *slider, SelectionAmountField *field, NumericScrub::Options scrub);
void synchronize();
EditorSession &m_session;
+21 -8
View File
@@ -1,13 +1,14 @@
#include "UI/TransformInspector.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/LayerIcons.h"
#include "UI/NumericScrub.h"
#include <QEvent>
#include <QKeyEvent>
#include <QLabel>
#include <cmath>
TransformValueField::TransformValueField(const QString &label, const QString &suffix, EditorSession &session, std::function<void(double)> change,
QWidget *parent)
TransformValueField::TransformValueField(const QString &label, const QString &suffix, double low, double high, EditorSession &session,
std::function<void(double)> change, QWidget *parent)
: QWidget(parent), field(new QLineEdit(this)), m_session(session), m_change(std::move(change))
{
auto *row = new QHBoxLayout(this);
@@ -16,6 +17,11 @@ TransformValueField::TransformValueField(const QString &label, const QString &su
auto *name = new QLabel(label, this);
name->setFont(ToolHeaderStyle::controlFont());
name->setForegroundRole(QPalette::PlaceholderText);
// A scrub writes over typing; an unfocused field then follows.
m_scrub = new NumericScrub(name, {.sensitivity = 1, .low = low, .high = high, .step = 1, .value = [this] { return m_value; }, .set = [this](double value) {
field->setText(formatted(value));
m_change(value);
}});
row->addWidget(name);
field->setObjectName(QStringLiteral("transform") + label);
field->setAccessibleName(label);
@@ -44,6 +50,11 @@ void TransformValueField::sync(double value)
field->setText(formatted(value));
}
void TransformValueField::endScrub()
{
m_scrub->end();
}
QString TransformValueField::formatted(double value)
{
return std::abs(value - std::round(value)) < 0.005 ? QString::number(std::lround(value)) : QString::number(value, 'f', 2);
@@ -79,15 +90,15 @@ bool TransformValueField::eventFilter(QObject *watched, QEvent *event)
TransformInspector::TransformInspector(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QStringLiteral("Transform"), parent), m_session(session), m_autoSelect(new QCheckBox(QStringLiteral("Auto Select"), this)),
m_showControls(new QCheckBox(QStringLiteral("Show Controls"), this)), m_fields(new QScrollArea(this)),
m_x(new TransformValueField(QStringLiteral("X"), QString(), session, [this](double number) { change([number](LayerTransform &value) { value.origin.setX(number); }); })),
m_y(new TransformValueField(QStringLiteral("Y"), QString(), session, [this](double number) { change([number](LayerTransform &value) { value.origin.setY(number); }); })),
m_width(new TransformValueField(QStringLiteral("W"), QString(), session, [this](double number) { resizeBox(number, true); })),
m_height(new TransformValueField(QStringLiteral("H"), QString(), session, [this](double number) { resizeBox(number, false); })),
m_x(new TransformValueField(QStringLiteral("X"), QString(), -30000, 30000, session, [this](double number) { change([number](LayerTransform &value) { value.origin.setX(number); }); })),
m_y(new TransformValueField(QStringLiteral("Y"), QString(), -30000, 30000, session, [this](double number) { change([number](LayerTransform &value) { value.origin.setY(number); }); })),
m_width(new TransformValueField(QStringLiteral("W"), QString(), 1, 30000, session, [this](double number) { resizeBox(number, true); })),
m_height(new TransformValueField(QStringLiteral("H"), QString(), 1, 30000, session, [this](double number) { resizeBox(number, false); })),
m_lock(new QToolButton(this)),
m_scale(new TransformValueField(QStringLiteral("Scale"), QStringLiteral("%"), session, [this](double number) {
m_scale(new TransformValueField(QStringLiteral("Scale"), QStringLiteral("%"), 0.1, 30000, session, [this](double number) {
change([&](LayerTransform &value) { value = value.scaled(number, pixelSize()); });
})),
m_rotation(new TransformValueField(QStringLiteral("°"), QString(), session, [this](double number) {
m_rotation(new TransformValueField(QStringLiteral("°"), QString(), -360, 360, session, [this](double number) {
change([number](LayerTransform &value) { value.rotation = std::fmod(number, 360.0); });
})),
m_sampling(new QComboBox(this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_apply(new QPushButton(QStringLiteral("Apply"), this))
@@ -239,6 +250,8 @@ void TransformInspector::synchronize()
m_layerID = m_session.activeLayerID();
if (QWidget *focused = focusWidget(); focused && isAncestorOf(focused))
focused->clearFocus();
for (TransformValueField *field : {m_x, m_y, m_width, m_height, m_scale, m_rotation})
field->endScrub();
}
title->setText(m_session.transformTargetsMask() ? QStringLiteral("Transform Mask") : QStringLiteral("Transform"));
const auto set = [](QAbstractButton *button, bool checked) {
+8 -2
View File
@@ -9,15 +9,20 @@
#include <QToolButton>
#include <functional>
class NumericScrub;
// A labelled number, typed at once, stepped by arrows.
class TransformValueField : public QWidget {
Q_OBJECT
public:
TransformValueField(const QString &label, const QString &suffix, EditorSession &session, std::function<void(double)> change,
QWidget *parent = nullptr);
// The label scrubs within low and high, in whole steps.
TransformValueField(const QString &label, const QString &suffix, double low, double high, EditorSession &session,
std::function<void(double)> change, QWidget *parent = nullptr);
// Shown when the field is not being edited.
void sync(double value);
// Swift's `.id` drops a scrub under way with its layer.
void endScrub();
// No idle zeros on a whole number, two decimals otherwise.
static QString formatted(double value);
@@ -29,6 +34,7 @@ protected:
private:
EditorSession &m_session;
const std::function<void(double)> m_change;
NumericScrub *m_scrub = nullptr;
double m_value = 0;
// A menu or popup borrows the focus: the typing stays.
bool m_borrowed = false;
+19 -2
View File
@@ -8,6 +8,7 @@
#include <QComboBox>
#include <QFocusEvent>
#include <QHBoxLayout>
#include "UI/NumericScrub.h"
#include <QLabel>
#include <QPainter>
#include <QPushButton>
@@ -61,6 +62,11 @@ void TextStyleField::sync()
setText(m_shown());
}
void TextStyleField::showValue()
{
setText(m_shown());
}
void TextStyleField::keyPressEvent(QKeyEvent *event)
{
// Swift's bar keeps the focus: Return shows the number, selected.
@@ -211,8 +217,19 @@ TypeControls::TypeControls(EditorSession &session, QWidget *parent)
aligned->setSpacing(2);
for (QToolButton *button : m_alignments)
aligned->addWidget(button);
for (QWidget *widget : std::initializer_list<QWidget *>{m_font, m_size, new QLabel(QStringLiteral("px")), m_colour, alignments, new QLabel(QStringLiteral("Tracking")),
m_tracking, new QLabel(QStringLiteral("Leading")), m_leading})
// Swift's scrubbable size unit, tracking and leading: whole numbers.
const auto scrub = [this](const QString &text, TextStyleField *shown, double low, double high, double LayerTextStyle::*key) {
auto *label = new QLabel(text);
new NumericScrub(label, {.sensitivity = 1, .low = low, .high = high, .step = 1,
.value = [this, key] { return m_session.currentTextStyle().*key; }, .set = [this, shown, key](double value) {
m_session.changeTextStyle([&](LayerTextStyle &style) { style.*key = value; });
shown->showValue();
}});
return label;
};
for (QWidget *widget : std::initializer_list<QWidget *>{m_font, m_size, scrub(QStringLiteral("px"), m_size, 1, 2000, &LayerTextStyle::fontSize), m_colour, alignments,
scrub(QStringLiteral("Tracking"), m_tracking, -100, 1000, &LayerTextStyle::tracking), m_tracking,
scrub(QStringLiteral("Leading"), m_leading, 0, 5000, &LayerTextStyle::leading), m_leading})
fields->addWidget(widget);
// Swift's scroll content keeps its size at the leading edge.
fields->addStretch(1);
+2
View File
@@ -20,6 +20,8 @@ public:
QWidget *parent);
// Shows the session's number unless being edited.
void sync();
// The session's number, over any typing: a scrub.
void showValue();
protected:
void keyPressEvent(QKeyEvent *event) override;
+282
View File
@@ -0,0 +1,282 @@
#include "UI/NumericScrub.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QtTest>
// Swift's NumericScrub: a label dragged sideways sets its value.
namespace {
// Counts the moves that pass the scrub, which runs first.
struct MoveCounter : QObject {
int moves = 0;
bool eventFilter(QObject *, QEvent *event) override
{
moves += event->type() == QEvent::MouseMove;
return false;
}
};
struct Probe {
QWidget window;
QLabel *label = new QLabel(QStringLiteral("Size"), &window);
QLineEdit *other = new QLineEdit(&window);
double value = 10;
QStringList log;
MoveCounter counter;
explicit Probe(double sensitivity = 1, double low = 0, double high = 100, std::optional<double> step = std::nullopt)
{
auto *row = new QHBoxLayout(&window);
row->addWidget(label);
row->addWidget(other);
window.resize(300, 40);
label->installEventFilter(&counter);
new NumericScrub(label, {.sensitivity = sensitivity, .low = low, .high = high, .step = step, .value = [this] { return value; },
.set = [this](double set) { value = set; log << QString::number(set); },
.onStart = [this] { log << "began"; }, .onEnd = [this] { log << "ended"; }});
window.show();
if (!QTest::qWaitForWindowExposed(&window))
throw std::runtime_error("the probe never showed");
}
};
// A mouse event at a widget's point, sent directly.
bool send(QWidget *widget, QEvent::Type type, QPointF at, Qt::MouseButton button, Qt::MouseButtons buttons)
{
QMouseEvent event(type, at, widget->mapToGlobal(at), button, buttons, Qt::NoModifier);
QApplication::sendEvent(widget, &event);
return event.isAccepted();
}
void press(QWidget *widget, QPointF at, Qt::MouseButton button = Qt::LeftButton)
{
send(widget, QEvent::MouseButtonPress, at, button, button);
}
void move(QWidget *widget, QPointF at, Qt::MouseButtons buttons = Qt::LeftButton)
{
send(widget, QEvent::MouseMove, at, Qt::NoButton, buttons);
}
void release(QWidget *widget, QPointF at, Qt::MouseButton button = Qt::LeftButton)
{
send(widget, QEvent::MouseButtonRelease, at, button, Qt::NoButton);
}
}
class NumericScrubTests : public QObject {
Q_OBJECT
private slots:
void theLabelShowsTheSidewaysCursor();
void aDragFromThePressSetsTheValueOnceItMoves();
void stepsSnapAndTheRangeClamps();
void aClickAloneChangesNothing();
void onlyTheLeftButtonScrubs();
void aLostReleaseEndsTheDrag();
void theDragOutlivesThePointerLeavingTheLabel();
void aDisabledLabelScrubsNothing();
void anyMoveOfAPointBeginsTheDrag();
void aDoubleClicksSecondPressScrubs();
};
void NumericScrubTests::theLabelShowsTheSidewaysCursor()
{
Probe probe;
QCOMPARE(probe.label->cursor().shape(), Qt::SizeHorCursor);
QVERIFY(probe.label->testAttribute(Qt::WA_SetCursor));
QVERIFY(!probe.window.testAttribute(Qt::WA_SetCursor));
// Tracked, so a move after a lost release arrives.
QVERIFY(probe.label->hasMouseTracking());
}
void NumericScrubTests::aDragFromThePressSetsTheValueOnceItMoves()
{
Probe probe(0.5);
press(probe.label, QPointF(10, 5));
// Unmoved, Swift's gesture has not begun.
move(probe.label, QPointF(10, 5));
QVERIFY(probe.log.isEmpty());
move(probe.label, QPointF(14, 5));
QCOMPARE(probe.log, QStringList({"began", "12"}));
// Only the sideways part counts; the start stays fixed.
move(probe.label, QPointF(40, 30));
QCOMPARE(probe.value, 25.0);
move(probe.label, QPointF(9, 5));
QCOMPARE(probe.value, 9.5);
QCOMPARE(probe.log.count("began"), 1);
release(probe.label, QPointF(9, 5));
QCOMPARE(probe.log.last(), QString("ended"));
QCOMPARE(probe.log.count("ended"), 1);
// The next drag starts from where the last one left.
press(probe.label, QPointF(10, 5));
move(probe.label, QPointF(30, 5));
QCOMPARE(probe.value, 19.5);
release(probe.label, QPointF(30, 5));
QCOMPARE(probe.log.count("began"), 2);
QCOMPARE(probe.log.count("ended"), 2);
}
void NumericScrubTests::stepsSnapAndTheRangeClamps()
{
Probe probe(0.3, 5, 12, 1);
press(probe.label, QPointF(100, 5));
move(probe.label, QPointF(105, 5));
// 10 + 1.5 rounds away from zero, as Swift's `rounded()`.
QCOMPARE(probe.value, 12.0);
move(probe.label, QPointF(104, 5));
QCOMPARE(probe.value, 11.0);
move(probe.label, QPointF(200, 5));
QCOMPARE(probe.value, 12.0);
move(probe.label, QPointF(0, 5));
QCOMPARE(probe.value, 5.0);
release(probe.label, QPointF(0, 5));
// A step that is no step leaves the value unsnapped.
Probe free(0.3, 0, 100, 0);
press(free.label, QPointF(100, 5));
move(free.label, QPointF(105, 5));
QCOMPARE(free.value, 11.5);
release(free.label, QPointF(105, 5));
// Halves below zero round away from it, as Swift's.
Probe negative(0.5, -100, 100, 1);
press(negative.label, QPointF(100, 5));
move(negative.label, QPointF(77, 5));
QCOMPARE(negative.value, -2.0);
release(negative.label, QPointF(77, 5));
// A fractional step snaps to its multiples.
Probe tenths(0.07, 0, 100, 0.25);
press(tenths.label, QPointF(100, 5));
move(tenths.label, QPointF(105, 5));
QCOMPARE(tenths.value, 10.25);
release(tenths.label, QPointF(105, 5));
}
void NumericScrubTests::aClickAloneChangesNothing()
{
Probe probe;
QVERIFY(send(probe.label, QEvent::MouseButtonPress, QPointF(10, 5), Qt::LeftButton, Qt::LeftButton));
release(probe.label, QPointF(10, 5));
QVERIFY(probe.log.isEmpty());
// Moves without a press scrub nothing.
move(probe.label, QPointF(40, 5));
QVERIFY(probe.log.isEmpty());
QCOMPARE(probe.value, 10.0);
}
void NumericScrubTests::onlyTheLeftButtonScrubs()
{
Probe probe;
// Unpressed, moves go on to the label untouched.
send(probe.label, QEvent::MouseMove, QPointF(40, 5), Qt::NoButton, Qt::NoButton);
QCOMPARE(probe.counter.moves, 1);
// The label lets other presses go on to its parent.
QVERIFY(!send(probe.label, QEvent::MouseButtonPress, QPointF(10, 5), Qt::RightButton, Qt::RightButton));
move(probe.label, QPointF(40, 5), Qt::RightButton);
release(probe.label, QPointF(40, 5), Qt::RightButton);
QVERIFY(probe.log.isEmpty());
// Another button's release leaves a left drag going.
press(probe.label, QPointF(10, 5));
move(probe.label, QPointF(20, 5));
send(probe.label, QEvent::MouseButtonRelease, QPointF(20, 5), Qt::RightButton, Qt::LeftButton);
send(probe.other, QEvent::MouseButtonRelease, QPointF(5, 5), Qt::RightButton, Qt::LeftButton);
move(probe.label, QPointF(30, 5));
QCOMPARE(probe.log, QStringList({"began", "20", "30"}));
// The drag's own moves stop at the scrub.
QCOMPARE(probe.counter.moves, 2);
release(probe.label, QPointF(30, 5));
QCOMPARE(probe.log.last(), QString("ended"));
}
void NumericScrubTests::aLostReleaseEndsTheDrag()
{
Probe probe;
press(probe.label, QPointF(10, 5));
move(probe.label, QPointF(20, 5));
// A move without the button: the release went elsewhere.
move(probe.label, QPointF(60, 5), Qt::NoButton);
QCOMPARE(probe.log, QStringList({"began", "20", "ended"}));
move(probe.label, QPointF(70, 5));
QCOMPARE(probe.value, 20.0);
// A release a popup takes ends it, seen application-wide.
press(probe.label, QPointF(10, 5));
move(probe.label, QPointF(15, 5));
release(probe.other, QPointF(5, 5));
QCOMPARE(probe.log.mid(3), QStringList({"began", "25", "ended"}));
move(probe.label, QPointF(70, 5));
QCOMPARE(probe.value, 25.0);
// Another widget's release before any move ends only the press.
press(probe.label, QPointF(10, 5));
release(probe.other, QPointF(5, 5));
move(probe.label, QPointF(70, 5));
QCOMPARE(probe.log.size(), 6);
// A move holding another button alone lost the left release.
press(probe.label, QPointF(10, 5));
move(probe.label, QPointF(12, 5));
move(probe.label, QPointF(30, 5), Qt::RightButton);
QCOMPARE(probe.log.mid(6), QStringList({"began", "27", "ended"}));
}
void NumericScrubTests::theDragOutlivesThePointerLeavingTheLabel()
{
Probe probe;
QWindow *window = probe.window.windowHandle();
const QPoint start = probe.label->mapTo(&probe.window, QPoint(5, probe.label->height() / 2));
QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, start);
QTest::mouseMove(window, start + QPoint(3, 0));
// Past the label and the field, out of the window.
QTest::mouseMove(window, start + QPoint(200, 0));
QTest::mouseMove(window, start + QPoint(400, 0));
QCOMPARE(probe.value, 100.0);
QTest::mouseMove(window, start + QPoint(30, 0));
QCOMPARE(probe.value, 40.0);
QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, start + QPoint(30, 0));
QCOMPARE(probe.log, QStringList({"began", "13", "100", "100", "40", "ended"}));
QCOMPARE(probe.value, 40.0);
}
void NumericScrubTests::aDisabledLabelScrubsNothing()
{
Probe probe;
probe.label->setEnabled(false);
QWindow *window = probe.window.windowHandle();
const QPoint start = probe.label->mapTo(&probe.window, QPoint(5, probe.label->height() / 2));
QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, start);
QTest::mouseMove(window, start + QPoint(30, 0));
QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, start + QPoint(30, 0));
QVERIFY(probe.log.isEmpty());
QCOMPARE(probe.value, 10.0);
}
void NumericScrubTests::anyMoveOfAPointBeginsTheDrag()
{
Probe probe;
press(probe.label, QPointF(10, 5));
// Under a point, Swift's gesture has not begun.
move(probe.label, QPointF(10.6, 5));
QVERIFY(probe.log.isEmpty());
move(probe.label, QPointF(11, 5));
QCOMPARE(probe.log, QStringList({"began", "11"}));
release(probe.label, QPointF(11, 5));
// Straight down begins too, and sets the start again.
press(probe.label, QPointF(10, 5));
move(probe.label, QPointF(10, 8));
QCOMPARE(probe.log.mid(3), QStringList({"began", "11"}));
release(probe.label, QPointF(10, 8));
}
void NumericScrubTests::aDoubleClicksSecondPressScrubs()
{
Probe probe;
press(probe.label, QPointF(10, 5));
release(probe.label, QPointF(10, 5));
send(probe.label, QEvent::MouseButtonDblClick, QPointF(10, 5), Qt::LeftButton, Qt::LeftButton);
move(probe.label, QPointF(30, 5));
release(probe.label, QPointF(30, 5));
QCOMPARE(probe.log, QStringList({"began", "30", "ended"}));
// A disabled label's double click scrubs nothing either.
probe.label->setEnabled(false);
send(probe.label, QEvent::MouseButtonDblClick, QPointF(10, 5), Qt::LeftButton, Qt::LeftButton);
move(probe.label, QPointF(60, 5));
QCOMPARE(probe.value, 30.0);
}
QTEST_MAIN(NumericScrubTests)
#include "NumericScrubTests.moc"
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "SelectionFixtures.h"
#include "SessionFixtures.h"
#include <QLabel>
#include <QLineEdit>
#include <QtTest>
// Shared by the scrubbable label tests: finding, dragging, typing.
// The scrubbing labels saying `text`, left to right.
inline QLabel &scrubbed(QWidget &root, const QString &text, int index = 0)
{
std::vector<QLabel *> found;
for (QLabel *label : root.findChildren<QLabel *>())
if (label->text() == text && label->testAttribute(Qt::WA_SetCursor) && label->cursor().shape() == Qt::SizeHorCursor)
found.push_back(label);
std::sort(found.begin(), found.end(), [&root](QLabel *a, QLabel *b) { return a->mapTo(&root, QPoint()).x() < b->mapTo(&root, QPoint()).x(); });
if (size_t(index) >= found.size())
throw std::runtime_error("no scrubbing label " + text.toStdString());
return *found[size_t(index)];
}
inline void send(QWidget &widget, QEvent::Type type, QPointF at, Qt::MouseButton button, Qt::MouseButtons buttons)
{
QMouseEvent event(type, at, widget.mapToGlobal(at), button, buttons, Qt::NoModifier);
QApplication::sendEvent(&widget, &event);
}
// Presses, drags `dx` points sideways, and lets go.
inline void drag(QWidget &label, double dx, bool release = true)
{
send(label, QEvent::MouseButtonPress, QPointF(2, 2), Qt::LeftButton, Qt::LeftButton);
send(label, QEvent::MouseMove, QPointF(2 + dx, 2), Qt::NoButton, Qt::LeftButton);
if (release)
send(label, QEvent::MouseButtonRelease, QPointF(2 + dx, 2), Qt::LeftButton, Qt::NoButton);
}
// Types 7, which applies, then scrubs, holding the press.
inline QString scrubOverTyping(QLineEdit &field, QWidget &label, double dx)
{
field.setFocus();
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("7"));
if (!field.hasFocus())
throw std::runtime_error("the field lost its focus");
drag(label, dx, false);
const QString during = field.text();
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");
return during;
}
// Shown in an active window, so fields take the focus.
inline void showActive(QWidget &widget)
{
widget.show();
widget.activateWindow();
if (!QTest::qWaitForWindowActive(&widget))
throw std::runtime_error("the bar never became active");
}
template <typename Widget> inline Widget &find(QWidget &root, const char *name)
{
auto *found = root.findChild<Widget *>(QString::fromUtf8(name));
if (!found)
throw std::runtime_error(name);
return *found;
}
inline ImportedImage pixels(int width, int height)
{
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::white);
return ImportedImage(image, image, "Pixels");
}
+281
View File
@@ -0,0 +1,281 @@
#include "ScrubFixtures.h"
#include "UI/BrushControls.h"
#include "UI/GradientControls.h"
#include "UI/LassoControls.h"
#include "UI/ShapeControls.h"
#include "UI/TypeControls.h"
#include <QPushButton>
// Swift's scrubbable labels and units in the tool bars.
class ScrubbableLabelsTests : public QObject {
Q_OBJECT
private slots:
void theBrushsLabelsScrubItsTip();
void theGradientsOpacityScrubs();
void theSelectionBarsLabelsAndUnitsScrubWholeNumbers();
void theAmountSheetsTitleScrubsItsInput();
void theShapesWidthAndRadiusScrub();
void theTypeBarScrubsWholeSizesTrackingAndLeading();
void aScrubShowsItsValueInAFocusedField();
void featherScrubsWithoutASelection();
};
void ScrubbableLabelsTests::theBrushsLabelsScrubItsTip()
{
EditorSession session;
session.createDocument(100, 100, true);
session.selectTool(NavigationTool::brush);
BrushControls bar(session);
bar.show();
drag(scrubbed(bar, "Size"), 25);
QCOMPARE(session.brushSettings().diameter, 65.0);
QCOMPARE(find<QLineEdit>(bar, "brushSize").text(), QString("65"));
drag(scrubbed(bar, "Size"), -5000);
QCOMPARE(session.brushSettings().diameter, 1.0);
drag(scrubbed(bar, "Size"), 5000);
QCOMPARE(session.brushSettings().diameter, 2000.0);
// Unstepped, a fraction of a point counts.
drag(scrubbed(bar, "Size"), -1.5);
QCOMPARE(session.brushSettings().diameter, 1998.5);
drag(scrubbed(bar, "Hardness"), -30);
QVERIFY(qAbs(session.brushSettings().hardness - 0.7) < 1e-12);
drag(scrubbed(bar, "Hardness"), 90);
QCOMPARE(session.brushSettings().hardness, 1.0);
drag(scrubbed(bar, "Hardness"), -900);
QCOMPARE(session.brushSettings().hardness, 0.0);
drag(scrubbed(bar, "Opacity"), -50);
QVERIFY(qAbs(session.brushSettings().opacity - 0.5) < 1e-12);
drag(scrubbed(bar, "Opacity"), -500);
QCOMPARE(session.brushSettings().opacity, 0.01);
drag(scrubbed(bar, "Opacity"), 500);
QCOMPARE(session.brushSettings().opacity, 1.0);
drag(scrubbed(bar, "Smoothing"), 30);
QCOMPARE(session.brushSettings().smoothing, 30.0);
drag(scrubbed(bar, "Smoothing"), 300);
QCOMPARE(session.brushSettings().smoothing, 100.0);
drag(scrubbed(bar, "Smoothing"), -300);
QCOMPARE(session.brushSettings().smoothing, 0.0);
// The Smear's Strength is the same label, retitled.
session.selectTool(NavigationTool::blur);
const double strength = session.brushSettings().opacity;
drag(scrubbed(bar, "Strength"), -10);
QVERIFY(qAbs(session.brushSettings().opacity - (strength - 0.1)) < 1e-12);
}
void ScrubbableLabelsTests::theGradientsOpacityScrubs()
{
EditorSession session;
session.createDocument(100, 100, true);
session.selectTool(NavigationTool::gradient);
GradientControls bar(session);
bar.show();
// Unstepped: a point and a half is 1.5%.
drag(scrubbed(bar, "Opacity"), -1.5);
QVERIFY(qAbs(session.gradientSettings().opacity - 0.985) < 1e-12);
drag(scrubbed(bar, "Opacity"), 1.5);
drag(scrubbed(bar, "Opacity"), -40);
QVERIFY(qAbs(session.gradientSettings().opacity - 0.6) < 1e-12);
drag(scrubbed(bar, "Opacity"), -400);
QCOMPARE(session.gradientSettings().opacity, 0.01);
drag(scrubbed(bar, "Opacity"), 400);
QCOMPARE(session.gradientSettings().opacity, 1.0);
}
void ScrubbableLabelsTests::theSelectionBarsLabelsAndUnitsScrubWholeNumbers()
{
std::unique_ptr<EditorSession> session = selectionSession();
LassoControls bar(*session);
bar.resize(1600, bar.height());
bar.show();
// Without a selection the amounts rest, units and all.
QLabel &expand = scrubbed(bar, "px", 0);
QVERIFY(!expand.isEnabled());
QVERIFY(!scrubbed(bar, "px", 1).isEnabled());
drag(scrubbed(bar, "px", 1), 9);
QCOMPARE(session->selectionContractAmount(), 1);
lasso(*session, square(10, 10, 20));
QVERIFY(expand.isEnabled());
drag(expand, 7);
QCOMPARE(session->selectionExpandAmount(), 8);
drag(expand, 900);
QCOMPARE(session->selectionExpandAmount(), 500);
drag(expand, -900);
QCOMPARE(session->selectionExpandAmount(), 1);
drag(scrubbed(bar, "px", 1), 4);
QCOMPARE(session->selectionContractAmount(), 5);
drag(scrubbed(bar, "px", 1), 900);
QCOMPARE(session->selectionContractAmount(), 500);
drag(scrubbed(bar, "px", 1), -900);
QCOMPARE(session->selectionContractAmount(), 1);
drag(scrubbed(bar, "px", 2), 3);
QCOMPARE(session->selectionFeatherAmount(), 5);
drag(scrubbed(bar, "px", 2), 900);
QCOMPARE(session->selectionFeatherAmount(), 250);
drag(scrubbed(bar, "px", 2), -900);
QCOMPARE(session->selectionFeatherAmount(), 1);
QCOMPARE(find<QLineEdit>(bar, "selectionFeatherAmount").text(), QString("1"));
session->selectTool(NavigationTool::wand);
// Whole numbers round, where the setter would truncate.
drag(scrubbed(bar, "Tolerance"), 1.6);
QCOMPARE(session->wandSettings().tolerance, 34);
drag(scrubbed(bar, "Tolerance"), 8);
QCOMPARE(session->wandSettings().tolerance, 42);
drag(scrubbed(bar, "Tolerance"), 400);
QCOMPARE(session->wandSettings().tolerance, 255);
drag(scrubbed(bar, "Tolerance"), -400);
QCOMPARE(session->wandSettings().tolerance, 0);
session->setWandMode(WandMode::object);
drag(scrubbed(bar, "Edge"), -1.5);
QCOMPARE(session->objectSelectionSettings().edgeOffset, -2);
drag(scrubbed(bar, "Edge"), -2);
QCOMPARE(session->objectSelectionSettings().edgeOffset, -4);
drag(scrubbed(bar, "Edge"), 50);
QCOMPARE(session->objectSelectionSettings().edgeOffset, 10);
drag(scrubbed(bar, "Edge"), -50);
QCOMPARE(session->objectSelectionSettings().edgeOffset, -10);
}
void ScrubbableLabelsTests::theAmountSheetsTitleScrubsItsInput()
{
std::unique_ptr<EditorSession> session = selectionSession();
SelectionAmountSheet sheet(*session, SelectionAmountOperation::feather);
sheet.show();
QLineEdit &input = find<QLineEdit>(sheet, "amountField");
// Rounded to whole pixels, not truncated.
drag(scrubbed(sheet, "Amount"), 1.6);
QCOMPARE(input.text(), QString("4"));
input.setText(QStringLiteral("2"));
drag(scrubbed(sheet, "Amount"), 10);
QCOMPARE(input.text(), QString("12"));
drag(scrubbed(sheet, "Amount"), 900);
QCOMPARE(input.text(), QString("250"));
// Unreadable input starts the drag from one.
input.setText(QStringLiteral("abc"));
drag(scrubbed(sheet, "Amount"), 4);
QCOMPARE(input.text(), QString("5"));
drag(scrubbed(sheet, "Amount"), -40);
QCOMPARE(input.text(), QString("1"));
SelectionAmountSheet expand(*session, SelectionAmountOperation::expand);
drag(scrubbed(expand, "Amount"), 900);
QCOMPARE(find<QLineEdit>(expand, "amountField").text(), QString("500"));
}
void ScrubbableLabelsTests::theShapesWidthAndRadiusScrub()
{
EditorSession session;
session.createDocument(100, 100, true);
session.selectTool(NavigationTool::shape);
session.setShapeKind(ShapeKind::line);
ShapeControls bar(session);
bar.show();
drag(scrubbed(bar, "Width"), 6.5);
QCOMPARE(session.shapeLineWidth(), 10.5);
drag(scrubbed(bar, "Width"), 9000);
QCOMPARE(session.shapeLineWidth(), 5000.0);
drag(scrubbed(bar, "Width"), -9000);
QCOMPARE(session.shapeLineWidth(), 1.0);
session.setShapeKind(ShapeKind::rectangle);
drag(scrubbed(bar, "Radius"), 12.25);
QCOMPARE(session.shapeCornerRadius(), 12.25);
drag(scrubbed(bar, "Radius"), 9000);
QCOMPARE(session.shapeCornerRadius(), 5000.0);
drag(scrubbed(bar, "Radius"), -9000);
QCOMPARE(session.shapeCornerRadius(), 0.0);
}
void ScrubbableLabelsTests::theTypeBarScrubsWholeSizesTrackingAndLeading()
{
EditorSession session;
session.createDocument(400, 300, true);
session.selectTool(NavigationTool::type);
TypeControls bar(session);
bar.show();
session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 24.4; });
// Dragged sizes land on whole numbers, Swift's step of one.
drag(scrubbed(bar, "px"), 3);
QCOMPARE(session.currentTextStyle().fontSize, 27.0);
drag(scrubbed(bar, "px"), 5000);
QCOMPARE(session.currentTextStyle().fontSize, 2000.0);
drag(scrubbed(bar, "px"), -5000);
QCOMPARE(session.currentTextStyle().fontSize, 1.0);
session.changeTextStyle([](LayerTextStyle &style) { style.tracking = 2.6; });
drag(scrubbed(bar, "Tracking"), -5);
QCOMPARE(session.currentTextStyle().tracking, -2.0);
drag(scrubbed(bar, "Tracking"), -500);
QCOMPARE(session.currentTextStyle().tracking, -100.0);
drag(scrubbed(bar, "Tracking"), 5000);
QCOMPARE(session.currentTextStyle().tracking, 1000.0);
// Auto is zero, so a drag starts there.
drag(scrubbed(bar, "Leading"), 12);
QCOMPARE(session.currentTextStyle().leading, 12.0);
drag(scrubbed(bar, "Leading"), -50);
QCOMPARE(session.currentTextStyle().leading, 0.0);
drag(scrubbed(bar, "Leading"), 9000);
QCOMPARE(session.currentTextStyle().leading, 5000.0);
}
void ScrubbableLabelsTests::aScrubShowsItsValueInAFocusedField()
{
EditorSession session;
session.createDocument(100, 100, true);
session.selectTool(NavigationTool::brush);
{
BrushControls bar(session);
showActive(bar);
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "brushSize"), scrubbed(bar, "Size"), 25), QString("32"));
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "brushHardness"), scrubbed(bar, "Hardness"), 30), QString("37"));
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "brushOpacity"), scrubbed(bar, "Opacity"), 40), QString("47"));
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "brushSmoothing"), scrubbed(bar, "Smoothing"), 12), QString("19"));
}
session.selectTool(NavigationTool::gradient);
{
GradientControls bar(session);
showActive(bar);
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "gradientOpacity"), scrubbed(bar, "Opacity"), 25), QString("32"));
}
session.selectTool(NavigationTool::shape);
session.setShapeKind(ShapeKind::line);
{
ShapeControls bar(session);
showActive(bar);
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "shapeWidth"), scrubbed(bar, "Width"), 6), QString("13"));
}
session.selectTool(NavigationTool::wand);
{
LassoControls bar(session);
bar.resize(1600, bar.height());
showActive(bar);
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "wandTolerance"), scrubbed(bar, "Tolerance"), 10), QString("17"));
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "selectionFeatherAmount"), scrubbed(bar, "px", 2), 3), QString("10"));
}
session.selectTool(NavigationTool::type);
{
TypeControls bar(session);
showActive(bar);
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "typeTracking"), scrubbed(bar, "Tracking"), 4), QString("11"));
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "typeLeading"), scrubbed(bar, "Leading"), 30), QString("37"));
QCOMPARE(scrubOverTyping(find<QLineEdit>(bar, "typeSize"), scrubbed(bar, "px"), -2), QString("5"));
}
}
void ScrubbableLabelsTests::featherScrubsWithoutASelection()
{
std::unique_ptr<EditorSession> session = selectionSession();
LassoControls bar(*session);
bar.resize(1600, bar.height());
bar.show();
// Swift rests Feather's button alone; its amount stays.
drag(scrubbed(bar, "px", 2), 7);
QCOMPARE(session->selectionFeatherAmount(), 9);
QVERIFY(find<QLineEdit>(bar, "selectionFeatherAmount").isEnabled());
QVERIFY(!find<QLineEdit>(bar, "selectionExpandAmount").isEnabled());
QVERIFY(!find<QLineEdit>(bar, "selectionContractAmount").isEnabled());
for (QPushButton *button : bar.findChildren<QPushButton *>())
if (button->text() == "Expand" || button->text() == "Contract" || button->text() == "Feather")
QVERIFY(!button->isEnabled());
drag(scrubbed(bar, "px", 0), 7);
QCOMPARE(session->selectionExpandAmount(), 1);
}
QTEST_MAIN(ScrubbableLabelsTests)
#include "ScrubbableLabelsTests.moc"
+213
View File
@@ -0,0 +1,213 @@
#include "ScrubFixtures.h"
#include "UI/LayerAppearanceControls.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
// Swift's scrubbable Move bar, zoom unit and layer opacity.
class ScrubbableTransformTests : public QObject {
Q_OBJECT
private slots:
void theTransformLabelsScrubWholeNumbersOverTyping();
void scaleScrubsDownToATenth();
void theZoomUnitScrubsThePercentage();
void theLayerOpacityScrubIsOneStep();
void anOpacityScrubEndsWithItsLayer();
void aTransformScrubEndsWithItsLayer();
};
void ScrubbableTransformTests::theTransformLabelsScrubWholeNumbersOverTyping()
{
EditorSession session;
session.createDocument(200, 100);
session.insert(pixels(40, 20));
session.selectTool(NavigationTool::move);
session.setLocksTransformRatio(false);
TransformInspector bar(session);
bar.show();
bar.activateWindow();
QVERIFY(QTest::qWaitForWindowActive(&bar));
const QPointF origin = session.activeLayer().value().transform.origin;
// The first drag begins the edit, as typing does.
drag(scrubbed(bar, "X"), 7);
QCOMPARE(session.transformEdit().value().draft.origin.x(), std::round(origin.x()) + 7);
// A scrub writes over what is typed in its field.
QLineEdit &width = find<QLineEdit>(bar, "transformW");
width.setFocus();
width.selectAll();
QTest::keyClicks(&width, QStringLiteral("3"));
drag(scrubbed(bar, "W"), 10);
QCOMPARE(session.transformEdit().value().draft.size, QSizeF(13, 20));
QCOMPARE(width.text(), QString("13"));
drag(scrubbed(bar, "W"), -100);
QCOMPARE(session.transformEdit().value().draft.size.width(), 1.0);
drag(scrubbed(bar, "H"), 40000);
QCOMPARE(session.transformEdit().value().draft.size.height(), 30000.0);
drag(scrubbed(bar, "°"), 30);
QCOMPARE(session.transformEdit().value().draft.rotation, 30.0);
drag(scrubbed(bar, "°"), -900);
QCOMPARE(session.transformEdit().value().draft.rotation, -0.0);
// Unfocused, the field shows what the session keeps.
QCOMPARE(find<QLineEdit>(bar, "transform°").text(), QString("0"));
QCOMPARE(width.text(), QString("1"));
drag(scrubbed(bar, "Y"), -90000);
QCOMPARE(session.transformEdit().value().draft.origin.y(), -30000.0);
// A fractional start lands on a whole number.
QLineEdit &x = find<QLineEdit>(bar, "transformX");
x.setFocus();
x.selectAll();
QTest::keyClicks(&x, QStringLiteral("10.4"));
QCOMPARE(session.transformEdit().value().draft.origin.x(), 10.4);
drag(scrubbed(bar, "X"), 3);
QCOMPARE(session.transformEdit().value().draft.origin.x(), 13.0);
QCOMPARE(x.text(), QString("13"));
// Every range reaches its ends exactly.
drag(scrubbed(bar, "X"), -90000);
QCOMPARE(session.transformEdit().value().draft.origin.x(), -30000.0);
drag(scrubbed(bar, "X"), 90000);
QCOMPARE(session.transformEdit().value().draft.origin.x(), 30000.0);
drag(scrubbed(bar, "Y"), 90000);
QCOMPARE(session.transformEdit().value().draft.origin.y(), 30000.0);
drag(scrubbed(bar, "W"), 90000);
QCOMPARE(session.transformEdit().value().draft.size.width(), 30000.0);
drag(scrubbed(bar, "H"), -90000);
QCOMPARE(session.transformEdit().value().draft.size.height(), 1.0);
// 360 reaches the setter, whose remainder leaves none.
drag(scrubbed(bar, "°"), 9000);
QCOMPARE(session.transformEdit().value().draft.rotation, 0.0);
drag(scrubbed(bar, "°"), 359);
QCOMPARE(session.transformEdit().value().draft.rotation, 359.0);
// Scale snaps whole percents too.
session.cancelTransform();
drag(scrubbed(bar, "Scale"), 5);
QCOMPARE(session.transformEdit().value().draft.size, QSizeF(42, 21));
session.cancelTransform();
drag(scrubbed(bar, "Scale"), 90000);
QCOMPARE(session.transformEdit().value().draft.size, QSizeF(12000, 6000));
QCOMPARE(find<QLineEdit>(bar, "transformScale").text(), QString("30000"));
// While distorted the numbers rest, labels included.
session.cancelTransform();
session.beginTransform();
session.beginDistort();
QVERIFY(!scrubbed(bar, "X").isEnabled());
}
// Scale's floor is a tenth of a percent.
void ScrubbableTransformTests::scaleScrubsDownToATenth()
{
EditorSession session;
session.createDocument(4000, 2000);
session.insert(pixels(2000, 1000));
session.selectTool(NavigationTool::move);
TransformInspector bar(session);
bar.show();
drag(scrubbed(bar, "Scale"), -90000);
QCOMPARE(session.transformEdit().value().draft.size, QSizeF(2, 1));
QCOMPARE(find<QLineEdit>(bar, "transformScale").text(), QString("0.10"));
}
void ScrubbableTransformTests::theZoomUnitScrubsThePercentage()
{
EditorSession session;
session.createDocument(800, 600);
session.selectTool(NavigationTool::zoom);
NavigationToolHeader bar(session);
bar.show();
session.zoom(1);
drag(scrubbed(bar, "%"), 25.5);
QVERIFY(qAbs(session.viewport.zoom() - 1.255) < 1e-9);
QCOMPARE(find<QLineEdit>(bar, "zoomPercentage").text(), QString("125.5"));
drag(scrubbed(bar, "%"), -900);
QVERIFY(qAbs(session.viewport.zoom() - 0.001) < 1e-9);
drag(scrubbed(bar, "%"), 9000);
QVERIFY(qAbs(session.viewport.zoom() - 32) < 1e-9);
}
void ScrubbableTransformTests::theLayerOpacityScrubIsOneStep()
{
EditorSession session;
LayerAppearanceControls controls(session);
session.createDocument(4, 4);
session.insert(pixels(2, 2));
controls.show();
QLabel &opacity = scrubbed(controls, "Opacity");
// One drag is one opacity edit, open until the release.
drag(opacity, -30, false);
QVERIFY(qAbs(session.activeLayer().value().opacity - 0.7) < 1e-12);
QVERIFY(!session.canUndo());
send(opacity, QEvent::MouseMove, QPoint(-18, 2), Qt::NoButton, Qt::LeftButton);
QVERIFY(qAbs(session.activeLayer().value().opacity - 0.8) < 1e-12);
send(opacity, QEvent::MouseButtonRelease, QPoint(-18, 2), Qt::LeftButton, Qt::NoButton);
QVERIFY(session.canUndo());
QCOMPARE(find<QLineEdit>(controls, "opacityPercent").text(), QString("80"));
// One undo takes the whole drag back; the layer stays.
session.undo();
QCOMPARE(session.activeLayer().value().opacity, 1.0);
QCOMPARE(session.document().value().layers.size(), size_t(1));
drag(opacity, -30.5);
QVERIFY(qAbs(session.activeLayer().value().opacity - 0.695) < 1e-12);
drag(opacity, -500);
QCOMPARE(session.activeLayer().value().opacity, 0.0);
drag(opacity, 500);
QCOMPARE(session.activeLayer().value().opacity, 1.0);
}
void ScrubbableTransformTests::anOpacityScrubEndsWithItsLayer()
{
EditorSession session;
LayerAppearanceControls controls(session);
session.createDocument(4, 4);
session.insert(pixels(2, 2));
const QUuid lower = session.activeLayerID().value();
session.setLayerOpacity(0.3);
session.insert(pixels(2, 2));
const QUuid upper = session.activeLayerID().value();
controls.show();
QLabel &opacity = scrubbed(controls, "Opacity");
drag(opacity, -10, false);
QVERIFY(qAbs(layerWith(session, upper).opacity - 0.9) < 1e-12);
// Another layer ends the drag; held moves change nothing.
session.selectLayer(lower);
send(opacity, QEvent::MouseMove, QPointF(-18, 2), Qt::NoButton, Qt::LeftButton);
send(opacity, QEvent::MouseButtonRelease, QPointF(-18, 2), Qt::LeftButton, Qt::NoButton);
QCOMPARE(layerWith(session, lower).opacity, 0.3);
QVERIFY(qAbs(layerWith(session, upper).opacity - 0.9) < 1e-12);
QVERIFY(session.canUndo());
session.undo();
QCOMPARE(layerWith(session, upper).opacity, 1.0);
QCOMPARE(layerWith(session, lower).opacity, 0.3);
// A fresh press on the new layer scrubs it.
session.selectLayer(lower);
drag(opacity, 20);
QVERIFY(qAbs(layerWith(session, lower).opacity - 0.5) < 1e-12);
}
void ScrubbableTransformTests::aTransformScrubEndsWithItsLayer()
{
EditorSession session;
session.createDocument(200, 100);
session.insert(pixels(40, 20));
const QUuid first = session.activeLayerID().value();
session.insert(pixels(40, 20));
const QUuid second = session.activeLayerID().value();
session.selectTool(NavigationTool::move);
TransformInspector bar(session);
bar.show();
const double x = layerWith(session, second).transform.origin.x();
QLabel &label = scrubbed(bar, "X");
drag(label, 10, false);
QCOMPARE(session.transformEdit().value().draft.origin.x(), std::round(x) + 10);
session.commitTransform();
// Another layer ends the drag; held moves open no edit.
session.selectLayer(first);
send(label, QEvent::MouseMove, QPointF(22, 2), Qt::NoButton, Qt::LeftButton);
send(label, QEvent::MouseButtonRelease, QPointF(22, 2), Qt::LeftButton, Qt::NoButton);
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, first).transform.origin.x(), x);
QCOMPARE(layerWith(session, second).transform.origin.x(), std::round(x) + 10);
// A fresh press scrubs the new layer.
drag(label, 5);
QCOMPARE(session.transformEdit().value().draft.origin.x(), std::round(x) + 5);
}
QTEST_MAIN(ScrubbableTransformTests)
#include "ScrubbableTransformTests.moc"
+2 -1
View File
@@ -120,7 +120,8 @@ void SelectionAmountSheetTests::theBarFeathersByItsAmount()
QVERIFY(QTest::qWaitForWindowExposed(&controls));
auto &feather = find<QPushButton>(controls, "featherSelection");
auto &amount = find<QLineEdit>(controls, "selectionFeatherAmount");
QVERIFY(!feather.isEnabled() && !amount.isEnabled());
// Swift rests the button alone; the amount stays editable.
QVERIFY(!feather.isEnabled() && amount.isEnabled());
QCOMPARE(amount.text(), QString("2"));
lasso(*session, square(20, 20, 40));
QVERIFY(feather.isEnabled() && amount.isEnabled());
+3
View File
@@ -144,6 +144,9 @@ compositor_test(NativeLayerListTests)
compositor_test(LayerContextMenuTests)
compositor_test(LayerContextMenuEdgeTests)
compositor_test(NavigationToolHeaderTests)
compositor_test(NumericScrubTests)
compositor_test(ScrubbableLabelsTests)
compositor_test(ScrubbableTransformTests)
compositor_test(NewCanvasSheetTests)
compositor_test(OmarchyThemeTests)
compositor_test(ProjectControllerTests)