Skip to content

Commit

Permalink
Merge pull request #611 from ZLLentz/fix_failed_resizes
Browse files Browse the repository at this point in the history
ENH: positioner row widget sizing usability fixes and tweaks
  • Loading branch information
ZLLentz authored Jun 18, 2024
2 parents dbd3bc5 + b605ca7 commit 86ef472
Show file tree
Hide file tree
Showing 8 changed files with 1,644 additions and 1,111 deletions.
25 changes: 25 additions & 0 deletions docs/source/upcoming_release_notes/611-enh_positioner_resizing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
611 enh_positioner_resizing
#################

API Breaks
----------
- N/A

Features
--------
- Rework the design, sizing, and font scaling of the positioner row widget to address
concerns about readability and poor use of space for positioners that don't need
all of the widget components.
- Implement dynamic resizing in all directions for positioner row widgets.

Bugfixes
--------
- Fix various issues that cause font clipping for specific motors using the positioner row widget.

Maintenance
-----------
- N/A

Contributors
------------
- zllentz
263 changes: 241 additions & 22 deletions typhos/dynamic_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
Dynamically set widget font size based on its current size.
"""
from __future__ import annotations

from qtpy import QtCore, QtGui, QtWidgets
import functools
import logging

from qtpy import QtGui, QtWidgets
from qtpy.QtCore import QRectF, Qt

logger = logging.getLogger(__name__)


def get_widget_maximum_font_size(
widget: QtWidgets.QWidget,
Expand Down Expand Up @@ -106,10 +112,21 @@ def patch_widget(
widget: QtWidgets.QWidget,
*,
pad_percent: float = 0.0,
max_size: float | None = None,
min_size: float | None = None,
) -> None:
"""
Patch the widget to dynamically change its font.
This picks between patch_text_widget and patch_combo_widget as appropriate.
Depending on which is chosen, different methods may be patched to ensure
that the widget will always have the maximum size font that fits within
the bounding box.
Regardless of which method is chosen, the font will be dynamically
resized for the first time before this function returns.
Parameters
----------
widget : QtWidgets.QWidget
Expand All @@ -118,26 +135,170 @@ def patch_widget(
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.
max_size : float or None, optional
The maximum font point size we're allowed to apply to the widget.
min_size : float or None, optional
The minimum font point size we're allowed to apply to the widget.
"""
def resizeEvent(event: QtGui.QResizeEvent) -> 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 isinstance(widget, QtWidgets.QComboBox):
return patch_combo_widget(
widget=widget,
pad_percent=pad_percent,
max_size=max_size,
min_size=min_size,
)
elif hasattr(widget, "setText") and hasattr(widget, "text"):
return patch_text_widget(
widget=widget,
pad_percent=pad_percent,
max_size=max_size,
min_size=min_size,
)
else:
raise TypeError(f"Dynamic font not supported for {widget}")


def set_font_common(
widget: QtWidgets.QWidget,
font_size: int,
min_size: int,
max_size: int,
) -> None:
# 0.1 = avoid meaningless resizes
# 0.00001 = snap to min/max
delta = 0.1
if max_size is not None and font_size > max_size:
font_size = max_size
delta = 0.00001
if min_size is not None and font_size < min_size:
font_size = min_size
delta = 0.00001
font = widget.font()
if abs(font.pointSizeF() - font_size) > delta:
font.setPointSizeF(font_size)
# Set the font directly
widget.setFont(font)
# Also set the font in the stylesheet
# In case some code resets the style
patch_style_font_size(widget=widget, font_size=font_size)


def patch_text_widget(
widget: QtWidgets.QLabel | QtWidgets.QLineEdit,
*,
pad_percent: float = 0.0,
max_size: float | None = None,
min_size: float | None = None,
):
"""
Specific patching for widgets with text() and setText() methods.
This replaces resizeEvent and setText methods with versions that will
set the font size to the maximum fitting value when the widget is
resized or the text is updated.
The text is immediately resized for the first time during this function call.
"""
def set_font_size() -> None:
font_size = get_max_font_size_cached(
widget.text(),
widget.width(),
widget.height(),
)
set_font_common(
widget=widget,
font_size=font_size,
max_size=max_size,
min_size=min_size,
)

# Cache and reuse results per widget
# Low effort and not exhaustive, but reduces the usage of the big loop
@functools.lru_cache
def get_max_font_size_cached(text: str, width: int, height: int) -> float:
return get_widget_maximum_font_size(
widget,
text,
pad_width=width * pad_percent,
pad_height=height * pad_percent,
)
if abs(font.pointSizeF() - font_size) > 0.1:
font.setPointSizeF(font_size)
widget.setFont(font)

def resizeEvent(event: QtGui.QResizeEvent) -> None:
set_font_size()
return orig_resize_event(event)

def minimumSizeHint() -> QtCore.QSize:
# Do not give any size hint as it it changes during resizeEvent
return QtWidgets.QWidget.minimumSizeHint(widget)
def setText(*args, **kwargs) -> None:
# Re-evaluate the text size when the text changes too
rval = orig_set_text(*args, **kwargs)
set_font_size()
return rval

def sizeHint() -> QtCore.QSize:
# Do not give any size hint as it it changes during resizeEvent
return QtWidgets.QWidget.sizeHint(widget)
if hasattr(widget.resizeEvent, "_patched_methods_"):
return

orig_resize_event = widget.resizeEvent
orig_set_text = widget.setText

resizeEvent._patched_methods_ = (
widget.resizeEvent,
widget.setText,
)
widget.resizeEvent = resizeEvent
widget.setText = setText
set_font_size()


def patch_combo_widget(
widget: QtWidgets.QComboBox,
*,
pad_percent: float = 0.0,
max_size: float | None = None,
min_size: float | None = None,
):
"""
Specific patching for combobox widgets.
This replaces resizeEvent with a version that will
set the font size to the maximum fitting value
when the widget is resized.
The text is immediately resized for the first time during this function call.
"""
def set_font_size() -> None:
combo_options = [
widget.itemText(index) for index in range(widget.count())
]
font_sizes = [
get_max_font_size_cached(
text,
widget.width(),
widget.height(),
)
for text in combo_options
]
set_font_common(
widget=widget,
font_size=min(font_sizes),
max_size=max_size,
min_size=min_size,
)

# Cache and reuse results per widget
# Low effort and not exhaustive, but reduces the usage of the big loop
@functools.lru_cache
def get_max_font_size_cached(text: str, width: int, height: int) -> float:
return get_widget_maximum_font_size(
widget,
text,
pad_width=width * pad_percent,
pad_height=height * pad_percent,
)

def resizeEvent(event: QtGui.QResizeEvent) -> None:
set_font_size()
return orig_resize_event(event)

# TODO: figure out best way to resize font when combo options change

if hasattr(widget.resizeEvent, "_patched_methods_"):
return
Expand All @@ -146,12 +307,9 @@ def sizeHint() -> QtCore.QSize:

resizeEvent._patched_methods_ = (
widget.resizeEvent,
widget.sizeHint,
widget.minimumSizeHint,
)
widget.resizeEvent = resizeEvent
widget.sizeHint = sizeHint
widget.minimumSizeHint = minimumSizeHint
set_font_size()


def unpatch_widget(widget: QtWidgets.QWidget) -> None:
Expand All @@ -163,13 +321,27 @@ def unpatch_widget(widget: QtWidgets.QWidget) -> None:
widget : QtWidgets.QWidget
The widget to unpatch.
"""
unpatch_style_font_size(widget=widget)
if not hasattr(widget.resizeEvent, "_patched_methods_"):
return
if isinstance(widget, QtWidgets.QComboBox):
return unpatch_combo_widget(widget)
elif hasattr(widget, "setText") and hasattr(widget, "text"):
return unpatch_text_widget(widget)
else:
raise TypeError("Somehow, we have a patched widget that is unpatchable.")


def unpatch_text_widget(widget: QtWidgets.QLabel | QtWidgets.QLineEdit):
(
widget.resizeEvent,
widget.setText,
) = widget.resizeEvent._patched_methods_


def unpatch_combo_widget(widget: QtWidgets.QComboBox):
(
widget.resizeEvent,
widget.sizeHint,
widget.minimumSizeHint,
) = widget.resizeEvent._patched_methods_


Expand All @@ -188,3 +360,50 @@ def is_patched(widget: QtWidgets.QWidget) -> bool:
True if the widget has been patched.
"""
return hasattr(widget.resizeEvent, "_patched_methods_")


standard_comment = "/* Auto patch from typhos.dynamic_font */"


def patch_style_font_size(widget: QtWidgets.QWidget, font_size: int) -> None:
"""
Update a widget's stylesheet to force the font size.
Requires the widget stylesheet to either be empty or filled with
existing rules that have class specifiers, e.g. it's ok to have:
QPushButton { padding: 2px; margin: 0px; background-color: red }
But not to just have:
padding: 2px; margin: 0px; background-color: red
This will not be applied until next style reload, which can be
done via unpolish and polish
(see https://wiki.qt.io/Dynamic_Properties_and_Stylesheets)
In this module, this is used to guard against stylesheet reloads
undoing the dynamic font size. We will not call unpolish or polish
ourselves, but some other code might.
"""
starting_stylesheet = widget.styleSheet()
if standard_comment in starting_stylesheet:
unpatch_style_font_size(widget=widget)
widget.setStyleSheet(
f"{widget.styleSheet()}\n"
f"{standard_comment}\n"
f"{widget.__class__.__name__} {{ font-size: {font_size} pt }}"
)


def unpatch_style_font_size(widget: QtWidgets.QWidget) -> None:
"""
Undo the effects of patch_style_font_size.
Assumes that the last two lines of the stylesheet are the comment
and the rule that we added.
"""
if standard_comment in widget.styleSheet():
widget.setStyleSheet(
"\n".join(widget.styleSheet().split("\n")[:-2])
)
Loading

0 comments on commit 86ef472

Please sign in to comment.