diff --git a/docs/source/upcoming_release_notes/570-dynamic_fonts.rst b/docs/source/upcoming_release_notes/570-dynamic_fonts.rst new file mode 100644 index 00000000..076b116e --- /dev/null +++ b/docs/source/upcoming_release_notes/570-dynamic_fonts.rst @@ -0,0 +1,23 @@ +570 dynamic_fontsize +#################### + +API Changes +----------- +- N/A + +Features +-------- +- Added dynamic font sizer utility which can work with some Qt-provided widgets + as well as PyDM widgets. + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- N/A + +Contributors +------------ +- klauer diff --git a/typhos/dynamic_font.py b/typhos/dynamic_font.py new file mode 100644 index 00000000..aea7be5f --- /dev/null +++ b/typhos/dynamic_font.py @@ -0,0 +1,190 @@ +""" +Dynamic font size helper utilities: + +Dynamically set widget font size based on its current size. +""" + +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import QRectF, Qt + + +def get_widget_maximum_font_size( + widget: QtWidgets.QWidget, + text: str, + *, + pad_width: float = 0.0, + pad_height: float = 0.0, + precision: float = 0.5, +) -> float: + """ + Get the maximum font size for the given widget. + + Parameters + ---------- + widget : QtWidgets.QWidget + The widget to check. + text : str + The text for the widget to contain. + pad_width : float, optional + Padding to be used to reduce the size of the contents rectangle. + pad_height : float, optional + Padding to be used to reduce the size of the contents rectangle. + precision : float + Font size precision. + + Returns + ------- + float + """ + font = widget.font() + widget_contents_rect = QRectF(widget.contentsRect()) + target_width = widget_contents_rect.width() - pad_width + target_height = widget_contents_rect.height() - pad_height + + # QRectF new_rect + current_size = font.pointSizeF() + + if not text or target_width <= 0 or target_height <= 0: + return current_size + + step = current_size / 2.0 + + # If too small, increase step + if step <= precision: + step = precision * 4.0 + + last_tested_size = current_size + curent_height = 0.0 + current_width = 0.0 + + # Only stop when step is small enough and new size is smaller than QWidget + while ( + step > precision + or (curent_height > target_height) + or (current_width > target_width) + ): + # Keep last tested value + last_tested_size = current_size + + # Test label with its font + font.setPointSizeF(current_size) + # Use font metrics to test + fm = QtGui.QFontMetricsF(font) + + # Check if widget is QLabel + if isinstance(widget, QtWidgets.QLabel): + if widget.wordWrap(): + flags = Qt.TextFlag.TextWordWrap | widget.alignment() + else: + flags = widget.alignment() + new_rect = fm.boundingRect(widget_contents_rect, flags, text) + else: + new_rect = fm.boundingRect(widget_contents_rect, 0, text) + + curent_height = new_rect.height() + current_width = new_rect.width() + + # If new font size is too big, decrease it + if (curent_height > target_height) or (current_width > target_width): + current_size -= step + # if step is small enough, keep it constant, so it converges to + # biggest font size + if step > precision: + step /= 2.0 + # Do not allow negative size + if current_size <= 0: + break + else: + # If new font size is smaller than maximum possible size, increase + # it + current_size += step + + return last_tested_size + + +def patch_widget( + widget: QtWidgets.QWidget, + *, + pad_percent: float = 0.0, +) -> None: + """ + Patch the widget to dynamically change its font. + + Parameters + ---------- + widget : QtWidgets.QWidget + The widget to patch. + pad_percent : float, optional + The normalized padding percentage (0.0 - 1.0) to use in determining the + maximum font size. Content margin settings determine the content + rectangle, and this padding is applied as a percentage on top of that. + """ + def paintEvent(event: QtGui.QPaintEvent) -> None: + font = widget.font() + font_size = get_widget_maximum_font_size( + widget, widget.text(), + pad_width=widget.width() * pad_percent, + pad_height=widget.height() * pad_percent, + ) + if abs(font.pointSizeF() - font_size) > 0.1: + font.setPointSizeF(font_size) + widget.setFont(font) + return orig_paint_event(event) + + def minimumSizeHint() -> QtCore.QSize: + # Do not give any size hint as it it changes during paintEvent + return QtWidgets.QWidget.minimumSizeHint(widget) + + def sizeHint() -> QtCore.QSize: + # Do not give any size hint as it it changes during paintEvent + return QtWidgets.QWidget.sizeHint(widget) + + if hasattr(widget.paintEvent, "_patched_methods_"): + return + + orig_paint_event = widget.paintEvent + + paintEvent._patched_methods_ = ( + widget.paintEvent, + widget.sizeHint, + widget.minimumSizeHint, + ) + widget.paintEvent = paintEvent + widget.sizeHint = sizeHint + widget.minimumSizeHint = minimumSizeHint + + +def unpatch_widget(widget: QtWidgets.QWidget) -> None: + """ + Remove dynamic font size patch from the widget, if previously applied. + + Parameters + ---------- + widget : QtWidgets.QWidget + The widget to unpatch. + """ + if not hasattr(widget.paintEvent, "_patched_methods_"): + return + + ( + widget.paintEvent, + widget.sizeHint, + widget.minimumSizeHint, + ) = widget.paintEvent._patched_methods_ + + +def is_patched(widget: QtWidgets.QWidget) -> bool: + """ + Check if widget has been patched for dynamically-resizing fonts. + + Parameters + ---------- + widget : QtWidgets.QWidget + The widget to check. + + Returns + ------- + bool + True if the widget has been patched. + """ + return hasattr(widget.paintEvent, "_patched_methods_") diff --git a/typhos/positioner.py b/typhos/positioner.py index 887075b4..c2ca19ac 100644 --- a/typhos/positioner.py +++ b/typhos/positioner.py @@ -5,7 +5,7 @@ from pydm.widgets.channel import PyDMChannel from qtpy import QtCore, QtWidgets, uic -from . import utils, widgets +from . import dynamic_font, utils, widgets from .alarm import KindLevel, _KindLevel from .status import TyphosStatusThread @@ -117,6 +117,8 @@ def __init__(self, parent=None): self.show_expert_button = False self._after_set_moving(False) + dynamic_font.patch_widget(self.ui.user_readback, pad_percent=0.01) + def _clear_status_thread(self): """Clear a previous status thread.""" if self._status_thread is None: diff --git a/typhos/tests/test_dynamic_font.py b/typhos/tests/test_dynamic_font.py new file mode 100644 index 00000000..c4c04321 --- /dev/null +++ b/typhos/tests/test_dynamic_font.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import pytest +import pytestqt.qtbot +from qtpy import QtCore, QtGui, QtWidgets + +from typhos.tests import conftest + +from ..dynamic_font import is_patched, patch_widget, unpatch_widget + + +@pytest.mark.parametrize( + "cls", + [ + QtWidgets.QLabel, + QtWidgets.QPushButton, + ] +) +def test_patching( + request: pytest.FixtureRequest, + cls: type[QtWidgets.QWidget], + qtbot: pytestqt.qtbot.QtBot, +) -> None: + widget = cls() + widget.setText("Test\ntext") + widget.setFixedSize(500, 500) + qtbot.add_widget(widget) + + original_font_size = widget.font().pointSizeF() + conftest.save_image( + widget, + f"{request.node.name}_{cls.__name__}_default_font_size", + ) + print("Starting font size", original_font_size) + + event = QtGui.QPaintEvent(QtCore.QRect(0, 0, widget.width(), widget.height())) + + assert not is_patched(widget) + patch_widget(widget) + assert is_patched(widget) + + widget.paintEvent(event) + new_font_size = widget.font().pointSizeF() + print("Patched font size", new_font_size) + assert original_font_size != new_font_size + + assert is_patched(widget) + unpatch_widget(widget) + assert not is_patched(widget) + + conftest.save_image( + widget, + f"{request.node.name}_{cls.__name__}_dynamic_font_size", + ) diff --git a/typhos/widgets.py b/typhos/widgets.py index 2d2073cc..3a40453c 100644 --- a/typhos/widgets.py +++ b/typhos/widgets.py @@ -22,7 +22,7 @@ from qtpy.QtWidgets import (QAction, QDialog, QDockWidget, QPushButton, QToolBar, QVBoxLayout, QWidget) -from . import plugins, utils, variety +from . import dynamic_font, plugins, utils, variety from .textedit import TyphosTextEdit # noqa: F401 from .tweakable import TyphosTweakable # noqa: F401 from .variety import use_for_variety_read, use_for_variety_write @@ -393,6 +393,18 @@ def unit_changed(self, new_unit): if new_unit.lower() in EXPONENTIAL_UNITS and default: self.displayFormat = DisplayFormat.Exponential + @Property(bool, "dynamicFontSize") + def dynamic_font_size(self) -> bool: + """Dynamically adjust the font size""" + return dynamic_font.is_patched(self) + + @dynamic_font_size.setter + def dynamic_font_size(self, value: bool): + if value: + dynamic_font.patch_widget(self) + else: + dynamic_font.unpatch_widget(self) + class TyphosSidebarItem(ParameterItem): """