diff --git a/CMakeLists.txt b/CMakeLists.txt index a5821ced..d2a9ae52 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,8 @@ add_library(omasnap-core STATIC src/pin-layout.hpp src/capture.cpp src/capture.hpp + src/capture-delay.cpp + src/capture-delay.hpp src/cut.cpp src/cut.hpp src/palette-config.cpp @@ -99,6 +101,8 @@ target_link_libraries(omasnap PRIVATE omasnap-core) if(BUILD_TESTING) qt_add_executable(omasnap-smoke tests/editor-smoke.cpp + tests/capture-delay-smoke.cpp + tests/capture-delay-smoke.hpp tests/clipboard-smoke.cpp tests/clipboard-smoke.hpp tests/instance-lock-smoke.cpp diff --git a/README.md b/README.md index 49351fea..67b87369 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ resizable vector layers and preserves the monitor's native pixels on scaled disp ## Features - Freeform region, window, and full-monitor capture modes. +- Delayed capture with a countdown in any screen corner; longer delays use a + compact timer until the final five seconds. - A pointer-side readout that turns any drag into a ruler: the pointer position while the crosshair is idle, then the frame size in native export pixels while a region, a hovered window, or a crop handle is being sized. @@ -185,6 +187,22 @@ omasnap smart # maps to region selection These options choose what is initially selected; the editor still controls whether the result is copied, saved, or both. +Use `--delay SECONDS` to wait before capturing. The countdown appears in the top-right +corner by default; `--delay-position` accepts `top-left`, `top-right`, `bottom-left`, +or `bottom-right`: + +```bash +omasnap --delay 5 +omasnap --capture-window --delay 3 --delay-position bottom-left +omasnap --capture-fullscreen --copy --delay 10 +``` + +The delay must be a whole number from 0 through 3600 and applies only to screen +captures, not `--file`, `--clipboard`, or `--pin`. The countdown stays on the monitor +that was focused when Omasnap started, which is also the monitor captured. Starting +Omasnap again during the countdown cancels it through the normal single-instance +behavior. + Quick output skips the annotation editor. Add `--copy` to copy only, `--save` to save only, or both flags to copy and save. Region and window captures output after selection; fullscreen captures output immediately. Quick output cannot be combined with `--file`, diff --git a/src/capture-delay.cpp b/src/capture-delay.cpp new file mode 100644 index 00000000..63891776 --- /dev/null +++ b/src/capture-delay.cpp @@ -0,0 +1,383 @@ +/** @fileoverview Paints and times the delayed-capture shutter token. */ +#include "capture-delay.hpp" + +#include "capture.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +constexpr int kMaximumDelaySeconds = 3600; +constexpr int kSurfaceSize = 116; +constexpr qreal kTokenDiameter = 86.0; +constexpr qreal kQuietWidth = 90.0; +constexpr qreal kQuietHeight = 46.0; +constexpr int kEntranceMs = 240; +constexpr int kDigitTransitionMs = 180; +constexpr int kMorphMs = 260; +constexpr int kExitMs = 180; +constexpr int kFrameMs = 16; +constexpr int kFinalSeconds = 5; + +qreal clampedProgress(qreal value) { return std::clamp(value, 0.0, 1.0); } + +qreal eased(qreal value, QEasingCurve::Type curve) { + return QEasingCurve(curve).valueForProgress(clampedProgress(value)); +} + +QPointF entranceOffset(CaptureDelayPosition position) { + constexpr qreal offset = 9.0; + switch (position) { + case CaptureDelayPosition::TopLeft: + return {-offset, -offset}; + case CaptureDelayPosition::TopRight: + return {offset, -offset}; + case CaptureDelayPosition::BottomLeft: + return {-offset, offset}; + case CaptureDelayPosition::BottomRight: + return {offset, offset}; + } + return {}; +} + +void drawCenteredText(QPainter &painter, const QString &text, + const QRectF &bounds, const QFont &font, + const QColor &color, qreal opacity, qreal scale, + qreal verticalOffset = 0.0) { + painter.save(); + painter.setOpacity(painter.opacity() * clampedProgress(opacity)); + const QPointF center = bounds.center() + QPointF(0, verticalOffset); + painter.translate(center); + painter.scale(scale, scale); + painter.translate(-center); + painter.setFont(font); + painter.setPen(color); + painter.drawText(bounds.translated(0, verticalOffset), Qt::AlignCenter, text); + painter.restore(); +} + +void paintAperture(QPainter &painter, const QPointF ¢er, qreal amount) { + amount = clampedProgress(amount); + if (amount <= 0.001) + return; + painter.save(); + painter.translate(center); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(7, 7, 10, qRound(115 * amount))); + const qreal inner = 5.0 + 15.0 * (1.0 - amount); + for (int blade = 0; blade < 6; ++blade) { + painter.save(); + painter.rotate(blade * 60.0 + 12.0 * (1.0 - amount)); + QPainterPath path; + path.moveTo(0, -34); + path.cubicTo(13, -31, 22, -22, 25, -11); + path.lineTo(inner, -2); + path.cubicTo(10, -11, 4, -17, 0, -23); + path.closeSubpath(); + painter.drawPath(path); + painter.restore(); + } + painter.restore(); +} +} // namespace + +QString captureDelayText(int seconds, bool quiet) { + if (!quiet || seconds < 60) + return QString::number(seconds); + return QStringLiteral("%1:%2") + .arg(seconds / 60) + .arg(seconds % 60, 2, 10, QLatin1Char('0')); +} + +bool parseCaptureDelay(const QString &value, int &seconds, QString &error) { + // The expression rejects signs/whitespace/fractions; conversion still checks + // numeric overflow before the bounded cast below. + static const QRegularExpression wholeSeconds( + QStringLiteral("^[0-9]+$")); + bool ok = false; + const qlonglong parsed = value.toLongLong(&ok, 10); + if (!wholeSeconds.match(value).hasMatch() || !ok || parsed < 0 || parsed > kMaximumDelaySeconds) { + error = QStringLiteral( + "Delay must be a whole number of seconds between 0 and %1") + .arg(kMaximumDelaySeconds); + return false; + } + seconds = static_cast(parsed); + return true; +} + +bool parseCaptureDelayPosition(const QString &value, + CaptureDelayPosition &position, + QString &error) { + if (value == QStringLiteral("top-left")) + position = CaptureDelayPosition::TopLeft; + else if (value == QStringLiteral("top-right")) + position = CaptureDelayPosition::TopRight; + else if (value == QStringLiteral("bottom-left")) + position = CaptureDelayPosition::BottomLeft; + else if (value == QStringLiteral("bottom-right")) + position = CaptureDelayPosition::BottomRight; + else { + error = QStringLiteral( + "Delay position must be top-left, top-right, bottom-left, or " + "bottom-right"); + return false; + } + return true; +} + +CaptureDelayVisualState captureDelayVisualState(int totalSeconds, + qint64 elapsedMs) { + CaptureDelayVisualState state; + const qint64 totalMs = std::max(0, totalSeconds) * qint64(1000); + const qint64 elapsed = std::clamp(elapsedMs, 0, totalMs); + state.complete = elapsed >= totalMs; + state.remainingSeconds = + std::max(1, static_cast((totalMs - elapsed + 999) / 1000)); + state.secondProgress = static_cast(elapsed % 1000) / 1000.0; + state.entranceProgress = clampedProgress( + static_cast(elapsed) / static_cast(kEntranceMs)); + const qint64 secondOffset = elapsed % 1000; + state.digitTransitionProgress = + elapsed < 1000 + ? 1.0 + : clampedProgress(static_cast(secondOffset) / + static_cast(kDigitTransitionMs)); + state.quiet = totalSeconds > 10 && state.remainingSeconds > kFinalSeconds; + if (totalSeconds > 10 && state.remainingSeconds == kFinalSeconds) + state.circleProgress = + clampedProgress(static_cast(secondOffset) / + static_cast(kMorphMs)); + else + state.circleProgress = state.quiet ? 0.0 : 1.0; + state.exitProgress = + totalMs > 0 + ? clampedProgress( + static_cast(elapsed - (totalMs - kExitMs)) / + static_cast(kExitMs)) + : 1.0; + return state; +} + +CaptureDelayWidget::CaptureDelayWidget(int seconds, + CaptureDelayPosition position, + QWidget *parent) + : QWidget(parent), seconds_(seconds), position_(position) { + setWindowTitle(QStringLiteral("omasnap-delay")); + setWindowFlags(Qt::Window | Qt::FramelessWindowHint | + Qt::WindowDoesNotAcceptFocus | Qt::WindowTransparentForInput); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_TransparentForMouseEvents); + setFocusPolicy(Qt::NoFocus); + setFixedSize(kSurfaceSize, kSurfaceSize); + + frameTimer_.setInterval(kFrameMs); + frameTimer_.setTimerType(Qt::PreciseTimer); + connect(&frameTimer_, &QTimer::timeout, this, + &CaptureDelayWidget::updateAnimation); + deadlineTimer_.setSingleShot(true); + deadlineTimer_.setTimerType(Qt::PreciseTimer); + connect(&deadlineTimer_, &QTimer::timeout, this, + &CaptureDelayWidget::finishCountdown); +} + +void CaptureDelayWidget::startCountdown() { + if (finished_) { + emit countdownFinished(); + return; + } + if (seconds_ <= 0) { + finishCountdown(); + return; + } + clock_.start(); + frameTimer_.start(); + deadlineTimer_.start(seconds_ * 1000); + update(); +} + +void CaptureDelayWidget::destroySurface() { destroy(); } + +void CaptureDelayWidget::updateAnimation() { + if (!clock_.isValid()) + return; + update(); + const qint64 elapsed = clock_.elapsed(); + const CaptureDelayVisualState state = + captureDelayVisualState(seconds_, elapsed); + const bool animating = state.entranceProgress < 1.0 || + state.digitTransitionProgress < 1.0 || !state.quiet || + state.exitProgress > 0.0; + const int interval = + animating ? kFrameMs : std::max(1, 1000 - int(elapsed % 1000)); + if (frameTimer_.interval() != interval) + frameTimer_.setInterval(interval); +} + +void CaptureDelayWidget::finishCountdown() { + if (finished_) + return; + finished_ = true; + frameTimer_.stop(); + deadlineTimer_.stop(); + emit countdownFinished(); +} + +QImage CaptureDelayWidget::renderFrameForTest(qint64 elapsedMs) const { + QImage image(size(), QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + paintFrame(painter, elapsedMs); + return image; +} + +void CaptureDelayWidget::paintEvent(QPaintEvent *) { + QPainter painter(this); + paintFrame(painter, clock_.isValid() ? clock_.elapsed() : 0); +} + +void CaptureDelayWidget::paintFrame(QPainter &painter, qint64 elapsedMs) const { + painter.setRenderHint(QPainter::Antialiasing, true); + const CaptureDelayVisualState state = + captureDelayVisualState(seconds_, elapsedMs); + if (state.complete) + return; + const qreal entrance = eased(state.entranceProgress, QEasingCurve::OutBack); + const qreal exit = eased(state.exitProgress, QEasingCurve::InCubic); + const qreal circle = eased(state.circleProgress, QEasingCurve::InOutCubic); + const QPointF center(width() / 2.0, height() / 2.0); + + painter.save(); + painter.setOpacity(1.0 - exit); + painter.translate(entranceOffset(position_) * (1.0 - state.entranceProgress)); + painter.translate(center); + const qreal scale = std::max(0.01, (0.72 + 0.28 * entrance) * + (1.0 - exit)); + painter.scale(scale, scale); + painter.translate(-center); + + const qreal tokenWidth = kQuietWidth + (kTokenDiameter - kQuietWidth) * circle; + const qreal tokenHeight = kQuietHeight + (kTokenDiameter - kQuietHeight) * circle; + const QRectF token(center.x() - tokenWidth / 2.0, + center.y() - tokenHeight / 2.0, tokenWidth, tokenHeight); + const qreal radius = tokenHeight / 2.0; + + for (int layer = 6; layer > 0; --layer) { + const qreal spread = layer * 1.4; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 8 + (6 - layer) * 5)); + painter.drawRoundedRect(token.adjusted(-spread, -spread, spread, spread), + radius + spread, radius + spread); + } + QRadialGradient glass(token.center(), tokenHeight * 0.62, + token.center() - QPointF(tokenWidth * 0.12, + tokenHeight * 0.18)); + glass.setColorAt(0, QColor(31, 30, 34, 248)); + glass.setColorAt(0.72, QColor(17, 17, 21, 247)); + glass.setColorAt(1, QColor(10, 10, 14, 250)); + painter.setPen(QPen(QColor(255, 255, 255, 36), 1.0)); + painter.setBrush(glass); + painter.drawRoundedRect(token, radius, radius); + + const QRectF ring = QRectF(center.x() - 39, center.y() - 39, 78, 78); + if (circle > 0.02) { + painter.save(); + painter.setOpacity(painter.opacity() * circle); + const int segmentCount = + seconds_ <= 10 ? seconds_ : kFinalSeconds; + const int activeIndex = segmentCount - state.remainingSeconds; + const qreal span = 360.0 / std::max(1, segmentCount); + constexpr qreal gap = 7.0; + painter.setPen(QPen(QColor(255, 255, 255, 27), 3.0, + Qt::SolidLine, Qt::RoundCap)); + for (int index = 0; index < segmentCount; ++index) { + const int start = qRound((90.0 - index * span) * 16.0); + painter.drawArc(ring, start, qRound(-(span - gap) * 16.0)); + } + painter.setPen(QPen(QColor(242, 198, 109), 3.2, Qt::SolidLine, + Qt::RoundCap)); + for (int index = 0; index < std::max(0, activeIndex); ++index) { + const int start = qRound((90.0 - index * span) * 16.0); + painter.drawArc(ring, start, qRound(-(span - gap) * 16.0)); + } + if (activeIndex >= 0 && activeIndex < segmentCount) { + const qreal fill = state.exitProgress > 0 ? 1.0 : state.secondProgress; + const int start = qRound((90.0 - activeIndex * span) * 16.0); + painter.drawArc(ring, start, + qRound(-(span - gap) * fill * 16.0)); + } + painter.restore(); + } + + const qreal apertureAmount = + std::max(1.0 - state.entranceProgress, state.exitProgress) * circle; + paintAperture(painter, center, apertureAmount); + + // Stable timer strings use a compact UI face; the expressive Neucha digit + // takes over for the camera-style final countdown. + const bool quiet = state.quiet && circle < 0.5; + const bool previousQuiet = + quiet || (seconds_ > 10 && state.remainingSeconds == kFinalSeconds); + QFont font = quiet ? QFont(QStringLiteral("Sans Serif")) + : annotationTextFont(9.6); + const QString currentText = + captureDelayText(state.remainingSeconds, quiet); + const QString previousText = + captureDelayText(state.remainingSeconds + 1, previousQuiet); + font.setPixelSize(quiet ? (std::max(currentText.size(), previousText.size()) > 4 + ? 18 + : 23) + : 48); + if (quiet) + font.setWeight(QFont::DemiBold); + QFont previousFont = font; + if (previousQuiet != quiet) { + previousFont = QFont(QStringLiteral("Sans Serif")); + previousFont.setPixelSize(previousText.size() > 4 ? 18 : 23); + previousFont.setWeight(QFont::DemiBold); + } + const QRectF textBounds = token.adjusted(7, 2, -7, -2); + const QColor textColor(247, 242, 229); + QPainterPath textClip; + textClip.addRoundedRect(token.adjusted(4, 4, -4, -4), + std::max(0.0, radius - 4), + std::max(0.0, radius - 4)); + painter.save(); + painter.setClipPath(textClip); + const qreal transition = + eased(state.digitTransitionProgress, QEasingCurve::OutCubic); + if (state.digitTransitionProgress < 1.0) { + drawCenteredText(painter, previousText, textBounds, previousFont, textColor, + std::pow(1.0 - transition, 2.0), + 1.0 + transition * 0.14, -transition * 30.0); + drawCenteredText(painter, currentText, textBounds, font, textColor, + std::pow(transition, 2.0), + 0.86 + transition * 0.14, + (1.0 - transition) * 30.0); + } else { + drawCenteredText(painter, currentText, textBounds, font, textColor, 1.0, + 1.0); + } + painter.restore(); + + const qreal quietChrome = seconds_ > 10 ? 1.0 - circle : 0.0; + if (quietChrome > 0.0) { + painter.save(); + painter.setOpacity(painter.opacity() * quietChrome); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(242, 198, 109)); + painter.drawEllipse(QPointF(token.left() + 10, token.center().y()), 2.3, + 2.3); + painter.restore(); + } + painter.restore(); +} diff --git a/src/capture-delay.hpp b/src/capture-delay.hpp new file mode 100644 index 00000000..5ba821e1 --- /dev/null +++ b/src/capture-delay.hpp @@ -0,0 +1,68 @@ +/** @fileoverview Declares the delayed-capture countdown and its visual model. */ +#pragma once + +#include +#include +#include +#include +#include + +class QPainter; + +/** Corners accepted by --delay-position. */ +enum class CaptureDelayPosition { TopLeft, TopRight, BottomLeft, BottomRight }; + +struct CaptureDelayVisualState { + int remainingSeconds = 1; + qreal secondProgress = 0.0; + qreal entranceProgress = 0.0; + qreal digitTransitionProgress = 1.0; + qreal circleProgress = 1.0; + qreal exitProgress = 0.0; + bool quiet = false; + bool complete = false; +}; + +/** Strictly parses a whole-second delay in the inclusive range 0..3600. */ +[[nodiscard]] bool parseCaptureDelay(const QString &value, int &seconds, + QString &error); +/** Parses one of the four documented corner names. */ +[[nodiscard]] bool parseCaptureDelayPosition(const QString &value, + CaptureDelayPosition &position, + QString &error); +/** Pure animation state used by both painting and headless smoke coverage. */ +[[nodiscard]] CaptureDelayVisualState captureDelayVisualState( + int totalSeconds, qint64 elapsedMs); +/** Formats quiet-state delays as seconds or m:ss. */ +[[nodiscard]] QString captureDelayText(int seconds, bool quiet); + +/** Custom-painted, focusless countdown surface. Layer-shell setup is external. */ +class CaptureDelayWidget final : public QWidget { + Q_OBJECT +public: + CaptureDelayWidget(int seconds, CaptureDelayPosition position, + QWidget *parent = nullptr); + + void startCountdown(); + /** Immediately releases the native layer surface after it is hidden. */ + void destroySurface(); + [[nodiscard]] QImage renderFrameForTest(qint64 elapsedMs) const; + +signals: + void countdownFinished(); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + void paintFrame(QPainter &painter, qint64 elapsedMs) const; + void updateAnimation(); + void finishCountdown(); + + int seconds_ = 0; + CaptureDelayPosition position_ = CaptureDelayPosition::TopRight; + QElapsedTimer clock_; + QTimer frameTimer_; + QTimer deadlineTimer_; + bool finished_ = false; +}; diff --git a/src/capture.cpp b/src/capture.cpp index d17f6a47..e778f29d 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1127,6 +1127,8 @@ QString temporaryExportPath() { } QString operationLogPath(const QString &imagePath) { + if (imagePath.isEmpty()) + return {}; const QFileInfo info(imagePath); return info.dir().filePath(info.completeBaseName() + QStringLiteral(".json")); } diff --git a/src/main.cpp b/src/main.cpp index f9e3ec06..d36977cf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #include "capture.hpp" +#include "capture-delay.hpp" #include "cli-path.hpp" #include "editor.hpp" #include "instance-lock.hpp" @@ -13,11 +14,14 @@ #include #include #include +#include #include #include +#include #include #include #include +#include #include #include @@ -26,6 +30,7 @@ #include #include #include +#include namespace { class PosixSignalNotifier final : public QObject { @@ -56,6 +61,7 @@ class PosixSignalNotifier final : public QObject { notifier_ = new QSocketNotifier(fds_[1], QSocketNotifier::Read, this); connect(notifier_, &QSocketNotifier::activated, this, [this] { + notified_ = true; notifier_->setEnabled(false); char bytes[32]; while (::read(fds_[1], bytes, sizeof(bytes)) > 0) { @@ -72,6 +78,8 @@ class PosixSignalNotifier final : public QObject { closeSockets(); } + [[nodiscard]] bool wasNotified() const { return notified_; } + private: void closeSockets() { signalFd_ = -1; @@ -89,8 +97,127 @@ class PosixSignalNotifier final : public QObject { struct sigaction previousSigterm_{}; bool sigintInstalled_ = false; bool sigtermInstalled_ = false; + bool notified_ = false; QSocketNotifier *notifier_ = nullptr; }; + +QScreen *screenForMonitor(const MonitorInfo &monitor, + bool fallbackToPrimary = true) { + for (QScreen *screen : QGuiApplication::screens()) { + if (screen->name() == monitor.name) + return screen; + } + return fallbackToPrimary ? QGuiApplication::primaryScreen() : nullptr; +} + +enum class DelayRunResult { Completed, Cancelled, Failed }; + +DelayRunResult runCaptureDelay(QScreen *screen, int seconds, + CaptureDelayPosition position, + const PosixSignalNotifier &signalNotifier, + QString &error) { + if (seconds <= 0) + return DelayRunResult::Completed; + if (signalNotifier.wasNotified()) + return DelayRunResult::Cancelled; + if (!screen) { + error = QStringLiteral("Could not find a screen for the delay countdown"); + return DelayRunResult::Failed; + } + + CaptureDelayWidget countdown(seconds, position); + countdown.setScreen(screen); + static_cast(countdown.winId()); + QWindow *handle = countdown.windowHandle(); + LayerShellQt::Window *layer = + handle ? LayerShellQt::Window::get(handle) : nullptr; + if (!handle || !layer) { + error = QStringLiteral("Could not create delay countdown layer"); + return DelayRunResult::Failed; + } + + layer->setScope(QStringLiteral("omasnap-delay")); + layer->setScreen(screen); + layer->setLayer(LayerShellQt::Window::LayerOverlay); + LayerShellQt::Window::Anchors anchors; + if (position == CaptureDelayPosition::TopLeft || + position == CaptureDelayPosition::TopRight) + anchors.setFlag(LayerShellQt::Window::AnchorTop); + else + anchors.setFlag(LayerShellQt::Window::AnchorBottom); + if (position == CaptureDelayPosition::TopLeft || + position == CaptureDelayPosition::BottomLeft) + anchors.setFlag(LayerShellQt::Window::AnchorLeft); + else + anchors.setFlag(LayerShellQt::Window::AnchorRight); + layer->setAnchors(anchors); + layer->setMargins(QMargins(24, 24, 24, 24)); + layer->setExclusiveZone(0); + layer->setDesiredSize(countdown.size()); + layer->setKeyboardInteractivity( + LayerShellQt::Window::KeyboardInteractivityNone); + layer->setActivateOnShow(false); + + QEventLoop delayLoop; + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval(seconds * 1000 + 2000); + bool completed = false; + bool timedOut = false; + bool screenRemoved = false; + QObject::connect(&countdown, &CaptureDelayWidget::countdownFinished, + &delayLoop, [&] { + completed = true; + delayLoop.quit(); + }); + QObject::connect(&watchdog, &QTimer::timeout, &delayLoop, [&] { + timedOut = true; + delayLoop.quit(); + }); + QObject::connect(qGuiApp, &QGuiApplication::screenRemoved, &delayLoop, + [&](QScreen *removed) { + if (removed == screen) { + screenRemoved = true; + delayLoop.quit(); + } + }); + countdown.show(); + QTimer::singleShot(0, &countdown, + &CaptureDelayWidget::startCountdown); + watchdog.start(); + if (signalNotifier.wasNotified()) + delayLoop.quit(); + else + delayLoop.exec(); + watchdog.stop(); + // Completion, cancellation, timeout, and output removal all converge here. + countdown.hide(); + countdown.destroySurface(); + QCoreApplication::processEvents(); + + if (signalNotifier.wasNotified()) + return DelayRunResult::Cancelled; + if (screenRemoved) { + error = QStringLiteral("Capture monitor was disconnected during the delay"); + return DelayRunResult::Failed; + } + if (timedOut || !completed) { + error = QStringLiteral("Delay countdown stopped unexpectedly"); + return DelayRunResult::Failed; + } + + // Qt and ext-image-copy-capture use separate Wayland connections. A sync on + // Qt's connection guarantees the compositor has processed destruction of + // the countdown surface before the capture request is sent on the other one. + auto *wayland = + qGuiApp->nativeInterface(); + if (!wayland || !wayland->display() || + wl_display_roundtrip(wayland->display()) < 0) { + error = QStringLiteral("Could not synchronize the countdown surface"); + return DelayRunResult::Failed; + } + return DelayRunResult::Completed; +} } // namespace int main(int argc, char **argv) { @@ -164,6 +291,18 @@ 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 delayOption( + QStringLiteral("delay"), + QStringLiteral("Wait 0-3600 seconds before capturing and show a " + "countdown."), + QStringLiteral("seconds")); + const QCommandLineOption delayPositionOption( + QStringLiteral("delay-position"), + QStringLiteral("Countdown corner: top-left, top-right, bottom-left, or " + "bottom-right (default: top-right)."), + QStringLiteral("position")); + parser.addOption(delayOption); + parser.addOption(delayPositionOption); parser.addPositionalArgument( QStringLiteral("target"), QStringLiteral("Capture mode (smart, region, windows, fullscreen) or the " @@ -173,6 +312,24 @@ int main(int argc, char **argv) { QString filePath = parser.value(fileOption); const bool clipboardInput = parser.isSet(clipboardOption); + int delaySeconds = 0; + CaptureDelayPosition delayPosition = CaptureDelayPosition::TopRight; + QString optionError; + if (parser.isSet(delayOption) && + !parseCaptureDelay(parser.value(delayOption), delaySeconds, optionError)) { + qCritical().noquote() << optionError; + return 2; + } + if (parser.isSet(delayPositionOption) && !parser.isSet(delayOption)) { + qCritical() << "--delay-position requires --delay"; + return 2; + } + if (parser.isSet(delayPositionOption) && + !parseCaptureDelayPosition(parser.value(delayPositionOption), + delayPosition, optionError)) { + qCritical().noquote() << optionError; + return 2; + } QuickOutputMode quickOutputMode = QuickOutputMode::None; if (parser.isSet(copyOption) && parser.isSet(saveOption)) @@ -196,7 +353,8 @@ int main(int argc, char **argv) { const QStringList positional = parser.positionalArguments(); if (parser.isSet(pinOption)) { if (!filePath.isEmpty() || clipboardInput || requestedModes > 0 || - !positional.isEmpty() || quickOutputMode != QuickOutputMode::None) { + !positional.isEmpty() || quickOutputMode != QuickOutputMode::None || + parser.isSet(delayOption) || parser.isSet(delayPositionOption)) { qCritical() << "Pinned mode cannot be combined with capture or edit targets"; return 2; @@ -247,6 +405,10 @@ int main(int argc, char **argv) { return 2; } const bool editingImage = clipboardInput || !filePath.isEmpty(); + if (editingImage && parser.isSet(delayOption)) { + qCritical() << "Delay options cannot be combined with an image input"; + return 2; + } if (editingImage && quickOutputMode != QuickOutputMode::None) { qCritical() << "Quick output options cannot be combined with an image input"; @@ -325,8 +487,25 @@ int main(int argc, char **argv) { return 1; } - // Grab the output before the layer exists. ext-image-copy-capture waits for - // a composited frame, so mapping the dim overlay first photographs the veil. + if (!editingImage && delaySeconds > 0) { + QScreen *countdownScreen = screenForMonitor(capture.monitor, false); + // The launch-time focused output remains the capture target while the user + // arranges windows; changing keyboard focus during the wait must not move + // the promised capture to another monitor. + const DelayRunResult delayResult = + runCaptureDelay(countdownScreen, delaySeconds, delayPosition, + signalNotifier, error); + if (delayResult == DelayRunResult::Cancelled) + return 0; + if (delayResult == DelayRunResult::Failed) { + qCritical().noquote() << error; + return 1; + } + } + + // Grab the output before the editor layer exists. The delay surface has + // already unmapped and settled; ext-image-copy-capture would otherwise + // photograph either overlay. const bool instantFullscreenOutput = !editingImage && captureMode == CaptureEditor::CaptureMode::Fullscreen && quickOutputMode != QuickOutputMode::None; @@ -356,12 +535,10 @@ int main(int argc, char **argv) { return 0; } - QScreen *targetScreen = QGuiApplication::primaryScreen(); - for (QScreen *screen : QGuiApplication::screens()) { - if (screen->name() == capture.monitor.name) { - targetScreen = screen; - break; - } + QScreen *targetScreen = screenForMonitor(capture.monitor); + if (!targetScreen) { + qCritical() << "Could not find a screen for the capture overlay"; + return 1; } if (!editingImage) { diff --git a/tests/capture-delay-smoke.cpp b/tests/capture-delay-smoke.cpp new file mode 100644 index 00000000..e744a25c --- /dev/null +++ b/tests/capture-delay-smoke.cpp @@ -0,0 +1,137 @@ +/** @fileoverview Exercises delay parsing, animation state, painting, and timing. */ +#include "capture-delay-smoke.hpp" + +#include "capture-delay.hpp" + +#include +#include +#include +#include + +namespace { +bool hasVisiblePixels(const QImage &image) { + for (int y = 0; y < image.height(); ++y) { + const QRgb *line = reinterpret_cast(image.constScanLine(y)); + for (int x = 0; x < image.width(); ++x) { + if (qAlpha(line[x]) > 20) + return true; + } + } + return false; +} +} // namespace + +bool runCaptureDelaySmoke(QString &error) { + int seconds = -1; + QString parseError; + if (!parseCaptureDelay(QStringLiteral("0"), seconds, parseError) || + seconds != 0 || + !parseCaptureDelay(QStringLiteral("3600"), seconds, parseError) || + seconds != 3600) { + error = QStringLiteral("Valid capture delays were not parsed"); + return false; + } + for (const QString &invalid : {QStringLiteral("-1"), QStringLiteral("+1"), + QStringLiteral(" 1"), QStringLiteral("1.5"), + QStringLiteral("soon"), + QStringLiteral("3601")}) { + parseError.clear(); + if (parseCaptureDelay(invalid, seconds, parseError) || parseError.isEmpty()) { + error = QStringLiteral("Invalid capture delay was accepted: %1") + .arg(invalid); + return false; + } + } + + for (const auto &[name, expected] : + {std::pair{QStringLiteral("top-left"), + CaptureDelayPosition::TopLeft}, + std::pair{QStringLiteral("top-right"), + CaptureDelayPosition::TopRight}, + std::pair{QStringLiteral("bottom-left"), + CaptureDelayPosition::BottomLeft}, + std::pair{QStringLiteral("bottom-right"), + CaptureDelayPosition::BottomRight}}) { + CaptureDelayPosition parsed = CaptureDelayPosition::TopLeft; + if (!parseCaptureDelayPosition(name, parsed, parseError) || + parsed != expected) { + error = QStringLiteral("Delay position was not parsed: %1").arg(name); + return false; + } + } + CaptureDelayPosition invalidPosition = CaptureDelayPosition::TopLeft; + if (parseCaptureDelayPosition(QStringLiteral("center"), invalidPosition, + parseError)) { + error = QStringLiteral("Invalid delay position was accepted"); + return false; + } + + if (captureDelayText(59, true) != QStringLiteral("59") || + captureDelayText(60, true) != QStringLiteral("1:00") || + captureDelayText(61, true) != QStringLiteral("1:01") || + captureDelayText(3600, true) != QStringLiteral("60:00") || + captureDelayText(60, false) != QStringLiteral("60")) { + error = QStringLiteral("Capture delay text formatting was incorrect"); + return false; + } + + const CaptureDelayVisualState zero = captureDelayVisualState(0, 0); + const CaptureDelayVisualState ten = captureDelayVisualState(10, 0); + const CaptureDelayVisualState eleven = captureDelayVisualState(11, 0); + const CaptureDelayVisualState quiet = captureDelayVisualState(60, 1200); + const CaptureDelayVisualState morphing = captureDelayVisualState(60, 55200); + const CaptureDelayVisualState finalPhase = + captureDelayVisualState(60, 55900); + const CaptureDelayVisualState exiting = captureDelayVisualState(60, 59910); + const CaptureDelayVisualState complete = captureDelayVisualState(60, 60000); + if (!zero.complete || zero.exitProgress != 1.0 || ten.quiet || + ten.circleProgress != 1.0 || !eleven.quiet || + eleven.circleProgress != 0.0 || quiet.remainingSeconds != 59 || + !quiet.quiet || quiet.circleProgress != 0.0 || morphing.remainingSeconds != 5 || + morphing.quiet || morphing.circleProgress <= 0.0 || + morphing.circleProgress >= 1.0 || finalPhase.circleProgress != 1.0 || + exiting.exitProgress < 0.49 || exiting.exitProgress > 0.51 || + !complete.complete) { + error = QStringLiteral("Capture delay animation state was inconsistent"); + return false; + } + + const CaptureDelayWidget painted(5, CaptureDelayPosition::TopLeft); + const CaptureDelayWidget sevenSeconds(7, CaptureDelayPosition::TopRight); + const CaptureDelayWidget morphPainted(60, + CaptureDelayPosition::BottomRight); + if (!hasVisiblePixels(painted.renderFrameForTest(500)) || + hasVisiblePixels(painted.renderFrameForTest(5000)) || + !hasVisiblePixels(sevenSeconds.renderFrameForTest(500)) || + !hasVisiblePixels(morphPainted.renderFrameForTest(55200))) { + error = QStringLiteral("Capture delay token did not paint or clear cleanly"); + return false; + } + + CaptureDelayWidget timed(1, CaptureDelayPosition::TopLeft); + int completions = 0; + QObject::connect(&timed, &CaptureDelayWidget::countdownFinished, + [&] { ++completions; }); + timed.startCountdown(); + QElapsedTimer wait; + wait.start(); + while (completions == 0 && wait.elapsed() < 5000) { + QApplication::processEvents(); + QThread::msleep(5); + } + if (completions != 1 || wait.elapsed() < 850) { + error = QStringLiteral("Capture delay deadline did not fire once on time"); + return false; + } + QElapsedTimer duplicateWindow; + duplicateWindow.start(); + while (duplicateWindow.elapsed() < 150) { + QApplication::processEvents(); + QThread::msleep(5); + } + if (completions != 1) { + error = QStringLiteral("Capture delay deadline fired more than once"); + return false; + } + return true; +} diff --git a/tests/capture-delay-smoke.hpp b/tests/capture-delay-smoke.hpp new file mode 100644 index 00000000..0f2ab6a3 --- /dev/null +++ b/tests/capture-delay-smoke.hpp @@ -0,0 +1,6 @@ +/** @fileoverview Declares delayed-capture model and widget smoke checks. */ +#pragma once + +class QString; + +[[nodiscard]] bool runCaptureDelaySmoke(QString &error); diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 925bca70..8983f643 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -1,6 +1,7 @@ /** @fileoverview Exercises capture editor behavior without a live compositor. */ #include "capture.hpp" +#include "capture-delay-smoke.hpp" #include "output-config.hpp" #include "cli-path.hpp" #include "clipboard-smoke.hpp" @@ -2306,6 +2307,10 @@ bool runSpotlightWheelSmoke(QApplication &application, QString &error) { /** Window crop, undo/redo replay, persist+reload, and redaction order. */ bool runOpLogSmoke(QApplication &application, QString &error) { + if (!operationLogPath(QString()).isEmpty()) { + error = QStringLiteral("Empty snapshot path produced a sidecar path"); + return false; + } CaptureData capture; capture.monitor.name = QStringLiteral("TEST"); capture.monitor.geometry = {0, 0, 800, 600}; @@ -6760,6 +6765,12 @@ int main(int argc, char **argv) { } } + QString delayError; + if (!runCaptureDelaySmoke(delayError)) { + qWarning().noquote() << "capture delay smoke failed:" << delayError; + return 124; + } + QString clipboardError; if (!runClipboardSmoke(clipboardError)) { qWarning().noquote() << clipboardError;