Add global UI font-size zoom scaling (from @dnks)

Add `Ctrl+Shift+Plus/Minus/0` shortcuts for zooming all
UI widget font sizes via a `GlobalZoomEventFilter`
installed at the `QApplication` level.

Deats,
- `.ui._window`: add `GlobalZoomEventFilter` event
  filter class and `MainWindow.zoom_in/out/reset_zoom()`
  methods that reconfigure `DpiAwareFont` with a
  `zoom_level` multiplier then propagate to all child
  widgets.
- `.ui._style`: extend `DpiAwareFont.configure_to_dpi()`
  and `_config_fonts_to_screen()` to accept a
  `zoom_level` float multiplier; cast `px_size` to `int`.
- `.ui._forms`: add `update_fonts()` to `Edit`,
  `Selection`, `FieldsForm` and `FillStatusBar` for
  stylesheet regen.
- `.ui._label`: add `FormatLabel.update_font()` method.
- `.ui._position`: add `SettingsPane.update_fonts()`.
- `.ui._search`: add `update_fonts()` to `CompleterView`
  and `SearchWidget`.
- `.ui._exec`: install the zoom filter on window show.
- `.ui.qt`: import `QObject` from `PyQt6`.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
dpi-font-auto-calc
Gud Boi 2026-03-04 18:24:59 -05:00 committed by di1ara
parent 392640fd51
commit 81c3e8f7e6
8 changed files with 394 additions and 67 deletions

View File

@ -203,6 +203,9 @@ def run_qtractor(
if is_windows:
window.configure_to_desktop()
# install global keyboard shortcuts for UI zoom
window.install_global_zoom_filter()
# actually render to screen
window.show()
app.exec_()

View File

@ -124,6 +124,13 @@ class Edit(QLineEdit):
self.sizeHint()
self.update()
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate widget size.'''
self.dpi_font = font
self.setFont(font.font)
# tell Qt our size hint changed so it recalculates layout
self.updateGeometry()
def focus(self) -> None:
self.selectAll()
self.show()
@ -241,6 +248,14 @@ class Selection(QComboBox):
icon_size = round(h * 0.75)
self.setIconSize(QSize(icon_size, icon_size))
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate widget size.'''
self.setFont(font.font)
# recalculate heights with new font
self.resize()
# tell Qt our size hint changed so it recalculates layout
self.updateGeometry()
def set_items(
self,
keys: list[str],
@ -431,6 +446,39 @@ class FieldsForm(QWidget):
self.fields[key] = select
return select
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
from ._style import _font, _font_small
# update stored font size
self._font_size = _font_small.px_size - 2
# update all labels
for name, label in self.labels.items():
if hasattr(label, 'update_font'):
label.update_font(_font.font, self._font_size - 1)
# update all fields (edits, selects)
for key, field in self.fields.items():
# first check for our custom update_fonts method (Edit, Selection)
if hasattr(field, 'update_fonts'):
field.update_fonts(_font)
# then handle stylesheet updates for those without custom methods
elif hasattr(field, 'setStyleSheet'):
# regenerate stylesheet with new font size
field.setStyleSheet(
f"""QLineEdit {{
color : {hcolor('gunmetal')};
font-size : {self._font_size}px;
}}
"""
)
field.setFont(_font.font)
# for Selection widgets that need style updates
if hasattr(field, 'set_style'):
field.set_style(color='gunmetal', font_size=self._font_size)
async def handle_field_input(
@ -633,6 +681,37 @@ class FillStatusBar(QProgressBar):
self.setRange(0, int(slots))
self.setValue(value)
def update_fonts(self, font_size: int) -> None:
'''Update font size after zoom change.'''
from ._style import _font_small
self.font_size = font_size
# regenerate stylesheet with new font size
self.setStyleSheet(
f"""
QProgressBar {{
text-align: center;
font-size : {self.font_size - 2}px;
background-color: {hcolor('papas_special')};
color : {hcolor('papas_special')};
border: {self.border_px}px solid {hcolor('default_light')};
border-radius: 2px;
}}
QProgressBar::chunk {{
background-color: {hcolor('default_spotlight')};
color: {hcolor('bracket')};
border-radius: 2px;
}}
"""
)
self.setFont(_font_small.font)
def mk_fill_status_bar(

View File

@ -334,3 +334,19 @@ class FormatLabel(QLabel):
out = self.fmt_str.format(**fields)
self.setText(out)
return out
def update_font(
self,
font: QtGui.QFont,
font_size: int,
font_color: str = 'default_lightest',
) -> None:
'''Update font after zoom change.'''
self.setStyleSheet(
f"""QLabel {{
color : {hcolor(font_color)};
font-size : {font_size}px;
}}
"""
)
self.setFont(font)

View File

@ -178,6 +178,26 @@ class SettingsPane:
# encompasing high level namespace
order_mode: OrderMode | None = None # typing: ignore # noqa
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
from ._style import _font_small
# update form fields
if self.form and hasattr(self.form, 'update_fonts'):
self.form.update_fonts()
# update fill status bar
if self.fill_bar and hasattr(self.fill_bar, 'update_fonts'):
self.fill_bar.update_fonts(_font_small.px_size)
# update labels with new fonts
if self.step_label:
self.step_label.setFont(_font_small.font)
if self.pnl_label:
self.pnl_label.setFont(_font_small.font)
if self.limit_label:
self.limit_label.setFont(_font_small.font)
def set_accounts(
self,
names: list[str],

View File

@ -174,6 +174,12 @@ class CompleterView(QTreeView):
self.setStyleSheet(f"font: {size}px")
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
self.set_font_size(_font.px_size)
self.setIndentation(_font.px_size)
self.setFont(_font.font)
def resize_to_results(
self,
w: float | None = 0,
@ -630,6 +636,27 @@ class SearchWidget(QtWidgets.QWidget):
| align_flag.AlignLeft,
)
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
# regenerate label stylesheet with new font size
self.label.setStyleSheet(
f"""QLabel {{
color : {hcolor('default_lightest')};
font-size : {_font.px_size - 2}px;
}}
"""
)
self.label.setFont(_font.font)
# update search bar and view fonts
if hasattr(self.bar, 'update_fonts'):
self.bar.update_fonts(_font)
elif hasattr(self.bar, 'setFont'):
self.bar.setFont(_font.font)
if hasattr(self.view, 'update_fonts'):
self.view.update_fonts()
def focus(self) -> None:
self.show()
self.bar.focus()

View File

@ -80,7 +80,7 @@ class DpiAwareFont:
self._screen = None
def _set_qfont_px_size(self, px_size: int) -> None:
self._qfont.setPixelSize(px_size)
self._qfont.setPixelSize(int(px_size))
self._qfm = QtGui.QFontMetrics(self._qfont)
@property
@ -124,7 +124,11 @@ class DpiAwareFont:
return size
def configure_to_dpi(self, screen: QtGui.QScreen | None = None):
def configure_to_dpi(
self,
screen: QtGui.QScreen | None = None,
zoom_level: float = 1.0,
):
'''
Set an appropriately sized font size depending on the screen DPI.
@ -133,7 +137,7 @@ class DpiAwareFont:
'''
if self._font_size is not None:
self._set_qfont_px_size(self._font_size)
self._set_qfont_px_size(self._font_size * zoom_level)
return
# NOTE: if no font size set either in the [ui] section of the
@ -199,16 +203,14 @@ class DpiAwareFont:
self._font_inches = inches
font_size = math.floor(inches * pdpi)
ftype: str = f'{type(self)!r}'
log.info(
f'screen: {screen.name()}\n'
f'pDPI: {pdpi!r}\n'
f'lDPI: {ldpi!r}\n'
f'scale: {scale!r}\n'
f'{ftype}._font_inches={self._font_inches!r}\n'
f'\n'
f"Our best guess for an auto-font-size is,\n"
f'font_size: {font_size!r}\n'
# apply zoom level multiplier
font_size = int(font_size * zoom_level)
log.debug(
f"screen:{screen.name()}\n"
f"pDPI: {pdpi}, lDPI: {ldpi}, scale: {scale}\n"
f"zoom_level: {zoom_level}\n"
f"\nOur best guess font size is {font_size}\n"
)
# apply the size
self._set_qfont_px_size(font_size)
@ -231,12 +233,12 @@ _font = DpiAwareFont()
_font_small = DpiAwareFont(_font_size_key='small')
def _config_fonts_to_screen() -> None:
def _config_fonts_to_screen(zoom_level: float = 1.0) -> None:
'configure global DPI aware font sizes'
global _font, _font_small
_font.configure_to_dpi()
_font_small.configure_to_dpi()
_font.configure_to_dpi(zoom_level=zoom_level)
_font_small.configure_to_dpi(zoom_level=zoom_level)
def get_fonts() -> tuple[

View File

@ -18,6 +18,8 @@
Qt main window singletons and stuff.
"""
from __future__ import annotations
import os
import signal
import time
@ -38,6 +40,8 @@ from piker.ui.qt import (
QScreen,
QCloseEvent,
QSettings,
QEvent,
QObject,
)
from ..log import get_logger
from ._style import _font_small, hcolor
@ -47,8 +51,61 @@ from ._widget import GodWidget
log = get_logger(__name__)
class MultiStatus:
class GlobalZoomEventFilter(QObject):
"""
Application-level event filter for global UI zoom shortcuts.
This filter intercepts keyboard events BEFORE they reach widgets,
allowing us to implement global UI zoom shortcuts that take precedence
over widget-specific shortcuts.
Shortcuts:
- Ctrl+Shift+Plus/Equal: Zoom in
- Ctrl+Shift+Minus: Zoom out
- Ctrl+Shift+0: Reset zoom
"""
def __init__(self, main_window: MainWindow):
super().__init__()
self.main_window = main_window
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
"""
Filter keyboard events for global zoom shortcuts.
Returns True to filter out (consume) the event, False to pass through.
"""
if event.type() == QEvent.Type.KeyPress:
key = event.key()
mods = event.modifiers()
# Check for Ctrl+Shift modifier combination
has_ctrl_shift = (mods & Qt.KeyboardModifier.ControlModifier) and (
mods & Qt.KeyboardModifier.ShiftModifier
)
if has_ctrl_shift:
# Zoom in: Ctrl+Shift+Plus or Ctrl+Shift+Equal
if key in (Qt.Key.Key_Plus, Qt.Key.Key_Equal):
self.main_window.zoom_in()
return True # consume event
# Zoom out: Ctrl+Shift+Minus
elif key == Qt.Key.Key_Minus:
self.main_window.zoom_out()
return True # consume event
# Reset zoom: Ctrl+Shift+0
elif key == Qt.Key.Key_0:
self.main_window.reset_zoom()
return True # consume event
# Pass through all other events
return False
class MultiStatus:
bar: QStatusBar
statuses: list[str]
@ -59,19 +116,17 @@ class MultiStatus:
self._status_groups: dict[str, (set, Callable)] = {}
def open_status(
self,
msg: str,
final_msg: str|None = None,
final_msg: str | None = None,
clear_on_next: bool = False,
group_key: Union[bool, str]|None = False,
group_key: Union[bool, str] | None = False,
) -> Union[Callable[..., None], str]:
'''
"""
Add a status to the status bar and return a close callback which
when called will remove the status ``msg``.
'''
"""
for old_msg in self._to_clear:
try:
self.statuses.remove(old_msg)
@ -150,10 +205,10 @@ class MultiStatus:
return ret
def render(self) -> None:
'''
"""
Display all open statuses to bar.
'''
"""
if self.statuses:
self.bar.showMessage(f'{" ".join(self.statuses)}')
else:
@ -161,7 +216,6 @@ class MultiStatus:
class MainWindow(QMainWindow):
# XXX: for tiling wms this should scale
# with the alloted window size.
# TODO: detect for tiling and if untrue set some size?
@ -176,11 +230,11 @@ class MainWindow(QMainWindow):
self.setWindowTitle(self.title)
# set by runtime after `trio` is engaged.
self.godwidget: GodWidget|None = None
self.godwidget: GodWidget | None = None
self._status_bar: QStatusBar = None
self._status_label: QLabel = None
self._size: tuple[int, int]|None = None
self._size: tuple[int, int] | None = None
# restore window geometry from previous session
settings = QSettings('pikers', 'piker')
@ -189,12 +243,29 @@ class MainWindow(QMainWindow):
self.restoreGeometry(geometry)
log.debug('Restored window geometry from previous session')
# zoom level for UI scaling (1.0 = 100%, 1.5 = 150%, etc)
# Change this value to set the default startup zoom level
self._zoom_level: float = 4.0 # Start at 200% zoom
self._min_zoom: float = 0.5
self._max_zoom: float = 10.0
self._zoom_step: float = 1.0
# event filter for global zoom shortcuts
self._zoom_filter: GlobalZoomEventFilter | None = None
def install_global_zoom_filter(self) -> None:
"""Install application-level event filter for global UI zoom shortcuts."""
if self._zoom_filter is None:
self._zoom_filter = GlobalZoomEventFilter(self)
app = QApplication.instance()
app.installEventFilter(self._zoom_filter)
log.info('Installed global zoom shortcuts: Ctrl+Shift+Plus/Minus/0')
@property
def mode_label(self) -> QLabel:
# init mode label
if not self._status_label:
self._status_label = label = QLabel()
label.setStyleSheet(
f"""QLabel {{
@ -203,15 +274,10 @@ class MainWindow(QMainWindow):
"""
# font-size : {font_size}px;
)
label.setTextFormat(
Qt.TextFormat.MarkdownText
)
label.setTextFormat(Qt.TextFormat.MarkdownText)
label.setFont(_font_small.font)
label.setMargin(2)
label.setAlignment(
QtCore.Qt.AlignVCenter
|QtCore.Qt.AlignRight
)
label.setAlignment(QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight)
self.statusBar().addPermanentWidget(label)
label.show()
@ -220,11 +286,8 @@ class MainWindow(QMainWindow):
def closeEvent(
self,
event: QCloseEvent,
) -> None:
'''Cancel the root actor asap.
'''
"""Cancel the root actor asap."""
# save window geometry for next session
settings = QSettings('pikers', 'piker')
settings.setValue('windowGeometry', self.saveGeometry())
@ -238,16 +301,17 @@ class MainWindow(QMainWindow):
# style and cached the status bar on first access
if not self._status_bar:
sb = self.statusBar()
sb.setStyleSheet((
f"color : {hcolor('gunmetal')};"
f"background : {hcolor('default_dark')};"
f"font-size : {_font_small.px_size}px;"
"padding : 0px;"
# "min-height : 19px;"
# "qproperty-alignment: AlignVCenter;"
))
sb.setStyleSheet(
(
f'color : {hcolor("gunmetal")};'
f'background : {hcolor("default_dark")};'
f'font-size : {_font_small.px_size}px;'
'padding : 0px;'
# "min-height : 19px;"
# "qproperty-alignment: AlignVCenter;"
)
)
self.setStatusBar(sb)
self._status_bar = MultiStatus(sb, [])
@ -256,28 +320,22 @@ class MainWindow(QMainWindow):
def set_mode_name(
self,
name: str,
) -> None:
self.mode_label.setText(f'mode:{name}')
def on_focus_change(
self,
last: QWidget,
current: QWidget,
) -> None:
'''
"""
Focus handler.
For now updates the "current mode" name.
'''
log.debug(
f'widget focus changed from,\n'
f'{last} -> {current}'
)
"""
log.debug(f'widget focus changed from,\n{last} -> {current}')
if current is not None:
# cursor left window?
@ -285,10 +343,10 @@ class MainWindow(QMainWindow):
self.set_mode_name(name)
def current_screen(self) -> QScreen:
'''
"""
Get a frickin screen (if we can, gawd).
'''
"""
app = QApplication.instance()
for _ in range(3):
@ -304,29 +362,28 @@ class MainWindow(QMainWindow):
# try for the first one we can find
screen = app.screens()[0]
assert screen, "Wow Qt is dumb as shit and has no screen..."
assert screen, 'Wow Qt is dumb as shit and has no screen...'
return screen
def configure_to_desktop(
self,
size: tuple[int, int]|None = None,
size: tuple[int, int] | None = None,
) -> None:
'''
"""
Explicitly size the window dimensions (for stacked window
managers).
For tina systems (like windoze) try to do a sane window size on
startup.
'''
"""
# https://stackoverflow.com/a/18975846
if not size and not self._size:
# app = QApplication.instance()
geo = self.current_screen().geometry()
h, w = geo.height(), geo.width()
# use approx 1/3 of the area of the screen by default
self._size = round(w * .666), round(h * .666)
self._size = round(w * 0.666), round(h * 0.666)
self.resize(*size or self._size)
@ -357,13 +414,135 @@ class MainWindow(QMainWindow):
self.godwidget.on_win_resize(event)
event.accept()
def zoom_in(self) -> None:
"""Increase UI zoom level."""
new_zoom = min(self._zoom_level + self._zoom_step, self._max_zoom)
if new_zoom != self._zoom_level:
self._zoom_level = new_zoom
self._apply_zoom()
log.info(f'Zoomed in to {self._zoom_level:.1%}')
def zoom_out(self) -> None:
"""Decrease UI zoom level."""
new_zoom = max(self._zoom_level - self._zoom_step, self._min_zoom)
if new_zoom != self._zoom_level:
self._zoom_level = new_zoom
self._apply_zoom()
log.info(f'Zoomed out to {self._zoom_level:.1%}')
def reset_zoom(self) -> None:
"""Reset UI zoom to 100%."""
if self._zoom_level != 1.0:
self._zoom_level = 1.0
self._apply_zoom()
log.info('Reset zoom to 100%')
def _apply_zoom(self) -> None:
"""Apply current zoom level to all UI elements."""
from . import _style
# reconfigure fonts with zoom multiplier
_style._config_fonts_to_screen(zoom_level=self._zoom_level)
# update status bar styling with new font size
if self._status_bar:
sb = self.statusBar()
sb.setStyleSheet(
(
f'color : {hcolor("gunmetal")};'
f'background : {hcolor("default_dark")};'
f'font-size : {_style._font_small.px_size}px;'
'padding : 0px;'
)
)
# force update of mode label if it exists
if self._status_label:
self._status_label.setFont(_style._font_small.font)
# update godwidget and its children
if self.godwidget:
# update search widget if it exists
if hasattr(self.godwidget, 'search') and self.godwidget.search:
self.godwidget.search.update_fonts()
# update order mode panes in all chart views
self._update_chart_order_panes()
# recursively update all other widgets with stylesheets
self._refresh_widget_fonts(self.godwidget)
self.godwidget.update()
def _update_chart_order_panes(self) -> None:
"""Update order entry panels in all charts."""
if not self.godwidget:
return
# iterate through all linked splits (hist and rt)
for splits_name in ['hist_linked', 'rt_linked']:
splits = getattr(self.godwidget, splits_name, None)
if not splits:
continue
# get main chart
chart = getattr(splits, 'chart', None)
if chart and hasattr(chart, 'view'):
view = chart.view
if hasattr(view, 'order_mode') and view.order_mode:
order_mode = view.order_mode
if hasattr(order_mode, 'pane') and order_mode.pane:
order_mode.pane.update_fonts()
# also check subplots
subplots = getattr(splits, 'subplots', {})
for name, subplot_chart in subplots.items():
if hasattr(subplot_chart, 'view'):
subplot_view = subplot_chart.view
if hasattr(subplot_view, 'order_mode') and subplot_view.order_mode:
subplot_order_mode = subplot_view.order_mode
if (
hasattr(subplot_order_mode, 'pane')
and subplot_order_mode.pane
):
subplot_order_mode.pane.update_fonts()
def _refresh_widget_fonts(self, widget: QWidget) -> None:
"""
Recursively update font sizes in all child widgets.
This handles widgets that have font-size hardcoded in their stylesheets.
"""
from . import _style
# recursively process all children
for child in widget.findChildren(QWidget):
# skip widgets that have their own update_fonts method (handled separately)
if hasattr(child, 'update_fonts'):
continue
# update child's stylesheet if it has font-size
child_stylesheet = child.styleSheet()
if child_stylesheet and 'font-size' in child_stylesheet:
# for labels and simple widgets, regenerate stylesheet
# this is a heuristic - may need refinement
try:
child.setFont(_style._font.font)
except (AttributeError, RuntimeError):
pass
# update child's font
try:
child.setFont(_style._font.font)
except (AttributeError, RuntimeError):
pass
# singleton app per actor
_qt_win: QMainWindow = None
def main_window() -> MainWindow:
'Return the actor-global Qt window.'
"Return the actor-global Qt window."
global _qt_win
assert _qt_win

View File

@ -42,6 +42,7 @@ from PyQt6.QtCore import (
QSize,
QModelIndex,
QItemSelectionModel,
QObject,
pyqtBoundSignal,
pyqtRemoveInputHook,
QSettings,