From 2cc0a7d4da129089d0b2a96fbd5c4f088a5c79b6 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 23 Aug 2026 13:35:52 -0500 Subject: [PATCH 01/10] feat(pin): pinned shots are floating compositor windows A pin was an overlay layer surface, positioned by margins and moved by hand-tracking the pointer. It is now a normal frameless window that asks the compositor to float it, keep it on every workspace, and pack it snugly into the bottom-right column, one gap above whatever is already there. The compositor draws the frame and shadow, its own move gesture handles dragging, and the pin appears in window lists like anything else. Placement goes through dispatchers, not window rules, so nothing has to live in the user's config. On a Lua-configured Hyprland the dispatch argument is a Lua expression addressed at this pin's unique title (the classic grammar parses as Lua and fails while reporting success); sway gets the criteria grammar; any other compositor still shows a working pin and just decides the position itself. Positions are read back from the compositor's client list instead of lock files: a count cannot tell a crashed pin from a dragged-away one, and every pin on screen blocks the space it covers, whatever its shape, so a new pin never lands on one the user placed. When a pin closes or is dragged out of the column, the survivors pack back down to close the gap. --- src/main.cpp | 14 +- src/pin-file.cpp | 32 --- src/pin-file.hpp | 17 -- src/pin-layout.cpp | 101 +++++-- src/pin-layout.hpp | 37 ++- src/pin.cpp | 500 +++++++++++++++++++++++----------- src/pin.hpp | 2 +- tests/pin-layout-smoke.cpp | 91 ++++--- tests/pin-lifecycle-smoke.cpp | 18 -- 9 files changed, 517 insertions(+), 295 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index ea146887..0e049d3b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -100,7 +100,19 @@ 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. + bool pinInvocation = false; + for (int index = 1; index < argc; ++index) + pinInvocation = pinInvocation || qstrcmp(argv[index], "--pin") == 0; + 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 diff --git a/src/pin-file.cpp b/src/pin-file.cpp index 10985cae..13297db6 100644 --- a/src/pin-file.cpp +++ b/src/pin-file.cpp @@ -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_; } diff --git a/src/pin-file.hpp b/src/pin-file.hpp index b1482488..f5d99368 100644 --- a/src/pin-file.hpp +++ b/src/pin-file.hpp @@ -20,20 +20,3 @@ class PinSnapshotFile { int fd_ = -1; bool preserve_ = false; }; - -/** Holds the first free layout slot while a pin is active. */ -class PinSlotLock { -public: - PinSlotLock(); - ~PinSlotLock(); - - PinSlotLock(const PinSlotLock &) = delete; - PinSlotLock &operator=(const PinSlotLock &) = delete; - - [[nodiscard]] bool isLocked() const; - [[nodiscard]] int index() const; - -private: - int fd_ = -1; - int index_ = -1; -}; diff --git a/src/pin-layout.cpp b/src/pin-layout.cpp index c05e2cde..8cca3079 100644 --- a/src/pin-layout.cpp +++ b/src/pin-layout.cpp @@ -1,35 +1,76 @@ -/** @fileoverview Implements pinned-window stacking and clamping helpers. */ +/** @fileoverview Implements pinned-window stacking and dispatch helpers. */ #include "pin-layout.hpp" #include +#include -QPoint pinPositionFromGlobalPointer(const QPoint &globalPointer, - const QPoint &screenOrigin, - const QPoint &pressOffset) { - return globalPointer - screenOrigin - pressOffset; -} - -QPoint pinSlotPosition(const QSize &screenSize, const QSize &pinSize, - const QSize &slotSize, int index, int gap, int margin) { - const int verticalSpace = std::max(1, screenSize.height() - 2 * margin); - const int rowHeight = std::max(1, slotSize.height() + gap); - const int rows = std::max(1, (verticalSpace + gap) / rowHeight); - const int row = std::max(0, index) % rows; - const int column = std::max(0, index) / rows; - const int x = screenSize.width() - margin - pinSize.width() - - column * (std::max(1, slotSize.width()) + gap); - const int y = - screenSize.height() - margin - pinSize.height() - row * rowHeight; - return {x, y}; -} - -QRect clampPinGeometry(const QRect &pin, const QRect &bounds) { - if (bounds.isEmpty()) - return pin; - const int x = std::clamp(pin.x(), bounds.left(), - std::max(bounds.left(), bounds.right() - pin.width() + 1)); - const int y = std::clamp( - pin.y(), bounds.top(), - std::max(bounds.top(), bounds.bottom() - pin.height() + 1)); - return {x, y, pin.width(), pin.height()}; +QPoint pinPackedPosition(const QVector &blockers, + const QSize &screenSize, const QSize &frame, int gap, + int margin) { + int x = screenSize.width() - margin - frame.width(); + for (int column = 0; column < 8; ++column) { + int y = screenSize.height() - margin - frame.height(); + while (y >= margin) { + const QRect candidate(x, y, frame.width(), frame.height()); + int lowestTop = -1; + for (const QRect &blocker : blockers) { + if (candidate.intersects(blocker)) + lowestTop = std::max(lowestTop, blocker.top()); + } + if (lowestTop < 0) + return {x, y}; + // Climb to one gap above the lowest pin in the way, then look again: + // the spot up there may graze another one. + y = lowestTop - gap - frame.height(); + } + x -= frame.width() + gap; + } + return {screenSize.width() - margin - frame.width(), + screenSize.height() - margin - frame.height()}; +} + +bool pinInColumn(const QRect &rect, const QSize &screenSize, int margin) { + constexpr int tolerance = 6; + return std::abs(rect.right() + 1 - (screenSize.width() - margin)) <= + tolerance; +} + +namespace { +// The whole expression is one dispatch argument; the title has a space in +// it, so the selector is quoted inside the expression rather than around it. +QString windowSelector(const QString &title) { + return QStringLiteral("window = \"title:^(%1)$\"").arg(title); +} +} // namespace + +QString pinFloatDispatch(const QString &title) { + return QStringLiteral("hl.dsp.window.float({ %1 })") + .arg(windowSelector(title)); +} + +QString pinPinDispatch(const QString &title) { + return QStringLiteral("hl.dsp.window.pin({ %1 })").arg(windowSelector(title)); +} + +QString pinMoveDispatch(const QString &title, int x, int y) { + return QStringLiteral( + "hl.dsp.window.move({ x = %1, y = %2, relative = false, %3 })") + .arg(x) + .arg(y) + .arg(windowSelector(title)); +} + +QString pinSwayArrangeCommand(const QString &title, int x, int y) { + return QStringLiteral("[title=\"^%1$\"] floating enable, sticky enable, " + "move absolute position %2 %3") + .arg(title) + .arg(x) + .arg(y); +} + +QString pinSwayMoveCommand(const QString &title, int x, int y) { + return QStringLiteral("[title=\"^%1$\"] move absolute position %2 %3") + .arg(title) + .arg(x) + .arg(y); } diff --git a/src/pin-layout.hpp b/src/pin-layout.hpp index 2b3e48d7..b449ef07 100644 --- a/src/pin-layout.hpp +++ b/src/pin-layout.hpp @@ -1,15 +1,34 @@ -/** @fileoverview Provides pure pinned-window layout helpers. */ +/** @fileoverview Provides pure pinned-window packing and dispatch helpers. */ #pragma once +#include #include #include #include +#include +#include -[[nodiscard]] QPoint pinPositionFromGlobalPointer(const QPoint &globalPointer, - const QPoint &screenOrigin, - const QPoint &pressOffset); -[[nodiscard]] QPoint pinSlotPosition(const QSize &screenSize, - const QSize &pinSize, - const QSize &slotSize, int index, int gap, - int margin); -[[nodiscard]] QRect clampPinGeometry(const QRect &pin, const QRect &bounds); +/// Where a frame of `frame` size lands so it covers none of `blockers`: +/// snug in the bottom-right corner, or one gap above whatever occupies it, +/// climbing the column and starting a new column to the left when this one +/// is full. Blockers can be any size; the pin packs against what is +/// actually there rather than onto a grid that wastes a slot for every +/// straddled boundary. +[[nodiscard]] QPoint pinPackedPosition(const QVector &blockers, + const QSize &screenSize, + const QSize &frame, int gap, + int margin); + +/// Whether a pin still hugs the right edge column; dragging one away from +/// the edge takes it out of the column, and compaction leaves it alone. +[[nodiscard]] bool pinInColumn(const QRect &rect, const QSize &screenSize, + int margin); + +/// Dispatch expressions for a Lua-configured Hyprland, which evaluates the +/// dispatch argument as Lua; the classic dispatcher grammar parses as an +/// expression there and fails while reporting success. +[[nodiscard]] QString pinFloatDispatch(const QString &title); +[[nodiscard]] QString pinPinDispatch(const QString &title); +[[nodiscard]] QString pinMoveDispatch(const QString &title, int x, int y); +[[nodiscard]] QString pinSwayArrangeCommand(const QString &title, int x, int y); +[[nodiscard]] QString pinSwayMoveCommand(const QString &title, int x, int y); diff --git a/src/pin.cpp b/src/pin.cpp index 42a06956..ec9d9028 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -4,13 +4,13 @@ #include "pin-layout.hpp" #include "icons.hpp" -#include #include #include +#include #include +#include #include #include -#include #include #include #include @@ -19,16 +19,22 @@ #include #include #include -#include +#include +#include +#include +#include #include -#include #include #include #include #include +#include +#include + #include +#include #include namespace { @@ -40,68 +46,250 @@ constexpr qreal kDragButtonWidth = kCloseButtonSize * 2 + kControlGap; constexpr qreal kCornerMargin = 14; constexpr int kPinGap = 10; constexpr int kToastMs = 1200; -constexpr qreal kVisualInset = 8; -constexpr qreal kVisualRadius = 12; -constexpr qreal kControlsHeight = 36; -constexpr int kPinWidth = 250; -constexpr int kPinHeight = 200; -class PinWindow final : public QWidget { -public: - explicit PinWindow(QImage image, QString path) - : image_(std::move(image)), path_(std::move(path)), snapshotFile_(path_) { - setWindowTitle(QStringLiteral("omasnap-pin")); - setWindowFlags(Qt::Window | Qt::FramelessWindowHint); - setAttribute(Qt::WA_TranslucentBackground); - resize(initialSize()); +// Every pin's title starts with this, followed by the process id, so pins +// can recognize each other in the compositor's client list and a dispatcher +// can name exactly one of them. Without the unique half a title pattern +// matches every pin and the compositor acts on whichever it finds first. +const QString kPinTitlePrefix = QStringLiteral("omasnap-pin"); + +QString pinTitle() { + return QStringLiteral("%1 %2").arg(kPinTitlePrefix).arg( + QCoreApplication::applicationPid()); +} + +// The compositors a pin knows how to ask for placement. Wayland has no +// protocol for a window to position itself, so corner placement goes +// through compositor IPC; anywhere else the pin still opens, drags, edits, +// copies and drags out, it just lands where the compositor decides. +enum class Desktop { Hyprland, Sway, Unknown }; + +Desktop detectDesktop() { + if (qEnvironmentVariableIsSet("HYPRLAND_INSTANCE_SIGNATURE")) + return Desktop::Hyprland; + if (qEnvironmentVariableIsSet("SWAYSOCK")) + return Desktop::Sway; + return Desktop::Unknown; +} + +QString runForOutput(const QString &program, const QStringList &arguments) { + QProcess process; + process.start(program, arguments); + if (!process.waitForFinished(1000)) + return {}; + return QString::fromUtf8(process.readAllStandardOutput()); +} + +// Dispatchers rather than window rules: rules have to exist before a window +// maps and live in the user's config, and a pin should need neither. +void hyprDispatch(const QString &expression) { + static_cast(runForOutput(QStringLiteral("hyprctl"), + {QStringLiteral("dispatch"), expression})); +} + +void swayCommand(const QString &command) { + static_cast(runForOutput(QStringLiteral("swaymsg"), {command})); +} + +// The focused output's size in logical pixels; windows are placed in +// logical pixels while Hyprland reports device ones. +QSize compositorScreenSize(Desktop desktop) { + if (desktop == Desktop::Hyprland) { + const QJsonDocument document = QJsonDocument::fromJson( + runForOutput(QStringLiteral("hyprctl"), + {QStringLiteral("-j"), QStringLiteral("monitors")}) + .toUtf8()); + for (const QJsonValue &value : document.array()) { + const QJsonObject monitor = value.toObject(); + if (!monitor.value(QStringLiteral("focused")).toBool()) + continue; + const double scale = + std::max(0.0001, monitor.value(QStringLiteral("scale")).toDouble(1.0)); + return {qRound(monitor.value(QStringLiteral("width")).toDouble() / scale), + qRound(monitor.value(QStringLiteral("height")).toDouble() / + scale)}; + } + return {}; + } + if (desktop == Desktop::Sway) { + const QJsonDocument document = QJsonDocument::fromJson( + runForOutput(QStringLiteral("swaymsg"), + {QStringLiteral("-t"), QStringLiteral("get_outputs"), + QStringLiteral("-r")}) + .toUtf8()); + for (const QJsonValue &value : document.array()) { + const QJsonObject output = value.toObject(); + if (!output.value(QStringLiteral("focused")).toBool()) + continue; + const QJsonObject rect = output.value(QStringLiteral("rect")).toObject(); + return {rect.value(QStringLiteral("width")).toInt(), + rect.value(QStringLiteral("height")).toInt()}; + } } + return {}; +} - [[nodiscard]] bool hasPinLock() const { - return snapshotFile_.isLocked() && slotLock_.isLocked(); +struct CompositorPin { + QString title; + QRect rect; +}; + +// Where every pin currently sits, by title; the title is how a move +// addresses one pin and not the others. +QVector compositorPinRects(Desktop desktop) { + QVector pins; + if (desktop == Desktop::Hyprland) { + const QJsonDocument document = QJsonDocument::fromJson( + runForOutput(QStringLiteral("hyprctl"), + {QStringLiteral("-j"), QStringLiteral("clients")}) + .toUtf8()); + for (const QJsonValue &value : document.array()) { + const QJsonObject client = value.toObject(); + const QString title = client.value(QStringLiteral("title")).toString(); + if (!title.startsWith(kPinTitlePrefix)) + continue; + const QJsonArray at = client.value(QStringLiteral("at")).toArray(); + const QJsonArray size = client.value(QStringLiteral("size")).toArray(); + if (at.size() == 2 && size.size() == 2) { + pins.push_back({title, QRect(at.at(0).toInt(), at.at(1).toInt(), + size.at(0).toInt(), size.at(1).toInt())}); + } + } + return pins; + } + if (desktop == Desktop::Sway) { + // The tree is nested: a floating pin hangs off a workspace's floating + // list rather than sitting beside the tiled windows. + const QJsonDocument document = QJsonDocument::fromJson( + runForOutput(QStringLiteral("swaymsg"), + {QStringLiteral("-t"), QStringLiteral("get_tree"), + QStringLiteral("-r")}) + .toUtf8()); + QVector pending{document.object()}; + while (!pending.isEmpty()) { + const QJsonObject node = pending.takeLast(); + const QString name = node.value(QStringLiteral("name")).toString(); + if (name.startsWith(kPinTitlePrefix)) { + const QJsonObject rect = node.value(QStringLiteral("rect")).toObject(); + pins.push_back({name, QRect(rect.value(QStringLiteral("x")).toInt(), + rect.value(QStringLiteral("y")).toInt(), + rect.value(QStringLiteral("width")).toInt(), + rect.value(QStringLiteral("height")) + .toInt())}); + } + for (const char *key : {"nodes", "floating_nodes"}) { + for (const QJsonValue &child : + node.value(QLatin1String(key)).toArray()) + pending.push_back(child.toObject()); + } + } + } + return pins; +} + +bool compositorSeesPin(Desktop desktop, const QString &title) { + for (const CompositorPin &pin : compositorPinRects(desktop)) { + if (pin.title == title) + return true; } + return false; +} + +void movePin(Desktop desktop, const QString &title, const QPoint &position) { + if (desktop == Desktop::Hyprland) + hyprDispatch(pinMoveDispatch(title, position.x(), position.y())); + else if (desktop == Desktop::Sway) + swayCommand(pinSwayMoveCommand(title, position.x(), position.y())); +} + +// Where a new pin lands: ask the compositor where the existing pins are +// rather than keeping a count. Every pin is its own process, a file of +// positions goes stale the first time one crashes, and every pin on screen +// blocks the space it covers, whatever its shape, so a new pin packs snugly +// above what is there and never lands on a pin the user placed or an older +// build left behind. +QPoint nextPinPosition(Desktop desktop, const QSize &screen, + const QSize &frame, const QString &ownTitle) { + QVector blockers; + for (const CompositorPin &pin : compositorPinRects(desktop)) { + if (pin.title != ownTitle) + blockers.push_back(pin.rect); + } + return pinPackedPosition(blockers, screen, frame, kPinGap, + qRound(kCornerMargin)); +} - [[nodiscard]] int slotIndex() const { return slotLock_.index(); } +// A pin left the column: pack the survivors back down, keeping their order. +// Only pins still hugging the right edge take part; one dragged elsewhere +// is left alone and packed around. `excludedTitle` names one to leave out +// even if the compositor still lists it, which it may while that pin is +// closing. +void compactPinColumn(Desktop desktop, const QString &excludedTitle) { + const QSize screen = compositorScreenSize(desktop); + if (screen.isEmpty()) + return; + QVector column; + QVector blockers; + for (const CompositorPin &pin : compositorPinRects(desktop)) { + if (pin.title == excludedTitle) + continue; + if (pinInColumn(pin.rect, screen, qRound(kCornerMargin))) + column.push_back(pin); + else + blockers.push_back(pin.rect); + } + std::sort(column.begin(), column.end(), + [](const CompositorPin &a, const CompositorPin &b) { + return a.rect.y() > b.rect.y(); + }); + for (const CompositorPin &pin : column) { + const QPoint target = pinPackedPosition( + blockers, screen, pin.rect.size(), kPinGap, qRound(kCornerMargin)); + if ((target - pin.rect.topLeft()).manhattanLength() > 4) + movePin(desktop, pin.title, target); + blockers.push_back(QRect(target, pin.rect.size())); + } +} - [[nodiscard]] QSize availableSize() const { - const QScreen *target = - screen() ? screen() : QGuiApplication::primaryScreen(); - return target ? target->availableGeometry().size() : QSize(1920, 1080); +class PinWindow final : public QWidget { +public: + explicit PinWindow(QImage image, QString path) + : image_(std::move(image)), path_(std::move(path)), snapshotFile_(path_), + desktop_(detectDesktop()) { + setWindowTitle(pinTitle()); + setWindowFlags(Qt::Window | Qt::FramelessWindowHint); + // Fixed, not merely sized: min equal to max is the hint a compositor + // honors when floating, and Hyprland floats an unresizable window on + // its own instead of first stretching it into a tile. + setFixedSize(QSize(250, 200)); + dragWatchTimer_.setInterval(300); + connect(&dragWatchTimer_, &QTimer::timeout, this, + [this] { observeDrag(); }); } + [[nodiscard]] bool hasPinLock() const { return snapshotFile_.isLocked(); } + protected: void paintEvent(QPaintEvent *) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::SmoothPixmapTransform, true); - const QRectF frame = - QRectF(rect()).adjusted(kVisualInset, kVisualInset, -kVisualInset, - -kVisualInset); - for (int layer = 8; layer > 0; --layer) { - const qreal spread = layer * 1.5; - painter.setPen(Qt::NoPen); - painter.setBrush(QColor(0, 0, 0, 10 + (8 - layer) * 3)); - painter.drawRoundedRect( - frame.adjusted(-spread, -spread, spread, spread), - kVisualRadius + spread, kVisualRadius + spread); + // The compositor draws the border and the shadow around a floating + // window, so the picture is the whole window: a mat of our own would be + // a second frame inside the first. Cover-cropped and anchored to the + // top rather than the middle, because a capture's top is its title bar, + // its tabs, its heading; a tall page cropped to its middle is a slab of + // body text that could be any of them. + painter.fillRect(rect(), QColor(18, 18, 22)); + if (!image_.isNull()) { + const qreal scale = + std::max(static_cast(width()) / image_.width(), + static_cast(height()) / image_.height()); + const QRectF source((image_.width() - width() / scale) / 2.0, 0.0, + width() / scale, height() / scale); + painter.drawImage(QRectF(rect()), image_, source); } - painter.setBrush(QColor(18, 18, 22, 245)); - painter.drawRoundedRect(frame, kVisualRadius, kVisualRadius); - - const QRectF imageArea = - frame.adjusted(0, kControlsHeight, 0, -kVisualInset); - const QSize fitted = - image_.size().scaled(imageArea.size().toSize(), Qt::KeepAspectRatio); - const QRectF imageRect( - imageArea.center().x() - fitted.width() / 2.0, - imageArea.center().y() - fitted.height() / 2.0, fitted.width(), - fitted.height()); - QPainterPath clip; - clip.addRoundedRect(imageRect, kVisualRadius, kVisualRadius); - painter.save(); - painter.setClipPath(clip); - painter.drawImage(imageRect, image_); - painter.restore(); if (!toast_.isEmpty()) paintToast(painter); if (!hovered_) @@ -112,22 +300,6 @@ class PinWindow final : public QWidget { drawControlButton(painter, pathButtonRect(), QStringLiteral("path")); drawControlButton(painter, copyButtonRect(), QStringLiteral("copy")); drawControlButton(painter, closeButtonRect(), QStringLiteral("close")); - if (!hoverTip_.isEmpty()) - paintHoverTip(painter); - } - - // Layer surfaces cannot reliably map an independent QToolTip toplevel, so - // the hint is drawn inside the window like the toast pill. - void paintHoverTip(QPainter &painter) const { - const QFontMetrics metrics(painter.font()); - const qreal width = metrics.horizontalAdvance(hoverTip_) + 22; - const QRectF pill(std::max(0.0, this->width() - width - kCloseButtonInset), - kCloseButtonInset + kCloseButtonSize + 5, width, 22); - painter.setPen(Qt::NoPen); - painter.setBrush(QColor(12, 12, 16, 210)); - painter.drawRoundedRect(pill, 11, 11); - painter.setPen(QColor(240, 240, 245)); - painter.drawText(pill, Qt::AlignCenter, hoverTip_); } void drawControlButton(QPainter &painter, const QRectF &rect, @@ -184,12 +356,58 @@ class PinWindow final : public QWidget { reopenInEditor(); return; } - dragging_ = true; - dragOffset_ = position.toPoint(); + if (QWindow *handle = windowHandle()) + handle->startSystemMove(); + beginDragWatch(); event->accept(); } } + // startSystemMove hands the drag to the compositor and this side never + // hears when it ends, so the end is read off the effect: the window left + // its starting position and then held one spot for a few polls. A press + // that never moves the window was a click and times out instead. Either + // way the column closes the gap behind a pin that was dragged away. + void beginDragWatch() { + if (desktop_ == Desktop::Unknown) + return; + dragStartRect_ = ownCompositorRect(); + if (dragStartRect_.isNull()) + return; + dragPreviousRect_ = {}; + dragMoved_ = false; + dragPolls_ = 0; + dragStablePolls_ = 0; + dragWatchTimer_.start(); + } + + void observeDrag() { + const QRect rect = ownCompositorRect(); + ++dragPolls_; + const bool clickTimeout = !dragMoved_ && dragPolls_ >= 10; + if (rect.isNull() || clickTimeout || dragPolls_ >= 200) { + dragWatchTimer_.stop(); + return; + } + dragMoved_ = dragMoved_ || rect != dragStartRect_; + dragStablePolls_ = dragMoved_ && rect == dragPreviousRect_ + ? dragStablePolls_ + 1 + : 0; + dragPreviousRect_ = rect; + if (dragStablePolls_ >= 3) { + dragWatchTimer_.stop(); + compactPinColumn(desktop_, QString()); + } + } + + [[nodiscard]] QRect ownCompositorRect() const { + for (const CompositorPin &pin : compositorPinRects(desktop_)) { + if (pin.title == windowTitle()) + return pin.rect; + } + return {}; + } + void reopenInEditor() { if (!QProcess::startDetached(QCoreApplication::applicationFilePath(), {path_})) @@ -202,37 +420,16 @@ class PinWindow final : public QWidget { void mouseMoveEvent(QMouseEvent *event) override { const QPointF position = event->position(); - if (dragging_) { - const QScreen *target = - screen() ? screen() : QGuiApplication::primaryScreen(); - const QPoint origin = - target ? target->availableGeometry().topLeft() : QPoint(); - const QRect bounds(QPoint(), - target ? target->availableGeometry().size() - : availableSize()); - const QPoint requested = pinPositionFromGlobalPointer( - event->globalPosition().toPoint(), origin, dragOffset_); - applyPosition(clampPinGeometry(QRect(requested, size()), bounds).topLeft()); - event->accept(); - return; - } setCursor(controlRectAt(position) >= 0 ? Qt::PointingHandCursor : Qt::ArrowCursor); const int control = controlRectAt(position); if (control != hoveredControl_) { hoveredControl_ = control; - hoverTip_ = control >= 0 ? controlTip(control) : QString(); update(); } } - void mouseReleaseEvent(QMouseEvent *event) override { - if (event->button() == Qt::LeftButton) - dragging_ = false; - QWidget::mouseReleaseEvent(event); - } - // A layer surface can still initiate a Wayland uri-list drag just like a // file manager; the six-dot control is the drag handle. void beginFileDrag() { @@ -274,6 +471,13 @@ class PinWindow final : public QWidget { QWidget::keyPressEvent(event); } + void closeEvent(QCloseEvent *event) override { + // The compositor may still list this window while it closes, so it is + // excluded by name rather than trusted to be gone. + compactPinColumn(desktop_, windowTitle()); + QWidget::closeEvent(event); + } + void enterEvent(QEnterEvent *) override { hovered_ = true; hoveredControl_ = -1; @@ -283,33 +487,11 @@ class PinWindow final : public QWidget { void leaveEvent(QEvent *) override { hovered_ = false; hoveredControl_ = -1; - hoverTip_.clear(); setCursor(Qt::ArrowCursor); update(); } private: - [[nodiscard]] QString controlTip(int index) const { - switch (index) { - case 0: - return QStringLiteral("Close · Esc or middle-click"); - case 1: - return QStringLiteral("Copy image to clipboard"); - case 2: - return QStringLiteral("Copy file path"); - case 3: - return QStringLiteral("Edit in omasnap"); - case 4: - return QStringLiteral("Drag this image out"); - default: - return {}; - } - } - - [[nodiscard]] QSize initialSize() const { - return {kPinWidth, kPinHeight}; - } - void showToast(QString message) { toast_ = std::move(message); update(); @@ -319,17 +501,6 @@ class PinWindow final : public QWidget { }); } - void applyPosition(QPoint position) { - position_ = position; - if (QWindow *handle = windowHandle()) { - if (LayerShellQt::Window *layer = LayerShellQt::Window::get(handle)) { - const QSize available = availableSize(); - layer->setMargins( - QMargins(0, 0, available.width() - position.x() - width(), - available.height() - position.y() - height())); - } - } - } // The wide drag handle stands alone in the top-left; edit, path, copy, and // close remain grouped in the top-right. [[nodiscard]] QRectF closeButtonRect() const { return controlRect(0); } @@ -363,12 +534,14 @@ class PinWindow final : public QWidget { QImage image_; QString path_; PinSnapshotFile snapshotFile_; - PinSlotLock slotLock_; - QPoint position_; - QPoint dragOffset_; - bool dragging_ = false; + Desktop desktop_; + QTimer dragWatchTimer_; + QRect dragStartRect_; + QRect dragPreviousRect_; + bool dragMoved_ = false; + int dragPolls_ = 0; + int dragStablePolls_ = 0; QString toast_; - QString hoverTip_; bool hovered_ = false; int hoveredControl_ = -1; }; @@ -382,39 +555,54 @@ int runPinnedCapture(const QString &path) { return 1; } + const Desktop desktop = detectDesktop(); PinWindow window(std::move(image), path); if (!window.hasPinLock()) { qWarning("omasnap: could not lock pinned image %s", qUtf8Printable(path)); return 1; } - static_cast(window.winId()); - QWindow *handle = window.windowHandle(); - LayerShellQt::Window *layer = - handle ? LayerShellQt::Window::get(handle) : nullptr; - if (!handle || !layer) { - qCritical("omasnap: could not create pinned layer surface"); - return 1; - } - - layer->setScope(QStringLiteral("omasnap-pin")); - LayerShellQt::Window::Anchors anchors; - anchors.setFlag(LayerShellQt::Window::AnchorBottom); - anchors.setFlag(LayerShellQt::Window::AnchorRight); - layer->setAnchors(anchors); - const QPoint slot = pinSlotPosition(window.availableSize(), window.size(), - window.size(), window.slotIndex(), - kPinGap, kCornerMargin); - layer->setMargins(QMargins(0, 0, - window.availableSize().width() - slot.x() - - window.width(), - window.availableSize().height() - slot.y() - - window.height())); - layer->setExclusiveZone(0); - layer->setDesiredSize(window.size()); - layer->setKeyboardInteractivity( - LayerShellQt::Window::KeyboardInteractivityOnDemand); - layer->setActivateOnShow(false); - layer->setLayer(LayerShellQt::Window::LayerOverlay); window.show(); + + // A normal window, floated and pinned through the compositor, instead of + // a layer surface: the compositor draws its frame, moves it, and keeps it + // on every workspace. Not immediately though: showing is this side's word + // for mapped, and the compositor has not necessarily registered the + // window under its title yet; dispatches sent then report success and do + // nothing, which leaves a pin centered and unpinned. Retry until the + // client list has it, then float first (a tiled window has no position of + // its own to set), pin it, and drop it into the lowest free slot. + if (desktop != Desktop::Unknown) { + auto attempts = std::make_shared(0); + QTimer *settle = new QTimer(&window); + settle->setInterval(50); + QObject::connect(settle, &QTimer::timeout, &window, + [&window, desktop, settle, attempts] { + ++*attempts; + if (!compositorSeesPin(desktop, window.windowTitle())) { + if (*attempts >= 10) + settle->stop(); + return; + } + settle->stop(); + const QString title = window.windowTitle(); + if (desktop == Desktop::Hyprland) { + hyprDispatch(pinFloatDispatch(title)); + hyprDispatch(pinPinDispatch(title)); + } + const QSize screen = compositorScreenSize(desktop); + if (screen.isEmpty()) + return; + const QPoint origin = + nextPinPosition(desktop, screen, window.size(), + window.windowTitle()); + if (desktop == Desktop::Hyprland) { + movePin(desktop, title, origin); + } else { + swayCommand(pinSwayArrangeCommand(title, origin.x(), + origin.y())); + } + }); + settle->start(); + } return QApplication::exec(); } diff --git a/src/pin.hpp b/src/pin.hpp index bb35a44c..6fee0595 100644 --- a/src/pin.hpp +++ b/src/pin.hpp @@ -2,5 +2,5 @@ #include -/** Runs a detached pinned-image layer using the current Omasnap process. */ +/** Runs a detached pinned-image window using the current Omasnap process. */ [[nodiscard]] int runPinnedCapture(const QString &path); diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index 2eef4b59..47059e53 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -1,49 +1,78 @@ -/** @fileoverview Tests stacked pin slots and drag clamping. */ +/** @fileoverview Tests stacked pin slots and compositor dispatch strings. */ #include "pin-layout-smoke.hpp" #include "pin-layout.hpp" bool runPinLayoutSmoke(QString &error) { - const QPoint firstDrag = pinPositionFromGlobalPointer( - QPoint(446, 306), QPoint(100, 50), QPoint(50, 40)); - const QPoint secondDrag = pinPositionFromGlobalPointer( - QPoint(456, 316), QPoint(100, 50), QPoint(50, 40)); - if (firstDrag != QPoint(296, 216) || secondDrag != QPoint(306, 226)) { - error = QStringLiteral("Pin drag did not follow the global pointer"); - return false; - } - const QSize screen(400, 300); const QSize pin(100, 80); - const QPoint first = pinSlotPosition(screen, pin, pin, 0, 10, 14); - const QPoint second = pinSlotPosition(screen, pin, pin, 1, 10, 14); - const QPoint third = pinSlotPosition(screen, pin, pin, 2, 10, 14); - const QPoint wrapped = pinSlotPosition(screen, pin, pin, 3, 10, 14); - if (first != QPoint(286, 206) || second != QPoint(286, 116) || - third != QPoint(286, 26) || wrapped != QPoint(176, 206)) { - error = QStringLiteral("Pinned slots did not stack and wrap correctly"); + + // An empty corner takes the first pin snug against the margins; the next + // ones pack one gap above whatever is there, whatever its size, and a + // full column starts a new one to the left. + const QPoint first = pinPackedPosition({}, screen, pin, 10, 14); + if (first != QPoint(286, 206)) { + error = QStringLiteral("The first pin did not land in the corner"); + return false; + } + const QPoint second = + pinPackedPosition({QRect(first, pin)}, screen, pin, 10, 14); + if (second != QPoint(286, 116)) { + error = QStringLiteral("The second pin did not pack above the first"); + return false; + } + const QRect oddSize(QPoint(280, 150), QSize(110, 130)); + if (pinPackedPosition({oddSize}, screen, pin, 10, 14) != QPoint(286, 60)) { + error = QStringLiteral("An odd-sized pin was not packed above snugly"); + return false; + } + const QVector fullColumn{QRect(286, 206, 100, 80), + QRect(286, 116, 100, 80), + QRect(286, 26, 100, 80)}; + if (pinPackedPosition(fullColumn, screen, pin, 10, 14) != + QPoint(176, 206)) { + error = QStringLiteral("A full column did not wrap to a new one"); + return false; + } + const QRect elsewhere(QPoint(20, 20), QSize(100, 80)); + if (pinPackedPosition({elsewhere}, screen, pin, 10, 14) != + QPoint(286, 206)) { + error = QStringLiteral("A pin away from the column blocked the corner"); return false; } - const QSize cell(120, 90); - const QRect large(pinSlotPosition(screen, QSize(120, 90), cell, 0, 10, 14), - QSize(120, 90)); - const QRect small(pinSlotPosition(screen, QSize(60, 40), cell, 1, 10, 14), - QSize(60, 40)); - if (large.intersects(small)) { - error = QStringLiteral("Different pin sizes overlapped their layout slots"); + // Column membership is hugging the right edge; dragging a pin away from + // it takes the pin out of the column, whatever its height. + if (!pinInColumn(QRect(286, 26, 100, 80), screen, 14) || + !pinInColumn(QRect(282, 140, 104, 120), screen, 14) || + pinInColumn(QRect(200, 26, 100, 80), screen, 14)) { + error = QStringLiteral("Column membership did not follow the right edge"); return false; } - const QRect bounds(QPoint(10, 20), QSize(400, 300)); - if (clampPinGeometry(QRect(380, 290, 100, 80), bounds) != - QRect(310, 240, 100, 80)) { - error = QStringLiteral("Pinned geometry was not clamped to the screen"); + // The dispatch expressions are Lua for a Lua-configured Hyprland and the + // classic criteria grammar for sway; a placement that silently does + // nothing is exactly the failure these guard. + const QString title = QStringLiteral("omasnap-pin 1234"); + if (pinFloatDispatch(title) != + QStringLiteral( + "hl.dsp.window.float({ window = \"title:^(omasnap-pin 1234)$\" })") || + pinPinDispatch(title) != + QStringLiteral( + "hl.dsp.window.pin({ window = \"title:^(omasnap-pin 1234)$\" })") || + pinMoveDispatch(title, 120, 40) != + QStringLiteral("hl.dsp.window.move({ x = 120, y = 40, relative = " + "false, window = \"title:^(omasnap-pin 1234)$\" })")) { + error = QStringLiteral("Hyprland dispatch expressions were malformed"); return false; } - if (clampPinGeometry(QRect(-20, 0, 100, 80), bounds) != - QRect(10, 20, 100, 80)) { - error = QStringLiteral("Pinned geometry did not clamp at top-left"); + if (pinSwayArrangeCommand(title, 120, 40) != + QStringLiteral("[title=\"^omasnap-pin 1234$\"] floating enable, " + "sticky enable, move absolute position 120 40") || + pinSwayMoveCommand(title, 120, 40) != + QStringLiteral( + "[title=\"^omasnap-pin 1234$\"] move absolute position 120 40")) { + error = QStringLiteral("Sway commands were malformed"); return false; } return true; diff --git a/tests/pin-lifecycle-smoke.cpp b/tests/pin-lifecycle-smoke.cpp index f5ca2483..ca874dfc 100644 --- a/tests/pin-lifecycle-smoke.cpp +++ b/tests/pin-lifecycle-smoke.cpp @@ -168,24 +168,6 @@ bool runPinLifecycleSmoke(QString &error) { } QFile::remove(unrelatedPath); - int expectedReusedSlot = -1; - { - PinSlotLock first; - PinSlotLock second; - if (!first.isLocked() || !second.isLocked() || first.index() == second.index()) { - error = QStringLiteral("Pin slots were not claimed uniquely"); - return false; - } - expectedReusedSlot = first.index(); - } - { - PinSlotLock reused; - if (!reused.isLocked() || reused.index() != expectedReusedSlot) { - error = QStringLiteral("Closed pin slot was not reused"); - return false; - } - } - const QString path = pinnedSnapshotPath(987654); if (!saveTemporarySnapshot(image, path, error)) return false; From 290a1b3f57eaf4dce8973450908da523a189829e Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 23 Aug 2026 13:36:17 -0500 Subject: [PATCH 02/10] feat(pin): shape the pin like the display A fixed 250x200 frame letterboxed most captures. The frame is now the display's aspect at a fixed width, clamped so a tall pivot or an ultrawide still yields a pin rather than a line, with 16:9 as the guess when the display cannot be asked. The picture fills the whole window, cover-cropped and anchored to the top: a capture's top is its title bar and tabs, which is what identifies the shot, and a mat of our own would be a second frame inside the compositor's. The controls scale to fit. --- src/pin-layout.cpp | 11 +++++++++++ src/pin-layout.hpp | 5 +++++ src/pin.cpp | 11 ++++++----- tests/pin-layout-smoke.cpp | 12 ++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/pin-layout.cpp b/src/pin-layout.cpp index 8cca3079..52ed0d7b 100644 --- a/src/pin-layout.cpp +++ b/src/pin-layout.cpp @@ -4,6 +4,17 @@ #include #include +QSize pinFrameSize(const QSize &screenSize) { + constexpr int width = 200; + const double aspect = + screenSize.width() > 0 && screenSize.height() > 0 + ? static_cast(screenSize.height()) / screenSize.width() + : 9.0 / 16.0; + const int height = std::clamp(static_cast(std::lround(width * aspect)), + width / 4, width * 2); + return {width, height}; +} + QPoint pinPackedPosition(const QVector &blockers, const QSize &screenSize, const QSize &frame, int gap, int margin) { diff --git a/src/pin-layout.hpp b/src/pin-layout.hpp index b449ef07..3b69edca 100644 --- a/src/pin-layout.hpp +++ b/src/pin-layout.hpp @@ -14,6 +14,11 @@ /// is full. Blockers can be any size; the pin packs against what is /// actually there rather than onto a grid that wastes a slot for every /// straddled boundary. +/// The frame a pin fills: the display's aspect at a fixed width, clamped +/// so a tall pivot or an ultrawide still yields a pin rather than a line; +/// 16:9 when the display cannot be asked. +[[nodiscard]] QSize pinFrameSize(const QSize &screenSize); + [[nodiscard]] QPoint pinPackedPosition(const QVector &blockers, const QSize &screenSize, const QSize &frame, int gap, diff --git a/src/pin.cpp b/src/pin.cpp index ec9d9028..b49372bd 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -39,8 +39,8 @@ namespace { -constexpr qreal kCloseButtonSize = 22; -constexpr qreal kCloseButtonInset = 8; +constexpr qreal kCloseButtonSize = 18; +constexpr qreal kCloseButtonInset = 7; constexpr qreal kControlGap = 6; constexpr qreal kDragButtonWidth = kCloseButtonSize * 2 + kControlGap; constexpr qreal kCornerMargin = 14; @@ -253,7 +253,7 @@ void compactPinColumn(Desktop desktop, const QString &excludedTitle) { class PinWindow final : public QWidget { public: - explicit PinWindow(QImage image, QString path) + explicit PinWindow(QImage image, QString path, const QSize &frame) : image_(std::move(image)), path_(std::move(path)), snapshotFile_(path_), desktop_(detectDesktop()) { setWindowTitle(pinTitle()); @@ -261,7 +261,7 @@ class PinWindow final : public QWidget { // Fixed, not merely sized: min equal to max is the hint a compositor // honors when floating, and Hyprland floats an unresizable window on // its own instead of first stretching it into a tile. - setFixedSize(QSize(250, 200)); + setFixedSize(frame); dragWatchTimer_.setInterval(300); connect(&dragWatchTimer_, &QTimer::timeout, this, [this] { observeDrag(); }); @@ -556,7 +556,8 @@ int runPinnedCapture(const QString &path) { } const Desktop desktop = detectDesktop(); - PinWindow window(std::move(image), path); + PinWindow window(std::move(image), path, + pinFrameSize(compositorScreenSize(desktop))); if (!window.hasPinLock()) { qWarning("omasnap: could not lock pinned image %s", qUtf8Printable(path)); return 1; diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index 47059e53..00e3180a 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -4,6 +4,18 @@ #include "pin-layout.hpp" bool runPinLayoutSmoke(QString &error) { + // The frame follows the display's shape at a fixed width, clamps the + // extremes, and guesses 16:9 when the display cannot be asked. + if (pinFrameSize(QSize(2560, 1600)) != QSize(200, 125) || + pinFrameSize(QSize(3440, 1440)) != QSize(200, 84) || + pinFrameSize(QSize(1080, 1920)) != QSize(200, 356) || + pinFrameSize(QSize(1000, 5000)) != QSize(200, 400) || + pinFrameSize(QSize(5000, 500)) != QSize(200, 50) || + pinFrameSize(QSize()) != QSize(200, 113)) { + error = QStringLiteral("Pin frames did not follow the display's shape"); + return false; + } + const QSize screen(400, 300); const QSize pin(100, 80); From e54e01f7569b3f0d88e367a549878572747925ab Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 23 Aug 2026 13:36:42 -0500 Subject: [PATCH 03/10] feat(pin): the controls carry the app's own tooltips The old layer surface could not reliably map a tooltip toplevel, so the hint was painted inside the window at a fixed spot, where only the button next to it read as having one. An ordinary window can use real tooltips at the cursor; anchored to each control's rect so the tip stays while the cursor is on the button, and the always-show attribute set because a pin is deliberately never the active window, which is the only kind Qt tips by default. --- src/pin-layout.cpp | 17 +++++++++++++++++ src/pin-layout.hpp | 3 +++ src/pin.cpp | 21 +++++++++++++++++++++ tests/pin-layout-smoke.cpp | 17 +++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/src/pin-layout.cpp b/src/pin-layout.cpp index 52ed0d7b..7c286ac8 100644 --- a/src/pin-layout.cpp +++ b/src/pin-layout.cpp @@ -85,3 +85,20 @@ QString pinSwayMoveCommand(const QString &title, int x, int y) { .arg(x) .arg(y); } + +QString pinControlTip(int index) { + switch (index) { + case 0: + return QStringLiteral("Close · Esc or middle-click"); + case 1: + return QStringLiteral("Copy image to clipboard"); + case 2: + return QStringLiteral("Copy file path"); + case 3: + return QStringLiteral("Edit in omasnap"); + case 4: + return QStringLiteral("Drag this image out"); + default: + return {}; + } +} diff --git a/src/pin-layout.hpp b/src/pin-layout.hpp index 3b69edca..a715bb40 100644 --- a/src/pin-layout.hpp +++ b/src/pin-layout.hpp @@ -37,3 +37,6 @@ [[nodiscard]] QString pinMoveDispatch(const QString &title, int x, int y); [[nodiscard]] QString pinSwayArrangeCommand(const QString &title, int x, int y); [[nodiscard]] QString pinSwayMoveCommand(const QString &title, int x, int y); + +/** The hover tip for a pin control, empty outside the known controls. */ +[[nodiscard]] QString pinControlTip(int index); diff --git a/src/pin.cpp b/src/pin.cpp index b49372bd..d72f6d32 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -262,6 +263,7 @@ class PinWindow final : public QWidget { // honors when floating, and Hyprland floats an unresizable window on // its own instead of first stretching it into a tile. setFixedSize(frame); + setAttribute(Qt::WA_AlwaysShowToolTips, true); dragWatchTimer_.setInterval(300); connect(&dragWatchTimer_, &QTimer::timeout, this, [this] { observeDrag(); }); @@ -430,6 +432,25 @@ class PinWindow final : public QWidget { } } + // The window is an ordinary toplevel now, so the app's own tooltip can + // appear at the cursor instead of a pill painted in one corner. Anchored + // to the control's rect so it stays up while the cursor is inside it. + bool event(QEvent *event) override { + if (event->type() == QEvent::ToolTip) { + auto *help = static_cast(event); + const int control = controlRectAt(help->pos()); + if (control >= 0) { + QToolTip::showText(help->globalPos(), pinControlTip(control), this, + controlRect(control).toAlignedRect()); + } else { + QToolTip::hideText(); + } + return true; + } + return QWidget::event(event); + } + + // A layer surface can still initiate a Wayland uri-list drag just like a // file manager; the six-dot control is the drag handle. void beginFileDrag() { diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index 00e3180a..60f054d9 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -3,6 +3,8 @@ #include "pin-layout.hpp" +#include + bool runPinLayoutSmoke(QString &error) { // The frame follows the display's shape at a fixed width, clamps the // extremes, and guesses 16:9 when the display cannot be asked. @@ -16,6 +18,21 @@ bool runPinLayoutSmoke(QString &error) { return false; } + // Every control explains itself; an index outside the controls is empty. + QSet tips; + for (int control = 0; control < 5; ++control) { + if (pinControlTip(control).isEmpty()) { + error = QStringLiteral("A pin control has no tooltip"); + return false; + } + tips.insert(pinControlTip(control)); + } + if (tips.size() != 5 || !pinControlTip(5).isEmpty() || + !pinControlTip(-1).isEmpty()) { + error = QStringLiteral("Pin control tooltips repeat or overflow"); + return false; + } + const QSize screen(400, 300); const QSize pin(100, 80); From ccd8bf29466381639cade739cce2df94368f6d49 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 23 Aug 2026 13:37:31 -0500 Subject: [PATCH 04/10] feat(pin): drag a pin back into the stack While a drag hovers the column, the other pins step aside around a hole where the dragged one would land, and it snaps into the hole on release. Any part of the window over the column band joins the stack; the band is the bounding box of every pin and would-be seat, including the open seat on top, so a pin nudged off the top snaps back to its own place until it has been dragged fully past it. Fully outside the band, the drag is just a move and the column packs itself behind it. The dragged pin's place in the order comes from its center against the column as it would pack, not the already-spread live positions, so the preview does not chase its own moves; and each move is dispatched once, against what was last commanded rather than the live mid-animation rect, so the slide plays out instead of restarting every poll. --- src/pin-layout.cpp | 74 ++++++++++++++++++++++++++++++ src/pin-layout.hpp | 16 +++++++ src/pin.cpp | 74 +++++++++++++++++++++++++++++- tests/pin-layout-smoke.cpp | 92 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 2 deletions(-) diff --git a/src/pin-layout.cpp b/src/pin-layout.cpp index 7c286ac8..272bfcdf 100644 --- a/src/pin-layout.cpp +++ b/src/pin-layout.cpp @@ -40,6 +40,80 @@ QPoint pinPackedPosition(const QVector &blockers, screenSize.height() - margin - frame.height()}; } +PinInsertionPlan pinInsertionPlan(QVector> column, + const QVector &blockers, + const QRect &dragged, + const QSize &screenSize, int gap, + int margin) { + PinInsertionPlan plan; + std::sort(column.begin(), column.end(), + [](const auto &a, const auto &b) { + return a.second.y() > b.second.y(); + }); + // The dragged pin's place in the order comes from its center against the + // column as it would pack, not against the possibly already-spread live + // positions, so the preview does not chase its own moves. + QVector seed = blockers; + QVector packed; + QVector packedCenters; + for (const auto &pair : column) { + const QPoint at = + pinPackedPosition(seed, screenSize, pair.second.size(), gap, margin); + seed.push_back(QRect(at, pair.second.size())); + packed.push_back(QRect(at, pair.second.size())); + packedCenters.push_back(at.y() + pair.second.height() / 2); + } + // Touching any part of the stack joins it; fully outside stays out. The + // stack includes the open spot on top, which is where a pin dragged off + // the top of the stack came from: it snaps back until it has been + // dragged fully past where it would sit. For an empty column that spot + // is the corner itself. + QVector stack = packed; + stack.push_back( + QRect(pinPackedPosition(seed, screenSize, dragged.size(), gap, margin), + dragged.size())); + // The pins' live positions count too: a stack that has not packed down + // yet is still the stack the user sees and aims for. + for (const auto &pair : column) + stack.push_back(pair.second); + // The region a drag folds back into is the whole column band: the + // bounding box of every pin and seat, which always reaches the bottom + // corner because packing anchors there. Anywhere inside it is where some + // pin would live, not just the dragged pin's own former spot; only fully + // outside it stays out. + QRect band; + for (const QRect &rect : stack) + band |= rect; + if (!dragged.intersects(band)) + return plan; + int index = 0; + for (const int centerY : packedCenters) + index += centerY > dragged.center().y() ? 1 : 0; + plan.index = index; + + // Pack again with a dragged-sized hole at the insertion point. + seed = blockers; + for (int position = 0; position < column.size(); ++position) { + if (position == index) { + const QPoint at = + pinPackedPosition(seed, screenSize, dragged.size(), gap, margin); + plan.spot = QRect(at, dragged.size()); + seed.push_back(plan.spot); + } + const auto &pair = column.at(position); + const QPoint at = + pinPackedPosition(seed, screenSize, pair.second.size(), gap, margin); + seed.push_back(QRect(at, pair.second.size())); + plan.spread.push_back({pair.first, QRect(at, pair.second.size())}); + } + if (index == column.size()) { + const QPoint at = + pinPackedPosition(seed, screenSize, dragged.size(), gap, margin); + plan.spot = QRect(at, dragged.size()); + } + return plan; +} + bool pinInColumn(const QRect &rect, const QSize &screenSize, int margin) { constexpr int tolerance = 6; return std::abs(rect.right() + 1 - (screenSize.width() - margin)) <= diff --git a/src/pin-layout.hpp b/src/pin-layout.hpp index a715bb40..775b28c9 100644 --- a/src/pin-layout.hpp +++ b/src/pin-layout.hpp @@ -24,6 +24,22 @@ const QSize &frame, int gap, int margin); +/// What inserting a dragged pin into the column would look like right now. +/// `index` is -1 while the drag touches no part of the stack; any overlap +/// with a stacked pin (or with the empty corner spot) is enough to join, +/// and fully outside is what keeps a pin out. While joined, the column +/// pins in `spread` step aside around a dragged-sized hole at `spot`, and +/// releasing snaps the pin into it. +struct PinInsertionPlan { + int index = -1; + QRect spot; + QVector> spread; +}; +[[nodiscard]] PinInsertionPlan +pinInsertionPlan(QVector> column, + const QVector &blockers, const QRect &dragged, + const QSize &screenSize, int gap, int margin); + /// Whether a pin still hugs the right edge column; dragging one away from /// the edge takes it out of the column, and compaction leaves it alone. [[nodiscard]] bool pinInColumn(const QRect &rect, const QSize &screenSize, diff --git a/src/pin.cpp b/src/pin.cpp index d72f6d32..12c95f65 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +36,7 @@ #include #include +#include #include #include @@ -376,10 +378,14 @@ class PinWindow final : public QWidget { dragStartRect_ = ownCompositorRect(); if (dragStartRect_.isNull()) return; + dragScreen_ = compositorScreenSize(desktop_); dragPreviousRect_ = {}; + commandedTargets_.clear(); dragMoved_ = false; dragPolls_ = 0; dragStablePolls_ = 0; + spreadActive_ = false; + snapSpot_ = {}; dragWatchTimer_.start(); } @@ -389,19 +395,79 @@ class PinWindow final : public QWidget { const bool clickTimeout = !dragMoved_ && dragPolls_ >= 10; if (rect.isNull() || clickTimeout || dragPolls_ >= 200) { dragWatchTimer_.stop(); + if (spreadActive_) + compactPinColumn(desktop_, windowTitle()); + spreadActive_ = false; return; } dragMoved_ = dragMoved_ || rect != dragStartRect_; + if (dragMoved_) + previewInsertion(rect); dragStablePolls_ = dragMoved_ && rect == dragPreviousRect_ ? dragStablePolls_ + 1 : 0; dragPreviousRect_ = rect; - if (dragStablePolls_ >= 3) { - dragWatchTimer_.stop(); + if (dragMoved_ && dragStablePolls_ >= 3) + finishDrag(); + } + + void finishDrag() { + dragWatchTimer_.stop(); + // One last look at the true final position; the last poll can be a + // frame behind it. + const QRect rect = ownCompositorRect(); + if (!rect.isNull()) + previewInsertion(rect); + if (!snapSpot_.isNull()) + movePin(desktop_, windowTitle(), snapSpot_.topLeft()); + else compactPinColumn(desktop_, QString()); + spreadActive_ = false; + } + + // While the drag hovers the column, the others step aside around a hole + // where this pin would land, live; leaving the column packs them back. + // While the drag hovers the column, the others step aside around a hole + // where this pin would land, live; leaving the column packs them back. + void previewInsertion(const QRect &rect) { + if (dragScreen_.isEmpty()) + return; + QVector> column; + QVector blockers; + for (const CompositorPin &pin : compositorPinRects(desktop_)) { + if (pin.title == windowTitle()) + continue; + if (pinInColumn(pin.rect, dragScreen_, qRound(kCornerMargin))) + column.push_back({pin.title, pin.rect}); + else + blockers.push_back(pin.rect); + } + const PinInsertionPlan plan = pinInsertionPlan( + column, blockers, rect, dragScreen_, kPinGap, qRound(kCornerMargin)); + if (plan.index < 0) { + if (spreadActive_) + compactPinColumn(desktop_, windowTitle()); + spreadActive_ = false; + snapSpot_ = {}; + commandedTargets_.clear(); + return; + } + // Dispatch each move once, against what was last commanded rather than + // the live rect: a pin mid-animation is never at its target yet, and + // re-sending the same move every poll restarts the animation, which + // reads as flicker. One command per new target lets the slide play out. + for (const auto &[title, target] : plan.spread) { + if (commandedTargets_.value(title, QPoint(INT_MIN, INT_MIN)) != + target.topLeft()) { + movePin(desktop_, title, target.topLeft()); + commandedTargets_.insert(title, target.topLeft()); + } } + spreadActive_ = true; + snapSpot_ = plan.spot; } + [[nodiscard]] QRect ownCompositorRect() const { for (const CompositorPin &pin : compositorPinRects(desktop_)) { if (pin.title == windowTitle()) @@ -559,6 +625,10 @@ class PinWindow final : public QWidget { QTimer dragWatchTimer_; QRect dragStartRect_; QRect dragPreviousRect_; + QSize dragScreen_; + QHash commandedTargets_; + QRect snapSpot_; + bool spreadActive_ = false; bool dragMoved_ = false; int dragPolls_ = 0; int dragStablePolls_ = 0; diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index 60f054d9..f1665b11 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -79,6 +79,98 @@ bool runPinLayoutSmoke(QString &error) { return false; } + // Dragging a pin over the column spreads the others around a hole where + // it would land; covering half the hole or more is close enough to snap. + const QVector> column{ + {QStringLiteral("low"), QRect(286, 206, 100, 80)}, + {QStringLiteral("high"), QRect(286, 116, 100, 80)}}; + const PinInsertionPlan between = pinInsertionPlan( + column, {}, QRect(286, 140, 100, 80), screen, 10, 14); + if (between.index != 1 || between.spot != QRect(286, 116, 100, 80) || + between.spread.size() != 2 || + between.spread.at(0) != + QPair(QStringLiteral("low"), QRect(286, 206, 100, 80)) || + between.spread.at(1) != + QPair(QStringLiteral("high"), QRect(286, 26, 100, 80))) { + error = QStringLiteral("Hovering between pins did not open a hole there"); + return false; + } + // Any overlap with the stack joins it, however slight; a drag that + // clears the stack entirely, even right beside it, stays out. + const PinInsertionPlan grazing = pinInsertionPlan( + column, {}, QRect(200, 140, 100, 80), screen, 10, 14); + if (grazing.index != 1) { + error = QStringLiteral("A partial overlap with the stack did not join it"); + return false; + } + if (pinInsertionPlan(column, {}, QRect(120, 140, 100, 80), screen, 10, 14) + .index != -1) { + error = QStringLiteral("A drag clear of the stack joined it anyway"); + return false; + } + const QVector> emptyColumn; + if (pinInsertionPlan(emptyColumn, {}, QRect(240, 180, 100, 80), screen, 10, + 14) + .index != 0 || + pinInsertionPlan(emptyColumn, {}, QRect(60, 60, 100, 80), screen, 10, + 14) + .index != -1) { + error = QStringLiteral("An empty stack's corner spot did not gate joining"); + return false; + } + // A pin nudged off the top of the stack still overlaps the spot it came + // from and snaps back there; dragged fully past it, it is free. + const PinInsertionPlan nudged = pinInsertionPlan( + column, {}, QRect(250, 10, 100, 80), screen, 10, 14); + if (nudged.index != 2 || nudged.spot != QRect(286, 26, 100, 80)) { + error = QStringLiteral("A nudged top pin did not snap back to its seat"); + return false; + } + if (pinInsertionPlan(column, {}, QRect(150, 20, 100, 80), screen, 10, 14) + .index != -1) { + error = QStringLiteral("A pin dragged past its seat was still captured"); + return false; + } + // A stack that has not packed down yet is still the stack the user sees: + // overlapping a pin's live position joins even when the packed baseline + // is elsewhere. + const QVector> floating{ + {QStringLiteral("high"), QRect(286, 40, 100, 80)}}; + if (pinInsertionPlan(floating, {}, QRect(240, 20, 100, 80), screen, 10, 14) + .index == -1) { + error = QStringLiteral("Overlapping a live pin did not join the stack"); + return false; + } + // The band spans the vacancies too: with one pin floating high on a + // tall screen, a drag into the empty stretch between it and the bottom + // seats still folds in; beside the band it stays out. + const QSize tall(400, 600); + const QVector> lofty{ + {QStringLiteral("high"), QRect(286, 30, 100, 80)}}; + if (pinInsertionPlan(lofty, {}, QRect(240, 200, 100, 80), tall, 10, 14) + .index == -1) { + error = QStringLiteral("A drag into the column's vacancy stayed out"); + return false; + } + if (pinInsertionPlan(lofty, {}, QRect(100, 200, 100, 80), tall, 10, 14) + .index != -1) { + error = QStringLiteral("A drag beside the column folded in"); + return false; + } + const PinInsertionPlan below = pinInsertionPlan( + column, {}, QRect(286, 216, 100, 80), screen, 10, 14); + if (below.index != 0 || below.spot != QRect(286, 206, 100, 80) || + below.spread.at(0) != + QPair(QStringLiteral("low"), QRect(286, 116, 100, 80))) { + error = QStringLiteral("Hovering the corner did not open the bottom slot"); + return false; + } + if (pinInsertionPlan(column, {}, QRect(60, 140, 100, 80), screen, 10, 14) + .index != -1) { + error = QStringLiteral("A drag far from the column planned an insertion"); + return false; + } + // The dispatch expressions are Lua for a Lua-configured Hyprland and the // classic criteria grammar for sway; a placement that silently does // nothing is exactly the failure these guard. From 93b492929d44bbfae20d51908b67612d2dffd659 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 23 Aug 2026 13:38:13 -0500 Subject: [PATCH 05/10] fix(pin): finish the drag the moment the button releases The end of a compositor-side move has to be inferred: the client never hears it directly. Stillness alone concluded drags early, and a snap dispatched while the button was still down fought the user's grab. Now the release is read three ways, best first: the mouse event devices push the button-up the instant it happens when permissions allow; the first pointer event after the grab began is the release too, since the grab starves the window of them; and a long stillness timeout remains for sessions where neither source exists. The watch polls faster while it lasts, and holding a pin still mid-drag stays a drag. --- src/pin-layout.hpp | 10 ++--- src/pin.cpp | 100 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/pin-layout.hpp b/src/pin-layout.hpp index 775b28c9..2919cd35 100644 --- a/src/pin-layout.hpp +++ b/src/pin-layout.hpp @@ -8,17 +8,17 @@ #include #include +/// The frame a pin fills: the display's aspect at a fixed width, clamped +/// so a tall pivot or an ultrawide still yields a pin rather than a line; +/// 16:9 when the display cannot be asked. +[[nodiscard]] QSize pinFrameSize(const QSize &screenSize); + /// Where a frame of `frame` size lands so it covers none of `blockers`: /// snug in the bottom-right corner, or one gap above whatever occupies it, /// climbing the column and starting a new column to the left when this one /// is full. Blockers can be any size; the pin packs against what is /// actually there rather than onto a grid that wastes a slot for every /// straddled boundary. -/// The frame a pin fills: the display's aspect at a fixed width, clamped -/// so a tall pivot or an ultrawide still yields a pin rather than a line; -/// 16:9 when the display cannot be asked. -[[nodiscard]] QSize pinFrameSize(const QSize &screenSize); - [[nodiscard]] QPoint pinPackedPosition(const QVector &blockers, const QSize &screenSize, const QSize &frame, int gap, diff --git a/src/pin.cpp b/src/pin.cpp index 12c95f65..51eeab57 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ #include #include +#include #include #include @@ -266,7 +268,7 @@ class PinWindow final : public QWidget { // its own instead of first stretching it into a tile. setFixedSize(frame); setAttribute(Qt::WA_AlwaysShowToolTips, true); - dragWatchTimer_.setInterval(300); + dragWatchTimer_.setInterval(80); connect(&dragWatchTimer_, &QTimer::timeout, this, [this] { observeDrag(); }); } @@ -386,15 +388,17 @@ class PinWindow final : public QWidget { dragStablePolls_ = 0; spreadActive_ = false; snapSpot_ = {}; + openButtonWatch(); dragWatchTimer_.start(); } void observeDrag() { const QRect rect = ownCompositorRect(); ++dragPolls_; - const bool clickTimeout = !dragMoved_ && dragPolls_ >= 10; - if (rect.isNull() || clickTimeout || dragPolls_ >= 200) { + const bool clickTimeout = !dragMoved_ && dragPolls_ >= 12; + if (rect.isNull() || clickTimeout || dragPolls_ >= 750) { dragWatchTimer_.stop(); + closeButtonWatch(); if (spreadActive_) compactPinColumn(desktop_, windowTitle()); spreadActive_ = false; @@ -403,16 +407,92 @@ class PinWindow final : public QWidget { dragMoved_ = dragMoved_ || rect != dragStartRect_; if (dragMoved_) previewInsertion(rect); - dragStablePolls_ = dragMoved_ && rect == dragPreviousRect_ - ? dragStablePolls_ + 1 - : 0; + const bool still = rect == dragPreviousRect_; + dragStablePolls_ = dragMoved_ && still ? dragStablePolls_ + 1 : 0; dragPreviousRect_ = rect; - if (dragMoved_ && dragStablePolls_ >= 3) + // The release normally arrives from the input device watch or as a + // pointer event; this long stillness fallback only catches a session + // where neither could be established. + if (dragMoved_ && dragStablePolls_ >= 25) finishDrag(); } + // The kernel pushes the button release the instant it happens, no matter + // whether the pointer ever moves again; the compositor tells this window + // nothing until it does. Best effort: without permission to read the + // devices, the pointer-event and stillness paths still finish the drag. + void openButtonWatch() { + closeButtonWatch(); + QDir devices(QStringLiteral("/dev/input/by-id")); + const QStringList entries = + devices.entryList({QStringLiteral("*-event-mouse")}, + QDir::System | QDir::Files | QDir::NoDotAndDotDot); + for (const QString &entry : entries) { + const int fd = + ::open(QFile::encodeName(devices.filePath(entry)).constData(), + O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) + continue; + auto *notifier = new QSocketNotifier(fd, QSocketNotifier::Read, this); + connect(notifier, &QSocketNotifier::activated, this, + [this, fd] { readButtonEvents(fd); }); + buttonWatches_.push_back({fd, notifier}); + } + } + + void readButtonEvents(int fd) { + struct input_event events[16]; + for (;;) { + const ssize_t bytes = ::read(fd, events, sizeof events); + if (bytes <= 0) + return; + const int count = static_cast(bytes / sizeof(input_event)); + for (int index = 0; index < count; ++index) { + if (events[index].type != EV_KEY || events[index].code != BTN_LEFT || + events[index].value != 0 || !dragWatchTimer_.isActive()) + continue; + const QRect rect = ownCompositorRect(); + dragMoved_ = + dragMoved_ || (!rect.isNull() && rect != dragStartRect_); + if (dragMoved_) + finishDrag(); + else + dragWatchTimer_.stop(); + closeButtonWatch(); + return; + } + } + } + + void closeButtonWatch() { + for (const auto &[fd, notifier] : buttonWatches_) { + delete notifier; + ::close(fd); + } + buttonWatches_.clear(); + } + + // The compositor's move grab starves this window of pointer events, so + // the first enter or hover after the grab began is the release itself, + // and the snap can happen right then instead of waiting for a poll. + // Holding the pin still mid-drag stays a drag for as long as the button + // is down. + void pointerWokeDuringWatch() { + if (!dragWatchTimer_.isActive()) + return; + const QRect rect = ownCompositorRect(); + dragMoved_ = dragMoved_ || (!rect.isNull() && rect != dragStartRect_); + if (!dragMoved_) { + // A click, not a drag: nothing moved, nothing to restore. + dragWatchTimer_.stop(); + return; + } + finishDrag(); + } + void finishDrag() { dragWatchTimer_.stop(); + closeButtonWatch(); // One last look at the true final position; the last poll can be a // frame behind it. const QRect rect = ownCompositorRect(); @@ -425,8 +505,6 @@ class PinWindow final : public QWidget { spreadActive_ = false; } - // While the drag hovers the column, the others step aside around a hole - // where this pin would land, live; leaving the column packs them back. // While the drag hovers the column, the others step aside around a hole // where this pin would land, live; leaving the column packs them back. void previewInsertion(const QRect &rect) { @@ -467,7 +545,6 @@ class PinWindow final : public QWidget { snapSpot_ = plan.spot; } - [[nodiscard]] QRect ownCompositorRect() const { for (const CompositorPin &pin : compositorPinRects(desktop_)) { if (pin.title == windowTitle()) @@ -487,6 +564,7 @@ class PinWindow final : public QWidget { } void mouseMoveEvent(QMouseEvent *event) override { + pointerWokeDuringWatch(); const QPointF position = event->position(); setCursor(controlRectAt(position) >= 0 ? Qt::PointingHandCursor : Qt::ArrowCursor); @@ -566,6 +644,7 @@ class PinWindow final : public QWidget { } void enterEvent(QEnterEvent *) override { + pointerWokeDuringWatch(); hovered_ = true; hoveredControl_ = -1; update(); @@ -627,6 +706,7 @@ class PinWindow final : public QWidget { QRect dragPreviousRect_; QSize dragScreen_; QHash commandedTargets_; + QVector> buttonWatches_; QRect snapSpot_; bool spreadActive_ = false; bool dragMoved_ = false; From 888209ffc305f3e5488b9b974701c59de19c53c5 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 6 Sep 2026 18:28:21 -0500 Subject: [PATCH 06/10] fix(pin): serialize placement and move compositor IPC off the UI thread --- README.md | 26 +- docs/dependencies.md | 4 +- docs/threading.md | 10 +- src/pin-file.hpp | 2 +- src/pin-layout.cpp | 69 ++--- src/pin-layout.hpp | 9 +- src/pin.cpp | 521 ++++++++++++++++++++----------------- tests/pin-layout-smoke.cpp | 42 ++- 8 files changed, 382 insertions(+), 301 deletions(-) diff --git a/README.md b/README.md index 68f1edec..4cb5388a 100644 --- a/README.md +++ b/README.md @@ -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//omasnap/` (falling back to a private `/tmp/omasnap-/`): the original source image plus a sidecar JSON @@ -396,11 +396,19 @@ without reaching for the pointer. `P` renders the current capture, writes it to a `pin---.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 @@ -415,12 +423,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 diff --git a/docs/dependencies.md b/docs/dependencies.md index 446300f7..fb004800 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -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 | @@ -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`) | diff --git a/docs/threading.md b/docs/threading.md index cc2095d2..858a27d2 100644 --- a/docs/threading.md +++ b/docs/threading.md @@ -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 | @@ -130,3 +130,11 @@ 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 is bounded and runs before mapping. diff --git a/src/pin-file.hpp b/src/pin-file.hpp index f5d99368..884081ed 100644 --- a/src/pin-file.hpp +++ b/src/pin-file.hpp @@ -1,4 +1,4 @@ -/** @fileoverview Owns locks for pinned snapshot files and layout slots. */ +/** @fileoverview Owns locks for pinned snapshot files. */ #pragma once #include diff --git a/src/pin-layout.cpp b/src/pin-layout.cpp index 272bfcdf..16bcac81 100644 --- a/src/pin-layout.cpp +++ b/src/pin-layout.cpp @@ -15,11 +15,13 @@ QSize pinFrameSize(const QSize &screenSize) { return {width, height}; } -QPoint pinPackedPosition(const QVector &blockers, +std::optional pinPackedPosition(const QVector &blockers, const QSize &screenSize, const QSize &frame, int gap, int margin) { int x = screenSize.width() - margin - frame.width(); - for (int column = 0; column < 8; ++column) { + if (frame.isEmpty() || gap < 0 || margin < 0) + return std::nullopt; + while (x >= margin) { int y = screenSize.height() - margin - frame.height(); while (y >= margin) { const QRect candidate(x, y, frame.width(), frame.height()); @@ -29,15 +31,14 @@ QPoint pinPackedPosition(const QVector &blockers, lowestTop = std::max(lowestTop, blocker.top()); } if (lowestTop < 0) - return {x, y}; + return QPoint(x, y); // Climb to one gap above the lowest pin in the way, then look again: // the spot up there may graze another one. y = lowestTop - gap - frame.height(); } x -= frame.width() + gap; } - return {screenSize.width() - margin - frame.width(), - screenSize.height() - margin - frame.height()}; + return std::nullopt; } PinInsertionPlan pinInsertionPlan(QVector> column, @@ -57,11 +58,13 @@ PinInsertionPlan pinInsertionPlan(QVector> column, QVector packed; QVector packedCenters; for (const auto &pair : column) { - const QPoint at = + const auto at = pinPackedPosition(seed, screenSize, pair.second.size(), gap, margin); - seed.push_back(QRect(at, pair.second.size())); - packed.push_back(QRect(at, pair.second.size())); - packedCenters.push_back(at.y() + pair.second.height() / 2); + if (!at) + return {}; + seed.push_back(QRect(*at, pair.second.size())); + packed.push_back(QRect(*at, pair.second.size())); + packedCenters.push_back(at->y() + pair.second.height() / 2); } // Touching any part of the stack joins it; fully outside stays out. The // stack includes the open spot on top, which is where a pin dragged off @@ -69,9 +72,9 @@ PinInsertionPlan pinInsertionPlan(QVector> column, // dragged fully past where it would sit. For an empty column that spot // is the corner itself. QVector stack = packed; - stack.push_back( - QRect(pinPackedPosition(seed, screenSize, dragged.size(), gap, margin), - dragged.size())); + const auto vacant = pinPackedPosition(seed, screenSize, dragged.size(), gap, margin); + if (vacant) + stack.push_back(QRect(*vacant, dragged.size())); // The pins' live positions count too: a stack that has not packed down // yet is still the stack the user sees and aims for. for (const auto &pair : column) @@ -95,21 +98,27 @@ PinInsertionPlan pinInsertionPlan(QVector> column, seed = blockers; for (int position = 0; position < column.size(); ++position) { if (position == index) { - const QPoint at = + const auto at = pinPackedPosition(seed, screenSize, dragged.size(), gap, margin); - plan.spot = QRect(at, dragged.size()); + if (!at) + return {}; + plan.spot = QRect(*at, dragged.size()); seed.push_back(plan.spot); } const auto &pair = column.at(position); - const QPoint at = + const auto at = pinPackedPosition(seed, screenSize, pair.second.size(), gap, margin); - seed.push_back(QRect(at, pair.second.size())); - plan.spread.push_back({pair.first, QRect(at, pair.second.size())}); + if (!at) + return {}; + seed.push_back(QRect(*at, pair.second.size())); + plan.spread.push_back({pair.first, QRect(*at, pair.second.size())}); } if (index == column.size()) { - const QPoint at = + const auto at = pinPackedPosition(seed, screenSize, dragged.size(), gap, margin); - plan.spot = QRect(at, dragged.size()); + if (!at) + return {}; + plan.spot = QRect(*at, dragged.size()); } return plan; } @@ -145,19 +154,15 @@ QString pinMoveDispatch(const QString &title, int x, int y) { .arg(windowSelector(title)); } -QString pinSwayArrangeCommand(const QString &title, int x, int y) { - return QStringLiteral("[title=\"^%1$\"] floating enable, sticky enable, " - "move absolute position %2 %3") - .arg(title) - .arg(x) - .arg(y); -} - -QString pinSwayMoveCommand(const QString &title, int x, int y) { - return QStringLiteral("[title=\"^%1$\"] move absolute position %2 %3") - .arg(title) - .arg(x) - .arg(y); +QRect pinMonitorGeometry(const QJsonObject &monitor) { + const qreal scale = std::max(0.0001, monitor.value(QStringLiteral("scale")).toDouble(1)); + QSize pixels(monitor.value(QStringLiteral("width")).toInt(), + monitor.value(QStringLiteral("height")).toInt()); + if (monitor.value(QStringLiteral("transform")).toInt() % 2 != 0) + pixels.transpose(); + return {monitor.value(QStringLiteral("x")).toInt(), + monitor.value(QStringLiteral("y")).toInt(), + qRound(pixels.width() / scale), qRound(pixels.height() / scale)}; } QString pinControlTip(int index) { diff --git a/src/pin-layout.hpp b/src/pin-layout.hpp index 2919cd35..79743504 100644 --- a/src/pin-layout.hpp +++ b/src/pin-layout.hpp @@ -2,6 +2,8 @@ #pragma once #include +#include +#include #include #include #include @@ -19,7 +21,8 @@ /// is full. Blockers can be any size; the pin packs against what is /// actually there rather than onto a grid that wastes a slot for every /// straddled boundary. -[[nodiscard]] QPoint pinPackedPosition(const QVector &blockers, +// Returns no position when the output is full or smaller than the frame. +[[nodiscard]] std::optional pinPackedPosition(const QVector &blockers, const QSize &screenSize, const QSize &frame, int gap, int margin); @@ -51,8 +54,8 @@ pinInsertionPlan(QVector> column, [[nodiscard]] QString pinFloatDispatch(const QString &title); [[nodiscard]] QString pinPinDispatch(const QString &title); [[nodiscard]] QString pinMoveDispatch(const QString &title, int x, int y); -[[nodiscard]] QString pinSwayArrangeCommand(const QString &title, int x, int y); -[[nodiscard]] QString pinSwayMoveCommand(const QString &title, int x, int y); +/// Global logical geometry, including scale and quarter-turn transforms. +[[nodiscard]] QRect pinMonitorGeometry(const QJsonObject &monitor); /** The hover tip for a pin control, empty outside the known controls. */ [[nodiscard]] QString pinControlTip(int index); diff --git a/src/pin.cpp b/src/pin.cpp index 51eeab57..f527017a 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -1,3 +1,9 @@ +#include +#include +#include +#include +#include +#include #include "pin.hpp" #include "capture.hpp" #include "pin-file.hpp" @@ -63,204 +69,180 @@ QString pinTitle() { QCoreApplication::applicationPid()); } -// The compositors a pin knows how to ask for placement. Wayland has no -// protocol for a window to position itself, so corner placement goes -// through compositor IPC; anywhere else the pin still opens, drags, edits, -// copies and drags out, it just lands where the compositor decides. -enum class Desktop { Hyprland, Sway, Unknown }; - -Desktop detectDesktop() { - if (qEnvironmentVariableIsSet("HYPRLAND_INSTANCE_SIGNATURE")) - return Desktop::Hyprland; - if (qEnvironmentVariableIsSet("SWAYSOCK")) - return Desktop::Sway; - return Desktop::Unknown; +// A single worker preserves each process's dispatch order. Cross-process +// placement is protected separately by the runtime transaction below. +QThreadPool &pinPool() { + static QThreadPool pool; + pool.setMaxThreadCount(1); + return pool; } QString runForOutput(const QString &program, const QStringList &arguments) { QProcess process; process.start(program, arguments); - if (!process.waitForFinished(1000)) + if (!process.waitForFinished(500)) { + process.kill(); + process.waitForFinished(500); return {}; + } return QString::fromUtf8(process.readAllStandardOutput()); } -// Dispatchers rather than window rules: rules have to exist before a window -// maps and live in the user's config, and a pin should need neither. void hyprDispatch(const QString &expression) { static_cast(runForOutput(QStringLiteral("hyprctl"), {QStringLiteral("dispatch"), expression})); } -void swayCommand(const QString &command) { - static_cast(runForOutput(QStringLiteral("swaymsg"), {command})); -} - -// The focused output's size in logical pixels; windows are placed in -// logical pixels while Hyprland reports device ones. -QSize compositorScreenSize(Desktop desktop) { - if (desktop == Desktop::Hyprland) { - const QJsonDocument document = QJsonDocument::fromJson( - runForOutput(QStringLiteral("hyprctl"), - {QStringLiteral("-j"), QStringLiteral("monitors")}) - .toUtf8()); - for (const QJsonValue &value : document.array()) { - const QJsonObject monitor = value.toObject(); - if (!monitor.value(QStringLiteral("focused")).toBool()) - continue; - const double scale = - std::max(0.0001, monitor.value(QStringLiteral("scale")).toDouble(1.0)); - return {qRound(monitor.value(QStringLiteral("width")).toDouble() / scale), - qRound(monitor.value(QStringLiteral("height")).toDouble() / - scale)}; - } - return {}; - } - if (desktop == Desktop::Sway) { - const QJsonDocument document = QJsonDocument::fromJson( - runForOutput(QStringLiteral("swaymsg"), - {QStringLiteral("-t"), QStringLiteral("get_outputs"), - QStringLiteral("-r")}) - .toUtf8()); - for (const QJsonValue &value : document.array()) { - const QJsonObject output = value.toObject(); - if (!output.value(QStringLiteral("focused")).toBool()) - continue; - const QJsonObject rect = output.value(QStringLiteral("rect")).toObject(); - return {rect.value(QStringLiteral("width")).toInt(), - rect.value(QStringLiteral("height")).toInt()}; - } - } - return {}; +QRect compositorScreenRect(const QPoint &point = {}, bool usePoint = false) { + const QJsonArray monitors = QJsonDocument::fromJson( + runForOutput(QStringLiteral("hyprctl"), + {QStringLiteral("-j"), QStringLiteral("monitors")}).toUtf8()).array(); + QRect focused; + for (const QJsonValue &value : monitors) { + const QJsonObject monitor = value.toObject(); + const QRect geometry = pinMonitorGeometry(monitor); + if (usePoint && geometry.contains(point)) + return geometry; + if (monitor.value(QStringLiteral("focused")).toBool()) + focused = geometry; + } + return focused; } struct CompositorPin { QString title; QRect rect; + bool floating = false; + bool pinned = false; }; -// Where every pin currently sits, by title; the title is how a move -// addresses one pin and not the others. -QVector compositorPinRects(Desktop desktop) { +QVector compositorPinRects() { QVector pins; - if (desktop == Desktop::Hyprland) { - const QJsonDocument document = QJsonDocument::fromJson( - runForOutput(QStringLiteral("hyprctl"), - {QStringLiteral("-j"), QStringLiteral("clients")}) - .toUtf8()); - for (const QJsonValue &value : document.array()) { - const QJsonObject client = value.toObject(); - const QString title = client.value(QStringLiteral("title")).toString(); - if (!title.startsWith(kPinTitlePrefix)) - continue; - const QJsonArray at = client.value(QStringLiteral("at")).toArray(); - const QJsonArray size = client.value(QStringLiteral("size")).toArray(); - if (at.size() == 2 && size.size() == 2) { - pins.push_back({title, QRect(at.at(0).toInt(), at.at(1).toInt(), - size.at(0).toInt(), size.at(1).toInt())}); - } - } - return pins; - } - if (desktop == Desktop::Sway) { - // The tree is nested: a floating pin hangs off a workspace's floating - // list rather than sitting beside the tiled windows. - const QJsonDocument document = QJsonDocument::fromJson( - runForOutput(QStringLiteral("swaymsg"), - {QStringLiteral("-t"), QStringLiteral("get_tree"), - QStringLiteral("-r")}) - .toUtf8()); - QVector pending{document.object()}; - while (!pending.isEmpty()) { - const QJsonObject node = pending.takeLast(); - const QString name = node.value(QStringLiteral("name")).toString(); - if (name.startsWith(kPinTitlePrefix)) { - const QJsonObject rect = node.value(QStringLiteral("rect")).toObject(); - pins.push_back({name, QRect(rect.value(QStringLiteral("x")).toInt(), - rect.value(QStringLiteral("y")).toInt(), - rect.value(QStringLiteral("width")).toInt(), - rect.value(QStringLiteral("height")) - .toInt())}); - } - for (const char *key : {"nodes", "floating_nodes"}) { - for (const QJsonValue &child : - node.value(QLatin1String(key)).toArray()) - pending.push_back(child.toObject()); - } - } + const QJsonArray clients = QJsonDocument::fromJson( + runForOutput(QStringLiteral("hyprctl"), + {QStringLiteral("-j"), QStringLiteral("clients")}).toUtf8()).array(); + for (const QJsonValue &value : clients) { + const QJsonObject client = value.toObject(); + const QString title = client.value(QStringLiteral("title")).toString(); + if (!title.startsWith(kPinTitlePrefix + QLatin1Char(' '))) + continue; + const QJsonArray at = client.value(QStringLiteral("at")).toArray(); + const QJsonArray size = client.value(QStringLiteral("size")).toArray(); + if (at.size() == 2 && size.size() == 2) + pins.push_back({title, QRect(at.at(0).toInt(), at.at(1).toInt(), + size.at(0).toInt(), size.at(1).toInt()), + client.value(QStringLiteral("floating")).toBool(), + client.value(QStringLiteral("pinned")).toBool()}); } return pins; } -bool compositorSeesPin(Desktop desktop, const QString &title) { - for (const CompositorPin &pin : compositorPinRects(desktop)) { - if (pin.title == title) - return true; +// Dispatch completion precedes the compositor's animation. Reserve targets +// under one lock so another pin cannot claim the same corner in that gap. +// Reservations disappear once reached, on drag, or after a bounded timeout; +// they are never a persistent substitute for actual compositor geometry. +class PinPlacement { +public: + PinPlacement() : root_(secureRuntimeDirectory()), + lock_(QDir(root_).filePath(QStringLiteral("pin-placement.lock"))) { + ready_ = !root_.isEmpty() && lock_.tryLock(1000); + if (!ready_) + return; + QFile file(QDir(root_).filePath(QStringLiteral("pin-targets.json"))); + if (file.open(QIODevice::ReadOnly)) + targets_ = QJsonDocument::fromJson(file.readAll()).object(); + pins = compositorPinRects(); + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + for (const QString &title : targets_.keys()) { + const QJsonObject target = targets_.value(title).toObject(); + const QRect rect(target.value(QStringLiteral("x")).toInt(), + target.value(QStringLiteral("y")).toInt(), + target.value(QStringLiteral("w")).toInt(), + target.value(QStringLiteral("h")).toInt()); + auto pin = std::find_if(pins.begin(), pins.end(), [&](const CompositorPin &p) { + return p.title == title; + }); + if (now - target.value(QStringLiteral("time")).toInteger() > 5000 || + pin == pins.end() || pin->rect == rect) { + targets_.remove(title); + } else { + pin->rect = rect; + } + } } - return false; -} - -void movePin(Desktop desktop, const QString &title, const QPoint &position) { - if (desktop == Desktop::Hyprland) - hyprDispatch(pinMoveDispatch(title, position.x(), position.y())); - else if (desktop == Desktop::Sway) - swayCommand(pinSwayMoveCommand(title, position.x(), position.y())); -} + bool ready() const { return ready_; } + void release(const QString &title) { + targets_.remove(title); + save(); + } + bool move(const QString &title, const QRect &rect) { + targets_.insert(title, QJsonObject{{QStringLiteral("x"), rect.x()}, + {QStringLiteral("y"), rect.y()}, + {QStringLiteral("w"), rect.width()}, + {QStringLiteral("h"), rect.height()}, + {QStringLiteral("time"), QDateTime::currentMSecsSinceEpoch()}}); + if (!save()) + return false; + hyprDispatch(pinMoveDispatch(title, rect.x(), rect.y())); + return true; + } + QVector pins; +private: + bool save() { + QSaveFile file(QDir(root_).filePath(QStringLiteral("pin-targets.json"))); + const QByteArray data = QJsonDocument(targets_).toJson(QJsonDocument::Compact); + return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() && file.commit(); + } + QString root_; + QLockFile lock_; + QJsonObject targets_; + bool ready_ = false; +}; -// Where a new pin lands: ask the compositor where the existing pins are -// rather than keeping a count. Every pin is its own process, a file of -// positions goes stale the first time one crashes, and every pin on screen -// blocks the space it covers, whatever its shape, so a new pin packs snugly -// above what is there and never lands on a pin the user placed or an older -// build left behind. -QPoint nextPinPosition(Desktop desktop, const QSize &screen, - const QSize &frame, const QString &ownTitle) { - QVector blockers; - for (const CompositorPin &pin : compositorPinRects(desktop)) { - if (pin.title != ownTitle) - blockers.push_back(pin.rect); - } - return pinPackedPosition(blockers, screen, frame, kPinGap, - qRound(kCornerMargin)); +void movePin(const QString &title, const QRect &target) { + static_cast(QtConcurrent::run(&pinPool(), [title, target] { + PinPlacement placement; + if (placement.ready()) + placement.move(title, target); + })); } -// A pin left the column: pack the survivors back down, keeping their order. -// Only pins still hugging the right edge take part; one dragged elsewhere -// is left alone and packed around. `excludedTitle` names one to leave out -// even if the compositor still lists it, which it may while that pin is -// closing. -void compactPinColumn(Desktop desktop, const QString &excludedTitle) { - const QSize screen = compositorScreenSize(desktop); - if (screen.isEmpty()) - return; - QVector column; - QVector blockers; - for (const CompositorPin &pin : compositorPinRects(desktop)) { - if (pin.title == excludedTitle) - continue; - if (pinInColumn(pin.rect, screen, qRound(kCornerMargin))) - column.push_back(pin); - else - blockers.push_back(pin.rect); - } - std::sort(column.begin(), column.end(), - [](const CompositorPin &a, const CompositorPin &b) { - return a.rect.y() > b.rect.y(); - }); - for (const CompositorPin &pin : column) { - const QPoint target = pinPackedPosition( - blockers, screen, pin.rect.size(), kPinGap, qRound(kCornerMargin)); - if ((target - pin.rect.topLeft()).manhattanLength() > 4) - movePin(desktop, pin.title, target); - blockers.push_back(QRect(target, pin.rect.size())); - } +void compactPinColumn(const QString &excludedTitle, const QRect &screen) { + static_cast(QtConcurrent::run(&pinPool(), [excludedTitle, screen] { + PinPlacement placement; + if (!placement.ready() || screen.isEmpty()) + return; + QVector column; + QVector blockers; + for (CompositorPin pin : placement.pins) { + if (pin.title == excludedTitle || !screen.intersects(pin.rect)) + continue; + pin.rect.translate(-screen.topLeft()); + if (pinInColumn(pin.rect, screen.size(), qRound(kCornerMargin))) + column.push_back(pin); + else + blockers.push_back(pin.rect); + } + std::sort(column.begin(), column.end(), [](const CompositorPin &a, const CompositorPin &b) { + return a.rect.y() > b.rect.y(); + }); + for (const CompositorPin &pin : column) { + const auto at = pinPackedPosition(blockers, screen.size(), pin.rect.size(), + kPinGap, qRound(kCornerMargin)); + if (!at) + return; + const QRect target(*at, pin.rect.size()); + if ((*at - pin.rect.topLeft()).manhattanLength() > 4) + placement.move(pin.title, target.translated(screen.topLeft())); + blockers.push_back(target); + } + })); } class PinWindow final : public QWidget { public: explicit PinWindow(QImage image, QString path, const QSize &frame) - : image_(std::move(image)), path_(std::move(path)), snapshotFile_(path_), - desktop_(detectDesktop()) { + : image_(std::move(image)), path_(std::move(path)), snapshotFile_(path_) { setWindowTitle(pinTitle()); setWindowFlags(Qt::Window | Qt::FramelessWindowHint); // Fixed, not merely sized: min equal to max is the hint a compositor @@ -270,9 +252,52 @@ class PinWindow final : public QWidget { setAttribute(Qt::WA_AlwaysShowToolTips, true); dragWatchTimer_.setInterval(80); connect(&dragWatchTimer_, &QTimer::timeout, this, - [this] { observeDrag(); }); + [this] { requestDragSnapshot(); }); + } + + void setPlacementSnapshot(const QRect &screen, const QVector &pins) { + dragScreen_ = screen; + cachedPins_ = pins; + } + + void requestDragSnapshot() { + if (queryPending_) + return; + queryPending_ = true; + using Snapshot = QPair>; + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher] { + const auto snapshot = watcher->result(); + cachedPins_ = snapshot.second; + if (!snapshot.first.isEmpty() && snapshot.first != dragScreen_) { + compactPinColumn(windowTitle(), dragScreen_); + dragScreen_ = snapshot.first; + commandedTargets_.clear(); + } + watcher->deleteLater(); + queryPending_ = false; + if (closing_) + return; + if (finishRequested_) { + finishRequested_ = false; + finishDragFromSnapshot(); + } else if (dragWatchTimer_.isActive()) { + observeDrag(); + } + }); + const QString title = windowTitle(); + watcher->setFuture(QtConcurrent::run(&pinPool(), [title]() -> Snapshot { + const auto pins = compositorPinRects(); + for (const CompositorPin &pin : pins) { + if (pin.title == title) + return {compositorScreenRect(pin.rect.center(), true), pins}; + } + return {{}, pins}; + })); } + ~PinWindow() override { closeButtonWatch(); } + [[nodiscard]] bool hasPinLock() const { return snapshotFile_.isLocked(); } protected: @@ -375,12 +400,16 @@ class PinWindow final : public QWidget { // that never moves the window was a click and times out instead. Either // way the column closes the gap behind a pin that was dragged away. void beginDragWatch() { - if (desktop_ == Desktop::Unknown) - return; + const QString title = windowTitle(); + static_cast(QtConcurrent::run(&pinPool(), [title] { + PinPlacement placement; + if (placement.ready()) + placement.release(title); + })); dragStartRect_ = ownCompositorRect(); if (dragStartRect_.isNull()) return; - dragScreen_ = compositorScreenSize(desktop_); + dragPreviousRect_ = {}; commandedTargets_.clear(); dragMoved_ = false; @@ -400,7 +429,7 @@ class PinWindow final : public QWidget { dragWatchTimer_.stop(); closeButtonWatch(); if (spreadActive_) - compactPinColumn(desktop_, windowTitle()); + compactPinColumn(windowTitle(), dragScreen_); spreadActive_ = false; return; } @@ -451,13 +480,7 @@ class PinWindow final : public QWidget { if (events[index].type != EV_KEY || events[index].code != BTN_LEFT || events[index].value != 0 || !dragWatchTimer_.isActive()) continue; - const QRect rect = ownCompositorRect(); - dragMoved_ = - dragMoved_ || (!rect.isNull() && rect != dragStartRect_); - if (dragMoved_) - finishDrag(); - else - dragWatchTimer_.stop(); + finishDrag(); closeButtonWatch(); return; } @@ -480,28 +503,28 @@ class PinWindow final : public QWidget { void pointerWokeDuringWatch() { if (!dragWatchTimer_.isActive()) return; - const QRect rect = ownCompositorRect(); - dragMoved_ = dragMoved_ || (!rect.isNull() && rect != dragStartRect_); - if (!dragMoved_) { - // A click, not a drag: nothing moved, nothing to restore. - dragWatchTimer_.stop(); - return; - } finishDrag(); } void finishDrag() { + finishRequested_ = true; + requestDragSnapshot(); + } + + void finishDragFromSnapshot() { dragWatchTimer_.stop(); closeButtonWatch(); // One last look at the true final position; the last poll can be a // frame behind it. const QRect rect = ownCompositorRect(); + if (!dragMoved_ && rect == dragStartRect_) + return; if (!rect.isNull()) previewInsertion(rect); if (!snapSpot_.isNull()) - movePin(desktop_, windowTitle(), snapSpot_.topLeft()); + movePin(windowTitle(), snapSpot_); else - compactPinColumn(desktop_, QString()); + compactPinColumn(QString(), dragScreen_); spreadActive_ = false; } @@ -512,19 +535,19 @@ class PinWindow final : public QWidget { return; QVector> column; QVector blockers; - for (const CompositorPin &pin : compositorPinRects(desktop_)) { - if (pin.title == windowTitle()) + for (const CompositorPin &pin : cachedPins_) { + if (pin.title == windowTitle() || !dragScreen_.intersects(pin.rect)) continue; - if (pinInColumn(pin.rect, dragScreen_, qRound(kCornerMargin))) - column.push_back({pin.title, pin.rect}); + if (pinInColumn(pin.rect.translated(-dragScreen_.topLeft()), dragScreen_.size(), qRound(kCornerMargin))) + column.push_back({pin.title, pin.rect.translated(-dragScreen_.topLeft())}); else - blockers.push_back(pin.rect); + blockers.push_back(pin.rect.translated(-dragScreen_.topLeft())); } const PinInsertionPlan plan = pinInsertionPlan( - column, blockers, rect, dragScreen_, kPinGap, qRound(kCornerMargin)); + column, blockers, rect.translated(-dragScreen_.topLeft()), dragScreen_.size(), kPinGap, qRound(kCornerMargin)); if (plan.index < 0) { if (spreadActive_) - compactPinColumn(desktop_, windowTitle()); + compactPinColumn(windowTitle(), dragScreen_); spreadActive_ = false; snapSpot_ = {}; commandedTargets_.clear(); @@ -537,16 +560,16 @@ class PinWindow final : public QWidget { for (const auto &[title, target] : plan.spread) { if (commandedTargets_.value(title, QPoint(INT_MIN, INT_MIN)) != target.topLeft()) { - movePin(desktop_, title, target.topLeft()); + movePin(title, target.translated(dragScreen_.topLeft())); commandedTargets_.insert(title, target.topLeft()); } } spreadActive_ = true; - snapSpot_ = plan.spot; + snapSpot_ = plan.spot.translated(dragScreen_.topLeft()); } [[nodiscard]] QRect ownCompositorRect() const { - for (const CompositorPin &pin : compositorPinRects(desktop_)) { + for (const CompositorPin &pin : cachedPins_) { if (pin.title == windowTitle()) return pin.rect; } @@ -616,7 +639,7 @@ class PinWindow final : public QWidget { } void wheelEvent(QWheelEvent *event) override { - // Pinned captures deliberately keep a stable 250x200 frame so the + // Pinned captures deliberately keep a stable display-shaped frame so the // controls remain usable and the image area never reflows. event->accept(); } @@ -637,9 +660,12 @@ class PinWindow final : public QWidget { } void closeEvent(QCloseEvent *event) override { + closing_ = true; + dragWatchTimer_.stop(); + closeButtonWatch(); // The compositor may still list this window while it closes, so it is // excluded by name rather than trusted to be gone. - compactPinColumn(desktop_, windowTitle()); + compactPinColumn(windowTitle(), dragScreen_); QWidget::closeEvent(event); } @@ -700,11 +726,14 @@ class PinWindow final : public QWidget { QImage image_; QString path_; PinSnapshotFile snapshotFile_; - Desktop desktop_; + QVector cachedPins_; + bool closing_ = false; + bool queryPending_ = false; + bool finishRequested_ = false; QTimer dragWatchTimer_; QRect dragStartRect_; QRect dragPreviousRect_; - QSize dragScreen_; + QRect dragScreen_; QHash commandedTargets_; QVector> buttonWatches_; QRect snapSpot_; @@ -726,55 +755,61 @@ int runPinnedCapture(const QString &path) { return 1; } - const Desktop desktop = detectDesktop(); - PinWindow window(std::move(image), path, - pinFrameSize(compositorScreenSize(desktop))); + const QRect screen = compositorScreenRect(); + PinWindow window(std::move(image), path, pinFrameSize(screen.size())); if (!window.hasPinLock()) { qWarning("omasnap: could not lock pinned image %s", qUtf8Printable(path)); return 1; } window.show(); - - // A normal window, floated and pinned through the compositor, instead of - // a layer surface: the compositor draws its frame, moves it, and keeps it - // on every workspace. Not immediately though: showing is this side's word - // for mapped, and the compositor has not necessarily registered the - // window under its title yet; dispatches sent then report success and do - // nothing, which leaves a pin centered and unpinned. Retry until the - // client list has it, then float first (a tiled window has no position of - // its own to set), pin it, and drop it into the lowest free slot. - if (desktop != Desktop::Unknown) { - auto attempts = std::make_shared(0); - QTimer *settle = new QTimer(&window); - settle->setInterval(50); - QObject::connect(settle, &QTimer::timeout, &window, - [&window, desktop, settle, attempts] { - ++*attempts; - if (!compositorSeesPin(desktop, window.windowTitle())) { - if (*attempts >= 10) - settle->stop(); - return; - } - settle->stop(); - const QString title = window.windowTitle(); - if (desktop == Desktop::Hyprland) { - hyprDispatch(pinFloatDispatch(title)); - hyprDispatch(pinPinDispatch(title)); - } - const QSize screen = compositorScreenSize(desktop); - if (screen.isEmpty()) - return; - const QPoint origin = - nextPinPosition(desktop, screen, window.size(), - window.windowTitle()); - if (desktop == Desktop::Hyprland) { - movePin(desktop, title, origin); - } else { - swayCommand(pinSwayArrangeCommand(title, origin.x(), - origin.y())); - } - }); - settle->start(); - } + auto *settle = new QTimer(&window); + settle->setSingleShot(true); + settle->setInterval(50); + using PlacementResult = QPair>; + auto *watcher = new QFutureWatcher(&window); + QObject::connect(watcher, &QFutureWatcher::finished, &window, + [&window, watcher, settle, attempts = 0]() mutable { + const auto result = watcher->result(); + if (!result.first.isEmpty()) { + window.setPlacementSnapshot(result.first, result.second); + watcher->deleteLater(); + settle->deleteLater(); + } else if (++attempts < 10) { + settle->start(); + } else { + watcher->deleteLater(); + settle->deleteLater(); + } + }); + QObject::connect(settle, &QTimer::timeout, &window, [&window, watcher, screen] { + const QString title = window.windowTitle(); + const QSize frame = window.size(); + watcher->setFuture(QtConcurrent::run(&pinPool(), [title, frame, screen]() -> PlacementResult { + PinPlacement placement; + if (!placement.ready() || screen.isEmpty()) + return {}; + auto own = std::find_if(placement.pins.begin(), placement.pins.end(), + [&](const CompositorPin &pin) { return pin.title == title; }); + if (own == placement.pins.end()) + return {}; + if (!own->floating) + hyprDispatch(pinFloatDispatch(title)); + if (!own->pinned) + hyprDispatch(pinPinDispatch(title)); + QVector blockers; + for (const CompositorPin &pin : placement.pins) { + if (pin.title != title && screen.intersects(pin.rect)) + blockers.push_back(pin.rect.translated(-screen.topLeft())); + } + const auto at = pinPackedPosition(blockers, screen.size(), frame, + kPinGap, qRound(kCornerMargin)); + if (at) { + own->rect = QRect(*at + screen.topLeft(), frame); + placement.move(title, own->rect); + } + return {screen, placement.pins}; + })); + }); + settle->start(); return QApplication::exec(); } diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index f1665b11..0702fb1f 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -39,13 +39,13 @@ bool runPinLayoutSmoke(QString &error) { // An empty corner takes the first pin snug against the margins; the next // ones pack one gap above whatever is there, whatever its size, and a // full column starts a new one to the left. - const QPoint first = pinPackedPosition({}, screen, pin, 10, 14); + const QPoint first = pinPackedPosition({}, screen, pin, 10, 14).value_or(QPoint()); if (first != QPoint(286, 206)) { error = QStringLiteral("The first pin did not land in the corner"); return false; } const QPoint second = - pinPackedPosition({QRect(first, pin)}, screen, pin, 10, 14); + pinPackedPosition({QRect(first, pin)}, screen, pin, 10, 14).value_or(QPoint()); if (second != QPoint(286, 116)) { error = QStringLiteral("The second pin did not pack above the first"); return false; @@ -172,7 +172,7 @@ bool runPinLayoutSmoke(QString &error) { } // The dispatch expressions are Lua for a Lua-configured Hyprland and the - // classic criteria grammar for sway; a placement that silently does + // a placement that silently does // nothing is exactly the failure these guard. const QString title = QStringLiteral("omasnap-pin 1234"); if (pinFloatDispatch(title) != @@ -187,13 +187,35 @@ bool runPinLayoutSmoke(QString &error) { error = QStringLiteral("Hyprland dispatch expressions were malformed"); return false; } - if (pinSwayArrangeCommand(title, 120, 40) != - QStringLiteral("[title=\"^omasnap-pin 1234$\"] floating enable, " - "sticky enable, move absolute position 120 40") || - pinSwayMoveCommand(title, 120, 40) != - QStringLiteral( - "[title=\"^omasnap-pin 1234$\"] move absolute position 120 40")) { - error = QStringLiteral("Sway commands were malformed"); + QVector occupied; + const QSize wide(2400, 110); + for (int index = 0; index < 21; ++index) { + const auto at = pinPackedPosition(occupied, wide, pin, 10, 14); + if (!at || !QRect(QPoint(), wide).contains(QRect(*at, pin))) { + error = QStringLiteral("Packing stopped before all visible columns were used"); + return false; + } + for (const QRect &blocker : occupied) { + if (blocker.intersects(QRect(*at, pin))) { + error = QStringLiteral("Packing reused an occupied slot"); + return false; + } + } + occupied.push_back(QRect(*at, pin)); + } + if (pinPackedPosition(occupied, wide, pin, 10, 14) || + pinPackedPosition({}, QSize(90, 70), pin, 10, 14)) { + error = QStringLiteral("A full or undersized output returned an unsafe slot"); + return false; + } + const QJsonObject rotated{{QStringLiteral("x"), -1080}, + {QStringLiteral("y"), 200}, + {QStringLiteral("width"), 3840}, + {QStringLiteral("height"), 2160}, + {QStringLiteral("scale"), 2}, + {QStringLiteral("transform"), 1}}; + if (pinMonitorGeometry(rotated) != QRect(-1080, 200, 1080, 1920)) { + error = QStringLiteral("Pin monitor geometry lost origin, scale or transform"); return false; } return true; From 4fda8f7e7c26927f8807614659a236443d5f9d24 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 6 Sep 2026 19:34:18 -0500 Subject: [PATCH 07/10] fix(pin): parse shell arguments consistently and defer monitor lookup --- docs/threading.md | 3 +- src/cli-path.cpp | 68 +++++++++++++++++++++++++ src/cli-path.hpp | 4 ++ src/main.cpp | 101 ++++++++----------------------------- src/pin.cpp | 21 ++++++-- tests/pin-layout-smoke.cpp | 23 +++++++-- 6 files changed, 133 insertions(+), 87 deletions(-) diff --git a/docs/threading.md b/docs/threading.md index 858a27d2..8fab629b 100644 --- a/docs/threading.md +++ b/docs/threading.md @@ -137,4 +137,5 @@ 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 is bounded and runs before mapping. +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. diff --git a/src/cli-path.cpp b/src/cli-path.cpp index d5698725..97c5f77e 100644 --- a/src/cli-path.cpp +++ b/src/cli-path.cpp @@ -1,6 +1,9 @@ /** @fileoverview Resolves local image targets accepted by the command line. */ #include "cli-path.hpp" +#include +#include + #include #include @@ -22,3 +25,68 @@ QString resolveLocalImagePath(const QString &target) { const QFileInfo file(path); return file.isFile() ? file.absoluteFilePath() : QString{}; } + +void configureCaptureCommandLine(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]")); +} diff --git a/src/cli-path.hpp b/src/cli-path.hpp index 51443077..5ec2ec34 100644 --- a/src/cli-path.hpp +++ b/src/cli-path.hpp @@ -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); diff --git a/src/main.cpp b/src/main.cpp index 0e049d3b..83c506f5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -103,9 +103,13 @@ int main(int argc, char **argv) { // 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. - bool pinInvocation = false; - for (int index = 1; index < argc; ++index) - pinInvocation = pinInvocation || qstrcmp(argv[index], "--pin") == 0; + QStringList rawArguments; + for (int index = 0; index < argc; ++index) + rawArguments.push_back(QString::fromLocal8Bit(argv[index])); + QCommandLineParser startupParser; + configureCaptureCommandLine(startupParser); + const bool pinInvocation = startupParser.parse(rawArguments) && + startupParser.isSet(QStringLiteral("pin")); if (pinInvocation) { // Unset rather than merely not set: a pin spawned from the editor // inherits the editor's environment, layer-shell included. @@ -138,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) { diff --git a/src/pin.cpp b/src/pin.cpp index f527017a..04c437a6 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -755,8 +755,7 @@ int runPinnedCapture(const QString &path) { return 1; } - const QRect screen = compositorScreenRect(); - PinWindow window(std::move(image), path, pinFrameSize(screen.size())); + PinWindow window(std::move(image), path, pinFrameSize({})); if (!window.hasPinLock()) { qWarning("omasnap: could not lock pinned image %s", qUtf8Printable(path)); return 1; @@ -781,10 +780,12 @@ int runPinnedCapture(const QString &path) { settle->deleteLater(); } }); + auto screen = std::make_shared(); QObject::connect(settle, &QTimer::timeout, &window, [&window, watcher, screen] { const QString title = window.windowTitle(); const QSize frame = window.size(); - watcher->setFuture(QtConcurrent::run(&pinPool(), [title, frame, screen]() -> PlacementResult { + const QRect geometry = *screen; + watcher->setFuture(QtConcurrent::run(&pinPool(), [title, frame, screen = geometry]() -> PlacementResult { PinPlacement placement; if (!placement.ready() || screen.isEmpty()) return {}; @@ -810,6 +811,18 @@ int runPinnedCapture(const QString &path) { return {screen, placement.pins}; })); }); - settle->start(); + auto *monitor = new QFutureWatcher(&window); + QObject::connect(monitor, &QFutureWatcher::finished, &window, + [&window, monitor, settle, screen] { + *screen = monitor->result(); + monitor->deleteLater(); + if (screen->isEmpty()) + return; + window.setFixedSize(pinFrameSize(screen->size())); + settle->start(); + }); + monitor->setFuture(QtConcurrent::run(&pinPool(), [] { + return compositorScreenRect(); + })); return QApplication::exec(); } diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index 0702fb1f..f7a526e6 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -2,6 +2,8 @@ #include "pin-layout-smoke.hpp" #include "pin-layout.hpp" +#include "cli-path.hpp" +#include #include @@ -33,6 +35,22 @@ bool runPinLayoutSmoke(QString &error) { return false; } + const QList> invocations{ + {{QStringLiteral("--pin"), QStringLiteral("image.png")}, true}, + {{QStringLiteral("--pin=image.png")}, true}, + {{QStringLiteral("--"), QStringLiteral("--pin")}, false}, + {{QStringLiteral("--"), QStringLiteral("--pin=image.png")}, false}, + {{QStringLiteral("--file"), QStringLiteral("--pin")}, false}}; + for (const auto &[arguments, pinned] : invocations) { + QCommandLineParser parser; + configureCaptureCommandLine(parser); + if (!parser.parse(QStringList{QStringLiteral("omasnap")} + arguments) || + parser.isSet(QStringLiteral("pin")) != pinned) { + error = QStringLiteral("Pin argv selected the wrong Wayland shell"); + return false; + } + } + const QSize screen(400, 300); const QSize pin(100, 80); @@ -171,9 +189,8 @@ bool runPinLayoutSmoke(QString &error) { return false; } - // The dispatch expressions are Lua for a Lua-configured Hyprland and the - // a placement that silently does - // nothing is exactly the failure these guard. + // The dispatch expressions are Lua for a Lua-configured Hyprland; + // a placement that silently does nothing is exactly the failure these guard. const QString title = QStringLiteral("omasnap-pin 1234"); if (pinFloatDispatch(title) != QStringLiteral( From 105ed97bb9f88b0ae245427b3355bccb5351a8ea Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 6 Sep 2026 19:48:54 -0500 Subject: [PATCH 08/10] Retry failed pin placement and accept Qt startup options --- src/cli-path.cpp | 23 ++++++++++++++++++++-- src/cli-path.hpp | 2 +- src/main.cpp | 2 +- src/pin-layout.cpp | 9 +++++---- src/pin.cpp | 40 +++++++++++++++++++++++++------------- tests/pin-layout-smoke.cpp | 12 +++++++++++- 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/cli-path.cpp b/src/cli-path.cpp index 97c5f77e..ad403b5b 100644 --- a/src/cli-path.cpp +++ b/src/cli-path.cpp @@ -26,7 +26,26 @@ QString resolveLocalImagePath(const QString &target) { return file.isFile() ? file.absoluteFilePath() : QString{}; } -void configureCaptureCommandLine(QCommandLineParser &parser) { +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" @@ -76,7 +95,7 @@ void configureCaptureCommandLine(QCommandLineParser &parser) { parser.addOption(clipboardOption); const QCommandLineOption pinOption( QStringLiteral("pin"), - QStringLiteral("Show an image as a pinned always-visible layer."), + QStringLiteral("Show an image as a floating window pinned on every workspace."), QStringLiteral("path")); parser.addOption(pinOption); const QCommandLineOption scrollOption( diff --git a/src/cli-path.hpp b/src/cli-path.hpp index 5ec2ec34..2e4e5673 100644 --- a/src/cli-path.hpp +++ b/src/cli-path.hpp @@ -8,4 +8,4 @@ QString resolveLocalImagePath(const QString &target); class QCommandLineParser; /** Shared by the pre-QApplication shell choice and normal CLI validation. */ -void configureCaptureCommandLine(QCommandLineParser &parser); +void configureCaptureCommandLine(QCommandLineParser &parser, bool beforeQt = false); diff --git a/src/main.cpp b/src/main.cpp index 83c506f5..323c746f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -107,7 +107,7 @@ int main(int argc, char **argv) { for (int index = 0; index < argc; ++index) rawArguments.push_back(QString::fromLocal8Bit(argv[index])); QCommandLineParser startupParser; - configureCaptureCommandLine(startupParser); + configureCaptureCommandLine(startupParser, true); const bool pinInvocation = startupParser.parse(rawArguments) && startupParser.isSet(QStringLiteral("pin")); if (pinInvocation) { diff --git a/src/pin-layout.cpp b/src/pin-layout.cpp index 16bcac81..bed1d185 100644 --- a/src/pin-layout.cpp +++ b/src/pin-layout.cpp @@ -25,16 +25,17 @@ std::optional pinPackedPosition(const QVector &blockers, int y = screenSize.height() - margin - frame.height(); while (y >= margin) { const QRect candidate(x, y, frame.width(), frame.height()); - int lowestTop = -1; + std::optional lowestTop; for (const QRect &blocker : blockers) { if (candidate.intersects(blocker)) - lowestTop = std::max(lowestTop, blocker.top()); + lowestTop = lowestTop ? std::max(*lowestTop, blocker.top()) + : blocker.top(); } - if (lowestTop < 0) + if (!lowestTop) return QPoint(x, y); // Climb to one gap above the lowest pin in the way, then look again: // the spot up there may graze another one. - y = lowestTop - gap - frame.height(); + y = *lowestTop - gap - frame.height(); } x -= frame.width() + gap; } diff --git a/src/pin.cpp b/src/pin.cpp index 04c437a6..c7e71cd3 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -77,7 +77,10 @@ QThreadPool &pinPool() { return pool; } -QString runForOutput(const QString &program, const QStringList &arguments) { +QString runForOutput(const QString &program, const QStringList &arguments, + bool *ok = nullptr) { + if (ok) + *ok = false; QProcess process; process.start(program, arguments); if (!process.waitForFinished(500)) { @@ -85,12 +88,18 @@ QString runForOutput(const QString &program, const QStringList &arguments) { process.waitForFinished(500); return {}; } + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) + return {}; + if (ok) + *ok = true; return QString::fromUtf8(process.readAllStandardOutput()); } -void hyprDispatch(const QString &expression) { - static_cast(runForOutput(QStringLiteral("hyprctl"), - {QStringLiteral("dispatch"), expression})); +bool hyprDispatch(const QString &expression) { + bool ok = false; + const QString output = runForOutput(QStringLiteral("hyprctl"), + {QStringLiteral("dispatch"), expression}, &ok); + return ok && output.trimmed() == QStringLiteral("ok"); } QRect compositorScreenRect(const QPoint &point = {}, bool usePoint = false) { @@ -183,8 +192,11 @@ class PinPlacement { {QStringLiteral("time"), QDateTime::currentMSecsSinceEpoch()}}); if (!save()) return false; - hyprDispatch(pinMoveDispatch(title, rect.x(), rect.y())); - return true; + if (hyprDispatch(pinMoveDispatch(title, rect.x(), rect.y()))) + return true; + targets_.remove(title); + save(); + return false; } QVector pins; private: @@ -232,8 +244,9 @@ void compactPinColumn(const QString &excludedTitle, const QRect &screen) { if (!at) return; const QRect target(*at, pin.rect.size()); - if ((*at - pin.rect.topLeft()).manhattanLength() > 4) - placement.move(pin.title, target.translated(screen.topLeft())); + if ((*at - pin.rect.topLeft()).manhattanLength() > 4 && + !placement.move(pin.title, target.translated(screen.topLeft()))) + return; blockers.push_back(target); } })); @@ -793,10 +806,10 @@ int runPinnedCapture(const QString &path) { [&](const CompositorPin &pin) { return pin.title == title; }); if (own == placement.pins.end()) return {}; - if (!own->floating) - hyprDispatch(pinFloatDispatch(title)); - if (!own->pinned) - hyprDispatch(pinPinDispatch(title)); + if (!own->floating && !hyprDispatch(pinFloatDispatch(title))) + return {}; + if (!own->pinned && !hyprDispatch(pinPinDispatch(title))) + return {}; QVector blockers; for (const CompositorPin &pin : placement.pins) { if (pin.title != title && screen.intersects(pin.rect)) @@ -806,7 +819,8 @@ int runPinnedCapture(const QString &path) { kPinGap, qRound(kCornerMargin)); if (at) { own->rect = QRect(*at + screen.topLeft(), frame); - placement.move(title, own->rect); + if (!placement.move(title, own->rect)) + return {}; } return {screen, placement.pins}; })); diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index f7a526e6..b83ad753 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -38,12 +38,16 @@ bool runPinLayoutSmoke(QString &error) { const QList> invocations{ {{QStringLiteral("--pin"), QStringLiteral("image.png")}, true}, {{QStringLiteral("--pin=image.png")}, true}, + {{QStringLiteral("-platform"), QStringLiteral("offscreen"), + QStringLiteral("--pin=image.png")}, true}, + {{QStringLiteral("--pin"), QStringLiteral("image.png"), + QStringLiteral("-platformtheme"), QStringLiteral("gtk3")}, true}, {{QStringLiteral("--"), QStringLiteral("--pin")}, false}, {{QStringLiteral("--"), QStringLiteral("--pin=image.png")}, false}, {{QStringLiteral("--file"), QStringLiteral("--pin")}, false}}; for (const auto &[arguments, pinned] : invocations) { QCommandLineParser parser; - configureCaptureCommandLine(parser); + configureCaptureCommandLine(parser, true); if (!parser.parse(QStringList{QStringLiteral("omasnap")} + arguments) || parser.isSet(QStringLiteral("pin")) != pinned) { error = QStringLiteral("Pin argv selected the wrong Wayland shell"); @@ -81,6 +85,12 @@ bool runPinLayoutSmoke(QString &error) { error = QStringLiteral("A full column did not wrap to a new one"); return false; } + const QRect negativeTop(280, -20, 110, 310); + const auto besideNegative = pinPackedPosition({negativeTop}, screen, pin, 10, 14); + if (!besideNegative || QRect(*besideNegative, pin).intersects(negativeTop)) { + error = QStringLiteral("A negative-top blocker was ignored during packing"); + return false; + } const QRect elsewhere(QPoint(20, 20), QSize(100, 80)); if (pinPackedPosition({elsewhere}, screen, pin, 10, 14) != QPoint(286, 206)) { From de8299d91d44af438d2d655a1480b98371a6178c Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 6 Sep 2026 20:00:02 -0500 Subject: [PATCH 09/10] Manage wrapped pin columns and target exact compositor clients --- src/pin-layout.cpp | 59 ++++++++++++++++++++++++++------------ src/pin-layout.hpp | 12 ++++---- src/pin.cpp | 34 ++++++++++++++++------ tests/pin-layout-smoke.cpp | 30 ++++++++++++++----- 4 files changed, 95 insertions(+), 40 deletions(-) diff --git a/src/pin-layout.cpp b/src/pin-layout.cpp index bed1d185..fc1849dc 100644 --- a/src/pin-layout.cpp +++ b/src/pin-layout.cpp @@ -49,7 +49,14 @@ PinInsertionPlan pinInsertionPlan(QVector> column, int margin) { PinInsertionPlan plan; std::sort(column.begin(), column.end(), - [](const auto &a, const auto &b) { + [screenSize, gap, margin](const auto &a, const auto &b) { + const auto columnIndex = [screenSize, gap, margin](const QRect &rect) { + return qRound(qreal(screenSize.width() - margin - rect.right() - 1) / + (rect.width() + gap)); + }; + const int aColumn = columnIndex(a.second), bColumn = columnIndex(b.second); + if (aColumn != bColumn) + return aColumn < bColumn; return a.second.y() > b.second.y(); }); // The dragged pin's place in the order comes from its center against the @@ -57,7 +64,6 @@ PinInsertionPlan pinInsertionPlan(QVector> column, // positions, so the preview does not chase its own moves. QVector seed = blockers; QVector packed; - QVector packedCenters; for (const auto &pair : column) { const auto at = pinPackedPosition(seed, screenSize, pair.second.size(), gap, margin); @@ -65,7 +71,6 @@ PinInsertionPlan pinInsertionPlan(QVector> column, return {}; seed.push_back(QRect(*at, pair.second.size())); packed.push_back(QRect(*at, pair.second.size())); - packedCenters.push_back(at->y() + pair.second.height() / 2); } // Touching any part of the stack joins it; fully outside stays out. The // stack includes the open spot on top, which is where a pin dragged off @@ -90,9 +95,24 @@ PinInsertionPlan pinInsertionPlan(QVector> column, band |= rect; if (!dragged.intersects(band)) return plan; + // Choose the column with the largest horizontal overlap, then order + // vertically within it. Earlier columns remain ahead of the insertion. + int columnRight = screenSize.width() - margin - 1; + int overlap = 0; + for (const QRect &seat : stack) { + const int width = std::max(0, std::min(seat.right(), dragged.right()) - + std::max(seat.left(), dragged.left()) + 1); + if (width > overlap) { + overlap = width; + columnRight = seat.right(); + } + } int index = 0; - for (const int centerY : packedCenters) - index += centerY > dragged.center().y() ? 1 : 0; + for (const QRect &seat : packed) + if (seat.right() > columnRight + 6 || + (std::abs(seat.right() - columnRight) <= 6 && + seat.center().y() > dragged.center().y())) + ++index; plan.index = index; // Pack again with a dragged-sized hole at the insertion point. @@ -124,35 +144,38 @@ PinInsertionPlan pinInsertionPlan(QVector> column, return plan; } -bool pinInColumn(const QRect &rect, const QSize &screenSize, int margin) { +bool pinInColumn(const QRect &rect, const QSize &screenSize, int margin, int gap) { constexpr int tolerance = 6; - return std::abs(rect.right() + 1 - (screenSize.width() - margin)) <= - tolerance; + const int stride = rect.width() + gap; + if (stride <= 0 || rect.left() < margin - tolerance) + return false; + const int offset = screenSize.width() - margin - rect.right() - 1; + const int column = std::max(0, qRound(qreal(offset) / stride)); + return std::abs(offset - column * stride) <= tolerance; } namespace { -// The whole expression is one dispatch argument; the title has a space in -// it, so the selector is quoted inside the expression rather than around it. -QString windowSelector(const QString &title) { - return QStringLiteral("window = \"title:^(%1)$\"").arg(title); +// Address selectors identify the exact client already filtered by app class. +QString windowSelector(const QString &address) { + return QStringLiteral("window = \"address:%1\"").arg(address); } } // namespace -QString pinFloatDispatch(const QString &title) { +QString pinFloatDispatch(const QString &address) { return QStringLiteral("hl.dsp.window.float({ %1 })") - .arg(windowSelector(title)); + .arg(windowSelector(address)); } -QString pinPinDispatch(const QString &title) { - return QStringLiteral("hl.dsp.window.pin({ %1 })").arg(windowSelector(title)); +QString pinPinDispatch(const QString &address) { + return QStringLiteral("hl.dsp.window.pin({ %1 })").arg(windowSelector(address)); } -QString pinMoveDispatch(const QString &title, int x, int y) { +QString pinMoveDispatch(const QString &address, int x, int y) { return QStringLiteral( "hl.dsp.window.move({ x = %1, y = %2, relative = false, %3 })") .arg(x) .arg(y) - .arg(windowSelector(title)); + .arg(windowSelector(address)); } QRect pinMonitorGeometry(const QJsonObject &monitor) { diff --git a/src/pin-layout.hpp b/src/pin-layout.hpp index 79743504..355954d2 100644 --- a/src/pin-layout.hpp +++ b/src/pin-layout.hpp @@ -43,17 +43,17 @@ pinInsertionPlan(QVector> column, const QVector &blockers, const QRect &dragged, const QSize &screenSize, int gap, int margin); -/// Whether a pin still hugs the right edge column; dragging one away from -/// the edge takes it out of the column, and compaction leaves it alone. +/// Whether a pin hugs one of the packed columns. Freely placed pins are +/// left alone during compaction. [[nodiscard]] bool pinInColumn(const QRect &rect, const QSize &screenSize, - int margin); + int margin, int gap); /// Dispatch expressions for a Lua-configured Hyprland, which evaluates the /// dispatch argument as Lua; the classic dispatcher grammar parses as an /// expression there and fails while reporting success. -[[nodiscard]] QString pinFloatDispatch(const QString &title); -[[nodiscard]] QString pinPinDispatch(const QString &title); -[[nodiscard]] QString pinMoveDispatch(const QString &title, int x, int y); +[[nodiscard]] QString pinFloatDispatch(const QString &address); +[[nodiscard]] QString pinPinDispatch(const QString &address); +[[nodiscard]] QString pinMoveDispatch(const QString &address, int x, int y); /// Global logical geometry, including scale and quarter-turn transforms. [[nodiscard]] QRect pinMonitorGeometry(const QJsonObject &monitor); diff --git a/src/pin.cpp b/src/pin.cpp index c7e71cd3..39711f2a 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -120,6 +120,7 @@ QRect compositorScreenRect(const QPoint &point = {}, bool usePoint = false) { struct CompositorPin { QString title; + QString address; QRect rect; bool floating = false; bool pinned = false; @@ -133,12 +134,14 @@ QVector compositorPinRects() { for (const QJsonValue &value : clients) { const QJsonObject client = value.toObject(); const QString title = client.value(QStringLiteral("title")).toString(); - if (!title.startsWith(kPinTitlePrefix + QLatin1Char(' '))) + if (client.value(QStringLiteral("class")).toString() != QStringLiteral("omasnap") || + !title.startsWith(kPinTitlePrefix + QLatin1Char(' ')) || + client.value(QStringLiteral("address")).toString().isEmpty()) continue; const QJsonArray at = client.value(QStringLiteral("at")).toArray(); const QJsonArray size = client.value(QStringLiteral("size")).toArray(); if (at.size() == 2 && size.size() == 2) - pins.push_back({title, QRect(at.at(0).toInt(), at.at(1).toInt(), + pins.push_back({title, client.value(QStringLiteral("address")).toString(), QRect(at.at(0).toInt(), at.at(1).toInt(), size.at(0).toInt(), size.at(1).toInt()), client.value(QStringLiteral("floating")).toBool(), client.value(QStringLiteral("pinned")).toBool()}); @@ -185,6 +188,11 @@ class PinPlacement { save(); } bool move(const QString &title, const QRect &rect) { + const auto pin = std::find_if(pins.cbegin(), pins.cend(), [&](const CompositorPin &p) { + return p.title == title; + }); + if (pin == pins.cend()) + return false; targets_.insert(title, QJsonObject{{QStringLiteral("x"), rect.x()}, {QStringLiteral("y"), rect.y()}, {QStringLiteral("w"), rect.width()}, @@ -192,7 +200,7 @@ class PinPlacement { {QStringLiteral("time"), QDateTime::currentMSecsSinceEpoch()}}); if (!save()) return false; - if (hyprDispatch(pinMoveDispatch(title, rect.x(), rect.y()))) + if (hyprDispatch(pinMoveDispatch(pin->address, rect.x(), rect.y()))) return true; targets_.remove(title); save(); @@ -230,12 +238,19 @@ void compactPinColumn(const QString &excludedTitle, const QRect &screen) { if (pin.title == excludedTitle || !screen.intersects(pin.rect)) continue; pin.rect.translate(-screen.topLeft()); - if (pinInColumn(pin.rect, screen.size(), qRound(kCornerMargin))) + if (pinInColumn(pin.rect, screen.size(), qRound(kCornerMargin), kPinGap)) column.push_back(pin); else blockers.push_back(pin.rect); } - std::sort(column.begin(), column.end(), [](const CompositorPin &a, const CompositorPin &b) { + std::sort(column.begin(), column.end(), [screen](const CompositorPin &a, const CompositorPin &b) { + const auto columnIndex = [screen](const QRect &rect) { + return qRound(qreal(screen.width() - qRound(kCornerMargin) - rect.right() - 1) / + (rect.width() + kPinGap)); + }; + const int aColumn = columnIndex(a.rect), bColumn = columnIndex(b.rect); + if (aColumn != bColumn) + return aColumn < bColumn; return a.rect.y() > b.rect.y(); }); for (const CompositorPin &pin : column) { @@ -502,7 +517,8 @@ class PinWindow final : public QWidget { void closeButtonWatch() { for (const auto &[fd, notifier] : buttonWatches_) { - delete notifier; + notifier->setEnabled(false); + notifier->deleteLater(); ::close(fd); } buttonWatches_.clear(); @@ -551,7 +567,7 @@ class PinWindow final : public QWidget { for (const CompositorPin &pin : cachedPins_) { if (pin.title == windowTitle() || !dragScreen_.intersects(pin.rect)) continue; - if (pinInColumn(pin.rect.translated(-dragScreen_.topLeft()), dragScreen_.size(), qRound(kCornerMargin))) + if (pinInColumn(pin.rect.translated(-dragScreen_.topLeft()), dragScreen_.size(), qRound(kCornerMargin), kPinGap)) column.push_back({pin.title, pin.rect.translated(-dragScreen_.topLeft())}); else blockers.push_back(pin.rect.translated(-dragScreen_.topLeft())); @@ -806,9 +822,9 @@ int runPinnedCapture(const QString &path) { [&](const CompositorPin &pin) { return pin.title == title; }); if (own == placement.pins.end()) return {}; - if (!own->floating && !hyprDispatch(pinFloatDispatch(title))) + if (!own->floating && !hyprDispatch(pinFloatDispatch(own->address))) return {}; - if (!own->pinned && !hyprDispatch(pinPinDispatch(title))) + if (!own->pinned && !hyprDispatch(pinPinDispatch(own->address))) return {}; QVector blockers; for (const CompositorPin &pin : placement.pins) { diff --git a/tests/pin-layout-smoke.cpp b/tests/pin-layout-smoke.cpp index b83ad753..7333c287 100644 --- a/tests/pin-layout-smoke.cpp +++ b/tests/pin-layout-smoke.cpp @@ -100,13 +100,29 @@ bool runPinLayoutSmoke(QString &error) { // Column membership is hugging the right edge; dragging a pin away from // it takes the pin out of the column, whatever its height. - if (!pinInColumn(QRect(286, 26, 100, 80), screen, 14) || - !pinInColumn(QRect(282, 140, 104, 120), screen, 14) || - pinInColumn(QRect(200, 26, 100, 80), screen, 14)) { + if (!pinInColumn(QRect(286, 26, 100, 80), screen, 14, 10) || + !pinInColumn(QRect(282, 140, 104, 120), screen, 14, 10) || + pinInColumn(QRect(200, 26, 100, 80), screen, 14, 10)) { error = QStringLiteral("Column membership did not follow the right edge"); return false; } + if (!pinInColumn(QRect(176, 206, 100, 80), screen, 14, 10)) { + error = QStringLiteral("A wrapped pin was excluded from compaction"); + return false; + } + const QVector> wrappedColumn{ + {QStringLiteral("a"), QRect(286, 206, 100, 80)}, + {QStringLiteral("b"), QRect(286, 116, 100, 80)}, + {QStringLiteral("c"), QRect(286, 26, 100, 80)}, + {QStringLiteral("d"), QRect(176, 206, 100, 80)}}; + const auto wrappedPlan = pinInsertionPlan( + wrappedColumn, {}, QRect(176, 110, 100, 80), screen, 10, 14); + if (wrappedPlan.index != 4 || wrappedPlan.spot != QRect(176, 116, 100, 80)) { + error = QStringLiteral("Insertion did not target the wrapped column"); + return false; + } + // Dragging a pin over the column spreads the others around a hole where // it would land; covering half the hole or more is close enough to snap. const QVector> column{ @@ -201,16 +217,16 @@ bool runPinLayoutSmoke(QString &error) { // The dispatch expressions are Lua for a Lua-configured Hyprland; // a placement that silently does nothing is exactly the failure these guard. - const QString title = QStringLiteral("omasnap-pin 1234"); + const QString title = QStringLiteral("0x1234"); if (pinFloatDispatch(title) != QStringLiteral( - "hl.dsp.window.float({ window = \"title:^(omasnap-pin 1234)$\" })") || + "hl.dsp.window.float({ window = \"address:0x1234\" })") || pinPinDispatch(title) != QStringLiteral( - "hl.dsp.window.pin({ window = \"title:^(omasnap-pin 1234)$\" })") || + "hl.dsp.window.pin({ window = \"address:0x1234\" })") || pinMoveDispatch(title, 120, 40) != QStringLiteral("hl.dsp.window.move({ x = 120, y = 40, relative = " - "false, window = \"title:^(omasnap-pin 1234)$\" })")) { + "false, window = \"address:0x1234\" })")) { error = QStringLiteral("Hyprland dispatch expressions were malformed"); return false; } From d824bd5ed5f72d82fc44e07b1a3a659c2a63006e Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 6 Sep 2026 20:09:58 -0500 Subject: [PATCH 10/10] Keep free drops out of pin compaction and clarify platform scope --- README.md | 2 ++ src/pin.cpp | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cb5388a..ebac629f 100644 --- a/README.md +++ b/README.md @@ -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")`: diff --git a/src/pin.cpp b/src/pin.cpp index 39711f2a..7288ed98 100644 --- a/src/pin.cpp +++ b/src/pin.cpp @@ -553,7 +553,7 @@ class PinWindow final : public QWidget { if (!snapSpot_.isNull()) movePin(windowTitle(), snapSpot_); else - compactPinColumn(QString(), dragScreen_); + compactPinColumn(windowTitle(), dragScreen_); spreadActive_ = false; }