feat(main_app): add a bec services pill to the status bar

This commit is contained in:
Claude committed 2026-10-13 09:27:41 +00:00
1 parent 707c6a08d4
commit 89c671806d
3 files changed
+251

No files matched your search

+26
View File
@@ -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()
@@ -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()
+66
View File
@@ -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