From 4c1f83b17b22c343bdaa12b1ea1398ab6df3daef Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Fri, 28 Aug 2026 07:05:27 -0400 Subject: [PATCH 1/8] feat(editor): clip a screenshot region by dragging the select marquee Select already grows the canvas when you drag a layer off the screenshot; this lets the screenshot itself be the source of that layer. An empty marquee locks a pixel clip; drag it to lift those pixels, punch a hole (backdrop shows through), and commit one Clip operation. Repeat to pull another region. Ctrl+Z undoes the hole and the layer together. Covered by runClipSmoke (copy/punch/mapping) and runClipMappingSmoke (empty marquee, lift, hole, undo, repeat). The suite fails without the change. make check is green. --- AGENTS.md | 1 + CMakeLists.txt | 6 + README.md | 6 +- docs/clip-out.gif | Bin 0 -> 81586 bytes docs/editing-model.md | 11 +- src/capture.cpp | 53 ++++++ src/capture.hpp | 12 +- src/clip.cpp | 63 ++++++ src/clip.hpp | 44 +++++ src/editor.cpp | 360 +++++++++++++++++++++++++++++++++-- src/editor.hpp | 26 +++ tests/clip-mapping-smoke.cpp | 214 +++++++++++++++++++++ tests/clip-mapping-smoke.hpp | 9 + tests/clip-smoke.cpp | 78 ++++++++ tests/clip-smoke.hpp | 6 + tests/editor-smoke.cpp | 12 ++ 16 files changed, 881 insertions(+), 20 deletions(-) create mode 100644 docs/clip-out.gif create mode 100644 src/clip.cpp create mode 100644 src/clip.hpp create mode 100644 tests/clip-mapping-smoke.cpp create mode 100644 tests/clip-mapping-smoke.hpp create mode 100644 tests/clip-smoke.cpp create mode 100644 tests/clip-smoke.hpp diff --git a/AGENTS.md b/AGENTS.md index a902d3cd..97b4eb6d 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 rectangle, 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..774fc2fa 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ 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, an empty marquee on the screenshot locks a pixel region; + drag it to lift those pixels as a layer and leave a hole (backdrop shows through). + Release near the hole to snap back; release elsewhere to commit. Repeat to pull + another region. Ctrl+Z undoes the clip. Distinct from Cut, which collapses a band. - 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 @@ -350,7 +354,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; drag empty canvas for a marquee; a marquee that hits no layers locks a pixel clip — 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 | | `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 0000000000000000000000000000000000000000..6068e575d310acf118305b09b39cdced2531c9a7 GIT binary patch literal 81586 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 z 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 OnEo4|`hRA(wEq{0aPAWT literal 0 HcmV?d00001 diff --git a/docs/editing-model.md b/docs/editing-model.md index 72681b0d..ad38fd88 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,15 @@ 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 rectangle of native pixels and + punches a transparent hole of the same size, then adds those pixels as a + `Annotation::Kind::Clip` layer at the drop location. The hole does **not** + collapse the image (that is Cut). Replay walks the log from `pristineSource_` + and applies cuts and clip punches in order, copying the tile from the + composed image as it existed at that op so later cuts cannot rewrite an + already-torn piece. 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. - **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/src/capture.cpp b/src/capture.cpp index 8d43355d..4c7bde30 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,16 @@ 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.drawImage(bounds, annotation.image); + return; + } const qreal width = std::max(2.0, annotation.size); QPen pen(annotation.color, width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); @@ -1548,6 +1559,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 +1586,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 +1734,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 +1791,10 @@ bool annotationFromJson(const QJsonObject &object, Annotation &annotation, : spotlightShape == QStringLiteral("rounded") ? SpotlightShape::RoundedRectangle : SpotlightShape::Ellipse; + const QByteArray png = QByteArray::fromBase64( + object.value(QStringLiteral("png")).toString().toLatin1()); + if (!png.isEmpty()) + annotation.image.loadFromData(png, "PNG"); return true; } @@ -1822,6 +1849,18 @@ 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.annotations.isEmpty()) + object.insert(QStringLiteral("annotation"), + annotationToJson(operation.annotations.constFirst())); + break; + } } return object; } @@ -1896,6 +1935,20 @@ 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 (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..2c50cb4e --- /dev/null +++ b/src/clip.cpp @@ -0,0 +1,63 @@ +/** @fileoverview Rectangular clip-out engine: copy a region and punch a hole. */ +#include "clip.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QImage copyRect(const QImage &source, QRect sourceRect) { + if (source.isNull()) + return {}; + sourceRect = sourceRect.intersected(source.rect()); + if (sourceRect.isEmpty()) + return {}; + return source.copy(sourceRect); +} + +void punchRect(QImage &image, QRect sourceRect) { + if (image.isNull()) + return; + sourceRect = sourceRect.intersected(image.rect()); + if (sourceRect.isEmpty()) + return; + if (image.format() != QImage::Format_ARGB32 && + image.format() != QImage::Format_ARGB32_Premultiplied) + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + QPainter painter(&image); + painter.setCompositionMode(QPainter::CompositionMode_Clear); + painter.fillRect(sourceRect, Qt::transparent); +} + +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)); +} + +qreal clipSnapThreshold(qreal viewScale) { + return 8.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; +} diff --git a/src/clip.hpp b/src/clip.hpp new file mode 100644 index 00000000..c2938dce --- /dev/null +++ b/src/clip.hpp @@ -0,0 +1,44 @@ +/** @fileoverview Rectangular clip-out engine: copy a native-pixel rect and + * punch a transparent hole without collapsing the gap. + * + * Distinct from the Cut tool, which removes a band and shifts the rest. + * Coordinates follow Cut: `sourceRect` is native pixels 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 + +/** One applied clip. `sourceRect` is inclusive top-left, exclusive of the + * pixels past `right()`/`bottom()` in the usual QRect sense (x, y, w, h). + * An empty rect, or one that misses the image, is a no-op. */ +struct ClipOp { + QRect sourceRect; + bool operator==(const ClipOp &) const = default; +}; + +/** Returns a copy of `sourceRect` intersected with `source`. Null when the + * intersection is empty. */ +[[nodiscard]] QImage copyRect(const QImage &source, QRect sourceRect); + +/** 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); + +/** 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); + +/** Annotation-space distance at which a lifted clip snaps back into its + * hole. `viewScale` is the editor's annotation-to-widget scale. */ +[[nodiscard]] qreal clipSnapThreshold(qreal 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); diff --git a/src/editor.cpp b/src/editor.cpp index faa458a4..c076260f 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" @@ -254,7 +255,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). @@ -758,7 +760,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 +1253,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 · empty marquee clips pixels · drag inside to lift · " + "Ctrl+wheel zooms · outer handles crop"); case Tool::Spotlight: { const QString shape = spotlightShape_ == SpotlightShape::Ellipse ? QStringLiteral("ellipse") @@ -1588,13 +1591,16 @@ void CaptureEditor::adjustSelectedAnnotation(int step) { case Annotation::Kind::Highlighter: case Annotation::Kind::Rectangle: case Annotation::Kind::Ellipse: + case Annotation::Kind::Clip: break; } // 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, - // and says so. - if (annotation.filled && (annotation.kind == Annotation::Kind::Rectangle || - annotation.kind == Annotation::Kind::Ellipse)) { + // and says so. Clip tiles grow the same way: they are a torn-off image, + // not a stroke. + if (annotation.kind == Annotation::Kind::Clip || + (annotation.filled && (annotation.kind == Annotation::Kind::Rectangle || + annotation.kind == Annotation::Kind::Ellipse))) { const qreal factor = step > 0 ? 1.1 : 1.0 / 1.1; const QRectF bounds = annotationBounds(annotation); const QPointF center = bounds.center(); @@ -2437,6 +2443,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 +2480,131 @@ 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; + annotation.image = {}; + Operation op; + op.type = Operation::Type::Clip; + op.clip = std::move(clip); + op.annotations = {std::move(annotation)}; + commitOp(std::move(op)); +} + +void CaptureEditor::applyClipForTest(const QRectF &logicalRect, + const QRectF &dest) { + 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{native}, std::move(annotation)); +} + +void CaptureEditor::clearPixelClip() { + pixelClipRect_ = {}; + originalPixelClip_ = {}; + pixelClipResizing_ = false; + cancelClipLift(); +} + +void CaptureEditor::cancelClipLift() { + clipLiftActive_ = false; + clipLiftOrigin_ = {}; + clipLiftDest_ = {}; + clipLiftGrabOffset_ = {}; + clipLiftTile_ = {}; + clipLiftSnapped_ = false; +} + +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()) + 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; +} + +void CaptureEditor::beginClipLift(const QPointF &point) { + const QRect native = nativeRectForPixelClip(pixelClipRect_); + const QImage tile = copyRect(capture_.source, native); + if (tile.isNull()) + return; + clipLiftActive_ = true; + clipLiftOrigin_ = pixelClipRect_.normalized(); + clipLiftDest_ = clipLiftOrigin_; + clipLiftGrabOffset_ = point - clipLiftOrigin_.center(); + clipLiftTile_ = tile; + clipLiftSnapped_ = true; + 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 threshold = clipSnapThreshold(editScale()); + clipLiftSnapped_ = clipDestSnapped(dest, clipLiftOrigin_, threshold); + if (clipLiftSnapped_) + dest = clipLiftOrigin_; + clipLiftDest_ = dest; +} + +void CaptureEditor::finishClipLift() { + if (!clipLiftActive_) + return; + if (clipLiftSnapped_) { + cancelClipLift(); + dragging_ = false; + setStatus(QStringLiteral("Selection snapped back into place")); + updatePointerCursor(); + update(); + return; + } + const QRect native = nativeRectForPixelClip(clipLiftOrigin_); + Annotation annotation; + annotation.kind = Annotation::Kind::Clip; + annotation.start = clipLiftDest_.topLeft(); + annotation.end = clipLiftDest_.bottomRight(); + cancelClipLift(); + pixelClipRect_ = {}; + dragging_ = false; + commitClip(ClipOp{native}, 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 +2747,7 @@ void CaptureEditor::replayLog() { CanvasBoundaryMode canvasBoundary = CanvasBoundaryMode::Framed; QVector annotations; QVector cuts; + QImage composed = pristineSource_; int nextMarker = 1; for (int index = 0; index < opIndex_ && index < ops_.size(); ++index) { const Operation &op = ops_.at(index); @@ -2651,6 +2786,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 +2824,21 @@ void CaptureEditor::replayLog() { selection.setWidth(std::max(1.0, selection.width() - band)); } cuts.push_back(op.cut); + if (!composed.isNull()) + composed = removeBand(composed, op.cut.orientation, op.cut.sourceStart, + op.cut.sourceEnd); + break; + } + case Operation::Type::Clip: { + QImage tile = copyRect(composed, op.clip.sourceRect); + punchRect(composed, op.clip.sourceRect); + Annotation layer; + if (!op.annotations.isEmpty()) + layer = op.annotations.constFirst(); + layer.kind = Annotation::Kind::Clip; + if (layer.image.isNull()) + layer.image = std::move(tile); + annotations.push_back(std::move(layer)); break; } } @@ -2696,10 +2848,14 @@ 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_); + backdropKey_ = 0; + redactionBaseStale_ = true; if (!selection.isEmpty()) selection_ = selection; refreshCanvasRect(); @@ -2947,6 +3103,22 @@ void CaptureEditor::enterExport() { } void CaptureEditor::handleEscape() { + 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; @@ -3691,6 +3863,11 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { return; } + if (clipLiftActive_) { + cancelClipLift(); + dragging_ = false; + setStatus(QStringLiteral("Clip cancelled")); + } 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 @@ -3974,6 +4151,26 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { if (!dragging_) updatePointerCursor(); } else { + if (tool_ == Tool::Select && clipLiftActive_) { + updateClipLift(toUnclampedAnnotationPoint(cursor_)); + update(); + 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 (updated.width() >= 2.0 && updated.height() >= 2.0) + pixelClipRect_ = updated; + update(); + return; + } if (tool_ == Tool::Select && marqueeSelecting_) { marqueeRect_ = QRectF(dragStart_, toAnnotationPoint(cursor_)).normalized(); update(); @@ -4368,7 +4565,8 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { return; } } - if (tool_ == Tool::Select && selectedAnnotations_.isEmpty()) { + if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && + pixelClipRect_.isEmpty()) { const int cropHandle = cropHandleAt(cursor_); if (cropHandle >= 0) { originalSelection_ = selection_; @@ -4427,6 +4625,36 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { 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; + 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); @@ -4670,6 +4898,20 @@ 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(); QVector matches; @@ -4693,6 +4935,21 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { marqueeRect_ = {}; dragging_ = false; interaction_ = Interaction::None; + if (matches.isEmpty() && area.width() >= 2.0 && area.height() >= 2.0) { + const QRectF bounds(QPointF(), selection_.size()); + const QRectF clipped = area.intersected(bounds); + if (clipped.width() >= 2.0 && clipped.height() >= 2.0 && + !nativeRectForPixelClip(clipped).isEmpty()) { + pixelClipRect_ = clipped; + selectedAnnotations_.clear(); + selectedAnnotation_ = -1; + setStatus(QStringLiteral( + "Selection ready — drag inside to clip out · Esc cancels")); + updatePointerCursor(); + update(); + return; + } + } setStatus(selectedAnnotations_.isEmpty() ? QStringLiteral("No layers selected") : QStringLiteral("%1 layers selected · drag to move") @@ -5081,8 +5338,26 @@ 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() && pixelClipRect_.isEmpty() + ? cropHandleAt(cursor_) + : -1; if (dragging_ && interaction_ >= Interaction::CropTopLeft) { cropHandle = static_cast(interaction_) - static_cast(Interaction::CropTopLeft); @@ -5126,7 +5401,16 @@ 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) + punchRect(composed, op.clip.sourceRect); + } + capture_.source = std::move(composed); capture_.previewSize = composedLogicalSize(pristineLogicalSize_, cuts_); backdropKey_ = 0; // force backdrop pixmap rebuild redactionBaseStale_ = true; // force redaction layer rebuild @@ -5369,6 +5653,7 @@ void CaptureEditor::returnToSelect(bool windowMode) { editingAnnotation_ = -1; dragging_ = false; cutDragActive_ = false; + clearPixelClip(); marqueeSelecting_ = false; interaction_ = Interaction::None; colorPaletteOpen_ = false; @@ -5817,7 +6102,7 @@ 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 / clip / move")}, {QStringLiteral("A"), QStringLiteral("Arrow")}, {QStringLiteral("L"), QStringLiteral("Line")}, {QStringLiteral("F / H"), QStringLiteral("Freehand / Highlighter")}, @@ -5919,6 +6204,26 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.drawImage(sourceImage, redactionLayer); else painter.drawImage(sourceImage, capture_.source, sourceRect(selection_)); + if (clipLiftActive_ && !clipLiftOrigin_.isEmpty()) { + const qreal scale = std::max(editScale(), 0.01); + const QRectF hole( + sourceImage.topLeft() + clipLiftOrigin_.topLeft() * scale, + clipLiftOrigin_.size() * scale); + painter.save(); + painter.setClipRect(hole, Qt::IntersectClip); + if (hasBackground) { + const QRectF backing = + grown ? image + : (framedBackground ? image.adjusted(-28, -28, 28, 28) + : sourceImage); + paintCaptureBackground(painter, backing, background, customBackdrop_); + } else { + painter.setCompositionMode(QPainter::CompositionMode_Clear); + painter.fillRect(hole, Qt::transparent); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + } + painter.restore(); + } painter.restore(); @@ -6098,6 +6403,28 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.setBrush(QColor(10, 132, 255, 38)); painter.drawRect(marqueeRect_.normalized()); } + if (tool_ == Tool::Select && !pixelClipRect_.isEmpty() && + !clipLiftActive_) { + const qreal scale = std::max(editScale(), 0.01); + painter.setPen( + QPen(QColor(QStringLiteral("#ffd60a")), 2.0 / scale, Qt::DashLine)); + painter.setBrush(QColor(255, 214, 10, 40)); + painter.drawRect(pixelClipRect_.normalized()); + 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_ && !clipLiftTile_.isNull()) { + painter.setOpacity(clipLiftSnapped_ ? 1.0 : 0.95); + painter.drawImage(clipLiftDest_, clipLiftTile_); + painter.setOpacity(1.0); + } if (cutDragActive_) { const qreal scale = std::max(editScale(), 0.01); const QRectF band = liveCut_.orientation == Qt::Horizontal @@ -6198,7 +6525,8 @@ void CaptureEditor::paintEdit(QPainter &painter) { // 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()) { + if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && + pixelClipRect_.isEmpty() && !clipLiftActive_) { painter.setPen(QPen(QColor(QStringLiteral("#0a84ff")), 1, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawRect(image.adjusted(-1, -1, 1, 1)); diff --git a/src/editor.hpp b/src/editor.hpp index 4941f7d6..ec1eb94a 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -267,6 +267,13 @@ 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); + /// Pixel-marquee awaiting a lift. Test accessor. + [[nodiscard]] QRectF pixelClipRectForTest() const { return pixelClipRect_; } + [[nodiscard]] bool clipLiftActiveForTest() const { return clipLiftActive_; } + [[nodiscard]] QImage composedSourceForTest() const { return capture_.source; } /// Number of selected layers. Test accessor. [[nodiscard]] int selectedCountForTest() const { return static_cast(selectedAnnotations_.size()); @@ -538,7 +545,15 @@ 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 beginClipLift(const QPointF &point); + void updateClipLift(const QPointF &point); + void finishClipLift(); + void cancelClipLift(); + [[nodiscard]] QRect nativeRectForPixelClip(const QRectF &logical) const; + [[nodiscard]] Interaction pixelClipHandleAt(const QPointF &point) const; void commitCanvasBoundary(CanvasBoundaryMode mode); void cycleCanvasBoundary(bool reverse); void cycleBackground(); @@ -651,6 +666,17 @@ 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 rect on the source. Dragging it lifts a clip layer. + QRectF pixelClipRect_; + QRectF originalPixelClip_; + bool pixelClipResizing_ = false; + bool clipLiftActive_ = false; + QRectF clipLiftOrigin_; + QRectF clipLiftDest_; + QPointF clipLiftGrabOffset_; + QImage clipLiftTile_; + bool clipLiftSnapped_ = false; QPointF cutDragStart_; CutOp liveCut_; qreal cutBandLo_ = 0.0; diff --git a/tests/clip-mapping-smoke.cpp b/tests/clip-mapping-smoke.cpp new file mode 100644 index 00000000..a9de0d62 --- /dev/null +++ b/tests/clip-mapping-smoke.cpp @@ -0,0 +1,214 @@ +/** @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 "editor.hpp" +#include "image-fixture.hpp" + +#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.toScreenPointForTest(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; +} + +} // 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(); + } + + // 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(); + 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 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; + } + 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::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(); + } + + // 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(); + } + + 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..c7344389 --- /dev/null +++ b/tests/clip-smoke.cpp @@ -0,0 +1,78 @@ +/** @fileoverview Tests the clip engine: copy, punch, native mapping, snap. */ +#include "clip-smoke.hpp" + +#include "clip.hpp" + +#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)); + + 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; + } + + // 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 (clipSnapThreshold(2.0) != 4.0) { + error = QStringLiteral("clipSnapThreshold scale mapping wrong"); + 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; From 4dee1b51a805c534f01db08620e854938c2efb92 Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Fri, 28 Aug 2026 07:53:11 -0400 Subject: [PATCH 2/8] feat(editor): hole-fill fly-out on pixel clip (transparent default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an empty Select marquee locks a region, a swatch row appears under the rect: transparent (checkerboard, default), the palette colours, and custom. 1–8 and the eyedropper set the infill while the clip is armed. The fill is stored on Operation::Clip so undo/replay keep the hole. --- README.md | 5 +- docs/editing-model.md | 17 ++-- src/capture.cpp | 6 ++ src/clip.cpp | 19 ++++ src/clip.hpp | 14 ++- src/editor.cpp | 182 +++++++++++++++++++++++++++++++---- src/editor.hpp | 12 ++- tests/clip-mapping-smoke.cpp | 43 +++++++++ tests/clip-smoke.cpp | 17 ++++ 9 files changed, 285 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 774fc2fa..70135e01 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ resizable vector layers and preserves the monitor's native pixels on scaled disp - 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, an empty marquee on the screenshot locks a pixel region; - drag it to lift those pixels as a layer and leave a hole (backdrop shows through). + a fly-out next to it picks the hole infill (transparent by default, or any + palette / custom colour). Drag the rect to lift those pixels as a layer. Release near the hole to snap back; release elsewhere to commit. Repeat to pull another region. Ctrl+Z undoes the clip. Distinct from Cut, which collapses a band. - Pin a finished capture as a bottom-right always-on-top layer surface, launched @@ -354,7 +355,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; a marquee that hits no layers locks a pixel clip — 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 | +| `V` | Select/move/resize layers; carrying one past the source grows the canvas; drag empty canvas for a marquee; a marquee that hits no layers locks a pixel clip with a hole-fill fly-out (transparent default, 1–8 / eyedropper / custom for colour) — 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 | | `A` | Arrow | | `S` | Spotlight/loupe; press again to cycle ellipse, rectangle, rounded | | `L` | Straight line | diff --git a/docs/editing-model.md b/docs/editing-model.md index ad38fd88..f33777c6 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -48,14 +48,15 @@ still fully undoable because of how they're kept in the log: (`CaptureEditor::refreshComposedCapture()`). Undo a cut and the composed image is rebuilt without it; `pristineSource_` was never modified. - **Clip** (`Operation::Type::Clip`) copies a rectangle of native pixels and - punches a transparent hole of the same size, then adds those pixels as a - `Annotation::Kind::Clip` layer at the drop location. The hole does **not** - collapse the image (that is Cut). Replay walks the log from `pristineSource_` - and applies cuts and clip punches in order, copying the tile from the - composed image as it existed at that op so later cuts cannot rewrite an - already-torn piece. 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. + 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. The hole does **not** collapse the image (that + is Cut). Replay walks the log from `pristineSource_` and applies cuts and + clip fills in order, copying the tile from the composed image as it existed + at that op so later cuts cannot rewrite an already-torn piece. 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. The fill is stored on the Clip + op (`fill` as HexArgb; omitted when transparent). - **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/src/capture.cpp b/src/capture.cpp index 4c7bde30..b0d861e1 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1856,6 +1856,9 @@ QJsonObject operationToJson(const Operation &operation) { operation.clip.sourceRect.y(), operation.clip.sourceRect.width(), operation.clip.sourceRect.height()}); + 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())); @@ -1939,6 +1942,9 @@ bool operationFromJson(const QJsonObject &object, Operation &operation, operation.type = Operation::Type::Clip; const QRectF source = rectFromArray(object.value(QStringLiteral("sourceRect"))); operation.clip.sourceRect = source.toRect(); + const QString fill = object.value(QStringLiteral("fill")).toString(); + if (!fill.isEmpty()) + operation.clip.fill = QColor(fill); if (object.contains(QStringLiteral("annotation"))) { Annotation annotation; if (!annotationFromJson( diff --git a/src/clip.cpp b/src/clip.cpp index 2c50cb4e..091b6d95 100644 --- a/src/clip.cpp +++ b/src/clip.cpp @@ -1,6 +1,7 @@ /** @fileoverview Rectangular clip-out engine: copy a region and punch a hole. */ #include "clip.hpp" +#include #include #include #include @@ -34,6 +35,24 @@ void punchRect(QImage &image, QRect sourceRect) { painter.fillRect(sourceRect, Qt::transparent); } +void fillHole(QImage &image, QRect sourceRect, const QColor &fill) { + if (!clipFillOpaque(fill)) { + punchRect(image, sourceRect); + return; + } + if (image.isNull()) + return; + sourceRect = sourceRect.intersected(image.rect()); + if (sourceRect.isEmpty()) + return; + if (image.format() != QImage::Format_ARGB32 && + image.format() != QImage::Format_ARGB32_Premultiplied) + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + QPainter painter(&image); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.fillRect(sourceRect, fill); +} + QRect nativeClipRect(QRectF logical, QSize preview, QSize source) { logical = logical.normalized(); if (logical.isEmpty() || !preview.isValid() || preview.width() <= 0 || diff --git a/src/clip.hpp b/src/clip.hpp index c2938dce..1073496f 100644 --- a/src/clip.hpp +++ b/src/clip.hpp @@ -7,6 +7,7 @@ * order so later cuts see the hole already punched. */ #pragma once +#include #include #include #include @@ -14,12 +15,19 @@ /** One applied clip. `sourceRect` is inclusive top-left, exclusive of the * pixels past `right()`/`bottom()` in the usual QRect sense (x, y, w, h). - * An empty rect, or one that misses the image, is a no-op. */ + * An empty rect, or one that misses the image, is a no-op. `fill` is the + * hole infill: default (invalid or alpha 0) punches transparent so a + * backdrop shows through; a solid color paints that swatch into the hole. */ struct ClipOp { QRect sourceRect; + QColor fill; bool operator==(const ClipOp &) const = default; }; +[[nodiscard]] inline bool clipFillOpaque(const QColor &fill) { + return fill.isValid() && fill.alpha() > 0; +} + /** Returns a copy of `sourceRect` intersected with `source`. Null when the * intersection is empty. */ [[nodiscard]] QImage copyRect(const QImage &source, QRect sourceRect); @@ -29,6 +37,10 @@ struct ClipOp { * 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); + /** 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. */ diff --git a/src/editor.cpp b/src/editor.cpp index c076260f..29fd088a 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -271,6 +271,23 @@ 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; @@ -1254,8 +1271,8 @@ QString CaptureEditor::toolStatus() const { switch (tool_) { case Tool::Select: return QStringLiteral( - "Select · empty marquee clips pixels · drag inside to lift · " - "Ctrl+wheel zooms · outer handles crop"); + "Select · empty marquee clips pixels · pick a hole fill · " + "drag inside to lift · Ctrl+wheel zooms · outer handles crop"); case Tool::Spotlight: { const QString shape = spotlightShape_ == SpotlightShape::Ellipse ? QStringLiteral("ellipse") @@ -1773,6 +1790,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) { @@ -2279,6 +2298,27 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, QStringLiteral("tool-eyedropper"), {}, QStringLiteral("Sample from image · I"), {}}); } + if (includeSubmenus && !pixelClipRect_.isEmpty() && !clipLiftActive_) { + const QRectF menu = clipFillMenuRect(); + if (!menu.isEmpty()) { + buttons.push_back({{menu.left() + 4, menu.top() + 4, 24, 28}, + QStringLiteral("clip-fill-transparent"), {}, + QStringLiteral("Hole fill · transparent"), {}}); + const int presetCount = static_cast(paletteConfig_.palette.size()); + for (int index = 0; index < presetCount; ++index) { + buttons.push_back( + {{menu.left() + 4 + (index + 1) * 28, menu.top() + 4, 24, 28}, + QStringLiteral("clip-fill-%1").arg(index), + {}, + QStringLiteral("Hole fill · %1").arg(index + 1), + paletteConfig_.palette.at(static_cast(index))}); + } + buttons.push_back( + {{menu.left() + 4 + (presetCount + 1) * 28, menu.top() + 4, 24, 28}, + QStringLiteral("clip-fill-custom"), {}, + QStringLiteral("Hole fill · custom color"), {}}); + } + } return buttons; } @@ -2493,7 +2533,7 @@ void CaptureEditor::commitClip(ClipOp clip, Annotation annotation) { } void CaptureEditor::applyClipForTest(const QRectF &logicalRect, - const QRectF &dest) { + const QRectF &dest, const QColor &fill) { const QRect native = nativeRectForPixelClip(logicalRect); if (native.isEmpty()) return; @@ -2501,13 +2541,14 @@ void CaptureEditor::applyClipForTest(const QRectF &logicalRect, annotation.kind = Annotation::Kind::Clip; annotation.start = dest.topLeft(); annotation.end = dest.bottomRight(); - commitClip(ClipOp{native}, std::move(annotation)); + commitClip(ClipOp{native, fill}, std::move(annotation)); } void CaptureEditor::clearPixelClip() { pixelClipRect_ = {}; originalPixelClip_ = {}; pixelClipResizing_ = false; + clipFill_ = QColor(0, 0, 0, 0); cancelClipLift(); } @@ -2544,6 +2585,44 @@ CaptureEditor::pixelClipHandleAt(const QPointF &point) const { 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); +} + +QRectF CaptureEditor::clipFillMenuRect() const { + if (pixelClipRect_.isEmpty() || clipLiftActive_) + return {}; + const int swatches = + static_cast(paletteConfig_.palette.size()) + 2; // transparent + custom + const qreal menuWidth = 8.0 + swatches * 28.0; + const QRectF clip = pixelClipWidgetRect(); + QRectF menu(clip.center().x() - menuWidth / 2.0, clip.bottom() + 8.0, + menuWidth, 36.0); + if (menu.bottom() > height() - 8.0) + menu.moveBottom(clip.top() - 8.0); + if (menu.left() < 8.0) + menu.moveLeft(8.0); + if (menu.right() > this->width() - 8.0) + menu.moveRight(this->width() - 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::beginClipLift(const QPointF &point) { const QRect native = nativeRectForPixelClip(pixelClipRect_); const QImage tile = copyRect(capture_.source, native); @@ -2593,7 +2672,7 @@ void CaptureEditor::finishClipLift() { cancelClipLift(); pixelClipRect_ = {}; dragging_ = false; - commitClip(ClipOp{native}, std::move(annotation)); + commitClip(ClipOp{native, clipFill_}, std::move(annotation)); selectedAnnotation_ = annotations_.isEmpty() ? -1 : static_cast(annotations_.size()) - 1; @@ -2831,7 +2910,7 @@ void CaptureEditor::replayLog() { } case Operation::Type::Clip: { QImage tile = copyRect(composed, op.clip.sourceRect); - punchRect(composed, op.clip.sourceRect); + fillHole(composed, op.clip.sourceRect, op.clip.fill); Annotation layer; if (!op.annotations.isEmpty()) layer = op.annotations.constFirst(); @@ -3704,7 +3783,27 @@ 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; + } + 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; @@ -4086,9 +4185,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_}); } @@ -4602,6 +4704,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 && @@ -4941,10 +5045,12 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { if (clipped.width() >= 2.0 && clipped.height() >= 2.0 && !nativeRectForPixelClip(clipped).isEmpty()) { pixelClipRect_ = clipped; + clipFill_ = QColor(0, 0, 0, 0); selectedAnnotations_.clear(); selectedAnnotation_ = -1; setStatus(QStringLiteral( - "Selection ready — drag inside to clip out · Esc cancels")); + "Selection ready — pick a hole fill or drag inside to clip out · " + "Esc cancels")); updatePointerCursor(); update(); return; @@ -5317,7 +5423,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; } @@ -5408,7 +5515,7 @@ void CaptureEditor::refreshComposedCapture() { composed = removeBand(composed, op.cut.orientation, op.cut.sourceStart, op.cut.sourceEnd); else if (op.type == Operation::Type::Clip) - punchRect(composed, op.clip.sourceRect); + fillHole(composed, op.clip.sourceRect, op.clip.fill); } capture_.source = std::move(composed); capture_.previewSize = composedLogicalSize(pristineLogicalSize_, cuts_); @@ -6211,7 +6318,9 @@ void CaptureEditor::paintEdit(QPainter &painter) { clipLiftOrigin_.size() * scale); painter.save(); painter.setClipRect(hole, Qt::IntersectClip); - if (hasBackground) { + if (clipFillOpaque(clipFill_)) { + painter.fillRect(hole, clipFill_); + } else if (hasBackground) { const QRectF backing = grown ? image : (framedBackground ? image.adjusted(-28, -28, 28, 28) @@ -6406,10 +6515,19 @@ void CaptureEditor::paintEdit(QPainter &painter) { if (tool_ == Tool::Select && !pixelClipRect_.isEmpty() && !clipLiftActive_) { const qreal scale = std::max(editScale(), 0.01); + const QRectF locked = pixelClipRect_.normalized(); + if (clipFillOpaque(clipFill_)) { + QColor overlay = clipFill_; + overlay.setAlpha(std::min(200, overlay.alpha())); + painter.setPen(Qt::NoPen); + painter.setBrush(overlay); + painter.drawRect(locked); + } painter.setPen( QPen(QColor(QStringLiteral("#ffd60a")), 2.0 / scale, Qt::DashLine)); - painter.setBrush(QColor(255, 214, 10, 40)); - painter.drawRect(pixelClipRect_.normalized()); + painter.setBrush(clipFillOpaque(clipFill_) ? Qt::NoBrush + : QColor(255, 214, 10, 40)); + painter.drawRect(locked); painter.setPen(QPen(Qt::white, 1.0 / scale)); painter.setBrush(QColor(QStringLiteral("#0a84ff"))); const qreal radius = 5.0 / scale; @@ -6550,6 +6668,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); @@ -6654,7 +6777,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; @@ -6665,7 +6798,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)); diff --git a/src/editor.hpp b/src/editor.hpp index ec1eb94a..bcb4094a 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -269,10 +269,15 @@ class CaptureEditor final : public QWidget { 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); + void applyClipForTest(const QRectF &logicalRect, const QRectF &dest, + const QColor &fill = {}); /// Pixel-marquee awaiting a lift. Test accessor. [[nodiscard]] QRectF pixelClipRectForTest() const { return pixelClipRect_; } [[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 { @@ -554,6 +559,9 @@ class CaptureEditor final : public QWidget { void cancelClipLift(); [[nodiscard]] QRect nativeRectForPixelClip(const QRectF &logical) const; [[nodiscard]] Interaction pixelClipHandleAt(const QPointF &point) const; + [[nodiscard]] QRectF pixelClipWidgetRect() const; + [[nodiscard]] QRectF clipFillMenuRect() const; + void setClipFill(const QColor &fill); void commitCanvasBoundary(CanvasBoundaryMode mode); void cycleCanvasBoundary(bool reverse); void cycleBackground(); @@ -677,6 +685,8 @@ class CaptureEditor final : public QWidget { QPointF clipLiftGrabOffset_; QImage clipLiftTile_; bool clipLiftSnapped_ = false; + /// Hole infill for the next clip. Transparent (alpha 0) is the default. + QColor clipFill_ = QColor(0, 0, 0, 0); QPointF cutDragStart_; CutOp liveCut_; qreal cutBandLo_ = 0.0; diff --git a/tests/clip-mapping-smoke.cpp b/tests/clip-mapping-smoke.cpp index a9de0d62..96cfb4e6 100644 --- a/tests/clip-mapping-smoke.cpp +++ b/tests/clip-mapping-smoke.cpp @@ -7,6 +7,7 @@ #include "clip-mapping-smoke.hpp" #include "capture.hpp" +#include "clip.hpp" #include "editor.hpp" #include "image-fixture.hpp" @@ -113,6 +114,26 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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. { @@ -141,6 +162,28 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, error = QStringLiteral("empty marquee did not lock a pixel clip"); 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 was not transparent by default"); + 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; + } if (!saveGrab(editor, outputRoot + QStringLiteral("-clip-marquee.png"), error)) return false; diff --git a/tests/clip-smoke.cpp b/tests/clip-smoke.cpp index c7344389..c5d823ea 100644 --- a/tests/clip-smoke.cpp +++ b/tests/clip-smoke.cpp @@ -37,6 +37,23 @@ bool runClipSmoke(QString &error) { 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"); From 490a37f5ab5097873d4064cde5628f561b682ad2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 13:40:33 +0000 Subject: [PATCH 3/8] fix(editor): clip-out lift cancel, tile replay, and live-drag UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+Z during a lift cancels only the in-progress drag — QTest (and real keyboards) send Control before Z, so a modifier-only press must not drop the lift and then let Z undo the previous op. Empty marquees need a 12px floor, crop chrome stays until lift, and the hole-fill fly-out docks to the side. Replay keeps a present clip tile, PNG loads only on Clip, and live lift paints a downsampled pixmap with snap hysteresis. Co-authored-by: Luke Morrison --- README.md | 13 +- docs/editing-model.md | 7 +- src/capture.cpp | 13 +- src/clip.cpp | 15 +- src/clip.hpp | 21 +- src/editor.cpp | 405 ++++++++++++++++++++++++++++------- src/editor.hpp | 18 ++ tests/clip-mapping-smoke.cpp | 73 ++++++- tests/clip-smoke.cpp | 86 +++++++- 9 files changed, 553 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 70135e01..d3e85175 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,12 @@ 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, an empty marquee on the screenshot locks a pixel region; - a fly-out next to it picks the hole infill (transparent by default, or any - palette / custom colour). Drag the rect to lift those pixels as a layer. - Release near the hole to snap back; release elsewhere to commit. Repeat to pull - another region. Ctrl+Z undoes the clip. Distinct from Cut, which collapses a band. +- Clip-out: in Select, a large enough empty marquee on the screenshot locks a + pixel region (crop handles stay until you lift); a side fly-out picks the hole + infill (transparent by default, or any palette / custom colour). Drag the rect + to lift those pixels as a layer. Release near the hole to snap back; release + elsewhere to commit. Repeat to pull another region. Esc / Ctrl+Z during a lift + cancels only the lift. Distinct from Cut, which collapses a band. - 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 @@ -355,7 +356,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; a marquee that hits no layers locks a pixel clip with a hole-fill fly-out (transparent default, 1–8 / eyedropper / custom for colour) — 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 | +| `V` | Select/move/resize layers; carrying one past the source grows the canvas; drag empty canvas for a marquee; a large enough empty marquee locks a pixel clip (crop handles stay until you lift) with a side hole-fill fly-out (transparent default, 1–8 / eyedropper / custom for colour) — 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/editing-model.md b/docs/editing-model.md index f33777c6..1ac2372f 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -51,9 +51,10 @@ still fully undoable because of how they're kept in the log: 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. The hole does **not** collapse the image (that - is Cut). Replay walks the log from `pristineSource_` and applies cuts and - clip fills in order, copying the tile from the composed image as it existed - at that op so later cuts cannot rewrite an already-torn piece. Undo a clip + 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. 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. The fill is stored on the Clip op (`fill` as HexArgb; omitted when transparent). diff --git a/src/capture.cpp b/src/capture.cpp index b0d861e1..c2e74ec8 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -431,7 +431,10 @@ void drawAnnotation(QPainter &painter, const Annotation &annotation) { 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; } @@ -1791,10 +1794,12 @@ bool annotationFromJson(const QJsonObject &object, Annotation &annotation, : spotlightShape == QStringLiteral("rounded") ? SpotlightShape::RoundedRectangle : SpotlightShape::Ellipse; - const QByteArray png = QByteArray::fromBase64( - object.value(QStringLiteral("png")).toString().toLatin1()); - if (!png.isEmpty()) - annotation.image.loadFromData(png, "PNG"); + 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; } diff --git a/src/clip.cpp b/src/clip.cpp index 091b6d95..00a90217 100644 --- a/src/clip.cpp +++ b/src/clip.cpp @@ -71,8 +71,12 @@ QRect nativeClipRect(QRectF logical, QSize preview, QSize source) { return native.intersected(QRect(QPoint(), source)); } -qreal clipSnapThreshold(qreal viewScale) { - return 8.0 / std::max(0.001, viewScale); +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, @@ -80,3 +84,10 @@ bool clipDestSnapped(const QRectF &dest, const QRectF &origin, 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) { + if (!existing.isNull() && !prefixChanged) + return existing; + return copyRect(composed, sourceRect); +} diff --git a/src/clip.hpp b/src/clip.hpp index 1073496f..aad286da 100644 --- a/src/clip.hpp +++ b/src/clip.hpp @@ -46,11 +46,26 @@ void fillHole(QImage &image, QRect sourceRect, const QColor &fill); * the Cut tool's band mapping. */ [[nodiscard]] QRect nativeClipRect(QRectF logical, QSize preview, QSize source); -/** Annotation-space distance at which a lifted clip snaps back into its - * hole. `viewScale` is the editor's annotation-to-widget scale. */ -[[nodiscard]] qreal clipSnapThreshold(qreal viewScale); +/** 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); diff --git a/src/editor.cpp b/src/editor.cpp index 29fd088a..f6e8cdf0 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,7 @@ #include #include +#include #include #include #include @@ -291,6 +293,50 @@ void paintCheckerboard(QPainter &painter, const QRectF &bounds, qreal cell) { /// 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; @@ -1271,8 +1317,8 @@ QString CaptureEditor::toolStatus() const { switch (tool_) { case Tool::Select: return QStringLiteral( - "Select · empty marquee clips pixels · pick a hole fill · " - "drag inside to lift · Ctrl+wheel zooms · outer handles crop"); + "Select · drag to select layers · Ctrl+wheel zooms · outer handles " + "crop"); case Tool::Spotlight: { const QString shape = spotlightShape_ == SpotlightShape::Ellipse ? QStringLiteral("ellipse") @@ -1608,16 +1654,17 @@ void CaptureEditor::adjustSelectedAnnotation(int step) { case Annotation::Kind::Highlighter: case Annotation::Kind::Rectangle: case Annotation::Kind::Ellipse: - case Annotation::Kind::Clip: 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, - // and says so. Clip tiles grow the same way: they are a torn-off image, - // not a stroke. - if (annotation.kind == Annotation::Kind::Clip || - (annotation.filled && (annotation.kind == Annotation::Kind::Rectangle || - annotation.kind == Annotation::Kind::Ellipse))) { + // and says so. + if (annotation.filled && (annotation.kind == Annotation::Kind::Rectangle || + annotation.kind == Annotation::Kind::Ellipse)) { const qreal factor = step > 0 ? 1.1 : 1.0 / 1.1; const QRectF bounds = annotationBounds(annotation); const QPointF center = bounds.center(); @@ -1749,12 +1796,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; } @@ -2410,6 +2461,8 @@ void CaptureEditor::applyEditState(const EditState &state) { } void CaptureEditor::cancelActiveDragForHistory() { + if (clipLiftActive_) + cancelClipLift(); if (dragStartStateValid_) replayLog(); dragging_ = false; @@ -2524,12 +2577,45 @@ void CaptureEditor::commitClip(ClipOp clip, Annotation annotation) { if (annotation.id == 0) annotation.id = nextAnnotationId_++; annotation.kind = Annotation::Kind::Clip; - annotation.image = {}; + 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 = std::move(clip); - op.annotations = {std::move(annotation)}; - commitOp(std::move(op)); + 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.sourceRect, clip.fill); + punchDisplayCaches(clip.sourceRect, clip.fill); + annotations_.push_back(std::move(annotation)); + composedPrefixHash_ = composedMutationHash(ops_, opIndex_); + refreshCanvasRect(); + scheduleSnapshot(); + update(); } void CaptureEditor::applyClipForTest(const QRectF &logicalRect, @@ -2544,6 +2630,18 @@ void CaptureEditor::applyClipForTest(const QRectF &logicalRect, commitClip(ClipOp{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_ = {}; @@ -2558,9 +2656,94 @@ void CaptureEditor::cancelClipLift() { 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 &hole) const { + if (hole.isEmpty()) + return; + painter.save(); + painter.setClipRect(hole, 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()); @@ -2601,14 +2784,18 @@ QRectF CaptureEditor::clipFillMenuRect() const { static_cast(paletteConfig_.palette.size()) + 2; // transparent + custom const qreal menuWidth = 8.0 + swatches * 28.0; const QRectF clip = pixelClipWidgetRect(); - QRectF menu(clip.center().x() - menuWidth / 2.0, clip.bottom() + 8.0, - menuWidth, 36.0); - if (menu.bottom() > height() - 8.0) - menu.moveBottom(clip.top() - 8.0); + 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.left() < 8.0) menu.moveLeft(8.0); - if (menu.right() > this->width() - 8.0) - menu.moveRight(this->width() - 8.0); + if (menu.top() < 8.0) + menu.moveTop(8.0); + if (menu.bottom() > height() - 8.0) + menu.moveBottom(height() - 8.0); + if (!customColorPickerOpen_ && menu.intersects(clip) && + clip.contains(cursor_) && !menu.contains(cursor_)) + return {}; return menu; } @@ -2634,6 +2821,7 @@ void CaptureEditor::beginClipLift(const QPointF &point) { clipLiftGrabOffset_ = point - clipLiftOrigin_.center(); clipLiftTile_ = tile; clipLiftSnapped_ = true; + rebuildClipLiftPixmap(); dragging_ = true; interaction_ = Interaction::None; setCursor(Qt::ClosedHandCursor); @@ -2646,10 +2834,12 @@ void CaptureEditor::updateClipLift(const QPointF &point) { return; QRectF dest = clipLiftOrigin_; dest.moveCenter(point - clipLiftGrabOffset_); - const qreal threshold = clipSnapThreshold(editScale()); - clipLiftSnapped_ = clipDestSnapped(dest, clipLiftOrigin_, threshold); + const qreal enter = clipSnapEnterThreshold(editScale()); + const qreal leave = clipSnapLeaveThreshold(editScale()); if (clipLiftSnapped_) - dest = clipLiftOrigin_; + clipLiftSnapped_ = clipDestSnapped(dest, clipLiftOrigin_, leave); + else + clipLiftSnapped_ = clipDestSnapped(dest, clipLiftOrigin_, enter); clipLiftDest_ = dest; } @@ -2669,10 +2859,12 @@ void CaptureEditor::finishClipLift() { annotation.kind = Annotation::Kind::Clip; annotation.start = clipLiftDest_.topLeft(); annotation.end = clipLiftDest_.bottomRight(); + annotation.image = clipLiftTile_; + const QColor fill = clipFill_; cancelClipLift(); pixelClipRect_ = {}; dragging_ = false; - commitClip(ClipOp{native, clipFill_}, std::move(annotation)); + commitClip(ClipOp{native, fill}, std::move(annotation)); selectedAnnotation_ = annotations_.isEmpty() ? -1 : static_cast(annotations_.size()) - 1; @@ -2826,7 +3018,11 @@ void CaptureEditor::replayLog() { CanvasBoundaryMode canvasBoundary = CanvasBoundaryMode::Framed; QVector annotations; QVector cuts; - QImage composed = pristineSource_; + 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); @@ -2903,20 +3099,26 @@ void CaptureEditor::replayLog() { selection.setWidth(std::max(1.0, selection.width() - band)); } cuts.push_back(op.cut); - if (!composed.isNull()) + if (!reuseComposed && !composed.isNull()) composed = removeBand(composed, op.cut.orientation, op.cut.sourceStart, op.cut.sourceEnd); break; } case Operation::Type::Clip: { - QImage tile = copyRect(composed, op.clip.sourceRect); - fillHole(composed, op.clip.sourceRect, op.clip.fill); Annotation layer; if (!op.annotations.isEmpty()) layer = op.annotations.constFirst(); layer.kind = Annotation::Kind::Clip; - if (layer.image.isNull()) - layer.image = std::move(tile); + 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.sourceRect, layer.image, + prefixChanged); + if (layer.id != 0) + clipTilePrefix_.insert(layer.id, prefixBefore); + fillHole(composed, op.clip.sourceRect, op.clip.fill); + } annotations.push_back(std::move(layer)); break; } @@ -2933,8 +3135,11 @@ void CaptureEditor::replayLog() { composedLogicalSize(pristineLogicalSize_.isEmpty() ? capture_.previewSize : pristineLogicalSize_, cuts_); - backdropKey_ = 0; - redactionBaseStale_ = true; + composedPrefixHash_ = prefixHash; + if (!reuseComposed) { + backdropKey_ = 0; + redactionBaseStale_ = true; + } if (!selection.isEmpty()) selection_ = selection; refreshCanvasRect(); @@ -2955,7 +3160,6 @@ void CaptureEditor::replayLog() { if (selectedAnnotation_ < 0 && !selectedAnnotations_.isEmpty()) selectedAnnotation_ = selectedAnnotations_.constLast(); editingAnnotation_ = -1; - redactionBaseStale_ = true; updatePointerCursor(); update(); } @@ -2984,6 +3188,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) { @@ -3963,9 +4178,21 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { } 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 @@ -4254,8 +4481,9 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { updatePointerCursor(); } else { if (tool_ == Tool::Select && clipLiftActive_) { + const QRect previous = clipLiftRepaintRect(); updateClipLift(toUnclampedAnnotationPoint(cursor_)); - update(); + update(previous.united(clipLiftRepaintRect())); return; } if (tool_ == Tool::Select && pixelClipResizing_ && dragging_) { @@ -4268,13 +4496,30 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { QRectF updated = QRectF(box.start, box.end).normalized(); const QRectF bounds(QPointF(), selection_.size()); updated = updated.intersected(bounds); - if (updated.width() >= 2.0 && updated.height() >= 2.0) + if (pixelClipLargeEnough(updated)) pixelClipRect_ = updated; update(); return; } if (tool_ == Tool::Select && marqueeSelecting_) { marqueeRect_ = QRectF(dragStart_, toAnnotationPoint(cursor_)).normalized(); + bool hitsLayer = false; + if (pixelClipLargeEnough(marqueeRect_)) { + for (const Annotation &annotation : annotations_) { + const QRectF bounds = annotationBounds(annotation); + if (!bounds.isEmpty() && marqueeRect_.contains(bounds)) { + hitsLayer = true; + break; + } + } + } + const QString next = + !hitsLayer && pixelClipLargeEnough(marqueeRect_) + ? QStringLiteral( + "Release to lock these pixels · then drag inside to lift") + : QStringLiteral("Drag to select layers"); + if (status_ != next) + status_ = next; update(); return; } @@ -4668,9 +4913,10 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { } } if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && - pixelClipRect_.isEmpty()) { + !clipLiftActive_) { const int cropHandle = cropHandleAt(cursor_); if (cropHandle >= 0) { + clearPixelClip(); originalSelection_ = selection_; cropDragImageRect_ = sourceFrameWidgetRect(); interaction_ = static_cast( @@ -5039,11 +5285,10 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { marqueeRect_ = {}; dragging_ = false; interaction_ = Interaction::None; - if (matches.isEmpty() && area.width() >= 2.0 && area.height() >= 2.0) { + if (matches.isEmpty() && pixelClipLargeEnough(area)) { const QRectF bounds(QPointF(), selection_.size()); const QRectF clipped = area.intersected(bounds); - if (clipped.width() >= 2.0 && clipped.height() >= 2.0 && - !nativeRectForPixelClip(clipped).isEmpty()) { + if (pixelClipLargeEnough(clipped)) { pixelClipRect_ = clipped; clipFill_ = QColor(0, 0, 0, 0); selectedAnnotations_.clear(); @@ -5462,7 +5707,7 @@ void CaptureEditor::updatePointerCursor() { } } int cropHandle = - selectedAnnotations_.isEmpty() && pixelClipRect_.isEmpty() + selectedAnnotations_.isEmpty() && !clipLiftActive_ ? cropHandleAt(cursor_) : -1; if (dragging_ && interaction_ >= Interaction::CropTopLeft) { @@ -5519,6 +5764,7 @@ void CaptureEditor::refreshComposedCapture() { } 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(); @@ -6311,27 +6557,13 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.drawImage(sourceImage, redactionLayer); else painter.drawImage(sourceImage, capture_.source, sourceRect(selection_)); - if (clipLiftActive_ && !clipLiftOrigin_.isEmpty()) { + const QRectF holeLogical = + clipLiftActive_ ? clipLiftOrigin_ : pixelClipRect_.normalized(); + if (!holeLogical.isEmpty() && (clipLiftActive_ || !pixelClipRect_.isEmpty())) { const qreal scale = std::max(editScale(), 0.01); - const QRectF hole( - sourceImage.topLeft() + clipLiftOrigin_.topLeft() * scale, - clipLiftOrigin_.size() * scale); - painter.save(); - painter.setClipRect(hole, Qt::IntersectClip); - if (clipFillOpaque(clipFill_)) { - painter.fillRect(hole, clipFill_); - } else if (hasBackground) { - const QRectF backing = - grown ? image - : (framedBackground ? image.adjusted(-28, -28, 28, 28) - : sourceImage); - paintCaptureBackground(painter, backing, background, customBackdrop_); - } else { - painter.setCompositionMode(QPainter::CompositionMode_Clear); - painter.fillRect(hole, Qt::transparent); - painter.setCompositionMode(QPainter::CompositionMode_SourceOver); - } - painter.restore(); + const QRectF hole(sourceImage.topLeft() + holeLogical.topLeft() * scale, + holeLogical.size() * scale); + paintClipHolePreview(painter, hole); } painter.restore(); @@ -6516,17 +6748,12 @@ void CaptureEditor::paintEdit(QPainter &painter) { !clipLiftActive_) { const qreal scale = std::max(editScale(), 0.01); const QRectF locked = pixelClipRect_.normalized(); - if (clipFillOpaque(clipFill_)) { - QColor overlay = clipFill_; - overlay.setAlpha(std::min(200, overlay.alpha())); - painter.setPen(Qt::NoPen); - painter.setBrush(overlay); - painter.drawRect(locked); - } + painter.setBrush(Qt::NoBrush); + painter.setPen( + QPen(QColor(20, 20, 22, 230), 3.2 / scale, Qt::DashLine)); + painter.drawRect(locked); painter.setPen( - QPen(QColor(QStringLiteral("#ffd60a")), 2.0 / scale, Qt::DashLine)); - painter.setBrush(clipFillOpaque(clipFill_) ? Qt::NoBrush - : QColor(255, 214, 10, 40)); + QPen(QColor(QStringLiteral("#ffd60a")), 1.6 / scale, Qt::DashLine)); painter.drawRect(locked); painter.setPen(QPen(Qt::white, 1.0 / scale)); painter.setBrush(QColor(QStringLiteral("#0a84ff"))); @@ -6538,10 +6765,12 @@ void CaptureEditor::paintEdit(QPainter &painter) { for (const auto &item : annotationHandles(box)) painter.drawEllipse(item.first, radius, radius); } - if (clipLiftActive_ && !clipLiftTile_.isNull()) { - painter.setOpacity(clipLiftSnapped_ ? 1.0 : 0.95); - painter.drawImage(clipLiftDest_, clipLiftTile_); - painter.setOpacity(1.0); + 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); @@ -6640,11 +6869,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. + // Crop handles stay while a pixel clip is only armed; a live lift owns + // the pointer, so hide them then. if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && - pixelClipRect_.isEmpty() && !clipLiftActive_) { + !clipLiftActive_) { painter.setPen(QPen(QColor(QStringLiteral("#0a84ff")), 1, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawRect(image.adjusted(-1, -1, 1, 1)); diff --git a/src/editor.hpp b/src/editor.hpp index bcb4094a..8529719a 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -271,6 +272,10 @@ class CaptureEditor final : public QWidget { /// 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]] bool clipLiftActiveForTest() const { return clipLiftActive_; } @@ -557,10 +562,15 @@ class CaptureEditor final : public QWidget { 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]] Interaction pixelClipHandleAt(const QPointF &point) const; [[nodiscard]] QRectF pixelClipWidgetRect() const; [[nodiscard]] QRectF clipFillMenuRect() const; + [[nodiscard]] QRect clipLiftRepaintRect() const; + void paintClipHolePreview(QPainter &painter, const QRectF &hole) const; void setClipFill(const QColor &fill); void commitCanvasBoundary(CanvasBoundaryMode mode); void cycleCanvasBoundary(bool reverse); @@ -684,9 +694,17 @@ class CaptureEditor final : public QWidget { QRectF clipLiftDest_; QPointF clipLiftGrabOffset_; QImage clipLiftTile_; + QPixmap clipLiftPixmap_; + qreal clipLiftPixmapScale_ = 0.0; bool clipLiftSnapped_ = false; /// Hole infill for the next clip. Transparent (alpha 0) is the default. QColor clipFill_ = QColor(0, 0, 0, 0); + /// 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; diff --git a/tests/clip-mapping-smoke.cpp b/tests/clip-mapping-smoke.cpp index 96cfb4e6..a910e54b 100644 --- a/tests/clip-mapping-smoke.cpp +++ b/tests/clip-mapping-smoke.cpp @@ -30,7 +30,7 @@ CaptureData fixtureCapture(const QImage &source) { } QPoint screenOf(const CaptureEditor &editor, qreal ax, qreal ay) { - return editor.toScreenPointForTest(QPointF(ax, ay)).toPoint(); + return editor.annotationPointToWidgetForTest(QPointF(ax, ay)).toPoint(); } bool saveGrab(CaptureEditor &editor, const QString &path, QString &error) { @@ -151,6 +151,22 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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); @@ -162,6 +178,14 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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; @@ -196,6 +220,22 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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)) @@ -230,6 +270,37 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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), diff --git a/tests/clip-smoke.cpp b/tests/clip-smoke.cpp index c5d823ea..118e0479 100644 --- a/tests/clip-smoke.cpp +++ b/tests/clip-smoke.cpp @@ -2,8 +2,12 @@ #include "clip-smoke.hpp" #include "clip.hpp" +#include "capture.hpp" +#include #include +#include +#include #include #include #include @@ -86,10 +90,88 @@ bool runClipSmoke(QString &error) { error = QStringLiteral("clipDestSnapped threshold wrong"); return false; } - if (clipSnapThreshold(2.0) != 4.0) { - error = QStringLiteral("clipSnapThreshold scale mapping wrong"); + 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); + return true; } From e131cd153bd2c55502beb1315fdc19adf914ceee Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Fri, 28 Aug 2026 11:26:49 -0400 Subject: [PATCH 4/8] docs: clip-out shape mask design Rectangle, ellipse, and lasso masks with click-to-snap, keyboard hole fill, and drag-to-lift. Spec for the follow-on to rectangular clip-out. --- .gitignore | 1 + .../2026-08-28-clip-shape-masks-design.md | 184 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md 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/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..62f7cd48 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md @@ -0,0 +1,184 @@ +# Clip-out shape masks + +Date: 2026-08-28 +Branch: `feat/select-clip-out` +Status: design locked; awaiting spec review before the implementation plan + +## 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 + +While Select is the current tool, a strip sits **above the toolbar**: + +| Chip | First key (stays in Select) | Tooltip | +|---|---|---| +| Rect | `R` | Rectangle · R · V to cycle · press R again to draw | +| Ellipse | `E` | Ellipse · E · V to cycle · press E again to draw | +| Lasso | `F` | Lasso · F · V to cycle · press F again to draw | +| Snap | click, no drag | Snap · click the object · no drag | + +`V` (and a second click of the Select toolbar button) **cycles** Rect → Ellipse → Lasso → Snap → Rect. Status pill names the shape (`Clip · ellipse · drag to lock · click to snap · Esc cancels`). + +The **bottom-left hotkey legend** gains the clip rows while Select is on, same low-opacity column as today. Hover tooltips on the chips match those rows. Do not add a settings UI. + +### Key rule: first press clips, second press draws + +`R`, `E`, and `F` currently leave Select and arm the drawing tools. That stays available: + +- **First press** while Select is on: only changes the clip-mask shape; stay in Select. +- **Second press of the same key**: leave Select and arm the normal drawing tool (rectangle / ellipse / freehand), the same “press again” pattern as Spotlight (`S`) and Highlighter (`H`). +- Toolbar icons still arm drawing tools in one click, unchanged. + +A press-and-release that never exceeds the editor’s existing drag slop is Snap **in every clip shape**, not only when the Snap chip is lit. The Snap chip is the visible “this click will snap” state; cycling to it is optional. A drag that passes slop draws the current Rect / Ellipse / Lasso instead. + +### Fill (hole colour) + +The existing clip fill fly-out (transparent + palette + custom + eyedropper) stays, labeled with keys. Default fill is transparent. + +Fill keys apply **only while a dotted mask is locked** (the path is on screen, not yet lifted). Idle Select does not steal them: `T` still starts Text, `1`–`8` still set annotation colour. Sequence: pick a shape, draw/snap the mask, then `T` / `1`–`8` / `I` / `#`, then grab. + +| Key | While a dotted mask is locked | Otherwise | +|---|---|---| +| `T` | Transparent hole | 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. `T` is the transparent path. `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 polygon in native pixels; empty otherwise + 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]` 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**, not the local colour. + +Algorithm, Qt only, bounded so it stays on the UI thread: + +1. Map the click to native pixels. +2. Take a search window centred on the click, cap the window at 512 px on a side, clipped to the composed image. +3. Compute a simple gradient magnitude in that window (Sobel or adjacent-pixel difference on luma). +4. Find the **strongest closed high-contrast contour that encloses the click** (connected edge pixels, or a thresholded gradient ring). Prefer the innermost tight ring that still encloses the click if several exist. +5. Fit an ellipse (or a circle when width/height are within a small ratio) to that contour. Lock an **Ellipse** mask with handles. +6. If no enclosing contour, the window is empty, or the fitted ellipse would cover most of the screenshot, **fail**: status `Nothing to snap · drag a shape instead`. No mask, no log entry. + +Do not add OpenCV, ONNX, or a model. Do not run this on a worker unless a later measurement shows a hitch; the 512 px cap exists so it should not. + +### 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. +- **Second-press** — `E` in Select sets ellipse clip shape and `tool_ == Select`; a second `E` arms `Tool::Ellipse`. + +`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 | From 21e60a54fae5af78bc29057f8f29c514b2ac4b76 Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Fri, 28 Aug 2026 12:08:03 -0400 Subject: [PATCH 5/8] feat(editor): ellipse, lasso, and snap masks for clip-out Select cycles clip shape with V and a chip strip. Empty-canvas drags that miss layers still lock a mask; R/E/F keep arming drawing tools. Snap ray-casts to an axis-aligned ellipse. Lifted tiles carry alpha outside the path. Fable and Sol P0s applied to the key layer. --- AGENTS.md | 2 +- README.md | 17 +- docs/editing-model.md | 26 +- .../2026-08-28-clip-shape-masks-design.md | 61 +-- src/capture.cpp | 22 + src/clip.cpp | 224 +++++++++- src/clip.hpp | 86 +++- src/editor.cpp | 413 ++++++++++++++++-- src/editor.hpp | 17 +- tests/clip-smoke.cpp | 67 +++ 10 files changed, 828 insertions(+), 107 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97b4eb6d..752b2be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +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 rectangle, punch a hole, lift as a layer | +| `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/README.md b/README.md index d3e85175..f9870e28 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,15 @@ 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, a large enough empty marquee on the screenshot locks a - pixel region (crop handles stay until you lift); a side fly-out picks the hole - infill (transparent by default, or any palette / custom colour). Drag the rect - to lift those pixels as a layer. Release near the hole to snap back; release - elsewhere to commit. Repeat to pull another region. Esc / Ctrl+Z during a lift - cancels only the lift. Distinct from Cut, which collapses a band. +- Clip-out: in Select, `V` cycles clip shape (rectangle, ellipse, lasso, snap) + and a strip under the toolbar shows the same chips. A large enough empty + marquee that hits no layers locks a pixel mask of that shape (snap is + click-to-fit an ellipse on a high-contrast object; crop handles stay until + you lift). A fly-out picks the hole infill (transparent by default; `T` / + `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 @@ -356,7 +359,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; a large enough empty marquee locks a pixel clip (crop handles stay until you lift) with a side hole-fill fly-out (transparent default, 1–8 / eyedropper / custom for colour) — 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) | +| `V` | Select/move/resize layers; carrying one past the source grows the canvas; press again to cycle clip shape (rectangle, ellipse, lasso, snap); drag empty canvas for a marquee; a large enough empty marquee that hits no layers locks a pixel mask of the current shape (snap: click an object; crop handles stay until you lift) with a hole-fill fly-out (transparent default; `T` / `1`–`8` / eyedropper / `#` 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/editing-model.md b/docs/editing-model.md index 1ac2372f..f0027005 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -47,17 +47,21 @@ 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 rectangle of native pixels 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. 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. 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. The fill is stored on the Clip - op (`fill` as HexArgb; omitted when transparent). +- **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 fits an axis-aligned ellipse and then behaves as + a normal ellipse mask. - **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 index 62f7cd48..1d33ec33 100644 --- a/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md +++ b/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md @@ -2,7 +2,7 @@ Date: 2026-08-28 Branch: `feat/select-clip-out` -Status: design locked; awaiting spec review before the implementation plan +Status: design locked; Fable + Sol P0s applied (2026-08-28) ## Problem @@ -29,36 +29,39 @@ Stay in **Select** (`V`). Empty-canvas interaction locks a **pixel mask**, not a Shift while drawing an ellipse still forces 1:1 (a circle), matching existing shape tools. Alt still draws from the centre. -### Shape strip +### 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 | First key (stays in Select) | Tooltip | +| Chip | How you pick it | Tooltip | |---|---|---| -| Rect | `R` | Rectangle · R · V to cycle · press R again to draw | -| Ellipse | `E` | Ellipse · E · V to cycle · press E again to draw | -| Lasso | `F` | Lasso · F · V to cycle · press F again to draw | -| Snap | click, no drag | Snap · click the object · no drag | +| 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 | `V` cycle / chip | Snap · click the object · empty click only in this mode | -`V` (and a second click of the Select toolbar button) **cycles** Rect → Ellipse → Lasso → Snap → Rect. Status pill names the shape (`Clip · ellipse · drag to lock · click to snap · Esc cancels`). +`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 → Snap → Rect. Status pill names the shape. -The **bottom-left hotkey legend** gains the clip rows while Select is on, same low-opacity column as today. Hover tooltips on the chips match those rows. Do not add a settings UI. +The **bottom-left hotkey legend** gains the clip rows while Select is on. Hover tooltips on the chips match those rows. No settings UI. -### Key rule: first press clips, second press draws +### Gestures (keep today’s Select arbitration) -`R`, `E`, and `F` currently leave Select and arm the drawing tools. That stays available: +Empty-canvas interaction is still **layer marquee first**: -- **First press** while Select is on: only changes the clip-mask shape; stay in Select. -- **Second press of the same key**: leave Select and arm the normal drawing tool (rectangle / ellipse / freehand), the same “press again” pattern as Spotlight (`S`) and Highlighter (`H`). -- Toolbar icons still arm drawing tools in one click, unchanged. +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. +2. The same drag with **no layers inside** locks a pixel mask in the current geometric shape (Rect / Ellipse / Lasso). If the current chip is Snap, an empty drag locks an **Ellipse** from the drag bbox (manual fallback). +3. A press-and-release that never reaches 2×2 is a **click**: it deselects, dismisses an uncommitted mask if the click is outside it, or **runs Snap only when the Snap chip is selected** and the click hits no layer. Snap is never implicit in Rect/Ellipse/Lasso. +4. `Alt+click` on empty canvas also snaps (modifier explicit), even if the chip is not Snap. -A press-and-release that never exceeds the editor’s existing drag slop is Snap **in every clip shape**, not only when the Snap chip is lit. The Snap chip is the visible “this click will snap” state; cycling to it is optional. A drag that passes slop draws the current Rect / Ellipse / Lasso instead. +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 existing clip fill fly-out (transparent + palette + custom + eyedropper) stays, labeled with keys. Default fill is transparent. -Fill keys apply **only while a dotted mask is locked** (the path is on screen, not yet lifted). Idle Select does not steal them: `T` still starts Text, `1`–`8` still set annotation colour. Sequence: pick a shape, draw/snap the mask, then `T` / `1`–`8` / `I` / `#`, then grab. +Fill keys apply **only while a dotted mask is locked and Select is the active tool**. 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 | |---|---|---| @@ -85,7 +88,8 @@ 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 polygon in native pixels; empty otherwise + 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 }; ``` @@ -96,7 +100,7 @@ JSON (`type: "clip"`): - `sourceRect` as today `[x,y,w,h]` - `shape`: `"rect"` | `"ellipse"` | `"lasso"` — omitted means `rect` -- `points`: array of `[x,y]` for lasso only +- `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 @@ -118,18 +122,20 @@ Lasso **adjust** after lock is the eight bbox handles (scale/translate the polyg ### Click-to-snap -Naive flood-fill from a face click selects cheek, not the circular avatar. Snap must find the **object**, not the local colour. +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, bounded so it stays on the UI thread: +Algorithm, Qt only, **axis-aligned ellipse**, cheap enough for the UI thread (no contour extraction): 1. Map the click to native pixels. -2. Take a search window centred on the click, cap the window at 512 px on a side, clipped to the composed image. -3. Compute a simple gradient magnitude in that window (Sobel or adjacent-pixel difference on luma). -4. Find the **strongest closed high-contrast contour that encloses the click** (connected edge pixels, or a thresholded gradient ring). Prefer the innermost tight ring that still encloses the click if several exist. -5. Fit an ellipse (or a circle when width/height are within a small ratio) to that contour. Lock an **Ellipse** mask with handles. -6. If no enclosing contour, the window is empty, or the fitted ellipse would cover most of the screenshot, **fail**: status `Nothing to snap · drag a shape instead`. No mask, no log entry. +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. -Do not add OpenCV, ONNX, or a model. Do not run this on a worker unless a later measurement shows a hitch; the 512 px cap exists so it should not. +`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 @@ -152,7 +158,8 @@ Headless offscreen, in the existing smoke suite. Failures name the first wrong p - **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. -- **Second-press** — `E` in Select sets ellipse clip shape and `tool_ == Select`; a second `E` arms `Tool::Ellipse`. +- **V-cycle** — `E` from idle Select still arms `Tool::Ellipse`. `V` then `V` cycles clip shape while `tool_ == Select`. `R` with a rectangle layer selected still toggles fill. +- **Snap vs deselect** — click empty canvas in Rect mode clears selection and does not snap; Snap mode click on a synthetic disk locks an ellipse. `make check` after the behavioural change. diff --git a/src/capture.cpp b/src/capture.cpp index c2e74ec8..4ea1f315 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1861,6 +1861,16 @@ QJsonObject operationToJson(const Operation &operation) { 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 (clipFillOpaque(operation.clip.fill)) object.insert(QStringLiteral("fill"), operation.clip.fill.name(QColor::HexArgb)); @@ -1947,6 +1957,18 @@ bool operationFromJson(const QJsonObject &object, Operation &operation, 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); diff --git a/src/clip.cpp b/src/clip.cpp index 00a90217..88de22b6 100644 --- a/src/clip.cpp +++ b/src/clip.cpp @@ -1,9 +1,11 @@ -/** @fileoverview Rectangular clip-out engine: copy a region and punch a hole. */ +/** @fileoverview Clip-out engine: copy a path and punch a hole. */ #include "clip.hpp" #include #include #include +#include +#include #include #include #include @@ -11,6 +13,54 @@ #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)); +} + +} // namespace + +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: + path.addRect(QRectF(box)); + break; + } + return path; +} QImage copyRect(const QImage &source, QRect sourceRect) { if (source.isNull()) @@ -21,36 +71,65 @@ QImage copyRect(const QImage &source, QRect sourceRect) { 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) + 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; - if (image.format() != QImage::Format_ARGB32 && - image.format() != QImage::Format_ARGB32_Premultiplied) - image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + ensurePremultiplied(image); QPainter painter(&image); painter.setCompositionMode(QPainter::CompositionMode_Clear); painter.fillRect(sourceRect, Qt::transparent); } void fillHole(QImage &image, QRect sourceRect, const QColor &fill) { - if (!clipFillOpaque(fill)) { - punchRect(image, sourceRect); - return; - } + fillHole(image, ClipOp{ClipShape::Rect, sourceRect, {}, fill}); +} + +void fillHole(QImage &image, const ClipOp &clip) { if (image.isNull()) return; - sourceRect = sourceRect.intersected(image.rect()); - if (sourceRect.isEmpty()) + const QRect box = clip.sourceRect.intersected(image.rect()); + if (box.isEmpty()) return; - if (image.format() != QImage::Format_ARGB32 && - image.format() != QImage::Format_ARGB32_Premultiplied) - image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + 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.fillRect(sourceRect, fill); + painter.fillPath(path, clip.fill); } QRect nativeClipRect(QRectF logical, QSize preview, QSize source) { @@ -71,6 +150,114 @@ QRect nativeClipRect(QRectF logical, QSize preview, QSize source) { 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) { + ClipOp clip; + clip.shape = shape; + clip.fill = fill; + 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 snapEllipseRect(const QImage &source, QPoint click) { + if (source.isNull() || !source.rect().contains(click)) + return std::nullopt; + const QImage img = source.convertToFormat(QImage::Format_ARGB32); + constexpr int kRays = 36; + constexpr int kMax = 256; + constexpr int kThresh = 28; + constexpr int kMinHits = 12; + 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 <= kMax; ++s) { + const int x = click.x() + static_cast(std::lround(dx * s)); + const int y = click.y() + static_cast(std::lround(dy * s)); + if (!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 (hits.size() < kMinHits) + return std::nullopt; + int minX = hits.constFirst().x(); + int maxX = minX; + int minY = hits.constFirst().y(); + int maxY = minY; + 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()); + } + QRect box(QPoint(minX, minY), QPoint(maxX, maxY)); + box = box.intersected(source.rect()); + if (box.width() < 6 || box.height() < 6) + return std::nullopt; + const qreal cover = static_cast(box.width()) * box.height() / + std::max(1, source.width() * source.height()); + if (cover > 0.70) + return std::nullopt; + const QRectF ellipse(box); + if (!ellipse.contains(click)) + return std::nullopt; + 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()); + } + return box; +} + qreal clipSnapEnterThreshold(qreal viewScale) { return 14.0 / std::max(0.001, viewScale); } @@ -87,7 +274,14 @@ bool clipDestSnapped(const QRectF &dest, const QRectF &origin, 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 copyRect(composed, sourceRect); + return copyMasked(composed, clip); } diff --git a/src/clip.hpp b/src/clip.hpp index aad286da..9e5a6e15 100644 --- a/src/clip.hpp +++ b/src/clip.hpp @@ -1,25 +1,36 @@ -/** @fileoverview Rectangular clip-out engine: copy a native-pixel rect and - * punch a transparent hole without collapsing the gap. +/** @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 native pixels 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. */ + * 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 -/** One applied clip. `sourceRect` is inclusive top-left, exclusive of the - * pixels past `right()`/`bottom()` in the usual QRect sense (x, y, w, h). - * An empty rect, or one that misses the image, is a no-op. `fill` is the - * hole infill: default (invalid or alpha 0) punches transparent so a - * backdrop shows through; a solid color paints that swatch into the hole. */ +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; bool operator==(const ClipOp &) const = default; }; @@ -28,10 +39,44 @@ struct ClipOp { return fill.isValid() && fill.alpha() > 0; } +[[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. */ @@ -41,11 +86,30 @@ void punchRect(QImage &image, QRect sourceRect); * 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); + +/** Ray-cast snap: axis-aligned ellipse around `click`, or nullopt. */ +[[nodiscard]] std::optional snapEllipseRect(const QImage &source, + QPoint click); + /** 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. */ @@ -69,3 +133,5 @@ void fillHole(QImage &image, QRect sourceRect, const QColor &fill); * 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 f6e8cdf0..50923c50 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -2627,7 +2628,7 @@ void CaptureEditor::applyClipForTest(const QRectF &logicalRect, annotation.kind = Annotation::Kind::Clip; annotation.start = dest.topLeft(); annotation.end = dest.bottomRight(); - commitClip(ClipOp{native, fill}, std::move(annotation)); + commitClip(ClipOp{ClipShape::Rect, native, {}, fill}, std::move(annotation)); } void CaptureEditor::markLastClipTileForTest(const QColor &color) { @@ -2645,11 +2646,159 @@ void CaptureEditor::markLastClipTileForTest(const QColor &color) { void CaptureEditor::clearPixelClip() { pixelClipRect_ = {}; originalPixelClip_ = {}; + pixelClipPoints_.clear(); + originalPixelClipPoints_.clear(); pixelClipResizing_ = false; + clipHexEntry_.clear(); clipFill_ = QColor(0, 0, 0, 0); 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::Snap); + break; + case PixelClipShape::Snap: + setPixelClipShape(PixelClipShape::Rect); + break; + } +} + +void CaptureEditor::setPixelClipShape(PixelClipShape shape) { + pixelClipShape_ = shape; + if (shape != PixelClipShape::Lasso && pixelClipRect_.isEmpty()) + pixelClipPoints_.clear(); + switch (shape) { + case PixelClipShape::Rect: + setStatus(QStringLiteral( + "Clip · rectangle · empty drag with no layers locks a mask · V cycles")); + break; + case PixelClipShape::Ellipse: + setStatus(QStringLiteral( + "Clip · ellipse · empty drag with no layers locks a mask · V cycles")); + break; + case PixelClipShape::Lasso: + setStatus(QStringLiteral( + "Clip · lasso · empty drag with no layers locks a mask · V cycles")); + break; + case PixelClipShape::Snap: + setStatus(QStringLiteral( + "Clip · snap · click an object · empty drag still selects layers · V " + "cycles")); + break; + } + update(); +} + +ClipOp CaptureEditor::lockedClipOp() const { + ClipShape shape = ClipShape::Rect; + if (!pixelClipPoints_.isEmpty()) + shape = ClipShape::Lasso; + else if (pixelClipShape_ == PixelClipShape::Ellipse || + pixelClipShape_ == PixelClipShape::Snap) + 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_); +} + +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 (pixelClipShape_ == PixelClipShape::Ellipse || + pixelClipShape_ == PixelClipShape::Snap) + path.addEllipse(box); + else + path.addRect(box); + return path; +} + +void CaptureEditor::trySnapAt(const QPointF &annotationPoint) { + const QRectF logical(annotationPoint, QSizeF(1, 1)); + const QRect native = nativeRectForPixelClip(logical); + if (native.isEmpty()) { + setStatus(QStringLiteral("Nothing to snap · drag a shape instead")); + return; + } + const std::optional box = + snapEllipseRect(capture_.source, native.center()); + if (!box) { + setStatus(QStringLiteral("Nothing to snap · drag a shape instead")); + return; + } + 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) { + setStatus(QStringLiteral("Nothing to snap · drag a shape instead")); + return; + } + pixelClipRect_ = logicalBox; + pixelClipPoints_.clear(); + clipFill_ = QColor(0, 0, 0, 0); + selectedAnnotations_.clear(); + selectedAnnotation_ = -1; + setStatus(QStringLiteral( + "Snapped to ellipse · pick a hole fill or drag inside to clip out · Esc " + "cancels")); +} + +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"), + QStringLiteral("Snap · click an object")); + return buttons; +} + void CaptureEditor::cancelClipLift() { clipLiftActive_ = false; clipLiftOrigin_ = {}; @@ -2811,8 +2960,8 @@ void CaptureEditor::setClipFill(const QColor &fill) { } void CaptureEditor::beginClipLift(const QPointF &point) { - const QRect native = nativeRectForPixelClip(pixelClipRect_); - const QImage tile = copyRect(capture_.source, native); + clipLiftOp_ = lockedClipOp(); + const QImage tile = copyMasked(capture_.source, clipLiftOp_); if (tile.isNull()) return; clipLiftActive_ = true; @@ -2854,17 +3003,18 @@ void CaptureEditor::finishClipLift() { update(); return; } - const QRect native = nativeRectForPixelClip(clipLiftOrigin_); + ClipOp clip = clipLiftOp_; + clip.fill = clipFill_; Annotation annotation; annotation.kind = Annotation::Kind::Clip; annotation.start = clipLiftDest_.topLeft(); annotation.end = clipLiftDest_.bottomRight(); annotation.image = clipLiftTile_; - const QColor fill = clipFill_; cancelClipLift(); pixelClipRect_ = {}; + pixelClipPoints_.clear(); dragging_ = false; - commitClip(ClipOp{native, fill}, std::move(annotation)); + commitClip(std::move(clip), std::move(annotation)); selectedAnnotation_ = annotations_.isEmpty() ? -1 : static_cast(annotations_.size()) - 1; @@ -3113,11 +3263,11 @@ void CaptureEditor::replayLog() { const quint64 prefixBefore = composedMutationHash(ops_, index); const bool prefixChanged = layer.id != 0 && clipTilePrefix_.value(layer.id) != prefixBefore; - layer.image = resolveClipTile(composed, op.clip.sourceRect, layer.image, + layer.image = resolveClipTile(composed, op.clip, layer.image, prefixChanged); if (layer.id != 0) clipTilePrefix_.insert(layer.id, prefixBefore); - fillHole(composed, op.clip.sourceRect, op.clip.fill); + fillHole(composed, op.clip); } annotations.push_back(std::move(layer)); break; @@ -3397,6 +3547,12 @@ void CaptureEditor::enterExport() { } void CaptureEditor::handleEscape() { + if (!clipHexEntry_.isEmpty()) { + clipHexEntry_.clear(); + setStatus(QStringLiteral("Hex cancelled")); + update(); + return; + } if (clipLiftActive_) { cancelClipLift(); dragging_ = false; @@ -3926,8 +4082,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")) + setPixelClipShape(PixelClipShape::Snap); else if (action == QStringLiteral("tool-arrow")) tool_ = Tool::Arrow; else if (action == QStringLiteral("tool-line")) @@ -4052,6 +4221,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(); } @@ -4080,6 +4251,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. @@ -4304,7 +4519,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) { @@ -4381,13 +4601,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 (tool_ == Tool::Select && !pixelClipRect_.isEmpty() && + !clipLiftActive_) { + setClipFill(QColor(0, 0, 0, 0)); + } 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_; @@ -4430,6 +4661,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(); } @@ -4496,13 +4729,37 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { QRectF updated = QRectF(box.start, box.end).normalized(); const QRectF bounds(QPointF(), selection_.size()); updated = updated.intersected(bounds); - if (pixelClipLargeEnough(updated)) + if (pixelClipLargeEnough(updated)) { pixelClipRect_ = updated; + 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); + } bool hitsLayer = false; if (pixelClipLargeEnough(marqueeRect_)) { for (const Annotation &annotation : annotations_) { @@ -4912,6 +5169,12 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { return; } } + for (const ToolbarButton &button : clipShapeStripButtons()) { + if (button.rect.contains(cursor_)) { + handleToolbar(button.action); + return; + } + } if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && !clipLiftActive_) { const int cropHandle = cropHandleAt(cursor_); @@ -4991,6 +5254,7 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { if (clipHandle != Interaction::None) { pixelClipResizing_ = true; originalPixelClip_ = locked; + originalPixelClipPoints_ = pixelClipPoints_; interaction_ = clipHandle; dragStart_ = point; dragging_ = true; @@ -5038,12 +5302,20 @@ 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}; setStatus(QStringLiteral("Drag to select layers")); update(); return; @@ -5285,6 +5557,14 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { marqueeRect_ = {}; dragging_ = false; interaction_ = Interaction::None; + if (matches.isEmpty() && !pixelClipLargeEnough(area) && + pixelClipShape_ == PixelClipShape::Snap) { + trySnapAt(dragStart_); + pixelClipPoints_.clear(); + updatePointerCursor(); + update(); + return; + } if (matches.isEmpty() && pixelClipLargeEnough(area)) { const QRectF bounds(QPointF(), selection_.size()); const QRectF clipped = area.intersected(bounds); @@ -5293,14 +5573,27 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { clipFill_ = QColor(0, 0, 0, 0); selectedAnnotations_.clear(); selectedAnnotation_ = -1; - setStatus(QStringLiteral( - "Selection ready — pick a hole fill or drag inside to clip out · " - "Esc cancels")); - updatePointerCursor(); - update(); - return; + if (pixelClipShape_ == PixelClipShape::Lasso) { + if (pixelClipPoints_.size() < 3) { + pixelClipPoints_.clear(); + pixelClipRect_ = {}; + } + } else if (pixelClipShape_ == PixelClipShape::Snap) { + pixelClipPoints_.clear(); + } else { + pixelClipPoints_.clear(); + } + if (!pixelClipRect_.isEmpty()) { + setStatus(QStringLiteral( + "Selection ready — pick a hole fill or drag inside to clip out " + "· Esc cancels")); + updatePointerCursor(); + update(); + return; + } } } + pixelClipPoints_.clear(); setStatus(selectedAnnotations_.isEmpty() ? QStringLiteral("No layers selected") : QStringLiteral("%1 layers selected · drag to move") @@ -5760,7 +6053,7 @@ void CaptureEditor::refreshComposedCapture() { composed = removeBand(composed, op.cut.orientation, op.cut.sourceStart, op.cut.sourceEnd); else if (op.type == Operation::Type::Clip) - fillHole(composed, op.clip.sourceRect, op.clip.fill); + fillHole(composed, op.clip); } capture_.source = std::move(composed); capture_.previewSize = composedLogicalSize(pristineLogicalSize_, cuts_); @@ -6455,14 +6748,14 @@ void CaptureEditor::paintEdit(QPainter &painter) { // image, the toolbar, a popup) simply covers it wherever they overlap. drawHotkeyLegend( painter, rect(), - {{QStringLiteral("V"), QStringLiteral("Select / clip / move")}, + {{QStringLiteral("V"), QStringLiteral("Select / cycle clip shape")}, {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 · transparent hole when clipping")}, {QStringLiteral("Double click"), QStringLiteral("Edit text layer")}, {QStringLiteral("1–8"), QStringLiteral("Color")}, {QStringLiteral("Wheel"), QStringLiteral("Zoom selected / tool size")}, @@ -6742,19 +7035,43 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.setPen( QPen(QColor(QStringLiteral("#0a84ff")), 2.0 / scale)); painter.setBrush(QColor(10, 132, 255, 38)); - painter.drawRect(marqueeRect_.normalized()); + const QRectF box = marqueeRect_.normalized(); + if (pixelClipShape_ == PixelClipShape::Lasso && + pixelClipPoints_.size() >= 2) { + 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 || + pixelClipShape_ == PixelClipShape::Snap) { + painter.drawEllipse(box); + } else { + painter.drawRect(box); + } } - if (tool_ == Tool::Select && !pixelClipRect_.isEmpty() && - !clipLiftActive_) { + if ((tool_ == Tool::Select || tool_ == Tool::Eyedropper) && + !pixelClipRect_.isEmpty() && !clipLiftActive_) { const qreal scale = std::max(editScale(), 0.01); - const QRectF locked = pixelClipRect_.normalized(); + 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.drawRect(locked); + painter.drawPath(path); painter.setPen( QPen(QColor(QStringLiteral("#ffd60a")), 1.6 / scale, Qt::DashLine)); - painter.drawRect(locked); + painter.drawPath(path); painter.setPen(QPen(Qt::white, 1.0 / scale)); painter.setBrush(QColor(QStringLiteral("#0a84ff"))); const qreal radius = 5.0 / scale; @@ -7005,6 +7322,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; @@ -7080,6 +7398,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") && + pixelClipShape_ == PixelClipShape::Snap); + 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 8529719a..4dd86d1f 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -152,6 +152,7 @@ class CaptureEditor final : public QWidget { Ocr, Eyedropper }; + enum class PixelClipShape { Rect, Ellipse, Lasso, Snap }; private: enum class Phase { Select, Export, Edit }; @@ -278,6 +279,9 @@ class CaptureEditor final : public QWidget { void replayLogForTest() { replayLog(); } /// Pixel-marquee awaiting a lift. Test accessor. [[nodiscard]] QRectF pixelClipRectForTest() const { return pixelClipRect_; } + [[nodiscard]] PixelClipShape pixelClipShapeForTest() const { + return pixelClipShape_; + } [[nodiscard]] bool clipLiftActiveForTest() const { return clipLiftActive_; } [[nodiscard]] QColor clipFillForTest() const { return clipFill_; } [[nodiscard]] QRectF clipFillMenuRectForTest() const { @@ -558,6 +562,11 @@ class CaptureEditor final : public QWidget { void commitClip(ClipOp clip, Annotation annotation); void commitBackground(BackgroundStyle style, bool imageShadow); void clearPixelClip(); + void cyclePixelClipShape(); + void setPixelClipShape(PixelClipShape shape); + [[nodiscard]] ClipOp lockedClipOp() const; + [[nodiscard]] QPainterPath logicalClipPath() const; + void trySnapAt(const QPointF &annotationPoint); void beginClipLift(const QPointF &point); void updateClipLift(const QPointF &point); void finishClipLift(); @@ -566,6 +575,7 @@ class CaptureEditor final : public QWidget { 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; @@ -685,9 +695,12 @@ class CaptureEditor final : public QWidget { // 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 rect on the source. Dragging it lifts a clip layer. + /// a locked path on the source. Dragging it lifts a clip layer. + PixelClipShape pixelClipShape_ = PixelClipShape::Rect; QRectF pixelClipRect_; QRectF originalPixelClip_; + QVector pixelClipPoints_; + QVector originalPixelClipPoints_; bool pixelClipResizing_ = false; bool clipLiftActive_ = false; QRectF clipLiftOrigin_; @@ -696,9 +709,11 @@ class CaptureEditor final : public QWidget { 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; diff --git a/tests/clip-smoke.cpp b/tests/clip-smoke.cpp index 118e0479..060d6fe4 100644 --- a/tests/clip-smoke.cpp +++ b/tests/clip-smoke.cpp @@ -9,9 +9,13 @@ #include #include #include +#include +#include +#include #include #include #include +#include bool runClipSmoke(QString &error) { QImage source(8, 8, QImage::Format_ARGB32_Premultiplied); @@ -173,5 +177,68 @@ bool runClipSmoke(QString &error) { } 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; + } + return true; } From 2d1eaeef8285645e3c211811cf6e01af054f4865 Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Fri, 28 Aug 2026 12:44:50 -0400 Subject: [PATCH 6/8] fix(editor): V converts a locked clip mask and install the new binary Cycling clip shape now updates the path already on screen (rect to ellipse, and Snap ray-casts from the mask centre). Installed to ~/.local/bin so Print no longer launches last morning's build. --- src/editor.cpp | 56 ++++++++++++++++++++++++++++++++++++++++---------- src/editor.hpp | 2 +- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/editor.cpp b/src/editor.cpp index 50923c50..cbd871ea 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -2672,26 +2672,59 @@ void CaptureEditor::cyclePixelClipShape() { } void CaptureEditor::setPixelClipShape(PixelClipShape shape) { + const bool hadLock = !pixelClipRect_.isEmpty(); + const bool wasLasso = !pixelClipPoints_.isEmpty(); + const bool wasEllipse = + !wasLasso && (pixelClipShape_ == PixelClipShape::Ellipse || + pixelClipShape_ == PixelClipShape::Snap); pixelClipShape_ = shape; - if (shape != PixelClipShape::Lasso && pixelClipRect_.isEmpty()) + 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(); + if (shape == PixelClipShape::Snap) + trySnapAt(pixelClipRect_.normalized().center(), false); + } else if (shape != PixelClipShape::Lasso) { pixelClipPoints_.clear(); + } switch (shape) { case PixelClipShape::Rect: - setStatus(QStringLiteral( - "Clip · rectangle · empty drag with no layers locks a mask · V cycles")); + setStatus(hadLock ? QStringLiteral( + "Clip · rectangle · drag inside to lift · V cycles") + : QStringLiteral("Clip · rectangle · drag a box on empty " + "canvas · V cycles")); break; case PixelClipShape::Ellipse: - setStatus(QStringLiteral( - "Clip · ellipse · empty drag with no layers locks a mask · V cycles")); + setStatus(hadLock ? QStringLiteral( + "Clip · ellipse · drag inside to lift · V cycles") + : QStringLiteral("Clip · ellipse · drag a box on empty " + "canvas · V cycles")); break; case PixelClipShape::Lasso: - setStatus(QStringLiteral( - "Clip · lasso · empty drag with no layers locks a mask · V cycles")); + setStatus(hadLock ? QStringLiteral( + "Clip · lasso · drag inside to lift · V cycles") + : QStringLiteral("Clip · lasso · trace a shape · V cycles")); break; case PixelClipShape::Snap: setStatus(QStringLiteral( - "Clip · snap · click an object · empty drag still selects layers · V " - "cycles")); + "Clip · snap · click the object (Esc first if a mask is in the way) · " + "V cycles")); break; } update(); @@ -2734,7 +2767,7 @@ QPainterPath CaptureEditor::logicalClipPath() const { return path; } -void CaptureEditor::trySnapAt(const QPointF &annotationPoint) { +void CaptureEditor::trySnapAt(const QPointF &annotationPoint, bool resetFill) { const QRectF logical(annotationPoint, QSizeF(1, 1)); const QRect native = nativeRectForPixelClip(logical); if (native.isEmpty()) { @@ -2763,7 +2796,8 @@ void CaptureEditor::trySnapAt(const QPointF &annotationPoint) { } pixelClipRect_ = logicalBox; pixelClipPoints_.clear(); - clipFill_ = QColor(0, 0, 0, 0); + if (resetFill) + clipFill_ = QColor(0, 0, 0, 0); selectedAnnotations_.clear(); selectedAnnotation_ = -1; setStatus(QStringLiteral( diff --git a/src/editor.hpp b/src/editor.hpp index 4dd86d1f..c12c6817 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -566,7 +566,7 @@ class CaptureEditor final : public QWidget { void setPixelClipShape(PixelClipShape shape); [[nodiscard]] ClipOp lockedClipOp() const; [[nodiscard]] QPainterPath logicalClipPath() const; - void trySnapAt(const QPointF &annotationPoint); + void trySnapAt(const QPointF &annotationPoint, bool resetFill = true); void beginClipLift(const QPointF &point); void updateClipLift(const QPointF &point); void finishClipLift(); From 5e51400ce7da2b296fa59cf1b347d9707eca9716 Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Fri, 28 Aug 2026 21:37:17 -0400 Subject: [PATCH 7/8] feat(editor): snap the object in the drag, round the overlay, fill the hole Snap treats the drag as a search window: 8-connected flood with a 1px close, an enclosing circle for Ellipse (pins stay in), and a rounded rect for Rect when two corners agree on a radius. Crop chrome hides while a mask is locked. The hole-fill fly-out adds match-surroundings and the same Sample from image eyedropper as the toolbar. --- README.md | 28 +- docs/editing-model.md | 5 +- .../2026-08-28-clip-shape-masks-design.md | 20 +- src/capture.cpp | 4 + src/clip.cpp | 584 +++++++++++++++- src/clip.hpp | 47 +- src/editor.cpp | 623 +++++++++++++++--- src/editor.hpp | 45 +- tests/clip-mapping-smoke.cpp | 528 +++++++++++++++ tests/clip-smoke.cpp | 260 ++++++++ 10 files changed, 1975 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index f9870e28..291d4ce1 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,23 @@ 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, snap) - and a strip under the toolbar shows the same chips. A large enough empty - marquee that hits no layers locks a pixel mask of that shape (snap is - click-to-fit an ellipse on a high-contrast object; crop handles stay until - you lift). A fly-out picks the hole infill (transparent by default; `T` / - `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. +- 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 + on: 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. 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 (transparent by default; match-surroundings swatch; `T` / + `1`–`8` / Sample from image `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 @@ -359,7 +367,7 @@ without reaching for the pointer. | Input | Action | |---|---| -| `V` | Select/move/resize layers; carrying one past the source grows the canvas; press again to cycle clip shape (rectangle, ellipse, lasso, snap); drag empty canvas for a marquee; a large enough empty marquee that hits no layers locks a pixel mask of the current shape (snap: click an object; crop handles stay until you lift) with a hole-fill fly-out (transparent default; `T` / `1`–`8` / eyedropper / `#` 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) | +| `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 (transparent default; match surroundings; `T` / `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/editing-model.md b/docs/editing-model.md index f0027005..d8faedd9 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -60,8 +60,9 @@ still fully undoable because of how they're kept in the log: 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 fits an axis-aligned ellipse and then behaves as - a normal ellipse mask. + 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 index 1d33ec33..176f15b5 100644 --- a/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md +++ b/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md @@ -40,9 +40,9 @@ While Select is the current tool, a strip sits **above the toolbar**: | 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 | `V` cycle / chip | Snap · click the object · empty click only in this mode | +| Snap | chip toggle | Snap on/off · with it on, 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 → Snap → Rect. Status pill names the shape. +`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 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. @@ -50,16 +50,17 @@ The **bottom-left hotkey legend** gains the clip rows while Select is on. Hover 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. -2. The same drag with **no layers inside** locks a pixel mask in the current geometric shape (Rect / Ellipse / Lasso). If the current chip is Snap, an empty drag locks an **Ellipse** from the drag bbox (manual fallback). -3. A press-and-release that never reaches 2×2 is a **click**: it deselects, dismisses an uncommitted mask if the click is outside it, or **runs Snap only when the Snap chip is selected** and the click hits no layer. Snap is never implicit in Rect/Ellipse/Lasso. -4. `Alt+click` on empty canvas also snaps (modifier explicit), even if the chip is not Snap. +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 existing clip fill fly-out (transparent + palette + custom + eyedropper) stays, labeled with keys. Default fill is transparent. +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 transparent. Fill keys apply **only while a dotted mask is locked and Select is the active tool**. 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. @@ -158,8 +159,9 @@ Headless offscreen, in the existing smoke suite. Failures name the first wrong p - **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`. `R` with a rectangle layer selected still toggles fill. -- **Snap vs deselect** — click empty canvas in Rect mode clears selection and does not snap; Snap mode click on a synthetic disk locks an ellipse. +- **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. diff --git a/src/capture.cpp b/src/capture.cpp index 4ea1f315..12a15671 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1871,6 +1871,8 @@ QJsonObject operationToJson(const Operation &operation) { 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)); @@ -1972,6 +1974,8 @@ bool operationFromJson(const QJsonObject &object, Operation &operation, 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( diff --git a/src/clip.cpp b/src/clip.cpp index 88de22b6..8c0a6097 100644 --- a/src/clip.cpp +++ b/src/clip.cpp @@ -5,15 +5,18 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include #include +#include namespace { @@ -35,8 +38,274 @@ 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; @@ -56,7 +325,13 @@ QPainterPath clipPath(const ClipOp &clip) { break; } case ClipShape::Rect: - path.addRect(QRectF(box)); + 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; @@ -75,7 +350,7 @@ QImage copyMasked(const QImage &source, const ClipOp &clip) { QImage tile = copyRect(source, clip.sourceRect); if (tile.isNull()) return {}; - if (clip.shape == ClipShape::Rect) + if (clip.shape == ClipShape::Rect && clip.radius < 1.0) return tile; ensurePremultiplied(tile); QImage mask(tile.size(), QImage::Format_ARGB32_Premultiplied); @@ -159,10 +434,17 @@ QPointF nativeClipPoint(QPointF logical, QSize preview, QSize source) { ClipOp nativeClipOp(ClipShape shape, QRectF logical, const QVector &logicalPoints, QSize preview, - QSize source, const QColor &fill) { + 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; @@ -189,14 +471,167 @@ ClipOp nativeClipOp(ClipShape shape, QRectF logical, return clip; } -std::optional snapEllipseRect(const QImage &source, QPoint click) { +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 kMax = 256; constexpr int kThresh = 28; - constexpr int kMinHits = 12; + 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) { @@ -206,10 +641,10 @@ std::optional snapEllipseRect(const QImage &source, QPoint click) { int prev = lumaAt(img, click.x(), click.y()); int bestS = -1; int bestD = 0; - for (int s = 4; s <= kMax; ++s) { + 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 (!img.rect().contains(x, y)) + 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); @@ -225,39 +660,122 @@ std::optional snapEllipseRect(const QImage &source, QPoint click) { click.y() + static_cast(std::lround(dy * bestS)))); } } - if (hits.size() < kMinHits) - return std::nullopt; - int minX = hits.constFirst().x(); - int maxX = minX; - int minY = hits.constFirst().y(); - int maxY = minY; - 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()); + 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; + } } - QRect box(QPoint(minX, minY), QPoint(maxX, maxY)); - box = box.intersected(source.rect()); - if (box.width() < 6 || box.height() < 6) - return std::nullopt; - const qreal cover = static_cast(box.width()) * box.height() / - std::max(1, source.width() * source.height()); - if (cover > 0.70) - return std::nullopt; - const QRectF ellipse(box); - if (!ellipse.contains(click)) + 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; - const qreal ratio = box.width() / std::max(1.0, static_cast(box.height())); + 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 circle(0, 0, side, side); - circle.moveCenter(box.center()); - box = circle.intersected(source.rect()); + 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); } diff --git a/src/clip.hpp b/src/clip.hpp index 9e5a6e15..c9970428 100644 --- a/src/clip.hpp +++ b/src/clip.hpp @@ -32,13 +32,26 @@ struct ClipOp { 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: @@ -104,11 +117,37 @@ void fillHole(QImage &image, const ClipOp &clip); [[nodiscard]] ClipOp nativeClipOp(ClipShape shape, QRectF logical, const QVector &logicalPoints, QSize preview, QSize source, - const QColor &fill); - -/** Ray-cast snap: axis-aligned ellipse around `click`, or nullopt. */ + 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); + 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. diff --git a/src/editor.cpp b/src/editor.cpp index cbd871ea..535c9e06 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include @@ -553,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) { @@ -767,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, @@ -2353,22 +2383,30 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, if (includeSubmenus && !pixelClipRect_.isEmpty() && !clipLiftActive_) { const QRectF menu = clipFillMenuRect(); if (!menu.isEmpty()) { - buttons.push_back({{menu.left() + 4, menu.top() + 4, 24, 28}, - QStringLiteral("clip-fill-transparent"), {}, + 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( - {{menu.left() + 4 + (index + 1) * 28, menu.top() + 4, 24, 28}, - QStringLiteral("clip-fill-%1").arg(index), + {slotRect(), QStringLiteral("clip-fill-%1").arg(index), {}, QStringLiteral("Hole fill · %1").arg(index + 1), paletteConfig_.palette.at(static_cast(index))}); } - buttons.push_back( - {{menu.left() + 4 + (presetCount + 1) * 28, menu.top() + 4, 24, 28}, - QStringLiteral("clip-fill-custom"), {}, - QStringLiteral("Hole fill · custom color"), {}}); + 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; @@ -2610,8 +2648,11 @@ void CaptureEditor::commitClip(ClipOp clip, Annotation annotation) { return; } - fillHole(capture_.source, clip.sourceRect, clip.fill); - punchDisplayCaches(clip.sourceRect, clip.fill); + 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(); @@ -2648,9 +2689,16 @@ void CaptureEditor::clearPixelClip() { 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(); } @@ -2663,20 +2711,44 @@ void CaptureEditor::cyclePixelClipShape() { setPixelClipShape(PixelClipShape::Lasso); break; case PixelClipShape::Lasso: - setPixelClipShape(PixelClipShape::Snap); - break; - case PixelClipShape::Snap: 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 && (pixelClipShape_ == PixelClipShape::Ellipse || - pixelClipShape_ == PixelClipShape::Snap); + const bool wasEllipse = !wasLasso && pixelClipLockedEllipse_; pixelClipShape_ = shape; if (hadLock) { if (shape == PixelClipShape::Lasso && pixelClipPoints_.isEmpty()) { @@ -2687,7 +2759,8 @@ void CaptureEditor::setPixelClipShape(PixelClipShape shape) { 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); + 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))); } @@ -2698,34 +2771,26 @@ void CaptureEditor::setPixelClipShape(PixelClipShape shape) { } if (shape != PixelClipShape::Lasso) pixelClipPoints_.clear(); - if (shape == PixelClipShape::Snap) - trySnapAt(pixelClipRect_.normalized().center(), false); } else if (shape != PixelClipShape::Lasso) { pixelClipPoints_.clear(); } - switch (shape) { - case PixelClipShape::Rect: - setStatus(hadLock ? QStringLiteral( - "Clip · rectangle · drag inside to lift · V cycles") - : QStringLiteral("Clip · rectangle · drag a box on empty " - "canvas · V cycles")); - break; - case PixelClipShape::Ellipse: - setStatus(hadLock ? QStringLiteral( - "Clip · ellipse · drag inside to lift · V cycles") - : QStringLiteral("Clip · ellipse · drag a box on empty " - "canvas · V cycles")); - break; - case PixelClipShape::Lasso: - setStatus(hadLock ? QStringLiteral( - "Clip · lasso · drag inside to lift · V cycles") - : QStringLiteral("Clip · lasso · trace a shape · V cycles")); - break; - case PixelClipShape::Snap: + 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 · click the object (Esc first if a mask is in the way) · " - "V cycles")); - break; + "Clip · snap on · click an object or drag Rect/Ellipse/Lasso")); + } else { + setStatus(clipShapeStatus(!pixelClipRect_.isEmpty())); } update(); } @@ -2734,8 +2799,7 @@ ClipOp CaptureEditor::lockedClipOp() const { ClipShape shape = ClipShape::Rect; if (!pixelClipPoints_.isEmpty()) shape = ClipShape::Lasso; - else if (pixelClipShape_ == PixelClipShape::Ellipse || - pixelClipShape_ == PixelClipShape::Snap) + else if (pixelClipLockedEllipse_) shape = ClipShape::Ellipse; QVector points; points.reserve(pixelClipPoints_.size()); @@ -2744,7 +2808,25 @@ ClipOp CaptureEditor::lockedClipOp() const { return nativeClipOp(shape, pixelClipRect_.normalized().translated( selection_.topLeft()), points, capture_.previewSize, capture_.source.size(), - clipFill_); + 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 { @@ -2759,27 +2841,22 @@ QPainterPath CaptureEditor::logicalClipPath() const { path.closeSubpath(); return path; } - if (pixelClipShape_ == PixelClipShape::Ellipse || - pixelClipShape_ == PixelClipShape::Snap) + if (pixelClipLockedEllipse_) path.addEllipse(box); - else + 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; } -void CaptureEditor::trySnapAt(const QPointF &annotationPoint, bool resetFill) { - const QRectF logical(annotationPoint, QSizeF(1, 1)); - const QRect native = nativeRectForPixelClip(logical); - if (native.isEmpty()) { - setStatus(QStringLiteral("Nothing to snap · drag a shape instead")); - return; - } - const std::optional box = - snapEllipseRect(capture_.source, native.center()); - if (!box) { - setStatus(QStringLiteral("Nothing to snap · drag a shape instead")); - return; - } +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 = @@ -2790,19 +2867,93 @@ void CaptureEditor::trySnapAt(const QPointF &annotationPoint, bool resetFill) { logicalBox.translate(-selection_.topLeft()); const QRectF bounds(QPointF(), selection_.size()); logicalBox = logicalBox.intersected(bounds); - if (logicalBox.width() < 2.0 || logicalBox.height() < 2.0) { + 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_ = logicalBox; - pixelClipPoints_.clear(); + 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) clipFill_ = QColor(0, 0, 0, 0); selectedAnnotations_.clear(); selectedAnnotation_ = -1; + const QString kind = pixelClipShape_ == PixelClipShape::Lasso + ? QStringLiteral("outline") + : pixelClipShape_ == PixelClipShape::Ellipse + ? QStringLiteral("ellipse") + : QStringLiteral("rectangle"); setStatus(QStringLiteral( - "Snapped to ellipse · pick a hole fill or drag inside to clip out · Esc " - "cancels")); + "Snapped to %1 · pick a hole fill or drag inside to clip out " + "· Esc cancels") + .arg(kind)); + startSnapTrace(false); } QVector @@ -2829,7 +2980,12 @@ CaptureEditor::clipShapeStripButtons() const { add(QStringLiteral("clip-shape-lasso"), QStringLiteral("Lasso"), QStringLiteral("Clip lasso · V cycles")); add(QStringLiteral("clip-shape-snap"), QStringLiteral("Snap"), - QStringLiteral("Snap · click an object")); + 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; } @@ -2913,11 +3069,20 @@ QRect CaptureEditor::clipLiftRepaintRect() const { } void CaptureEditor::paintClipHolePreview(QPainter &painter, - const QRectF &hole) const { + 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.setClipRect(hole, Qt::IntersectClip); + painter.setClipPath(widget, Qt::IntersectClip); painter.setCompositionMode(QPainter::CompositionMode_Source); if (clipFillOpaque(clipFill_)) painter.fillRect(hole, clipFill_); @@ -2934,7 +3099,7 @@ QRect CaptureEditor::nativeRectForPixelClip(const QRectF &logical) const { CaptureEditor::Interaction CaptureEditor::pixelClipHandleAt(const QPointF &point) const { - if (pixelClipRect_.isEmpty()) + if (pixelClipRect_.isEmpty() || snapTraceRevealing_) return Interaction::None; Annotation box; box.kind = Annotation::Kind::Rectangle; @@ -2960,25 +3125,38 @@ QRectF CaptureEditor::pixelClipWidgetRect() const { 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_) + if (pixelClipRect_.isEmpty() || clipLiftActive_ || snapTraceRevealing_) return {}; - const int swatches = - static_cast(paletteConfig_.palette.size()) + 2; // transparent + custom + 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); - if (!customColorPickerOpen_ && menu.intersects(clip) && - clip.contains(cursor_) && !menu.contains(cursor_)) - return {}; return menu; } @@ -2998,6 +3176,7 @@ void CaptureEditor::beginClipLift(const QPointF &point) { const QImage tile = copyMasked(capture_.source, clipLiftOp_); if (tile.isNull()) return; + stopSnapTrace(); clipLiftActive_ = true; clipLiftOrigin_ = pixelClipRect_.normalized(); clipLiftDest_ = clipLiftOrigin_; @@ -3047,6 +3226,8 @@ void CaptureEditor::finishClipLift() { cancelClipLift(); pixelClipRect_ = {}; pixelClipPoints_.clear(); + pixelClipLockedEllipse_ = false; + pixelClipRadius_ = 0; dragging_ = false; commitClip(std::move(clip), std::move(annotation)); selectedAnnotation_ = annotations_.isEmpty() @@ -3908,6 +4089,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()) @@ -4130,7 +4409,7 @@ void CaptureEditor::handleToolbar(const QString &action) { else if (action == QStringLiteral("clip-shape-lasso")) setPixelClipShape(PixelClipShape::Lasso); else if (action == QStringLiteral("clip-shape-snap")) - setPixelClipShape(PixelClipShape::Snap); + setPixelClipSnapEnabled(!pixelClipSnapEnabled_); else if (action == QStringLiteral("tool-arrow")) tool_ = Tool::Arrow; else if (action == QStringLiteral("tool-line")) @@ -4215,6 +4494,14 @@ void CaptureEditor::handleToolbar(const QString &action) { 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; @@ -4765,6 +5052,13 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { 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) { @@ -4794,23 +5088,56 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { if (QLineF(pixelClipPoints_.constLast(), point).length() >= 1.0) pixelClipPoints_.push_back(point); } + const QRectF area = pixelClipDragBounds(); bool hitsLayer = false; - if (pixelClipLargeEnough(marqueeRect_)) { + if (pixelClipLargeEnough(area)) { for (const Annotation &annotation : annotations_) { const QRectF bounds = annotationBounds(annotation); - if (!bounds.isEmpty() && marqueeRect_.contains(bounds)) { + 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 = - !hitsLayer && pixelClipLargeEnough(marqueeRect_) - ? QStringLiteral( - "Release to lock these pixels · then drag inside to lift") - : QStringLiteral("Drag to select layers"); + 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; } @@ -5210,7 +5537,7 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { } } if (tool_ == Tool::Select && selectedAnnotations_.isEmpty() && - !clipLiftActive_) { + !clipLiftActive_ && pixelClipRect_.isEmpty()) { const int cropHandle = cropHandleAt(cursor_); if (cropHandle >= 0) { clearPixelClip(); @@ -5265,8 +5592,9 @@ 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; @@ -5289,6 +5617,7 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { pixelClipResizing_ = true; originalPixelClip_ = locked; originalPixelClipPoints_ = pixelClipPoints_; + originalPixelClipRadius_ = pixelClipRadius_; interaction_ = clipHandle; dragStart_ = point; dragging_ = true; @@ -5350,6 +5679,9 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { interaction_ = Interaction::None; if (pixelClipShape_ == PixelClipShape::Lasso) pixelClipPoints_ = {point}; + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); setStatus(QStringLiteral("Drag to select layers")); update(); return; @@ -5569,7 +5901,7 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { 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) { @@ -5592,7 +5924,7 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { dragging_ = false; interaction_ = Interaction::None; if (matches.isEmpty() && !pixelClipLargeEnough(area) && - pixelClipShape_ == PixelClipShape::Snap) { + pixelClipSnapEnabled_) { trySnapAt(dragStart_); pixelClipPoints_.clear(); updatePointerCursor(); @@ -5611,16 +5943,67 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { if (pixelClipPoints_.size() < 3) { pixelClipPoints_.clear(); pixelClipRect_ = {}; + pixelClipLockedEllipse_ = false; + } else { + pixelClipLockedEllipse_ = false; } - } else if (pixelClipShape_ == PixelClipShape::Snap) { - pixelClipPoints_.clear(); } 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()) { - setStatus(QStringLiteral( - "Selection ready — pick a hole fill or drag inside to clip out " - "· Esc cancels")); + 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; @@ -5628,6 +6011,10 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { } } pixelClipPoints_.clear(); + pixelClipTraceSnap_.reset(); + pixelClipTraceRadius_ = 0; + pixelClipTracePoints_.clear(); + stopSnapTrace(); setStatus(selectedAnnotations_.isEmpty() ? QStringLiteral("No layers selected") : QStringLiteral("%1 layers selected · drag to move") @@ -6034,7 +6421,8 @@ void CaptureEditor::updatePointerCursor() { } } int cropHandle = - selectedAnnotations_.isEmpty() && !clipLiftActive_ + selectedAnnotations_.isEmpty() && !clipLiftActive_ && + pixelClipRect_.isEmpty() ? cropHandleAt(cursor_) : -1; if (dragging_ && interaction_ >= Interaction::CropTopLeft) { @@ -6783,6 +7171,7 @@ void CaptureEditor::paintEdit(QPainter &painter) { drawHotkeyLegend( painter, rect(), {{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")}, @@ -6884,14 +7273,8 @@ void CaptureEditor::paintEdit(QPainter &painter) { painter.drawImage(sourceImage, redactionLayer); else painter.drawImage(sourceImage, capture_.source, sourceRect(selection_)); - const QRectF holeLogical = - clipLiftActive_ ? clipLiftOrigin_ : pixelClipRect_.normalized(); - if (!holeLogical.isEmpty() && (clipLiftActive_ || !pixelClipRect_.isEmpty())) { - const qreal scale = std::max(editScale(), 0.01); - const QRectF hole(sourceImage.topLeft() + holeLogical.topLeft() * scale, - holeLogical.size() * scale); - paintClipHolePreview(painter, hole); - } + if ((clipLiftActive_ || !pixelClipRect_.isEmpty()) && !snapTraceRevealing_) + paintClipHolePreview(painter, sourceImage); painter.restore(); @@ -7066,27 +7449,48 @@ 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)); - const QRectF box = marqueeRect_.normalized(); - if (pixelClipShape_ == PixelClipShape::Lasso && - pixelClipPoints_.size() >= 2) { + 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 || - pixelClipShape_ == PixelClipShape::Snap) { + (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_; @@ -7115,6 +7519,7 @@ void CaptureEditor::paintEdit(QPainter &painter) { 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); @@ -7243,10 +7648,10 @@ void CaptureEditor::paintEdit(QPainter &painter) { // 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. - // Crop handles stay while a pixel clip is only armed; a live lift owns - // the pointer, so hide them then. + // 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_) { + !clipLiftActive_ && pixelClipRect_.isEmpty()) { painter.setPen(QPen(QColor(QStringLiteral("#0a84ff")), 1, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawRect(image.adjusted(-1, -1, 1, 1)); @@ -7446,7 +7851,7 @@ void CaptureEditor::paintEdit(QPainter &painter) { (button.action == QStringLiteral("clip-shape-lasso") && pixelClipShape_ == PixelClipShape::Lasso) || (button.action == QStringLiteral("clip-shape-snap") && - pixelClipShape_ == PixelClipShape::Snap); + pixelClipSnapEnabled_); painter.setPen(QPen(QColor(255, 255, 255, 34), 1)); painter.setBrush(on ? QColor(QStringLiteral("#0a84ff")) : QColor(30, 32, 38, 230)); diff --git a/src/editor.hpp b/src/editor.hpp index c12c6817..e604ab44 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -152,7 +152,7 @@ class CaptureEditor final : public QWidget { Ocr, Eyedropper }; - enum class PixelClipShape { Rect, Ellipse, Lasso, Snap }; + enum class PixelClipShape { Rect, Ellipse, Lasso }; private: enum class Phase { Select, Export, Edit }; @@ -282,6 +282,13 @@ class CaptureEditor final : public QWidget { [[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 { @@ -364,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 { @@ -564,8 +574,22 @@ class CaptureEditor final : public QWidget { 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); @@ -579,8 +603,10 @@ class CaptureEditor final : public QWidget { [[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 &hole) const; + void paintClipHolePreview(QPainter &painter, + const QRectF &sourceImage) const; void setClipFill(const QColor &fill); void commitCanvasBoundary(CanvasBoundaryMode mode); void cycleCanvasBoundary(bool reverse); @@ -599,6 +625,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(); @@ -697,10 +727,17 @@ class CaptureEditor final : public QWidget { /// 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_ = false; + 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_; @@ -872,6 +909,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 index a910e54b..9dfa430b 100644 --- a/tests/clip-mapping-smoke.cpp +++ b/tests/clip-mapping-smoke.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include namespace { @@ -41,6 +42,66 @@ bool saveGrab(CaptureEditor &editor, const QString &path, QString &error) { 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 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, @@ -208,6 +269,44 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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; @@ -324,5 +423,434 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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(); + 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.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { + error = QStringLiteral("snap chip stole the Rect/Ellipse/Lasso shape"); + return false; + } + if (!editor.pixelClipSnapEnabledForTest()) { + error = QStringLiteral("snap chip did not turn snap on"); + 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; + } + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + 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; + } + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + 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; + } + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + 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; + } + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + 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(); + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + 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(); + const QRectF snapChip = + editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + snapChip.center().toPoint()); + application.processEvents(); + 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-smoke.cpp b/tests/clip-smoke.cpp index 060d6fe4..79ae8f52 100644 --- a/tests/clip-smoke.cpp +++ b/tests/clip-smoke.cpp @@ -22,6 +22,25 @@ bool runClipSmoke(QString &error) { 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) || @@ -240,5 +259,246 @@ bool runClipSmoke(QString &error) { 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; } From 5d933d78b29db8caa6330468af530ef76ace000e Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Sat, 29 Aug 2026 08:44:15 -0400 Subject: [PATCH 8/8] feat(editor): Snap on and surroundings fill by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select starts with Snap armed. A locked clip fills with the page colour around the mask. T cycles surroundings → Sample from image → transparent while the mask is locked; idle T is still Text. --- README.md | 19 ++-- .../2026-08-28-clip-shape-masks-design.md | 12 +- src/editor.cpp | 47 +++++++- src/editor.hpp | 5 +- tests/clip-mapping-smoke.cpp | 103 +++++++++++------- 5 files changed, 126 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 291d4ce1..e2db9c85 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,17 @@ resizable vector layers and preserves the monitor's native pixels on scaled disp 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 - on: 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. A scan-dot traces the outline (OCR-style) then the mask locks. Rect + 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 (transparent by default; match-surroundings swatch; `T` / - `1`–`8` / Sample from image `I` / `#` while the mask is locked). Drag inside to lift a layer with alpha outside the path. + 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. @@ -282,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 @@ -367,7 +370,7 @@ without reaching for the pointer. | Input | Action | |---|---| -| `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 (transparent default; match surroundings; `T` / `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) | +| `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/superpowers/specs/2026-08-28-clip-shape-masks-design.md b/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md index 176f15b5..6796cd64 100644 --- a/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md +++ b/docs/superpowers/specs/2026-08-28-clip-shape-masks-design.md @@ -40,9 +40,9 @@ While Select is the current tool, a strip sits **above the toolbar**: | 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 · with it on, click an object (or refine a locked mask) | +| 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 so you can still draw Rect / Ellipse / Lasso with snap armed. Status pill names the shape and whether snap is on. +`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. @@ -60,18 +60,18 @@ Arming any drawing tool **clears** an uncommitted mask. Esc clears hex-entry fir ### 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 transparent. +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 and Select is the active tool**. 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. +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` | Transparent hole | Text tool, unchanged | +| `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. `T` is the transparent path. `Esc` (or a snap-back lift) clears the mask; `T` is Text again. +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`). diff --git a/src/editor.cpp b/src/editor.cpp index 535c9e06..27592ebb 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -2941,7 +2941,7 @@ void CaptureEditor::trySnapAt(const QPointF &annotationPoint, bool resetFill) { pixelClipRadius_ = snap->radius; } if (resetFill) - clipFill_ = QColor(0, 0, 0, 0); + applyDefaultClipFill(); selectedAnnotations_.clear(); selectedAnnotation_ = -1; const QString kind = pixelClipShape_ == PixelClipShape::Lasso @@ -3171,6 +3171,40 @@ void CaptureEditor::setClipFill(const QColor &fill) { 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_); @@ -4922,9 +4956,9 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { event->modifiers() == Qt::ShiftModifier) { cycleTextFont(); } else if (event->key() == Qt::Key_T) { - if (tool_ == Tool::Select && !pixelClipRect_.isEmpty() && - !clipLiftActive_) { - setClipFill(QColor(0, 0, 0, 0)); + if (!pixelClipRect_.isEmpty() && !clipLiftActive_ && + (tool_ == Tool::Select || tool_ == Tool::Eyedropper)) { + cycleClipHoleFill(); } else { const bool textSelected = selectedAnnotation_ >= 0 && @@ -5936,7 +5970,6 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { const QRectF clipped = area.intersected(bounds); if (pixelClipLargeEnough(clipped)) { pixelClipRect_ = clipped; - clipFill_ = QColor(0, 0, 0, 0); selectedAnnotations_.clear(); selectedAnnotation_ = -1; if (pixelClipShape_ == PixelClipShape::Lasso) { @@ -5987,6 +6020,7 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { pixelClipTraceRadius_ = 0; pixelClipTracePoints_.clear(); if (!pixelClipRect_.isEmpty()) { + applyDefaultClipFill(); const QString kind = pixelClipShape_ == PixelClipShape::Lasso ? QStringLiteral("outline") : pixelClipShape_ == PixelClipShape::Ellipse @@ -7178,7 +7212,8 @@ void CaptureEditor::paintEdit(QPainter &painter) { {QStringLiteral("C"), QStringLiteral("Marker")}, {QStringLiteral("R / E"), QStringLiteral("Rectangle / Ellipse")}, {QStringLiteral("X"), QStringLiteral("Cut out a band")}, - {QStringLiteral("T"), QStringLiteral("Text · transparent hole when clipping")}, + {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")}, diff --git a/src/editor.hpp b/src/editor.hpp index e604ab44..b3105ad8 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -608,6 +608,9 @@ class CaptureEditor final : public QWidget { 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(); @@ -727,7 +730,7 @@ class CaptureEditor final : public QWidget { /// 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_ = false; + bool pixelClipSnapEnabled_ = true; bool pixelClipLockedEllipse_ = false; std::optional pixelClipTraceSnap_; qreal pixelClipTraceRadius_ = 0; diff --git a/tests/clip-mapping-smoke.cpp b/tests/clip-mapping-smoke.cpp index 9dfa430b..d3eb9973 100644 --- a/tests/clip-mapping-smoke.cpp +++ b/tests/clip-mapping-smoke.cpp @@ -96,6 +96,27 @@ QImage checkerPinImage() { 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 || @@ -203,6 +224,8 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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); @@ -251,8 +274,31 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, error = QStringLiteral("pixel clip did not show a hole-fill fly-out"); return false; } - if (clipFillOpaque(editor.clipFillForTest())) { - error = QStringLiteral("hole fill was not transparent by default"); + 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); @@ -522,23 +568,20 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, 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; } - QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, - snapChip.center().toPoint()); - application.processEvents(); if (editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Rect) { error = QStringLiteral("snap chip stole the Rect/Ellipse/Lasso shape"); return false; } - if (!editor.pixelClipSnapEnabledForTest()) { - error = QStringLiteral("snap chip did not turn snap on"); - return false; - } QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, screenOf(editor, 32, 32)); application.processEvents(); @@ -591,11 +634,8 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, error = QStringLiteral("V did not arm clip ellipse"); return false; } - const QRectF snapChip = - editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); - QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, - snapChip.center().toPoint()); - application.processEvents(); + if (!ensureSnap(editor, application, true, error)) + return false; if (!editor.pixelClipSnapEnabledForTest()) { error = QStringLiteral("ellipse+snap could not turn snap on"); return false; @@ -644,11 +684,8 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, error = QStringLiteral("V did not arm clip ellipse for the pin wheel"); return false; } - const QRectF snapChip = - editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); - QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, - snapChip.center().toPoint()); - 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); @@ -692,11 +729,8 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, error = QStringLiteral("two V presses did not arm clip lasso"); return false; } - const QRectF snapChip = - editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); - QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, - snapChip.center().toPoint()); - application.processEvents(); + if (!ensureSnap(editor, application, true, error)) + return false; if (!editor.pixelClipSnapEnabledForTest()) { error = QStringLiteral("lasso+snap could not turn snap on"); return false; @@ -745,11 +779,8 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, error = QStringLiteral("clip shape did not start on Rect"); return false; } - const QRectF snapChip = - editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); - QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, - snapChip.center().toPoint()); - application.processEvents(); + if (!ensureSnap(editor, application, true, error)) + return false; if (!editor.pixelClipSnapEnabledForTest()) { error = QStringLiteral("rect+snap could not turn snap on"); return false; @@ -784,11 +815,8 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, editor.resize(800, 600); editor.show(); application.processEvents(); - const QRectF snapChip = - editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); - QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, - snapChip.center().toPoint()); - 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); @@ -821,11 +849,8 @@ bool runClipMappingSmoke(QApplication &application, const QString &outputRoot, application.processEvents(); QTest::keyClick(&editor, Qt::Key_V); application.processEvents(); - const QRectF snapChip = - editor.toolbarButtonRectForTest(QStringLiteral("clip-shape-snap")); - QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, - snapChip.center().toPoint()); - application.processEvents(); + if (!ensureSnap(editor, application, true, error)) + return false; if (!editor.pixelClipSnapEnabledForTest() || editor.pixelClipShapeForTest() != CaptureEditor::PixelClipShape::Lasso) {