Skip to content

Commit

Permalink
Fix tiny borders when display scaling is in use
Browse files Browse the repository at this point in the history
Qt makes this difficult by not providing access to the native geometry.
  • Loading branch information
jdpurcell committed Dec 26, 2024
1 parent 81b4ea4 commit 158c934
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 22 deletions.
22 changes: 18 additions & 4 deletions src/mainwindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
76 changes: 58 additions & 18 deletions src/qvgraphicsview.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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<double>::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<double>::epsilon();
#endif
setTransform(getTransformWithNoScaling().scale(value, value));
}
Expand Down Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions src/qvgraphicsview.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down

0 comments on commit 158c934

Please sign in to comment.