From 2042fbb3570947cc68fc461fe8468ac6b3e53c0c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 29 May 2026 16:45:48 +0200 Subject: [PATCH] wip pill design with background --- .../beamline_states/beamline_state_pill.py | 109 ++++++++++++++++-- tests/unit_tests/test_beamline_state_pill.py | 24 ++++ 2 files changed, 121 insertions(+), 12 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 0940c248..75702cb8 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -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: diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 5f6f5bce..fb19c131 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -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)