diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py index da865281..dd925ebf 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py @@ -6,8 +6,7 @@ from typing import Any from bec_lib import bl_states, messages from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon -from qtpy.QtCore import QAbstractListModel, QModelIndex, QRect, QSize, Qt -from qtpy.QtGui import QColor, QPainter, QPen +from qtpy.QtCore import QAbstractListModel, QModelIndex, QSize, Qt from qtpy.QtWidgets import ( QAbstractItemView, QApplication, @@ -152,8 +151,45 @@ class _BeamlineStateListModel(QAbstractListModel): return self.index(row, 0) +class _BeamlineStateSectionHeader(QWidget): + """Section header row (icon + label + rule line) shown above each state group.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("beamline_state_section_header") + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self._icon = QLabel(self) + self._label = QLabel(self) + self._label.setObjectName("beamline_state_section_label") + self._rule = QWidget(self) + self._rule.setObjectName("beamline_state_section_rule") + self._rule.setFixedHeight(1) + self._rule.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 0, 8, 0) + layout.setSpacing(6) + layout.addWidget(self._icon) + layout.addWidget(self._label) + layout.addWidget(self._rule, 1, Qt.AlignmentFlag.AlignVCenter) + + def set_header(self, *, icon_name: str, text: str, color: str, filled: bool, rule: str) -> None: + self._icon.setPixmap(material_icon(icon_name, size=(14, 14), color=color, filled=filled)) + self._label.setText(text) + self.setStyleSheet( + "QLabel#beamline_state_section_label {" + f"color: {color};" + "font-weight: 700;" + "font-size: 11px;" + "}" + "QWidget#beamline_state_section_rule {" + f"background-color: {rule};" + "}" + ) + + class _BeamlineStatePillDelegate(QStyledItemDelegate): - """Delegate painting section headers and providing BeamlineStatePill persistent editors.""" + """Delegate providing persistent editors: a pill for state rows, a header widget for headers.""" HEADER_HEIGHT = 26 @@ -161,51 +197,20 @@ class _BeamlineStatePillDelegate(QStyledItemDelegate): super().__init__(manager) self._manager = manager - def paint(self, painter, option: QStyleOptionViewItem, index: QModelIndex) -> None: - kind = index.data(_BeamlineStateListModel.HeaderRole) - if kind is None: - return - - colors = BeamlineStatePill._state_colors("unknown") - armed = ( - kind == _BeamlineStateListModel.INTERLOCK_HEADER and self._manager._interlock_enabled - ) - label_color = QColor(colors["foreground"] if armed else colors["muted"]) - icon_name = ( - "lock" if kind == _BeamlineStateListModel.INTERLOCK_HEADER else "lock_open_right" - ) - pixmap = material_icon(icon_name, size=(14, 14), color=label_color, filled=armed) - text = str(index.data(Qt.ItemDataRole.DisplayRole)) - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - rect = option.rect.adjusted(8, 0, -8, 0) - - font = painter.font() - font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) - font.setBold(True) - painter.setFont(font) - - icon_width = int(pixmap.width() / pixmap.devicePixelRatio()) - icon_height = int(pixmap.height() / pixmap.devicePixelRatio()) - painter.drawPixmap(rect.left(), rect.center().y() - icon_height // 2, pixmap) - - text_left = rect.left() + icon_width + 6 - painter.setPen(label_color) - text_rect = QRect(text_left, rect.top(), rect.width() - icon_width - 6, rect.height()) - painter.drawText( - text_rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text - ) - - rule_left = text_left + painter.fontMetrics().horizontalAdvance(text) + 8 - if rule_left < rect.right(): - painter.setPen(QPen(QColor(colors["border"]), 1)) - painter.drawLine(rule_left, rect.center().y(), rect.right(), rect.center().y()) - painter.restore() + def paint(self, _painter, _option: QStyleOptionViewItem, _index: QModelIndex) -> None: + # Every row is rendered by its persistent editor widget (pill or section header), + # so the delegate itself paints nothing. + return def createEditor( # noqa: N802 self, parent: QWidget, _option: QStyleOptionViewItem, index: QModelIndex ) -> QWidget: + kind = index.data(_BeamlineStateListModel.HeaderRole) + if kind is not None: + header = _BeamlineStateSectionHeader(parent) + self._manager._section_headers[str(kind)] = header + return header + name = index.data(_BeamlineStateListModel.NameRole) state_config = index.data(_BeamlineStateListModel.ConfigRole) pill = BeamlineStatePill(parent=parent, state_name=name, client=self._manager.client) @@ -219,6 +224,10 @@ class _BeamlineStatePillDelegate(QStyledItemDelegate): return pill def setEditorData(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802 + kind = index.data(_BeamlineStateListModel.HeaderRole) + if isinstance(editor, _BeamlineStateSectionHeader): + self._manager._apply_section_header(editor, str(kind)) + return if not isinstance(editor, BeamlineStatePill): return name = index.data(_BeamlineStateListModel.NameRole) @@ -241,7 +250,11 @@ class _BeamlineStatePillDelegate(QStyledItemDelegate): return QSize(120, 58) def destroyEditor(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802 - if isinstance(editor, BeamlineStatePill): + if isinstance(editor, _BeamlineStateSectionHeader): + for kind, header in list(self._manager._section_headers.items()): + if header is editor: + self._manager._section_headers.pop(kind, None) + elif isinstance(editor, BeamlineStatePill): name = editor.state_name if name and self._manager._state_pills.get(name) is editor: self._manager._state_pills.pop(name, None) @@ -312,6 +325,7 @@ class BeamlineStateManager(BECWidget, QWidget): ) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._state_pills: dict[str, BeamlineStatePill] = {} + self._section_headers: dict[str, _BeamlineStateSectionHeader] = {} self._state_configs: dict[str, messages.BeamlineStateConfig] = {} self._state_order: list[str] = [] self._selected_statuses: set[str] | None = None @@ -438,7 +452,7 @@ class BeamlineStateManager(BECWidget, QWidget): pill.apply_theme(_theme) self._interlock_action_armed = None self._sync_interlock_action() - self._view.viewport().update() + self._refresh_section_headers() self._refresh_hidden_summary() @SafeSlot() @@ -555,8 +569,8 @@ class BeamlineStateManager(BECWidget, QWidget): self._open_persistent_editors(expanded_names) for name, pill in self._state_pills.items(): self._apply_interlock_to_pill(name, pill) + self._refresh_section_headers() self._apply_filters() - self._view.viewport().update() def _sync_interlock_action(self) -> None: action = self._toolbar.components.get_action("scan_interlock").action @@ -590,6 +604,23 @@ class BeamlineStateManager(BECWidget, QWidget): self._interlock_states.get(name), self._is_interlock_triggered(name) ) + def _apply_section_header(self, header: _BeamlineStateSectionHeader, kind: str) -> None: + colors = BeamlineStatePill._state_colors("unknown") + armed = kind == _BeamlineStateListModel.INTERLOCK_HEADER and self._interlock_enabled + header.set_header( + icon_name=( + "lock" if kind == _BeamlineStateListModel.INTERLOCK_HEADER else "lock_open_right" + ), + text=_BeamlineStateListModel.HEADER_LABELS[kind], + color=colors["foreground"] if armed else colors["muted"], + filled=armed, + rule=colors["border"], + ) + + def _refresh_section_headers(self) -> None: + for kind, header in self._section_headers.items(): + self._apply_section_header(header, kind) + def _is_interlock_triggered(self, name: str) -> bool: required_status = self._interlock_states.get(name) if required_status is None or not self._interlock_enabled: @@ -642,9 +673,9 @@ class BeamlineStateManager(BECWidget, QWidget): expanded_names = expanded_names or set() for row in range(self._model.rowCount()): index = self._model.index(row, 0) + self._view.openPersistentEditor(index) if index.data(_BeamlineStateListModel.HeaderRole) is not None: continue - self._view.openPersistentEditor(index) name = str(index.data(_BeamlineStateListModel.NameRole)) pill = self._state_pills.get(name) if pill is not None: @@ -806,6 +837,7 @@ class BeamlineStateManager(BECWidget, QWidget): pill.cleanup() pill.deleteLater() self._state_pills.clear() + self._section_headers.clear() self._toolbar.components.cleanup() super().cleanup() diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 5c6debb2..3bff8f9a 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -1,7 +1,6 @@ import shiboken6 from bec_lib import bl_states, messages -from qtpy.QtCore import QCoreApplication, QEvent, QRect, Qt -from qtpy.QtGui import QPainter, QPixmap +from qtpy.QtCore import QCoreApplication, QEvent, Qt from qtpy.QtWidgets import QMessageBox, QStyleOptionViewItem from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -702,7 +701,7 @@ def test_beamline_state_manager_interlock_states_bypass_filters(qtbot, mocked_cl assert not beamline_state_manager._view.isRowHidden(model.index_for_name("shutter_open").row()) -def test_beamline_state_manager_paints_section_headers(qtbot, mocked_client): +def test_beamline_state_manager_section_headers_are_widgets(qtbot, mocked_client): beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client) beamline_state_manager.update_available_states( {"states": [_limits_state(), _shutter_state()]}, {} @@ -721,14 +720,12 @@ def test_beamline_state_manager_paints_section_headers(qtbot, mocked_client): ) assert model.flags(header_index) == Qt.ItemFlag.NoItemFlags - target = QPixmap(400, delegate.HEADER_HEIGHT) - target.fill(Qt.GlobalColor.transparent) - painter = QPainter(target) - option = QStyleOptionViewItem() - option.rect = QRect(0, 0, 400, delegate.HEADER_HEIGHT) - delegate.paint(painter, option, header_index) - delegate.paint(painter, option, model.index(2, 0)) - painter.end() + # Both section headers are rendered by persistent header widgets, not by a custom paint(). + headers = beamline_state_manager._section_headers + assert set(headers) == {model.INTERLOCK_HEADER, model.OTHERS_HEADER} + assert headers[model.INTERLOCK_HEADER]._label.text() == "Scan interlock states" + assert headers[model.OTHERS_HEADER]._label.text() == "Not included in scan interlock" + assert not headers[model.INTERLOCK_HEADER]._icon.pixmap().isNull() def test_beamline_state_manager_marks_triggered_pills(qtbot, mocked_client):