mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-31 16:41:27 +01:00
feat(main_app): add a bec services pill to the status bar
This commit is contained in:
3 files changed
+251
No files matched your search
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user