mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-27 16:01:27 +01:00
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:
10 files changed
+1777
-13
No files matched your search
@@ -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()
|
||||
+1
-1
@@ -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():
|
||||
|
||||
+1
-1
@@ -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():
|
||||
|
||||
+11
-1
@@ -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."""
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user