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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EaukntuyeZn7cPqVqDTBE
This commit is contained in:
Claude committed 2026-10-09 22:07:49 +00:00
1 parent 894b96a544
commit a6b10f2e23
10 files changed
+1777 -13

No files matched your search

+122 -5
View File
@@ -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()
@@ -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"]
@@ -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))
@@ -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()
@@ -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():
@@ -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():
@@ -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."""
@@ -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 {
@@ -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
+305
View File
@@ -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()