From bd103141c691c84fa863bb44dc058ed9de5b8de0 Mon Sep 17 00:00:00 2001 From: Piotr Chmolowski Date: Mon, 21 Sep 2026 15:10:26 +0200 Subject: [PATCH] feat: add adjustable aspect-ratio capture regions Adds common aspect-ratio presets to region capture. Use `F` / `Shift+F` to cycle through square, landscape, and portrait ratios, or `Ctrl+F` to reset to Free. Freeform regions capture on release. Fixed-ratio regions remain adjustable: drag inside to reposition, drag corners to resize, and press Enter to capture. The real pointer stays visible and can move outside the constrained selection. Restored regions can also be adjusted before capture. Includes updated documentation and regression coverage for ratio geometry, direction changes, repositioning, resizing, cancellation, and immediate freeform capture. --- CHANGELOG.md | 6 + README.md | 44 +++++--- docs/editing-model.md | 6 + src/editor.cpp | 248 ++++++++++++++++++++++++++++++++++++----- src/editor.hpp | 11 ++ tests/editor-smoke.cpp | 233 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 506 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc3652..33422c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ earlier development is recorded in the [commit history](https://github.com/omaco ### Added +- Capture regions in common square, landscape, and portrait aspect ratios. + Cycle with `F` / `Shift+F` and reset to Free with `Ctrl+F`. Fixed-ratio + regions can be moved and resized before Enter confirms the capture; + freeform regions still capture on release. Restored regions can also be + adjusted before capture. + - A folder button beside each preview or pin's link-copy button, plus `R` while hovered, to save the shot if needed and reveal it in the default file browser. - A readable shortcuts card above the capture/editor canvas. Press `?` or click diff --git a/README.md b/README.md index ce4a4a7..e85d8a9 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,11 @@ preview keeps it on screen. ## Features -- Smart selection by default: drag a freeform region, click a window to crop - it, or click open monitor space for the full monitor. Explicit region, - window, fullscreen, and scrolling-region modes remain available. +- Smart selection by default: drag a freeform region and release to capture. + With a fixed aspect ratio, adjust the region and press Enter to capture. + Click a window to crop it, or click open monitor space for the full monitor. + Explicit region, window, fullscreen, and scrolling-region modes remain + available. - Fresh captures copy immediately and show a floating preview for 10 seconds without taking keyboard focus. Hover for Pin, Edit, Copy, file drag, and Close controls. Use the pin button, `Ctrl+P`, `T` while hovered, or drag the preview to @@ -253,7 +255,9 @@ the annotator open after the capture. Press either key again to turn it off. The editor then controls whether the result is copied, saved, or both. Quick output skips the preview as well as the annotation editor. Add `--copy` to copy -only, `--save` to save only, or both flags to copy and save. Region and window captures output after selection; +only, `--save` to save only, or both flags to copy and save. Region captures +output on release in Free mode or after Enter with a fixed aspect ratio; +window captures output on selection; fullscreen captures output immediately. Quick output cannot be combined with `--file`, `--clipboard`, or `--pin`. @@ -416,12 +420,17 @@ annotation. The windowed editor keeps its guide above the toolbar. The default smart picker infers the capture kind from the gesture: drag for a region, click a window for that window, or click open space for the full -focused monitor. Whatever is lit is what will be captured. - -Press `S` before drawing to select a scrolling region; once drawn, the page -inside it goes live and the scroll controls appear in place. A small **Scroll -capture** button under an image already open in the editor turns that region -into a scrolling capture. Explicit `region`, `windows`, `fullscreen`, and +focused monitor. In **Free** mode, releasing the mouse captures immediately. +With a **fixed aspect ratio**, releasing leaves an adjustable box: drag inside +it to move it or drag a corner handle to resize it, then press **Enter** to capture. +**Esc** cancels an in-progress adjustment or clears a completed selection; +clicking outside the box starts a new region. Window and monitor clicks capture +immediately. Whatever is lit is what will be captured. + +Press `S` before drawing to select a scrolling region; after release in Free +mode or **Enter** with a fixed ratio, the page inside it goes live and the scroll +controls appear in place. A small **Scroll capture** button under an image +already open in the editor turns that region into a scrolling capture. Explicit `region`, `windows`, `fullscreen`, and `scroll` command-line targets remain available for scripts and keybindings. While scrolling, use the on-screen Done and Cancel buttons, or `Enter` and `Esc` when the overlay has keyboard focus. Move the pointer back over the @@ -430,15 +439,22 @@ controls to return focus from the live page. | Input | Action | |---|---| | Click | In smart mode, capture the window under the pointer, or the full monitor outside any window | -| Drag | Select a region, with its native pixel size shown at the pointer | +| Drag | Free: release to capture. Fixed ratio: release to adjust, drag inside to move or a corner to resize, then Enter to capture | +| `F` / `Shift+F` | Cycle aspect ratio forward/backward before, during, or after a region drag: Free, 1:1, 16:9, 16:10, 4:3, 3:2, 9:16, 10:16, 3:4, 2:3 | +| `Ctrl+F` | Reset aspect ratio to Free, including during a drag | | `S` | Toggle scrolling-region mode | | `E` / `A` | Toggle annotation after capture; the capture guide shows on/off, and the choice also applies to scrolling captures | -| `R` | Restore the last drawn region, including from a previous Omasnap launch in this login session (same monitor and overlay size) | +| `R` | Restore the last drawn region, including from a previous Omasnap launch in this login session (same monitor and overlay size); adjust it and press Enter | | `SUPER + Arrow` | Move among windows in window mode | -| `Enter` | Capture the highlighted window | +| `Enter` | Capture the adjusted region or highlighted window | | `Ctrl+A` | Select the full focused monitor | | Hover the right-edge stack | Fan out the five most recent captures; click one to reopen it | -| `Esc` | Dismiss; cancel a selection drag if one is in progress | +| `Esc` | Undo an in-progress adjustment, clear a drawn region, or dismiss the picker | + +Aspect-ratio drawing keeps the starting corner fixed and fits inside the mouse +drag using both axes. The real pointer can move outside the constrained box. +The guide shows the active ratio, and each launch starts Free. For scrolling +captures, the ratio constrains only the initial region. Region memory is stored in Omasnap's private runtime directory. It survives closing and reopening Omasnap and lasts until those runtime files are removed diff --git a/docs/editing-model.md b/docs/editing-model.md index efdae24..ad44e00 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -32,6 +32,12 @@ flatten or repaint the full capture. **Nothing is baked into the working image as you draw.** Add a rectangle, change your mind, delete it — the source pixels underneath were never touched. +Freeform regions capture on mouse release. A region drawn with a fixed aspect +ratio stays in the picker until Enter confirms it. Moving and resizing this +box are tentative capture adjustments; Escape restores an in-progress adjustment +or clears the pending region. Confirmation creates the initial crop, then +follows the normal output or annotation path. + Completing a fresh capture copies it and shows a timed preview by default. The preview fades after 10 seconds of idle time; its pin button, Ctrl+P, or moving the preview keeps it, including a drag that only reorders the stack. diff --git a/src/editor.cpp b/src/editor.cpp index 353f773..e9e9cdd 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -257,6 +257,17 @@ QRect parseStoredRegion(const QString &line, const QString &monitor, constexpr qreal kNudgeStep = 1.0; constexpr qreal kNudgeStepShift = 10.0; +struct CaptureAspectRatio { + const char *label; + qreal ratio; +}; +constexpr std::array kCaptureAspectRatios{{ + {"Free", 0.0}, {"1:1", 1.0}, {"16:9", 16.0 / 9.0}, + {"16:10", 16.0 / 10.0}, {"4:3", 4.0 / 3.0}, {"3:2", 3.0 / 2.0}, + {"9:16", 9.0 / 16.0}, {"10:16", 10.0 / 16.0}, + {"3:4", 3.0 / 4.0}, {"2:3", 2.0 / 3.0}, +}}; + /** Keeps a downward submenu alive while the pointer travels toward it. */ bool inDownwardSubmenuTriangle(const QPointF &origin, const QRectF &submenu, const QPointF &pointer) { @@ -1108,6 +1119,7 @@ CaptureEditor::~CaptureEditor() { finishWatcher_.waitForFinished(); pinWatcher_.waitForFinished(); dismissFuture_.waitForFinished(); + regionMemoryFuture_.waitForFinished(); // Never remove the working snapshot under an in-flight write; drain the // current render (dropping any coalesced follow-up) before cleanup. snapshotDirty_ = false; @@ -2087,8 +2099,25 @@ QRectF CaptureEditor::normalizedSelection(const QPointF &first, const QRectF bounds(QPointF(), QSizeF(width(), height())); const QPointF a(std::clamp(first.x(), bounds.left(), bounds.right()), std::clamp(first.y(), bounds.top(), bounds.bottom())); - const QPointF b(std::clamp(second.x(), bounds.left(), bounds.right()), - std::clamp(second.y(), bounds.top(), bounds.bottom())); + QPointF b(std::clamp(second.x(), bounds.left(), bounds.right()), + std::clamp(second.y(), bounds.top(), bounds.bottom())); + qreal ratio = kCaptureAspectRatios.at(captureAspectIndex_).ratio; + if (ratio > 0.0) { + // Constrain native pixels, including when the overlay and source have + // different dimensions. Fit inside the raw drag with the press point fixed. + // Both dimensions reach zero at either anchor axis, so changing quadrants + // cannot flip a nonzero rectangle across the anchor. + if (!capture_.source.isNull() && width() > 0 && height() > 0) + ratio *= (qreal(width()) / height()) * + (qreal(capture_.source.height()) / capture_.source.width()); + const QPointF delta = b - a; + const qreal signX = delta.x() < 0 ? -1.0 : 1.0; + const qreal signY = delta.y() < 0 ? -1.0 : 1.0; + const qreal constrainedHeight = + std::min(std::abs(delta.x()) / ratio, std::abs(delta.y())); + b = a + QPointF(signX * constrainedHeight * ratio, + signY * constrainedHeight); + } return QRectF(a, b).normalized(); } @@ -2101,6 +2130,63 @@ QSizeF CaptureEditor::windowLegendSize() const { return legendSize_; } +std::array CaptureEditor::selectionCorners() const { + return {selection_.topLeft(), selection_.topRight(), + selection_.bottomRight(), selection_.bottomLeft()}; +} + +int CaptureEditor::selectionHandleAt(const QPointF &point) const { + if (!selectionReady_) + return -1; + const auto corners = selectionCorners(); + for (int i = 0; i < 4; ++i) { + if (QRectF(corners.at(i) - QPointF(9, 9), QSizeF(18, 18)).contains(point)) + return i; + } + return -1; +} + +void CaptureEditor::adjustPendingSelection(const QPointF &point) { + const QPointF delta = point - selectionPress_; + if (selectionDragHandle_ == -2) { + selection_ = originalSelection_.translated( + std::clamp(delta.x(), -originalSelection_.left(), + width() - originalSelection_.right()), + std::clamp(delta.y(), -originalSelection_.top(), + height() - originalSelection_.bottom())); + return; + } + const std::array corners{ + originalSelection_.topLeft(), originalSelection_.topRight(), + originalSelection_.bottomRight(), originalSelection_.bottomLeft()}; + const QPointF anchor = corners.at((selectionDragHandle_ + 2) % 4); + const QRectF resized = normalizedSelection( + anchor, corners.at(selectionDragHandle_) + delta); + // Keep handles usable when the pointer crosses the fixed corner. + if (resized.width() >= 2 && resized.height() >= 2) + selection_ = resized; +} + +void CaptureEditor::confirmRegionSelection() { + if (!selectionReady_ || dragging_) + return; + const QString saved = formatStoredRegion(capture_.monitor.name, size(), + selection_.toRect()); + // Only the confirmed box becomes the remembered region; tentative moves + // never touch the session file, and writing it must not delay capture. + regionMemoryFuture_ = QtConcurrent::run([saved] { + const QString path = storedCaptureRegionPath(); + if (path.isEmpty()) + return; + QFile file(path); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + file.write(saved.toUtf8()); + }); + selectionReady_ = false; + commitRegion(selection_, QStringLiteral("Area selected · Select moves layers · " + "wheel zooms · outer handles crop")); +} + qreal CaptureEditor::toolbarTop() const { if (windowedPresentation_) return 14 + windowLegendSize().height() + 42; @@ -2535,8 +2621,13 @@ QString CaptureEditor::measurementText() const { } // A fresh drag reads 0 × 0 rather than falling back to the pointer // position: the number must track the frame the moment it starts. - if (dragging_ || !selection_.isEmpty()) - return formatPixelSize(sourceRect(selection_).size()); + if (dragging_ || !selection_.isEmpty()) { + QString text = formatPixelSize(sourceRect(mapWidgetToPreview(selection_)).size()); + if (captureAspectIndex_ != 0) + text += QStringLiteral(" · %1").arg(QString::fromLatin1( + kCaptureAspectRatios.at(captureAspectIndex_).label)); + return text; + } return formatPixelPoint(sourcePoint(cursor_)); } if (tool_ == Tool::Select && dragging_ && @@ -3487,6 +3578,7 @@ void CaptureEditor::enterEdit(QString status) { } void CaptureEditor::enterSelectedCapture(QString editStatus) { + selectionReady_ = false; if (quickOutputMode_ != QuickOutputMode::None) { if (configuredCustomDefaultPending_) { pendingSelectedCapture_ = std::move(editStatus); @@ -3524,9 +3616,25 @@ void CaptureEditor::enterExport() { void CaptureEditor::handleEscape() { // Selecting: there is nothing to step back from, so one Esc closes (the - // launch key then Esc is the quickest "never mind"). Only a drag in flight - // is cancelled first. Editing: dismiss and return the document to its pin. + // launch key then Esc is the quickest "never mind"). Pending selections + // and in-progress drags are cancelled first. Editing: dismiss and return the document to its pin. if (phase_ == Phase::Select) { + if (selectionReady_) { + if (dragging_) { + selection_ = originalSelection_; + dragging_ = false; + setStatus(QStringLiteral("Adjustment cancelled · Enter captures")); + } else { + selectionReady_ = false; + selection_ = {}; + smartMode_ = captureMode_ == CaptureMode::Smart && !scrollMode_; + hoveredWindow_ = smartMode_ ? windowAt(cursor_) : -1; + setStatus(QStringLiteral("Selection cancelled · drag to select an area")); + } + updatePointerCursor(); + update(); + return; + } if (!dragging_) { close(); return; @@ -4373,6 +4481,34 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { return; } if (phase_ == Phase::Select) { + if (selectionReady_ && + (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)) { + confirmRegionSelection(); + event->accept(); + return; + } + if (!windowMode_ && event->key() == Qt::Key_F && + (event->modifiers() == Qt::NoModifier || + event->modifiers() == Qt::ShiftModifier || + event->modifiers() == Qt::ControlModifier)) { + if (!event->isAutoRepeat() && !(selectionReady_ && dragging_)) { + if (event->modifiers() == Qt::ControlModifier) { + captureAspectIndex_ = 0; + } else { + const int count = static_cast(kCaptureAspectRatios.size()); + const int step = event->modifiers() == Qt::ShiftModifier ? -1 : 1; + captureAspectIndex_ = (captureAspectIndex_ + step + count) % count; + } + if (dragging_) + selection_ = normalizedSelection(dragStart_, cursor_); + else if (selectionReady_) + selection_ = normalizedSelection(selection_.topLeft(), selection_.bottomRight()); + updatePointerCursor(); + update(); + } + event->accept(); + return; + } if (event->matches(QKeySequence::SelectAll)) { selectFullscreen(); return; @@ -4425,10 +4561,11 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { parseStoredRegion(QString::fromUtf8(file.readLine(256)), capture_.monitor.name, size()); if (!region.isEmpty()) { - commitRegion(QRectF(region), - QStringLiteral("Last area restored · Select moves " - "layers · Ctrl+wheel zooms · outer handles " - "crop")); + selection_ = QRectF(region); + selectionReady_ = true; + smartMode_ = false; + setStatus(QStringLiteral("Last area restored · drag to move · corners resize · Enter captures")); + updatePointerCursor(); update(); } } @@ -4949,7 +5086,7 @@ QRegion CaptureEditor::pointerMotionRegion(const QPointF &point, add(QRectF(point.x() - 230, point.y() - 70, 460, 140)); if (phase_ == Phase::Select) { - if (!windowMode_ && !dragging_ && !recentsOpen_) { + if (!windowMode_ && !dragging_ && !selectionReady_ && !recentsOpen_) { add(QRectF(point.x() - 3, 0, 7, height())); add(QRectF(0, point.y() - 3, width(), 7)); } @@ -5190,9 +5327,12 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { if (capturePending_) return; if (phase_ == Phase::Select) { - if (!dragging_) + if (!dragging_ && !selectionReady_) trackRecentsHover(); - if (smartMode_) { + if (selectionReady_) { + if (dragging_) + adjustPendingSelection(cursor_); + } else if (smartMode_) { if (dragging_) selection_ = normalizedSelection(dragStart_, cursor_); else @@ -5596,6 +5736,20 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { return; } if (phase_ == Phase::Select) { + if (selectionReady_) { + selectionDragHandle_ = selectionHandleAt(cursor_); + if (selectionDragHandle_ >= 0 || selection_.contains(cursor_)) { + if (selectionDragHandle_ < 0) + selectionDragHandle_ = -2; + originalSelection_ = selection_; + selectionPress_ = cursor_; + dragging_ = true; + updatePointerCursor(); + update(); + return; + } + selectionReady_ = false; + } trackRecentsHover(); if (recentsOpen_) { if (const int recent = recentAt(cursor_); recent >= 0) @@ -5607,6 +5761,7 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { return; } dragStart_ = cursor_; + selectionDragHandle_ = -1; selection_ = {}; dragging_ = true; if (smartMode_) { @@ -5944,6 +6099,15 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { if (capturePending_ || event->button() != Qt::LeftButton || !dragging_) return; if (phase_ == Phase::Select) { + if (selectionReady_) { + adjustPendingSelection(event->position()); + dragging_ = false; + if (captureAspectIndex_ == 0) + confirmRegionSelection(); + updatePointerCursor(); + update(); + return; + } selection_ = normalizedSelection(dragStart_, event->position()); dragging_ = false; const qreal selectedArea = selection_.width() * selection_.height(); @@ -5960,19 +6124,13 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { return; } if (selection_.width() >= 2 && selection_.height() >= 2) { - // Remember the drawn region for this session, so R can bring it back - // on the next capture. A convenience, so failing to write is no error. - const QString path = storedCaptureRegionPath(); - if (!path.isEmpty()) { - QFile file(path); - if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) - file.write(formatStoredRegion(capture_.monitor.name, size(), - selection_.toRect()) - .toUtf8()); - } - commitRegion(selection_, - QStringLiteral("Area selected · Select moves layers · wheel " - "zooms · outer handles crop")); + selectionReady_ = true; + smartMode_ = false; + hoveredWindow_ = -1; + if (captureAspectIndex_ == 0) + confirmRegionSelection(); + else + setStatus(QStringLiteral("Drag inside to move · corners resize · Enter captures · Esc cancels")); } updatePointerCursor(); update(); @@ -6429,6 +6587,13 @@ void CaptureEditor::updatePointerCursor() { } if (phase_ == Phase::Select) { clearHighlighterPreview(); + if (selectionReady_) { + const int handle = dragging_ ? selectionDragHandle_ : selectionHandleAt(cursor_); + applyCursor(handle >= 0 ? (handle % 2 == 0 ? Qt::SizeFDiagCursor : Qt::SizeBDiagCursor) + : dragging_ ? Qt::ClosedHandCursor + : selection_.contains(cursor_) ? Qt::OpenHandCursor : Qt::CrossCursor); + return; + } const bool pointing = windowMode_ || (recentsOpen_ && recentAt(cursor_) >= 0); applyCursor(pointing ? Qt::PointingHandCursor @@ -6589,6 +6754,7 @@ bool CaptureEditor::hasLiveScreen() const { } void CaptureEditor::setScrollMode(bool enabled) { + selectionReady_ = false; smartMode_ = false; scrollMode_ = enabled; if (enabled) @@ -6735,6 +6901,7 @@ void CaptureEditor::adoptImage(QImage image, OperationLog log, CaptureMode kind, } void CaptureEditor::returnToSelect() { + selectionReady_ = false; pinDocument_.reset(); if (textEditing()) { textEditor_->clear(); @@ -7074,7 +7241,12 @@ void CaptureEditor::selectFullscreen() { QVector> CaptureEditor::captureHotkeyEntries() const { QVector> hotkeys; - if (smartMode_) + if (selectionReady_) + hotkeys = {{QStringLiteral("Drag inside"), QStringLiteral("Move area")}, + {QStringLiteral("Drag corner"), QStringLiteral("Resize area")}, + {QStringLiteral("Enter"), QStringLiteral("Capture area")}, + {QStringLiteral("Esc"), QStringLiteral("Cancel area")}}; + else if (smartMode_) hotkeys = { {QStringLiteral("Click"), QStringLiteral("Window / full screen")}, {QStringLiteral("Drag"), QStringLiteral("Area")}, @@ -7087,6 +7259,20 @@ QVector> CaptureEditor::captureHotkeyEntries() const { {QStringLiteral("R"), QStringLiteral("Last region")}, {QStringLiteral("S"), QStringLiteral("Scrolling region")}, {QStringLiteral("Esc"), QStringLiteral("Close")}}; + if (!windowMode_) { + hotkeys.insert(hotkeys.size() - 1, + {QStringLiteral("Release"), + captureAspectIndex_ == 0 ? QStringLiteral("Capture area") + : selectionReady_ ? QStringLiteral("Keep adjusting") + : QStringLiteral("Adjust, then Enter")}); + hotkeys.insert(hotkeys.size() - 1, + {QStringLiteral("Ctrl+F"), QStringLiteral("Free aspect ratio")}); + hotkeys.insert(hotkeys.size() - 1, + {QStringLiteral("F / Shift+F"), + QStringLiteral("Aspect ratio: %1") + .arg(QString::fromLatin1( + kCaptureAspectRatios.at(captureAspectIndex_).label))}); + } hotkeys.insert(hotkeys.size() - 1, {QStringLiteral("E / A"), quickOutputMode_ == QuickOutputMode::None @@ -7163,7 +7349,13 @@ void CaptureEditor::paintSelect(QPainter &painter) { painter.drawRect(outline); } - if (!exporting && !windowMode_ && !dragging_ && !recentsOpen_) { + if (!exporting && selectionReady_) { + painter.setPen(QPen(chromeTheme().surface, 1)); + painter.setBrush(chromeTheme().foreground); + for (const QPointF &corner : selectionCorners()) + painter.drawRect(QRectF(corner - QPointF(3.5, 3.5), QSizeF(7, 7))); + } + if (!exporting && !windowMode_ && !dragging_ && !selectionReady_ && !recentsOpen_) { painter.setPen(QPen(chromeAlpha(chromeTheme().foreground, 56), 1)); painter.drawLine(QPointF(cursor_.x(), 0), QPointF(cursor_.x(), height())); painter.drawLine(QPointF(0, cursor_.y()), QPointF(width(), cursor_.y())); diff --git a/src/editor.hpp b/src/editor.hpp index c64b7ea..d937d10 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -18,6 +18,7 @@ #include #include +#include #include #include @@ -572,6 +573,11 @@ class CaptureEditor final : public QWidget { /// `kind` records which coordinate space produced it. void adoptImage(QImage image, OperationLog log, CaptureMode kind, const QString &status); + /// Tentative capture adjustments before committing the selected region. + [[nodiscard]] std::array selectionCorners() const; + [[nodiscard]] int selectionHandleAt(const QPointF &point) const; + void adjustPendingSelection(const QPointF &point); + void confirmRegionSelection(); /// Leaves the select phase with a drawn region: edit it, or scroll it. void commitRegion(const QRectF ®ion, const QString &editStatus); /// Whether there is a live screen behind this capture to re-select from @@ -747,6 +753,10 @@ class CaptureEditor final : public QWidget { qreal recentsFanFrom_ = 0.0; QElapsedTimer recentsAnimClock_; QTimer recentsAnimTimer_; + bool selectionReady_ = false; + int selectionDragHandle_ = -1; // -1 draws, -2 moves; 0..3 resize corners. + QPointF selectionPress_; + int captureAspectIndex_ = 0; // Free by default; applies only to region drawing. QRectF selection_; // Annotation coordinates stay anchored to the source frame at 0,0. This // derived rect expands around them without translating either the source or @@ -947,6 +957,7 @@ class CaptureEditor final : public QWidget { QColor textColor_; QFutureWatcher ocrWatcher_; QFutureWatcher finishWatcher_; + QFuture regionMemoryFuture_; QFuture dismissFuture_; QFutureWatcher reopenWatcher_; QFutureWatcher backdropWatcher_; diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 199452f..2acb550 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -530,6 +530,231 @@ bool runMeasurementReadoutCheck(QString &error) { } /** Smart selection infers a click target but keeps a real drag freeform. */ +bool runRegionAdjustmentSmoke(QApplication &application, QString &error) { + CaptureData capture; + capture.monitor.name = QStringLiteral("ADJUST-TEST"); + capture.monitor.geometry = QRect(0, 0, 800, 600); + capture.monitor.pixelSize = QSize(800, 600); + capture.monitor.scale = 1.0; + capture.previewSize = QSize(800, 600); + capture.source = QImage(capture.previewSize, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(Qt::darkGray); + for (const auto mode : {CaptureEditor::CaptureMode::Smart, + CaptureEditor::CaptureMode::Region}) { + for (const int ratioIndex : {1, 2}) { + CaptureEditor editor(capture, mode); + prepareSelectEditor(editor, capture.previewSize); + for (int i = 0; i < ratioIndex; ++i) + QTest::keyClick(&editor, Qt::Key_F); + const auto drag = [&editor](QPoint from, QPoint to) { + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, from); + QTest::mouseMove(&editor, to, 1); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, to); + }; + drag({300, 100}, {620, 280}); + const QRectF drawn = editor.currentSelection(); + if (!editor.selectingForTest() || !editor.operationLog().isEmpty()) { + error = QStringLiteral("Region release captured before confirmation"); + return false; + } + drag(drawn.center().toPoint(), drawn.center().toPoint() + QPoint(60, 40)); + if (editor.currentSelection() != drawn.translated(60, 40) || + !editor.selectingForTest()) { + error = QStringLiteral("Moving a pending region changed its size or captured it"); + return false; + } + const QRectF moved = editor.currentSelection(); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, moved.center().toPoint()); + QTest::mouseMove(&editor, moved.center().toPoint() + QPoint(-40, 40), 1); + QTest::keyClick(&editor, Qt::Key_Escape); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, moved.center().toPoint()); + if (editor.currentSelection() != moved || !editor.selectingForTest()) { + error = QStringLiteral("Esc did not undo an in-progress region move"); + return false; + } + drag(moved.bottomRight().toPoint(), moved.bottomRight().toPoint() + QPoint(60, 40)); + const QRectF resized = editor.currentSelection(); + if (resized.topLeft() != moved.topLeft() || resized.width() <= moved.width() || + std::abs(resized.width() / resized.height() - + (ratioIndex == 1 ? 1.0 : 16.0 / 9.0)) > 0.001) { + error = QStringLiteral("Region handle lost its anchor or locked aspect ratio"); + return false; + } + drag(resized.center().toPoint(), {790, 10}); + const QRectF final = editor.currentSelection(); + if (final.size() != resized.size() || final.right() > 800 || final.top() < 0) { + error = QStringLiteral("Region move was not clamped to the monitor"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Return); + application.processEvents(); + if (!editor.editingForTest() || editor.currentSelection() != final) { + error = QStringLiteral("Enter did not capture the adjusted region"); + return false; + } + editor.close(); + } + } + // Free draws finish on release, including when reset from a fixed ratio + // during the drag. No synthetic Enter is sent by these tests. + for (const auto mode : {CaptureEditor::CaptureMode::Smart, + CaptureEditor::CaptureMode::Region}) { + for (const bool resetDuringDrag : {false, true}) { + CaptureEditor editor(capture, mode); + prepareSelectEditor(editor, capture.previewSize); + if (resetDuringDrag) + QTest::keyClick(&editor, Qt::Key_F); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, {300, 100}); + QTest::mouseMove(&editor, {620, 280}, 1); + if (resetDuringDrag) + QTest::keyClick(&editor, Qt::Key_F, Qt::ControlModifier); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, {620, 280}); + if (!editor.editingForTest() || editor.currentSelection() != QRectF(300, 100, 320, 180)) { + error = QStringLiteral("Free region required confirmation after release"); + return false; + } + editor.close(); + } + } + CaptureEditor quick(capture, CaptureEditor::CaptureMode::Smart, + QuickOutputMode::CopyAndPreview); + prepareSelectEditor(quick, capture.previewSize); + QTest::keyClick(&quick, Qt::Key_F); + QTest::mousePress(&quick, Qt::LeftButton, Qt::NoModifier, {300, 100}); + QTest::mouseMove(&quick, {620, 280}, 1); + QTest::mouseRelease(&quick, Qt::LeftButton, Qt::NoModifier, {620, 280}); + application.processEvents(); + if (!quick.selectingForTest() || !quick.isVisible()) { + error = QStringLiteral("Quick output ran before region confirmation"); + return false; + } + QTest::keyClick(&quick, Qt::Key_Escape); + if (!quick.selectingForTest() || !quick.currentSelection().isEmpty()) { + error = QStringLiteral("Esc did not cancel the pending region"); + return false; + } + quick.close(); + return true; +} + +bool runCaptureAspectRatioSmoke(QApplication &application, QString &error) { + CaptureData capture; + capture.monitor.name = QStringLiteral("ASPECT-TEST"); + capture.monitor.geometry = QRect(0, 0, 800, 600); + capture.monitor.pixelSize = QSize(1600, 1200); + capture.monitor.scale = 2.0; + capture.source = QImage(1600, 1200, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(Qt::darkGray); + capture.previewSize = QSize(800, 600); + const std::array ratios{ + 1.0, 16.0 / 9.0, 1.6, 4.0 / 3.0, 1.5, + 9.0 / 16.0, 0.625, 0.75, 2.0 / 3.0}; + for (const auto mode : {CaptureEditor::CaptureMode::Region, + CaptureEditor::CaptureMode::Smart}) { + for (int direction = 0; direction < 4; ++direction) { + CaptureEditor editor(capture, mode); + editor.setSuppressSnapshots(true); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + const QPoint start(400, 300); + const QPoint end(direction & 1 ? 790 : 10, direction & 2 ? 590 : 10); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, start); + QTest::mouseMove(&editor, end); + for (const qreal ratio : ratios) { + QTest::keyClick(&editor, Qt::Key_F); + const int signX = direction & 1 ? 1 : -1; + const int signY = direction & 2 ? 1 : -1; + const QPoint firstMove = start + QPoint(signX * 160, signY * 120); + for (const QPoint &point : {firstMove, + start + QPoint(signX * 280, signY * 180), + end}) { + QTest::mouseMove(&editor, point); + const QRectF rect = editor.currentSelection(); + const qreal nativeRatio = rect.width() / rect.height(); + const QPointF anchor(direction & 1 ? rect.left() : rect.right(), + direction & 2 ? rect.top() : rect.bottom()); + if (std::abs(nativeRatio - ratio) > 0.00001 || + !QRectF(editor.rect()).adjusted(-0.001, -0.001, 0.001, 0.001) + .contains(rect) || + QLineF(anchor, start).length() > 0.00001) { + error = QStringLiteral("Capture resizing lost ratio, fixed press point, or bounds"); + return false; + } + if (editor.cursor().shape() != Qt::CrossCursor || + !editor.pointerMotionRegionForTest(point).contains(point)) { + error = QStringLiteral("Aspect-ratio capture hid the real pointer or lost its badge damage"); + return false; + } + // The square fits inside the raw drag instead of extending past + // either axis, including as an axis approaches or crosses zero. + if (ratio == 1.0 && point == firstMove && + rect.size() != QSizeF(120, 120)) { + error = QStringLiteral("Constrained corner extended beyond the raw drag"); + return false; + } + } + // Cross each anchor axis and reverse over it again without releasing. + // Testing separate quadrant drags misses a sign flip at nonzero size. + for (const bool horizontal : {false, true}) { + for (const int side : {-1, 1}) { + QPointF previousCorner; + bool havePrevious = false; + for (const int offset : {-3, -2, -1, 0, 1, 2, 3, 2, 1, 0, -1, -2, -3}) { + const QPoint delta = horizontal ? QPoint(offset, side * 180) + : QPoint(side * 180, offset); + const QPoint point = start + delta; + QTest::mouseMove(&editor, point, 1); + const QRectF selection = editor.currentSelection(); + const QPointF corner(delta.x() < 0 ? selection.left() : selection.right(), + delta.y() < 0 ? selection.top() : selection.bottom()); + const QPointF anchor(delta.x() < 0 ? selection.right() : selection.left(), + delta.y() < 0 ? selection.bottom() : selection.top()); + const qreal maxCornerStep = std::hypot(ratio, 1.0) * + std::max(1.0, 1.0 / ratio); + if (QLineF(anchor, start).length() > 0.00001 || + std::abs(selection.width() - selection.height() * ratio) > 0.00001 || + (offset == 0 && (selection.size() != QSizeF(0, 0) || corner != start)) || + (havePrevious && QLineF(previousCorner, corner).length() > + maxCornerStep + 0.00001)) { + error = QStringLiteral("Constrained drag jumped when reversing across an anchor axis"); + return false; + } + previousCorner = corner; + havePrevious = true; + } + } + } + QTest::mouseMove(&editor, end, 1); + } + QTest::keyClick(&editor, Qt::Key_F); + if (editor.currentSelection() != QRectF(start, end).normalized()) { + error = QStringLiteral("Cycling capture aspect did not restore free drawing"); + return false; + } + QTest::keyClick(&editor, Qt::Key_F, Qt::ShiftModifier); + QTest::keyClick(&editor, Qt::Key_F, Qt::ControlModifier); + if (editor.currentSelection() != QRectF(start, end).normalized() || + editor.cursor().shape() != Qt::CrossCursor) { + error = QStringLiteral("Ctrl+F did not immediately restore free drawing and cursor"); + return false; + } + QTest::keyClick(&editor, Qt::Key_F); + const QRectF beforeRelease = editor.currentSelection(); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, end); + QTest::keyClick(&editor, Qt::Key_Return); + application.processEvents(); + if (!editor.editingForTest() || editor.currentSelection() != beforeRelease || + editor.cursor().shape() == Qt::BlankCursor) { + error = QStringLiteral("Capture release did not preserve the region and restore the cursor"); + return false; + } + editor.close(); + } + } + return true; +} + bool runSmartSelectionSmoke(QApplication &application, QString &error) { CaptureData capture; capture.monitor.name = QStringLiteral("TEST"); @@ -12117,6 +12342,14 @@ int main(int argc, char **argv) { qWarning().noquote() << snapshotError; return 95; } + if (!runRegionAdjustmentSmoke(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 217; + } + if (!runCaptureAspectRatioSmoke(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 216; + } if (!runSmartSelectionSmoke(application, snapshotError)) { qWarning().noquote() << snapshotError; return 136;