refactor(beamline_states): beamline headed as separate widget, not in paint

This commit is contained in:
2026-06-17 10:00:54 +02:00
committed by wakonig_k
co-authored by wakonig_k
parent beddfb14b2
commit 2a8a7261be
2 changed files with 88 additions and 59 deletions
@@ -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()
+8 -11
View File
@@ -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):