diff --git a/README.md b/README.md index 68f1edec..8aa99320 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,10 @@ Runtime commands used by the application: - `hyprctl` - `wl-copy` and `wl-paste` - `tesseract` -- `omarchy-notification-send` when available; saved captures include a thumbnail and - reopen in Omasnap when clicked. Notification failure does not invalidate output. +- `omarchy-notification-send` when available; saved captures include a thumbnail. + Notification failure does not invalidate output. +- `uwsm-app` and Nautilus for the optional reveal-after-save gesture and saved + notification click. A reveal failure does not invalidate output. ## Install on Omarchy @@ -216,8 +218,7 @@ the overlay that is still on screen. Editing an existing image is never cancelled this way: `--file`, `--clipboard`, or an image path stops the running instance, waits up to two seconds for the lock, and opens the -editor on that image. That is how a pin's Edit button and a notification click always land -in the editor. +editor on that image. That is how a pin's Edit button lands in the editor. A lock left behind by a crashed instance is removed and reclaimed. A lock file that cannot be read or written at all is reported on stderr instead of being mistaken for a running @@ -251,8 +252,9 @@ omasnap --clipboard The clipboard must offer readable image data. Text-only clipboard contents return an error instead of opening an empty editor. -File URLs are accepted too. A saved capture notification's "Click to edit" action launches -`omasnap` on the finished screenshot, so it can be reopened and re-annotated. +File URLs are accepted too. A saved capture notification's "Click to show in folder" +action reveals the finished screenshot in Files. To reopen it with editable layers, use +the Recent captures shelf. ### Recent captures @@ -387,11 +389,16 @@ without reaching for the pointer. | `Ctrl+Shift+Z`, `Ctrl+Y` | Redo | | `Ctrl+C` | Copy PNG only | | `Ctrl+S` | Save PNG only | +| `Ctrl+Shift+S` | Save and reveal the selected file in Files | | `Enter` | Copy and save (with a text layer selected: edit it) | +| `Shift+Enter` | Copy, save, and reveal the selected file in Files | | `P` | Pin the capture on screen and close the editor | | `Esc` | Return to Select; press again to close | | Right-click | Return to Select; cancel active drawing | +Holding `Shift` while clicking the toolbar's Save or Copy+Save button also +reveals the finished file in Files. + ### Pinned captures `P` renders the current capture, writes it to a `pin---.png` under diff --git a/docs/dependencies.md b/docs/dependencies.md index 446300f7..318e8eff 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -37,6 +37,7 @@ no user-visible benefit. | `wl-copy` / `wl-paste` | Writing PNG/text to the Wayland clipboard, and verifying the write | Yes | | `tesseract` | OCR text recognition | Only if OCR is used; missing tesseract fails just that action | | `omarchy-notification-send` | Capture-finished notifications | No — falls back silently if absent (checked with `command -v` semantics via failed `QProcess::startDetached`) | +| `uwsm-app` / `nautilus` | Reveal a saved screenshot selected in Files | No — missing/failed reveal never invalidates a successful save | Each of these is invoked through the same small `runProcess`/ `QProcess::startDetached` helpers in `src/capture.cpp`, from a background diff --git a/src/capture.cpp b/src/capture.cpp index 8d43355d..cb3110ce 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -2024,9 +2024,29 @@ QString recognizeText(const QImage &image, QString &error) { return text; } -QString shellQuote(QString value) { - value.replace('\'', QStringLiteral("'\"'\"'")); - return QStringLiteral("'%1'").arg(value); +namespace { +struct RevealCommand { + QString program; + QStringList arguments; +}; + +QString absoluteLocalFileUrl(const QString &path) { + return QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()) + .toString(QUrl::FullyEncoded); +} + +RevealCommand revealFileCommand(const QString &path) { + return {QStringLiteral("uwsm-app"), + {QStringLiteral("--"), QStringLiteral("nautilus"), + QStringLiteral("--select"), absoluteLocalFileUrl(path)}}; +} +} // namespace + +bool revealFileInFolder(const QString &path) { + if (path.isEmpty()) + return false; + const RevealCommand command = revealFileCommand(path); + return QProcess::startDetached(command.program, command.arguments); } void sendCaptureNotification(const QString &message, const QString &imagePath) { @@ -2034,18 +2054,16 @@ void sendCaptureNotification(const QString &message, const QString &imagePath) { QStringLiteral("--app-name"), QStringLiteral("omasnap"), message}; if (!imagePath.isEmpty()) { - const QString imageUrl = - QUrl::fromLocalFile(imagePath).toString(QUrl::FullyEncoded); - QString omasnap = QDir(QCoreApplication::applicationDirPath()) - .filePath(QStringLiteral("omasnap")); - if (!QFileInfo::exists(omasnap)) - omasnap = QStringLiteral("omasnap"); - arguments << QStringLiteral("Click to edit") << QStringLiteral("--image") - << imagePath << QStringLiteral("--exec") - << QStringLiteral("%1 %2").arg(shellQuote(omasnap), - shellQuote(imageUrl)); - } - arguments << QStringLiteral("-t") << QStringLiteral("4500"); + const QString absoluteImagePath = QFileInfo(imagePath).absoluteFilePath(); + const RevealCommand reveal = revealFileCommand(absoluteImagePath); + arguments << QStringLiteral("Click to show in folder") + << QStringLiteral("--image") << absoluteImagePath + << QStringLiteral("-t") << QStringLiteral("4500") + << QStringLiteral("--exec") << reveal.program; + arguments << reveal.arguments; + } else { + arguments << QStringLiteral("-t") << QStringLiteral("4500"); + } QProcess::startDetached(QStringLiteral("omarchy-notification-send"), arguments); } diff --git a/src/capture.hpp b/src/capture.hpp index 108b0b4e..b3e41c09 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -328,7 +328,8 @@ void prunePinnedSnapshots(); [[nodiscard]] bool saveTemporarySnapshot(const QImage &image, QString path, QString &error, int quality = -1); [[nodiscard]] QString recognizeText(const QImage &image, QString &error); -/** Quotes a string for a shell argument passed to omarchy-notification-send. */ -[[nodiscard]] QString shellQuote(QString value); +/** Opens the file manager with `path` selected. Best-effort: save succeeds + * even when the desktop launcher is unavailable. */ +[[nodiscard]] bool revealFileInFolder(const QString &path); void sendCaptureNotification(const QString &message, const QString &imagePath = {}); diff --git a/src/editor.cpp b/src/editor.cpp index 9c85413f..41c0d788 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -2230,8 +2230,10 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, add(36, QStringLiteral("pin"), {}, QStringLiteral("Pin on screen · P · Ctrl+C on the pin copies it")); add(36, QStringLiteral("copy"), {}, QStringLiteral("Copy only · Ctrl+C")); - add(40, QStringLiteral("both"), {}, QStringLiteral("Copy and save · Enter")); - add(36, QStringLiteral("save"), {}, QStringLiteral("Save only · Ctrl+S")); + add(40, QStringLiteral("both"), {}, + QStringLiteral("Copy and save · Enter · Shift reveals")); + add(36, QStringLiteral("save"), {}, + QStringLiteral("Save only · Ctrl+S · Shift reveals")); add(36, QStringLiteral("close"), {}, QStringLiteral("Close · Esc twice")); if (includeSubmenus && shapeMenuOpen_) { @@ -3349,7 +3351,7 @@ void CaptureEditor::paintOcrOverlay(QPainter &painter, const QRectF &image, painter.restore(); } -void CaptureEditor::finish(OutputMode mode) { +void CaptureEditor::finish(OutputMode mode, bool reveal) { if (busy_ || selection_.isEmpty()) return; busy_ = true; @@ -3368,47 +3370,50 @@ void CaptureEditor::finish(OutputMode mode) { const QImage backdrop = customBackdrop_; const QString appSlug = appFilenameSlug(dominantAppClass(capture_.windows, selection_)); - finishWatcher_.setFuture(QtConcurrent::run([captureCopy, selection, - annotations, background, - imageShadow, canvasBoundary, - backdrop, appSlug, mode]() { - FinishResult result; - result.mode = mode; - const QImage image = renderCapture(captureCopy, selection, annotations, - background, imageShadow, - canvasBoundary, backdrop); - if (!image.isNull()) - result.thumbnail = image.scaled(kRecentThumbEdge, kRecentThumbEdge, - Qt::KeepAspectRatio, - Qt::SmoothTransformation); - const QString exportPath = temporaryExportPath(); - QString error; - if (image.isNull() || exportPath.isEmpty() || - !saveTemporarySnapshot(image, exportPath, error, -1)) { - result.error = error.isEmpty() - ? QStringLiteral("Could not prepare screenshot snapshot") - : error; - return result; - } - if (mode == OutputMode::Copy || mode == OutputMode::Both) { - if (!copyPngFileToClipboard(exportPath, error)) { - QFile::remove(exportPath); - result.error = error; - return result; - } - } - if (mode == OutputMode::Save || mode == OutputMode::Both) { - result.saved = moveSnapshotToScreenshots(exportPath, error, appSlug); - if (result.saved.isEmpty()) { - QFile::remove(exportPath); - result.error = error; + finishWatcher_.setFuture(QtConcurrent::run( + [captureCopy, selection, annotations, background, imageShadow, + canvasBoundary, backdrop, appSlug, mode, reveal]() { + FinishResult result; + result.mode = mode; + const QImage image = + renderCapture(captureCopy, selection, annotations, background, + imageShadow, canvasBoundary, backdrop); + if (!image.isNull()) + result.thumbnail = + image.scaled(kRecentThumbEdge, kRecentThumbEdge, + Qt::KeepAspectRatio, Qt::SmoothTransformation); + const QString exportPath = temporaryExportPath(); + QString error; + if (image.isNull() || exportPath.isEmpty() || + !saveTemporarySnapshot(image, exportPath, error, -1)) { + result.error = + error.isEmpty() + ? QStringLiteral("Could not prepare screenshot snapshot") + : error; + return result; + } + if (mode == OutputMode::Copy || mode == OutputMode::Both) { + if (!copyPngFileToClipboard(exportPath, error)) { + QFile::remove(exportPath); + result.error = error; + return result; + } + } + if (mode == OutputMode::Save || mode == OutputMode::Both) { + result.saved = moveSnapshotToScreenshots(exportPath, error, appSlug); + if (result.saved.isEmpty()) { + QFile::remove(exportPath); + result.error = error; + return result; + } + if (reveal && !revealFileInFolder(result.saved)) + qWarning().noquote() + << QStringLiteral("Could not reveal saved screenshot"); + } else { + QFile::remove(exportPath); + } return result; - } - } else { - QFile::remove(exportPath); - } - return result; - })); + })); } void CaptureEditor::completeFinish(const FinishResult &result) { @@ -3452,7 +3457,7 @@ void CaptureEditor::completeFinish(const FinishResult &result) { close(); } -void CaptureEditor::handleToolbar(const QString &action) { +void CaptureEditor::handleToolbar(const QString &action, bool reveal) { const Tool toolBefore = tool_; const QString statusBefore = status_; if (action == QStringLiteral("tool-select")) @@ -3554,9 +3559,9 @@ void CaptureEditor::handleToolbar(const QString &action) { else if (action == QStringLiteral("copy")) finish(OutputMode::Copy); else if (action == QStringLiteral("both")) - finish(OutputMode::Both); + finish(OutputMode::Both, reveal); else if (action == QStringLiteral("save")) - finish(OutputMode::Save); + finish(OutputMode::Save, reveal); else if (action == QStringLiteral("close")) close(); if (tool_ != toolBefore && status_ == statusBefore) @@ -3736,13 +3741,17 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { } else if (event->matches(QKeySequence::Copy)) { finish(OutputMode::Copy); return; - } else if (event->matches(QKeySequence::Save)) { - finish(OutputMode::Save); + } else if (event->matches(QKeySequence::Save) || + (event->key() == Qt::Key_S && + event->modifiers() == + (Qt::ControlModifier | Qt::ShiftModifier))) { + finish(OutputMode::Save, event->modifiers().testFlag(Qt::ShiftModifier)); return; } else if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { // Enter on a selected label reopens it for editing; anywhere else it // finishes the capture. - if (selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && + if (!event->modifiers().testFlag(Qt::ShiftModifier) && + selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && selectedAnnotations_.size() <= 1 && annotations_.at(selectedAnnotation_).kind == Annotation::Kind::Text && !dragging_ && !textEditing()) { @@ -3751,16 +3760,16 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { update(); return; } - finish(OutputMode::Both); + finish(OutputMode::Both, event->modifiers().testFlag(Qt::ShiftModifier)); return; } else if (event->key() == Qt::Key_D && event->modifiers() == Qt::AltModifier) { duplicateSelectedAnnotation(); } else if (const QPointF nudge = arrowKeyDelta( - event->key(), heldModifiers(event->modifiers()) - .testFlag(Qt::ShiftModifier) - ? kNudgeStepShift - : kNudgeStep); + event->key(), + heldModifiers(event->modifiers()).testFlag(Qt::ShiftModifier) + ? kNudgeStepShift + : kNudgeStep); !nudge.isNull() && !heldModifiers(event->modifiers()) .testAnyFlags(Qt::ControlModifier | Qt::AltModifier | @@ -3771,9 +3780,8 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { nudgeSelectedAnnotation(nudge); } else if (viewZoom_ > 1.0 && selectedAnnotation_ < 0 && !dragging_ && !textEditing() && - !event->modifiers().testAnyFlags(Qt::ControlModifier | - Qt::AltModifier | - Qt::MetaModifier) && + !event->modifiers().testAnyFlags( + Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier) && (event->key() == Qt::Key_Left || event->key() == Qt::Key_Right || event->key() == Qt::Key_Up || event->key() == Qt::Key_Down)) { // With nothing selected the arrows have nothing else to do, so they walk @@ -4359,7 +4367,9 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { for (const ToolbarButton &button : toolbarButtons()) { if (button.rect.contains(cursor_)) { - handleToolbar(button.action); + handleToolbar( + button.action, + heldModifiers(event->modifiers()).testFlag(Qt::ShiftModifier)); return; } } @@ -5838,9 +5848,9 @@ void CaptureEditor::paintEdit(QPainter &painter) { {QStringLiteral("B / P"), QStringLiteral("Backdrop / Pin on screen")}, {QStringLiteral("Ctrl+Z"), QStringLiteral("Undo")}, {QStringLiteral("Ctrl+Shift+Z"), QStringLiteral("Redo")}, - {QStringLiteral("Enter"), QStringLiteral("Copy + save")}, + {QStringLiteral("Enter"), QStringLiteral("Copy + save · Shift reveals")}, {QStringLiteral("Ctrl+C"), QStringLiteral("Copy only")}, - {QStringLiteral("Ctrl+S"), QStringLiteral("Save only")}, + {QStringLiteral("Ctrl+S"), QStringLiteral("Save only · Shift reveals")}, {QStringLiteral("Esc"), QStringLiteral("Arrow / twice close")}}); // When zoomed past fit the image is larger than the viewport; clip content // to the band between the toolbar and the status so it cannot overdraw them. diff --git a/src/editor.hpp b/src/editor.hpp index 4941f7d6..1f818365 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -545,10 +545,10 @@ class CaptureEditor final : public QWidget { void replayLog(); void redoEdit(); void selectWindowInDirection(int key); - void finish(OutputMode mode); + void finish(OutputMode mode, bool reveal = false); void completeFinish(const FinishResult &result); void handleEscape(); - void handleToolbar(const QString &action); + void handleToolbar(const QString &action, bool reveal); void paintEdit(QPainter &painter); void paintSelect(QPainter &painter); void refreshBackdropCache(); diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 9e6dd015..ff870b82 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -2917,6 +2917,296 @@ bool runAsyncCaptureRegionSmoke(QApplication &application, QString &error) { return true; } +enum class RevealGesture { + SaveShortcut, + BothShortcut, + SaveClick, + BothClick, + PlainSave +}; + +struct RevealExpectation { + RevealGesture gesture; + QString label; + bool copied; + bool revealed; +}; + +struct RevealSmokeFiles { + QString screenshots; + QString revealLog; + QString clipboardPng; +}; + +bool waitForSmokeFile(QApplication &application, const QString &path) { + QElapsedTimer timer; + timer.start(); + while (!QFile::exists(path) && timer.elapsed() < 3000) { + application.processEvents(); + QThread::msleep(1); + } + return QFile::exists(path); +} + +QStringList readArgumentLog(const QString &path) { + QFile file(path); + return file.open(QIODevice::ReadOnly) + ? QString::fromUtf8(file.readAll()).split('\n', Qt::SkipEmptyParts) + : QStringList{}; +} + +void restoreEnvironmentVariable(const char *name, const QByteArray &value) { + if (value.isEmpty()) + qunsetenv(name); + else + qputenv(name, value); +} + +bool runRevealGestureSmoke(QApplication &application, + const CaptureData &capture, + const RevealExpectation &expectation, + const RevealSmokeFiles &files, + const QString ¬ificationLog, QString &savedPath, + QString &error) { + QDir(files.screenshots).removeRecursively(); + QFile::remove(files.revealLog); + QFile::remove(files.clipboardPng); + QFile::remove(notificationLog); + qputenv("OMASNAP_TEST_NOTIFICATION_LOG", notificationLog.toUtf8()); + + CaptureEditor editor(capture, CaptureEditor::CaptureMode::File); + editor.setSuppressSnapshots(true); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + if (expectation.gesture == RevealGesture::BothShortcut) { + QTest::keyClick(&editor, Qt::Key_T); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + editor.toScreenPointForTest({80, 80}).toPoint()); + QWidget *inlineEditor = QApplication::focusWidget(); + if (!inlineEditor) { + error = QStringLiteral( + "Could not open a label before testing selected Shift+Enter"); + return false; + } + QTest::keyClicks(inlineEditor, QStringLiteral("Selected label")); + QTest::keyClick(inlineEditor, Qt::Key_Escape); + application.processEvents(); + if (editor.annotationCountForTest() != 1 || + editor.selectedCountForTest() != 1) { + error = QStringLiteral( + "Could not select a committed label before Shift+Enter"); + return false; + } + } + switch (expectation.gesture) { + case RevealGesture::SaveShortcut: + QTest::keyClick(&editor, Qt::Key_S, + Qt::ControlModifier | Qt::ShiftModifier); + break; + case RevealGesture::BothShortcut: + QTest::keyClick(&editor, Qt::Key_Return, Qt::ShiftModifier); + break; + case RevealGesture::SaveClick: + case RevealGesture::BothClick: + QTest::keyPress(&editor, Qt::Key_Shift); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::ShiftModifier, + editor.toolbarButtonCenterForTest( + expectation.gesture == RevealGesture::SaveClick + ? QStringLiteral("save") + : QStringLiteral("both"))); + QTest::keyRelease(&editor, Qt::Key_Shift); + break; + case RevealGesture::PlainSave: + QTest::keyClick(&editor, Qt::Key_S, Qt::ControlModifier); + break; + } + editor.waitForExport(); + static_cast(waitForSmokeFile(application, notificationLog)); + if (expectation.revealed) + static_cast(waitForSmokeFile(application, files.revealLog)); + else { + QThread::msleep(50); + application.processEvents(); + } + + const QStringList saved = + QDir(files.screenshots) + .entryList({QStringLiteral("*.png")}, QDir::Files); + const bool copied = QFile::exists(files.clipboardPng); + const bool revealed = QFile::exists(files.revealLog); + if (editor.isVisible() || saved.size() != 1 || copied != expectation.copied || + revealed != expectation.revealed) { + error = QStringLiteral("%1 failed (saved=%2, copied=%3, revealed=%4)") + .arg(expectation.label) + .arg(saved.size()) + .arg(copied) + .arg(revealed); + return false; + } + + savedPath = QDir(files.screenshots).filePath(saved.constFirst()); + if (!revealed) + return true; + const QStringList expected{ + QStringLiteral("--"), QStringLiteral("nautilus"), + QStringLiteral("--select"), + QUrl::fromLocalFile(QFileInfo(savedPath).absoluteFilePath()) + .toString(QUrl::FullyEncoded)}; + const QStringList actual = readArgumentLog(files.revealLog); + if (actual == expected) + return true; + error = QStringLiteral("%1 reveal command was %2, expected %3") + .arg(expectation.label, actual.join(QStringLiteral(" | ")), + expected.join(QStringLiteral(" | "))); + return false; +} + +bool runRevealSavedScreenshotSmoke(QApplication &application, QString &error) { + QTemporaryDir root; + if (!root.isValid()) { + error = + QStringLiteral("Could not create reveal-screenshot smoke directory"); + return false; + } + + const auto writeExecutable = [](const QString &path, + const QByteArray &contents) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly) || + file.write(contents) != contents.size()) + return false; + file.close(); + return QFile::setPermissions(path, QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner); + }; + const QString commands = QDir(root.path()).filePath(QStringLiteral("bin")); + if (!QDir().mkpath(commands)) { + error = + QStringLiteral("Could not create reveal-screenshot command directory"); + return false; + } + const QByteArray revealScript = QByteArrayLiteral( + "#!/bin/sh\n" + "tmp=\"${OMASNAP_TEST_REVEAL_LOG}.tmp.$$\"\n" + "printf '%s\\n' \"$@\" > \"$tmp\"\n" + "mv \"$tmp\" \"$OMASNAP_TEST_REVEAL_LOG\"\n"); + const QByteArray notificationScript = QByteArrayLiteral( + "#!/bin/sh\n" + "tmp=\"${OMASNAP_TEST_NOTIFICATION_LOG}.tmp.$$\"\n" + "printf '%s\\n' \"$@\" > \"$tmp\"\n" + "mv \"$tmp\" \"$OMASNAP_TEST_NOTIFICATION_LOG\"\n"); + const QByteArray copyScript = + QByteArrayLiteral("#!/bin/sh\ncat > \"$OMASNAP_TEST_CLIPBOARD_PNG\"\n"); + const QByteArray pasteScript = + QByteArrayLiteral("#!/bin/sh\ncat \"$OMASNAP_TEST_CLIPBOARD_PNG\"\n"); + if (!writeExecutable(QDir(commands).filePath(QStringLiteral("uwsm-app")), + revealScript) || + !writeExecutable( + QDir(commands).filePath(QStringLiteral("omarchy-notification-send")), + notificationScript) || + !writeExecutable(QDir(commands).filePath(QStringLiteral("wl-copy")), + copyScript) || + !writeExecutable(QDir(commands).filePath(QStringLiteral("wl-paste")), + pasteScript)) { + error = QStringLiteral("Could not create reveal-screenshot commands"); + return false; + } + + const QByteArray previousPath = qgetenv("PATH"); + const QByteArray previousScreenshotDir = qgetenv("OMASNAP_SCREENSHOT_DIR"); + const QByteArray previousRevealLog = qgetenv("OMASNAP_TEST_REVEAL_LOG"); + const QByteArray previousClipboard = qgetenv("OMASNAP_TEST_CLIPBOARD_PNG"); + const QByteArray previousNotificationLog = + qgetenv("OMASNAP_TEST_NOTIFICATION_LOG"); + const QString previousCurrentPath = QDir::currentPath(); + const auto restoreEnvironment = qScopeGuard([&] { + QDir::setCurrent(previousCurrentPath); + qputenv("PATH", previousPath); + restoreEnvironmentVariable("OMASNAP_SCREENSHOT_DIR", previousScreenshotDir); + restoreEnvironmentVariable("OMASNAP_TEST_REVEAL_LOG", previousRevealLog); + restoreEnvironmentVariable("OMASNAP_TEST_CLIPBOARD_PNG", previousClipboard); + restoreEnvironmentVariable("OMASNAP_TEST_NOTIFICATION_LOG", + previousNotificationLog); + }); + + if (!QDir::setCurrent(root.path())) { + error = QStringLiteral("Could not enter reveal-screenshot smoke directory"); + return false; + } + const RevealSmokeFiles files{ + QStringLiteral("relative-screenshots"), + QDir(root.path()).filePath(QStringLiteral("reveal-arguments")), + QDir(root.path()).filePath(QStringLiteral("clipboard.png"))}; + qputenv("PATH", commands.toUtf8() + ':' + previousPath); + qputenv("OMASNAP_SCREENSHOT_DIR", files.screenshots.toUtf8()); + qputenv("OMASNAP_TEST_REVEAL_LOG", files.revealLog.toUtf8()); + qputenv("OMASNAP_TEST_CLIPBOARD_PNG", files.clipboardPng.toUtf8()); + + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.geometry = {0, 0, 320, 240}; + capture.monitor.pixelSize = {320, 240}; + capture.monitor.scale = 1.0; + capture.source = QImage(320, 240, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(QColor(QStringLiteral("#183048"))); + capture.previewSize = capture.source.size(); + + const std::array expectations{ + RevealExpectation{RevealGesture::SaveShortcut, + QStringLiteral("Ctrl+Shift+S"), false, true}, + RevealExpectation{RevealGesture::BothShortcut, + QStringLiteral("Shift+Enter with selected text"), true, + true}, + RevealExpectation{RevealGesture::SaveClick, + QStringLiteral("Shift-click Save"), false, true}, + RevealExpectation{RevealGesture::BothClick, + QStringLiteral("Shift-click Copy+Save"), true, true}, + RevealExpectation{RevealGesture::PlainSave, QStringLiteral("Ctrl+S"), + false, false}}; + QString savedPath; + for (std::size_t index = 0; index < expectations.size(); ++index) { + const QString notificationLog = + QDir(root.path()) + .filePath(QStringLiteral("notification-%1").arg(index)); + if (!runRevealGestureSmoke(application, capture, expectations.at(index), + files, notificationLog, savedPath, error)) + return false; + } + + const QString notificationActionLog = + QDir(root.path()).filePath(QStringLiteral("notification-action")); + qputenv("OMASNAP_TEST_NOTIFICATION_LOG", notificationActionLog.toUtf8()); + sendCaptureNotification(QStringLiteral("Screenshot saved"), savedPath); + if (!waitForSmokeFile(application, notificationActionLog)) { + error = QStringLiteral("Saved notification did not launch"); + return false; + } + const QStringList notificationActual = readArgumentLog(notificationActionLog); + const QString savedUrl = + QUrl::fromLocalFile(QFileInfo(savedPath).absoluteFilePath()) + .toString(QUrl::FullyEncoded); + const int execIndex = notificationActual.indexOf(QStringLiteral("--exec")); + const QStringList notificationExec = + execIndex < 0 ? QStringList{} : notificationActual.sliced(execIndex + 1); + const int imageIndex = notificationActual.indexOf(QStringLiteral("--image")); + const QString notificationImage = + imageIndex < 0 || imageIndex + 1 >= notificationActual.size() + ? QString() + : notificationActual.at(imageIndex + 1); + const QStringList expectedExec{ + QStringLiteral("uwsm-app"), QStringLiteral("--"), + QStringLiteral("nautilus"), QStringLiteral("--select"), savedUrl}; + if (notificationActual.contains(QStringLiteral("Click to show in folder")) && + notificationImage == QFileInfo(savedPath).absoluteFilePath() && + notificationExec == expectedExec) + return true; + error = QStringLiteral("Saved notification did not offer reveal: %1") + .arg(notificationActual.join(QStringLiteral(" | "))); + return false; +} + /** Checks that the wheel retargets the spotlight under the cursor. */ bool runSpotlightWheelSmoke(QApplication &application, QString &error) { CaptureData capture; @@ -3410,26 +3700,6 @@ bool runRecentsShelfSmoke(QApplication &application, QString &error) { return true; } -/** Quotes the same way sendCaptureNotification builds --exec. */ -bool runShellQuoteCheck(QString &error) { - if (shellQuote(QStringLiteral("omasnap")) != QStringLiteral("'omasnap'")) { - error = QStringLiteral("shellQuote did not wrap a simple token"); - return false; - } - if (shellQuote(QStringLiteral("omasnap /tmp/a.png")) != - QStringLiteral("'omasnap /tmp/a.png'")) { - error = QStringLiteral("shellQuote did not keep spaces inside quotes"); - return false; - } - if (shellQuote(QStringLiteral("it's")) != QStringLiteral("'it'\"'\"'s'")) { - error = QStringLiteral("shellQuote did not escape a single quote (%1)") - .arg(shellQuote(QStringLiteral("it's"))); - return false; - } - sendCaptureNotification(QStringLiteral("smoke")); - return true; -} - /** * Filling the 100-op cap used to drop the leading Crop, so replay started at * the full monitor while later annotations stayed in cropped space. @@ -7640,6 +7910,10 @@ int main(int argc, char **argv) { qWarning().noquote() << snapshotError; return 82; } + if (!runRevealSavedScreenshotSmoke(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 135; + } if (!runPinLayoutSmoke(snapshotError)) { qWarning().noquote() << snapshotError; return 77; @@ -7861,10 +8135,6 @@ int main(int argc, char **argv) { qWarning().noquote() << snapshotError; return 120; } - if (!runShellQuoteCheck(snapshotError)) { - qWarning().noquote() << snapshotError; - return 83; - } if (!runOpLogCapKeepsLeadingCrop(application, snapshotError)) { qWarning().noquote() << snapshotError; return 84;