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:
1 parent
e572a439b5
commit
a9165a604c
2 files changed
+77
-11
No files matched your search
+26
-10
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user