Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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-<pid>-<n>-<random>.png` under
Expand Down
1 change: 1 addition & 0 deletions docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 33 additions & 15 deletions src/capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2024,28 +2024,46 @@ 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) {
QStringList arguments{QStringLiteral("-g"), QStringLiteral(""),
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);
}
Expand Down
5 changes: 3 additions & 2 deletions src/capture.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {});
130 changes: 70 additions & 60 deletions src/editor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2230,8 +2230,10 @@ CaptureEditor::toolbarButtons(QVector<qreal> *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_) {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()) {
Expand All @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/editor.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading