diff --git a/src/qt/guiutil.h b/src/qt/guiutil.h index b8cc74d8954d..4cd9748a4545 100644 --- a/src/qt/guiutil.h +++ b/src/qt/guiutil.h @@ -243,6 +243,19 @@ namespace GUIUtil * point size on first call so re-application on font/theme changes preserves it. */ void setStyledHtml(QTextEdit* widget, const QString& html); + //! Implementation details exposed only so qt/test can cover them directly. + namespace internal { + /** Return `font`'s size in points, converting from pixels when it was specified that way + * (e.g. a stylesheet's `font-size: Npx`). QFont stores exactly one of the two sizes and + * reports -1 for the other, so a pixel-sized font yields -1 from both pointSize() and + * pointSizeF() and must be converted rather than read directly. + * + * `dpi_y` is the target device's vertical logical DPI (QPaintDevice::logicalDpiY()). + * Returns std::nullopt when no usable size can be derived, which callers must handle: + * a font can carry no valid size at all, and `dpi_y` is not guaranteed to be positive. */ + std::optional effectivePointSize(const QFont& font, int dpi_y); + } // namespace internal + /** * Determine default data directory for operating system. */ diff --git a/src/qt/guiutil_font.cpp b/src/qt/guiutil_font.cpp index 966c0ecd1f36..e249a073cc12 100644 --- a/src/qt/guiutil_font.cpp +++ b/src/qt/guiutil_font.cpp @@ -15,7 +15,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -26,6 +28,7 @@ #include #include #include +#include #include namespace { @@ -148,11 +151,13 @@ std::unique_ptr g_font_db{nullptr}; //! loadFonts stores the SystemDefault font in g_default_font to be able to reference it later again std::unique_ptr g_default_font{nullptr}; -//! Font scaling information for Qt classes -std::map mapClassFontUpdates{ - {"QMenu", -1}, - {"QMessageBox", -1}, - {"QTipLabel", -1}, +//! Font scaling information for Qt classes. The base size is captured on the first pass; +//! std::nullopt means "not captured yet", which a plain -1 could not express because that +//! is also what a pixel-sized font reports as its point size. +std::map> mapClassFontUpdates{ + {"QMenu", std::nullopt}, + {"QMessageBox", std::nullopt}, + {"QTipLabel", std::nullopt}, }; //! Contains all widgets and its font attributes (weight, italic, size) with font changes due to GUIUtil::setFont @@ -536,6 +541,25 @@ bool FontRegistry::IsValidWeight(const QFont::Weight& weight) const namespace GUIUtil { +namespace internal { +std::optional effectivePointSize(const QFont& font, int dpi_y) +{ + // Both accessors return a non-positive sentinel when the size was given in the other + // unit, and a font with no usable size at all reports non-positive from both. Compare + // against 0 rather than -1: the exact sentinel is not guaranteed (a box-engine fallback + // yields values such as -0.72). + if (const double point_size{font.pointSizeF()}; point_size > 0) { + return point_size; + } + if (const int pixel_size{font.pixelSize()}; pixel_size > 0 && dpi_y > 0) { + // Mirrors Qt's own pixel-to-point conversion in QFontDatabase::load(), including + // its guard against a non-positive DPI. + return pixel_size * 72.0 / dpi_y; + } + return std::nullopt; +} +} // namespace internal + int defaultFontScale() { return DEFAULT_FONT_SCALE; } int defaultFontSize() { return DEFAULT_FONT_SIZE; } QString defaultFontFamily() { return DEFAULT_FONT.toString(); } @@ -728,17 +752,28 @@ void updateFonts() // Do not apply styling logic if ignored or handled separately continue; } + QFont font = w->font(); + // A stylesheet rule such as `font-size: Npx` leaves the widget with a pixel-sized + // font, which reports no point size. Convert instead of assuming, and skip the + // widget outright if no size can be derived -- leaving one widget unscaled beats + // aborting, and substituting a default would resize a widget the stylesheet + // deliberately sized. + const std::optional base_size{internal::effectivePointSize(font, w->logicalDpiY())}; + if (!base_size) { + continue; + } ++nUpdatable; - QFont font = w->font(); - assert(font.pointSize() > 0); font.setFamily(qApp->font().family()); font.setWeight(g_font_registry.GetWeightNormal()); font.setStyleName(qApp->font().styleName()); font.setStyle(qApp->font().style()); - // Insert/Get the default font size of the widget - auto itDefault = mapWidgetDefaultFontSizes.emplace(w, font.pointSize()); + // Insert/Get the default font size of the widget. Seeded once per widget, so a + // later stylesheet re-apply cannot compound the scaling. Note this freezes a + // pixel-derived size at the DPI first seen; moving the window to a screen with a + // different DPI will not re-honour the stylesheet's pixel intent. + auto itDefault = mapWidgetDefaultFontSizes.emplace(w, *base_size); auto it = mapFontUpdates.find(w); if (it != mapFontUpdates.end()) { @@ -769,13 +804,20 @@ void updateFonts() it.first->setFont(it.second); } - // Scale the global font size for the classes in the map below + // Scale the global font size for the classes in the map below. These fonts belong to no + // widget, so the primary screen supplies the DPI for any pixel-to-point conversion. + const QScreen* primary_screen{QGuiApplication::primaryScreen()}; + const int screen_dpi_y{primary_screen ? qRound(primary_screen->logicalDotsPerInchY()) : 0}; for (auto& it : mapClassFontUpdates) { QFont fontClass = qApp->font(it.first.c_str()); - if (it.second == -1) { - it.second = fontClass.pointSize(); + if (!it.second) { + // Leave the entry uncaptured and retry on the next pass if the size is unusable. + it.second = internal::effectivePointSize(fontClass, screen_dpi_y); + if (!it.second) { + continue; + } } - double dSize = g_font_registry.GetScaledFontSize(it.second); + double dSize = g_font_registry.GetScaledFontSize(*it.second); if (fontClass.pointSizeF() != dSize) { fontClass.setPointSizeF(dSize); qApp->setFont(fontClass, it.first.c_str()); @@ -825,11 +867,9 @@ void setStyledHtml(QTextEdit* widget, const QString& html) base_size = it->second.base_size; it->second.html = html; } else { - // First registration, capture the widget's native font size - double widget_size{widget->font().pointSizeF()}; - if (widget_size > 0) { - base_size = widget_size; - } + // First registration, capture the widget's native font size, converting it when the + // widget was sized in pixels. Falls back to DEFAULT_FONT_SIZE if no size is usable. + base_size = internal::effectivePointSize(widget->font(), widget->logicalDpiY()).value_or(base_size); mapTextEditStyleUpdates[widget] = {html, base_size}; } setFontBodyHTML(widget, html, base_size); diff --git a/src/qt/test/optiontests.cpp b/src/qt/test/optiontests.cpp index 17ffeb220b69..52a70d009022 100644 --- a/src/qt/test/optiontests.cpp +++ b/src/qt/test/optiontests.cpp @@ -9,8 +9,12 @@ #include #include +#include +#include +#include #include #include +#include #include @@ -132,3 +136,67 @@ void OptionTests::extractFilter() filter = QString("Image (*.png *.jpg)"); QCOMPARE(GUIUtil::ExtractFirstSuffixFromFilter(filter), "png"); } + +void OptionTests::effectivePointSize() +{ + using GUIUtil::internal::effectivePointSize; + + // A point-sized font reports its size directly, fractions included, whatever the DPI. + QFont point_font; + point_font.setPointSizeF(12.5); + QCOMPARE(effectivePointSize(point_font, 96).value_or(0), 12.5); + QCOMPARE(effectivePointSize(point_font, 72).value_or(0), 12.5); + + // A pixel-sized font carries no point size and must be converted using the target DPI. + QFont pixel_font; + pixel_font.setPixelSize(17); + QVERIFY(pixel_font.pointSizeF() <= 0); + QCOMPARE(effectivePointSize(pixel_font, 96).value_or(0), 17 * 72.0 / 96); + QCOMPARE(effectivePointSize(pixel_font, 144).value_or(0), 8.5); + // At 72 DPI points and pixels coincide; pinned so the identity is deliberate rather + // than an accident of whichever DPI the host happens to report. + QCOMPARE(effectivePointSize(pixel_font, 72).value_or(0), 17.0); + + // A non-positive DPI cannot yield a conversion factor, so the pixel size is unusable + // even though it is valid. QWidget::logicalDpiY() is not guaranteed to be positive. + QVERIFY(!effectivePointSize(pixel_font, 0).has_value()); + QVERIFY(!effectivePointSize(pixel_font, -1).has_value()); + + // The remaining branch -- neither size usable -- is guarded but not asserted here: Qt + // rejects non-positive sizes in the setters, and once a QGuiApplication exists (as it + // does in this binary) every QFont is handed a valid default point size. The state is + // still reachable in production, e.g. a font engine that populates no size at all, so + // the helper compares against 0 rather than trusting any particular sentinel. +} + +void OptionTests::updateFontsWithPixelSizedWidget() +{ + if (QApplication::platformName() == "minimal") { + QSKIP("AppTests cannot initialize fonts with the 'minimal' platform plugin."); + } + + // updateFonts() is a no-op until loadFonts() has run, and loadFonts() is process-global, + // non-idempotent state owned by AppTests. Treat missing initialization as a failure on + // supported platforms so the regression test cannot pass without exercising updateFonts(). + QVERIFY2(GUIUtil::fontsLoaded(), + "GUIUtil::loadFonts() must succeed in AppTests::appTests() before OptionTests run."); + + QWidget host; + QLabel* label{new QLabel(&host)}; + QFont pixel_font{label->font()}; + pixel_font.setPixelSize(17); + label->setFont(pixel_font); + QVERIFY(label->font().pointSizeF() <= 0); + + // The pre-fix code asserted pointSize() > 0 here and aborted the process. The widget must + // now be swept normally and end up with a usable point size. The exact value depends on + // the host DPI, so the arithmetic is pinned in effectivePointSize() above instead. + GUIUtil::updateFonts(); + const double scaled_size{label->font().pointSizeF()}; + QVERIFY(scaled_size > 0); + + // The size is cached per widget on the first sweep, so repeated passes must not compound + // it -- the defect that makes the cache load-bearing rather than an optimisation. + GUIUtil::updateFonts(); + QCOMPARE(label->font().pointSizeF(), scaled_size); +} diff --git a/src/qt/test/optiontests.h b/src/qt/test/optiontests.h index 57ec8bd0f2ab..aaf3311744fe 100644 --- a/src/qt/test/optiontests.h +++ b/src/qt/test/optiontests.h @@ -23,6 +23,8 @@ private Q_SLOTS: void integerGetArgBug(); void parametersInteraction(); void extractFilter(); + void effectivePointSize(); + void updateFontsWithPixelSizedWidget(); private: interfaces::Node& m_node;