From a6b10f2e23a617c53af19c791b99bcbc080d98e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:07:49 +0000 Subject: [PATCH] feat(main_app): right-edge system dock with progress, queue, status, beamline states and notifications panels in QML and QWidget versions Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019EaukntuyeZn7cPqVqDTBE --- bec_widgets/applications/main_app.py | 127 ++- .../applications/system_dock/__init__.py | 5 + .../applications/system_dock/panels.py | 347 +++++++ .../applications/system_dock/system_dock.py | 965 ++++++++++++++++++ .../notification_center/notification_qml.py | 2 +- .../notification_qwidget.py | 2 +- .../notification_ux_common.py | 12 +- .../qml/NotificationDrawer.qml | 14 +- .../containers/main_window/main_window.py | 11 +- tests/unit_tests/test_system_dock.py | 305 ++++++ 10 files changed, 1777 insertions(+), 13 deletions(-) create mode 100644 bec_widgets/applications/system_dock/__init__.py create mode 100644 bec_widgets/applications/system_dock/panels.py create mode 100644 bec_widgets/applications/system_dock/system_dock.py create mode 100644 tests/unit_tests/test_system_dock.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index e238599d..476f7789 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -1,13 +1,17 @@ from bec_widgets.applications.startup_profiler import startup_profiler # isort: skip +import os + +from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from qtpy.QtCore import QTimer +from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QAction # type: ignore -from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget +from qtpy.QtWidgets import QApplication, QHBoxLayout, QSplitter, QStackedWidget, QWidget from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION from bec_widgets.applications.navigation_centre.side_bar import SideBar from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +from bec_widgets.applications.system_dock.system_dock import SystemDock from bec_widgets.applications.views.admin_view.admin_view import AdminView from bec_widgets.applications.views.developer_view.developer_view import DeveloperView from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView @@ -22,11 +26,34 @@ from bec_widgets.utils.screen_utils import ( available_screen_geometry, main_app_size_for_screen, ) -from bec_widgets.widgets.containers.dock_area.profile_utils import is_experimental_features_enabled +from bec_widgets.widgets.containers.dock_area.profile_utils import ( + _app_settings, + is_experimental_features_enabled, +) from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow startup_profiler.mark("module imports") +logger = bec_logger.logger + + +def _system_dock_ui(system_dock: str | bool | None) -> str | None: + """Resolve which version of the system dock to build: ``qml``, ``qwidget`` or None (off). + + ``BEC_SYSTEM_DOCK`` (``qml``, ``qwidget`` or ``off``) overrides the argument. + """ + value = os.environ.get("BEC_SYSTEM_DOCK") + if value is None: + value = system_dock + if value is None or value is True: + return "qml" + if value is False: + return None + value = str(value).lower() + if value in ("0", "off", "false", "no", "none"): + return None + return "qwidget" if value in ("qwidget", "widgets", "qt") else "qml" + class BECMainApp(BECMainWindow): RPC = False @@ -38,14 +65,19 @@ class BECMainApp(BECMainWindow): *args, anim_duration: int = ANIMATION_DURATION, show_examples: bool = False, + system_dock: str | bool | None = None, **kwargs, ): + self._system_dock_ui = _system_dock_ui(system_dock) + if self._system_dock_ui is not None: + # the dock hosts the notification history, so use the matching notification UI + kwargs.setdefault("notification_ui", self._system_dock_ui) super().__init__(parent=parent, *args, **kwargs) startup_profiler.mark("BEC connection + base window") self._show_examples = bool(show_examples) self._launcher_ready_notified = False - # --- Compose central UI (sidebar + stack) + # --- Compose central UI (sidebar + stack + system dock) self.sidebar = SideBar(parent=self, anim_duration=anim_duration) self.stack = QStackedWidget(self) @@ -54,7 +86,12 @@ class BECMainApp(BECMainWindow): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.sidebar, 0) - layout.addWidget(self.stack, 1) + self.system_dock: SystemDock | None = None + self.content_splitter: QSplitter | None = None + if self._system_dock_ui is not None: + self._add_system_dock(container, layout) + else: + layout.addWidget(self.stack, 1) self.setCentralWidget(container) # Mapping for view switching @@ -70,8 +107,76 @@ class BECMainApp(BECMainWindow): self._setup_guided_tour() startup_profiler.mark("guided tour") + def _add_system_dock(self, container: QWidget, layout: QHBoxLayout) -> None: + """Right-edge dock with the single-instance core widgets, see :mod:`system_dock`.""" + # pylint: disable=import-outside-toplevel + from bec_widgets.applications.system_dock.panels import build_core_panels + + try: + settings = _app_settings() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"System dock settings unavailable, nothing will be persisted: {exc}") + settings = None + self.system_dock = SystemDock(container, settings=settings) + self.content_splitter = QSplitter(Qt.Orientation.Horizontal, container) + self.content_splitter.setObjectName("systemDockSplitter") + self.content_splitter.setChildrenCollapsible(False) + self.content_splitter.setHandleWidth(1) + self.content_splitter.addWidget(self.stack) + self.content_splitter.addWidget(self.system_dock.pinned_column) + self.content_splitter.setStretchFactor(0, 1) + self.content_splitter.setStretchFactor(1, 0) + layout.addWidget(self.content_splitter, 1) + layout.addWidget(self.system_dock, 0) + + host = getattr(self, "notifications", None) + build_core_panels( + self.system_dock, ui=self._system_dock_ui, notification_host=host, client=self.client + ) + # the rail carries the scan progress and the unread count, so the status bar copies go + progress = getattr(self, "_scan_progress_bar_with_separator", None) + if progress is not None: + progress.hide() + if host is not None: + host.right_inset = self.system_dock.width() + if host.bell is not None: + host.bell.hide() + self.notification_indicator = self.system_dock.button("notifications") + self._add_system_dock_menu_action() + self.system_dock.pinned_changed.connect(self._on_system_dock_pinned) + self.content_splitter.splitterMoved.connect(self._store_system_dock_width) + self.system_dock.restore_state() + startup_profiler.mark("system dock") + + def _add_system_dock_menu_action(self) -> None: + """View > System Dock toggles the whole dock (Ctrl+Shift+D).""" + for action in self.menuBar().actions(): + if action.text() == "View" and action.menu() is not None: + toggle = QAction("System Dock", self, checkable=True) + toggle.setChecked(True) + toggle.setShortcut("Ctrl+Shift+D") + toggle.toggled.connect(self.system_dock.set_dock_visible) + action.menu().addSeparator() + action.menu().addAction(toggle) + self._system_dock_action = toggle + return + + def _on_system_dock_pinned(self, pinned: list) -> None: + if not pinned or self.content_splitter is None: + return + total = sum(self.content_splitter.sizes()) + width = self.system_dock.pinned_width + if total > width: + self.content_splitter.setSizes([total - width, width]) + + def _store_system_dock_width(self, *_args) -> None: + if self.system_dock is not None and self.system_dock.pinned: + self.system_dock.store_pinned_width(self.content_splitter.sizes()[1]) + def showEvent(self, event): super().showEvent(event) + if self.system_dock is not None and self.system_dock.pinned: + QTimer.singleShot(0, lambda: self._on_system_dock_pinned(self.system_dock.pinned)) if self._launcher_ready_notified: return self._launcher_ready_notified = True @@ -326,6 +431,16 @@ class BECMainApp(BECMainWindow): ) tour_steps.append(notif_step) + if self.system_dock is not None: + dock_step = self.guided_tour.register_widget( + widget=self.system_dock, + title="System Dock", + text="Scan progress, the queue, BEC status, beamline states and notifications " + "live here once for the whole app. Click an icon to open its panel, and use the " + "pin in the panel header to dock it next to your workspace.", + ) + tour_steps.append(dock_step) + # --- View-Specific Components --- # Register all views that can extend the tour @@ -392,6 +507,8 @@ class BECMainApp(BECMainWindow): help_menu.addAction(tour_action) def cleanup(self): + if self.system_dock is not None: + self.system_dock.cleanup() for view_id, idx in self._view_index.items(): view = self.stack.widget(idx) view.close() diff --git a/bec_widgets/applications/system_dock/__init__.py b/bec_widgets/applications/system_dock/__init__.py new file mode 100644 index 00000000..76eb76d6 --- /dev/null +++ b/bec_widgets/applications/system_dock/__init__.py @@ -0,0 +1,5 @@ +"""Right-edge system dock of the BEC main app: one place for the single-instance core widgets.""" + +from bec_widgets.applications.system_dock.system_dock import RailIndicator, SystemDock, SystemPanel + +__all__ = ["RailIndicator", "SystemDock", "SystemPanel"] diff --git a/bec_widgets/applications/system_dock/panels.py b/bec_widgets/applications/system_dock/panels.py new file mode 100644 index 00000000..85a4eff3 --- /dev/null +++ b/bec_widgets/applications/system_dock/panels.py @@ -0,0 +1,347 @@ +""" +The core panels of the main app's system dock. + +Each panel wraps a widget from its own module (the QML or the QWidget version, picked with +``ui``) and turns that widget's model into the rail indicator. The widgets are created up front so +the indicators are live while the panels are closed. +""" + +from __future__ import annotations + +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QFrame, QVBoxLayout, QWidget + +from bec_widgets.applications.system_dock.system_dock import RailIndicator, SystemPanel +from bec_widgets.utils.quick.host import ThemeTokens + +UI_KINDS = ("qml", "qwidget") +STATES_MIN_WIDTH = 660 + + +def _ui(ui: str) -> str: + ui = (ui or "qml").lower() + return ui if ui in UI_KINDS else "qml" + + +class ScanProgressPanel(SystemPanel): + """Progress of the running scan, in a compact flyout. + + The rail shows a live progress ring while a scan runs, amber while it is paused. + """ + + def __init__(self, parent: QWidget | None = None, ui: str = "qml", client=None): + # pylint: disable=import-outside-toplevel + if _ui(ui) == "qml": + from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar_qml import ( + ScanProgressBarQml as Progress, + ) + else: + from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar_modern import ( + ScanProgressBarModern as Progress, + ) + content = QFrame(parent) + content.setObjectName("systemDockProgress") + self.progress = Progress(parent=content, client=client, rpc_exposed=False) + layout = QVBoxLayout(content) + layout.setContentsMargins(14, 12, 14, 12) + layout.addWidget(self.progress) + layout.addStretch(1) + super().__init__( + "progress", + "Scan progress", + "play_arrow", + "Progress", + content, + parent, + preferred_height=self.progress.sizeHint().height() + 24, + ) + self.progress.model.changed.connect(self.refresh) + self.refresh() + + def refresh(self, *_args) -> None: + """Recompute the indicator from the progress model.""" + tokens = ThemeTokens() + model = self.progress.model + if model.active: + summary = f"{model.title} · {int(round(model.fraction * 100))} %" + if model.state == "paused": + summary = f"Paused · {summary}" + elif model.title == "No scan yet": + summary = "No scan yet" + else: + summary = f"{model.stateLabel} · {model.title}" + self.set_indicator( + RailIndicator( + progress=float(model.fraction) if model.active else None, + progress_color=QColor( + tokens.warning if model.state == "paused" else tokens.primary + ), + tooltip=summary, + summary=summary, + ) + ) + + +class QueuePanel(SystemPanel): + """The scan queue. + + The rail shows the number of waiting scans: blue while the queue runs, amber when it is + paused and red when it is locked. Without waiting scans a paused or locked queue shows a dot. + """ + + def __init__(self, parent: QWidget | None = None, ui: str = "qml", client=None): + # pylint: disable=import-outside-toplevel + if _ui(ui) == "qml": + from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue as Queue + else: + from bec_widgets.widgets.services.bec_queue.bec_queue_qt import BECQueueQt as Queue + self.queue = Queue(parent=parent, client=client, rpc_exposed=False) + super().__init__("queue", "Scan queue", "format_list_numbered", "Queue", self.queue, parent) + self.queue.controller.changed.connect(self.refresh) + self.refresh() + + def refresh(self, *_args) -> None: + """Recompute the indicator from the queue controller.""" + tokens = ThemeTokens() + queue = self.queue.controller + waiting = int(queue.waitingCount) + state = str(queue.state) + color = {"paused": tokens.warning, "locked": tokens.danger}.get(state, tokens.primary) + parts = {"paused": ["Paused"], "locked": ["Locked"], "running": ["Running"]}.get( + state, ["Idle"] + ) + if waiting: + parts.append(f"{waiting} waiting") + if state == "locked" and queue.lockReason: + parts.append(str(queue.lockReason)) + summary = " · ".join(parts) + self.set_indicator( + RailIndicator( + badge=str(waiting) if waiting else "", + badge_color=QColor(color), + dot_color=QColor(color) if state in ("paused", "locked") else None, + tooltip=summary, + summary=summary, + ) + ) + + +class ServicesPanel(SystemPanel): + """BEC services (status box). The rail shows a dot only when something is wrong.""" + + def __init__(self, parent: QWidget | None = None, ui: str = "qml", client=None): + # pylint: disable=import-outside-toplevel + from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox + + self.box = BECStatusBox( + parent=parent, + client=client, + view="qml" if _ui(ui) == "qml" else "widgets", + rpc_exposed=False, + ) + super().__init__("services", "BEC status", "monitor_heart", "Status", self.box, parent) + self.box.model.summaryChanged.connect(self.refresh) + self.refresh() + + def refresh(self, *_args) -> None: + """Recompute the indicator from the status model.""" + tokens = ThemeTokens() + model = self.box.model + dot = {"warning": tokens.warning, "emergency": tokens.danger}.get(model.tone) + summary = model.headline or "Waiting for services" + self.set_indicator( + RailIndicator( + dot_color=QColor(dot) if dot is not None else None, + tooltip=" · ".join(t for t in (model.headline, model.detail) if t), + summary=summary, + ) + ) + + +class NotificationsPanel(SystemPanel): + """The notification history, docked from the window's notification host. + + The host's history drawer moves into the panel; opening the panel opens the drawer (which + marks everything read and holds back toasts) and the toast "Details" action opens the panel. + The rail shows the unread count in the colour of the most serious unread entry, pulsing while + a critical error waits to be acknowledged. + """ + + def __init__(self, host, parent: QWidget | None = None): + # pylint: disable=import-outside-toplevel + from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_ux_common import ( + Severity, + severity_color, + ) + + self._severity_color = severity_color + self._critical = Severity.CRITICAL + self.host = host + content = QWidget(parent) + content.setObjectName("systemDockNotifications") + layout = QVBoxLayout(content) + layout.setContentsMargins(0, 0, 0, 0) + host.drawer_docked = True + host.drawer.setParent(content) + layout.addWidget(host.drawer) + # the dock header already has the title and close button + for name in ("title", "close_btn"): + widget = getattr(host.drawer, name, None) + if widget is not None: + widget.hide() + root = host.drawer.rootObject() if hasattr(host.drawer, "rootObject") else None + if root is not None: + root.setProperty("embedded", True) + super().__init__( + "notifications", "Notifications", "notifications", "Alerts", content, parent + ) + self.dock = None + host.hub.changed.connect(self.refresh) + host.drawer_toggled.connect(self._on_drawer_toggled) + self.refresh() + + def attach(self, dock) -> None: + """Let the toast "Details" action open this panel in ``dock``.""" + self.dock = dock + + def set_shown(self, shown: bool) -> None: + if self.host.drawer_open != shown: + self.host.set_drawer_open(shown) + + def _on_drawer_toggled(self, open_: bool) -> None: + if self.dock is None: + return + if open_ and not self.dock.is_shown(self.panel_id): + self.dock.open_panel(self.panel_id, sticky=True) + elif not open_ and self.dock.is_shown(self.panel_id): + self.dock.close_panel(self.panel_id) + + def refresh(self, *_args) -> None: + """Recompute the indicator from the notification hub.""" + tokens = ThemeTokens() + hub = self.host.hub + unread = hub.unread_count() + top = hub.top_unread_severity() + criticals = sum(1 for e in hub.entries if e.needs_ack) + if criticals: + summary = f"{criticals} critical error(s) need attention" + color = self._severity_color(tokens, self._critical) + elif unread: + summary = f"{unread} unread" + color = self._severity_color(tokens, top) if top is not None else tokens.primary + else: + summary = f"{len(hub.entries)} in history" if hub.entries else "All caught up" + color = tokens.primary + count = max(unread, criticals) + self.set_indicator( + RailIndicator( + badge=("99+" if count > 99 else str(count)) if count else "", + badge_color=QColor(color), + pulse=bool(criticals), + tooltip=summary, + summary=summary, + ) + ) + + +class BeamlineStatesPanel(SystemPanel): + """Beamline states with the scan interlock. + + The rail shows a red "!" when the armed interlock trips (scans are blocked) and an amber dot + when any state is invalid or in warning. + """ + + def __init__(self, parent: QWidget | None = None, ui: str = "qml", client=None): + # pylint: disable=import-outside-toplevel + if _ui(ui) == "qml": + from bec_widgets.widgets.services.beamline_states.modern.beamline_states_qml import ( + BeamlineStatesQML as States, + ) + else: + from bec_widgets.widgets.services.beamline_states.modern.beamline_states_widget import ( + BeamlineStatesWidget as States, + ) + self.states = States(parent=parent, client=client, rpc_exposed=False) + super().__init__( + "beamline_states", + "Beamline states", + "verified_user", + "States", + self.states, + parent, + # the states view has no compact layout yet: its chip row needs this much + min_width=STATES_MIN_WIDTH, + ) + self.states.controller.changed.connect(self.refresh) + self.refresh() + + def refresh(self, *_args) -> None: + """Recompute the indicator from the states controller.""" + tokens = ThemeTokens() + controller = self.states.controller + blocking = list(controller.blockingStates) + counts = dict(controller.counts) + total = int(controller.totalCount) + invalid = int(counts.get("invalid", 0)) + warning = int(counts.get("warning", 0)) + interlock = "interlock armed" if controller.interlockArmed else "interlock off" + badge, dot = "", None + if blocking: + badge = "!" + summary = f"Scans blocked by {', '.join(blocking)}" + elif invalid or warning: + dot = tokens.warning + issues = [] + if invalid: + issues.append(f"{invalid} invalid") + if warning: + issues.append(f"{warning} warning") + summary = f"{' · '.join(issues)} of {total} · {interlock}" + elif total: + summary = f"All {total} valid · {interlock}" + else: + summary = "No states configured" + self.set_indicator( + RailIndicator( + badge=badge, + badge_color=QColor(tokens.danger), + dot_color=QColor(dot) if dot is not None else None, + pulse=bool(blocking), + tooltip=summary, + summary=summary, + ) + ) + + +class DeviceBrowserPanel(SystemPanel): + """The device browser (QWidget only: there is no QML version yet).""" + + def __init__(self, parent: QWidget | None = None, client=None): + # pylint: disable=import-outside-toplevel + from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser + + self.browser = DeviceBrowser(parent=parent, client=client, rpc_exposed=False) + super().__init__("devices", "Devices", "memory", "Devices", self.browser, parent) + self.set_indicator(RailIndicator(tooltip="Browse and edit the devices of this session")) + + +def build_core_panels(dock, ui: str = "qml", notification_host=None, client=None) -> None: + """Add the core panels to ``dock`` in rail order. + + Args: + dock(SystemDock): The dock to fill. + ui(str): ``qml`` or ``qwidget``: which version of each widget to use. + notification_host: The window's reworked notification host; without one there is no + notifications panel (the legacy notification centre stays in the status bar). + client: BEC client passed to the widgets. + """ + dock.add_panel(ScanProgressPanel(dock, ui=ui, client=client)) + dock.add_panel(QueuePanel(dock, ui=ui, client=client), shortcut="Ctrl+Shift+Q") + dock.add_panel(ServicesPanel(dock, ui=ui, client=client), shortcut="Ctrl+Shift+S") + dock.add_panel(BeamlineStatesPanel(dock, ui=ui, client=client), shortcut="Ctrl+Shift+B") + if notification_host is not None: + panel = NotificationsPanel(notification_host, dock) + dock.add_panel(panel, shortcut="Ctrl+Shift+N") + panel.attach(dock) + dock.add_separator() + dock.add_panel(DeviceBrowserPanel(dock, client=client)) diff --git a/bec_widgets/applications/system_dock/system_dock.py b/bec_widgets/applications/system_dock/system_dock.py new file mode 100644 index 00000000..c2f4106f --- /dev/null +++ b/bec_widgets/applications/system_dock/system_dock.py @@ -0,0 +1,965 @@ +""" +Right-edge system dock of the BEC main app. + +The dock holds the core widgets that only make sense once per application (scan progress, the +scan queue, notifications, beamline states, BEC services). It has three parts: + +* the **rail**: a narrow strip of icon buttons on the right edge of the window, visible in every + view. Each button carries a live indicator (an unread count, a status dot or a progress ring), + so the state of the beamline can be read without opening anything; +* the **flyout**: clicking a button slides its panel out over the content. A click outside, Esc + or the same button closes it again. Opening on hover is available as an opt-in from the rail's + context menu; +* the **pinned column**: the pin button in a panel header docks the panel next to the content, + pushing the workspace aside instead of covering it. Up to two pinned panels share the column; + the pinned set is restored on the next start. + +Plugins can add their own entries with :meth:`SystemDock.add_panel`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import shiboken6 +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import ( + QEasingCurve, + QEvent, + QObject, + QPoint, + QPointF, + QPropertyAnimation, + QRect, + QRectF, + QSettings, + Qt, + QTimer, + QVariantAnimation, + Signal, +) +from qtpy.QtGui import ( + QAction, + QColor, + QCursor, + QFont, + QFontMetrics, + QKeySequence, + QLinearGradient, + QPainter, + QPen, + QShortcut, +) +from qtpy.QtWidgets import ( + QAbstractButton, + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMenu, + QSplitter, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.eliding_label import ElidingLabel +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import IconButton + +logger = bec_logger.logger + +RAIL_WIDTH = 48 +BUTTON_HEIGHT = 44 +PEEK_WIDTH = 480 +PINNED_WIDTH = 480 +PINNED_MIN_WIDTH = 380 +PINNED_MAX_WIDTH = 600 +MAX_PINNED = 2 +HEADER_HEIGHT = 44 +SHADOW = 14 +HOVER_OPEN_DELAY = 280 # ms the pointer has to rest on a rail button before its panel peeks out +LEAVE_CLOSE_DELAY = 450 # ms after the pointer left rail and panel before a hover peek hides +SLIDE_DURATION = 140 + +SETTINGS_PINNED = "system_dock/pinned" +SETTINGS_HOVER = "system_dock/hover_open" +SETTINGS_WIDTH = "system_dock/pinned_width" + + +@dataclass +class RailIndicator: + """What a rail button shows on top of its icon, and the panel header's summary line. + + Attributes: + badge(str): Short text in a pill at the top right, e.g. an unread count. Empty for none. + badge_color(QColor | None): Fill of the badge pill. + dot_color(QColor | None): A small status dot, used when there is no badge. + progress(float | None): 0..1 draws a progress ring around the icon; None for no ring. + progress_color(QColor | None): Colour of the progress ring; the theme primary if None. + pulse(bool): Pulse a halo around the badge, for something that needs attention now. + tooltip(str): Tooltip of the rail button. + summary(str): One line shown next to the title in the panel header. + """ + + badge: str = "" + badge_color: QColor | None = None + dot_color: QColor | None = None + progress: float | None = None + progress_color: QColor | None = None + pulse: bool = False + tooltip: str = "" + summary: str = "" + + +class SystemPanel(QObject): + """One entry of the system dock: a rail button plus a panel around ``content``. + + Subclasses compute the rail indicator from their widget's model and call + :meth:`set_indicator` whenever it changes. + + Args: + panel_id(str): Unique id, also used to persist the pinned state. + title(str): Title in the panel header. + icon(str): Material icon name of the rail button. + label(str): Short name, used in menus and for accessibility. + content(QWidget): The widget shown in the panel. + preferred_height(int | None): Height of the content for a compact flyout that only + covers part of the window next to its button; None for a full-height panel. + min_width(int | None): Width the content needs, when more than the default panel width. + """ + + indicator_changed = Signal() + + def __init__( + self, + panel_id: str, + title: str, + icon: str, + label: str, + content: QWidget, + parent: QObject | None = None, + preferred_height: int | None = None, + min_width: int | None = None, + ): + super().__init__(parent) + self.panel_id = panel_id + self.title = title + self.icon = icon + self.label = label + self.content = content + self.preferred_height = preferred_height + self.min_width = min_width + self._indicator = RailIndicator(tooltip=title) + + @property + def indicator(self) -> RailIndicator: + """The current rail indicator.""" + return self._indicator + + def set_indicator(self, indicator: RailIndicator) -> None: + """Replace the rail indicator; emits :attr:`indicator_changed` only on a change.""" + if indicator != self._indicator: + self._indicator = indicator + self.indicator_changed.emit() + + def set_shown(self, shown: bool) -> None: + """Called when the panel becomes visible (peeked or pinned) or hidden.""" + + def cleanup(self) -> None: + """Release the content widget.""" + if shiboken6.isValid(self.content): + self.content.close() + self.content.deleteLater() + + +class RailButton(QAbstractButton): + """Rail button: icon, short label, and the panel's live indicator.""" + + hovered = Signal(str, bool) + + def __init__(self, panel: SystemPanel, dock: "SystemDock"): + super().__init__(dock) + self.panel = panel + self.dock = dock + self._active = False + self._pinned = False + self._hover = False + self.shortcut_text = "" + self.setFixedSize(RAIL_WIDTH, BUTTON_HEIGHT) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setAccessibleName(panel.title) + self.setFocusPolicy(Qt.FocusPolicy.TabFocus) + panel.indicator_changed.connect(self.refresh_indicator) + self.refresh_indicator() + + def set_state(self, active: bool, pinned: bool) -> None: + """Highlight the button while its panel is shown; mark pinned panels.""" + if (active, pinned) != (self._active, self._pinned): + self._active, self._pinned = active, pinned + self.update() + + @property + def active(self) -> bool: + """Whether the panel of this button is visible.""" + return self._active + + def refresh_indicator(self) -> None: + ind = self.panel.indicator + title = self.panel.title + if self.shortcut_text: + title = f"{title} ({self.shortcut_text})" + tip = ind.tooltip + self.setToolTip(f"{title}\n{tip}" if tip and tip != self.panel.title else title) + self.update() + + def enterEvent(self, event): # pylint: disable=invalid-name + self._hover = True + self.update() + self.hovered.emit(self.panel.panel_id, True) + super().enterEvent(event) + + def leaveEvent(self, event): # pylint: disable=invalid-name + self._hover = False + self.update() + self.hovered.emit(self.panel.panel_id, False) + super().leaveEvent(event) + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = self.dock.tokens + ind = self.panel.indicator + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + tile = QRectF(5, 3, w - 10, h - 6) + if self._active: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.soft(tokens.primary, 0.2 if tokens.dark else 0.14)) + painter.drawRoundedRect(tile, 8, 8) + elif self._hover or self.hasFocus(): + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.hover) + painter.drawRoundedRect(tile, 8, 8) + if self._pinned: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.primary) + painter.drawRoundedRect(QRectF(1, tile.top() + 9, 3, tile.height() - 18), 1.5, 1.5) + + if self._active: + fg = tokens.primary + elif self._hover: + fg = tokens.fg + else: + fg = tokens.fg_muted + center = QPointF(w / 2, h / 2) + size = 20 if ind.progress is not None else 22 + pix = material_icon( + self.panel.icon, size=(size * 2, size * 2), color=fg, filled=self._active + ) + painter.drawPixmap( + QRectF(center.x() - size / 2, center.y() - size / 2, size, size), + pix, + QRectF(pix.rect()), + ) + + if ind.progress is not None: + ring = QRectF(center.x() - 15, center.y() - 15, 30, 30) + pen = QPen(tokens.track, 2.5) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse(ring) + pen.setColor(ind.progress_color or tokens.primary) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + span = int(-360 * 16 * max(0.0, min(1.0, ind.progress))) + painter.drawArc(ring, 90 * 16, span) + + rail_bg = self.dock.palette().window().color() + if ind.badge: + bfont = QFont(self.font()) + bfont.setPointSizeF(max(7.0, bfont.pointSizeF() - 2.5)) + bfont.setWeight(QFont.Weight.Bold) + metrics = QFontMetrics(bfont) + bw = max(16, metrics.horizontalAdvance(ind.badge) + 8) + rect = QRectF(min(center.x() + 2, w - bw - 2), 3, bw, 16) + color = ind.badge_color or tokens.primary + if ind.pulse: + halo = QColor(color) + halo.setAlphaF(0.45 * (1.0 - self.dock.pulse_phase)) + grow = 1 + 5 * self.dock.pulse_phase + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(halo) + painter.drawRoundedRect(rect.adjusted(-grow, -grow, grow, grow), 8 + grow, 8 + grow) + painter.setPen(QPen(rail_bg, 2)) + painter.setBrush(color) + painter.drawRoundedRect(rect, 8, 8) + painter.setFont(bfont) + painter.setPen(QColor("#ffffff")) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, ind.badge) + elif ind.dot_color is not None: + painter.setPen(QPen(rail_bg, 2)) + painter.setBrush(ind.dot_color) + painter.drawEllipse(QPointF(center.x() + 10, center.y() - 10), 5, 5) + painter.end() + + +class PanelFrame(QFrame): + """A panel as shown in the dock: header with title, summary, pin and close, then content.""" + + pin_toggled = Signal(str, bool) + close_requested = Signal(str) + + def __init__(self, panel: SystemPanel, parent: QWidget | None = None): + super().__init__(parent) + self.panel = panel + self.setObjectName("systemDockPanel") + self.setFrameShape(QFrame.Shape.NoFrame) + + self.header = QWidget(self) + self.header.setObjectName("systemDockPanelHeader") + self.header.setFixedHeight(44) + self.icon = QLabel(self.header) + self.icon.setFixedSize(20, 20) + self.title = QLabel(panel.title, self.header) + self.title.setObjectName("systemDockPanelTitle") + self.summary = ElidingLabel(self.header) + self.summary.setObjectName("systemDockPanelSummary") + self.pin_button = IconButton("push_pin", "Pin open next to the workspace", self.header) + self.pin_button.setCheckable(True) + self.pin_button.clicked.connect( + lambda checked: self.pin_toggled.emit(panel.panel_id, bool(checked)) + ) + self.close_button = IconButton("close", "Close", self.header) + self.close_button.clicked.connect(lambda: self.close_requested.emit(panel.panel_id)) + + head = QHBoxLayout(self.header) + head.setContentsMargins(14, 0, 8, 0) + head.setSpacing(8) + head.addWidget(self.icon) + head.addWidget(self.title) + head.addWidget(self.summary, 1) + head.addWidget(self.pin_button) + head.addWidget(self.close_button) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.header) + layout.addWidget(panel.content, 1) + panel.content.show() + panel.indicator_changed.connect(self._update_summary) + self._update_summary() + + def set_pinned(self, pinned: bool) -> None: + """Reflect the pinned state on the pin button.""" + self.pin_button.setChecked(pinned) + self.pin_button.setToolTip("Unpin" if pinned else "Pin open next to the workspace") + self.pin_button.refresh_theme(ThemeTokens()) + + def _update_summary(self) -> None: + text = self.panel.indicator.summary + self.summary.setText(text) + self.summary.setToolTip(text) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens to the header.""" + self.icon.setPixmap( + material_icon(self.panel.icon, size=(20, 20), color=tokens.primary, filled=True) + ) + self.header.setStyleSheet( + f"#systemDockPanelHeader {{ border-bottom: 1px solid {tokens.track.name()}; }}" + f"#systemDockPanelTitle {{ font-weight: 600; color: {tokens.fg.name()}; }}" + f"#systemDockPanelSummary {{ color: {tokens.fg_muted.name()}; }}" + ) + self.pin_button.refresh_theme(tokens) + self.close_button.refresh_theme(tokens) + + +class _PeekHost(QWidget): + """Floating container of the peek panel, drawn with a soft shadow on its left edge.""" + + def __init__(self, dock: "SystemDock", overlay_parent: QWidget): + super().__init__(overlay_parent) + self.dock = dock + self.setObjectName("systemDockPeek") + self.stack = QStackedWidget(self) + layout = QHBoxLayout(self) + layout.setContentsMargins(SHADOW, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.stack) + self.hide() + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = self.dock.tokens + painter = QPainter(self) + shade = QColor(0, 0, 0, 90 if tokens.dark else 40) + gradient = QLinearGradient(0, 0, SHADOW, 0) + gradient.setColorAt(0.0, QColor(0, 0, 0, 0)) + gradient.setColorAt(1.0, shade) + painter.fillRect(QRect(0, 0, SHADOW, self.height()), gradient) + painter.fillRect(QRect(SHADOW, 0, self.width() - SHADOW, self.height()), tokens.bg) + painter.setPen(tokens.border) + painter.drawLine(SHADOW, 0, SHADOW, self.height()) + if self.height() < self.dock.height(): + # compact flyout: close it off at the bottom as well + painter.drawLine(SHADOW, self.height() - 1, self.width(), self.height() - 1) + painter.end() + + +class SystemDock(QWidget): + """The rail on the right edge, with its peek panel and pinned column. + + The rail is this widget; put it at the right edge of the window layout. Put + :attr:`pinned_column` between the main content and the rail, ideally in a ``QSplitter`` with + the content so the user can resize it. The peek panel floats over ``overlay_parent``. + + Args: + overlay_parent(QWidget): Widget the peek panel floats over, usually the central widget + that also contains the rail. + settings(QSettings | None): Where to persist pinned panels and the hover preference. + hover_open(bool | None): Open panels when the pointer rests on a rail button. Defaults to + the persisted preference, or False. + """ + + panel_shown = Signal(str, bool) + pinned_changed = Signal(list) + + def __init__( + self, + overlay_parent: QWidget, + parent: QWidget | None = None, + settings: QSettings | None = None, + hover_open: bool | None = None, + ): + super().__init__(parent or overlay_parent) + self.setObjectName("systemDockRail") + self.setFixedWidth(RAIL_WIDTH) + self.setAutoFillBackground(True) + self.tokens = ThemeTokens() + self._overlay_parent = overlay_parent + self._settings = settings + self._panels: dict[str, SystemPanel] = {} + self._buttons: dict[str, RailButton] = {} + self._frames: dict[str, PanelFrame] = {} + self._pinned: list[str] = [] + self._peek_id: str | None = None + self._peek_sticky = False + self._filter_installed = False + self._cleaned = False + if hover_open is None: + hover_open = self._read_setting(SETTINGS_HOVER, False, bool) + self._hover_open = bool(hover_open) + self._pending_hover: str | None = None + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 8, 0, 8) + self._layout.setSpacing(2) + self._layout.addStretch(1) + + self.peek = _PeekHost(self, overlay_parent) + self.peek.installEventFilter(self) + self._slide = QPropertyAnimation(self.peek, b"pos", self) + self._slide.setDuration(SLIDE_DURATION) + self._slide.setEasingCurve(QEasingCurve.Type.OutCubic) + + self.pinned_column = QSplitter(Qt.Orientation.Vertical) + self.pinned_column.setObjectName("systemDockPinned") + self.pinned_column.setChildrenCollapsible(False) + self.pinned_column.setMinimumWidth(PINNED_MIN_WIDTH) + self.pinned_column.setMaximumWidth(PINNED_MAX_WIDTH) + self.pinned_column.hide() + + self._hover_timer = QTimer(self) + self._hover_timer.setSingleShot(True) + self._hover_timer.setInterval(HOVER_OPEN_DELAY) + self._hover_timer.timeout.connect(self._on_hover_timeout) + self._leave_timer = QTimer(self) + self._leave_timer.setSingleShot(True) + self._leave_timer.setInterval(LEAVE_CLOSE_DELAY) + self._leave_timer.timeout.connect(self._on_leave_timeout) + # one clock for every pulsing badge, running only while a badge pulses + self.pulse_phase = 0.0 + self._pulse = QVariantAnimation(self) + self._pulse.setStartValue(0.0) + self._pulse.setEndValue(1.0) + self._pulse.setDuration(1400) + self._pulse.setLoopCount(-1) + self._pulse.valueChanged.connect(self._on_pulse) + self._shortcuts: list[QShortcut] = [] + self._add_shortcut("Ctrl+Shift+P", self.toggle_pin_current) + + overlay_parent.installEventFilter(self) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_context_menu) + + app = QApplication.instance() + theme = getattr(app, "theme", None) + if theme is not None and hasattr(theme, "theme_changed"): + theme.theme_changed.connect(self._on_theme_changed) + + # ------------------------------------------------------------------ panels + def add_panel(self, panel: SystemPanel, shortcut: str | None = None) -> RailButton: + """Add a panel and its rail button below the existing ones. + + Args: + panel(SystemPanel): The panel to add. + shortcut(str | None): Key sequence that opens or closes the panel, e.g. "Ctrl+Shift+Q". + + Returns: + RailButton: The rail button of the panel. + """ + if panel.panel_id in self._panels: + raise ValueError(f"Panel {panel.panel_id!r} already exists in the system dock.") + panel.setParent(self) + self._panels[panel.panel_id] = panel + button = RailButton(panel, self) + button.clicked.connect(lambda: self.toggle_panel(panel.panel_id)) + button.hovered.connect(self._on_button_hovered) + self._layout.insertWidget(self._layout.count() - 1, button) + self._buttons[panel.panel_id] = button + panel.indicator_changed.connect(self._update_pulse) + if shortcut: + self._add_shortcut(shortcut, lambda: self.toggle_panel(panel.panel_id)) + button.shortcut_text = shortcut + button.refresh_indicator() + frame = PanelFrame(panel) + frame.pin_toggled.connect(self.set_pinned) + frame.close_requested.connect(self.close_panel) + frame.refresh_theme(self.tokens) + self.peek.stack.addWidget(frame) + self._frames[panel.panel_id] = frame + return button + + def add_separator(self) -> QFrame: + """Add a divider below the existing rail buttons, e.g. before plugin panels.""" + line = QFrame(self) + line.setObjectName("systemDockDivider") + line.setFixedSize(RAIL_WIDTH - 20, 1) + line.setStyleSheet(f"background: {self.tokens.border.name()};") + self._layout.insertWidget(self._layout.count() - 1, line, 0, Qt.AlignmentFlag.AlignHCenter) + self._layout.insertSpacing(self._layout.count() - 1, 2) + return line + + def panel(self, panel_id: str) -> SystemPanel: + """The panel with ``panel_id``.""" + return self._panels[panel_id] + + def button(self, panel_id: str) -> RailButton: + """The rail button of ``panel_id``.""" + return self._buttons[panel_id] + + def frame(self, panel_id: str) -> PanelFrame: + """The panel frame (header and content) of ``panel_id``.""" + return self._frames[panel_id] + + @property + def panel_ids(self) -> list[str]: + """Ids of all panels in rail order.""" + return list(self._panels) + + @property + def peek_panel_id(self) -> str | None: + """Id of the panel shown in the peek, or None when it is closed.""" + return self._peek_id + + @property + def peek_sticky(self) -> bool: + """Whether the peek stays open when the pointer leaves it (it was clicked).""" + return self._peek_sticky + + @property + def pinned(self) -> list[str]: + """Ids of the pinned panels, top to bottom.""" + return list(self._pinned) + + def is_shown(self, panel_id: str) -> bool: + """Whether ``panel_id`` is visible, peeked or pinned.""" + return panel_id in self._pinned or panel_id == self._peek_id + + # ------------------------------------------------------------------ hover preference + @property + def hover_open(self) -> bool: + """Whether resting the pointer on a rail button peeks its panel.""" + return self._hover_open + + def set_hover_open(self, enabled: bool) -> None: + """Enable or disable opening panels on hover, and persist the choice.""" + self._hover_open = bool(enabled) + self._hover_timer.stop() + self._write_setting(SETTINGS_HOVER, self._hover_open) + + # ------------------------------------------------------------------ open / close + def toggle_panel(self, panel_id: str) -> None: + """Rail button click: open the panel and keep it open, or close a kept-open panel.""" + self._hover_timer.stop() + if panel_id in self._pinned: + self._frames[panel_id].panel.content.setFocus() + return + if not self.isVisible(): + self.set_dock_visible(True) + if self._peek_id == panel_id: + if self._peek_sticky: + self.close_peek() + else: + self._peek_sticky = True + self._leave_timer.stop() + return + self.open_panel(panel_id, sticky=True) + + def open_panel(self, panel_id: str, sticky: bool = True) -> None: + """Show ``panel_id`` in the peek (no-op if it is pinned). + + Args: + panel_id(str): The panel to show. + sticky(bool): Keep it open when the pointer leaves; otherwise it hides like a hover. + """ + if panel_id not in self._panels: + raise KeyError(panel_id) + if panel_id in self._pinned: + return + was_open = self._peek_id is not None + previous = self._peek_id + self._peek_id = panel_id + self._peek_sticky = sticky + self.peek.stack.setCurrentWidget(self._frames[panel_id]) + if previous is not None and previous != panel_id: + self._set_shown(previous, False) + target = self._peek_geometry() + if not was_open: + self.peek.setGeometry(target.translated(24, 0)) + self.peek.show() + self.peek.raise_() + self._slide.stop() + self._slide.setStartValue(target.topLeft() + QPoint(24, 0)) + self._slide.setEndValue(target.topLeft()) + self._slide.start() + else: + self.peek.setGeometry(target) + self.peek.raise_() + self._install_app_filter(True) + if previous != panel_id: + self._set_shown(panel_id, True) + self._update_buttons() + + def close_peek(self) -> None: + """Hide the peek panel.""" + self._hover_timer.stop() + self._leave_timer.stop() + if self._peek_id is None: + return + closing = self._peek_id + self._peek_id = None + self._peek_sticky = False + self._slide.stop() + self.peek.hide() + self._install_app_filter(False) + self._set_shown(closing, False) + self._update_buttons() + + def close_panel(self, panel_id: str) -> None: + """Close ``panel_id``, unpinning it if it is pinned.""" + if panel_id in self._pinned: + self.set_pinned(panel_id, False) + return + if panel_id == self._peek_id: + self.close_peek() + + def set_pinned(self, panel_id: str, pinned: bool) -> None: + """Pin ``panel_id`` into the column next to the content, or unpin (and close) it.""" + frame = self._frames[panel_id] + if pinned and panel_id not in self._pinned: + if panel_id == self._peek_id: + # the frame moves from the peek into the column: the panel stays visible + self._peek_id = None + self._peek_sticky = False + self.peek.hide() + self._install_app_filter(False) + else: + self._set_shown(panel_id, True) + while len(self._pinned) >= MAX_PINNED: + self.set_pinned(self._pinned[0], False) + self._pinned.append(panel_id) + self.pinned_column.addWidget(frame) + frame.show() + elif not pinned and panel_id in self._pinned: + self._pinned.remove(panel_id) + self.peek.stack.addWidget(frame) + self._set_shown(panel_id, False) + else: + frame.set_pinned(panel_id in self._pinned) + return + frame.set_pinned(pinned) + needed = max([PINNED_MIN_WIDTH] + [self._panels[p].min_width or 0 for p in self._pinned]) + self.pinned_column.setMinimumWidth(needed) + self.pinned_column.setMaximumWidth(max(PINNED_MAX_WIDTH, needed)) + self.pinned_column.setVisible(bool(self._pinned)) + self._update_buttons() + self._write_setting(SETTINGS_PINNED, list(self._pinned)) + self.pinned_changed.emit(list(self._pinned)) + + def toggle_pin_current(self) -> None: + """Pin the open flyout, or unpin the last pinned panel when no flyout is open.""" + if self._peek_id is not None: + self.set_pinned(self._peek_id, True) + elif self._pinned: + self.set_pinned(self._pinned[-1], False) + + def set_dock_visible(self, visible: bool) -> None: + """Show or hide the whole dock: rail, flyout and pinned column.""" + if not visible: + self.close_peek() + self.setVisible(visible) + self.pinned_column.setVisible(visible and bool(self._pinned)) + + def restore_state(self) -> None: + """Pin the panels that were pinned in the last session.""" + stored = self._read_setting(SETTINGS_PINNED, [], list) + if isinstance(stored, str): + stored = [stored] + for panel_id in stored or []: + if panel_id in self._panels: + self.set_pinned(panel_id, True) + + @property + def pinned_width(self) -> int: + """Width for the pinned column: the persisted one, at least what the pinned panels need.""" + width = int(self._read_setting(SETTINGS_WIDTH, PINNED_WIDTH, int)) + return max(width, self.pinned_column.minimumWidth()) + + def store_pinned_width(self, width: int) -> None: + """Persist the width of the pinned column.""" + if width >= PINNED_MIN_WIDTH: + self._write_setting(SETTINGS_WIDTH, int(width)) + + # ------------------------------------------------------------------ internals + def _set_shown(self, panel_id: str, shown: bool) -> None: + try: + self._panels[panel_id].set_shown(shown) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"System dock panel {panel_id} failed to react to show={shown}: {exc}") + self.panel_shown.emit(panel_id, shown) + + def _update_buttons(self) -> None: + for panel_id, button in self._buttons.items(): + button.set_state(self.is_shown(panel_id), panel_id in self._pinned) + + def _peek_geometry(self) -> QRect: + parent = self._overlay_parent + rail = QRect(self.mapTo(parent, QPoint(0, 0)), self.size()) + anchor = rail.left() + if self.pinned_column.isVisible(): + anchor = self.pinned_column.mapTo(parent, QPoint(0, 0)).x() + panel = self._panels.get(self._peek_id) if self._peek_id else None + wanted = max(PEEK_WIDTH, (panel.min_width or 0) if panel is not None else 0) + width = min(wanted + SHADOW, max(240, anchor - 80)) + if panel is None or not panel.preferred_height: + return QRect(anchor - width, rail.top(), width, rail.height()) + # compact flyout: next to its button, as tall as its content + height = min(rail.height(), HEADER_HEIGHT + panel.preferred_height) + button = self._buttons[panel.panel_id] + top = self.mapTo(parent, button.geometry().topLeft()).y() + top = max(rail.top(), min(top, rail.bottom() + 1 - height)) + return QRect(anchor - width, top, width, height) + + def _on_button_hovered(self, panel_id: str, entered: bool) -> None: + if entered: + self._leave_timer.stop() + if not self._hover_open or panel_id in self._pinned: + return + if self._peek_id is not None and not self._peek_sticky: + if self._peek_id != panel_id: + self.open_panel(panel_id, sticky=False) + return + if self._peek_id is None: + self._pending_hover = panel_id + self._hover_timer.start() + else: + if self._pending_hover == panel_id: + self._hover_timer.stop() + self._pending_hover = None + + def _on_hover_timeout(self) -> None: + panel_id, self._pending_hover = self._pending_hover, None + if panel_id is None or self._peek_id is not None: + return + if self._buttons[panel_id].underMouse(): + self.open_panel(panel_id, sticky=False) + + def _pointer_inside(self) -> bool: + pos = QCursor.pos() + for widget in (self, self.peek): + if widget.isVisible() and widget.rect().contains(widget.mapFromGlobal(pos)): + return True + return False + + def _on_leave_timeout(self) -> None: + if self._peek_id is None or self._peek_sticky or self._pointer_inside(): + return + self.close_peek() + + def enterEvent(self, event): # pylint: disable=invalid-name + self._leave_timer.stop() + super().enterEvent(event) + + def leaveEvent(self, event): # pylint: disable=invalid-name + if self._peek_id is not None and not self._peek_sticky: + self._leave_timer.start() + super().leaveEvent(event) + + def _add_shortcut(self, sequence: str, slot) -> None: + shortcut = QShortcut(QKeySequence(sequence), self) + shortcut.setContext(Qt.ShortcutContext.WindowShortcut) + shortcut.activated.connect(slot) + self._shortcuts.append(shortcut) + + def _update_pulse(self) -> None: + pulsing = any(p.indicator.pulse for p in self._panels.values()) + running = self._pulse.state() == QVariantAnimation.State.Running + if pulsing and not running: + self._pulse.start() + elif not pulsing and running: + self._pulse.stop() + self.pulse_phase = 0.0 + + def _on_pulse(self, value) -> None: + self.pulse_phase = float(value) + for panel_id, button in self._buttons.items(): + if self._panels[panel_id].indicator.pulse: + button.update() + + def _install_app_filter(self, install: bool) -> None: + """Watch clicks and Esc on the window while a panel peeks, to close it on outside clicks. + + The filter sits on the window's ``QWindow``, which sees every mouse and key event of the + window before it is dispatched to a widget. An application-wide Python event filter + would also see events of objects being destroyed, which crashes PySide. + """ + if install == self._filter_installed: + return + if install: + handle = self.window().windowHandle() + if handle is None: + return + handle.installEventFilter(self) + self._filtered_window = handle + else: + handle = getattr(self, "_filtered_window", None) + if handle is not None and shiboken6.isValid(handle): + handle.removeEventFilter(self) + self._filtered_window = None + self._filter_installed = install + + def eventFilter(self, watched, event): # pylint: disable=invalid-name + etype = event.type() + if watched is self._overlay_parent and etype in (QEvent.Type.Resize, QEvent.Type.Show): + if self._peek_id is not None: + QTimer.singleShot(0, self._reposition_peek) + return False + if watched is self.peek: + if etype == QEvent.Type.Enter: + self._leave_timer.stop() + elif etype == QEvent.Type.Leave and self._peek_id is not None: + if not self._peek_sticky: + self._leave_timer.start() + return False + if self._peek_id is None: + return False + if watched is not getattr(self, "_filtered_window", None): + return False + if etype == QEvent.Type.MouseButtonPress: + # popups and dialogs opened from the panel are other windows: they keep it open + pos = event.globalPosition().toPoint() + if self.peek.rect().contains(self.peek.mapFromGlobal(pos)): + self._peek_sticky = True # working in the panel keeps it open + elif not self.rect().contains(self.mapFromGlobal(pos)): + self.close_peek() + elif etype == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Escape: + self.close_peek() + return False + + def _reposition_peek(self) -> None: + if self._peek_id is not None and self._slide.state() != QPropertyAnimation.State.Running: + self.peek.setGeometry(self._peek_geometry()) + + def _show_context_menu(self, pos: QPoint) -> None: + menu = QMenu(self) + hover = QAction("Open panels on hover", menu) + hover.setCheckable(True) + hover.setChecked(self._hover_open) + hover.toggled.connect(self.set_hover_open) + menu.addAction(hover) + unpin = QAction("Unpin all panels", menu) + unpin.setEnabled(bool(self._pinned)) + unpin.triggered.connect(lambda: [self.set_pinned(p, False) for p in list(self._pinned)]) + menu.addAction(unpin) + menu.exec(self.mapToGlobal(pos)) + + def _on_theme_changed(self, *_args) -> None: + self.tokens = ThemeTokens() + for frame in self._frames.values(): + frame.refresh_theme(self.tokens) + self._apply_style() + self.update() + self.peek.update() + for button in self._buttons.values(): + button.update() + + def _apply_style(self) -> None: + self.setStyleSheet( + f"#systemDockRail {{ border-left: 1px solid {self.tokens.border.name()}; }}" + ) + self.pinned_column.setStyleSheet( + f"#systemDockPinned {{ background: {self.tokens.bg.name()}; }}" + f"#systemDockPinned::handle {{ background: {self.tokens.track.name()}; height: 1px; }}" + ) + + def showEvent(self, event): # pylint: disable=invalid-name + self._apply_style() + super().showEvent(event) + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + painter.setPen(self.tokens.border) + painter.drawLine(0, 0, 0, self.height()) + painter.end() + + def _read_setting(self, key: str, default, kind): + if self._settings is None: + return default + try: + return self._settings.value(key, default, type=kind) + except Exception: # pylint: disable=broad-except + return default + + def _write_setting(self, key: str, value) -> None: + if self._settings is None: + return + try: + self._settings.setValue(key, value) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Could not persist system dock setting {key}: {exc}") + + def cleanup(self) -> None: + """Stop timers, detach event filters and release the panels.""" + if self._cleaned: + return + self._cleaned = True + self._hover_timer.stop() + self._leave_timer.stop() + self._slide.stop() + self._pulse.stop() + self._install_app_filter(False) + if shiboken6.isValid(self._overlay_parent): + self._overlay_parent.removeEventFilter(self) + app = QApplication.instance() + theme = getattr(app, "theme", None) + if theme is not None and hasattr(theme, "theme_changed"): + try: + theme.theme_changed.disconnect(self._on_theme_changed) + except (RuntimeError, TypeError): + pass + for panel in self._panels.values(): + panel.cleanup() + if shiboken6.isValid(self.pinned_column): + self.pinned_column.deleteLater() diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py index 7160fe14..60da3b56 100644 --- a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py @@ -153,7 +153,7 @@ class NotificationHostQML(NotificationHostBase): self.drawer.setVisible(state["drawerOpen"]) def _place_views(self, *_args) -> None: - if self.drawer.isVisible(): + if self.drawer.isVisible() and not self.drawer_docked: self.drawer.setGeometry(self.drawer_geometry()) self.drawer.raise_() if self.toasts.isVisible(): diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py index 8fba4ded..5323f636 100644 --- a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py @@ -718,7 +718,7 @@ class NotificationHostQWidget(NotificationHostBase): self.bell.set_state(state, tokens) def _place_views(self) -> None: - if self.drawer.isVisible(): + if self.drawer.isVisible() and not self.drawer_docked: self.drawer.setGeometry(self.drawer_geometry()) self.drawer.raise_() if self.toasts.isVisible(): diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py index ccc010f7..2f282eb4 100644 --- a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py @@ -684,6 +684,8 @@ class NotificationHostBase(QObject): DRAWER_WIDTH = 400 MARGIN = 16 + drawer_toggled = Signal(bool) + def __init__(self, window: QWidget, status_bar: QStatusBar | None = None, dispatcher=None): super().__init__(window) self.window = window @@ -696,6 +698,10 @@ class NotificationHostBase(QObject): self.search = "" self.selected_id: str | None = None self._sync_pending = False + # set by a container that hosts the drawer itself (the main app's system dock): the + # drawer is then laid out by that container, and toasts keep clear of ``right_inset`` + self.drawer_docked = False + self.right_inset = 0 if status_bar is None and isinstance(window, QMainWindow): status_bar = window.statusBar() self.status_bar = status_bar @@ -721,6 +727,7 @@ class NotificationHostBase(QObject): def set_drawer_open(self, open_: bool) -> None: """Open or close the history drawer. Opening marks everything as read.""" + changed = bool(open_) != self.drawer_open self.drawer_open = bool(open_) self.queue.set_suspended(self.drawer_open) if self.drawer_open: @@ -728,6 +735,8 @@ class NotificationHostBase(QObject): else: self.selected_id = None self.sync() + if changed: + self.drawer_toggled.emit(self.drawer_open) def open_details(self, entry_id: str) -> None: """Open the drawer on the details of one entry (the toast's Details action).""" @@ -891,7 +900,8 @@ class NotificationHostBase(QObject): status = self.window.statusBar() if self.window.statusBar() else None if status is not None and status.isVisible(): bottom = status.geometry().top() - 1 - return QRect(rect.left(), top, rect.width(), max(0, bottom - top + 1)) + width = max(0, rect.width() - self.right_inset) + return QRect(rect.left(), top, width, max(0, bottom - top + 1)) def toast_geometry(self, content_height: int) -> QRect: """Bottom-right rectangle for a toast stack of ``content_height`` pixels.""" diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml index bf234e44..b8ab9d58 100644 --- a/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml @@ -11,11 +11,14 @@ Rectangle { readonly property var st: backend.state readonly property var sel: st.selected || ({}) readonly property bool hasSelection: sel.entryId !== undefined + // set when a container (the main app's system dock) provides the title, close and edge + property bool embedded: false color: theme.bg // shadow towards the content and a hairline border Rectangle { + visible: !root.embedded anchors.right: parent.left width: 8; height: parent.height gradient: Gradient { @@ -24,7 +27,10 @@ Rectangle { GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, theme.dark ? 0.3 : 0.08) } } } - Rectangle { anchors.left: parent.left; width: 1; height: parent.height; color: theme.border } + Rectangle { + visible: !root.embedded + anchors.left: parent.left; width: 1; height: parent.height; color: theme.border + } ColumnLayout { anchors.fill: parent @@ -48,6 +54,7 @@ Rectangle { spacing: 0 Text { Layout.fillWidth: true + visible: !root.embedded text: "Notifications" color: theme.fg font.pixelSize: 15 @@ -63,7 +70,10 @@ Rectangle { } IconButton { iconName: "done_all"; tip: "Mark all as read"; onClicked: root.backend.markAllRead() } IconButton { iconName: "clear_all"; tip: "Clear history"; onClicked: root.backend.clearHistory() } - IconButton { iconName: "close"; tip: "Close"; onClicked: root.backend.setDrawerOpen(false) } + IconButton { + visible: !root.embedded + iconName: "close"; tip: "Close"; onClicked: root.backend.setDrawerOpen(false) + } } Flow { diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 1682596c..a806f34e 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -55,7 +55,9 @@ class BECMainWindow(BECWidget, QMainWindow): SCAN_PROGRESS_WIDTH = 120 # px SCAN_PROGRESS_HEIGHT = 20 # px - def __init__(self, parent=None, window_title: str = "BEC", **kwargs): + def __init__( + self, parent=None, window_title: str = "BEC", notification_ui: str | None = None, **kwargs + ): super().__init__(parent=parent, **kwargs) self.app = QApplication.instance() @@ -65,8 +67,11 @@ class BECMainWindow(BECWidget, QMainWindow): self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) # Notification Centre overlay. BEC_NOTIFICATION_UI=qml|qwidget opts into the reworked - # toasts and history drawer, which also replace the modal error dialogs. - self._notification_ui = os.environ.get("BEC_NOTIFICATION_UI", "legacy").lower() + # toasts and history drawer, which also replace the modal error dialogs. Subclasses can + # choose the UI with ``notification_ui``; the environment variable still wins. + self._notification_ui = os.environ.get( + "BEC_NOTIFICATION_UI", notification_ui or "legacy" + ).lower() self.notifications = None if self._notification_ui in ("qml", "qwidget"): self.notification_centre = None diff --git a/tests/unit_tests/test_system_dock.py b/tests/unit_tests/test_system_dock.py new file mode 100644 index 00000000..26693f59 --- /dev/null +++ b/tests/unit_tests/test_system_dock.py @@ -0,0 +1,305 @@ +from types import SimpleNamespace + +import pytest +from bec_lib import messages +from qtpy.QtCore import QEvent, QPointF, QSettings, Qt +from qtpy.QtGui import QKeyEvent, QMouseEvent +from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget + +from bec_widgets.applications.main_app import BECMainApp +from bec_widgets.applications.system_dock.system_dock import ( + MAX_PINNED, + RailIndicator, + SystemDock, + SystemPanel, +) +from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_ux_common import ( + NotificationHub, +) + + +@pytest.fixture +def settings(tmp_path): + return QSettings(str(tmp_path / "dock.ini"), QSettings.Format.IniFormat) + + +@pytest.fixture +def window(qtbot, settings): + """A plain window with a content area and the dock on its right edge.""" + win = QWidget() + win.resize(1200, 700) + layout = QHBoxLayout(win) + layout.setContentsMargins(0, 0, 0, 0) + win.content = QLabel("content", win) + layout.addWidget(win.content, 1) + win.dock = SystemDock(win, settings=settings) + layout.addWidget(win.dock.pinned_column) + layout.addWidget(win.dock) + for name in ("alpha", "beta", "gamma"): + win.dock.add_panel( + SystemPanel(name, name.title(), "info", name.title(), QLabel(name)), + shortcut="Ctrl+Shift+A" if name == "alpha" else None, + ) + win.dock.add_separator() + win.dock.add_panel( + SystemPanel("small", "Small", "info", "Small", QLabel("small"), preferred_height=80) + ) + qtbot.addWidget(win) + win.show() + qtbot.waitExposed(win) + yield win + win.dock.cleanup() + + +def _press(widget: QWidget): + """Send a mouse press at the centre of ``widget`` through its window, like a real click.""" + handle = widget.window().windowHandle() + local = QPointF(widget.mapTo(widget.window(), widget.rect().center())) + global_pos = QPointF(widget.mapToGlobal(widget.rect().center())) + event = QMouseEvent( + QEvent.Type.MouseButtonPress, + local, + global_pos, + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + QApplication.sendEvent(handle, event) + + +def test_rail_lists_panels_in_order(window): + assert window.dock.panel_ids == ["alpha", "beta", "gamma", "small"] + assert window.dock.peek_panel_id is None + assert not window.dock.hover_open # click to open is the default + + +def test_click_opens_flyout_and_click_again_closes(qtbot, window): + dock = window.dock + qtbot.mouseClick(dock.button("alpha"), Qt.MouseButton.LeftButton) + assert dock.peek_panel_id == "alpha" + assert dock.peek_sticky + assert dock.peek.isVisible() + assert dock.button("alpha").active + qtbot.mouseClick(dock.button("beta"), Qt.MouseButton.LeftButton) + assert dock.peek_panel_id == "beta" + assert not dock.button("alpha").active + qtbot.mouseClick(dock.button("beta"), Qt.MouseButton.LeftButton) + assert dock.peek_panel_id is None + assert not dock.peek.isVisible() + + +def test_click_outside_and_escape_close_the_flyout(qtbot, window): + dock = window.dock + dock.open_panel("alpha") + _press(dock.frame("alpha").panel.content) + assert dock.peek_panel_id == "alpha" # clicks inside the flyout keep it open + _press(window.content) + assert dock.peek_panel_id is None + dock.open_panel("alpha") + # real key presses reach the window first, which is where the dock listens + escape = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier) + QApplication.sendEvent(window.windowHandle(), escape) + assert dock.peek_panel_id is None + + +def test_flyout_is_left_of_the_rail_and_compact_panels_are_short(qtbot, window): + dock = window.dock + dock.open_panel("alpha") + qtbot.wait(200) + assert dock.peek.geometry().right() < dock.geometry().left() + assert dock.peek.height() == dock.height() + dock.open_panel("small") + assert dock.peek.height() < dock.height() + + +def test_pin_moves_panel_into_the_column(qtbot, window, settings): + dock = window.dock + dock.open_panel("alpha") + dock.set_pinned("alpha", True) + assert dock.pinned == ["alpha"] + assert dock.peek_panel_id is None + assert dock.pinned_column.isVisible() + assert dock.frame("alpha").parent() is dock.pinned_column + assert dock.is_shown("alpha") + assert dock.button("alpha").active + assert settings.value("system_dock/pinned") in (["alpha"], "alpha") + # a pinned panel's rail button does not open a flyout + qtbot.mouseClick(dock.button("alpha"), Qt.MouseButton.LeftButton) + assert dock.peek_panel_id is None + dock.close_panel("alpha") + assert dock.pinned == [] + assert not dock.pinned_column.isVisible() + assert not dock.is_shown("alpha") + + +def test_at_most_two_panels_are_pinned(window): + dock = window.dock + for panel_id in ("alpha", "beta", "gamma"): + dock.set_pinned(panel_id, True) + assert len(dock.pinned) == MAX_PINNED + assert dock.pinned == ["beta", "gamma"] + + +def test_pin_shortcut_pins_the_open_flyout_then_unpins(window): + dock = window.dock + dock.open_panel("beta") + dock.toggle_pin_current() + assert dock.pinned == ["beta"] + dock.toggle_pin_current() + assert dock.pinned == [] + + +def test_panel_shortcut_toggles_the_panel(qtbot, window): + qtbot.keyClick( + window, + Qt.Key.Key_A, + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier, + ) + assert window.dock.peek_panel_id == "alpha" + assert "Ctrl+Shift+A" in window.dock.button("alpha").toolTip() + + +def test_pinned_panels_are_restored(qtbot, window, settings): + window.dock.set_pinned("gamma", True) + other = QWidget() + qtbot.addWidget(other) + dock = SystemDock(other, settings=settings) + dock.add_panel(SystemPanel("gamma", "Gamma", "info", "Gamma", QLabel("g"))) + dock.restore_state() + assert dock.pinned == ["gamma"] + dock.cleanup() + + +def test_hover_opens_only_when_enabled(qtbot, window): + dock = window.dock + dock._on_button_hovered("alpha", True) + qtbot.wait(400) + assert dock.peek_panel_id is None + dock.set_hover_open(True) + dock.open_panel("alpha", sticky=False) + dock._on_button_hovered("beta", True) # a hover flyout follows the pointer + assert dock.peek_panel_id == "beta" + assert not dock.peek_sticky + + +def test_indicator_updates_tooltip_and_pulse(window): + dock = window.dock + panel = dock.panel("beta") + panel.set_indicator(RailIndicator(badge="2", tooltip="2 things", pulse=True)) + assert "2 things" in dock.button("beta").toolTip() + assert dock._pulse.state() == dock._pulse.State.Running + panel.set_indicator(RailIndicator()) + assert dock._pulse.state() != dock._pulse.State.Running + + +def test_hiding_the_dock_closes_the_flyout(window): + dock = window.dock + dock.open_panel("alpha") + dock.set_pinned("beta", True) + dock.set_dock_visible(False) + assert dock.peek_panel_id is None + assert not dock.isVisible() + assert not dock.pinned_column.isVisible() + dock.set_dock_visible(True) + assert dock.pinned_column.isVisible() + + +@pytest.fixture +def main_app(qtbot, mocked_client, monkeypatch): + monkeypatch.delenv("BEC_SYSTEM_DOCK", raising=False) + monkeypatch.delenv("BEC_NOTIFICATION_UI", raising=False) + app = BECMainApp(client=mocked_client, anim_duration=10, system_dock="qwidget") + qtbot.addWidget(app) + app.show() + qtbot.waitExposed(app) + yield app + # the reworked notification hub is an app-wide singleton + NotificationHub.reset_instance() + + +def test_main_app_builds_the_core_panels(main_app): + dock = main_app.system_dock + assert dock.panel_ids == [ + "progress", + "queue", + "services", + "beamline_states", + "notifications", + "devices", + ] + assert main_app.content_splitter.widget(1) is dock.pinned_column + # the rail replaces the status bar progress bar and bell + assert not main_app._scan_progress_bar_with_separator.isVisible() + assert not main_app.notifications.bell.isVisible() + assert main_app.notification_indicator is dock.button("notifications") + + +def test_main_app_notifications_open_in_the_dock(qtbot, main_app): + dock = main_app.system_dock + host = main_app.notifications + entry = host.hub.notify("Detector timeout", "eiger9m", "error") + qtbot.waitUntil(lambda: host.hub.unread_count() == 1) + qtbot.waitUntil(lambda: dock.panel("notifications").indicator.badge == "1") + # the toast's Details action opens the dock panel at that entry + host.open_details(entry) + assert dock.peek_panel_id == "notifications" + assert host.drawer_open + assert host.drawer.parent() is not main_app + qtbot.waitUntil(lambda: dock.panel("notifications").indicator.badge == "") + dock.close_peek() + assert not host.drawer_open + + +def test_main_app_badges_follow_the_widgets(main_app): + dock = main_app.system_dock + tokens = ThemeTokens() + queue = dock.panel("queue") + paused = messages.ScanQueueStatusMessage( + metadata={}, queue={"primary": {"info": [], "status": "PAUSED", "locks": []}} + ) + queue.queue.update_queue(paused.content, {}) + assert queue.indicator.badge == "" + assert queue.indicator.dot_color == tokens.warning + assert queue.indicator.summary.startswith("Paused") + + services = dock.panel("services") + assert services.indicator.dot_color is None or services.box.model.tone in ( + "warning", + "emergency", + ) + + states = dock.panel("beamline_states") + real = states.states + try: + controller = SimpleNamespace( + blockingStates=["shutter_open"], + counts={"invalid": 1, "warning": 0, "unknown": 0, "valid": 3}, + totalCount=4, + interlockArmed=True, + ) + states.states = SimpleNamespace(controller=controller) + states.refresh() + assert states.indicator.badge == "!" + assert states.indicator.pulse + controller.blockingStates = [] + states.refresh() + assert states.indicator.badge == "" + assert states.indicator.dot_color == tokens.warning + controller.counts = {"invalid": 0, "warning": 0, "unknown": 0, "valid": 4} + states.refresh() + assert states.indicator.dot_color is None + assert states.indicator.summary == "All 4 valid · interlock armed" + finally: + states.states = real + + +def test_main_app_without_dock(qtbot, mocked_client, monkeypatch): + monkeypatch.setenv("BEC_SYSTEM_DOCK", "off") + monkeypatch.delenv("BEC_NOTIFICATION_UI", raising=False) + app = BECMainApp(client=mocked_client, anim_duration=10) + qtbot.addWidget(app) + assert app.system_dock is None + assert app.notifications is None + assert app.stack.parent() is app.centralWidget()