wip
CI for debye_bec / test (push) Successful in 57s
CI for debye_bec / test (pull_request) Successful in 54s

This commit is contained in:
x01da
2026-09-15 14:51:20 +02:00
parent ebef7a610d
commit b0d2ed6434
2 changed files with 316 additions and 118 deletions
@@ -1,37 +1,119 @@
from bec_widgets.utils.colors import get_accent_colors
from qtpy.QtCore import Qt, Signal
from qtpy.QtCore import QModelIndex, QSize, Qt, Signal
# pylint: disable=E0611
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import QListWidget, QPushButton
from qtpy.QtWidgets import QHeaderView, QPushButton, QStyle, QStyledItemDelegate, QTableView
_ROW_MARGIN = 2 # total vertical padding (top+bottom) added around the text block
class MyListWidget(QListWidget):
class _MultilineTextDelegate(QStyledItemDelegate):
"""
Paints and sizes a cell whose DisplayRole text may contain literal
'\\n' line breaks (schedule item labels are built with explicit line
breaks, e.g. the digital-twin config summary). The base
QStyledItemDelegate only reliably shows a single line, so without this
a multi-line label got clipped once rows stopped auto-sizing the way
QListWidgetItem used to. `sizeHint()` reports the full multi-line
height so the view's ResizeToContents row mode can actually fit it.
Selection is painted here with `option.palette.highlight()` - the
same approach BecLogTableView's `_LogCellDelegate` uses - so the row
gets the theme's native (blue) highlight color instead of whatever a
stylesheet might otherwise hardcode.
"""
def paint(self, painter, option, index):
painter.save()
if option.state & QStyle.StateFlag.State_Selected:
painter.fillRect(option.rect, option.palette.highlight())
painter.setPen(option.palette.highlightedText().color())
else:
painter.setPen(option.palette.text().color())
painter.setFont(option.font)
text = index.data(Qt.ItemDataRole.DisplayRole) or ""
rect = option.rect.adjusted(4, _ROW_MARGIN // 2, -4, -(_ROW_MARGIN // 2))
# painter.drawText(rect, int(Qt.TextFlag.TextWordWrap), text)
painter.drawText(
rect,
int(
Qt.TextFlag.TextWordWrap
| Qt.AlignmentFlag.AlignLeft
| Qt.AlignmentFlag.AlignVCenter
),
text,
)
painter.restore()
def sizeHint(self, option, index):
text = index.data(Qt.ItemDataRole.DisplayRole) or ""
lines = text.count("\n") + 1
height = option.fontMetrics.lineSpacing() * lines + _ROW_MARGIN
width = super().sizeHint(option, index).width()
return QSize(width, height)
class MyTableView(QTableView):
deletePressed = Signal()
emptySpaceClicked = Signal()
copyPressed = Signal()
pastePressed = Signal()
def __init__(self, parent=None):
# Column layout is fixed: 0 = status icon, 1 = text (stretches), 2 = pause-on-failure icon.
TEXT_COLUMN = 1
def __init__(self, parent=None, status_col_width: int = 60, pause_col_width: int = 130):
super().__init__(parent)
self._status_col_width = status_col_width
self._pause_col_width = pause_col_width
self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.setSelectionMode(QTableView.SelectionMode.SingleSelection)
self.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
self.setShowGrid(False)
self.setWordWrap(True)
self.setItemDelegateForColumn(self.TEXT_COLUMN, _MultilineTextDelegate(self))
self.verticalHeader().hide()
# ResizeToContents (not Fixed) so a row grows to fit however many
# lines its text cell reports via the delegate's sizeHint above -
# this is what lets multi-line item text show in full again.
self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
# No `:selected` rule here on purpose - leaving it out lets Qt fall
# back to the palette highlight color (blue) for selected rows,
# matching BecLogTableView instead of a hardcoded dark gray.
self.setStyleSheet("""
QListWidget::item {
QTableView::item {
border-bottom: 1px solid #444444;
padding: 4px;
}
QListWidget::item:selected {
background-color: #3a3a3a;
border-bottom: 1px solid #444444;
}
""")
""")
def configure_columns(self):
header = self.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
header.resizeSection(0, self._status_col_width)
header.resizeSection(2, self._pause_col_width)
header.setStretchLastSection(False)
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Delete and self.currentItem() is not None:
if event.key() == Qt.Key_Delete and self.currentIndex().isValid():
self.deletePressed.emit()
event.accept()
return
if event.matches(QKeySequence.StandardKey.Copy) and self.currentItem() is not None:
if event.matches(QKeySequence.StandardKey.Copy) and self.currentIndex().isValid():
self.copyPressed.emit()
event.accept()
return
@@ -44,9 +126,12 @@ class MyListWidget(QListWidget):
super().keyPressEvent(event)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton and self.itemAt(event.position().toPoint()) is None:
if (
event.button() == Qt.LeftButton
and not self.indexAt(event.position().toPoint()).isValid()
):
self.clearSelection()
self.setCurrentRow(-1)
self.setCurrentIndex(QModelIndex())
self.emptySpaceClicked.emit()
return
@@ -30,16 +30,16 @@ from bec_widgets.utils.error_popups import SafeSlot
from pydantic import ValidationError
# pylint: disable=E0611
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
from qtpy.QtWidgets import (
QApplication,
QDialog,
QFileDialog,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QVBoxLayout,
@@ -58,16 +58,13 @@ from .notifications import (
notify_item_finished,
notify_schedule_state,
)
from .qt_widgets import MyButton, MyListWidget
from .qt_widgets import MyButton, MyTableView
from .schedule_item import Schedule, ScheduleItem
from .schedule_logic import index_of, pick_next_runnable, protected_prefix_length
logger = bec_logger.logger
_ACTIVE_QUEUE_STATES = ("PENDING", "RUNNING")
_ITEM_ID_ROLE = (
Qt.ItemDataRole.UserRole + 1
) # QListWidgetItem data role used to map a row back to an item_id
ICON_SIZE = 20
_ICON_MAP = {
@@ -79,6 +76,178 @@ _ICON_MAP = {
}
def build_item_display_text(item: ScheduleItem, beamline: str | None) -> str:
"""
Build the human-readable label for a schedule item, pulled out of the
old `_refresh_ui` QListWidgetItem-building loop so the table model's
`data()` can call it too. Pure function of (item, beamline) - no Qt
widgets, no locking - so it's safe to call from the model on every
repaint.
"""
text = ""
if item.form_state is not None:
if item.command.startswith("scans.xas_simple_scan"):
text = (
f"XAS simple scan from {item.form_state['kwargs']['start']} eV to "
+ f"{item.form_state['kwargs']['stop']} eV, {item.form_state['kwargs']['scan_time']}"
+ f" s per spectrum, measure {item.form_state['kwargs']['scan_duration']}s"
+ f"\n Comment: {item.form_state['kwargs']['metadata']['comment']}, "
+ f"Sample Name: {item.form_state['kwargs']['metadata']['sample_name']}"
)
elif re.match(r"^dev\..*\.move", item.command):
if item.form_state["relative"]:
text = f"Move {item.form_state['device_name']} by {item.form_state['value']:+g}"
else:
text = f"Move {item.form_state['device_name']} to {item.form_state['value']}"
elif item.command.startswith("dev.ic"):
text = (
f"Fill {item.form_state['chamber']} with {item.form_state['conc1']} % "
+ f"{item.form_state['gas1']} and {item.form_state['conc2']} % "
+ f"{item.form_state['gas2']} to {item.form_state['pressure']} bar"
)
elif item.command.startswith("dev.reffoilchanger"):
text = f"Insert {item.form_state['foil']} reference foil"
elif item.command.startswith("digital_twin.move_with_config({"):
config = item.form_state["config"]
if config["mo1_mode"] == "Monochromatic":
text = f"Align Beamline for {config['energy']:.0f} eV"
else:
text = "Align Beamline for pinkbeam"
text += (
f"\n FE: {config['h_acc']*1e3:.3f} mrad x {config['v_acc']*1e3:.3f} mrad"
+ f"\n CM: {config['cm_stripe']} at {config['cm_pitch']*1e3:.3f} mrad"
)
if config["mo1_mode"] == "Monochromatic":
text += f"\n MO1: {config['mo1_xtal']}"
text += f"\n FM: {config['fm_stripe']} at {config['fm_rotx']*1e3:.3f} mrad"
if beamline == "x01da":
text += f"\n ES1: {config['ot_es1_trz']:.0f} mm"
text += f"\n SMPL_TRZ: {config['es1man_trz']:.3f} mm"
if beamline == "x01da":
text += f"\n SMPL-XRD: {config['smpl_to_xrd']:.3f} mm"
elif item.command.startswith("auto_gain.start("):
config = item.form_state
text += f"Auto-configure gains for {item.form_state['element']}, {item.form_state['edge']}-edge"
devices = ""
for device in ["ic0", "ic1", "ic2", "pips"]:
if config[device] is True:
if len(devices) == 0:
devices += f"\n for devices: {device}"
else:
devices += f", {device}"
text += devices
if text == "":
text = f"{item.command}"
if item.error:
text += f" ({item.error.strip().splitlines()[-1]})"
return text
class ScheduleTableModel(QAbstractTableModel):
"""
Read-mostly table model over `Schedule.items`, rebuilt in one shot via
`set_items()` whenever the Scheduler widget refreshes - the same
"clear and repopulate" pattern the old QListWidget code used, just
routed through Qt's model/view API instead of manual
QListWidgetItem management. Mirrors the shape of `BecLogTableView`'s
`BecLogsTableModel`: a flat Python list backing store, `data()`
dispatching on `role`/`column`, icons returned as pixmaps via
`DecorationRole`.
"""
COL_STATUS = 0
COL_TEXT = 1
COL_PAUSE = 2
_HEADERS = ("Status", "Item", "Pause on failure")
def __init__(self, parent=None, beamline: str | None = None):
super().__init__(parent)
self._items: list[ScheduleItem] = []
self._beamline = beamline
# Drives the "selected row -> white status icon" recolor; kept
# separate from the view's own selection model so the icon color
# survives a `set_items()` reset (which clears Qt's selection).
self._selected_item_id: str | None = None
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else len(self._items)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else len(self._HEADERS)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self._HEADERS[section]
return None
def set_items(self, items: list[ScheduleItem]):
self.beginResetModel()
self._items = list(items)
self.endResetModel()
def set_selected_item_id(self, item_id: str | None):
"""
Repaint just the status-icon column for the recolor - avoids a
full model reset, which would otherwise drop the view's own
selection/scroll state on every click.
"""
self._selected_item_id = item_id
if self._items:
top_left = self.index(0, self.COL_STATUS)
bottom_right = self.index(len(self._items) - 1, self.COL_STATUS)
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DecorationRole])
def item_id(self, row: int) -> str | None:
if 0 <= row < len(self._items):
return self._items[row].item_id
return None
def row_of(self, item_id: str | None) -> int | None:
if item_id is None:
return None
for row, item in enumerate(self._items):
if item.item_id == item_id:
return row
return None
def item_at(self, row: int) -> ScheduleItem | None:
return self._items[row] if 0 <= row < len(self._items) else None
def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
item = self._items[index.row()]
col = index.column()
if role == Qt.ItemDataRole.DecorationRole:
if col == self.COL_STATUS:
icon_name, color = _ICON_MAP[item.status]
if item.item_id == self._selected_item_id:
color = "#FFFFFF"
return material_icon(
icon_name, size=(ICON_SIZE, ICON_SIZE), color=color, convert_to_pixmap=True
)
if col == self.COL_PAUSE and item.pause_on_failure:
return material_icon(
"pause_circle",
size=(ICON_SIZE, ICON_SIZE),
color="#e6d922",
convert_to_pixmap=True,
)
return None
if role == Qt.ItemDataRole.DisplayRole and col == self.COL_TEXT:
return build_item_display_text(item, self._beamline)
if role == Qt.ItemDataRole.ToolTipRole:
if col == self.COL_PAUSE:
return "Pauses the schedule if this item fails" if item.pause_on_failure else None
if item.error:
return item.error
return None
class ScheduleWidgetConfig(ConnectionConfig):
"""
Per-instance identity/settings for `ScheduleWidget`, following the same
@@ -252,15 +421,18 @@ class Scheduler(BECWidget, QWidget):
edit_row.addStretch()
schedule_layout.addLayout(edit_row)
self.list_widget = MyListWidget()
self.list_widget.currentRowChanged.connect(self._on_selection_changed)
self.list_widget.deletePressed.connect(self._on_delete_clicked)
self.list_widget.emptySpaceClicked.connect(self._on_empty_space_clicked)
self.list_widget.itemDoubleClicked.connect(self._on_edit_clicked)
self.list_widget.copyPressed.connect(self._on_copy_clicked)
self.list_widget.pastePressed.connect(self._on_paste_clicked)
self._table_model = ScheduleTableModel(parent=self, beamline=self.beamline)
self.table_view = MyTableView()
self.table_view.setModel(self._table_model)
self.table_view.configure_columns()
self.table_view.selectionModel().currentRowChanged.connect(self._on_selection_changed)
self.table_view.deletePressed.connect(self._on_delete_clicked)
self.table_view.emptySpaceClicked.connect(self._on_empty_space_clicked)
self.table_view.doubleClicked.connect(self._on_edit_clicked)
self.table_view.copyPressed.connect(self._on_copy_clicked)
self.table_view.pastePressed.connect(self._on_paste_clicked)
schedule_layout.addWidget(self.list_widget)
schedule_layout.addWidget(self.table_view)
schedule_layout.addWidget(QLabel("Notes"))
self.notes_edit = QPlainTextEdit()
@@ -472,78 +644,23 @@ class Scheduler(BECWidget, QWidget):
self.schedule_name_input.setText(schedule_name)
self.list_widget.blockSignals(True)
self.list_widget.clear()
selected_row = None
for row, item in enumerate(items):
text = ""
if item.form_state is not None:
if item.command.startswith("scans.xas_simple_scan"):
text = (
f"XAS simple scan from {item.form_state['kwargs']['start']} eV to "
+ f"{item.form_state['kwargs']['stop']} eV, {item.form_state['kwargs']['scan_time']}"
+ f" s per spectrum, measure {item.form_state['kwargs']['scan_duration']}s"
+ f"\n Comment: {item.form_state['kwargs']['metadata']['comment']}, "
+ f"Sample Name: {item.form_state['kwargs']['metadata']['sample_name']}"
)
elif re.match(r"^dev\..*\.move", item.command):
if item.form_state["relative"]:
text = f"Move {item.form_state['device_name']} by {item.form_state['value']:+g}"
else:
text = (
f"Move {item.form_state['device_name']} to {item.form_state['value']}"
)
elif item.command.startswith("dev.ic"):
text = (
f"Fill {item.form_state['chamber']} with {item.form_state['conc1']} % "
+ f"{item.form_state['gas1']} and {item.form_state['conc2']} % "
+ f"{item.form_state['gas2']} to {item.form_state['pressure']} bar"
)
elif item.command.startswith("dev.reffoilchanger"):
text = f"Insert {item.form_state['foil']} reference foil"
elif item.command.startswith("digital_twin.move_with_config({"):
config = item.form_state["config"]
if config["mo1_mode"] == "Monochromatic":
text = f"Align Beamline for {config['energy']:.0f} eV"
else:
text = "Align Beamline for pinkbeam"
text += (
f"\n FE: {config['h_acc']*1e3:.3f} mrad x {config['v_acc']*1e3:.3f} mrad"
+ f"\n CM: {config['cm_stripe']} at {config['cm_pitch']*1e3:.3f} mrad"
)
if config["mo1_mode"] == "Monochromatic":
text += f"\n MO1: {config['mo1_xtal']}"
text += f"\n FM: {config['fm_stripe']} at {config['fm_rotx']*1e3:.3f} mrad"
if self.beamline == "x01da":
text += f"\n ES1: {config['ot_es1_trz']:.0f} mm"
text += f"\n SMPL_TRZ: {config['es1man_trz']:.3f} mm"
if self.beamline == "x01da":
text += f"\n SMPL-XRD: {config['smpl_to_xrd']:.3f} mm"
elif item.command.startswith("auto_gain.start("):
config = item.form_state
text += f"Auto-configure gains for {item.form_state['element']}, {item.form_state['edge']}-edge"
devices = ""
for device in ["ic0", "ic1", "ic2", "pips"]:
if config[device] is True:
if len(devices) == 0:
devices += f"\n for devices: {device}"
else:
devices += f", {device}"
text += devices
if text == "":
text = f"{item.command}"
if item.error:
text += f" ({item.error.strip().splitlines()[-1]})"
list_item = QListWidgetItem(text, self.list_widget)
list_item.setData(_ITEM_ID_ROLE, item.item_id)
if item.item_id == self._selected_item_id:
selected_row = row
self._update_icons()
if selected_row is not None:
self.list_widget.setCurrentRow(selected_row)
selection_model = self.table_view.selectionModel()
selection_model.blockSignals(True)
# Repopulate the model (full reset, same "clear and rebuild" idea
# the old QListWidget code used); the selected item's status-icon
# color is baked in via `_selected_item_id` so the highlighted row
# keeps its white icon straight after the reset repaints it.
self._table_model._selected_item_id = self._selected_item_id
self._table_model.set_items(items)
row = self._table_model.row_of(self._selected_item_id)
if row is not None:
self.table_view.selectRow(row)
else:
self._selected_item_id = None
self.list_widget.blockSignals(False)
selection_model.blockSignals(False)
# Don't stomp on notes the operator is actively typing (e.g. a
# remote update, or our own debounce timer firing right after
@@ -554,6 +671,8 @@ class Scheduler(BECWidget, QWidget):
self.notes_edit.setPlainText(notes)
self.notes_edit.blockSignals(False)
self._update_buttons()
def _update_guard_label(self, *_):
if self._guard.enabled:
state = "OK" if self._guard.is_clear() else "PAUSED - waiting to resume"
@@ -600,18 +719,6 @@ class Scheduler(BECWidget, QWidget):
self.copy_btn.setEnabled(selected_item is not None)
self.paste_btn.setEnabled(self._clipboard_item is not None)
def _update_icons(self):
items = list(self.schedule.items)
for row, item in enumerate(items):
icon_name, color = _ICON_MAP[item.status]
if self._selected_item_id == item.item_id:
color = "#FFFFFF"
icon = material_icon(
icon_name, size=(ICON_SIZE, ICON_SIZE), color=color, convert_to_pixmap=True
)
list_item = self.list_widget.item(row)
list_item.setIcon(icon)
@SafeSlot()
def _on_schedule_name_change(self):
name = self.schedule_name_input.text()
@@ -619,16 +726,16 @@ class Scheduler(BECWidget, QWidget):
self.schedule.schedule_name = name
self._persist_locked()
@SafeSlot()
def _on_selection_changed(self, row: int):
item = self.list_widget.item(row) if row >= 0 else None
self._selected_item_id = item.data(_ITEM_ID_ROLE) if item is not None else None
self._update_icons()
@SafeSlot(QModelIndex, QModelIndex)
def _on_selection_changed(self, current: QModelIndex, previous: QModelIndex):
item_id = self._table_model.item_id(current.row()) if current.isValid() else None
self._selected_item_id = item_id
self._table_model.set_selected_item_id(item_id)
self._update_buttons()
@SafeSlot()
def _on_empty_space_clicked(self):
self.list_widget.setCurrentRow(-1)
self.table_view.setCurrentIndex(QModelIndex())
self._refresh_ui()
# ---- notes ---- #
@@ -857,6 +964,7 @@ class Scheduler(BECWidget, QWidget):
self._update_buttons()
@SafeSlot()
@SafeSlot(QModelIndex)
def _on_edit_clicked(self, *_):
if self._selected_item_id is None:
return
@@ -904,14 +1012,19 @@ class Scheduler(BECWidget, QWidget):
def _on_delete_clicked(self):
if self._selected_item_id is None:
return
row = self.list_widget.currentRow()
row = self.table_view.currentIndex().row()
try:
self.delete_item(self._selected_item_id)
except RuntimeError as exc:
QMessageBox.warning(self, "Cannot delete item", str(exc))
return
self._selected_item_id = None
self._on_selection_changed(max(0, row - 1))
# delete_item()'s schedule_changed emit already ran _refresh_ui()
# with an empty selection; now re-select the row that took the
# deleted item's place (or the new last row, if it was last).
new_row = max(0, row - 1)
if 0 <= new_row < self._table_model.rowCount():
self.table_view.selectRow(new_row)
self._refresh_ui()
@SafeSlot()