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
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ 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.
- Pin a finished capture as a bottom-right always-on-top layer surface, launched
- Pin a finished capture as a bottom-right floating compositor window, launched
from the same `omasnap` executable and visible on every workspace.
- Crash-resistant working documents under `/run/user/<UID>/omasnap/` (falling back to
a private `/tmp/omasnap-<UID>/`): the original source image plus a sidecar JSON
Expand Down Expand Up @@ -84,6 +84,8 @@ The installer uses Omarchy's package helper for missing dependencies, builds in
`~/.cache/omasnap`, and installs under `~/.local`. It does not modify
Hyprland configuration.

Pinned-window placement uses the Lua dispatcher on Omarchy’s Hyprland.

### Hyprland binding

Paste this into a Lua config loaded after `require("default.hypr.omarchy")`:
Expand Down Expand Up @@ -396,11 +398,19 @@ without reaching for the pointer.

`P` renders the current capture, writes it to a `pin-<pid>-<n>-<random>.png` under
the runtime snapshot directory, and launches the same `omasnap` executable in
detached pin mode. Active pins stack from the bottom-right and can be dragged
by the image background. The layer stays visible on every workspace without
compositor window rules. It preserves the image
aspect ratio, with a maximum width of one third of the screen and a maximum height of one
half.
detached pin mode. Hyprland floats and pins each window on every workspace;
its border and shadow come from the compositor. Pins pack upward from the focused
monitor's bottom-right corner, then into further columns. Placement accounts for
monitor origins, scaling and rotation, and reserves each new target while the
compositor animates it. If no on-screen slot fits, automatic packing leaves the
window where the compositor placed it.

The preview is 200 logical pixels wide with the display's aspect ratio (height
clamped to 50–400 pixels). It fills that frame with a top-anchored cover crop;
copy, edit and drag-out still use the complete full-resolution image. Drag the
image background to move a pin; dragging over the stack opens an insertion gap,
and releasing snaps it into that gap. Moving or closing a stacked pin packs the
remaining column down.

Pinning neither touches the clipboard nor writes to the screenshot directory; it is a
fourth output alongside copy, save, and copy-and-save. `P` closes the editor and releases
Expand All @@ -415,12 +425,12 @@ Hover the pin to reveal its controls:
| Link button | Copy the source file path |
| Copy button, `Ctrl+C` | Copy the full-resolution PNG |
| Double-wide top-left drag handle | Drag the PNG into a file-capable drop target |
| Wheel | Resize within the screen caps, preserving aspect ratio |
| Wheel | Keep the fixed preview size |
| Close button, `Esc`, middle-click | Close |

Image and path copying use `wl-copy` rather than `QClipboard`, so clipboard data remains
available after the pin is closed. No font-based symbol set or compositor-specific window
rule is required; the controls use the same vector icon renderer as the annotation toolbar.
available after the pin is closed. Hyprland placement uses runtime dispatches and
requires no user window rules. The controls use the annotation toolbar’s vector icons.

Canvas boundary changes affect only preview and export clipping. The complete vector
geometry stays in the operation log, so switching back to Grow restores every off-canvas
Expand Down
4 changes: 2 additions & 2 deletions docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ From `CMakeLists.txt`, this is the entire list:
| Dependency | What it's for |
|---|---|
| **Qt6** (Concurrent, Core, Gui, Test, Widgets) 6.8+ | Everything: windowing, painting, the editor UI, the worker-pool threading model ([threading.md](threading.md)), the test harness |
| **LayerShellQt** | Layer-shell surfaces (the capture overlay, the editor, pinned captures) |
| **LayerShellQt** | Layer-shell surfaces (the capture overlay and editor) |
| **wayland-client** (pkg-config) | Raw protocol client code (`ext-image-copy-capture`, `zwlr_virtual_pointer_v1`) that LayerShellQt/QtWayland don't expose |
| **wayland-scanner** + protocol XML | Generates the C bindings for the above at build time; not a runtime dependency |

Expand All @@ -33,7 +33,7 @@ no user-visible benefit.

| Process | Used for | Required? |
|---|---|---|
| `hyprctl` | Monitor/window discovery (`-j` JSON), natural-scroll policy query | Yes — see [platform-scope.md](platform-scope.md) |
| `hyprctl` | Monitor/window discovery (`-j` JSON), floating pin placement, natural-scroll policy query | Yes — see [platform-scope.md](platform-scope.md) |
| `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`) |
Expand Down
11 changes: 10 additions & 1 deletion docs/threading.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ reading its corresponding worker:
| `ocrWatcher_` | Renders the OCR crop and runs `tesseract` |
| `finishWatcher_` | Renders the export, encodes PNG, does the clipboard round trip, moves the file |
| `snapshotWatcher_` | Writes the crash-recovery working snapshot + operation log |
| `pinWatcher_` | Renders the image for a pinned layer surface |
| `pinWatcher_` | Renders the image for a pinned compositor window |
| `recentsWatcher_` | Lists and decodes thumbnails for the recents shelf |
| `backdropWatcher_` | Decodes an optional user-supplied backdrop image |
| `highlighterProbeWatcher_` | Detects a nearby screenshot text row for highlighter Snap mode |
Expand Down Expand Up @@ -130,3 +130,12 @@ earlier in the same call.
See also [editing-model.md](editing-model.md) for what state a background
render is allowed to read, and [dependencies.md](dependencies.md) for the
processes (`tesseract`, `wl-copy`/`wl-paste`, `hyprctl`) these workers spawn.

## Floating pins

Pin placement, compositor polling, and move dispatches run on a single worker
per pin process. The GUI applies completed geometry snapshots through a watcher;
it never waits for `hyprctl` during a drag. A runtime lock serializes placement
across pin processes, with short-lived target reservations covering compositor
animation latency. The initial monitor query uses the same worker pool; a fallback frame maps
immediately and adopts the display-shaped size when the query finishes.
87 changes: 87 additions & 0 deletions src/cli-path.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/** @fileoverview Resolves local image targets accepted by the command line. */
#include "cli-path.hpp"

#include <QCommandLineParser>
#include <QCommandLineOption>

#include <QFileInfo>
#include <QUrl>

Expand All @@ -22,3 +25,87 @@ QString resolveLocalImagePath(const QString &target) {
const QFileInfo file(path);
return file.isFile() ? file.absoluteFilePath() : QString{};
}

void configureCaptureCommandLine(QCommandLineParser &parser, bool beforeQt) {
// QApplication consumes these before the normal parse. Recognize them in
// the early shell-role parse too, without exposing or applying them here.
if (beforeQt) {
parser.setSingleDashWordOptionMode(QCommandLineParser::ParseAsLongOptions);
for (const char *name : {"platform", "platformpluginpath", "platformtheme",
"plugin", "qmljsdebugger", "qwindowgeometry",
"qwindowicon", "qwindowtitle", "session",
"style", "stylesheet"}) {
QCommandLineOption option(QString::fromLatin1(name), QString(),
QStringLiteral("value"));
option.setFlags(QCommandLineOption::HiddenFromHelp);
parser.addOption(option);
}
for (const char *name : {"reverse", "widgetcount"}) {
QCommandLineOption option(QString::fromLatin1(name));
option.setFlags(QCommandLineOption::HiddenFromHelp);
parser.addOption(option);
}
}
parser.setApplicationDescription(QStringLiteral(
"Native Wayland screenshot and annotation overlay for Hyprland and "
"Omarchy.\n"
"\n"
"Only one capture overlay runs at a time. Starting omasnap again while "
"an\noverlay is open dismisses it: the running instance is asked to "
"quit and the\nnew process exits without capturing, so the same hotkey "
"opens and closes the\noverlay. Quick output (--copy, --save) dismisses "
"it the same way instead of\nscreenshotting the overlay. With --file (or "
"an image path) or --clipboard, the running\ninstance is stopped and "
"the editor opens on that image instead.\n"
"\n"
"Exit codes: 0 success, including dismissing a running overlay; 1 "
"capture,\nimage, or single-instance lock failure; 2 usage error."));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption fullscreenOption(
QStringLiteral("capture-fullscreen"),
QStringLiteral("Start with the entire focused monitor selected."));
const QCommandLineOption windowOption(
{QStringLiteral("capture-window"), QStringLiteral("capture-windows")},
QStringLiteral("Start in window selection mode."));
const QCommandLineOption regionOption(
QStringLiteral("capture-region"),
QStringLiteral("Start in freeform region selection mode (default)."));
parser.addOption(fullscreenOption);
parser.addOption(windowOption);
parser.addOption(regionOption);
const QCommandLineOption copyOption(
QStringLiteral("copy"),
QStringLiteral("Copy the capture directly without opening the editor."));
const QCommandLineOption saveOption(
QStringLiteral("save"),
QStringLiteral("Save the capture directly without opening the editor."));
parser.addOption(copyOption);
parser.addOption(saveOption);
const QCommandLineOption fileOption(
QStringLiteral("file"),
QStringLiteral("Open an existing image file in the annotation editor "
"instead of capturing the screen."),
QStringLiteral("path"));
parser.addOption(fileOption);
const QCommandLineOption clipboardOption(
QStringLiteral("clipboard"),
QStringLiteral("Open the current clipboard image in the annotation "
"editor instead of capturing the screen."));
parser.addOption(clipboardOption);
const QCommandLineOption pinOption(
QStringLiteral("pin"),
QStringLiteral("Show an image as a floating window pinned on every workspace."),
QStringLiteral("path"));
parser.addOption(pinOption);
const QCommandLineOption scrollOption(
QStringLiteral("scroll"),
QStringLiteral("Capture a scrolling region and stitch it into one tall "
"image, then open it in the editor."));
parser.addOption(scrollOption);
parser.addPositionalArgument(
QStringLiteral("target"),
QStringLiteral("Capture mode (smart, region, windows, fullscreen) or the "
"path of an image file to edit."),
QStringLiteral("[target]"));
}
4 changes: 4 additions & 0 deletions src/cli-path.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@

/** Returns an existing local path for a raw path or local file URL. */
QString resolveLocalImagePath(const QString &target);

class QCommandLineParser;
/** Shared by the pre-QApplication shell choice and normal CLI validation. */
void configureCaptureCommandLine(QCommandLineParser &parser, bool beforeQt = false);
109 changes: 32 additions & 77 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,23 @@ int main(int argc, char **argv) {
QCoreApplication::setApplicationName(QStringLiteral("omasnap"));
QCoreApplication::setApplicationVersion(QString::fromLatin1(OMASNAP_VERSION));
QCoreApplication::setOrganizationName(QStringLiteral("Omarchy"));
qputenv("QT_WAYLAND_SHELL_INTEGRATION", "layer-shell");
// The overlay is a layer surface, but a pin is an ordinary compositor
// window the compositor floats and places; forcing layer-shell on the
// whole process would map the pin as a fullscreen overlay instead.
QStringList rawArguments;
for (int index = 0; index < argc; ++index)
rawArguments.push_back(QString::fromLocal8Bit(argv[index]));
QCommandLineParser startupParser;
configureCaptureCommandLine(startupParser, true);
const bool pinInvocation = startupParser.parse(rawArguments) &&
startupParser.isSet(QStringLiteral("pin"));
Comment on lines +111 to +112
if (pinInvocation) {
// Unset rather than merely not set: a pin spawned from the editor
// inherits the editor's environment, layer-shell included.
qunsetenv("QT_WAYLAND_SHELL_INTEGRATION");
} else {
qputenv("QT_WAYLAND_SHELL_INTEGRATION", "layer-shell");
}
// Omarchy exports QT_QPA_PLATFORMTHEME=gtk3 session-wide. Honouring it
// loads the qgtk3 plugin, which initialises GTK inside this process
// (measured 81-112 ms of QApplication construction, plus ~20-24 MiB of
Expand All @@ -126,104 +142,43 @@ int main(int argc, char **argv) {
PosixSignalNotifier signalNotifier(&application);

QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral(
"Native Wayland screenshot and annotation overlay for Hyprland and "
"Omarchy.\n"
"\n"
"Only one capture overlay runs at a time. Starting omasnap again while "
"an\noverlay is open dismisses it: the running instance is asked to "
"quit and the\nnew process exits without capturing, so the same hotkey "
"opens and closes the\noverlay. Quick output (--copy, --save) dismisses "
"it the same way instead of\nscreenshotting the overlay. With --file (or "
"an image path) or --clipboard, the running\ninstance is stopped and "
"the editor opens on that image instead.\n"
"\n"
"Exit codes: 0 success, including dismissing a running overlay; 1 "
"capture,\nimage, or single-instance lock failure; 2 usage error."));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption fullscreenOption(
QStringLiteral("capture-fullscreen"),
QStringLiteral("Start with the entire focused monitor selected."));
const QCommandLineOption windowOption(
{QStringLiteral("capture-window"), QStringLiteral("capture-windows")},
QStringLiteral("Start in window selection mode."));
const QCommandLineOption regionOption(
QStringLiteral("capture-region"),
QStringLiteral("Start in freeform region selection mode (default)."));
parser.addOption(fullscreenOption);
parser.addOption(windowOption);
parser.addOption(regionOption);
const QCommandLineOption copyOption(
QStringLiteral("copy"),
QStringLiteral("Copy the capture directly without opening the editor."));
const QCommandLineOption saveOption(
QStringLiteral("save"),
QStringLiteral("Save the capture directly without opening the editor."));
parser.addOption(copyOption);
parser.addOption(saveOption);
const QCommandLineOption fileOption(
QStringLiteral("file"),
QStringLiteral("Open an existing image file in the annotation editor "
"instead of capturing the screen."),
QStringLiteral("path"));
parser.addOption(fileOption);
const QCommandLineOption clipboardOption(
QStringLiteral("clipboard"),
QStringLiteral("Open the current clipboard image in the annotation "
"editor instead of capturing the screen."));
parser.addOption(clipboardOption);
const QCommandLineOption pinOption(
QStringLiteral("pin"),
QStringLiteral("Show an image as a pinned always-visible layer."),
QStringLiteral("path"));
parser.addOption(pinOption);
const QCommandLineOption scrollOption(
QStringLiteral("scroll"),
QStringLiteral("Capture a scrolling region and stitch it into one tall "
"image, then open it in the editor."));
parser.addOption(scrollOption);
parser.addPositionalArgument(
QStringLiteral("target"),
QStringLiteral("Capture mode (smart, region, windows, fullscreen) or the "
"path of an image file to edit."),
QStringLiteral("[target]"));
configureCaptureCommandLine(parser);
parser.process(application);
startupTimingMark("command line parsed");

QString filePath = parser.value(fileOption);
const bool clipboardInput = parser.isSet(clipboardOption);
QString filePath = parser.value(QStringLiteral("file"));
const bool clipboardInput = parser.isSet(QStringLiteral("clipboard"));

QuickOutputMode quickOutputMode = QuickOutputMode::None;
if (parser.isSet(copyOption) && parser.isSet(saveOption))
if (parser.isSet(QStringLiteral("copy")) && parser.isSet(QStringLiteral("save")))
quickOutputMode = QuickOutputMode::Both;
else if (parser.isSet(copyOption))
else if (parser.isSet(QStringLiteral("copy")))
quickOutputMode = QuickOutputMode::Copy;
else if (parser.isSet(saveOption))
else if (parser.isSet(QStringLiteral("save")))
quickOutputMode = QuickOutputMode::Save;

CaptureEditor::CaptureMode captureMode = CaptureEditor::CaptureMode::Region;
int requestedModes = parser.isSet(fullscreenOption) +
parser.isSet(windowOption) + parser.isSet(regionOption) +
parser.isSet(scrollOption);
if (parser.isSet(fullscreenOption))
int requestedModes = parser.isSet(QStringLiteral("capture-fullscreen")) +
parser.isSet(QStringLiteral("capture-window")) + parser.isSet(QStringLiteral("capture-region")) +
parser.isSet(QStringLiteral("scroll"));
if (parser.isSet(QStringLiteral("capture-fullscreen")))
captureMode = CaptureEditor::CaptureMode::Fullscreen;
else if (parser.isSet(windowOption))
else if (parser.isSet(QStringLiteral("capture-window")))
captureMode = CaptureEditor::CaptureMode::Window;
else if (parser.isSet(scrollOption))
else if (parser.isSet(QStringLiteral("scroll")))
captureMode = CaptureEditor::CaptureMode::Scroll;

const QStringList positional = parser.positionalArguments();
if (parser.isSet(pinOption)) {
if (parser.isSet(QStringLiteral("pin"))) {
if (!filePath.isEmpty() || clipboardInput || requestedModes > 0 ||
!positional.isEmpty() || quickOutputMode != QuickOutputMode::None) {
qCritical()
<< "Pinned mode cannot be combined with capture or edit targets";
return 2;
}
QString pinPath = QUrl(parser.value(pinOption)).toLocalFile();
QString pinPath = QUrl(parser.value(QStringLiteral("pin"))).toLocalFile();
if (pinPath.isEmpty())
pinPath = parser.value(pinOption);
pinPath = parser.value(QStringLiteral("pin"));
return runPinnedCapture(pinPath);
}
if (positional.size() > 1) {
Expand Down
32 changes: 0 additions & 32 deletions src/pin-file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,35 +45,3 @@ PinSnapshotFile::~PinSnapshotFile() {
bool PinSnapshotFile::isLocked() const { return fd_ >= 0; }

void PinSnapshotFile::preserveForEditor() { preserve_ = true; }

PinSlotLock::PinSlotLock() {
const QString runtime = secureRuntimeDirectory();
if (runtime.isEmpty())
return;
for (int candidate = 0; candidate < 1024; ++candidate) {
const QString path = QDir(runtime).filePath(
QStringLiteral(".pin-slot-%1.lock").arg(candidate));
const int fd = ::open(QFile::encodeName(path).constData(),
O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW,
S_IRUSR | S_IWUSR);
if (fd < 0)
continue;
if (::fchmod(fd, S_IRUSR | S_IWUSR) == 0 &&
::flock(fd, LOCK_EX | LOCK_NB) == 0) {
fd_ = fd;
index_ = candidate;
return;
}
::close(fd);
}
}

PinSlotLock::~PinSlotLock() {
if (fd_ < 0)
return;
::close(fd_);
}

bool PinSlotLock::isLocked() const { return fd_ >= 0; }

int PinSlotLock::index() const { return index_; }
Loading
Loading