wip
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user