Magic tool Object mode (Compositor 1.1.8)

This commit is contained in:
雷电芽衣 committed 2026-09-26 23:24:35 -04:00
1 parent 78bfa2445d
commit 3d56688ce4
39 files changed
+1193 -134

No files matched your search

+1
View File
@@ -116,6 +116,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `compositor.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`.
- Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a namespace), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`.
- Rulers, and guides on the canvas (12.3b, Swift 1.1.7): `UI/CanvasRulers.h/.cpp` twin Swift's file: `CanvasRuler::thickness`, `CanvasRulerCorner` and `CanvasRulerView` (a widget per strip, repainting on every `changed()`, where Swift's canvas asks each ruler to redraw), with Swift's `majorStep` and `label`. The canvas is the rulers' sibling, so a ruler maps its points through the screen (`inCanvas`), where Swift reads its own bounds as the canvas's. A press hands the canvas the keys and begins a guide from that ruler (horizontal from the top one), a drag moves it, a release over a ruler drops it; a ruler has no mouse tracking, so it sees no buttonless move and leaves a lost release to the canvas's next press. `ContentView` frames the canvas slot in a grid with the corner and the two strips, shown while `showsRulers` holds and a document is open. `TransformOverlay` draws the layout grid (majors solid gray 0.7 at 45%, subdivisions dotted one point on and two off in gray 0.55 at 28% once four points apart, both a hairline) and the guides (`EditorSession::guideColor`, a hairline across the whole view) first, as Swift's `draw`; the canvas redraws the view when the grid's flag, the guides' flag or the displayed guides change (`GuidesShown`), Swift's observation. Guide drags live in `EditorCanvas+Guides.cpp`: with the Move tool a press on a guide (`canEditGuides` and `hitGuide`) moves it ahead of any layer under the resize cursor of its axis, a drag follows, a release over a ruler (`isOverRuler`: a point left of or above the canvas while the rulers show) deletes it; Escape cancels it ahead of a transform edit's keys; wheels and pinches rest while one is dragged; the Move tool's hover shows the guide's resize cursor after the handles and before a layer's move cursor. Qt can lose a release: a buttonless move and a lost focus finish the drag where it is. Equivalent mutant: the ruler press's `canEditGuides` test (`beginGuideCreation` asks it again). Tests: `GuideCanvasTests` (Swift's `moveToolDragsAGuideAndRulerDropDeletesIt` and `rulerStepUsesNicePixelIntervals`; the drawing, repaints, cursors, keys, lost releases, the rulers' layout, ticks, repaints, focus and drags), the canvas's place in `ContentViewTests`.
- The Magic tool's Object mode (12.4, Swift 1.1.8): `Document/ObjectSelection.h/.cpp` twin Swift's file, `ObjectSelection+Session.h` its session half; `WandMode` (Swift's raw values, `allWandModes`) sits in `Selection.h`. Vision's foreground instances have no Linux twin (the user's decision): `SubjectRemoval::foreground` hands back U²-Net's mask at the image's size (the Remove Background model and cache, 9.7), which `select` scales to the model's 320 grid, cuts at half, and splits into 8-connected regions (`region`, the neighbours a traced outline joins); the region under the clicked cell is the instance, background none, and a picture where the model finds no subject none, as Vision's empty result. Its cells are enlarged smoothly to the image and refined by `GuidedMatte::refine` (radius 5) on the image, for Swift's edge-preserving upsample, then cut at half; `adjusted` erodes or dilates a pixel ring a step (eight neighbours, ten steps at most, the image's edge counting as inside, Swift's `eroded` and `dilated`), `MagicWand::outline` traces it and `smoothed` rounds the stairs as Swift's (the start Qt's close repeats dropped first, since a CGPath's close adds no point, then a subpath under three points dropped, Douglas–Peucker at 1.6 from the leftmost point, Chaikin three times, the fill rule kept; Swift's later drops of a repeated end and Chaikin's guard under three points are dead after that and left out). A `bad_alloc` is `ExportError(render)`; Swift's `unsupported` has no twin. The worker catches `SubjectRemovalError`, `MagicWandError` (the trace's refusals) and `ExportError`, each landing in `brushError` as Swift's `catch`. `selectionSample` is the wand's sample made public as Swift's; `selectObject` runs through the wand's watcher and landing (`Wanding` carries the step's name, `Object Selection`), with a completion callback, the offset clamped to ten either way and the anti-alias setting as its smoothing. `pressWandKey` chooses the tool, W on the canvas and in the list, a repeat ignored; Tab steps the mode; the tool's label, its shortcut definition (`Magic`) and the bar's title say Magic. The bar (`LassoControls`) shows Wand and Object as checkable buttons for Magic alone, the colour wand's controls in Wand mode, and in Object mode This Layer and All Layers and an Edge field of −10 to 10 px (`SelectionAmountField` takes a sign when its range goes below zero; a lone minus changes nothing, an overflow reads as its end), with Swift's help; the anti-alias help follows the mode. The canvas's press runs `selectObject` ahead of the colour wand, a click inside the selection picks afresh, the cursor is the crosshair with `SelectionIcon::objectSelection` (haloed by eight offsets as every selection icon, where Swift strokes it white), and the rail draws the icon for Object mode (`ToolIconKind::object`); the status hint follows the mode. Known difference: U²-Net finds one salient mask, so objects touching in it are one region where Vision may tell instances apart. Left out as dead: the mode picker's `cancelLasso` (Magic never holds a draft: `beginLasso` refuses it and `selectTool` cancels one), `selectObject`'s busy term (`canEditSelection` reads it) and its clamp of the offset (`adjusted` clamps), and a null check of the enlarged cells (`GuidedMatte::refine` checks its own). Equivalent mutants: the grid's cut at exactly 128, `simplifyClosed`'s guard under four points (the fallback returns the same points), `finish`'s three-point threshold at four (a traced loop has four corners), the Chaikin reserve, the edge's rounding (typing and arrows give whole numbers), W calling `selectTool` (`pressWandKey` is that call). Untested failure paths: the region's own 320-cell image, 100 KB, which no limit starves without starving the grid before it, and the worker's `MagicWandError`: a refined mask never nears eight million edges, and the trace's image-sized buffer asks less than the guided filter before it. Tests: `ObjectSelectionTests` (regions, edges, smoothing, the model on red discs, the session, keys, the canvas), the bar in `LassoControlsTests`, the icon sheet, the rail and hints in `ContentViewTests` and `ContentViewStatusTests`.
- 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 the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
+3
View File
@@ -49,6 +49,7 @@ add_library(compositor_core STATIC
src/Document/SelectionClipboard.cpp
src/Document/Crop.cpp
src/Document/Guides.cpp
src/Document/ObjectSelection.cpp
src/Document/Filters.cpp
src/Document/GuidedMatte.cpp
src/Document/SubjectRemoval.cpp
@@ -72,6 +73,7 @@ add_library(compositor_core STATIC
src/Document/MaskTracing.cpp
src/Document/PixelInvert.cpp
src/Document/Selection.cpp
src/Document/Selection+Modify.cpp
src/Document/SelectionEdits.cpp
src/Document/ShapeTool.cpp
src/Document/TypeTool.cpp
@@ -440,6 +442,7 @@ compositor_test(SubjectRemovalModelTests)
compositor_test(SubjectRemovalFailureTests)
compositor_test(RemoveBackgroundTests)
compositor_test(SelectSubjectTests)
compositor_test(ObjectSelectionTests)
compositor_test(LayerEffectsRendererTests)
compositor_test(LayerFlipTests)
compositor_test(LayerMergeTests)
+4 -1
View File
@@ -317,7 +317,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.3b work: the rulers beside the canvas (corner, strips, ticks and labels, drags that make and drop guides), the grid and guides drawn over the canvas, guide drags with the Move tool (cursor, Escape, rests for wheels and pinches, lost releases). Evidence: every test green in the dev image, lint clean; mutants over the rulers, the overlay, the canvas's drags, keys, cursor and repaint, and the layout: all caught but the equivalent one AGENTS.md names.
- 12.3b round 1: FAIL, three findings. Six drawn details passed their tests broken: the ruler's half-step tick, its labels' place, presence and gray, the guides' hairline width, the grid majors' ink, and Escape taking a guide drag ahead of an open transform edit (each pinned now, its mutant caught); a screenshot two tests saved in the working directory (they save beside the test binary now, the stray file removed); a no-op assertion (gone).
- 12.3b round 2: PASS, no findings.
- [ ] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region).
- [x] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region).
- 12.4 work: Object mode (U²-Net regions on the model grid, the guided edge, edge steps, smoothing), the session call, W and Tab, the Magic bar (modes, source, a signed Edge field), the canvas press and cursor, the rail icon and hints. Evidence: every test green in the dev image, lint clean; statement deletion over `ObjectSelection.cpp` and targeted mutants over the model, session, bar, canvas, list, hints, wand landing and icons: all caught but the equivalents AGENTS.md names.
- 12.4 round 1: FAIL, five findings. A trace the wand kernel refuses left the Object worker uncaught and aborted the app (the worker catches `MagicWandError` now; no test reaches it, named in AGENTS.md); three details passed their tests broken: the press handing the view point on (the click now lands through a zoom), the cell row read through the width (a disc low on a 4:3 picture now), the bounds rounding where Swift floors (a click in the last half pixel now), and a minus after a digit (typed now); the icon sheet saved in the working directory (beside the binary now, the stray file gone); Qt's repeated start counted toward three points (dropped first now, Swift's later drops and Chaikin's guard dead and gone); the signal record lacked the mode and settings (read now).
- 12.4 round 2: PASS. Its residuals: the three-point threshold named equivalent in AGENTS.md; the launch test's screenshot now saved beside its binary, the same cause as the icon sheet.
- [ ] 12.5a Version 1.2.0, part one: Photoshop PSD import (the reader, channel coder, vectors, document builder, the conversion report sheet).
- [ ] 12.5b Version 1.2.0, part two: the Outer Glow effect, brush Smoothing, and the release's other changes to what the twin holds.
- [ ] 12.6 Version 1.2.1: camera RAW import through LibRaw with its develop step, middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`.
+5 -1
View File
@@ -20,6 +20,8 @@ ToolIconKind ContentView::iconKind(NavigationTool tool) const
return ToolIconKind::polygonal;
if (tool == NavigationTool::brush && m_session.brushMode() == BrushToolMode::erase)
return ToolIconKind::eraser;
if (tool == NavigationTool::wand && m_session.wandMode() == WandMode::object)
return ToolIconKind::object;
return ToolIconKind::plain;
}
@@ -29,6 +31,8 @@ QString ContentView::hint(NavigationTool tool, ToolIconKind kind, BlurToolMode s
return QStringLiteral("Drag an ellipse · Shift add · Alt subtract · Shift again mid-drag circle · Drag inside to move · Delete clears · Ctrl+D deselect");
if (tool == NavigationTool::lasso && kind == ToolIconKind::polygonal)
return QStringLiteral("Click corners · Click start, double-click or Enter to close · Delete removes corner · Escape cancel");
if (tool == NavigationTool::wand && kind == ToolIconKind::object)
return QStringLiteral("Click an object to select its outline · Tab for Wand · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect");
if (tool == NavigationTool::brush && kind == ToolIconKind::eraser)
return QStringLiteral("Drag to erase · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan");
if (tool == NavigationTool::blur)
@@ -42,7 +46,7 @@ QString ContentView::hint(NavigationTool tool, ToolIconKind kind, BlurToolMode s
// Swift's last branch: the Eyedropper shows Zoom's words too.
static const std::map<NavigationTool, const char *> hints = {
{NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::wand, "Click to select similar colors · Tab for Object · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"},
{NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"},
{NavigationTool::cloneStamp, "Alt-click to set the source · Drag to clone · [ ] size · Shift-[ ] hardness · 1–0 opacity · Space to pan"},
+2 -1
View File
@@ -46,11 +46,12 @@ struct SessionJobs {
std::optional<QImage> image;
std::optional<QString> failure;
};
// What a wand click waits for and applies.
// The Magic tool's job, either mode, and its step's name.
struct Wanding {
SelectionMode mode;
QUuid documentID;
std::function<void()> done;
QString name;
};
struct Wanded {
std::optional<QPainterPath> path;
+1 -1
View File
@@ -96,7 +96,7 @@ QString label(NavigationTool tool)
case NavigationTool::move: return QStringLiteral("Move / Transform (V)");
case NavigationTool::marquee: return QStringLiteral("Marquee (M)");
case NavigationTool::lasso: return QStringLiteral("Lasso (L)");
case NavigationTool::wand: return QStringLiteral("Magic Wand (W)");
case NavigationTool::wand: return QStringLiteral("Magic (W) · Tab switches Wand and Object");
case NavigationTool::crop: return QStringLiteral("Crop (C)");
case NavigationTool::brush: return QStringLiteral("Brush (B) · Eraser (E)");
case NavigationTool::spotHealing: return QStringLiteral("Spot Healing Brush (J)");
+2
View File
@@ -165,6 +165,8 @@ void EditorSession::cycleToolMode()
return;
if (m_tool == NavigationTool::marquee)
toggleMarqueeKind();
else if (m_tool == NavigationTool::wand)
setWandMode(allWandModes[(size_t(m_wandMode) + 1) % allWandModes.size()]);
else if (m_tool == NavigationTool::lasso)
toggleLassoKind();
else if (m_tool == NavigationTool::shape)
+4
View File
@@ -8,6 +8,7 @@
#include "Document/LevelsAutomatic.h"
#include "Document/FloatingSelection.h"
#include "Document/MagicWand.h"
#include "Document/ObjectSelection.h"
#include "Document/SelectionClipboard.h"
#include "Document/SelectionEdits.h"
#include "Document/SmudgeLiquify.h"
@@ -298,6 +299,7 @@ public:
#include "Document/AdjustmentEditing+Session.h"
#include "Document/LayerEffects+Session.h"
#include "Document/Guides+Session.h"
#include "Document/ObjectSelection+Session.h"
signals:
void changed();
@@ -376,6 +378,8 @@ private:
std::optional<Inverting> m_inverting;
QFutureWatcher<Inverted> m_inverter;
WandSettings m_wandSettings;
WandMode m_wandMode = WandMode::wand;
ObjectSelectionSettings m_objectSelectionSettings;
std::optional<Wanding> m_wanding;
QFutureWatcher<Wanded> m_wand;
std::optional<Subjecting> m_subjecting;
+3 -1
View File
@@ -3,6 +3,8 @@ public:
// Off the UI thread; `done` follows the step or refusal.
void magicWand(QPointF point, SelectionMode mode, std::function<void()> done = {});
// What selection-from-image tools read, at document size.
std::optional<QImage> selectionSample(const CanvasDocument &document, bool sampleAllLayers) const;
private:
void finishWand();
std::optional<QImage> wandSample(const CanvasDocument &document) const;
+8 -8
View File
@@ -97,13 +97,13 @@ void EditorSession::setWandSettings(const WandSettings &settings)
}
// What the wand reads: composite, or the active layer's pixels.
std::optional<QImage> EditorSession::wandSample(const CanvasDocument &document) const
std::optional<QImage> EditorSession::selectionSample(const CanvasDocument &document, bool sampleAllLayers) const
{
try {
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
if (m_wandSettings.sampleAllLayers) {
if (sampleAllLayers) {
drawLiveComposite(document, painter);
} else if (const std::optional<ImageLayer> layer = activeLayer(); layer && !layer->isGroup && layer->asset) {
const LayerTransform transform = displayedTransform(*layer);
@@ -112,7 +112,7 @@ std::optional<QImage> EditorSession::wandSample(const CanvasDocument &document)
painter.end();
return context;
} catch (const ExportError &error) {
qCWarning(lcApp) << "the wand cannot sample the canvas:" << error.what();
qCWarning(lcApp) << "a selection tool cannot sample the canvas:" << error.what();
return std::nullopt;
}
}
@@ -131,13 +131,13 @@ void EditorSession::magicWand(QPointF point, SelectionMode mode, std::function<v
finish();
return;
}
const std::optional<QImage> sample = wandSample(*document);
const std::optional<QImage> sample = selectionSample(*document, m_wandSettings.sampleAllLayers);
if (!sample) {
finish();
return;
}
setIsProjectBusy(true);
m_wanding = Wanding{mode, document->id, std::move(done)};
m_wanding = Wanding{mode, document->id, std::move(done), QStringLiteral("Magic Wand")};
m_wand.setFuture(QtConcurrent::run([sample = *sample, point, settings = m_wandSettings]() -> Wanded {
try {
return Wanded{MagicWand::select(sample, point, settings), std::nullopt};
@@ -155,7 +155,7 @@ void EditorSession::finishWand()
const Wanding pending = std::exchange(m_wanding, std::nullopt).value();
setIsProjectBusy(false);
if (result.failure) {
qCWarning(lcApp).noquote() << "the wand could not select:" << *result.failure;
qCWarning(lcApp).noquote() << pending.name << "could not select:" << *result.failure;
setBrushError(result.failure);
} else if (m_document && m_document->id == pending.documentID) {
if (!result.path) {
@@ -164,9 +164,9 @@ void EditorSession::finishWand()
deselect();
} else if (pending.mode == SelectionMode::replace) {
// A traced outline lies on the canvas: no costly clip.
setSelection(DocumentSelection{*result.path, m_selectionAntialiased}, QStringLiteral("Magic Wand"));
setSelection(DocumentSelection{*result.path, m_selectionAntialiased}, pending.name);
} else {
applySelection(*result.path, pending.mode, QStringLiteral("Magic Wand"));
applySelection(*result.path, pending.mode, pending.name);
}
}
if (pending.done)
+8
View File
@@ -0,0 +1,8 @@
// Swift's ObjectSelection extension, and the Magic tool's mode.
public:
WandMode wandMode() const { return m_wandMode; }
void setWandMode(WandMode mode);
const ObjectSelectionSettings &objectSelectionSettings() const { return m_objectSelectionSettings; }
void setObjectSelectionSettings(const ObjectSelectionSettings &settings);
// Off the UI thread; `done` follows the step or refusal.
void selectObject(QPointF point, SelectionMode mode, std::function<void()> done = {});
+272
View File
@@ -0,0 +1,272 @@
#include "Document/ObjectSelection.h"
#include "Document/GuidedMatte.h"
#include "Document/EditorSession.h"
#include "Document/MagicWand.h"
#include "Document/SubjectRemoval.h"
#include "IO/ImageExporter.h"
#include <algorithm>
#include <cmath>
#include <deque>
#include <QtConcurrent>
#include <limits>
namespace {
double perpendicularDistance(QPointF point, QPointF a, QPointF b)
{
const double dx = b.x() - a.x(), dy = b.y() - a.y();
const double length = std::hypot(dx, dy);
if (!(length > 0))
return std::hypot(point.x() - a.x(), point.y() - a.y());
return std::abs(dy * point.x() - dx * point.y() + b.x() * a.y() - b.y() * a.x()) / length;
}
// Swift's Douglas–Peucker on an open polyline.
std::vector<QPointF> simplifyOpen(const std::vector<QPointF> &points, size_t first, size_t last, double tolerance)
{
if (last <= first + 1)
return {points[first], points[last]};
size_t farthest = first + 1;
double greatest = 0;
for (size_t index = first + 1; index < last; ++index) {
const double distance = perpendicularDistance(points[index], points[first], points[last]);
if (distance > greatest) {
greatest = distance;
farthest = index;
}
}
if (!(greatest > tolerance))
return {points[first], points[last]};
std::vector<QPointF> left = simplifyOpen(points, first, farthest, tolerance);
const std::vector<QPointF> right = simplifyOpen(points, farthest, last, tolerance);
left.pop_back();
left.insert(left.end(), right.begin(), right.end());
return left;
}
std::vector<QPointF> simplifyClosed(const std::vector<QPointF> &points, double tolerance)
{
if (points.size() < 4)
return points;
// Break at a stable extreme so the whole contour survives.
const size_t start = size_t(std::min_element(points.begin(), points.end(), [](QPointF lhs, QPointF rhs) {
return lhs.x() == rhs.x() ? lhs.y() < rhs.y() : lhs.x() < rhs.x();
}) - points.begin());
std::vector<QPointF> open(points.begin() + std::ptrdiff_t(start), points.end());
open.insert(open.end(), points.begin(), points.begin() + std::ptrdiff_t(start));
open.push_back(open.front());
open = simplifyOpen(open, 0, open.size() - 1, tolerance);
// Its ends are the same point.
open.pop_back();
return open.size() >= 3 ? open : points;
}
std::vector<QPointF> chaikin(std::vector<QPointF> points, int iterations)
{
for (int round = 0; round < iterations; ++round) {
std::vector<QPointF> next;
next.reserve(points.size() * 2);
for (size_t index = 0; index < points.size(); ++index) {
const QPointF a = points[index], b = points[(index + 1) % points.size()];
next.push_back(a * 0.75 + b * 0.25);
next.push_back(a * 0.25 + b * 0.75);
}
points = std::move(next);
}
return points;
}
bool any(const std::vector<uchar> &mask, int width, int height, int x, int y, bool set)
{
for (int ny = std::max(0, y - 1); ny <= std::min(height - 1, y + 1); ++ny) {
for (int nx = std::max(0, x - 1); nx <= std::min(width - 1, x + 1); ++nx) {
if ((mask[size_t(ny) * size_t(width) + size_t(nx)] != 0) == set)
return true;
}
}
return false;
}
std::optional<QPainterPath> traced(const QImage &image, QPointF point, int edgeOffset, bool smoothEdges)
{
const int width = image.width(), height = image.height();
const int x = int(std::floor(point.x())), y = int(std::floor(point.y()));
if (!std::isfinite(point.x()) || !std::isfinite(point.y()) || x < 0 || x >= width || y < 0 || y >= height)
return std::nullopt;
QImage foreground;
try {
foreground = SubjectRemoval::foreground(image);
} catch (const SubjectRemovalError &error) {
// No subject anywhere: Vision finds no instance.
if (error.kind == SubjectRemovalError::Kind::noSubject)
return std::nullopt;
throw;
}
using ObjectSelection::grid;
// Regions on the model's grid stand for Vision's instances.
const QImage cells = foreground.scaled(grid, grid, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_Grayscale8);
if (cells.isNull())
throw ExportError(ExportError::Kind::render);
std::vector<uchar> binary(size_t(grid) * grid);
for (int row = 0; row < grid; ++row) {
for (int column = 0; column < grid; ++column)
binary[size_t(row) * grid + size_t(column)] = cells.constScanLine(row)[column] >= 128;
}
const int gx = std::clamp(int(point.x() / width * grid), 0, grid - 1), gy = std::clamp(int(point.y() / height * grid), 0, grid - 1);
const std::optional<std::vector<uchar>> coarse = ObjectSelection::region(binary, grid, grid, gx, gy);
if (!coarse)
return std::nullopt;
QImage small(grid, grid, QImage::Format_Grayscale8);
if (small.isNull())
throw ExportError(ExportError::Kind::render);
for (int row = 0; row < grid; ++row) {
for (int column = 0; column < grid; ++column)
small.scanLine(row)[column] = (*coarse)[size_t(row) * grid + size_t(column)] ? 255 : 0;
}
// Swift's edge-preserving upsampling: the image guides the edge.
const QImage up = small.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
const QImage refined = GuidedMatte::refine(up, image, 5, std::numeric_limits<double>::max());
std::vector<uchar> mask(size_t(width) * size_t(height));
for (int row = 0; row < height; ++row) {
const uchar *line = refined.constScanLine(row);
for (int column = 0; column < width; ++column)
mask[size_t(row) * size_t(width) + size_t(column)] = line[column] >= 128 ? 255 : 0;
}
mask = ObjectSelection::adjusted(std::move(mask), width, height, edgeOffset);
const std::optional<QPainterPath> outline = MagicWand::outline(mask, width, height);
if (!outline)
return std::nullopt;
return smoothEdges ? ObjectSelection::smoothed(*outline) : *outline;
}
}
std::optional<QPainterPath> ObjectSelection::select(const QImage &image, QPointF point, int edgeOffset, bool smoothEdges)
{
try {
return traced(image, point, edgeOffset, smoothEdges);
} catch (const std::bad_alloc &) {
// Masks the size of the image found no memory.
throw ExportError(ExportError::Kind::render);
}
}
std::optional<std::vector<uchar>> ObjectSelection::region(const std::vector<uchar> &mask, int width, int height, int x, int y)
{
const auto at = [width](int column, int row) { return size_t(row) * size_t(width) + size_t(column); };
if (!mask[at(x, y)])
return std::nullopt;
std::vector<uchar> result(mask.size());
std::deque<std::pair<int, int>> pending{{x, y}};
// Eight neighbours join a region, the cell itself included.
while (!pending.empty()) {
const auto [column, row] = pending.front();
pending.pop_front();
for (int ny = std::max(0, row - 1); ny <= std::min(height - 1, row + 1); ++ny) {
for (int nx = std::max(0, column - 1); nx <= std::min(width - 1, column + 1); ++nx) {
if (mask[at(nx, ny)] && !result[at(nx, ny)]) {
result[at(nx, ny)] = 1;
pending.emplace_back(nx, ny);
}
}
}
}
return result;
}
std::vector<uchar> ObjectSelection::adjusted(std::vector<uchar> mask, int width, int height, int edgeOffset)
{
const int steps = std::min(10, std::abs(edgeOffset));
for (int step = 0; step < steps; ++step) {
std::vector<uchar> result = mask;
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const size_t index = size_t(y) * size_t(width) + size_t(x);
if (edgeOffset > 0 && mask[index])
result[index] = any(mask, width, height, x, y, false) ? 0 : 255;
else if (edgeOffset < 0 && !mask[index] && any(mask, width, height, x, y, true))
result[index] = 255;
}
}
mask = std::move(result);
}
return mask;
}
QPainterPath ObjectSelection::smoothed(const QPainterPath &path)
{
std::vector<std::vector<QPointF>> subpaths;
std::vector<QPointF> current;
const auto finish = [&] {
// Qt's close repeats the start, where a CGPath's adds none.
if (!current.empty() && current.front() == current.back())
current.pop_back();
if (current.size() >= 3)
subpaths.push_back(current);
};
for (int index = 0; index < path.elementCount(); ++index) {
const QPainterPath::Element element = path.elementAt(index);
if (element.isMoveTo()) {
finish();
current = {element};
} else if (element.isLineTo()) {
current.push_back(element);
} else if (element.type == QPainterPath::CurveToDataElement && (index + 1 == path.elementCount() || path.elementAt(index + 1).type != QPainterPath::CurveToDataElement)) {
// A curve's end point, as Swift keeps it.
current.push_back(element);
}
}
finish();
QPainterPath result;
result.setFillRule(path.fillRule());
for (const std::vector<QPointF> &subpath : subpaths) {
const std::vector<QPointF> points = chaikin(simplifyClosed(subpath, 1.6), 3);
result.moveTo(points.front());
for (size_t index = 1; index < points.size(); ++index)
result.lineTo(points[index]);
result.closeSubpath();
}
return result;
}
void EditorSession::setWandMode(WandMode mode)
{
m_wandMode = mode;
notify();
}
void EditorSession::setObjectSelectionSettings(const ObjectSelectionSettings &settings)
{
m_objectSelectionSettings = settings;
notify();
}
void EditorSession::selectObject(QPointF point, SelectionMode mode, std::function<void()> done)
{
// The caller resumes from the event loop, as after await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
const std::optional<CanvasDocument> &document = m_document;
// Swift's positive bounds refuse a point that is no number.
const bool inside = document && point.x() >= 0 && point.y() >= 0 && point.x() < document->width && point.y() < document->height;
const std::optional<QImage> sample = canEditSelection() && !m_selectionMoveOrigin && inside
? selectionSample(*document, m_objectSelectionSettings.sampleAllLayers)
: std::nullopt;
if (!sample) {
finish();
return;
}
setIsProjectBusy(true);
m_wanding = Wanding{mode, document->id, std::move(done), QStringLiteral("Object Selection")};
m_wand.setFuture(QtConcurrent::run([sample = *sample, point, offset = m_objectSelectionSettings.edgeOffset, smooth = m_selectionAntialiased]() -> Wanded {
try {
return Wanded{ObjectSelection::select(sample, point, offset, smooth), std::nullopt};
} catch (const SubjectRemovalError &error) {
return Wanded{std::nullopt, QString::fromUtf8(error.what())};
} catch (const MagicWandError &error) {
return Wanded{std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return Wanded{std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <QImage>
#include <QPainterPath>
#include <optional>
struct ObjectSelectionSettings {
// Read the visible composite rather than the active layer.
bool sampleAllLayers = true;
// Positive erodes the mask inward; negative grows it outward.
int edgeOffset = 0;
friend bool operator==(const ObjectSelectionSettings &, const ObjectSelectionSettings &) = default;
};
// Swift's ObjectSelection; U²-Net regions stand for Vision's instances.
namespace ObjectSelection {
// The model's grid the regions are found on.
inline constexpr int grid = 320;
// The outline in pixels; none off the image or background.
std::optional<QPainterPath> select(const QImage &image, QPointF point, int edgeOffset, bool smoothEdges);
// The binary grid's region holding the cell, else none.
std::optional<std::vector<uchar>> region(const std::vector<uchar> &mask, int width, int height, int x, int y);
// Swift's steps, a pixel ring each: erode inward, dilate outward.
std::vector<uchar> adjusted(std::vector<uchar> mask, int width, int height, int edgeOffset);
// Stair steps rounded off; winding and subpath order kept.
QPainterPath smoothed(const QPainterPath &path);
}
+83
View File
@@ -0,0 +1,83 @@
#include "Document/EditorSession.h"
#include <cmath>
// Select's Expand, Contract and Feather, and their amounts.
void EditorSession::setSelectionExpandAmount(int amount)
{
m_selectionExpandAmount = amount;
notify();
}
void EditorSession::setSelectionContractAmount(int amount)
{
m_selectionContractAmount = amount;
notify();
}
void EditorSession::setSelectionFeatherAmount(int amount)
{
m_selectionFeatherAmount = amount;
notify();
}
void EditorSession::setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation)
{
m_selectionAmountOperation = operation;
resumeFileRequests();
notify();
}
bool EditorSession::canModifySelection() const
{
const std::optional<DocumentSelection> current = selection();
return current && !current->isEmpty() && canEditSelection() && !m_lassoDraft;
}
void EditorSession::expandSelection(int amount)
{
resizeSelection(amount, QStringLiteral("Expand Selection"));
}
void EditorSession::contractSelection(int amount)
{
resizeSelection(-amount, QStringLiteral("Contract Selection"));
}
void EditorSession::promptSelectionAmount(SelectionAmountOperation operation)
{
if (!canModifySelection())
return;
setSelectionAmountOperation(operation);
}
void EditorSession::confirmSelectionAmount(int amount)
{
const std::optional<SelectionAmountOperation> operation = m_selectionAmountOperation;
if (!operation || amount < 1 || amount > (*operation == SelectionAmountOperation::feather ? 250 : 500))
return;
setSelectionAmountOperation(std::nullopt);
switch (*operation) {
case SelectionAmountOperation::expand:
setSelectionExpandAmount(amount);
expandSelection(amount);
break;
case SelectionAmountOperation::contract:
setSelectionContractAmount(amount);
contractSelection(amount);
break;
case SelectionAmountOperation::feather:
setSelectionFeatherAmount(amount);
featherSelection(amount);
break;
}
}
void EditorSession::featherSelection(int amount)
{
const std::optional<DocumentSelection> current = selection();
if (!canModifySelection() || !current || amount <= 0)
return;
// Two soft edges spread less than their sum.
const double softened = std::sqrt(current->feather * current->feather + double(amount) * amount);
setSelection(DocumentSelection{current->path, current->antialiased, std::min(250.0, softened)}, QStringLiteral("Feather Selection"));
}
+1
View File
@@ -16,6 +16,7 @@ public:
void cancelLasso();
void pressMarqueeKey();
void pressLassoKey();
void pressWandKey();
void toggleMarqueeKind();
void toggleLassoKind();
// Closes the outline and combines it with the selection.
+11 -80
View File
@@ -54,6 +54,11 @@ QString rawValue(SelectionAmountOperation operation)
throw std::logic_error("unknown selection amount operation");
}
QString rawValue(WandMode mode)
{
return mode == WandMode::object ? QStringLiteral("Object") : QStringLiteral("Wand");
}
QString rawValue(SelectionMode mode)
{
switch (mode) {
@@ -173,31 +178,6 @@ void EditorSession::setSelectionAntialiased(bool antialiased)
notify();
}
void EditorSession::setSelectionExpandAmount(int amount)
{
m_selectionExpandAmount = amount;
notify();
}
void EditorSession::setSelectionContractAmount(int amount)
{
m_selectionContractAmount = amount;
notify();
}
void EditorSession::setSelectionFeatherAmount(int amount)
{
m_selectionFeatherAmount = amount;
notify();
}
void EditorSession::setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation)
{
m_selectionAmountOperation = operation;
resumeFileRequests();
notify();
}
void EditorSession::beginLasso(QPointF point, SelectionMode mode)
{
// The Magic Wand selects with a click, never an outline.
@@ -266,6 +246,12 @@ void EditorSession::pressMarqueeKey()
selectTool(NavigationTool::marquee);
}
// W picks the Magic tool; Tab switches its two modes.
void EditorSession::pressWandKey()
{
selectTool(NavigationTool::wand);
}
void EditorSession::pressLassoKey()
{
selectTool(NavigationTool::lasso);
@@ -400,61 +386,6 @@ void EditorSession::nudgeSelection(double dx, double dy)
endSelectionMove();
}
bool EditorSession::canModifySelection() const
{
const std::optional<DocumentSelection> current = selection();
return current && !current->isEmpty() && canEditSelection() && !m_lassoDraft;
}
void EditorSession::expandSelection(int amount)
{
resizeSelection(amount, QStringLiteral("Expand Selection"));
}
void EditorSession::contractSelection(int amount)
{
resizeSelection(-amount, QStringLiteral("Contract Selection"));
}
void EditorSession::promptSelectionAmount(SelectionAmountOperation operation)
{
if (!canModifySelection())
return;
setSelectionAmountOperation(operation);
}
void EditorSession::confirmSelectionAmount(int amount)
{
const std::optional<SelectionAmountOperation> operation = m_selectionAmountOperation;
if (!operation || amount < 1 || amount > (*operation == SelectionAmountOperation::feather ? 250 : 500))
return;
setSelectionAmountOperation(std::nullopt);
switch (*operation) {
case SelectionAmountOperation::expand:
setSelectionExpandAmount(amount);
expandSelection(amount);
break;
case SelectionAmountOperation::contract:
setSelectionContractAmount(amount);
contractSelection(amount);
break;
case SelectionAmountOperation::feather:
setSelectionFeatherAmount(amount);
featherSelection(amount);
break;
}
}
void EditorSession::featherSelection(int amount)
{
const std::optional<DocumentSelection> current = selection();
if (!canModifySelection() || !current || amount <= 0)
return;
// Two soft edges spread less than their sum.
const double softened = std::sqrt(current->feather * current->feather + double(amount) * amount);
setSelection(DocumentSelection{current->path, current->antialiased, std::min(250.0, softened)}, QStringLiteral("Feather Selection"));
}
void EditorSession::resizeSelection(double delta, const QString &name)
{
const std::optional<DocumentSelection> current = selection();
+6
View File
@@ -4,6 +4,7 @@
#include <QPointF>
#include <QRectF>
#include <QString>
#include <array>
#include <optional>
#include <vector>
@@ -31,6 +32,11 @@ struct DocumentSelection {
friend bool operator==(const DocumentSelection &, const DocumentSelection &) = default;
};
// The Magic tool's modes: similar colours, or the object clicked.
enum class WandMode { wand, object };
inline constexpr std::array allWandModes{WandMode::wand, WandMode::object};
QString rawValue(WandMode mode);
// The Marquee's outlines are rectangle and ellipse.
enum class LassoKind { freehand, polygonal, rectangle, ellipse };
// The name the tool bar shows.
+5
View File
@@ -222,6 +222,11 @@ QImage SubjectRemoval::subjectMask(const QImage &image, const std::optional<QIma
return combined;
}
QImage SubjectRemoval::foreground(const QImage &image)
{
return vision(image);
}
QImage SubjectRemoval::run(const QImage &image, const FilterSettings &settings)
{
// The preview refines a copy 1400 long at most: quick.
+2
View File
@@ -23,6 +23,8 @@ std::vector<float> modelInput(const QImage &image);
QImage modelMask(const float *prediction, QSize size);
// White over the subject in the layer's grid, times `existing`.
QImage subjectMask(const QImage &image, const std::optional<QImage> &existing, const FilterSettings &settings);
// The model's mask at the image's size, cached by image.
QImage foreground(const QImage &image);
// The preview: the layer with its background cleared.
QImage run(const QImage &image, const FilterSettings &settings);
}
-2
View File
@@ -26,8 +26,6 @@ std::optional<NavigationTool> toolForKey(int key)
return NavigationTool::shape;
case Qt::Key_I:
return NavigationTool::eyedropper;
case Qt::Key_W:
return NavigationTool::wand;
case Qt::Key_A:
return NavigationTool::idle;
case Qt::Key_R:
+16 -5
View File
@@ -23,9 +23,11 @@ QCursor CanvasView::lassoCursor(Qt::KeyboardModifiers modifiers, std::optional<Q
if ((command || mode == SelectionMode::replace) && document && location
&& m_session.canMoveSelection(m_session.viewport.documentPoint(*location, document->size())))
return command ? pixelDragCursor(modifiers.testFlag(Qt::AltModifier), ratio) : moveSelectionCursor(ratio);
if (m_session.tool() == NavigationTool::wand)
const bool wand = m_session.tool() == NavigationTool::wand;
if (wand && m_session.wandMode() == WandMode::wand)
return wandCursor(mode, ratio);
const SelectionIcon icon = m_session.tool() == NavigationTool::marquee
const SelectionIcon icon = wand ? SelectionIcon::objectSelection
: m_session.tool() == NavigationTool::marquee
? (m_session.marqueeKind() == LassoKind::ellipse ? SelectionIcon::ellipseMarquee : SelectionIcon::rectangleMarquee)
: (m_session.lassoKind() == LassoKind::polygonal ? SelectionIcon::polygonalLasso : SelectionIcon::freehandLasso);
return selectionCursor(icon, mode, ratio);
@@ -56,6 +58,11 @@ void CanvasView::lassoMouseDown(QPointF point, Qt::KeyboardModifiers modifiers)
updateCursor();
return;
}
// Object mode runs before the colour wand, as Swift's.
if (m_session.tool() == NavigationTool::wand && m_session.wandMode() == WandMode::object) {
m_session.selectObject(pixel, mode);
return;
}
if (m_session.tool() == NavigationTool::wand) {
m_session.magicWand(pixel, mode);
return;
@@ -130,7 +137,9 @@ void CanvasView::releaseSelectionTool()
const bool moved = m_session.selectionMoveOrigin() != m_session.selection();
m_session.endSelectionMove();
// The wand's click inside selects afresh; another's deselects.
if (!moved && m_session.tool() == NavigationTool::wand)
if (!moved && m_session.tool() == NavigationTool::wand && m_session.wandMode() == WandMode::object)
m_session.selectObject(start, SelectionMode::replace);
else if (!moved && m_session.tool() == NavigationTool::wand)
m_session.magicWand(start, SelectionMode::replace);
else if (!moved)
m_session.deselect();
@@ -169,12 +178,14 @@ bool CanvasView::selectionKey(const QKeyEvent &key)
key.key() == Qt::Key_Up ? -step : key.key() == Qt::Key_Down ? step : 0);
return true;
}
// M and L choose their tools; a repeat does nothing.
if (plain && (key.key() == Qt::Key_M || key.key() == Qt::Key_L)) {
// M, W and L choose tools; a repeat does nothing.
if (plain && (key.key() == Qt::Key_M || key.key() == Qt::Key_W || key.key() == Qt::Key_L)) {
if (key.isAutoRepeat())
return true;
if (key.key() == Qt::Key_M)
m_session.pressMarqueeKey();
else if (key.key() == Qt::Key_W)
m_session.pressWandKey();
else
m_session.pressLassoKey();
updateCursor();
+13
View File
@@ -32,6 +32,19 @@ void SelectionIcons::paint(QPainter &painter, SelectionIcon icon)
painter.drawPath(tail);
return;
}
// Swift's ObjectSelectionToolIcon: four corners round a pointer.
case SelectionIcon::objectSelection: {
QPen pen = painter.pen();
pen.setWidthF(1.6);
painter.setPen(pen);
for (const QPolygonF &corner : {QPolygonF{QPointF(2, 6), QPointF(2, 2), QPointF(6, 2)}, QPolygonF{QPointF(12, 2), QPointF(16, 2), QPointF(16, 6)},
QPolygonF{QPointF(16, 12), QPointF(16, 16), QPointF(12, 16)}, QPolygonF{QPointF(6, 16), QPointF(2, 16), QPointF(2, 12)}})
painter.drawPolyline(corner);
painter.setPen(Qt::NoPen);
painter.setBrush(pen.color());
painter.drawPolygon(QPolygonF{QPointF(7, 5), QPointF(7, 14), QPointF(9.6, 11.7), QPointF(11.3, 15.3), QPointF(13.2, 14.4), QPointF(11.5, 10.9), QPointF(14.5, 10.9)});
return;
}
// Swift's PolygonalLassoToolIcon: loop, knot and rope as segments.
case SelectionIcon::polygonalLasso:
painter.drawPolygon(QPolygonF{QPointF(1.2, 7.0), QPointF(4.0, 2.4), QPointF(11.8, 1.8), QPointF(16.8, 5.2), QPointF(15.6, 10.4), QPointF(7.0, 11.6)});
+1 -1
View File
@@ -2,7 +2,7 @@
#include <QPainter>
// Which selection tool an icon names, by its kind.
enum class SelectionIcon { freehandLasso, polygonalLasso, rectangleMarquee, ellipseMarquee };
enum class SelectionIcon { freehandLasso, polygonalLasso, rectangleMarquee, ellipseMarquee, objectSelection };
// Icons the rail and the cursors share, 18-unit grid.
namespace SelectionIcons {
+1 -1
View File
@@ -140,7 +140,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
const std::vector<std::pair<const char *, QString>> tools{
{"Select tool", "a"}, {"Move / Transform tool", "v"}, {"Hand tool", "h"}, {"Zoom tool", "z"}, {"Brush tool", "b"},
{"Eraser", "e"}, {"Spot Healing", "j"}, {"Clone Stamp", "s"}, {"Type tool", "t"}, {"Gradient tool", "g"},
{"Shape tool", "u"}, {"Eyedropper tool", "i"}, {"Marquee / cycle shape", "m"}, {"Magic Wand", "w"},
{"Shape tool", "u"}, {"Eyedropper tool", "i"}, {"Marquee / cycle shape", "m"}, {"Magic", "w"},
{"Lasso / cycle mode", "l"}, {"Blur / Smudge / Liquify", "r"}, {"Crop tool", "c"}, {"Swap foreground/background", "x"},
{"Reset colors", "d"}, {"Cycle tool mode", "\t"}, {"Temporary Hand tool (hold)", " "},
{"Delete selection / layer / effect / lasso point", backspace}, {"Apply current canvas operation", "\r"},
+60 -18
View File
@@ -17,15 +17,15 @@ SelectionAmountField::SelectionAmountField(EditorSession &session, int low, int
setFont(ToolHeaderStyle::controlFont());
setFixedWidth(40);
setAlignment(Qt::AlignRight | Qt::AlignVCenter);
// Digits only; the bounds clamp, as Swift's binding does.
setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9]*")), this));
// Typing applies at once; an overflow reads as the top.
// Digits, signed below zero; the bounds clamp, as Swift's binding.
setValidator(new QRegularExpressionValidator(QRegularExpression(low < 0 ? QStringLiteral("-?[0-9]*") : QStringLiteral("[0-9]*")), this));
// Typing applies at once; an overflow reads as its end.
connect(this, &QLineEdit::textEdited, this, [this](const QString &text) {
if (text.isEmpty())
if (text.isEmpty() || text == QLatin1String("-"))
return;
bool fits = false;
const qlonglong typed = text.toLongLong(&fits);
m_change(double(fits ? std::clamp<qlonglong>(typed, m_low, m_high) : m_high));
m_change(double(fits ? std::clamp<qlonglong>(typed, m_low, m_high) : text.startsWith(QLatin1Char('-')) ? m_low : m_high));
});
}
@@ -95,7 +95,23 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent)
m_allLayers(choice(m_sources, QStringLiteral("wandAllLayers"), QStringLiteral("All Layers"),
QStringLiteral("Read colors from the active layer only, or from every visible layer as shown"),
[this] { changeWand([](WandSettings &wand) { wand.sampleAllLayers = true; }); })),
m_contiguous(new QCheckBox(QStringLiteral("Contiguous"), this)),
m_contiguous(new QCheckBox(QStringLiteral("Contiguous"), this)), m_wandModes(new QButtonGroup(this)),
m_wandMode(choice(m_wandModes, QStringLiteral("magicWandMode"), rawValue(WandMode::wand), QStringLiteral("Press Tab to switch between Wand and Object"),
[this] { m_session.setWandMode(WandMode::wand); })),
m_objectMode(choice(m_wandModes, QStringLiteral("magicObjectMode"), rawValue(WandMode::object), QStringLiteral("Press Tab to switch between Wand and Object"),
[this] { m_session.setWandMode(WandMode::object); })),
m_objectSources(new QButtonGroup(this)),
m_objectThisLayer(choice(m_objectSources, QStringLiteral("objectThisLayer"), QStringLiteral("This Layer"),
QStringLiteral("Analyze the active layer only, or every visible layer as shown"),
[this] { changeObject([](ObjectSelectionSettings &object) { object.sampleAllLayers = false; }); })),
m_objectAllLayers(choice(m_objectSources, QStringLiteral("objectAllLayers"), QStringLiteral("All Layers"),
QStringLiteral("Analyze the active layer only, or every visible layer as shown"),
[this] { changeObject([](ObjectSelectionSettings &object) { object.sampleAllLayers = true; }); })),
m_edgeLabel(new QLabel(QStringLiteral("Edge"), this)),
m_edge(new SelectionAmountField(session, -10, 10, [this] { return double(m_session.objectSelectionSettings().edgeOffset); },
[this](double edge) { changeObject([edge](ObjectSelectionSettings &object) { object.edgeOffset = int(std::lround(edge)); }); },
this)),
m_edgeUnit(new QLabel(QStringLiteral("px"), this)),
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)),
@@ -119,12 +135,16 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent)
connect(m_sampleSize, &QComboBox::activated, this, [this](int index) {
changeWand([index](WandSettings &wand) { wand.sampleSize = WandSampleSize(index); });
});
m_edge->setObjectName(QStringLiteral("objectEdge"));
for (QWidget *edge : std::initializer_list<QWidget *>{m_edgeLabel, m_edge, m_edgeUnit})
edge->setToolTip(QStringLiteral("Positive values tighten the detected mask inward; negative values expand it outward"));
m_edgeLabel->setObjectName(QStringLiteral("objectEdgeLabel"));
m_edgeLabel->setFont(ToolHeaderStyle::controlFont());
m_contiguous->setObjectName(QStringLiteral("wandContiguous"));
m_contiguous->setToolTip(QStringLiteral("Select only similar pixels connected to the one you click; off selects them everywhere"));
connect(m_contiguous, &QCheckBox::toggled, this, [this](bool on) { changeWand([on](WandSettings &wand) { wand.contiguous = on; }); });
// Rectangles snap to whole pixels, so smoothing applies elsewhere.
m_antialias->setObjectName(QStringLiteral("selectionAntialiased"));
m_antialias->setToolTip(QStringLiteral("Smooth selection edges; turn off for hard pixel edges"));
connect(m_antialias, &QCheckBox::toggled, this, [this](bool on) { m_session.setSelectionAntialiased(on); });
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::VLine);
@@ -150,8 +170,9 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent)
m_deselect->setObjectName(QStringLiteral("deselect"));
connect(m_deselect, &QPushButton::clicked, this, [this] { m_session.deselect(); });
// Swift's `unitSuffix`: a unit sits by each field.
for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_freehand, m_polygonal, m_replace, m_add, m_subtract, m_toleranceLabel,
m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, m_antialias,
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)})
@@ -170,6 +191,14 @@ void LassoControls::changeWand(const std::function<void(WandSettings &)> &change
m_session.setWandSettings(wand);
}
// One field of Object mode's settings changes; the rest stay.
void LassoControls::changeObject(const std::function<void(ObjectSelectionSettings &)> &change)
{
ObjectSelectionSettings object = m_session.objectSelectionSettings();
change(object);
m_session.setObjectSelectionSettings(object);
}
// A segment of Swift's segmented picker: one checkable button.
QToolButton *LassoControls::choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick)
{
@@ -188,7 +217,18 @@ QToolButton *LassoControls::choice(QButtonGroup *group, const QString &name, con
void LassoControls::synchronize()
{
const NavigationTool tool = m_session.tool();
title->setText(tool == NavigationTool::marquee ? QStringLiteral("Marquee") : tool == NavigationTool::wand ? QStringLiteral("Magic Wand") : QStringLiteral("Lasso"));
title->setText(tool == NavigationTool::marquee ? QStringLiteral("Marquee") : tool == NavigationTool::wand ? QStringLiteral("Magic") : QStringLiteral("Lasso"));
const bool wand = tool == NavigationTool::wand;
const bool object = wand && m_session.wandMode() == WandMode::object;
for (QToolButton *button : {m_wandMode, m_objectMode})
button->setVisible(wand);
m_wandMode->setChecked(!object);
m_objectMode->setChecked(object);
for (QWidget *widget : std::initializer_list<QWidget *>{m_objectThisLayer, m_objectAllLayers, m_edgeLabel, m_edge, m_edgeUnit})
widget->setVisible(object);
m_objectThisLayer->setChecked(!m_session.objectSelectionSettings().sampleAllLayers);
m_objectAllLayers->setChecked(m_session.objectSelectionSettings().sampleAllLayers);
m_edge->sync(m_session.objectSelectionSettings().edgeOffset);
for (QToolButton *button : {m_rectangle, m_ellipse})
button->setVisible(tool == NavigationTool::marquee);
for (QToolButton *button : {m_freehand, m_polygonal})
@@ -202,16 +242,18 @@ void LassoControls::synchronize()
m_replace->setChecked(mode == SelectionMode::replace);
m_add->setChecked(mode == SelectionMode::add);
m_subtract->setChecked(mode == SelectionMode::subtract);
const WandSettings &wand = m_session.wandSettings();
const WandSettings &colours = m_session.wandSettings();
for (QWidget *widget : std::initializer_list<QWidget *>{m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous})
widget->setVisible(tool == NavigationTool::wand);
m_tolerance->sync(wand.tolerance);
m_sampleSize->setCurrentIndex(int(wand.sampleSize));
m_thisLayer->setChecked(!wand.sampleAllLayers);
m_allLayers->setChecked(wand.sampleAllLayers);
if (m_contiguous->isChecked() != wand.contiguous)
m_contiguous->setChecked(wand.contiguous);
widget->setVisible(wand && !object);
m_tolerance->sync(colours.tolerance);
m_sampleSize->setCurrentIndex(int(colours.sampleSize));
m_thisLayer->setChecked(!colours.sampleAllLayers);
m_allLayers->setChecked(colours.sampleAllLayers);
if (m_contiguous->isChecked() != colours.contiguous)
m_contiguous->setChecked(colours.contiguous);
m_antialias->setVisible(tool == NavigationTool::lasso || tool == NavigationTool::wand || (tool == NavigationTool::marquee && m_session.marqueeKind() == LassoKind::ellipse));
m_antialias->setToolTip(object ? QStringLiteral("Smooth the detected object outline; turn off for the raw pixel mask")
: QStringLiteral("Smooth selection edges; turn off for hard pixel edges"));
if (m_antialias->isChecked() != m_session.selectionAntialiased())
m_antialias->setChecked(m_session.selectionAntialiased());
const bool modifies = m_session.canModifySelection();
+11
View File
@@ -45,6 +45,7 @@ public:
private:
QToolButton *choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick);
void changeWand(const std::function<void(WandSettings &)> &change);
void changeObject(const std::function<void(ObjectSelectionSettings &)> &change);
void synchronize();
EditorSession &m_session;
@@ -66,6 +67,16 @@ private:
QToolButton *const m_thisLayer;
QToolButton *const m_allLayers;
QCheckBox *const m_contiguous;
// The Magic tool's modes, and Object mode's own settings.
QButtonGroup *const m_wandModes;
QToolButton *const m_wandMode;
QToolButton *const m_objectMode;
QButtonGroup *const m_objectSources;
QToolButton *const m_objectThisLayer;
QToolButton *const m_objectAllLayers;
QLabel *const m_edgeLabel;
SelectionAmountField *const m_edge;
QLabel *const m_edgeUnit;
QCheckBox *const m_antialias;
QPushButton *const m_expand;
SelectionAmountField *const m_expandAmount;
+5 -1
View File
@@ -187,7 +187,7 @@ void NativeLayerList::pressKey(QKeyEvent *event)
static const std::map<int, NavigationTool> tools{
{Qt::Key_A, NavigationTool::idle}, {Qt::Key_V, NavigationTool::move}, {Qt::Key_H, NavigationTool::hand},
{Qt::Key_Z, NavigationTool::zoom}, {Qt::Key_G, NavigationTool::gradient}, {Qt::Key_L, NavigationTool::lasso}, {Qt::Key_M, NavigationTool::marquee},
{Qt::Key_W, NavigationTool::wand}, {Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp},
{Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp},
{Qt::Key_U, NavigationTool::shape}, {Qt::Key_R, NavigationTool::blur}, {Qt::Key_I, NavigationTool::eyedropper},
{Qt::Key_C, NavigationTool::crop}, {Qt::Key_T, NavigationTool::type}};
if (plain && (key == Qt::Key_Backspace || key == Qt::Key_Delete)) {
@@ -209,6 +209,10 @@ void NativeLayerList::pressKey(QKeyEvent *event)
m_session.swapPaletteColors();
else
m_session.resetPaletteColors();
} else if (plain && key == Qt::Key_W) {
// A held W picks the Magic tool once, as Swift's.
if (!event->isAutoRepeat())
m_session.pressWandKey();
} else if (plain && tools.contains(key)) {
m_session.selectTool(tools.at(key));
} else if (plain && event->text().size() == 1 && event->text().front().isDigit() && m_session.usesOpacityKeys()) {
+1 -1
View File
@@ -211,7 +211,7 @@ void ToolIcons::paint(QPainter &painter, NavigationTool tool, QPointF origin, do
case NavigationTool::move: move(painter); break;
case NavigationTool::marquee: marquee(painter, kind); break;
case NavigationTool::lasso: lasso(painter, kind); break;
case NavigationTool::wand: wand(painter); break;
case NavigationTool::wand: kind == ToolIconKind::object ? SelectionIcons::paint(painter, SelectionIcon::objectSelection) : wand(painter); break;
case NavigationTool::crop: crop(painter); break;
case NavigationTool::brush: kind == ToolIconKind::eraser ? eraser(painter) : brush(painter); break;
case NavigationTool::spotHealing: spotHealing(painter); break;
+1 -1
View File
@@ -5,7 +5,7 @@
#include <QRectF>
// The Marquee's and the Lasso's icons follow their kinds.
enum class ToolIconKind { plain, ellipse, polygonal, eraser };
enum class ToolIconKind { plain, ellipse, polygonal, eraser, object };
// The tool rail's icons, drawn: SF Symbols have no twin.
namespace ToolIcons {
+1 -1
View File
@@ -20,7 +20,7 @@ void CompositorUITestsLaunchTests::testLaunch()
// The welcome is up: Swift's launch test waits for it.
QVERIFY(window.findChild<QWidget *>("createCanvas")->isVisible());
QCoreApplication::processEvents();
QVERIFY(window.grab().save(QStringLiteral("LaunchScreen.png")));
QVERIFY(window.grab().save(QCoreApplication::applicationDirPath() + QStringLiteral("/LaunchScreen.png")));
}
void CompositorUITestsLaunchTests::testWindowKeepsItsMinimumSize()
+7 -1
View File
@@ -141,7 +141,7 @@ void ContentViewStatusTests::everyToolHasItsHint()
{NavigationTool::move, "Drag to move · Handles to resize · Circle to rotate · 1–0 layer opacity · Space to pan"},
{NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"},
{NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::wand, "Click to select similar colors · Tab for Object · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::crop, "Drag to crop · Enter apply · Escape cancel · Space to pan"},
{NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"},
{NavigationTool::spotHealing, "Drag over blemishes to heal · [ ] size · Shift-[ ] hardness · Escape cancel · Space to pan"},
@@ -159,6 +159,7 @@ void ContentViewStatusTests::everyToolHasItsHint()
QCOMPARE(ContentView::hint(NavigationTool::move, ToolIconKind::eraser), ContentView::hint(NavigationTool::move));
QCOMPARE(ContentView::hint(NavigationTool::lasso, ToolIconKind::ellipse), ContentView::hint(NavigationTool::lasso));
QCOMPARE(ContentView::hint(NavigationTool::marquee, ToolIconKind::polygonal), ContentView::hint(NavigationTool::marquee));
QCOMPARE(ContentView::hint(NavigationTool::lasso, ToolIconKind::object), ContentView::hint(NavigationTool::lasso));
EditorSession session;
session.createDocument(8, 8);
ContentView view(session);
@@ -166,6 +167,11 @@ void ContentViewStatusTests::everyToolHasItsHint()
session.selectTool(tool);
QCOMPARE(find<QLabel>(view, "activityStatus").text(), QString::fromUtf8(words));
}
// Magic's words follow its mode.
session.selectTool(NavigationTool::wand);
session.setWandMode(WandMode::object);
QCOMPARE(find<QLabel>(view, "activityStatus").text(),
QString("Click an object to select its outline · Tab for Wand · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"));
// The Shape's Shift follows its kind.
session.selectTool(NavigationTool::shape);
session.setShapeKind(ShapeKind::ellipse);
+9 -2
View File
@@ -32,7 +32,7 @@ const std::pair<NavigationTool, const char *> labelled[] = {
{NavigationTool::move, "Move / Transform (V)"},
{NavigationTool::marquee, "Marquee (M)"},
{NavigationTool::lasso, "Lasso (L)"},
{NavigationTool::wand, "Magic Wand (W)"},
{NavigationTool::wand, "Magic (W) · Tab switches Wand and Object"},
{NavigationTool::crop, "Crop (C)"},
{NavigationTool::brush, "Brush (B) · Eraser (E)"},
{NavigationTool::spotHealing, "Spot Healing Brush (J)"},
@@ -167,6 +167,13 @@ void ContentViewTests::theRailHoldsEveryToolInSwiftsOrder()
QVERIFY(brush->grab().toImage() != painting);
session.setBrushMode(BrushToolMode::paint);
QCOMPARE(brush->grab().toImage(), painting);
// Magic's shows the object icon in Object mode.
QToolButton *const magic = buttons[3];
const QImage colours = magic->grab().toImage();
session.setWandMode(WandMode::object);
QVERIFY(magic->grab().toImage() != colours);
session.setWandMode(WandMode::wand);
QCOMPARE(magic->grab().toImage(), colours);
}
void ContentViewTests::theBarFollowsTheTool()
@@ -236,7 +243,7 @@ void ContentViewTests::theBarFollowsTheTool()
QCOMPARE(bar().title->text(), QString("Lasso"));
session.selectTool(NavigationTool::wand);
QVERIFY(bar().property("kept").toBool());
QCOMPARE(bar().title->text(), QString("Magic Wand"));
QCOMPARE(bar().title->text(), QString("Magic"));
QCOMPARE(bar().height(), 42);
QCOMPARE(bar().height(), 42);
}
+98 -1
View File
@@ -33,6 +33,7 @@ private slots:
void expandContractAndTheirAmountsNeedASelection();
void deselectTheEmptyWordsAndAntialiasFollowTheSession();
void theWandsControlsWriteItsSettings();
void magicsModesAndObjectControlsWriteTheirSettings();
};
void LassoControlsTests::theBarShowsTheToolsKindsAndSwitchesThem()
@@ -63,7 +64,7 @@ void LassoControlsTests::theBarShowsTheToolsKindsAndSwitchesThem()
QVERIFY(bar.button("marqueeEllipse").isChecked() && !bar.button("marqueeRectangle").isChecked());
QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible());
session.selectTool(NavigationTool::wand);
QCOMPARE(bar.controls.title->text(), QString("Magic Wand"));
QCOMPARE(bar.controls.title->text(), QString("Magic"));
QVERIFY(!bar.button("marqueeRectangle").isVisible() && !bar.button("lassoFreehand").isVisible());
QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible());
QCOMPARE(bar.controls.height(), 42);
@@ -254,6 +255,10 @@ void LassoControlsTests::theWandsControlsWriteItsSettings()
QTest::keyClicks(&tolerance, "0");
QTest::keyClick(&tolerance, Qt::Key_Down);
QCOMPARE(session.wandSettings().tolerance, 0);
// A field that stays above zero takes no sign.
tolerance.selectAll();
QTest::keyClicks(&tolerance, "-");
QCOMPARE(tolerance.text(), QString("0"));
QTest::keyClick(&tolerance, Qt::Key_Return);
// The other three write their fields and read them back.
size.setCurrentIndex(2);
@@ -271,5 +276,97 @@ void LassoControlsTests::theWandsControlsWriteItsSettings()
QVERIFY(bar.button("wandThisLayer").isChecked() && contiguous.isChecked());
}
void LassoControlsTests::magicsModesAndObjectControlsWriteTheirSettings()
{
Bar bar;
EditorSession &session = *bar.session;
auto &edge = find<QLineEdit>(bar.controls, "objectEdge");
auto &antialias = find<QCheckBox>(bar.controls, "selectionAntialiased");
QToolButton &wand = bar.button("magicWandMode"), &object = bar.button("magicObjectMode");
// The modes show with Magic alone, labelled as Swift's.
QVERIFY(!wand.isVisible() && !object.isVisible());
session.selectTool(NavigationTool::wand);
QVERIFY(wand.isVisible() && object.isVisible() && wand.isChecked() && !object.isChecked());
QCOMPARE(wand.text(), QString("Wand"));
QCOMPARE(object.text(), QString("Object"));
QCOMPARE(object.toolTip(), QString("Press Tab to switch between Wand and Object"));
QCOMPARE(antialias.toolTip(), QString("Smooth selection edges; turn off for hard pixel edges"));
QVERIFY(!edge.isVisible() && !bar.button("objectThisLayer").isVisible());
// Magic takes no outline, so a choice only switches.
session.beginLasso(QPointF(5, 5), SelectionMode::replace);
QVERIFY(!session.lassoDraft());
QTest::mouseClick(&object, Qt::LeftButton);
QCOMPARE(session.wandMode(), WandMode::object);
QVERIFY(object.isChecked() && !wand.isChecked());
// Object shows its own controls and hides the wand's.
QVERIFY(edge.isVisible() && bar.button("objectThisLayer").isVisible() && bar.button("objectAllLayers").isVisible());
QVERIFY(find<QLabel>(bar.controls, "objectEdgeLabel").isVisible());
QVERIFY(!find<QLineEdit>(bar.controls, "wandTolerance").isVisible() && !find<QCheckBox>(bar.controls, "wandContiguous").isVisible());
QVERIFY(!bar.button("wandThisLayer").isVisible());
QCOMPARE(antialias.toolTip(), QString("Smooth the detected object outline; turn off for the raw pixel mask"));
QCOMPARE(bar.button("objectAllLayers").toolTip(), QString("Analyze the active layer only, or every visible layer as shown"));
QCOMPARE(edge.toolTip(), QString("Positive values tighten the detected mask inward; negative values expand it outward"));
QVERIFY(bar.button("objectAllLayers").isChecked() && !bar.button("objectThisLayer").isChecked());
QCOMPARE(edge.text(), QString("0"));
// The source writes its field alone.
QTest::mouseClick(&bar.button("objectThisLayer"), Qt::LeftButton);
QVERIFY(!session.objectSelectionSettings().sampleAllLayers);
QTest::mouseClick(&bar.button("objectAllLayers"), Qt::LeftButton);
QVERIFY(session.objectSelectionSettings().sampleAllLayers);
// The edge takes a sign, clamped to ten either way.
edge.setFocus();
QTRY_VERIFY(edge.hasFocus());
edge.selectAll();
QTest::keyClicks(&edge, "-");
QCOMPARE(session.objectSelectionSettings().edgeOffset, 0);
// A sign leads the digits, never follows them.
QTest::keyClicks(&edge, "-");
QCOMPARE(edge.text(), QString("-"));
QTest::keyClicks(&edge, "4");
QCOMPARE(session.objectSelectionSettings().edgeOffset, -4);
QTest::keyClicks(&edge, "0");
QCOMPARE(session.objectSelectionSettings().edgeOffset, -10);
edge.selectAll();
QTest::keyClicks(&edge, "12");
QCOMPARE(session.objectSelectionSettings().edgeOffset, 10);
edge.selectAll();
edge.insert(QStringLiteral("-99999999999999999999"));
QCOMPARE(session.objectSelectionSettings().edgeOffset, -10);
edge.selectAll();
edge.insert(QStringLiteral("99999999999999999999"));
QCOMPARE(session.objectSelectionSettings().edgeOffset, 10);
edge.selectAll();
QTest::keyClick(&edge, Qt::Key_Backspace);
QTest::keyClicks(&edge, "a+");
QCOMPARE(edge.text(), QString());
QCOMPARE(session.objectSelectionSettings().edgeOffset, 10);
QTest::keyClicks(&edge, "3");
QTest::keyClick(&edge, Qt::Key_Down, Qt::ShiftModifier);
QCOMPARE(session.objectSelectionSettings().edgeOffset, -7);
QTest::keyClick(&edge, Qt::Key_Down, Qt::ShiftModifier);
QCOMPARE(session.objectSelectionSettings().edgeOffset, -10);
QTest::keyClick(&edge, Qt::Key_Up);
QCOMPARE(session.objectSelectionSettings().edgeOffset, -9);
QVERIFY(session.objectSelectionSettings().sampleAllLayers);
QTest::keyClick(&edge, Qt::Key_Return);
// The session's own changes show; Wand brings its controls back.
ObjectSelectionSettings other;
other.sampleAllLayers = false;
other.edgeOffset = 6;
session.setObjectSelectionSettings(other);
QCOMPARE(edge.text(), QString("6"));
QVERIFY(bar.button("objectThisLayer").isChecked());
session.cycleToolMode();
QVERIFY(wand.isChecked() && !edge.isVisible() && find<QLineEdit>(bar.controls, "wandTolerance").isVisible());
session.setWandMode(WandMode::object);
QVERIFY(object.isChecked() && !wand.isChecked());
QTest::mouseClick(&object, Qt::LeftButton);
QTest::mouseClick(&wand, Qt::LeftButton);
QCOMPARE(session.wandMode(), WandMode::wand);
// The Lasso shows neither.
session.selectTool(NavigationTool::lasso);
QVERIFY(!wand.isVisible() && !edge.isVisible());
}
QTEST_MAIN(LassoControlsTests)
#include "LassoControlsTests.moc"
+475
View File
@@ -0,0 +1,475 @@
#include "SelectionCanvasFixtures.h"
#include "Document/BrushStroke.h"
#include "Document/MagicWand.h"
#include "Document/ObjectSelection.h"
#include "Rendering/SelectionIcons.h"
#include "UI/NativeLayerList.h"
#include <QPainter>
// The Magic tool's Object mode: regions, edges, outlines, clicks.
namespace {
// Red discs on white, or flat white.
QImage discs(const std::vector<std::pair<QPointF, double>> &shapes, QSize size = QSize(400, 300))
{
QImage image = BrushRaster::context(size.width(), size.height(), false);
image.fill(Qt::white);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(217, 26, 26));
for (const auto &[centre, radius] : shapes)
painter.drawEllipse(centre, radius, radius);
return image;
}
// Pixels an outline covers at half coverage or more.
int covered(const std::optional<QPainterPath> &path, QSize size)
{
const QImage coverage = DocumentSelection{path.value(), false}.coverage(size.width(), size.height());
int count = 0;
for (int y = 0; y < size.height(); ++y) {
for (int x = 0; x < size.width(); ++x)
count += coverage.constScanLine(y)[x] >= 128;
}
return count;
}
bool inside(const std::optional<QPainterPath> &path, QPointF point)
{
return path.value().contains(point);
}
std::unique_ptr<EditorSession> withDisc()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(400, 300);
const QImage image = discs({{QPointF(200, 150), 80}});
session->insert(ImportedImage(image, image, QStringLiteral("Disc")));
session->selectTool(NavigationTool::wand);
session->setWandMode(WandMode::object);
return session;
}
void selectObject(EditorSession &session, QPointF point, SelectionMode mode)
{
bool done = false;
session.selectObject(point, mode, [&done] { done = true; });
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
}
// Points of the path, a subpath's close included.
int points(const QPainterPath &path)
{
return path.elementCount();
}
}
class ObjectSelectionTests : public QObject {
Q_OBJECT
private slots:
void aRegionTakesTheEightConnectedCellsUnderTheClick();
void edgesStepARingAPixelAndStopAtTen();
void smoothingRoundsStairsAndKeepsSubpathsAndTheirRule();
void theObjectUnderTheClickIsTracedAndTheBackgroundIsNone();
void theEdgeOffsetTightensAndLoosensTheOutline();
void theEdgeFollowsTheImageNotTheModelsGrid();
void theSessionSelectsCombinesAndNamesTheStep();
void theSessionRefusesAndReadsTheLayerItIsTold();
void theModeSwitchesByTabAndWKeepsIt();
void theCanvasClicksPicksAgainInsideAndShowsTheObjectCursor();
};
void ObjectSelectionTests::aRegionTakesTheEightConnectedCellsUnderTheClick()
{
const std::vector<uchar> mask{
1, 1, 0, 0, 1,
0, 1, 0, 0, 1,
0, 0, 1, 0, 0,
1, 0, 0, 0, 1,
};
const std::vector<uchar> first = ObjectSelection::region(mask, 5, 4, 0, 0).value();
QCOMPARE(first, (std::vector<uchar>{
1, 1, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 0, 0,
}));
// One region from any cell, the diagonal included.
QCOMPARE(ObjectSelection::region(mask, 5, 4, 2, 2).value(), first);
QCOMPARE(ObjectSelection::region(mask, 5, 4, 4, 1).value(), (std::vector<uchar>{
0, 0, 0, 0, 1,
0, 0, 0, 0, 1,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
}));
QCOMPARE(ObjectSelection::region(mask, 5, 4, 4, 3).value(), (std::vector<uchar>{
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 1,
}));
// Background holds no region.
QVERIFY(!ObjectSelection::region(mask, 5, 4, 3, 0));
}
void ObjectSelectionTests::edgesStepARingAPixelAndStopAtTen()
{
std::vector<uchar> block(49);
for (int y = 2; y <= 4; ++y) {
for (int x = 2; x <= 4; ++x)
block[size_t(y * 7 + x)] = 255;
}
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 0), block);
std::vector<uchar> centre(49);
centre[24] = 255;
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 1), centre);
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 2), std::vector<uchar>(49));
std::vector<uchar> grown(49);
for (int y = 1; y <= 5; ++y) {
for (int x = 1; x <= 5; ++x)
grown[size_t(y * 7 + x)] = 255;
}
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, -1), grown);
// Past ten steps either way, ten.
std::vector<uchar> dot(25 * 25);
dot[12 * 25 + 12] = 255;
const std::vector<uchar> far = ObjectSelection::adjusted(dot, 25, 25, -20);
QCOMPARE(far, ObjectSelection::adjusted(dot, 25, 25, -10));
QCOMPARE(std::count(far.begin(), far.end(), uchar(255)), 21 * 21);
QCOMPARE(far[2 * 25 + 2], uchar(255));
QCOMPARE(far[1 * 25 + 12], uchar(0));
std::vector<uchar> full(25 * 25, 255);
QCOMPARE(ObjectSelection::adjusted(far, 25, 25, 20), ObjectSelection::adjusted(far, 25, 25, 10));
// The image's edge counts as inside: a full mask keeps.
QCOMPARE(ObjectSelection::adjusted(full, 25, 25, 3), full);
}
void ObjectSelectionTests::smoothingRoundsStairsAndKeepsSubpathsAndTheirRule()
{
// A staircase triangle and a holed square, traced.
std::vector<uchar> mask(40 * 40);
for (int y = 0; y < 40; ++y) {
for (int x = 0; x < 40; ++x)
mask[size_t(y * 40 + x)] = (x <= y && y < 20) || (x >= 24 && x < 36 && y >= 24 && y < 36 && !(x >= 28 && x < 32 && y >= 28 && y < 32)) ? 255 : 0;
}
const QPainterPath traced = MagicWand::outline(mask, 40, 40).value();
QVERIFY(points(traced) > 40);
const QPainterPath smooth = ObjectSelection::smoothed(traced);
QCOMPARE(smooth.fillRule(), traced.fillRule());
// Three subpaths, each simplified, then cut three times.
const QList<QPolygonF> parts = smooth.toSubpathPolygons();
QCOMPARE(parts.size(), 3);
QCOMPARE(parts[0].size(), 3 * 8 + 1);
QCOMPARE(parts[1].size(), 4 * 8 + 1);
QCOMPARE(parts[2].size(), 4 * 8 + 1);
// The stair is gone: the hypotenuse is straight.
QVERIFY(smooth.contains(QPointF(8, 12)) && !smooth.contains(QPointF(12, 8)));
// Corners round off; the hole stays a hole.
QVERIFY(smooth.contains(QPointF(30, 25)) && !smooth.contains(QPointF(30, 30)));
QVERIFY(!smooth.contains(QPointF(24.3, 24.3)));
QVERIFY(traced.contains(QPointF(24.3, 24.3)));
// By hand: the extreme breaks the loop; curves keep ends.
QPainterPath open;
open.moveTo(0, 0);
for (const QPointF point : {QPointF(5, -1.3), QPointF(10, 0), QPointF(10, 10), QPointF(0, 10), QPointF(0, 5)})
open.lineTo(point);
open.moveTo(20, 0);
open.lineTo(30, 0);
open.cubicTo(30, 3, 30, 7, 30, 10);
open.lineTo(20, 10);
const QList<QPolygonF> built = ObjectSelection::smoothed(open).toSubpathPolygons();
QCOMPARE(built.size(), 2);
QCOMPARE(built[0].size(), 33);
QCOMPARE(built[0].front(), QPointF(4.375, 0));
QCOMPARE(built[1].size(), 33);
QCOMPARE(built[1].front(), QPointF(24.375, 0));
QCOMPARE(built[1][8], QPointF(30, 4.375));
// A flat loop keeps its points; a closed pair goes.
QPainterPath flat;
flat.moveTo(40, 0);
for (const QPointF point : {QPointF(45, 0), QPointF(50, 0), QPointF(45, 0.5)})
flat.lineTo(point);
flat.closeSubpath();
flat.moveTo(60, 0);
flat.lineTo(70, 0);
flat.closeSubpath();
const QList<QPolygonF> kept = ObjectSelection::smoothed(flat).toSubpathPolygons();
QCOMPARE(kept.size(), 1);
QCOMPARE(kept[0].size(), 33);
// A subpath under three points is dropped.
QPainterPath thin;
thin.moveTo(0, 0);
thin.lineTo(5, 5);
QVERIFY(ObjectSelection::smoothed(thin).isEmpty());
}
void ObjectSelectionTests::theObjectUnderTheClickIsTracedAndTheBackgroundIsNone()
{
const QImage pair = discs({{QPointF(110, 150), 60}, {QPointF(290, 150), 60}});
const std::optional<QPainterPath> left = ObjectSelection::select(pair, QPointF(110, 150), 0, false);
QVERIFY(inside(left, QPointF(110, 150)) && inside(left, QPointF(160, 150)));
QVERIFY(!inside(left, QPointF(290, 150)) && !inside(left, QPointF(10, 10)) && !inside(left, QPointF(178, 150)));
const std::optional<QPainterPath> right = ObjectSelection::select(pair, QPointF(290.7, 150.2), 0, false);
QVERIFY(inside(right, QPointF(290, 150)) && !inside(right, QPointF(110, 150)));
// The raw mask hugs the disc, within two percent.
const int area = covered(left, pair.size());
QVERIFY2(std::abs(area - M_PI * 60 * 60) < 0.02 * M_PI * 60 * 60, qPrintable(QString::number(area)));
// Background, off the image, no number, or no subject: none.
QVERIFY(!ObjectSelection::select(pair, QPointF(10, 10), 0, false));
QVERIFY(!ObjectSelection::select(pair, QPointF(200, 150), 0, false));
for (const QPointF point : {QPointF(-0.5, 10), QPointF(400, 10), QPointF(10, 300), QPointF(std::nan(""), 10), QPointF(10, INFINITY)})
QVERIFY(!ObjectSelection::select(pair, point, 0, false));
QVERIFY(!ObjectSelection::select(discs({}), QPointF(200, 150), 0, false));
// The point floors: the last half pixel is inside.
const QImage edges = discs({{QPointF(20, 150), 60}, {QPointF(380, 150), 60}});
QVERIFY(inside(ObjectSelection::select(edges, QPointF(399.5, 150), 0, false), QPointF(390, 150)));
QVERIFY(inside(ObjectSelection::select(edges, QPointF(0, 150), 0, false), QPointF(10, 150)));
QVERIFY(!ObjectSelection::select(edges, QPointF(-0.5, 150), 0, false));
// The cell row comes from the height.
const QImage low = discs({{QPointF(800, 1000), 150}}, QSize(1600, 1200));
QVERIFY(inside(ObjectSelection::select(low, QPointF(800, 1000), 0, false), QPointF(800, 1000)));
// Smoothing keeps the object and drops the stairs.
const std::optional<QPainterPath> smooth = ObjectSelection::select(pair, QPointF(110, 150), 0, true);
QVERIFY(inside(smooth, QPointF(110, 150)) && !inside(smooth, QPointF(290, 150)));
QVERIFY(points(smooth.value()) < points(left.value()));
// Cutting corners shaves a convex outline a little.
const int rounded = covered(smooth, pair.size());
QVERIFY2(rounded < area && rounded > 0.97 * area, qPrintable(QString::number(rounded)));
}
void ObjectSelectionTests::theEdgeOffsetTightensAndLoosensTheOutline()
{
const QImage disc = discs({{QPointF(200, 150), 80}});
const int plain = covered(ObjectSelection::select(disc, QPointF(200, 150), 0, false), disc.size());
const int tight = covered(ObjectSelection::select(disc, QPointF(200, 150), 4, false), disc.size());
const int loose = covered(ObjectSelection::select(disc, QPointF(200, 150), -4, false), disc.size());
// Square steps widen a round rim: 8 r k.
const double ring = 8 * 80 * 4;
QVERIFY2(std::abs(plain - tight - ring) < 0.05 * ring, qPrintable(QString::number(plain - tight)));
QVERIFY2(std::abs(loose - plain - ring - 4 * 4 * 4) < 0.05 * ring, qPrintable(QString::number(loose - plain)));
}
void ObjectSelectionTests::theEdgeFollowsTheImageNotTheModelsGrid()
{
// Grid cells five pixels wide; the image decides the edge.
const QSize size(1600, 1200);
const QImage disc = discs({{QPointF(800, 600), 320}}, size);
const QImage coverage = DocumentSelection{ObjectSelection::select(disc, QPointF(800, 600), 0, false).value(), false}.coverage(size.width(), size.height());
int wrong = 0, outside = 0;
for (int y = 0; y < size.height(); ++y) {
for (int x = 0; x < size.width(); ++x) {
const bool truth = std::hypot(x + 0.5 - 800, y + 0.5 - 600) < 320;
const bool chosen = coverage.constScanLine(y)[x] >= 128;
wrong += truth != chosen;
outside += chosen && !truth;
}
}
// Measured: 190 wrong, 141 outside; the grid alone, 1682.
QVERIFY2(wrong < 230 && outside > 70 && wrong - outside < 100, qPrintable(QStringLiteral("%1 %2").arg(wrong).arg(outside)));
}
void ObjectSelectionTests::theSessionSelectsCombinesAndNamesTheStep()
{
const auto session = withDisc();
bool done = false;
session->selectObject(QPointF(200, 150), SelectionMode::replace, [&done] { done = true; });
QVERIFY(session->isProjectBusy());
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
QVERIFY(!session->isProjectBusy());
QCOMPARE(session->history.undoName(), QString("Object Selection"));
QCOMPARE(coverage(*session, 200, 150), 255);
QCOMPARE(coverage(*session, 10, 10), 0);
// A traced outline is kept as traced, antialiased as set.
QVERIFY(session->selection().value().antialiased);
// Add and Subtract combine with what is there.
session->applySelection(rectPath(QRectF(10, 10, 30, 30)), SelectionMode::replace, "Select");
selectObject(*session, QPointF(200, 150), SelectionMode::add);
QCOMPARE(session->history.undoName(), QString("Object Selection"));
QCOMPARE(coverage(*session, 20, 20), 255);
QCOMPARE(coverage(*session, 200, 150), 255);
selectObject(*session, QPointF(200, 150), SelectionMode::subtract);
QCOMPARE(coverage(*session, 20, 20), 255);
QCOMPARE(coverage(*session, 200, 150), 0);
// Background adds nothing, and New there deselects.
const int steps = session->history.undoCount();
selectObject(*session, QPointF(10, 290), SelectionMode::add);
QCOMPARE(session->history.undoCount(), steps);
QCOMPARE(coverage(*session, 20, 20), 255);
selectObject(*session, QPointF(10, 290), SelectionMode::replace);
QVERIFY(!session->selection());
// The anti-alias setting reaches the selection.
const auto whole = [](const QPainterPath &path) {
for (int index = 0; index < path.elementCount(); ++index) {
const QPainterPath::Element element = path.elementAt(index);
if (element.x != std::round(element.x) || element.y != std::round(element.y))
return false;
}
return true;
};
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QVERIFY(!whole(session->selection().value().path));
session->setSelectionAntialiased(false);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QVERIFY(!session->selection().value().antialiased);
// Without anti-alias the traced pixel edges stay.
QVERIFY(whole(session->selection().value().path));
// The document gone mid-run: the outline is dropped.
done = false;
session->selectObject(QPointF(200, 150), SelectionMode::replace, [&done] { done = true; });
session->clearProject();
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
QVERIFY(!session->document() && !session->isProjectBusy());
}
void ObjectSelectionTests::theSessionRefusesAndReadsTheLayerItIsTold()
{
const auto session = withDisc();
session->selectAll();
const int steps = session->history.undoCount();
for (const QPointF point : {QPointF(-1, 10), QPointF(400, 10), QPointF(10, 300), QPointF(std::nan(""), 10)}) {
selectObject(*session, point, SelectionMode::replace);
QCOMPARE(coverage(*session, 10, 10), 255);
}
session->setIsProjectBusy(true);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
session->setIsProjectBusy(false);
QVERIFY(session->beginSelectionMove());
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
session->endSelectionMove();
QCOMPARE(coverage(*session, 10, 10), 255);
QCOMPARE(session->history.undoCount(), steps);
// This Layer reads a blank layer: nothing there.
session->addBlankLayer();
ObjectSelectionSettings own = session->objectSelectionSettings();
own.sampleAllLayers = false;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QVERIFY(!session->selection());
own.sampleAllLayers = true;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QCOMPARE(coverage(*session, 200, 150), 255);
// The edge offset reaches the trace, clamped to ten.
const int plain = covered(session->selection().value().path, QSize(400, 300));
own.edgeOffset = 50;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
const int clamped = covered(session->selection().value().path, QSize(400, 300));
QCOMPARE(session->objectSelectionSettings().edgeOffset, 50);
const QImage disc = discs({{QPointF(200, 150), 80}});
QCOMPARE(clamped, covered(ObjectSelection::select(disc, QPointF(200, 150), 10, true), QSize(400, 300)));
QVERIFY(clamped < plain);
own.edgeOffset = -50;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QCOMPARE(covered(session->selection().value().path, QSize(400, 300)), covered(ObjectSelection::select(disc, QPointF(200, 150), -10, true), QSize(400, 300)));
}
void ObjectSelectionTests::theModeSwitchesByTabAndWKeepsIt()
{
EditorSession session;
session.createDocument(20, 20);
QCOMPARE(session.wandMode(), WandMode::wand);
QCOMPARE(rawValue(WandMode::wand), QString("Wand"));
QCOMPARE(rawValue(WandMode::object), QString("Object"));
QSignalSpy changed(&session, &EditorSession::changed);
session.setWandMode(WandMode::object);
QCOMPARE(changed.count(), 1);
ObjectSelectionSettings settings;
QVERIFY(settings.sampleAllLayers && settings.edgeOffset == 0);
settings.edgeOffset = -3;
session.setObjectSelectionSettings(settings);
QCOMPARE(changed.count(), 2);
QCOMPARE(session.objectSelectionSettings(), settings);
// Tab cycles the mode only under the Magic tool.
session.selectTool(NavigationTool::lasso);
session.cycleToolMode();
QCOMPARE(session.wandMode(), WandMode::object);
session.pressWandKey();
QCOMPARE(session.tool(), NavigationTool::wand);
QCOMPARE(session.wandMode(), WandMode::object);
session.cycleToolMode();
QCOMPARE(session.wandMode(), WandMode::wand);
session.cycleToolMode();
QCOMPARE(session.wandMode(), WandMode::object);
QCOMPARE(label(NavigationTool::wand), QString("Magic (W) · Tab switches Wand and Object"));
// The list's W picks the tool; a repeat changes nothing.
NativeLayerList list(session);
list.show();
QVERIFY(QTest::qWaitForWindowExposed(&list));
list.setFocus();
QTRY_VERIFY(list.hasFocus());
session.selectTool(NavigationTool::brush);
QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::NoModifier, QStringLiteral("w"), true);
QApplication::sendEvent(&list, &repeat);
QCOMPARE(session.tool(), NavigationTool::brush);
QTest::keyClick(&list, Qt::Key_W);
QCOMPARE(session.tool(), NavigationTool::wand);
QCOMPARE(session.wandMode(), WandMode::object);
}
void ObjectSelectionTests::theCanvasClicksPicksAgainInsideAndShowsTheObjectCursor()
{
Canvas shown;
EditorSession &session = shown.session;
const QImage image = discs({{QPointF(200, 150), 80}});
session.insert(ImportedImage(image, image, QStringLiteral("Disc")));
session.selectTool(NavigationTool::wand);
shown.canvas->synchronizeDisplay();
const double ratio = shown.canvas->devicePixelRatio();
shown.hover(QPointF(20, 20));
QVERIFY(shown.shows(CanvasView::wandCursor(SelectionMode::replace, ratio)));
// Tab on the canvas switches to Object and its cursor.
shown.canvas->setFocus();
QTRY_VERIFY(shown.canvas->hasFocus());
QTest::keyClick(shown.canvas, Qt::Key_Tab);
QCOMPARE(session.wandMode(), WandMode::object);
shown.hover(QPointF(20, 20));
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::objectSelection, SelectionMode::replace, ratio)));
shown.hover(QPointF(20, 20), Qt::ShiftModifier);
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::objectSelection, SelectionMode::add, ratio)));
// A click traces the object under the document point.
session.zoom(2);
shown.canvas->synchronizeDisplay();
shown.click(session.viewport.viewPoint(QPointF(260, 150), shown.documentSize()));
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && session.selection().has_value(), 20000);
QCOMPARE(session.history.undoName(), QString("Object Selection"));
QCOMPARE(shown.coverage(200, 150), 255);
QCOMPARE(shown.coverage(20, 20), 0);
session.zoom(1);
shown.canvas->synchronizeDisplay();
// A click inside a selection picks afresh, never deselects.
session.selectAll();
shown.canvas->synchronizeDisplay();
shown.click(QPointF(200, 150));
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && shown.coverage(20, 20) == 0, 20000);
QCOMPARE(shown.coverage(200, 150), 255);
QCOMPARE(session.history.undoName(), QString("Object Selection"));
// Shift adds the object to a box elsewhere.
session.applySelection(rectPath(QRectF(10, 10, 20, 20)), SelectionMode::replace, "Select");
shown.canvas->synchronizeDisplay();
shown.click(QPointF(200, 150), Qt::ShiftModifier);
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && shown.coverage(200, 150) == 255, 20000);
QCOMPARE(shown.coverage(20, 20), 255);
// Wand mode's click matches colour again.
session.setWandMode(WandMode::wand);
session.deselect();
shown.canvas->synchronizeDisplay();
shown.click(QPointF(20, 20));
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && session.selection().has_value(), 20000);
QCOMPARE(session.history.undoName(), QString("Magic Wand"));
QCOMPARE(shown.coverage(20, 20), 255);
QCOMPARE(shown.coverage(200, 150), 0);
// W picks Magic, keeps the mode, and ignores a repeat.
session.setWandMode(WandMode::object);
session.selectTool(NavigationTool::brush);
QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::NoModifier, QStringLiteral("w"), true);
QApplication::sendEvent(shown.canvas, &repeat);
QCOMPARE(session.tool(), NavigationTool::brush);
QTest::keyClick(shown.canvas, Qt::Key_W);
QCOMPARE(session.tool(), NavigationTool::wand);
QCOMPARE(session.wandMode(), WandMode::object);
}
QTEST_MAIN(ObjectSelectionTests)
#include "ObjectSelectionTests.moc"
+2
View File
@@ -79,6 +79,8 @@ inline QStringList described(const EditorSession &session)
.arg(int(session.selectionAntialiased())).arg(session.selectionExpandAmount()).arg(session.selectionContractAmount());
result << QStringLiteral("wand %1 %2 %3 %4").arg(session.wandSettings().tolerance).arg(int(session.wandSettings().sampleSize))
.arg(int(session.wandSettings().contiguous)).arg(int(session.wandSettings().sampleAllLayers));
result << QStringLiteral("magic %1 object %2 %3").arg(int(session.wandMode())).arg(int(session.objectSelectionSettings().sampleAllLayers))
.arg(session.objectSelectionSettings().edgeOffset);
if (session.lassoDraft().has_value()) {
const LassoDraft &draft = session.lassoDraft().value();
result << QStringLiteral("lasso %1 %2 points %3 cursor %4").arg(int(draft.kind)).arg(int(draft.mode)).arg(draft.points.size())
+19
View File
@@ -1,4 +1,5 @@
#include "AddressSpaceLimit.h"
#include "Document/ObjectSelection.h"
#include "Document/BrushStroke.h"
#include "Document/SubjectRemoval.h"
#include "IO/ImageExporter.h"
@@ -14,6 +15,7 @@ private slots:
void initTestCase() { mallopt(M_MMAP_THRESHOLD, 64 * 1024); }
void aPreviewThatFindsNoMemoryIsARenderError();
void aModelRunThatFindsNoMemoryIsARenderError();
void anObjectSelectionThatFindsNoMemoryIsARenderError();
};
namespace {
@@ -51,5 +53,22 @@ void SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError()
QVERIFY_THROWS_EXCEPTION(ExportError, SubjectRemoval::run(fresh, FilterSettings()));
}
void SubjectRemovalFailureTests::anObjectSelectionThatFindsNoMemoryIsARenderError()
{
const QImage large = disc(6000, 4000);
// The model's mask is kept, so only the selection allocates.
QVERIFY(ObjectSelection::select(large, QPointF(3000, 2000), 0, false).has_value());
malloc_trim(0);
{
// The model's grid finds no room.
const AddressSpaceLimit limit(16ll * 1024);
QVERIFY_THROWS_EXCEPTION(ExportError, ObjectSelection::select(large, QPointF(3000, 2000), 0, false));
}
malloc_trim(0);
// The grid fits; the image-sized masks do not.
const AddressSpaceLimit limit(8ll * 1024 * 1024);
QVERIFY_THROWS_EXCEPTION(ExportError, ObjectSelection::select(large, QPointF(3000, 2000), 0, false));
}
QTEST_GUILESS_MAIN(SubjectRemovalFailureTests)
#include "SubjectRemovalFailureTests.moc"
+19
View File
@@ -1,4 +1,6 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/ObjectSelection.h"
#include "Document/SubjectRemoval.h"
#include <QPainter>
#include <QTemporaryDir>
@@ -48,6 +50,23 @@ void SubjectRemovalModelTests::aModelThatFailsIsNamedAndTheNextUseLoadsAgain()
said = failure(image);
QVERIFY2(said.startsWith(QStringLiteral("Remove Background could not use its model: omaphoto/u2net.onnx is in no data folder (")), qPrintable(said));
QVERIFY2(said.contains(empty.path()), qPrintable(said));
// Object mode passes the model's failure on unchanged.
try {
ObjectSelection::select(image, QPointF(100, 75), 0, true);
QFAIL("the object selection ran without a model");
} catch (const SubjectRemovalError &error) {
QVERIFY(error.kind == SubjectRemovalError::Kind::model);
}
// The session reports it and keeps the selection.
EditorSession session;
session.createDocument(200, 150);
session.insert(ImportedImage(image, image, QStringLiteral("Disc")));
session.selectAll();
bool done = false;
session.selectObject(QPointF(100, 75), SelectionMode::replace, [&done] { done = true; });
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
QVERIFY(session.brushError().value().startsWith(QStringLiteral("Remove Background could not use its model: ")));
QCOMPARE(session.selection().value().path.boundingRect(), QRectF(0, 0, 200, 150));
// Back in its folder, the next use loads it.
if (folders.isEmpty())
qunsetenv("XDG_DATA_DIRS");
+7 -6
View File
@@ -67,9 +67,9 @@ void ToolIconTests::everyToolHasItsOwnIcon()
icons << icon;
}
QCOMPARE(icons.size(), 15);
// The Marquee's, the Lasso's and the brush's kinds are icons.
// Marquee, Lasso, brush and Magic kinds are icons.
for (const auto &[tool, kind] : {std::pair(NavigationTool::marquee, ToolIconKind::ellipse), std::pair(NavigationTool::lasso, ToolIconKind::polygonal),
std::pair(NavigationTool::brush, ToolIconKind::eraser)}) {
std::pair(NavigationTool::brush, ToolIconKind::eraser), std::pair(NavigationTool::wand, ToolIconKind::object)}) {
const QImage icon = drawn(tool, Qt::white, 36, {0, 0}, kind);
QVERIFY(inked(icon) > 60 && !icons.contains(icon));
icons << icon;
@@ -77,6 +77,7 @@ void ToolIconTests::everyToolHasItsOwnIcon()
// A kind another tool lacks leaves its icon alone.
QCOMPARE(drawn(NavigationTool::brush, Qt::white, 36, {0, 0}, ToolIconKind::ellipse), drawn(NavigationTool::brush, Qt::white));
QCOMPARE(drawn(NavigationTool::move, Qt::white, 36, {0, 0}, ToolIconKind::eraser), drawn(NavigationTool::move, Qt::white));
QCOMPARE(drawn(NavigationTool::lasso, Qt::white, 36, {0, 0}, ToolIconKind::object), drawn(NavigationTool::lasso, Qt::white));
}
void ToolIconTests::anIconTakesTheColourItIsGivenAndNoOther()
@@ -312,22 +313,22 @@ void ToolIconTests::anIconKeepsToTheCallersClip()
void ToolIconTests::theDrawingsMatchTheirReference()
{
// Every stroke is pinned by the picture in the fixtures.
QImage sheet(18 * 44 + 8, 2 * 52, QImage::Format_ARGB32_Premultiplied);
QImage sheet(19 * 44 + 8, 2 * 52, QImage::Format_ARGB32_Premultiplied);
sheet.fill(QColor(36, 36, 36));
QPainter painter(&sheet);
for (int index = 0; index < 15; ++index) {
ToolIcons::paint(painter, tools[index], QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235));
ToolIcons::paint(painter, tools[index], QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235));
}
// Three more: the ellipse Marquee, polygonal Lasso, the eraser.
// Four more: ellipse Marquee, polygonal Lasso, eraser, Object mode.
for (const auto &[index, tool, kind] : {std::tuple(15, NavigationTool::marquee, ToolIconKind::ellipse), std::tuple(16, NavigationTool::lasso, ToolIconKind::polygonal),
std::tuple(17, NavigationTool::brush, ToolIconKind::eraser)}) {
std::tuple(17, NavigationTool::brush, ToolIconKind::eraser), std::tuple(18, NavigationTool::wand, ToolIconKind::object)}) {
ToolIcons::paint(painter, tool, QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235), kind);
ToolIcons::paint(painter, tool, QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235), kind);
}
painter.end();
// After a wanted change, look at this file; commit it.
QVERIFY(sheet.save(QStringLiteral("ToolIcons.png")));
QVERIFY(sheet.save(QCoreApplication::applicationDirPath() + QStringLiteral("/ToolIcons.png")));
const QImage reference = QImage(QFINDTESTDATA("fixtures/ToolIcons.png")).convertToFormat(QImage::Format_ARGB32_Premultiplied);
QCOMPARE(reference.size(), sheet.size());
// Type's letters take the system's sans; the sheet's is DejaVu.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB