From a9165a604c957715f71a14ff9f2864d301e0b5d0 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 11 Aug 2026 09:26:52 +0200 Subject: [PATCH] feat: float alert banners over content as anchored toasts Banners in the root layout shifted the whole UI on every message; now they float over the content root (repositioned on resize) and the baton toast anchors as a compact pill under the camera view, auto-clearing in 4s instead of 10s. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 36 +++++++++++++------ src/aare/gui/widgets/alert_banner.py | 52 +++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 7cf2f43a..cb47f0f9 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -153,6 +153,17 @@ class ClickableCursorFilter(QObject): return False +class _AlertBannerHost(QWidget): + """Content root that repositions the floating alert banners on resize. + A plain resizeEvent override instead of event filters: filters firing + during widget teardown corrupted PySide in the test suite.""" + + def resizeEvent(self, event): + super().resizeEvent(event) + for banner in self.findChildren(AlertBanner): + banner.reposition() + + class MainWindow(QMainWindow): sample_geometry = Signal(SampleGeometryModel) @@ -277,17 +288,19 @@ class MainWindow(QMainWindow): self._separator_hint_timer.setInterval(SEPARATOR_HINT_DELAY_MS) self._separator_hint_timer.timeout.connect(lambda: self._set_separator_hint(True)) - root_widget = QWidget(parent=self) + root_widget = _AlertBannerHost(parent=self) root_widget.setObjectName("mainContentRoot") root_layout = QVBoxLayout(root_widget) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) + # Banners float over the content instead of sitting in root_layout, so + # messages (e.g. "Baton acquired!") no longer shift the whole UI. self.alert_banner = AlertBanner(parent=root_widget) - root_layout.addWidget(self.alert_banner) + self.alert_banner.float_over(root_widget) self.alert_banner_secondary = AlertBanner(parent=root_widget) - root_layout.addWidget(self.alert_banner_secondary) + self.alert_banner_secondary.float_over(root_widget) self.content_stack = QStackedWidget(parent=root_widget) root_layout.addWidget(self.content_stack, 1) @@ -424,6 +437,9 @@ class MainWindow(QMainWindow): self.sample_camera = SampleCameraImageLabel( geom=geom, raster=self.raster, parent=top_widget, default_image=default_image ) + # Baton toasts sit as a compact pill under the camera view instead of + # a full-width bar at the top of the window. + self.alert_banner.anchor_to(self.sample_camera) self.beamline_view = VideoGraphicsView() self.beamline_view_panel = AxisVideoPanel( @@ -2635,10 +2651,10 @@ class MainWindow(QMainWindow): self._close_baton_pending_dialog() if status.you_are_holder: - self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) + self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000) else: self.alert_banner.show_message( - "Request declined or cancelled", False, auto_clear_ms=10000 + "Request declined or cancelled", False, auto_clear_ms=4000 ) # Manage incoming request dialog (when someone requests from us) @@ -2656,7 +2672,7 @@ class MainWindow(QMainWindow): """ if result.get("granted"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) + self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000) logger.info("Baton acquired") # Close pending dialog; StatusBar will trigger p-group selection via SSE self._close_baton_pending_dialog() @@ -2717,12 +2733,12 @@ class MainWindow(QMainWindow): logger.debug(f"Baton response result: {result}") if result.get("accepted"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Control transferred", False, auto_clear_ms=10000) + self.alert_banner.show_message("Control transferred", False, auto_clear_ms=4000) self._close_baton_dialog() self.status_bar.update_baton_status(self.status_bar._baton_status) elif result.get("refused"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Request declined", False, auto_clear_ms=10000) + self.alert_banner.show_message("Request declined", False, auto_clear_ms=4000) self._close_baton_dialog() self.status_bar.update_baton_status(self.status_bar._baton_status) else: @@ -2741,7 +2757,7 @@ class MainWindow(QMainWindow): elif result.get("granted"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) + self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000) self._close_baton_pending_dialog() elif result.get("queued"): @@ -2760,7 +2776,7 @@ class MainWindow(QMainWindow): elif result.get("refused"): self._waiting_for_baton_response = False - self.alert_banner.show_message("Request declined", False, auto_clear_ms=10000) + self.alert_banner.show_message("Request declined", False, auto_clear_ms=4000) self._close_baton_pending_dialog() else: diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 9f4fab21..5fb4c5b0 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -1,5 +1,5 @@ from aarecommon.config.logger import setup_logger -from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtCore import QPoint, Qt, QTimer, Slot from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QSizePolicy from aare.gui.constants import LOGGER_NAME @@ -45,6 +45,53 @@ class AlertBanner(QFrame): self.setVisible(False) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._float_host = None + self._float_anchor = None + + def float_over(self, host) -> None: + """Overlay this banner at the top of `host` instead of occupying layout + space — added because showing/hiding the baton banner was shifting the + whole content stack up and down. No event filters on purpose: filters + firing during widget teardown corrupted PySide (tests crashed with + "QPushButton returned NULL"); the host repositions us on resize instead + (see _AlertBannerHost in main_window).""" + self.setParent(host) + self._float_host = host + # Click-through: the banner covers live UI now, so it must not eat + # mouse events meant for the widgets underneath. + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + def anchor_to(self, widget) -> None: + """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). + 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 + + def showEvent(self, event): + super().showEvent(event) + self.reposition() + + def reposition(self) -> None: + host = self._float_host + if host is None or not self.isVisible(): + return + anchor = self._float_anchor + if anchor is not None and anchor.isVisible(): + top_left = anchor.mapTo(host, QPoint(0, 0)) + w = min(self.sizeHint().width(), anchor.width()) + h = self.heightForWidth(w) if self.hasHeightForWidth() else self.sizeHint().height() + x = top_left.x() + (anchor.width() - w) // 2 + y = min(top_left.y() + anchor.height() + 4, host.height() - h) + self.setGeometry(x, y, w, h) + else: + w = host.width() + h = self.heightForWidth(w) if self.hasHeightForWidth() else self.sizeHint().height() + self.setGeometry(0, 0, w, h) + self.raise_() + def _set_alert_kind(self, kind: str) -> None: self.setProperty("alertKind", kind) self.style().unpolish(self) @@ -79,6 +126,8 @@ class AlertBanner(QFrame): self._current_is_error = is_error self._label.setText(decorated) self.setVisible(True) + # Resize to the new text even when already visible (no showEvent then). + self.reposition() @Slot(str, int) def show_waiting(self, msg: str, countdown_seconds: int = 0): @@ -106,6 +155,7 @@ class AlertBanner(QFrame): self._countdown_timer.start() self.setVisible(True) + self.reposition() def _apply_waiting_style(self): """Apply yellow/waiting style."""