From b0d2ed64345802eb71acf5657d052ec00d67e649 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 15 Sep 2026 14:51:20 +0200 Subject: [PATCH] wip --- .../widgets/scheduler/qt_widgets.py | 113 +++++- .../widgets/scheduler/scheduler.py | 321 ++++++++++++------ 2 files changed, 316 insertions(+), 118 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/scheduler/qt_widgets.py b/debye_bec/bec_widgets/widgets/scheduler/qt_widgets.py index ce5d057..cb3ee0b 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/scheduler/qt_widgets.py @@ -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 diff --git a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py index 7f56f7f..ff903f1 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py +++ b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py @@ -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()