From fd0edffdc98ff3c2bec7a53b76e7f2b7c29e53dc Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 10:30:21 +0200 Subject: [PATCH 01/12] WIP: resizable camera region, menu panel collapse, zoom ladder - center splitter: left controls | camera tabs | beamline column; drag resizes the camera width, View menu check actions collapse the fixed-width side panels (no on-screen buttons) - font zoom restricted to a 100/125/150 percent ladder; the fixed-width side boxes scale with it so zoomed text does not clip - camera tab widget min width clamped to 1/3 of its natural minimum - resize bars: short centered grip PNGs shared by QMainWindow separators and QSplitter handles, single style source in styles.py - window min width 1244 -> ~376: automation row and sample-list chip row stop propagating their natural minimums (Ignored-width hosts), portrait page fixed width removed (portrait mode pins the window width itself) Co-Authored-By: Claude Fable 5 --- .../gui/graphics/resize_grip_h_hint_dark.png | Bin 0 -> 139 bytes .../gui/graphics/resize_grip_h_hint_light.png | Bin 0 -> 141 bytes .../gui/graphics/resize_grip_h_idle_dark.png | Bin 0 -> 148 bytes .../gui/graphics/resize_grip_h_idle_light.png | Bin 0 -> 137 bytes .../gui/graphics/resize_grip_v_hint_dark.png | Bin 0 -> 145 bytes .../gui/graphics/resize_grip_v_hint_light.png | Bin 0 -> 155 bytes .../gui/graphics/resize_grip_v_idle_dark.png | Bin 0 -> 151 bytes .../gui/graphics/resize_grip_v_idle_light.png | Bin 0 -> 143 bytes src/aare/gui/main_window.py | 108 ++++++++++++++---- src/aare/gui/panels/tell_sample_panel.py | 11 +- src/aare/gui/styles.py | 107 ++++++++--------- tests/unit/gui/test_main_window.py | 35 +++++- 12 files changed, 173 insertions(+), 88 deletions(-) create mode 100644 src/aare/gui/graphics/resize_grip_h_hint_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_h_hint_light.png create mode 100644 src/aare/gui/graphics/resize_grip_h_idle_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_h_idle_light.png create mode 100644 src/aare/gui/graphics/resize_grip_v_hint_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_v_hint_light.png create mode 100644 src/aare/gui/graphics/resize_grip_v_idle_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_v_idle_light.png diff --git a/src/aare/gui/graphics/resize_grip_h_hint_dark.png b/src/aare/gui/graphics/resize_grip_h_hint_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6e9bdd7921a5fde40f83a35b24155adf8f39a71c GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^PC(4e!3HGN18yn;Db50q$YKTtz9S&aI8~cZ8YpP% z>EaloG4bgIM_vX69+rc9_g!pRDRqE>Q%LaQf5pZ8ZU@s!&-*N4<@pzKLwmxcdUXb# jB|L9#j+S3j3^P6EaloG4bgILs13=o+B4x(oHrlW)d`QV7ycIY{J0<;SEaloG4bgIL(u~Y0xTCJ*Exo%w3uysq^LV7>G7=UZXT|EO_huP@6DSk-+itz<mdKI;Vst0Q8$I)&Kwi literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_h_idle_light.png b/src/aare/gui/graphics/resize_grip_h_idle_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ed9bffc9f4e53765105418b3774a10fcbbc4d4aa GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^PC(4e!3HGN18yn;Db50q$YKTtz9S&aI8~cZ8YpP( z>EaloG4bgIL(u~YJPrrBjrZ*ece^fefFW=5aw7=~*8hJi?&-I-zVutVR&hzS`T>!m h4C&Hc&lX(z#$TJoRnsWwz7A*%gQu&X%Q~loCIAMVDZ&5% literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_hint_dark.png b/src/aare/gui/graphics/resize_grip_v_hint_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7ca63bff592a0585086412e7e1e13e5a0b19accc GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-O z>EaloG4bv3jl2gGcvvrP&Q4Qu-pIi1!X|-2DINd- literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_hint_light.png b/src/aare/gui/graphics/resize_grip_v_hint_light.png new file mode 100644 index 0000000000000000000000000000000000000000..02c3badbc3f63e17d4827d840c2a200dc355e2c1 GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-N z>EaloG4btjMbQQWiPnqn|K70VdaxmbWu8b&M5AbgB3EES75`DomK&wv$177ibAp$r z-pxx0+rX~Im=N^A*vROUs|kY(cRAOG9l>#4uLK;9RJ?l&G?>BD)z4*}Q$iB}KVvXh literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_idle_dark.png b/src/aare/gui/graphics/resize_grip_v_idle_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3f125af8f6cfb2f6606a191ab233b4ea86378d54 GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-L z>EaloG4bv3g`y1x0xk#lb3Qt?s3qaDnC+1uA=QNBce-qjf|BOD&hMRQw_=ys(%r5a wce`B~B3vG(r=~vbKEpC$=Fw2*&pWdABw35q&Gwtz1~inx)78&qol`;+0L3pbg8%>k literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_idle_light.png b/src/aare/gui/graphics/resize_grip_v_idle_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ddef85aa1c9769af79d40052bf042f49f4d7ca65 GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-K z>EaloG4btjL%{|E0fvKrc~WaGDmR<4-D9vaoM0!C$ma57)$JSon>o)G1)t?YQ2rJP_juG>pO1)z4*}Q$iB}+W0GB literal 0 HcmV?d00001 diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 46423584..260e9fec 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -55,6 +55,7 @@ from PySide6.QtWidgets import ( QScrollArea, QSizePolicy, QSlider, + QSplitter, QStackedWidget, QTabWidget, QToolBar, @@ -101,7 +102,7 @@ from aare.gui.styles import ( APP_BACKGROUND, DARK_TEXT, DOCK_CONTENT_LEFT_PAD, - FONT_SCALE_STEP, + FONT_SCALE_LADDER, SEPARATOR_HINT_DELAY_MS, THEME_BLUEBIRD, THEME_FADE_MS, @@ -460,20 +461,32 @@ class MainWindow(QMainWindow): self.left_column_layout.addWidget(self.left_column_tabs) self.left_column_layout.addStretch() - top_widget_layout.addWidget(self.collection_controls_scroll) + # Splitter instead of a plain HBox: the side columns kept their fixed + # widths while the camera region could only grow vertically; dragging + # the handles now resizes the camera width too. Collapsing to zero is + # off — the View-menu toggles are the collapse feature. + # No local handle styling: the app stylesheet's QSplitter::handle rule + # already paints the same line as the QMainWindow::separator between + # docks, so both resize bars share the one definition in styles.py. + self.center_splitter = QSplitter(Qt.Orientation.Horizontal, top_widget) + self.center_splitter.setChildrenCollapsible(False) + top_widget_layout.addWidget(self.center_splitter) + + self.center_splitter.addWidget(self.collection_controls_scroll) self.collection_controls_scroll.setWidget(self.left_column) + # AsNeeded (was AlwaysOff): the splitter can now make the viewport + # narrower than the fixed-width column, and clipped controls must + # stay reachable. self.collection_controls_scroll.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff + Qt.ScrollBarPolicy.ScrollBarAsNeeded ) self.collection_controls_scroll.setWidgetResizable(True) # No frame: its border drew a line above the tab bar (Dewar tabs have # none). Freeze the inner column width: widgetResizable makes it track # the viewport, so the scrollbar appearing used to re-flow every - # banner. Fixed width + a permanent 10px scrollbar gutter means the - # scrollbar pops into spare space and nothing moves. - self.collection_controls_scroll.setFrameShape(QFrame.Shape.NoFrame) - self.left_column.setFixedWidth(self.data_collection.set_width) - self.collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10) + # banner. Fixed inner width + a 10px scrollbar gutter means the + # scrollbar pops into spare space and nothing moves; the widths are + # zoom-dependent and live in _apply_zoom_widths. self.video_tab = QTabWidget(parent=top_widget) @@ -514,6 +527,14 @@ class MainWindow(QMainWindow): self.video_tab.addTab(self.beamline_view_panel, "Beamline view") self.video_tab.addTab(self.beamline_combined_panel, "Beamline combined view") + # The camera region's natural minimum (~284px) came from the widest + # page's controls row (title + refresh button) and the tab labels — + # not from the video views, which rescale freely like they do in + # height. Clamp to 1/3 so the splitter can shrink all four camera + # views that far; past the natural width the tab bar scrolls and the + # controls rows clip. + self.video_tab.setMinimumWidth(self.video_tab.minimumSizeHint().width() // 3) + # if cfg_get("gui.cameras.secondary_beamline_camera_url", None): # self.secondary_beamline_view = VideoGraphicsView() # self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view, @@ -548,28 +569,32 @@ class MainWindow(QMainWindow): portrait_page_layout = QHBoxLayout(self.portrait_mode_page) portrait_page_layout.setContentsMargins(0, 0, 0, 0) portrait_page_layout.setSpacing(0) - self.portrait_mode_page.setFixedWidth(self.portrait_mode_panel.PORTRAIT_WIDTH + 24) + # No fixed page width: an explicit minimum beats the content_stack's + # Ignored-when-hidden policy and pinned the whole window >=444px in + # NORMAL mode. enter_portrait_mode pins the window width itself. portrait_page_layout.addWidget( self.portrait_mode_panel, alignment=Qt.AlignmentFlag.AlignHCenter ) - top_widget_layout.addWidget(self.video_tab) + self.center_splitter.addWidget(self.video_tab) self._start_axis_camera_threads() self.beamline_controls_scroll = NoWheelScrollArea(top_widget) self.beamline = BeamlineControls(self.beamline_controls_scroll) - top_widget_layout.addWidget(self.beamline_controls_scroll) + self.center_splitter.addWidget(self.beamline_controls_scroll) self.beamline_controls_scroll.setWidget(self.beamline) # Resizable so the column shrinks when panels collapse; without it the # scrollbar keeps dead range below the collapsed panels. self.beamline_controls_scroll.setWidgetResizable(True) self.beamline_controls_scroll.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff + Qt.ScrollBarPolicy.ScrollBarAsNeeded ) - # Same gutter math as the left column: 10px scrollbar + 2px frame, so - # the fixed-width controls are never clipped when the scrollbar shows. - self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 12) + # Only the camera region absorbs a window resize; the side columns + # keep whatever width the user dragged. + self.center_splitter.setStretchFactor(1, 1) + # Gutter math per column (10px scrollbar + frame) lives here too. + self._apply_zoom_widths() self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[])) self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[])) @@ -624,7 +649,14 @@ class MainWindow(QMainWindow): # and the buttons balloon; five buttons plus two long checkboxes need # them at their natural size. automation_row.addStretch(1) - dewar_layout.addLayout(automation_row) + # Host widget with Ignored horizontal policy: the row's ~1000px + # natural minimum used to propagate dock -> window and pin the whole + # window wider than small screens (unshrinkable). Now the row clips + # from the right instead. + automation_row_host = QWidget(dewar_tab) + automation_row_host.setLayout(automation_row) + automation_row_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + dewar_layout.addWidget(automation_row_host) # "Remove selected" now unqueues the dewar-table selection — the # queue's own table is no longer displayed. @@ -2059,15 +2091,32 @@ class MainWindow(QMainWindow): settings.setValue("appearance/font_scale", font_scale()) def _change_font_zoom(self, direction: int) -> None: - """Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: step the FONT_* - ladder scale and rebuild the app stylesheet (same repolish path as a - theme switch, so the whole UI rescales in one pass).""" - # round: repeated 0.1 float steps otherwise drift (1.2000000000000002) - set_font_scale( - 1.0 if direction == 0 else round(font_scale() + direction * FONT_SCALE_STEP, 2) - ) + """Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: walk the + 100/125/150% ladder and rebuild the app stylesheet (same repolish + path as a theme switch, so the whole UI rescales in one pass).""" + # index() is safe: set_font_scale snaps every value onto the ladder. + i = FONT_SCALE_LADDER.index(font_scale()) + i = 0 if direction == 0 else max(0, min(len(FONT_SCALE_LADDER) - 1, i + direction)) + set_font_scale(FONT_SCALE_LADDER[i]) self._save_theme_settings() self._apply_theme() + self._apply_zoom_widths() + + def _apply_zoom_widths(self) -> None: + # The side columns are fixed-width designs; when the ladder scales the + # text, the boxes must widen with it or the zoomed text clips. Runs + # once at construction and again on every zoom change. + s = font_scale() + left = round(self.data_collection.set_width * s) + self.data_collection.setFixedWidth(left) + self.left_column.setFixedWidth(left) + self.collection_controls_scroll.setMaximumWidth(left + 10) + right = round(self.beamline.set_width * s) + self.beamline.setFixedWidth(right) + self.beamline_controls_scroll.setMaximumWidth(right + 12) + # Re-seat the splitter: open both columns to their (new) full width, + # the camera region takes the rest. + self.center_splitter.setSizes([left + 10, 10_000, right + 12]) @Slot() def use_legacy_theme(self) -> None: @@ -2157,6 +2206,19 @@ class MainWindow(QMainWindow): reset_text_action.triggered.connect(lambda: self._change_font_zoom(0)) view_menu.addAction(reset_text_action) view_menu.addSeparator() + + # Side-panel collapse lives here, not as on-screen buttons. + self._show_left_panel_action = QAction("Show Left Panel", self) + self._show_left_panel_action.setCheckable(True) + self._show_left_panel_action.setChecked(True) + self._show_left_panel_action.toggled.connect(self.collection_controls_scroll.setVisible) + view_menu.addAction(self._show_left_panel_action) + self._show_right_panel_action = QAction("Show Right Panel", self) + self._show_right_panel_action.setCheckable(True) + self._show_right_panel_action.setChecked(True) + self._show_right_panel_action.toggled.connect(self.beamline_controls_scroll.setVisible) + view_menu.addAction(self._show_right_panel_action) + view_menu.addSeparator() view_menu.addAction(self._portrait_mode_action) view_menu.addAction(self._enter_automation_view_action) view_menu.addSeparator() diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 1fcfcbba..cb20127d 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -15,7 +15,9 @@ from PySide6.QtWidgets import ( QHeaderView, QMenu, QPushButton, + QSizePolicy, QTableView, + QWidget, ) from aare.gui.constants import LOGGER_NAME @@ -196,7 +198,14 @@ class TellSamplePanel(QFrame): self.status_chips.addButton(chip) chip_row.addWidget(chip) chip_row.addStretch() - grid_layout.addLayout(chip_row, 1, 0, 1, 4) + # Ignored-width host (same trick as the automation row in + # main_window): the five chips' ~380px natural minimum otherwise + # propagates dock -> window and blocks shrinking the window on small + # screens. The row clips from the right instead. + chip_host = QWidget(self) + chip_host.setLayout(chip_row) + chip_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + grid_layout.addWidget(chip_host, 1, 0, 1, 4) self.status_chips.buttonClicked.connect( lambda chip: self.table_model.set_status_filter(chip.property("status_key")) ) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 719a0d2e..3110354c 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -82,6 +82,20 @@ DARK_SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_dark.png").as_posix() # Check marks (PRIMARY blue / dusk gold at generation time): CHECK_MARK = (_GRAPHICS_DIR / "check_mark_light.png").as_posix() DARK_CHECK_MARK = (_GRAPHICS_DIR / "check_mark_dark.png").as_posix() +# Resize-bar grips: a short (66px) centered bar instead of a full-length +# line — the full line read as a wall between panels. QSS can't paint a +# fixed-length centered segment, so PNGs like the arrows above; used by BOTH +# QMainWindow::separator and QSplitter::handle, so this is the one place. +# Colors baked in (SEPARATOR_IDLE / SEPARATOR_HINT / DARK_SEPARATOR_IDLE / +# DARK_ACCENT) — regenerate if those knobs change. +GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_light.png").as_posix() +GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_light.png").as_posix() +GRIP_HINT_V = (_GRAPHICS_DIR / "resize_grip_v_hint_light.png").as_posix() +GRIP_HINT_H = (_GRAPHICS_DIR / "resize_grip_h_hint_light.png").as_posix() +DARK_GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_dark.png").as_posix() +DARK_GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_dark.png").as_posix() +DARK_GRIP_HINT_V = (_GRAPHICS_DIR / "resize_grip_v_hint_dark.png").as_posix() +DARK_GRIP_HINT_H = (_GRAPHICS_DIR / "resize_grip_h_hint_dark.png").as_posix() # Borders (all can be "transparent" to hide the line): BORDER = "transparent" # main dividers, e.g. the beamline state bar top line @@ -124,32 +138,10 @@ SEPARATOR_HINT = "#3f4a5f" SEPARATOR_IDLE = "#47536a" SEPARATOR_HINT_DELAY_MS = 66 # int, used in code, not QSS -# The gutter keeps this full width for the mouse; only a 2px line + 1px -# shadow is painted inside it (a solid 5px bar read too heavy). 5px is a +# The gutter keeps this full width for the mouse; only the short GRIP_* +# bar is painted inside it (a full-length line read as a wall). 5px is a # first guess — adjust here if the grab target feels off. SEPARATOR_REGION = "5px" -SEPARATOR_SHADOW = "rgba(31, 41, 59, 25%)" - - -def _separator_gradient(line: str, shadow: str, axis: str) -> str: - """Paint of a resize gutter: transparent 1px, shadow 1px, line 2px, - shadow 1px — hard gradient stops at 1/5 steps of SEPARATOR_REGION. - axis "x" runs the gradient left->right (an upright line), "y" top->down - (a lying line).""" - x2, y2 = ("1", "0") if axis == "x" else ("0", "1") - return ( - f"qlineargradient(x1:0, y1:0, x2:{x2}, y2:{y2}," - f" stop:0 transparent, stop:0.19 transparent," - f" stop:0.2 {shadow}, stop:0.39 {shadow}," - f" stop:0.4 {line}, stop:0.79 {line}," - f" stop:0.8 {shadow}, stop:1 {shadow})" - ) - - -SEP_IDLE_X = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "x") -SEP_IDLE_Y = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "y") -SEP_HINT_X = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "x") -SEP_HINT_Y = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "y") # Theme-switch screenshot cross-fade duration (int ms, used in code). THEME_FADE_MS = 250 @@ -257,11 +249,6 @@ DARK_ACCENT = "#e0913f" # gold # DARK_ACCENT @70%: 10% measured 1.16:1 on DARK_BG (invisible); 70% blends to # >=3.74:1 — same WCAG 1.4.11 floor as the light-theme separators. DARK_SEPARATOR_IDLE = "rgba(224, 145, 63, 70%)" -DARK_SEPARATOR_SHADOW = "rgba(0, 0, 0, 45%)" -DARK_SEP_IDLE_X = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "x") -DARK_SEP_IDLE_Y = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "y") -DARK_SEP_HINT_X = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "x") -DARK_SEP_HINT_Y = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "y") DARK_ACCENT_HOVER = "#eaa253" # accent2 — brighter gold DARK_ACCENT_FILL = "#89b4fa" # action blue DARK_ACCENT_FILL_HOVER = "#9ec2fb" # +10% white, derived (site has no step) @@ -607,9 +594,10 @@ FONT_FINE = "11px" # fine print, queue titles # Whole-app text scale applied to the FONT_* ladder when the QSS is built, so # one re-apply of the stylesheet rescales every rule. Floats, so _palette()'s # str filter never picks them up as colors. -FONT_SCALE_MIN = 0.8 -FONT_SCALE_MAX = 1.6 -FONT_SCALE_STEP = 0.1 +# Discrete ladder: 100% / 125% / 150% only. Free ±10% steps produced +# in-between sizes where the fixed-width side boxes clipped their text; +# three vetted stops keep fonts and box widths in lockstep. +FONT_SCALE_LADDER = (1.0, 1.25, 1.5) _font_scale = 1.0 @@ -618,10 +606,10 @@ def font_scale() -> float: def set_font_scale(scale: float) -> None: - # Clamped: below 0.8 the fine-print sizes fall under 9px (unreadable), - # above 1.6 the fixed-height banner rows start clipping their text. + # Snap to the ladder so old saved settings (e.g. 1.2 from the former + # free-step zoom) land on the nearest vetted stop. global _font_scale - _font_scale = max(FONT_SCALE_MIN, min(FONT_SCALE_MAX, scale)) + _font_scale = min(FONT_SCALE_LADDER, key=lambda s: abs(s - scale)) # -- Hover tooltips (the QToolTip popup; styled borderless) ----------------- @@ -1169,20 +1157,21 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: } /* Resize gutters: the mouse keeps the full $separator_region, but only - a 2px line + 1px shadow paints (gradients in _separator_gradient). - An upright line needs the x-gradient: that is a :vertical main-window - separator but a :horizontal splitter handle (handle orientation - follows the splitter, not the bar). */ + the short centered GRIP_* bar paints (image: never stretches, it + centers). An upright grip is the _v file: that is a :vertical + main-window separator but a :horizontal splitter handle (handle + orientation follows the splitter, not the bar). */ QMainWindow::separator { width: $separator_region; height: $separator_region; background: transparent; } + QSplitter::handle { background: transparent; } QMainWindow::separator:vertical, QSplitter::handle:horizontal { - background: $sep_idle_x; + image: url($grip_idle_v); } QMainWindow::separator:horizontal, QSplitter::handle:vertical { - background: $sep_idle_y; + image: url($grip_idle_h); } QSplitter::handle:horizontal { width: $separator_region; } QSplitter::handle:vertical { height: $separator_region; } @@ -1191,19 +1180,20 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: MainWindow.event() after a 1s hover rest or on press; :hover limits the fill to the exact separator being dragged. */ QMainWindow[separatorHint="true"]::separator:vertical:hover { - background: $sep_hint_x; + image: url($grip_hint_v); } QMainWindow[separatorHint="true"]::separator:horizontal:hover { - background: $sep_hint_y; + image: url($grip_hint_h); } - /* Splitter handles (prediction metrics) are plain child widgets the - property gate above doesn't reach — immediate hover/press hint. */ + /* Splitter handles (camera region, prediction metrics) are plain child + widgets the property gate above doesn't reach — immediate hover/press + hint. */ QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { - background: $sep_hint_x; + image: url($grip_hint_v); } QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { - background: $sep_hint_y; + image: url($grip_hint_h); } QFrame#beamlineControls, @@ -1846,33 +1836,34 @@ def _sunset_stylesheet() -> str: background: transparent; } - /* Idle + hover resize lines, dark flavor — see the light-theme note. */ + /* Idle + hover resize grips, dark flavor — see the light-theme note. */ QMainWindow::separator { width: $separator_region; height: $separator_region; background: transparent; } + QSplitter::handle { background: transparent; } QMainWindow::separator:vertical, QSplitter::handle:horizontal { - background: $dark_sep_idle_x; + image: url($dark_grip_idle_v); } QMainWindow::separator:horizontal, QSplitter::handle:vertical { - background: $dark_sep_idle_y; + image: url($dark_grip_idle_h); } QSplitter::handle:horizontal { width: $separator_region; } QSplitter::handle:vertical { height: $separator_region; } QMainWindow[separatorHint="true"]::separator:vertical:hover { - background: $dark_sep_hint_x; + image: url($dark_grip_hint_v); } QMainWindow[separatorHint="true"]::separator:horizontal:hover { - background: $dark_sep_hint_y; + image: url($dark_grip_hint_h); } QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { - background: $dark_sep_hint_x; + image: url($dark_grip_hint_v); } QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { - background: $dark_sep_hint_y; + image: url($dark_grip_hint_h); } /* Plain scroll containers stay frameless. */ @@ -2047,12 +2038,12 @@ if __name__ == "__main__": assert APP_BACKGROUND not in build_app_stylesheet(THEME_BLUEBIRD) # Font zoom: the FONT_* ladder must follow the scale, and the scale must # clamp to its documented bounds. - set_font_scale(1.3) - assert "font-size: 21px" in build_app_stylesheet(THEME_SUNRISE) # title 16->21 + set_font_scale(1.3) # snaps to the 1.25 ladder stop + assert "font-size: 20px" in build_app_stylesheet(THEME_SUNRISE) # title 16->20 set_font_scale(99.0) - assert font_scale() == FONT_SCALE_MAX + assert font_scale() == FONT_SCALE_LADDER[-1] set_font_scale(0.0) - assert font_scale() == FONT_SCALE_MIN + assert font_scale() == FONT_SCALE_LADDER[0] set_font_scale(1.0) assert "font-size: 16px" in build_app_stylesheet(THEME_SUNRISE) # This line was added by Claude. But I would do the same. So all gude. diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 380424cc..da5e175e 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -72,6 +72,21 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): win.data_collection._emit_change_energy() assert sent and abs(sent[0] - 12400.0) < 1e-6 + # Side panels collapse from the View menu (no on-screen buttons), and + # the camera region sits in a splitter so its width is drag-resizable. + assert win.center_splitter.widget(1) is win.video_tab + # The clamp lets the splitter shrink the camera region well below the + # natural minimum the tab labels + controls rows would demand. + assert 0 < win.video_tab.minimumWidth() < win.video_tab.minimumSizeHint().width() + win._show_left_panel_action.trigger() + assert win.collection_controls_scroll.isHidden() + win._show_left_panel_action.trigger() + assert not win.collection_controls_scroll.isHidden() + win._show_right_panel_action.trigger() + assert win.beamline_controls_scroll.isHidden() + win._show_right_panel_action.trigger() + assert not win.beamline_controls_scroll.isHidden() + # Motion watch: only the robot station switches to the combined # beamline view. Moving no longer does (users kept losing the sample # camera on short gonio moves), and busy alone never does — Sample @@ -525,20 +540,28 @@ def test_font_zoom_steps_clamps_and_resets(qtbot, mock_ui_state): try: base_pt = win._default_app_font.pointSizeF() win._change_font_zoom(1) - assert styles.font_scale() == pytest.approx(1.1) + assert styles.font_scale() == pytest.approx(1.25) # Part 2 of the zoom: the app default font scales with the ladder. app = QApplication.instance() assert isinstance(app, QApplication) # narrow from QCoreApplication|None - assert app.font().pointSizeF() == pytest.approx(base_pt * 1.1) - for _ in range(20): + assert app.font().pointSizeF() == pytest.approx(base_pt * 1.25) + # Part 3: the fixed-width side boxes follow the ladder, else the + # zoomed text clips inside them. + assert win.data_collection.minimumWidth() == round(win.data_collection.set_width * 1.25) + assert win.collection_controls_scroll.maximumWidth() == ( + round(win.data_collection.set_width * 1.25) + 10 + ) + assert win.beamline.minimumWidth() == round(win.beamline.set_width * 1.25) + for _ in range(5): win._change_font_zoom(1) - assert styles.font_scale() == styles.FONT_SCALE_MAX + assert styles.font_scale() == styles.FONT_SCALE_LADDER[-1] win._change_font_zoom(0) assert styles.font_scale() == 1.0 assert app.font().pointSizeF() == pytest.approx(base_pt) - for _ in range(20): + assert win.data_collection.minimumWidth() == win.data_collection.set_width + for _ in range(5): win._change_font_zoom(-1) - assert styles.font_scale() == styles.FONT_SCALE_MIN + assert styles.font_scale() == styles.FONT_SCALE_LADDER[0] # floor is 100% finally: styles.set_font_scale(1.0) if saved is None: -- 2.54.0 From 750559fadedbdbefa09b7e8d58fbbf1475c706df Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 10:46:09 +0200 Subject: [PATCH 02/12] fix: full-length hint line on resize-bar hover/drag, short grip idle User feedback: idle bars stay short centered grips, but hovering or grabbing a resize bar brings back the full-length 2px line + shadow gradient so the active bar reads over its whole run. The now-unused hint grip PNGs are removed. Co-Authored-By: Claude Fable 5 --- .../gui/graphics/resize_grip_h_hint_dark.png | Bin 139 -> 0 bytes .../gui/graphics/resize_grip_h_hint_light.png | Bin 141 -> 0 bytes .../gui/graphics/resize_grip_v_hint_dark.png | Bin 145 -> 0 bytes .../gui/graphics/resize_grip_v_hint_light.png | Bin 155 -> 0 bytes src/aare/gui/styles.py | 72 +++++++++++++----- 5 files changed, 51 insertions(+), 21 deletions(-) delete mode 100644 src/aare/gui/graphics/resize_grip_h_hint_dark.png delete mode 100644 src/aare/gui/graphics/resize_grip_h_hint_light.png delete mode 100644 src/aare/gui/graphics/resize_grip_v_hint_dark.png delete mode 100644 src/aare/gui/graphics/resize_grip_v_hint_light.png diff --git a/src/aare/gui/graphics/resize_grip_h_hint_dark.png b/src/aare/gui/graphics/resize_grip_h_hint_dark.png deleted file mode 100644 index 6e9bdd7921a5fde40f83a35b24155adf8f39a71c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^PC(4e!3HGN18yn;Db50q$YKTtz9S&aI8~cZ8YpP% z>EaloG4bgIM_vX69+rc9_g!pRDRqE>Q%LaQf5pZ8ZU@s!&-*N4<@pzKLwmxcdUXb# jB|L9#j+S3j3^P6EaloG4bgILs13=o+B4x(oHrlW)d`QV7ycIY{J0<;SEx6FDb50q$YKTtz9S&aI8~cZ8Yt-O z>EaloG4bv3jl2gGcvvrP&Q4Qu-pIi1!X|-2DINd- diff --git a/src/aare/gui/graphics/resize_grip_v_hint_light.png b/src/aare/gui/graphics/resize_grip_v_hint_light.png deleted file mode 100644 index 02c3badbc3f63e17d4827d840c2a200dc355e2c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-N z>EaloG4btjMbQQWiPnqn|K70VdaxmbWu8b&M5AbgB3EES75`DomK&wv$177ibAp$r z-pxx0+rX~Im=N^A*vROUs|kY(cRAOG9l>#4uLK;9RJ?l&G?>BD)z4*}Q$iB}KVvXh diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 3110354c..1f05bd8c 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -82,20 +82,17 @@ DARK_SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_dark.png").as_posix() # Check marks (PRIMARY blue / dusk gold at generation time): CHECK_MARK = (_GRAPHICS_DIR / "check_mark_light.png").as_posix() DARK_CHECK_MARK = (_GRAPHICS_DIR / "check_mark_dark.png").as_posix() -# Resize-bar grips: a short (66px) centered bar instead of a full-length -# line — the full line read as a wall between panels. QSS can't paint a -# fixed-length centered segment, so PNGs like the arrows above; used by BOTH -# QMainWindow::separator and QSplitter::handle, so this is the one place. -# Colors baked in (SEPARATOR_IDLE / SEPARATOR_HINT / DARK_SEPARATOR_IDLE / -# DARK_ACCENT) — regenerate if those knobs change. +# Resize-bar grips: IDLE paints a short (66px) centered bar instead of a +# full-length line — the full idle line read as a wall between panels; the +# hover/drag hint keeps the full-length gradient (see _separator_gradient). +# QSS can't paint a fixed-length centered segment, so PNGs like the arrows +# above; used by BOTH QMainWindow::separator and QSplitter::handle, so this +# is the one place. Colors baked in (SEPARATOR_IDLE / DARK_SEPARATOR_IDLE) +# — regenerate if those knobs change. GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_light.png").as_posix() GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_light.png").as_posix() -GRIP_HINT_V = (_GRAPHICS_DIR / "resize_grip_v_hint_light.png").as_posix() -GRIP_HINT_H = (_GRAPHICS_DIR / "resize_grip_h_hint_light.png").as_posix() DARK_GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_dark.png").as_posix() DARK_GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_dark.png").as_posix() -DARK_GRIP_HINT_V = (_GRAPHICS_DIR / "resize_grip_v_hint_dark.png").as_posix() -DARK_GRIP_HINT_H = (_GRAPHICS_DIR / "resize_grip_h_hint_dark.png").as_posix() # Borders (all can be "transparent" to hide the line): BORDER = "transparent" # main dividers, e.g. the beamline state bar top line @@ -138,10 +135,32 @@ SEPARATOR_HINT = "#3f4a5f" SEPARATOR_IDLE = "#47536a" SEPARATOR_HINT_DELAY_MS = 66 # int, used in code, not QSS -# The gutter keeps this full width for the mouse; only the short GRIP_* -# bar is painted inside it (a full-length line read as a wall). 5px is a -# first guess — adjust here if the grab target feels off. +# The gutter keeps this full width for the mouse; idle paints only the +# short GRIP_* bar (a full-length idle line read as a wall), hover/drag +# brings back the full-length hint gradient so the grabbed bar reads over +# its whole run. 5px is a first guess — adjust here if the grab target +# feels off. SEPARATOR_REGION = "5px" +SEPARATOR_SHADOW = "rgba(31, 41, 59, 25%)" + + +def _separator_gradient(line: str, shadow: str, axis: str) -> str: + """Full-length hover/drag hint of a resize gutter: transparent 1px, + shadow 1px, line 2px, shadow 1px — hard gradient stops at 1/5 steps of + SEPARATOR_REGION. axis "x" runs the gradient left->right (an upright + line), "y" top->down (a lying line).""" + x2, y2 = ("1", "0") if axis == "x" else ("0", "1") + return ( + f"qlineargradient(x1:0, y1:0, x2:{x2}, y2:{y2}," + f" stop:0 transparent, stop:0.19 transparent," + f" stop:0.2 {shadow}, stop:0.39 {shadow}," + f" stop:0.4 {line}, stop:0.79 {line}," + f" stop:0.8 {shadow}, stop:1 {shadow})" + ) + + +SEP_HINT_X = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "x") +SEP_HINT_Y = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "y") # Theme-switch screenshot cross-fade duration (int ms, used in code). THEME_FADE_MS = 250 @@ -249,6 +268,9 @@ DARK_ACCENT = "#e0913f" # gold # DARK_ACCENT @70%: 10% measured 1.16:1 on DARK_BG (invisible); 70% blends to # >=3.74:1 — same WCAG 1.4.11 floor as the light-theme separators. DARK_SEPARATOR_IDLE = "rgba(224, 145, 63, 70%)" +DARK_SEPARATOR_SHADOW = "rgba(0, 0, 0, 45%)" +DARK_SEP_HINT_X = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "x") +DARK_SEP_HINT_Y = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "y") DARK_ACCENT_HOVER = "#eaa253" # accent2 — brighter gold DARK_ACCENT_FILL = "#89b4fa" # action blue DARK_ACCENT_FILL_HOVER = "#9ec2fb" # +10% white, derived (site has no step) @@ -1180,20 +1202,24 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: MainWindow.event() after a 1s hover rest or on press; :hover limits the fill to the exact separator being dragged. */ QMainWindow[separatorHint="true"]::separator:vertical:hover { - image: url($grip_hint_v); + image: none; + background: $sep_hint_x; } QMainWindow[separatorHint="true"]::separator:horizontal:hover { - image: url($grip_hint_h); + image: none; + background: $sep_hint_y; } /* Splitter handles (camera region, prediction metrics) are plain child widgets the property gate above doesn't reach — immediate hover/press hint. */ QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { - image: url($grip_hint_v); + image: none; + background: $sep_hint_x; } QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { - image: url($grip_hint_h); + image: none; + background: $sep_hint_y; } QFrame#beamlineControls, @@ -1853,17 +1879,21 @@ def _sunset_stylesheet() -> str: QSplitter::handle:vertical { height: $separator_region; } QMainWindow[separatorHint="true"]::separator:vertical:hover { - image: url($dark_grip_hint_v); + image: none; + background: $dark_sep_hint_x; } QMainWindow[separatorHint="true"]::separator:horizontal:hover { - image: url($dark_grip_hint_h); + image: none; + background: $dark_sep_hint_y; } QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { - image: url($dark_grip_hint_v); + image: none; + background: $dark_sep_hint_x; } QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { - image: url($dark_grip_hint_h); + image: none; + background: $dark_sep_hint_y; } /* Plain scroll containers stay frameless. */ -- 2.54.0 From 809959df33fbf4f023b42347d86f73a7a03bcdae Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 11:12:34 +0200 Subject: [PATCH 03/12] feat!: remove Playlist Mode and Automation View, unpin window minimum Why the width shrink only worked after a Playlist Mode round-trip: Qt auto-pins a top-level window's minimum size to its layout minimum, and returning from Playlist Mode hid the Information dock (and every side dock), which happened to drop that layout minimum. The main view now gets an explicit small minimum (320x240) which disables the auto-pin outright, so the window shrinks directly and the dock contents clip or scroll instead. The Playlist Mode (Ctrl+6) and Automation View (Ctrl+5) prototypes are deleted per user decision: panels, pages, the content stack (the standard page is the only page left), menu actions, camera fan-out to their view copies, portrait alert interceptors (now plain runtime notification routers), and their styles (portraitRoot, compact* cards, COMPACT_* and FONT_HERO knobs). Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 428 +--------- .../gui/panels/compact_automation_panel.py | 227 ----- src/aare/gui/panels/portrait_mode.py | 805 ------------------ src/aare/gui/styles.py | 254 +----- src/aare/gui/widgets/alert_banner.py | 2 +- src/aare/gui/widgets/automation_progress.py | 198 ----- tests/unit/gui/test_main_window.py | 108 +-- tests/unit/gui/test_qt_override_reduction.py | 9 - 8 files changed, 64 insertions(+), 1967 deletions(-) delete mode 100644 src/aare/gui/panels/compact_automation_panel.py delete mode 100644 src/aare/gui/panels/portrait_mode.py delete mode 100644 src/aare/gui/widgets/automation_progress.py diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 260e9fec..ade38ac7 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -32,7 +32,6 @@ from PySide6.QtGui import ( QColor, QCursor, QFont, - QGuiApplication, QImage, QKeySequence, QPalette, @@ -56,7 +55,6 @@ from PySide6.QtWidgets import ( QSizePolicy, QSlider, QSplitter, - QStackedWidget, QTabWidget, QToolBar, QToolTip, @@ -75,7 +73,6 @@ from aare.gui.panels.axis_video_panel import AxisVideoPanel from aare.gui.panels.beamline_controls import BeamConfigPanel, BeamlineControls from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog from aare.gui.panels.beamline_state_panel import BeamlineStatePanel -from aare.gui.panels.compact_automation_panel import CompactAutomationPanel from aare.gui.panels.data_collection_settings import DataCollectionSettings from aare.gui.panels.developer_help_dialog import DeveloperHelpDialog from aare.gui.panels.face_detection_panel import FaceDetectionPanel @@ -85,7 +82,6 @@ from aare.gui.panels.local_contact_panel import LocalContactDialog # panels from aare.gui.panels.log_panel import LogPanel from aare.gui.panels.monochromator_panel import MonochromatorPanel -from aare.gui.panels.portrait_mode import PortraitModePanel from aare.gui.panels.prediction_metrics_panel import PredictionMetricsPanel from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel from aare.gui.panels.samcam_panel import SamcamPanel @@ -219,12 +215,6 @@ class MainWindow(QMainWindow): self._local_contact_dialog = None self._controls_help_dialog = None self._default_window_state = None - self._pre_automation_window_state = None - self._pre_automation_left_column_visible = True - self._pre_automation_right_column_visible = True - self._in_compact_automation_view = False - self._enter_automation_view_action = None - self._return_main_view_action = None self._beamline_cam_addr = beamline_cam_addr self._gonio_cam_addr = gonio_cam_addr @@ -313,9 +303,6 @@ class MainWindow(QMainWindow): self.alert_banner_secondary = AlertBanner(parent=root_widget) self.alert_banner_secondary.float_over(root_widget) - self.content_stack = QStackedWidget(parent=root_widget) - root_layout.addWidget(self.content_stack, 1) - top_widget = QWidget(parent=root_widget) top_widget.setObjectName("standardMainPage") top_widget_layout = QHBoxLayout(top_widget) @@ -542,40 +529,6 @@ class MainWindow(QMainWindow): # self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras) # self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view") - self.compact_sample_camera = SampleCameraImageLabel( - geom=geom, raster=self.raster, parent=root_widget, default_image=default_image - ) - self.compact_automation_panel = CompactAutomationPanel( - self.compact_sample_camera, parent=root_widget - ) - - self.compact_automation_page = QWidget(parent=root_widget) - self.compact_automation_page.setObjectName("compactAutomationPage") - self.compact_automation_page_layout = QVBoxLayout(self.compact_automation_page) - self.compact_automation_page_layout.setContentsMargins(18, 18, 18, 18) - self.compact_automation_page_layout.setSpacing(0) - self.compact_automation_page_layout.addWidget(self.compact_automation_panel) - - # ── Portrait mode page ────────────────────────────────────────── - self.portrait_sample_camera = SampleCameraImageLabel( - geom=geom, raster=self.raster, parent=root_widget, default_image=default_image - ) - self.portrait_mode_panel = PortraitModePanel( - sample_camera_widget=self.portrait_sample_camera, parent=root_widget - ) - - self.portrait_mode_page = QWidget(parent=root_widget) - self.portrait_mode_page.setObjectName("portraitModePage") - portrait_page_layout = QHBoxLayout(self.portrait_mode_page) - portrait_page_layout.setContentsMargins(0, 0, 0, 0) - portrait_page_layout.setSpacing(0) - # No fixed page width: an explicit minimum beats the content_stack's - # Ignored-when-hidden policy and pinned the whole window >=444px in - # NORMAL mode. enter_portrait_mode pins the window width itself. - portrait_page_layout.addWidget( - self.portrait_mode_panel, alignment=Qt.AlignmentFlag.AlignHCenter - ) - self.center_splitter.addWidget(self.video_tab) self._start_axis_camera_threads() @@ -600,16 +553,6 @@ class MainWindow(QMainWindow): self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[])) self.job_list_panel = SampleQueuePanel(show_user=self._decoded_token.staff) - self.compact_automation_panel.play_pause_clicked.connect(self.job_list_panel.run) - self.compact_automation_panel.skip_clicked.connect(self.job_list_panel.skip_current_sample) - self.compact_automation_panel.step_through_toggled.connect( - self.job_list_panel.set_step_through - ) - self.compact_automation_panel.show_full_view_requested.connect( - self._return_from_compact_automation_view - ) - self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation) - # One dock for both lists: the old tabified Reference Tools dock was # staff-only and hid behind the Sample List tab, so it "sometimes" # showed. aaregui2 concept: one panel, Dewar + Auxiliary-puck tabs @@ -810,33 +753,11 @@ class MainWindow(QMainWindow): self.automation_progress_panel.set_running ) self.job_list_panel.automation_running_changed.connect(self._on_automation_running_changed) - self.job_list_panel.automation_running_changed.connect( - self.compact_automation_panel.set_running - ) - self.job_list_panel.step_through_changed.connect( - self.compact_automation_panel.set_step_through - ) - self.job_list_panel.samples_in_queue_changed.connect( - self.compact_automation_panel.set_samples_in_queue - ) - self.job_list_panel.samples_in_queue_changed.connect(self._refresh_compact_queue_preview) - # Portrait mode: queue size + running state + preview - self.job_list_panel.automation_running_changed.connect(self.portrait_mode_panel.set_running) - self.job_list_panel.samples_in_queue_changed.connect( - self.portrait_mode_panel.set_samples_in_queue - ) - self.job_list_panel.samples_in_queue_changed.connect(self._refresh_portrait_queue_preview) self.automation_progress_panel.set_samples_in_queue( len(self.job_list_panel.table_model.samples) ) self.automation_progress_panel.set_running(self.job_list_panel.is_running()) - self.compact_automation_panel.set_running(self.job_list_panel.is_running()) - self.compact_automation_panel.set_step_through(self.job_list_panel.is_step_through()) - self.compact_automation_panel.set_samples_in_queue( - len(self.job_list_panel.table_model.samples) - ) - self._refresh_compact_queue_preview() # smargon trace panel self.smargon_trace_panel = SmargonTracePanel() @@ -899,33 +820,20 @@ class MainWindow(QMainWindow): standard_page_scroll.setWidgetResizable(True) standard_page_scroll.setFrameShape(QFrame.Shape.NoFrame) standard_page_scroll.setWidget(top_widget) - - self.content_stack.addWidget(standard_page_scroll) - self.content_stack.addWidget(self.compact_automation_page) - self.content_stack.addWidget(self.portrait_mode_page) - self.content_stack.setCurrentWidget(standard_page_scroll) - self._standard_main_page = standard_page_scroll - - # QStackedWidget's minimum size is the max over ALL pages, so the hidden - # portrait/compact pages inflated the window minimum past small monitors - # (portrait alone demands 720px height). Only the visible page should - # count — the mode-switch code resizes the window explicitly anyway. - def _only_current_page_counts(index: int) -> None: - for i in range(self.content_stack.count()): - page = self.content_stack.widget(i) - policy = QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored - page.setSizePolicy(policy, policy) - - self.content_stack.currentChanged.connect(_only_current_page_counts) - _only_current_page_counts(self.content_stack.currentIndex()) + root_layout.addWidget(standard_page_scroll, 1) self.setCentralWidget(root_widget) + # Qt auto-pins a top-level window's minimum size to its layout + # minimum, which the bottom dock row pushed past small monitors. + # An explicit minimum disables that auto-pin: the window shrinks to + # this floor and the dock contents clip/scroll instead. + self.setMinimumSize(320, 240) + self.setWindowTitle("AareGUI") self._restore_theme_settings() self._apply_theme() self.create_menu_bar() - self._update_view_mode_actions() self._setup_global_shortcuts() # Default bottom-dock height: the sample list used to grab ~40% of the # window and squeeze the Beamline column behind a scrollbar. Before @@ -987,15 +895,8 @@ class MainWindow(QMainWindow): self._axis_camera_refresh_timer.timeout.connect(self.refresh_axis_cameras) self._axis_camera_refresh_timer.start() - self.portrait_mode_panel.wire_to_main_window( - job_list_panel=self.job_list_panel, tell_samples=self.tell_samples - ) - self.portrait_mode_panel._back_btn.clicked.connect(self._return_from_portrait_mode) - self.portrait_mode_panel.grab_session_requested.connect(self.status_bar.request_baton) - - # Route alert banner signals through portrait-aware interceptors - self.daq.polled_devices_status.connect(self._portrait_alert_primary) - self.daq.detector_error.connect(self._portrait_alert_secondary) + self.daq.polled_devices_status.connect(self._runtime_alert_primary) + self.daq.detector_error.connect(self._runtime_alert_secondary) if hasattr(self, "monochromator_panel"): self.daq.steer_beam_available.connect( @@ -1008,8 +909,6 @@ class MainWindow(QMainWindow): self.daq.baton_response_result.connect(self._on_baton_response_result) self.daq.baton_timeout_checked.connect(self._on_baton_timeout_checked) self.daq.automation_progress.connect(self.automation_progress_panel.set_progress) - self.daq.automation_progress.connect(self.compact_automation_panel.set_progress) - self.daq.automation_progress.connect(self.portrait_mode_panel.set_progress) self.status_bar.baton_request_received.connect(self._show_baton_request_dialog) self.status_bar.baton_request_accepted.connect(self._accept_baton_request) @@ -1089,18 +988,10 @@ class MainWindow(QMainWindow): self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"") self.prediction_thread.image.connect(self._on_sample_camera_frame) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) - self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections) - self.prediction_thread.prediction.connect(self.portrait_sample_camera.update_detections) self.prediction_thread.prediction.connect( self.prediction_metrics_panel.update_from_prediction ) self.prediction_thread.target_point.connect(self.sample_camera.update_target_point) - self.prediction_thread.target_point.connect( - self.compact_sample_camera.update_target_point - ) - self.prediction_thread.target_point.connect( - self.portrait_sample_camera.update_target_point - ) self.prediction_thread.target_point.connect( self.target_stability_panel.update_target_point ) @@ -1109,12 +1000,6 @@ class MainWindow(QMainWindow): self.prediction_thread.camera_availability_changed.connect( self.sample_camera.set_camera_available ) - self.prediction_thread.camera_availability_changed.connect( - self.compact_sample_camera.set_camera_available - ) - self.prediction_thread.camera_availability_changed.connect( - self.portrait_sample_camera.set_camera_available - ) self.prediction_thread.camera_availability_changed.connect( self._on_sample_camera_availability_changed ) @@ -1123,8 +1008,6 @@ class MainWindow(QMainWindow): else: self.prediction_thread = None self.sample_camera.set_camera_available(False) - self.compact_sample_camera.set_camera_available(False) - self.portrait_sample_camera.set_camera_available(False) self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured") # @@ -1236,10 +1119,7 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.beamline.illumination_panel.update_daq_status) self.daq.update.connect(self.raster.update_daq_status) self.daq.update.connect(self.sample_camera.update_daq_status) - self.daq.update.connect(self.compact_sample_camera.update_daq_status) - self.daq.update.connect(self.portrait_sample_camera.update_daq_status) - for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera): - self.daq.auto_centering.connect(camera.set_auto_centering) + self.daq.auto_centering.connect(self.sample_camera.set_auto_centering) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) if self.prediction_thread is not None: @@ -1578,23 +1458,6 @@ class MainWindow(QMainWindow): self.tell_samples_dock.raise_() self.sample_lists_tabs.setCurrentIndex(1) - def _return_to_main_view_for_shutdown(self) -> None: - try: - if getattr(self, "content_stack", None) is None: - return - - current_widget = self.content_stack.currentWidget() - - if hasattr(self, "portrait_mode_page") and current_widget is self.portrait_mode_page: - self._return_from_portrait_mode() - elif bool(getattr(self, "_in_compact_automation_view", False)): - self._return_from_compact_automation_view() - - if hasattr(self, "content_stack") and hasattr(self, "_standard_main_page"): - self.content_stack.setCurrentWidget(self._standard_main_page) - except Exception as e: - logger.warning(f"Failed to return to main view during shutdown: {e}", exc_info=True) - def _restore_samcam_overlay_settings(self) -> None: settings = QSettings("PSI", "AareGUI") # bool()/str() wraps: QSettings.value is typed "object" even with @@ -1659,13 +1522,8 @@ class MainWindow(QMainWindow): """ try: pixmap = QPixmap.fromImage(image) - for view in ( - self.sample_camera, - self.compact_sample_camera, - self.portrait_sample_camera, - ): - if view.isVisible(): - view.update_pixmap(pixmap) + if self.sample_camera.isVisible(): + self.sample_camera.update_pixmap(pixmap) finally: # The slot is only connected once the subscriber exists, but the # attribute is Optional so the guard is what the type checker needs. @@ -1685,13 +1543,7 @@ class MainWindow(QMainWindow): logger.warning(message) self._show_samcam_feed_banner(message or "Sample camera feed unavailable") # The camera overlays show the reason too, not just the boolean. - for cam in ( - self.sample_camera, - getattr(self, "compact_sample_camera", None), - getattr(self, "portrait_sample_camera", None), - ): - if cam is not None: - cam.set_camera_error_message(message or "Sample camera feed unavailable") + self.sample_camera.set_camera_error_message(message or "Sample camera feed unavailable") def _show_samcam_feed_banner(self, message: str) -> None: self._samcam_feed_banner_message = message @@ -1763,200 +1615,27 @@ class MainWindow(QMainWindow): key=lambda sample: sample.loc_str_sort(), ) - def _update_view_mode_actions(self) -> None: - in_automation_view = bool(self._in_compact_automation_view) - - if self._enter_automation_view_action is not None: - self._enter_automation_view_action.setVisible(not in_automation_view) - self._enter_automation_view_action.setEnabled(not in_automation_view) - - if self._return_main_view_action is not None: - self._return_main_view_action.setVisible(in_automation_view) - self._return_main_view_action.setEnabled(in_automation_view) - - @Slot() - def enter_compact_automation_view(self) -> None: - self.job_list_panel.ensure_default_queue_from_samples( - self._all_tell_samples_in_default_order() - ) - self._refresh_compact_queue_preview() - - if not self._in_compact_automation_view: - self._pre_automation_window_state = self.saveState() - self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible() - self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible() - - self.tell_samples_dock.setVisible(False) - self.information_dock.setVisible(False) - self.face_panel_dock.setVisible(False) - self.fluor_panel_dock.setVisible(False) - self.smargon_trace_dock.setVisible(False) - self.target_stability_dock.setVisible(False) - self.prediction_metrics_dock.setVisible(False) - - self.collection_controls_scroll.setVisible(False) - self.beamline_controls_scroll.setVisible(False) - - self.content_stack.setCurrentWidget(self.compact_automation_page) - self._in_compact_automation_view = True - self._update_view_mode_actions() - - @Slot() - def _return_from_compact_automation_view(self) -> None: - self.content_stack.setCurrentWidget(self._standard_main_page) - - if self._pre_automation_window_state is not None: - self.restoreState(self._pre_automation_window_state) - - self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible) - self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible) - - self._in_compact_automation_view = False - self._update_view_mode_actions() - - self.tell_samples_dock.setVisible(True) - self.tell_samples_dock.raise_() - - @Slot() - def _refresh_compact_queue_preview(self) -> None: - current_sample, next_sample, next_next_sample = self.job_list_panel.queue_preview() - self.compact_automation_panel.set_samples(current_sample, next_sample, next_next_sample) - - @Slot() - def enter_portrait_mode(self) -> None: - """Switch to the portrait / phone-screen view and resize the window.""" - self._pre_portrait_geometry = self.saveGeometry() - - self.portrait_mode_panel.set_running(self.job_list_panel.is_running()) - self.portrait_mode_panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples)) - self._refresh_portrait_queue_preview() - self.content_stack.setCurrentWidget(self.portrait_mode_page) - - # ── Camera: scale-to-fit + hide legend ───────────────────────────── - self.portrait_sample_camera.set_show_overlay_legend(False) - try: - self.portrait_sample_camera._autoscale = True - self.portrait_sample_camera._scaling() - except Exception: - logger.debug("Could not autoscale the portrait-mode camera", exc_info=True) - - # ── Hide all chrome that contributes to window width ──────────────── - if self.status_bar is not None: - self.status_bar.setVisible(False) - self.menuBar().setVisible(False) - - # Alert banners take up horizontal space even when hidden via QFrame - # — force them to zero height so they cannot influence the minimum width. - self.alert_banner.setVisible(False) - self.alert_banner.setMaximumHeight(0) - self.alert_banner_secondary.setVisible(False) - self.alert_banner_secondary.setMaximumHeight(0) - - # Hide all dock widgets - for dock_attr in ( - "tell_samples_dock", - "information_dock", - "face_panel_dock", - "fluor_panel_dock", - "smargon_trace_dock", - "target_stability_dock", - "prediction_metrics_dock", - ): - dock = getattr(self, dock_attr, None) - if dock is not None: - dock.setVisible(False) - - # ── Resize to phone footprint ─────────────────────────────────────── - screen = QGuiApplication.screenAt(self.geometry().center()) - if screen is None: - screen = QGuiApplication.primaryScreen() - - available = screen.availableGeometry() - portrait_w = self.portrait_mode_panel.PORTRAIT_WIDTH + 24 - portrait_h = min(860, available.height() - 40) - - new_x = available.x() + (available.width() - portrait_w) // 2 - new_y = available.y() + (available.height() - portrait_h) // 2 - - self.setMinimumWidth(portrait_w) - self.setMaximumWidth(portrait_w) - self.resize(portrait_w, portrait_h) - self.move(new_x, new_y) - - @Slot() - def _return_from_portrait_mode(self) -> None: - """Restore the window to its pre-portrait geometry and switch page.""" - # ── Lift hard width cap before restoring geometry ─────────────────── - self.setMinimumWidth(0) - self.setMaximumWidth(16777215) # Qt's QWIDGETSIZE_MAX - - self.content_stack.setCurrentWidget(self._standard_main_page) - - # ── Restore chrome ────────────────────────────────────────────────── - if self.status_bar is not None: - self.status_bar.setVisible(True) - self.menuBar().setVisible(True) - - # Restore alert banners to normal operation - self.alert_banner.setMaximumHeight(16777215) - self.alert_banner_secondary.setMaximumHeight(16777215) - # Replay any pending messages that arrived during portrait mode - self.portrait_mode_panel._flush_portrait_alerts_to_banners( - self.alert_banner, self.alert_banner_secondary - ) - - # ── Restore camera legend ─────────────────────────────────────────── - try: - settings = self.portrait_sample_camera.target_overlay_settings() - self.portrait_sample_camera.set_show_overlay_legend( - settings.get("show_overlay_legend", False) - ) - except Exception: - logger.debug("Could not restore the camera overlay legend", exc_info=True) - - if hasattr(self, "_pre_portrait_geometry") and self._pre_portrait_geometry: - self.restoreGeometry(self._pre_portrait_geometry) - self._pre_portrait_geometry = None - - self.tell_samples_dock.setVisible(True) - self.information_dock.setVisible(False) - self.face_panel_dock.setVisible(False) - self.fluor_panel_dock.setVisible(False) - self.smargon_trace_dock.setVisible(False) - self.target_stability_dock.setVisible(False) - self.prediction_metrics_dock.setVisible(False) - @Slot(str, bool) - def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: + def _runtime_alert_primary(self, msg: str, is_error: bool) -> None: """Route primary alerts into the runtime dock.""" - if self.content_stack.currentWidget() is self.portrait_mode_page: - self.portrait_mode_panel.show_portrait_alert(msg, is_error) - else: - self._show_runtime_notification( - title="Runtime alert" if is_error else "Runtime update", - message=msg, - level="error" if is_error else "success", - sticky=is_error, - auto_clear_ms=None if is_error else 4000, - ) + self._show_runtime_notification( + title="Runtime alert" if is_error else "Runtime update", + message=msg, + level="error" if is_error else "success", + sticky=is_error, + auto_clear_ms=None if is_error else 4000, + ) @Slot(str, bool) - def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None: + def _runtime_alert_secondary(self, msg: str, is_error: bool) -> None: """Route secondary alerts into the runtime dock.""" - if self.content_stack.currentWidget() is self.portrait_mode_page: - self.portrait_mode_panel.show_portrait_alert(msg, is_error) - else: - self._show_runtime_notification( - title="Device alert" if is_error else "Device update", - message=msg, - level="warning" if is_error else "info", - sticky=is_error, - auto_clear_ms=None if is_error else 4000, - ) - - @Slot() - def _refresh_portrait_queue_preview(self) -> None: - self.portrait_mode_panel.refresh_queue_preview() + self._show_runtime_notification( + title="Device alert" if is_error else "Device update", + message=msg, + level="warning" if is_error else "info", + sticky=is_error, + auto_clear_ms=None if is_error else 4000, + ) @staticmethod def _annotation_token(annotation: str) -> str: @@ -1983,26 +1662,6 @@ class MainWindow(QMainWindow): return f"{current} | {token}" - @Slot(str) - def _handle_compact_annotation(self, annotation: str) -> None: - current_sample, _, _ = self.job_list_panel.queue_preview() - if current_sample is None: - self.status_bar.show_connection_message("No sample selected for annotation.", True) - return - - updated_comment = self._append_annotation_to_comment( - getattr(current_sample, "comment", None), annotation - ) - - self.job_list_panel.annotate_sample_comment(current_sample.db_id, updated_comment) - self.tell_samples.annotate_sample_comment(current_sample.db_id, updated_comment) - self._refresh_compact_queue_preview() - - self.status_bar.show_connection_message( - f"Annotation added: {self._annotation_token(annotation)} — {current_sample.sample_name}", - False, - ) - @Slot() def refresh_axis_cameras(self) -> None: logger.info("Refreshing Axis camera threads") @@ -2143,24 +1802,6 @@ class MainWindow(QMainWindow): quit_action.triggered.connect(self.close) file_menu.addAction(quit_action) - # Prototype views live in the View menu (below the themes), not on the - # menubar; only their in-progress labels changed, the internal - # action/slot names stay. - self._enter_automation_view_action = QAction("Automation View (prototyping)", self) - self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5")) - self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view) - - # Stays top-level: it only shows while INSIDE the automation view, - # where the way back must not hide in a menu. - self._return_main_view_action = QAction("Return to Main View", self) - self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5")) - self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view) - menu_bar.addAction(self._return_main_view_action) - - self._portrait_mode_action = QAction("Playlist Mode (work in progress)", self) - self._portrait_mode_action.setShortcut(QKeySequence("Ctrl+6")) - self._portrait_mode_action.triggered.connect(self.enter_portrait_mode) - view_menu = menu_bar.addMenu("View") self._theme_action_group = QActionGroup(self) @@ -2219,9 +1860,6 @@ class MainWindow(QMainWindow): self._show_right_panel_action.toggled.connect(self.beamline_controls_scroll.setVisible) view_menu.addAction(self._show_right_panel_action) view_menu.addSeparator() - view_menu.addAction(self._portrait_mode_action) - view_menu.addAction(self._enter_automation_view_action) - view_menu.addSeparator() show_samples_action = QAction("Show Sample List", self) show_samples_action.setCheckable(True) @@ -2359,10 +1997,6 @@ class MainWindow(QMainWindow): if self._default_window_state is not None: self.restoreState(self._default_window_state) - self.content_stack.setCurrentWidget(self._standard_main_page) - self._in_compact_automation_view = False - self._update_view_mode_actions() - self.collection_controls_scroll.setVisible(True) self.beamline_controls_scroll.setVisible(True) @@ -2477,7 +2111,7 @@ class MainWindow(QMainWindow): These come from polling, resync and other background requests; a modal pop-up would be too intrusive, so they go to the runtime dock/banner. """ - self._portrait_alert_primary(message, True) + self._runtime_alert_primary(message, True) @Slot(bool) def _on_pss_alarm_changed(self, active: bool) -> None: @@ -2940,8 +2574,6 @@ class MainWindow(QMainWindow): s.geom.beam_location_pxl.x, s.geom.beam_location_pxl.y ) - self._refresh_compact_queue_preview() - current_session = int(getattr(self._decoded_token, "session", -1)) for gui in getattr(s, "open_guis", []) or []: try: diff --git a/src/aare/gui/panels/compact_automation_panel.py b/src/aare/gui/panels/compact_automation_panel.py deleted file mode 100644 index cd48421b..00000000 --- a/src/aare/gui/panels/compact_automation_panel.py +++ /dev/null @@ -1,227 +0,0 @@ -from aarecommon.models.automation import AutomationProgress -from aarecommon.models.models import SampleShortInfo -from PySide6.QtCore import Qt, Signal, Slot -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QLabel, - QMenu, - QPushButton, - QToolButton, - QVBoxLayout, - QWidget, -) - -from aare.gui.widgets.automation_progress import CompactAutomationProgressStrip - - -class CompactAutomationPanel(QFrame): - play_pause_clicked = Signal() - skip_clicked = Signal() - step_through_toggled = Signal(bool) - annotation_selected = Signal(str) - show_full_view_requested = Signal() - ensure_default_queue_requested = Signal() - - def __init__(self, camera_widget: QWidget, parent=None): - super().__init__(parent) - - self._running = False - self._step_through = False - self._current_sample: SampleShortInfo | None = None - self._next_sample: SampleShortInfo | None = None - self._next_next_sample: SampleShortInfo | None = None - - self.setFrameShape(QFrame.Shape.NoFrame) - self.setObjectName("compactAutomationPanel") - - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(18, 18, 18, 18) - main_layout.setSpacing(12) - - camera_card = QFrame(self) - camera_card.setObjectName("compactCameraCard") - camera_layout = QVBoxLayout(camera_card) - camera_layout.setContentsMargins(12, 12, 12, 12) - camera_layout.setSpacing(8) - - camera_title = QLabel("Camera", self) - camera_title.setObjectName("compactSectionTitle") - camera_layout.addWidget(camera_title) - camera_layout.addWidget(camera_widget, 1) - - main_layout.addWidget(camera_card, 1) - - controls_card = QFrame(self) - controls_card.setObjectName("compactControlsCard") - controls_layout = QVBoxLayout(controls_card) - controls_layout.setContentsMargins(12, 12, 12, 12) - controls_layout.setSpacing(10) - - controls_header = QHBoxLayout() - controls_header.setSpacing(10) - - controls_title_wrap = QVBoxLayout() - controls_title_wrap.setContentsMargins(0, 0, 0, 0) - controls_title_wrap.setSpacing(2) - - controls_title = QLabel("Controls", self) - controls_title.setObjectName("compactSectionTitle") - controls_hint = QLabel("Run and manage the automation queue.", self) - controls_hint.setObjectName("compactSectionHint") - - controls_title_wrap.addWidget(controls_title) - controls_title_wrap.addWidget(controls_hint) - - self.menu_button = QToolButton(self) - self.menu_button.setObjectName("compactMenuButton") - self.menu_button.setText("☰") - self.menu_button.setToolTip("Return to main view") - self.menu_button.clicked.connect(self.show_full_view_requested.emit) - - controls_header.addLayout(controls_title_wrap, 1) - controls_header.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop) - - controls_layout.addLayout(controls_header) - - button_row = QHBoxLayout() - button_row.setSpacing(10) - - self.play_pause_button = QPushButton("▶ Play", self) - self.play_pause_button.setObjectName("compactPrimaryButton") - self.play_pause_button.clicked.connect(self.play_pause_clicked.emit) - - self.skip_button = QPushButton("⏭ Skip", self) - self.skip_button.setObjectName("compactSecondaryButton") - self.skip_button.clicked.connect(self.skip_clicked.emit) - - self.step_button = QPushButton("Step", self) - self.step_button.setObjectName("compactSecondaryButton") - self.step_button.setCheckable(True) - self.step_button.toggled.connect(self._on_step_toggled) - - self.annotation_button = QToolButton(self) - self.annotation_button.setObjectName("compactSecondaryButton") - self.annotation_button.setText("Annotate") - self.annotation_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - - annotation_menu = QMenu(self.annotation_button) - for label in ["Heart", "Thumbs Up", "Thumbs Down", "Eyes", "Scan Again"]: - action = annotation_menu.addAction(label) - action.triggered.connect( - lambda checked=False, value=label: self.annotation_selected.emit(value) - ) - self.annotation_button.setMenu(annotation_menu) - - button_row.addWidget(self.play_pause_button, 2) - button_row.addWidget(self.skip_button, 1) - button_row.addWidget(self.step_button, 1) - button_row.addWidget(self.annotation_button, 1) - - controls_layout.addLayout(button_row) - main_layout.addWidget(controls_card) - - progress_card = QFrame(self) - progress_card.setObjectName("compactProgressCard") - progress_layout = QVBoxLayout(progress_card) - progress_layout.setContentsMargins(12, 12, 12, 12) - progress_layout.setSpacing(8) - - progress_title = QLabel("Progress", self) - progress_title.setObjectName("compactSectionTitle") - progress_layout.addWidget(progress_title) - - self.progress_strip = CompactAutomationProgressStrip(self) - progress_layout.addWidget(self.progress_strip) - - main_layout.addWidget(progress_card) - - queue_card = QFrame(self) - queue_card.setObjectName("compactQueueCard") - queue_layout = QVBoxLayout(queue_card) - queue_layout.setContentsMargins(12, 12, 12, 12) - queue_layout.setSpacing(10) - - queue_title = QLabel("Queue Preview", self) - queue_title.setObjectName("compactSectionTitle") - queue_layout.addWidget(queue_title) - - queue_row = QHBoxLayout() - queue_row.setSpacing(10) - - self.current_card = self._build_queue_item("Now", "—") - self.next_card = self._build_queue_item("Next", "—") - self.next_next_card = self._build_queue_item("Later", "—") - - queue_row.addWidget(self.current_card["frame"], 1) - queue_row.addWidget(self.next_card["frame"], 1) - queue_row.addWidget(self.next_next_card["frame"], 1) - - queue_layout.addLayout(queue_row) - main_layout.addWidget(queue_card) - - def _build_queue_item(self, title: str, value: str) -> dict[str, QWidget | QLabel]: - frame = QFrame(self) - frame.setObjectName("compactQueueItem") - - layout = QVBoxLayout(frame) - layout.setContentsMargins(12, 10, 12, 10) - layout.setSpacing(4) - - title_label = QLabel(title, self) - title_label.setObjectName("compactQueueTitle") - - value_label = QLabel(value, self) - value_label.setObjectName("compactQueueValue") - value_label.setWordWrap(True) - - layout.addWidget(title_label) - layout.addWidget(value_label) - - return {"frame": frame, "title": title_label, "value": value_label} - - @Slot(bool) - def set_running(self, running: bool) -> None: - self._running = bool(running) - self.play_pause_button.setText("⏸ Pause" if self._running else "▶ Play") - self.progress_strip.set_running(running) - - @Slot(bool) - def set_step_through(self, enabled: bool) -> None: - self._step_through = bool(enabled) - self.step_button.blockSignals(True) - self.step_button.setChecked(self._step_through) - self.step_button.blockSignals(False) - - @Slot(int) - def set_samples_in_queue(self, count: int) -> None: - self.progress_strip.set_samples_in_queue(count) - - @Slot(object) - def set_progress(self, progress: AutomationProgress) -> None: - self.progress_strip.set_progress(progress) - - @Slot(object, object, object) - def set_samples( - self, - current_sample: SampleShortInfo | None, - next_sample: SampleShortInfo | None, - next_next_sample: SampleShortInfo | None, - ) -> None: - self._current_sample = current_sample - self._next_sample = next_sample - self._next_next_sample = next_next_sample - - self.current_card["value"].setText(self._format_sample(current_sample)) - self.next_card["value"].setText(self._format_sample(next_sample)) - self.next_next_card["value"].setText(self._format_sample(next_next_sample)) - - def _on_step_toggled(self, checked: bool) -> None: - self._step_through = checked - self.step_through_toggled.emit(checked) - - @staticmethod - def _format_sample(sample: SampleShortInfo | None) -> str: - if sample is None: - return "—" - return f"{sample.sample_name} ({sample.loc_str()})" diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py deleted file mode 100644 index 50db0af5..00000000 --- a/src/aare/gui/panels/portrait_mode.py +++ /dev/null @@ -1,805 +0,0 @@ -from __future__ import annotations - -from typing import ClassVar - -from aarecommon.config.logger import setup_logger -from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind -from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QLabel, - QPushButton, - QScrollArea, - QSizePolicy, - QSpinBox, - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from aare.gui.constants import LOGGER_NAME -from aare.gui.styles import DARK_ACCENT as ACCENT -from aare.gui.styles import DARK_BG as BG -from aare.gui.styles import DARK_BORDER as ACCENT_DIM -from aare.gui.styles import DARK_BORDER as LED_OFF -from aare.gui.styles import DARK_ELEVATED as BUTTON_BG -from aare.gui.styles import ( - DARK_ERROR_BG, - DARK_ERROR_BORDER, - DARK_ERROR_TEXT, - DARK_SUCCESS_BG, - DARK_SUCCESS_BORDER, - DARK_SUCCESS_TEXT, - FONT_FINE, - FONT_HERO, - FONT_HINT, - FONT_LABEL, - FONT_VALUE, - WHITE, - qcolor, -) -from aare.gui.styles import DARK_MUTED as SUBTEXT -from aare.gui.styles import DARK_SURFACE as CARD_BG -from aare.gui.styles import DARK_TEXT as TEXT -from aare.gui.styles import WHITE as ACTIVE_STEP - -logger = setup_logger(LOGGER_NAME) - - -# --------------------------------------------------------------------------- -# LED step indicator -# --------------------------------------------------------------------------- -class LEDStages(QWidget): - STEPS: ClassVar[list[str]] = ["Mount", "Centre", "Raster", "Collect"] - - # WorkflowStateKind → LED index - _KIND_TO_INDEX: ClassVar[dict[WorkflowStateKind, int]] = { - WorkflowStateKind.MOUNT: 0, - WorkflowStateKind.LOOP_CENTRE: 1, - WorkflowStateKind.RASTER: 2, - WorkflowStateKind.DATA_COLLECTION: 3, - } - - def __init__(self, active_step: int = 0, parent=None): - super().__init__(parent) - self._active = active_step - self.setFixedHeight(72) - - def set_active_step(self, step: int) -> None: - self._active = step - self.update() - - def set_from_progress(self, progress: AutomationProgress) -> None: - """Derive active LED index from an AutomationProgress object.""" - running_index = -1 - last_success = -1 - - for step_state in progress.steps: - idx = self._KIND_TO_INDEX.get(step_state.step) - if idx is None: - continue - if step_state.status == StepStatus.RUNNING: - running_index = idx - elif step_state.status == StepStatus.SUCCESS and idx > last_success: - last_success = idx - - if running_index >= 0: - self.set_active_step(running_index) - elif last_success >= 0: - self.set_active_step(min(last_success + 1, len(self.STEPS) - 1)) - else: - self.set_active_step(0) - - def paintEvent(self, event): - p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing) - - w = self.width() - n = len(self.STEPS) - step_w = w / n - led_r = 10 - cy = 44 - label_y = 16 - - for i, name in enumerate(self.STEPS): - cx = step_w * i + step_w / 2 - - if i < n - 1: - next_cx = step_w * (i + 1) + step_w / 2 - line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF) - pen = QPen(line_color, 2) - p.setPen(pen) - p.drawLine(QPointF(cx + led_r + 3, cy), QPointF(next_cx - led_r - 3, cy)) - - p.setPen(Qt.NoPen) - if i < self._active: - p.setBrush(QColor(ACCENT)) - p.drawEllipse(QPointF(cx, cy), led_r, led_r) - pen = QPen(QColor(BG), 2) - pen.setCapStyle(Qt.RoundCap) - p.setPen(pen) - p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3)) - p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3)) - elif i == self._active: - # was QColor(ACCENT + "55"), which mis-parsed as #AARRGGBB - glow_pen = QPen(qcolor(ACCENT, 0x55), 4) - p.setPen(glow_pen) - p.setBrush(Qt.NoBrush) - p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) - p.setPen(Qt.NoPen) - p.setBrush(QColor(ACTIVE_STEP)) - p.drawEllipse(QPointF(cx, cy), led_r, led_r) - else: - p.setBrush(QColor(LED_OFF)) - p.drawEllipse(QPointF(cx, cy), led_r, led_r) - - label_color = QColor(ACCENT) if i <= self._active else QColor(SUBTEXT) - p.setPen(label_color) - font = QFont("Inter", 10) - if i == self._active: - font.setWeight(QFont.Weight.DemiBold) - p.setFont(font) - fm = QFontMetrics(font) - text_w = fm.horizontalAdvance(name) - p.drawText( - QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), Qt.AlignCenter, name - ) - - -# --------------------------------------------------------------------------- -# Play/Pause primary button -# --------------------------------------------------------------------------- -class PlayPauseButton(QPushButton): - def __init__(self, parent=None): - super().__init__(parent) - self._running = False - self.setFixedSize(64, 64) - self.setMouseTracking(True) - - def set_running(self, running: bool) -> None: - self._running = running - self.update() - - def paintEvent(self, event): - p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing) - rect = self.rect() - cx, cy = rect.width() / 2, rect.height() / 2 - r = min(rect.width(), rect.height()) / 2 - 2 - - # underMouse() instead of enter/leave overrides tracking a _hovered - # flag: QPushButton already repaints on hover (WA_Hover), so the - # two extra Qt→Python callbacks bought nothing. - bg_color = qcolor(WHITE) if self.underMouse() else QColor(ACCENT) - p.setBrush(bg_color) - p.setPen(Qt.NoPen) - p.drawEllipse(QPointF(cx, cy), r, r) - - sym = "⏸" if self._running else "▶" - p.setPen(QPen(QColor(BG), 2)) - font = QFont("Arial", 16) - p.setFont(font) - p.drawText(rect, Qt.AlignCenter, sym) - - -# --------------------------------------------------------------------------- -# Queue item card -# --------------------------------------------------------------------------- -class QueueItemCard(QFrame): - def __init__( - self, index: int | str, title: str, subtitle: str, is_next: bool = False, parent=None - ): - super().__init__(parent) - self.setFixedHeight(72) - self.setStyleSheet(f""" - QFrame {{ - background: {BUTTON_BG if is_next else CARD_BG}; - border-radius: 14px; - border: {"1px solid " + ACCENT_DIM if is_next else "none"}; - }} - """) - - layout = QHBoxLayout(self) - layout.setContentsMargins(14, 0, 14, 0) - layout.setSpacing(12) - - badge = QLabel() - badge.setFixedSize(32, 32) - badge.setAlignment(Qt.AlignCenter) - if is_next: - badge.setText("▶") - badge.setStyleSheet(f""" - color: {ACCENT}; background: {ACCENT_DIM}; - border-radius: 16px; font-size: {FONT_HINT}; font-weight: bold; - """) - else: - badge.setText(str(index)) - badge.setStyleSheet(f""" - color: {SUBTEXT}; background: {BUTTON_BG}; - border-radius: 16px; font-size: {FONT_HINT}; - """) - layout.addWidget(badge) - - text_col = QVBoxLayout() - text_col.setSpacing(2) - text_col.setContentsMargins(0, 0, 0, 0) - - title_lbl = QLabel(title) - title_lbl.setStyleSheet( - f"color: {TEXT}; font-size: {FONT_LABEL}; font-weight: 700; background: transparent;" - ) - title_lbl.setWordWrap(False) - sub_lbl = QLabel(subtitle) - sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_FINE}; background: transparent;") - text_col.addWidget(title_lbl) - text_col.addWidget(sub_lbl) - layout.addLayout(text_col, stretch=1) - - -# --------------------------------------------------------------------------- -# Main portrait-mode panel -# --------------------------------------------------------------------------- -class PortraitModePanel(QWidget): - PORTRAIT_WIDTH = 420 - grab_session_requested = Signal() - - def __init__(self, sample_camera_widget: QWidget, parent=None): - super().__init__(parent) - self.setObjectName("portraitRoot") - self.setMaximumWidth(self.PORTRAIT_WIDTH) - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - - # Created here rather than in the build helper so the attributes are - # initialized in __init__ (basedpyright gate); the helper styles and - # mounts them. - self._name_lbl = QLabel("—") - self._sub_lbl = QLabel("No sample queued") - - self._job_list_panel = None - self._tell_samples = None - self._is_running = False - self._loop_enabled = False - self._loop_remaining = 0 - self._loop_samples = [] - - self._loop_restart_timer = QTimer(self) - self._loop_restart_timer.setInterval(1000) - self._loop_restart_timer.timeout.connect(self._try_loop_restart) - - self._loop_restart_deadline = None - - # Pending alert messages received while in portrait mode - # Each entry: (msg, is_error) - self._pending_alerts: list[tuple[str, bool]] = [] - - self._stack = QStackedWidget() - self._player_page = self._build_player(sample_camera_widget) - self._queue_page = self._build_queue() - self._stack.addWidget(self._player_page) - self._stack.addWidget(self._queue_page) - - root = QVBoxLayout(self) - root.setContentsMargins(12, 12, 12, 12) - root.addWidget(self._stack) - - # ------------------------------------------------------------------ - # Player page - # ------------------------------------------------------------------ - def _build_player(self, cam_widget: QWidget) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(10) - layout.setContentsMargins(0, 0, 0, 0) - - # Title - title = QLabel("S A M C A M E R A") - title.setAlignment(Qt.AlignCenter) - title.setStyleSheet( - f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" - ) - layout.addWidget(title) - - # ── Portrait alert toast (hidden by default) ─────────────────────── - self._alert_toast = QFrame() - self._alert_toast.setVisible(False) - self._alert_toast.setStyleSheet(f""" - QFrame {{ - background: {DARK_ERROR_BG}; - border: 1px solid {DARK_ERROR_BORDER}; - border-radius: 10px; - }} - """) - toast_layout = QHBoxLayout(self._alert_toast) - toast_layout.setContentsMargins(12, 8, 12, 8) - self._alert_toast_label = QLabel("") - self._alert_toast_label.setWordWrap(True) - self._alert_toast_label.setStyleSheet( - f"color: {DARK_ERROR_TEXT}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" - ) - toast_layout.addWidget(self._alert_toast_label) - # Dismiss button - dismiss_btn = QPushButton("✕") - dismiss_btn.setFixedSize(20, 20) - dismiss_btn.setStyleSheet(f""" - QPushButton {{ - color: {SUBTEXT}; - background: transparent; - border: none; - font-size: {FONT_FINE}; - }} - QPushButton:hover {{ color: {TEXT}; }} - """) - dismiss_btn.clicked.connect(self._dismiss_portrait_alert) - toast_layout.addWidget(dismiss_btn) - layout.addWidget(self._alert_toast) - - self._alert_toast_timer = QTimer(self) - self._alert_toast_timer.setSingleShot(True) - self._alert_toast_timer.timeout.connect(self._dismiss_portrait_alert) - - # Camera card — wraps the real compact_sample_camera - cam_card = QFrame() - cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") - cam_card_layout = QVBoxLayout(cam_card) - cam_card_layout.setContentsMargins(4, 4, 4, 4) - cam_card_layout.setSpacing(0) - cam_widget.setMinimumHeight(220) - cam_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - cam_card_layout.addWidget(cam_widget) - layout.addWidget(cam_card) - - # Sample name labels (created in __init__) - self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: {FONT_VALUE}; font-weight: 700;") - self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_HINT};") - layout.addWidget(self._name_lbl) - layout.addWidget(self._sub_lbl) - - # LED step indicator - self._leds = LEDStages(active_step=0) - layout.addWidget(self._leds) - - # Play/Pause button (single, centred) - ctrl_frame = QFrame() - ctrl_frame.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") - - ctrl_layout = QHBoxLayout(ctrl_frame) - ctrl_layout.setContentsMargins(12, 12, 12, 12) - ctrl_layout.setSpacing(12) - - ctrl_layout.addStretch() - - self._play_pause_btn = PlayPauseButton() - self._play_pause_btn.clicked.connect(self._on_play_pause_clicked) - ctrl_layout.addWidget(self._play_pause_btn) - - # Loop arrow button - self._loop_btn = QPushButton("↻") - self._loop_btn.setFixedSize(52, 52) - self._loop_btn.setCheckable(True) - self._loop_btn.setToolTip("Loop queue") - self._loop_btn.setStyleSheet(f""" - QPushButton {{ - border-radius: 26px; - background: {BUTTON_BG}; - color: {ACCENT}; - font-size: {FONT_HERO}; - font-weight: bold; - }} - QPushButton:checked {{ - background: {ACCENT}; - color: {BG}; - }} - """) - self._loop_btn.toggled.connect(self._toggle_loop) - ctrl_layout.addWidget(self._loop_btn) - - self._loop_count = QSpinBox() - self._loop_count.setRange(1, 999) - self._loop_count.setValue(2) - self._loop_count.setPrefix("× ") - self._loop_count.setFixedHeight(40) - self._loop_count.setToolTip("Number of queue repeats") - ctrl_layout.addWidget(self._loop_count) - - ctrl_layout.addStretch() - - layout.addWidget(ctrl_frame) - - # "UP NEXT" header - up_next_row = QHBoxLayout() - up_next_lbl = QLabel("UP NEXT") - up_next_lbl.setStyleSheet( - f"color: {ACCENT}; font-size: {FONT_FINE}; letter-spacing: 2px; font-weight: 700;" - ) - self._samples_count_lbl = QLabel("0 SAMPLES") - self._samples_count_lbl.setStyleSheet( - f"color: {SUBTEXT}; font-size: {FONT_FINE}; letter-spacing: 1px;" - ) - up_next_row.addWidget(up_next_lbl) - up_next_row.addStretch() - up_next_row.addWidget(self._samples_count_lbl) - layout.addLayout(up_next_row) - - # Preview card container (up to 4 cards) - self._preview_container = QWidget() - self._preview_layout = QVBoxLayout(self._preview_container) - self._preview_layout.setSpacing(6) - self._preview_layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._preview_container) - - # View full queue button - view_btn = self._accent_button("VIEW FULL QUEUE ☰") - view_btn.clicked.connect(self._on_view_full_queue) - layout.addWidget(view_btn) - - # ── Session / utility row ────────────────────────────────────────── - util_row = QHBoxLayout() - util_row.setSpacing(8) - - self._grab_session_btn = self._accent_button("⚡ GRAB SESSION") - self._grab_session_btn.clicked.connect(self.grab_session_requested) - util_row.addWidget(self._grab_session_btn) - - layout.addLayout(util_row) - - # Back to main view button - self._back_btn = self._accent_button("← MAIN VIEW") - layout.addWidget(self._back_btn) - # Connected externally by MainWindow - - return page - - # ------------------------------------------------------------------ - # Queue / full list page - # ------------------------------------------------------------------ - def _build_queue(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(10) - layout.setContentsMargins(0, 0, 0, 0) - - title = QLabel("SAMPLE QUEUE") - title.setAlignment(Qt.AlignCenter) - title.setStyleSheet( - f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" - ) - layout.addWidget(title) - - self._queue_scroll = QScrollArea() - self._queue_scroll.setWidgetResizable(True) - self._queue_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self._queue_inner = QWidget() - self._queue_inner_layout = QVBoxLayout(self._queue_inner) - self._queue_inner_layout.setSpacing(6) - self._queue_inner_layout.setContentsMargins(0, 4, 0, 4) - - self._queue_scroll.setWidget(self._queue_inner) - layout.addWidget(self._queue_scroll, stretch=1) - - back_btn = self._accent_button("← BACK TO CAMERA") - back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page)) - layout.addWidget(back_btn) - - return page - - # ------------------------------------------------------------------ - # Wiring - # ------------------------------------------------------------------ - def wire_to_main_window(self, job_list_panel, tell_samples) -> None: - """Call this from MainWindow after both panels are constructed.""" - self._job_list_panel = job_list_panel - self._tell_samples = tell_samples - - self._job_list_panel.loop_restart_requested = self._restart_loop_if_needed - - self._populate_queue_from_tell_samples_if_empty() - self.refresh_queue_preview() - - # ------------------------------------------------------------------ - # Public update slots - # ------------------------------------------------------------------ - @Slot(object) - def set_progress(self, progress: AutomationProgress) -> None: - """Driven by daq.automation_progress signal.""" - self._leds.set_from_progress(progress) - - @Slot(bool) - def set_running(self, running: bool) -> None: - self._is_running = running - self._play_pause_btn.set_running(running) - - @Slot(int) - def set_samples_in_queue(self, count: int) -> None: - label = f"{count} SAMPLE{'S' if count != 1 else ''}" - self._samples_count_lbl.setText(label) - - def refresh_queue_preview(self) -> None: - """ - Refresh sample name, subtitle, and preview cards from queue_preview(). - Falls back to tell_samples sorted by location when the queue is empty. - """ - if self._job_list_panel is None: - return - - current, nxt, nxt2 = self._job_list_panel.queue_preview() - - # --- sample name / subtitle --- - if current is not None: - name = str(getattr(current, "sample_name", "") or "—") - puck = str(getattr(current, "puck_name", "") or "") - pin = getattr(current, "pin", None) - subtitle_parts = [puck] - if pin is not None: - subtitle_parts.append(f"Pin {pin}") - self._name_lbl.setText(name) - self._sub_lbl.setText(" · ".join(p for p in subtitle_parts if p)) - else: - self._name_lbl.setText("—") - self._sub_lbl.setText("No sample queued") - - # --- preview cards --- - # Clear existing - while self._preview_layout.count(): - item = self._preview_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - previews = [s for s in [current, nxt, nxt2] if s is not None] - - # If queue is empty, suggest from tell_samples sorted by location - if not previews and self._tell_samples is not None: - raw = list(getattr(self._tell_samples.table_model, "samples", [])) - suggested = sorted( - [s for s in raw if getattr(s, "location", None) is not None], - key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", - )[:4] - for i, s in enumerate(suggested): - name = str(getattr(s, "sample_name", "") or f"Sample {i + 1}") - puck = str(getattr(s, "puck_name", "") or "") - card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False) - self._preview_layout.addWidget(card) - return - - for i, sample in enumerate(previews): - name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") - puck = str(getattr(sample, "puck_name", "") or "") - card = QueueItemCard( - index="▶" if i == 0 else i + 1, title=name, subtitle=puck, is_next=(i == 0) - ) - self._preview_layout.addWidget(card) - - # ------------------------------------------------------------------ - # Full queue page population - # ------------------------------------------------------------------ - def _on_view_full_queue(self) -> None: - self._rebuild_full_queue() - self._stack.setCurrentWidget(self._queue_page) - - def _rebuild_full_queue(self) -> None: - # Clear existing cards - while self._queue_inner_layout.count(): - item = self._queue_inner_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - samples = [] - - if self._job_list_panel is not None: - raw = list(getattr(self._job_list_panel.table_model, "samples", [])) - samples = raw - - # Fall back to tell_samples sorted by location if queue is empty - if not samples and self._tell_samples is not None: - raw = list(getattr(self._tell_samples.table_model, "samples", [])) - samples = sorted( - [s for s in raw if getattr(s, "location", None) is not None], - key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", - ) - - if not samples: - placeholder = QLabel("No samples in queue") - placeholder.setStyleSheet( - f"color: {SUBTEXT}; font-size: {FONT_LABEL}; font-weight: 700;" - ) - placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._queue_inner_layout.addWidget(placeholder) - return - - for i, sample in enumerate(samples): - name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") - puck = str(getattr(sample, "puck_name", "") or "") - card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False) - self._queue_inner_layout.addWidget(card) - - # ------------------------------------------------------------------ - # Play/Pause handler - # ------------------------------------------------------------------ - @Slot() - def _on_play_pause_clicked(self) -> None: - if self._job_list_panel is None: - return - if self._is_running: - self._job_list_panel.pause_automation() - else: - self._job_list_panel.run() - - # ------------------------------------------------------------------ - # Helper - # ------------------------------------------------------------------ - @staticmethod - def _accent_button(text: str) -> QPushButton: - btn = QPushButton(text) - btn.setFixedHeight(48) - btn.setStyleSheet(f""" - QPushButton {{ - background: transparent; - border: 1.5px solid {ACCENT}; - border-radius: 14px; - color: {ACCENT}; - font-size: {FONT_HINT}; - font-weight: 700; - letter-spacing: 1.5px; - }} - QPushButton:hover {{ - background: {ACCENT_DIM}; - }} - QPushButton:pressed {{ - background: {ACCENT}; - color: {BG}; - }} - """) - return btn - - # ------------------------------------------------------------------ - # Portrait alert toast - # ------------------------------------------------------------------ - - @Slot(str, bool) - def show_portrait_alert(self, msg: str, is_error: bool) -> None: - """Show a compact dark-themed alert inside the portrait panel.""" - if not msg: - self._dismiss_portrait_alert() - return - - # Store for replay when returning to main view - self._pending_alerts.append((msg, is_error)) - - icon = "🛑" if is_error else "✅" - self._alert_toast_label.setText(f"{icon} {msg}") - - border_color = DARK_ERROR_BORDER if is_error else DARK_SUCCESS_BORDER - text_color = DARK_ERROR_TEXT if is_error else DARK_SUCCESS_TEXT - bg_color = DARK_ERROR_BG if is_error else DARK_SUCCESS_BG - - self._alert_toast.setStyleSheet(f""" - QFrame {{ - background: {bg_color}; - border: 1px solid {border_color}; - border-radius: 10px; - }} - """) - self._alert_toast_label.setStyleSheet( - f"color: {text_color}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" - ) - self._alert_toast.setVisible(True) - - # Auto-dismiss success after 5 s; errors persist until dismissed - self._alert_toast_timer.stop() - if not is_error: - self._alert_toast_timer.start(5000) - - @Slot() - def _dismiss_portrait_alert(self) -> None: - self._alert_toast_timer.stop() - self._alert_toast.setVisible(False) - self._alert_toast_label.clear() - - def _flush_portrait_alerts_to_banners(self, primary_banner, secondary_banner) -> None: - """ - Called when returning to main view — replay any error alerts that - arrived during portrait mode so the operator doesn't miss them. - Only the last error (if any) is surfaced to avoid flooding. - """ - errors = [(m, e) for m, e in self._pending_alerts if e] - if errors: - last_msg, last_is_error = errors[-1] - primary_banner.show_message(last_msg, last_is_error) - self._pending_alerts.clear() - self._dismiss_portrait_alert() - - @Slot(bool) - def _toggle_loop(self, enabled: bool) -> None: - self._loop_enabled = enabled - - if enabled and self._job_list_panel: - self._loop_samples = list(self._job_list_panel.table_model.samples) - self._loop_remaining = self._loop_count.value() - else: - self._loop_remaining = 0 - - def _try_loop_restart(self): - """ - Polls beamline state until safe to restart. - """ - - if self._job_list_panel is None: - self._loop_restart_timer.stop() - return - - # Timeout protection - if self._loop_restart_deadline is not None and self._loop_restart_deadline.hasExpired(): - self._loop_restart_timer.stop() - - self._loop_enabled = False - self._loop_btn.setChecked(False) - - self.show_portrait_alert("Loop stopped: beamline remained busy too long", True) - return - - # Still busy, wait - if getattr(self._job_list_panel, "_busy", False): - return - - # Safe to restart - self._loop_restart_timer.stop() - - self._job_list_panel.run() - - def _restart_loop_if_needed(self) -> bool: - """ - Restore queue and wait for beamline idle before restarting. - Has timeout protection. - """ - - if not self._loop_enabled: - return False - - if not self._loop_samples: - return False - - if self._loop_remaining <= 0: - return False - - self._job_list_panel.queue_samples(list(self._loop_samples), replace=True) - - self._loop_remaining -= 1 - - # Start waiting for idle - self._loop_restart_deadline = QTimer().remainingTime() - - # 30 second safety timeout - from PySide6.QtCore import QDeadlineTimer - - self._loop_restart_deadline = QDeadlineTimer(30000) - - self._loop_restart_timer.start() - - return True - - def _populate_queue_from_tell_samples_if_empty(self) -> None: - """ - Fill the automation queue from TELL samples if no queue exists. - Samples are ordered by physical sample position. - """ - - if self._job_list_panel is None or self._tell_samples is None: - logger.info("Job list panel or tell_samples not ready") - return - - # Do not overwrite an existing queue - if self._job_list_panel.table_model.samples: - logger.info("Queue already exists, not populating from tell_samples") - return - - samples = list(getattr(self._tell_samples.table_model, "samples", [])) - - ordered = sorted( - [s for s in samples if getattr(s, "location", None) is not None], - key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", - ) - - if ordered: - self._job_list_panel.queue_samples(ordered, replace=True) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 1f05bd8c..d2a64dbb 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -97,7 +97,6 @@ DARK_GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_dark.png").as_posix() # Borders (all can be "transparent" to hide the line): BORDER = "transparent" # main dividers, e.g. the beamline state bar top line CARD_BORDER = "transparent" # cards / group boxes (Local Contact, automation) -COMPACT_BORDER = "transparent" # compact-automation cards and buttons # -- Box-frame borders by nesting level ------------------------------------- # One knob per level so the amount of "boxing" is tunable in one place: @@ -176,13 +175,6 @@ TAB_FACE_BG = "#ffffff" SELECTION_BG = BANNER SELECTION_TEXT = "#263043" # same as TEXT — readable on the banner blue -# Compact-automation page: -COMPACT_CARD_BG = "#e6eefc" -COMPACT_TITLE = "#17324d" # section titles + menu/secondary button text -COMPACT_HINT = "#51657d" # secondary text: hints, queue titles -COMPACT_VALUE = "#10263a" -COMPACT_MENU_BG = "#cbdcf8" -COMPACT_MENU_BG_HOVER = "#bfd4f6" PRIMARY = "#2563eb" # main action button PRIMARY_HOVER = "#1d4ed8" PRIMARY_TEXT = "white" @@ -602,7 +594,6 @@ TUTORIAL_BORDER = "#555555" TUTORIAL_HIGHLIGHT = "#ffff00" # widget spotlight pen (was Qt.yellow) # -- Typography ladder (role-named; tune sizes here, not in widgets) -------- -FONT_HERO = "28px" # portrait main action button FONT_ALERT = "18px" # alert banner text FONT_VALUE = "18px" # prominent values, big glyph buttons FONT_TITLE = "16px" # section / panel titles @@ -908,15 +899,10 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: } QWidget#mainContentRoot, - QWidget#standardMainPage, - QWidget#compactAutomationPage { + QWidget#standardMainPage { background-color: transparent; } - QWidget#portraitModePage { - background-color: $dark_bg; - } - /* Borderless hover hints. The transparent border is required — QToolTip only honours the stylesheet background once a border is set. */ QToolTip { @@ -926,93 +912,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: padding: 4px 6px; } - QFrame#compactAutomationPanel { - background: transparent; - border: none; - border-radius: 18px; - } - - QFrame#compactCameraCard, - QFrame#compactControlsCard, - QFrame#compactProgressCard, - QFrame#compactQueueCard, - QFrame#compactQueueItem { - background: $compact_card_bg; - border: 1px solid $compact_border; - border-radius: 16px; - } - - QLabel#compactSectionTitle { - background: transparent; - color: $compact_title; - font-size: $font_body; - font-weight: 700; - } - - QLabel#compactSectionHint { - background: transparent; - color: $compact_hint; - font-size: $font_hint; - } - - QLabel#compactQueueTitle { - background: transparent; - color: $compact_hint; - font-size: $font_fine; - font-weight: 700; - } - - QLabel#compactQueueValue { - background: transparent; - color: $compact_value; - font-size: $font_body; - font-weight: 700; - } - - QToolButton#compactMenuButton { - background: $compact_menu_bg; - color: $compact_title; - border: 1px solid $compact_border; - border-radius: 14px; - padding: 10px 14px; - font-size: $font_value; - font-weight: 700; - } - - QToolButton#compactMenuButton:hover { - background: $compact_menu_bg_hover; - } - - QPushButton#compactPrimaryButton { - background: $primary; - color: $primary_text; - border: none; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body_lg; - font-weight: 700; - } - - QPushButton#compactPrimaryButton:hover { - background: $primary_hover; - } - - QPushButton#compactSecondaryButton, - QToolButton#compactSecondaryButton { - background: $secondary_bg; - color: $compact_title; - border: 1px solid $compact_border; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body; - font-weight: 700; - } - - QPushButton#compactSecondaryButton:hover, - QToolButton#compactSecondaryButton:hover { - background: $secondary_bg_hover; - } - QFrame#alertBanner[alertKind="error"] { background-color: $error_bg; border: 2px solid $error_border; @@ -1397,35 +1296,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: border: none; } - QWidget#portraitRoot, - QWidget#portraitRoot QWidget { - background: $dark_bg; - color: $dark_text; - font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: $font_body; - } - - QWidget#portraitRoot QScrollArea { - border: none; - background: transparent; - } - - QWidget#portraitRoot QScrollBar:vertical { - background: $dark_surface; - width: 4px; - border-radius: 2px; - } - - QWidget#portraitRoot QScrollBar::handle:vertical { - background: $dark_border; - border-radius: 2px; - min-height: 20px; - } - - QWidget#portraitRoot QScrollBar::add-line:vertical, - QWidget#portraitRoot QScrollBar::sub-line:vertical { - height: 0px; - } """).substitute(mapping) @@ -1444,10 +1314,6 @@ def _sunset_stylesheet() -> str: background: $dark_app_background; } - QWidget#portraitModePage { - background: $dark_bg; - } - /* Interactive faces sit one step above the backdrop (site: glass2) with the faint gold hairline. Same pinned height as the light sheet so buttons and entry boxes match in both themes. */ @@ -1634,7 +1500,7 @@ def _sunset_stylesheet() -> str: QPushButton#filterChip[status_key="flagged"]:checked { background: $sample_status_flagged_bg; } QPushButton#filterChip[status_key="measured"]:checked { background: $sample_status_measured_bg; } - /* In-panel section headings — gold, matching the compact page titles. */ + /* In-panel section headings — gold. */ QLabel#sectionTitle { background: transparent; color: $dark_accent; @@ -1695,93 +1561,6 @@ def _sunset_stylesheet() -> str: padding: 4px 6px; } - QFrame#compactAutomationPanel { - background: transparent; - border: none; - border-radius: 18px; - } - - QFrame#compactCameraCard, - QFrame#compactControlsCard, - QFrame#compactProgressCard, - QFrame#compactQueueCard, - QFrame#compactQueueItem { - background: $dark_surface; - border: 1px solid $dark_border; - border-radius: 16px; - } - - QLabel#compactSectionTitle { - background: transparent; - color: $dark_accent; - font-size: $font_body; - font-weight: 700; - } - - QLabel#compactSectionHint { - background: transparent; - color: $dark_subtext; - font-size: $font_hint; - } - - QLabel#compactQueueTitle { - background: transparent; - color: $dark_subtext; - font-size: $font_fine; - font-weight: 700; - } - - QLabel#compactQueueValue { - background: transparent; - color: $dark_text; - font-size: $font_body; - font-weight: 700; - } - - QToolButton#compactMenuButton { - background: $dark_elevated; - color: $dark_accent; - border: 1px solid $dark_border; - border-radius: 14px; - padding: 10px 14px; - font-size: $font_value; - font-weight: 700; - } - - QToolButton#compactMenuButton:hover { - background: $dark_border; - } - - QPushButton#compactPrimaryButton { - background: $dark_accent_fill; - color: $dark_on_accent; - border: none; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body_lg; - font-weight: 700; - } - - QPushButton#compactPrimaryButton:hover { - background: $dark_accent_fill_hover; - } - - QPushButton#compactSecondaryButton, - QToolButton#compactSecondaryButton { - background: $dark_elevated; - color: $dark_text; - border: 1px solid $dark_border; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body; - font-weight: 700; - } - - QPushButton#compactSecondaryButton:hover, - QToolButton#compactSecondaryButton:hover { - background: $dark_border; - } - QFrame#alertBanner[alertKind="error"] { background: $dark_error_bg; border: 2px solid $dark_error_border; @@ -2029,35 +1808,6 @@ def _sunset_stylesheet() -> str: border: none; } - QWidget#portraitRoot, - QWidget#portraitRoot QWidget { - background: $dark_bg; - color: $dark_text; - font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: $font_body; - } - - QWidget#portraitRoot QScrollArea { - border: none; - background: transparent; - } - - QWidget#portraitRoot QScrollBar:vertical { - background: $dark_surface; - width: 4px; - border-radius: 2px; - } - - QWidget#portraitRoot QScrollBar::handle:vertical { - background: $dark_border; - border-radius: 2px; - min-height: 20px; - } - - QWidget#portraitRoot QScrollBar::add-line:vertical, - QWidget#portraitRoot QScrollBar::sub-line:vertical { - height: 0px; - } """).substitute(_palette()) diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 5fb4c5b0..2198f178 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -65,7 +65,7 @@ class AlertBanner(QFrame): """Render as a compact toast under `widget`'s bottom edge (must be a descendant of the float host) instead of a full-width top bar — the baton messages sit below the sample camera view this way. Falls back - to the top bar while the anchor is hidden (e.g. portrait mode). + to the top bar while the anchor is hidden. ponytail: position goes stale if a splitter drag moves the anchor while the toast is up; it self-corrects on the next show.""" self._float_anchor = widget diff --git a/src/aare/gui/widgets/automation_progress.py b/src/aare/gui/widgets/automation_progress.py deleted file mode 100644 index af5ccfda..00000000 --- a/src/aare/gui/widgets/automation_progress.py +++ /dev/null @@ -1,198 +0,0 @@ -from __future__ import annotations - -import time -from datetime import datetime - -from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind -from PySide6.QtCore import QTimer, Slot -from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget - -from aare.gui.styles import ( - FAINT_TEXT, - FONT_HINT, - FONT_VALUE, - STEP_FAILED_TEXT, - STEP_PAUSED_TEXT, - STEP_RUNNING_TEXT, - STEP_SUCCESS_TEXT, -) - - -class CompactAutomationProgressStrip(QFrame): - DEFAULT_SAMPLE_ESTIMATE_S = 150.0 - - def __init__(self, parent: QWidget | None = None): - super().__init__(parent) - self._progress: AutomationProgress | None = None - self._queue_count = 0 - self._running = False - self._step_labels: dict[WorkflowStateKind, QLabel] = {} - - self._timer = QTimer(self) - self._timer.setInterval(1000) - self._timer.timeout.connect(self._refresh_live_view) - - self.setObjectName("compactAutomationProgressStrip") - self.setStyleSheet( - """ - QFrame#compactAutomationProgressStrip { - background: transparent; - border: none; - } - """ - ) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) - - self._summary_label = QLabel("Automation idle", self) - self._summary_label.setObjectName("compactProgressSummary") - self._summary_label.setWordWrap(True) - layout.addWidget(self._summary_label) - - steps_row = QHBoxLayout() - steps_row.setSpacing(8) - - for step in ( - WorkflowStateKind.MOUNT, - WorkflowStateKind.LOOP_CENTRE, - WorkflowStateKind.RASTER, - WorkflowStateKind.DATA_COLLECTION, - WorkflowStateKind.FINAL, - ): - label = QLabel(self) - label.setObjectName("compactProgressStep") - label.setWordWrap(True) - steps_row.addWidget(label, 1) - self._step_labels[step] = label - - layout.addLayout(steps_row) - self._apply_empty_state() - - def _apply_empty_state(self) -> None: - self._summary_label.setText("Automation idle") - for step, label in self._step_labels.items(): - label.setText(self._format_step_html(step, StepStatus.PENDING)) - - @staticmethod - def _step_title(step: WorkflowStateKind) -> str: - return { - WorkflowStateKind.MOUNT: "Mount", - WorkflowStateKind.LOOP_CENTRE: "Center", - WorkflowStateKind.RASTER: "Raster", - WorkflowStateKind.DATA_COLLECTION: "Collect", - WorkflowStateKind.FINAL: "Finish", - }.get(step, str(step.value)) - - @staticmethod - def _step_icon(status: StepStatus) -> str: - return { - StepStatus.PENDING: "○", - StepStatus.RUNNING: "◉", - StepStatus.SUCCESS: "✓", - StepStatus.FAILED: "✕", - StepStatus.SKIPPED: "↷", - StepStatus.PAUSED: "⏸", - }.get(status, "○") - - @staticmethod - def _step_color(status: StepStatus) -> str: - return { - StepStatus.PENDING: FAINT_TEXT, - StepStatus.RUNNING: STEP_RUNNING_TEXT, - StepStatus.SUCCESS: STEP_SUCCESS_TEXT, - StepStatus.FAILED: STEP_FAILED_TEXT, - StepStatus.SKIPPED: FAINT_TEXT, - StepStatus.PAUSED: STEP_PAUSED_TEXT, - }.get(status, FAINT_TEXT) - - def _format_step_html(self, step: WorkflowStateKind, status: StepStatus) -> str: - color = self._step_color(status) - icon = self._step_icon(status) - title = self._step_title(step) - return ( - f"
" - f"
{icon}
" - f"
{title}
" - f"
" - ) - - @staticmethod - def _format_duration(seconds: float | None) -> str: - if seconds is None or seconds <= 0: - return "0m 00s" - total = round(seconds) - minutes, secs = divmod(total, 60) - if minutes < 60: - return f"{minutes}m {secs:02d}s" - hours, minutes = divmod(minutes, 60) - return f"{hours}h {minutes:02d}m" - - @staticmethod - def _format_eta(epoch_seconds: float | None) -> str: - if epoch_seconds is None: - return "N/A" - return datetime.fromtimestamp(epoch_seconds).strftime("%H:%M:%S") - - @Slot() - def _refresh_live_view(self) -> None: - if self._progress is not None: - self.set_progress(self._progress) - - @Slot(bool) - def set_running(self, running: bool) -> None: - self._running = bool(running) - if self._progress is not None: - self.set_progress(self._progress) - - @Slot(int) - def set_samples_in_queue(self, count: int) -> None: - self._queue_count = max(0, int(count)) - if self._progress is not None: - self.set_progress(self._progress) - - @Slot(object) - def set_progress(self, progress: AutomationProgress) -> None: - self._progress = progress - - any_running = any(step.status == StepStatus.RUNNING for step in progress.steps) - if any_running and self._running: - if not self._timer.isActive(): - self._timer.start() - else: - self._timer.stop() - - current_sample = progress.current_sample_name or "None" - avg_time = ( - progress.avg_time_per_sample - if progress.avg_time_per_sample > 0 - else self.DEFAULT_SAMPLE_ESTIMATE_S - ) - queue_remaining = avg_time * self._queue_count - eta = time.time() + queue_remaining if queue_remaining > 0 else None - - state_text = "Paused" - state_color = STEP_PAUSED_TEXT - if progress.finished and progress.success is True: - state_text = "Completed" - state_color = STEP_SUCCESS_TEXT - elif progress.finished and progress.success is False: - state_text = "Failed" - state_color = STEP_FAILED_TEXT - elif self._running: - state_text = "Running" - state_color = STEP_RUNNING_TEXT - - self._summary_label.setText( - f"Status: " - f"{state_text}" - f"    Now: {current_sample}" - f"    Queue: {self._queue_count}" - f"    ETA: {self._format_duration(queue_remaining)}" - f"    Done: {self._format_eta(eta)}" - ) - - states = {step.step: step.status for step in progress.steps} - for step, label in self._step_labels.items(): - label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING))) diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index da5e175e..6a901043 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -87,6 +87,35 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): win._show_right_panel_action.trigger() assert not win.beamline_controls_scroll.isHidden() + # Alert routers land straight in the runtime notification dock (the + # portrait-mode interceptors are gone with Playlist Mode). + with patch.object(win, "_show_runtime_notification") as note: + win._runtime_alert_primary("beam lost", True) + win._runtime_alert_secondary("shutter", False) + win._on_http_error("boom") + win._on_sample_camera_error("no frames") + assert note.call_count == 4 + + assert ( + win._detector_error_banner_text(automation=True, message="x") + == "Automation halted: detector error." + ) + assert ( + win._detector_error_banner_text(automation=False, message="x") + == "Manual collection stopped: detector error." + ) + + # Manual unmount respects the hutch PSS gate. + with ( + patch.object(win, "_hutch_blocks_mount", return_value="hutch open"), + patch("aare.gui.main_window.QMessageBox"), + ): + win._on_manual_unmount_requested() + win.daq.unmount.assert_not_called() + with patch.object(win, "_hutch_blocks_mount", return_value=None): + win._on_manual_unmount_requested() + win.daq.unmount.assert_called_once() + # Motion watch: only the robot station switches to the combined # beamline view. Moving no longer does (users kept losing the sample # camera on short gonio moves), and busy alone never does — Sample @@ -383,80 +412,6 @@ def test_idle_timeout_does_not_close_while_automation_active(qtbot, mock_ui_stat win.close.assert_not_called() -def test_cleanup_returns_from_portrait_mode(qtbot, mock_ui_state): - with ( - patch("requests.get"), - patch("aare.gui.main_window.DAQWorker"), - patch("aare.gui.main_window.PredictionSubscriber"), - patch("aare.gui.main_window.VideoThread"), - patch("aare.gui.main_window.JFJochDBusClient"), - patch("aare.gui.main_window.jwt.decode") as mock_jwt, - ): - mock_jwt.return_value = { - "sub": "testuser", - "staff": True, - "pgroups": ["p123"], - "session": 15, - } - fake_token = "header.payload.signature" - - win = MainWindow( - base_url=None, - token=fake_token, - default_image=None, - zmq_addr=None, - pred_zmq_addr=None, - beamline_cam_addr=None, - gonio_cam_addr=None, - gonio_cam_id=None, - ) - qtbot.addWidget(win) - - win.enter_portrait_mode() - assert win.content_stack.currentWidget() is win.portrait_mode_page - - win.cleanup() - - assert win.content_stack.currentWidget() is win._standard_main_page - - -def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state): - with ( - patch("requests.get"), - patch("aare.gui.main_window.DAQWorker"), - patch("aare.gui.main_window.PredictionSubscriber"), - patch("aare.gui.main_window.VideoThread"), - patch("aare.gui.main_window.JFJochDBusClient"), - patch("aare.gui.main_window.jwt.decode") as mock_jwt, - ): - mock_jwt.return_value = { - "sub": "testuser", - "staff": True, - "pgroups": ["p123"], - "session": 15, - } - fake_token = "header.payload.signature" - - win = MainWindow( - base_url=None, - token=fake_token, - default_image=None, - zmq_addr=None, - pred_zmq_addr=None, - beamline_cam_addr=None, - gonio_cam_addr=None, - gonio_cam_id=None, - ) - qtbot.addWidget(win) - - win.enter_compact_automation_view() - assert win.content_stack.currentWidget() is win.compact_automation_page - - win.cleanup() - - assert win.content_stack.currentWidget() is win._standard_main_page - - def _make_window(qtbot): win = MainWindow( base_url=None, @@ -695,15 +650,14 @@ def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state, } win = _make_window(qtbot) - views = (win.sample_camera, win.compact_sample_camera, win.portrait_sample_camera) - # Nothing is shown in tests, so pretend every view is on screen to reach + # Nothing is shown in tests, so pretend the view is on screen to reach # the paint branch. monkeypatch.setattr(type(win.sample_camera), "isVisible", lambda self: True) win.prediction_thread = MagicMock() win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888)) - assert all(v.pixmap_item.pixmap().width() == 4 for v in views) + assert win.sample_camera.pixmap_item.pixmap().width() == 4 # The ack is what paces the subscriber; it must fire after every frame. win.prediction_thread.notify_frame_displayed.assert_called_once() diff --git a/tests/unit/gui/test_qt_override_reduction.py b/tests/unit/gui/test_qt_override_reduction.py index 83934a53..99231ec8 100644 --- a/tests/unit/gui/test_qt_override_reduction.py +++ b/tests/unit/gui/test_qt_override_reduction.py @@ -6,7 +6,6 @@ from PySide6.QtCore import QEvent, QPointF, Qt from PySide6.QtGui import QMouseEvent from aare.gui.panels.fluorescence_panel import FluorescencePanel -from aare.gui.panels.portrait_mode import PlayPauseButton from aare.gui.widgets.baton_request_dialog import BatonPendingDialog, BatonRequestDialog from aare.gui.widgets.value_label import ValueLabel from aare.gui.widgets.video_image import VideoGraphicsView @@ -86,14 +85,6 @@ def test_video_view_shortcuts_replace_keypress_override(qtbot): qtbot.keyClick(view, Qt.Key.Key_F) # fit_to_view: just must not raise -def test_play_pause_button_paints_without_hover_overrides(qtbot): - btn = PlayPauseButton() - qtbot.addWidget(btn) - btn.set_running(True) - # grab() forces a real paintEvent pass over the underMouse() branch - assert not btn.grab().isNull() - - def test_value_label_inherits_click(qtbot): label = ValueLabel("Energy", "keV") qtbot.addWidget(label) -- 2.54.0 From e31b0e9a6f7bd8adb0ce60e1cfe4b8eefecbe2d2 Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 11:53:49 +0200 Subject: [PATCH 04/12] fix: cast the mocked daq.unmount for basedpyright on CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's basedpyright (latest from the index, newer than the local venv) types win.daq as the real DAQWorker, so assert_not_called on the patched-in MagicMock flagged the two new gate-test lines. Note: the pytest job segfaults are NOT from this branch — plain origin/main crashes 2/4 runs under PySide6 6.11.2 (what CI installs; uv.lock pins 6.9.0 which is stable). Needs a separate dependency fix. Co-Authored-By: Claude Fable 5 --- tests/unit/gui/test_main_window.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 6a901043..8e79c9be 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -1,3 +1,4 @@ +from typing import cast from unittest.mock import MagicMock, patch import pytest @@ -105,16 +106,18 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): == "Manual collection stopped: detector error." ) - # Manual unmount respects the hutch PSS gate. + # Manual unmount respects the hutch PSS gate. cast: win.daq is a + # patched-in MagicMock, but pyright sees the real DAQWorker type. + unmount_mock = cast(MagicMock, win.daq.unmount) with ( patch.object(win, "_hutch_blocks_mount", return_value="hutch open"), patch("aare.gui.main_window.QMessageBox"), ): win._on_manual_unmount_requested() - win.daq.unmount.assert_not_called() + unmount_mock.assert_not_called() with patch.object(win, "_hutch_blocks_mount", return_value=None): win._on_manual_unmount_requested() - win.daq.unmount.assert_called_once() + unmount_mock.assert_called_once() # Motion watch: only the robot station switches to the combined # beamline view. Moving no longer does (users kept losing the sample -- 2.54.0 From 32f98d7285e4dc78cac80085602a2d3d8385e99f Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 11:58:03 +0200 Subject: [PATCH 05/12] fix: pin PySide6 below 6.10 so CI stops segfaulting CI installs latest index deps (it does not read uv.lock) and resolved PySide6 6.11.2, which segfaults the unit suite in teardown on about half the runs - reproduced on PLAIN origin/main, so it is not this branch's code. 6.9.0 (the uv.lock pin) is stable. Cap the dependency so CI resolves a 6.9.x build; drop the cap once the teardown crash is hunted down. uv.lock re-locked (it was stale at aaredaq 0.22.1). Co-Authored-By: Claude Fable 5 --- pyproject.toml | 5 +- uv.lock | 138 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 103 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a5ca8f0a..16fd6963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,10 @@ dependencies = [ "pyJWT", "pyzmq", "opencv-python-headless", - "PySide6", + # <6.10: 6.11.2 (what the unpinned CI resolves) segfaults the unit suite + # in teardown on ~half the runs — on plain main too, not this branch. + # Drop the cap once the teardown crash is hunted down. + "PySide6<6.10", "requests", "pyepics~=3.5", "redis", diff --git a/uv.lock b/uv.lock index 5e34f601..d754e769 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ overrides = [{ name = "opencv-python", marker = "sys_platform == 'nonexistent'" [[package]] name = "aarecommon" -version = "0.7.3" +version = "0.9.1" source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" } dependencies = [ { name = "jfjoch-client" }, @@ -24,14 +24,14 @@ dependencies = [ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3.tar.gz", hash = "sha256:e85ddf9d3b740ca7536597b26cf839b159e96efc68845a6efb667a7dca5c4e0f" } +sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.9.1/aarecommon-0.9.1.tar.gz", hash = "sha256:64bb008516e1d08851aa7febaaafacb56fd5285f773275c9fc4da17a2a660fb8" } wheels = [ - { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3-py3-none-any.whl", hash = "sha256:22e8c87e2800cbe9223341b43b448f4d0a4f2c199e59c27d17eed2a4b2e11531" }, + { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.9.1/aarecommon-0.9.1-py3-none-any.whl", hash = "sha256:0b052077236d68b2dbdd4e4dcdd28ed5a180ed1c5f4e0e92390c14d8337ec463" }, ] [[package]] name = "aaredaq" -version = "0.22.1" +version = "0.32.0" source = { editable = "." } dependencies = [ { name = "aarecommon" }, @@ -41,7 +41,6 @@ dependencies = [ { name = "bec-ipython-client" }, { name = "bec-lib" }, { name = "fastapi" }, - { name = "gunicorn" }, { name = "httpx" }, { name = "jfjoch-client" }, { name = "matplotlib" }, @@ -54,7 +53,6 @@ dependencies = [ { name = "pyjwt" }, { name = "pyside6" }, { name = "python-multipart" }, - { name = "python-redis-lock" }, { name = "pyzmq" }, { name = "redis" }, { name = "requests" }, @@ -71,6 +69,7 @@ docs = [ test = [ { name = "basedpyright" }, { name = "diff-cover" }, + { name = "fakeredis", extra = ["lua"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -82,18 +81,18 @@ test = [ [package.metadata] requires-dist = [ - { name = "aarecommon", specifier = ">=0.7.3" }, + { name = "aarecommon", specifier = ">=0.9.0" }, { name = "aaredb", specifier = ">=0.83.1", index = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }, { name = "aarelcinfer-client", specifier = "==0.1.1a8" }, - { name = "aarescan-client", specifier = "==1.0.0rc5" }, + { name = "aarescan-client", specifier = ">=1.0.0rc15" }, { name = "basedpyright", marker = "extra == 'test'" }, { name = "bec-ipython-client", specifier = ">=3.130.3" }, { name = "bec-lib", specifier = ">=3.130.3" }, { name = "diff-cover", marker = "extra == 'test'" }, + { name = "fakeredis", extras = ["lua"], marker = "extra == 'test'" }, { name = "fastapi" }, - { name = "gunicorn" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "jfjoch-client", specifier = ">=1.0.0rc165" }, + { name = "jfjoch-client", specifier = ">=1.0.0rc166" }, { name = "matplotlib", specifier = ">=3.10.3" }, { name = "numpy" }, { name = "opencv-python-headless" }, @@ -101,7 +100,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11" }, { name = "pyepics", specifier = "~=3.5" }, { name = "pyjwt" }, - { name = "pyside6", specifier = "==6.9.0" }, + { name = "pyside6", specifier = "<6.10" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, @@ -109,7 +108,6 @@ requires-dist = [ { name = "pytest-qt", marker = "extra == 'test'" }, { name = "pytest-timeout", marker = "extra == 'test'" }, { name = "python-multipart" }, - { name = "python-redis-lock" }, { name = "pyzmq" }, { name = "redis" }, { name = "requests" }, @@ -152,7 +150,7 @@ wheels = [ [[package]] name = "aarescan-client" -version = "1.0.0rc5" +version = "1.0.0rc15" source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" } dependencies = [ { name = "pydantic" }, @@ -160,9 +158,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc5/aarescan_client-1.0.0rc5.tar.gz", hash = "sha256:b31ec4a4809cf48828a498d45080a053270802c142c9a584b06d5467265fb3d6" } +sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc15/aarescan_client-1.0.0rc15.tar.gz", hash = "sha256:2e578da285415880dfe4adfe633493b5f85037b4837031576b544a8a4f64e615" } wheels = [ - { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc5/aarescan_client-1.0.0rc5-py3-none-any.whl", hash = "sha256:571f0863b321962fda12943198a4b269aced818548c276006e7b1d553b043c49" }, + { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc15/aarescan_client-1.0.0rc15-py3-none-any.whl", hash = "sha256:1aa42cec49f850203cf7141d895b9ef8f496c6f56dc71b0223ad8cd920ae35a3" }, ] [[package]] @@ -817,6 +815,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fakeredis" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/6b/ad7db311fea3a62f7e359720cc8c064f06f8f0006e65272f50d3a23d37f6/fakeredis-2.38.0.tar.gz", hash = "sha256:d2abfd24652f86501044499bf08c9d639db050f695eefc06b8b8b6f0bb24dbd6", size = 271116, upload-time = "2026-09-08T21:15:04.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/8e/9a80a8808e8a723aacb3a5d9df07890b46b363a056b372d3cd2e655259fe/fakeredis-2.38.0-py3-none-any.whl", hash = "sha256:d9fb0518c4eaa35f1f2c94df6b4a4c97ff3ca9f43d6cc8112317a9037d244301", size = 167360, upload-time = "2026-09-08T21:15:03.219Z" }, +] + +[package.optional-dependencies] +lua = [ + { name = "lupa" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -929,18 +945,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/af012eda9507494f28b99b077423806c43a11573eb6225dd46f19ae2d263/fpdf2-2.8.8-py3-none-any.whl", hash = "sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17", size = 337000, upload-time = "2026-08-09T23:32:43.728Z" }, ] -[[package]] -name = "gunicorn" -version = "26.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1362,6 +1366,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/4f/ea47579b38165bab7985d666ec49181883599399266ad42ff14e999ac493/Louie-2.0.1-py3-none-any.whl", hash = "sha256:4c6f385bb5084285eab72bf6d5af9576a4e8d37af34e61bd01bcb18dea02c1db", size = 17889, upload-time = "2023-07-22T07:07:53.424Z" }, ] +[[package]] +name = "lupa" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/a6/0f869fbb07c393f15473b1eefefb7b5bec162fb7481803d040ed4dc46002/lupa-2.8.tar.gz", hash = "sha256:d8022641b9ec8ecf2c5ecbe9f47e5a70e0b87c4b5ae921b92cb02a638e0acd08", size = 6156370, upload-time = "2026-04-15T20:08:30.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/21/9be4516ddd22f8eadba336d9ba065d17d79108465ae1b7f71424ab99b9d0/lupa-2.8-cp310-abi3-win32.whl", hash = "sha256:c2a5fd15dc62374e1661a55f01744c9ec1c56f291ba4a0749d3af2174556e78f", size = 1594887, upload-time = "2026-04-15T20:05:23.377Z" }, + { url = "https://files.pythonhosted.org/packages/2d/99/1557c9685d7034d9ce8dd2b54c40a26d6deb7c67c1fdb5c801abd1a02c3f/lupa-2.8-cp310-abi3-win_arm64.whl", hash = "sha256:9e304fb1c50cf23fd8882afbe1aa87525ef8a72667bcab3b37b2bbb2bc542269", size = 1371742, upload-time = "2026-04-15T20:05:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/b7/0a/5a740717f27aa77481e6a61b97cf79d1e0c1ede729b1268caacded915326/lupa-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b12e43c1fb787189dfc28cd604aef0baa2cb95e27da19498d520361d0ace070a", size = 1202376, upload-time = "2026-04-15T20:05:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/1b/75/6b64d0098c64275a801896cb7a6a30e7e653d25fa102c64e747292afcdbb/lupa-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f603391dffb256e36a79fd2044084d5f4b8a0a4c0e5ad291cd3ab3aaf1fd0a", size = 1839271, upload-time = "2026-04-15T20:05:47.399Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2f/0d4f00563046ff616ef6a421f8b776a5ffb327f7b32ed69e856d52b917a8/lupa-2.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f6f41c91366e7d0d474f87d81c1274af861f40812bf729c9f97ab4c8f3c7ac8", size = 2376251, upload-time = "2026-04-15T20:05:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8e/caa83237f427d9e85b7f02c816e7270c9c9571dec1673e06b0180402f70e/lupa-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:f5a6af145b0ea818f01d27bfe2583a4b538570bef61d22c8773e0eccf011234c", size = 1923488, upload-time = "2026-04-15T20:05:52.954Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0b/368f2f0bc750b25c69d4563e44f677925ab5dd3d2887f9b0c15465d21a2a/lupa-2.8-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:f4342f4de76ae7ce2ab0672d36003bdb7e1a33252f293b569298ddd792e70e33", size = 1194056, upload-time = "2026-04-15T20:05:55.794Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0f/c89eb8dd36fdea4e50ae3f7f5275bea3b0cc5d4057b8ee7b3bbc78010422/lupa-2.8-cp312-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:4203fa1659315e939a5304e75001b8cc14234fb3cbb3ed86c049b0cc5d90fcee", size = 1434278, upload-time = "2026-04-15T20:05:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/47/30/c3b4d2cd8733621b404b8a4214e5f852955c4ba632546dc84123bea9ee89/lupa-2.8-cp312-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:81f2d843ce668b653146c007467570210ae44be51dac6926666c51d49536f307", size = 1150068, upload-time = "2026-04-15T20:06:01.04Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/bac12c398519efafc6af84be1974edd0d7a4895fb4735b5c8d615d298595/lupa-2.8-cp312-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3d0cde2c77588d1c60875a4f34f059513476c6e1775351897195b51e0f3df08", size = 1409532, upload-time = "2026-04-15T20:06:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6a/18b52e11962014026e07813530b0b108ee8bc0a2a13ef0eaea5d41dce023/lupa-2.8-cp312-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e0d11b8f3a8dac6413f704fef7161d048bb10c58bdac6cbffa5e60efa56e9a3", size = 1242687, upload-time = "2026-04-15T20:06:06.863Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8e/7fd4eb049875f61429b96780d2eae4700f0e78fe0a52db8edb231b1cd09f/lupa-2.8-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54cff414f21f8cd8c6be4aae52541f3b9cd39602b59e3a3db9b5c9f9f674ff18", size = 1856038, upload-time = "2026-04-15T20:06:09.358Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/37ad9d2773d30f2931890d310a4bdce28d45484206e6f48bc18b0325eabd/lupa-2.8-cp312-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24b4d8af5558e549b70daf1547f5c1c1d664ecea9fc790f83efe5d75e9a93797", size = 1128982, upload-time = "2026-04-15T20:06:12.312Z" }, + { url = "https://files.pythonhosted.org/packages/57/31/c0fd7984c24844ea79caa45c0235f61a06b38fd69a839f6c62770f8d684a/lupa-2.8-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce86dff1ee7f7cf45f5622065ae991949dd7bb1703581cbc58a630137bb7ccf9", size = 1457594, upload-time = "2026-04-15T20:06:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/a28e411be30ec1bf0db1eb0c087eebc73be9e7a1adcfe6ac209861ccc446/lupa-2.8-cp312-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f4d01b2a08c70bbb883a9e082b6b36b89121ed5910b710f1ba11c73295ff4fba", size = 1425721, upload-time = "2026-04-15T20:06:18.009Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c1/359f767c4ae024be30d909fe8a9f0e9af266bad47ce2bd2ed248fb986fcf/lupa-2.8-cp312-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:7f210d5a8353e510ea1199c42cf3cbdd630553bf2bc8fb4c00fea06fdec7c798", size = 1253258, upload-time = "2026-04-15T20:06:21.17Z" }, + { url = "https://files.pythonhosted.org/packages/17/52/473f11790c261fd02bbf318a546fe040e9ec9f677181272fa78d3b4112a4/lupa-2.8-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4f81a02806e7c7ad26d8c6fa222c8bef1b0c1b124347c879be880b41339d41e4", size = 2395272, upload-time = "2026-04-15T20:06:24.137Z" }, + { url = "https://files.pythonhosted.org/packages/94/bf/75c8795655a8836eab6a11a630352c4b7c5dc5c54d075077bc9bffdeee45/lupa-2.8-cp312-abi3-win32.whl", hash = "sha256:360056453a7a4eaa4ac5a204c31a5a014b1eb2ee5490603234d2ba831684f1f2", size = 1606136, upload-time = "2026-04-15T20:06:27.815Z" }, + { url = "https://files.pythonhosted.org/packages/d8/29/11a2cdd612b6f55e506292dfb6ba343216e80a693e7fe3f876ef204ce9c6/lupa-2.8-cp312-abi3-win_arm64.whl", hash = "sha256:1628371c6592a6d5650497a9e31fb2bb3a7e9883c1f301d1111265e484045af9", size = 1364495, upload-time = "2026-04-15T20:06:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/4d/17/fa834b6b09ad17e7df5d0f7715d64877a125a3776ada689751a1f9dc2959/lupa-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:450650f91c48c2415b0d59ab3abfcfda3b6efb5b858205f4d4bda8ad141fa529", size = 1190111, upload-time = "2026-04-15T20:06:32.84Z" }, + { url = "https://files.pythonhosted.org/packages/ab/43/45589901b7d1a0e3a9d91d19a311fb6a56924e8571536c3f2212160fd953/lupa-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27044f3363047f946b3d3aab9157cbd172b3538ada9ec1baef43432bf7d03a78", size = 1812999, upload-time = "2026-04-15T20:06:35.664Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ac/4ade7d15ff5c61758d7943ac6f0a496bf1cc65b6c09f842b52a0702e664c/lupa-2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cf4f064a0e5531afce2d7d750120c10c10f9529139af6ca6150d13151034398", size = 2368731, upload-time = "2026-04-15T20:06:37.959Z" }, + { url = "https://files.pythonhosted.org/packages/0c/27/05f950d15b8ab120b39c43588b438ff3ace70c1b1b0225a960393a497483/lupa-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:281bedc5deb92d31e649a3552edd662449365a635904fa4d5cb4509c7245e34e", size = 1941809, upload-time = "2026-04-15T20:06:40.302Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3f/19f83c3a0c84dc8bea8a58e7416dca6a3ede662c33c8d1ec758e5afc754a/lupa-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fc9da0145ecb0083ef5ff9975116cc784bd0258bdc2bd131ba15483ce18398", size = 1201203, upload-time = "2026-04-15T20:06:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/89/0f/a14f0073f09610158038582e230618a48c14da6bd88185289461aa4cb854/lupa-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e18afed57955b41130e269c78f53d4123ab86e236b53816f4cbffa25cb5d30", size = 1806210, upload-time = "2026-04-15T20:06:45.486Z" }, + { url = "https://files.pythonhosted.org/packages/2f/14/48fff156c63a136001a7620878af7d31aa07e66b495ed621e3eddd73c294/lupa-2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc47f536ac13a79cef47d29a2b205576a22841f042a2bcec1676b95806e7706a", size = 2359005, upload-time = "2026-04-15T20:06:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/fe/18/3ac638ec90edf178242b8a2b2f00f8adae694248c03a26341ef941bb746e/lupa-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:ce9404c661dbac65cc9bed351ad45e797af93d30d70be309a3fa8209ac86d93b", size = 1936754, upload-time = "2026-04-15T20:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/5ee5fed6ea7459a671196359ce04bfeeaf26be1dac8ff24bf28e5c7a6e81/lupa-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:348c3f8ecabb6324dcbc05c2740d762ef8fcec7b06c79e45262ab97a217684e3", size = 1209388, upload-time = "2026-04-15T20:06:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b1/67a940d5542cb0384b443fe951b5a83ea9340d1333a733a258fdd1c619ba/lupa-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951496471056061598a7d1729a6cdf48d662fec777a9f2d8aa5a1e62fd30e5a5", size = 1826821, upload-time = "2026-04-15T20:06:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a2/b354e5ba3b911ec50686003dc8897e892b9e8c5c036b33219b03d54c4daf/lupa-2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a591b9947ca347b41a63370e121d6e2b1458fe6dde9ae065029ec10a37f25ff4", size = 2366893, upload-time = "2026-04-15T20:06:58.9Z" }, + { url = "https://files.pythonhosted.org/packages/8e/52/d76066401f29539df5352f70ecded66576f32933b6045cd0bfc56cb770b9/lupa-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:3903c9cf628dae2f56405503247b77a61a3a61bd2dda470e336950c74776d55d", size = 1994716, upload-time = "2026-04-15T20:07:19.194Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/3efc437a4361c16d25e66478c50357c9a8e8ecfb718fe749eb9ca3176ef6/lupa-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f711a8ab0486b9ac6fdda94a22ddcfbc9f0d4a27e3a8cf1bf79c6e48b33017c1", size = 1251217, upload-time = "2026-04-15T20:07:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f4/2e9f8ecbaca854bfdf14af8a9b505ec0cbc640377b3b218921594b7563cd/lupa-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc51250e76367a3e27fcd01dc769b9bfcbbc34f48df48dde53d6af6e75b7eaa5", size = 1814701, upload-time = "2026-04-15T20:07:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/4000b1acaa8b1f3827fcff0cfcdff44d3befddda42cab7e685a49689b5a1/lupa-2.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8a22088a552828958603323f0a5c4b3e11e03b75d0bf4c965ef879de9b60a8d", size = 2348414, upload-time = "2026-04-15T20:07:07.285Z" }, + { url = "https://files.pythonhosted.org/packages/d5/78/26ee48d3890cddf03cefb65f433e3492759c0b3c0582180755bddbaab7bd/lupa-2.8-cp314-cp314t-win32.whl", hash = "sha256:4f7c553c1d8cfffbe85d81daef730d12cae4b6002d457542914da0ac8a1145b3", size = 1831611, upload-time = "2026-04-15T20:07:09.752Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/4a5cc64a3cad22821ae4c3f7a90456a08ca19457d8354f4abf46ad03c7e8/lupa-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:d8766aff03a78c80ad2d188a8bdb216de5ec838359cd87e05bbdfa56394a6105", size = 2209250, upload-time = "2026-04-15T20:07:11.906Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/cdcb654daf668192aaf36b0aeb94f2281dad092aaa5003688691131736ea/lupa-2.8-cp314-cp314t-win_arm64.whl", hash = "sha256:91d622777febda3ab1bed1d45295f2f32a4680c7b3d7caf8c669998ed5c44118", size = 1126735, upload-time = "2026-04-15T20:07:15.434Z" }, + { url = "https://files.pythonhosted.org/packages/1d/44/de1961ad38e17cd326a53c246c7e3b91178ed578f4cf22ffcd5e7e11b041/lupa-2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b036738282a5acd2e71fdddb317c9df8b87c1673aa57f403d05fcc2be8abc4ba", size = 1186020, upload-time = "2026-04-15T20:07:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/13/c2/276f0b9dc8bcc5a8a58af5316dfa0e6f56be3613dd6dbcc8d3d2cb6559ba/lupa-2.8-cp39-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ac6b6e8d0e617e26a98cbb44880bcd75de5d32b3ad7b3b3793583909292b47ed", size = 1468944, upload-time = "2026-04-15T20:07:37.782Z" }, + { url = "https://files.pythonhosted.org/packages/63/38/52934e52a5180dc6425d20284d004fe4b27a4f9171a82dc99fb67af250bf/lupa-2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ba3a7dd839f90c3d2e53bebe3c192b1f3f9fd720a6781256405123211fd0dce6", size = 1172998, upload-time = "2026-04-15T20:07:40.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/82/76b3809bd0839d9b3b4ec58d06591e08f17337b6d9576877cb9d48b34e94/lupa-2.8-cp39-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7edb13a7a5250b5c6c22d1495d9e842b5c9fc5081c8fe6b5efe2112fe3e41f9", size = 1449975, upload-time = "2026-04-15T20:07:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/16/07/2f89d54f747c67c23b4b9ae4aa8c8dd06bb409155dedcf406157f2736b66/lupa-2.8-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:891f72e0bffbed1e4175f975aeb2a083956586a100066525e1be485f617f7b25", size = 1281944, upload-time = "2026-04-15T20:07:46.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bd/7375d2b0fcae79d806baf52a76f26c96964593f58e1372d13ae5ac09c676/lupa-2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a295f87b5b7ebbfd5191932e8cb0e51df3c7769101ac6b6c7d7c9fb27bfd1307", size = 1910455, upload-time = "2026-04-15T20:07:49.75Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0c/8abb3bc0e08b311fc01db05b6e9f9ff31a8f65e4fc3f0aeb05cfef75c8ac/lupa-2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4fe5d7a810b64ea8511eb885fc8cdde042ee5ff7b7d08ae78f32449756acb177", size = 1155548, upload-time = "2026-04-15T20:07:52.657Z" }, + { url = "https://files.pythonhosted.org/packages/80/2e/9eeecd3f493099721c1d3f31beeca23a4237db1a54223684df4dc96aa1bd/lupa-2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bfc470012ef66ad064c7bd77416af03a3452ef630b04b9012595ea13f2e54518", size = 1489232, upload-time = "2026-04-15T20:07:54.92Z" }, + { url = "https://files.pythonhosted.org/packages/c3/13/731c99dc2e7652ae818a6de45bdf0142049f7cb566049061c898355f1891/lupa-2.8-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:250e035fdaffe8c87093e3ebc206ac29a26131b1568ea711d780c26001ce96e7", size = 1466321, upload-time = "2026-04-15T20:07:57.627Z" }, + { url = "https://files.pythonhosted.org/packages/de/71/3ad8cc4fc05a77dc0d3f7079348bd1cad4675a0d14c24f8e6a3ce5f008f7/lupa-2.8-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b9bddb09acfffb4f828f790f444b11dc0cca591afea1a244d9329eea2d20c003", size = 1288577, upload-time = "2026-04-15T20:07:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b2/1175f6d0aa7b68627fbe2f58bd1e8bea36a89d10dfd67671d2b024c96162/lupa-2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2e64acbbd47e9b82a64405a39e0d2b36a5a7dad8ab41c0f3437f572f7d282ba3", size = 2444866, upload-time = "2026-04-15T20:08:02.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/e78df680c7a0ea452daac07467ca188d63c2c00ca1c884c0a50e27eb83b5/lupa-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e4e5103bbddcdd2458fb2ccae6c8ba11c9997c711d7e379e0d45551d109c76", size = 1778509, upload-time = "2026-04-15T20:08:21.784Z" }, + { url = "https://files.pythonhosted.org/packages/e6/23/0e53cabb16b2a8aa9cf1fde499c097d8942c5dab709fc8e921f3b824b18b/lupa-2.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7667001804657496dee9feced2daae5000b4604a3218dd8e6b7b754982ba88b8", size = 2300480, upload-time = "2026-04-15T20:08:24.394Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/0271227eab939921a12ebba5d17aa4cd18346aa534ca7f5da09cd0b63dd4/lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878", size = 1847445, upload-time = "2026-04-15T20:08:27.031Z" }, +] + [[package]] name = "markdown" version = "3.10.3" @@ -2379,18 +2442,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] -[[package]] -name = "python-redis-lock" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/a3/1c5872ce7dafef9b4f31fbbf54938d9be632e3b7d53146b0eac26150ec97/python_redis_lock-4.0.1.tar.gz", hash = "sha256:57995e13dce196301138d710d4f132d667c200cd814718e081bf299d6cf11d70", size = 164986, upload-time = "2026-04-08T15:41:18.551Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/b5/d2bb0d46b31620753ba2c976f97fb24d7e0f2fb1390a911f632d43fe1c1b/python_redis_lock-4.0.1-py3-none-any.whl", hash = "sha256:1d39f579ff3f96d7c14e523680951aa60eb4d397f4ad1bc72a3440b7460d18d5", size = 12358, upload-time = "2026-04-08T15:41:16.822Z" }, -] - [[package]] name = "python-slugify" version = "8.0.4" @@ -2742,6 +2793,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "soupsieve" version = "2.9.2" -- 2.54.0 From c203a657c5694cd3f67ceb079ed3a32096c7c5e9 Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 12:15:25 +0200 Subject: [PATCH 06/12] fix: re-pin PySide6 after the beamline-plugin install in CI The bec plugin editable installs upgrade PySide6 to 6.11.x past the pyproject cap, which brings the teardown segfault back in exactly the test-with-beamline-plugins jobs. Reinstall the capped version after the plugin install. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6cf003b3..efe0318d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -102,6 +102,10 @@ jobs: git clone https://gitea.psi.ch/bec/${{ matrix.plugin_repo }}.git uv pip install -e ./mx_bec uv pip install -e ./${{ matrix.plugin_repo }} + # The bec plugin deps drag PySide6 up to >=6.10, which segfaults + # the unit suite in teardown (see the pyproject pin). Force the + # project's capped version back after the plugin install. + uv pip install "pyside6<6.10" - name: Run Pytest env: -- 2.54.0 From b29b5f121e1cb011b22c73fe85b78b190457ddb7 Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 12:23:26 +0200 Subject: [PATCH 07/12] chore: retrigger CI (pxi_bec job hit the known eventFilter teardown flake) Co-Authored-By: Claude Fable 5 -- 2.54.0 From 7fb9f8bf9cf2623591417fe7d3774f71caa10f64 Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 12:23:26 +0200 Subject: [PATCH 08/12] chore: retrigger CI (pxi_bec job hit the known eventFilter teardown flake) Co-Authored-By: Claude Fable 5 -- 2.54.0 From 26fbe2ca421eafd550b2696173fb40c6d8114abc Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 13:28:05 +0200 Subject: [PATCH 09/12] fix: give deferred singleShot callbacks a receiver context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the long-standing cross-test SystemError flake ("QPushButton returned NULL", CI failing test_title_label after an unrelated file): TitleLabel schedules QTimer.singleShot(0, self._apply_collapsed) at construction; when the banner is deleted before the event loop spins (what every panel test does), the pending timer fires into the dead C++ object and poisons whatever test runs next. Deterministic repro added as a regression test. The receiver-context overload singleShot(0, self, ...) auto- cancels the callback when the widget dies. Same fix for the two deferred main-window callbacks (dock split, floating-dock enlarge). Note for the record: the LOCAL segfault storm this afternoon was the dev machine's disk hitting 100% full (uv cache at 15G) — Bus errors from mmap on a full disk, unrelated to any code. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 4 ++-- src/aare/gui/widgets/title_label.py | 8 ++++++-- tests/unit/gui/test_title_label.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index ade38ac7..e91d5dde 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2865,7 +2865,7 @@ class MainWindow(QMainWindow): if not self._default_dock_split_done: self._default_dock_split_done = True if not self.state_manager.settings.value("main_window/state"): - QTimer.singleShot(0, self._apply_default_dock_split) + QTimer.singleShot(0, self, self._apply_default_dock_split) def _apply_default_dock_split(self) -> None: self.resizeDocks( @@ -3022,7 +3022,7 @@ class MainWindow(QMainWindow): # Pop-outs open enlarged instead of keeping the cramped docked # size. Deferred: the window is mid-reparent while the signal # fires. - QTimer.singleShot(0, lambda d=dock: self._enlarge_floating_dock(d)) + QTimer.singleShot(0, dock, lambda d=dock: self._enlarge_floating_dock(d)) def _enlarge_floating_dock(self, dock: QDockWidget) -> None: if not dock.isFloating(): diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 6c5d5c28..0abc91fa 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -89,8 +89,12 @@ class TitleLabel(QLabel): if settings.value(self._settings_key, default_collapsed, type=bool): self._collapsed = True # Deferred: the panel adds its other widgets after constructing - # the TitleLabel, so siblings don't exist yet. - QTimer.singleShot(0, self._apply_collapsed) + # the TitleLabel, so siblings don't exist yet. `self` as the + # receiver context, or the pending timer outlives a deleted + # banner and fires into the dead C++ object — the source of the + # long-standing "QPushButton returned NULL" SystemError flake + # poisoning whatever test runs next. + QTimer.singleShot(0, self, self._apply_collapsed) def paintEvent(self, event): # QSS has no text-shadow, so paint by hand: the QSS background box diff --git a/tests/unit/gui/test_title_label.py b/tests/unit/gui/test_title_label.py index 168dc31d..daf48ecf 100644 --- a/tests/unit/gui/test_title_label.py +++ b/tests/unit/gui/test_title_label.py @@ -73,3 +73,19 @@ def test_not_collapsible_by_default(qtbot): title = TitleLabel("Plain", panel) grid.addWidget(title, 0, 0) assert not hasattr(title, "toggle_button") + + +def test_pending_collapse_timer_dies_with_the_banner(): + """The deferred _apply_collapsed must not fire into a deleted banner — + that was the historic "QPushButton returned NULL" / SystemError flake + poisoning whichever test ran next (singleShot without receiver context + outlives the widget).""" + from PySide6.QtWidgets import QApplication + + _remove_key() + host = QWidget() + TitleLabel("DoomedBanner", host, collapsible=True, default_collapsed=True) + # Python-owned host: del cascades the C++ delete to the banner while + # its singleShot(0) is still pending. + del host + QApplication.processEvents() # must not raise into the event loop -- 2.54.0 From 33239bf51f9cb5aa070ef7b9664e82e7a5198885 Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 14:37:19 +0200 Subject: [PATCH 10/12] refactor: drop the camera-region splitter, side columns fixed again User decision: the drag-resize bars beside the Sample camera view are not wanted - the side panels don't need resizing. Back to the plain HBox with zoom-scaled fixed column widths; the View-menu collapse toggles and the camera minimum-width clamp stay. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 42 +++++++++--------------------- tests/unit/gui/test_main_window.py | 8 +++--- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index e91d5dde..21e12fc4 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -54,7 +54,6 @@ from PySide6.QtWidgets import ( QScrollArea, QSizePolicy, QSlider, - QSplitter, QTabWidget, QToolBar, QToolTip, @@ -448,24 +447,13 @@ class MainWindow(QMainWindow): self.left_column_layout.addWidget(self.left_column_tabs) self.left_column_layout.addStretch() - # Splitter instead of a plain HBox: the side columns kept their fixed - # widths while the camera region could only grow vertically; dragging - # the handles now resizes the camera width too. Collapsing to zero is - # off — the View-menu toggles are the collapse feature. - # No local handle styling: the app stylesheet's QSplitter::handle rule - # already paints the same line as the QMainWindow::separator between - # docks, so both resize bars share the one definition in styles.py. - self.center_splitter = QSplitter(Qt.Orientation.Horizontal, top_widget) - self.center_splitter.setChildrenCollapsible(False) - top_widget_layout.addWidget(self.center_splitter) - - self.center_splitter.addWidget(self.collection_controls_scroll) + # Plain HBox, no splitter: drag-resizing the side columns was tried + # and rejected (user: the panels don't need resizing — the View-menu + # toggles collapse them, and the camera takes whatever is left). + top_widget_layout.addWidget(self.collection_controls_scroll) self.collection_controls_scroll.setWidget(self.left_column) - # AsNeeded (was AlwaysOff): the splitter can now make the viewport - # narrower than the fixed-width column, and clipped controls must - # stay reachable. self.collection_controls_scroll.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded + Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.collection_controls_scroll.setWidgetResizable(True) # No frame: its border drew a line above the tab bar (Dewar tabs have @@ -517,7 +505,7 @@ class MainWindow(QMainWindow): # The camera region's natural minimum (~284px) came from the widest # page's controls row (title + refresh button) and the tab labels — # not from the video views, which rescale freely like they do in - # height. Clamp to 1/3 so the splitter can shrink all four camera + # height. Clamp to 1/3 so a narrow window can shrink all four camera # views that far; past the natural width the tab bar scrolls and the # controls rows clip. self.video_tab.setMinimumWidth(self.video_tab.minimumSizeHint().width() // 3) @@ -529,24 +517,21 @@ class MainWindow(QMainWindow): # self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras) # self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view") - self.center_splitter.addWidget(self.video_tab) + top_widget_layout.addWidget(self.video_tab, 1) self._start_axis_camera_threads() self.beamline_controls_scroll = NoWheelScrollArea(top_widget) self.beamline = BeamlineControls(self.beamline_controls_scroll) - self.center_splitter.addWidget(self.beamline_controls_scroll) + top_widget_layout.addWidget(self.beamline_controls_scroll) self.beamline_controls_scroll.setWidget(self.beamline) # Resizable so the column shrinks when panels collapse; without it the # scrollbar keeps dead range below the collapsed panels. self.beamline_controls_scroll.setWidgetResizable(True) self.beamline_controls_scroll.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded + Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - # Only the camera region absorbs a window resize; the side columns - # keep whatever width the user dragged. - self.center_splitter.setStretchFactor(1, 1) - # Gutter math per column (10px scrollbar + frame) lives here too. + # Gutter math per column (10px scrollbar + frame) lives here. self._apply_zoom_widths() self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[])) @@ -1769,13 +1754,10 @@ class MainWindow(QMainWindow): left = round(self.data_collection.set_width * s) self.data_collection.setFixedWidth(left) self.left_column.setFixedWidth(left) - self.collection_controls_scroll.setMaximumWidth(left + 10) + self.collection_controls_scroll.setFixedWidth(left + 10) right = round(self.beamline.set_width * s) self.beamline.setFixedWidth(right) - self.beamline_controls_scroll.setMaximumWidth(right + 12) - # Re-seat the splitter: open both columns to their (new) full width, - # the camera region takes the rest. - self.center_splitter.setSizes([left + 10, 10_000, right + 12]) + self.beamline_controls_scroll.setFixedWidth(right + 12) @Slot() def use_legacy_theme(self) -> None: diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 8e79c9be..36b718f3 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -73,11 +73,9 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): win.data_collection._emit_change_energy() assert sent and abs(sent[0] - 12400.0) < 1e-6 - # Side panels collapse from the View menu (no on-screen buttons), and - # the camera region sits in a splitter so its width is drag-resizable. - assert win.center_splitter.widget(1) is win.video_tab - # The clamp lets the splitter shrink the camera region well below the - # natural minimum the tab labels + controls rows would demand. + # Side panels collapse from the View menu (no on-screen buttons). + # The clamp lets a narrow window shrink the camera region well below + # the natural minimum the tab labels + controls rows would demand. assert 0 < win.video_tab.minimumWidth() < win.video_tab.minimumSizeHint().width() win._show_left_panel_action.trigger() assert win.collection_controls_scroll.isHidden() -- 2.54.0 From 5cb57f4737812f9a781334be88eef46bf2586868 Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 14:37:19 +0200 Subject: [PATCH 11/12] feat: status-bar readouts wrap on narrow windows instead of clipping All readouts (sharpness ... Cryo | Fast Shutter | State | p-group | Session) now live in one FlowLayout host: when the window gets narrow the bar grows extra rows and every value stays readable, where QStatusBar used to clip the permanent group and hide the rest. FlowLayout is the canonical Qt example trimmed down (Qt ships no built-in); the host pins its minimum height to the wrapped height because QStatusBar ignores heightForWidth. The old left/right addWidget vs addPermanentWidget split is gone with the wrap. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/flow_layout.py | 93 +++++++++++++++++++++++++++++ src/aare/gui/widgets/status_bar.py | 28 +++++---- tests/unit/gui/test_status_bar.py | 36 ++++++++--- 3 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 src/aare/gui/widgets/flow_layout.py diff --git a/src/aare/gui/widgets/flow_layout.py b/src/aare/gui/widgets/flow_layout.py new file mode 100644 index 00000000..7799bb0c --- /dev/null +++ b/src/aare/gui/widgets/flow_layout.py @@ -0,0 +1,93 @@ +from PySide6.QtCore import QPoint, QRect, QSize, Qt +from PySide6.QtWidgets import QLayout, QWidget + + +class FlowLayout(QLayout): + """Left-to-right layout that wraps to new rows when the width runs out — + Qt ships no built-in for this, so this is the canonical Qt example + trimmed down. Added for the status-bar readouts: on narrow windows they + were simply clipped; now they wrap and stay readable. + """ + + def __init__(self, parent=None, hspacing: int = 10, vspacing: int = 2): + super().__init__(parent) + self._items = [] + self._hspace = hspacing + self._vspace = vspacing + + def addItem(self, item): + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, index): + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index): + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self): + return Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) + + def _do_layout(self, rect, test_only: bool) -> int: + margins = self.contentsMargins() + x = rect.x() + margins.left() + y = rect.y() + margins.top() + right = rect.right() - margins.right() + line_height = 0 + for item in self._items: + # Hidden widgets must not leave gaps in the row. + if item.isEmpty(): + continue + hint = item.sizeHint() + next_x = x + hint.width() + if next_x - 1 > right and line_height > 0: + x = rect.x() + margins.left() + y += line_height + self._vspace + next_x = x + hint.width() + line_height = 0 + if not test_only: + item.setGeometry(QRect(QPoint(x, y), hint)) + x = next_x + self._hspace + line_height = max(line_height, hint.height()) + return y + line_height + margins.bottom() - rect.y() + + +class FlowHost(QWidget): + """Widget owning a FlowLayout. Parents like QStatusBar ignore + heightForWidth, so the host pins its own minimum height to the wrapped + height on every resize — the bar then grows to fit the rows. + """ + + def __init__(self, parent=None, hspacing: int = 10, vspacing: int = 2): + super().__init__(parent) + self._flow = FlowLayout(self, hspacing=hspacing, vspacing=vspacing) + self._flow.setContentsMargins(0, 0, 0, 0) + + def add_widget(self, widget: QWidget) -> None: + self._flow.addWidget(widget) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self._flow.heightForWidth(self.width())) diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 6d939345..4b31e3f8 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -11,6 +11,7 @@ from aare.gui.constants import LOGGER_NAME from aare.gui.styles import THEME_SUNRISE, status_colors from aare.gui.widgets.baton_request_dialog import BatonRequestDialog from aare.gui.widgets.clickable_label import ClickableLabel +from aare.gui.widgets.flow_layout import FlowHost from aare.gui.widgets.pgroup_dialog import PGroupDialog from aare.gui.widgets.value_label import ValueLabel @@ -92,13 +93,19 @@ class StatusBar(QStatusBar): self.exp_shutter_label = ValueLabel("ExpHutch Shutter", "", self) - # Passive readouts group left (addWidget), operable controls group - # right (addPermanentWidget) wearing the shared ClickableLabel hover - # affordance — fixed order Cryo | Fast Shutter | State | p-group | - # Session. No showMessage is ever used here, so the left section is - # never hidden by temporary messages. - self.addWidget(self.message_label) + # Every readout lives in ONE wrapping flow host: on narrow windows + # QStatusBar used to clip/hide the labels outright, now they wrap to + # extra rows and stay readable. Fixed order: passives first, then the + # operables (shared ClickableLabel hover affordance) — Cryo | Fast + # Shutter | State | p-group | Session at the end. The old left/right + # split (addWidget vs addPermanentWidget) is gone with the wrap; no + # showMessage is ever used here, so nothing hides the host. + # TODO(cryo): passive for now, but placed with the operables because a + # cryo operation (fill/anneal menu) is planned; when it gets a click + # handler, swap it to a ClickableLabel so it inherits the affordance. + self.info_host = FlowHost(self) for widget in ( + self.message_label, self.sharpness, self.samcam_fps, self.flux, @@ -108,19 +115,14 @@ class StatusBar(QStatusBar): self.exp_shutter_label, self.tell_state_label, self.busy_label, - ): - self.addWidget(widget) - # TODO(cryo): passive for now, but placed with the operables because a - # cryo operation (fill/anneal menu) is planned; when it gets a click - # handler, swap it to a ClickableLabel so it inherits the affordance. - for widget in ( self.cryo_label, self.shutter_label, self.state_label, self.pgroup_label, self.session_label, ): - self.addPermanentWidget(widget) + self.info_host.add_widget(widget) + self.addWidget(self.info_host, 1) def set_theme(self, theme: str) -> None: """Adopt the theme's flag colors: recolor the connection message and diff --git a/tests/unit/gui/test_status_bar.py b/tests/unit/gui/test_status_bar.py index 1f5bcbde..dc4f4e7f 100644 --- a/tests/unit/gui/test_status_bar.py +++ b/tests/unit/gui/test_status_bar.py @@ -1,10 +1,12 @@ -"""Status bar layout contract: passive readouts left, operable controls -right, and every operable label wears the shared hover affordance.""" +"""Status bar layout contract: every readout lives in one wrapping flow +host (narrow windows wrap instead of clipping), and every operable label +wears the shared hover affordance.""" from aarecommon.models.models import TokenData from PySide6.QtCore import QEvent, QPointF, Qt from PySide6.QtGui import QEnterEvent +from aare.gui.widgets.flow_layout import FlowLayout from aare.gui.widgets.status_bar import StatusBar @@ -62,18 +64,34 @@ def test_state_menu_gates_beam_location_for_non_staff(qtbot, daq_status_factory, assert not _state_menu_entries(non_staff)["Beam location (admin mode only)"] -def test_passives_left_operables_right(qtbot): +def test_readouts_wrap_instead_of_clipping(qtbot): bar = _bar(qtbot) - # QStatusBar hides only the non-permanent (left) section behind a - # temporary message — probe the grouping through that behavior. - bar.showMessage("probe") - assert not bar.flux.isVisible() - assert not bar.busy_label.isVisible() + host = bar.info_host + flow = host.layout() + assert isinstance(flow, FlowLayout) # narrows Optional for the checker + # Every readout is in the flow host, none clipped away by QStatusBar. for label in ( + bar.flux, + bar.busy_label, bar.cryo_label, bar.shutter_label, bar.state_label, bar.pgroup_label, bar.session_label, ): - assert label.isVisible() + assert label.parentWidget() is host + # Narrow width -> the flow reports a taller (multi-row) height than one + # row, and resizing the host pins its minimum height to the wrapped + # height so the bar grows instead of cutting labels off. + one_row = flow.heightForWidth(100_000) + wrapped = flow.heightForWidth(200) + assert wrapped > one_row + host.resize(200, wrapped) + assert host.minimumHeight() >= wrapped + # Layout bookkeeping the canonical example demands. + assert flow.count() > 0 + assert flow.itemAt(0) is not None + assert flow.itemAt(9999) is None + taken = flow.takeAt(flow.count() - 1) + assert taken is not None + assert flow.takeAt(9999) is None -- 2.54.0 From 145b94b7eff87c9585681ba4c6c5acbcb5ccd613 Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 14:40:49 +0200 Subject: [PATCH 12/12] fix: suppress takeAt override complaint from newer basedpyright stubs Qt's takeAt contract is nullptr-if-out-of-range; the newer PySide stubs on CI declare a non-Optional return. Suppress rather than lie. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/flow_layout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/aare/gui/widgets/flow_layout.py b/src/aare/gui/widgets/flow_layout.py index 7799bb0c..20378222 100644 --- a/src/aare/gui/widgets/flow_layout.py +++ b/src/aare/gui/widgets/flow_layout.py @@ -24,7 +24,9 @@ class FlowLayout(QLayout): def itemAt(self, index): return self._items[index] if 0 <= index < len(self._items) else None - def takeAt(self, index): + # Newer PySide stubs declare a non-Optional return, but Qt's own takeAt + # contract is "nullptr if out of range" — suppress, don't lie. + def takeAt(self, index): # pyright: ignore[reportIncompatibleMethodOverride] return self._items.pop(index) if 0 <= index < len(self._items) else None def expandingDirections(self): -- 2.54.0