From 3780452b413f5ea582ea8254fc6ebf3c1293ac0d Mon Sep 17 00:00:00 2001 From: starlit Date: Sat, 25 Jul 2026 13:21:59 +0300 Subject: [PATCH 1/3] Add circular magnifying glass option Introduce an optional circular loupe for the magnifying glass, toggled from the Options window (General tab). The circle's diameter is the wider of the two configured rectangle dimensions, derived at display time only: the rectangle size setting (MAG_GLASS_SIZE) is never overwritten, so switching modes restores the exact saved size. MagnifyingGlass now separates its logical rectangle (the source of truth for size gestures and the persisted setting) from its display geometry, which grows to a max(w, h) square while circular. The shape is realised with a widget mask for corner transparency plus a custom paintEvent for antialiased interior scaling. A secondary bezel-ring option (default on, enabled only when circular is active) draws an antialiased filled annulus inset just inside the mask so its outer edge forms a smooth silhouette that blends into the page, hiding the aliased mask edge. --- YACReader/configuration.cpp | 4 + YACReader/configuration.h | 4 + YACReader/magnifying_glass.cpp | 134 ++++++++++++++++++++++++++++----- YACReader/magnifying_glass.h | 24 +++++- YACReader/options_dialog.cpp | 18 +++++ YACReader/options_dialog.h | 3 + YACReader/viewer.cpp | 5 ++ common/yacreader_global_gui.h | 2 + 8 files changed, 173 insertions(+), 21 deletions(-) diff --git a/YACReader/configuration.cpp b/YACReader/configuration.cpp index a424aef7b..3e4758672 100644 --- a/YACReader/configuration.cpp +++ b/YACReader/configuration.cpp @@ -28,6 +28,10 @@ void Configuration::load(QSettings *settings) settings->setValue(MAG_GLASS_SIZE, QSize(350, 175)); if (!settings->contains(MAG_GLASS_ZOOM)) settings->setValue(MAG_GLASS_ZOOM, 0.5); + if (!settings->contains(MAG_GLASS_CIRCULAR)) + settings->setValue(MAG_GLASS_CIRCULAR, false); + if (!settings->contains(MAG_GLASS_RING)) + settings->setValue(MAG_GLASS_RING, true); if (!settings->contains(FLOW_TYPE)) settings->setValue(FLOW_TYPE, 0); if (!settings->contains(FULLSCREEN)) diff --git a/YACReader/configuration.h b/YACReader/configuration.h index d872cd3e4..c2d2f1938 100644 --- a/YACReader/configuration.h +++ b/YACReader/configuration.h @@ -55,6 +55,10 @@ class Configuration : public QObject void setMagnifyingGlassSize(const QSize &mgs) { settings->setValue(MAG_GLASS_SIZE, mgs); } float getMagnifyingGlassZoom() { return settings->value(MAG_GLASS_ZOOM, 0.5).toFloat(); } void setMagnifyingGlassZoom(float mgz) { settings->setValue(MAG_GLASS_ZOOM, mgz); } + bool getMagnifyingGlassCircular() { return settings->value(MAG_GLASS_CIRCULAR, false).toBool(); } + void setMagnifyingGlassCircular(bool circular) { settings->setValue(MAG_GLASS_CIRCULAR, circular); } + bool getMagnifyingGlassRing() { return settings->value(MAG_GLASS_RING, true).toBool(); } + void setMagnifyingGlassRing(bool ring) { settings->setValue(MAG_GLASS_RING, ring); } QSize getGotoSlideSize() { return settings->value(GO_TO_FLOW_SIZE).toSize(); } void setGotoSlideSize(const QSize &gss) { settings->setValue(GO_TO_FLOW_SIZE, gss); } float getZoomLevel() { return settings->value(ZOOM_LEVEL).toFloat(); } diff --git a/YACReader/magnifying_glass.cpp b/YACReader/magnifying_glass.cpp index 306c48a8f..213771b01 100644 --- a/YACReader/magnifying_glass.cpp +++ b/YACReader/magnifying_glass.cpp @@ -2,24 +2,118 @@ #include "viewer.h" -MagnifyingGlass::MagnifyingGlass(int w, int h, float zoomLevel, QWidget *parent) - : QLabel(parent), zoomLevel(zoomLevel) +#include +#include + +MagnifyingGlass::MagnifyingGlass(int w, int h, float zoomLevel, bool circular, bool ring, QWidget *parent) + : QLabel(parent), zoomLevel(zoomLevel), circular(circular), ring(ring) { setup(QSize(w, h)); } -MagnifyingGlass::MagnifyingGlass(const QSize &size, float zoomLevel, QWidget *parent) - : QLabel(parent), zoomLevel(zoomLevel) +MagnifyingGlass::MagnifyingGlass(const QSize &size, float zoomLevel, bool circular, bool ring, QWidget *parent) + : QLabel(parent), zoomLevel(zoomLevel), circular(circular), ring(ring) { setup(size); } void MagnifyingGlass::setup(const QSize &size) { - resize(size); + logicalSize = size; + resize(displaySize()); setScaledContents(true); setMouseTracking(true); setCursor(QCursor(QBitmap(1, 1), QBitmap(1, 1))); + applyShape(); +} + +QSize MagnifyingGlass::displaySize() const +{ + if (circular) { + const int side = qMax(logicalSize.width(), logicalSize.height()); + return QSize(side, side); + } + return logicalSize; +} + +void MagnifyingGlass::applyShape() +{ + if (circular) + setMask(QRegion(rect(), QRegion::Ellipse)); + else + clearMask(); +} + +void MagnifyingGlass::setCircular(bool circular) +{ + if (this->circular == circular) + return; + this->circular = circular; + // Only the display geometry and mask change; logicalSize (and thus the saved + // MAG_GLASS_SIZE) must not be touched, so do not emit sizeChanged here. + resize(displaySize()); + applyShape(); + updateImage(); +} + +void MagnifyingGlass::setRing(bool ring) +{ + if (this->ring == ring) + return; + this->ring = ring; + if (circular) + update(); // ring only affects the circular rendering; repaint, no geometry change +} + +void MagnifyingGlass::paintEvent(QPaintEvent *event) +{ + if (!circular) { + QLabel::paintEvent(event); + return; + } + + const QPixmap pm = pixmap(); + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + + const QRectF fullRect(rect()); + + if (!ring) { + QPainterPath clip; + clip.addEllipse(fullRect); + painter.setClipPath(clip); + if (!pm.isNull()) + painter.drawPixmap(rect(), pm); // mirrors setScaledContents: scale to fill + return; + } + + // Circular + ring. The widget mask (setMask) is a hard-edged ellipse, so anything + // drawn out to the widget boundary keeps that aliased silhouette. Instead, inset the + // whole loupe a couple of pixels inside the mask and let the bezel's own antialiased + // outer edge be the silhouette: the thin margin between bezel and mask stays unpainted + // (transparent) so the page shows through and the antialiased edge blends into it. + const qreal bezelWidth = qMax(2.0, width() / 80.0); + const qreal outerInset = 1.5; // transparent margin left for the antialiased blend + const QRectF outerRect = fullRect.adjusted(outerInset, outerInset, -outerInset, -outerInset); + const QRectF innerRect = outerRect.adjusted(bezelWidth, bezelWidth, -bezelWidth, -bezelWidth); + + // Content clipped to just past the bezel's inner edge, so the content's own (hard) + // clip edge is hidden underneath the opaque part of the bezel. + QPainterPath contentClip; + contentClip.addEllipse(innerRect.adjusted(-0.5, -0.5, 0.5, 0.5)); + painter.setClipPath(contentClip); + if (!pm.isNull()) + painter.drawPixmap(rect(), pm); + painter.setClipping(false); + + // Bezel as a filled annulus so both edges are antialiased: the inner edge blends onto + // the content, the outer edge blends onto the page. + QPainterPath bezel; + bezel.setFillRule(Qt::OddEvenFill); + bezel.addEllipse(outerRect); + bezel.addEllipse(innerRect); + painter.fillPath(bezel, QColor(30, 30, 30)); } void MagnifyingGlass::mouseMoveEvent(QMouseEvent *event) @@ -100,46 +194,46 @@ void MagnifyingGlass::zoomOut() void MagnifyingGlass::sizeUp() { - auto w = width(); - auto h = height(); + auto w = logicalSize.width(); + auto h = logicalSize.height(); if (growWidth(w) | growHeight(h)) // bitwise OR prevents short-circuiting resizeAndUpdate(w, h); } void MagnifyingGlass::sizeDown() { - auto w = width(); - auto h = height(); + auto w = logicalSize.width(); + auto h = logicalSize.height(); if (shrinkWidth(w) | shrinkHeight(h)) // bitwise OR prevents short-circuiting resizeAndUpdate(w, h); } void MagnifyingGlass::heightUp() { - auto h = height(); + auto h = logicalSize.height(); if (growHeight(h)) - resizeAndUpdate(width(), h); + resizeAndUpdate(logicalSize.width(), h); } void MagnifyingGlass::heightDown() { - auto h = height(); + auto h = logicalSize.height(); if (shrinkHeight(h)) - resizeAndUpdate(width(), h); + resizeAndUpdate(logicalSize.width(), h); } void MagnifyingGlass::widthUp() { - auto w = width(); + auto w = logicalSize.width(); if (growWidth(w)) - resizeAndUpdate(w, height()); + resizeAndUpdate(w, logicalSize.height()); } void MagnifyingGlass::widthDown() { - auto w = width(); + auto w = logicalSize.width(); if (shrinkWidth(w)) - resizeAndUpdate(w, height()); + resizeAndUpdate(w, logicalSize.height()); } void MagnifyingGlass::reset() @@ -151,8 +245,10 @@ void MagnifyingGlass::reset() void MagnifyingGlass::resizeAndUpdate(int w, int h) { - resize(w, h); - emit sizeChanged(size()); + logicalSize = QSize(w, h); + resize(displaySize()); + applyShape(); + emit sizeChanged(logicalSize); // persist the rectangle, never the circular square updateImage(); } diff --git a/YACReader/magnifying_glass.h b/YACReader/magnifying_glass.h index d174c93f0..2f801b3c2 100644 --- a/YACReader/magnifying_glass.h +++ b/YACReader/magnifying_glass.h @@ -3,6 +3,7 @@ #include #include +#include #include class MagnifyingGlass : public QLabel @@ -10,8 +11,24 @@ class MagnifyingGlass : public QLabel Q_OBJECT private: float zoomLevel; + // The rectangle the user configures via the size gestures. This is the source of + // truth for sizing and the only value ever persisted to MAG_GLASS_SIZE. The widget's + // actual geometry (see displaySize()) may differ from this in circular mode. + QSize logicalSize; + // When true the loupe is rendered as a circle whose diameter is the wider of the two + // logicalSize dimensions. The widget grows to a square for display, but logicalSize + // (and therefore the saved setting) is left untouched. + bool circular; + // When true (and circular), a bezel ring is drawn along the circle boundary to hide + // the aliased edge left by the circular mask. Has no effect in rectangular mode. + bool ring; void setup(const QSize &size); void resizeAndUpdate(int w, int h); + // The widget geometry to use for the current mode: a max(w, h) square when circular, + // otherwise the logical rectangle. + QSize displaySize() const; + // Masks the widget to a circle (or clears the mask) to match the current mode. + void applyShape(); // The following 4 functions increase/decrease their argument and return true, // unless the maximum dimension value has been reached, in which case they @@ -22,9 +39,10 @@ class MagnifyingGlass : public QLabel bool shrinkHeight(int &h) const; public: - MagnifyingGlass(int width, int height, float zoomLevel, QWidget *parent); - MagnifyingGlass(const QSize &size, float zoomLevel, QWidget *parent); + MagnifyingGlass(int width, int height, float zoomLevel, bool circular, bool ring, QWidget *parent); + MagnifyingGlass(const QSize &size, float zoomLevel, bool circular, bool ring, QWidget *parent); void mouseMoveEvent(QMouseEvent *event) override; + void paintEvent(QPaintEvent *event) override; public slots: void updateImage(int x, int y); void updateImage(); @@ -37,6 +55,8 @@ public slots: void heightDown(); void widthUp(); void widthDown(); + void setCircular(bool circular); + void setRing(bool ring); void reset(); signals: diff --git a/YACReader/options_dialog.cpp b/YACReader/options_dialog.cpp index f54af77c8..fce3347c6 100644 --- a/YACReader/options_dialog.cpp +++ b/YACReader/options_dialog.cpp @@ -61,6 +61,16 @@ OptionsDialog::OptionsDialog(QWidget *parent) displayLayout->addWidget(showTimeInInformationLabel); displayBox->setLayout(displayLayout); + QGroupBox *magnifyingGlassBox = new QGroupBox(tr("Magnifying glass")); + auto magnifyingGlassLayout = new QVBoxLayout(); + circularMagnifyingGlass = new QCheckBox(tr("Circular magnifying glass")); + magnifyingGlassRing = new QCheckBox(tr("Draw a ring around the circular magnifying glass")); + // The ring only applies to the circular loupe, so it is enabled only when circular is on. + connect(circularMagnifyingGlass, &QCheckBox::toggled, magnifyingGlassRing, &QWidget::setEnabled); + magnifyingGlassLayout->addWidget(circularMagnifyingGlass); + magnifyingGlassLayout->addWidget(magnifyingGlassRing); + magnifyingGlassBox->setLayout(magnifyingGlassLayout); + connect(pathFindButton, &QAbstractButton::clicked, this, &OptionsDialog::findFolder); QGroupBox *slideSizeBox = new QGroupBox(tr("\"Go to flow\" size")); @@ -122,6 +132,7 @@ OptionsDialog::OptionsDialog(QWidget *parent) layoutGeneral->addWidget(pathBox); layoutGeneral->addWidget(languageBox); layoutGeneral->addWidget(displayBox); + layoutGeneral->addWidget(magnifyingGlassBox); layoutGeneral->addWidget(slideSizeBox); // layoutGeneral->addWidget(fitBox); layoutGeneral->addWidget(colorBox); @@ -312,6 +323,9 @@ void OptionsDialog::saveOptions() Configuration::getConfiguration().setShowTimeInInformation(showTimeInInformationLabel->isChecked()); + Configuration::getConfiguration().setMagnifyingGlassCircular(circularMagnifyingGlass->isChecked()); + Configuration::getConfiguration().setMagnifyingGlassRing(magnifyingGlassRing->isChecked()); + if (!backgroundColorFollowsTheme) { settings->setValue(BACKGROUND_COLOR, currentColor); } else { @@ -365,6 +379,10 @@ void OptionsDialog::restoreOptions(QSettings *settings) showTimeInInformationLabel->setChecked(Configuration::getConfiguration().getShowTimeInInformation()); + circularMagnifyingGlass->setChecked(Configuration::getConfiguration().getMagnifyingGlassCircular()); + magnifyingGlassRing->setChecked(Configuration::getConfiguration().getMagnifyingGlassRing()); + magnifyingGlassRing->setEnabled(circularMagnifyingGlass->isChecked()); + backgroundColorFollowsTheme = !settings->contains(BACKGROUND_COLOR); updateColor(backgroundColorFollowsTheme ? theme.viewer.defaultBackgroundColor diff --git a/YACReader/options_dialog.h b/YACReader/options_dialog.h index eb65fe1dd..cafe705d8 100644 --- a/YACReader/options_dialog.h +++ b/YACReader/options_dialog.h @@ -33,6 +33,9 @@ class OptionsDialog : public YACReaderOptionsDialog, protected Themable QCheckBox *showTimeInInformationLabel; + QCheckBox *circularMagnifyingGlass; + QCheckBox *magnifyingGlassRing; + QCheckBox *quickNavi; QCheckBox *disableShowOnMouseOver; QCheckBox *scaleCheckbox; diff --git a/YACReader/viewer.cpp b/YACReader/viewer.cpp index df7cbacc9..9b3cbe2b6 100644 --- a/YACReader/viewer.cpp +++ b/YACReader/viewer.cpp @@ -78,6 +78,8 @@ Viewer::Viewer(QWidget *parent) mglass = new MagnifyingGlass( Configuration::getConfiguration().getMagnifyingGlassSize(), Configuration::getConfiguration().getMagnifyingGlassZoom(), + Configuration::getConfiguration().getMagnifyingGlassCircular(), + Configuration::getConfiguration().getMagnifyingGlassRing(), this); connect(mglass, &MagnifyingGlass::sizeChanged, this, [](QSize size) { @@ -1697,6 +1699,9 @@ void Viewer::updateConfig(QSettings *settings) { goToFlow->updateConfig(settings); + mglass->setCircular(Configuration::getConfiguration().getMagnifyingGlassCircular()); + mglass->setRing(Configuration::getConfiguration().getMagnifyingGlassRing()); + QPalette palette; palette.setColor(backgroundRole(), Configuration::getConfiguration().getBackgroundColor(theme.viewer.defaultBackgroundColor)); setPalette(palette); diff --git a/common/yacreader_global_gui.h b/common/yacreader_global_gui.h index 03f580fd5..3eca02b90 100644 --- a/common/yacreader_global_gui.h +++ b/common/yacreader_global_gui.h @@ -12,6 +12,8 @@ #define UI_LANGUAGE "UI_LANGUAGE" #define MAG_GLASS_SIZE "MAG_GLASS_SIZE" #define MAG_GLASS_ZOOM "MAG_GLASS_ZOOM" +#define MAG_GLASS_CIRCULAR "MAG_GLASS_CIRCULAR" +#define MAG_GLASS_RING "MAG_GLASS_RING" #define ZOOM_LEVEL "ZOOM_LEVEL" #define SLIDE_SIZE "SLIDE_SIZE" #define GO_TO_FLOW_SIZE "GO_TO_FLOW_SIZE" From f529dca315d5241e6e2c55f6510b9c8b00b21e99 Mon Sep 17 00:00:00 2001 From: starlit Date: Sat, 25 Jul 2026 13:24:27 +0300 Subject: [PATCH 2/3] Add magnifier edge easing (ease cursor movement toward the edges) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loupe's sampled content is pushed outward toward the viewport edges so edge content is reachable without moving the cursor all the way to the edge, while tracking the cursor 1:1 in the middle. - Viewer::easeViewerPos normalises the cursor about the viewport centre (the region the page is actually drawn in — the top-level window would fold in the toolbar/chrome and ease too hard), runs it through a smoothstep ramp, and scales it by the loupe half-extent. The half-size cap keeps the cursor's point inside the loupe view and ties the strength to loupe size: a minimum loupe is nearly linear, a large one eases hard. Rectangular loupes cap per axis; circular loupes cap the displacement vector radially. - Two tuning knobs: edgeReach saturates the ramp before the cursor reaches the border, and edgeStrength scales the peak push below the half-extent. - Per-axis letterbox gate: easing only helps where there is off-page content to bring toward the cursor, so it is skipped on an axis once the page is letterboxed by more than minLetterboxFraction (10%) of its own size there. Overflow, an exact fit, or a thin margin still ease, so the effect does not vanish the instant a page is a hair smaller than the viewport. In continuous view the horizontal extent is the page under the cursor, so the gate can vary per row. - Only the content is eased; the loupe widget follows the cursor, so the zoomed image swims a little toward the edge inside the loupe. - MouseHandler's mouse-move path routes through updateImage so both update paths share the eased positioning. - New MAG_GLASS_EDGE_EASE config (default on) with an Options toggle, applied live via Viewer::updateConfig(). --- YACReader/configuration.cpp | 2 + YACReader/configuration.h | 2 + YACReader/magnifying_glass.cpp | 7 ++- YACReader/mouse_handler.cpp | 5 +- YACReader/options_dialog.cpp | 4 ++ YACReader/options_dialog.h | 1 + YACReader/viewer.cpp | 112 +++++++++++++++++++++++++++++++++ YACReader/viewer.h | 14 +++++ common/yacreader_global_gui.h | 1 + 9 files changed, 146 insertions(+), 2 deletions(-) diff --git a/YACReader/configuration.cpp b/YACReader/configuration.cpp index 3e4758672..e4df16791 100644 --- a/YACReader/configuration.cpp +++ b/YACReader/configuration.cpp @@ -32,6 +32,8 @@ void Configuration::load(QSettings *settings) settings->setValue(MAG_GLASS_CIRCULAR, false); if (!settings->contains(MAG_GLASS_RING)) settings->setValue(MAG_GLASS_RING, true); + if (!settings->contains(MAG_GLASS_EDGE_EASE)) + settings->setValue(MAG_GLASS_EDGE_EASE, true); if (!settings->contains(FLOW_TYPE)) settings->setValue(FLOW_TYPE, 0); if (!settings->contains(FULLSCREEN)) diff --git a/YACReader/configuration.h b/YACReader/configuration.h index c2d2f1938..58fb39b3c 100644 --- a/YACReader/configuration.h +++ b/YACReader/configuration.h @@ -59,6 +59,8 @@ class Configuration : public QObject void setMagnifyingGlassCircular(bool circular) { settings->setValue(MAG_GLASS_CIRCULAR, circular); } bool getMagnifyingGlassRing() { return settings->value(MAG_GLASS_RING, true).toBool(); } void setMagnifyingGlassRing(bool ring) { settings->setValue(MAG_GLASS_RING, ring); } + bool getMagnifyingGlassEdgeEase() { return settings->value(MAG_GLASS_EDGE_EASE, true).toBool(); } + void setMagnifyingGlassEdgeEase(bool ease) { settings->setValue(MAG_GLASS_EDGE_EASE, ease); } QSize getGotoSlideSize() { return settings->value(GO_TO_FLOW_SIZE).toSize(); } void setGotoSlideSize(const QSize &gss) { settings->setValue(GO_TO_FLOW_SIZE, gss); } float getZoomLevel() { return settings->value(ZOOM_LEVEL).toFloat(); } diff --git a/YACReader/magnifying_glass.cpp b/YACReader/magnifying_glass.cpp index 213771b01..4f59af69a 100644 --- a/YACReader/magnifying_glass.cpp +++ b/YACReader/magnifying_glass.cpp @@ -125,7 +125,12 @@ void MagnifyingGlass::mouseMoveEvent(QMouseEvent *event) void MagnifyingGlass::updateImage(int x, int y) { auto *const viewer = qobject_cast(parentWidget()); - QImage img = viewer->grabMagnifiedRegion(QPoint(x, y), size(), zoomLevel); + // The loupe widget follows the cursor (and may overhang the window edge, as before). Its + // *content* is sampled at the eased centre, so the zoomed image swims a little toward the + // edges within the loupe — bounded by the loupe's own half-size so the cursor's point + // never leaves the view. + const QPoint sampleCenter = viewer->easeViewerPos(QPoint(x, y), size(), circular); + QImage img = viewer->grabMagnifiedRegion(sampleCenter, size(), zoomLevel); setPixmap(QPixmap::fromImage(img)); move(static_cast(x - float(width()) / 2), static_cast(y - float(height()) / 2)); } diff --git a/YACReader/mouse_handler.cpp b/YACReader/mouse_handler.cpp index dcdfec9f4..b02f02285 100644 --- a/YACReader/mouse_handler.cpp +++ b/YACReader/mouse_handler.cpp @@ -99,7 +99,10 @@ void YACReader::MouseHandler::mouseMoveEvent(QMouseEvent *event) auto position = event->position(); if (viewer->magnifyingGlassShown) - viewer->mglass->move(static_cast(position.x() - float(viewer->mglass->width()) / 2), static_cast(position.y() - float(viewer->mglass->height()) / 2)); + // Route through updateImage so the loupe uses the same eased content centre and + // on-screen-clamped widget position as its own mouseMoveEvent handler, instead of + // snapping to the raw cursor (which fought the easing near the edges). + viewer->mglass->updateImage(static_cast(position.x()), static_cast(position.y())); if (viewer->render->hasLoadedComic()) { if (viewer->showGoToFlowAnimation->state() != QPropertyAnimation::Running) { diff --git a/YACReader/options_dialog.cpp b/YACReader/options_dialog.cpp index fce3347c6..af7d8c493 100644 --- a/YACReader/options_dialog.cpp +++ b/YACReader/options_dialog.cpp @@ -67,8 +67,10 @@ OptionsDialog::OptionsDialog(QWidget *parent) magnifyingGlassRing = new QCheckBox(tr("Draw a ring around the circular magnifying glass")); // The ring only applies to the circular loupe, so it is enabled only when circular is on. connect(circularMagnifyingGlass, &QCheckBox::toggled, magnifyingGlassRing, &QWidget::setEnabled); + magnifyingGlassEdgeEase = new QCheckBox(tr("Ease cursor movement toward the edges")); magnifyingGlassLayout->addWidget(circularMagnifyingGlass); magnifyingGlassLayout->addWidget(magnifyingGlassRing); + magnifyingGlassLayout->addWidget(magnifyingGlassEdgeEase); magnifyingGlassBox->setLayout(magnifyingGlassLayout); connect(pathFindButton, &QAbstractButton::clicked, this, &OptionsDialog::findFolder); @@ -325,6 +327,7 @@ void OptionsDialog::saveOptions() Configuration::getConfiguration().setMagnifyingGlassCircular(circularMagnifyingGlass->isChecked()); Configuration::getConfiguration().setMagnifyingGlassRing(magnifyingGlassRing->isChecked()); + Configuration::getConfiguration().setMagnifyingGlassEdgeEase(magnifyingGlassEdgeEase->isChecked()); if (!backgroundColorFollowsTheme) { settings->setValue(BACKGROUND_COLOR, currentColor); @@ -382,6 +385,7 @@ void OptionsDialog::restoreOptions(QSettings *settings) circularMagnifyingGlass->setChecked(Configuration::getConfiguration().getMagnifyingGlassCircular()); magnifyingGlassRing->setChecked(Configuration::getConfiguration().getMagnifyingGlassRing()); magnifyingGlassRing->setEnabled(circularMagnifyingGlass->isChecked()); + magnifyingGlassEdgeEase->setChecked(Configuration::getConfiguration().getMagnifyingGlassEdgeEase()); backgroundColorFollowsTheme = !settings->contains(BACKGROUND_COLOR); updateColor(backgroundColorFollowsTheme diff --git a/YACReader/options_dialog.h b/YACReader/options_dialog.h index cafe705d8..a078cbd32 100644 --- a/YACReader/options_dialog.h +++ b/YACReader/options_dialog.h @@ -35,6 +35,7 @@ class OptionsDialog : public YACReaderOptionsDialog, protected Themable QCheckBox *circularMagnifyingGlass; QCheckBox *magnifyingGlassRing; + QCheckBox *magnifyingGlassEdgeEase; QCheckBox *quickNavi; QCheckBox *disableShowOnMouseOver; diff --git a/YACReader/viewer.cpp b/YACReader/viewer.cpp index 9b3cbe2b6..500b4b186 100644 --- a/YACReader/viewer.cpp +++ b/YACReader/viewer.cpp @@ -22,6 +22,8 @@ #include #include +#include + Viewer::Viewer(QWidget *parent) : QScrollArea(parent), fullscreen(false), @@ -82,6 +84,8 @@ Viewer::Viewer(QWidget *parent) Configuration::getConfiguration().getMagnifyingGlassRing(), this); + magnifierEdgeEase = Configuration::getConfiguration().getMagnifyingGlassEdgeEase(); + connect(mglass, &MagnifyingGlass::sizeChanged, this, [](QSize size) { Configuration::getConfiguration().setMagnifyingGlassSize(size); }); @@ -924,8 +928,115 @@ QList Viewer::currentVisiblePages() return pages; } +namespace { +// The magnifier edge easing pushes the loupe's sampled centre outward toward the viewport +// edges so edge content is reachable with less cursor travel. Crucially the push is bounded +// by the loupe's own half-size, which (a) keeps the cursor's true point inside the loupe +// view and (b) scales the effect with loupe size: a minimum-size loupe is nearly linear, a +// large one eases strongly. + +// Fraction of the half-axis at which the easing reaches full displacement. Smaller = the +// effect ramps in sooner and saturates before the cursor reaches the edge, so edge content +// is reachable well before the pointer is jammed against the border; beyond this the loupe +// is already at full reach (still capped at the loupe half-size, so the cursor point stays +// in view). 1.0 would only reach full push exactly at the edge. +constexpr double edgeReach = 0.4; + +// Overall strength of the push, as a fraction of the loupe half-extent (its natural cap). 1.0 +// pushes the sampled centre by up to a full half-loupe at the edge; lower values keep the +// content swim gentler so it tracks the cursor more closely. The cursor's point stays in view +// for any value in (0, 1]. +constexpr double edgeStrength = 0.3; + +// Smoothstep ramp: 0 at the viewport centre (1:1 there, and zero slope so it stays linear +// near the middle), rising to 1 by edgeReach (and held there to the edge). Multiplied by the +// loupe half-extent to give the outward displacement. +double edgeRamp(double u) // u = |normalised cursor offset from centre|, in [0, 1] +{ + u = qBound(0.0, u / edgeReach, 1.0); + return u * u * (3.0 - 2.0 * u); +} + +// The edge easing is cancelled on an axis only once the page is letterboxed by at least this +// fraction of its own size on that axis. Below it — a thin margin, an exact fit, or a page +// that overflows the viewport — the curve still applies, so the effect stays visible for +// pages that nearly fill the view instead of vanishing the instant the page is a hair smaller +// than the viewport. Above it the background beside the page is wide enough that easing would +// mostly reveal that background, so the axis is left at 1:1. +constexpr double minLetterboxFraction = 0.10; +} + +QPoint Viewer::easeViewerPos(const QPoint &viewerPos, const QSize &glassSize, bool circular) const +{ + if (!magnifierEdgeEase) { + return viewerPos; + } + // Reference frame = the viewport (the visible scroll region the page is drawn in), not the + // top-level window. The window includes the toolbar/chrome, whose height inflates the + // frame well beyond the page and pushes the "centre" off, so the loupe eases too hard. In + // fullscreen the viewport already fills the screen, so this matches the old behaviour there. + const double vpW = viewport()->width(); + const double vpH = viewport()->height(); + if (vpW <= 1.0 || vpH <= 1.0) { + return viewerPos; + } + + // Normalised cursor offset from the viewport centre, per axis in [-1, 1]. + const double tx = qBound(-1.0, (viewerPos.x() - vpW / 2.0) / (vpW / 2.0), 1.0); + const double ty = qBound(-1.0, (viewerPos.y() - vpH / 2.0) / (vpH / 2.0), 1.0); + + // Easing helps on an axis where there is off-page content to bring toward the cursor. A page + // that overflows the viewport always qualifies; a letterboxed page qualifies until the + // margin beside it grows large enough that easing would mostly reveal background. Cancel the + // curve on an axis only once the letterbox reaches minLetterboxFraction of the page's size on + // that axis, so a page that nearly fills the viewport (thin margin or exact fit) still eases + // instead of dropping the effect the instant the page is a hair smaller than the view. + bool easeX = true; + bool easeY = true; + if (const QWidget *w = widget()) { + double pageW = w->width(); + const double pageH = w->height(); + if (continuousScroll && w == continuousWidget && continuousViewModel != nullptr) { + // The continuous widget fills the viewport width with each page centred inside it, + // so the page under the cursor — not the widget — is the horizontal extent. The + // document is contiguous vertically, so the widget height is the vertical extent. + const int cwY = viewerPos.y() + verticalScrollBar()->sliderPosition(); + const int idx = qBound(0, continuousViewModel->pageAtY(cwY), continuousViewModel->numPages() - 1); + pageW = continuousViewModel->scaledPageSize(idx).width(); + } + // Letterbox = how far the viewport exceeds the page on the axis; keep easing until it + // reaches minLetterboxFraction of the page dimension (overflow and exact fit stay on the + // "ease" side). + easeX = (vpW - pageW) < minLetterboxFraction * pageW; + easeY = (vpH - pageH) < minLetterboxFraction * pageH; + } + + // Outward displacement, capped per axis at the loupe half-extent (the loupe's "reach") and + // scaled by edgeStrength. The cap is what guarantees the cursor's point never leaves the + // loupe view, and what makes a small loupe nearly linear while a large loupe eases hard. + double dx = easeX ? edgeStrength * (glassSize.width() / 2.0) * edgeRamp(qAbs(tx)) * (tx < 0.0 ? -1.0 : 1.0) : 0.0; + double dy = easeY ? edgeStrength * (glassSize.height() / 2.0) * edgeRamp(qAbs(ty)) * (ty < 0.0 ? -1.0 : 1.0) : 0.0; + + if (circular) { + // A round loupe's limit is radial (Pythagorean): the displacement vector may not + // exceed the radius, rather than being capped independently on each axis. + const double radius = qMax(glassSize.width(), glassSize.height()) / 2.0; + const double mag = std::sqrt(dx * dx + dy * dy); + if (mag > radius && mag > 0.0) { + dx *= radius / mag; + dy *= radius / mag; + } + } + + // The displacement is a translation, so add it back in the caller's (viewport) frame. + return QPoint(qRound(viewerPos.x() + dx), qRound(viewerPos.y() + dy)); +} + QImage Viewer::grabMagnifiedRegion(const QPoint &viewerPos, const QSize &glassSize, float zoomLevel) const { + // viewerPos is expected already eased (see MagnifyingGlass::updateImage / easeViewerPos): + // this samples the loupe's *content*, which swims a little toward the edge relative to the + // loupe widget (which itself follows the cursor). const int glassW = glassSize.width(); const int glassH = glassSize.height(); const int zoomW = static_cast(glassW * zoomLevel); @@ -1701,6 +1812,7 @@ void Viewer::updateConfig(QSettings *settings) mglass->setCircular(Configuration::getConfiguration().getMagnifyingGlassCircular()); mglass->setRing(Configuration::getConfiguration().getMagnifyingGlassRing()); + magnifierEdgeEase = Configuration::getConfiguration().getMagnifyingGlassEdgeEase(); QPalette palette; palette.setColor(backgroundRole(), Configuration::getConfiguration().getBackgroundColor(theme.viewer.defaultBackgroundColor)); diff --git a/YACReader/viewer.h b/YACReader/viewer.h index 2393ff338..98706de34 100644 --- a/YACReader/viewer.h +++ b/YACReader/viewer.h @@ -176,6 +176,13 @@ public slots: bool magnifyingGlassShown; bool restoreMagnifyingGlass; void setMagnifyingGlassShown(bool shown); + //! When true, the loupe's sampled-region centre is pushed non-linearly toward the + //! viewport edges so edge content is reachable with less cursor travel. + //! The push is applied per axis until the page is letterboxed by more than a fraction of + //! its own size on that axis (a thin margin, an exact fit, or an overflowing page still + //! ease); past that threshold the background beside the page is wide enough that easing + //! would mostly reveal it, so the axis is left at 1:1. + bool magnifierEdgeEase; //! Event handlers: void resizeEvent(QResizeEvent *event) override; @@ -231,6 +238,13 @@ public slots: QByteArray rawPage(int page) const; QList currentVisiblePages(); QImage grabMagnifiedRegion(const QPoint &viewerPos, const QSize &glassSize, float zoomLevel) const; + //! Eases a cursor position (viewport coords) toward the edges to give the loupe's + //! *content* its sampled-region centre, normalised against the viewport. The outward push + //! is bounded by the loupe's own half-size (per-axis for a rect, radially for a circle), + //! so the cursor's point stays inside the loupe view and the strength scales with loupe + //! size; it is skipped on an axis only where the page is letterboxed beyond a fraction of + //! its own size. + QPoint easeViewerPos(const QPoint &viewerPos, const QSize &glassSize, bool circular) const; // Comic * getComic(){return comic;} const BookmarksDialog *getBookmarksDialog() { return bd; } // returns the current index starting in 1 [1,nPages] diff --git a/common/yacreader_global_gui.h b/common/yacreader_global_gui.h index 3eca02b90..80376a1a9 100644 --- a/common/yacreader_global_gui.h +++ b/common/yacreader_global_gui.h @@ -14,6 +14,7 @@ #define MAG_GLASS_ZOOM "MAG_GLASS_ZOOM" #define MAG_GLASS_CIRCULAR "MAG_GLASS_CIRCULAR" #define MAG_GLASS_RING "MAG_GLASS_RING" +#define MAG_GLASS_EDGE_EASE "MAG_GLASS_EDGE_EASE" #define ZOOM_LEVEL "ZOOM_LEVEL" #define SLIDE_SIZE "SLIDE_SIZE" #define GO_TO_FLOW_SIZE "GO_TO_FLOW_SIZE" From 4d6d5476eb94e7ebdcb1d956a860ebae41e38693 Mon Sep 17 00:00:00 2001 From: starlit Date: Sat, 25 Jul 2026 13:24:41 +0300 Subject: [PATCH 3/3] Make magnifier resize scroll gesture require intent (accumulate wheel delta) Resizing the magnifying glass by scrolling used to step on the mere sign of each wheel event, so a trackpad's many tiny high-resolution events each fired a full step and the faintest two-finger brush resized the loupe. Accumulate the active gesture's signed angleDelta and only take a step once the running total crosses a 120-unit threshold (looping so a fast multi-notch event still steps several times). A real mouse wheel delivers 120 per notch in one event, so it still steps once per notch; the 120 threshold is itself the trackpad/mouse discriminator, so no device-type branching is needed. The accumulator resets when the gesture changes (different modifier) or after ~400ms idle so a stale partial gesture can't leak forward. Applies to the size, height (Ctrl), width (Alt), and zoom (Shift) branches. --- YACReader/magnifying_glass.cpp | 91 ++++++++++++++++++++++------------ YACReader/magnifying_glass.h | 12 +++++ 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/YACReader/magnifying_glass.cpp b/YACReader/magnifying_glass.cpp index 4f59af69a..00aa762d1 100644 --- a/YACReader/magnifying_glass.cpp +++ b/YACReader/magnifying_glass.cpp @@ -145,38 +145,67 @@ void MagnifyingGlass::updateImage() } void MagnifyingGlass::wheelEvent(QWheelEvent *event) { - switch (event->modifiers()) { - // size - case Qt::NoModifier: - if (event->angleDelta().y() < 0) - sizeUp(); - else - sizeDown(); - break; - // size height - case Qt::ControlModifier: - if (event->angleDelta().y() < 0) - heightUp(); - else - heightDown(); - break; - // size width - case Qt::AltModifier: // alt modifier can actually modify the behavior of the event delta, so let's check both x & y - if (event->angleDelta().y() < 0 || event->angleDelta().x() < 0) - widthUp(); - else - widthDown(); - break; - // zoom level - case Qt::ShiftModifier: - if (event->angleDelta().y() < 0) - zoomIn(); - else - zoomOut(); - break; - default: - break; // Never propagate a wheel event to the parent widget, even if we ignore it. + // One notch of a real mouse wheel is 120 angle-delta units in a single event, so this + // threshold makes a mouse still step once per notch while a trackpad's tiny events must + // sum to 120 before stepping — the "intent" that stops a faint brush from resizing. + static constexpr int scrollStepThreshold = 120; + // Drop a partial accumulation that has gone stale, so an old half-finished gesture can't + // leak into an unrelated later one. + static constexpr qint64 scrollResetMs = 400; + + const Qt::KeyboardModifiers modifiers = event->modifiers(); + + // The active gesture reads a single signed axis. Alt (width) can swap the delta onto the + // x axis, so for it take whichever axis carries the larger movement. + int delta = 0; + if (modifiers == Qt::AltModifier) { + const int dy = event->angleDelta().y(); + const int dx = event->angleDelta().x(); + delta = (qAbs(dx) > qAbs(dy)) ? dx : dy; + } else { + delta = event->angleDelta().y(); } + + // Only the four handled gestures accumulate; anything else is swallowed (never propagated + // to the parent) without touching the accumulator. + const bool handled = modifiers == Qt::NoModifier || modifiers == Qt::ControlModifier || modifiers == Qt::AltModifier || modifiers == Qt::ShiftModifier; + if (!handled || delta == 0) { + event->setAccepted(true); + return; + } + + // Reset the running total when the gesture changes (different modifier) or when too much + // time has passed since the last wheel event of this gesture. + if (modifiers != lastScrollModifiers || !scrollTimer.isValid() || scrollTimer.elapsed() > scrollResetMs) + scrollAccumulator = 0; + lastScrollModifiers = modifiers; + scrollTimer.restart(); + + scrollAccumulator += delta; + + // A fast, high-magnitude event may cross the threshold several times over; step once per + // crossing and keep the remainder so accumulation stays smooth. + while (qAbs(scrollAccumulator) >= scrollStepThreshold) { + const bool up = scrollAccumulator < 0; // convention: negative delta grows the loupe + switch (modifiers) { + case Qt::NoModifier: + up ? sizeUp() : sizeDown(); + break; + case Qt::ControlModifier: + up ? heightUp() : heightDown(); + break; + case Qt::AltModifier: + up ? widthUp() : widthDown(); + break; + case Qt::ShiftModifier: + up ? zoomIn() : zoomOut(); + break; + default: + break; + } + scrollAccumulator -= up ? -scrollStepThreshold : scrollStepThreshold; + } + event->setAccepted(true); } void MagnifyingGlass::zoomIn() diff --git a/YACReader/magnifying_glass.h b/YACReader/magnifying_glass.h index 2f801b3c2..48d9c82a1 100644 --- a/YACReader/magnifying_glass.h +++ b/YACReader/magnifying_glass.h @@ -1,6 +1,7 @@ #ifndef __MAGNIFYING_GLASS #define __MAGNIFYING_GLASS +#include #include #include #include @@ -22,6 +23,17 @@ class MagnifyingGlass : public QLabel // When true (and circular), a bezel ring is drawn along the circle boundary to hide // the aliased edge left by the circular mask. Has no effect in rectangular mode. bool ring; + + // Wheel/scroll accumulation for the resize & zoom gestures. Rather than stepping on the + // mere sign of each wheel event (which makes a trackpad's many tiny high-resolution + // events each fire a full step), we sum the signed angle delta of the active gesture and + // only take a step once it crosses scrollStepThreshold. A real mouse wheel delivers 120 + // units per notch in a single event, so it still steps once per notch; a light trackpad + // brush no longer does anything. + int scrollAccumulator = 0; + Qt::KeyboardModifiers lastScrollModifiers = Qt::NoModifier; + QElapsedTimer scrollTimer; + void setup(const QSize &size); void resizeAndUpdate(int w, int h); // The widget geometry to use for the current mode: a max(w, h) square when circular,