From 75d8582b20ccf5dfb61d8b5146e89f104b8f9f87 Mon Sep 17 00:00:00 2001 From: wygud Date: Wed, 1 Oct 2025 09:26:18 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=9F=A2=20config/conf.toml=20for=20upd?= =?UTF-8?q?ated=20UI=20font=20size=20and=20graphics=20throttle=20?= =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20piker/cli/=5F=5Finit=5F=5F.py=20->=20Ch?= =?UTF-8?q?anged=20transport=20from=20UDP=20to=20TCP=20in=20service=20mana?= =?UTF-8?q?ger=20=F0=9F=9B=A0=EF=B8=8F=20piker/data/=5Fsymcache.py=20->=20?= =?UTF-8?q?Added=20recursive=20dict=20cleaning=20for=20TOML=20serializatio?= =?UTF-8?q?n=20=F0=9F=9B=A0=EF=B8=8F=20piker/fsp/=5Fapi.py=20->=20Hash-bas?= =?UTF-8?q?ed=20key=20for=20shared=20memory=20buffers=20(macOS=20compatibi?= =?UTF-8?q?lity)=20=F0=9F=9B=A0=EF=B8=8F=20piker/tsp/=5F=5Finit=5F=5F.py?= =?UTF-8?q?=20->=20Hash-based=20key=20for=20history=20buffers=20for=20macO?= =?UTF-8?q?S=20compatibility=20=F0=9F=9B=A0=EF=B8=8F=20piker/ui/=5Fdisplay?= =?UTF-8?q?.py=20->=20Modified=20SHM=20name=20assertion=20for=20macOS=20co?= =?UTF-8?q?mpatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/conf.toml | 6 ++++-- piker.sh | 20 ++++++++++++++++++++ piker/data/_symcache.py | 13 ++++++++++++- piker/fsp/_api.py | 8 ++++++-- piker/tsp/__init__.py | 8 ++++++-- piker/ui/_display.py | 4 +++- pikerd.sh | 20 ++++++++++++++++++++ 7 files changed, 71 insertions(+), 8 deletions(-) create mode 100755 piker.sh create mode 100755 pikerd.sh diff --git a/config/conf.toml b/config/conf.toml index 1f2a64bc..696d0a40 100644 --- a/config/conf.toml +++ b/config/conf.toml @@ -6,9 +6,11 @@ pikerd = [ [ui] -# set custom font + size which will scale entire UI +# set custom font + size which will scale entire UI~ # font_size = 16 +# font_size = 32 # font_name = 'Monospaced' # colorscheme = 'default' # UNUSED -# graphics.update_throttle = 60 # Hz # TODO +# graphics.update_throttle = 120 # Hz #PENDING TODO + diff --git a/piker.sh b/piker.sh new file mode 100755 index 00000000..a16a32c0 --- /dev/null +++ b/piker.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# macOS wrapper for piker to handle missing XDG_RUNTIME_DIR + +# Set up runtime directory for macOS if not already set +if [ -z "$XDG_RUNTIME_DIR" ]; then + # Use macOS standard temp directory with user-specific subdirectory + export XDG_RUNTIME_DIR="/tmp/piker-runtime-$(id -u)" + + # Create the directory if it doesn't exist + if [ ! -d "$XDG_RUNTIME_DIR" ]; then + mkdir -p "$XDG_RUNTIME_DIR" + # Set proper permissions (only user can access) + chmod 700 "$XDG_RUNTIME_DIR" + fi + + echo "Set XDG_RUNTIME_DIR to: $XDG_RUNTIME_DIR" +fi + +# Run piker with all passed arguments +exec uv run piker "$@" \ No newline at end of file diff --git a/piker/data/_symcache.py b/piker/data/_symcache.py index 1f1cb9ec..cfcf6ce6 100644 --- a/piker/data/_symcache.py +++ b/piker/data/_symcache.py @@ -105,6 +105,15 @@ class SymbologyCache(Struct): def write_config(self) -> None: + def clean_dict_for_toml(d): + '''Remove None values from dict recursively for TOML serialization''' + if isinstance(d, dict): + return {k: clean_dict_for_toml(v) for k, v in d.items() if v is not None} + elif isinstance(d, list): + return [clean_dict_for_toml(item) for item in d if item is not None] + else: + return d + # put the backend's pair-struct type ref at the top # of file if possible. cachedict: dict[str, Any] = { @@ -125,7 +134,9 @@ class SymbologyCache(Struct): dct = cachedict[key] = {} for key, struct in table.items(): - dct[key] = struct.to_dict(include_non_members=False) + raw_dict = struct.to_dict(include_non_members=False) + # Clean None values for TOML compatibility + dct[key] = clean_dict_for_toml(raw_dict) try: with self.fp.open(mode='wb') as fp: diff --git a/piker/fsp/_api.py b/piker/fsp/_api.py index 92f8f271..bb2dea50 100644 --- a/piker/fsp/_api.py +++ b/piker/fsp/_api.py @@ -200,9 +200,13 @@ def maybe_mk_fsp_shm( ) # (attempt to) uniquely key the fsp shm buffers + # Use hash for macOS compatibility (31 char limit) + import hashlib actor_name, uuid = tractor.current_actor().uid - uuid_snip: str = uuid[:16] - key: str = f'piker.{actor_name}[{uuid_snip}].{sym}.{target.name}' + # Create short hash of sym and target name + content = f'{sym}.{target.name}' + content_hash = hashlib.md5(content.encode()).hexdigest()[:8] + key: str = f'{uuid[:8]}_{content_hash}.fsp' shm, opened = maybe_open_shm_array( key, diff --git a/piker/tsp/__init__.py b/piker/tsp/__init__.py index 121fcbb7..830e8aba 100644 --- a/piker/tsp/__init__.py +++ b/piker/tsp/__init__.py @@ -1257,13 +1257,17 @@ async def manage_history( service: str = name.rstrip(f'.{mod.name}') fqme: str = mkt.get_fqme(delim_char='') + # Create a short hash of the fqme for macOS compatibility + import hashlib + fqme_hash = hashlib.md5(fqme.encode()).hexdigest()[:8] + # (maybe) allocate shm array for this broker/symbol which will # be used for fast near-term history capture and processing. hist_shm, opened = maybe_open_shm_array( size=_default_hist_size, append_start_index=_hist_buffer_start, - key=f'piker.{service}[{uuid[:16]}].{fqme}.hist', + key=f'{uuid[:8]}_{fqme_hash}.h', # use any broker defined ohlc dtype: dtype=getattr(mod, '_ohlc_dtype', def_iohlcv_fields), @@ -1282,7 +1286,7 @@ async def manage_history( rt_shm, opened = maybe_open_shm_array( size=_default_rt_size, append_start_index=_rt_buffer_start, - key=f'piker.{service}[{uuid[:16]}].{fqme}.rt', + key=f'{uuid[:8]}_{fqme_hash}.r', # use any broker defined ohlc dtype: dtype=getattr(mod, '_ohlc_dtype', def_iohlcv_fields), diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 690bfb18..514a5850 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -212,7 +212,9 @@ async def increment_history_view( hist_chart: ChartPlotWidget = ds.hist_chart hist_viz: Viz = ds.hist_viz # viz: Viz = ds.viz - assert 'hist' in hist_viz.shm.token['shm_name'] + # NOTE: Changed for macOS compatibility with shortened shm names + # assert 'hist' in hist_viz.shm.token['shm_name'] + assert hist_viz.shm.token['shm_name'].endswith('.h') # name: str = hist_viz.name # TODO: seems this is more reliable at keeping the slow diff --git a/pikerd.sh b/pikerd.sh new file mode 100755 index 00000000..447e1370 --- /dev/null +++ b/pikerd.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# macOS wrapper for pikerd to handle missing XDG_RUNTIME_DIR + +# Set up runtime directory for macOS if not already set +if [ -z "$XDG_RUNTIME_DIR" ]; then + # Use macOS standard temp directory with user-specific subdirectory + export XDG_RUNTIME_DIR="/tmp/piker-runtime-$(id -u)" + + # Create the directory if it doesn't exist + if [ ! -d "$XDG_RUNTIME_DIR" ]; then + mkdir -p "$XDG_RUNTIME_DIR" + # Set proper permissions (only user can access) + chmod 700 "$XDG_RUNTIME_DIR" + fi + + echo "Set XDG_RUNTIME_DIR to: $XDG_RUNTIME_DIR" +fi + +# Run pikerd with all passed arguments +exec uv run pikerd "$@" \ No newline at end of file -- 2.34.1 From d8b9af46dfa2a1cac04f6f8ae3560af41ef99e75 Mon Sep 17 00:00:00 2001 From: wygud Date: Wed, 1 Oct 2025 16:35:46 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=B4=20No=20files=20deleted.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🟢 a/piker/ui/_exec.py for adding global keyboard shortcuts for UI zoom. 🛠️ a/piker/ui/_forms.py -> Added methods to update fonts and sizes dynamically. 🛠️ a/piker/ui/_label.py -> Added method to update font and color after zoom. 🛠️ a/piker/ui/_position.py -> Included update_fonts method for labels and bars. 🛠️ a/piker/ui/_search.py -> Added update_fonts method for search view components. 🛠️ a/piker/ui/_style.py -> Enhanced configure_to_dpi() to support zoom level multipliers. 🛠️ a/piker/ui/_window.py -> Implemented application-wide zoom with event filter and zoom methods, including signals to update UI elements' font sizes and styles. 🔴 No files deleted. --- piker/ui/_exec.py | 3 + piker/ui/_forms.py | 79 +++++++++++++++++ piker/ui/_label.py | 16 ++++ piker/ui/_position.py | 20 +++++ piker/ui/_search.py | 27 ++++++ piker/ui/_style.py | 20 +++-- piker/ui/_window.py | 192 ++++++++++++++++++++++++++++++++++++++++++ piker/ui/qt.py | 1 + 8 files changed, 352 insertions(+), 6 deletions(-) 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, ) -- 2.34.1 From fbcf7960fdce8060df10932a969fdaae8fde1548 Mon Sep 17 00:00:00 2001 From: wygud Date: Thu, 2 Oct 2025 14:25:44 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9F=A2=20.gitignore=20=F0=9F=9B=A0?= =?UTF-8?q?=EF=B8=8F=20piker/ui/=5Faxes.py=20->=20Enhance=20axis=20font=20?= =?UTF-8?q?and=20size=20handling=20=F0=9F=9B=A0=EF=B8=8F=20piker/ui/=5Fwin?= =?UTF-8?q?dow.py=20->=20Improve=20zoom=20key=20detection=20and=20event=20?= =?UTF-8?q?handling=20=F0=9F=9B=A0=EF=B8=8F=20piker/ui/=5Fwindow.py=20->?= =?UTF-8?q?=20Update=20axes=20fonts=20and=20layout=20after=20zoom=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + piker/ui/_axes.py | 56 ++++++++++++++++++++++++++++++++-- piker/ui/_window.py | 74 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 70826d07..bef34b85 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,4 @@ ENV/ # mypy .mypy_cache/ .vscode/settings.json +**/.DS_Store diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 5eab5afe..a4ae601b 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -75,6 +75,9 @@ class Axis(pg.AxisItem): self.pi = plotitem self._dpi_font = _font + # store for later recalculation on zoom + self._typical_max_str = typical_max_str + self.setTickFont(_font.font) font_size = self._dpi_font.font.pixelSize() @@ -156,6 +159,41 @@ class Axis(pg.AxisItem): def size_to_values(self) -> None: pass + def update_fonts(self, font: DpiAwareFont) -> None: + '''Update font and recalculate axis sizing after zoom change.''' + # IMPORTANT: tell Qt we're about to change geometry + self.prepareGeometryChange() + + self._dpi_font = font + self.setTickFont(font.font) + font_size = font.font.pixelSize() + + # recalculate text offset based on new font size + text_offset = None + if self.orientation in ('bottom',): + text_offset = floor(0.25 * font_size) + elif self.orientation in ('left', 'right'): + text_offset = floor(font_size / 2) + + if text_offset: + self.setStyle(tickTextOffset=text_offset) + + # recalculate bounding rect with new font + # Note: typical_max_str should be stored from init + if not hasattr(self, '_typical_max_str'): + self._typical_max_str = '100 000.000 ' # fallback default + self.typical_br = font._qfm.boundingRect(self._typical_max_str) + + # Update PyQtGraph's internal text size tracking + # This is critical - PyQtGraph uses these internally for auto-expand + if self.orientation in ['left', 'right']: + self.textWidth = self.typical_br.width() + else: + self.textHeight = self.typical_br.height() + + # resize axis to fit new font - this triggers PyQtGraph's auto-expand + self.size_to_values() + def txt_offsets(self) -> tuple[int, int]: return tuple(self.style['tickTextOffset']) @@ -256,7 +294,14 @@ class PriceAxis(Axis): self._min_tick = size def size_to_values(self) -> None: - self.setWidth(self.typical_br.width()) + # Call PyQtGraph's internal width update mechanism + # This respects autoExpandTextSpace and updates min/max constraints + self._updateWidth() + # tell Qt our preferred size changed so layout recalculates + self.updateGeometry() + # force parent plot item to recalculate its layout + if self.pi and hasattr(self.pi, 'updateGeometry'): + self.pi.updateGeometry() # XXX: drop for now since it just eats up h space @@ -300,7 +345,14 @@ class DynamicDateAxis(Axis): } def size_to_values(self) -> None: - self.setHeight(self.typical_br.height() + 1) + # Call PyQtGraph's internal height update mechanism + # This respects autoExpandTextSpace and updates min/max constraints + self._updateHeight() + # tell Qt our preferred size changed so layout recalculates + self.updateGeometry() + # force parent plot item to recalculate its layout + if self.pi and hasattr(self.pi, 'updateGeometry'): + self.pi.updateGeometry() def _indexes_to_timestrs( self, diff --git a/piker/ui/_window.py b/piker/ui/_window.py index 27db80b3..47777c30 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -77,14 +77,19 @@ class GlobalZoomEventFilter(QObject): key = event.key() mods = event.modifiers() - # Check for Ctrl+Shift modifier combination - has_ctrl_shift = ( - (mods & Qt.KeyboardModifier.ControlModifier) and - (mods & Qt.KeyboardModifier.ShiftModifier) - ) + # Mask out the KeypadModifier which Qt sometimes adds + mods = mods & ~Qt.KeyboardModifier.KeypadModifier - if has_ctrl_shift: - # Zoom in: Ctrl+Shift+Plus or Ctrl+Shift+Equal + # Check if we have Ctrl+Shift (both required) + has_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier) + has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier) + + # Only handle UI zoom if BOTH Ctrl and Shift are pressed + # For Plus key: user presses Cmd+Shift+Equal (which makes Plus) + # For Minus key: user presses Cmd+Shift+Minus + if has_ctrl and has_shift: + # Zoom in: Ctrl+Shift+Plus + # Note: Plus key usually comes as Key_Equal with Shift modifier if key in (Qt.Key.Key_Plus, Qt.Key.Key_Equal): self.main_window.zoom_in() return True # consume event @@ -99,7 +104,10 @@ class GlobalZoomEventFilter(QObject): self.main_window.reset_zoom() return True # consume event - # Pass through all other events + # Pass through if only Ctrl (no Shift) - this goes to chart zoom + # Pass through all other events too + return False + return False @@ -481,16 +489,25 @@ class MainWindow(QMainWindow): # 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() + if chart: + # update axes + self._update_chart_axes(chart) + + # update order pane + if 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(): + # update subplot axes + self._update_chart_axes(subplot_chart) + + # update subplot order pane if hasattr(subplot_chart, 'view'): subplot_view = subplot_chart.view if hasattr(subplot_view, 'order_mode') and subplot_view.order_mode: @@ -498,6 +515,35 @@ class MainWindow(QMainWindow): if hasattr(subplot_order_mode, 'pane') and subplot_order_mode.pane: subplot_order_mode.pane.update_fonts() + # resize all sidepanes to match main chart's sidepane width + # this ensures volume/subplot sidepanes match the main chart + if splits and hasattr(splits, 'resize_sidepanes'): + splits.resize_sidepanes() + + def _update_chart_axes(self, chart) -> None: + '''Update axis fonts and sizing for a chart.''' + from . import _style + + # update price axis (right side) + if hasattr(chart, 'pi') and chart.pi: + plot_item = chart.pi + # get all axes from plot item + for axis_name in ['left', 'right', 'bottom', 'top']: + axis = plot_item.getAxis(axis_name) + if axis and hasattr(axis, 'update_fonts'): + axis.update_fonts(_style._font) + + # force plot item to recalculate its entire layout + plot_item.updateGeometry() + + # force chart widget to update + if hasattr(chart, 'updateGeometry'): + chart.updateGeometry() + + # trigger a full scene update + if hasattr(chart, 'update'): + chart.update() + def _refresh_widget_fonts(self, widget: QWidget) -> None: ''' Recursively update font sizes in all child widgets. -- 2.34.1