diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index cb20127d..dceb9a0c 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -5,7 +5,7 @@ from aarecommon.models.models import ( SampleShortInfo, SampleShortInfoList, ) -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import QEvent, Qt, QTimer, Signal, Slot from PySide6.QtWidgets import ( QAbstractItemView, QButtonGroup, @@ -34,6 +34,10 @@ class FrozenColumnTableView(QTableView): FROZEN_WIDTH = 36 + # Emitted after this view's font changed (Ctrl+plus/minus zoom): the + # panel re-autosizes its columns, which were sized once at startup. + font_changed = Signal() + def __init__(self, parent=None): super().__init__(parent) self.frozen = QTableView(self) @@ -71,6 +75,12 @@ class FrozenColumnTableView(QTableView): self._update_frozen_geometry() def _update_frozen_geometry(self) -> None: + # The overlay header must be exactly as tall as the main one, or its + # rows sit above the main rows. Qt sizes a header to its tallest + # visible section, and the main header also sees the symbol columns + # ("⧂", "⌕"), whose fallback-font glyphs have a taller line box than + # "#" at some zoom steps (1px off at 125% on macOS fonts). + self.frozen.horizontalHeader().setFixedHeight(self.horizontalHeader().height()) self.frozen.setGeometry( self.frameWidth(), self.frameWidth(), @@ -82,6 +92,19 @@ class FrozenColumnTableView(QTableView): super().resizeEvent(event) self._update_frozen_geometry() + def changeEvent(self, event) -> None: + super().changeEvent(event) + if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange): + # Zoom/theme re-lay the main header; re-sync the overlay once the + # layout settled. Deferred with `self` as receiver context so a + # pending call dies with the view (no callback into a dead C++ + # object). Not an updateGeometries override or a header-signal + # slot: both get invoked during teardown and a Python callback on + # a half-destroyed view leaves a lost exception behind. + QTimer.singleShot(0, self, self._update_frozen_geometry) + if event.type() == QEvent.Type.FontChange: + self.font_changed.emit() + class QueueDropChip(QPushButton): """Filter chip that doubles as a drop target: dragging table rows onto it @@ -129,6 +152,11 @@ class TellSamplePanel(QFrame): used by the pop-out window so both panels operate on the same data, tints and filters with no syncing.""" super().__init__(parent) + # Before the table view exists: its font_changed slot reads this, and + # a FontChange can already arrive while the view is being parented + # into this panel (raising inside a C++-invoked slot leaves PySide + # with a lost exception and a later "returned NULL" SystemError). + self._columns_autosized = False if samples is None: samples = SampleShortInfoList(s=[]) @@ -215,6 +243,7 @@ class TellSamplePanel(QFrame): self.table_view = FrozenColumnTableView() self.table_view.setShowGrid(False) self.table_view.setAlternatingRowColors(True) + self.table_view.font_changed.connect(self._on_table_font_changed) grid_layout.addWidget(self.table_view, 2, 0, 1, 4) self.table_model = model if model is not None else UserSampleSpreadsheet(samples=samples.s) @@ -263,8 +292,7 @@ class TellSamplePanel(QFrame): # Columns at full content width (horizontal scroll instead of # squishing); done ONCE so later data refreshes don't fight manual - # column adjustments. - self._columns_autosized = False + # column adjustments (zoom re-runs it, see _on_table_font_changed). if self.table_model.rowCount() > 0: self._autosize_columns() @@ -279,6 +307,14 @@ class TellSamplePanel(QFrame): self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH) self._columns_autosized = True + @Slot() + def _on_table_font_changed(self) -> None: + # Zoom changed the glyph widths under columns sized once at startup + # (headers got truncated). Deferred: the view's own font is updated + # only after this signal. Receiver context: cancels if we die first. + if self._columns_autosized: + QTimer.singleShot(0, self, self._autosize_columns) + @Slot(SampleShortInfoList) def new_sample_list(self, samples: SampleShortInfoList): self.table_model.updateData(samples=samples.s) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index d2a64dbb..8df63336 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -613,6 +613,12 @@ FONT_FINE = "11px" # fine print, queue titles FONT_SCALE_LADDER = (1.0, 1.25, 1.5) _font_scale = 1.0 +# Control box height (buttons, combos, entry boxes): scaled with the ladder +# in _palette() like FONT_*. As raw px the 16px cap clipped descenders once +# the 14px body font became 18/21px at the 125/150% stops. +CONTROL_HEIGHT = "16px" +CONTROL_HEIGHT_LOOSE = "24px" # dark-theme button cap (room for icon buttons) + def font_scale() -> float: return _font_scale @@ -680,7 +686,7 @@ def _palette() -> dict[str, str]: # import FONT_* into local f-string QSS keep 1.0 — port them to the # app sheet if zoom must reach them. for k, v in mapping.items(): - if k.startswith("font_") and v.endswith("px"): + if k.startswith(("font_", "control_")) and v.endswith("px"): mapping[k] = f"{round(int(v[:-2]) * _font_scale)}px" return mapping @@ -780,8 +786,8 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: QPushButton, QToolButton, QComboBox { background-color: $button_bg; border: 1px solid $button_border; - min-height: 16px; - max-height: 16px; + min-height: $control_height; + max-height: $control_height; padding: 1px 8px; } @@ -799,8 +805,8 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: QLineEdit, QAbstractSpinBox { background-color: $input_bg; border: 1px solid $button_border; - min-height: 16px; - max-height: 16px; + min-height: $control_height; + max-height: $control_height; padding: 1px 6px; } @@ -1320,8 +1326,8 @@ def _sunset_stylesheet() -> str: QPushButton, QToolButton, QComboBox { background-color: $dark_elevated; border: 1px solid $dark_border_faint; - min-height: 16px; - max-height: 24px; + min-height: $control_height; + max-height: $control_height_loose; padding-top: 1px; padding-bottom: 1px; } @@ -1332,8 +1338,8 @@ def _sunset_stylesheet() -> str: QLineEdit, QAbstractSpinBox { background-color: $dark_input_bg; border: 1px solid $dark_border_faint; - min-height: 16px; - max-height: 16px; + min-height: $control_height; + max-height: $control_height; padding-top: 1px; padding-bottom: 1px; } diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 04705f8b..dc3b42b2 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -1277,6 +1277,12 @@ class SampleCameraImageLabel(QGraphicsView): label = f"{bar_um / 1000.0:g} mm" if bar_um >= 1000.0 else f"{bar_um:g} µm" return bar_um, label + def _mounted_hud_ink(self) -> tuple[str, str]: + """(text, shadow) for the mounted-sample HUD: black on a white halo in + the light themes, white on black in Sunset. The camera image behind + is arbitrary, so the pair follows the theme rather than the pixels.""" + return (WHITE, SHADOW) if self._dark_theme else (SHADOW, WHITE) + def _draw_mounted_sample(self, painter: QPainter): # Top-left HUD line (legend sits bottom-left, scale bar bottom-right): # which sample is on the gonio, readable without leaving the camera. @@ -1297,9 +1303,10 @@ class SampleCameraImageLabel(QGraphicsView): margin = 18 text = f"Currently mounted: {self._mounted_sample_name}" baseline = margin + fm.ascent() - painter.setPen(QPen(qcolor(SHADOW, 200))) + ink, halo = self._mounted_hud_ink() + painter.setPen(QPen(qcolor(halo, 200))) painter.drawText(QPointF(margin + 1, baseline + 1), text) - painter.setPen(QPen(qcolor(WHITE))) + painter.setPen(QPen(qcolor(ink))) painter.drawText(QPointF(margin, baseline), text) painter.restore() diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index bf28c75e..51ff331a 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -324,3 +324,13 @@ def test_mounted_sample_hud_follows_status(camera): # Unmount (sample gone from the status) clears the line again. camera.update_daq_status(status) assert camera._mounted_sample_name is None + + +def test_mounted_hud_ink_follows_theme(camera): + """Mounted-sample HUD: dark ink + white halo on light themes, inverted on Sunset.""" + from aare.gui.styles import SHADOW, THEME_SUNRISE, THEME_SUNSET, WHITE + + camera.set_theme(THEME_SUNRISE) + assert camera._mounted_hud_ink() == (SHADOW, WHITE) + camera.set_theme(THEME_SUNSET) + assert camera._mounted_hud_ink() == (WHITE, SHADOW) diff --git a/tests/unit/gui/test_styles.py b/tests/unit/gui/test_styles.py new file mode 100644 index 00000000..8916e62e --- /dev/null +++ b/tests/unit/gui/test_styles.py @@ -0,0 +1,20 @@ +"""App stylesheet knobs that must track the font zoom ladder.""" + +from aare.gui import styles + + +def test_control_height_scales_with_font_ladder(): + """Buttons/inputs pinned at 16px clipped descenders at 125/150%; the pin + must scale with the ladder in both sheets.""" + + try: + styles.set_font_scale(1.0) + for theme in (styles.THEME_SUNRISE, styles.THEME_SUNSET): + assert "max-height: 16px" in styles.build_app_stylesheet(theme), theme + styles.set_font_scale(1.5) + for theme in (styles.THEME_SUNRISE, styles.THEME_SUNSET): + sheet = styles.build_app_stylesheet(theme) + assert "max-height: 24px" in sheet, theme + assert "max-height: 16px" not in sheet, theme + finally: + styles.set_font_scale(1.0) diff --git a/tests/unit/gui/test_tell_sample_panel.py b/tests/unit/gui/test_tell_sample_panel.py index 2774afb0..0ccd940a 100644 --- a/tests/unit/gui/test_tell_sample_panel.py +++ b/tests/unit/gui/test_tell_sample_panel.py @@ -142,3 +142,45 @@ def test_selected_samples_follow_the_click(panel): assert [s.db_id for s in panel._selected_samples(0)] == [row_ids[0]] # Click outside the selection: only the clicked row is acted on. assert [s.db_id for s in panel._selected_samples(2)] == [row_ids[2]] + + +def test_font_zoom_keeps_frozen_column_aligned_and_reautosizes(panel, qtbot): + """Ctrl+plus zoom: the frozen "#" overlay header must stay exactly as + tall as the main header (else its rows sit 1px higher), and the columns + sized once at startup must re-autosize for the wider glyphs.""" + from PySide6.QtGui import QFont + from PySide6.QtWidgets import QApplication + + from aare.gui import styles + + app = QApplication.instance() + assert isinstance(app, QApplication) # font() lives on QApplication, not QCoreApplication + base = QFont(app.font()) + # Startup look first: under a styled ancestor (MainWindow) fonts reach + # the view through the QSS path; without any sheet Qt would not + # propagate the app font to a child that carries WA_StyleSheet. + panel.setStyleSheet(styles.build_app_stylesheet(styles.THEME_SUNRISE)) + panel.resize(600, 300) + panel.show() + qtbot.waitExposed(panel) + view = panel.table_view + width_before = view.columnWidth(1) + + def row0_top(v): + return v.viewport().mapTo(panel, v.viewport().rect().topLeft()).y() + v.rowViewportPosition( + 0 + ) + + try: + # Same three steps as MainWindow._apply_theme on a zoom change. + styles.set_font_scale(1.25) + big = QFont(base) + big.setPointSizeF(base.pointSizeF() * 1.25) + app.setFont(big) + panel.setStyleSheet(styles.build_app_stylesheet(styles.THEME_SUNRISE)) + qtbot.waitUntil(lambda: view.columnWidth(1) > width_before) + assert view.frozen.horizontalHeader().height() == view.horizontalHeader().height() + assert row0_top(view.frozen) == row0_top(view) + finally: + styles.set_font_scale(1.0) + app.setFont(base)