From 328a68cc4952c88e76ab5a3c75c0298e49039819 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 29 May 2026 15:12:15 +0200 Subject: [PATCH] wip improved design --- .../beamline_states/beamline_state_pill.py | 214 +++++++++++++++--- tests/unit_tests/test_beamline_state_pill.py | 3 + 2 files changed, 184 insertions(+), 33 deletions(-) diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py index 77c38450..94ee7b78 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -4,13 +4,21 @@ import sys from typing import Any from bec_lib.endpoints import MessageEndpoints -from qtpy.QtCore import Qt, Signal, Slot -from qtpy.QtGui import QColor -from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QScrollArea, QVBoxLayout, QWidget +from bec_qthemes import material_icon +from qtpy.QtCore import Qt, QTimer, Signal, Slot +from qtpy.QtGui import QColor, QPalette +from qtpy.QtWidgets import ( + QApplication, + QHBoxLayout, + QLabel, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors class BeamlineStatePill(BECWidget, QWidget): @@ -33,6 +41,12 @@ class BeamlineStatePill(BECWidget, QWidget): "warning": "WARNING", "unknown": "UNKNOWN", } + _STATUS_ICONS = { + "valid": "check_circle", + "invalid": "cancel", + "warning": "warning", + "unknown": "help", + } def __init__( self, @@ -44,22 +58,55 @@ class BeamlineStatePill(BECWidget, QWidget): gui_id: str | None = None, **kwargs, ) -> None: - super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs) + super().__init__( + parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs + ) self._state_name: str | None = None self._title: str | None = None + self._status = "unknown" + self._label = "No state information available." + self._flash_active = False + self._flash_timer = QTimer(self) + self._flash_timer.setSingleShot(True) + self._flash_timer.timeout.connect(self._clear_state_flash) + + self._stripe = QWidget(self) + self._stripe.setObjectName("beamline_state_stripe") + self._stripe.setFixedWidth(4) + self._stripe.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + + self._icon_label = QLabel(self) + self._icon_label.setObjectName("beamline_state_icon") + self._icon_label.setFixedSize(32, 32) + self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._name_label = QLabel(self) self._name_label.setObjectName("beamline_state_name") + self._name_label.setTextFormat(Qt.TextFormat.PlainText) self._status_label = QLabel(self) self._status_label.setObjectName("beamline_state_status") + self._status_label.setTextFormat(Qt.TextFormat.PlainText) + self._detail_label = QLabel(self) + self._detail_label.setObjectName("beamline_state_detail") + self._detail_label.setTextFormat(Qt.TextFormat.PlainText) + self._detail_label.setWordWrap(True) + + text_layout = QVBoxLayout() + text_layout.setContentsMargins(0, 0, 0, 0) + text_layout.setSpacing(1) + text_layout.addWidget(self._name_label) + text_layout.addWidget(self._detail_label) layout = QHBoxLayout(self) - layout.setContentsMargins(10, 4, 10, 4) - layout.setSpacing(8) - layout.addWidget(self._name_label) + layout.setContentsMargins(10, 8, 12, 8) + layout.setSpacing(10) + layout.addWidget(self._stripe) + layout.addWidget(self._icon_label) + layout.addLayout(text_layout, 1) layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight) self.setLayout(layout) + self.setMinimumHeight(58) self.set_state_name(state_name, title=title) @property @@ -123,41 +170,118 @@ class BeamlineStatePill(BECWidget, QWidget): status = str(content.get("status", "unknown")).lower() label = str(content.get("label", "No state information available.")) - self._set_visual_state(status, label) + status_changed = status != self._status or label != self._label + self._set_visual_state(status, label, flash=status_changed) self.state_changed.emit(self._state_name or str(name or ""), status, label) - def _set_visual_state(self, status: str, label: str) -> None: + @Slot(str) + def apply_theme(self, _theme: str) -> None: + self._apply_visual_state() + + def _set_visual_state(self, status: str, label: str, flash: bool = False) -> None: status = status if status in self._STATUS_LABELS else "unknown" - color = self._status_color(status) - self._status_label.setText(self._STATUS_LABELS[status]) - self.setToolTip(label) + self._status = status + self._label = label + + if flash: + self._flash_active = True + self._flash_timer.start(900) + + self._apply_visual_state() + + def _apply_visual_state(self) -> None: + colors = self._state_colors(self._status) + accent = colors["accent"] + on_accent = colors["on_accent"] + border = colors["flash_border"] if self._flash_active else colors["border"] + background = colors["flash_background"] if self._flash_active else colors["background"] + + icon_name = self._STATUS_ICONS[self._status] + self._icon_label.setPixmap( + material_icon(icon_name, size=(20, 20), color=on_accent, filled=True) + ) + self._status_label.setText(self._STATUS_LABELS[self._status]) + self._detail_label.setText(self._label) + self.setToolTip(self._label) self.setStyleSheet( "BeamlineStatePill {" - f"border: 1px solid {color};" - "border-radius: 10px;" + f"background-color: {background};" + f"border: 1px solid {border};" + "border-radius: 8px;" + "}" + "QWidget#beamline_state_stripe {" + f"background-color: {accent};" + "border-radius: 2px;" + "}" + "QLabel#beamline_state_icon {" + f"background-color: {accent};" + "border-radius: 16px;" "}" "QLabel#beamline_state_name {" + f"color: {colors['foreground']};" "font-weight: 600;" "}" "QLabel#beamline_state_status {" - f"color: {color};" + f"color: {accent};" "font-weight: 700;" + "font-size: 13px;" + "}" + "QLabel#beamline_state_detail {" + f"color: {colors['muted']};" + "font-size: 11px;" "}" ) - @staticmethod - def _status_color(status: str) -> str: - accent_colors = get_accent_colors() - colors = { - "valid": accent_colors.success, - "invalid": accent_colors.emergency, - "warning": accent_colors.warning, - "unknown": "#7a7a7a", + def _clear_state_flash(self) -> None: + self._flash_active = False + self._apply_visual_state() + + @classmethod + def _state_colors(cls, status: str) -> dict[str, str]: + app = QApplication.instance() + palette = app.palette() if app is not None else QPalette() + theme = getattr(app, "theme", None) if app is not None else None + + card_bg = cls._theme_color(theme, "CARD_BG", palette.window().color()) + foreground = cls._theme_color(theme, "FG", palette.text().color()) + border = cls._theme_color(theme, "BORDER", palette.mid().color()) + on_primary = cls._theme_color(theme, "ON_PRIMARY", QColor("#ffffff")) + + accents = getattr(theme, "accent_colors", None) + accent = { + "valid": getattr(accents, "success", QColor("#2CA58D")), + "invalid": getattr(accents, "emergency", QColor("#CC181E")), + "warning": getattr(accents, "warning", QColor("#EAC435")), + "unknown": cls._theme_color(theme, "ACCENT_DEFAULT", QColor("#7a7a7a")), + }.get(status, QColor("#7a7a7a")) + + return { + "accent": accent.name(), + "on_accent": on_primary.name(), + "background": cls._blend(card_bg, accent, 0.10).name(), + "flash_background": cls._blend(card_bg, accent, 0.22).name(), + "border": cls._blend(border, accent, 0.35).name(), + "flash_border": accent.name(), + "foreground": foreground.name(), + "muted": cls._blend(card_bg, foreground, 0.66).name(), } - color = colors.get(status, colors["unknown"]) - if isinstance(color, QColor): - return color.name() - return str(color) + + @staticmethod + def _theme_color(theme: Any, key: str, fallback: QColor) -> QColor: + if theme is None: + return fallback + color = theme.color(key, fallback.name()) + return color if isinstance(color, QColor) else QColor(str(color)) + + @staticmethod + def _blend(base: QColor, overlay: QColor, overlay_alpha: float) -> QColor: + overlay_alpha = max(0.0, min(1.0, overlay_alpha)) + base_alpha = 1.0 - overlay_alpha + return QColor( + round(base.red() * base_alpha + overlay.red() * overlay_alpha), + round(base.green() * base_alpha + overlay.green() * overlay_alpha), + round(base.blue() * base_alpha + overlay.blue() * overlay_alpha), + ) def cleanup(self) -> None: if self._state_name is not None: @@ -187,7 +311,9 @@ class BeamlineStateList(BECWidget, QWidget): gui_id: str | None = None, **kwargs, ) -> None: - super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs) + super().__init__( + parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs + ) self._state_pills: dict[str, BeamlineStatePill] = {} self._empty_label = QLabel("No beamline states available.", self) @@ -218,6 +344,12 @@ class BeamlineStateList(BECWidget, QWidget): ) self.refresh_states() + @Slot(str) + def apply_theme(self, _theme: str) -> None: + self.setStyleSheet("BeamlineStateList { border: none; }") + for pill in self._state_pills.values(): + pill.apply_theme(_theme) + def refresh_states(self) -> None: """Fetch the latest cached available beamline states and update the list immediately.""" msg_container = self.client.connector.get_last(MessageEndpoints.available_beamline_states()) @@ -284,8 +416,24 @@ class BeamlineStateList(BECWidget, QWidget): if __name__ == "__main__": # pragma: no cover app = QApplication(sys.argv) - widget = BeamlineStateList() - widget.setWindowTitle("Beamline States") - widget.resize(360, 420) - widget.show() + + from bec_widgets.utils.colors import apply_theme + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + apply_theme("dark") + + window = QWidget() + window.setWindowTitle("Beamline States") + layout = QVBoxLayout(window) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + theme_row = QHBoxLayout() + theme_row.addStretch(1) + theme_row.addWidget(DarkModeButton(parent=window)) + layout.addLayout(theme_row) + layout.addWidget(BeamlineStateList(parent=window)) + + window.resize(420, 480) + window.show() sys.exit(app.exec()) diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 16b9195a..fe5cbfcc 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -19,6 +19,9 @@ def test_beamline_state_pill_updates_from_message(qtbot, mocked_client): assert widget.state_name == "shutter_open" assert widget._name_label.text() == "Shutter" assert widget._status_label.text() == "VALID" + assert widget._detail_label.text() == "Shutter is open." + assert not widget._icon_label.pixmap().isNull() + assert widget._flash_active assert widget.toolTip() == "Shutter is open."