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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user