diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cdac5e2..b19b4322 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,7 +5,7 @@ include(CTest) set(CMAKE_AUTOMOC ON) find_package(PkgConfig REQUIRED) -find_package(Qt6 6.8 REQUIRED COMPONENTS Concurrent Core Gui Test Widgets) +find_package(Qt6 6.8 REQUIRED COMPONENTS Concurrent Core Gui Network Test Widgets) find_package(LayerShellQt REQUIRED) pkg_check_modules(WaylandClient REQUIRED IMPORTED_TARGET wayland-client) find_program(WAYLAND_SCANNER wayland-scanner REQUIRED) @@ -43,6 +43,8 @@ add_library(omasnap-core STATIC src/pin-file.hpp src/pin-layout.cpp src/pin-layout.hpp + src/shelf-layout.cpp + src/shelf-layout.hpp src/capture.cpp src/capture.hpp src/cut.cpp @@ -128,10 +130,19 @@ target_link_libraries(omasnap-core PUBLIC PkgConfig::WaylandClient ) -qt_add_executable(omasnap src/main.cpp src/pin.cpp src/pin.hpp) +qt_add_executable(omasnap + src/main.cpp + src/pin.cpp + src/pin.hpp + src/shelf.cpp + src/shelf.hpp +) target_compile_options(omasnap PRIVATE -Wall -Wextra -Wpedantic) target_compile_definitions(omasnap PRIVATE OMASNAP_VERSION="${PROJECT_VERSION}") -target_link_libraries(omasnap PRIVATE omasnap-core) +target_link_libraries(omasnap PRIVATE + omasnap-core + Qt6::Network +) target_precompile_headers(omasnap REUSE_FROM omasnap-core) if(BUILD_TESTING) @@ -169,6 +180,8 @@ qt_add_executable(omasnap-smoke src/auto-capture.hpp tests/stitch-smoke.cpp tests/stitch-smoke.hpp + tests/shelf-layout-smoke.cpp + tests/shelf-layout-smoke.hpp ) target_include_directories(omasnap-smoke PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests" diff --git a/src/capture.cpp b/src/capture.cpp index 8d43355d..571442d1 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1399,6 +1399,22 @@ QString temporarySnapshotPath() { .arg(nonce, 8, 16, QChar('0'))); } +QString shelfSnapshotPath() { + return runtimePath(QStringLiteral("shelf-%1-%2.png") + .arg(QCoreApplication::applicationPid()) + .arg(QRandomGenerator::global()->generate64(), 16, 16, + QChar('0'))); +} + +bool isShelfSnapshotPath(const QString &path) { + const QString runtime = secureRuntimeDirectory(); + const QFileInfo info(path); + return !runtime.isEmpty() && + QDir::cleanPath(info.absolutePath()) == QDir::cleanPath(runtime) && + info.fileName().startsWith(QStringLiteral("shelf-")) && + info.suffix().compare(QStringLiteral("png"), Qt::CaseInsensitive) == 0; +} + QString temporaryExportPath() { return runtimePath(QStringLiteral("export-%1-%2.png") .arg(QCoreApplication::applicationPid()) diff --git a/src/capture.hpp b/src/capture.hpp index 108b0b4e..d0842ced 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -314,6 +314,8 @@ QImage applyRedactionsScaled(QImage image, const QVector &redactions QString &error, const QString &appSlug = {}); [[nodiscard]] QString temporarySnapshotPath(); +[[nodiscard]] QString shelfSnapshotPath(); +[[nodiscard]] bool isShelfSnapshotPath(const QString &path); [[nodiscard]] QString pinnedSnapshotPath(int index); void prunePinnedSnapshots(); /** diff --git a/src/editor.cpp b/src/editor.cpp index faa458a4..5d03833f 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -629,9 +629,11 @@ QPointF centeredCreationStart(CaptureEditor::Tool tool, const QPointF ¢er, CaptureEditor::CaptureEditor(CaptureData capture, CaptureMode mode, QuickOutputMode quickOutput, OperationLog log, + PostCaptureHandler postCaptureHandler, QWidget *parent) : QWidget(parent), capture_(std::move(capture)), - quickOutputMode_(quickOutput) { + quickOutputMode_(quickOutput), + postCaptureHandler_(std::move(postCaptureHandler)) { startupTimingMark("CaptureEditor constructor entered"); pristineSource_ = capture_.source; pristineLogicalSize_ = capture_.previewSize; @@ -862,10 +864,15 @@ CaptureEditor::CaptureEditor(CaptureData capture, CaptureMode mode, ? QStringLiteral("Editing image from file · Copy/Save to output") : QStringLiteral("Full screen selected · native resolution · " "outer handles crop"); - if (mode == CaptureMode::File) + if (mode == CaptureMode::File) { enterEdit(editStatus); - else + } else if (postCaptureHandler_) { + QTimer::singleShot(0, this, [this, editStatus] { + enterSelectedCapture(editStatus); + }); + } else { enterSelectedCapture(editStatus); + } } else if (mode == CaptureMode::Window) { windowMode_ = true; hoveredWindow_ = windowAt(cursor_); @@ -2924,6 +2931,22 @@ void CaptureEditor::enterSelectedCapture(QString editStatus) { enterExport(); return; } + // A fresh capture with somewhere to go does not open the editor: it is + // handed off and the window closes. An explicit --copy/--save above still + // wins, and a file opened for editing never reaches here. + if (postCaptureHandler_) { + const QImage image = renderCurrentOutput(); + QString error; + if (image.isNull() || !postCaptureHandler_(image, error)) { + setStatus(error.isEmpty() + ? QStringLiteral("Could not add capture to Shelf") + : error); + update(); + return; + } + close(); + return; + } enterEdit(std::move(editStatus)); } @@ -3373,10 +3396,12 @@ void CaptureEditor::finish(OutputMode mode) { const QImage backdrop = customBackdrop_; const QString appSlug = appFilenameSlug(dominantAppClass(capture_.windows, selection_)); + const QString replacementOutput = replacementOutputPath_; finishWatcher_.setFuture(QtConcurrent::run([captureCopy, selection, annotations, background, imageShadow, canvasBoundary, - backdrop, appSlug, mode]() { + backdrop, appSlug, + replacementOutput, mode]() { FinishResult result; result.mode = mode; const QImage image = renderCapture(captureCopy, selection, annotations, @@ -3403,11 +3428,22 @@ void CaptureEditor::finish(OutputMode mode) { } } if (mode == OutputMode::Save || mode == OutputMode::Both) { - result.saved = moveSnapshotToScreenshots(exportPath, error, appSlug); - if (result.saved.isEmpty()) { + if (!replacementOutput.isEmpty()) { + if (!saveTemporarySnapshot(image, replacementOutput, error, -1)) { + QFile::remove(exportPath); + result.error = error; + return result; + } + result.saved = replacementOutput; QFile::remove(exportPath); - result.error = error; - return result; + QFile::remove(operationLogPath(result.saved)); + } else { + result.saved = moveSnapshotToScreenshots(exportPath, error, appSlug); + if (result.saved.isEmpty()) { + QFile::remove(exportPath); + result.error = error; + return result; + } } } else { QFile::remove(exportPath); @@ -3435,19 +3471,30 @@ void CaptureEditor::completeFinish(const FinishResult &result) { QString recentError; const bool drained = waitForSnapshot(); snapshotDirty_ = false; - if (drained && recordRecentSnap(snapshotPath_, workingLogPath(), - result.thumbnail, recentError)) { - if (editingRecent_) - removeRecentSnap(*editingRecent_); - } else { - if (!recentError.isEmpty()) - qWarning().noquote() << recentError; + if (!replacementOutputPath_.isEmpty()) { QFile::remove(workingLogPath()); QFile::remove(snapshotPath_); + } else { + if (drained && recordRecentSnap(snapshotPath_, workingLogPath(), + result.thumbnail, recentError)) { + if (editingRecent_) + removeRecentSnap(*editingRecent_); + } else { + if (!recentError.isEmpty()) + qWarning().noquote() << recentError; + QFile::remove(workingLogPath()); + QFile::remove(snapshotPath_); + } } snapshotPath_.clear(); } - if (result.mode == OutputMode::Copy) + if (!replacementOutputPath_.isEmpty() && result.mode == OutputMode::Save) + sendCaptureNotification(QStringLiteral("Shelf screenshot updated")); + else if (!replacementOutputPath_.isEmpty() && + result.mode == OutputMode::Both) + sendCaptureNotification( + QStringLiteral("Shelf screenshot updated and copied")); + else if (result.mode == OutputMode::Copy) sendCaptureNotification(QStringLiteral("Screenshot copied to clipboard")); else if (result.mode == OutputMode::Save) sendCaptureNotification(QStringLiteral("Screenshot saved"), result.saved); diff --git a/src/editor.hpp b/src/editor.hpp index 4941f7d6..c294f20b 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -15,7 +15,9 @@ #include #include +#include #include +#include class QKeyEvent; class QMouseEvent; @@ -44,11 +46,14 @@ class CaptureEditor final : public QWidget { Q_OBJECT public: enum class CaptureMode { Region, Scroll, Window, Fullscreen, File }; + using PostCaptureHandler = + std::function; explicit CaptureEditor(CaptureData capture, CaptureMode mode = CaptureMode::Region, QuickOutputMode quickOutput = QuickOutputMode::None, OperationLog log = {}, + PostCaptureHandler postCaptureHandler = {}, QWidget *parent = nullptr); ~CaptureEditor() override; @@ -121,6 +126,10 @@ class CaptureEditor final : public QWidget { * for it would render the full capture for nothing and stall process exit. */ void setSuppressSnapshots(bool suppress) { suppressSnapshots_ = suppress; } + /** Save/Enter replaces this Shelf image instead of creating a new file. */ + void setReplacementOutputPath(QString path) { + replacementOutputPath_ = std::move(path); + } protected: bool eventFilter(QObject *watched, QEvent *event) override; @@ -759,7 +768,9 @@ class CaptureEditor final : public QWidget { bool dragStartStateValid_ = false; bool dragChanged_ = false; QString snapshotPath_; + QString replacementOutputPath_; QuickOutputMode quickOutputMode_ = QuickOutputMode::None; + PostCaptureHandler postCaptureHandler_; int pinCount_ = 0; QString status_ = QStringLiteral("Drag to select an area · Space selects a window"); diff --git a/src/main.cpp b/src/main.cpp index 3ece660e..bf5fa288 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include "instance-lock.hpp" #include "pin.hpp" #include "recent-snaps.hpp" +#include "shelf.hpp" #include "startup-timing.hpp" #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -152,6 +154,11 @@ int main(int argc, char **argv) { "instead of capturing the screen."), QStringLiteral("path")); parser.addOption(fileOption); + const QCommandLineOption editShelfOption( + QStringLiteral("edit-shelf"), + QStringLiteral("Edit a private Shelf image in place."), + QStringLiteral("path")); + parser.addOption(editShelfOption); const QCommandLineOption clipboardOption( QStringLiteral("clipboard"), QStringLiteral("Open the current clipboard image in the annotation " @@ -167,6 +174,16 @@ int main(int argc, char **argv) { QStringLiteral("Capture a scrolling region and stitch it into one tall " "image, then open it in the editor.")); parser.addOption(scrollOption); + const QCommandLineOption shelfOption( + QStringLiteral("shelf"), + QStringLiteral("Add an image to the capture Shelf."), + QStringLiteral("path")); + const QCommandLineOption shelfScreenOption( + QStringLiteral("shelf-screen"), + QStringLiteral("Place the capture Shelf on this output."), + QStringLiteral("name")); + parser.addOption(shelfOption); + parser.addOption(shelfScreenOption); parser.addPositionalArgument( QStringLiteral("target"), QStringLiteral("Capture mode (smart, region, windows, fullscreen) or the " @@ -176,7 +193,15 @@ int main(int argc, char **argv) { startupTimingMark("command line parsed"); QString filePath = parser.value(fileOption); + const bool editingShelf = parser.isSet(editShelfOption); const bool clipboardInput = parser.isSet(clipboardOption); + if (editingShelf) { + if (!filePath.isEmpty() || clipboardInput) { + qCritical() << "--edit-shelf cannot be combined with another image input"; + return 2; + } + filePath = parser.value(editShelfOption); + } QuickOutputMode quickOutputMode = QuickOutputMode::None; if (parser.isSet(copyOption) && parser.isSet(saveOption)) @@ -198,6 +223,23 @@ int main(int argc, char **argv) { captureMode = CaptureEditor::CaptureMode::Scroll; const QStringList positional = parser.positionalArguments(); + if (parser.isSet(shelfOption)) { + if (parser.isSet(pinOption) || !filePath.isEmpty() || clipboardInput || + requestedModes > 0 || !positional.isEmpty() || + quickOutputMode != QuickOutputMode::None) { + qCritical() + << "Shelf mode cannot be combined with capture or edit targets"; + return 2; + } + QString shelfPath = QUrl(parser.value(shelfOption)).toLocalFile(); + if (shelfPath.isEmpty()) + shelfPath = parser.value(shelfOption); + return runCaptureShelf(shelfPath, parser.value(shelfScreenOption)); + } + if (parser.isSet(shelfScreenOption)) { + qCritical() << "--shelf-screen requires --shelf"; + return 2; + } if (parser.isSet(pinOption)) { if (!filePath.isEmpty() || clipboardInput || requestedModes > 0 || !positional.isEmpty() || quickOutputMode != QuickOutputMode::None) { @@ -286,9 +328,16 @@ int main(int argc, char **argv) { qCritical().noquote() << lockResult.error; return lockResult.exitCode; } + const bool shelfHiddenForCapture = + !editingImage && setCaptureShelfHidden(true); + const auto restoreShelf = qScopeGuard([shelfHiddenForCapture] { + if (shelfHiddenForCapture) + static_cast(setCaptureShelfHidden(false)); + }); CaptureData capture; OperationLog restoredLog; + QString replacementOutputPath; QString error; if (editingImage) { QImage image; @@ -306,6 +355,10 @@ int main(int argc, char **argv) { QString localFile = QUrl(filePath).toLocalFile(); if (localFile.isEmpty()) localFile = filePath; + if (editingShelf && !isShelfSnapshotPath(localFile)) { + qCritical() << "--edit-shelf only accepts private Shelf snapshots"; + return 2; + } image.load(localFile); if (image.isNull()) { qCritical().noquote() @@ -313,6 +366,8 @@ int main(int argc, char **argv) { return 1; } inputName = localFile; + if (editingShelf) + replacementOutputPath = localFile; const QString sidecar = operationLogPath(localFile); if (QFile::exists(sidecar) && !loadOperationLog(sidecar, restoredLog, error)) { @@ -385,9 +440,18 @@ int main(int argc, char **argv) { .arg(capture.windows.size()); } + CaptureEditor::PostCaptureHandler postCaptureHandler; + if (!editingImage && quickOutputMode == QuickOutputMode::None) { + const QString screenName = capture.monitor.name; + postCaptureHandler = [screenName](const QImage &image, QString &outputError) { + return queueCaptureOnShelf(image, screenName, outputError); + }; + } CaptureEditor editor(std::move(capture), captureMode, quickOutputMode, - restoredLog); + restoredLog, std::move(postCaptureHandler)); startupTimingMark("CaptureEditor constructed"); + if (!replacementOutputPath.isEmpty()) + editor.setReplacementOutputPath(replacementOutputPath); editor.setScreen(targetScreen); editor.setGeometry(targetScreen->geometry()); editor.winId(); diff --git a/src/shelf-layout.cpp b/src/shelf-layout.cpp new file mode 100644 index 00000000..4ee4e69a --- /dev/null +++ b/src/shelf-layout.cpp @@ -0,0 +1,53 @@ +#include "shelf-layout.hpp" + +#include + +namespace { +constexpr int kThumbnailWidth = 230; +constexpr int kThumbnailHeight = 130; +constexpr int kSurfacePadding = 8; +constexpr int kStackPeek = 13; +constexpr int kExpandedGap = 10; +constexpr int kExpandedHandleHeight = 26; +constexpr int kTuckedHeight = 24; +} // namespace + +CaptureShelfLayout captureShelfLayout(int itemCount, + ShelfPresentation presentation) { + CaptureShelfLayout layout; + itemCount = std::clamp(itemCount, 0, captureShelfMaximumItems()); + const int width = kThumbnailWidth + kSurfacePadding * 2; + if (presentation == ShelfPresentation::Tucked) { + layout.size = QSize(width, kTuckedHeight); + layout.handle = QRectF(kSurfacePadding, 0, kThumbnailWidth, kTuckedHeight); + return layout; + } + + const bool expanded = presentation == ShelfPresentation::Expanded; + const int step = expanded ? kThumbnailHeight + kExpandedGap : kStackPeek; + const int handleHeight = expanded ? kExpandedHandleHeight : 0; + const int stackHeight = + itemCount == 0 ? 0 : kThumbnailHeight + (itemCount - 1) * step; + layout.size = QSize(width, stackHeight + kSurfacePadding * 2 + handleHeight); + if (expanded) + layout.handle = QRectF(kSurfacePadding, kSurfacePadding, kThumbnailWidth, + kExpandedHandleHeight - 4); + layout.thumbnails.reserve(itemCount); + for (int index = 0; index < itemCount; ++index) { + const int y = + kSurfacePadding + handleHeight + (itemCount - 1 - index) * step; + layout.thumbnails.push_back( + QRectF(kSurfacePadding, y, kThumbnailWidth, kThumbnailHeight)); + } + return layout; +} + +int captureShelfItemAt(const CaptureShelfLayout &layout, + const QPointF &position) { + // Newest entries are painted last, so test them first in overlap regions. + for (int index = 0; index < layout.thumbnails.size(); ++index) { + if (layout.thumbnails.at(index).contains(position)) + return index; + } + return -1; +} diff --git a/src/shelf-layout.hpp b/src/shelf-layout.hpp new file mode 100644 index 00000000..b10ce907 --- /dev/null +++ b/src/shelf-layout.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + +enum class ShelfPresentation { Stacked, Expanded, Tucked }; + +struct CaptureShelfLayout { + QSize size; + QVector thumbnails; + QRectF handle; +}; + +/** Returns the bottom-right Shelf layout. Index zero is always the newest. */ +[[nodiscard]] CaptureShelfLayout +captureShelfLayout(int itemCount, ShelfPresentation presentation); + +/** Returns the front-most thumbnail at `position`, or -1 outside the stack. */ +[[nodiscard]] int captureShelfItemAt(const CaptureShelfLayout &layout, + const QPointF &position); + +[[nodiscard]] constexpr int captureShelfMaximumItems() { return 5; } diff --git a/src/shelf.cpp b/src/shelf.cpp new file mode 100644 index 00000000..9b955ea3 --- /dev/null +++ b/src/shelf.cpp @@ -0,0 +1,599 @@ +#include "shelf.hpp" + +#include "capture.hpp" +#include "icons.hpp" +#include "shelf-layout.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr int kCornerRadius = 12; +constexpr int kEdgeMargin = 18; +constexpr int kSwipeThreshold = 42; +constexpr int kActionSize = 28; +constexpr int kActionInset = 7; + +struct ShelfRequest { + QString action = QStringLiteral("add"); + QString path; + QString screenName; +}; + +QString shelfSocketPath() { + const QString runtime = secureRuntimeDirectory(); + return runtime.isEmpty() + ? QString() + : QDir(runtime).filePath(QStringLiteral("shelf.socket")); +} + +QByteArray encodeRequest(const ShelfRequest &request) { + QJsonObject object; + object.insert(QStringLiteral("action"), request.action); + object.insert(QStringLiteral("path"), request.path); + object.insert(QStringLiteral("screen"), request.screenName); + return QJsonDocument(object).toJson(QJsonDocument::Compact) + '\n'; +} + +ShelfRequest decodeRequest(const QByteArray &payload) { + const QJsonDocument document = QJsonDocument::fromJson(payload.trimmed()); + if (!document.isObject()) + return {}; + const QJsonObject object = document.object(); + return { + object.value(QStringLiteral("action")).toString(QStringLiteral("add")), + object.value(QStringLiteral("path")).toString(), + object.value(QStringLiteral("screen")).toString()}; +} + +bool notifyExistingShelf(const QString &socketPath, + const ShelfRequest &request) { + QLocalSocket socket; + socket.connectToServer(socketPath, QIODevice::ReadWrite); + if (!socket.waitForConnected(150)) + return false; + const QByteArray payload = encodeRequest(request); + if (socket.write(payload) != payload.size()) + return false; + socket.flush(); + if (socket.bytesToWrite() > 0 && !socket.waitForBytesWritten(500)) + return false; + static_cast(socket.waitForReadyRead(1000)); + const QByteArray response = socket.readAll().trimmed(); + const bool acknowledged = response == QByteArrayLiteral("ok"); + socket.disconnectFromServer(); + return acknowledged; +} + +QScreen *screenByName(const QString &name) { + if (!name.isEmpty()) { + for (QScreen *screen : QGuiApplication::screens()) { + if (screen->name() == name) + return screen; + } + } + return QGuiApplication::primaryScreen(); +} + +void removeOwnedShelfSnapshot(const QString &path) { + if (!isShelfSnapshotPath(path)) + return; + QFile::remove(operationLogPath(path)); + QFile::remove(path); +} + +class CaptureShelfWindow final : public QWidget { +public: + CaptureShelfWindow() { + setWindowTitle(QStringLiteral("omasnap-shelf")); + setWindowFlags(Qt::Window | Qt::FramelessWindowHint); + setAttribute(Qt::WA_TranslucentBackground); + setMouseTracking(true); + } + + ~CaptureShelfWindow() override { + for (const Item &item : std::as_const(items_)) + removeOwnedShelfSnapshot(item.path); + } + + void setLayerWindow(LayerShellQt::Window *layer) { + layer_ = layer; + applyLayout(); + } + + void handleRequest(const ShelfRequest &request) { + if (request.action == QStringLiteral("hide")) { + captureHidden_ = true; + hide(); + QGuiApplication::sync(); + } else if (request.action == QStringLiteral("show")) { + captureHidden_ = false; + if (!items_.isEmpty() && !editingProcess_) + show(); + QGuiApplication::sync(); + } else { + captureHidden_ = false; + addCapture(request); + } + } + + void addCapture(const ShelfRequest &request) { + const QFileInfo info(request.path); + QImage image(info.absoluteFilePath()); + if (!info.isFile() || image.isNull()) + return; + + const QString path = info.absoluteFilePath(); + for (int index = items_.size() - 1; index >= 0; --index) { + if (items_.at(index).path == path) + items_.removeAt(index); + } + items_.prepend({path, std::move(image)}); + while (items_.size() > captureShelfMaximumItems()) { + removeOwnedShelfSnapshot(items_.constLast().path); + items_.removeLast(); + } + + presentation_ = ShelfPresentation::Stacked; + hoveredItem_ = 0; + if (QScreen *target = screenByName(request.screenName)) { + setScreen(target); + if (layer_) + layer_->setScreen(target); + } + applyLayout(); + if (!captureHidden_ && !editingProcess_) + show(); + update(); + } + +protected: + void paintEvent(QPaintEvent *) override { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + + if (presentation_ == ShelfPresentation::Tucked) { + paintHandle(painter); + return; + } + + // Oldest first, newest last: index zero remains front-most. + for (int index = items_.size() - 1; index >= 0; --index) + paintThumbnail(painter, index); + if (presentation_ == ShelfPresentation::Expanded) + paintExpandedHandle(painter); + if (!toast_.isEmpty()) + paintToast(painter); + } + + void mousePressEvent(QMouseEvent *event) override { + if (event->button() != Qt::LeftButton) + return; + pressPosition_ = event->position(); + dragging_ = true; + event->accept(); + } + + void mouseMoveEvent(QMouseEvent *event) override { + hoveredItem_ = captureShelfItemAt(layout_, event->position()); + if (dragging_ && presentation_ == ShelfPresentation::Stacked) { + swipeOffset_ = + std::max(0, event->position().y() - pressPosition_.y()); + } + setCursor(Qt::PointingHandCursor); + update(); + } + + void mouseReleaseEvent(QMouseEvent *event) override { + if (event->button() != Qt::LeftButton || !dragging_) + return; + dragging_ = false; + if (presentation_ == ShelfPresentation::Tucked) { + presentation_ = ShelfPresentation::Stacked; + } else if (presentation_ == ShelfPresentation::Stacked) { + presentation_ = swipeOffset_ >= kSwipeThreshold + ? ShelfPresentation::Tucked + : ShelfPresentation::Expanded; + } else if (layout_.handle.contains(event->position())) { + presentation_ = ShelfPresentation::Stacked; + } else { + const int index = captureShelfItemAt(layout_, event->position()); + if (index >= 0) { + if (deleteButtonRect(index).contains(event->position())) + removeItem(index); + else if (copyButtonRect(index).contains(event->position())) + copyItem(index); + else + annotateItem(index); + } + } + swipeOffset_ = 0; + applyLayout(); + update(); + event->accept(); + } + + void keyPressEvent(QKeyEvent *event) override { + if (event->key() == Qt::Key_Escape && + presentation_ == ShelfPresentation::Expanded) { + presentation_ = ShelfPresentation::Stacked; + applyLayout(); + update(); + return; + } + QWidget::keyPressEvent(event); + } + + void enterEvent(QEnterEvent *) override { update(); } + + void leaveEvent(QEvent *) override { + hoveredItem_ = -1; + setCursor(Qt::ArrowCursor); + update(); + } + +private: + struct Item { + QString path; + QImage image; + }; + + void applyLayout() { + layout_ = captureShelfLayout(items_.size(), presentation_); + if (layout_.size.isEmpty()) { + hide(); + return; + } + resize(layout_.size); + if (layer_) { + layer_->setDesiredSize(layout_.size); + layer_->setMargins(QMargins( + 0, 0, kEdgeMargin, + presentation_ == ShelfPresentation::Tucked ? -5 : kEdgeMargin)); + } + } + + void paintThumbnail(QPainter &painter, int index) const { + QRectF frame = layout_.thumbnails.at(index); + if (presentation_ == ShelfPresentation::Stacked) + frame.translate(0, std::min(swipeOffset_, frame.height())); + + for (int layer = 4; layer > 0; --layer) { + const qreal spread = layer * 1.2; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 12 + (4 - layer) * 5)); + painter.drawRoundedRect(frame.adjusted(-spread, -spread, spread, spread), + kCornerRadius + spread, kCornerRadius + spread); + } + const bool newest = + index == 0 && presentation_ == ShelfPresentation::Stacked; + painter.setPen( + QPen(newest ? QColor(70, 145, 255) : QColor(245, 245, 247, 75), + newest ? 2 : 1)); + painter.setBrush(QColor(18, 18, 22, 246)); + painter.drawRoundedRect(frame, kCornerRadius, kCornerRadius); + + const QRectF imageRect = frame.adjusted(4, 4, -4, -4); + QPainterPath clip; + clip.addRoundedRect(imageRect, kCornerRadius - 3, kCornerRadius - 3); + painter.save(); + painter.setClipPath(clip); + const QImage &image = items_.at(index).image; + const QSize fitted = image.size().scaled(imageRect.size().toSize(), + Qt::KeepAspectRatioByExpanding); + const QRectF target(imageRect.center().x() - fitted.width() / 2.0, + imageRect.center().y() - fitted.height() / 2.0, + fitted.width(), fitted.height()); + painter.drawImage(target, image); + if (presentation_ == ShelfPresentation::Expanded && hoveredItem_ == index) + painter.fillRect(imageRect, QColor(0, 0, 0, 35)); + painter.restore(); + if (presentation_ == ShelfPresentation::Expanded && hoveredItem_ == index) + paintActionButton(painter, annotateButtonRect(index), + QStringLiteral("edit")); + if (presentation_ == ShelfPresentation::Expanded && hoveredItem_ == index) + paintActionButton(painter, copyButtonRect(index), QStringLiteral("copy")); + if (presentation_ == ShelfPresentation::Expanded && hoveredItem_ == index) + paintActionButton(painter, deleteButtonRect(index), + QStringLiteral("close")); + } + + QRectF annotateButtonRect(int index) const { + const QRectF frame = layout_.thumbnails.at(index); + return QRectF(frame.left() + kActionInset, frame.top() + kActionInset, + kActionSize, kActionSize); + } + + QRectF copyButtonRect(int index) const { + return annotateButtonRect(index).translated(kActionSize + 4, 0); + } + + QRectF deleteButtonRect(int index) const { + const QRectF frame = layout_.thumbnails.at(index); + return QRectF(frame.right() - kActionInset - kActionSize, + frame.top() + kActionInset, kActionSize, kActionSize); + } + + void paintActionButton(QPainter &painter, const QRectF &button, + const QString &icon) const { + painter.setPen(QPen(QColor(245, 245, 247, 65), 1)); + painter.setBrush(QColor(12, 12, 16, 215)); + painter.drawEllipse(button); + drawToolbarIcon(painter, button, icon, {}, QColor(245, 245, 247)); + } + + void annotateItem(int index) { + if (editingProcess_ || index < 0 || index >= items_.size()) + return; + const Item &item = items_.at(index); + const QString path = item.path; + QString error; + if (!QFileInfo::exists(path) && + !saveTemporarySnapshot(item.image, path, error, -1)) { + showToast(error); + return; + } + auto *process = new QProcess(this); + editingProcess_ = process; + hide(); + const auto finish = [this, process, path] { + if (editingProcess_ != process) + return; + for (Item &item : items_) { + if (item.path == path) { + const QImage updated(path); + if (!updated.isNull()) + item.image = updated; + break; + } + } + editingProcess_ = nullptr; + process->deleteLater(); + applyLayout(); + show(); + update(); + }; + connect(process, &QProcess::finished, this, + [finish](int, QProcess::ExitStatus) { finish(); }); + connect(process, &QProcess::errorOccurred, this, + [finish](QProcess::ProcessError) { finish(); }); + process->start(QCoreApplication::applicationFilePath(), + {QStringLiteral("--edit-shelf"), path}); + } + + void copyItem(int index) { + if (index < 0 || index >= items_.size()) + return; + QString error; + showToast(copyImageToClipboard(items_.at(index).image, error) + ? QStringLiteral("Copied to clipboard") + : error); + } + + void removeItem(int index) { + if (index < 0 || index >= items_.size()) + return; + const QString path = items_.at(index).path; + items_.removeAt(index); + removeOwnedShelfSnapshot(path); + hoveredItem_ = items_.isEmpty() + ? -1 + : std::min(index, static_cast(items_.size()) - 1); + if (items_.isEmpty()) + presentation_ = ShelfPresentation::Stacked; + applyLayout(); + update(); + } + + void showToast(QString message) { + toast_ = std::move(message); + update(); + QTimer::singleShot(1200, this, [this] { + toast_.clear(); + update(); + }); + } + + void paintToast(QPainter &painter) const { + const QFontMetrics metrics(painter.font()); + const qreal width = metrics.horizontalAdvance(toast_) + 28; + const QRectF pill((this->width() - width) / 2.0, this->height() - 36, width, + 26); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(12, 12, 16, 220)); + painter.drawRoundedRect(pill, 13, 13); + painter.setPen(QColor(245, 245, 247)); + painter.drawText(pill, Qt::AlignCenter, toast_); + } + + void paintExpandedHandle(QPainter &painter) const { + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(18, 18, 22, 185)); + painter.drawRoundedRect(layout_.handle, 8, 8); + painter.setPen(QColor(245, 245, 247, 190)); + painter.drawText(layout_.handle, Qt::AlignCenter, + QStringLiteral("Collapse · Esc")); + } + + void paintHandle(QPainter &painter) const { + painter.setPen(QPen(QColor(70, 145, 255, 145), 1)); + painter.setBrush(QColor(18, 18, 22, 145)); + painter.drawRoundedRect(layout_.handle, 8, 8); + painter.setPen(QColor(245, 245, 247, 185)); + painter.drawText(layout_.handle, Qt::AlignCenter, QStringLiteral("⌃")); + } + + QVector items_; + CaptureShelfLayout layout_; + ShelfPresentation presentation_ = ShelfPresentation::Stacked; + LayerShellQt::Window *layer_ = nullptr; + QPointF pressPosition_; + qreal swipeOffset_ = 0; + bool dragging_ = false; + int hoveredItem_ = -1; + QProcess *editingProcess_ = nullptr; + QString toast_; + bool captureHidden_ = false; +}; + +class ShelfServer final : public QObject { +public: + ShelfServer(QLocalServer &server, CaptureShelfWindow &window) + : server_(server), window_(window) { + connect(&server_, &QLocalServer::newConnection, this, + [this] { acceptConnections(); }); + } + +private: + void acceptConnections() { + while (server_.hasPendingConnections()) { + QLocalSocket *socket = server_.nextPendingConnection(); + connect(socket, &QLocalSocket::readyRead, socket, [this, socket] { + QByteArray payload = socket->property("omasnap-payload").toByteArray(); + payload += socket->readAll(); + if (!payload.contains('\n')) { + socket->setProperty("omasnap-payload", payload); + return; + } + const ShelfRequest request = decodeRequest(payload); + if (request.action != QStringLiteral("add") || + !request.path.isEmpty()) { + window_.handleRequest(request); + socket->write(QByteArrayLiteral("ok\n")); + socket->flush(); + } + }); + connect(socket, &QLocalSocket::disconnected, socket, + &QObject::deleteLater); + } + } + + QLocalServer &server_; + CaptureShelfWindow &window_; +}; + +} // namespace + +bool queueCaptureOnShelf(const QImage &image, const QString &screenName, + QString &error) { + const QString path = shelfSnapshotPath(); + if (path.isEmpty() || !saveTemporarySnapshot(image, path, error, -1)) + return false; + if (!copyPngFileToClipboard(path, error)) { + QFile::remove(path); + return false; + } + QStringList arguments{QStringLiteral("--shelf"), path}; + if (!screenName.isEmpty()) + arguments << QStringLiteral("--shelf-screen") << screenName; + if (!QProcess::startDetached(QCoreApplication::applicationFilePath(), + arguments)) { + QFile::remove(path); + error = QStringLiteral("Could not start the capture Shelf"); + return false; + } + return true; +} + +bool setCaptureShelfHidden(bool hidden) { + const QString socketPath = shelfSocketPath(); + return !socketPath.isEmpty() && + notifyExistingShelf(socketPath, {hidden ? QStringLiteral("hide") + : QStringLiteral("show"), + {}, + {}}); +} + +int runCaptureShelf(const QString &path, const QString &screenName) { + const ShelfRequest request{QStringLiteral("add"), + QFileInfo(path).absoluteFilePath(), screenName}; + if (!QFileInfo::exists(request.path)) { + qCritical("omasnap: Shelf image does not exist: %s", + qUtf8Printable(request.path)); + return 1; + } + + const QString socketPath = shelfSocketPath(); + if (socketPath.isEmpty()) + return 1; + if (notifyExistingShelf(socketPath, request)) + return 0; + + const QString runtime = secureRuntimeDirectory(); + QLockFile shelfLock(QDir(runtime).filePath(QStringLiteral("shelf.instance"))); + shelfLock.setStaleLockTime(0); + if (!shelfLock.tryLock()) { + if (notifyExistingShelf(socketPath, request)) + return 0; + qCritical("omasnap: the capture Shelf is running but did not respond"); + return 1; + } + + QLocalServer::removeServer(socketPath); + QLocalServer server; + if (!server.listen(socketPath)) { + if (notifyExistingShelf(socketPath, request)) + return 0; + qCritical("omasnap: could not listen for Shelf captures: %s", + qUtf8Printable(server.errorString())); + return 1; + } + + QApplication::setQuitOnLastWindowClosed(false); + CaptureShelfWindow window; + 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 Shelf layer surface"); + return 1; + } + + layer->setScope(QStringLiteral("omasnap-shelf")); + if (QScreen *target = screenByName(screenName)) { + window.setScreen(target); + layer->setScreen(target); + } + LayerShellQt::Window::Anchors anchors; + anchors.setFlag(LayerShellQt::Window::AnchorBottom); + anchors.setFlag(LayerShellQt::Window::AnchorRight); + layer->setAnchors(anchors); + layer->setExclusiveZone(0); + layer->setKeyboardInteractivity( + LayerShellQt::Window::KeyboardInteractivityOnDemand); + layer->setActivateOnShow(false); + layer->setLayer(LayerShellQt::Window::LayerOverlay); + window.setLayerWindow(layer); + ShelfServer shelfServer(server, window); + window.addCapture(request); + return QApplication::exec(); +} diff --git a/src/shelf.hpp b/src/shelf.hpp new file mode 100644 index 00000000..1d39d583 --- /dev/null +++ b/src/shelf.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +/** Persists a capture and starts the Shelf process that will display it. */ +[[nodiscard]] bool queueCaptureOnShelf(const QImage &image, + const QString &screenName, + QString &error); + +/** Hides/restores an existing Shelf while the compositor captures the screen. + */ +[[nodiscard]] bool setCaptureShelfHidden(bool hidden); + +/** Runs or notifies the single per-session capture Shelf process. */ +[[nodiscard]] int runCaptureShelf(const QString &path, + const QString &screenName = {}); diff --git a/tests/clipboard-smoke.cpp b/tests/clipboard-smoke.cpp index 21e88753..afb96269 100644 --- a/tests/clipboard-smoke.cpp +++ b/tests/clipboard-smoke.cpp @@ -111,6 +111,17 @@ bool runClipboardSmoke(QString &error) { error = QStringLiteral("Could not create fake wl-paste command"); return false; } + const QString fakeWlCopy = + QDir(directory.path()).filePath(QStringLiteral("wl-copy")); + const QByteArray copyScript = QByteArrayLiteral( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "[[ \"${1:-}\" == \"--type\" && \"${2:-}\" == \"image/png\" ]]\n" + "cat > \"$OMASNAP_TEST_CLIPBOARD_COPY\"\n"); + if (!writeExecutable(fakeWlCopy, copyScript)) { + error = QStringLiteral("Could not create fake wl-copy command"); + return false; + } const bool pathWasSet = qEnvironmentVariableIsSet("PATH"); const QByteArray oldPath = qgetenv("PATH"); @@ -124,6 +135,9 @@ bool runClipboardSmoke(QString &error) { qEnvironmentVariableIsSet("OMASNAP_TEST_CLIPBOARD_READ_FAILURE"); const QByteArray oldReadFailure = qgetenv("OMASNAP_TEST_CLIPBOARD_READ_FAILURE"); + const bool copyWasSet = + qEnvironmentVariableIsSet("OMASNAP_TEST_CLIPBOARD_COPY"); + const QByteArray oldCopy = qgetenv("OMASNAP_TEST_CLIPBOARD_COPY"); const auto restoreEnvironment = qScopeGuard([=] { pathWasSet ? qputenv("PATH", oldPath) : qunsetenv("PATH"); imageWasSet ? qputenv("OMASNAP_TEST_CLIPBOARD_IMAGE", oldImage) @@ -134,12 +148,23 @@ bool runClipboardSmoke(QString &error) { readFailureWasSet ? qputenv("OMASNAP_TEST_CLIPBOARD_READ_FAILURE", oldReadFailure) : qunsetenv("OMASNAP_TEST_CLIPBOARD_READ_FAILURE"); + copyWasSet ? qputenv("OMASNAP_TEST_CLIPBOARD_COPY", oldCopy) + : qunsetenv("OMASNAP_TEST_CLIPBOARD_COPY"); }); + const QString copiedPath = + QDir(directory.path()).filePath(QStringLiteral("copied.png")); qputenv("PATH", directory.path().toUtf8() + ':' + oldPath); qputenv("OMASNAP_TEST_CLIPBOARD_IMAGE", imagePath.toUtf8()); + qputenv("OMASNAP_TEST_CLIPBOARD_COPY", copiedPath.toUtf8()); qunsetenv("OMASNAP_TEST_CLIPBOARD_TEXT_ONLY"); qunsetenv("OMASNAP_TEST_CLIPBOARD_READ_FAILURE"); + if (!copyImageToClipboard(expected, error) || + QImage(copiedPath) != expected) { + if (error.isEmpty()) + error = QStringLiteral("Clipboard image copy did not preserve pixels"); + return false; + } return runImageCheck(error) && runTextOnlyCheck(error) && runReadFailureCheck(error); } diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 68f5516c..01a7b0fb 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -14,6 +14,7 @@ #include "stitch-smoke.hpp" #include "stitch.hpp" #include "pin-lifecycle-smoke.hpp" +#include "shelf-layout-smoke.hpp" #include "text-band.hpp" #include "transform-smoke.hpp" #include "eyedropper.hpp" @@ -7370,6 +7371,75 @@ bool runAreaLastRegionSmoke(QApplication &application, QString &error) { return true; } +bool runPostCaptureHandoffSmoke(QApplication &application, QString &error) { + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.geometry = QRect(0, 0, 800, 600); + capture.monitor.pixelSize = QSize(800, 600); + capture.monitor.scale = 1.0; + capture.previewSize = QSize(800, 600); + capture.source = QImage(800, 600, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(QColor(QStringLiteral("#365070"))); + + QImage handedOff; + CaptureEditor editor( + capture, CaptureEditor::CaptureMode::Region, QuickOutputMode::None, {}, + [&handedOff](const QImage &image, QString &) { + handedOff = image; + return true; + }); + editor.resize(capture.previewSize); + editor.show(); + application.processEvents(); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(100, 150)); + QTest::mouseMove(&editor, QPoint(500, 400), 10); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + QPoint(500, 400)); + application.processEvents(); + if (handedOff.size() != QSize(400, 250) || editor.isVisible()) { + error = QStringLiteral("Post-capture handoff did not receive the region"); + return false; + } + return true; +} + +bool runShelfReplacementOutputSmoke(QApplication &application, + QString &error) { + const QString path = shelfSnapshotPath(); + QImage source(400, 300, QImage::Format_ARGB32_Premultiplied); + source.fill(QColor(QStringLiteral("#365070"))); + if (path.isEmpty() || !isShelfSnapshotPath(path) || + !saveTemporarySnapshot(source, path, error)) + return false; + + CaptureData capture; + capture.monitor.geometry = QRect(QPoint(), source.size()); + capture.monitor.pixelSize = source.size(); + capture.monitor.scale = 1.0; + capture.previewSize = source.size(); + capture.source = source; + CaptureEditor editor(capture, CaptureEditor::CaptureMode::File); + editor.setReplacementOutputPath(path); + editor.resize(source.size()); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_A); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + QTest::mouseMove(&editor, QPoint(250, 180), 10); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + QPoint(250, 180)); + QTest::keyClick(&editor, Qt::Key_S, Qt::ControlModifier); + editor.waitForExport(); + const QImage updated(path); + QFile::remove(operationLogPath(path)); + QFile::remove(path); + if (editor.isVisible() || updated.isNull() || updated == source) { + error = QStringLiteral("Shelf annotation did not replace its source item"); + return false; + } + return true; +} + int main(int argc, char **argv) { // Re-executed by the instance-lock checks as the process holding the lock. const QString heldLockPath = @@ -7429,6 +7499,14 @@ int main(int argc, char **argv) { return 0; } QString snapshotError; + if (!runPostCaptureHandoffSmoke(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 125; + } + if (!runShelfReplacementOutputSmoke(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 126; + } if (!runAreaLastRegionSmoke(application, snapshotError)) { qWarning().noquote() << snapshotError; return 119; @@ -8887,5 +8965,11 @@ int main(int argc, char **argv) { qWarning().noquote() << instanceError; return 85; } + + QString shelfLayoutError; + if (!runShelfLayoutSmoke(shelfLayoutError)) { + qWarning().noquote() << shelfLayoutError; + return 89; + } return 0; } diff --git a/tests/shelf-layout-smoke.cpp b/tests/shelf-layout-smoke.cpp new file mode 100644 index 00000000..8cad65fe --- /dev/null +++ b/tests/shelf-layout-smoke.cpp @@ -0,0 +1,38 @@ +#include "shelf-layout-smoke.hpp" + +#include "shelf-layout.hpp" + +bool runShelfLayoutSmoke(QString &error) { + const CaptureShelfLayout stacked = + captureShelfLayout(3, ShelfPresentation::Stacked); + const CaptureShelfLayout expanded = + captureShelfLayout(3, ShelfPresentation::Expanded); + const CaptureShelfLayout tucked = + captureShelfLayout(3, ShelfPresentation::Tucked); + if (stacked.thumbnails.size() != 3 || expanded.thumbnails.size() != 3 || + !tucked.thumbnails.isEmpty() || tucked.handle.isEmpty()) { + error = QStringLiteral("Shelf layouts did not expose the expected items"); + return false; + } + if (stacked.size.height() >= expanded.size.height() || + tucked.size.height() >= stacked.size.height()) { + error = QStringLiteral("Shelf presentation heights are not ordered"); + return false; + } + if (stacked.thumbnails.at(0).top() <= stacked.thumbnails.at(1).top() || + expanded.thumbnails.at(0).top() <= expanded.thumbnails.at(1).top()) { + error = QStringLiteral("Newest Shelf item was not placed at the front"); + return false; + } + const QPointF overlap = stacked.thumbnails.at(0).topLeft() + QPointF(5, 5); + if (captureShelfItemAt(stacked, overlap) != 0) { + error = QStringLiteral("Shelf hit testing did not prefer the newest item"); + return false; + } + if (captureShelfLayout(20, ShelfPresentation::Expanded).thumbnails.size() != + captureShelfMaximumItems()) { + error = QStringLiteral("Shelf did not enforce its five-item limit"); + return false; + } + return true; +} diff --git a/tests/shelf-layout-smoke.hpp b/tests/shelf-layout-smoke.hpp new file mode 100644 index 00000000..54ba0c1c --- /dev/null +++ b/tests/shelf-layout-smoke.hpp @@ -0,0 +1,5 @@ +#pragma once + +#include + +[[nodiscard]] bool runShelfLayoutSmoke(QString &error);