From 158c934bc312775e5bfcc69b0c926bbefcf140a4 Mon Sep 17 00:00:00 2001 From: "J.D. Purcell" Date: Thu, 26 Dec 2024 15:49:51 -0500 Subject: [PATCH] Fix tiny borders when display scaling is in use Qt makes this difficult by not providing access to the native geometry. --- src/mainwindow.cpp | 22 +++++++++--- src/qvgraphicsview.cpp | 76 ++++++++++++++++++++++++++++++++---------- src/qvgraphicsview.h | 4 +++ 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 4d63bc6d..54a88b82 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -743,14 +743,28 @@ void MainWindow::setWindowSize(const bool isFromTransform) const QSizeF imageSize = graphicsView->getEffectiveOriginalSize() * (isZoomFixed ? graphicsView->getZoomLevel() : 1.0); const int fitOverscan = graphicsView->getFitOverscan(); const QSize fitOverscanSize = QSize(fitOverscan * 2, fitOverscan * 2); + const qreal logicalPixelScale = graphicsView->devicePixelRatioF(); - QSize targetSize = imageSize.toSize() - fitOverscanSize; + const auto gvRoundSizeF = [logicalPixelScale](const QSizeF value) { + return QSize( + QVGraphicsView::roundToCompleteLogicalPixel(value.width(), logicalPixelScale), + QVGraphicsView::roundToCompleteLogicalPixel(value.height(), logicalPixelScale) + ); + }; + const auto gvReverseRoundSize = [logicalPixelScale](const QSize value) { + return QSizeF( + QVGraphicsView::reverseLogicalPixelRounding(value.width(), logicalPixelScale), + QVGraphicsView::reverseLogicalPixelRounding(value.height(), logicalPixelScale) + ); + }; + + QSize targetSize = gvRoundSizeF(imageSize) - fitOverscanSize; if (targetSize.width() > maxWindowSize.width() || targetSize.height() > maxWindowSize.height()) { - const QSizeF viewSize = maxWindowSize + fitOverscanSize; - const qreal fitRatio = qMin(viewSize.width() / imageSize.width(), viewSize.height() / imageSize.height()); - targetSize = (imageSize * fitRatio).toSize() - fitOverscanSize; + const QSizeF enforcedSize = gvReverseRoundSize(maxWindowSize) + fitOverscanSize; + const qreal fitRatio = qMin(enforcedSize.width() / imageSize.width(), enforcedSize.height() / imageSize.height()); + targetSize = gvRoundSizeF(imageSize * fitRatio) - fitOverscanSize; } targetSize = targetSize.expandedTo(minWindowSize).boundedTo(maxWindowSize); diff --git a/src/qvgraphicsview.cpp b/src/qvgraphicsview.cpp index 8daec7ba..41e20dac 100644 --- a/src/qvgraphicsview.cpp +++ b/src/qvgraphicsview.cpp @@ -706,44 +706,58 @@ void QVGraphicsView::recalculateZoom() if (!getCurrentFileDetails().isPixmapLoaded || !calculatedZoomMode.has_value()) return; - QSizeF effectiveImageSize = getEffectiveOriginalSize(); - QSize viewSize = getUsableViewportRect(true).size(); + const QSizeF imageSize = getEffectiveOriginalSize(); + const QSize viewSize = getUsableViewportRect(true).size(); if (viewSize.isEmpty()) return; - qreal fitXRatio = viewSize.width() / effectiveImageSize.width(); - qreal fitYRatio = viewSize.height() / effectiveImageSize.height(); + const qreal logicalPixelScale = devicePixelRatioF(); + const auto gvRound = [logicalPixelScale](const qreal value) { + return roundToCompleteLogicalPixel(value, logicalPixelScale); + }; + const auto gvReverseRound = [logicalPixelScale](const int value) { + return reverseLogicalPixelRounding(value, logicalPixelScale); + }; + + const qreal fitXRatio = gvReverseRound(viewSize.width()) / imageSize.width(); + const qreal fitYRatio = gvReverseRound(viewSize.height()) / imageSize.height(); qreal targetRatio; // Each mode will check if the rounded image size already produces the desired fit, // in which case we can use exactly 1.0 to avoid unnecessary scaling + const int imageOverflowX = gvRound(imageSize.width()) - viewSize.width(); + const int imageOverflowY = gvRound(imageSize.height()) - viewSize.height(); switch (calculatedZoomMode.value()) { case Qv::CalculatedZoomMode::ZoomToFit: - if ((qRound(effectiveImageSize.height()) == viewSize.height() && qRound(effectiveImageSize.width()) <= viewSize.width()) || - (qRound(effectiveImageSize.width()) == viewSize.width() && qRound(effectiveImageSize.height()) <= viewSize.height())) + // In rare cases, if the window sizing code just barely increased the size to enforce + // the minimum and intends for a tiny upscale to occur (e.g. to 100.3%), that could get + // misdetected as the special case for 1.0 here and leave an unintentional 1 pixel + // border. So if we match on only one dimension, make sure the other dimension will have + // at least a few pixels of border showing. + if ((imageOverflowX == 0 && (imageOverflowY == 0 || imageOverflowY <= -2)) || + (imageOverflowY == 0 && (imageOverflowX == 0 || imageOverflowX <= -2))) { targetRatio = 1.0; } else { - QSize xRatioSize = (effectiveImageSize * fitXRatio * devicePixelRatioF()).toSize(); - QSize yRatioSize = (effectiveImageSize * fitYRatio * devicePixelRatioF()).toSize(); - QSize maxSize = (QSizeF(viewSize) * devicePixelRatioF()).toSize(); // If the fit ratios are extremely close, it's possible that both are sufficient to // contain the image, but one results in the opposing dimension getting rounded down // to just under the view size, so use the larger of the two ratios in that case. - if (xRatioSize.boundedTo(maxSize) == xRatioSize && yRatioSize.boundedTo(maxSize) == yRatioSize) + const bool isOverallFitToXRatio = gvRound(imageSize.height() * fitXRatio) == viewSize.height(); + const bool isOverallFitToYRatio = gvRound(imageSize.width() * fitYRatio) == viewSize.width(); + if (isOverallFitToXRatio || isOverallFitToYRatio) targetRatio = qMax(fitXRatio, fitYRatio); else targetRatio = qMin(fitXRatio, fitYRatio); } break; case Qv::CalculatedZoomMode::FillWindow: - if ((qRound(effectiveImageSize.height()) == viewSize.height() && qRound(effectiveImageSize.width()) >= viewSize.width()) || - (qRound(effectiveImageSize.width()) == viewSize.width() && qRound(effectiveImageSize.height()) >= viewSize.height())) + if ((imageOverflowX == 0 && imageOverflowY >= 0) || + (imageOverflowY == 0 && imageOverflowX >= 0)) { targetRatio = 1.0; } @@ -918,7 +932,12 @@ QRect QVGraphicsView::getContentRect() const const QRectF loadedPixmapBoundingRect = QRectF(QPoint(), getCurrentFileDetails().loadedPixmapSize); const qreal effectiveTransformScale = zoomLevel / appliedDpiAdjustment; const QTransform effectiveTransform = getTransformWithNoScaling().scale(effectiveTransformScale, effectiveTransformScale); - return effectiveTransform.mapRect(loadedPixmapBoundingRect).toRect(); + const QRectF contentRect = effectiveTransform.mapRect(loadedPixmapBoundingRect); + const qreal logicalPixelScale = devicePixelRatioF(); + const auto gvRound = [logicalPixelScale](const qreal value) { + return roundToCompleteLogicalPixel(qAbs(value), logicalPixelScale) * (value >= 0 ? 1 : -1); + }; + return QRect(gvRound(contentRect.left()), gvRound(contentRect.top()), gvRound(contentRect.width()), gvRound(contentRect.height())); } QRect QVGraphicsView::getUsableViewportRect(const bool addOverscan) const @@ -937,11 +956,14 @@ QRect QVGraphicsView::getUsableViewportRect(const bool addOverscan) const void QVGraphicsView::setTransformScale(qreal value) { -#ifdef Q_OS_WIN - // On Windows, the positioning of scaled pixels seems to follow a floor rule rather - // than rounding, so increase the scale just a hair to cover rounding errors in case - // the desired scale was targeting an integer pixel boundary. - value *= 1.0 + std::numeric_limits::epsilon(); +#ifndef Q_OS_MACOS + // If fractional display scaling is in use, when attempting to target a given size, the resulting error + // can be [0,1) unlike the typical [0,0.5] without scaling or integer scaling. This is because the + // image origin which is always at an integer logical pixel becomes potentially a fractional physical + // pixel due to the display scaling, adding to the overall error. As a result, tiny rounding errors can + // cause us to miss the size we were targetting, so increase the scale just a hair to compensate. + if (value != std::floor(value)) + value *= 1.0 + std::numeric_limits::epsilon(); #endif setTransform(getTransformWithNoScaling().scale(value, value)); } @@ -1128,3 +1150,21 @@ void QVGraphicsView::resetTransformation() setTransform(QTransform::fromScale(scale, scale)); matchContentCenter(oldRect); } + +int QVGraphicsView::roundToCompleteLogicalPixel(const qreal value, const qreal logicalScale) +{ + const int valueRoundedDown = qFloor(value); + const int valueRoundedUp = valueRoundedDown + 1; + const int physicalPixelsDrawn = qRound(value * logicalScale); + const int physicalPixelsShownIfRoundingUp = qRound(valueRoundedUp * logicalScale); + return physicalPixelsDrawn >= physicalPixelsShownIfRoundingUp ? valueRoundedUp : valueRoundedDown; +} + +qreal QVGraphicsView::reverseLogicalPixelRounding(const int value, const qreal logicalScale) +{ + // For a given input value, its physical pixels fall within [value-0.5,value+0.5), so + // calculate the first physical pixel of the next value (rounding up if between pixels), + // and the pixel prior to that is the last one within the current value. + int maxPhysicalPixelForValue = qCeil((value + 0.5) * logicalScale) - 1; + return maxPhysicalPixelForValue / logicalScale; +} diff --git a/src/qvgraphicsview.h b/src/qvgraphicsview.h index a685c1dd..321a100e 100644 --- a/src/qvgraphicsview.h +++ b/src/qvgraphicsview.h @@ -87,6 +87,10 @@ class QVGraphicsView : public QGraphicsView int getFitOverscan() const { return fitOverscan; } + static int roundToCompleteLogicalPixel(const qreal value, const qreal logicalScale); + + static qreal reverseLogicalPixelRounding(const int value, const qreal logicalScale); + signals: void cancelSlideshow();