Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: dynamic font resizing functionality #570

Merged
merged 1 commit into from
Aug 21, 2023
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
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")
Copy link
Contributor Author

@klauer klauer Aug 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't used and probably should be removed
I erroneously thought that TyphosLabel et al were in the designer, but these are really used exclusively for auto-generated device displays (where we have the ophyd signal at instantiation time, etc)

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
Loading