Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/release_notes/release_3.16.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@

* 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:

* **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 ##
Expand Down
68 changes: 68 additions & 0 deletions guidata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -98,6 +165,7 @@ def qapplication():
_configure_fontdir()
_configure_high_dpi()
app = QApplication([])
_configure_default_font(app)
install_translator(app)
from guidata import qthelpers

Expand Down
64 changes: 61 additions & 3 deletions guidata/configtools.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

.. autofunction:: get_image_file_path

.. autofunction:: get_pixmap

.. autofunction:: get_image_label

.. autofunction:: get_image_layout
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
123 changes: 123 additions & 0 deletions guidata/tests/unit/test_hidpi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# -*- 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). 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


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)


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"])
Loading