mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-05 21:08:40 +02:00
wip improved design
This commit is contained in:
@@ -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."
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user