wip pill design with background

This commit is contained in:
2026-05-29 16:45:48 +02:00
parent 2435d0172d
commit 2042fbb357
2 changed files with 121 additions and 12 deletions
@@ -9,7 +9,7 @@ from bec_lib import bl_states
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import QByteArray, QEvent, QMimeData, QPoint, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QColor, QDrag, QPalette
from qtpy.QtGui import QColor, QCursor, QDrag, QPalette
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -17,6 +17,7 @@ from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QGraphicsDropShadowEffect,
QGroupBox,
QHBoxLayout,
QLabel,
@@ -89,6 +90,8 @@ class BeamlineStatePill(BECWidget, QWidget):
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
self.setObjectName("BeamlineStatePill")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self._state_name: str | None = None
self._title: str | None = None
self._state_config: dict[str, Any] = {}
@@ -96,14 +99,23 @@ class BeamlineStatePill(BECWidget, QWidget):
self._label = "No state information available."
self._flash_active = False
self._expanded = False
self._hovered = False
self._drag_payload_mode = "config"
self._drag_start_position: QPoint | None = None
self._drag_started = False
self.setMouseTracking(True)
self._flash_timer = QTimer(self)
self._flash_timer.setSingleShot(True)
self._flash_timer.timeout.connect(self._clear_state_flash)
self._shadow = QGraphicsDropShadowEffect(self)
self._shadow.setBlurRadius(18)
self._shadow.setOffset(0, 2)
self._shadow.setColor(QColor(0, 0, 0, 120))
self._shadow.setEnabled(False)
self.setGraphicsEffect(self._shadow)
self._header = QWidget(self)
self._header.setObjectName("beamline_state_header")
self._header.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -221,20 +233,14 @@ class BeamlineStatePill(BECWidget, QWidget):
layout.addWidget(self._settings)
self.setLayout(layout)
for widget in (
self._header,
self._stripe,
self._icon_label,
self._name_label,
self._status_label,
self._detail_label,
):
for widget in self._hover_widgets():
widget.installEventFilter(self)
self.setMinimumHeight(58)
self.set_state_name(state_name, title=title)
def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802
hover_widgets = set(self._hover_widgets())
draggable_widgets = {
self._header,
self._stripe,
@@ -243,6 +249,14 @@ class BeamlineStatePill(BECWidget, QWidget):
self._status_label,
self._detail_label,
}
if watched in hover_widgets:
if event.type() == QEvent.Type.Enter:
self._set_hovered(True)
return False
if event.type() == QEvent.Type.Leave:
QTimer.singleShot(0, self._sync_hover_state)
return False
if watched not in draggable_widgets:
return super().eventFilter(watched, event)
@@ -277,6 +291,14 @@ class BeamlineStatePill(BECWidget, QWidget):
return True
return super().eventFilter(watched, event)
def enterEvent(self, event: QEvent) -> None: # noqa: N802
self._set_hovered(True)
super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
QTimer.singleShot(0, self._sync_hover_state)
super().leaveEvent(event)
@property
def state_name(self) -> str | None:
"""Name of the BEC beamline state displayed by this pill."""
@@ -388,8 +410,20 @@ class BeamlineStatePill(BECWidget, QWidget):
colors = self._state_colors(self._status)
accent = colors["accent"]
on_accent = colors["on_accent"]
active_card = self._hovered or self._expanded
border = colors["flash_border"] if self._flash_active else colors["border"]
background = colors["flash_background"] if self._flash_active else colors["background"]
if active_card:
background = (
"qlineargradient("
"x1:0, y1:0, x2:1, y2:0, "
f"stop:0 {colors['gradient_accent']}, "
f"stop:0.62 {colors['card_background']}, "
f"stop:1 {colors['card_background']}"
")"
)
border = colors["card_border"]
self._shadow.setEnabled(active_card)
icon_name = self._STATUS_ICONS[self._status]
self._icon_label.setPixmap(
@@ -401,10 +435,13 @@ class BeamlineStatePill(BECWidget, QWidget):
self._detail_label.setText(self._label)
self.setToolTip(self._label)
self.setStyleSheet(
"BeamlineStatePill {"
f"background-color: {background};"
"#BeamlineStatePill {"
f"background: {background};"
f"border: 1px solid {border};"
"border-radius: 8px;"
f"border-radius: {'12px' if active_card else '8px'};"
"}"
"QWidget#beamline_state_header {"
"background: transparent;"
"}"
"QWidget#beamline_state_stripe {"
f"background-color: {accent};"
@@ -428,6 +465,7 @@ class BeamlineStatePill(BECWidget, QWidget):
"font-size: 11px;"
"}"
"QWidget#beamline_state_settings {"
"background: transparent;"
f"border-top: 1px solid {colors['border']};"
"}"
"QPushButton#beamline_state_remove_button {"
@@ -448,6 +486,16 @@ class BeamlineStatePill(BECWidget, QWidget):
self._settings.setVisible(self._expanded)
self._apply_visual_state()
def _set_hovered(self, hovered: bool) -> None:
if hovered == self._hovered:
return
self._hovered = hovered
self._apply_visual_state()
def _sync_hover_state(self) -> None:
inside = self.rect().contains(self.mapFromGlobal(QCursor.pos()))
self._set_hovered(inside)
def _populate_settings(self) -> None:
state_type = self._state_type()
self._state_type_value.setText(state_type or "-")
@@ -634,6 +682,27 @@ class BeamlineStatePill(BECWidget, QWidget):
if enabled:
spin_box.setValue(float(value))
def _hover_widgets(self) -> tuple[QWidget, ...]:
return (
self,
self._header,
self._stripe,
self._icon_label,
self._name_label,
self._status_label,
self._detail_label,
self._expand_button,
self._settings,
self._title_edit,
self._device_edit,
self._signal_edit,
self._low_limit.parentWidget(),
self._high_limit.parentWidget(),
self._tolerance,
self._update_button,
self._remove_button,
)
def _settings_input_fields(self) -> tuple[QWidget, ...]:
return (
self._state_type_value,
@@ -678,6 +747,11 @@ class BeamlineStatePill(BECWidget, QWidget):
return {
"accent": accent.name(),
"on_accent": on_primary.name(),
"card_background": card_bg.name(),
"card_border": cls._blend(border, accent, 0.45).name(),
"gradient_accent": cls._rgba(
accent, 110 if cls._is_light_theme(theme, palette) else 62
),
"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(),
@@ -686,6 +760,17 @@ class BeamlineStatePill(BECWidget, QWidget):
"muted": cls._blend(card_bg, foreground, 0.66).name(),
}
@staticmethod
def _is_light_theme(theme: Any, palette: QPalette) -> bool:
theme_name = str(getattr(theme, "theme", "")).lower()
if theme_name in {"light", "dark"}:
return theme_name == "light"
return palette.window().color().lightness() > 128
@staticmethod
def _rgba(color: QColor, alpha: int) -> str:
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {max(0, min(255, alpha))})"
@staticmethod
def _theme_color(theme: Any, key: str, fallback: QColor) -> QColor:
if theme is None:
@@ -92,6 +92,30 @@ def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_clie
assert signal.args[1]["tolerance"] == 0.1
def test_beamline_state_pill_uses_card_style_when_expanded(qtbot, mocked_client):
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
qtbot.addWidget(widget)
assert "qlineargradient" not in widget.styleSheet()
widget._toggle_expanded()
assert "qlineargradient" in widget.styleSheet()
assert widget._shadow.isEnabled()
def test_beamline_state_pill_uses_card_style_when_hovered(qtbot, mocked_client):
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
qtbot.addWidget(widget)
assert "qlineargradient" not in widget.styleSheet()
widget.eventFilter(widget._header, QEvent(QEvent.Type.Enter))
assert "qlineargradient" in widget.styleSheet()
assert widget._shadow.isEnabled()
def test_beamline_state_pill_drag_payload_modes(qtbot, mocked_client):
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
qtbot.addWidget(widget)