Extract the layout logic to a view model to simplify the update logic in continuous scroll mode

This commit is contained in:
luisangelsm
2026-02-26 16:19:58 +01:00
committed by Luis Ángel San Martín
parent f61c5280c9
commit 0bd291ba98
7 changed files with 506 additions and 375 deletions

View File

@ -11,6 +11,8 @@ qt_add_executable(YACReader WIN32
main_window_viewer.cpp main_window_viewer.cpp
continuous_page_widget.h continuous_page_widget.h
continuous_page_widget.cpp continuous_page_widget.cpp
continuous_view_model.h
continuous_view_model.cpp
mouse_handler.h mouse_handler.h
mouse_handler.cpp mouse_handler.cpp
viewer.h viewer.h

View File

@ -1,10 +1,9 @@
#include "continuous_page_widget.h" #include "continuous_page_widget.h"
#include "continuous_view_model.h"
#include "render.h" #include "render.h"
#include <QPainter> #include <QPainter>
#include <QPaintEvent> #include <QPaintEvent>
#include <algorithm>
#include <limits>
ContinuousPageWidget::ContinuousPageWidget(QWidget *parent) ContinuousPageWidget::ContinuousPageWidget(QWidget *parent)
: QWidget(parent) : QWidget(parent)
@ -26,80 +25,32 @@ void ContinuousPageWidget::setRender(Render *r)
render = r; render = r;
} }
void ContinuousPageWidget::setNumPages(int count) void ContinuousPageWidget::setViewModel(ContinuousViewModel *viewModel)
{ {
numPages = count; if (continuousViewModel == viewModel) {
defaultPageSize = QSize(800, 1200);
pageSizes.fill(QSize(0, 0), count);
relayout(false);
}
void ContinuousPageWidget::setZoomFactor(int zoom)
{
if (zoomFactor == zoom) {
return;
}
zoomFactor = zoom;
relayout(true);
update();
}
void ContinuousPageWidget::probeBufferedPages()
{
if (!render || numPages == 0) {
return; return;
} }
bool changed = false; if (continuousViewModel) {
for (int i = 0; i < numPages; ++i) { disconnect(continuousViewModel, &ContinuousViewModel::stateChanged, this, QOverload<>::of(&ContinuousPageWidget::update));
const QImage *img = render->bufferedImage(i);
bool hasKnownSize = pageSizes[i].width() > 0 && pageSizes[i].height() > 0;
if (img && !img->isNull() && !hasKnownSize) {
pageSizes[i] = img->size();
if (defaultPageSize == QSize(800, 1200)) {
defaultPageSize = img->size();
}
changed = true;
}
} }
if (changed) { continuousViewModel = viewModel;
relayout(true);
update(); if (continuousViewModel) {
} connect(continuousViewModel, &ContinuousViewModel::stateChanged, this, QOverload<>::of(&ContinuousPageWidget::update));
} }
void ContinuousPageWidget::reset()
{
numPages = 0;
pageSizes.clear();
yPositions.clear();
currentTotalHeight = 0;
layoutSnapshot = LayoutSnapshot();
defaultPageSize = QSize(800, 1200);
setMinimumHeight(0);
setMaximumHeight(QWIDGETSIZE_MAX);
updateGeometry(); updateGeometry();
update(); update();
} }
int ContinuousPageWidget::centerPage(int scrollY, int viewportHeight) const void ContinuousPageWidget::reset()
{ {
const int centerY = scrollY + std::max(0, viewportHeight / 2); setMinimumHeight(0);
return pageAtY(centerY); setMaximumHeight(QWIDGETSIZE_MAX);
} updateGeometry();
update();
int ContinuousPageWidget::yPositionForPage(int pageIndex) const
{
if (pageIndex < 0 || pageIndex >= yPositions.size()) {
return 0;
}
return yPositions[pageIndex];
}
int ContinuousPageWidget::totalHeight() const
{
return currentTotalHeight;
} }
bool ContinuousPageWidget::hasHeightForWidth() const bool ContinuousPageWidget::hasHeightForWidth() const
@ -109,26 +60,24 @@ bool ContinuousPageWidget::hasHeightForWidth() const
int ContinuousPageWidget::heightForWidth(int w) const int ContinuousPageWidget::heightForWidth(int w) const
{ {
if (numPages == 0 || w <= 0) { if (!continuousViewModel || w <= 0) {
return 0; return 0;
} }
Q_UNUSED(w)
int h = 0; return continuousViewModel->totalHeight();
for (int i = 0; i < numPages; ++i) {
QSize scaled = scaledPageSize(i, w);
h += scaled.height();
}
return h;
} }
QSize ContinuousPageWidget::sizeHint() const QSize ContinuousPageWidget::sizeHint() const
{ {
return QSize(defaultPageSize.width(), currentTotalHeight > 0 ? currentTotalHeight : 0); if (!continuousViewModel) {
return QSize(800, 0);
}
return QSize(width(), continuousViewModel->totalHeight());
} }
void ContinuousPageWidget::onPageAvailable(int absolutePageIndex) void ContinuousPageWidget::onPageAvailable(int absolutePageIndex)
{ {
if (!render || absolutePageIndex < 0 || absolutePageIndex >= numPages) { if (!render || !continuousViewModel || absolutePageIndex < 0 || absolutePageIndex >= continuousViewModel->numPages()) {
return; return;
} }
@ -137,45 +86,35 @@ void ContinuousPageWidget::onPageAvailable(int absolutePageIndex)
return; return;
} }
QSize naturalSize = img->size();
// update default page size from the first real page we see
if (defaultPageSize == QSize(800, 1200) && !naturalSize.isNull()) {
defaultPageSize = naturalSize;
}
bool sizeChanged = (pageSizes[absolutePageIndex] != naturalSize);
pageSizes[absolutePageIndex] = naturalSize;
if (sizeChanged) {
// keep anchor page visually stable while refined page sizes arrive
relayout(true);
}
// repaint the region where this page lives // repaint the region where this page lives
if (absolutePageIndex < yPositions.size()) { if (absolutePageIndex < continuousViewModel->numPages()) {
QSize scaled = scaledPageSize(absolutePageIndex, width()); QSize scaled = continuousViewModel->scaledPageSize(absolutePageIndex);
QRect pageRect(0, yPositions[absolutePageIndex], scaled.width(), scaled.height()); const int y = continuousViewModel->yPositionForPage(absolutePageIndex);
int x = (width() - scaled.width()) / 2;
if (x < 0) {
x = 0;
}
QRect pageRect(x, y, scaled.width(), scaled.height());
update(pageRect); update(pageRect);
} }
} }
void ContinuousPageWidget::paintEvent(QPaintEvent *event) void ContinuousPageWidget::paintEvent(QPaintEvent *event)
{ {
if (numPages == 0 || !render) { if (!continuousViewModel || continuousViewModel->numPages() == 0 || !render) {
return; return;
} }
QPainter painter(this); QPainter painter(this);
QRect visibleRect = event->rect(); QRect visibleRect = event->rect();
int firstPage = pageAtY(visibleRect.top()); int firstPage = continuousViewModel->pageAtY(visibleRect.top());
int lastPage = pageAtY(visibleRect.bottom()); int lastPage = continuousViewModel->pageAtY(visibleRect.bottom());
int w = width(); int w = width();
for (int i = firstPage; i <= lastPage && i < numPages; ++i) { for (int i = firstPage; i <= lastPage && i < continuousViewModel->numPages(); ++i) {
int y = yPositions[i]; int y = continuousViewModel->yPositionForPage(i);
QSize scaled = scaledPageSize(i, w); QSize scaled = continuousViewModel->scaledPageSize(i);
// center horizontally if page is narrower than widget // center horizontally if page is narrower than widget
int x = (w - scaled.width()) / 2; int x = (w - scaled.width()) / 2;
if (x < 0) { if (x < 0) {
@ -202,192 +141,7 @@ void ContinuousPageWidget::paintEvent(QPaintEvent *event)
void ContinuousPageWidget::resizeEvent(QResizeEvent *event) void ContinuousPageWidget::resizeEvent(QResizeEvent *event)
{ {
QWidget::resizeEvent(event); QWidget::resizeEvent(event);
relayout(true); if (continuousViewModel) {
} continuousViewModel->setViewportSize(width(), continuousViewModel->viewportHeight());
}
void ContinuousPageWidget::setAnchorPage(int page)
{
anchorPage = page;
}
void ContinuousPageWidget::setViewportState(int scrollY, int viewportHeight)
{
viewportScrollY = std::max(0, scrollY);
currentViewportHeight = std::max(0, viewportHeight);
hasViewportState = true;
}
void ContinuousPageWidget::updateLayout()
{
relayout(false);
}
void ContinuousPageWidget::updateLayoutWithAnchor()
{
relayout(true);
}
void ContinuousPageWidget::relayout(bool preserveAnchor)
{
int w = width();
if (w <= 0) {
w = parentWidget() ? parentWidget()->width() : 0;
}
if (w <= 0) {
w = defaultPageSize.width();
}
const LayoutSnapshot oldSnapshot = layoutSnapshot;
ViewportAnchor anchor;
if (preserveAnchor && hasViewportState && !oldSnapshot.yPositions.isEmpty()) {
anchor = anchorFromViewport(oldSnapshot, viewportScrollY, currentViewportHeight);
} else if (preserveAnchor && anchorPage >= 0) {
anchor.pageIndex = anchorPage;
anchor.offsetRatio = 0.5f;
anchor.valid = true;
}
layoutSnapshot = buildLayoutSnapshot(w);
#ifndef NDEBUG
Q_ASSERT(layoutSnapshot.yPositions.size() == numPages);
Q_ASSERT(layoutSnapshot.scaledSizes.size() == numPages);
for (int i = 0; i < layoutSnapshot.scaledSizes.size(); ++i) {
Q_ASSERT(layoutSnapshot.scaledSizes[i].width() > 0);
Q_ASSERT(layoutSnapshot.scaledSizes[i].height() > 0);
if (i > 0) {
Q_ASSERT(layoutSnapshot.yPositions[i] >= layoutSnapshot.yPositions[i - 1]);
}
}
#endif
yPositions = layoutSnapshot.yPositions;
currentTotalHeight = layoutSnapshot.totalHeight;
setFixedHeight(currentTotalHeight);
updateGeometry();
if (!preserveAnchor || !anchor.valid || currentViewportHeight <= 0) {
return;
}
const int newScrollForAnchor = resolveAnchorToScrollY(layoutSnapshot, anchor, currentViewportHeight);
emit layoutScrollPositionRequested(newScrollForAnchor);
}
ContinuousPageWidget::LayoutSnapshot ContinuousPageWidget::buildLayoutSnapshot(int w) const
{
LayoutSnapshot snapshot;
if (numPages <= 0 || w <= 0) {
return snapshot;
}
snapshot.yPositions.resize(numPages);
snapshot.scaledSizes.resize(numPages);
qint64 y = 0;
for (int i = 0; i < numPages; ++i) {
snapshot.yPositions[i] = static_cast<int>(std::min<qint64>(y, std::numeric_limits<int>::max()));
QSize scaled = scaledPageSize(i, w);
scaled.setWidth(std::max(1, scaled.width()));
scaled.setHeight(std::max(1, scaled.height()));
snapshot.scaledSizes[i] = scaled;
y += scaled.height();
}
snapshot.totalHeight = static_cast<int>(std::min<qint64>(y, static_cast<qint64>(QWIDGETSIZE_MAX)));
return snapshot;
}
int ContinuousPageWidget::pageAtY(const LayoutSnapshot &snapshot, int y) const
{
if (snapshot.yPositions.isEmpty()) {
return 0;
}
auto it = std::upper_bound(snapshot.yPositions.constBegin(), snapshot.yPositions.constEnd(), y);
if (it == snapshot.yPositions.constBegin()) {
return 0;
}
--it;
return static_cast<int>(it - snapshot.yPositions.constBegin());
}
ContinuousPageWidget::ViewportAnchor ContinuousPageWidget::anchorFromViewport(const LayoutSnapshot &snapshot, int scrollY, int viewportHeight) const
{
ViewportAnchor anchor;
if (snapshot.yPositions.isEmpty() || viewportHeight <= 0) {
return anchor;
}
const int maxScroll = std::max(0, snapshot.totalHeight - viewportHeight);
const int clampedScroll = qBound(0, scrollY, maxScroll);
const int anchorY = clampedScroll + viewportHeight / 2;
const int page = pageAtY(snapshot, anchorY);
if (page < 0 || page >= snapshot.scaledSizes.size()) {
return anchor;
}
const int pageTop = snapshot.yPositions[page];
const int pageHeight = std::max(1, snapshot.scaledSizes[page].height());
const float ratio = static_cast<float>(anchorY - pageTop) / static_cast<float>(pageHeight);
anchor.pageIndex = page;
anchor.offsetRatio = qBound(0.0f, ratio, 1.0f);
anchor.valid = true;
return anchor;
}
int ContinuousPageWidget::resolveAnchorToScrollY(const LayoutSnapshot &snapshot, const ViewportAnchor &anchor, int viewportHeight) const
{
if (!anchor.valid || viewportHeight <= 0 || snapshot.yPositions.isEmpty()) {
return 0;
}
if (anchor.pageIndex < 0 || anchor.pageIndex >= snapshot.yPositions.size() || anchor.pageIndex >= snapshot.scaledSizes.size()) {
return 0;
}
const int pageTop = snapshot.yPositions[anchor.pageIndex];
const int pageHeight = std::max(1, snapshot.scaledSizes[anchor.pageIndex].height());
const int anchorY = pageTop + qRound(anchor.offsetRatio * pageHeight);
const int maxScroll = std::max(0, snapshot.totalHeight - viewportHeight);
const int target = anchorY - viewportHeight / 2;
return qBound(0, target, maxScroll);
}
int ContinuousPageWidget::pageAtY(int y) const
{
return pageAtY(layoutSnapshot, y);
}
QSize ContinuousPageWidget::scaledPageSize(int pageIndex, int forWidth) const
{
QSize natural = (pageIndex < pageSizes.size() && pageSizes[pageIndex].width() > 0 && pageSizes[pageIndex].height() > 0)
? pageSizes[pageIndex]
: defaultPageSize;
float scale = scaleForPage(pageIndex, forWidth);
int scaledW = std::max(1, qRound(natural.width() * scale));
int scaledH = std::max(1, qRound(natural.height() * scale));
return QSize(scaledW, scaledH);
}
float ContinuousPageWidget::scaleForPage(int pageIndex, int forWidth) const
{
QSize natural = (pageIndex < pageSizes.size() && pageSizes[pageIndex].width() > 0 && pageSizes[pageIndex].height() > 0)
? pageSizes[pageIndex]
: defaultPageSize;
if (natural.width() <= 0 || forWidth <= 0) {
return 1.0f;
}
float baseScale = static_cast<float>(forWidth) / natural.width();
float zoomMultiplier = zoomFactor / 100.0f;
return baseScale * zoomMultiplier;
} }

View File

@ -8,6 +8,7 @@
#include "themable.h" #include "themable.h"
class Render; class Render;
class ContinuousViewModel;
class ContinuousPageWidget : public QWidget, protected Themable class ContinuousPageWidget : public QWidget, protected Themable
{ {
@ -16,27 +17,13 @@ public:
explicit ContinuousPageWidget(QWidget *parent = nullptr); explicit ContinuousPageWidget(QWidget *parent = nullptr);
void setRender(Render *r); void setRender(Render *r);
void setNumPages(int count); void setViewModel(ContinuousViewModel *viewModel);
void setZoomFactor(int zoom);
void probeBufferedPages();
void reset(); void reset();
int centerPage(int scrollY, int viewportHeight) const;
int yPositionForPage(int pageIndex) const;
int totalHeight() const;
bool hasHeightForWidth() const override; bool hasHeightForWidth() const override;
int heightForWidth(int w) const override; int heightForWidth(int w) const override;
QSize sizeHint() const override; QSize sizeHint() const override;
void setAnchorPage(int page);
void setViewportState(int scrollY, int viewportHeight);
signals:
// emitted after layout recomputation when the preserved viewport anchor
// resolves to an absolute scroll position
void layoutScrollPositionRequested(int scrollY);
public slots: public slots:
void onPageAvailable(int absolutePageIndex); void onPageAvailable(int absolutePageIndex);
@ -46,41 +33,8 @@ protected:
void applyTheme(const Theme &theme) override; void applyTheme(const Theme &theme) override;
private: private:
struct LayoutSnapshot {
QVector<int> yPositions;
QVector<QSize> scaledSizes;
int totalHeight = 0;
};
struct ViewportAnchor {
int pageIndex = -1;
float offsetRatio = 0.0f;
bool valid = false;
};
void updateLayout();
void updateLayoutWithAnchor();
void relayout(bool preserveAnchor);
LayoutSnapshot buildLayoutSnapshot(int w) const;
int pageAtY(const LayoutSnapshot &snapshot, int y) const;
ViewportAnchor anchorFromViewport(const LayoutSnapshot &snapshot, int scrollY, int viewportHeight) const;
int resolveAnchorToScrollY(const LayoutSnapshot &snapshot, const ViewportAnchor &anchor, int viewportHeight) const;
int pageAtY(int y) const;
QSize scaledPageSize(int pageIndex, int forWidth) const;
float scaleForPage(int pageIndex, int forWidth) const;
Render *render = nullptr; Render *render = nullptr;
int numPages = 0; ContinuousViewModel *continuousViewModel = nullptr;
QVector<QSize> pageSizes;
QVector<int> yPositions;
int currentTotalHeight = 0;
LayoutSnapshot layoutSnapshot;
QSize defaultPageSize { 800, 1200 };
int zoomFactor = 100;
int anchorPage = -1;
int viewportScrollY = 0;
int currentViewportHeight = 0;
bool hasViewportState = false;
}; };
#endif // CONTINUOUS_PAGE_WIDGET_H #endif // CONTINUOUS_PAGE_WIDGET_H

View File

@ -0,0 +1,299 @@
#include "continuous_view_model.h"
#include <QtMath>
#include <QWidget>
#include <algorithm>
#include <limits>
ContinuousViewModel::ContinuousViewModel(QObject *parent)
: QObject(parent)
{
}
void ContinuousViewModel::reset()
{
numPagesValue = 0;
pageSizes.clear();
defaultPageSize = QSize(800, 1200);
scrollYValue = 0;
anchorPage = -1;
layoutSnapshot = LayoutSnapshot();
emit stateChanged();
}
void ContinuousViewModel::setNumPages(int count)
{
numPagesValue = std::max(0, count);
pageSizes.fill(QSize(0, 0), numPagesValue);
defaultPageSize = QSize(800, 1200);
recompute(RecomputePolicy::PreserveScrollClamped);
}
void ContinuousViewModel::setZoomFactor(int zoom)
{
if (zoomFactorValue == zoom) {
return;
}
zoomFactorValue = zoom;
recompute(RecomputePolicy::PreserveViewportAnchor);
}
void ContinuousViewModel::setViewportSize(int width, int height)
{
width = std::max(0, width);
height = std::max(0, height);
if (viewportWidth == width && viewportHeightValue == height) {
return;
}
viewportWidth = width;
viewportHeightValue = height;
recompute(RecomputePolicy::PreserveViewportAnchor);
}
void ContinuousViewModel::setScrollYFromUser(int scrollY)
{
scrollYValue = std::max(0, scrollY);
recompute(RecomputePolicy::PreserveScrollClamped);
}
void ContinuousViewModel::setAnchorPage(int page)
{
if (page < 0 || page >= numPagesValue) {
return;
}
anchorPage = page;
}
void ContinuousViewModel::setCurrentPage(int page)
{
if (page < 0 || page >= numPagesValue) {
return;
}
anchorPage = page;
recompute(RecomputePolicy::ScrollToPageTop, page);
}
void ContinuousViewModel::setPageNaturalSize(int pageIndex, const QSize &size)
{
if (pageIndex < 0 || pageIndex >= numPagesValue || size.isEmpty()) {
return;
}
if (defaultPageSize == QSize(800, 1200)) {
defaultPageSize = size;
}
if (pageSizes[pageIndex] == size) {
return;
}
pageSizes[pageIndex] = size;
recompute(RecomputePolicy::PreserveViewportAnchor);
}
int ContinuousViewModel::numPages() const
{
return numPagesValue;
}
int ContinuousViewModel::totalHeight() const
{
return layoutSnapshot.totalHeight;
}
int ContinuousViewModel::scrollY() const
{
return scrollYValue;
}
int ContinuousViewModel::viewportHeight() const
{
return viewportHeightValue;
}
int ContinuousViewModel::zoomFactor() const
{
return zoomFactorValue;
}
int ContinuousViewModel::centerPage() const
{
const int centerY = scrollYValue + std::max(0, viewportHeightValue / 2);
return pageAtY(centerY);
}
int ContinuousViewModel::yPositionForPage(int pageIndex) const
{
if (pageIndex < 0 || pageIndex >= layoutSnapshot.yPositions.size()) {
return 0;
}
return layoutSnapshot.yPositions[pageIndex];
}
int ContinuousViewModel::pageAtY(int y) const
{
return pageAtY(layoutSnapshot, y);
}
QSize ContinuousViewModel::scaledPageSize(int pageIndex) const
{
if (pageIndex < 0 || pageIndex >= layoutSnapshot.scaledSizes.size()) {
return QSize();
}
return layoutSnapshot.scaledSizes[pageIndex];
}
void ContinuousViewModel::recompute(RecomputePolicy policy, int targetPage)
{
const LayoutSnapshot oldSnapshot = layoutSnapshot;
const int effectiveWidth = viewportWidth > 0 ? viewportWidth : defaultPageSize.width();
ViewportAnchor anchor;
if (policy == RecomputePolicy::PreserveViewportAnchor) {
if (!oldSnapshot.yPositions.isEmpty() && viewportHeightValue > 0) {
anchor = anchorFromViewport(oldSnapshot, scrollYValue, viewportHeightValue);
} else if (anchorPage >= 0) {
anchor.pageIndex = anchorPage;
anchor.offsetRatio = 0.5f;
anchor.valid = true;
}
}
layoutSnapshot = buildLayoutSnapshot(effectiveWidth);
if (policy == RecomputePolicy::ScrollToPageTop) {
scrollYValue = yPositionForPage(targetPage);
} else if (policy == RecomputePolicy::PreserveViewportAnchor && anchor.valid && viewportHeightValue > 0) {
scrollYValue = resolveAnchorToScrollY(layoutSnapshot, anchor, viewportHeightValue);
}
scrollYValue = qBound(0, scrollYValue, maxScrollFor(layoutSnapshot));
emit stateChanged();
}
ContinuousViewModel::LayoutSnapshot ContinuousViewModel::buildLayoutSnapshot(int width) const
{
LayoutSnapshot snapshot;
if (numPagesValue <= 0 || width <= 0) {
return snapshot;
}
snapshot.yPositions.resize(numPagesValue);
snapshot.scaledSizes.resize(numPagesValue);
qint64 y = 0;
for (int i = 0; i < numPagesValue; ++i) {
snapshot.yPositions[i] = static_cast<int>(std::min<qint64>(y, std::numeric_limits<int>::max()));
QSize scaled = scaledPageSizeForWidth(i, width);
scaled.setWidth(std::max(1, scaled.width()));
scaled.setHeight(std::max(1, scaled.height()));
snapshot.scaledSizes[i] = scaled;
y += scaled.height();
}
snapshot.totalHeight = static_cast<int>(std::min<qint64>(y, static_cast<qint64>(QWIDGETSIZE_MAX)));
return snapshot;
}
ContinuousViewModel::ViewportAnchor ContinuousViewModel::anchorFromViewport(const LayoutSnapshot &snapshot, int scrollY, int viewportHeight) const
{
ViewportAnchor anchor;
if (snapshot.yPositions.isEmpty() || viewportHeight <= 0) {
return anchor;
}
const int maxScroll = std::max(0, snapshot.totalHeight - viewportHeight);
const int clampedScroll = qBound(0, scrollY, maxScroll);
const int anchorY = clampedScroll + viewportHeight / 2;
const int page = pageAtY(snapshot, anchorY);
if (page < 0 || page >= snapshot.scaledSizes.size()) {
return anchor;
}
const int pageTop = snapshot.yPositions[page];
const int pageHeight = std::max(1, snapshot.scaledSizes[page].height());
const float ratio = static_cast<float>(anchorY - pageTop) / static_cast<float>(pageHeight);
anchor.pageIndex = page;
anchor.offsetRatio = qBound(0.0f, ratio, 1.0f);
anchor.valid = true;
return anchor;
}
int ContinuousViewModel::resolveAnchorToScrollY(const LayoutSnapshot &snapshot, const ViewportAnchor &anchor, int viewportHeight) const
{
if (!anchor.valid || viewportHeight <= 0 || snapshot.yPositions.isEmpty()) {
return 0;
}
if (anchor.pageIndex < 0 || anchor.pageIndex >= snapshot.yPositions.size() || anchor.pageIndex >= snapshot.scaledSizes.size()) {
return 0;
}
const int pageTop = snapshot.yPositions[anchor.pageIndex];
const int pageHeight = std::max(1, snapshot.scaledSizes[anchor.pageIndex].height());
const int anchorY = pageTop + qRound(anchor.offsetRatio * pageHeight);
const int maxScroll = std::max(0, snapshot.totalHeight - viewportHeight);
const int target = anchorY - viewportHeight / 2;
return qBound(0, target, maxScroll);
}
int ContinuousViewModel::pageAtY(const LayoutSnapshot &snapshot, int y) const
{
if (snapshot.yPositions.isEmpty()) {
return 0;
}
auto it = std::upper_bound(snapshot.yPositions.constBegin(), snapshot.yPositions.constEnd(), y);
if (it == snapshot.yPositions.constBegin()) {
return 0;
}
--it;
return static_cast<int>(it - snapshot.yPositions.constBegin());
}
int ContinuousViewModel::maxScrollFor(const LayoutSnapshot &snapshot) const
{
return std::max(0, snapshot.totalHeight - viewportHeightValue);
}
QSize ContinuousViewModel::scaledPageSizeForWidth(int pageIndex, int width) const
{
QSize natural = (pageIndex < pageSizes.size() && pageSizes[pageIndex].width() > 0 && pageSizes[pageIndex].height() > 0)
? pageSizes[pageIndex]
: defaultPageSize;
const float scale = scaleForPage(pageIndex, width);
const int scaledW = std::max(1, qRound(natural.width() * scale));
const int scaledH = std::max(1, qRound(natural.height() * scale));
return QSize(scaledW, scaledH);
}
float ContinuousViewModel::scaleForPage(int pageIndex, int width) const
{
QSize natural = (pageIndex < pageSizes.size() && pageSizes[pageIndex].width() > 0 && pageSizes[pageIndex].height() > 0)
? pageSizes[pageIndex]
: defaultPageSize;
if (natural.width() <= 0 || width <= 0) {
return 1.0f;
}
const float baseScale = static_cast<float>(width) / natural.width();
const float zoomMultiplier = zoomFactorValue / 100.0f;
return baseScale * zoomMultiplier;
}

View File

@ -0,0 +1,79 @@
#ifndef CONTINUOUS_VIEW_MODEL_H
#define CONTINUOUS_VIEW_MODEL_H
#include <QObject>
#include <QSize>
#include <QVector>
class ContinuousViewModel : public QObject
{
Q_OBJECT
public:
explicit ContinuousViewModel(QObject *parent = nullptr);
void reset();
void setNumPages(int count);
void setZoomFactor(int zoom);
void setViewportSize(int width, int height);
void setScrollYFromUser(int scrollY);
void setAnchorPage(int page);
void setCurrentPage(int page);
void setPageNaturalSize(int pageIndex, const QSize &size);
int numPages() const;
int totalHeight() const;
int scrollY() const;
int viewportHeight() const;
int zoomFactor() const;
int centerPage() const;
int yPositionForPage(int pageIndex) const;
int pageAtY(int y) const;
QSize scaledPageSize(int pageIndex) const;
signals:
void stateChanged();
private:
struct LayoutSnapshot {
QVector<int> yPositions;
QVector<QSize> scaledSizes;
int totalHeight = 0;
};
struct ViewportAnchor {
int pageIndex = -1;
float offsetRatio = 0.0f;
bool valid = false;
};
enum class RecomputePolicy {
PreserveViewportAnchor,
PreserveScrollClamped,
ScrollToPageTop
};
void recompute(RecomputePolicy policy, int targetPage = -1);
LayoutSnapshot buildLayoutSnapshot(int width) const;
ViewportAnchor anchorFromViewport(const LayoutSnapshot &snapshot, int scrollY, int viewportHeight) const;
int resolveAnchorToScrollY(const LayoutSnapshot &snapshot, const ViewportAnchor &anchor, int viewportHeight) const;
int pageAtY(const LayoutSnapshot &snapshot, int y) const;
int maxScrollFor(const LayoutSnapshot &snapshot) const;
QSize scaledPageSizeForWidth(int pageIndex, int width) const;
float scaleForPage(int pageIndex, int width) const;
int numPagesValue = 0;
QVector<QSize> pageSizes;
QSize defaultPageSize { 800, 1200 };
int zoomFactorValue = 100;
int viewportWidth = 0;
int viewportHeightValue = 0;
int scrollYValue = 0;
int anchorPage = -1;
LayoutSnapshot layoutSnapshot;
};
#endif // CONTINUOUS_VIEW_MODEL_H

View File

@ -1,5 +1,6 @@
#include "viewer.h" #include "viewer.h"
#include "continuous_page_widget.h" #include "continuous_page_widget.h"
#include "continuous_view_model.h"
#include "configuration.h" #include "configuration.h"
#include "magnifying_glass.h" #include "magnifying_glass.h"
#include "goto_flow_widget.h" #include "goto_flow_widget.h"
@ -65,6 +66,8 @@ Viewer::Viewer(QWidget *parent)
setAlignment(Qt::AlignCenter); setAlignment(Qt::AlignCenter);
continuousWidget = new ContinuousPageWidget(); continuousWidget = new ContinuousPageWidget();
continuousViewModel = new ContinuousViewModel(this);
continuousWidget->setViewModel(continuousViewModel);
continuousWidget->installEventFilter(this); continuousWidget->installEventFilter(this);
//--------------------------------------- //---------------------------------------
mglass = new MagnifyingGlass( mglass = new MagnifyingGlass(
@ -153,6 +156,7 @@ Viewer::~Viewer()
if (widget() != continuousWidget) { if (widget() != continuousWidget) {
delete continuousWidget; delete continuousWidget;
} }
delete continuousViewModel;
delete hideCursorTimer; delete hideCursorTimer;
delete informationLabel; delete informationLabel;
delete verticalScroller; delete verticalScroller;
@ -204,7 +208,8 @@ void Viewer::createConnections()
connect(render, QOverload<int, const QByteArray &>::of(&Render::imageLoaded), goToFlow, &GoToFlowWidget::setImageReady); connect(render, QOverload<int, const QByteArray &>::of(&Render::imageLoaded), goToFlow, &GoToFlowWidget::setImageReady);
connect(render, &Render::currentPageReady, this, &Viewer::updatePage); connect(render, &Render::currentPageReady, this, &Viewer::updatePage);
connect(render, &Render::pageRendered, continuousWidget, &ContinuousPageWidget::onPageAvailable); connect(render, &Render::pageRendered, continuousWidget, &ContinuousPageWidget::onPageAvailable);
connect(continuousWidget, &ContinuousPageWidget::layoutScrollPositionRequested, this, &Viewer::onContinuousLayoutScrollRequested); connect(render, &Render::pageRendered, this, &Viewer::onContinuousPageRendered);
connect(continuousViewModel, &ContinuousViewModel::stateChanged, this, &Viewer::onContinuousViewModelChanged);
connect(render, qOverload<unsigned int>(&Render::numPages), this, &Viewer::onNumPagesReady); connect(render, qOverload<unsigned int>(&Render::numPages), this, &Viewer::onNumPagesReady);
connect(verticalScrollBar(), &QScrollBar::valueChanged, this, &Viewer::onContinuousScroll); connect(verticalScrollBar(), &QScrollBar::valueChanged, this, &Viewer::onContinuousScroll);
connect(render, &Render::processingPage, this, &Viewer::setLoadingMessage); connect(render, &Render::processingPage, this, &Viewer::setLoadingMessage);
@ -342,7 +347,7 @@ void Viewer::goTo(unsigned int page)
if (continuousScroll) { if (continuousScroll) {
lastCenterPage = page; lastCenterPage = page;
continuousWidget->setAnchorPage(page); continuousViewModel->setAnchorPage(static_cast<int>(page));
render->goTo(page); render->goTo(page);
scrollToCurrentContinuousPage(); scrollToCurrentContinuousPage();
return; return;
@ -448,7 +453,7 @@ void Viewer::increaseZoomFactor()
zoom = std::min(zoom + 10, 500); zoom = std::min(zoom + 10, 500);
if (continuousScroll) { if (continuousScroll) {
continuousWidget->setZoomFactor(zoom); continuousViewModel->setZoomFactor(zoom);
} else { } else {
updateContentSize(); updateContentSize();
} }
@ -462,7 +467,7 @@ void Viewer::decreaseZoomFactor()
zoom = std::max(zoom - 10, 30); zoom = std::max(zoom - 10, 30);
if (continuousScroll) { if (continuousScroll) {
continuousWidget->setZoomFactor(zoom); continuousViewModel->setZoomFactor(zoom);
} else { } else {
updateContentSize(); updateContentSize();
} }
@ -831,7 +836,7 @@ void Viewer::resizeEvent(QResizeEvent *event)
QScrollArea::resizeEvent(event); QScrollArea::resizeEvent(event);
if (continuousScroll) { if (continuousScroll) {
continuousWidget->setViewportState(verticalScrollBar()->value(), viewport()->height()); continuousViewModel->setViewportSize(viewport()->width(), viewport()->height());
} }
updateContentSize(); updateContentSize();
@ -1019,15 +1024,15 @@ void Viewer::setContinuousScroll(bool enabled)
Configuration::getConfiguration().setContinuousScroll(continuousScroll); Configuration::getConfiguration().setContinuousScroll(continuousScroll);
if (continuousScroll) { if (continuousScroll) {
continuousWidget->setZoomFactor(zoom); continuousViewModel->setZoomFactor(zoom);
if (render->hasLoadedComic()) { if (render->hasLoadedComic()) {
continuousWidget->setViewportState(verticalScrollBar()->value(), viewport()->height()); continuousViewModel->setViewportSize(viewport()->width(), viewport()->height());
continuousWidget->setNumPages(render->numPages()); continuousViewModel->setNumPages(render->numPages());
// set the current page as model state before any layout/scroll happens // set the current page as model state before any layout/scroll happens
lastCenterPage = render->getIndex(); lastCenterPage = render->getIndex();
continuousWidget->setAnchorPage(lastCenterPage); continuousViewModel->setAnchorPage(lastCenterPage);
// pick up sizes of pages already in the buffer // pick up sizes of pages already in the buffer
continuousWidget->probeBufferedPages(); probeContinuousBufferedPages();
// trigger a render cycle so new pages arrive via pageRendered signal // trigger a render cycle so new pages arrive via pageRendered signal
render->update(); render->update();
setActiveWidget(continuousWidget); setActiveWidget(continuousWidget);
@ -1047,17 +1052,17 @@ void Viewer::setContinuousScroll(bool enabled)
void Viewer::onContinuousScroll(int value) void Viewer::onContinuousScroll(int value)
{ {
if (!continuousScroll || !render->hasLoadedComic()) { if (!continuousScroll || !render->hasLoadedComic() || applyingContinuousModelState) {
return; return;
} }
continuousWidget->setViewportState(value, viewport()->height()); continuousViewModel->setScrollYFromUser(value);
int center = continuousWidget->centerPage(value, viewport()->height()); int center = continuousViewModel->centerPage();
if (center != lastCenterPage && center >= 0) { if (center != lastCenterPage && center >= 0) {
lastCenterPage = center; lastCenterPage = center;
continuousWidget->setAnchorPage(center); continuousViewModel->setAnchorPage(center);
syncingRenderFromContinuousScroll = true; syncingRenderFromContinuousScroll = true;
render->goTo(center); render->goTo(center);
syncingRenderFromContinuousScroll = false; syncingRenderFromContinuousScroll = false;
@ -1065,20 +1070,65 @@ void Viewer::onContinuousScroll(int value)
} }
} }
void Viewer::onContinuousLayoutScrollRequested(int scrollY) void Viewer::onContinuousViewModelChanged()
{ {
if (!continuousScroll) { if (!continuousScroll) {
return; return;
} }
auto *sb = verticalScrollBar(); applyContinuousStateToUi();
const int target = qBound(sb->minimum(), scrollY, sb->maximum()); }
void Viewer::onContinuousPageRendered(int absolutePageIndex)
{
if (!continuousScroll || !render->hasLoadedComic()) {
return;
}
const QImage *img = render->bufferedImage(absolutePageIndex);
if (!img || img->isNull()) {
return;
}
continuousViewModel->setPageNaturalSize(absolutePageIndex, img->size());
}
void Viewer::probeContinuousBufferedPages()
{
if (!render->hasLoadedComic()) {
return;
}
const int totalPages = static_cast<int>(render->numPages());
for (int i = 0; i < totalPages; ++i) {
const QImage *img = render->bufferedImage(i);
if (img && !img->isNull()) {
continuousViewModel->setPageNaturalSize(i, img->size());
}
}
}
void Viewer::applyContinuousStateToUi()
{
if (!continuousScroll) {
return;
}
applyingContinuousModelState = true;
continuousWidget->setFixedHeight(continuousViewModel->totalHeight());
continuousWidget->updateGeometry();
auto *sb = verticalScrollBar();
const int target = qBound(sb->minimum(), continuousViewModel->scrollY(), sb->maximum());
sb->blockSignals(true); sb->blockSignals(true);
sb->setValue(target); sb->setValue(target);
sb->blockSignals(false); sb->blockSignals(false);
continuousWidget->setViewportState(target, viewport()->height()); applyingContinuousModelState = false;
continuousWidget->update();
viewport()->update();
} }
void Viewer::scrollToCurrentContinuousPage() void Viewer::scrollToCurrentContinuousPage()
@ -1087,22 +1137,7 @@ void Viewer::scrollToCurrentContinuousPage()
return; return;
} }
auto applyPosition = [this]() { continuousViewModel->setCurrentPage(lastCenterPage);
auto *sb = verticalScrollBar();
int targetY = continuousWidget->yPositionForPage(lastCenterPage);
targetY = qBound(sb->minimum(), targetY, sb->maximum());
sb->blockSignals(true);
sb->setValue(targetY);
sb->blockSignals(false);
continuousWidget->setViewportState(targetY, viewport()->height());
continuousWidget->update();
viewport()->update();
};
applyPosition();
} }
void Viewer::onNumPagesReady(unsigned int numPages) void Viewer::onNumPagesReady(unsigned int numPages)
@ -1110,8 +1145,9 @@ void Viewer::onNumPagesReady(unsigned int numPages)
if (continuousScroll && numPages > 0) { if (continuousScroll && numPages > 0) {
setActiveWidget(continuousWidget); setActiveWidget(continuousWidget);
continuousWidget->setViewportState(verticalScrollBar()->value(), viewport()->height()); continuousViewModel->setViewportSize(viewport()->width(), viewport()->height());
continuousWidget->setNumPages(numPages); continuousViewModel->setNumPages(numPages);
probeContinuousBufferedPages();
int page = lastCenterPage; int page = lastCenterPage;
if (page < 0) { if (page < 0) {
@ -1119,7 +1155,7 @@ void Viewer::onNumPagesReady(unsigned int numPages)
} }
page = qBound(0, page, static_cast<int>(numPages) - 1); page = qBound(0, page, static_cast<int>(numPages) - 1);
lastCenterPage = page; lastCenterPage = page;
continuousWidget->setAnchorPage(page); continuousViewModel->setAnchorPage(page);
scrollToCurrentContinuousPage(); scrollToCurrentContinuousPage();
} }
@ -1132,7 +1168,7 @@ void Viewer::onRenderPageChanged(int page)
} }
lastCenterPage = page; lastCenterPage = page;
continuousWidget->setAnchorPage(page); continuousViewModel->setAnchorPage(page);
scrollToCurrentContinuousPage(); scrollToCurrentContinuousPage();
} }
@ -1157,6 +1193,7 @@ void Viewer::resetContent()
{ {
configureContent(tr("Press 'O' to open comic.")); configureContent(tr("Press 'O' to open comic."));
goToFlow->reset(); goToFlow->reset();
continuousViewModel->reset();
continuousWidget->reset(); continuousWidget->reset();
lastCenterPage = -1; lastCenterPage = -1;
emit reset(); emit reset();
@ -1316,7 +1353,7 @@ void Viewer::updateZoomRatio(int ratio)
{ {
zoom = ratio; zoom = ratio;
if (continuousScroll) { if (continuousScroll) {
continuousWidget->setZoomFactor(zoom); continuousViewModel->setZoomFactor(zoom);
} else { } else {
updateContentSize(); updateContentSize();
} }

View File

@ -31,6 +31,7 @@ class YACReaderTranslator;
class GoToFlowWidget; class GoToFlowWidget;
class Bookmarks; class Bookmarks;
class ContinuousPageWidget; class ContinuousPageWidget;
class ContinuousViewModel;
class PageLabelWidget; class PageLabelWidget;
class NotificationsLabelWidget; class NotificationsLabelWidget;
@ -149,8 +150,10 @@ private:
QLabel *content; QLabel *content;
QLabel *messageLabel; QLabel *messageLabel;
ContinuousPageWidget *continuousWidget; ContinuousPageWidget *continuousWidget;
ContinuousViewModel *continuousViewModel;
int lastCenterPage = -1; int lastCenterPage = -1;
bool syncingRenderFromContinuousScroll = false; bool syncingRenderFromContinuousScroll = false;
bool applyingContinuousModelState = false;
YACReaderTranslator *translator; YACReaderTranslator *translator;
int translatorXPos; int translatorXPos;
@ -191,7 +194,10 @@ private:
int animationDuration() const; int animationDuration() const;
void animateScroll(QPropertyAnimation &scroller, const QScrollBar &scrollBar, int delta); void animateScroll(QPropertyAnimation &scroller, const QScrollBar &scrollBar, int delta);
void onContinuousScroll(int value); void onContinuousScroll(int value);
void onContinuousLayoutScrollRequested(int scrollY); void onContinuousViewModelChanged();
void onContinuousPageRendered(int absolutePageIndex);
void probeContinuousBufferedPages();
void applyContinuousStateToUi();
void scrollToCurrentContinuousPage(); void scrollToCurrentContinuousPage();
void onNumPagesReady(unsigned int numPages); void onNumPagesReady(unsigned int numPages);
void onRenderPageChanged(int page); void onRenderPageChanged(int page);