fix(gui): zoom-proof the Dewar table and control heights, theme the mounted HUD ink

Dewar "#" column sat 1px above the other rows at some zoom steps: the
frozen-column overlay sizes its own header from "#" alone while the main
header also sees the symbol columns, whose fallback-font glyphs have a
taller line box. The overlay header is now pinned to the main header
height on resize and (deferred, receiver-bound) on font/style change.

Zoom also left columns at their startup widths (headers truncated) and
clipped descenders in every button/combo/entry box: the 16px height pin
in both sheets was raw px while the body font grew to 18/21px. Columns
re-autosize on FontChange and the pin scales with the font ladder.

Camera "Currently mounted" HUD: black ink on a white halo in the light
themes, white on black in Sunset, instead of white-on-black everywhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 18:44:56 +02:00
committed by duan_j
co-authored by Claude Fable 5.1
parent 1ea607ba56
commit bf11528e86
6 changed files with 135 additions and 14 deletions
+39 -3
View File
@@ -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)
+15 -9
View File
@@ -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;
}
+9 -2
View File
@@ -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()
+10
View File
@@ -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)
+20
View File
@@ -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)
+42
View File
@@ -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)