From b9293de7b819c53fcce8f336b51b1680225a1cf0 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:00:57 +0100 Subject: [PATCH 1/2] feat(configtools): add high-DPI aware get_pixmap Load an image as a pixmap at the resolution of the display: when the device pixel ratio is greater than 1 (display scaling greater than 100%), the high-resolution variants of the image following Qt's @Nx naming convention (e.g. logo@2x.png next to logo.png) are resampled to the device resolution, so that the image remains sharp instead of being upscaled by Qt. get_image_label (and thus get_image_layout) now relies on it. Refs #121 Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_3.16.md | 4 ++ guidata/configtools.py | 64 +++++++++++++++++++++++-- guidata/tests/unit/test_hidpi.py | 79 +++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 guidata/tests/unit/test_hidpi.py diff --git a/doc/release_notes/release_3.16.md b/doc/release_notes/release_3.16.md index 6b37c01..ce2fdd4 100644 --- a/doc/release_notes/release_3.16.md +++ b/doc/release_notes/release_3.16.md @@ -4,6 +4,10 @@ * Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release. +✨ New features: + +* **`get_pixmap` — sharp images with display scaling** — the new `guidata.configtools.get_pixmap` function loads an image as a pixmap at the resolution of the display: when the display scaling is greater than 100% (e.g. 125%, 150%), the high-resolution variants of the image following Qt's `@Nx` naming convention (e.g. `logo@2x.png` next to `logo.png`) are used, so that the image remains sharp instead of being upscaled. `get_image_label` (and thus `get_image_layout`) now relies on it. + 🛠 Bug fixes: * **Application startup blocked by a corrupted translation file** — when a `.mo` translation catalog was corrupted or unreadable (e.g. truncated during installation), importing guidata failed with an error such as `OSError: Bad magic number`. The same applied to any application relying on `guidata.configtools.get_translation`, like PlotPy, Sigima or DataLab, so the application could not start at all. The interface now falls back to English, and a `RuntimeWarning` identifies the faulty catalog (fixes [Issue #119](https://github.com/PlotPyStack/guidata/issues/119)). diff --git a/guidata/configtools.py b/guidata/configtools.py index 707057a..b63e1db 100644 --- a/guidata/configtools.py +++ b/guidata/configtools.py @@ -14,6 +14,8 @@ .. autofunction:: get_image_file_path +.. autofunction:: get_pixmap + .. autofunction:: get_image_label .. autofunction:: get_image_layout @@ -253,6 +255,64 @@ def get_icon(name: str, default: str = "not_found.png") -> QG.QIcon: return icon +def get_pixmap( + name: str, + default: str | None = "not_found.png", + device_pixel_ratio: float | None = None, +) -> QG.QPixmap: + """ + Construct a QPixmap from the image file with specified name, taking into + account the device pixel ratio (high-DPI displays, display scaling). + + The pixmap has the logical size of the image file. When the device pixel ratio + is greater than 1, the high-resolution variants of the image following Qt's + ``@Nx`` naming convention (e.g. ``logo@2x.png`` next to ``logo.png``) are used, + if any: the smallest variant covering the device pixel ratio (or the largest + one) is resampled to the device resolution, so that the image remains sharp. + Without variants, the image is returned as is (and is upscaled by Qt). + + Args: + name: name of the image (or absolute path to the image file) + default: default image name. Defaults to "not_found.png". If None, + a RuntimeError is raised when the image is not found. + device_pixel_ratio: device pixel ratio. Defaults to None (i.e. the highest + device pixel ratio of the screens, like for icons) + + Returns: + Pixmap + """ + # Importing Qt here because this module should be independent from it + from qtpy import QtCore as QC # pylint: disable=import-outside-toplevel + from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel + + path = get_image_file_path(name, default) + pixmap = QG.QPixmap(path) + if device_pixel_ratio is None: + device_pixel_ratio = QG.QGuiApplication.instance().devicePixelRatio() + if pixmap.isNull() or device_pixel_ratio <= 1.0: + return pixmap + root, ext = osp.splitext(path) + source_path = None + for factor in range(2, 10): + variant_path = f"{root}@{factor}x{ext}" + if osp.isfile(variant_path): + source_path = variant_path + if factor >= device_pixel_ratio: + break + source = QG.QPixmap() if source_path is None else QG.QPixmap(source_path) + if source.isNull(): + return pixmap + size = QC.QSize( + round(pixmap.width() * device_pixel_ratio), + round(pixmap.height() * device_pixel_ratio), + ) + hidpi_pixmap = source.scaled( + size, QC.Qt.IgnoreAspectRatio, QC.Qt.SmoothTransformation + ) + hidpi_pixmap.setDevicePixelRatio(device_pixel_ratio) + return hidpi_pixmap + + def get_image_label(name, default="not_found.png") -> QW.QLabel: """ Construct a QLabel from the file with specified name @@ -266,12 +326,10 @@ def get_image_label(name, default="not_found.png") -> QW.QLabel: QW.QLabel: label """ # Importing Qt here because this module should be independent from it - from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel from qtpy import QtWidgets as QW # pylint: disable=import-outside-toplevel label = QW.QLabel() - pixmap = QG.QPixmap(get_image_file_path(name, default)) - label.setPixmap(pixmap) + label.setPixmap(get_pixmap(name, default)) return label diff --git a/guidata/tests/unit/test_hidpi.py b/guidata/tests/unit/test_hidpi.py new file mode 100644 index 0000000..e386932 --- /dev/null +++ b/guidata/tests/unit/test_hidpi.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see guidata/LICENSE for details) + +""" +High-DPI support tests + +Images must remain sharp with display scaling: ``get_pixmap`` builds pixmaps at the +device resolution from the high-resolution variants of an image (``@Nx`` naming +convention). +""" + +from __future__ import annotations + +import pytest +from qtpy import QtCore as QC +from qtpy import QtGui as QG + +from guidata.configtools import get_pixmap +from guidata.qthelpers import qt_app_context + + +def _create_image(path: str, width: int, height: int, color: str) -> None: + """Create an image file filled with a color""" + image = QG.QImage(width, height, QG.QImage.Format_ARGB32) + image.fill(QG.QColor(color)) + assert image.save(path) + + +def _color(pixmap: QG.QPixmap) -> str: + """Return the color of the top-left pixel of a pixmap""" + return pixmap.toImage().pixelColor(0, 0).name() + + +def test_get_pixmap_variants(tmp_path) -> None: + """Test that the variant best suited to the device pixel ratio is used""" + with qt_app_context(): + path = str(tmp_path / "logo.png") + _create_image(path, 40, 20, "#ff0000") + _create_image(str(tmp_path / "logo@2x.png"), 80, 40, "#0000ff") + _create_image(str(tmp_path / "logo@3x.png"), 120, 60, "#00ff00") + for ratio, size, color in ( + (1.0, (40, 20), "#ff0000"), + (1.25, (50, 25), "#0000ff"), + (2.0, (80, 40), "#0000ff"), + (2.5, (100, 50), "#00ff00"), + (4.0, (160, 80), "#00ff00"), + ): + pixmap = get_pixmap(path, device_pixel_ratio=ratio) + assert pixmap.size() == QC.QSize(*size) + assert pixmap.devicePixelRatio() == pytest.approx(ratio) + assert _color(pixmap) == color + + +def test_get_pixmap_without_variant(tmp_path) -> None: + """Test that an image without high-resolution variant is returned as is""" + with qt_app_context(): + path = str(tmp_path / "single.png") + _create_image(path, 40, 20, "#ff0000") + pixmap = get_pixmap(path, device_pixel_ratio=1.5) + assert pixmap.size() == QC.QSize(40, 20) + assert pixmap.devicePixelRatio() == 1.0 + + +def test_get_pixmap_default_ratio(tmp_path) -> None: + """Test that the device pixel ratio of the application is used by default""" + with qt_app_context() as qapp: + path = str(tmp_path / "logo.png") + _create_image(path, 40, 20, "#ff0000") + _create_image(str(tmp_path / "logo@2x.png"), 80, 40, "#0000ff") + pixmap = get_pixmap(path) + ratio = max(1.0, qapp.devicePixelRatio()) + assert pixmap.devicePixelRatio() == pytest.approx(ratio) + assert pixmap.width() == round(40 * ratio) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From f75388890821e282ac1ea95a00ee60e4f337022b Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:01:11 +0100 Subject: [PATCH 2/2] fix(qt5): scale the default font with the Windows display scaling With Qt 5 on Windows, the default application font is derived from the legacy DEFAULT_GUI_FONT stock object, whose pixel size ignores the display scaling, while Qt converts it to points with the DPI of the primary screen: at 125%, it ends up at 6.6pt instead of 8.25pt, so that the text of most widgets keeps its 100% size, unlike menus and status bars. qapplication() now uses the message font of the system (typically "Segoe UI", 9pt) as default font with Qt 5 on Windows, like Qt 6 does (QTBUG-58610), and restores the fonts set by the platform theme for specific widget classes, which QApplication.setFont clears. Closes #121 Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_3.16.md | 1 + guidata/__init__.py | 68 +++++++++++++++++++++++++++++++ guidata/tests/unit/test_hidpi.py | 46 ++++++++++++++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/doc/release_notes/release_3.16.md b/doc/release_notes/release_3.16.md index ce2fdd4..a45fb2b 100644 --- a/doc/release_notes/release_3.16.md +++ b/doc/release_notes/release_3.16.md @@ -10,6 +10,7 @@ 🛠 Bug fixes: +* **Text not scaled with display scaling on Windows (Qt5)** — with a Windows display scaling greater than 100% (e.g. 125%), the text of most widgets (labels, buttons, data set editors, ...) kept the size it has at 100%, while menus and status bars were scaled, because Qt5 derives its default font from a legacy system font which ignores the display scaling. Applications created with `guidata.qapplication` (or `qt_app_context`) now use the message font of the system as default font (typically "Segoe UI", 9pt), like with Qt6: the text follows the display scaling and the Windows text size setting. * **Application startup blocked by a corrupted translation file** — when a `.mo` translation catalog was corrupted or unreadable (e.g. truncated during installation), importing guidata failed with an error such as `OSError: Bad magic number`. The same applied to any application relying on `guidata.configtools.get_translation`, like PlotPy, Sigima or DataLab, so the application could not start at all. The interface now falls back to English, and a `RuntimeWarning` identifies the faulty catalog (fixes [Issue #119](https://github.com/PlotPyStack/guidata/issues/119)). ## guidata Version 3.16.0 ## diff --git a/guidata/__init__.py b/guidata/__init__.py index 95a3740..e92f13d 100644 --- a/guidata/__init__.py +++ b/guidata/__init__.py @@ -86,6 +86,73 @@ def _configure_high_dpi(): ) +#: Widget classes which may receive a specific font from the Qt platform theme +#: (see ``QApplicationPrivate::initializeWidgetFontHash`` in Qt 5.15) +_QT5_THEME_FONT_CLASSES = ( + "QMenu", + "QMenuBar", + "QMenuItem", + "QMessageBox", + "QLabel", + "QTipLabel", + "QTitleBar", + "QStatusBar", + "QMdiSubWindowTitleBar", + "QDockWidgetTitle", + "QPushButton", + "QCheckBox", + "QRadioButton", + "QToolButton", + "QAbstractItemView", + "QListView", + "QHeaderView", + "QListBox", + "QComboMenuItem", + "QComboLineEdit", + "QSmallFont", + "QMiniFont", +) + + +def _configure_default_font(app) -> None: + """Use the Windows message font as default font with Qt5 on Windows. + + Qt5 takes its default font from the legacy ``DEFAULT_GUI_FONT`` stock object + ("MS Shell Dlg 2", 8pt), whose pixel size ignores the display scaling: with a + scaling factor of 125%, widgets using the default font show text which is not + scaled (labels, buttons, dataset editors, ...), unlike menus or status bars. + Qt6 uses the message font of the system instead (typically "Segoe UI", 9pt, + see QTBUG-58610), which follows the display scaling and the Windows text size + setting. This function applies the Qt6 behavior to Qt5. + + Must be called right after QApplication creation, before creating widgets. + + Args: + app: QApplication instance + """ + import sys + + from qtpy import QT5 + from qtpy.QtWidgets import QApplication + + if not QT5 or sys.platform != "win32": + return + default_font = app.font() + message_font = app.font("QMessageBox") + if message_font == default_font: + return + # Setting the application font clears the fonts set by the platform theme for + # specific widget classes (menus, status bar, item views, ...): restore them. + class_fonts = {} + for class_name in _QT5_THEME_FONT_CLASSES: + font = app.font(class_name) + if font != default_font: + class_fonts[class_name] = font + QApplication.setFont(message_font) + for class_name, font in class_fonts.items(): + QApplication.setFont(font, class_name) + + def qapplication(): """ Return QApplication instance @@ -98,6 +165,7 @@ def qapplication(): _configure_fontdir() _configure_high_dpi() app = QApplication([]) + _configure_default_font(app) install_translator(app) from guidata import qthelpers diff --git a/guidata/tests/unit/test_hidpi.py b/guidata/tests/unit/test_hidpi.py index e386932..dde4c6e 100644 --- a/guidata/tests/unit/test_hidpi.py +++ b/guidata/tests/unit/test_hidpi.py @@ -8,15 +8,21 @@ Images must remain sharp with display scaling: ``get_pixmap`` builds pixmaps at the device resolution from the high-resolution variants of an image (``@Nx`` naming -convention). +convention). With Qt5 on Windows, the default application font must follow the +display scaling, like the message font of the system. """ from __future__ import annotations +import sys + import pytest +import qtpy from qtpy import QtCore as QC from qtpy import QtGui as QG +from qtpy import QtWidgets as QW +import guidata from guidata.configtools import get_pixmap from guidata.qthelpers import qt_app_context @@ -75,5 +81,43 @@ def test_get_pixmap_default_ratio(tmp_path) -> None: assert pixmap.width() == round(40 * ratio) +def test_default_font(monkeypatch) -> None: + """Test that the message font becomes the default font, and that the fonts of + specific widget classes are kept""" + with qt_app_context() as qapp: + saved_default = qapp.font() + saved_fonts = { + name: qapp.font(name) + for name in guidata._QT5_THEME_FONT_CLASSES + if qapp.font(name) != saved_default + } + try: + QW.QApplication.setFont(QG.QFont("Courier New", 7)) + QW.QApplication.setFont(QG.QFont("Arial", 13), "QMenu") + QW.QApplication.setFont(QG.QFont("Verdana", 11), "QMessageBox") + monkeypatch.setattr(qtpy, "QT5", True) + monkeypatch.setattr(sys, "platform", "win32") + guidata._configure_default_font(qapp) + font = qapp.font() + assert (font.family(), font.pointSize()) == ("Verdana", 11) + font = qapp.font("QMenu") + assert (font.family(), font.pointSize()) == ("Arial", 13) + font = qapp.font("QStatusBar") + assert (font.family(), font.pointSize()) == ("Verdana", 11) + finally: + QW.QApplication.setFont(saved_default) + for name, font in saved_fonts.items(): + QW.QApplication.setFont(font, name) + + +@pytest.mark.skipif( + not qtpy.QT5 or sys.platform != "win32", reason="Qt5 on Windows only" +) +def test_default_font_qt5_windows() -> None: + """Test that the default font is the message font with Qt5 on Windows""" + with qt_app_context() as qapp: + assert qapp.font() == qapp.font("QMessageBox") + + if __name__ == "__main__": pytest.main([__file__, "-v"])