diff --git a/YACReader/configuration.cpp b/YACReader/configuration.cpp index a424aef7b..e4df16791 100644 --- a/YACReader/configuration.cpp +++ b/YACReader/configuration.cpp @@ -28,6 +28,12 @@ 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(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 d872cd3e4..58fb39b3c 100644 --- a/YACReader/configuration.h +++ b/YACReader/configuration.h @@ -55,6 +55,12 @@ 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); } + 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 306c48a8f..00aa762d1 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) @@ -31,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)); } @@ -46,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() @@ -100,46 +228,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 +279,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..48d9c82a1 100644 --- a/YACReader/magnifying_glass.h +++ b/YACReader/magnifying_glass.h @@ -1,8 +1,10 @@ #ifndef __MAGNIFYING_GLASS #define __MAGNIFYING_GLASS +#include #include #include +#include #include class MagnifyingGlass : public QLabel @@ -10,8 +12,35 @@ 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; + + // 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, + // 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 +51,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 +67,8 @@ public slots: void heightDown(); void widthUp(); void widthDown(); + void setCircular(bool circular); + void setRing(bool ring); void reset(); signals: 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 f54af77c8..af7d8c493 100644 --- a/YACReader/options_dialog.cpp +++ b/YACReader/options_dialog.cpp @@ -61,6 +61,18 @@ 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); + 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); QGroupBox *slideSizeBox = new QGroupBox(tr("\"Go to flow\" size")); @@ -122,6 +134,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 +325,10 @@ void OptionsDialog::saveOptions() Configuration::getConfiguration().setShowTimeInInformation(showTimeInInformationLabel->isChecked()); + Configuration::getConfiguration().setMagnifyingGlassCircular(circularMagnifyingGlass->isChecked()); + Configuration::getConfiguration().setMagnifyingGlassRing(magnifyingGlassRing->isChecked()); + Configuration::getConfiguration().setMagnifyingGlassEdgeEase(magnifyingGlassEdgeEase->isChecked()); + if (!backgroundColorFollowsTheme) { settings->setValue(BACKGROUND_COLOR, currentColor); } else { @@ -365,6 +382,11 @@ void OptionsDialog::restoreOptions(QSettings *settings) showTimeInInformationLabel->setChecked(Configuration::getConfiguration().getShowTimeInInformation()); + 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 ? theme.viewer.defaultBackgroundColor diff --git a/YACReader/options_dialog.h b/YACReader/options_dialog.h index eb65fe1dd..a078cbd32 100644 --- a/YACReader/options_dialog.h +++ b/YACReader/options_dialog.h @@ -33,6 +33,10 @@ class OptionsDialog : public YACReaderOptionsDialog, protected Themable QCheckBox *showTimeInInformationLabel; + QCheckBox *circularMagnifyingGlass; + QCheckBox *magnifyingGlassRing; + QCheckBox *magnifyingGlassEdgeEase; + QCheckBox *quickNavi; QCheckBox *disableShowOnMouseOver; QCheckBox *scaleCheckbox; diff --git a/YACReader/viewer.cpp b/YACReader/viewer.cpp index df7cbacc9..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), @@ -78,8 +80,12 @@ Viewer::Viewer(QWidget *parent) mglass = new MagnifyingGlass( Configuration::getConfiguration().getMagnifyingGlassSize(), Configuration::getConfiguration().getMagnifyingGlassZoom(), + Configuration::getConfiguration().getMagnifyingGlassCircular(), + Configuration::getConfiguration().getMagnifyingGlassRing(), this); + magnifierEdgeEase = Configuration::getConfiguration().getMagnifyingGlassEdgeEase(); + connect(mglass, &MagnifyingGlass::sizeChanged, this, [](QSize size) { Configuration::getConfiguration().setMagnifyingGlassSize(size); }); @@ -922,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); @@ -1697,6 +1810,10 @@ void Viewer::updateConfig(QSettings *settings) { goToFlow->updateConfig(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)); setPalette(palette); 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 03f580fd5..80376a1a9 100644 --- a/common/yacreader_global_gui.h +++ b/common/yacreader_global_gui.h @@ -12,6 +12,9 @@ #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 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"