From 910bb2aa184406f8daf0d2b595df658ff0959f68 Mon Sep 17 00:00:00 2001 From: Thomas Foricher Date: Wed, 22 Apr 2026 13:53:35 +0200 Subject: [PATCH 1/4] Add trackpad pinch-to-zoom gesture to the comic viewer Enable the macOS and other Qt-supported trackpad pinch gesture to zoom continuously within the existing 30% to 500% bounds. Keep the cursor anchor stationary in single-page mode and preserve the existing viewport anchor in continuous-scroll mode. Show a compact translucent HUD in the bottom-right corner with the live zoom percentage while the gesture is active. The existing centered notification remains in use for keyboard zoom shortcuts. Relates to #435 --- YACReader/viewer.cpp | 81 ++++++++++++++++++++++++++++++++++++++++++++ YACReader/viewer.h | 10 ++++++ 2 files changed, 91 insertions(+) diff --git a/YACReader/viewer.cpp b/YACReader/viewer.cpp index 0f3bc3f06..8cb2e81f7 100644 --- a/YACReader/viewer.cpp +++ b/YACReader/viewer.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -72,8 +73,23 @@ Viewer::Viewer(QWidget *parent) shouldOpenPrevious(false), magnifyingGlassShown(false), restoreMagnifyingGlass(false), + pinchStartZoom(100), + pinchAnchorNormX(0.5), + pinchAnchorNormY(0.5), + pinchZoomHud(nullptr), mouseHandler(std::make_unique(this)) { + grabGesture(Qt::PinchGesture); + + pinchZoomHud = new QLabel(this); + pinchZoomHud->setAlignment(Qt::AlignCenter); + pinchZoomHud->setAttribute(Qt::WA_TransparentForMouseEvents); + pinchZoomHud->setTextFormat(Qt::RichText); + pinchZoomHud->setStyleSheet( + "background-color: rgba(0, 0, 0, 153); border-radius: 3px;"); + pinchZoomHud->setFixedSize(100, 60); + pinchZoomHud->hide(); + translator = new YACReaderTranslator(this); translator->hide(); translatorAnimation = new QPropertyAnimation(translator, "pos"); @@ -1822,6 +1838,71 @@ bool Viewer::eventFilter(QObject *obj, QEvent *event) return QScrollArea::eventFilter(obj, event); } +bool Viewer::event(QEvent *event) +{ + if (event->type() == QEvent::Gesture) { + return gestureEvent(static_cast(event)); + } + return QScrollArea::event(event); +} + +void Viewer::positionPinchZoomHud() +{ + const int margin = 16; + pinchZoomHud->move(width() - pinchZoomHud->width() - margin, + height() - pinchZoomHud->height() - margin); + pinchZoomHud->raise(); +} + +bool Viewer::gestureEvent(QGestureEvent *event) +{ + if (QGesture *g = event->gesture(Qt::PinchGesture)) { + auto *pinch = static_cast(g); + if (!render->hasLoadedComic()) { + event->accept(pinch); + return true; + } + if (pinch->state() == Qt::GestureStarted) { + pinchStartZoom = zoom; + const QPoint cursorViewport = viewport()->mapFromGlobal(QCursor::pos()); + pinchAnchorViewport = cursorViewport; + if (content->width() > 0 && content->height() > 0) { + const QPoint cursorInContent = content->mapFrom(viewport(), cursorViewport); + pinchAnchorNormX = std::clamp(double(cursorInContent.x()) / content->width(), 0.0, 1.0); + pinchAnchorNormY = std::clamp(double(cursorInContent.y()) / content->height(), 0.0, 1.0); + } else { + pinchAnchorNormX = 0.5; + pinchAnchorNormY = 0.5; + } + } + int newZoom = std::clamp(std::lround(pinchStartZoom * pinch->totalScaleFactor()), 30, 500); + if (newZoom != zoom) { + updateZoomRatio(newZoom); + + if (!continuousScroll) { + const int alignX = std::max(0, (viewport()->width() - content->width()) / 2); + const int alignY = std::max(0, (viewport()->height() - content->height()) / 2); + const int targetH = std::lround(pinchAnchorNormX * content->width()) + alignX - pinchAnchorViewport.x(); + const int targetV = std::lround(pinchAnchorNormY * content->height()) + alignY - pinchAnchorViewport.y(); + horizontalScrollBar()->setValue(targetH); + verticalScrollBar()->setValue(targetV); + } + + pinchZoomHud->setText(QStringLiteral("%1%").arg(zoom)); + positionPinchZoomHud(); + pinchZoomHud->show(); + + emit zoomUpdated(zoom); + } + if (pinch->state() == Qt::GestureFinished || pinch->state() == Qt::GestureCanceled) { + pinchZoomHud->hide(); + } + event->accept(pinch); + return true; + } + return QScrollArea::event(event); +} + void Viewer::setActiveWidget(QWidget *w) { if (widget() == w) { diff --git a/YACReader/viewer.h b/YACReader/viewer.h index 9d2770bfd..317a5d363 100644 --- a/YACReader/viewer.h +++ b/YACReader/viewer.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -192,6 +193,15 @@ public slots: void wheelEventMouse(QWheelEvent *event); void wheelEventTrackpad(QWheelEvent *event); void mouseMoveEvent(QMouseEvent *event) override; + bool event(QEvent *event) override; + bool gestureEvent(QGestureEvent *event); + void positionPinchZoomHud(); + + int pinchStartZoom; + QPoint pinchAnchorViewport; + double pinchAnchorNormX; + double pinchAnchorNormY; + QLabel *pinchZoomHud; int verticalScrollStep() const; int horizontalScrollStep() const; From b8f1cc8cdb7fc460ef918aafe03de2572ec8d111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20A=CC=81ngel=20San=20Marti=CC=81n=20Rodri=CC=81guez?= Date: Fri, 7 Aug 2026 18:59:37 +0200 Subject: [PATCH 2/4] Support ctrl+mouse wheel for zooming too --- YACReader/viewer.cpp | 145 ++++++++++++++++++++++++++++++------------- YACReader/viewer.h | 17 +++-- 2 files changed, 114 insertions(+), 48 deletions(-) diff --git a/YACReader/viewer.cpp b/YACReader/viewer.cpp index 8cb2e81f7..06871365a 100644 --- a/YACReader/viewer.cpp +++ b/YACReader/viewer.cpp @@ -74,21 +74,26 @@ Viewer::Viewer(QWidget *parent) magnifyingGlassShown(false), restoreMagnifyingGlass(false), pinchStartZoom(100), - pinchAnchorNormX(0.5), - pinchAnchorNormY(0.5), - pinchZoomHud(nullptr), + zoomAnchorNormX(0.5), + zoomAnchorNormY(0.5), + zoomHud(nullptr), + zoomHudHideTimer(nullptr), mouseHandler(std::make_unique(this)) { grabGesture(Qt::PinchGesture); - pinchZoomHud = new QLabel(this); - pinchZoomHud->setAlignment(Qt::AlignCenter); - pinchZoomHud->setAttribute(Qt::WA_TransparentForMouseEvents); - pinchZoomHud->setTextFormat(Qt::RichText); - pinchZoomHud->setStyleSheet( + zoomHud = new QLabel(this); + zoomHud->setAlignment(Qt::AlignCenter); + zoomHud->setAttribute(Qt::WA_TransparentForMouseEvents); + zoomHud->setTextFormat(Qt::RichText); + zoomHud->setStyleSheet( "background-color: rgba(0, 0, 0, 153); border-radius: 3px;"); - pinchZoomHud->setFixedSize(100, 60); - pinchZoomHud->hide(); + zoomHud->setFixedSize(100, 60); + zoomHud->hide(); + + zoomHudHideTimer = new QTimer(this); + zoomHudHideTimer->setSingleShot(true); + connect(zoomHudHideTimer, &QTimer::timeout, zoomHud, &QWidget::hide); translator = new YACReaderTranslator(this); translator->hide(); @@ -818,6 +823,16 @@ void Viewer::wheelEvent(QWheelEvent *event) return; } + // Check the modifier before choosing the regular mouse/trackpad scroll path so + // high-resolution devices with pixelDelta (notably on macOS) zoom as well. Qt maps + // ControlModifier to Command on macOS unless the application opts out of that mapping. + if (event->modifiers() == Qt::ControlModifier && event->angleDelta().y() != 0) { + wheelEventZoom(event); + return; + } + + wheelZoomAccumulator = 0; + if (!event->pixelDelta().isNull()) { wheelEventTrackpad(event); } else { @@ -825,6 +840,36 @@ void Viewer::wheelEvent(QWheelEvent *event) } } +void Viewer::wheelEventZoom(QWheelEvent *event) +{ + static constexpr int wheelStep = 120; + static constexpr int zoomStep = 10; + static constexpr qint64 accumulatorResetMs = 400; + static constexpr int hudTimeoutMs = 500; + + horizontalScroller->stop(); + verticalScroller->stop(); + wheelStop = false; + + if (!wheelZoomTimer.isValid() || wheelZoomTimer.elapsed() > accumulatorResetMs) { + wheelZoomAccumulator = 0; + } + wheelZoomTimer.restart(); + + wheelZoomAccumulator += event->angleDelta().y(); + const int steps = wheelZoomAccumulator / wheelStep; + wheelZoomAccumulator -= steps * wheelStep; + + if (steps != 0) { + captureZoomAnchor(); + if (applyZoomAtAnchor(zoom + steps * zoomStep)) { + zoomHudHideTimer->start(hudTimeoutMs); + } + } + + event->accept(); +} + void Viewer::wheelEventMouse(QWheelEvent *event) { auto delta = event->angleDelta(); @@ -1846,12 +1891,51 @@ bool Viewer::event(QEvent *event) return QScrollArea::event(event); } -void Viewer::positionPinchZoomHud() +void Viewer::captureZoomAnchor() +{ + zoomAnchorViewport = viewport()->mapFromGlobal(QCursor::pos()); + if (content->width() > 0 && content->height() > 0) { + const QPoint cursorInContent = content->mapFrom(viewport(), zoomAnchorViewport); + zoomAnchorNormX = std::clamp(double(cursorInContent.x()) / content->width(), 0.0, 1.0); + zoomAnchorNormY = std::clamp(double(cursorInContent.y()) / content->height(), 0.0, 1.0); + } else { + zoomAnchorNormX = 0.5; + zoomAnchorNormY = 0.5; + } +} + +bool Viewer::applyZoomAtAnchor(int newZoom) +{ + newZoom = std::clamp(newZoom, 30, 500); + if (newZoom == zoom) { + return false; + } + + updateZoomRatio(newZoom); + + if (!continuousScroll) { + const int alignX = std::max(0, (viewport()->width() - content->width()) / 2); + const int alignY = std::max(0, (viewport()->height() - content->height()) / 2); + const int targetH = std::lround(zoomAnchorNormX * content->width()) + alignX - zoomAnchorViewport.x(); + const int targetV = std::lround(zoomAnchorNormY * content->height()) + alignY - zoomAnchorViewport.y(); + horizontalScrollBar()->setValue(targetH); + verticalScrollBar()->setValue(targetV); + } + + zoomHud->setText(QStringLiteral("%1%").arg(zoom)); + positionZoomHud(); + zoomHud->show(); + + emit zoomUpdated(zoom); + return true; +} + +void Viewer::positionZoomHud() { const int margin = 16; - pinchZoomHud->move(width() - pinchZoomHud->width() - margin, - height() - pinchZoomHud->height() - margin); - pinchZoomHud->raise(); + zoomHud->move(width() - zoomHud->width() - margin, + height() - zoomHud->height() - margin); + zoomHud->raise(); } bool Viewer::gestureEvent(QGestureEvent *event) @@ -1863,39 +1947,14 @@ bool Viewer::gestureEvent(QGestureEvent *event) return true; } if (pinch->state() == Qt::GestureStarted) { + zoomHudHideTimer->stop(); pinchStartZoom = zoom; - const QPoint cursorViewport = viewport()->mapFromGlobal(QCursor::pos()); - pinchAnchorViewport = cursorViewport; - if (content->width() > 0 && content->height() > 0) { - const QPoint cursorInContent = content->mapFrom(viewport(), cursorViewport); - pinchAnchorNormX = std::clamp(double(cursorInContent.x()) / content->width(), 0.0, 1.0); - pinchAnchorNormY = std::clamp(double(cursorInContent.y()) / content->height(), 0.0, 1.0); - } else { - pinchAnchorNormX = 0.5; - pinchAnchorNormY = 0.5; - } + captureZoomAnchor(); } int newZoom = std::clamp(std::lround(pinchStartZoom * pinch->totalScaleFactor()), 30, 500); - if (newZoom != zoom) { - updateZoomRatio(newZoom); - - if (!continuousScroll) { - const int alignX = std::max(0, (viewport()->width() - content->width()) / 2); - const int alignY = std::max(0, (viewport()->height() - content->height()) / 2); - const int targetH = std::lround(pinchAnchorNormX * content->width()) + alignX - pinchAnchorViewport.x(); - const int targetV = std::lround(pinchAnchorNormY * content->height()) + alignY - pinchAnchorViewport.y(); - horizontalScrollBar()->setValue(targetH); - verticalScrollBar()->setValue(targetV); - } - - pinchZoomHud->setText(QStringLiteral("%1%").arg(zoom)); - positionPinchZoomHud(); - pinchZoomHud->show(); - - emit zoomUpdated(zoom); - } + applyZoomAtAnchor(newZoom); if (pinch->state() == Qt::GestureFinished || pinch->state() == Qt::GestureCanceled) { - pinchZoomHud->hide(); + zoomHud->hide(); } event->accept(pinch); return true; diff --git a/YACReader/viewer.h b/YACReader/viewer.h index 317a5d363..49352bae4 100644 --- a/YACReader/viewer.h +++ b/YACReader/viewer.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -192,16 +193,22 @@ public slots: void wheelEvent(QWheelEvent *event) override; void wheelEventMouse(QWheelEvent *event); void wheelEventTrackpad(QWheelEvent *event); + void wheelEventZoom(QWheelEvent *event); void mouseMoveEvent(QMouseEvent *event) override; bool event(QEvent *event) override; bool gestureEvent(QGestureEvent *event); - void positionPinchZoomHud(); + void captureZoomAnchor(); + bool applyZoomAtAnchor(int newZoom); + void positionZoomHud(); int pinchStartZoom; - QPoint pinchAnchorViewport; - double pinchAnchorNormX; - double pinchAnchorNormY; - QLabel *pinchZoomHud; + QPoint zoomAnchorViewport; + double zoomAnchorNormX; + double zoomAnchorNormY; + QLabel *zoomHud; + QTimer *zoomHudHideTimer; + int wheelZoomAccumulator = 0; + QElapsedTimer wheelZoomTimer; int verticalScrollStep() const; int horizontalScrollStep() const; From a9368b6e02d4fa29e54c04e8f38518b68fe85dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20A=CC=81ngel=20San=20Marti=CC=81n=20Rodri=CC=81guez?= Date: Fri, 7 Aug 2026 19:29:30 +0200 Subject: [PATCH 3/4] Debounce renders while zoom is changing for better performance --- YACReader/viewer.cpp | 70 +++++++++++++++++++++++++++++++++++++++----- YACReader/viewer.h | 9 ++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/YACReader/viewer.cpp b/YACReader/viewer.cpp index 06871365a..78370628a 100644 --- a/YACReader/viewer.cpp +++ b/YACReader/viewer.cpp @@ -78,6 +78,7 @@ Viewer::Viewer(QWidget *parent) zoomAnchorNormY(0.5), zoomHud(nullptr), zoomHudHideTimer(nullptr), + zoomPreviewFinishTimer(nullptr), mouseHandler(std::make_unique(this)) { grabGesture(Qt::PinchGesture); @@ -95,6 +96,11 @@ Viewer::Viewer(QWidget *parent) zoomHudHideTimer->setSingleShot(true); connect(zoomHudHideTimer, &QTimer::timeout, zoomHud, &QWidget::hide); + zoomPreviewFinishTimer = new QTimer(this); + zoomPreviewFinishTimer->setSingleShot(true); + zoomPreviewFinishTimer->setInterval(250); + connect(zoomPreviewFinishTimer, &QTimer::timeout, this, &Viewer::renderFinalZoomImage); + translator = new YACReaderTranslator(this); translator->hide(); translatorAnimation = new QPropertyAnimation(translator, "pos"); @@ -494,6 +500,8 @@ void Viewer::updatePage() void Viewer::updateContentSize() { + cancelZoomPreview(); + // there is an image to resize if (currentPage != nullptr && !currentPage->isNull()) { QSize pagefit = currentPage->size(); @@ -1911,15 +1919,27 @@ bool Viewer::applyZoomAtAnchor(int newZoom) return false; } - updateZoomRatio(newZoom); + if (continuousScroll) { + updateZoomRatio(newZoom); + } else { + const int previousZoom = zoom; + zoom = newZoom; + + if (!zoomPreviewActive) { + // Reuse the current high-quality pixmap while the label follows the requested + // geometry. The normal renderer replaces it after the interaction pauses. + scaledContentsBeforeZoomPreview = content->hasScaledContents(); + zoomPreviewBaseSize = content->size(); + zoomPreviewBaseZoom = previousZoom; + content->setScaledContents(true); + zoomPreviewActive = true; + } - if (!continuousScroll) { - const int alignX = std::max(0, (viewport()->width() - content->width()) / 2); - const int alignY = std::max(0, (viewport()->height() - content->height()) / 2); - const int targetH = std::lround(zoomAnchorNormX * content->width()) + alignX - zoomAnchorViewport.x(); - const int targetV = std::lround(zoomAnchorNormY * content->height()) + alignY - zoomAnchorViewport.y(); - horizontalScrollBar()->setValue(targetH); - verticalScrollBar()->setValue(targetV); + const double scale = static_cast(newZoom) / zoomPreviewBaseZoom; + content->resize(std::max(1, qRound(zoomPreviewBaseSize.width() * scale)), + std::max(1, qRound(zoomPreviewBaseSize.height() * scale))); + restoreZoomAnchor(); + zoomPreviewFinishTimer->start(); } zoomHud->setText(QStringLiteral("%1%").arg(zoom)); @@ -1930,6 +1950,38 @@ bool Viewer::applyZoomAtAnchor(int newZoom) return true; } +void Viewer::restoreZoomAnchor() +{ + const int alignX = std::max(0, (viewport()->width() - content->width()) / 2); + const int alignY = std::max(0, (viewport()->height() - content->height()) / 2); + const int targetH = std::lround(zoomAnchorNormX * content->width()) + alignX - zoomAnchorViewport.x(); + const int targetV = std::lround(zoomAnchorNormY * content->height()) + alignY - zoomAnchorViewport.y(); + horizontalScrollBar()->setValue(targetH); + verticalScrollBar()->setValue(targetV); +} + +void Viewer::cancelZoomPreview() +{ + if (!zoomPreviewActive) { + return; + } + + zoomPreviewFinishTimer->stop(); + content->setScaledContents(scaledContentsBeforeZoomPreview); + zoomPreviewActive = false; +} + +void Viewer::renderFinalZoomImage() +{ + if (!zoomPreviewActive) { + return; + } + + cancelZoomPreview(); + updateContentSize(); + restoreZoomAnchor(); +} + void Viewer::positionZoomHud() { const int margin = 16; @@ -1954,6 +2006,7 @@ bool Viewer::gestureEvent(QGestureEvent *event) int newZoom = std::clamp(std::lround(pinchStartZoom * pinch->totalScaleFactor()), 30, 500); applyZoomAtAnchor(newZoom); if (pinch->state() == Qt::GestureFinished || pinch->state() == Qt::GestureCanceled) { + renderFinalZoomImage(); zoomHud->hide(); } event->accept(pinch); @@ -1967,6 +2020,7 @@ void Viewer::setActiveWidget(QWidget *w) if (widget() == w) { return; } + cancelZoomPreview(); verticalScrollBar()->blockSignals(true); takeWidget(); const bool isContinuous = (w == continuousWidget); diff --git a/YACReader/viewer.h b/YACReader/viewer.h index 49352bae4..0e2441800 100644 --- a/YACReader/viewer.h +++ b/YACReader/viewer.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -199,6 +200,9 @@ public slots: bool gestureEvent(QGestureEvent *event); void captureZoomAnchor(); bool applyZoomAtAnchor(int newZoom); + void restoreZoomAnchor(); + void cancelZoomPreview(); + void renderFinalZoomImage(); void positionZoomHud(); int pinchStartZoom; @@ -207,6 +211,11 @@ public slots: double zoomAnchorNormY; QLabel *zoomHud; QTimer *zoomHudHideTimer; + QTimer *zoomPreviewFinishTimer; + bool zoomPreviewActive = false; + bool scaledContentsBeforeZoomPreview = false; + QSize zoomPreviewBaseSize; + int zoomPreviewBaseZoom = 100; int wheelZoomAccumulator = 0; QElapsedTimer wheelZoomTimer; From 970fda66ecaeb9587d35bdab49502a9e424218c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20A=CC=81ngel=20San=20Marti=CC=81n=20Rodri=CC=81guez?= Date: Fri, 7 Aug 2026 19:29:53 +0200 Subject: [PATCH 4/4] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c02a816..177702616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Version counting is based on semantic versioning (Major.Feature.Patch) * Fix showing the go to flow bar asking for permission to control the computer on macOS. Moving the cursor into the bar now works without granting any accessibility permission, where before it was silently doing nothing. * Add a setting to control what the Escape key does. It can keep quitting the reader, as before, or instead cancel the topmost active mode: magnifying glass, dictionary, go to flow and then fullscreen. * Fix crash caused by changing reading direction while quickly turning pages. +* Use pinch and ctrl+wheel mouse to change the zoom level. ### YACReaderLibrary * Add a library repair function to restore missing covers and rescan files that previously failed to be added.