diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index 632bf31..cf9f233 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -14,6 +14,8 @@ #include "liquidappwebpage.hpp" class LiquidAppWebPage; +class SnapshotFlashOverlay; +class QPainter; class LiquidAppWindow : public QWebEngineView { @@ -56,7 +58,9 @@ public slots: private: void takeSnapshot(const bool fullPage); QImage captureFullPage(void); + void renderSnapshot(QPainter* painter); static bool isUserInputEvent(const QEvent::Type eventType); + static bool isEscapeKeyEvent(QEvent* event); QVariant runJavaScriptSync(const QString& js, const int timeoutMs = 5000); void waitForNextFrame(void); const QString colorToRgba(const QColor color); @@ -79,6 +83,7 @@ public slots: bool pageIsLoading = false; bool windowGeometryIsLocked = false; bool isCapturingFullPage = false; + bool fullPageCaptureCancelled = false; QNetworkProxy* proxy = Q_NULLPTR; @@ -114,6 +119,9 @@ public slots: QAction* contextMenuForwardAction; QAction* contextMenuCloseAction; + // Camera flash effect for snapshots + SnapshotFlashOverlay* snapshotFlashOverlay = Q_NULLPTR; + void bindKeyboardShortcuts(void); void loadLiquidAppConfig(void); void saveLiquidAppConfig(void); diff --git a/inc/lqd.h b/inc/lqd.h index abbad7a..9c0009f 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -11,6 +11,9 @@ #define LQD_APP_WIN_MIN_SIZE_W 160 #define LQD_APP_WIN_MIN_SIZE_H 120 #define LQD_UI_MARGIN 24 +#define LQD_SNAPSHOT_FLASH_HOLD 50 // Milliseconds at full brightness +#define LQD_SNAPSHOT_FLASH_FADE 300 // Milliseconds to fade out +#define LQD_SNAPSHOT_FLASH_OPACITY 0.8 #define LQD_SNAPSHOT_MAX_PIXELS 134217728 // Full-page snapshot size limit, in logical pixels (128 megapixels, 512 MB of RAM) #define LQD_ZOOM_LVL_MIN 0.25 #define LQD_ZOOM_LVL_MAX 5.0 // Limited to 5.0 by Chromium diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index ce27b55..9956a53 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +29,74 @@ #include "SetWindowBackgroundColor.h" #endif +// White overlay that briefly lights up the window and fades away, imitating a camera flash +class SnapshotFlashOverlay : public QWidget +{ +public: + explicit SnapshotFlashOverlay(QWidget* parent) : QWidget(parent) + { + setAttribute(Qt::WA_TransparentForMouseEvents); + setAttribute(Qt::WA_NoSystemBackground); + hide(); + + // Stay at full brightness for a moment, then gradually fade out (think flashbang, just quicker) + static const int totalDuration = LQD_SNAPSHOT_FLASH_HOLD + LQD_SNAPSHOT_FLASH_FADE; + animation = new QVariantAnimation(this); + animation->setDuration(totalDuration); + animation->setKeyValueAt(0.0, 1.0); + animation->setKeyValueAt((qreal)LQD_SNAPSHOT_FLASH_HOLD / totalDuration, 1.0); + animation->setKeyValueAt(1.0, 0.0); + + connect(animation, &QVariantAnimation::valueChanged, this, [this](const QVariant& value) { + intensity = value.toReal(); + update(); + }); + connect(animation, &QVariantAnimation::finished, this, &QWidget::hide); + } + + void flash(void) + { + // Cover the entire window, on top of the web view + setGeometry(parentWidget()->rect()); + raise(); + show(); + + // Paint at full brightness right away: the program may be too busy (e.g. saving the snapshot to disk) + // to process events for a while, and the brightest part of the animation would be over by then + animation->stop(); + intensity = 1; + repaint(); + + // Begin fading out only once the program gets back to processing events + // (consecutive flashes simply restart the animation) + QTimer::singleShot(0, animation, [this]() { + animation->stop(); + animation->start(); + }); + } + + void dismiss(void) + { + animation->stop(); + hide(); + } + +protected: + void paintEvent(QPaintEvent* event) override + { + Q_UNUSED(event); + + QPainter painter(this); + QColor color(Qt::white); + color.setAlphaF(intensity * LQD_SNAPSHOT_FLASH_OPACITY); + painter.fillRect(rect(), color); + } + +private: + QVariantAnimation* animation; + qreal intensity = 0; +}; + LiquidAppWindow::LiquidAppWindow(const QString* name) : QWebEngineView() { // Prevent window from getting way too tiny @@ -394,10 +464,18 @@ void LiquidAppWindow::contextMenuEvent(QContextMenuEvent* event) bool LiquidAppWindow::eventFilter(QObject* watched, QEvent* event) { - // Ignore all user input while a full-page snapshot is being taken - // (clicking, selecting text, scrolling, or triggering keyboard shortcuts would mess it up) - if (isCapturingFullPage && isUserInputEvent(event->type())) { - return true; + if (isCapturingFullPage) { + // Hitting Escape cancels taking full-page snapshot + if (isEscapeKeyEvent(event)) { + fullPageCaptureCancelled = true; + return true; + } + + // Ignore all other user input while a full-page snapshot is being taken + // (clicking, selecting text, scrolling, or triggering keyboard shortcuts would mess it up) + if (isUserInputEvent(event->type())) { + return true; + } } if (watched->parent() == this) { @@ -925,7 +1003,7 @@ void LiquidAppWindow::takeSnapshot(const bool fullPage) #endif // Render contents of QWidget into QPainter - render(painter); + renderSnapshot(painter); #if QT_VERSION >= QT_VERSION_CHECK(5, 10, 0) if (hadScrollBarsShown) { @@ -946,10 +1024,25 @@ void LiquidAppWindow::takeSnapshot(const bool fullPage) delete image; } - // TODO: add camera flash visual effect // TODO: add shutter sound } +void LiquidAppWindow::renderSnapshot(QPainter* painter) +{ + if (snapshotFlashOverlay == Q_NULLPTR) { + snapshotFlashOverlay = new SnapshotFlashOverlay(this); + } + + // Make sure that the flash from the previous snapshot (or chunk) doesn't end up in this one + snapshotFlashOverlay->hide(); + + // Render contents of QWidget into QPainter + render(painter); + + // Flash, the way cameras do + snapshotFlashOverlay->flash(); +} + QImage LiquidAppWindow::captureFullPage(void) { // The page gets captured one viewport-sized tile at a time while scrolling it from top to bottom, @@ -967,6 +1060,7 @@ QImage LiquidAppWindow::captureFullPage(void) // Lock the window down for the duration of the capture: no resizing, and no user input (see eventFilter()) isCapturingFullPage = true; + fullPageCaptureCancelled = false; const QSize origMinimumSize = minimumSize(); const QSize origMaximumSize = maximumSize(); setFixedSize(size()); @@ -999,6 +1093,11 @@ QImage LiquidAppWindow::captureFullPage(void) // Keep scrolling down until the page can't be scrolled any further // (measuring the page's height upfront isn't reliable, pages may grow while being scrolled through) for (qreal y = 0; ; y += viewportHeight) { + // The user may have hit Escape while the program was waiting for the page + if (fullPageCaptureCancelled) { + break; + } + const int destTop = qRound(y * zoom); if (destTop >= maxImageHeight) { @@ -1024,6 +1123,10 @@ QImage LiquidAppWindow::captureFullPage(void) waitForNextFrame(); + if (fullPageCaptureCancelled) { + break; + } + // Render contents of QWidget into the tile tile.fill(Qt::transparent); { @@ -1031,7 +1134,7 @@ QImage LiquidAppWindow::captureFullPage(void) painter.setRenderHint(QPainter::Antialiasing); painter.setRenderHint(QPainter::TextAntialiasing); painter.setRenderHint(QPainter::SmoothPixmapTransform); - render(&painter); + renderSnapshot(&painter); } QImage logicalTile = (tileSize != logicalTileSize) @@ -1051,14 +1154,22 @@ QImage LiquidAppWindow::captureFullPage(void) } } - // Stitch the pieces together + // Stitch the pieces together (unless cancelled, in which case nothing gets saved) QImage image; - if (imageHeight > 0) { + if (fullPageCaptureCancelled) { + qDebug().noquote() << "Full-page snapshot cancelled"; + pieces.clear(); + if (snapshotFlashOverlay != Q_NULLPTR) { + snapshotFlashOverlay->dismiss(); + } + } else if (imageHeight > 0) { image = QImage(logicalTileSize.width(), imageHeight, QImage::Format_ARGB32); } if (image.isNull()) { - qDebug().noquote() << "Unable to take full-page snapshot"; + if (!fullPageCaptureCancelled) { + qDebug().noquote() << "Unable to take full-page snapshot"; + } } else { image.fill(Qt::transparent); @@ -1089,6 +1200,22 @@ QImage LiquidAppWindow::captureFullPage(void) return image; } +bool LiquidAppWindow::isEscapeKeyEvent(QEvent* event) +{ + switch (event->type()) { + case QEvent::KeyPress: + case QEvent::ShortcutOverride: + return static_cast(event)->key() == Qt::Key_Escape; + + // Escape is bound to "stop loading / exit full-screen mode", so it usually arrives as a shortcut + case QEvent::Shortcut: + return static_cast(event)->key() == QKeySequence(Qt::Key_Escape); + + default: + return false; + } +} + bool LiquidAppWindow::isUserInputEvent(const QEvent::Type eventType) { switch (eventType) { diff --git a/src/main.cpp b/src/main.cpp index e8b7805..e44a873 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -80,6 +80,8 @@ int main(int argc, char **argv) #if QT_VERSION >= QT_VERSION_CHECK(5, 6, 0) && QT_VERSION < QT_VERSION_CHECK(6, 0, 0) // Account for running on high-DPI displays QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); + // Make pixmaps assume high-DPI scaling by default (always the case in Qt 6) + QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); #endif QCoreApplication::setAttribute(Qt::AA_UseOpenGLES, true);