From 38a71dc7cac76f22334bed6e52c2d86f30853759 Mon Sep 17 00:00:00 2001 From: Luke Morrison Date: Sat, 29 Aug 2026 10:57:41 -0400 Subject: [PATCH 1/2] feat(editor): Ctrl+Cut inserts a transparent band With Cut armed, hold Ctrl (or press Ctrl+X) to insert space instead of collapsing it. The toolbar icon swaps to a split-plus, the live band shows a plus, and annotations past the seam shift out. Undo is the same Cut op with insert set. --- README.md | 4 +- docs/editing-model.md | 7 ++- src/capture.cpp | 3 + src/cut.cpp | 65 +++++++++++++++++++- src/cut.hpp | 13 ++++ src/editor.cpp | 115 ++++++++++++++++++++++++++++-------- src/editor.hpp | 4 ++ src/icons.cpp | 6 ++ tests/cut-mapping-smoke.cpp | 38 ++++++++++++ tests/cut-smoke.cpp | 33 +++++++++++ 10 files changed, 255 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 614733ca..7eec7da2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ 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. + Hold **Ctrl** (or **Ctrl+X**) to insert a transparent band instead; the toolbar + icon swaps to a split-plus while Ctrl is down. - 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 @@ -361,7 +363,7 @@ without reaching for the pointer. | `R` | Rectangle; hover the shape button for rectangle, ellipse, and fill controls; `Alt`+wheel rounds corners | | `E` | Ellipse; shares the shape submenu and filled/hollow toggle | | `D` | Redact; press again to toggle randomized pixelation or solid redaction | -| `X` | Cut out a band; drag to preview the crossed-out strip, then release to remove and collapse it | +| `X` | Cut out a band; drag to preview the crossed-out strip, then release to remove and collapse it. **Ctrl** (with Cut armed, or **Ctrl+X**) inserts a band instead; the cut icon becomes a split-plus | | `T` | Text on a cream readability pill, with Neucha as the default. Click for a one-line label, or drag a box to give it room for several lines: Enter moves to the next line while there is room and commits on the last one; `Shift+Enter` always adds a line; `Esc` commits too but keeps the label selected, so `Backspace` removes it; clicking away keeps the text; press T again to toggle the pill | | `Shift+T` | Cycle the next or selected text through Neucha, JetBrains Mono, and Inter Display | | `O` | Recognize and copy all text in the current image | diff --git a/docs/editing-model.md b/docs/editing-model.md index 72681b0d..d1bd0604 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -42,9 +42,10 @@ Two operations *do* need to touch real pixels before export, and both are still fully undoable because of how they're kept in the log: - **Cut** (`Operation::Type::Cut`) removes a band of the image and shifts - everything after it. The working image (`capture_.source`) after a cut is - `composeCuts(pristineSource_, cuts_)` — recomputed from the untouched - original plus the list of cut ops every time the list changes + everything after it, or **inserts** a transparent band (`cut.insert`) and + shifts everything after the seam out. The working image (`capture_.source`) + after a cut is `composeCuts(pristineSource_, cuts_)` — recomputed from the + untouched 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. - **Redaction** exists to permanently destroy sensitive content, so it is diff --git a/src/capture.cpp b/src/capture.cpp index 8d43355d..6349208b 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1821,6 +1821,8 @@ QJsonObject operationToJson(const Operation &operation) { object.insert(QStringLiteral("sourceEnd"), operation.cut.sourceEnd); object.insert(QStringLiteral("logicalStart"), operation.cut.logicalStart); object.insert(QStringLiteral("logicalEnd"), operation.cut.logicalEnd); + if (operation.cut.insert) + object.insert(QStringLiteral("insert"), true); break; } return object; @@ -1894,6 +1896,7 @@ bool operationFromJson(const QJsonObject &object, Operation &operation, object.value(QStringLiteral("logicalStart")).toInt(); operation.cut.logicalEnd = object.value(QStringLiteral("logicalEnd")).toInt(); + operation.cut.insert = object.value(QStringLiteral("insert")).toBool(); return true; } error = QStringLiteral("Operation log has an unknown operation type"); diff --git a/src/cut.cpp b/src/cut.cpp index 832abf71..07def961 100644 --- a/src/cut.cpp +++ b/src/cut.cpp @@ -44,27 +44,80 @@ QImage removeBand(const QImage &source, Qt::Orientation orientation, return out; } +QImage insertBand(const QImage &source, Qt::Orientation orientation, + int start, int end) { + if (source.isNull()) + return source; + const int band = end - start; + if (band <= 0) + return source; + QImage image = source; + if (image.format() != QImage::Format_ARGB32 && + image.format() != QImage::Format_ARGB32_Premultiplied) + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + if (orientation == Qt::Horizontal) { + const int height = image.height(); + start = std::clamp(start, 0, height); + QImage out(image.width(), height + band, image.format()); + out.setDevicePixelRatio(image.devicePixelRatio()); + out.fill(Qt::transparent); + for (int y = 0; y < start; ++y) + std::memcpy(out.scanLine(y), image.constScanLine(y), image.bytesPerLine()); + for (int y = start; y < height; ++y) + std::memcpy(out.scanLine(y + band), image.constScanLine(y), + image.bytesPerLine()); + return out; + } + const int width = image.width(); + start = std::clamp(start, 0, width); + QImage out(width + band, image.height(), image.format()); + out.setDevicePixelRatio(image.devicePixelRatio()); + out.fill(Qt::transparent); + QPainter painter(&out); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(QPoint(0, 0), image, QRect(0, 0, start, image.height())); + painter.drawImage(QPoint(start + band, 0), image, + QRect(start, 0, width - start, image.height())); + return out; +} + +QImage applyCutOp(const QImage &source, const CutOp &cut) { + return cut.insert ? insertBand(source, cut.orientation, cut.sourceStart, + cut.sourceEnd) + : removeBand(source, cut.orientation, cut.sourceStart, + cut.sourceEnd); +} + QImage composeCuts(const QImage &pristine, const QVector &cuts) { QImage image = pristine; for (const CutOp &cut : cuts) - image = removeBand(image, cut.orientation, cut.sourceStart, cut.sourceEnd); + image = applyCutOp(image, cut); return image; } QSize composedLogicalSize(QSize pristineLogical, const QVector &cuts) { for (const CutOp &cut : cuts) { const int band = cut.logicalEnd - cut.logicalStart; + if (band <= 0) + continue; + if (cut.insert) { + if (cut.orientation == Qt::Horizontal) + pristineLogical.setHeight(pristineLogical.height() + band); + else + pristineLogical.setWidth(pristineLogical.width() + band); + continue; + } if (cut.orientation == Qt::Horizontal) { const int extent = pristineLogical.height(); // Mirror removeBand()'s no-op guard so this can never disagree with // what composeCuts() actually produces: an empty or full-extent band // leaves the image unchanged. - if (band <= 0 || band >= extent) + if (band >= extent) continue; pristineLogical.setHeight(std::max(1, extent - band)); } else { const int extent = pristineLogical.width(); - if (band <= 0 || band >= extent) + if (band >= extent) continue; pristineLogical.setWidth(std::max(1, extent - band)); } @@ -79,3 +132,9 @@ qreal shiftForCut(qreal value, qreal start, qreal end) { return value - (end - start); return start; } + +qreal shiftForInsert(qreal value, qreal start, qreal size) { + if (size <= 0.0 || value < start) + return value; + return value + size; +} diff --git a/src/cut.hpp b/src/cut.hpp index 1ba0fc22..4bb22919 100644 --- a/src/cut.hpp +++ b/src/cut.hpp @@ -18,6 +18,7 @@ struct CutOp { int sourceEnd = 0; int logicalStart = 0; int logicalEnd = 0; + bool insert = false; bool operator==(const CutOp &) const = default; }; @@ -28,6 +29,14 @@ struct CutOp { Qt::Orientation orientation, int start, int end); +/** Inserts a transparent band `[start, end)` and shifts the rest out. An + * empty band is a no-op. */ +[[nodiscard]] QImage insertBand(const QImage &source, + Qt::Orientation orientation, int start, + int end); + +[[nodiscard]] QImage applyCutOp(const QImage &source, const CutOp &cut); + /** Replays `cuts` in order over the pristine image. */ [[nodiscard]] QImage composeCuts(const QImage &pristine, const QVector &cuts); @@ -39,3 +48,7 @@ struct CutOp { /** Shifts one coordinate for a removed band [start, end): values past the * band shift back by the band size, values inside clamp to the seam. */ [[nodiscard]] qreal shiftForCut(qreal value, qreal start, qreal end); + +/** Shifts one coordinate for an inserted band of `size` at `start`: values + * on or past the seam move out by `size`. */ +[[nodiscard]] qreal shiftForInsert(qreal value, qreal start, qreal size); diff --git a/src/editor.cpp b/src/editor.cpp index faa458a4..2d7dfac1 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1298,7 +1298,9 @@ QString CaptureEditor::toolStatus() const { "it circular") .arg(size); case Tool::Cut: - return QStringLiteral("Cut · drag a band to remove it"); + return cutInsertHint() + ? QStringLiteral("Insert a band · drag across") + : QStringLiteral("Cut · drag a band to remove it"); case Tool::Highlighter: return highlighterStatus(); case Tool::Arrow: @@ -2220,7 +2222,9 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, QStringLiteral("Redact · D · %1 · D again toggles") .arg(redactionStyleName(redactionStyle_))); add(36, QStringLiteral("tool-cut"), {}, - QStringLiteral("Cut out a band · X · drag across")); + cutInsertHint() + ? QStringLiteral("Insert a band · drag across") + : QStringLiteral("Cut out a band · X · drag across")); add(36, QStringLiteral("tool-text"), {}, QStringLiteral("%1 text · T · %2 · %3 · T again cycles style · " "Shift+T cycles font · Wheel") @@ -2464,6 +2468,15 @@ void CaptureEditor::commitCrop(const QRectF &crop) { commitOp(std::move(op)); } +bool CaptureEditor::cutInsertHint() const { + if (tool_ != Tool::Cut) + return false; + if (cutDragActive_) + return liveCut_.insert; + return QGuiApplication::queryKeyboardModifiers().testFlag( + Qt::ControlModifier); +} + void CaptureEditor::commitCut(CutOp cut) { Operation op; op.type = Operation::Type::Cut; @@ -2670,6 +2683,13 @@ void CaptureEditor::replayLog() { const qreal band = hi - lo; for (Annotation &annotation : annotations) { auto shift = [&](QPointF &point) { + if (op.cut.insert) { + if (horizontal) + point.setY(shiftForInsert(point.y(), lo, band)); + else + point.setX(shiftForInsert(point.x(), lo, band)); + return; + } if (horizontal) point.setY(shiftForCut(point.y(), lo, hi)); else @@ -2681,7 +2701,12 @@ void CaptureEditor::replayLog() { shift(point); } if (band > 0.0) { - if (horizontal) + if (op.cut.insert) { + if (horizontal) + selection.setHeight(selection.height() + band); + else + selection.setWidth(selection.width() + band); + } else if (horizontal) selection.setHeight(std::max(1.0, selection.height() - band)); else selection.setWidth(std::max(1.0, selection.width() - band)); @@ -3514,7 +3539,9 @@ void CaptureEditor::handleToolbar(const QString &action) { } else if (action == QStringLiteral("tool-cut")) { tool_ = Tool::Cut; selectedAnnotation_ = -1; - setStatus(QStringLiteral("Cut: drag across a band to remove it")); + setStatus(cutInsertHint() + ? QStringLiteral("Insert a band · drag across") + : QStringLiteral("Cut: drag across a band to remove it")); } else if (action == QStringLiteral("tool-text")) { if (tool_ == Tool::Text) toggleTextBackground(); @@ -3873,7 +3900,9 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { } } else if (event->key() == Qt::Key_X) { tool_ = Tool::Cut; - setStatus(QStringLiteral("Cut: drag across a band to remove it")); + setStatus(heldModifiers(event->modifiers()).testFlag(Qt::ControlModifier) + ? QStringLiteral("Insert a band · drag across") + : QStringLiteral("Cut: drag across a band to remove it")); } else if (event->key() == Qt::Key_T && event->modifiers() == Qt::ShiftModifier) { cycleTextFont(); @@ -3906,6 +3935,10 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { commitBackground(backgroundStyle_, next); } else cycleBackground(); + } else if (tool_ == Tool::Cut && + (event->key() == Qt::Key_Control || + event->key() == Qt::Key_Meta)) { + setStatus(QStringLiteral("Insert a band · drag across")); } else if (event->key() >= Qt::Key_1 && event->key() <= Qt::Key_8) { colorIndex_ = event->key() - Qt::Key_1; usingCustomColor_ = false; @@ -3930,6 +3963,14 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { void CaptureEditor::keyReleaseEvent(QKeyEvent *event) { modifiersSeen_ = true; + if (tool_ == Tool::Cut && + (event->key() == Qt::Key_Control || event->key() == Qt::Key_Meta) && + !cutDragActive_) { + setStatus(QStringLiteral("Cut: drag across a band to remove it")); + QWidget::keyReleaseEvent(event); + update(); + return; + } if (event->key() == Qt::Key_Shift && (creationConstraintActive_ || resizeConstraintActive_)) { creationConstraintActive_ = false; @@ -4138,6 +4179,9 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { liveCut_.orientation = std::abs(delta.y()) >= std::abs(delta.x()) ? Qt::Horizontal : Qt::Vertical; + liveCut_.insert = + liveCut_.insert || heldModifiers(event->modifiers()) + .testFlag(Qt::ControlModifier); // Lock the source/preview mapping together with the drag axis. The // capture remains unchanged until this preview is committed. cutDragOriginOffset_ = liveCut_.orientation == Qt::Horizontal @@ -4591,9 +4635,12 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { } else if (tool_ == Tool::Cut) { // Activation waits for a dominant drag axis (see mouseMoveEvent); a // plain click never crosses that threshold and mouseReleaseEvent treats - // it as a no-op. + // it as a no-op. Ctrl at press arms insert; the 3px lock records it. cutDragStart_ = point; cutDragActive_ = false; + liveCut_ = {}; + liveCut_.insert = heldModifiers(event->modifiers()) + .testFlag(Qt::ControlModifier); dragging_ = true; } else { dragStart_ = point; @@ -4735,23 +4782,24 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { const qreal band = hi - lo; const qreal extent = horizontal ? selection_.height() : selection_.width(); - if (band <= 0.0 || band >= extent) { - // Full-extent (or empty) band: toAnnotationPoint clamps lo/hi to - // [0, extent], so an edge-to-edge drag on a full selection lands - // exactly here. removeBand() no-ops on this band, so applying it - // would still shrink composedLogicalSize/selection_ while the actual - // pixels don't shrink -- desyncing preview size from the source - // aspect. Bail out instead. + if (band <= 0.0 || (!liveCut_.insert && band >= extent)) { + // Full-extent (or empty) remove is a no-op in removeBand(), so + // applying it would still shrink composedLogicalSize/selection_ + // while the pixels stay put. Insert may grow past the current + // extent. Empty always bails. cutDragActive_ = false; refreshComposedCapture(); - setStatus(QStringLiteral("Cut too large — nothing left")); + setStatus(liveCut_.insert ? QStringLiteral("Insert too small") + : QStringLiteral("Cut too large — nothing left")); updatePointerCursor(); update(); return; } cutDragActive_ = false; commitCut(liveCut_); - setStatus(QStringLiteral("Cut applied · Ctrl+Z to undo")); + setStatus(liveCut_.insert + ? QStringLiteral("Band inserted · Ctrl+Z to undo") + : QStringLiteral("Cut applied · Ctrl+Z to undo")); updatePointerCursor(); update(); return; @@ -5824,6 +5872,7 @@ void CaptureEditor::paintEdit(QPainter &painter) { {QStringLiteral("C"), QStringLiteral("Marker")}, {QStringLiteral("R / E"), QStringLiteral("Rectangle / Ellipse")}, {QStringLiteral("X"), QStringLiteral("Cut out a band")}, + {QStringLiteral("Ctrl"), QStringLiteral("Insert a band (with Cut)")}, {QStringLiteral("T"), QStringLiteral("Text")}, {QStringLiteral("Double click"), QStringLiteral("Edit text layer")}, {QStringLiteral("1–8"), QStringLiteral("Color")}, @@ -6107,13 +6156,17 @@ void CaptureEditor::paintEdit(QPainter &painter) { selection_.height()); painter.save(); painter.setClipRect(band); - painter.fillRect(band, QColor(104, 110, 120, 175)); - - const qreal spacing = 14.0 / scale; - painter.setPen(QPen(QColor(255, 255, 255, 72), 1.0 / scale)); - for (qreal x = band.left() - band.height(); x < band.right(); x += spacing) - painter.drawLine(QPointF(x, band.bottom()), - QPointF(x + band.height(), band.top())); + painter.fillRect(band, liveCut_.insert ? QColor(48, 180, 90, 140) + : QColor(104, 110, 120, 175)); + + if (!liveCut_.insert) { + const qreal spacing = 14.0 / scale; + painter.setPen(QPen(QColor(255, 255, 255, 72), 1.0 / scale)); + for (qreal x = band.left() - band.height(); x < band.right(); + x += spacing) + painter.drawLine(QPointF(x, band.bottom()), + QPointF(x + band.height(), band.top())); + } const QPointF center = band.center(); const qreal crossRadius = @@ -6122,10 +6175,17 @@ void CaptureEditor::paintEdit(QPainter &painter) { if (crossRadius >= 3.0 / scale) { painter.setPen(QPen(QColor(255, 255, 255, 230), 2.0 / scale, Qt::SolidLine, Qt::RoundCap)); - painter.drawLine(center + QPointF(-crossRadius, -crossRadius), - center + QPointF(crossRadius, crossRadius)); - painter.drawLine(center + QPointF(-crossRadius, crossRadius), - center + QPointF(crossRadius, -crossRadius)); + if (liveCut_.insert) { + painter.drawLine(center + QPointF(-crossRadius, 0), + center + QPointF(crossRadius, 0)); + painter.drawLine(center + QPointF(0, -crossRadius), + center + QPointF(0, crossRadius)); + } else { + painter.drawLine(center + QPointF(-crossRadius, -crossRadius), + center + QPointF(crossRadius, crossRadius)); + painter.drawLine(center + QPointF(-crossRadius, crossRadius), + center + QPointF(crossRadius, -crossRadius)); + } } painter.restore(); @@ -6350,6 +6410,9 @@ void CaptureEditor::paintEdit(QPainter &painter) { ? QStringLiteral("tool-ellipse") : button.action == QStringLiteral("shape-fill") ? QStringLiteral("tool-rectangle") + : button.action == QStringLiteral("tool-cut") && + cutInsertHint() + ? QStringLiteral("tool-cut-insert") : button.action; drawToolbarIcon(painter, button.rect, icon, button.label, QColor(245, 245, 247)); diff --git a/src/editor.hpp b/src/editor.hpp index 4941f7d6..9abcaa4b 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -267,6 +267,9 @@ 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)); } + /// Composed source after cuts. Test accessor: insert-band smoke checks + /// the transparent gap on the working image, not the flattened export. + [[nodiscard]] QImage composedSourceForTest() const { return capture_.source; } /// Number of selected layers. Test accessor. [[nodiscard]] int selectedCountForTest() const { return static_cast(selectedAnnotations_.size()); @@ -538,6 +541,7 @@ class CaptureEditor final : public QWidget { void commitDelete(const QVector &indices); void commitCrop(const QRectF &crop); void commitCut(CutOp cut); + [[nodiscard]] bool cutInsertHint() const; void commitBackground(BackgroundStyle style, bool imageShadow); void commitCanvasBoundary(CanvasBoundaryMode mode); void cycleCanvasBoundary(bool reverse); diff --git a/src/icons.cpp b/src/icons.cpp index a56b0250..d67b4e44 100644 --- a/src/icons.cpp +++ b/src/icons.cpp @@ -92,6 +92,12 @@ void drawToolbarIcon(QPainter &painter, const QRectF &bounds, painter.drawRect(QRectF(5, 14.5, 14, 5.5)); painter.setPen(QPen(color, 1.4, Qt::DashLine, Qt::FlatCap)); painter.drawLine(QPointF(5, 12), QPointF(19, 12)); + } else if (action == QStringLiteral("tool-cut-insert")) { + // Two image halves opening, plus in the gap (insert a band). + painter.drawRect(QRectF(5, 3, 14, 5)); + painter.drawRect(QRectF(5, 16, 14, 5)); + painter.drawLine(QPointF(12, 9.5), QPointF(12, 14.5)); + painter.drawLine(QPointF(9.5, 12), QPointF(14.5, 12)); } else if (action == QStringLiteral("tool-text")) { painter.drawLine(QPointF(5, 5), QPointF(19, 5)); painter.drawLine(QPointF(12, 5), QPointF(12, 19)); diff --git a/tests/cut-mapping-smoke.cpp b/tests/cut-mapping-smoke.cpp index fefd7bb7..7267f948 100644 --- a/tests/cut-mapping-smoke.cpp +++ b/tests/cut-mapping-smoke.cpp @@ -298,5 +298,43 @@ bool runCutMappingSmoke(QApplication &application, const QString &outputRoot, editor.close(); } + // Ctrl+drag inserts a transparent band and grows the image. + { + CaptureEditor editor(fixtureCapture(source), + CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_X, Qt::ControlModifier); + application.processEvents(); + if (editor.armedToolForTest() != CaptureEditor::Tool::Cut) { + error = QStringLiteral("Ctrl+X did not arm the cut tool"); + return false; + } + const QPoint from = screenOf(editor, kWidth / 2.0, 80); + const QPoint to = screenOf(editor, kWidth / 2.0, 96); + QTest::mousePress(&editor, Qt::LeftButton, Qt::ControlModifier, from); + QTest::mouseMove(&editor, to, 10); + application.processEvents(); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::ControlModifier, to); + application.processEvents(); + bool inserted = false; + for (const Operation &op : editor.operationLog()) { + if (op.type == Operation::Type::Cut && op.cut.insert) + inserted = true; + } + if (!inserted) { + error = QStringLiteral("Ctrl+drag did not log an insert band"); + return false; + } + const QImage composed = editor.composedSourceForTest(); + if (composed.height() != kBand * kBands + 16 || + composed.pixelColor(0, 80).alpha() != 0) { + error = QStringLiteral("insert band did not open a transparent gap"); + return false; + } + editor.close(); + } + return true; } diff --git a/tests/cut-smoke.cpp b/tests/cut-smoke.cpp index 8b2c8ff9..0187ca81 100644 --- a/tests/cut-smoke.cpp +++ b/tests/cut-smoke.cpp @@ -89,6 +89,39 @@ bool runCutSmoke(QString &error) { return false; } + if (shiftForInsert(3.0, 5.0, 4.0) != 3.0 || + shiftForInsert(5.0, 5.0, 4.0) != 9.0 || + shiftForInsert(12.0, 5.0, 4.0) != 16.0) { + error = QStringLiteral("shiftForInsert wrong"); + return false; + } + + // Horizontal insert at row 2 of width 2: 10,20, gap, gap, 30,40. + const QImage inserted = insertBand(source, Qt::Horizontal, 2, 4); + if (inserted.size() != QSize(4, 6) || redAt(inserted, 0, 0) != 10 || + redAt(inserted, 0, 1) != 20 || inserted.pixelColor(0, 2).alpha() != 0 || + inserted.pixelColor(0, 3).alpha() != 0 || redAt(inserted, 0, 4) != 30 || + redAt(inserted, 0, 5) != 40) { + error = QStringLiteral("horizontal insertBand produced wrong image"); + return false; + } + if (insertBand(source, Qt::Horizontal, 2, 2) != source) { + error = QStringLiteral("empty insertBand changed the image"); + return false; + } + CutOp insertOp{Qt::Vertical, 1, 3, 1, 3, true}; + const QImage viaOp = applyCutOp( + indexedImage({{1, 2, 3, 4}, {1, 2, 3, 4}}), insertOp); + if (viaOp.size() != QSize(6, 2) || redAt(viaOp, 0, 0) != 1 || + viaOp.pixelColor(1, 0).alpha() != 0 || redAt(viaOp, 3, 0) != 2) { + error = QStringLiteral("applyCutOp insert was not a vertical gap"); + return false; + } + if (composedLogicalSize(QSize(100, 80), {insertOp}) != QSize(102, 80)) { + error = QStringLiteral("composedLogicalSize did not grow on insert"); + return false; + } + // Vertical band removal must preserve alpha: create ARGB32 image with // semi-transparent pixel, remove a vertical band, verify alpha is unchanged. QImage alphaSource(4, 2, QImage::Format_ARGB32); From d15b2064bdf65bfa4363b99b435cea4293f10156 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Mon, 31 Aug 2026 12:44:03 -0400 Subject: [PATCH 2/2] fix(editor): clamp inserted cut bands Clamp both source endpoints before deriving the transparent band size, keep the insert hint Ctrl-only, and cover out-of-range horizontal and vertical insertions. --- src/cut.cpp | 11 ++++++----- src/editor.cpp | 7 ++----- tests/cut-smoke.cpp | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/cut.cpp b/src/cut.cpp index 07def961..49a92155 100644 --- a/src/cut.cpp +++ b/src/cut.cpp @@ -1,6 +1,5 @@ -/** @fileoverview Band cut-out engine: removes a horizontal or vertical band - * from an image and collapses the gap (Snagit-style "cut out", ported from - * omapic). */ +/** @fileoverview Band cut engine: removes a horizontal or vertical band and + * collapses the gap, or inserts transparent space at the seam. */ #include "cut.hpp" #include @@ -48,6 +47,10 @@ QImage insertBand(const QImage &source, Qt::Orientation orientation, int start, int end) { if (source.isNull()) return source; + const int extent = + orientation == Qt::Horizontal ? source.height() : source.width(); + start = std::clamp(start, 0, extent); + end = std::clamp(end, start, extent); const int band = end - start; if (band <= 0) return source; @@ -57,7 +60,6 @@ QImage insertBand(const QImage &source, Qt::Orientation orientation, image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); if (orientation == Qt::Horizontal) { const int height = image.height(); - start = std::clamp(start, 0, height); QImage out(image.width(), height + band, image.format()); out.setDevicePixelRatio(image.devicePixelRatio()); out.fill(Qt::transparent); @@ -69,7 +71,6 @@ QImage insertBand(const QImage &source, Qt::Orientation orientation, return out; } const int width = image.width(); - start = std::clamp(start, 0, width); QImage out(width + band, image.height(), image.format()); out.setDevicePixelRatio(image.devicePixelRatio()); out.fill(Qt::transparent); diff --git a/src/editor.cpp b/src/editor.cpp index 2d7dfac1..e7a09a50 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -3935,9 +3935,7 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { commitBackground(backgroundStyle_, next); } else cycleBackground(); - } else if (tool_ == Tool::Cut && - (event->key() == Qt::Key_Control || - event->key() == Qt::Key_Meta)) { + } else if (tool_ == Tool::Cut && event->key() == Qt::Key_Control) { setStatus(QStringLiteral("Insert a band · drag across")); } else if (event->key() >= Qt::Key_1 && event->key() <= Qt::Key_8) { colorIndex_ = event->key() - Qt::Key_1; @@ -3963,8 +3961,7 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { void CaptureEditor::keyReleaseEvent(QKeyEvent *event) { modifiersSeen_ = true; - if (tool_ == Tool::Cut && - (event->key() == Qt::Key_Control || event->key() == Qt::Key_Meta) && + if (tool_ == Tool::Cut && event->key() == Qt::Key_Control && !cutDragActive_) { setStatus(QStringLiteral("Cut: drag across a band to remove it")); QWidget::keyReleaseEvent(event); diff --git a/tests/cut-smoke.cpp b/tests/cut-smoke.cpp index 0187ca81..216bf6f1 100644 --- a/tests/cut-smoke.cpp +++ b/tests/cut-smoke.cpp @@ -109,6 +109,22 @@ bool runCutSmoke(QString &error) { error = QStringLiteral("empty insertBand changed the image"); return false; } + + // Derive insertion size after clamping both bounds. Mapping round-off may + // put an endpoint just outside the source; it must not create extra space. + const QImage clampedRows = insertBand(source, Qt::Horizontal, -5, 1); + const QImage columnSource = indexedImage({{1, 2, 3, 4}, {1, 2, 3, 4}}); + const QImage clampedCols = insertBand(columnSource, Qt::Vertical, 3, 99); + if (clampedRows.size() != QSize(4, 5) || + clampedRows.pixelColor(0, 0).alpha() != 0 || + redAt(clampedRows, 0, 1) != 10 || redAt(clampedRows, 0, 4) != 40 || + clampedCols.size() != QSize(5, 2) || redAt(clampedCols, 2, 0) != 3 || + clampedCols.pixelColor(3, 0).alpha() != 0 || + redAt(clampedCols, 4, 0) != 4) { + error = QStringLiteral("insertBand bounds handling wrong"); + return false; + } + CutOp insertOp{Qt::Vertical, 1, 3, 1, 3, true}; const QImage viaOp = applyCutOp( indexedImage({{1, 2, 3, 4}, {1, 2, 3, 4}}), insertOp);