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 <noreply@anthropic.com>
This commit is contained in:
duan_jandClaude Fable 5 committed 2026-08-11 09:26:52 +02:00
1 parent e572a439b5
commit a9165a604c
2 files changed
+77 -11

No files matched your search

+26 -10
View File
@@ -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:
+51 -1
View File
@@ -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."""