diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index ba91e534..570a14d2 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -199,6 +199,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_() diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index 504dd418..c9e354bb 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -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( diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 07956e4a..2b6a7457 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -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) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index e071ecd9..72e89285 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -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], diff --git a/piker/ui/_search.py b/piker/ui/_search.py index aa6f6623..b1b3e270 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -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() diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 196d6fd8..2885bedb 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -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 @@ -109,7 +109,11 @@ class DpiAwareFont: def px_size(self) -> int: return self._qfont.pixelSize() - 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. @@ -118,7 +122,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 @@ -184,9 +188,13 @@ class DpiAwareFont: self._font_inches = inches font_size = math.floor(inches * dpi) + # 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 @@ -213,12 +221,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) # TODO: re-compute font size when main widget switches screens? diff --git a/piker/ui/_window.py b/piker/ui/_window.py index a15ecd24..27db80b3 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -18,6 +18,7 @@ Qt main window singletons and stuff. """ +from __future__ import annotations import os import signal import time @@ -37,6 +38,8 @@ from piker.ui.qt import ( QStatusBar, QScreen, QCloseEvent, + QEvent, + QObject, ) from ..log import get_logger from ._style import _font_small, hcolor @@ -46,6 +49,60 @@ from ._chart import GodWidget log = get_logger(__name__) +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 @@ -181,6 +238,24 @@ class MainWindow(QMainWindow): self._status_label: QLabel = None self._size: tuple[int, int] | None = None + # 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: @@ -336,6 +411,123 @@ 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 diff --git a/piker/ui/qt.py b/piker/ui/qt.py index 9dbb971c..1a212982 100644 --- a/piker/ui/qt.py +++ b/piker/ui/qt.py @@ -42,6 +42,7 @@ from PyQt6.QtCore import ( QSize, QModelIndex, QItemSelectionModel, + QObject, pyqtBoundSignal, pyqtRemoveInputHook, )