diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index e238599d..0ae9671b 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -24,6 +24,8 @@ from bec_widgets.utils.screen_utils import ( ) from bec_widgets.widgets.containers.dock_area.profile_utils import is_experimental_features_enabled from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow +from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox +from bec_widgets.widgets.services.bec_status_box.status_pill import BECStatusPill startup_profiler.mark("module imports") @@ -65,6 +67,9 @@ class BECMainApp(BECMainWindow): self._add_views() startup_profiler.mark("views added") + self._add_status_pill() + startup_profiler.mark("status pill") + # Initialize guided tour self.guided_tour = GuidedTour(self) self._setup_guided_tour() @@ -139,6 +144,26 @@ class BECMainApp(BECMainWindow): # Add guided tour to Help menu self._add_guided_tour_to_menu() + def _add_status_pill(self): + """Add the BEC services pill to the status bar, left of the notification indicator.""" + self.status_pill = BECStatusPill(self, client=self.client) + self.status_pill.dock_requested.connect(self.dock_status_box) + self.status_bar.insertPermanentWidget(0, self.status_pill) + + def dock_status_box(self) -> None: + """Show a status box in the dock area, reusing one that is already docked.""" + dock_area = self.dock_area.dock_area + for widget in dock_area.widget_list(): + if isinstance(widget, BECStatusBox): + dock = next((d for d in dock_area.dock_list() if d.widget() is widget), None) + if dock is not None: + dock.toggleView(True) + dock.raise_() + break + else: + dock_area.new("BECStatusBox") + self.set_current("dock_area") + # --- Public API ------------------------------------------------------ def add_section(self, title: str, id: str, position: int | None = None): return self.sidebar.add_section(title, id, position) @@ -392,6 +417,7 @@ class BECMainApp(BECMainWindow): help_menu.addAction(tour_action) def cleanup(self): + self.status_pill.cleanup() for view_id, idx in self._view_index.items(): view = self.stack.widget(idx) view.close() diff --git a/bec_widgets/widgets/services/bec_status_box/status_pill.py b/bec_widgets/widgets/services/bec_status_box/status_pill.py new file mode 100644 index 00000000..9660fa4a --- /dev/null +++ b/bec_widgets/widgets/services/bec_status_box/status_pill.py @@ -0,0 +1,159 @@ +"""Compact status-bar pill for the BEC status box. + +The pill shows the overall BEC state in a few words. Clicking it opens the full status box in a +popup, which has a button to put a status box into the dock area. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QPoint, QSize, Qt, Signal +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import rgba +from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox +from bec_widgets.widgets.services.bec_status_box.status_box_widgets import theme_colors, tone_color + +PILL_TEXT = {"All core services running": "BEC running", "Waiting for BEC services": "BEC: waiting"} + + +class StatusPopup(QFrame): + """Popup frame holding the full status box and the dock button.""" + + dock_requested = Signal() + + def __init__(self, parent: QWidget, status_box: BECStatusBox): + super().__init__(parent, Qt.WindowType.Popup) + self.setObjectName("StatusPopup") + self.setFrameShape(QFrame.Shape.StyledPanel) + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(4) + header = QHBoxLayout() + title = QLabel("BEC services", self) + title.setObjectName("popupTitle") + self.dock_button = QToolButton(self) + self.dock_button.setObjectName("dockButton") + self.dock_button.setText("Open in dock area") + self.dock_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.dock_button.setIconSize(QSize(16, 16)) + self.dock_button.setAutoRaise(True) + self.dock_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.dock_button.clicked.connect(self._on_dock) + header.addWidget(title) + header.addStretch() + header.addWidget(self.dock_button) + layout.addLayout(header) + self.status_box = status_box + status_box.setParent(self) + layout.addWidget(status_box, 1) + self.resize(400, 540) + self.apply_theme() + + def _on_dock(self): + self.hide() + self.dock_requested.emit() + + def apply_theme(self) -> None: + """Restyle with the current theme colors.""" + colors = theme_colors() + self.dock_button.setIcon( + material_icon( + "dock_to_right", size=(16, 16), color=colors["FG"], convert_to_pixmap=False + ) + ) + self.setStyleSheet( + f"#StatusPopup {{ background: {colors['BG'].name()}; border: 1px solid " + f"{colors['BORDER'].name()}; border-radius: 8px; }}" + f"#popupTitle {{ color: {colors['FG'].name()}; font-weight: bold; }}" + ) + + +class BECStatusPill(QPushButton): + """A pill for the status bar that summarises BEC and opens the status box on click. + + Args: + parent (QWidget | None): The parent widget, usually the main window. + view (str | None): View of the status box in the popup, "qml" or "widgets". + client: The BEC client, passed to the status box. + """ + + dock_requested = Signal() + + def __init__(self, parent: QWidget | None = None, view: str | None = None, client=None): + super().__init__(parent) + self.setObjectName("BECStatusPill") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.setIconSize(QSize(10, 10)) + self.status_box = BECStatusBox(view=view, client=client, rpc_exposed=False) + self.popup = StatusPopup(self, self.status_box) + self.popup.dock_requested.connect(self.dock_requested) + self.model = self.status_box.model + self.model.summaryChanged.connect(self.refresh) + self.model.restartChanged.connect(self.refresh) + self.clicked.connect(self.toggle_popup) + theme = getattr(QApplication.instance(), "theme", None) + if theme is not None: + theme.theme_changed.connect(self.apply_theme) + self.refresh() + + @property + def tone(self) -> str: + """The tone of the pill, following a running restart first.""" + state = self.model.restartState + if state: + return {"running": "progress", "done": "success"}.get(state, "emergency") + return self.model.tone + + def text_for_state(self) -> str: + """The short pill text.""" + if self.model.restartState: + return self.model.restartText.replace("BEC server", "BEC") + return PILL_TEXT.get(self.model.headline, self.model.headline) + + def refresh(self) -> None: + """Update the text, colors and tooltip from the model.""" + self.setText(self.text_for_state()) + self.setToolTip(f"{self.model.headline}\n{self.model.detail}\nClick for details") + self.apply_theme() + + def apply_theme(self, _theme: str | None = None) -> None: + """Restyle with the current theme colors.""" + colors = theme_colors() + accent = tone_color(colors, self.tone) + self.setIcon(material_icon("circle", size=(10, 10), color=accent, filled=True)) + self.setStyleSheet( + f"#BECStatusPill {{ color: {colors['FG'].name()}; background: {rgba(accent, 38)};" + f" border: 1px solid {rgba(accent, 120)}; border-radius: 9px; padding: 1px 10px;" + f" font-size: 11px; }}" + f"#BECStatusPill:hover {{ background: {rgba(accent, 70)}; }}" + ) + self.popup.apply_theme() + + def toggle_popup(self) -> None: + """Open the status box above the pill, or close it when open.""" + if self.popup.isVisible(): + self.popup.hide() + return + size = self.popup.size() + anchor = self.mapToGlobal(QPoint(self.width(), 0)) + self.popup.move(anchor.x() - size.width(), anchor.y() - size.height() - 6) + self.popup.show() + self.popup.raise_() + + def cleanup(self) -> None: + """Clean up the status box inside the popup.""" + self.status_box.cleanup() + self.status_box.deleteLater() + self.popup.deleteLater() diff --git a/tests/unit_tests/test_bec_status_pill.py b/tests/unit_tests/test_bec_status_pill.py new file mode 100644 index 00000000..75577f8f --- /dev/null +++ b/tests/unit_tests/test_bec_status_pill.py @@ -0,0 +1,66 @@ +# pylint: skip-file +from unittest import mock + +import pytest + +from bec_widgets.applications.main_app import BECMainApp +from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.tests.utils import create_widget +from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox +from bec_widgets.widgets.services.bec_status_box.status_pill import BECStatusPill + +from .test_bec_status_box import _all_services + + +@pytest.fixture +def pill(qtbot, mocked_client): + widget = create_widget(qtbot, BECStatusPill, view="widgets", client=mocked_client) + yield widget + widget.popup.hide() + widget.cleanup() + + +def test_pill_text_follows_state(pill): + assert pill.text() == "BEC: waiting" + pill.status_box.update_service_status(*_all_services()) + assert pill.text() == "BEC running" + assert pill.tone == "success" + pill.status_box.update_service_status(*_all_services(skip=["ScanBundler"])) + assert pill.text() == "Scan bundler: not connected" + assert pill.tone == "emergency" + + +def test_pill_shows_restart(pill): + pill.status_box.update_service_status(*_all_services()) + pill.model.set_restart("running", "Restarting BEC server · 2/5") + assert pill.text() == "Restarting BEC · 2/5" + assert pill.tone == "progress" + + +def test_pill_toggles_popup(pill, qtbot): + pill.click() + qtbot.waitUntil(pill.popup.isVisible) + pill.click() + qtbot.waitUntil(lambda: not pill.popup.isVisible()) + + +def test_dock_button_requests_dock(pill, qtbot): + pill.click() + with qtbot.waitSignal(pill.dock_requested): + pill.popup.dock_button.click() + assert not pill.popup.isVisible() + + +def test_main_app_docks_one_status_box(qtbot, mocked_client): + app = BECMainApp(client=mocked_client, anim_duration=60, show_examples=False) + qtbot.addWidget(app) + assert isinstance(app.status_pill, BECStatusPill) + + def docked(): + return [w for w in app.dock_area.dock_area.widget_list() if isinstance(w, BECStatusBox)] + + before = len(docked()) + app.dock_status_box() + assert len(docked()) == before + 1 + app.dock_status_box() + assert len(docked()) == before + 1