wip improved design

This commit is contained in:
2026-05-29 15:12:15 +02:00
parent 96073557e4
commit 328a68cc49
2 changed files with 184 additions and 33 deletions
@@ -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())
@@ -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."