From 02205b43c55dcbb5f103abc961cd412cacf0fe4f Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 2 Sep 2026 08:10:16 +0200 Subject: [PATCH] wip --- .../widgets/scheduler/item_dialog.py | 27 +- .../widgets/scheduler/schedule_item.py | 1 + .../widgets/scheduler/scheduler.py | 246 +++++++++++++++++- 3 files changed, 267 insertions(+), 7 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py b/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py index 7aaf895..69c4942 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py +++ b/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py @@ -26,6 +26,8 @@ can reopen the dialog pre-filled instead of asking the user to start over. from __future__ import annotations +import math + from bec_lib.logger import bec_logger from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( BECDeviceFilter, @@ -146,24 +148,37 @@ class ScheduleItemDialog(QDialog): form = QFormLayout(tab) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint) - # self.move_device_combo = QComboBox() - # self.move_device_combo.addItems(list_movable_device_names(self._dev)) - # self.move_device_combo.setMaxVisibleItems(12) - self.move_device_combo = DeviceComboBox(self, device_filter=[BECDeviceFilter.POSITIONER]) form.addRow("Device", self.move_device_combo) self.move_value_spin = QDoubleSpinBox() - self.move_value_spin.setDecimals(6) - self.move_value_spin.setRange(*_DSPIN_RANGE) form.addRow("Value", self.move_value_spin) self.move_relative_check = QCheckBox("") form.addRow("Relative move", self.move_relative_check) + self.move_device_combo.currentIndexChanged.connect(self._adjust_spinbox) + self.tabs.addTab(tab, "Move") + def _adjust_spinbox(self, _) -> None: + prec = self._dev[self.move_device_combo.currentText()].precision + units = self._dev[self.move_device_combo.currentText()].egu() + ll = self._dev[self.move_device_combo.currentText()].low_limit + hl = self._dev[self.move_device_combo.currentText()].high_limit + + self.move_value_spin.setDecimals(prec) + self.move_value_spin.setSuffix(f" {units}") + if (hl - ll) > 0: + self.move_value_spin.setMinimum(ll) + self.move_value_spin.setMaximum(hl) + self.move_value_spin.setSingleStep(10 ** round(math.log10((hl - ll) / 100))) + else: + self.move_value_spin.setMinimum(1e6) + self.move_value_spin.setMaximum(-1e6) + self.move_value_spin.setSingleStep(1) + def _collect_move_result(self) -> dict: device_name = self.move_device_combo.currentText() if not device_name: diff --git a/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py b/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py index 872b2a5..74cb2e0 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py +++ b/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py @@ -67,6 +67,7 @@ class Schedule(BaseModel): schedule_name: str items: list[ScheduleItem] = Field(default_factory=list) is_running: bool = False + notes: str = "" # Settings for two independent, optional features (see notifications.py # and guard.py) - persisted here alongside the schedule itself so they diff --git a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py index 6a247bc..cf7385c 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py +++ b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py @@ -9,6 +9,7 @@ or finished cannot. from __future__ import annotations +import json import threading import time import traceback @@ -24,17 +25,20 @@ from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot -from qtpy.QtCore import Qt, Signal +from pydantic import ValidationError +from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtGui import QKeySequence, QShortcut from qtpy.QtWidgets import ( QApplication, QDialog, + QFileDialog, QGroupBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMessageBox, + QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, @@ -100,6 +104,8 @@ class ScheduleWidgetConfig(ConnectionConfig): class MyListWidget(QListWidget): deletePressed = Signal() emptySpaceClicked = Signal() + copyPressed = Signal() + pastePressed = Signal() def keyPressEvent(self, event): if event.key() == Qt.Key_Delete and self.currentItem() is not None: @@ -107,6 +113,16 @@ class MyListWidget(QListWidget): event.accept() return + if event.matches(QKeySequence.StandardKey.Copy) and self.currentItem() is not None: + self.copyPressed.emit() + event.accept() + return + + if event.matches(QKeySequence.StandardKey.Paste): + self.pastePressed.emit() + event.accept() + return + super().keyPressEvent(event) def mousePressEvent(self, event): @@ -200,6 +216,14 @@ class Scheduler(BECWidget, QWidget): self._current_report = None # ScanReport of the item currently executing, if any self._current_item_kind: str | None = None # kind of the item _current_report belongs to self._selected_item_id: str | None = None # survives list repopulation on refresh + self._clipboard_item: dict | None = None # copy/paste buffer, see _on_copy_clicked + + # Notes are auto-saved on a debounce timer rather than on every + # keystroke - see _on_notes_changed/_persist_notes. + self._notes_save_timer = QTimer(self) + self._notes_save_timer.setSingleShot(True) + self._notes_save_timer.setInterval(600) + self._notes_save_timer.timeout.connect(self._persist_notes) self.schedule_changed.connect(self._refresh_ui) @@ -235,12 +259,16 @@ class Scheduler(BECWidget, QWidget): self.delete_btn = MyButton("Delete", "default") self.move_up_btn = MyButton("Move Up", "default") self.move_down_btn = MyButton("Move Down", "default") + self.copy_btn = MyButton("Copy", "default") + self.paste_btn = MyButton("Paste", "default") for button in ( self.add_btn, self.edit_btn, self.delete_btn, self.move_up_btn, self.move_down_btn, + self.copy_btn, + self.paste_btn, ): edit_row.addWidget(button) edit_row.addStretch() @@ -253,14 +281,26 @@ class Scheduler(BECWidget, QWidget): 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) schedule_layout.addWidget(self.list_widget) + schedule_layout.addWidget(QLabel("Notes")) + self.notes_edit = QPlainTextEdit() + self.notes_edit.setPlaceholderText("Notes about this schedule...") + self.notes_edit.setPlainText(self.schedule.notes) + self.notes_edit.setMaximumHeight(100) + self.notes_edit.textChanged.connect(self._on_notes_changed) + schedule_layout.addWidget(self.notes_edit) + self.add_btn.clicked.connect(self._on_add_clicked) self.edit_btn.clicked.connect(self._on_edit_clicked) self.delete_btn.clicked.connect(self._on_delete_clicked) self.move_up_btn.clicked.connect(self._on_move_up_clicked) self.move_down_btn.clicked.connect(self._on_move_down_clicked) + self.copy_btn.clicked.connect(self._on_copy_clicked) + self.paste_btn.clicked.connect(self._on_paste_clicked) layout.addWidget(schedule_group) @@ -298,6 +338,19 @@ class Scheduler(BECWidget, QWidget): layout.addWidget(settings_group) + file_group = QGroupBox("File") + file_layout = QHBoxLayout(file_group) + self.save_file_btn = MyButton("Save to file", "default") + self.load_file_btn = MyButton("Load from file", "default") + file_layout.addWidget(self.save_file_btn) + file_layout.addWidget(self.load_file_btn) + file_layout.addStretch() + + self.save_file_btn.clicked.connect(self._on_save_to_file_clicked) + self.load_file_btn.clicked.connect(self._on_load_from_file_clicked) + + layout.addWidget(file_group) + self.guard_status_label = QLabel() self.guard_status_label.setStyleSheet("color: gray;") layout.addWidget(self.guard_status_label) @@ -321,11 +374,15 @@ class Scheduler(BECWidget, QWidget): self.delete_btn, self.move_down_btn, self.move_up_btn, + self.copy_btn, + self.paste_btn, self.run_btn, self.abort_btn, self.reset_btn, self.notifications_btn, self.guard_btn, + self.save_file_btn, + self.load_file_btn, ]: button.apply_theme() @@ -433,6 +490,7 @@ class Scheduler(BECWidget, QWidget): def _refresh_ui(self): with self._lock: items = list(self.schedule.items) + notes = self.schedule.notes self.list_widget.blockSignals(True) self.list_widget.clear() @@ -456,6 +514,15 @@ class Scheduler(BECWidget, QWidget): self._selected_item_id = None self.list_widget.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 + # local edits) - only resync when the field isn't focused and the + # text actually differs from what we already have. + if not self.notes_edit.hasFocus() and self.notes_edit.toPlainText() != notes: + self.notes_edit.blockSignals(True) + self.notes_edit.setPlainText(notes) + self.notes_edit.blockSignals(False) + if self._guard.enabled: state = "OK" if self._guard.is_clear() else "PAUSED - waiting to resume" value = self._guard.current_value @@ -494,6 +561,12 @@ class Scheduler(BECWidget, QWidget): self.move_down_btn.setEnabled( can_edit_selected and idx is not None and idx < len(items) - 1 ) + # Copy just captures the command text, so it's fine on any item + # (e.g. copying a COMPLETED item's command to reuse it later). + # Paste always goes through add_item(), which already clamps the + # insert position past the protected prefix - see _on_paste_clicked. + self.copy_btn.setEnabled(selected_item is not None) + self.paste_btn.setEnabled(self._clipboard_item is not None) @SafeSlot() def _on_selection_changed(self, row: int): @@ -506,6 +579,122 @@ class Scheduler(BECWidget, QWidget): self.list_widget.setCurrentRow(-1) self._refresh_ui() + # ---- notes ---- # + def _on_notes_changed(self): + # Debounced: restart the timer on every keystroke, only persist + # once typing pauses, so we're not writing to Redis on every + # character. + self._notes_save_timer.start() + + def _persist_notes(self): + text = self.notes_edit.toPlainText() + with self._lock: + if self.schedule.notes == text: + return + self.schedule.notes = text + self._persist_locked() + + # ---- save / restore to file ---- # + @SafeSlot() + def _on_save_to_file_clicked(self): + with self._lock: + data = self.schedule.model_dump(mode="json") + default_name = f"{self.schedule_name}.json" + path, _ = QFileDialog.getSaveFileName( + self, "Save schedule to file", default_name, "JSON files (*.json);;All files (*)" + ) + if not path: + return + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except OSError as exc: + QMessageBox.critical(self, "Save failed", f"Could not write to {path}:\n{exc}") + return + logger.info(f"Saved schedule '{self.schedule_name}' to {path}") + + @SafeSlot() + def _on_load_from_file_clicked(self): + with self._lock: + if self.schedule.is_running: + QMessageBox.warning( + self, + "Cannot load", + "Stop the running schedule (Abort) before loading a new one from file.", + ) + return + + path, _ = QFileDialog.getOpenFileName( + self, "Load schedule from file", "", "JSON files (*.json);;All files (*)" + ) + if not path: + return + + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + loaded = Schedule.model_validate(data) + except (OSError, json.JSONDecodeError, ValidationError) as exc: + QMessageBox.critical( + self, "Load failed", f"Could not load a schedule from {path}:\n{exc}" + ) + return + + with self._lock: + current_count = len(self.schedule.items) + confirm = QMessageBox.question( + self, + "Load schedule", + f"Replace the current {current_count} item(s) with {len(loaded.items)} " + f"item(s) from the file?\n\nAll items will be loaded as PENDING, regardless " + f"of their status when the file was saved.", + ) + if confirm != QMessageBox.StandardButton.Yes: + return + + # Loading a file restores a *definition* (what to run), not a past + # execution: every item comes back PENDING with a fresh item_id and + # its execution bookkeeping cleared, and is_running is always + # forced False here - regardless of what the file says - so we + # never end up "reconciling" against request/scan ids from a + # different session. schedule_name is deliberately left as this + # widget's own name, not overwritten by the file's. + new_items = [ + item.model_copy( + update={ + "item_id": str(uuid.uuid4()), + "status": ScheduleItemStatus.PENDING, + "request_id": None, + "scan_id": None, + "scan_number": None, + "error": None, + "started_at": None, + "finished_at": None, + } + ) + for item in loaded.items + ] + + with self._lock: + if self.schedule.is_running: + QMessageBox.warning( + self, "Cannot load", "The schedule started running while the dialog was open." + ) + return + self.schedule.items = new_items + self.schedule.notes = loaded.notes + self.schedule.guard = loaded.guard + self.schedule.notifications = loaded.notifications + self._persist_locked() + + self._guard.configure(self.schedule.guard) + self._selected_item_id = None + logger.info( + f"Loaded schedule from {path} ({len(new_items)} item(s)) into '{self.schedule_name}'" + ) + self._refresh_ui() + self._update_buttons() + # ---- notifications / auto-pause settings ---- # @SafeSlot() def _on_notifications_clicked(self): @@ -642,6 +831,55 @@ class Scheduler(BECWidget, QWidget): except RuntimeError as exc: QMessageBox.warning(self, "Cannot move item", str(exc)) + @SafeSlot() + def _on_copy_clicked(self): + """ + Copy the selected item's command onto an internal clipboard. Only + the command/kind/form_state are captured (not status, timestamps, + request/scan ids, ...) since pasting always creates a fresh, + PENDING item - this is "copy the command", not "duplicate the + history". + """ + if self._selected_item_id is None: + return + with self._lock: + idx = self._index_of_locked(self._selected_item_id) + if idx is None: + return + item = self.schedule.items[idx] + self._clipboard_item = { + "command": item.command, + "kind": item.kind, + "form_state": dict(item.form_state) if item.form_state else None, + } + logger.info(f"Copied schedule item: {self._clipboard_item['command']}") + self._update_buttons() + + @SafeSlot() + def _on_paste_clicked(self): + """ + Paste the copied command as a new item, right after whatever is + currently selected (or at the end, if nothing is selected) - same + placement `_on_add_clicked` uses. This goes through `add_item()` + unchanged, so the existing protected-prefix clamping applies here + too: pasting can never land before an item that's already + running or finished. + """ + if self._clipboard_item is None: + return + with self._lock: + idx = self._index_of_locked(self._selected_item_id) + insert_at = None if idx is None else idx + 1 + new_id = self.add_item( + self._clipboard_item["command"], + index=insert_at, + kind=self._clipboard_item["kind"], + form_state=self._clipboard_item["form_state"], + ) + self._selected_item_id = new_id + self._refresh_ui() + self._update_buttons() + # ------------------------------------------------------------------ # # RPC-exposed actions (USER_ACCESS) # ------------------------------------------------------------------ # @@ -870,6 +1108,12 @@ class Scheduler(BECWidget, QWidget): """Stop background execution loops without cancelling server-side scans.""" self._closing.set() + # Flush any not-yet-saved notes edit instead of losing it - the + # debounce timer won't get a chance to fire once we're closing. + if self._notes_save_timer.isActive(): + self._notes_save_timer.stop() + self._persist_notes() + # Interrupt guard wait loops if any thread is blocked in self._guard.wait_until_clear() self._guard.cleanup()