From b6dd65a49c3dd1e7542dadabb5640568e01b01ca Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 29 Jul 2026 13:06:35 -0500 Subject: [PATCH 1/5] qt: add effectivePointSize() helper for pixel-sized fonts QFont stores either a point size or a pixel size, never both, and reports -1 for whichever was not set. A font sized in pixels -- which is what a stylesheet rule such as `font-size: 17px` produces, since Qt's CSS parser calls setPixelSize() with no unit conversion -- therefore yields -1 from both pointSize() and pointSizeF(). Code that reads a point size off an arbitrary widget has to convert rather than assume, and several call sites in this file currently do not. Qt offers no usable public API for this: - QFont(font, paintDevice) only stamps the target device's DPI onto the font; it leaves the requested size untouched, so a pixel font stays a pixel font. - QFontInfo does resolve the size, and is Qt's documented answer to this problem (QTBUG-3555), but it forces a font-engine load per query -- unacceptable in a loop over every widget in the application -- and degrades to the same useless sentinel under the headless "minimal" platform plugin, where the box-engine fallback reports a point size of -0.72. That -0.72 also shows why the guards here compare against 0 instead of -1: the sentinel's exact value is not contractual. The conversion mirrors Qt's own, in QFontDatabase::load(), including its guard against a non-positive DPI. Taking the DPI as an explicit int rather than reading it from a QWidget keeps the helper usable for the application-wide class fonts, which have no associated widget, and testable without constructing one. No behavior change; the helper is unused until the following commits. Co-Authored-By: Claude --- src/qt/guiutil.h | 13 +++++++++++++ src/qt/guiutil_font.cpp | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) 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..851dd29b06ee 100644 --- a/src/qt/guiutil_font.cpp +++ b/src/qt/guiutil_font.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include namespace { @@ -536,6 +537,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(); } From 322f25725def82c86d1dbf626626445cacf5a441 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 29 Jul 2026 13:07:05 -0500 Subject: [PATCH 2/5] qt: fix abort in updateFonts() with pixel-sized fonts updateFonts() sweeps every widget in the application and asserted that each one reported a positive point size. Widgets sized in pixels report -1, so the assert fired and aborted the process. assert() is live in release builds here -- src/util/check.h refuses to compile with NDEBUG -- so this was a crash in shipped binaries, not a debug-only check. The trigger is in the shipped stylesheet: general.css sizes MnemonicVerificationDialog's warningLabel and instructionLabel with `font-size: 17px` / `14px`. QDialog is in vecIgnoreClasses but QLabel is not, so those labels are swept. Qt writes the stylesheet's font into the widget's own font, which is what the sweep reads. (QGroupBox's `font-size: 16px` rule looks like a second trigger but is not: QGroupBox is ignored, and a pixel size on it does not propagate to its children.) The assert was never a designed invariant. 634ccc8c9db added it while a working defaultFontSize fallback still handled this case, and c6bf0d35d21 removed that fallback the same day, leaving the assert as the sole handler -- a regression from dashpay/dash#3772. Skip the widget when no size can be derived rather than substituting a default, which would resize a widget the stylesheet deliberately sized. The counter is moved below the check so it keeps reflecting the widgets actually considered updatable. Seeding the per-widget cache from pointSizeF() rather than pointSize() also completes cdd7b37e3fb ("refactor: consolidate font attributes to struct"), which widened this map and its consumer from int to double but left the producer truncating, silently discarding the fractional part on every pass. Fixes #7281 Fixes #7464 Co-Authored-By: Claude --- src/qt/guiutil_font.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/qt/guiutil_font.cpp b/src/qt/guiutil_font.cpp index 851dd29b06ee..8fbe2971550a 100644 --- a/src/qt/guiutil_font.cpp +++ b/src/qt/guiutil_font.cpp @@ -748,17 +748,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()) { From 443e1d336009641527584b07b8502ef6a4f48e81 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 29 Jul 2026 13:07:23 -0500 Subject: [PATCH 3/5] qt: convert pixel font sizes in class and QTextEdit paths Two more paths read a point size straight off a font that may not have one. mapClassFontUpdates used -1 as its "not captured yet" marker, which collides with the value a pixel-sized font reports. If an application-wide class font were ever set in pixels, the capture branch would store -1, re-fire on every subsequent pass, and feed a negative size into GetScaledFontSize(). Storing std::optional removes the collision, and the map's value type becomes double so the captured size is no longer truncated -- the same producer/consumer mismatch the previous commit fixed for widgets. These fonts belong to no widget, so the primary screen's DPI drives the conversion; if it is unavailable the entry is left uncaptured and retried next pass rather than poisoned. This one is defensive, not a live fix: class-scoped stylesheet rules do not reach qApp->font(class), so nothing in the shipped CSS can currently reach it. It takes an explicit qApp->setFont(pixel_font, class) call. setStyledHtml() was already guarded and so never crashed, but it silently fell back to DEFAULT_FONT_SIZE for a pixel-sized QTextEdit instead of honouring the size the widget was given. It now converts, keeping the existing fallback for the genuinely-unusable case. Co-Authored-By: Claude --- src/qt/guiutil_font.cpp | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/qt/guiutil_font.cpp b/src/qt/guiutil_font.cpp index 8fbe2971550a..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 @@ -149,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 @@ -800,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()); @@ -856,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); From 469abf8f942d52f7dacb88f86080973a5ef046d3 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 29 Jul 2026 13:07:45 -0500 Subject: [PATCH 4/5] test: cover pixel-sized font conversion effectivePointSize() takes the DPI as a parameter, so its arithmetic can be pinned exactly without a widget, a screen, or a particular host: point sizes pass through unchanged, pixel sizes convert at 72, 96 and 144 DPI, and a non-positive DPI reports failure rather than producing a negative size. Pinning the 72 DPI case matters because points and pixels coincide there -- a test that only ran at whatever DPI the host reports could assert 17 == 17 and look like it was checking the conversion when it was not. The integration test covers the smallest meaningful slice of the production path: a pixel-sized widget survives an updateFonts() sweep, which aborted the process before this series, and a second sweep does not compound the size, since the per-widget cache is what keeps repeated passes stable. updateFonts() is a no-op until loadFonts() has run, and loadFonts() is process-global, non-idempotent state owned by AppTests. Rather than calling it here -- which would make this test's behavior depend on suite execution order and could corrupt AppTests -- it is skipped when fonts are not loaded. It therefore skips under the "minimal" platform plugin the test runner defaults to, whose plugin cannot load application fonts at all. The helper tests carry the coverage that always runs. These live in OptionTests rather than a new translation unit, and the existing test registration order in test_main.cpp is left untouched. Co-Authored-By: Claude --- src/qt/test/optiontests.cpp | 64 +++++++++++++++++++++++++++++++++++++ src/qt/test/optiontests.h | 2 ++ 2 files changed, 66 insertions(+) diff --git a/src/qt/test/optiontests.cpp b/src/qt/test/optiontests.cpp index 17ffeb220b69..665e0fd6c154 100644 --- a/src/qt/test/optiontests.cpp +++ b/src/qt/test/optiontests.cpp @@ -9,8 +9,11 @@ #include #include +#include +#include #include #include +#include #include @@ -132,3 +135,64 @@ 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() +{ + // updateFonts() is a no-op until loadFonts() has run, and loadFonts() is process-global, + // non-idempotent state owned by AppTests. Skip rather than call it here so this test + // never depends on, or corrupts, another suite's state. + if (!GUIUtil::fontsLoaded()) { + QSKIP("Fonts are not loaded in this configuration; see AppTests::appTests()."); + } + + 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; From 67d25da616aaca0126aaa80055543b996c1ffc35 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 29 Jul 2026 13:40:23 -0500 Subject: [PATCH 5/5] test: require fonts for pixel-sized widget regression --- src/qt/test/optiontests.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/qt/test/optiontests.cpp b/src/qt/test/optiontests.cpp index 665e0fd6c154..52a70d009022 100644 --- a/src/qt/test/optiontests.cpp +++ b/src/qt/test/optiontests.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -170,13 +171,16 @@ void OptionTests::effectivePointSize() void OptionTests::updateFontsWithPixelSizedWidget() { - // updateFonts() is a no-op until loadFonts() has run, and loadFonts() is process-global, - // non-idempotent state owned by AppTests. Skip rather than call it here so this test - // never depends on, or corrupts, another suite's state. - if (!GUIUtil::fontsLoaded()) { - QSKIP("Fonts are not loaded in this configuration; see AppTests::appTests()."); + 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()};