diff --git a/.gitignore b/.gitignore index f9753b9d..03c7c57c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ build/ stage/ *.tar.gz +.superpowers/ diff --git a/AGENTS.md b/AGENTS.md index a902d3cd..752b2be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ change that touches the principle, not just this summary. | `src/stitch-replay.cpp` | Standalone tool: replay a dumped frame directory through the stitcher with no compositor | | `src/surface-capture.cpp` | In-process output/window capture via `ext-image-copy-capture` | | `src/cut.cpp/.hpp` | Cut-band tool: remove a strip and collapse the gap | +| `src/clip.cpp/.hpp` | Clip-out: copy a path (rect/ellipse/lasso), punch a hole, lift as a layer | | `src/recent-snaps.cpp/.hpp` | The recents shelf: shelving/reopening working documents | | `src/output-config.cpp/.hpp`, `src/palette-config.cpp/.hpp` | The optional `omasnap.conf` INI: output destination/filename, color presets | | `src/pin.cpp/.hpp`, `src/pin-file.cpp/.hpp`, `src/pin-layout.cpp/.hpp` | Pinned-capture layer-shell surfaces (bottom-right, all workspaces) | diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cdac5e2..b0f517db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,8 @@ add_library(omasnap-core STATIC src/capture.hpp src/cut.cpp src/cut.hpp + src/clip.cpp + src/clip.hpp src/palette-config.cpp src/palette-config.hpp src/background-config.cpp @@ -147,6 +149,10 @@ qt_add_executable(omasnap-smoke tests/cut-smoke.hpp tests/cut-mapping-smoke.cpp tests/cut-mapping-smoke.hpp + tests/clip-smoke.cpp + tests/clip-smoke.hpp + tests/clip-mapping-smoke.cpp + tests/clip-mapping-smoke.hpp tests/palette-config-smoke.cpp tests/palette-config-smoke.hpp src/cli-path.cpp diff --git a/README.md b/README.md index 614733ca..e2db9c85 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,24 @@ resizable vector layers and preserves the monitor's native pixels on scaled disp mesh-gradient backdrops, and rendered drop shadows on standard backdrop cards. - Cut tool: drag across a band of the image to remove it and collapse the gap, with a live preview and dashed seam marker while dragging; annotations shift to follow. +- Clip-out: in Select, `V` cycles clip shape (rectangle, ellipse, lasso) and a + strip under the toolbar shows those chips plus a Snap toggle. A large enough + empty drag that hits no layers locks a pixel mask of the current shape + (lasso keeps the traced path even if you close back near the start). Snap + is **on by default**: drag a box around the object (leave a little + background in the corners) then Snap reads what is connected under the + pointer inside that box. Toggle the Snap chip to draw a free mask. A scan-dot traces the outline (OCR-style) then the mask locks. Rect + keeps gray chrome, not just the colourful bits. Ellipse fits a circle that + covers protrusions (a pin on a badge). Lasso follows the silhouette. + Click Snap again with a mask locked to refine it. Rect + Snap uses the + object's corner radius so the overlay matches a rounded card, not a + sharp box. Crop handles hide while that mask is locked. A fly-out picks the + hole infill (match surroundings by default; `T` cycles surroundings → + Sample from image → transparent; `1`–`8` / `I` / `#` while the mask is + locked). Drag inside to lift a layer with alpha outside the path. + Release near the hole to snap back; elsewhere to commit. Esc / Ctrl+Z + during a lift cancels only the lift. Distinct from Cut, which collapses a + band. `R` / `E` / `F` still arm the drawing tools. - Pin a finished capture as a bottom-right always-on-top layer surface, launched from the same `omasnap` executable and visible on every workspace. - Crash-resistant working documents under `/run/user//omasnap/` (falling back to @@ -265,8 +283,10 @@ of a new screenshot. Finishing a reopened capture replaces its shelf entry. ### Configuration (optional) -Omasnap has no settings UI and runs fine with no config at all. If you want to -change where screenshots land or what they are called, create +Omasnap has no settings UI and runs fine with no config at all. Clip-out +defaults (Snap on, hole fill match-surroundings, `T` to cycle fill) are the +product, not INI keys. If you want to change where screenshots land or what +they are called, create `~/.config/omasnap/omasnap.conf` (INI format); every key is optional: ```ini @@ -350,7 +370,7 @@ without reaching for the pointer. | Input | Action | |---|---| -| `V` | Select/move/resize layers; carrying one past the source grows the canvas; drag empty canvas for a marquee; multi-select outlines each layer without treating the canvas as one layer; wheel scales the selected layer | +| `V` | Select/move/resize layers; carrying one past the source grows the canvas; press again to cycle clip shape (rectangle, ellipse, lasso); Snap is a strip toggle (Rect snaps to a square/rounded rectangle, Ellipse/Lasso to a circle); drag empty canvas for a marquee; a large enough empty drag that hits no layers locks a pixel mask of the current shape (lasso uses the traced path unless Snap magnets to a circle; crop handles hide while that mask is locked) with a hole-fill fly-out (match surroundings by default; `T` cycles fill; `1`–`8` / Sample from image / `#` hex while locked) — drag inside to lift it out as a new layer (repeat); multi-select outlines each layer without treating the canvas as one layer; wheel scales the selected layer (ignored on a clip) | | `A` | Arrow | | `S` | Spotlight/loupe; press again to cycle ellipse, rectangle, rounded | | `L` | Straight line | diff --git a/docs/clip-out.gif b/docs/clip-out.gif new file mode 100644 index 00000000..6068e575 Binary files /dev/null and b/docs/clip-out.gif differ diff --git a/docs/editing-model.md b/docs/editing-model.md index 72681b0d..d8faedd9 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -5,7 +5,7 @@ The image on screen while editing is not the working state — the operation log (`ops_` in `CaptureEditor`, an ordered `QVector` with an `opIndex_` cursor) is. Every action that changes the picture — a crop, a -background choice, adding/patching/deleting an annotation, a cut — appends +background choice, adding/patching/deleting an annotation, a cut, a clip — appends one `Operation`. Undo moves `opIndex_` back; redo moves it forward. `CaptureEditor::replayLog()` rebuilds the entire visible state (selection, background, annotation list, cuts) by replaying the log from empty up to @@ -47,6 +47,22 @@ still fully undoable because of how they're kept in the log: original plus the list of cut ops every time the list changes (`CaptureEditor::refreshComposedCapture()`). Undo a cut and the composed image is rebuilt without it; `pristineSource_` was never modified. +- **Clip** (`Operation::Type::Clip`) copies a **path** of native pixels + (rectangle, ellipse, or lasso polygon) and fills that hole — transparent by + default, or a solid colour from the selection fly-out — then adds those + pixels as a `Annotation::Kind::Clip` layer at the drop location. Pixels + outside the path on the lifted tile are alpha 0. The hole does **not** + collapse the image (that is Cut). The torn-off tile is kept on the Clip + annotation (and in JSON). Replay applies cuts and clip fills in order and + recopies from the composed image only when that tile is missing or the + cut/clip prefix before the op changed, so later cuts cannot rewrite an + already-torn piece. `refreshComposedCapture()` uses the same path fill. + Undo a clip and both the hole and the layer disappear together. Live drag is + editor-only preview; the log is touched only on release. Shape is stored as + `shape` (`rect` omitted, `ellipse`, `lasso` plus `points`); fill as HexArgb + when opaque. Click-to-snap / Snap-on drag fits the current clip shape + (rounded rect, ellipse, or silhouette) with a one-lap scan-dot, then the + mask locks. Rect stores optional `radius`. - **Redaction** exists to permanently destroy sensitive content, so it is the one place where "non-destructive until export" would be a bug, not a feature: `renderCapture` applies redactions to the cropped pixels diff --git a/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md b/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md new file mode 100644 index 00000000..6796cd64 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md @@ -0,0 +1,193 @@ +# Clip-out shape masks + +Date: 2026-08-28 +Branch: `feat/select-clip-out` +Status: design locked; Fable + Sol P0s applied (2026-08-28) + +## Problem + +Clip-out today only locks a **rectangle**. Lifting DHH’s circular X avatar keeps the teal corners and punches a square hole. The job is to clip a **path** — rectangle, ellipse, or freehand lasso — see the hole before committing, then drag the masked pixels out as a layer with a real transparent (or solid) hole left behind. + +Click-to-snap is the fast path for that circular avatar: click the object, get an adjustable ellipse, then lift. + +## What this is not + +- Not a general drawing program, magic-wand editor, or SAM-style subject cutout. +- Not a new linked library or model. Qt6 painter paths, a bounded contour fit, and the existing palette/eyedropper. +- Not a collapse-the-gap tool. That remains Cut (`X`). +- Not an opacity slider. A hole is transparent or a solid colour. + +## User-visible behaviour + +Stay in **Select** (`V`). Empty-canvas interaction locks a **pixel mask**, not a vector annotation. + +1. Pick a clip shape (strip, keys, or cycle). +2. Draw it (or click to snap). A **dotted outline** and **handles** appear. Outside the path is dimmed; inside previews the hole fill (checkerboard if transparent, the swatch if solid). +3. Adjust handles. Change fill from the keyboard. The hole updates live. +4. Grab **inside** the path and **drag to lift**. Release near the hole to snap back (no log entry). Release elsewhere to commit one Clip op: punch/fill the hole, add a `Annotation::Kind::Clip` layer whose bitmap has alpha outside the path. +5. Repeat. `Ctrl+Z` undoes hole and layer together. `Esc` cancels an uncommitted mask. + +Shift while drawing an ellipse still forces 1:1 (a circle), matching existing shape tools. Alt still draws from the centre. + +### Shape strip and keys (Fable/Sol P0) + +`R`, `E`, and `F` **keep arming the drawing tools on first press**, including the selected-shape fill toggle. Clip shape is **not** stolen from those keys. That is the Spotlight/Highlighter pattern: first press arms the tool; a second press on an already-armed tool cycles a variant. Select is already armed, so **`V` while in Select cycles the clip-shape variant.** + +While Select is the current tool, a strip sits **above the toolbar**: + +| Chip | How you pick it | Tooltip | +|---|---|---| +| Rect | `V` cycle / chip | Clip rectangle · V cycles · empty drag with no layers | +| Ellipse | `V` cycle / chip | Clip ellipse · V cycles · empty drag with no layers | +| Lasso | `V` cycle / chip | Clip lasso · V cycles · empty drag with no layers | +| Snap | chip toggle | Snap on/off · **on by default** · click an object (or refine a locked mask) | + +`V` from any other tool enters Select on **Rect** (today’s default). `V` (and a second click of the Select toolbar button) **while already in Select** cycles Rect → Ellipse → Lasso → Rect. Snap is **not** in that cycle: it is an independent toggle, **on when Select starts**, so you can still draw Rect / Ellipse / Lasso with snap armed. Status pill names the shape and whether snap is on. + +The **bottom-left hotkey legend** gains the clip rows while Select is on. Hover tooltips on the chips match those rows. No settings UI. + +### Gestures (keep today’s Select arbitration) + +Empty-canvas interaction is still **layer marquee first**: + +1. A drag whose normalised rect is at least 2×2 and **encloses one or more layers** multi-selects those layers, **regardless of clip shape**. No pixel mask. For lasso, that rect is the **path bounding box**, not the start–end fidget. +2. The same drag with **no layers inside** locks a pixel mask in the current geometric shape (Rect / Ellipse / Lasso). Lasso keeps the traced vertices; a closed stroke that returns near the start still locks. +3. A press-and-release that never reaches a large-enough path/box is a **click**: it deselects, dismisses an uncommitted mask if the click is outside it, or **runs Snap when the Snap toggle is on** (and the click hits no layer). +4. **Snap while tracing:** the drag is the search window. Paper is sampled just **outside** that box; inside it, the connected object under the pointer grows 8-connected (so a checkerboard wheel is one blob, not one square) and a 1 px close fills dark cells. **Ellipse + Snap** then fits a circle that **covers that blob** (a wheel with a pin is enclosed, not cropped). Without a drag, click-to-snap still votes for a consistent radius so a round portrait on a card stays a circle, but keeps a small protrusion if the blob is only a bit larger than that circle. **Rect + Snap** uses the blob's AABB and its corner radius (a Messenger card locks a rounded rect on screen, not a sharp box). **Lasso + Snap** uses the blob silhouette. A blue scan-dot traces the outline, then the mask locks. Crop handles hide while that mask is locked. +5. `Alt+click` on empty canvas also snaps (modifier explicit), even if the toggle is off. Turning Snap on while a mask is locked refines from the mask centre. + +Arming any drawing tool **clears** an uncommitted mask. Esc clears hex-entry first if it is open, then the mask, then Select, then the editor — same layered Esc as today. + +### Fill (hole colour) + +The clip fill fly-out is transparent + **match surroundings** (median colour just outside the mask) + palette + custom + **Sample from image** (same eyedropper as the toolbar). Default fill is **match surroundings** when a ring of page pixels exists, else transparent. + +Fill keys apply **only while a dotted mask is locked** (Select or the clip eyedropper). Idle Select does not steal them: `T` still starts Text, `1`–`8` still set annotation colour. Arming a drawing tool clears the uncommitted mask. `I` while locked samples a fill colour **without** leaving Select (the lock stays visible). `#` opens a hex-entry field: digits go to the field, Enter commits, first Esc cancels typing only. + +| Key | While a dotted mask is locked | Otherwise | +|---|---|---| +| `T` | Cycle hole fill: surroundings → Sample from image → transparent → surroundings | Text tool, unchanged | +| `1`–`8` | Palette fill (already wired on the rect PR) | Annotation colour, unchanged | +| `I` | Eyedropper, sample the screenshot | Eyedropper for annotation colour, unchanged | +| `#` | Type `#RRGGBB` (optional `#RGB`); Enter commits, Esc cancels typing | Ignored | + +No `#AARRGGBB` and no alpha slider. No settings UI for these: Snap on and surroundings fill are the product defaults. `Esc` (or a snap-back lift) clears the mask; `T` is Text again. + +Live preview: the interior of the path shows the fill immediately (checkerboard for transparent). Status names it (`Hole fill transparent · drag inside to clip out` / `Hole fill #E03131 · drag inside to clip out`). + +## Engine + +Live drag remains editor-only. The log is touched only on release that does not snap back. One `Operation::Type::Clip`. Undo/redo and recents-shelf replay stay exact because the op still reconstructs both hole and layer. + +### `ClipOp` + +Replace “always a rectangle” with a shape. Native pixels of the composed image at apply-time, same contract as Cut/clip today: + +``` +enum class ClipShape { Rect, Ellipse, Lasso }; + +struct ClipOp { + ClipShape shape = ClipShape::Rect; + QRect sourceRect; // bbox; for Rect/Ellipse this is the shape + QVector points; // Lasso vertices in native space; empty otherwise + // Integer bbox is derived; quantize only when rasterizing + QColor fill; // invalid or alpha 0 = transparent punch +}; +``` + +Snap is **not** a fourth shape. It produces an Ellipse `ClipOp` (and an ellipse mask the user can still resize) before lift. + +JSON (`type: "clip"`): + +- `sourceRect` as today `[x,y,w,h]` +- `shape`: `"rect"` | `"ellipse"` | `"lasso"` — omitted means `rect` +- `points`: array of `[x,y]` floats for lasso only +- `fill`: HexArgb when opaque, omitted when transparent +- `annotation`: dest rect of the lifted layer, as today + +No migration shim. Omitted `shape` reads as rect so a working snapshot from the rect-only clip PR still reopens. + +### Copy, punch, lift + +New helpers in `src/clip.cpp` (keep `copyRect` / `punchRect` / `fillHole` as the rect special case, or thin wrappers): + +- `clipPath(const ClipOp &) → QPainterPath` in native space. +- `copyMasked(source, op) → QImage` of `sourceRect` intersected with the image, Format_ARGB32_Premultiplied, pixels **outside** the path alpha 0. +- `fillHole(image, op)` fills the path with `fill`, or punches transparent when `!clipFillOpaque(fill)`. + +The lifted `Annotation::Kind::Clip` **image** is that masked tile. `start`/`end` are the dest bbox in annotation space (same as today). Replay copies with `copyMasked` from the composed source **at that op**, then `fillHole` on the composed image, then attaches the tile. Later cuts cannot rewrite an already-torn piece. + +Lasso close: if the pointer is near the start point on release, close; otherwise close with a straight segment. Degenerate paths (empty, < 3 points, zero area) are a no-op, no log entry. + +Lasso **adjust** after lock is the eight bbox handles (scale/translate the polygon). No vertex editing. + +### Click-to-snap + +Naive flood-fill from a face click selects cheek, not the circular avatar. Snap must find the **object boundary**, not the local colour. + +Algorithm, Qt only, **axis-aligned ellipse**, cheap enough for the UI thread (no contour extraction): + +1. Map the click to native pixels. +2. Cast **36 rays** from the click, evenly spaced, up to 256 px or the image edge. +3. Along each ray, take the first pixel whose luma differs from the previous sample by more than 28 (of 255), or whose 3×3 neighbour contrast spikes. Record that point. +4. Need at least 12 hit rays. Fit an **axis-aligned ellipse** as the bounding ellipse of those points (min/max x/y). If width/height ratio is within 1.12, use a circle (1:1). +5. Reject if the ellipse covers more than 70% of the screenshot, has a radius under 6 px, or the click is not inside it. Status: `Nothing to snap · drag a shape instead`. No mask, no log entry. +6. Lock an Ellipse mask with handles. + +Do not add OpenCV, ONNX, or a model. If a later measurement shows a hitch, move this to `QtConcurrent` with a generation token and discard stale results; do not start there. + +`copyMasked` stamps any **redaction annotations already in the replayed list at this op** onto the copied tile before it becomes a layer, so a clip cannot lift pixels that a prior redaction already destroyed. `refreshComposedCapture()` must call the same `fillHole(image, ClipOp)` as `replayLog()` so Cut preview cannot reconstruct an ellipse/lasso as a rectangle. + +### Threading + +Unchanged: PNG encode, disk, `wl-copy` stay off the UI thread. Mask preview, handle math, and snap stay on the UI thread. Commit still appends one op and `replayLog()` rebuilds. + +## Editor integration + +- Pixel-clip state today is `pixelClipRect_` (logical QRectF). Generalise to a small editor-only struct: shape, logical rect, optional logical lasso points, fill, lift preview. Not a log entry until release. +- Handle hit-testing for ellipse uses the ellipse bbox handles (same eight as the current rect clip / ellipse annotations). Lasso uses the polygon’s bbox handles. +- Hole-fill fly-out stays next to the locked mask, including lasso/ellipse bboxes. +- `nativeClipRect` stays for mapping a logical rect to native. Lasso points map with the same floor/ceil scaling as cut/clip rects. + +## Tests + +Headless offscreen, in the existing smoke suite. Failures name the first wrong pixel. + +- **Rect** — existing `clip-smoke` / `clip-mapping-smoke` still pass. +- **Ellipse** — synthetic circle (opaque disk on a solid field). Clip the disk with an ellipse mask; corners of the bbox are transparent; hole in the source is transparent (or the chosen fill); lifted layer composite matches the disk. +- **Lasso** — triangle or irregular polygon; outside-path pixels in the tile are alpha 0; hole matches the path. +- **Snap** — fixture of a high-contrast circle (the DHH-avatar case, simplified). Click the centre; fitted ellipse covers the disk and not the field. Click empty field; no op, status set. +- **Undo** — ellipse clip then `Ctrl+Z` restores hole and drops the layer. +- **Fill keys** — with a locked mask, `T` sets transparent; `1` sets palette[0]; `#` + hex + Enter sets custom. After Esc (no mask), `T` arms the text tool. Idle Select without a mask: `T` must not set clip fill. +- **V-cycle** — `E` from idle Select still arms `Tool::Ellipse`. `V` then `V` cycles clip shape while `tool_ == Select` (Rect → Ellipse → Lasso → Rect). `R` with a rectangle layer selected still toggles fill. +- **Snap toggle** — Snap chip does not steal Rect/Ellipse/Lasso. Rect + Snap around a square locks a rectangle; around a rounded rect the tile corners are transparent. Ellipse + Snap around a disk locks an ellipse. Lasso + Snap around a disk locks a silhouette path. A closed lasso over stripes still keeps the hand-drawn path. +- **Lasso release** — a freehand that returns near its start keeps the path; lock uses the path bbox, not the start–end marquee. + +`make check` after the behavioural change. + +## Docs to update in the same work + +- `README.md` — clip-out bullet and the Select / `V` / `R` / `E` / `F` / `T` / `#` rows. +- `docs/editing-model.md` — Clip paragraph: path, alpha tile, snap → ellipse. +- `AGENTS.md` layout line for `src/clip.cpp` if the file overview changes. +- `docs/dependencies.md` — no new rows. If snap stays Qt-only it does not belong there. + +## Out of scope + +- Neural / SAM / OpenCV subject cutout. +- Vertex-level lasso editing. +- Animated marching ants (a dashed stroke is enough). +- Fill alpha other than 0 or 255. +- Compositor-specific snap (Wayland window outlines). Hyprland-only remains the product; this feature is screenshot pixels, not `hyprctl`. +- Changing Cut, redaction, or pin. + +## Files (implementation, not this spec) + +| File | Role | +|---|---| +| `src/clip.hpp` / `src/clip.cpp` | Shape, path, copyMasked, fillHole, snap-fit | +| `src/capture.hpp` / `src/capture.cpp` | JSON for shape + points | +| `src/editor.cpp` / `src/editor.hpp` | Strip, keys, legend, preview, lift | +| `tests/clip-smoke.cpp` | Engine pixels | +| `tests/clip-mapping-smoke.cpp` | Editor mapping, snap fixture, undo, keys | +| `README.md`, `docs/editing-model.md` | User-facing + log contract | diff --git a/src/capture.cpp b/src/capture.cpp index 8d43355d..12a15671 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -4,6 +4,7 @@ #include "startup-timing.hpp" #include +#include #include #include #include @@ -423,6 +424,19 @@ void drawAnnotation(QPainter &painter, const Annotation &annotation) { if (annotation.kind == Annotation::Kind::Redaction || annotation.kind == Annotation::Kind::Spotlight) return; + if (annotation.kind == Annotation::Kind::Clip) { + if (annotation.image.isNull()) + return; + const QRectF bounds = + QRectF(annotation.start, annotation.end).normalized(); + if (bounds.width() < 1.0 || bounds.height() < 1.0) + return; + painter.save(); + painter.setRenderHint(QPainter::SmoothPixmapTransform, false); + painter.drawImage(bounds, annotation.image); + painter.restore(); + return; + } const qreal width = std::max(2.0, annotation.size); QPen pen(annotation.color, width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); @@ -1548,6 +1562,8 @@ QString annotationToolName(Annotation::Kind kind) { return QStringLiteral("redaction"); case Annotation::Kind::Spotlight: return QStringLiteral("spotlight"); + case Annotation::Kind::Clip: + return QStringLiteral("clip"); } return QStringLiteral("arrow"); } @@ -1573,6 +1589,8 @@ bool annotationKindFromName(const QString &name, Annotation::Kind &kind) { kind = Annotation::Kind::Redaction; else if (name == QStringLiteral("spotlight")) kind = Annotation::Kind::Spotlight; + else if (name == QStringLiteral("clip")) + kind = Annotation::Kind::Clip; else return false; return true; @@ -1719,6 +1737,14 @@ QJsonObject annotationToJson(const Annotation &annotation) { ? QStringLiteral("rounded") : QStringLiteral("ellipse")); } + if (annotation.kind == Annotation::Kind::Clip && !annotation.image.isNull()) { + QByteArray bytes; + QBuffer buffer(&bytes); + buffer.open(QIODevice::WriteOnly); + annotation.image.save(&buffer, "PNG"); + object.insert(QStringLiteral("png"), + QString::fromLatin1(bytes.toBase64())); + } return object; } @@ -1768,6 +1794,12 @@ bool annotationFromJson(const QJsonObject &object, Annotation &annotation, : spotlightShape == QStringLiteral("rounded") ? SpotlightShape::RoundedRectangle : SpotlightShape::Ellipse; + if (annotation.kind == Annotation::Kind::Clip) { + const QByteArray png = QByteArray::fromBase64( + object.value(QStringLiteral("png")).toString().toLatin1()); + if (!png.isEmpty()) + annotation.image.loadFromData(png, "PNG"); + } return true; } @@ -1822,6 +1854,33 @@ QJsonObject operationToJson(const Operation &operation) { object.insert(QStringLiteral("logicalStart"), operation.cut.logicalStart); object.insert(QStringLiteral("logicalEnd"), operation.cut.logicalEnd); break; + case Operation::Type::Clip: { + object.insert(QStringLiteral("type"), QStringLiteral("clip")); + object.insert(QStringLiteral("sourceRect"), + QJsonArray{operation.clip.sourceRect.x(), + operation.clip.sourceRect.y(), + operation.clip.sourceRect.width(), + operation.clip.sourceRect.height()}); + if (operation.clip.shape != ClipShape::Rect) + object.insert(QStringLiteral("shape"), + clipShapeName(operation.clip.shape)); + if (operation.clip.shape == ClipShape::Lasso && + !operation.clip.points.isEmpty()) { + QJsonArray points; + for (const QPointF &point : operation.clip.points) + points.push_back(QJsonArray{point.x(), point.y()}); + object.insert(QStringLiteral("points"), points); + } + if (operation.clip.shape == ClipShape::Rect && operation.clip.radius >= 1.0) + object.insert(QStringLiteral("radius"), operation.clip.radius); + if (clipFillOpaque(operation.clip.fill)) + object.insert(QStringLiteral("fill"), + operation.clip.fill.name(QColor::HexArgb)); + if (!operation.annotations.isEmpty()) + object.insert(QStringLiteral("annotation"), + annotationToJson(operation.annotations.constFirst())); + break; + } } return object; } @@ -1896,6 +1955,37 @@ bool operationFromJson(const QJsonObject &object, Operation &operation, object.value(QStringLiteral("logicalEnd")).toInt(); return true; } + if (type == QStringLiteral("clip")) { + operation.type = Operation::Type::Clip; + const QRectF source = rectFromArray(object.value(QStringLiteral("sourceRect"))); + operation.clip.sourceRect = source.toRect(); + if (!clipShapeFromName(object.value(QStringLiteral("shape")).toString(), + operation.clip.shape)) { + error = QStringLiteral("Operation log has an unknown clip shape"); + return false; + } + for (const QJsonValue value : + object.value(QStringLiteral("points")).toArray()) { + const QJsonArray pair = value.toArray(); + if (pair.size() >= 2) + operation.clip.points.push_back( + QPointF(pair.at(0).toDouble(), pair.at(1).toDouble())); + } + const QString fill = object.value(QStringLiteral("fill")).toString(); + if (!fill.isEmpty()) + operation.clip.fill = QColor(fill); + operation.clip.radius = + object.value(QStringLiteral("radius")).toDouble(0.0); + if (object.contains(QStringLiteral("annotation"))) { + Annotation annotation; + if (!annotationFromJson( + object.value(QStringLiteral("annotation")).toObject(), annotation, + error)) + return false; + operation.annotations = {annotation}; + } + return true; + } error = QStringLiteral("Operation log has an unknown operation type"); return false; } diff --git a/src/capture.hpp b/src/capture.hpp index 108b0b4e..19de01d0 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -1,6 +1,7 @@ /** @fileoverview Declares screenshot capture, rendering, and output types. */ #pragma once +#include "clip.hpp" #include "cut.hpp" #include @@ -72,7 +73,8 @@ struct Annotation { Ellipse, Text, Redaction, - Spotlight + Spotlight, + Clip }; Kind kind = Kind::Arrow; @@ -93,6 +95,10 @@ struct Annotation { /// Typeface is a layer property so reopened and duplicated labels keep it. TextFont textFont = TextFont::Neucha; quint64 id = 0; + /// Pixels of a clip layer, reconstructed on replay from the Clip op (or + /// loaded from a duplicated layer's PNG). Not a drawing-app raster: it is + /// the torn-off screenshot region. Implicitly shared. + QImage image; bool operator==(const Annotation &) const = default; }; @@ -105,7 +111,8 @@ struct Operation { Annotate, Patch, Delete, - Cut + Cut, + Clip }; Type type = Type::Annotate; @@ -116,6 +123,7 @@ struct Operation { QVector annotations; QVector ids; CutOp cut; + ClipOp clip; bool operator==(const Operation &) const = default; }; diff --git a/src/clip.cpp b/src/clip.cpp new file mode 100644 index 00000000..8c0a6097 --- /dev/null +++ b/src/clip.cpp @@ -0,0 +1,805 @@ +/** @fileoverview Clip-out engine: copy a path and punch a hole. */ +#include "clip.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void ensurePremultiplied(QImage &image) { + if (image.format() != QImage::Format_ARGB32 && + image.format() != QImage::Format_ARGB32_Premultiplied) + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); +} + +QRectF nativeScale(QSize preview, QSize source) { + if (!preview.isValid() || preview.width() <= 0 || preview.height() <= 0 || + !source.isValid() || source.width() <= 0 || source.height() <= 0) + return {}; + return QRectF(0, 0, source.width() / static_cast(preview.width()), + source.height() / static_cast(preview.height())); +} + +int lumaAt(const QImage &image, int x, int y) { + return qGray(image.pixel(x, y)); +} + +int channelDist(QRgb a, QRgb b) { + return std::max({std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), + std::abs(qBlue(a) - qBlue(b))}); +} + +QRgb samplePaper(const QImage &img, const QRect &roi, bool sampleOutside) { + QVector samples; + samples.reserve(128); + const auto add = [&](int x, int y) { + if (img.rect().contains(x, y)) + samples.push_back(img.pixel(x, y)); + }; + if (sampleOutside) { + const QRect outer = roi.adjusted(-2, -2, 2, 2).intersected(img.rect()); + for (int y = outer.top(); y <= outer.bottom(); ++y) { + for (int x = outer.left(); x <= outer.right(); ++x) { + if (!roi.contains(x, y)) + add(x, y); + } + } + } + if (samples.size() < 8) { + const int xStep = std::max(1, roi.width() / 8); + const int yStep = std::max(1, roi.height() / 8); + for (int x = roi.left(); x <= roi.right(); x += xStep) { + add(x, roi.top()); + add(x, roi.bottom()); + } + for (int y = roi.top(); y <= roi.bottom(); y += yStep) { + add(roi.left(), y); + add(roi.right(), y); + } + } + if (samples.isEmpty()) + return qRgb(0, 0, 0); + std::sort(samples.begin(), samples.end(), + [](QRgb a, QRgb b) { return qGray(a) < qGray(b); }); + return samples.at(samples.size() / 2); +} + +struct ObjectBlob { + QRect box; + QRect roi; + QImage mask; +}; + +constexpr int kSeedDx[8] = {1, -1, 0, 0, 1, 1, -1, -1}; +constexpr int kSeedDy[8] = {0, 0, 1, -1, 1, -1, 1, -1}; + +void closeMask(QImage &mask) { + const int w = mask.width(); + const int h = mask.height(); + if (w < 1 || h < 1) + return; + QImage dilated = mask; + for (int y = 0; y < h; ++y) { + uchar *out = dilated.scanLine(y); + for (int x = 0; x < w; ++x) { + if (mask.constScanLine(y)[x] != 0) + continue; + for (int i = 0; i < 8; ++i) { + const int nx = x + kSeedDx[i]; + const int ny = y + kSeedDy[i]; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) + continue; + if (mask.constScanLine(ny)[nx] != 0) { + out[x] = 255; + break; + } + } + } + } + mask.fill(0); + for (int y = 0; y < h; ++y) { + uchar *out = mask.scanLine(y); + for (int x = 0; x < w; ++x) { + if (dilated.constScanLine(y)[x] == 0) + continue; + bool all = true; + for (int i = 0; i < 8; ++i) { + const int nx = x + kSeedDx[i]; + const int ny = y + kSeedDy[i]; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) + continue; + if (dilated.constScanLine(ny)[nx] == 0) { + all = false; + break; + } + } + if (all) + out[x] = 255; + } + } +} + +QRect maskBounds(const QImage &mask, const QRect &roi) { + int minX = roi.right(); + int maxX = roi.left(); + int minY = roi.bottom(); + int maxY = roi.top(); + bool any = false; + for (int y = 0; y < mask.height(); ++y) { + const uchar *row = mask.constScanLine(y); + for (int x = 0; x < mask.width(); ++x) { + if (row[x] == 0) + continue; + any = true; + minX = std::min(minX, roi.left() + x); + maxX = std::max(maxX, roi.left() + x); + minY = std::min(minY, roi.top() + y); + maxY = std::max(maxY, roi.top() + y); + } + } + if (!any) + return {}; + return QRect(QPoint(minX, minY), QPoint(maxX, maxY)); +} + +std::optional findObjectSeed(const QImage &img, QPoint click, + const QRect &roi, const QRgb paper, + int enter, bool userRoi) { + const auto dist = [&](int x, int y) { + return channelDist(img.pixel(x, y), paper); + }; + if (dist(click.x(), click.y()) > enter) + return click; + // Click-snap only nudges onto a neighboring checker/AA pixel. A drag + // searches the whole box so the object can sit off the drag centre. + const int maxR = + userRoi ? std::max(roi.width(), roi.height()) : 4; + for (int r = 1; r <= maxR; ++r) { + for (int y = click.y() - r; y <= click.y() + r; ++y) { + for (int x = click.x() - r; x <= click.x() + r; ++x) { + if (std::max(std::abs(x - click.x()), std::abs(y - click.y())) != r) + continue; + if (!roi.contains(x, y)) + continue; + if (dist(x, y) > enter) + return QPoint(x, y); + } + } + } + return std::nullopt; +} + +std::optional fillObjectBlob(const QImage &img, QPoint click, + QRect roi, bool userRoi) { + constexpr int kEnter = 12; + roi = roi.intersected(img.rect()); + if (roi.width() < 6 || roi.height() < 6 || !roi.contains(click)) + return std::nullopt; + const QRgb paper = samplePaper(img, roi, userRoi); + const auto dist = [&](int x, int y) { + return channelDist(img.pixel(x, y), paper); + }; + const std::optional seed = + findObjectSeed(img, click, roi, paper, kEnter, userRoi); + if (!seed) + return std::nullopt; + QImage mask(roi.size(), QImage::Format_Grayscale8); + mask.fill(0); + std::queue pending; + const auto mark = [&](int x, int y) { + const int lx = x - roi.left(); + const int ly = y - roi.top(); + if (mask.scanLine(ly)[lx] != 0) + return false; + mask.scanLine(ly)[lx] = 255; + pending.push(QPoint(x, y)); + return true; + }; + mark(seed->x(), seed->y()); + while (!pending.empty()) { + const QPoint p = pending.front(); + pending.pop(); + for (int i = 0; i < 8; ++i) { + const int x = p.x() + kSeedDx[i]; + const int y = p.y() + kSeedDy[i]; + if (!roi.contains(x, y)) + continue; + if (dist(x, y) <= kEnter) + continue; + mark(x, y); + } + } + const QImage flooded = mask; + const QRect floodBox = maskBounds(flooded, roi); + closeMask(mask); + QRect box = maskBounds(mask, roi); + // 1 px close fills checker holes. If it also swallows a nearby glyph, + // keep the flood so Rect snap can still see the card's corner bites. + const bool merged = + !floodBox.isEmpty() && + (box.left() < floodBox.left() - 2 || box.top() < floodBox.top() - 2 || + box.right() > floodBox.right() + 2 || + box.bottom() > floodBox.bottom() + 2); + if (merged) { + mask = flooded; + box = floodBox; + } + box = box.intersected(img.rect()); + if (box.width() < 6 || box.height() < 6) + return std::nullopt; + ObjectBlob blob; + blob.box = box; + blob.roi = roi; + blob.mask = std::move(mask); + return blob; +} + +bool maskAt(const ObjectBlob &blob, int x, int y) { + if (!blob.roi.contains(x, y)) + return false; + return blob.mask.constScanLine(y - blob.roi.top())[x - blob.roi.left()] != 0; +} + +std::optional blobEnclosingCircle(const ObjectBlob &blob, + QSize sourceSize) { + const QPointF origin = blob.box.center(); + qreal maxR = 0.0; + for (int y = blob.box.top(); y <= blob.box.bottom(); ++y) { + for (int x = blob.box.left(); x <= blob.box.right(); ++x) { + if (!maskAt(blob, x, y)) + continue; + maxR = std::max(maxR, QLineF(origin, QPointF(x, y)).length()); + } + } + if (maxR < 6.0) + return std::nullopt; + const int r = std::max(6, static_cast(std::ceil(maxR))); + const int cx = static_cast(std::lround(origin.x())); + const int cy = static_cast(std::lround(origin.y())); + QRect circle(QPoint(cx - r, cy - r), QPoint(cx + r, cy + r)); + circle = circle.intersected(QRect(QPoint(), sourceSize)); + const qreal cover = static_cast(circle.width()) * circle.height() / + std::max(1, sourceSize.width() * sourceSize.height()); + if (circle.width() < 6 || circle.height() < 6 || cover > 0.70) + return std::nullopt; + return circle; +} + +} // namespace + +QColor sampleClipSurroundings(const QImage &source, QRect nativeRect) { + if (source.isNull()) + return {}; + nativeRect = nativeRect.intersected(source.rect()); + if (nativeRect.isEmpty()) + return {}; + const QImage img = source.convertToFormat(QImage::Format_ARGB32); + QVector samples; + samples.reserve(128); + const QRect outer = + nativeRect.adjusted(-3, -3, 3, 3).intersected(img.rect()); + for (int y = outer.top(); y <= outer.bottom(); ++y) { + for (int x = outer.left(); x <= outer.right(); ++x) { + if (nativeRect.contains(x, y)) + continue; + samples.push_back(img.pixel(x, y)); + } + } + if (samples.size() < 8) + return {}; + std::sort(samples.begin(), samples.end(), + [](QRgb a, QRgb b) { return qGray(a) < qGray(b); }); + return QColor(samples.at(samples.size() / 2)); +} + +QPainterPath clipPath(const ClipOp &clip) { + QPainterPath path; + const QRect box = clip.sourceRect; + if (box.isEmpty()) + return path; + switch (clip.shape) { + case ClipShape::Ellipse: + path.addEllipse(QRectF(box)); + break; + case ClipShape::Lasso: { + if (clip.points.size() < 3) + return {}; + path.moveTo(clip.points.constFirst()); + for (int i = 1; i < clip.points.size(); ++i) + path.lineTo(clip.points.at(i)); + path.closeSubpath(); + break; + } + case ClipShape::Rect: + if (clip.radius >= 1.0) { + const qreal r = + std::min({clip.radius, box.width() / 2.0, box.height() / 2.0}); + path.addRoundedRect(QRectF(box), r, r); + } else { + path.addRect(QRectF(box)); + } + break; + } + return path; +} + +QImage copyRect(const QImage &source, QRect sourceRect) { + if (source.isNull()) + return {}; + sourceRect = sourceRect.intersected(source.rect()); + if (sourceRect.isEmpty()) + return {}; + return source.copy(sourceRect); +} + +QImage copyMasked(const QImage &source, const ClipOp &clip) { + QImage tile = copyRect(source, clip.sourceRect); + if (tile.isNull()) + return {}; + if (clip.shape == ClipShape::Rect && clip.radius < 1.0) + return tile; + ensurePremultiplied(tile); + QImage mask(tile.size(), QImage::Format_ARGB32_Premultiplied); + mask.fill(Qt::transparent); + QPainter maskPainter(&mask); + maskPainter.setRenderHint(QPainter::Antialiasing, true); + maskPainter.setPen(Qt::NoPen); + maskPainter.setBrush(Qt::white); + QPainterPath path = clipPath(clip); + path.translate(-clip.sourceRect.topLeft()); + maskPainter.drawPath(path); + maskPainter.end(); + QPainter painter(&tile); + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawImage(0, 0, mask); + return tile; +} + +void punchRect(QImage &image, QRect sourceRect) { + if (image.isNull()) + return; + sourceRect = sourceRect.intersected(image.rect()); + if (sourceRect.isEmpty()) + return; + ensurePremultiplied(image); + QPainter painter(&image); + painter.setCompositionMode(QPainter::CompositionMode_Clear); + painter.fillRect(sourceRect, Qt::transparent); +} + +void fillHole(QImage &image, QRect sourceRect, const QColor &fill) { + fillHole(image, ClipOp{ClipShape::Rect, sourceRect, {}, fill}); +} + +void fillHole(QImage &image, const ClipOp &clip) { + if (image.isNull()) + return; + const QRect box = clip.sourceRect.intersected(image.rect()); + if (box.isEmpty()) + return; + const QPainterPath path = clipPath(clip); + if (path.isEmpty()) + return; + ensurePremultiplied(image); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(Qt::NoPen); + if (!clipFillOpaque(clip.fill)) { + painter.setCompositionMode(QPainter::CompositionMode_Clear); + painter.fillPath(path, Qt::transparent); + return; + } + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.fillPath(path, clip.fill); +} + +QRect nativeClipRect(QRectF logical, QSize preview, QSize source) { + logical = logical.normalized(); + if (logical.isEmpty() || !preview.isValid() || preview.width() <= 0 || + preview.height() <= 0 || !source.isValid() || source.width() <= 0 || + source.height() <= 0) + return {}; + const qreal scaleX = + source.width() / static_cast(preview.width()); + const qreal scaleY = + source.height() / static_cast(preview.height()); + const int left = static_cast(std::floor(logical.left() * scaleX)); + const int top = static_cast(std::floor(logical.top() * scaleY)); + const int right = static_cast(std::ceil(logical.right() * scaleX)); + const int bottom = static_cast(std::ceil(logical.bottom() * scaleY)); + const QRect native(QPoint(left, top), QPoint(right - 1, bottom - 1)); + return native.intersected(QRect(QPoint(), source)); +} + +QPointF nativeClipPoint(QPointF logical, QSize preview, QSize source) { + const QRectF scale = nativeScale(preview, source); + if (scale.isEmpty()) + return {}; + return QPointF(logical.x() * scale.width(), logical.y() * scale.height()); +} + +ClipOp nativeClipOp(ClipShape shape, QRectF logical, + const QVector &logicalPoints, QSize preview, + QSize source, const QColor &fill, qreal logicalRadius) { + ClipOp clip; + clip.shape = shape; + clip.fill = fill; + if (logicalRadius > 0.0 && preview.width() > 0 && preview.height() > 0) { + const qreal scale = + (source.width() / static_cast(preview.width()) + + source.height() / static_cast(preview.height())) / + 2.0; + clip.radius = logicalRadius * scale; + } + if (shape == ClipShape::Lasso) { + clip.points.reserve(logicalPoints.size()); + QRectF bounds; + for (const QPointF &point : logicalPoints) { + const QPointF native = nativeClipPoint(point, preview, source); + clip.points.push_back(native); + if (bounds.isNull()) + bounds = QRectF(native, QSizeF(0.01, 0.01)); + else + bounds |= QRectF(native, QSizeF(0.01, 0.01)); + } + if (!bounds.isEmpty()) { + const int left = static_cast(std::floor(bounds.left())); + const int top = static_cast(std::floor(bounds.top())); + const int right = static_cast(std::ceil(bounds.right())); + const int bottom = static_cast(std::ceil(bounds.bottom())); + clip.sourceRect = + QRect(QPoint(left, top), QPoint(right - 1, bottom - 1)) + .intersected(QRect(QPoint(), source)); + } + return clip; + } + clip.sourceRect = nativeClipRect(logical, preview, source); + return clip; +} + +std::optional snapObject(const QImage &source, QPoint click, + QRect roi) { + if (source.isNull() || !source.rect().contains(click)) + return std::nullopt; + const QImage img = source.convertToFormat(QImage::Format_ARGB32); + constexpr int kRays = 48; + const bool userRoi = !roi.isEmpty(); + if (roi.isEmpty()) + roi = QRect(click.x() - 256, click.y() - 256, 513, 513); + const std::optional blob = fillObjectBlob(img, click, roi, userRoi); + if (!blob) + return std::nullopt; + const QRect box = blob->box; + const qreal cover = static_cast(box.width()) * box.height() / + std::max(1, source.width() * source.height()); + if (cover > 0.70) + return std::nullopt; + const int maxR = std::min(box.width(), box.height()) / 2; + const auto inset = [&](int x0, int y0, int dx, int dy) { + int i = 0; + while (i < maxR && !maskAt(*blob, x0 + i * dx, y0 + i * dy)) + ++i; + return i; + }; + const auto cornerRadius = [&](int x0, int y0, int inx, int iny) { + int sx = x0; + int sy = y0; + for (int t = 0; t < 3 && maskAt(*blob, sx, sy); ++t) { + sx += inx; + sy += iny; + } + return std::min(inset(sx, sy, inx, 0), inset(sx, sy, 0, iny)); + }; + int radii[4] = { + cornerRadius(box.left(), box.top(), 1, 1), + cornerRadius(box.right(), box.top(), -1, 1), + cornerRadius(box.left(), box.bottom(), 1, -1), + cornerRadius(box.right(), box.bottom(), -1, -1)}; + std::sort(radii, radii + 4); + int strong = 0; + for (int r : radii) { + if (r >= 3) + ++strong; + } + qreal radius = 0.0; + if (strong >= 2) + radius = radii[2]; + else if (radii[1] >= 3) + radius = radii[1]; + + QPoint origin = click; + if (!maskAt(*blob, origin.x(), origin.y())) + origin = box.center(); + ClipSnapHit hit; + hit.box = box; + hit.radius = radius; + hit.contour.reserve(kRays); + const int maxS = std::max(box.width(), box.height()) + 2; + for (int i = 0; i < kRays; ++i) { + const qreal ang = static_cast(i) * (2.0 * M_PI / kRays); + const qreal dx = std::cos(ang); + const qreal dy = std::sin(ang); + QPointF last(origin); + bool sawInside = maskAt(*blob, origin.x(), origin.y()); + for (int s = 1; s <= maxS; ++s) { + const int x = origin.x() + static_cast(std::lround(dx * s)); + const int y = origin.y() + static_cast(std::lround(dy * s)); + if (!blob->roi.contains(x, y)) + break; + if (maskAt(*blob, x, y)) { + last = QPointF(x, y); + sawInside = true; + } else if (sawInside) { + break; + } + } + if (sawInside) + hit.contour.push_back(last); + } + if (hit.contour.size() < 8) + hit.contour.clear(); + return hit; +} + +std::optional snapEllipseRect(const QImage &source, QPoint click, + QRect roi) { + if (source.isNull() || !source.rect().contains(click)) + return std::nullopt; + const QImage img = source.convertToFormat(QImage::Format_ARGB32); + constexpr int kRays = 36; + constexpr int kThresh = 28; + constexpr int kMinInliers = 16; + constexpr qreal kTol = 0.14; + const bool userRoi = !roi.isEmpty(); + if (roi.isEmpty()) + roi = QRect(click.x() - 256, click.y() - 256, 513, 513); + roi = roi.intersected(img.rect()); + if (userRoi) { + const std::optional blob = + fillObjectBlob(img, click, roi, true); + if (!blob) + return std::nullopt; + return blobEnclosingCircle(*blob, source.size()); + } + const int maxS = std::min( + 256, std::max(roi.width(), roi.height())); + QVector> jumps(kRays); + QVector all; + all.reserve(kRays * 4); + for (int i = 0; i < kRays; ++i) { + const qreal ang = static_cast(i) * (2.0 * M_PI / kRays); + const qreal dx = std::cos(ang); + const qreal dy = std::sin(ang); + int prev = lumaAt(img, click.x(), click.y()); + for (int s = 4; s <= maxS; ++s) { + const int x = click.x() + static_cast(std::lround(dx * s)); + const int y = click.y() + static_cast(std::lround(dy * s)); + if (!roi.contains(x, y) || !img.rect().contains(x, y)) + break; + const int L = lumaAt(img, x, y); + if (std::abs(L - prev) > kThresh) { + jumps[i].push_back(s); + all.push_back(s); + } + prev = L; + } + } + int bestR = 0; + int bestN = 0; + for (int candidate : all) { + if (candidate < 6) + continue; + int n = 0; + const int lo = static_cast(std::floor(candidate * (1.0 - kTol))); + const int hi = static_cast(std::ceil(candidate * (1.0 + kTol))); + for (int i = 0; i < kRays; ++i) { + for (int s : jumps.at(i)) { + if (s >= lo && s <= hi) { + ++n; + break; + } + } + } + if (n > bestN) { + bestN = n; + bestR = candidate; + } + } + std::optional voted; + if (bestN >= kMinInliers && bestR >= 6) { + QRect circle(0, 0, bestR * 2, bestR * 2); + circle.moveCenter(click); + circle = circle.intersected(source.rect()); + if (circle.width() >= 6 && circle.height() >= 6 && circle.contains(click)) { + const qreal cover = static_cast(circle.width()) * circle.height() / + std::max(1, source.width() * source.height()); + if (cover <= 0.70) + voted = circle; + } + } + // Fallback: strongest jump per ray, AABB, circle-ify if near 1:1. + QVector hits; + hits.reserve(kRays); + for (int i = 0; i < kRays; ++i) { + const qreal ang = static_cast(i) * (2.0 * M_PI / kRays); + const qreal dx = std::cos(ang); + const qreal dy = std::sin(ang); + int prev = lumaAt(img, click.x(), click.y()); + int bestS = -1; + int bestD = 0; + for (int s = 4; s <= maxS; ++s) { + const int x = click.x() + static_cast(std::lround(dx * s)); + const int y = click.y() + static_cast(std::lround(dy * s)); + if (!roi.contains(x, y) || !img.rect().contains(x, y)) + break; + const int L = lumaAt(img, x, y); + const int d = std::abs(L - prev); + if (d > bestD) { + bestD = d; + bestS = s; + } + prev = L; + } + if (bestD > kThresh && bestS > 0) { + hits.push_back( + QPoint(click.x() + static_cast(std::lround(dx * bestS)), + click.y() + static_cast(std::lround(dy * bestS)))); + } + } + if (!voted && hits.size() >= 12) { + int minX = hits.constFirst().x(); + int maxX = minX; + int minY = hits.constFirst().y(); + int maxY = minY; + qreal meanR = 0.0; + for (const QPoint &hit : hits) { + minX = std::min(minX, hit.x()); + maxX = std::max(maxX, hit.x()); + minY = std::min(minY, hit.y()); + maxY = std::max(maxY, hit.y()); + meanR += QLineF(click, hit).length(); + } + meanR /= hits.size(); + qreal var = 0.0; + for (const QPoint &hit : hits) { + const qreal d = QLineF(click, hit).length() - meanR; + var += d * d; + } + const qreal rel = std::sqrt(var / hits.size()) / std::max(1.0, meanR); + QRect box(QPoint(minX, minY), QPoint(maxX, maxY)); + box = box.intersected(source.rect()); + if (rel <= 0.28 && meanR >= 6.0) { + const int side = static_cast(std::lround(meanR * 2.0)); + QRect circle(0, 0, side, side); + circle.moveCenter(click); + box = circle.intersected(source.rect()); + } else { + const qreal ratio = + box.width() / std::max(1.0, static_cast(box.height())); + if (ratio > 1.0 / 1.12 && ratio < 1.12) { + const int side = std::max(box.width(), box.height()); + QRect circle(0, 0, side, side); + circle.moveCenter(box.center()); + box = circle.intersected(source.rect()); + } + } + if (box.width() >= 6 && box.height() >= 6 && box.contains(click)) { + const qreal cover = static_cast(box.width()) * box.height() / + std::max(1, source.width() * source.height()); + if (cover <= 0.70) + voted = box; + } + } + const std::optional blob = + fillObjectBlob(img, click, roi, false); + if (blob) { + if (const std::optional covered = + blobEnclosingCircle(*blob, source.size())) { + if (!voted) + return covered; + if (covered->width() <= voted->width() * 3 / 2 && + covered->height() <= voted->height() * 3 / 2) + return covered; + // Checker noise votes a tiny radius; the blob is the real object. + if (voted->width() < blob->box.width() * 0.55 || + voted->height() < blob->box.height() * 0.55) + return covered; + } + } + return voted; +} + +std::optional snapRectRect(const QImage &source, QPoint click) { + const std::optional hit = snapObject(source, click); + if (!hit) + return std::nullopt; + QRect box = hit->box; + const qreal ratio = + box.width() / std::max(1.0, static_cast(box.height())); + if (ratio > 1.0 / 1.12 && ratio < 1.12) { + const int side = std::max(box.width(), box.height()); + QRect square(0, 0, side, side); + square.moveCenter(box.center()); + box = square.intersected(source.rect()); + } + return box; +} + +bool clipTraceSnapFits(const QRectF &drawn, const QRectF &snapped) { + const QRectF drag = drawn.normalized(); + const QRectF snap = snapped.normalized(); + if (drag.isEmpty() || snap.isEmpty()) + return false; + if (!drag.contains(snap.center())) + return false; + const qreal ratio = + snap.width() / std::max(1.0, snap.height()); + if (ratio < 1.0 / 1.35 || ratio > 1.35) + return false; + if (snap.width() < drag.width() * 0.4 || snap.height() < drag.height() * 0.4) + return false; + if (drag.width() < snap.width() * 0.6 || drag.height() < snap.height() * 0.6) + return false; + return true; +} + +bool clipRectTraceSnapFits(const QRectF &drawn, const QRectF &snapped) { + const QRectF drag = drawn.normalized(); + const QRectF snap = snapped.normalized(); + if (drag.isEmpty() || snap.isEmpty()) + return false; + if (!drag.contains(snap.center())) + return false; + if (snap.width() < drag.width() * 0.4 || snap.height() < drag.height() * 0.4) + return false; + if (drag.width() < snap.width() * 0.6 || drag.height() < snap.height() * 0.6) + return false; + const qreal dragRatio = drag.width() / std::max(1.0, drag.height()); + const qreal snapRatio = snap.width() / std::max(1.0, snap.height()); + const qreal aspect = dragRatio / std::max(0.001, snapRatio); + if (aspect < 0.5 || aspect > 2.0) + return false; + return true; +} + +qreal clipSnapEnterThreshold(qreal viewScale) { + return 14.0 / std::max(0.001, viewScale); +} + +qreal clipSnapLeaveThreshold(qreal viewScale) { + return 20.0 / std::max(0.001, viewScale); +} + +bool clipDestSnapped(const QRectF &dest, const QRectF &origin, + qreal threshold) { + const QPointF delta = dest.center() - origin.center(); + return std::hypot(delta.x(), delta.y()) <= threshold; +} + +QImage resolveClipTile(const QImage &composed, QRect sourceRect, + const QImage &existing, bool prefixChanged) { + return resolveClipTile(composed, + ClipOp{ClipShape::Rect, sourceRect, {}, {}}, existing, + prefixChanged); +} + +QImage resolveClipTile(const QImage &composed, const ClipOp &clip, + const QImage &existing, bool prefixChanged) { + if (!existing.isNull() && !prefixChanged) + return existing; + return copyMasked(composed, clip); +} diff --git a/src/clip.hpp b/src/clip.hpp new file mode 100644 index 00000000..c9970428 --- /dev/null +++ b/src/clip.hpp @@ -0,0 +1,176 @@ +/** @fileoverview Clip-out engine: copy a native-pixel path and punch a hole + * without collapsing the gap. + * + * Distinct from the Cut tool, which removes a band and shifts the rest. + * Coordinates follow Cut: `sourceRect` is the native-pixel bbox of the + * composed image as it existed when the clip was applied; replay applies + * ops in order so later cuts see the hole already punched. */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum class ClipShape { Rect, Ellipse, Lasso }; + +/** One applied clip. `sourceRect` is the integer bbox (inclusive top-left, + * usual QRect). For Rect and Ellipse that bbox *is* the shape. Lasso + * vertices live in `points` (native space, floating); the bbox is derived. + * An empty bbox, or one that misses the image, is a no-op. `fill` is the + * hole infill: default (invalid or alpha 0) punches transparent; a solid + * color paints that swatch into the hole. */ +struct ClipOp { + ClipShape shape = ClipShape::Rect; + QRect sourceRect; + QVector points; + QColor fill; + qreal radius = 0; // native px; Rect only, 0 is sharp + bool operator==(const ClipOp &) const = default; +}; + +/** Outer object under a click: AABB, optional corner radius, silhouette. */ +struct ClipSnapHit { + QRect box; + qreal radius = 0; + QVector contour; +}; + +[[nodiscard]] inline bool clipFillOpaque(const QColor &fill) { + return fill.isValid() && fill.alpha() > 0; +} + +/** Median colour of a thin ring just outside `nativeRect` — the page the + * clipped object sat on. Invalid when the rect has no outside samples. */ +[[nodiscard]] QColor sampleClipSurroundings(const QImage &source, + QRect nativeRect); + +[[nodiscard]] inline QString clipShapeName(ClipShape shape) { + switch (shape) { + case ClipShape::Ellipse: + return QStringLiteral("ellipse"); + case ClipShape::Lasso: + return QStringLiteral("lasso"); + case ClipShape::Rect: + break; + } + return QStringLiteral("rect"); +} + +[[nodiscard]] inline bool clipShapeFromName(const QString &name, + ClipShape &shape) { + if (name.isEmpty() || name == QStringLiteral("rect")) { + shape = ClipShape::Rect; + return true; + } + if (name == QStringLiteral("ellipse")) { + shape = ClipShape::Ellipse; + return true; + } + if (name == QStringLiteral("lasso")) { + shape = ClipShape::Lasso; + return true; + } + return false; +} + +[[nodiscard]] QPainterPath clipPath(const ClipOp &clip); + +/** Returns a copy of `sourceRect` intersected with `source`. Null when the + * intersection is empty. */ +[[nodiscard]] QImage copyRect(const QImage &source, QRect sourceRect); + +/** Copy of the clip bbox with pixels outside the path alpha 0. */ +[[nodiscard]] QImage copyMasked(const QImage &source, const ClipOp &clip); + +/** Punches `sourceRect` to transparent. Converts `image` to premultiplied + * ARGB when needed so the hole can reveal a backdrop. No-op on an empty + * intersection. */ +void punchRect(QImage &image, QRect sourceRect); + +/** Fills `sourceRect` with `fill`. Transparent / invalid `fill` punches a + * hole, same as `punchRect`. */ +void fillHole(QImage &image, QRect sourceRect, const QColor &fill); + +/** Fills the clip path. Transparent `fill` punches the path. */ +void fillHole(QImage &image, const ClipOp &clip); + +/** Maps a logical (preview) rectangle onto native source pixels. Floor the + * start and ceil the end so a drag covers every pixel it touches, matching + * the Cut tool's band mapping. */ +[[nodiscard]] QRect nativeClipRect(QRectF logical, QSize preview, QSize source); + +/** Maps one logical point onto native space (not quantized to a pixel until + * rasterization). */ +[[nodiscard]] QPointF nativeClipPoint(QPointF logical, QSize preview, + QSize source); + +/** Builds a native ClipOp from a logical shape. Lasso points are mapped + * individually; the bbox is their native bounds. */ +[[nodiscard]] ClipOp nativeClipOp(ClipShape shape, QRectF logical, + const QVector &logicalPoints, + QSize preview, QSize source, + const QColor &fill, qreal logicalRadius = 0); + +/** Connected object inside `roi` (the user's drag). Paper is sampled just + * outside that box when possible, then high-contrast pixels grow through + * weaker chrome so a gray card margin is not mistaken for the page. + * `roi` empty → 256 px around `click`. Rect uses `box`+`radius`; lasso uses + * `contour`. */ +[[nodiscard]] std::optional +snapObject(const QImage &source, QPoint click, QRect roi = {}); + +/** Circle-seeking snap from `click`. With a drag `roi`, this is the + * smallest circle that covers the connected object in that box (a wheel + * with a pin stays enclosed). With `roi` empty, rays vote for a consistent + * radius so a round crop on a rectangular card stays a circle. */ +[[nodiscard]] std::optional snapEllipseRect(const QImage &source, + QPoint click, + QRect roi = {}); + +/** Axis-aligned rectangle around `click`. Near-square results become 1:1. */ +[[nodiscard]] std::optional snapRectRect(const QImage &source, + QPoint click); + +/** True when a dragged Ellipse/Lasso is tracing a detected circle. + * The snap centre must sit inside the drag, the snap must be near-circular, + * and the two boxes must be within a tracing factor of each other. */ +[[nodiscard]] bool clipTraceSnapFits(const QRectF &drawn, const QRectF &snapped); + +/** True when a dragged Rect is tracing a detected rectangle/square. Same + * centre and size checks, but any aspect is allowed if it matches the drag. */ +[[nodiscard]] bool clipRectTraceSnapFits(const QRectF &drawn, + const QRectF &snapped); + +/** Annotation-space distance at which a lifted clip *enters* the snap zone + * (~14 widget px). `viewScale` is the editor's annotation-to-widget scale. + * Dest is never clamped to the hole while dragging; snap applies on release. */ +[[nodiscard]] qreal clipSnapEnterThreshold(qreal viewScale); + +/** Annotation-space distance at which a lifted clip *leaves* the snap zone + * (~20 widget px). Wider than enter so the snap ghost does not chatter. */ +[[nodiscard]] qreal clipSnapLeaveThreshold(qreal viewScale); + +[[nodiscard]] inline qreal clipSnapThreshold(qreal viewScale) { + return clipSnapEnterThreshold(viewScale); +} + +/** True when `dest` is close enough to `origin` that releasing should restore + * the hole instead of committing a new layer. */ +[[nodiscard]] bool clipDestSnapped(const QRectF &dest, const QRectF &origin, + qreal threshold); + +/** Tile for a Clip op. Keep `existing` (from beginClipLift / JSON png / a + * prior commit) unless it is null or the cut/clip prefix before that op + * changed. */ +[[nodiscard]] QImage resolveClipTile(const QImage &composed, QRect sourceRect, + const QImage &existing, bool prefixChanged); +[[nodiscard]] QImage resolveClipTile(const QImage &composed, const ClipOp &clip, + const QImage &existing, bool prefixChanged); diff --git a/src/editor.cpp b/src/editor.cpp index faa458a4..27592ebb 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -2,6 +2,7 @@ */ #include "editor.hpp" +#include "clip.hpp" #include "stitch.hpp" #include "icons.hpp" #include "eyedropper.hpp" @@ -27,7 +28,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -39,10 +42,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -254,7 +259,8 @@ bool hasEndpointHandles(Annotation::Kind kind) { kind == Annotation::Kind::Rectangle || kind == Annotation::Kind::Ellipse || kind == Annotation::Kind::Redaction || - kind == Annotation::Kind::Spotlight; + kind == Annotation::Kind::Spotlight || + kind == Annotation::Kind::Clip; } /// Any handle drag on a layer (as opposed to a move, or a capture crop). @@ -269,9 +275,70 @@ bool isBoxResize(CaptureEditor::Interaction interaction) { interaction <= CaptureEditor::Interaction::ResizeLeft; } +void paintCheckerboard(QPainter &painter, const QRectF &bounds, qreal cell) { + painter.fillRect(bounds, QColor(245, 245, 247)); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(170, 172, 178)); + const int columns = std::max(1, static_cast(std::ceil(bounds.width() / cell))); + const int rows = std::max(1, static_cast(std::ceil(bounds.height() / cell))); + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < columns; ++column) { + if (((row + column) & 1) == 0) + continue; + painter.fillRect(QRectF(bounds.left() + column * cell, + bounds.top() + row * cell, cell, cell), + QColor(170, 172, 178)); + } + } +} + /// How far from an edge a press still counts as grabbing it: wide enough to /// hit without aiming, since some layers are grabbable only by their border. constexpr qreal kEdgeGrabTolerance = 12.0; +/// Empty-marquee arming: a 2 px fidget must not become a clip. +constexpr qreal kMinPixelClipLogical = 12.0; +constexpr qreal kMinPixelClipWidget = 12.0; + +quint64 mixPrefix(quint64 hash, quint64 value) { + return (hash ^ value) * 1099511628211ull; +} + +quint64 composedMutationHash(const QVector &ops, int end) { + quint64 hash = 14695981039346656037ull; + const int last = std::min(end, static_cast(ops.size())); + for (int index = 0; index < last; ++index) { + const Operation &op = ops.at(index); + if (op.type == Operation::Type::Cut) { + hash = mixPrefix(hash, 1); + hash = mixPrefix(hash, static_cast(op.cut.orientation)); + hash = mixPrefix(hash, (static_cast(op.cut.sourceStart) << 32) | + static_cast(op.cut.sourceEnd)); + hash = mixPrefix(hash, (static_cast(op.cut.logicalStart) << 32) | + static_cast(op.cut.logicalEnd)); + } else if (op.type == Operation::Type::Clip) { + hash = mixPrefix(hash, 2); + const QRect rect = op.clip.sourceRect; + hash = mixPrefix(hash, (static_cast(rect.x()) << 32) | + static_cast(rect.y())); + hash = mixPrefix(hash, (static_cast(rect.width()) << 32) | + static_cast(rect.height())); + hash = mixPrefix(hash, op.clip.fill.isValid() ? op.clip.fill.rgba() : 0); + } + } + return hash; +} + +bool clipOpsHaveTiles(const QVector &ops, int end) { + const int last = std::min(end, static_cast(ops.size())); + for (int index = 0; index < last; ++index) { + const Operation &op = ops.at(index); + if (op.type != Operation::Type::Clip) + continue; + if (op.annotations.isEmpty() || op.annotations.constFirst().image.isNull()) + return false; + } + return true; +} bool showsSelectionBounds(Annotation::Kind kind) { return kind != Annotation::Kind::Arrow && kind != Annotation::Kind::Line; @@ -487,6 +554,27 @@ QPointF constrainedRedactionEndpoint(const QPointF &candidate, /// One top-to-bottom pass of the OCR scan band. constexpr qint64 kOcrSweepMs = 1200; +/// One lap of the snap-recognition dot around a detected outline. +constexpr qint64 kSnapTraceMs = 900; + +void appendPathPercentRange(QPainterPath &out, const QPainterPath &src, + qreal from, qreal to) { + if (src.isEmpty()) + return; + from = std::clamp(from, 0.0, 1.0); + to = std::clamp(to, 0.0, 1.0); + if (to < from) + std::swap(from, to); + const int samples = std::max(8, static_cast(std::ceil((to - from) * 64))); + for (int i = 0; i <= samples; ++i) { + const qreal u = from + (to - from) * static_cast(i) / samples; + const QPointF p = src.pointAtPercent(u); + if (i == 0) + out.moveTo(p); + else + out.lineTo(p); + } +} void drawInstantTooltip(QPainter &painter, const QRect &bounds, const QRectF &anchor, const QString &text) { @@ -701,6 +789,14 @@ CaptureEditor::CaptureEditor(CaptureData capture, CaptureMode mode, ocrAnimTimer_.setInterval(16); connect(&ocrAnimTimer_, &QTimer::timeout, this, [this] { update(); }); + snapAnimTimer_.setInterval(16); + connect(&snapAnimTimer_, &QTimer::timeout, this, [this] { + if (!snapTraceLoop_ && snapAnimClock_.elapsed() >= kSnapTraceMs) { + snapAnimTimer_.stop(); + snapTraceRevealing_ = false; + } + update(); + }); ocrResultTimer_.setSingleShot(true); ocrResultTimer_.setInterval(6000); connect(&ocrResultTimer_, &QTimer::timeout, this, @@ -758,7 +854,7 @@ CaptureEditor::CaptureEditor(CaptureData capture, CaptureMode mode, sourceWritten_ = true; // Let the cut finish before chaining another snapshot so persistence sees // its final committed operation rather than an intermediate interaction. - if (snapshotDirty_ && !cutDragActive_) + if (snapshotDirty_ && !cutDragActive_ && !clipLiftActive_) startSnapshotRender(); }); @@ -1251,8 +1347,9 @@ QString CaptureEditor::toolStatus() const { const int size = qRound(annotationSize_); switch (tool_) { case Tool::Select: - return QStringLiteral("Select · drag moves layers · Ctrl+wheel zooms · " - "outer handles crop"); + return QStringLiteral( + "Select · drag to select layers · Ctrl+wheel zooms · outer handles " + "crop"); case Tool::Spotlight: { const QString shape = spotlightShape_ == SpotlightShape::Ellipse ? QStringLiteral("ellipse") @@ -1589,6 +1686,10 @@ void CaptureEditor::adjustSelectedAnnotation(int step) { case Annotation::Kind::Rectangle: case Annotation::Kind::Ellipse: break; + case Annotation::Kind::Clip: + setStatus(QStringLiteral( + "Clip layer · drag to move · handles resize · wheel ignored")); + return; } // A filled shape has no stroke showing, so weighing it would be a gesture // that does nothing visible: it grows instead, like the redaction above, @@ -1726,12 +1827,16 @@ QRectF CaptureEditor::colorPaletteRect() const { } QRectF CaptureEditor::customColorPanelRect() const { - QRectF panel(colorPaletteRect().left(), colorPaletteRect().bottom() + 6, 220, - 150); + const QRectF clipMenu = clipFillMenuRect(); + const QRectF anchor = !clipMenu.isEmpty() ? clipMenu : colorPaletteRect(); + QRectF panel(anchor.left(), anchor.bottom() + 6, 220, 150); if (panel.right() > width() - 8) panel.moveRight(width() - 8); - if (panel.bottom() > height() - 8) - panel.moveBottom(height() - 8); + if (panel.bottom() > height() - 8) { + panel.moveBottom(anchor.top() - 6); + if (panel.top() < 8) + panel.moveTop(8); + } return panel; } @@ -1767,6 +1872,8 @@ void CaptureEditor::applyCustomColor(const QPointF &position) { } customColor_ = QColor::fromHsvF(customHue_, saturation, value); usingCustomColor_ = true; + if (!pixelClipRect_.isEmpty() && !clipLiftActive_) + setClipFill(customColor_); if (selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && annotations_.at(selectedAnnotation_).kind != Annotation::Kind::Redaction) { @@ -2273,6 +2380,35 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, QStringLiteral("tool-eyedropper"), {}, QStringLiteral("Sample from image · I"), {}}); } + if (includeSubmenus && !pixelClipRect_.isEmpty() && !clipLiftActive_) { + const QRectF menu = clipFillMenuRect(); + if (!menu.isEmpty()) { + int slot = 0; + const auto slotRect = [&]() { + return QRectF(menu.left() + 4 + slot++ * 28, menu.top() + 4, 24, 28); + }; + buttons.push_back({slotRect(), QStringLiteral("clip-fill-transparent"), + {}, + QStringLiteral("Hole fill · transparent"), {}}); + const QColor surroundings = clipSurroundingFill(); + if (clipFillOpaque(surroundings)) + buttons.push_back( + {slotRect(), QStringLiteral("clip-fill-surroundings"), {}, + QStringLiteral("Hole fill · match surroundings"), surroundings}); + const int presetCount = static_cast(paletteConfig_.palette.size()); + for (int index = 0; index < presetCount; ++index) { + buttons.push_back( + {slotRect(), QStringLiteral("clip-fill-%1").arg(index), + {}, + QStringLiteral("Hole fill · %1").arg(index + 1), + paletteConfig_.palette.at(static_cast(index))}); + } + buttons.push_back({slotRect(), QStringLiteral("clip-fill-custom"), {}, + QStringLiteral("Hole fill · custom color"), {}}); + buttons.push_back({slotRect(), QStringLiteral("tool-eyedropper"), {}, + QStringLiteral("Sample from image · I"), {}}); + } + } return buttons; } @@ -2364,6 +2500,8 @@ void CaptureEditor::applyEditState(const EditState &state) { } void CaptureEditor::cancelActiveDragForHistory() { + if (clipLiftActive_) + cancelClipLift(); if (dragStartStateValid_) replayLog(); dragging_ = false; @@ -2437,6 +2575,9 @@ void CaptureEditor::commitPatch(const QVector &indices) { Annotation annotation = annotations_.at(index); if (annotation.id == 0) annotation.id = nextAnnotationId_++; + // Clip pixels live on the Clip op; a patch only moves the dest rect. + if (annotation.kind == Annotation::Kind::Clip) + annotation.image = {}; op.annotations.push_back(std::move(annotation)); } if (op.annotations.isEmpty()) @@ -2471,6 +2612,669 @@ void CaptureEditor::commitCut(CutOp cut) { commitOp(std::move(op)); } +void CaptureEditor::commitClip(ClipOp clip, Annotation annotation) { + if (annotation.id == 0) + annotation.id = nextAnnotationId_++; + annotation.kind = Annotation::Kind::Clip; + if (annotation.image.isNull()) + annotation.image = copyRect(capture_.source, clip.sourceRect); + if (annotation.image.isNull()) + return; + + const quint64 prefixBefore = composedMutationHash(ops_, opIndex_); + if (opIndex_ < ops_.size()) + ops_.resize(opIndex_); + Operation op; + op.type = Operation::Type::Clip; + op.clip = clip; + op.annotations = {annotation}; + ops_.push_back(std::move(op)); + constexpr qsizetype maximumOps = 100; + bool trimmed = false; + while (ops_.size() > maximumOps) { + if (ops_.constFirst().type == Operation::Type::Crop) + ops_.removeAt(1); + else + ops_.removeFirst(); + if (opIndex_ > 0) + --opIndex_; + trimmed = true; + } + opIndex_ = ops_.size(); + clipTilePrefix_.insert(annotation.id, prefixBefore); + if (trimmed) { + replayLog(); + scheduleSnapshot(); + return; + } + + fillHole(capture_.source, clip); + if (clip.shape == ClipShape::Rect) + punchDisplayCaches(clip.sourceRect, clip.fill); + else + redactionBaseStale_ = true; + annotations_.push_back(std::move(annotation)); + composedPrefixHash_ = composedMutationHash(ops_, opIndex_); + refreshCanvasRect(); + scheduleSnapshot(); + update(); +} + +void CaptureEditor::applyClipForTest(const QRectF &logicalRect, + const QRectF &dest, const QColor &fill) { + const QRect native = nativeRectForPixelClip(logicalRect); + if (native.isEmpty()) + return; + Annotation annotation; + annotation.kind = Annotation::Kind::Clip; + annotation.start = dest.topLeft(); + annotation.end = dest.bottomRight(); + commitClip(ClipOp{ClipShape::Rect, native, {}, fill}, std::move(annotation)); +} + +void CaptureEditor::markLastClipTileForTest(const QColor &color) { + for (int index = ops_.size() - 1; index >= 0; --index) { + Operation &op = ops_[index]; + if (op.type != Operation::Type::Clip || op.annotations.isEmpty()) + continue; + if (op.annotations.front().image.isNull()) + return; + op.annotations.front().image.fill(color); + return; + } +} + +void CaptureEditor::clearPixelClip() { + pixelClipRect_ = {}; + originalPixelClip_ = {}; + pixelClipPoints_.clear(); + originalPixelClipPoints_.clear(); + pixelClipLockedEllipse_ = false; + pixelClipRadius_ = 0; + originalPixelClipRadius_ = 0; + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); + pixelClipResizing_ = false; + clipHexEntry_.clear(); + clipFill_ = QColor(0, 0, 0, 0); + stopSnapTrace(); + cancelClipLift(); +} + +void CaptureEditor::cyclePixelClipShape() { + switch (pixelClipShape_) { + case PixelClipShape::Rect: + setPixelClipShape(PixelClipShape::Ellipse); + break; + case PixelClipShape::Ellipse: + setPixelClipShape(PixelClipShape::Lasso); + break; + case PixelClipShape::Lasso: + setPixelClipShape(PixelClipShape::Rect); + break; + } +} + +QString CaptureEditor::clipShapeStatus(bool hadLock) const { + const QString snap = + pixelClipSnapEnabled_ ? QStringLiteral(" · snap on") : QString(); + switch (pixelClipShape_) { + case PixelClipShape::Rect: + return hadLock ? QStringLiteral( + "Clip · rectangle%1 · drag inside to lift · V cycles") + .arg(snap) + : QStringLiteral("Clip · rectangle%1 · drag a box on empty " + "canvas · V cycles") + .arg(snap); + case PixelClipShape::Ellipse: + return hadLock ? QStringLiteral( + "Clip · ellipse%1 · drag inside to lift · V cycles") + .arg(snap) + : QStringLiteral("Clip · ellipse%1 · drag a box on empty " + "canvas · V cycles") + .arg(snap); + case PixelClipShape::Lasso: + return hadLock + ? QStringLiteral( + "Clip · lasso%1 · drag inside to lift · V cycles") + .arg(snap) + : QStringLiteral("Clip · lasso%1 · trace a shape · V cycles") + .arg(snap); + } + return {}; +} + +void CaptureEditor::setPixelClipShape(PixelClipShape shape) { + const bool hadLock = !pixelClipRect_.isEmpty(); + const bool wasLasso = !pixelClipPoints_.isEmpty(); + const bool wasEllipse = !wasLasso && pixelClipLockedEllipse_; + pixelClipShape_ = shape; + if (hadLock) { + if (shape == PixelClipShape::Lasso && pixelClipPoints_.isEmpty()) { + const QRectF box = pixelClipRect_.normalized(); + if (wasEllipse) { + const QPointF c = box.center(); + const qreal rx = box.width() / 2.0; + const qreal ry = box.height() / 2.0; + pixelClipPoints_.clear(); + for (int i = 0; i < 24; ++i) { + const qreal a = + static_cast(i) * (2.0 * std::numbers::pi / 24.0); + pixelClipPoints_.push_back( + QPointF(c.x() + rx * std::cos(a), c.y() + ry * std::sin(a))); + } + } else { + pixelClipPoints_ = {box.topLeft(), box.topRight(), box.bottomRight(), + box.bottomLeft()}; + } + } + if (shape != PixelClipShape::Lasso) + pixelClipPoints_.clear(); + } else if (shape != PixelClipShape::Lasso) { + pixelClipPoints_.clear(); + } + pixelClipLockedEllipse_ = + hadLock && pixelClipPoints_.isEmpty() && shape == PixelClipShape::Ellipse; + if (shape != PixelClipShape::Rect) + pixelClipRadius_ = 0; + setStatus(clipShapeStatus(hadLock)); + update(); +} + +void CaptureEditor::setPixelClipSnapEnabled(bool enabled) { + pixelClipSnapEnabled_ = enabled; + if (enabled && !pixelClipRect_.isEmpty()) { + trySnapAt(pixelClipRect_.normalized().center(), false); + } else if (enabled) { + setStatus(QStringLiteral( + "Clip · snap on · click an object or drag Rect/Ellipse/Lasso")); + } else { + setStatus(clipShapeStatus(!pixelClipRect_.isEmpty())); + } + update(); +} + +ClipOp CaptureEditor::lockedClipOp() const { + ClipShape shape = ClipShape::Rect; + if (!pixelClipPoints_.isEmpty()) + shape = ClipShape::Lasso; + else if (pixelClipLockedEllipse_) + shape = ClipShape::Ellipse; + QVector points; + points.reserve(pixelClipPoints_.size()); + for (const QPointF &point : pixelClipPoints_) + points.push_back(point + selection_.topLeft()); + return nativeClipOp(shape, pixelClipRect_.normalized().translated( + selection_.topLeft()), + points, capture_.previewSize, capture_.source.size(), + clipFill_, pixelClipRadius_); +} + +QRectF CaptureEditor::pixelClipDragBounds() const { + if (pixelClipShape_ == PixelClipShape::Lasso && + pixelClipPoints_.size() >= 2) { + qreal minX = pixelClipPoints_.constFirst().x(); + qreal maxX = minX; + qreal minY = pixelClipPoints_.constFirst().y(); + qreal maxY = minY; + for (const QPointF &point : pixelClipPoints_) { + minX = std::min(minX, point.x()); + maxX = std::max(maxX, point.x()); + minY = std::min(minY, point.y()); + maxY = std::max(maxY, point.y()); + } + return QRectF(QPointF(minX, minY), QPointF(maxX, maxY)).normalized(); + } + return marqueeRect_.normalized(); +} + +QPainterPath CaptureEditor::logicalClipPath() const { + QPainterPath path; + const QRectF box = pixelClipRect_.normalized(); + if (box.isEmpty() && pixelClipPoints_.size() < 3) + return path; + if (!pixelClipPoints_.isEmpty()) { + path.moveTo(pixelClipPoints_.constFirst()); + for (int i = 1; i < pixelClipPoints_.size(); ++i) + path.lineTo(pixelClipPoints_.at(i)); + path.closeSubpath(); + return path; + } + if (pixelClipLockedEllipse_) + path.addEllipse(box); + else if (pixelClipRadius_ >= 1.0) { + const qreal r = std::min({pixelClipRadius_, box.width() / 2.0, + box.height() / 2.0}); + path.addRoundedRect(box, r, r); + } else { + path.addRect(box); + } + return path; +} + +std::optional +CaptureEditor::mapNativeSnap(const std::optional &box) const { + if (!box) + return std::nullopt; + const qreal scaleX = capture_.previewSize.width() / + static_cast(std::max(1, capture_.source.width())); + const qreal scaleY = + capture_.previewSize.height() / + static_cast(std::max(1, capture_.source.height())); + QRectF logicalBox(box->x() * scaleX, box->y() * scaleY, box->width() * scaleX, + box->height() * scaleY); + logicalBox.translate(-selection_.topLeft()); + const QRectF bounds(QPointF(), selection_.size()); + logicalBox = logicalBox.intersected(bounds); + if (logicalBox.width() < 2.0 || logicalBox.height() < 2.0) + return std::nullopt; + return logicalBox; +} + +std::optional +CaptureEditor::snapLogicalObjectAt(const QPointF &annotationPoint, + const QRectF &logicalRoi, + PixelClipShape shape) const { + const QRect native = + nativeRectForPixelClip(QRectF(annotationPoint, QSizeF(1, 1))); + if (native.isEmpty()) + return std::nullopt; + QRect roi; + if (logicalRoi.isValid() && !logicalRoi.isEmpty()) + roi = nativeRectForPixelClip(logicalRoi); + if (shape == PixelClipShape::Ellipse) { + const std::optional circle = + snapEllipseRect(capture_.source, native.center(), roi); + const std::optional box = mapNativeSnap(circle); + if (!box) + return std::nullopt; + LogicalSnap snap; + snap.box = *box; + return snap; + } + const std::optional hit = + snapObject(capture_.source, native.center(), roi); + if (!hit) + return std::nullopt; + const std::optional box = mapNativeSnap(hit->box); + if (!box) + return std::nullopt; + const qreal scaleX = capture_.previewSize.width() / + static_cast(std::max(1, capture_.source.width())); + const qreal scaleY = + capture_.previewSize.height() / + static_cast(std::max(1, capture_.source.height())); + LogicalSnap snap; + snap.box = *box; + snap.radius = hit->radius * (scaleX + scaleY) / 2.0; + snap.contour.reserve(hit->contour.size()); + for (const QPointF &point : hit->contour) { + snap.contour.push_back(QPointF(point.x() * scaleX, point.y() * scaleY) - + selection_.topLeft()); + } + return snap; +} + +void CaptureEditor::trySnapAt(const QPointF &annotationPoint, bool resetFill) { + const std::optional snap = + snapLogicalObjectAt(annotationPoint, {}, pixelClipShape_); + if (!snap) { + setStatus(QStringLiteral("Nothing to snap · drag a shape instead")); + return; + } + pixelClipRect_ = snap->box; + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); + if (pixelClipShape_ == PixelClipShape::Lasso && snap->contour.size() >= 8) { + pixelClipPoints_ = snap->contour; + pixelClipLockedEllipse_ = false; + pixelClipRadius_ = 0; + } else if (pixelClipShape_ == PixelClipShape::Ellipse) { + pixelClipPoints_.clear(); + pixelClipLockedEllipse_ = true; + pixelClipRadius_ = 0; + } else { + pixelClipPoints_.clear(); + pixelClipLockedEllipse_ = false; + pixelClipRadius_ = snap->radius; + } + if (resetFill) + applyDefaultClipFill(); + selectedAnnotations_.clear(); + selectedAnnotation_ = -1; + const QString kind = pixelClipShape_ == PixelClipShape::Lasso + ? QStringLiteral("outline") + : pixelClipShape_ == PixelClipShape::Ellipse + ? QStringLiteral("ellipse") + : QStringLiteral("rectangle"); + setStatus(QStringLiteral( + "Snapped to %1 · pick a hole fill or drag inside to clip out " + "· Esc cancels") + .arg(kind)); + startSnapTrace(false); +} + +QVector +CaptureEditor::clipShapeStripButtons() const { + if (tool_ != Tool::Select || phase_ != Phase::Edit) + return {}; + const qreal scale = toolbarScale(width()); + const qreal height = 28 * scale; + const qreal chip = 64 * scale; + const qreal gap = 4 * scale; + const qreal total = 4 * chip + 3 * gap + 8 * scale; + qreal x = (width() - total) / 2.0 + 4 * scale; + const qreal y = toolbarTop() + 36.0 * scale + 6 * scale; + QVector buttons; + const auto add = [&](const QString &action, const QString &label, + const QString &tip) { + buttons.push_back({QRectF(x, y, chip, height), action, label, tip, {}}); + x += chip + gap; + }; + add(QStringLiteral("clip-shape-rect"), QStringLiteral("Rect"), + QStringLiteral("Clip rectangle · V cycles")); + add(QStringLiteral("clip-shape-ellipse"), QStringLiteral("Ellipse"), + QStringLiteral("Clip ellipse · V cycles")); + add(QStringLiteral("clip-shape-lasso"), QStringLiteral("Lasso"), + QStringLiteral("Clip lasso · V cycles")); + add(QStringLiteral("clip-shape-snap"), QStringLiteral("Snap"), + pixelClipSnapEnabled_ + ? QStringLiteral( + "Snap on · click an object or drag around it") + : QStringLiteral( + "Snap off · click to turn on, then click an object or drag " + "around it")); + return buttons; +} + +void CaptureEditor::cancelClipLift() { + clipLiftActive_ = false; + clipLiftOrigin_ = {}; + clipLiftDest_ = {}; + clipLiftGrabOffset_ = {}; + clipLiftTile_ = {}; + clipLiftPixmap_ = {}; + clipLiftPixmapScale_ = 0.0; + clipLiftSnapped_ = false; +} + +void CaptureEditor::rebuildClipLiftPixmap() { + if (clipLiftTile_.isNull() || clipLiftOrigin_.isEmpty()) { + clipLiftPixmap_ = {}; + clipLiftPixmapScale_ = 0.0; + return; + } + const qreal dpr = std::max(devicePixelRatioF(), 0.01); + const qreal pixelScale = std::max(editScale() * dpr, 0.01); + const QSize size(std::max(1, qRound(clipLiftOrigin_.width() * pixelScale)), + std::max(1, qRound(clipLiftOrigin_.height() * pixelScale))); + QImage scaled = clipLiftTile_.scaled(size, Qt::IgnoreAspectRatio, + Qt::FastTransformation); + clipLiftPixmap_ = QPixmap::fromImage(std::move(scaled)); + clipLiftPixmap_.setDevicePixelRatio(dpr); + clipLiftPixmapScale_ = editScale(); +} + +void CaptureEditor::punchDisplayCaches(QRect nativeRect, const QColor &fill) { + if (redactionBaseStale_ || redactionBase_.isNull() || selection_.isEmpty() || + capture_.previewSize.isEmpty() || capture_.source.isNull()) + return; + const QSize sourceSize = capture_.source.size(); + if (sourceSize.isEmpty()) + return; + const qreal toLogicalX = + capture_.previewSize.width() / static_cast(sourceSize.width()); + const qreal toLogicalY = + capture_.previewSize.height() / static_cast(sourceSize.height()); + const QRectF logical(nativeRect.x() * toLogicalX - selection_.left(), + nativeRect.y() * toLogicalY - selection_.top(), + nativeRect.width() * toLogicalX, + nativeRect.height() * toLogicalY); + const qreal toCacheX = redactionBase_.width() / selection_.width(); + const qreal toCacheY = redactionBase_.height() / selection_.height(); + const QRect cacheRect( + QPoint(static_cast(std::floor(logical.left() * toCacheX)), + static_cast(std::floor(logical.top() * toCacheY))), + QPoint(static_cast(std::ceil(logical.right() * toCacheX) - 1), + static_cast(std::ceil(logical.bottom() * toCacheY) - 1))); + fillHole(redactionBase_, cacheRect, fill); + if (!redactionLayerCache_.isNull() && + redactionLayerCache_.size() == redactionBase_.size()) + fillHole(redactionLayerCache_, cacheRect, fill); +} + +bool CaptureEditor::pixelClipLargeEnough(const QRectF &logical) const { + const QRectF box = logical.normalized(); + if (box.width() < kMinPixelClipLogical || box.height() < kMinPixelClipLogical) + return false; + const qreal scale = std::max(editScale(), 0.01); + if (box.width() * scale < kMinPixelClipWidget || + box.height() * scale < kMinPixelClipWidget) + return false; + return !nativeRectForPixelClip(box).isEmpty(); +} + +QRect CaptureEditor::clipLiftRepaintRect() const { + if (!clipLiftActive_) + return {}; + const qreal scale = std::max(editScale(), 0.01); + const QPointF origin = sourceFrameWidgetRect().topLeft(); + const auto toWidget = [&](const QRectF &logical) { + return QRectF(origin + logical.topLeft() * scale, logical.size() * scale); + }; + QRectF area = toWidget(clipLiftDest_).united(toWidget(clipLiftOrigin_)); + return area.adjusted(-10, -10, 18, 18).toAlignedRect(); +} + +void CaptureEditor::paintClipHolePreview(QPainter &painter, + const QRectF &sourceImage) const { + const QPainterPath logical = logicalClipPath(); + if (logical.isEmpty()) + return; + const qreal scale = std::max(editScale(), 0.01); + QTransform toWidget; + toWidget.translate(sourceImage.left(), sourceImage.top()); + toWidget.scale(scale, scale); + const QPainterPath widget = toWidget.map(logical); + const QRectF hole = widget.boundingRect(); + if (hole.isEmpty()) + return; + painter.save(); + painter.setClipPath(widget, Qt::IntersectClip); + painter.setCompositionMode(QPainter::CompositionMode_Source); + if (clipFillOpaque(clipFill_)) + painter.fillRect(hole, clipFill_); + else + paintCheckerboard(painter, hole, 8.0); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + painter.restore(); +} + +QRect CaptureEditor::nativeRectForPixelClip(const QRectF &logical) const { + const QRectF absolute = logical.normalized().translated(selection_.topLeft()); + return nativeClipRect(absolute, capture_.previewSize, capture_.source.size()); +} + +CaptureEditor::Interaction +CaptureEditor::pixelClipHandleAt(const QPointF &point) const { + if (pixelClipRect_.isEmpty() || snapTraceRevealing_) + return Interaction::None; + Annotation box; + box.kind = Annotation::Kind::Rectangle; + box.start = pixelClipRect_.topLeft(); + box.end = pixelClipRect_.bottomRight(); + const qreal scale = std::max(editScale(), 0.01); + const QRectF bounds = pixelClipRect_.normalized(); + const qreal radius = + std::min({6.0 / scale, bounds.width() / 4.0, bounds.height() / 4.0}); + for (const auto &[position, handle] : annotationHandles(box)) { + if (QLineF(point, position).length() <= radius) + return handle; + } + return Interaction::None; +} + +QRectF CaptureEditor::pixelClipWidgetRect() const { + if (pixelClipRect_.isEmpty()) + return {}; + const qreal scale = std::max(editScale(), 0.01); + return QRectF(sourceFrameWidgetRect().topLeft() + + pixelClipRect_.topLeft() * scale, + pixelClipRect_.size() * scale); +} + +QColor CaptureEditor::clipSurroundingFill() const { + if (pixelClipRect_.isEmpty()) + return {}; + return sampleClipSurroundings( + capture_.source, nativeRectForPixelClip(pixelClipRect_.normalized())); +} + +QRectF CaptureEditor::clipFillMenuRect() const { + if (pixelClipRect_.isEmpty() || clipLiftActive_ || snapTraceRevealing_) + return {}; + int swatches = + static_cast(paletteConfig_.palette.size()) + 3; // T + custom + I + if (clipFillOpaque(clipSurroundingFill())) + ++swatches; + const qreal menuWidth = 8.0 + swatches * 28.0; + const QRectF clip = pixelClipWidgetRect(); + QRectF menu(clip.right() + 8.0, clip.top(), menuWidth, 36.0); + if (menu.right() > width() - 8.0) + menu.moveRight(clip.left() - 8.0); + if (menu.intersects(clip)) { + menu.moveCenter(QPointF(clip.center().x(), clip.top() - 26.0)); + if (menu.top() < 8.0) + menu.moveTop(clip.bottom() + 8.0); + } + if (menu.left() < 8.0) + menu.moveLeft(8.0); + if (menu.right() > width() - 8.0) + menu.moveRight(width() - 8.0); + if (menu.top() < 8.0) + menu.moveTop(8.0); + if (menu.bottom() > height() - 8.0) + menu.moveBottom(height() - 8.0); + return menu; +} + +void CaptureEditor::setClipFill(const QColor &fill) { + clipFill_ = fill.isValid() ? fill : QColor(0, 0, 0, 0); + if (clipFillOpaque(clipFill_)) + setStatus(QStringLiteral("Hole fill %1 · drag inside to clip out") + .arg(clipFill_.name(QColor::HexRgb).toUpper())); + else + setStatus(QStringLiteral( + "Hole fill transparent · drag inside to clip out · Esc cancels")); + update(); +} + +void CaptureEditor::applyDefaultClipFill() { + const QColor around = clipSurroundingFill(); + clipFill_ = clipFillOpaque(around) ? around : QColor(0, 0, 0, 0); +} + +void CaptureEditor::armClipFillEyedropper() { + if (tool_ != Tool::Eyedropper) + toolBeforeEyedropper_ = tool_; + tool_ = Tool::Eyedropper; + setStatus(QStringLiteral( + "Sample hole fill from the image · click a pixel · T cycles")); +} + +void CaptureEditor::cycleClipHoleFill() { + if (tool_ == Tool::Eyedropper) { + tool_ = Tool::Select; + setClipFill(QColor(0, 0, 0, 0)); + return; + } + const QColor around = clipSurroundingFill(); + if (!clipFillOpaque(clipFill_)) { + if (clipFillOpaque(around)) + setClipFill(around); + else + armClipFillEyedropper(); + return; + } + if (clipFillOpaque(around) && clipFill_.rgb() == around.rgb()) { + armClipFillEyedropper(); + return; + } + setClipFill(QColor(0, 0, 0, 0)); +} + +void CaptureEditor::beginClipLift(const QPointF &point) { + clipLiftOp_ = lockedClipOp(); + const QImage tile = copyMasked(capture_.source, clipLiftOp_); + if (tile.isNull()) + return; + stopSnapTrace(); + clipLiftActive_ = true; + clipLiftOrigin_ = pixelClipRect_.normalized(); + clipLiftDest_ = clipLiftOrigin_; + clipLiftGrabOffset_ = point - clipLiftOrigin_.center(); + clipLiftTile_ = tile; + clipLiftSnapped_ = true; + rebuildClipLiftPixmap(); + dragging_ = true; + interaction_ = Interaction::None; + setCursor(Qt::ClosedHandCursor); + setStatus(QStringLiteral( + "Dragging selection — release near the hole to snap it back")); +} + +void CaptureEditor::updateClipLift(const QPointF &point) { + if (!clipLiftActive_) + return; + QRectF dest = clipLiftOrigin_; + dest.moveCenter(point - clipLiftGrabOffset_); + const qreal enter = clipSnapEnterThreshold(editScale()); + const qreal leave = clipSnapLeaveThreshold(editScale()); + if (clipLiftSnapped_) + clipLiftSnapped_ = clipDestSnapped(dest, clipLiftOrigin_, leave); + else + clipLiftSnapped_ = clipDestSnapped(dest, clipLiftOrigin_, enter); + clipLiftDest_ = dest; +} + +void CaptureEditor::finishClipLift() { + if (!clipLiftActive_) + return; + if (clipLiftSnapped_) { + cancelClipLift(); + dragging_ = false; + setStatus(QStringLiteral("Selection snapped back into place")); + updatePointerCursor(); + update(); + return; + } + ClipOp clip = clipLiftOp_; + clip.fill = clipFill_; + Annotation annotation; + annotation.kind = Annotation::Kind::Clip; + annotation.start = clipLiftDest_.topLeft(); + annotation.end = clipLiftDest_.bottomRight(); + annotation.image = clipLiftTile_; + cancelClipLift(); + pixelClipRect_ = {}; + pixelClipPoints_.clear(); + pixelClipLockedEllipse_ = false; + pixelClipRadius_ = 0; + dragging_ = false; + commitClip(std::move(clip), std::move(annotation)); + selectedAnnotation_ = annotations_.isEmpty() + ? -1 + : static_cast(annotations_.size()) - 1; + selectedAnnotations_.clear(); + if (selectedAnnotation_ >= 0) + selectedAnnotations_ = {selectedAnnotation_}; + setStatus(QStringLiteral( + "Clipped to a new layer · drag another region to clip again · Ctrl+Z " + "undoes")); +} + void CaptureEditor::commitBackground(BackgroundStyle style, bool imageShadow) { Operation op; op.type = Operation::Type::Background; @@ -2613,6 +3417,11 @@ void CaptureEditor::replayLog() { CanvasBoundaryMode canvasBoundary = CanvasBoundaryMode::Framed; QVector annotations; QVector cuts; + const quint64 prefixHash = composedMutationHash(ops_, opIndex_); + const bool reuseComposed = prefixHash == composedPrefixHash_ && + !capture_.source.isNull() && + clipOpsHaveTiles(ops_, opIndex_); + QImage composed = reuseComposed ? capture_.source : pristineSource_; int nextMarker = 1; for (int index = 0; index < opIndex_ && index < ops_.size(); ++index) { const Operation &op = ops_.at(index); @@ -2651,6 +3460,8 @@ void CaptureEditor::replayLog() { }); if (match != annotations.end()) { Annotation updated = annotation; + if (updated.kind == Annotation::Kind::Clip && updated.image.isNull()) + updated.image = match->image; annotations.erase(match); annotations.push_back(std::move(updated)); } @@ -2687,6 +3498,27 @@ void CaptureEditor::replayLog() { selection.setWidth(std::max(1.0, selection.width() - band)); } cuts.push_back(op.cut); + if (!reuseComposed && !composed.isNull()) + composed = removeBand(composed, op.cut.orientation, op.cut.sourceStart, + op.cut.sourceEnd); + break; + } + case Operation::Type::Clip: { + Annotation layer; + if (!op.annotations.isEmpty()) + layer = op.annotations.constFirst(); + layer.kind = Annotation::Kind::Clip; + if (!reuseComposed) { + const quint64 prefixBefore = composedMutationHash(ops_, index); + const bool prefixChanged = + layer.id != 0 && clipTilePrefix_.value(layer.id) != prefixBefore; + layer.image = resolveClipTile(composed, op.clip, layer.image, + prefixChanged); + if (layer.id != 0) + clipTilePrefix_.insert(layer.id, prefixBefore); + fillHole(composed, op.clip); + } + annotations.push_back(std::move(layer)); break; } } @@ -2696,9 +3528,16 @@ void CaptureEditor::replayLog() { backgroundStyle_ = background; imageShadow_ = imageShadow; canvasBoundaryMode_ = canvasBoundary; - if (cuts != cuts_) { - cuts_ = std::move(cuts); - refreshComposedCapture(); + cuts_ = std::move(cuts); + capture_.source = std::move(composed); + capture_.previewSize = + composedLogicalSize(pristineLogicalSize_.isEmpty() ? capture_.previewSize + : pristineLogicalSize_, + cuts_); + composedPrefixHash_ = prefixHash; + if (!reuseComposed) { + backdropKey_ = 0; + redactionBaseStale_ = true; } if (!selection.isEmpty()) selection_ = selection; @@ -2720,7 +3559,6 @@ void CaptureEditor::replayLog() { if (selectedAnnotation_ < 0 && !selectedAnnotations_.isEmpty()) selectedAnnotation_ = selectedAnnotations_.constLast(); editingAnnotation_ = -1; - redactionBaseStale_ = true; updatePointerCursor(); update(); } @@ -2749,6 +3587,17 @@ int CaptureEditor::raiseAnnotation(int index) { } void CaptureEditor::undoEdit() { + if (clipLiftActive_) { + // A lift is an uncommitted drag. Ctrl+Z / the undo button must drop it + // without popping the last committed op (QTest also synthesizes a bare + // Control press before Z, which used to cancel first and then undo). + cancelClipLift(); + dragging_ = false; + setStatus(QStringLiteral("Clip cancelled")); + updatePointerCursor(); + update(); + return; + } endNudgeRun(); cancelActiveDragForHistory(); if (opIndex_ <= 0) { @@ -2947,6 +3796,28 @@ void CaptureEditor::enterExport() { } void CaptureEditor::handleEscape() { + if (!clipHexEntry_.isEmpty()) { + clipHexEntry_.clear(); + setStatus(QStringLiteral("Hex cancelled")); + update(); + return; + } + if (clipLiftActive_) { + cancelClipLift(); + dragging_ = false; + setStatus(QStringLiteral("Clip cancelled")); + updatePointerCursor(); + update(); + return; + } + if (!pixelClipRect_.isEmpty()) { + clearPixelClip(); + dragging_ = false; + setStatus(QStringLiteral("Selection cancelled")); + updatePointerCursor(); + update(); + return; + } if (cutDragActive_) { cutDragActive_ = false; dragging_ = false; @@ -3252,6 +4123,104 @@ void CaptureEditor::dismissOcrOverlay() { update(); } +void CaptureEditor::startSnapTrace(bool loop) { + snapTraceLoop_ = loop; + snapTraceRevealing_ = !loop; + if (!loop || !snapAnimTimer_.isActive()) + snapAnimClock_.restart(); + if (!snapAnimTimer_.isActive()) + snapAnimTimer_.start(); +} + +void CaptureEditor::stopSnapTrace() { + snapAnimTimer_.stop(); + snapTraceLoop_ = false; + snapTraceRevealing_ = false; +} + +QPainterPath CaptureEditor::snapTracePath() const { + QPainterPath path; + if (marqueeSelecting_ && pixelClipTraceSnap_) { + const QRectF box = pixelClipTraceSnap_->normalized(); + if (pixelClipShape_ == PixelClipShape::Lasso && + pixelClipTracePoints_.size() >= 3) { + path.moveTo(pixelClipTracePoints_.constFirst()); + for (int i = 1; i < pixelClipTracePoints_.size(); ++i) + path.lineTo(pixelClipTracePoints_.at(i)); + path.closeSubpath(); + } else if (pixelClipShape_ == PixelClipShape::Ellipse) { + path.addEllipse(box); + } else if (pixelClipTraceRadius_ >= 1.0) { + const qreal r = std::min({pixelClipTraceRadius_, box.width() / 2.0, + box.height() / 2.0}); + path.addRoundedRect(box, r, r); + } else { + path.addRect(box); + } + return path; + } + if (snapTraceRevealing_ && !pixelClipRect_.isEmpty()) + return logicalClipPath(); + return path; +} + +void CaptureEditor::paintSnapTrace(QPainter &painter, qreal scale) const { + const QPainterPath path = snapTracePath(); + if (path.isEmpty() || path.length() < 0.5) + return; + const QColor accent(QStringLiteral("#0a84ff")); + const qreal elapsed = static_cast(snapAnimClock_.elapsed()); + qreal t = elapsed / qreal(kSnapTraceMs); + if (snapTraceLoop_) + t = std::fmod(t, 1.0); + else + t = std::clamp(t, 0.0, 1.0); + painter.save(); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(accent.red(), accent.green(), accent.blue(), 36)); + painter.drawPath(path); + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(accent.red(), accent.green(), accent.blue(), 70), + 1.6 / scale)); + painter.drawPath(path); + if (!snapTraceLoop_) { + QPainterPath written; + appendPathPercentRange(written, path, 0.0, t); + painter.setPen(QPen(accent, 2.2 / scale, Qt::SolidLine, Qt::RoundCap, + Qt::RoundJoin)); + painter.drawPath(written); + } + QPainterPath trail; + const qreal trailStart = t - 0.18; + if (snapTraceLoop_ && trailStart < 0.0) { + appendPathPercentRange(trail, path, 1.0 + trailStart, 1.0); + appendPathPercentRange(trail, path, 0.0, t); + } else if (t > 0.0) { + appendPathPercentRange(trail, path, std::max(0.0, trailStart), t); + } + if (!trail.isEmpty()) { + QLinearGradient glow(trail.pointAtPercent(0.0), trail.pointAtPercent(1.0)); + glow.setColorAt( + 0.0, QColor(accent.red(), accent.green(), accent.blue(), 0)); + glow.setColorAt( + 0.65, QColor(accent.red(), accent.green(), accent.blue(), 160)); + glow.setColorAt(1.0, QColor(255, 255, 255, 230)); + painter.setPen(QPen(QBrush(glow), 3.0 / scale, Qt::SolidLine, Qt::RoundCap, + Qt::RoundJoin)); + painter.drawPath(trail); + } + const QPointF tip = path.pointAtPercent(t); + const qreal glowR = 7.0 / scale; + const qreal coreR = 3.4 / scale; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(accent.red(), accent.green(), accent.blue(), 90)); + painter.drawEllipse(tip, glowR, glowR); + painter.setPen(QPen(Qt::white, 1.2 / scale)); + painter.setBrush(accent); + painter.drawEllipse(tip, coreR, coreR); + painter.restore(); +} + void CaptureEditor::paintOcrOverlay(QPainter &painter, const QRectF &image, qreal scale) { if (ocrRegion_.isEmpty()) @@ -3460,8 +4429,21 @@ void CaptureEditor::completeFinish(const FinishResult &result) { void CaptureEditor::handleToolbar(const QString &action) { const Tool toolBefore = tool_; const QString statusBefore = status_; - if (action == QStringLiteral("tool-select")) - tool_ = Tool::Select; + if (action == QStringLiteral("tool-select")) { + if (tool_ == Tool::Select) + cyclePixelClipShape(); + else { + tool_ = Tool::Select; + pixelClipShape_ = PixelClipShape::Rect; + } + } else if (action == QStringLiteral("clip-shape-rect")) + setPixelClipShape(PixelClipShape::Rect); + else if (action == QStringLiteral("clip-shape-ellipse")) + setPixelClipShape(PixelClipShape::Ellipse); + else if (action == QStringLiteral("clip-shape-lasso")) + setPixelClipShape(PixelClipShape::Lasso); + else if (action == QStringLiteral("clip-shape-snap")) + setPixelClipSnapEnabled(!pixelClipSnapEnabled_); else if (action == QStringLiteral("tool-arrow")) tool_ = Tool::Arrow; else if (action == QStringLiteral("tool-line")) @@ -3532,7 +4514,35 @@ void CaptureEditor::handleToolbar(const QString &action) { } else if (action == QStringLiteral("palette")) colorPaletteOpen_ = true; - else if (action.startsWith(QStringLiteral("color-"))) { + else if (action == QStringLiteral("clip-fill-transparent")) { + setClipFill(QColor(0, 0, 0, 0)); + return; + } else if (action.startsWith(QStringLiteral("clip-fill-"))) { + const QString rest = action.sliced(10); + if (rest == QStringLiteral("custom")) { + usingCustomColor_ = true; + customColorPickerOpen_ = !customColorPickerOpen_; + if (clipFillOpaque(customColor_)) + setClipFill(customColor_); + else + update(); + return; + } + if (rest == QStringLiteral("surroundings")) { + usingCustomColor_ = false; + customColorPickerOpen_ = false; + const QColor sampled = clipSurroundingFill(); + if (clipFillOpaque(sampled)) + setClipFill(sampled); + return; + } + const int index = std::clamp( + rest.toInt(), 0, static_cast(paletteConfig_.palette.size()) - 1); + usingCustomColor_ = false; + customColorPickerOpen_ = false; + setClipFill(paletteConfig_.palette.at(static_cast(index))); + return; + } else if (action.startsWith(QStringLiteral("color-"))) { colorIndex_ = std::clamp(action.sliced(6).toInt(), 0, static_cast(paletteConfig_.palette.size()) - 1); usingCustomColor_ = false; @@ -3566,6 +4576,8 @@ void CaptureEditor::handleToolbar(const QString &action) { close(); if (tool_ != toolBefore && status_ == statusBefore) setStatus(toolStatus()); + if (tool_ != Tool::Select && tool_ != Tool::Eyedropper) + clearPixelClip(); updatePointerCursor(); update(); } @@ -3594,6 +4606,50 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { event->accept(); return; } + if (phase_ == Phase::Edit && !clipHexEntry_.isEmpty()) { + const int key = event->key(); + if (key == Qt::Key_Escape) { + clipHexEntry_.clear(); + setStatus(QStringLiteral("Hex cancelled")); + event->accept(); + update(); + return; + } + if (key == Qt::Key_Return || key == Qt::Key_Enter) { + QColor color(clipHexEntry_); + clipHexEntry_.clear(); + if (color.isValid()) + setClipFill(color); + else + setStatus(QStringLiteral("Invalid hex colour")); + event->accept(); + update(); + return; + } + if (key == Qt::Key_Backspace) { + clipHexEntry_.chop(1); + if (clipHexEntry_.isEmpty()) + clipHexEntry_ = QStringLiteral("#"); + setStatus(QStringLiteral("Hole fill %1").arg(clipHexEntry_.toUpper())); + event->accept(); + update(); + return; + } + const QString text = event->text().toUpper(); + if (text.size() == 1) { + const QChar ch = text.at(0); + if (ch.isDigit() || (ch >= QLatin1Char('A') && ch <= QLatin1Char('F'))) { + if (clipHexEntry_.size() < 7) + clipHexEntry_ += ch; + setStatus(QStringLiteral("Hole fill %1").arg(clipHexEntry_)); + event->accept(); + update(); + return; + } + } + event->accept(); + return; + } if (event->key() == Qt::Key_Shift && phase_ == Phase::Edit && dragging_) { // Shift pressed mid-drag constrains the drag: creation for drawing // tools, handle resizing for the Select tool. @@ -3691,6 +4747,23 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { return; } + if (clipLiftActive_) { + const int key = event->key(); + const bool modifierOnly = key == Qt::Key_Shift || key == Qt::Key_Control || + key == Qt::Key_Alt || key == Qt::Key_Meta; + // QTest (and real keyboards) send the modifier press before Z. Cancelling + // on Control alone would leave the lift inactive so Ctrl+Z then undoes. + if (modifierOnly) { + event->accept(); + return; + } + cancelClipLift(); + dragging_ = false; + setStatus(QStringLiteral("Clip cancelled")); + updatePointerCursor(); + update(); + return; + } if (cutDragActive_) { // Any key here (Esc's own cancel already returned above) leaves the // A tool-switch key would otherwise leave the preview active because the @@ -3801,7 +4874,12 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { selectedAnnotations_.clear(); selectedAnnotation_ = -1; } else if (event->key() == Qt::Key_V) { - tool_ = Tool::Select; + if (tool_ == Tool::Select) + cyclePixelClipShape(); + else { + tool_ = Tool::Select; + pixelClipShape_ = PixelClipShape::Rect; + } } else if (event->matches(QKeySequence::SelectAll)) { selectAllAnnotations(); } else if (event->key() == Qt::Key_A) { @@ -3878,13 +4956,24 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { event->modifiers() == Qt::ShiftModifier) { cycleTextFont(); } else if (event->key() == Qt::Key_T) { - const bool textSelected = - selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && - annotations_.at(selectedAnnotation_).kind == Annotation::Kind::Text; - if (tool_ == Tool::Text || textSelected) - toggleTextBackground(); - else - tool_ = Tool::Text; + if (!pixelClipRect_.isEmpty() && !clipLiftActive_ && + (tool_ == Tool::Select || tool_ == Tool::Eyedropper)) { + cycleClipHoleFill(); + } else { + const bool textSelected = + selectedAnnotation_ >= 0 && + selectedAnnotation_ < annotations_.size() && + annotations_.at(selectedAnnotation_).kind == Annotation::Kind::Text; + if (tool_ == Tool::Text || textSelected) + toggleTextBackground(); + else + tool_ = Tool::Text; + } + } else if (event->key() == Qt::Key_NumberSign && + tool_ == Tool::Select && !pixelClipRect_.isEmpty() && + !clipLiftActive_) { + clipHexEntry_ = QStringLiteral("#"); + setStatus(QStringLiteral("Hole fill # · type hex · Enter commits")); } else if (event->key() == Qt::Key_I) { if (tool_ != Tool::Eyedropper) toolBeforeEyedropper_ = tool_; @@ -3909,9 +4998,12 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { } else if (event->key() >= Qt::Key_1 && event->key() <= Qt::Key_8) { colorIndex_ = event->key() - Qt::Key_1; usingCustomColor_ = false; - if (selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && - annotations_.at(selectedAnnotation_).kind != - Annotation::Kind::Redaction) { + if (!pixelClipRect_.isEmpty() && !clipLiftActive_) { + setClipFill(annotationColor()); + } else if (selectedAnnotation_ >= 0 && + selectedAnnotation_ < annotations_.size() && + annotations_.at(selectedAnnotation_).kind != + Annotation::Kind::Redaction) { annotations_[selectedAnnotation_].color = annotationColor(); commitPatch({selectedAnnotation_}); } @@ -3924,6 +5016,8 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { // specific (a restyled layer, a toggled fill) have already set it. if (tool_ != toolBefore && status_ == statusBefore) setStatus(toolStatus()); + if (tool_ != Tool::Select && tool_ != Tool::Eyedropper) + clearPixelClip(); updatePointerCursor(); update(); } @@ -3974,8 +5068,110 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { if (!dragging_) updatePointerCursor(); } else { + if (tool_ == Tool::Select && clipLiftActive_) { + const QRect previous = clipLiftRepaintRect(); + updateClipLift(toUnclampedAnnotationPoint(cursor_)); + update(previous.united(clipLiftRepaintRect())); + return; + } + if (tool_ == Tool::Select && pixelClipResizing_ && dragging_) { + Annotation box; + box.kind = Annotation::Kind::Rectangle; + box.start = originalPixelClip_.topLeft(); + box.end = originalPixelClip_.bottomRight(); + applyBoxResize(box, interaction_, toUnclampedAnnotationPoint(cursor_), + originalPixelClip_); + QRectF updated = QRectF(box.start, box.end).normalized(); + const QRectF bounds(QPointF(), selection_.size()); + updated = updated.intersected(bounds); + if (pixelClipLargeEnough(updated)) { + pixelClipRect_ = updated; + if (originalPixelClipRadius_ > 0.0 && + originalPixelClip_.width() > 0.0 && + originalPixelClip_.height() > 0.0) { + const qreal f = std::min(updated.width() / originalPixelClip_.width(), + updated.height() / originalPixelClip_.height()); + pixelClipRadius_ = originalPixelClipRadius_ * f; + } + if (!originalPixelClipPoints_.isEmpty() && + originalPixelClip_.width() > 0.0 && + originalPixelClip_.height() > 0.0) { + pixelClipPoints_.clear(); + for (const QPointF &point : originalPixelClipPoints_) { + const qreal nx = + (point.x() - originalPixelClip_.left()) / + originalPixelClip_.width(); + const qreal ny = + (point.y() - originalPixelClip_.top()) / + originalPixelClip_.height(); + pixelClipPoints_.push_back( + QPointF(updated.left() + nx * updated.width(), + updated.top() + ny * updated.height())); + } + } + } + update(); + return; + } if (tool_ == Tool::Select && marqueeSelecting_) { - marqueeRect_ = QRectF(dragStart_, toAnnotationPoint(cursor_)).normalized(); + const QPointF point = toAnnotationPoint(cursor_); + marqueeRect_ = QRectF(dragStart_, point).normalized(); + if (pixelClipShape_ == PixelClipShape::Lasso) { + if (pixelClipPoints_.isEmpty()) + pixelClipPoints_.push_back(dragStart_); + if (QLineF(pixelClipPoints_.constLast(), point).length() >= 1.0) + pixelClipPoints_.push_back(point); + } + const QRectF area = pixelClipDragBounds(); + bool hitsLayer = false; + if (pixelClipLargeEnough(area)) { + for (const Annotation &annotation : annotations_) { + const QRectF bounds = annotationBounds(annotation); + if (!bounds.isEmpty() && area.contains(bounds)) { + hitsLayer = true; + break; + } + } + } + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); + if (!hitsLayer && pixelClipSnapEnabled_ && pixelClipLargeEnough(area)) { + if (const auto snapped = + snapLogicalObjectAt(area.center(), area, pixelClipShape_)) { + const bool fits = + pixelClipShape_ == PixelClipShape::Ellipse + ? clipTraceSnapFits(area, snapped->box) + : clipRectTraceSnapFits(area, snapped->box); + const bool lassoOk = pixelClipShape_ != PixelClipShape::Lasso || + snapped->contour.size() >= 8; + if (fits && lassoOk) { + pixelClipTraceSnap_ = snapped->box; + pixelClipTraceRadius_ = snapped->radius; + pixelClipTracePoints_ = snapped->contour; + } + } + } + const QString next = + pixelClipTraceSnap_ + ? pixelClipShape_ == PixelClipShape::Lasso + ? QStringLiteral("Snapped to outline · release to lock · " + "then drag inside to lift") + : pixelClipShape_ == PixelClipShape::Rect + ? QStringLiteral("Snapped to rectangle · release to lock · " + "then drag inside to lift") + : QStringLiteral("Snapped to circle · release to lock · " + "then drag inside to lift") + : !hitsLayer && pixelClipLargeEnough(area) + ? QStringLiteral( + "Release to lock these pixels · then drag inside to lift") + : QStringLiteral("Drag to select layers"); + if (status_ != next) + status_ = next; + if (pixelClipTraceSnap_) + startSnapTrace(true); + else if (snapTraceLoop_) + stopSnapTrace(); update(); return; } @@ -4368,9 +5564,17 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { return; } } - if (tool_ == Tool::Select && selectedAnnotations_.isEmpty()) { + for (const ToolbarButton &button : clipShapeStripButtons()) { + if (button.rect.contains(cursor_)) { + handleToolbar(button.action); + return; + } + } + if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && + !clipLiftActive_ && pixelClipRect_.isEmpty()) { const int cropHandle = cropHandleAt(cursor_); if (cropHandle >= 0) { + clearPixelClip(); originalSelection_ = selection_; cropDragImageRect_ = sourceFrameWidgetRect(); interaction_ = static_cast( @@ -4404,6 +5608,8 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { usingCustomColor_ = true; if (customColor_.hsvHueF() >= 0) customHue_ = customColor_.hsvHueF(); + if (!pixelClipRect_.isEmpty() && !clipLiftActive_) + setClipFill(customColor_); if (selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && annotations_.at(selectedAnnotation_).kind != Annotation::Kind::Redaction && @@ -4420,13 +5626,46 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { // recolored selected so another color can be tried on it. Dropping both // meant that taking a color cost you your place twice over. tool_ = toolBeforeEyedropper_; - setStatus(QStringLiteral("Sampled %1").arg( - customColor_.name(QColor::HexRgb).toUpper())); + if (pixelClipRect_.isEmpty() || clipLiftActive_) + setStatus(QStringLiteral("Sampled %1").arg( + customColor_.name(QColor::HexRgb).toUpper())); updatePointerCursor(); update(); return; } if (tool_ == Tool::Select) { + if (!pixelClipRect_.isEmpty()) { + const QRectF locked = pixelClipRect_.normalized(); + const qreal scale = std::max(editScale(), 0.01); + const qreal pad = std::min({6.0 / scale, locked.width() / 4.0, + locked.height() / 4.0}); + const QRectF inner = locked.adjusted(pad, pad, -pad, -pad); + if (inner.width() >= 1.0 && inner.height() >= 1.0 && + inner.contains(point)) { + beginClipLift(point); + update(); + return; + } + const Interaction clipHandle = pixelClipHandleAt(point); + if (clipHandle != Interaction::None) { + pixelClipResizing_ = true; + originalPixelClip_ = locked; + originalPixelClipPoints_ = pixelClipPoints_; + originalPixelClipRadius_ = pixelClipRadius_; + interaction_ = clipHandle; + dragStart_ = point; + dragging_ = true; + setStatus(QStringLiteral("Resize the clip region · drag inside to lift")); + update(); + return; + } + if (locked.contains(point)) { + beginClipLift(point); + update(); + return; + } + clearPixelClip(); + } const bool additive = heldModifiers(event->modifiers()).testFlag(Qt::ControlModifier) || heldModifiers(event->modifiers()).testFlag(Qt::MetaModifier); @@ -4460,12 +5699,23 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { } if (hit < 0) { + if ((event->modifiers() & Qt::AltModifier) && + pixelClipRect_.isEmpty()) { + trySnapAt(point); + update(); + return; + } marqueeSelecting_ = true; marqueeAdditive_ = additive; marqueeRect_ = QRectF(point, point); dragStart_ = point; dragging_ = true; interaction_ = Interaction::None; + if (pixelClipShape_ == PixelClipShape::Lasso) + pixelClipPoints_ = {point}; + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); setStatus(QStringLiteral("Drag to select layers")); update(); return; @@ -4670,8 +5920,22 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { return; } if (tool_ == Tool::Select) { + if (clipLiftActive_) { + finishClipLift(); + update(); + return; + } + if (pixelClipResizing_) { + pixelClipResizing_ = false; + dragging_ = false; + interaction_ = Interaction::None; + setStatus(QStringLiteral("Selection ready — drag inside to clip out")); + updatePointerCursor(); + update(); + return; + } if (marqueeSelecting_) { - const QRectF area = marqueeRect_.normalized(); + const QRectF area = pixelClipDragBounds(); QVector matches; if (area.width() >= 2.0 && area.height() >= 2.0) { for (int index = 0; index < annotations_.size(); ++index) { @@ -4693,6 +5957,98 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { marqueeRect_ = {}; dragging_ = false; interaction_ = Interaction::None; + if (matches.isEmpty() && !pixelClipLargeEnough(area) && + pixelClipSnapEnabled_) { + trySnapAt(dragStart_); + pixelClipPoints_.clear(); + updatePointerCursor(); + update(); + return; + } + if (matches.isEmpty() && pixelClipLargeEnough(area)) { + const QRectF bounds(QPointF(), selection_.size()); + const QRectF clipped = area.intersected(bounds); + if (pixelClipLargeEnough(clipped)) { + pixelClipRect_ = clipped; + selectedAnnotations_.clear(); + selectedAnnotation_ = -1; + if (pixelClipShape_ == PixelClipShape::Lasso) { + if (pixelClipPoints_.size() < 3) { + pixelClipPoints_.clear(); + pixelClipRect_ = {}; + pixelClipLockedEllipse_ = false; + } else { + pixelClipLockedEllipse_ = false; + } + } else { + pixelClipPoints_.clear(); + pixelClipLockedEllipse_ = + pixelClipShape_ == PixelClipShape::Ellipse; + } + bool snappedTrace = false; + if (pixelClipSnapEnabled_ && !pixelClipRect_.isEmpty()) { + const auto snapped = + snapLogicalObjectAt(pixelClipRect_.center(), pixelClipRect_, + pixelClipShape_); + if (snapped && pixelClipLargeEnough(snapped->box)) { + const bool fits = + pixelClipShape_ == PixelClipShape::Ellipse + ? clipTraceSnapFits(pixelClipRect_, snapped->box) + : clipRectTraceSnapFits(pixelClipRect_, snapped->box); + const bool lassoOk = pixelClipShape_ != PixelClipShape::Lasso || + snapped->contour.size() >= 8; + if (fits && lassoOk) { + pixelClipRect_ = snapped->box; + if (pixelClipShape_ == PixelClipShape::Lasso) { + pixelClipPoints_ = snapped->contour; + pixelClipLockedEllipse_ = false; + pixelClipRadius_ = 0; + } else if (pixelClipShape_ == PixelClipShape::Ellipse) { + pixelClipPoints_.clear(); + pixelClipLockedEllipse_ = true; + pixelClipRadius_ = 0; + } else { + pixelClipPoints_.clear(); + pixelClipLockedEllipse_ = false; + pixelClipRadius_ = snapped->radius; + } + snappedTrace = true; + } + } + } + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); + if (!pixelClipRect_.isEmpty()) { + applyDefaultClipFill(); + const QString kind = pixelClipShape_ == PixelClipShape::Lasso + ? QStringLiteral("outline") + : pixelClipShape_ == PixelClipShape::Ellipse + ? QStringLiteral("ellipse") + : QStringLiteral("rectangle"); + setStatus(snappedTrace + ? QStringLiteral( + "Snapped to %1 · pick a hole fill or drag " + "inside to clip out · Esc cancels") + .arg(kind) + : QStringLiteral( + "Selection ready — pick a hole fill or drag " + "inside to clip out · Esc cancels")); + if (snappedTrace) + startSnapTrace(false); + else + stopSnapTrace(); + updatePointerCursor(); + update(); + return; + } + } + } + pixelClipPoints_.clear(); + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); + stopSnapTrace(); setStatus(selectedAnnotations_.isEmpty() ? QStringLiteral("No layers selected") : QStringLiteral("%1 layers selected · drag to move") @@ -5060,7 +6416,8 @@ void CaptureEditor::updatePointerCursor() { } if ((colorPaletteOpen_ && colorPaletteRect().contains(cursor_)) || (customColorPickerOpen_ && customColorPanelRect().contains(cursor_)) || - (shapeMenuOpen_ && shapeMenuRect().contains(cursor_))) { + (shapeMenuOpen_ && shapeMenuRect().contains(cursor_)) || + clipFillMenuRect().contains(cursor_)) { setCursor(Qt::PointingHandCursor); return; } @@ -5081,8 +6438,27 @@ void CaptureEditor::updatePointerCursor() { return; } if (tool_ == Tool::Select) { + if (clipLiftActive_) { + setCursor(Qt::ClosedHandCursor); + return; + } + if (!pixelClipRect_.isEmpty()) { + const QPointF point = toAnnotationPoint(cursor_); + const Interaction handle = pixelClipHandleAt(point); + if (handle != Interaction::None) { + setCursor(handleCursorShape(handle)); + return; + } + if (pixelClipRect_.normalized().contains(point)) { + setCursor(Qt::OpenHandCursor); + return; + } + } int cropHandle = - selectedAnnotations_.isEmpty() ? cropHandleAt(cursor_) : -1; + selectedAnnotations_.isEmpty() && !clipLiftActive_ && + pixelClipRect_.isEmpty() + ? cropHandleAt(cursor_) + : -1; if (dragging_ && interaction_ >= Interaction::CropTopLeft) { cropHandle = static_cast(interaction_) - static_cast(Interaction::CropTopLeft); @@ -5126,8 +6502,18 @@ void CaptureEditor::updatePointerCursor() { } void CaptureEditor::refreshComposedCapture() { - capture_.source = composeCuts(pristineSource_, cuts_); + QImage composed = pristineSource_; + for (int index = 0; index < opIndex_ && index < ops_.size(); ++index) { + const Operation &op = ops_.at(index); + if (op.type == Operation::Type::Cut) + composed = removeBand(composed, op.cut.orientation, op.cut.sourceStart, + op.cut.sourceEnd); + else if (op.type == Operation::Type::Clip) + fillHole(composed, op.clip); + } + capture_.source = std::move(composed); capture_.previewSize = composedLogicalSize(pristineLogicalSize_, cuts_); + composedPrefixHash_ = composedMutationHash(ops_, opIndex_); backdropKey_ = 0; // force backdrop pixmap rebuild redactionBaseStale_ = true; // force redaction layer rebuild update(); @@ -5369,6 +6755,7 @@ void CaptureEditor::returnToSelect(bool windowMode) { editingAnnotation_ = -1; dragging_ = false; cutDragActive_ = false; + clearPixelClip(); marqueeSelecting_ = false; interaction_ = Interaction::None; colorPaletteOpen_ = false; @@ -5817,14 +7204,16 @@ void CaptureEditor::paintEdit(QPainter &painter) { // image, the toolbar, a popup) simply covers it wherever they overlap. drawHotkeyLegend( painter, rect(), - {{QStringLiteral("V"), QStringLiteral("Select / move layer")}, + {{QStringLiteral("V"), QStringLiteral("Select / cycle clip shape")}, + {QStringLiteral("Snap"), QStringLiteral("Toggle · click object to fit")}, {QStringLiteral("A"), QStringLiteral("Arrow")}, {QStringLiteral("L"), QStringLiteral("Line")}, {QStringLiteral("F / H"), QStringLiteral("Freehand / Highlighter")}, {QStringLiteral("C"), QStringLiteral("Marker")}, {QStringLiteral("R / E"), QStringLiteral("Rectangle / Ellipse")}, {QStringLiteral("X"), QStringLiteral("Cut out a band")}, - {QStringLiteral("T"), QStringLiteral("Text")}, + {QStringLiteral("T"), + QStringLiteral("Text · T cycles hole fill when clipping")}, {QStringLiteral("Double click"), QStringLiteral("Edit text layer")}, {QStringLiteral("1–8"), QStringLiteral("Color")}, {QStringLiteral("Wheel"), QStringLiteral("Zoom selected / tool size")}, @@ -5919,6 +7308,8 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.drawImage(sourceImage, redactionLayer); else painter.drawImage(sourceImage, capture_.source, sourceRect(selection_)); + if ((clipLiftActive_ || !pixelClipRect_.isEmpty()) && !snapTraceRevealing_) + paintClipHolePreview(painter, sourceImage); painter.restore(); @@ -6093,10 +7484,84 @@ void CaptureEditor::paintEdit(QPainter &painter) { if (tool_ == Tool::Select && marqueeSelecting_ && !marqueeRect_.isEmpty()) { const qreal scale = std::max(editScale(), 0.01); + if (pixelClipTraceSnap_ && snapAnimTimer_.isActive()) { + paintSnapTrace(painter, scale); + } else { painter.setPen( QPen(QColor(QStringLiteral("#0a84ff")), 2.0 / scale)); painter.setBrush(QColor(10, 132, 255, 38)); - painter.drawRect(marqueeRect_.normalized()); + const QRectF box = pixelClipTraceSnap_ ? *pixelClipTraceSnap_ + : marqueeRect_.normalized(); + if (pixelClipTraceSnap_ && pixelClipShape_ == PixelClipShape::Lasso && + pixelClipTracePoints_.size() >= 2) { + QPainterPath path; + path.moveTo(pixelClipTracePoints_.constFirst()); + for (int i = 1; i < pixelClipTracePoints_.size(); ++i) + path.lineTo(pixelClipTracePoints_.at(i)); + path.closeSubpath(); + painter.drawPath(path); + } else if (pixelClipShape_ == PixelClipShape::Lasso && + pixelClipPoints_.size() >= 2 && !pixelClipTraceSnap_) { + QPainterPath path; + path.moveTo(pixelClipPoints_.constFirst()); + for (int i = 1; i < pixelClipPoints_.size(); ++i) + path.lineTo(pixelClipPoints_.at(i)); + painter.drawPath(path); + } else if (pixelClipShape_ == PixelClipShape::Ellipse || + (pixelClipTraceSnap_ && + pixelClipShape_ == PixelClipShape::Ellipse)) { + painter.drawEllipse(box); + } else if (pixelClipTraceSnap_ && pixelClipTraceRadius_ >= 1.0) { + const qreal r = std::min({pixelClipTraceRadius_, box.width() / 2.0, + box.height() / 2.0}); + painter.drawRoundedRect(box, r, r); + } else { + painter.drawRect(box); + } + } + } + if ((tool_ == Tool::Select || tool_ == Tool::Eyedropper) && + !pixelClipRect_.isEmpty() && !clipLiftActive_) { + const qreal scale = std::max(editScale(), 0.01); + if (snapTraceRevealing_ && snapAnimTimer_.isActive()) { + paintSnapTrace(painter, scale); + } else { + const QPainterPath path = logicalClipPath(); + if (clipFillOpaque(clipFill_)) { + QColor overlay = clipFill_; + overlay.setAlpha(std::min(200, overlay.alpha())); + painter.setPen(Qt::NoPen); + painter.setBrush(overlay); + painter.drawPath(path); + } else { + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 214, 10, 40)); + painter.drawPath(path); + } + painter.setBrush(Qt::NoBrush); + painter.setPen( + QPen(QColor(20, 20, 22, 230), 3.2 / scale, Qt::DashLine)); + painter.drawPath(path); + painter.setPen( + QPen(QColor(QStringLiteral("#ffd60a")), 1.6 / scale, Qt::DashLine)); + painter.drawPath(path); + painter.setPen(QPen(Qt::white, 1.0 / scale)); + painter.setBrush(QColor(QStringLiteral("#0a84ff"))); + const qreal radius = 5.0 / scale; + Annotation box; + box.kind = Annotation::Kind::Rectangle; + box.start = pixelClipRect_.topLeft(); + box.end = pixelClipRect_.bottomRight(); + for (const auto &item : annotationHandles(box)) + painter.drawEllipse(item.first, radius, radius); + } + } + if (clipLiftActive_ && clipLiftSnapped_ && !clipLiftOrigin_.isEmpty()) { + const qreal scale = std::max(editScale(), 0.01); + painter.setBrush(QColor(255, 214, 10, 36)); + painter.setPen( + QPen(QColor(QStringLiteral("#ffd60a")), 1.6 / scale, Qt::DashLine)); + painter.drawRect(clipLiftOrigin_); } if (cutDragActive_) { const qreal scale = std::max(editScale(), 0.01); @@ -6195,10 +7660,33 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.restore(); paintOcrOverlay(painter, sourceImage, editScale()); + if (clipLiftActive_ && !clipLiftTile_.isNull()) { + if (!qFuzzyCompare(clipLiftPixmapScale_, editScale())) + rebuildClipLiftPixmap(); + const qreal scale = std::max(editScale(), 0.01); + const QRectF dest(sourceImage.topLeft() + clipLiftDest_.topLeft() * scale, + clipLiftDest_.size() * scale); + if (!clipLiftSnapped_) { + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 90)); + painter.drawRect(dest.translated(2.0, 3.0)); + } + if (!clipLiftPixmap_.isNull()) + painter.drawPixmap(dest.topLeft(), clipLiftPixmap_); + else + painter.drawImage(dest, clipLiftTile_); + painter.setPen(QPen(QColor(255, 255, 255, 210), 1.0)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(dest); + } + // Screenshot chrome means "crop this source", not "this is another // selected object". Keep it out of the layer-selection state entirely; // clicking empty canvas puts the layers down and brings cropping back. - if (tool_ == Tool::Select && selectedAnnotations_.isEmpty()) { + // A locked clip path is the selection (rounded when Snap finds a + // corner radius), so hide crop handles until the mask is cleared. + if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && + !clipLiftActive_ && pixelClipRect_.isEmpty()) { painter.setPen(QPen(QColor(QStringLiteral("#0a84ff")), 1, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawRect(image.adjusted(-1, -1, 1, 1)); @@ -6222,6 +7710,11 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.setBrush(QColor(22, 22, 28, 248)); painter.drawRoundedRect(colorPaletteRect(), 9, 9); } + if (const QRectF clipFillMenu = clipFillMenuRect(); !clipFillMenu.isEmpty()) { + painter.setPen(QPen(QColor(255, 255, 255, 34), 1)); + painter.setBrush(QColor(22, 22, 28, 248)); + painter.drawRoundedRect(clipFillMenu, 9, 9); + } if (customColorPickerOpen_) { const QRectF panel = customColorPanelRect(); const QRectF field = panel.adjusted(12, 12, -36, -12); @@ -6303,6 +7796,7 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.setFont(buttonFont); QVector toolbarDividers; const QVector buttons = toolbarButtons(&toolbarDividers); + const QVector clipStrip = clipShapeStripButtons(); if (!toolbarDividers.isEmpty()) { const qreal scale = toolbarScale(width()); const qreal barHeight = 36 * scale; @@ -6326,7 +7820,17 @@ void CaptureEditor::paintEdit(QPainter &painter) { (button.action == QStringLiteral("custom-color") && usingCustomColor_) || (!usingCustomColor_ && - button.action == QStringLiteral("color-%1").arg(colorIndex_)); + button.action == QStringLiteral("color-%1").arg(colorIndex_)) || + (button.action == QStringLiteral("clip-fill-transparent") && + !clipFillOpaque(clipFill_)) || + (button.action == QStringLiteral("clip-fill-custom") && + clipFillOpaque(clipFill_) && usingCustomColor_ && + clipFill_ == customColor_) || + (button.action.startsWith(QStringLiteral("clip-fill-")) && + button.action != QStringLiteral("clip-fill-transparent") && + button.action != QStringLiteral("clip-fill-custom") && + clipFillOpaque(clipFill_) && button.color.isValid() && + button.color.rgb() == clipFill_.rgb()); const bool hovered = button.rect.contains(cursor_); if (hovered) hoveredButton = &button; @@ -6337,7 +7841,20 @@ void CaptureEditor::paintEdit(QPainter &painter) { if (button.action == QStringLiteral("both")) painter.setBrush(QColor(QStringLiteral("#0a84ff"))); painter.drawRoundedRect(button.rect, 8, 8); - if (button.color.isValid()) { + if (button.action == QStringLiteral("clip-fill-transparent")) { + const QPointF center = button.rect.center(); + const QRectF swatch(center.x() - 7, center.y() - 7, 14, 14); + painter.setPen(QPen(selected ? Qt::white : QColor(255, 255, 255, 80), + selected ? 2 : 1)); + painter.setBrush(Qt::NoBrush); + painter.save(); + QPainterPath clip; + clip.addEllipse(swatch); + painter.setClipPath(clip); + paintCheckerboard(painter, swatch, 3.5); + painter.restore(); + painter.drawEllipse(swatch.center(), 7, 7); + } else if (button.color.isValid()) { const QPointF center = button.rect.center(); painter.setPen(QPen(selected ? Qt::white : QColor(255, 255, 255, 80), selected ? 2 : 1)); @@ -6355,6 +7872,31 @@ void CaptureEditor::paintEdit(QPainter &painter) { QColor(245, 245, 247)); } } + if (!clipStrip.isEmpty()) { + QFont chipFont = QFontDatabase::systemFont(QFontDatabase::GeneralFont); + chipFont.setPixelSize(10); + chipFont.setBold(true); + painter.setFont(chipFont); + for (const ToolbarButton &button : clipStrip) { + const bool on = + (button.action == QStringLiteral("clip-shape-rect") && + pixelClipShape_ == PixelClipShape::Rect) || + (button.action == QStringLiteral("clip-shape-ellipse") && + pixelClipShape_ == PixelClipShape::Ellipse) || + (button.action == QStringLiteral("clip-shape-lasso") && + pixelClipShape_ == PixelClipShape::Lasso) || + (button.action == QStringLiteral("clip-shape-snap") && + pixelClipSnapEnabled_); + painter.setPen(QPen(QColor(255, 255, 255, 34), 1)); + painter.setBrush(on ? QColor(QStringLiteral("#0a84ff")) + : QColor(30, 32, 38, 230)); + painter.drawRoundedRect(button.rect, 9, 9); + painter.setPen(QColor(QStringLiteral("#f5f5f7"))); + painter.drawText(button.rect, Qt::AlignCenter, button.label); + if (button.rect.contains(cursor_)) + hoveredButton = &button; + } + } if (textSizeMenuOpen_ && !colorPaletteOpen_ && !customColorPickerOpen_) { const QRectF panel = textSizePanelRect(); painter.setPen(QPen(QColor(255, 255, 255, 34), 1)); diff --git a/src/editor.hpp b/src/editor.hpp index 4941f7d6..b3105ad8 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -151,6 +152,7 @@ class CaptureEditor final : public QWidget { Ocr, Eyedropper }; + enum class PixelClipShape { Rect, Ellipse, Lasso }; private: enum class Phase { Select, Export, Edit }; @@ -267,6 +269,32 @@ class CaptureEditor final : public QWidget { /// Apply a cut as if the user had dragged that band. Test hook: operate on /// a fixture raster without going through widget coordinates. void applyCutForTest(CutOp cut) { commitCut(std::move(cut)); } + /// Clip `logicalRect` (annotation space) out of the source and place the + /// tile at `dest`. Test hook: no widget drag. + void applyClipForTest(const QRectF &logicalRect, const QRectF &dest, + const QColor &fill = {}); + /// Overwrite the last Clip op's stored tile, then replay. Test hook for + /// "replay keeps a present tile instead of copyRect". + void markLastClipTileForTest(const QColor &color); + void replayLogForTest() { replayLog(); } + /// Pixel-marquee awaiting a lift. Test accessor. + [[nodiscard]] QRectF pixelClipRectForTest() const { return pixelClipRect_; } + [[nodiscard]] PixelClipShape pixelClipShapeForTest() const { + return pixelClipShape_; + } + [[nodiscard]] QVector pixelClipPointsForTest() const { + return pixelClipPoints_; + } + [[nodiscard]] bool pixelClipSnapEnabledForTest() const { + return pixelClipSnapEnabled_; + } + [[nodiscard]] ClipOp lockedClipOpForTest() const { return lockedClipOp(); } + [[nodiscard]] bool clipLiftActiveForTest() const { return clipLiftActive_; } + [[nodiscard]] QColor clipFillForTest() const { return clipFill_; } + [[nodiscard]] QRectF clipFillMenuRectForTest() const { + return clipFillMenuRect(); + } + [[nodiscard]] QImage composedSourceForTest() const { return capture_.source; } /// Number of selected layers. Test accessor. [[nodiscard]] int selectedCountForTest() const { return static_cast(selectedAnnotations_.size()); @@ -343,6 +371,9 @@ class CaptureEditor final : public QWidget { for (const ToolbarButton &button : toolbarButtons()) if (button.action == action) return button.rect; + for (const ToolbarButton &button : clipShapeStripButtons()) + if (button.action == action) + return button.rect; return {}; } [[nodiscard]] QRectF colorPaletteRectForTest() const { @@ -538,7 +569,48 @@ class CaptureEditor final : public QWidget { void commitDelete(const QVector &indices); void commitCrop(const QRectF &crop); void commitCut(CutOp cut); + void commitClip(ClipOp clip, Annotation annotation); void commitBackground(BackgroundStyle style, bool imageShadow); + void clearPixelClip(); + void cyclePixelClipShape(); + void setPixelClipShape(PixelClipShape shape); + void setPixelClipSnapEnabled(bool enabled); + [[nodiscard]] QString clipShapeStatus(bool hadLock) const; + [[nodiscard]] ClipOp lockedClipOp() const; + [[nodiscard]] QPainterPath logicalClipPath() const; + [[nodiscard]] QRectF pixelClipDragBounds() const; + struct LogicalSnap { + QRectF box; + qreal radius = 0; + QVector contour; + }; + [[nodiscard]] std::optional + mapNativeSnap(const std::optional &box) const; + [[nodiscard]] std::optional + snapLogicalObjectAt(const QPointF &annotationPoint, + const QRectF &logicalRoi = {}, + PixelClipShape shape = PixelClipShape::Rect) const; + void trySnapAt(const QPointF &annotationPoint, bool resetFill = true); + void beginClipLift(const QPointF &point); + void updateClipLift(const QPointF &point); + void finishClipLift(); + void cancelClipLift(); + void rebuildClipLiftPixmap(); + void punchDisplayCaches(QRect nativeRect, const QColor &fill); + [[nodiscard]] QRect nativeRectForPixelClip(const QRectF &logical) const; + [[nodiscard]] bool pixelClipLargeEnough(const QRectF &logical) const; + [[nodiscard]] QVector clipShapeStripButtons() const; + [[nodiscard]] Interaction pixelClipHandleAt(const QPointF &point) const; + [[nodiscard]] QRectF pixelClipWidgetRect() const; + [[nodiscard]] QRectF clipFillMenuRect() const; + [[nodiscard]] QColor clipSurroundingFill() const; + [[nodiscard]] QRect clipLiftRepaintRect() const; + void paintClipHolePreview(QPainter &painter, + const QRectF &sourceImage) const; + void setClipFill(const QColor &fill); + void applyDefaultClipFill(); + void armClipFillEyedropper(); + void cycleClipHoleFill(); void commitCanvasBoundary(CanvasBoundaryMode mode); void cycleCanvasBoundary(bool reverse); void cycleBackground(); @@ -556,6 +628,10 @@ class CaptureEditor final : public QWidget { void runOcr(const QRectF &localSelection = {}); void dismissOcrOverlay(); void paintOcrOverlay(QPainter &painter, const QRectF &image, qreal scale); + void startSnapTrace(bool loop); + void stopSnapTrace(); + [[nodiscard]] QPainterPath snapTracePath() const; + void paintSnapTrace(QPainter &painter, qreal scale) const; void setStatus(QString status); void toggleShapeFill(); void toggleTextBackground(); @@ -651,6 +727,39 @@ class CaptureEditor final : public QWidget { // px); the source stays untouched while a shaded removal band previews the // drag, and the cut is applied only when the pointer is released. bool cutDragActive_ = false; + /// Select-tool pixel clip: empty-canvas marquee that hit no layers becomes + /// a locked path on the source. Dragging it lifts a clip layer. + PixelClipShape pixelClipShape_ = PixelClipShape::Rect; + bool pixelClipSnapEnabled_ = true; + bool pixelClipLockedEllipse_ = false; + std::optional pixelClipTraceSnap_; + qreal pixelClipTraceRadius_ = 0; + QVector pixelClipTracePoints_; + QRectF pixelClipRect_; + QRectF originalPixelClip_; + QVector pixelClipPoints_; + QVector originalPixelClipPoints_; + qreal pixelClipRadius_ = 0; + qreal originalPixelClipRadius_ = 0; + bool pixelClipResizing_ = false; + bool clipLiftActive_ = false; + QRectF clipLiftOrigin_; + QRectF clipLiftDest_; + QPointF clipLiftGrabOffset_; + QImage clipLiftTile_; + QPixmap clipLiftPixmap_; + qreal clipLiftPixmapScale_ = 0.0; + ClipOp clipLiftOp_; + bool clipLiftSnapped_ = false; + /// Hole infill for the next clip. Transparent (alpha 0) is the default. + QColor clipFill_ = QColor(0, 0, 0, 0); + QString clipHexEntry_; + /// Hash of Cut+Clip ops in `ops_[0, opIndex_)`. When it matches, replay + /// keeps `capture_.source` and the display caches instead of rebuilding. + quint64 composedPrefixHash_ = 0; + /// Per-clip-id hash of the Cut+Clip prefix *before* that op. A mismatch + /// means the tile must be recopied from the composed image. + QHash clipTilePrefix_; QPointF cutDragStart_; CutOp liveCut_; qreal cutBandLo_ = 0.0; @@ -803,6 +912,10 @@ class CaptureEditor final : public QWidget { QElapsedTimer ocrClock_; QTimer ocrAnimTimer_; QTimer ocrResultTimer_; + QTimer snapAnimTimer_; + QElapsedTimer snapAnimClock_; + bool snapTraceLoop_ = false; + bool snapTraceRevealing_ = false; }; [[nodiscard]] QPointF constrainedCreationEndpoint(CaptureEditor::Tool tool, diff --git a/tests/clip-mapping-smoke.cpp b/tests/clip-mapping-smoke.cpp new file mode 100644 index 00000000..d3eb9973 --- /dev/null +++ b/tests/clip-mapping-smoke.cpp @@ -0,0 +1,881 @@ +/** @fileoverview Drives pixel-clip: marquee, lift, hole, backdrop, undo. + * + * A banded fixture makes the torn-off tile and the hole checkable by colour. + * Direct applyClipForTest covers the operation log; a widget drag covers the + * empty-marquee → lift path. Failures name the first wrong pixel. + */ +#include "clip-mapping-smoke.hpp" + +#include "capture.hpp" +#include "clip.hpp" +#include "editor.hpp" +#include "image-fixture.hpp" + +#include +#include +#include +#include +#include + +namespace { + +CaptureData fixtureCapture(const QImage &source) { + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.geometry = {0, 0, 800, 600}; + capture.monitor.pixelSize = {800, 600}; + capture.monitor.scale = 1.0; + capture.source = source.convertToFormat(QImage::Format_ARGB32_Premultiplied); + capture.previewSize = source.size(); + return capture; +} + +QPoint screenOf(const CaptureEditor &editor, qreal ax, qreal ay) { + return editor.annotationPointToWidgetForTest(QPointF(ax, ay)).toPoint(); +} + +bool saveGrab(CaptureEditor &editor, const QString &path, QString &error) { + const QImage grab = editor.grab().toImage(); + if (grab.save(path, "PNG")) + return true; + error = QStringLiteral("could not write %1").arg(path); + return false; +} + +QImage diskImage() { + QImage disk(64, 64, QImage::Format_ARGB32_Premultiplied); + disk.fill(QColor(20, 20, 40, 255)); + QPainter painter(&disk); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 80, 40, 255)); + painter.drawEllipse(QRect(8, 8, 48, 48)); + return disk; +} + +QImage squareImage() { + QImage square(64, 64, QImage::Format_ARGB32_Premultiplied); + square.fill(QColor(20, 20, 40, 255)); + QPainter painter(&square); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 80, 40, 255)); + painter.drawRect(QRect(8, 8, 48, 48)); + return square; +} + +QImage roundedCardImage() { + QImage card(64, 64, QImage::Format_ARGB32_Premultiplied); + card.fill(QColor(18, 18, 22, 255)); + QPainter painter(&card); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(40, 42, 48, 255)); + painter.drawRoundedRect(QRect(8, 8, 48, 48), 10, 10); + return card; +} + +QImage checkerPinImage() { + QImage wheel(64, 64, QImage::Format_ARGB32_Premultiplied); + wheel.fill(QColor(10, 10, 12, 255)); + for (int y = 8; y <= 47; ++y) { + for (int x = 8; x <= 47; ++x) { + const qreal dx = x - 27.5; + const qreal dy = y - 27.5; + if (dx * dx + dy * dy > 20.0 * 20.0) + continue; + wheel.setPixelColor(x, y, + ((x + y) % 2 == 0) ? QColor(200, 190, 80, 255) + : QColor(16, 16, 18, 255)); + } + } + QPainter painter(&wheel); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(240, 200, 40, 255)); + painter.drawRect(QRect(44, 26, 14, 6)); + return wheel; +} + +bool ensureSnap(CaptureEditor &editor, QApplication &application, bool on, + QString &error) { + if (editor.pixelClipSnapEnabledForTest() == on) + return true; + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + if (snapChip.isEmpty()) { + error = QStringLiteral("snap chip was missing from the clip strip"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + if (editor.pixelClipSnapEnabledForTest() != on) { + error = on ? QStringLiteral("could not turn snap on") + : QStringLiteral("could not turn snap off"); + return false; + } + return true; +} + +bool lockWasLooseDrag(const QRectF &locked) { + return locked.isEmpty() || locked.width() > 56.0 || locked.height() > 56.0 || + locked.left() < 3.0 || locked.top() < 3.0 || + !locked.contains(QPointF(32, 32)); +} + +} // namespace + +bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, + QString &error) { + constexpr int kWidth = 160; + constexpr int kBand = 16; + constexpr int kBands = 10; + const QImage source = rowBandImage(kWidth, kBand, kBands); + QDir().mkpath(QFileInfo(outputRoot).path()); + + // Direct apply: punch the green band (index 3, y 48..64) and park it to + // the right of the image so the canvas grows and the hole shows Slate. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + editor.applyClipForTest(QRectF(0, 48, kWidth, kBand), + QRectF(kWidth + 20, 48, kWidth, kBand)); + application.processEvents(); + + const QImage composed = editor.composedSourceForTest(); + if (composed.pixelColor(80, 56).alpha() != 0) { + error = QStringLiteral("direct clip left the green band in the source"); + return false; + } + if (composed.pixelColor(80, 8).alpha() == 0) { + error = QStringLiteral("direct clip punched outside the rect"); + return false; + } + + bool foundClip = false; + for (const Annotation &annotation : editor.currentAnnotationsForTest()) { + if (annotation.kind != Annotation::Kind::Clip) + continue; + foundClip = true; + if (annotation.image.isNull() || + annotation.image.pixelColor(0, 0) != fixtureBandColor(3)) { + error = QStringLiteral("clip tile is not the green band"); + return false; + } + } + if (!foundClip) { + error = QStringLiteral("direct clip did not add a clip layer"); + return false; + } + + if (!saveBmp(editor.renderCurrentOutput(), + outputRoot + QStringLiteral("-clip-direct-actual.bmp"), + error) || + !saveGrab(editor, outputRoot + QStringLiteral("-clip-direct.png"), + error)) + return false; + + const int beforeUndo = editor.operationIndex(); + QTest::keyClick(&editor, Qt::Key_Z, Qt::ControlModifier); + application.processEvents(); + if (editor.operationIndex() != beforeUndo - 1) { + error = QStringLiteral("clip undo did not move the log cursor"); + return false; + } + if (editor.composedSourceForTest().pixelColor(80, 56).alpha() == 0) { + error = QStringLiteral("clip undo left the hole"); + return false; + } + if (!editor.currentAnnotationsForTest().isEmpty()) { + error = QStringLiteral("clip undo left the clip layer"); + return false; + } + editor.close(); + } + + // Solid hole fill is stored on the Clip op and replayed. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + const QColor infill(10, 132, 255, 255); + editor.applyClipForTest(QRectF(0, 48, kWidth, kBand), + QRectF(kWidth + 20, 48, kWidth, kBand), infill); + application.processEvents(); + const QColor hole = editor.composedSourceForTest().pixelColor(80, 56); + if (hole.red() != infill.red() || hole.green() != infill.green() || + hole.blue() != infill.blue() || hole.alpha() == 0) { + error = QStringLiteral("solid clip fill did not land in the hole"); + return false; + } + editor.close(); + } + + // Empty Select marquee on a source with no layers becomes a pixel clip; + // dragging that rect lifts and commits. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + if (!ensureSnap(editor, application, false, error)) + return false; + QTest::keyClick(&editor, Qt::Key_B); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.armedToolForTest() != CaptureEditor::Tool::Select) { + error = QStringLiteral("V did not arm Select"); + return false; + } + + const QPoint fidgetFrom = screenOf(editor, 20, 20); + const QPoint fidgetTo = screenOf(editor, 22, 21); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, fidgetFrom); + application.processEvents(); + if (!editor.statusForTest().contains(QStringLiteral("Drag to select layers"))) { + error = QStringLiteral("empty marquee status was not Drag to select layers"); + return false; + } + QTest::mouseMove(&editor, fidgetTo, 10); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, fidgetTo); + application.processEvents(); + if (!editor.pixelClipRectForTest().isEmpty()) { + error = QStringLiteral("2px fidget armed a pixel clip"); + return false; + } + + const QPoint from = screenOf(editor, 10, 48); + const QPoint to = screenOf(editor, kWidth - 10, 64); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, from); + QTest::mouseMove(&editor, to, 10); + application.processEvents(); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, to); + application.processEvents(); + if (editor.pixelClipRectForTest().isEmpty()) { + error = QStringLiteral("empty marquee did not lock a pixel clip"); + return false; + } + editor.applyClipForTest(QRectF(0, 80, kWidth, kBand), + QRectF(0, 96, kWidth, kBand)); + application.processEvents(); + const int priorOps = editor.operationIndex(); + if (priorOps <= 0) { + error = QStringLiteral("prior clip did not land in the log"); + return false; + } + if (editor.clipFillMenuRectForTest().isEmpty()) { + error = QStringLiteral("pixel clip did not show a hole-fill fly-out"); + return false; + } + if (!clipFillOpaque(editor.clipFillForTest())) { + error = QStringLiteral( + "hole fill did not default to match surroundings"); + return false; + } + QTest::keyClick(&editor, Qt::Key_T); + application.processEvents(); + if (editor.armedToolForTest() != CaptureEditor::Tool::Eyedropper) { + error = QStringLiteral( + "T did not cycle hole fill from surroundings to Sample from image"); + return false; + } + QTest::keyClick(&editor, Qt::Key_T); + application.processEvents(); + if (editor.armedToolForTest() != CaptureEditor::Tool::Select || + clipFillOpaque(editor.clipFillForTest())) { + error = QStringLiteral( + "T did not cycle hole fill from Sample from image to transparent"); + return false; + } + QTest::keyClick(&editor, Qt::Key_T); + application.processEvents(); + if (!clipFillOpaque(editor.clipFillForTest())) { + error = QStringLiteral( + "T did not cycle hole fill from transparent to match surroundings"); + return false; + } + QTest::keyClick(&editor, Qt::Key_1); + application.processEvents(); + if (!clipFillOpaque(editor.clipFillForTest())) { + error = QStringLiteral("1 did not set a solid hole fill"); + return false; + } + const QRectF fillMenu = editor.clipFillMenuRectForTest(); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + fillMenu.topLeft().toPoint() + QPoint(16, 18)); + application.processEvents(); + if (clipFillOpaque(editor.clipFillForTest())) { + error = QStringLiteral("transparent swatch did not clear the hole fill"); + return false; + } + const QRectF eyedropper = + editor.toolbarButtonRectForTest(QStringLiteral("tool-eyedropper")); + if (eyedropper.isEmpty()) { + error = QStringLiteral("clip fill fly-out missing Sample from image"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + eyedropper.center().toPoint()); + application.processEvents(); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 80, 8)); + application.processEvents(); + if (editor.clipFillForTest().rgb() != fixtureBandColor(0).rgb()) { + error = QStringLiteral( + "eyedropper did not set hole fill from the sampled pixel"); + return false; + } + const QRectF surroundings = editor.toolbarButtonRectForTest( + QStringLiteral("clip-fill-surroundings")); + if (surroundings.isEmpty()) { + error = QStringLiteral("clip fill fly-out missing match surroundings"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + surroundings.center().toPoint()); + application.processEvents(); + if (!clipFillOpaque(editor.clipFillForTest())) { + error = QStringLiteral("match surroundings did not set a solid hole fill"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + fillMenu.topLeft().toPoint() + QPoint(16, 18)); + application.processEvents(); + if (clipFillOpaque(editor.clipFillForTest())) { + error = QStringLiteral( + "transparent swatch did not restore a clear hole after sampling"); + return false; + } + if (!saveGrab(editor, outputRoot + QStringLiteral("-clip-marquee.png"), + error)) + return false; + + const QPoint liftFrom = screenOf(editor, kWidth / 2.0, 56); + const QPoint liftTo = screenOf(editor, kWidth / 2.0, 120); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, liftFrom); + application.processEvents(); + if (!editor.clipLiftActiveForTest()) { + error = QStringLiteral("drag inside the pixel clip did not start a lift"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Z, Qt::ControlModifier); + application.processEvents(); + if (editor.clipLiftActiveForTest()) { + error = QStringLiteral("Ctrl+Z during a lift did not cancel the lift"); + return false; + } + if (editor.operationIndex() != priorOps) { + error = QStringLiteral("Ctrl+Z during a lift undid the previous op"); + return false; + } + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, liftFrom); + application.processEvents(); + if (!editor.clipLiftActiveForTest()) { + error = QStringLiteral("lift did not restart after key-cancel"); + return false; + } + QTest::mouseMove(&editor, liftTo, 10); + application.processEvents(); + if (!saveGrab(editor, outputRoot + QStringLiteral("-clip-lift.png"), error)) + return false; + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, liftTo); + application.processEvents(); + if (editor.clipLiftActiveForTest()) { + error = QStringLiteral("clip lift did not commit on release"); + return false; + } + if (editor.composedSourceForTest() + .pixelColor(kWidth / 2, 56) + .alpha() != 0) { + error = QStringLiteral("lifted clip left the band in the source"); + return false; + } + if (!saveGrab(editor, outputRoot + QStringLiteral("-clip-committed.png"), + error) || + !saveBmp(editor.renderCurrentOutput(), + outputRoot + QStringLiteral("-clip-committed.bmp"), error)) + return false; + + QTest::keyClick(&editor, Qt::Key_Z, Qt::ControlModifier); + application.processEvents(); + if (!saveGrab(editor, outputRoot + QStringLiteral("-clip-undo.png"), error)) + return false; + if (editor.composedSourceForTest().pixelColor(kWidth / 2, 56).alpha() == + 0) { + error = QStringLiteral("widget clip undo left the hole"); + return false; + } + editor.close(); + } + + // Replay keeps a stored clip tile instead of copyRect. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + editor.applyClipForTest(QRectF(0, 48, kWidth, kBand), + QRectF(kWidth + 20, 48, kWidth, kBand)); + const QColor mark(9, 9, 9, 255); + editor.markLastClipTileForTest(mark); + editor.replayLogForTest(); + application.processEvents(); + bool kept = false; + for (const Annotation &annotation : editor.currentAnnotationsForTest()) { + if (annotation.kind != Annotation::Kind::Clip) + continue; + if (annotation.image.isNull() || + annotation.image.pixelColor(0, 0) != mark) { + error = QStringLiteral("replay recopied a clip tile that was already present"); + return false; + } + kept = true; + } + if (!kept) { + error = QStringLiteral("replay dropped the marked clip tile"); + return false; + } + editor.close(); + } + + // Repeat: two clips from different bands both stay as layers. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + editor.applyClipForTest(QRectF(0, 48, kWidth, kBand), + QRectF(kWidth + 8, 48, kWidth, kBand)); + editor.applyClipForTest(QRectF(0, 80, kWidth, kBand), + QRectF(kWidth + 8, 80, kWidth, kBand)); + int clips = 0; + for (const Annotation &annotation : editor.currentAnnotationsForTest()) { + if (annotation.kind == Annotation::Kind::Clip) + ++clips; + } + if (clips != 2) { + error = QStringLiteral("repeat clip did not keep both layers"); + return false; + } + editor.close(); + } + + // Closed lasso: start and end sit on the same pixel, so the start–end + // marquee is a fidget. The path bbox must still lock. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Lasso) { + error = QStringLiteral("two V presses did not arm clip lasso"); + return false; + } + const QPoint start = screenOf(editor, 20, 20); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, start); + application.processEvents(); + const QPoint corners[] = {screenOf(editor, 90, 20), screenOf(editor, 90, 90), + screenOf(editor, 20, 90), screenOf(editor, 22, 22)}; + for (const QPoint &corner : corners) { + QTest::mouseMove(&editor, corner, 5); + application.processEvents(); + } + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 22, 22)); + application.processEvents(); + if (editor.pixelClipRectForTest().isEmpty()) { + error = QStringLiteral("closed lasso vanished on release"); + return false; + } + if (editor.pixelClipPointsForTest().size() < 3) { + error = QStringLiteral("closed lasso dropped its path on release"); + return false; + } + if (editor.pixelClipRectForTest().width() < 40.0 || + editor.pixelClipRectForTest().height() < 40.0) { + error = QStringLiteral("closed lasso locked the start–end fidget box"); + return false; + } + if (editor.lockedClipOpForTest().shape != ClipShape::Lasso) { + error = QStringLiteral("closed lasso did not lock a lasso clip op"); + return false; + } + editor.close(); + } + + // V cycles Rect → Ellipse → Lasso → Rect. Snap is a toggle, not a shape. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + if (editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { + error = QStringLiteral("clip shape did not start on Rect"); + return false; + } + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Ellipse) { + error = QStringLiteral("first V did not cycle to Ellipse"); + return false; + } + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Lasso) { + error = QStringLiteral("second V did not cycle to Lasso"); + return false; + } + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { + error = QStringLiteral("third V did not return to Rect (snap is a toggle)"); + return false; + } + editor.close(); + } + + // Snap chip is a toggle: Rect stays the draw shape; empty click then snaps. + { + QImage disk(64, 64, QImage::Format_ARGB32_Premultiplied); + disk.fill(QColor(20, 20, 40, 255)); + { + QPainter painter(&disk); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 80, 40, 255)); + painter.drawEllipse(QRect(8, 8, 48, 48)); + } + CaptureEditor editor(fixtureCapture(disk), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + if (!editor.pixelClipSnapEnabledForTest()) { + error = QStringLiteral("snap was not on by default"); + return false; + } + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + if (snapChip.isEmpty()) { + error = QStringLiteral("snap chip was missing from the clip strip"); + return false; + } + if (editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { + error = QStringLiteral("snap chip stole the Rect/Ellipse/Lasso shape"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 32, 32)); + application.processEvents(); + if (editor.pixelClipRectForTest().isEmpty()) { + error = QStringLiteral("snap-on click did not lock a mask"); + return false; + } + if (editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { + error = QStringLiteral("snap click changed the draw shape away from Rect"); + return false; + } + if (editor.lockedClipOpForTest().shape != ClipShape::Rect) { + error = QStringLiteral("rect+snap click did not lock a rectangle clip op"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + if (editor.pixelClipSnapEnabledForTest()) { + error = QStringLiteral("second snap-chip click did not turn snap off"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Escape); + application.processEvents(); + if (!editor.pixelClipRectForTest().isEmpty()) { + error = QStringLiteral("Esc did not clear the snapped mask"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 32, 32)); + application.processEvents(); + if (!editor.pixelClipRectForTest().isEmpty()) { + error = QStringLiteral("snap-off click still locked a mask"); + return false; + } + editor.close(); + } + + // Ellipse + Snap: a loose drag around a disk locks the detected circle. + { + CaptureEditor editor(fixtureCapture(diskImage()), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Ellipse) { + error = QStringLiteral("V did not arm clip ellipse"); + return false; + } + if (!ensureSnap(editor, application, true, error)) + return false; + if (!editor.pixelClipSnapEnabledForTest()) { + error = QStringLiteral("ellipse+snap could not turn snap on"); + return false; + } + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 2, 2)); + QTest::mouseMove(&editor, screenOf(editor, 62, 62), 10); + application.processEvents(); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 62, 62)); + application.processEvents(); + const QRectF locked = editor.pixelClipRectForTest(); + if (locked.isEmpty()) { + error = QStringLiteral("ellipse+snap drag did not lock a mask"); + return false; + } + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Ellipse) { + error = QStringLiteral("ellipse+snap changed the draw shape"); + return false; + } + if (editor.lockedClipOpForTest().shape != ClipShape::Ellipse) { + error = QStringLiteral("ellipse+snap did not lock an ellipse"); + return false; + } + if (lockWasLooseDrag(locked)) { + error = QStringLiteral( + "ellipse+snap kept the loose drag instead of the disk"); + return false; + } + editor.close(); + } + + // Ellipse + Snap around a checker wheel must enclose the pin, not Hough + // the clean circle. One grab only — do not dump drag frames. + { + CaptureEditor editor(fixtureCapture(checkerPinImage()), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Ellipse) { + error = QStringLiteral("V did not arm clip ellipse for the pin wheel"); + return false; + } + if (!ensureSnap(editor, application, true, error)) + return false; + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 2, 2)); + QTest::mouseMove(&editor, screenOf(editor, 62, 62), 10); + application.processEvents(); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 62, 62)); + application.processEvents(); + if (editor.lockedClipOpForTest().shape != ClipShape::Ellipse) { + error = QStringLiteral("ellipse+snap on a pin wheel did not lock an ellipse"); + return false; + } + const QRect native = editor.lockedClipOpForTest().sourceRect; + if (native.left() < 4 || native.right() < 56 || native.width() >= 60) { + error = QStringLiteral( + "ellipse+snap drag on a checker wheel missed the pin or kept the " + "loose drag (left=%1 right=%2 width=%3)") + .arg(native.left()) + .arg(native.right()) + .arg(native.width()); + return false; + } + if (!saveGrab(editor, outputRoot + QStringLiteral("-clip-pin-wheel.png"), + error)) + return false; + editor.close(); + } + + // Lasso + Snap around a disk magnets to the circle (the people-icon case). + { + CaptureEditor editor(fixtureCapture(diskImage()), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Lasso) { + error = QStringLiteral("two V presses did not arm clip lasso"); + return false; + } + if (!ensureSnap(editor, application, true, error)) + return false; + if (!editor.pixelClipSnapEnabledForTest()) { + error = QStringLiteral("lasso+snap could not turn snap on"); + return false; + } + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 2, 2)); + application.processEvents(); + const QPoint around[] = {screenOf(editor, 62, 2), screenOf(editor, 62, 62), + screenOf(editor, 2, 62), screenOf(editor, 4, 4)}; + for (const QPoint &corner : around) { + QTest::mouseMove(&editor, corner, 5); + application.processEvents(); + } + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 4, 4)); + application.processEvents(); + if (editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Lasso) { + error = QStringLiteral("lasso+snap changed the draw shape"); + return false; + } + if (editor.lockedClipOpForTest().shape != ClipShape::Lasso) { + error = QStringLiteral("lasso+snap around a disk did not lock an outline"); + return false; + } + if (editor.pixelClipPointsForTest().size() < 8) { + error = QStringLiteral("lasso+snap around a disk did not keep a silhouette"); + return false; + } + if (lockWasLooseDrag(editor.pixelClipRectForTest())) { + error = QStringLiteral( + "lasso+snap around a disk kept the loose path instead of the circle"); + return false; + } + editor.close(); + } + + // Rect + Snap around a square locks the detected rectangle, not a circle. + { + CaptureEditor editor(fixtureCapture(squareImage()), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + if (editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { + error = QStringLiteral("clip shape did not start on Rect"); + return false; + } + if (!ensureSnap(editor, application, true, error)) + return false; + if (!editor.pixelClipSnapEnabledForTest()) { + error = QStringLiteral("rect+snap could not turn snap on"); + return false; + } + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 2, 2)); + QTest::mouseMove(&editor, screenOf(editor, 62, 62), 10); + application.processEvents(); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 62, 62)); + application.processEvents(); + if (editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { + error = QStringLiteral("rect+snap changed the draw shape"); + return false; + } + if (editor.lockedClipOpForTest().shape != ClipShape::Rect) { + error = QStringLiteral("rect+snap around a square did not lock a rectangle"); + return false; + } + if (lockWasLooseDrag(editor.pixelClipRectForTest())) { + error = QStringLiteral( + "rect+snap around a square kept the loose drag instead of the square"); + return false; + } + editor.close(); + } + + // Rect + Snap around a rounded card locks a rounded rect, not a sharp box. + { + CaptureEditor editor(fixtureCapture(roundedCardImage()), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + if (!ensureSnap(editor, application, true, error)) + return false; + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 2, 2)); + QTest::mouseMove(&editor, screenOf(editor, 62, 62), 10); + application.processEvents(); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 62, 62)); + application.processEvents(); + const ClipOp locked = editor.lockedClipOpForTest(); + if (locked.shape != ClipShape::Rect || locked.radius < 5.0) { + error = QStringLiteral( + "rect+snap on a rounded card did not lock a corner radius " + "(radius=%1)") + .arg(locked.radius); + return false; + } + if (!saveGrab(editor, outputRoot + QStringLiteral("-clip-rounded-card.png"), + error)) + return false; + editor.close(); + } + + // Snap on does not replace a real lasso with a click-snap on release. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_V); + application.processEvents(); + if (!ensureSnap(editor, application, true, error)) + return false; + if (!editor.pixelClipSnapEnabledForTest() || + editor.pixelClipShapeForTest() != + CaptureEditor::PixelClipShape::Lasso) { + error = QStringLiteral("could not arm lasso with snap on"); + return false; + } + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 20, 20)); + application.processEvents(); + const QPoint lasso[] = {screenOf(editor, 90, 20), screenOf(editor, 90, 90), + screenOf(editor, 20, 90), screenOf(editor, 22, 22)}; + for (const QPoint &corner : lasso) { + QTest::mouseMove(&editor, corner, 5); + application.processEvents(); + } + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + screenOf(editor, 22, 22)); + application.processEvents(); + if (editor.pixelClipPointsForTest().size() < 3 || + editor.lockedClipOpForTest().shape != ClipShape::Lasso) { + error = QStringLiteral("snap-on closed lasso did not keep the lasso path"); + return false; + } + editor.close(); + } + + return true; +} diff --git a/tests/clip-mapping-smoke.hpp b/tests/clip-mapping-smoke.hpp new file mode 100644 index 00000000..1274da2c --- /dev/null +++ b/tests/clip-mapping-smoke.hpp @@ -0,0 +1,9 @@ +/** @fileoverview Clip-tool mapping: marquee, lift, hole, undo. */ +#pragma once + +#include +#include + +[[nodiscard]] bool runClipMappingSmoke(QApplication &application, + const QString &outputRoot, + QString &error); diff --git a/tests/clip-smoke.cpp b/tests/clip-smoke.cpp new file mode 100644 index 00000000..79ae8f52 --- /dev/null +++ b/tests/clip-smoke.cpp @@ -0,0 +1,504 @@ +/** @fileoverview Tests the clip engine: copy, punch, native mapping, snap. */ +#include "clip-smoke.hpp" + +#include "clip.hpp" +#include "capture.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +bool runClipSmoke(QString &error) { + QImage source(8, 8, QImage::Format_ARGB32_Premultiplied); + source.fill(QColor(40, 180, 60, 255)); + source.setPixelColor(2, 3, QColor(220, 50, 0, 255)); + + QImage page(32, 32, QImage::Format_ARGB32_Premultiplied); + page.fill(QColor(80, 120, 200, 255)); + { + QPainter painter(&page); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 50, 0, 255)); + painter.drawRect(QRect(8, 8, 16, 16)); + } + const QColor around = sampleClipSurroundings(page, QRect(8, 8, 16, 16)); + if (around.red() < 70 || around.red() > 90 || around.blue() < 190) { + error = QStringLiteral("sampleClipSurroundings missed the page colour"); + return false; + } + if (clipFillOpaque(sampleClipSurroundings(page, page.rect()))) { + error = QStringLiteral( + "sampleClipSurroundings invented a colour with no outside ring"); + return false; + } + + const QRect region(2, 2, 3, 3); + const QImage tile = copyRect(source, region); + if (tile.size() != QSize(3, 3) || + tile.pixelColor(0, 1) != QColor(220, 50, 0, 255)) { + error = QStringLiteral("copyRect did not keep the marked pixel"); + return false; + } + + QImage punched = source; + punchRect(punched, region); + if (punched.size() != source.size()) { + error = QStringLiteral("punchRect must not collapse the image"); + return false; + } + if (punched.pixelColor(2, 3).alpha() != 0) { + error = QStringLiteral("punchRect left pixels in the hole"); + return false; + } + if (punched.pixelColor(0, 0) != QColor(40, 180, 60, 255)) { + error = QStringLiteral("punchRect touched pixels outside the rect"); + return false; + } + + QImage filled = source; + fillHole(filled, region, QColor(10, 132, 255, 255)); + if (filled.pixelColor(2, 3) != QColor(10, 132, 255, 255)) { + error = QStringLiteral("fillHole did not paint the solid infill"); + return false; + } + if (filled.pixelColor(0, 0) != QColor(40, 180, 60, 255)) { + error = QStringLiteral("fillHole touched pixels outside the rect"); + return false; + } + QImage viaTransparent = source; + fillHole(viaTransparent, region, QColor(0, 0, 0, 0)); + if (viaTransparent.pixelColor(2, 3).alpha() != 0) { + error = QStringLiteral("fillHole with alpha 0 did not punch"); + return false; + } + + // Empty / out-of-bounds are no-ops. + if (!copyRect(source, QRect(20, 20, 2, 2)).isNull()) { + error = QStringLiteral("copyRect of a miss should be null"); + return false; + } + QImage untouched = source; + punchRect(untouched, QRect()); + if (untouched != source) { + error = QStringLiteral("empty punchRect changed the image"); + return false; + } + + const QRect native = + nativeClipRect(QRectF(2, 2, 3, 3), QSize(8, 8), QSize(8, 8)); + if (native != QRect(2, 2, 3, 3)) { + error = QStringLiteral("nativeClipRect 1:1 mapping wrong"); + return false; + } + + // 2× source: logical 2,2 3×3 → native 4,4 6×6. + const QRect hidpi = + nativeClipRect(QRectF(2, 2, 3, 3), QSize(8, 8), QSize(16, 16)); + if (hidpi != QRect(4, 4, 6, 6)) { + error = QStringLiteral("nativeClipRect hidpi mapping wrong"); + return false; + } + + if (!clipDestSnapped(QRectF(10, 10, 20, 20), QRectF(12, 11, 20, 20), 8.0) || + clipDestSnapped(QRectF(10, 10, 20, 20), QRectF(40, 40, 20, 20), 8.0)) { + error = QStringLiteral("clipDestSnapped threshold wrong"); + return false; + } + if (clipSnapEnterThreshold(2.0) != 7.0 || + clipSnapLeaveThreshold(2.0) != 10.0) { + error = QStringLiteral("clip snap hysteresis thresholds wrong"); + return false; + } + + QImage composed(8, 8, QImage::Format_ARGB32_Premultiplied); + composed.fill(QColor(220, 50, 0, 255)); + QImage existing(3, 3, QImage::Format_ARGB32_Premultiplied); + existing.fill(QColor(10, 132, 255, 255)); + const QImage kept = + resolveClipTile(composed, QRect(2, 2, 3, 3), existing, false); + if (kept.size() != existing.size() || + kept.pixelColor(1, 1) != QColor(10, 132, 255, 255)) { + error = QStringLiteral("resolveClipTile recopied when the tile was present"); + return false; + } + const QImage recopied = + resolveClipTile(composed, QRect(2, 2, 3, 3), existing, true); + if (recopied.pixelColor(0, 0) != QColor(220, 50, 0, 255)) { + error = QStringLiteral("resolveClipTile skipped copy when prefix changed"); + return false; + } + const QImage missing = + resolveClipTile(composed, QRect(2, 2, 3, 3), QImage(), false); + if (missing.pixelColor(0, 0) != QColor(220, 50, 0, 255)) { + error = QStringLiteral("resolveClipTile skipped copy when the tile was null"); + return false; + } + + QImage pngTile(2, 2, QImage::Format_ARGB32); + pngTile.fill(QColor(9, 8, 7, 255)); + QByteArray pngBytes; + QBuffer pngBuffer(&pngBytes); + pngBuffer.open(QIODevice::WriteOnly); + pngTile.save(&pngBuffer, "PNG"); + const QString b64 = QString::fromLatin1(pngBytes.toBase64()); + const QString jsonPath = + QDir::temp().filePath(QStringLiteral("omasnap-clip-png-gate.json")); + const auto writeLog = [&](const QString &tool) { + QFile file(jsonPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + const QString json = QStringLiteral( + "{\"version\":1,\"index\":1,\"nextId\":\"2\",\"nextMarker\":1,\"ops\":[" + "{\"type\":\"annotate\",\"annotation\":{\"id\":\"1\",\"tool\":\"%1\"," + "\"start\":[0,0],\"end\":[4,4],\"color\":\"#ffff0000\",\"size\":4," + "\"png\":\"%2\"}}]}") + .arg(tool, b64); + return file.write(json.toUtf8()) > 0; + }; + if (!writeLog(QStringLiteral("rectangle"))) { + error = QStringLiteral("could not write rectangle png-gate log"); + return false; + } + OperationLog rectangleLog; + QString loadError; + if (!loadOperationLog(jsonPath, rectangleLog, loadError) || + rectangleLog.ops.isEmpty() || + rectangleLog.ops.constFirst().annotations.isEmpty()) { + error = QStringLiteral("rectangle png-gate log failed to load: %1") + .arg(loadError); + return false; + } + if (!rectangleLog.ops.constFirst().annotations.constFirst().image.isNull()) { + error = QStringLiteral("annotationFromJson loaded png onto a non-Clip kind"); + return false; + } + if (!writeLog(QStringLiteral("clip"))) { + error = QStringLiteral("could not write clip png-gate log"); + return false; + } + OperationLog clipLog; + if (!loadOperationLog(jsonPath, clipLog, loadError) || + clipLog.ops.isEmpty() || clipLog.ops.constFirst().annotations.isEmpty() || + clipLog.ops.constFirst().annotations.constFirst().image.isNull() || + clipLog.ops.constFirst().annotations.constFirst().image.pixelColor(0, 0) != + QColor(9, 8, 7, 255)) { + error = QStringLiteral("annotationFromJson skipped png on a Clip kind"); + return false; + } + QFile::remove(jsonPath); + + QImage disk(32, 32, QImage::Format_ARGB32_Premultiplied); + disk.fill(QColor(20, 20, 40, 255)); + { + QPainter painter(&disk); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 80, 40, 255)); + painter.drawEllipse(QRect(6, 6, 20, 20)); + } + ClipOp ellipse; + ellipse.shape = ClipShape::Ellipse; + ellipse.sourceRect = QRect(6, 6, 20, 20); + const QImage ellipseTile = copyMasked(disk, ellipse); + if (ellipseTile.size() != QSize(20, 20)) { + error = QStringLiteral("copyMasked ellipse size wrong"); + return false; + } + if (ellipseTile.pixelColor(10, 10).alpha() == 0) { + error = QStringLiteral("copyMasked ellipse dropped the disk interior"); + return false; + } + if (ellipseTile.pixelColor(0, 0).alpha() != 0) { + error = QStringLiteral("copyMasked ellipse kept bbox corners"); + return false; + } + QImage ellipsePunched = disk; + fillHole(ellipsePunched, ellipse); + if (ellipsePunched.pixelColor(16, 16).alpha() != 0) { + error = QStringLiteral("fillHole ellipse left the disk interior"); + return false; + } + if (ellipsePunched.pixelColor(0, 0) != QColor(20, 20, 40, 255)) { + error = QStringLiteral("fillHole ellipse touched the field"); + return false; + } + + ClipOp lasso; + lasso.shape = ClipShape::Lasso; + lasso.points = {QPointF(2, 2), QPointF(14, 2), QPointF(8, 14)}; + lasso.sourceRect = QRect(2, 2, 13, 13); + QImage lassoField(16, 16, QImage::Format_ARGB32_Premultiplied); + lassoField.fill(QColor(10, 80, 180, 255)); + const QImage lassoTile = copyMasked(lassoField, lasso); + if (lassoTile.pixelColor(0, 0).alpha() == 0) { + error = QStringLiteral("copyMasked lasso dropped a vertex interior"); + return false; + } + if (lassoTile.pixelColor(12, 12).alpha() != 0) { + error = QStringLiteral("copyMasked lasso kept a point outside the triangle"); + return false; + } + + const std::optional snapped = snapEllipseRect(disk, QPoint(16, 16)); + if (!snapped || !snapped->contains(16, 16) || snapped->width() < 12 || + snapped->height() < 12) { + error = QStringLiteral("snapEllipseRect missed the synthetic disk"); + return false; + } + if (snapEllipseRect(disk, QPoint(1, 1))) { + error = QStringLiteral("snapEllipseRect snapped empty field"); + return false; + } + + // A round portrait sitting on a rectangular card: the circle, not the card. + QImage card(64, 64, QImage::Format_ARGB32_Premultiplied); + card.fill(QColor(18, 18, 22, 255)); + { + QPainter painter(&card); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(40, 44, 52, 255)); + painter.drawRect(QRect(6, 8, 52, 48)); + painter.setBrush(QColor(180, 190, 200, 255)); + painter.drawEllipse(QRect(16, 16, 32, 32)); + } + const std::optional portrait = + snapEllipseRect(card, QPoint(32, 32)); + if (!portrait || portrait->width() > 40 || portrait->height() > 40 || + portrait->width() < 24 || !portrait->contains(32, 32)) { + error = QStringLiteral( + "snapEllipseRect took the card instead of the circular portrait"); + return false; + } + + if (!clipTraceSnapFits(QRectF(4, 4, 28, 28), QRectF(6, 6, 20, 20))) { + error = QStringLiteral("clipTraceSnapFits rejected a tracing ellipse"); + return false; + } + if (clipTraceSnapFits(QRectF(0, 0, 32, 32), QRectF(6, 6, 8, 8))) { + error = QStringLiteral("clipTraceSnapFits accepted a tiny object in a huge drag"); + return false; + } + if (clipTraceSnapFits(QRectF(14, 14, 8, 8), QRectF(6, 6, 20, 20))) { + error = QStringLiteral("clipTraceSnapFits accepted a drag that has not covered the object"); + return false; + } + if (clipTraceSnapFits(QRectF(0, 0, 10, 10), QRectF(20, 20, 8, 8))) { + error = QStringLiteral("clipTraceSnapFits accepted a snap whose centre is outside the drag"); + return false; + } + if (clipTraceSnapFits(QRectF(20, 20, 70, 70), QRectF(12, 47, 112, 34))) { + error = QStringLiteral("clipTraceSnapFits accepted a wide band as a traced circle"); + return false; + } + + QImage plate(32, 32, QImage::Format_ARGB32_Premultiplied); + plate.fill(QColor(20, 20, 40, 255)); + { + QPainter painter(&plate); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 80, 40, 255)); + painter.drawRect(QRect(6, 8, 20, 16)); + } + const std::optional snappedRect = snapRectRect(plate, QPoint(16, 16)); + if (!snappedRect || !snappedRect->contains(16, 16) || + snappedRect->width() < 16 || snappedRect->height() < 12 || + snappedRect->width() > 24 || snappedRect->height() > 20) { + error = QStringLiteral("snapRectRect missed the synthetic rectangle"); + return false; + } + if (snapRectRect(plate, QPoint(1, 1))) { + error = QStringLiteral("snapRectRect snapped empty field"); + return false; + } + if (!clipRectTraceSnapFits(QRectF(2, 2, 28, 28), QRectF(6, 8, 20, 16))) { + error = QStringLiteral("clipRectTraceSnapFits rejected a tracing rectangle"); + return false; + } + if (clipRectTraceSnapFits(QRectF(20, 20, 70, 70), QRectF(12, 47, 112, 34))) { + error = QStringLiteral( + "clipRectTraceSnapFits accepted a wide band as a traced rectangle"); + return false; + } + + QImage roundField(48, 48, QImage::Format_ARGB32_Premultiplied); + roundField.fill(QColor(20, 20, 40, 255)); + { + QPainter painter(&roundField); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 80, 40, 255)); + painter.drawRoundedRect(QRect(6, 6, 36, 36), 8, 8); + } + const std::optional rounded = + snapObject(roundField, QPoint(24, 24)); + if (!rounded || rounded->box.width() < 28 || rounded->radius < 5.0) { + error = QStringLiteral("snapObject missed the rounded rectangle"); + return false; + } + ClipOp roundOp; + roundOp.shape = ClipShape::Rect; + roundOp.sourceRect = rounded->box; + roundOp.radius = rounded->radius; + const QImage roundTile = copyMasked(roundField, roundOp); + if (roundTile.isNull() || roundTile.pixelColor(0, 0).alpha() != 0) { + error = QStringLiteral("copyMasked rounded rect kept a bbox corner"); + return false; + } + const QPoint inner(roundTile.width() / 2, roundTile.height() / 2); + if (roundTile.pixelColor(inner).alpha() == 0) { + error = QStringLiteral("copyMasked rounded rect dropped the interior"); + return false; + } + if (rounded->contour.size() < 8) { + error = QStringLiteral("snapObject did not trace a silhouette"); + return false; + } + + // Two square top corners must not zero out a round-bottom card. + QImage banner(48, 48, QImage::Format_ARGB32_Premultiplied); + banner.fill(QColor(20, 20, 40, 255)); + { + QPainter painter(&banner); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(220, 80, 40, 255)); + painter.drawRoundedRect(QRect(6, 6, 36, 36), 8, 8); + painter.drawRect(QRect(6, 6, 36, 10)); + } + const std::optional roundBottom = + snapObject(banner, QPoint(24, 24)); + if (!roundBottom || roundBottom->radius < 5.0) { + error = QStringLiteral( + "snapObject dropped corner radius because two corners were square"); + return false; + } + + // A 1 px close must not swallow a nearby glyph and kill the card radius. + QImage chat(48, 48, QImage::Format_ARGB32_Premultiplied); + chat.fill(QColor(18, 18, 22, 255)); + { + QPainter painter(&chat); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(40, 42, 48, 255)); + painter.drawRoundedRect(QRect(12, 8, 32, 32), 8, 8); + painter.setBrush(QColor(200, 200, 210, 255)); + painter.drawRect(QRect(1, 18, 8, 10)); + } + const std::optional chatCard = + snapObject(chat, QPoint(28, 24), QRect(0, 0, 48, 48)); + if (!chatCard || chatCard->box.left() < 10 || chatCard->radius < 5.0) { + error = QStringLiteral( + "snapObject merged a nearby glyph into the rounded card"); + return false; + } + + // Gray card chrome on gray paper: the bright thumb must not steal the left + // edge. The drag (roi) is the search window. + QImage letter(48, 48, QImage::Format_ARGB32_Premultiplied); + letter.fill(QColor(80, 80, 84, 255)); + { + QPainter painter(&letter); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(102, 102, 110, 255)); + painter.drawRect(QRect(8, 10, 32, 24)); + painter.setBrush(QColor(220, 180, 40, 255)); + painter.drawRect(QRect(11, 13, 12, 12)); + } + const QRect cardRoi(6, 8, 36, 28); + const std::optional chrome = + snapObject(letter, QPoint(24, 22), cardRoi); + if (!chrome || chrome->box.left() > 8) { + error = QStringLiteral( + "snapObject missed gray card chrome to the left of the thumbnail"); + return false; + } + + // Circle with a pin: a drag around it must cover the protrusion. + QImage badge(48, 48, QImage::Format_ARGB32_Premultiplied); + badge.fill(QColor(16, 16, 20, 255)); + { + QPainter painter(&badge); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(200, 190, 80, 255)); + painter.drawEllipse(QRect(8, 8, 24, 24)); + painter.drawRect(QRect(20, 18, 16, 4)); + } + const std::optional withPin = + snapEllipseRect(badge, QPoint(20, 20), QRect(4, 4, 40, 40)); + if (!withPin || withPin->right() < 35) { + error = QStringLiteral( + "snapEllipseRect inside a drag clipped the circular badge's pin"); + return false; + } + const std::optional pinBlob = + snapObject(badge, QPoint(20, 20), QRect(4, 4, 40, 40)); + if (!pinBlob || pinBlob->box.right() < 35 || pinBlob->contour.size() < 8) { + error = QStringLiteral( + "snapObject lasso silhouette dropped the badge pin"); + return false; + } + + // Achievements-wheel case: a checker disk on dark paper. Dark cells sit + // near paper luma so 4-connected flood from one light square dies; Hough + // then votes the clean circle and clips the yellow pin. + QImage wheel(48, 48, QImage::Format_ARGB32_Premultiplied); + wheel.fill(QColor(10, 10, 12, 255)); + for (int y = 8; y <= 31; ++y) { + for (int x = 8; x <= 31; ++x) { + const qreal dx = x - 19.5; + const qreal dy = y - 19.5; + if (dx * dx + dy * dy > 12.0 * 12.0) + continue; + wheel.setPixelColor(x, y, + ((x + y) % 2 == 0) ? QColor(200, 190, 80, 255) + : QColor(16, 16, 18, 255)); + } + } + { + QPainter painter(&wheel); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(240, 200, 40, 255)); + painter.drawRect(QRect(28, 18, 12, 4)); + } + const QRect wheelRoi(4, 4, 40, 40); + const std::optional checkerPin = + snapEllipseRect(wheel, QPoint(20, 20), wheelRoi); + if (!checkerPin || checkerPin->right() < 38) { + error = QStringLiteral( + "snapEllipseRect on a checker wheel clipped the yellow pin"); + return false; + } + const std::optional darkSeed = + snapEllipseRect(wheel, QPoint(21, 20), wheelRoi); + if (!darkSeed || darkSeed->right() < 38) { + error = QStringLiteral( + "snapEllipseRect from a dark checker cell clipped the yellow pin"); + return false; + } + const std::optional checkerBlob = + snapObject(wheel, QPoint(21, 20), wheelRoi); + if (!checkerBlob || checkerBlob->box.right() < 38) { + error = QStringLiteral( + "snapObject from a dark checker cell dropped the yellow pin"); + return false; + } + const std::optional clickPin = snapEllipseRect(wheel, QPoint(20, 20)); + if (!clickPin || clickPin->right() < 38) { + error = QStringLiteral( + "click-snap on a checker wheel clipped the yellow pin"); + return false; + } + + return true; +} diff --git a/tests/clip-smoke.hpp b/tests/clip-smoke.hpp new file mode 100644 index 00000000..06241733 --- /dev/null +++ b/tests/clip-smoke.hpp @@ -0,0 +1,6 @@ +/** @fileoverview Declares the clip-engine smoke test. */ +#pragma once + +#include + +[[nodiscard]] bool runClipSmoke(QString &error); diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 68f5516c..4cd308ad 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -4,6 +4,8 @@ #include "output-config.hpp" #include "cli-path.hpp" #include "clipboard-smoke.hpp" +#include "clip-mapping-smoke.hpp" +#include "clip-smoke.hpp" #include "cut-mapping-smoke.hpp" #include "cut-smoke.hpp" #include "editor.hpp" @@ -7828,6 +7830,10 @@ int main(int argc, char **argv) { qWarning().noquote() << snapshotError; return 96; } + if (!runClipMappingSmoke(application, outputRoot, snapshotError)) { + qWarning().noquote() << snapshotError; + return 97; + } const QString snapshotPath = temporarySnapshotPath(); QFile::remove(snapshotPath); const QString savedRoot = QDir(outputRoot).filePath(QStringLiteral("saved")); @@ -8876,6 +8882,12 @@ int main(int argc, char **argv) { return EXIT_FAILURE; } + QString clipError; + if (!runClipSmoke(clipError)) { + qWarning().noquote() << "clip smoke failed:" << clipError; + return EXIT_FAILURE; + } + QString paletteError; if (!runPaletteConfigSmoke(paletteError)) { qWarning().noquote() << "palette config smoke failed:" << paletteError;