Skip to content

Commit

Permalink
ENH: dynamic font size patching utility
Browse files Browse the repository at this point in the history
  • Loading branch information
klauer committed Aug 18, 2023
1 parent 1fb9d4f commit 6904f5a
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 2 deletions.
23 changes: 23 additions & 0 deletions docs/source/upcoming_release_notes/570-dynamic_fonts.rst
Original file line number Diff line number Diff line change
@@ -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
190 changes: 190 additions & 0 deletions typhos/dynamic_font.py
Original file line number Diff line number Diff line change
@@ -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_")
4 changes: 3 additions & 1 deletion typhos/positioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions typhos/tests/test_dynamic_font.py
Original file line number Diff line number Diff line change
@@ -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",
)
14 changes: 13 additions & 1 deletion typhos/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down

0 comments on commit 6904f5a

Please sign in to comment.