diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 7f710de6..b171b98c 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -111,7 +111,7 @@ class BECMainApp(BECMainWindow): widget=self.device_manager, mini_text="DM", ) - self.sequencer_view = SequencerView(self) + self.sequencer_view = SequencerView(self, keep_drafts=True) self.add_view( icon="account_tree", title="Sequencer", diff --git a/bec_widgets/applications/views/sequencer_view/sequencer_view.py b/bec_widgets/applications/views/sequencer_view/sequencer_view.py index 7c7d602f..6c0e11c2 100644 --- a/bec_widgets/applications/views/sequencer_view/sequencer_view.py +++ b/bec_widgets/applications/views/sequencer_view/sequencer_view.py @@ -40,12 +40,17 @@ class SequencerView(ViewBase): parent (QWidget | None): Parent widget. concept (str | None): Concept to show right away. Without it, the view waits until it is opened and then shows ``BEC_SEQUENCER_CONCEPT`` or the first concept. + keep_drafts (bool): Keep each concept's work in a draft file, so it is still there + after the application was closed. """ RPC = False - def __init__(self, parent=None, *, concept: str | None = None, **kwargs): + def __init__( + self, parent=None, *, concept: str | None = None, keep_drafts: bool = False, **kwargs + ): super().__init__(parent=parent, view_id="sequencer", title="Sequencer", **kwargs) + self.keep_drafts = keep_drafts content = QWidget(self) layout = QVBoxLayout(content) layout.setContentsMargins(0, 0, 0, 0) @@ -54,10 +59,10 @@ class SequencerView(ViewBase): self.top = QWidget(content) self.top.setObjectName("SequencerTop") self.top.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - bar = QHBoxLayout(self.top) - bar.setContentsMargins(16, 10, 16, 10) - bar.setSpacing(14) - bar.addWidget(heading("Sequencer", self.top, 16)) + top_row = QHBoxLayout(self.top) + top_row.setContentsMargins(16, 10, 16, 10) + top_row.setSpacing(14) + top_row.addWidget(heading("Sequencer", self.top, 16)) self.switcher = QButtonGroup(self.top) self.switcher.setExclusive(True) segments = QHBoxLayout() @@ -69,11 +74,11 @@ class SequencerView(ViewBase): button.setToolTip(cls.CONCEPT_TAGLINE) self.switcher.addButton(button) segments.addWidget(button) - bar.addLayout(segments) + top_row.addLayout(segments) self.tagline = hint("", self.top) - bar.addWidget(self.tagline, 1) - self.prototype_note = QLabel("Prototype · 5 concepts to compare", self.top) - bar.addWidget(self.prototype_note) + top_row.addWidget(self.tagline, 1) + self.prototype_note = QLabel(f"{len(CONCEPTS)} layouts to compare", self.top) + top_row.addWidget(self.prototype_note) layout.addWidget(self.top) self._style_top() @@ -111,9 +116,7 @@ class SequencerView(ViewBase): f" border: 1px solid {primary.name()}; color: {primary.name()}; font-weight: 600; }}" ) self.tagline.setStyleSheet(f"color: {rgba(theme('FG', '#333333'), 150)};") - self.prototype_note.setStyleSheet( - f"color: {theme('ACCENT_WARNING', '#eda200').name()}; font-weight: 600;" - ) + self.prototype_note.setStyleSheet(f"color: {rgba(theme('FG', '#333333'), 150)};") def apply_theme(self, theme_name: str) -> None: """Restyle the view and rebuild the editors in the new colors, keeping their work.""" @@ -135,9 +138,8 @@ class SequencerView(ViewBase): self.show_concept(current) def _create(self, concept: str) -> QWidget: - editor = CONCEPTS[concept](self.stack) - if hasattr(editor, "open_in_recipe"): - editor.open_in_recipe.connect(self._open_in_recipe) + editor = CONCEPTS[concept](self.stack, draft_name=concept if self.keep_drafts else None) + editor.open_in_recipe.connect(self._open_in_recipe) self.editors[concept] = editor self.stack.addWidget(editor) return editor @@ -191,7 +193,7 @@ def main(): # pragma: no cover - manual entry point apply_theme(args.theme) window = BECMainWindow() window.setWindowTitle("BEC Sequencer (prototype)") - view = SequencerView(window, concept=args.concept or next(iter(CONCEPTS))) + view = SequencerView(window, concept=args.concept or next(iter(CONCEPTS)), keep_drafts=True) window.setCentralWidget(view) window.resize(1400, 860) window.show() diff --git a/bec_widgets/widgets/control/sequencer/backend.py b/bec_widgets/widgets/control/sequencer/backend.py index 28cb8519..84db3c2f 100644 --- a/bec_widgets/widgets/control/sequencer/backend.py +++ b/bec_widgets/widgets/control/sequencer/backend.py @@ -1,21 +1,27 @@ """Bridge between the sequencer editors and ``bec_lib.scan_sequencer``. -The backend lives in bec (draft PR bec-project/bec#1128). When the installed bec_lib does not -ship it yet, editing still works and ``available`` is False, so the GUI can say why checking -and running are disabled. +The backend lives in bec (bec-project/bec#1128). When the installed bec_lib does not ship it +yet, editing still works and ``available`` is False, so the GUI can say why checking and +running are disabled. """ from __future__ import annotations +import math import re from dataclasses import dataclass +from pathlib import Path +from bec_lib.device import Positioner from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger +from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter + logger = bec_logger.logger try: # the backend is not released yet + # pylint: disable=ungrouped-imports from bec_lib.scan_sequencer import DefinitionLibrary, ScanCatalog, Schedule from bec_lib.scan_sequencer.core.description import describe as _describe from bec_lib.scan_sequencer.core.python_export import export_python as _export_python @@ -39,11 +45,12 @@ _HIDDEN_SCANS = { } _NODE_PREFIX = re.compile(r"^Node (?P[^:]+): (?P.*)$", re.S) +_EXECUTION_ERROR = re.compile(r"ExecutionError: Node (?P[^:]+): (?P.*)$", re.S) @dataclass class Problem: - """A validation problem, attached to a step when the backend names one.""" + """A problem in a sequence, attached to a step when one is known.""" node_id: str message: str @@ -58,11 +65,63 @@ def friendly_message(message: str) -> str: return f"“{name}” is only known inside its “For each {name}” loop" if message.startswith("Unknown device or signal: "): return f"There is no device called “{message.split(': ', 1)[1]}”" + if message.startswith("Non-settable device or signal: "): + return f"“{message.split(': ', 1)[1]}” cannot be moved or set" if message.startswith("Unknown scan: "): return f"This beamline has no scan called “{message.split(': ', 1)[1]}”" + device = re.match(r"Device '(.*)' specified for scan argument", message) + if device: + name = device.group(1) + return f"There is no device called “{name}”" if name else "Choose a motor for the scan" return message.strip().splitlines()[0] if message.strip() else "Unknown problem" +def run_failure(error: str | None) -> Problem: + """Turn the error of a failed sequence into the step that failed and a short reason. + + Args: + error (str | None): Error text of the procedure, usually a traceback. + + Returns: + Problem: The failed step (empty id when unknown) and the last line of the reason. + """ + text = (error or "").strip() + match = _EXECUTION_ERROR.search(text) + node_id = "" + if match: + node_id = match.group("path").split("/")[-1] + text = match.group("message") + lines = [line.strip() for line in text.splitlines() if line.strip()] + return Problem(node_id, lines[-1] if lines else "The sequence failed") + + +def sequences_dir(client) -> Path | None: + """Folder where sequences and drafts are kept, next to the widget profiles. + + Args: + client: BEC client. + + Returns: + Path | None: The folder, or None when the client has no widget settings folder. + """ + # the same settings folder the widget profiles use + # pylint: disable=protected-access + try: + settings = client._service_config.config.get("widgets_settings") or {} + base = settings.get("base_path") + except Exception: # pylint: disable=broad-except + return None + if not isinstance(base, str) or not base: + return None + return Path(base).expanduser() / "sequences" + + +def _number(value) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + class SequencerBackend: """Check, describe, export and run sequence documents for one BEC client. @@ -76,6 +135,7 @@ class SequencerBackend: self.client = client self.definitions: list[dict] = [] self._helper = None + self._entries: dict[str, dict] | None = None @property def available(self) -> bool: @@ -83,26 +143,87 @@ class SequencerBackend: return BACKEND_AVAILABLE # --- scans ----------------------------------------------------------------------------- + def reload(self) -> None: + """Forget the cached scan list, e.g. after the beamline configuration changed.""" + self._entries = None + def scan_entries(self) -> dict[str, dict]: - """Advertised scans usable as a sequence step, by name.""" + """Advertised scans that can be a sequence step, by name (read once, then cached).""" + if self._entries is None: + try: + msg = self.client.connector.get(MessageEndpoints.available_scans()) + except Exception: # pylint: disable=broad-except + msg = None + resource = getattr(msg, "resource", None) or {} + self._entries = { + name: info + for name, info in sorted(resource.items()) + if name not in _HIDDEN_SCANS + and not name.startswith("_") + and not info.get("is_internal") + and ScanInfoAdapter.has_scan_ui_config(info) + } + return self._entries + + # --- devices --------------------------------------------------------------------------- + def _devices(self): try: - msg = self.client.connector.get(MessageEndpoints.available_scans()) + return self.client.device_manager.devices except Exception: # pylint: disable=broad-except return {} - resource = getattr(msg, "resource", None) or {} - return { - name: info - for name, info in resource.items() - if name not in _HIDDEN_SCANS and not info.get("is_internal") - } def device_names(self) -> list[str]: - """Names of devices that can be moved or set.""" + """Names of all configured devices.""" try: - return sorted(self.client.device_manager.devices.keys()) + return sorted(self._devices().keys()) except Exception: # pylint: disable=broad-except return [] + def device(self, name: str): + """The device proxy for a name (``samx`` or ``samx.velocity``), or None.""" + if not name: + return None + root, _, rest = name.partition(".") + try: + device = self._devices().get(root) + for part in [p for p in rest.split(".") if p]: + device = getattr(device, part) + return device + except Exception: # pylint: disable=broad-except + return None + + def is_motor(self, name: str) -> bool: + """Whether a name is a motor (a positioner), so its value must be a number.""" + device = self.device(name) + if isinstance(device, Positioner): + return True + return getattr(device, "_info", {}).get("device_base_class") == "positioner" + + def limits(self, name: str) -> tuple[float, float] | None: + """Soft limits of a motor, or None when it has none.""" + device = self.device(name) + if device is None or not self.is_motor(name): + return None + try: + low, high = (float(v) for v in device.limits) + except Exception: # pylint: disable=broad-except + return None + if low == high == 0 or low >= high: + return None + return low, high + + def position(self, name: str) -> float | None: + """Last known position of a motor (from the cache, no hardware access).""" + device = self.device(name) + if device is None or not self.is_motor(name): + return None + try: + reading = device.read(cached=True) or {} + except Exception: # pylint: disable=broad-except + return None + value = (reading.get(device.name) or reading.get(name) or {}).get("value") + return _number(value) + # --- checks and exports ---------------------------------------------------------------- def _tree(self, doc: dict): schedule = Schedule.model_validate(doc) @@ -113,7 +234,7 @@ class SequencerBackend: return getattr(self.client, "device_manager", None) def validate(self, doc: dict) -> list[Problem]: - """Validate a document; returns an empty list when it can run. + """Validate a document with the backend; returns an empty list when it can run. The backend stops at the first problem, so at most one problem is returned. """ @@ -159,3 +280,18 @@ class SequencerBackend: return self._helper.request.procedure( "run_sequence", ((doc, list(self.definitions)), {}), self.QUEUE ) + + def close(self) -> None: + """Stop listening for procedure updates.""" + helper, self._helper = self._helper, None + if helper is None: + return + connector = self.client.connector + # pylint: disable=protected-access + try: + connector.unregister( + MessageEndpoints.procedure_request_response(), cb=helper._request_cb + ) + connector.unregister(MessageEndpoints.procedure_status_update(), cb=helper._update_cb) + except Exception: # pylint: disable=broad-except + logger.debug("Could not unregister the procedure helper callbacks") diff --git a/bec_widgets/widgets/control/sequencer/blocks/blocks_editor.py b/bec_widgets/widgets/control/sequencer/blocks/blocks_editor.py index 2ada5fe1..981c8b77 100644 --- a/bec_widgets/widgets/control/sequencer/blocks/blocks_editor.py +++ b/bec_widgets/widgets/control/sequencer/blocks/blocks_editor.py @@ -12,10 +12,8 @@ from bec_qthemes import material_icon from qtpy.QtCore import QMimeData, QPoint, QRectF, Qt, Signal from qtpy.QtGui import QColor, QDrag, QPainter, QPainterPath, QPen from qtpy.QtWidgets import ( - QAbstractSpinBox, QCheckBox, QComboBox, - QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, @@ -33,17 +31,32 @@ from bec_widgets.utils.colors import rgba from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.sequencer import model from bec_widgets.widgets.control.sequencer.editor_base import SequenceEditorBase -from bec_widgets.widgets.control.sequencer.inspector import ( - ScanSettingsDialog, - parse_values, +from bec_widgets.widgets.control.sequencer.fields import ( + NumberEdit, + ScanPicker, + ValuesEdit, + device_picker, values_text, ) -from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_color, kind_icon, theme +from bec_widgets.widgets.control.sequencer.inspector import ( + RangeDialog, + ScanSettingsDialog, + rename_variable, + scan_arguments, +) +from bec_widgets.widgets.control.sequencer.style import ( + heading, + hint, + kind_color, + kind_icon, + problem_label, + theme, +) MIME = "application/x-bec-sequence-block" PALETTE = [ ("set", "Move a motor", "Go to a position"), - ("scan_call", "Run a scan", "Any scan from scan control"), + ("scan_call", "Run a scan", "Any scan of this beamline"), ("repeat", "Repeat", "Do the inside N times"), ("for_each", "For each value", "Once per value in a list"), ("while", "While moving", "Repeat inside until a motor arrives"), @@ -114,7 +127,7 @@ class PaletteTile(QFrame): self._press = None _start_drag(self, {"new": self.kind}) - def mouseReleaseEvent(self, event): # pylint: disable=invalid-name + def mouseReleaseEvent(self, _event): # pylint: disable=invalid-name if self._press is not None: self.clicked.emit(self.kind) self._press = None @@ -166,13 +179,15 @@ class BlockBody(QWidget): def dragEnterEvent(self, event): # pylint: disable=invalid-name payload = _payload(event.mimeData()) if payload is None or payload.get("move") == self.parent_id: - return event.ignore() + event.ignore() + return event.acceptProposedAction() def dragMoveEvent(self, event): # pylint: disable=invalid-name payload = _payload(event.mimeData()) if payload is None: - return event.ignore() + event.ignore() + return moving = payload.get("move") if moving and ( moving == self.parent_id @@ -181,7 +196,8 @@ class BlockBody(QWidget): for a in model.ancestors(self.editor.document.root, self.parent_id) ) ): - return event.ignore() + event.ignore() + return self._drop_y = self._line_y(self._index_at(int(event.position().y()))) self.update() event.acceptProposedAction() @@ -195,7 +211,8 @@ class BlockBody(QWidget): self._drop_y = None self.update() if payload is None: - return event.ignore() + event.ignore() + return index = self._index_at(int(event.position().y())) event.acceptProposedAction() self.editor.drop(payload, self.parent_id if self.parent_id != "root" else None, index) @@ -241,6 +258,9 @@ class Block(QFrame): icon.setPixmap(kind_icon(self.kind, 20).pixmap(20, 20)) self.row.addWidget(icon) self.outer.addWidget(self.header) + self.problem_text = problem_label(self) + self.problem_text.setContentsMargins(42, 0, 8, 6) + self.outer.addWidget(self.problem_text) getattr(self, f"_build_{self.kind}", self._build_other)() self.row.addStretch(1) delete = QToolButton(self.header) @@ -250,6 +270,7 @@ class Block(QFrame): delete.clicked.connect(lambda: editor.document.delete(node["id"])) self.row.addWidget(delete) + self.set_problem(self.problem) if model.is_container(node): self.body = BlockBody(editor, node["id"], self) wrapper = QHBoxLayout() @@ -260,6 +281,14 @@ class Block(QFrame): footer.setFixedHeight(14) self.outer.addWidget(footer) + def set_problem(self, message: str) -> None: + """Show what this step still needs (or nothing).""" + self.problem = message + self.problem_text.setText(message) + self.problem_text.setVisible(bool(message)) + self.setToolTip(message) + self.update() + # --- inline editors -------------------------------------------------------------------- def _word(self, text: str, bold: bool = False) -> QLabel: label = QLabel(text, self.header) @@ -276,19 +305,20 @@ class Block(QFrame): self.row.addWidget(widget) return widget - def _number(self, value, width: int = 80) -> QDoubleSpinBox: - spin = QDoubleSpinBox(self.header, decimals=3, minimum=-1e9, maximum=1e9) - spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) - spin.setValue(float(value) if isinstance(value, (int, float)) else 0.0) - return self._field(spin, width) + def _number(self, value, width: int = 90) -> NumberEdit: + edit = NumberEdit(self.header, "position") + edit.set_value(value) + return self._field(edit, width) - def _device_combo(self, current: str) -> QComboBox: - combo = QComboBox(self.header) - combo.setEditable(True) - names = self.editor.backend.device_names() - combo.addItems(names) + def _device_combo(self, current: str, settable: bool = True): + combo = device_picker(self.header, self.editor.client, settable=settable) combo.setCurrentText(current) - return self._field(combo, 120) + return self._field(combo, 150) + + @staticmethod + def _on_device(combo, commit) -> None: + combo.device_selected.connect(commit) + combo.lineEdit().editingFinished.connect(commit) def _build_set(self) -> None: node = self.node @@ -298,18 +328,18 @@ class Block(QFrame): self._word("to") variables = model.visible_variables(self.editor.document.root, node["id"]) source = QComboBox(self.header) - source.addItem("value", None) - for name in variables: + source.addItem("a value", None) + for name in reversed(variables): source.addItem(f"each {name}", name) - number = self._number(node.get("value")) if model.is_ref(node.get("value")) and source.findData(node["value"]["ref"]) >= 0: source.setCurrentIndex(source.findData(node["value"]["ref"])) if variables: self._field(source) else: source.hide() + number = self._number(node.get("value")) number.setVisible(source.currentData() is None) - wait = QCheckBox("wait", self.header) + wait = QCheckBox("wait until it arrives", self.header) wait.setChecked(bool(node.get("wait", True))) wait.setVisible(not bound) self.row.addWidget(wait) @@ -317,53 +347,51 @@ class Block(QFrame): def commit(*_): number.setVisible(source.currentData() is None) value = model.ref(source.currentData()) if source.currentData() else number.value() - if isinstance(value, float) and value.is_integer(): - value = int(value) self.editor.inline_update( node["id"], - device=device.currentText().strip() or node.get("device"), + device=device.currentText().strip(), value=value, wait=True if bound else wait.isChecked(), ) - device.currentTextChanged.connect(commit) + self._on_device(device, commit) source.currentIndexChanged.connect(commit) - number.valueChanged.connect(commit) + number.edited.connect(commit) wait.toggled.connect(commit) def _build_scan_call(self) -> None: node = self.node self._word("Run", True) - combo = QComboBox(self.header) entries = self.editor.scan_entries() - for name in sorted(entries): - combo.addItem(model.scan_title(name).lower(), name) - if combo.findData(node.get("name")) < 0: - combo.addItem(node.get("name", ""), node.get("name")) - combo.setCurrentIndex(combo.findData(node.get("name"))) - self._field(combo) - details = QLabel(model.scan_details(node, entries) or "no settings yet", self.header) + picker = ScanPicker(entries, self.header) + picker.set_scan(node.get("name", "")) + self._field(picker) + details = QLabel(model.scan_details(node, entries) if node.get("name") else "", self.header) details.setStyleSheet(f"color: {rgba(theme('FG', '#333333'), 170)};") self.row.addWidget(details) settings = QPushButton("Settings…", self.header) settings.setIcon(material_icon("tune", size=(16, 16), convert_to_pixmap=False)) + settings.setEnabled(bool(node.get("name"))) self.row.addWidget(settings) - def change_scan(_index): - name = combo.currentData() - if name and name != node.get("name"): - self.editor.document.update(node["id"], name=name, positional=[], arguments={}) - - def open_settings(): - dialog = ScanSettingsDialog(node, self.editor.client, self) + def open_settings(name: str | None = None): + shown = dict(node) if not name else {"name": name} + dialog = ScanSettingsDialog(shown, self.editor.client, entries, self) if dialog.exec(): - name, positional, arguments = dialog.values() - self.editor.document.update( - node["id"], name=name, positional=positional, arguments=arguments - ) + chosen, positional, arguments = dialog.values() + if chosen: + self.editor.document.update( + node["id"], name=chosen, **scan_arguments(node, positional, arguments) + ) + return + picker.set_scan(node.get("name", "")) - combo.currentIndexChanged.connect(change_scan) - settings.clicked.connect(open_settings) + def choose(name: str): + if name != node.get("name"): + open_settings(name) + + picker.scan_chosen.connect(choose) + settings.clicked.connect(lambda _checked=False: open_settings()) def _build_repeat(self) -> None: node = self.node @@ -380,20 +408,28 @@ class Block(QFrame): name = QLineEdit(node.get("variable", ""), self.header) self._field(name, 90) self._word("in") - values = QLineEdit(values_text(node.get("values") or []), self.header) - values.setPlaceholderText("1, 2, 3") + values = ValuesEdit(self.header) + values.set_values(node.get("values") or []) self._field(values) - values.setMinimumWidth(160) + values.setMinimumWidth(180) + fill = QToolButton(self.header) + fill.setIcon(material_icon("linear_scale", size=(16, 16), convert_to_pixmap=False)) + fill.setToolTip("Fill evenly spaced values") + fill.setAutoRaise(True) + self.row.addWidget(fill) def commit(*_): - self.editor.inline_update( - node["id"], - variable=name.text().strip() or node.get("variable"), - values=parse_values(values.text()), - ) + self.editor.inline_update(node["id"], values=values.values()) + + def fill_values(): + dialog = RangeDialog(self) + if dialog.exec(): + values.setText(values_text(dialog.values())) + commit() name.editingFinished.connect(lambda: self.editor.rename_variable(node, name.text())) - values.textChanged.connect(commit) + values.textEdited.connect(commit) + fill.clicked.connect(fill_values) def _build_while(self) -> None: node = self.node @@ -403,9 +439,7 @@ class Block(QFrame): if owner is None: self._word("the condition holds") return - self._word( - f"{owner.get('device')} is still moving to {model.format_value(owner.get('value'))}" - ) + self._word(f"{owner.get('device') or 'the motor'} is moving, keep repeating:") def _build_other(self) -> None: self._word(model.sentence(self.node, self.editor.document.root), True) @@ -449,9 +483,11 @@ class Block(QFrame): self._press = None -class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors +class BlocksEditor(SequenceEditorBase): """Scratch-style block editor.""" + # pylint: disable=too-many-ancestors, attribute-defined-outside-init + CONCEPT_ID = "blocks" CONCEPT_TITLE = "Blocks" CONCEPT_TAGLINE = "Colored blocks you drag together and edit in place, like Scratch." @@ -498,6 +534,7 @@ class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors def refresh(self) -> None: if not hasattr(self, "scroll") or self._inline: return + self._blocks: dict[str, Block] = {} position = self.scroll.verticalScrollBar().value() canvas = QWidget() canvas.setObjectName("BlocksCanvas") @@ -526,32 +563,28 @@ class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors ) for child in children: block = Block(self, child, body) + self._blocks[child["id"]] = block body.add_block(block) if model.is_container(child): self._fill(block.body, child) def show_problems(self, problems) -> None: - ids = {p.node_id for p in problems} - if ids != getattr(self, "_problem_ids", set()): - self._problem_ids = ids - self.refresh() + """Update the blocks in place, so typing in a block keeps its focus.""" + messages = {p.node_id: p.message for p in problems} + for node_id, block in getattr(self, "_blocks", {}).items(): + if block.problem != messages.get(node_id, ""): + block.set_problem(messages.get(node_id, "")) # --- actions --------------------------------------------------------------------------- @SafeSlot(str) def append(self, kind: str) -> None: """Add a block at the end of the sequence.""" - self.document.add(model.new_step(kind, device=self._default_device())) - - def _default_device(self) -> str: - devices = self.backend.device_names() - return "samx" if "samx" in devices or not devices else devices[0] + self.document.add(self.document.new_nodes(kind)) def drop(self, payload: dict, parent_id: str | None, index: int) -> None: """Handle a dropped palette tile or block.""" if "new" in payload: - self.document.add( - model.new_step(payload["new"], device=self._default_device()), parent_id, index - ) + self.document.add(self.document.new_nodes(payload["new"]), parent_id, index) return node_id = payload.get("move") node, parent = model.find(self.document.root, node_id) @@ -574,21 +607,7 @@ class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors def rename_variable(self, loop: dict, new_name: str) -> None: """Rename a for-each variable and every use of it, then redraw.""" new_name = new_name.strip() - old = loop.get("variable") - if not new_name or new_name == old: + if not new_name or new_name == loop.get("variable"): return - - def visit(value): - if model.is_ref(value) and value["ref"] == old: - return model.ref(new_name) - if isinstance(value, list): - return [visit(v) for v in value] - if isinstance(value, dict): - return {k: visit(v) for k, v in value.items()} - return value - - for node, _depth, _parent in model.walk(loop): - for key in ("value", "positional", "arguments"): - if key in node: - node[key] = visit(node[key]) + rename_variable(loop, loop.get("variable"), new_name) self.document.update(loop["id"], variable=new_name) diff --git a/bec_widgets/widgets/control/sequencer/checks.py b/bec_widgets/widgets/control/sequencer/checks.py new file mode 100644 index 00000000..9de9c25d --- /dev/null +++ b/bec_widgets/widgets/control/sequencer/checks.py @@ -0,0 +1,133 @@ +"""Checks that run in the editor before the backend sees a sequence. + +The backend validates scan arguments and devices but stops at the first error and does not +know about steps that are simply unfinished. These checks find every unfinished step at +once and also catch motor positions outside the soft limits, so every concept can mark each +step that needs attention. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from bec_widgets.widgets.control.sequencer import model +from bec_widgets.widgets.control.sequencer.backend import Problem + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.widgets.control.sequencer.backend import SequencerBackend + + +def _is_number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _format_limit(value: float) -> str: + return f"{value:g}" + + +def _check_set(node: dict, root: dict, backend: SequencerBackend, known: set[str]) -> str: + device = (node.get("device") or "").strip() + if not device: + return "Choose a motor" + if known and device.split(".", maxsplit=1)[0] not in known: + return f"There is no device called “{device}”" + value = node.get("value") + if value is None or value == "": + return "Enter a position" if backend.is_motor(device) else "Enter a value" + if model.is_ref(value): + values = model.loop_values(root, node["id"], value["ref"]) + if values is None: + return f"“{value['ref']}” is only known inside its “For each {value['ref']}” loop" + else: + values = [value] + if not backend.is_motor(device): + return "" + if any(not _is_number(v) for v in values): + return f"{device} needs a number" + limits = backend.limits(device) + if limits is not None: + low, high = limits + outside = [v for v in values if not low <= v <= high] + if outside: + return ( + f"{model.format_value(outside[0])} is outside the limits of {device}" + f" ({_format_limit(low)} to {_format_limit(high)})" + ) + return "" + + +def _is_device_input(kind) -> bool: + if isinstance(kind, dict): # annotated inputs: {"Annotated": {"type": "float", ...}} + kind = (kind.get("Annotated") or {}).get("type", "") + return "device" in str(kind).lower() + + +def _device_positions(info: dict) -> list[int]: + """Positions inside one argument bundle that hold a device name.""" + arg_input = info.get("arg_input") or {} + return [i for i, kind in enumerate(arg_input.values()) if _is_device_input(kind)] + + +def _check_scan(node: dict, entries: dict, known: set[str]) -> str: + name = node.get("name") or "" + if not name: + return "Choose a scan" + if entries and name not in entries: + return f"This beamline has no scan called “{name}”" + info = entries.get(name, {}) + bundle = (info.get("arg_bundle_size") or {}).get("bundle") or 0 + positional = node.get("positional") or [] + if bundle and not positional: + return "Add at least one motor to the scan" + if bundle: + for offset in _device_positions(info): + for value in positional[offset::bundle]: + if model.is_ref(value): + continue + if not value: + return "Choose a motor for the scan" + if known and str(value).split(".", maxsplit=1)[0] not in known: + return f"There is no device called “{value}”" + return "" + + +def check(root: dict, backend: SequencerBackend) -> list[Problem]: + """Find unfinished steps and positions outside the motor limits. + + Args: + root (dict): Root group of the document. + backend (SequencerBackend): Gives the scans, devices and limits of the beamline. + + Returns: + list[Problem]: One problem per step that needs attention, in document order. + """ + entries = backend.scan_entries() + known = set(backend.device_names()) + problems: list[Problem] = [] + for node, _depth, _parent in model.walk(root): + kind = node.get("type") + message = "" + if kind == "set": + message = _check_set(node, root, backend, known) + elif kind == "scan_call": + message = _check_scan(node, entries, known) + elif kind == "for_each": + if not (node.get("variable") or "").strip(): + message = "Give the value a name" + elif not node.get("values"): + message = "Enter at least one value" + elif kind == "repeat": + count = node.get("count") + if not isinstance(count, int) or isinstance(count, bool) or count < 1: + message = "Repeat at least once" + elif kind == "while": + status = model.while_status(node) + if status is None or model.status_owner(root, status) is None: + message = "This loop has no motor to wait for" + elif kind == "component_call": + message = "Saved blocks cannot be used in this editor yet" + if not message and kind in model.LOOP_TYPES and not model.children_of(node): + message = "This loop has no steps inside" + if message: + problems.append(Problem(node["id"], message)) + return problems diff --git a/bec_widgets/widgets/control/sequencer/document.py b/bec_widgets/widgets/control/sequencer/document.py index cdf365ac..f135207e 100644 --- a/bec_widgets/widgets/control/sequencer/document.py +++ b/bec_widgets/widgets/control/sequencer/document.py @@ -65,10 +65,23 @@ class SequenceDocument(QObject): self._selected = node_id or "" self.selection_changed.emit(self._selected) + def new_nodes(self, kind: str) -> list[dict]: + """Blank nodes for a step kind, with a loop variable name not used yet.""" + return model.new_step(kind, variable=model.unused_variable(self.root)) + def add(self, nodes: list[dict], parent_id: str | None = None, index: int = -1) -> None: - """Insert new steps and select the first one.""" + """Insert new steps and select the first one. + + A new move inside a "For each" loop goes to each value of the loop by default. + """ self._remember() model.insert(self.root, nodes, parent_id, index) + for node in nodes: + for step in [node, *(n for n, _d, _p in model.walk(node))]: + if step.get("type") == "set" and step.get("value") is None: + variables = model.visible_variables(self.root, step["id"]) + if variables and not step.get("bind_status"): + step["value"] = model.ref(variables[-1]) self.touch() if nodes: self.select(nodes[0]["id"]) @@ -156,7 +169,7 @@ class SequenceDocument(QObject): self._remember() siblings = model.children_of(parent) index = siblings.index(node) - loop = model.new_step(kind)[0] + loop = self.new_nodes(kind)[0] siblings[index] = loop model.children_of(loop).append(node) self.touch() diff --git a/bec_widgets/widgets/control/sequencer/editor_base.py b/bec_widgets/widgets/control/sequencer/editor_base.py index fabae5c8..51b2e9c7 100644 --- a/bec_widgets/widgets/control/sequencer/editor_base.py +++ b/bec_widgets/widgets/control/sequencer/editor_base.py @@ -3,26 +3,33 @@ from __future__ import annotations import copy +import json +from bec_lib.logger import bec_logger +from qtpy.QtCore import QTimer, Signal from qtpy.QtWidgets import QVBoxLayout, QWidget from bec_widgets import BECWidget from bec_widgets.widgets.control.sequencer import model -from bec_widgets.widgets.control.sequencer.backend import Problem, SequencerBackend +from bec_widgets.widgets.control.sequencer.backend import Problem, SequencerBackend, sequences_dir from bec_widgets.widgets.control.sequencer.document import SequenceDocument from bec_widgets.widgets.control.sequencer.run_bar import RunBar +logger = bec_logger.logger + class SequenceEditorBase(BECWidget, QWidget): """An editor for one sequence document, with the shared run bar underneath. Subclasses build their editing surface in ``build_editor`` and redraw in ``refresh``. - ``show_problems`` receives the validation problems so the editor can mark the step. + ``show_problems`` receives the problems so the editor can mark each step that needs a fix. Args: parent (QWidget | None): Parent widget. client: BEC client. - doc (dict | None): Initial document; defaults to the shared example. + doc (dict | None): Initial document; an empty sequence by default. + draft_name (str | None): Keep the work in a draft file with this name, so it is still + there after the application was closed. No draft is kept without a name. """ RPC = False @@ -31,12 +38,23 @@ class SequenceEditorBase(BECWidget, QWidget): CONCEPT_TITLE = "" CONCEPT_TAGLINE = "" - def __init__(self, parent=None, client=None, doc: dict | None = None, **kwargs): + # Asks the host to open a sequence in the Recipe editor, which can edit any sequence. + open_in_recipe = Signal(dict) + + def __init__( + self, + parent=None, + client=None, + doc: dict | None = None, + draft_name: str | None = None, + **kwargs, + ): super().__init__(parent=parent, client=client, **kwargs) self.get_bec_shortcuts() self.backend = SequencerBackend(self.client) - self.document = SequenceDocument(doc if doc is not None else model.example_document(), self) + self.document = SequenceDocument(doc, self) self.problems: list[Problem] = [] + self.draft_name = draft_name layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -44,10 +62,18 @@ class SequenceEditorBase(BECWidget, QWidget): self.editor = QWidget(self) layout.addWidget(self.editor, 1) self.build_editor(self.editor) - self.run_bar = RunBar(self.backend, self.document, self) + self.run_bar = RunBar(self.backend, self.document, self.bec_dispatcher, self) layout.addWidget(self.run_bar) self.run_bar.problems_changed.connect(self._on_problems) + self.run_bar.new_requested.connect(self.reset) + self.run_bar.opened.connect(self.open_document) self.document.changed.connect(self.refresh) + + self._draft_timer = QTimer(self, singleShot=True, interval=1000) + self._draft_timer.timeout.connect(self.save_draft) + if doc is None: + self.load_draft() + self.document.changed.connect(self._draft_timer.start) self.refresh() self.run_bar.check() @@ -61,6 +87,15 @@ class SequenceEditorBase(BECWidget, QWidget): def show_problems(self, problems: list[Problem]) -> None: """Mark steps that need a fix.""" + def reset(self) -> None: + """Start over with an empty sequence.""" + self.document.set_document(model.empty_document()) + + def open_document(self, doc: dict) -> None: + """Show a sequence opened from a file.""" + self.document.set_document(doc) + + # --- state and drafts ------------------------------------------------------------------ def save_state(self) -> dict: """What the editor needs to come back after being rebuilt (e.g. on a theme change).""" return {"doc": copy.deepcopy(self.document.doc), "selected": self.document.selected} @@ -71,8 +106,38 @@ class SequenceEditorBase(BECWidget, QWidget): if state.get("selected"): self.document.select(state["selected"]) + def _draft_path(self): + folder = sequences_dir(self.client) if self.draft_name else None + return folder / "drafts" / f"{self.draft_name}.json" if folder else None + + def save_draft(self) -> None: + """Write the current work to the draft file.""" + path = self._draft_path() + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self.save_state(), indent=1), encoding="utf-8") + except (OSError, TypeError, ValueError) as exc: + logger.warning(f"Could not save the sequence draft {path}: {exc}") + + def load_draft(self) -> None: + """Bring back the work from the draft file, if there is one.""" + path = self._draft_path() + if path is None or not path.exists(): + return + try: + self.restore_state(json.loads(path.read_text(encoding="utf-8"))) + self.document.select("") + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Could not restore the sequence draft {path}: {exc}") + + # --- problems -------------------------------------------------------------------------- def _on_problems(self, problems: list) -> None: self.problems = problems + inspector = getattr(self, "inspector", None) + if inspector is not None: + inspector.set_problems(problems) self.show_problems(problems) def problem_for(self, node_id: str) -> str: @@ -84,9 +149,12 @@ class SequenceEditorBase(BECWidget, QWidget): return self.backend.scan_entries() def cleanup(self): - """Stop timers of the shared parts.""" - self.run_bar.cleanup() + """Save the draft and stop timers of the shared parts.""" inspector = getattr(self, "inspector", None) if inspector is not None: inspector.cleanup() + if self._draft_timer.isActive(): + self._draft_timer.stop() + self.save_draft() + self.run_bar.cleanup() super().cleanup() diff --git a/bec_widgets/widgets/control/sequencer/fields.py b/bec_widgets/widgets/control/sequencer/fields.py new file mode 100644 index 00000000..969e2d48 --- /dev/null +++ b/bec_widgets/widgets/control/sequencer/fields.py @@ -0,0 +1,165 @@ +"""Input fields shared by the sequencer editors.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QComboBox, QLineEdit, QWidget + +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.sequencer import model +from bec_widgets.widgets.control.sequencer.style import theme + + +def parse_number(text: str): + """Parse a typed number; an empty text is None and anything else stays text. + + Args: + text (str): Typed text. + + Returns: + int | float | str | None: The value. + """ + text = text.strip() + if not text: + return None + try: + number = float(text) + except ValueError: + return text + return int(number) if number.is_integer() and "." not in text and "e" not in text else number + + +def parse_values(text: str) -> list: + """Parse ``1, -1, 2.5`` (or one value per line) into numbers, keeping words as text.""" + values = [] + for part in text.replace("\n", ",").replace(";", ",").split(","): + value = parse_number(part) + if value is not None: + values.append(value) + return values + + +def values_text(values: list) -> str: + """Inverse of ``parse_values``.""" + return ", ".join(model.format_value(v) for v in values) + + +class NumberEdit(QLineEdit): + """A number field that can also be empty ("not entered yet"). + + ``edited`` is emitted whenever the user changes the text. + """ + + edited = Signal() + + def __init__(self, parent: QWidget | None = None, placeholder: str = "number"): + super().__init__(parent) + self.setPlaceholderText(placeholder) + self.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.textEdited.connect(self._on_edited) + self._style(True) + + def value(self): + """The number, None when empty, or the text when it is not a number.""" + return parse_number(self.text()) + + def set_value(self, value) -> None: + """Show a value (None clears the field).""" + self.setText("" if value is None or model.is_ref(value) else model.format_value(value)) + self._style(True) + + def _on_edited(self, text: str) -> None: + value = parse_number(text) + self._style(value is None or not isinstance(value, str)) + self.edited.emit() + + def _style(self, valid: bool) -> None: + red = theme("ACCENT_EMERGENCY", "#e0534b").name() + self.setStyleSheet("" if valid else f"QLineEdit {{ border: 1px solid {red}; }}") + + +class ValuesEdit(QLineEdit): + """A comma separated list of values, e.g. ``1, -1, 2.5``.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setPlaceholderText("e.g. 1, -1, 2.5") + + def values(self) -> list: + """The parsed values.""" + return parse_values(self.text()) + + def set_values(self, values: list) -> None: + """Show values.""" + self.setText(values_text(values or [])) + + +class DevicePicker(DeviceComboBox): + """Device combo box that only turns red for a name that is not a device. + + An empty picker is not an error here: the editor says which step still needs a motor. + """ + + def _update_validity_style(self, is_valid: bool) -> None: + super()._update_validity_style(is_valid or not self.currentText().strip()) + + +def device_picker( + parent: QWidget | None, client, placeholder: str = "Choose a motor", settable: bool = False +) -> DevicePicker: + """A device combo box listing the motors (and, optionally, settable signals). + + Args: + parent (QWidget | None): Parent widget. + client: BEC client. + placeholder (str): Text shown while nothing is chosen. + settable (bool): Also list signals that can be written, e.g. a temperature setpoint. + + Returns: + DevicePicker: The picker, empty until the user chooses. + """ + picker = DevicePicker( + parent, + client=client, + device_filter=["Positioner"], + autocomplete=True, + include_signals_with_write_access=settable, + ) + picker.set_first_element_as_empty = True + picker.setCurrentText("") + picker.lineEdit().setPlaceholderText(placeholder) + picker.setMinimumContentsLength(10) + return picker + + +class ScanPicker(QComboBox): + """Choose one of the beamline's scans by its readable name. + + Args: + entries (dict): Advertised scans by name. + parent (QWidget | None): Parent widget. + """ + + scan_chosen = Signal(str) + + def __init__(self, entries: dict, parent: QWidget | None = None): + super().__init__(parent) + self.setPlaceholderText("Choose a scan…") + self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) + for name, info in entries.items(): + self.addItem(model.scan_title(name), name) + doc = (info.get("doc") or "").strip().splitlines() + if doc: + self.setItemData(self.count() - 1, doc[0], Qt.ItemDataRole.ToolTipRole) + self.setCurrentIndex(-1) + self.activated.connect(lambda _index: self.scan_chosen.emit(self.scan())) + + def scan(self) -> str: + """Name of the chosen scan, or an empty string.""" + return self.currentData() or "" + + def set_scan(self, name: str) -> None: + """Show a scan; an unknown name is listed so it is not lost.""" + if name and self.findData(name) < 0: + self.addItem(model.scan_title(name), name) + self.setCurrentIndex(self.findData(name) if name else -1) diff --git a/bec_widgets/widgets/control/sequencer/flow/flow_editor.py b/bec_widgets/widgets/control/sequencer/flow/flow_editor.py index 6b88731c..1ca302ca 100644 --- a/bec_widgets/widgets/control/sequencer/flow/flow_editor.py +++ b/bec_widgets/widgets/control/sequencer/flow/flow_editor.py @@ -88,10 +88,10 @@ class StepBox(QGraphicsPathItem): label.setBrush(QBrush(theme("FG", "#222222"))) label.setPos(rect.left() + 48, rect.center().y() - label.boundingRect().height() / 2) if problem: - badge = QGraphicsSimpleTextItem("needs a fix", self) + badge = QGraphicsSimpleTextItem(self._elide(problem, rect.width() * 0.6), self) badge.setFont(_font(8, True)) badge.setBrush(QBrush(theme("ACCENT_EMERGENCY", "#e0534b"))) - badge.setPos(rect.right() - badge.boundingRect().width() - 10, rect.top() + 3) + badge.setPos(rect.right() - badge.boundingRect().width() - 10, rect.top() + 2) @staticmethod def _elide(text: str, width: float) -> str: @@ -192,7 +192,7 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors ) left_layout.addWidget(self.timeline) - self.inspector = StepInspector(self.document, splitter, client=self.client) + self.inspector = StepInspector(self.document, self.backend, splitter, client=self.client) self.inspector.setMinimumWidth(340) splitter.addWidget(left) splitter.addWidget(self.inspector) @@ -211,6 +211,12 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors y = self._terminal("Start", 0, theme("PRIMARY", "#2f6fdf")) y = self._column(root, 0, y, ROOT_WIDTH) self._terminal("Finish", y, theme("ACCENT_SUCCESS", "#59a869")) + if not model.children_of(root): + note = QGraphicsSimpleTextItem("Click + to add the first step") + note.setFont(_font(10)) + note.setBrush(QBrush(theme("FG", "#333333"))) + note.setPos(ROOT_WIDTH / 2 + 20, 32 + GAP / 2 - note.boundingRect().height() / 2) + self.scene.addItem(note) self.scene.setSceneRect(self.scene.itemsBoundingRect().adjusted(-20, -20, 20, 20)) self._fill_timeline() @@ -303,8 +309,8 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors items = model.timeline(self.document.root, self._entries) for index, item in enumerate(items, 1): short = item.text - if item.kind == "set": - short = short.replace("Move ", "").replace(" to ", " → ") + if item.kind == "set" and short.startswith("Move "): + short = short[len("Move ") :].replace(" to ", " → ") elif item.kind == "scan_call": short = short.split(":")[0].replace("Run ", "") entry = QListWidgetItem(kind_icon(item.kind, 18), f"{index}. {short}\n{item.context}") @@ -328,9 +334,7 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors action.setData(kind) chosen = menu.exec(screen_pos) if chosen is not None: - devices = self.backend.device_names() - device = "samx" if "samx" in devices or not devices else devices[0] - self.document.add(model.new_step(chosen.data(), device=device), parent_id, index) + self.document.add(self.document.new_nodes(chosen.data()), parent_id, index) def step_menu(self, node_id: str, screen_pos) -> None: """Right-click menu of a step.""" @@ -359,9 +363,3 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors chosen = menu.exec(screen_pos) if chosen in actions: actions[chosen]() - - def screenshot_setup(self) -> None: - """Select the move step for screenshots.""" - moves = [n for n, _d, _p in model.walk(self.document.root) if n["type"] == "set"] - if moves: - self.document.select(moves[0]["id"]) diff --git a/bec_widgets/widgets/control/sequencer/guided/guided_editor.py b/bec_widgets/widgets/control/sequencer/guided/guided_editor.py index 8fd3dfe5..a7b5705b 100644 --- a/bec_widgets/widgets/control/sequencer/guided/guided_editor.py +++ b/bec_widgets/widgets/control/sequencer/guided/guided_editor.py @@ -7,16 +7,14 @@ the result to the Recipe editor for anything the sentence cannot say. from __future__ import annotations +import copy + from bec_qthemes import material_icon from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( - QAbstractSpinBox, - QComboBox, - QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, - QLineEdit, QListWidget, QListWidgetItem, QPushButton, @@ -29,13 +27,21 @@ from bec_widgets.utils.colors import rgba from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.sequencer import model from bec_widgets.widgets.control.sequencer.editor_base import SequenceEditorBase -from bec_widgets.widgets.control.sequencer.inspector import ( - RangeDialog, - ScanSettingsDialog, - parse_values, +from bec_widgets.widgets.control.sequencer.fields import ( + NumberEdit, + ValuesEdit, + device_picker, values_text, ) -from bec_widgets.widgets.control.sequencer.style import card_qss, heading, hint, kind_icon, theme +from bec_widgets.widgets.control.sequencer.inspector import RangeDialog, ScanSettingsDialog +from bec_widgets.widgets.control.sequencer.style import ( + card_qss, + heading, + hint, + kind_icon, + problem_label, + theme, +) GOALS = [ ( @@ -140,20 +146,21 @@ class GoalCard(QFrame): self.clicked.emit(self.key) -class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors +class GuidedEditor(SequenceEditorBase): """Goal picker with fill-in-the-blanks sentences.""" + # pylint: disable=too-many-ancestors, attribute-defined-outside-init + CONCEPT_ID = "guided" CONCEPT_TITLE = "Guided" CONCEPT_TAGLINE = "Pick a goal and fill in the blanks of a sentence; no loops to think about." - open_in_recipe = Signal(dict) - def build_editor(self, host: QWidget) -> None: self.goal = "positions" - self.scan = model.scan_node( - "line_scan", ["samy", -2, 2], {"steps": 20, "exp_time": 0.1, "relative": True} - ) + self.scan = model.scan_node("") + # What was typed, kept when switching goals: device, values, repeat, … + self.memory: dict = {} + self._field_nodes: dict[str, str] = {} layout = QHBoxLayout(host) layout.setContentsMargins(16, 14, 16, 8) layout.setSpacing(16) @@ -207,42 +214,45 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors for card in self.goal_cards: card.set_selected(card.key == self.goal) - # --- sentences ------------------------------------------------------------------------- - def _device_combo(self, current: str) -> QComboBox: - combo = QComboBox(self.sentence_card) - combo.setEditable(True) - combo.addItems(self.backend.device_names()) - combo.setCurrentText(current) - combo.currentTextChanged.connect(self._compile) - return combo + # --- sentence fields ------------------------------------------------------------------- + def _device(self, key: str, placeholder: str, settable: bool = False): + picker = device_picker(self.sentence_card, self.client, placeholder, settable=settable) + picker.setCurrentText(self.memory.get(key, "")) + picker.device_selected.connect(self._compile) + picker.lineEdit().editingFinished.connect(self._compile) + self.fields[key] = picker + return picker - def _spin(self, value: int, suffix: str = "") -> QSpinBox: + def _count_field(self, key: str, default: int) -> QSpinBox: spin = QSpinBox(self.sentence_card, minimum=1, maximum=100000) - spin.setValue(value) - spin.setSuffix(suffix) + spin.setValue(int(self.memory.get(key, default))) + spin.setSuffix(" ×") spin.valueChanged.connect(self._compile) + self.fields[key] = spin return spin - def _number(self, value: float) -> QDoubleSpinBox: - spin = QDoubleSpinBox(self.sentence_card, decimals=3, minimum=-1e9, maximum=1e9) - spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) - spin.setValue(value) - spin.valueChanged.connect(self._compile) - return spin - - def _values(self, values: list) -> QLineEdit: - edit = QLineEdit(values_text(values), self.sentence_card) - edit.setPlaceholderText("1, 2, 3") - edit.textChanged.connect(self._compile) + def _number(self, key: str) -> NumberEdit: + edit = NumberEdit(self.sentence_card, "position") + edit.set_value(self.memory.get(key)) + edit.edited.connect(self._compile) + self.fields[key] = edit return edit - def _fill_button(self, target: QLineEdit) -> QPushButton: + def _values(self, key: str) -> ValuesEdit: + edit = ValuesEdit(self.sentence_card) + edit.set_values(self.memory.get(key, [])) + edit.textEdited.connect(self._compile) + self.fields[key] = edit + return edit + + def _fill_button(self, target: ValuesEdit) -> QPushButton: button = QPushButton("Evenly spaced…", self.sentence_card) def fill(): dialog = RangeDialog(self) if dialog.exec(): target.setText(values_text(dialog.values())) + self._compile() button.clicked.connect(fill) return button @@ -250,20 +260,27 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors def _scan_button(self) -> QPushButton: button = QPushButton(self.sentence_card) button.setIcon(kind_icon("scan_call", 18)) - button.setText(model.sentence(self.scan, None, self.scan_entries())[4:]) - button.setToolTip("Change the scan and its settings") + button.setToolTip("Choose the scan and its settings") + self.fields["scan"] = button def edit(): - dialog = ScanSettingsDialog(self.scan, self.client, self) + dialog = ScanSettingsDialog(self.scan, self.client, self.scan_entries(), self) if dialog.exec(): name, positional, arguments = dialog.values() - self.scan.update(name=name, positional=positional, arguments=arguments) - button.setText(model.sentence(self.scan, None, self.scan_entries())[4:]) - self._compile() + if name: + self.scan.update(name=name, positional=positional, arguments=arguments) + self._compile() button.clicked.connect(edit) return button + def _scan_text(self) -> str: + if not self.scan.get("name"): + return "Choose a scan…" + details = model.scan_details(self.scan, self.scan_entries()) + title = model.scan_title(self.scan["name"]).lower() + return f"{title}: {details}" if details else title + def _line(self) -> FlowRow: line = FlowRow(self.sentence_card) self.sentence_layout.addWidget(line) @@ -273,89 +290,121 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors while self.sentence_layout.count(): item = self.sentence_layout.takeAt(0) if item.widget(): + item.widget().hide() # gone at once, deleted when Qt gets to it item.widget().deleteLater() - f = self.fields = {} + self.fields = {} title = next(g for g in GOALS if g[0] == self.goal)[2] self.sentence_layout.addWidget(heading(title, self.sentence_card, 16)) - if self.goal in ("positions", "values"): - device = "samx" if self.goal == "positions" else "samz" - f["device"] = self._device_combo(device) - f["values"] = self._values( - [1, -1] if self.goal == "positions" else [0.5, 1.0, 1.5, 2.0] - ) - verb = "Move" if self.goal == "positions" else "Set" - noun = "positions" if self.goal == "positions" else "values" - self._line().words(verb).field(f["device"], 140).words( - f"to each of these {noun}:" + if self.goal == "positions": + self._line().words("Move").field(self._device("motor", "Choose a motor"), 170).words( + "to each of these positions:" ).end() - self._line().field(f["values"], 320).field(self._fill_button(f["values"])).end() + values = self._values("positions") + self._line().field(values, 320).field(self._fill_button(values)).end() self._line().words("and there run a").field(self._scan_button()).end() - f["repeat"] = self._spin(2, " times") - self._line().words("Do all of this").field(f["repeat"], 100).end() + self._line().words("Do all of this").field(self._count_field("repeat", 1), 90).end() + elif self.goal == "values": + self._line().words("Set").field( + self._device("setting", "Choose a setting", settable=True), 170 + ).words("to each of these values:").end() + values = self._values("setting_values") + self._line().field(values, 320).field(self._fill_button(values)).end() + self._line().words("and at each value run a").field(self._scan_button()).end() + self._line().words("Do all of this").field(self._count_field("repeat", 1), 90).end() elif self.goal == "grid": - f["fast"] = self._device_combo("samx") - f["fast_values"] = self._values([0, 1, 2]) - f["slow"] = self._device_combo("samy") - f["slow_values"] = self._values([0, 1]) - self._line().words("For each").field(f["slow"], 120).words("position").field( - f["slow_values"], 200 - ).field(self._fill_button(f["slow_values"])).end() - self._line().words("go through every").field(f["fast"], 120).words("position").field( - f["fast_values"], 200 - ).field(self._fill_button(f["fast_values"])).end() + slow_values = self._values("slow_values") + self._line().words("For each").field(self._device("slow", "Choose a motor"), 150).words( + "position" + ).field(slow_values, 200).field(self._fill_button(slow_values)).end() + fast_values = self._values("fast_values") + self._line().words("go through every").field( + self._device("fast", "Choose a motor"), 150 + ).words("position").field(fast_values, 200).field(self._fill_button(fast_values)).end() self._line().words("and run a").field(self._scan_button()).words("at each point.").end() elif self.goal == "repeat": - f["repeat"] = self._spin(5, " times") self._line().words("Run a").field(self._scan_button()).end() - self._line().field(f["repeat"], 100).words("in a row.").end() + self._line().field(self._count_field("count", 2), 90).words("in a row.").end() elif self.goal == "during": - f["device"] = self._device_combo("samx") - f["target"] = self._number(10) - self._line().words("Start moving").field(f["device"], 140).words("to").field( - f["target"], 100 - ).end() + self._line().words("Start moving").field( + self._device("motor", "Choose a motor"), 170 + ).words("to").field(self._number("target"), 110).end() self._line().words("and keep running a").field(self._scan_button()).end() self._line().words("until it has arrived.").end() + self.problem = problem_label(self.sentence_card) + self.sentence_layout.addWidget(self.problem) self.sentence_layout.addStretch(1) self._compile() + # --- building the sequence ------------------------------------------------------------- + def _remember(self) -> None: + for key, widget in self.fields.items(): + if isinstance(widget, ValuesEdit): + self.memory[key] = widget.values() + elif isinstance(widget, NumberEdit): + self.memory[key] = widget.value() + elif isinstance(widget, QSpinBox): + self.memory[key] = widget.value() + elif hasattr(widget, "currentText"): + self.memory[key] = widget.currentText().strip() + + def _move(self, key: str, value) -> dict: + node = model.move_node(self.memory.get(key, ""), value) + self._field_nodes[node["id"]] = key + return node + + def _loop(self, variable: str, key: str, children: list[dict]) -> dict: + node = model.for_each_node(variable, self.memory.get(key, []), children) + self._field_nodes[node["id"]] = key + return node + + def _scan(self) -> dict: + node = model.clone(self.scan) + self._field_nodes[node["id"]] = "scan" + return node + @SafeSlot() def _compile(self, *_args) -> None: - f = self.fields - scan = model.clone(self.scan) + self._remember() + self._field_nodes = {} + memory = self.memory doc = model.empty_document(f"guided_{self.goal}") steps: list[dict] = [] if self.goal in ("positions", "values"): - variable = "position" if self.goal == "positions" else "value" - loop = model.for_each_node( - variable, - parse_values(f["values"].text()), - [model.move_node(f["device"].currentText(), model.ref(variable)), scan], + device_key, values_key = ( + ("motor", "positions") + if self.goal == "positions" + else ("setting", "setting_values") ) - repeat = f["repeat"].value() + variable = "position" if self.goal == "positions" else "value" + loop = self._loop( + variable, values_key, [self._move(device_key, model.ref(variable)), self._scan()] + ) + repeat = int(memory.get("repeat", 1)) steps = [model.repeat_node(repeat, [loop])] if repeat > 1 else [loop] elif self.goal == "grid": - fast, slow = f["fast"].currentText(), f["slow"].currentText() - inner = model.for_each_node( - fast, - parse_values(f["fast_values"].text()), - [model.move_node(fast, model.ref(fast)), scan], + inner = self._loop( + "fast_position", + "fast_values", + [self._move("fast", model.ref("fast_position")), self._scan()], ) steps = [ - model.for_each_node( - slow, - parse_values(f["slow_values"].text()), - [model.move_node(slow, model.ref(slow)), inner], + self._loop( + "slow_position", + "slow_values", + [self._move("slow", model.ref("slow_position")), inner], ) ] elif self.goal == "repeat": - steps = [model.repeat_node(f["repeat"].value(), [scan])] + steps = [model.repeat_node(int(memory.get("count", 2)), [self._scan()])] elif self.goal == "during": - target = f["target"].value() - steps = model.while_moving_nodes( - f["device"].currentText(), int(target) if target.is_integer() else target, [scan] + start, loop = model.while_moving_nodes( + memory.get("motor", ""), memory.get("target"), [self._scan()] ) + self._field_nodes[start["id"]] = "motor" + steps = [start, loop] doc["root"]["children"] = steps + if "scan" in self.fields: + self.fields["scan"].setText(self._scan_text()) self.document.set_document(doc) @SafeSlot() @@ -363,34 +412,59 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors if not hasattr(self, "preview"): return self.preview.clear() - items = model.timeline(self.document.root, self.scan_entries(), limit=200) + items = model.timeline(self.document.root, self.scan_entries(), limit=500) for index, item in enumerate(items, 1): text = f"{index}. {item.text}" if item.context: - text += f" — {item.context}" + text += f" ({item.context})" self.preview.addItem(QListWidgetItem(kind_icon(item.kind, 16), text)) - summary = model.summarize(self.document.root) - self.count.setText(summary.text()) + if not items: + self.preview.addItem("Fill in the sentence to see every step here.") + self.count.setText(model.summarize(self.document.root).text()) @SafeSlot(str) def _on_goal(self, key: str) -> None: + self._remember() self.goal = key self._style_goals() self._build_sentence() + def reset(self) -> None: + self.memory = {} + self.scan = model.scan_node("") + self._build_sentence() + + def open_document(self, doc: dict) -> None: + """A sequence from a file can have any shape, so it opens in the Recipe editor.""" + self.open_in_recipe.emit(doc) + def save_state(self) -> dict: - return {"goal": self.goal, "scan": self.scan} + self._remember() + return {"goal": self.goal, "scan": copy.deepcopy(self.scan), "memory": dict(self.memory)} def restore_state(self, state: dict) -> None: - self.goal = state["goal"] - self.scan = state["scan"] + self.goal = state.get("goal", "positions") + self.scan = state.get("scan") or model.scan_node("") + self.memory = dict(state.get("memory", {})) self._style_goals() self._build_sentence() def show_problems(self, problems) -> None: - red = theme("ACCENT_EMERGENCY", "#e0534b") - self.count.setStyleSheet(f"color: {red.name()};" if problems else "") - if problems: - self.count.setText( - f"{model.summarize(self.document.root).text()} · {problems[0].message}" + red = theme("ACCENT_EMERGENCY", "#e0534b").name() + marked = {self._field_nodes.get(p.node_id) for p in problems} + for key, widget in self.fields.items(): + if key == "scan" or not hasattr(widget, "setStyleSheet"): + continue + if isinstance(widget, (ValuesEdit, QSpinBox)): + widget.setStyleSheet( + f"{type(widget).__name__} {{ border: 1px solid {red}; }}" + if key in marked + else "" + ) + scan_button = self.fields.get("scan") + if scan_button is not None: + scan_button.setStyleSheet( + f"QPushButton {{ border: 1px solid {red}; }}" if "scan" in marked else "" ) + self.problem.setText(problems[0].message if problems else "") + self.problem.setVisible(bool(problems)) diff --git a/bec_widgets/widgets/control/sequencer/inspector.py b/bec_widgets/widgets/control/sequencer/inspector.py index 5b1dac6d..f3d0e398 100644 --- a/bec_widgets/widgets/control/sequencer/inspector.py +++ b/bec_widgets/widgets/control/sequencer/inspector.py @@ -7,9 +7,8 @@ form, so scientists see the same scan settings they know from the scan control w from __future__ import annotations import numpy as np -from qtpy.QtCore import QTimer +from qtpy.QtCore import QTimer, Signal from qtpy.QtWidgets import ( - QAbstractSpinBox, QCheckBox, QComboBox, QDialog, @@ -28,43 +27,30 @@ from qtpy.QtWidgets import ( ) from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.control.scan_control.scan_control import ScanControl from bec_widgets.widgets.control.sequencer import model +from bec_widgets.widgets.control.sequencer.backend import Problem, SequencerBackend from bec_widgets.widgets.control.sequencer.document import SequenceDocument -from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_icon - - -def parse_values(text: str) -> list: - """Parse ``1, -1, 2.5`` (or one value per line) into numbers, keeping words as text.""" - values = [] - for part in text.replace("\n", ",").replace(";", ",").split(","): - part = part.strip() - if not part: - continue - try: - number = float(part) - values.append(int(number) if number.is_integer() and "." not in part else number) - except ValueError: - values.append(part) - return values - - -def values_text(values: list) -> str: - """Inverse of ``parse_values``.""" - return ", ".join(model.format_value(v) for v in values) +from bec_widgets.widgets.control.sequencer.fields import ( + NumberEdit, + ScanPicker, + ValuesEdit, + device_picker, + values_text, +) +from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_icon, problem_label class RangeDialog(QDialog): """Fill a list of values from start, stop and number of points.""" - def __init__(self, parent: QWidget | None = None): + def __init__(self, parent: QWidget | None = None, title: str = "Fill evenly spaced values"): super().__init__(parent) - self.setWindowTitle("Fill evenly spaced values") + self.setWindowTitle(title) form = QFormLayout(self) - self.start = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6, value=0) - self.stop = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6, value=10) - self.points = QSpinBox(self, minimum=1, maximum=10000, value=11) + self.start = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6) + self.stop = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6) + self.points = QSpinBox(self, minimum=1, maximum=10000, value=2) form.addRow("From", self.start) form.addRow("To", self.stop) form.addRow("Number of values", self.points) @@ -84,9 +70,10 @@ class RangeDialog(QDialog): def make_scan_form(parent: QWidget | None, client) -> ScanControl: - """The scan control form without its Start/Stop buttons and metadata.""" + """The scan control form without its scan selector, Start/Stop buttons and metadata.""" form = ScanControl(parent, client=client) form.hide_scan_control_buttons = True + form.hide_scan_selection_combobox = True form.hide_metadata = True group = getattr(form, "_metadata_group", None) if group is not None: @@ -94,92 +81,127 @@ def make_scan_form(parent: QWidget | None, client) -> ScanControl: return form -def scan_form_values(form: ScanControl) -> tuple[str, list, dict]: - """Scan name, positional and keyword arguments shown in a scan form.""" +def scan_form_values(form: ScanControl) -> tuple[list, dict]: + """Positional and keyword arguments shown in a scan form.""" positional, arguments = form.get_scan_parameters(bec_object=False) arguments.pop("metadata", None) - return form.current_scan, list(positional), arguments + return list(positional), arguments -def set_scan_form_values(form: ScanControl, node: dict) -> None: - """Show a scan node in a scan form.""" - form.set_current_scan(node.get("name", "")) - positional = [v for v in node.get("positional", []) if not isinstance(v, dict)] - if form.arg_box is not None and positional: +def show_scan_in_form(form: ScanControl, node: dict) -> None: + """Show a scan node in a scan form; a fresh node gets the scan's own defaults.""" + name = node.get("name", "") + # The form remembers values per scan; a step must only ever show its own values. + form.config.scans.clear() + form.previous_scan = None + if form.current_scan == name: + form.scan_select(name) + else: + form.set_current_scan(name) + positional = node.get("positional") or [] + if form.arg_box is not None and positional and not any(isinstance(v, dict) for v in positional): form.arg_box.set_parameters(positional) arguments = node.get("arguments") if isinstance(node.get("arguments"), dict) else {} for box in form.kwarg_boxes: - box.set_parameters(arguments) + box.set_parameters({k: v for k, v in arguments.items() if not isinstance(v, dict)}) + + +def scan_arguments(node: dict, positional: list, arguments: dict) -> dict: + """New scan fields for a node, keeping metadata the form does not show (e.g. sample name).""" + old = node.get("arguments") if isinstance(node.get("arguments"), dict) else {} + if "metadata" in old: + arguments = {**arguments, "metadata": old["metadata"]} + return {"positional": list(positional), "arguments": arguments} class ScanSettingsDialog(QDialog): - """Edit one scan step in the familiar scan control form.""" + """Choose a scan and edit its settings in the familiar scan control form.""" - def __init__(self, node: dict, client, parent: QWidget | None = None): + def __init__(self, node: dict, client, entries: dict, parent: QWidget | None = None): super().__init__(parent) + self.node = node self.setWindowTitle("Scan settings") - self.resize(520, 620) + self.resize(540, 640) layout = QVBoxLayout(self) - scroll = QScrollArea(self) - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.Shape.NoFrame) - self.form = make_scan_form(scroll, client) - scroll.setWidget(self.form) - layout.addWidget(scroll, 1) - set_scan_form_values(self.form, node) - buttons = QDialogButtonBox( + row = QHBoxLayout() + row.addWidget(QLabel("Scan", self)) + self.picker = ScanPicker(entries, self) + self.picker.set_scan(node.get("name", "")) + row.addWidget(self.picker, 1) + layout.addLayout(row) + self.scroll = QScrollArea(self) + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QScrollArea.Shape.NoFrame) + self.form = make_scan_form(self.scroll, client) + self.scroll.setWidget(self.form) + layout.addWidget(self.scroll, 1) + self.empty = hint("Choose the scan to run at this step.", self) + layout.addWidget(self.empty) + self.picker.scan_chosen.connect(self._on_scan) + self._on_scan(node.get("name", ""), keep_values=True) + self.buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + layout.addWidget(self.buttons) + + def _on_scan(self, name: str, keep_values: bool = False) -> None: + shown = dict(self.node) if keep_values else {"name": name} + self.scroll.setVisible(bool(name)) + self.empty.setVisible(not name) + if name: + show_scan_in_form(self.form, shown) def values(self) -> tuple[str, list, dict]: """Scan name, positional and keyword arguments.""" - return scan_form_values(self.form) + name = self.picker.scan() + if not name: + return "", [], {} + positional, arguments = scan_form_values(self.form) + return name, positional, arguments class ValueInput(QWidget): - """Either a fixed number or "the current loop value".""" + """Either a typed number or "each value from the loop".""" + + edited = Signal() def __init__(self, parent: QWidget | None = None): super().__init__(parent) layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self.source = QComboBox(self) - self.number = QDoubleSpinBox(self, decimals=4, minimum=-1e9, maximum=1e9) - self.number.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + self.number = NumberEdit(self, "position") layout.addWidget(self.source, 1) layout.addWidget(self.number, 1) - self.source.currentIndexChanged.connect(self._sync) + self.source.activated.connect(self._on_source) + self.number.edited.connect(self.edited) def set_choices(self, variables: list[str]) -> None: """Offer the loop variables visible at this step.""" - self.source.blockSignals(True) self.source.clear() self.source.addItem("a fixed value", None) - for name in variables: + for name in reversed(variables): self.source.addItem(f"each {name} from the loop", name) - self.source.blockSignals(False) def set_value(self, value) -> None: """Show a literal or a reference.""" - if model.is_ref(value) and self.source.findData(value["ref"]) >= 0: - self.source.setCurrentIndex(self.source.findData(value["ref"])) - else: - self.source.setCurrentIndex(0) - self.number.setValue(float(value) if isinstance(value, (int, float)) else 0.0) + index = self.source.findData(value["ref"]) if model.is_ref(value) else 0 + self.source.setCurrentIndex(max(index, 0)) + self.number.set_value(None if model.is_ref(value) else value) self._sync() def value(self): - """Literal number or reference expression.""" + """Typed value (None when empty) or reference expression.""" name = self.source.currentData() - if name: - return model.ref(name) - number = self.number.value() - return int(number) if number.is_integer() else number + return model.ref(name) if name else self.number.value() - def _sync(self): + def _on_source(self, _index: int) -> None: + self._sync() + self.edited.emit() + + def _sync(self) -> None: self.number.setVisible(self.source.currentData() is None) self.source.setVisible(self.source.count() > 1) @@ -187,9 +209,17 @@ class ValueInput(QWidget): class StepInspector(QWidget): """Edit the step selected in a ``SequenceDocument``.""" - def __init__(self, document: SequenceDocument, parent: QWidget | None = None, client=None): + def __init__( + self, + document: SequenceDocument, + backend: SequencerBackend, + parent: QWidget | None = None, + client=None, + ): super().__init__(parent) self.document = document + self.backend = backend + self.problems: list[Problem] = [] self._node_id = "" self._loading = False self._commit_timer = QTimer(self, singleShot=True, interval=250) @@ -206,38 +236,51 @@ class StepInspector(QWidget): layout.addLayout(title_row) self.subtitle = hint("Select a step to change it.", self) layout.addWidget(self.subtitle) + self.problem = problem_label(self) + layout.addWidget(self.problem) self.pages = QStackedWidget(self) layout.addWidget(self.pages, 1) - self.pages.addWidget(QWidget(self)) # empty + self.pages.addWidget(QWidget(self)) # nothing selected # move / set self.move_page = QWidget(self) form = QFormLayout(self.move_page) - self.device = DeviceComboBox( - self.move_page, client=client, device_filter=["Positioner"], autocomplete=True - ) - self.device.setEditable(True) + self.device = device_picker(self.move_page, client, settable=True) self.move_value = ValueInput(self.move_page) + self.move_info = hint("", self.move_page) self.move_wait = QCheckBox("Wait until it has arrived", self.move_page) form.addRow("Motor", self.device) form.addRow("Go to", self.move_value) + form.addRow("", self.move_info) form.addRow("", self.move_wait) self.pages.addWidget(self.move_page) # scan - self.scan_page = QScrollArea(self) - self.scan_page.setWidgetResizable(True) - self.scan_page.setFrameShape(QScrollArea.Shape.NoFrame) - self.scan_control = make_scan_form(self.scan_page, client) - self.scan_page.setWidget(self.scan_control) + self.scan_page = QWidget(self) + scan_layout = QVBoxLayout(self.scan_page) + scan_layout.setContentsMargins(0, 0, 0, 0) + picker_row = QFormLayout() + self.scan_picker = ScanPicker(backend.scan_entries(), self.scan_page) + picker_row.addRow("Scan", self.scan_picker) + scan_layout.addLayout(picker_row) + self.scan_scroll = QScrollArea(self.scan_page) + self.scan_scroll.setWidgetResizable(True) + self.scan_scroll.setFrameShape(QScrollArea.Shape.NoFrame) + self.scan_control = make_scan_form(self.scan_scroll, client) + self.scan_scroll.setWidget(self.scan_control) + scan_layout.addWidget(self.scan_scroll, 1) + self.scan_empty = hint("Choose the scan to run at this step.", self.scan_page) + scan_layout.addWidget(self.scan_empty) + scan_layout.addStretch(0) self.pages.addWidget(self.scan_page) # repeat self.repeat_page = QWidget(self) form = QFormLayout(self.repeat_page) self.repeat_count = QSpinBox(self.repeat_page, minimum=1, maximum=100000) - form.addRow("How many times", self.repeat_count) + self.repeat_count.setSuffix(" times") + form.addRow("Repeat", self.repeat_count) form.addRow(hint("Everything inside the loop runs this many times.", self.repeat_page)) self.pages.addWidget(self.repeat_page) @@ -245,18 +288,18 @@ class StepInspector(QWidget): self.each_page = QWidget(self) form = QFormLayout(self.each_page) self.each_name = QLineEdit(self.each_page) - self.each_values = QLineEdit(self.each_page) - self.each_values.setPlaceholderText("e.g. 1, -1, 2.5") + self.each_values = ValuesEdit(self.each_page) fill = QPushButton("Fill evenly spaced…", self.each_page) fill.clicked.connect(self._fill_range) self.each_count = hint("", self.each_page) form.addRow("Name of the value", self.each_name) - form.addRow("Values (in order)", self.each_values) + form.addRow("Values, in order", self.each_values) form.addRow("", fill) - form.addRow(self.each_count) + form.addRow("", self.each_count) form.addRow( hint( - "Steps inside the loop can use this value, e.g. “Move samx to each position”.", + "Steps inside the loop run once for every value. A move inside the loop can go" + " to “each value from the loop”.", self.each_page, ) ) @@ -265,13 +308,12 @@ class StepInspector(QWidget): # while moving self.while_page = QWidget(self) form = QFormLayout(self.while_page) - self.while_device = DeviceComboBox( - self.while_page, client=client, device_filter=["Positioner"], autocomplete=True - ) - self.while_device.setEditable(True) - self.while_target = QDoubleSpinBox(self.while_page, decimals=4, minimum=-1e9, maximum=1e9) + self.while_device = device_picker(self.while_page, client) + self.while_target = NumberEdit(self.while_page, "position") + self.while_info = hint("", self.while_page) form.addRow("Motor", self.while_device) form.addRow("Moves to", self.while_target) + form.addRow("", self.while_info) form.addRow( hint( "The motor starts moving, and the steps inside keep repeating until it arrives.", @@ -282,21 +324,20 @@ class StepInspector(QWidget): # group / other self.info_page = QWidget(self) - QVBoxLayout(self.info_page).addWidget( - hint("A group just keeps steps together. Drag steps in or out.", self.info_page) - ) + self.info_text = hint("", self.info_page) + QVBoxLayout(self.info_page).addWidget(self.info_text) self.pages.addWidget(self.info_page) - for widget in (self.move_value.number, self.while_target): - widget.valueChanged.connect(self._schedule) + self.move_value.edited.connect(self._schedule) + self.while_target.edited.connect(self._schedule) self.repeat_count.valueChanged.connect(self._schedule) - self.move_value.source.currentIndexChanged.connect(self._schedule) self.move_wait.toggled.connect(self._schedule) - self.each_name.textChanged.connect(self._schedule) - self.each_values.textChanged.connect(self._schedule) + self.each_name.textEdited.connect(self._schedule) + self.each_values.textEdited.connect(self._schedule) for combo in (self.device, self.while_device): - combo.currentTextChanged.connect(self._schedule) - self.scan_control.scan_selected.connect(self._on_scan_selected) + combo.device_selected.connect(self._schedule) + combo.lineEdit().editingFinished.connect(self._schedule) + self.scan_picker.scan_chosen.connect(self._on_scan_chosen) document.selection_changed.connect(self.load) document.changed.connect(self._on_document_changed) @@ -305,7 +346,9 @@ class StepInspector(QWidget): @SafeSlot(str) def load(self, node_id: str) -> None: """Show the editor for a step.""" - self._commit_timer.stop() + if self._commit_timer.isActive(): + self._commit_timer.stop() + self._commit() self._node_id = node_id node = self.document.node(node_id) if node_id else None self._loading = True @@ -315,33 +358,53 @@ class StepInspector(QWidget): self.title.setText("Step details") self.subtitle.setText("Select a step to change it.") self.pages.setCurrentIndex(0) + self._show_problem() return kind = node["type"] self.icon.setPixmap(kind_icon(kind, 22).pixmap(22, 22)) title = model.KIND_TITLES.get(kind, kind) - if kind == "set" and "." not in node.get("device", ""): - title = "Start moving" if node.get("bind_status") else "Move" + if kind == "set" and node.get("bind_status"): + title = "Start moving" self.title.setText(title) - self.subtitle.setText(model.sentence(node, self.document.root, self._entries())) + self._update_subtitle(node) getattr(self, f"_load_{kind}", self._load_other)(node) + self._show_problem() finally: self._loading = False - def _entries(self) -> dict: - return getattr(self.scan_control, "available_scans", None) or {} + def _update_subtitle(self, node: dict) -> None: + self.subtitle.setText(model.sentence(node, self.document.root, self.backend.scan_entries())) + + def _motor_info(self, device: str) -> str: + if not device or self.backend.device(device) is None: + return "" + parts = [] + position = self.backend.position(device) + if position is not None: + parts.append(f"Now at {position:.4g}") + limits = self.backend.limits(device) + if limits is not None: + parts.append(f"limits {limits[0]:g} to {limits[1]:g}") + return " · ".join(parts) def _load_set(self, node: dict) -> None: self.device.setCurrentText(node.get("device", "")) self.move_value.set_choices(model.visible_variables(self.document.root, node["id"])) self.move_value.set_value(node.get("value")) + self.move_info.setText(self._motor_info(node.get("device", ""))) self.move_wait.setChecked(bool(node.get("wait", True))) - self.move_wait.setEnabled(not node.get("bind_status")) + self.move_wait.setVisible(not node.get("bind_status")) self.pages.setCurrentWidget(self.move_page) def _load_scan_call(self, node: dict) -> None: self.pages.setCurrentWidget(self.scan_page) - set_scan_form_values(self.scan_control, node) - self._hook_scan_inputs() + name = node.get("name", "") + self.scan_picker.set_scan(name) + self.scan_scroll.setVisible(bool(name)) + self.scan_empty.setVisible(not name) + if name: + show_scan_in_form(self.scan_control, node) + self._hook_scan_inputs() def _load_repeat(self, node: dict) -> None: self.repeat_count.setValue(int(node.get("count", 1))) @@ -351,30 +414,65 @@ class StepInspector(QWidget): self.each_name.setText(node.get("variable", "")) values = node.get("values") if isinstance(node.get("values"), list) else [] self.each_values.setText(values_text(values)) - self.each_count.setText(f"{len(values)} value{'s' if len(values) != 1 else ''}") + self._count_values(values) self.pages.setCurrentWidget(self.each_page) + def _count_values(self, values: list) -> None: + self.each_count.setText(f"{len(values)} value{'s' if len(values) != 1 else ''}") + def _load_while(self, node: dict) -> None: - status = model.while_status(node) - owner = model.status_owner(self.document.root, status) if status else None - if owner is not None: - self.while_device.setCurrentText(owner.get("device", "")) - value = owner.get("value") - self.while_target.setValue(float(value) if isinstance(value, (int, float)) else 0) + owner = self._while_owner(node) + device = owner.get("device", "") if owner else "" + self.while_device.setCurrentText(device) + self.while_target.set_value(owner.get("value") if owner else None) + self.while_info.setText(self._motor_info(device)) self.pages.setCurrentWidget(self.while_page) - def _load_other(self, _node: dict) -> None: + def _while_owner(self, node: dict) -> dict | None: + status = model.while_status(node) + return model.status_owner(self.document.root, status) if status else None + + def _load_other(self, node: dict) -> None: + if node.get("type") == "component_call": + text = "A saved block from another tool. It can be moved or deleted here." + else: + text = "A group keeps steps together. Drag steps in or out of it." + self.info_text.setText(text) self.pages.setCurrentWidget(self.info_page) + # --- problems -------------------------------------------------------------------------- + def set_problems(self, problems: list[Problem]) -> None: + """Show the problem of the selected step, if any.""" + self.problems = problems + self._show_problem() + + def _show_problem(self) -> None: + message = next((p.message for p in self.problems if p.node_id == self._node_id), "") + self.problem.setText(message) + self.problem.setVisible(bool(message and self._node_id)) + # --- committing ------------------------------------------------------------------------ def _schedule(self, *_args) -> None: if not self._loading and self._node_id: self._commit_timer.start() @SafeSlot(str) - def _on_scan_selected(self, _name: str) -> None: - self._hook_scan_inputs() - self._schedule() + def _on_scan_chosen(self, name: str) -> None: + node = self.document.node(self._node_id) + if node is None or node.get("type") != "scan_call" or name == node.get("name"): + return + self._loading = True + try: + self.scan_picker.set_scan(name) + self.scan_scroll.setVisible(True) + self.scan_empty.setVisible(False) + show_scan_in_form(self.scan_control, {"name": name}) + self._hook_scan_inputs() + finally: + self._loading = False + positional, arguments = scan_form_values(self.scan_control) + self.document.update(node["id"], name=name, **scan_arguments(node, positional, arguments)) + self._update_subtitle(node) def _hook_scan_inputs(self) -> None: for widget in self.scan_control.findChildren(QWidget): @@ -383,10 +481,7 @@ class StepInspector(QWidget): widget.setProperty("_sequencer_hooked", True) if isinstance(widget, (QSpinBox, QDoubleSpinBox)): widget.valueChanged.connect(self._schedule) - elif ( - isinstance(widget, QComboBox) - and widget is not self.scan_control.comboBox_scan_selection - ): + elif isinstance(widget, QComboBox): widget.currentTextChanged.connect(self._schedule) elif isinstance(widget, QCheckBox): widget.toggled.connect(self._schedule) @@ -400,56 +495,35 @@ class StepInspector(QWidget): return kind = node["type"] if kind == "set": + device = self.device.currentText().strip() self.document.update( node["id"], - device=self.device.currentText().strip() or node.get("device"), + device=device, value=self.move_value.value(), wait=True if node.get("bind_status") else self.move_wait.isChecked(), ) + self.move_info.setText(self._motor_info(device)) elif kind == "scan_call": - name, positional, arguments = scan_form_values(self.scan_control) - if not name: + if not node.get("name"): return - self.document.update( - node["id"], name=name, positional=list(positional), arguments=arguments - ) + positional, arguments = scan_form_values(self.scan_control) + self.document.update(node["id"], **scan_arguments(node, positional, arguments)) elif kind == "repeat": self.document.update(node["id"], count=self.repeat_count.value()) elif kind == "for_each": - values = parse_values(self.each_values.text()) - self.each_count.setText(f"{len(values)} value{'s' if len(values) != 1 else ''}") - new_name = self.each_name.text().strip() or node.get("variable") - self._rename_variable(node, node.get("variable"), new_name) + values = self.each_values.values() + self._count_values(values) + new_name = self.each_name.text().strip() + if new_name: + rename_variable(node, node.get("variable"), new_name) self.document.update(node["id"], variable=new_name, values=values) elif kind == "while": - status = model.while_status(node) - owner = model.status_owner(self.document.root, status) if status else None + owner = self._while_owner(node) if owner is not None: - target = self.while_target.value() - self.document.update( - owner["id"], - device=self.while_device.currentText().strip() or owner.get("device"), - value=int(target) if target.is_integer() else target, - ) - self.subtitle.setText(model.sentence(node, self.document.root, self._entries())) - - def _rename_variable(self, loop: dict, old: str, new: str) -> None: - if not old or old == new: - return - - def visit(value): - if model.is_ref(value) and value["ref"] == old: - return model.ref(new) - if isinstance(value, list): - return [visit(v) for v in value] - if isinstance(value, dict): - return {k: visit(v) for k, v in value.items()} - return value - - for node, _depth, _parent in model.walk(loop): - for key in ("value", "positional", "arguments", "values"): - if key in node: - node[key] = visit(node[key]) + device = self.while_device.currentText().strip() + self.document.update(owner["id"], device=device, value=self.while_target.value()) + self.while_info.setText(self._motor_info(device)) + self._update_subtitle(node) @SafeSlot() def _on_document_changed(self) -> None: @@ -461,7 +535,36 @@ class StepInspector(QWidget): dialog = RangeDialog(self) if dialog.exec(): self.each_values.setText(values_text(dialog.values())) + self._schedule() def cleanup(self) -> None: - """Stop the commit timer.""" - self._commit_timer.stop() + """Save a pending edit and stop the commit timer.""" + if self._commit_timer.isActive(): + self._commit_timer.stop() + self._commit() + + +def rename_variable(loop: dict, old: str | None, new: str) -> None: + """Rename a loop variable in every step inside the loop that uses it. + + Args: + loop (dict): The for-each node; its own ``variable`` is not changed. + old (str | None): Current name. + new (str): New name. + """ + if not old or old == new: + return + + def visit(value): + if model.is_ref(value) and value["ref"] == old: + return model.ref(new) + if isinstance(value, list): + return [visit(v) for v in value] + if isinstance(value, dict): + return {k: visit(v) for k, v in value.items()} + return value + + for node, _depth, _parent in model.walk(loop): + for key in ("value", "values", "positional", "arguments"): + if key in node: + node[key] = visit(node[key]) diff --git a/bec_widgets/widgets/control/sequencer/model.py b/bec_widgets/widgets/control/sequencer/model.py index a8356dc8..78258cf8 100644 --- a/bec_widgets/widgets/control/sequencer/model.py +++ b/bec_widgets/widgets/control/sequencer/model.py @@ -47,21 +47,6 @@ def empty_document(doc_id: str = "new_sequence") -> dict: } -def example_document() -> dict: - """Return the shared example: two rounds over two sample positions, one scan at each. - - Returns: - dict: Sequence document. - """ - doc = empty_document("alternating_positions") - loop = for_each_node("position", [1, -1], [move_node("samx", ref("position"))]) - loop["body"]["children"].append( - scan_node("line_scan", ["samy", -2, 2], {"steps": 20, "exp_time": 0.1, "relative": True}) - ) - doc["root"]["children"] = [repeat_node(2, [loop])] - return normalize(doc) - - # --------------------------------------------------------------------------------------------- # Node construction # --------------------------------------------------------------------------------------------- @@ -152,32 +137,42 @@ def while_moving_nodes(device: str, value: Any, children: list[dict] | None = No return [start, loop] -def new_step(kind: str, *, device: str = "samx", scan: str = "line_scan") -> list[dict]: - """Create default nodes for a step kind picked in an editor. +def new_step(kind: str, variable: str = "position") -> list[dict]: + """Create blank nodes for a step kind picked in an editor. + + Nothing is pre-filled: the motor, its position and the scan are chosen by the user, and + the checks point out what is still missing. Args: kind (str): One of the keys of ``KIND_TITLES`` (``while`` creates two nodes). - device (str): Default device for moves. - scan (str): Default scan name. + variable (str): Name for the value of a new "For each" loop. Returns: list[dict]: New nodes to insert. """ if kind == "set": - return [move_node(device, 0)] + return [move_node("", None)] if kind == "scan_call": - return [scan_node(scan)] + return [scan_node("")] if kind == "repeat": return [repeat_node(2)] if kind == "for_each": - return [for_each_node("position", [0, 1])] + return [for_each_node(variable, [])] if kind == "while": - return while_moving_nodes(device, 5) + return while_moving_nodes("", None) if kind == "sequence": return [group_node()] raise ValueError(f"Unknown step kind: {kind}") +def unused_variable(root: dict, base: str = "position") -> str: + """A loop variable name not used yet in the document, e.g. ``position2``.""" + used = {n.get("variable") for n, _d, _p in walk(root) if n.get("type") == "for_each"} + if base not in used: + return base + return next(f"{base}{i}" for i in itertools.count(2) if f"{base}{i}" not in used) + + # --------------------------------------------------------------------------------------------- # Tree access # --------------------------------------------------------------------------------------------- @@ -280,6 +275,15 @@ def visible_variables(root: dict, node_id: str) -> list[str]: return [n["variable"] for n in ancestors(root, node_id) if n.get("type") == "for_each"] +def loop_values(root: dict, node_id: str, variable: str) -> list | None: + """Values a loop variable takes at a node (from the innermost loop that defines it).""" + for loop in reversed(ancestors(root, node_id)): + if loop.get("type") == "for_each" and loop.get("variable") == variable: + values = loop.get("values") + return values if isinstance(values, list) else None + return None + + def status_owner(root: dict, status: str) -> dict | None: """Return the Set node that binds a status name.""" for node, _depth, _parent in walk(root): @@ -328,6 +332,8 @@ def scan_title(name: str) -> str: def format_value(value: Any) -> str: """Show a value the way a scientist would write it.""" + if value is None or value == "": + return "…" if is_ref(value): return f"each {value['ref']}" if isinstance(value, bool): @@ -365,9 +371,17 @@ def scan_details(node: dict, entries: dict | None = None) -> str: elif positional: parts.append(", ".join(format_value(v) for v in positional)) args = node.get("arguments") if isinstance(node.get("arguments"), dict) else {} + # leave out settings that are at the scan's own default, they only add noise + defaults = { + param.get("name"): param.get("default") + for param in info.get("signature") or [] + if isinstance(param, dict) and param.get("default") != "_empty" + } for key, value in args.items(): if key == "metadata" or value in (None, ""): continue + if key in defaults and not is_ref(value) and value == defaults[key]: + continue if key == "steps": parts.append(f"{format_value(value)} steps") elif key in ("exp_time", "interval") and isinstance(value, (int, float)): @@ -394,13 +408,15 @@ def sentence(node: dict, root: dict | None = None, entries: dict | None = None) """ kind = node.get("type") if kind == "set": - target = node.get("device", "?") + target = node.get("device") or "a motor" value = format_value(node.get("value")) verb = "Set" if "." in target else "Move" if node.get("wait", True): return f"{verb} {target} to {value}" return f"Start moving {target} to {value} (don't wait)" if kind == "scan_call": + if not node.get("name"): + return "Run a scan" details = scan_details(node, entries) return f"Run {scan_title(node.get('name', 'scan')).lower()}" + ( f": {details}" if details else "" @@ -414,7 +430,7 @@ def sentence(node: dict, root: dict | None = None, entries: dict | None = None) status = while_status(node) owner = status_owner(root, status) if (root and status) else None if owner is not None: - return f"While {owner.get('device')} is still moving" + return f"While {owner.get('device') or 'the motor'} is still moving" return "While the condition holds" if kind == "component_call": definition = node.get("definition", {}) diff --git a/bec_widgets/widgets/control/sequencer/recipe/recipe_editor.py b/bec_widgets/widgets/control/sequencer/recipe/recipe_editor.py index 7380a320..9f5ef0a8 100644 --- a/bec_widgets/widgets/control/sequencer/recipe/recipe_editor.py +++ b/bec_widgets/widgets/control/sequencer/recipe/recipe_editor.py @@ -8,12 +8,13 @@ from __future__ import annotations from bec_qthemes import material_icon from qtpy.QtCore import Qt -from qtpy.QtGui import QBrush, QColor +from qtpy.QtGui import QBrush, QColor, QFontMetrics, QKeySequence, QShortcut from qtpy.QtWidgets import ( QAbstractItemView, QHBoxLayout, QPushButton, QSplitter, + QStackedWidget, QToolButton, QTreeWidget, QTreeWidgetItem, @@ -26,7 +27,14 @@ from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.sequencer import model from bec_widgets.widgets.control.sequencer.editor_base import SequenceEditorBase from bec_widgets.widgets.control.sequencer.inspector import StepInspector -from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_color, kind_icon, theme +from bec_widgets.widgets.control.sequencer.style import ( + empty_state, + heading, + hint, + kind_color, + kind_icon, + theme, +) ID_ROLE = Qt.ItemDataRole.UserRole + 1 @@ -37,6 +45,7 @@ ADD_BUTTONS = [ ("for_each", "For each value"), ("while", "While moving"), ] +PROBLEM_WIDTH = 260 class RecipeTree(QTreeWidget): @@ -60,7 +69,8 @@ class RecipeTree(QTreeWidget): """Translate a drop into a document move.""" source = self.currentItem() if source is None: - return event.ignore() + event.ignore() + return node_id = source.data(0, ID_ROLE) target = self.itemAt(event.position().toPoint()) indicator = self.dropIndicatorPosition() @@ -77,8 +87,9 @@ class RecipeTree(QTreeWidget): else: siblings = model.children_of(target_parent) index = siblings.index(target_node) - if indicator == QAbstractItemView.DropIndicatorPosition.BelowItem or ( - indicator == QAbstractItemView.DropIndicatorPosition.OnItem + if indicator in ( + QAbstractItemView.DropIndicatorPosition.BelowItem, + QAbstractItemView.DropIndicatorPosition.OnItem, ): index += 1 source_node, source_parent = model.find(doc.root, node_id) @@ -123,9 +134,17 @@ class RecipeEditor(SequenceEditorBase): add_row.addStretch(1) left_layout.addLayout(add_row) + self.stack = QStackedWidget(left) + self.empty = empty_state( + "No steps yet", + "Add a move, a scan or a loop with the buttons above. Steps run from top to bottom.", + self.stack, + ) + self.stack.addWidget(self.empty) self.tree = RecipeTree(self) self.tree.itemSelectionChanged.connect(self._on_tree_selection) - left_layout.addWidget(self.tree, 1) + self.stack.addWidget(self.tree) + left_layout.addWidget(self.stack, 1) action_row = QHBoxLayout() action_row.setSpacing(2) @@ -148,14 +167,23 @@ class RecipeEditor(SequenceEditorBase): self.action_buttons[key] = btn action_row.addStretch(1) left_layout.addLayout(action_row) + for keys, key in ( + (QKeySequence.StandardKey.Delete, "delete"), + (QKeySequence.StandardKey.Undo, "undo"), + (QKeySequence("Ctrl+D"), "duplicate"), + ): + shortcut = QShortcut(QKeySequence(keys), self.tree) + shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) + shortcut.activated.connect(lambda k=key: self._action(k)) - self.inspector = StepInspector(self.document, splitter, client=self.client) + self.inspector = StepInspector(self.document, self.backend, splitter, client=self.client) self.inspector.setMinimumWidth(340) splitter.addWidget(left) splitter.addWidget(self.inspector) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 2) self.document.selection_changed.connect(self._select_in_tree) + self.document.selection_changed.connect(self._update_actions) self._style_tree() def _style_tree(self) -> None: @@ -214,13 +242,14 @@ class RecipeEditor(SequenceEditorBase): for index, child in enumerate(model.children_of(root), 1): add(child, None, f"{index}.") - self.tree.resizeColumnToContents(1) - self.tree.setColumnWidth(1, max(self.tree.columnWidth(1), 110)) + self.stack.setCurrentWidget(self.tree if model.children_of(root) else self.empty) + self.tree.setColumnWidth(1, PROBLEM_WIDTH) self.tree.header().setStretchLastSection(False) self.tree.header().setSectionResizeMode(0, self.tree.header().ResizeMode.Stretch) self.tree.blockSignals(False) self._select_in_tree(self.document.selected) self.show_problems(self.problems) + self._update_actions() @staticmethod def _side_text(node: dict) -> str: @@ -234,6 +263,9 @@ class RecipeEditor(SequenceEditorBase): return "until it arrives" if kind == "set": return "waits" if node.get("wait", True) else "keeps going" + if kind == "scan_call": + metadata = (node.get("arguments") or {}).get("metadata") or {} + return str(metadata.get("sample_name", "")) return "" def show_problems(self, problems) -> None: @@ -247,18 +279,35 @@ class RecipeEditor(SequenceEditorBase): if node_id in bad: item.setBackground(0, QBrush(QColor(red.red(), red.green(), red.blue(), 45))) item.setToolTip(0, bad[node_id]) - item.setText(1, "needs a fix") + item.setToolTip(1, bad[node_id]) + metrics = QFontMetrics(item.font(1)) + item.setText( + 1, + metrics.elidedText( + bad[node_id], Qt.TextElideMode.ElideRight, PROBLEM_WIDTH - 16 + ), + ) item.setForeground(1, QBrush(red)) elif node_id: + node = self.document.node(node_id) item.setBackground(0, QBrush()) item.setToolTip(0, "") + item.setToolTip(1, "") + if node is not None: + item.setText(1, self._side_text(node)) + item.setForeground(1, QBrush(kind_color(node["type"]))) # --- interaction ----------------------------------------------------------------------- def add_step(self, kind: str) -> None: - """Add a new step next to the selection.""" - devices = self.backend.device_names() - device = "samx" if "samx" in devices or not devices else devices[0] - self.document.add_next_to_selection(model.new_step(kind, device=device)) + """Add a new step next to the selection (inside it when a loop is selected).""" + self.document.add_next_to_selection(self.document.new_nodes(kind)) + + @SafeSlot() + @SafeSlot(str) + def _update_actions(self, *_args) -> None: + selected = bool(self.document.selected) + for key, button in self.action_buttons.items(): + button.setEnabled(self.document.can_undo() if key == "undo" else selected) @SafeSlot() def _on_tree_selection(self) -> None: @@ -299,9 +348,3 @@ class RecipeEditor(SequenceEditorBase): doc.wrap(node_id, "for_each") elif key == "delete": doc.delete(node_id) - - def screenshot_setup(self) -> None: - """Select the scan step (used for screenshots and the demo).""" - scans = [n for n, _d, _p in model.walk(self.document.root) if n["type"] == "scan_call"] - if scans: - self.document.select(scans[0]["id"]) diff --git a/bec_widgets/widgets/control/sequencer/run_bar.py b/bec_widgets/widgets/control/sequencer/run_bar.py index 1b027f70..5ea89f6c 100644 --- a/bec_widgets/widgets/control/sequencer/run_bar.py +++ b/bec_widgets/widgets/control/sequencer/run_bar.py @@ -3,32 +3,58 @@ from __future__ import annotations import json +import re import time from pathlib import Path +from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtGui import QFont from qtpy.QtWidgets import ( QDialog, + QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, + QMessageBox, QPlainTextEdit, QPushButton, + QSizePolicy, QTabWidget, QVBoxLayout, QWidget, ) +from bec_widgets.utils.colors import rgba from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.sequencer import model -from bec_widgets.widgets.control.sequencer.backend import Problem, SequencerBackend +from bec_widgets.widgets.control.sequencer.backend import ( + Problem, + SequencerBackend, + run_failure, + sequences_dir, +) +from bec_widgets.widgets.control.sequencer.checks import check from bec_widgets.widgets.control.sequencer.document import SequenceDocument from bec_widgets.widgets.control.sequencer.style import Chip, kind_color, kind_icon, theme +_MOVE_SCANS = ("mv", "umv") + + +def _mono() -> QFont: + font = QFont("Menlo") + font.setStyleHint(QFont.StyleHint.Monospace) + return font + + +def _clock(seconds: float) -> str: + seconds = int(seconds) + hours, rest = divmod(seconds, 3600) + return f"{hours}:{rest // 60:02d}:{rest % 60:02d}" if hours else f"{rest // 60}:{rest % 60:02d}" + class PreviewDialog(QDialog): """Show a sequence step by step, as the backend describes it, and as Python.""" @@ -36,48 +62,87 @@ class PreviewDialog(QDialog): def __init__(self, backend: SequencerBackend, doc: dict, parent: QWidget | None = None): super().__init__(parent) self.setWindowTitle("Preview sequence") - self.resize(720, 520) + self.resize(760, 540) layout = QVBoxLayout(self) tabs = QTabWidget(self) layout.addWidget(tabs) steps = QListWidget(tabs) - entries = backend.scan_entries() - for index, item in enumerate(model.timeline(doc["root"], entries), 1): - row = QListWidgetItem(kind_icon(item.kind, 16), f"{index:>3}. {item.text}") + items = model.timeline(doc["root"], backend.scan_entries(), limit=2000) + for index, item in enumerate(items, 1): + row = QListWidgetItem(kind_icon(item.kind, 16), f"{index:>4}. {item.text}") if item.context: - row.setToolTip(item.context) row.setText(f"{row.text()} ({item.context})") steps.addItem(row) + if len(items) >= 2000: + steps.addItem("… and more") tabs.addTab(steps, "Step by step") - mono = QFont("Menlo") - mono.setStyleHint(QFont.StyleHint.Monospace) for title, producer in (("In words", backend.describe), ("Python", backend.python)): text = QPlainTextEdit(tabs) text.setReadOnly(True) - text.setFont(mono) + text.setFont(_mono()) try: text.setPlainText(producer(doc)) except Exception as exc: # pylint: disable=broad-except text.setPlainText(f"Not available until the problems are fixed:\n{exc}") tabs.addTab(text, title) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + +class DetailsDialog(QDialog): + """The full error report of a failed run.""" + + def __init__(self, text: str, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Why the sequence failed") + self.resize(760, 460) + layout = QVBoxLayout(self) + view = QPlainTextEdit(self) + view.setReadOnly(True) + view.setFont(_mono()) + view.setPlainText(text) + layout.addWidget(view) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) class RunBar(QWidget): - """Summary, validation status and the Run/Stop buttons for one document.""" + """Summary, check result and the Run/Stop buttons for one document. + + Args: + backend (SequencerBackend): Checks and runs the sequence. + document (SequenceDocument): The sequence. + dispatcher: BEC dispatcher used to follow the scans while the sequence runs. + parent (QWidget | None): Parent widget. + """ problems_changed = Signal(list) + new_requested = Signal() + opened = Signal(dict) def __init__( - self, backend: SequencerBackend, document: SequenceDocument, parent: QWidget | None = None + self, + backend: SequencerBackend, + document: SequenceDocument, + dispatcher=None, + parent: QWidget | None = None, ): super().__init__(parent) self.backend = backend self.document = document + self.dispatcher = dispatcher + self.problems: list[Problem] = [] self._status = None self._started = 0.0 - self.problems: list[Problem] = [] + self._planned = model.Summary() + self._scans_done = 0 + self._scan_open = False + self._following = False + self._error_text = "" self.setObjectName("SequencerRunBar") self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) @@ -91,6 +156,7 @@ class RunBar(QWidget): self.state = Chip("", parent=self) self.message = QLabel(self) self.message.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.message.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) layout.addWidget(self.summary) layout.addWidget(self.state) layout.addWidget(self.message, 1) @@ -102,6 +168,9 @@ class RunBar(QWidget): layout.addWidget(btn) return btn + self.details_button = button("Details", "description", "Show the full error report") + self.details_button.setVisible(False) + self.new_button = button("New", "note_add", "Start a new, empty sequence") self.open_button = button("Open", "folder_open", "Open a saved sequence") self.save_button = button("Save", "save", "Save this sequence to a file") self.preview_button = button("Preview", "visibility", "See every step that will run") @@ -111,6 +180,8 @@ class RunBar(QWidget): self.stop_button.setProperty("variant", "danger") self.stop_button.setVisible(False) + self.details_button.clicked.connect(self.show_details) + self.new_button.clicked.connect(self.new) self.open_button.clicked.connect(self.open_file) self.save_button.clicked.connect(self.save_file) self.preview_button.clicked.connect(self.preview) @@ -123,99 +194,201 @@ class RunBar(QWidget): self._poll_timer.timeout.connect(self._poll) document.changed.connect(self._check_timer.start) self._apply_style() - self.check() def _apply_style(self) -> None: self.setStyleSheet( f"#SequencerRunBar {{ background: {theme('CARD_BG', '#ffffff').name()};" f" border-top: 1px solid {theme('BORDER', '#d0d0d0').name()}; }}" + # the theme keeps a disabled Run button green, which reads as "ready" + '#SequencerRunBar QPushButton[variant="success"]:disabled {' + f" background: transparent; color: {rgba(theme('FG', '#333333'), 110)};" + f" border: 1px solid {theme('BORDER', '#d0d0d0').name()}; }}" ) + @property + def running(self) -> bool: + """Whether a sequence from this bar is queued or running.""" + return self._status is not None + # --- checking -------------------------------------------------------------------------- @SafeSlot() def check(self) -> None: - """Count steps and validate with the backend.""" + """Count steps, find unfinished steps and validate with the backend.""" summary = model.summarize(self.document.root) self.summary.setText(summary.text()) - if self._status is not None: + if self.running: return - if not self.backend.available: - self.problems = [] + self.details_button.setVisible(False) + if not summary.scans and not summary.moves and not model.children_of(self.document.root): + self._set_problems([]) + self._set_state("Empty", theme("FG", "#666666")) + self._say("Add a step to get started.") + self.run_button.setEnabled(False) + return + problems = check(self.document.root, self.backend) + if not problems: + problems = self.backend.validate(self.document.doc) + self._set_problems(problems) + if problems: + count = len(problems) + self._set_state( + f"{count} step{'s' if count != 1 else ''} to finish", + theme("ACCENT_WARNING", "#eda200"), + ) + self._say(self._where(problems[0]) + problems[0].message) + elif not self.backend.available: self._set_state("Editing only", theme("ACCENT_WARNING", "#eda200")) - self.message.setText("Install bec with the scan sequencer (bec PR #1128) to run.") - self.run_button.setEnabled(False) - self.problems_changed.emit([]) - return - if not summary.scans and not summary.moves: - self.problems = [] - self._set_state("Empty", theme("BORDER", "#999999")) - self.message.setText("Add a step to get started.") - self.run_button.setEnabled(False) - self.problems_changed.emit([]) - return - self.problems = self.backend.validate(self.document.doc) - if summary.empty_loops and not self.problems: - self.problems = [Problem(summary.empty_loops[0], "This loop has no steps inside")] - if self.problems: - problem = self.problems[0] - self._set_state("Needs a fix", theme("ACCENT_EMERGENCY", "#e0534b")) - node = self.document.node(problem.node_id) if problem.node_id else None - where = f"{model.KIND_TITLES.get(node['type'], 'Step')}: " if node else "" - self.message.setText(where + problem.message) + self._say("This BEC version cannot run sequences yet.") else: self._set_state("Ready to run", theme("ACCENT_SUCCESS", "#59a869")) - self.message.setText("") - self.run_button.setEnabled(not self.problems) - self.problems_changed.emit(self.problems) + self._say("") + self.run_button.setEnabled(not problems and self.backend.available) + + def _where(self, problem: Problem) -> str: + node = self.document.node(problem.node_id) if problem.node_id else None + if node is None or node is self.document.root: + return "" + title = model.KIND_TITLES.get(node["type"], "Step") + if node["type"] == "set" and node.get("bind_status"): + title = "Start moving" + return f"{title}: " + + def _say(self, text: str) -> None: + self.message.setText(text) + self.message.setToolTip(text) + + def _set_problems(self, problems: list[Problem]) -> None: + self.problems = problems + self.problems_changed.emit(problems) def _set_state(self, text: str, color) -> None: self.state.setText(text) self.state.set_color(color) # --- files ----------------------------------------------------------------------------- - @SafeSlot() - def open_file(self) -> None: - """Load a sequence JSON file.""" - path, _ = QFileDialog.getOpenFileName(self, "Open sequence", "", "Sequence (*.json)") - if path: - self.document.set_document(json.loads(Path(path).read_text(encoding="utf-8"))) + def _folder(self) -> str: + folder = sequences_dir(self.backend.client) + if folder is None: + return "" + try: + folder.mkdir(parents=True, exist_ok=True) + except OSError: + return "" + return str(folder) @SafeSlot() + def new(self) -> None: + """Ask to start over with an empty sequence.""" + if model.children_of(self.document.root): + answer = QMessageBox.question( + self, + "New sequence", + "Start a new, empty sequence? Changes that are not saved are lost.", + ) + if answer != QMessageBox.StandardButton.Yes: + return + self.new_requested.emit() + + @SafeSlot(popup_error=True) + def open_file(self) -> None: + """Load a sequence file.""" + path, _ = QFileDialog.getOpenFileName( + self, "Open sequence", self._folder(), "Sequence (*.json)" + ) + if not path: + return + doc = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(doc, dict) or doc.get("type") != "sequence" or "root" not in doc: + raise ValueError(f"{Path(path).name} is not a sequence file.") + self.opened.emit(doc) + + @SafeSlot(popup_error=True) def save_file(self) -> None: - """Save the sequence as JSON.""" + """Save the sequence as JSON; the file name becomes the sequence name.""" name = f"{self.document.doc.get('id', 'sequence')}.json" - path, _ = QFileDialog.getSaveFileName(self, "Save sequence", name, "Sequence (*.json)") - if path: - Path(path).write_text(json.dumps(self.document.doc, indent=2), encoding="utf-8") + start = str(Path(self._folder()) / name) if self._folder() else name + path, _ = QFileDialog.getSaveFileName(self, "Save sequence", start, "Sequence (*.json)") + if not path: + return + path = Path(path).with_suffix(".json") + self.document.doc["id"] = re.sub(r"\W+", "_", path.stem).strip("_") or "sequence" + path.write_text(json.dumps(self.document.doc, indent=2), encoding="utf-8") + self._say(f"Saved as {path.name}") @SafeSlot() def preview(self) -> None: """Open the preview dialog.""" PreviewDialog(self.backend, self.document.doc, self).exec() + @SafeSlot() + def show_details(self) -> None: + """Show the full error of the last failed run.""" + DetailsDialog(self._error_text, self).exec() + # --- running --------------------------------------------------------------------------- @SafeSlot(popup_error=True) def run(self) -> None: """Queue the sequence.""" self.check() - if self.problems: + if self.problems or not self.backend.available: return doc = self.document.doc doc["revision"] = int(doc.get("revision", 1)) + 1 + self._planned = model.summarize(doc["root"]) + self._scans_done = 0 + self._scan_open = False self._status = self.backend.submit(doc) self._started = time.monotonic() + self._follow_scans(True) self.run_button.setVisible(False) self.stop_button.setVisible(True) + self.stop_button.setEnabled(True) + self.new_button.setEnabled(False) + self.open_button.setEnabled(False) self._set_state("Queued", kind_color("set")) - self.message.setText("Waiting for the procedure worker…") + self._say("Waiting for the sequence to start…") self._poll_timer.start() @SafeSlot(popup_error=True) def stop(self) -> None: - """Ask the worker to stop the sequence.""" + """Ask BEC to stop the sequence; the scan that is running is aborted.""" if self._status is not None and not self._status.done: self._status.cancel() - self.message.setText("Stopping… (a move that already started may finish first)") + self.stop_button.setEnabled(False) + self._set_state("Stopping", theme("ACCENT_WARNING", "#eda200")) + self._say("Stopping… a move that already started may still finish.") + + def _follow_scans(self, follow: bool) -> None: + if self.dispatcher is None or follow == self._following: + return + if follow: + self.dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status()) + else: + self.dispatcher.disconnect_slot(self._on_scan_status, MessageEndpoints.scan_status()) + self._following = follow + + @SafeSlot(dict, dict) + def _on_scan_status(self, content: dict, _metadata: dict) -> None: + if self._status is None: + return + name = (content.get("info") or {}).get("scan_name", "") + if name in _MOVE_SCANS: + return + status = content.get("status") + if status == "open": + self._scan_open = True + elif status == "closed" and self._scan_open: + self._scan_open = False + self._scans_done += 1 + + def _progress(self) -> str: + total = self._planned.scans + current = self._scans_done + (1 if self._scan_open else 0) + if not total and not self._planned.open_ended: + return "" + if self._planned.open_ended: + return f"scan {max(current, 1)}" + return f"scan {min(max(current, 1), total)} of {total}" @SafeSlot() def _poll(self) -> None: @@ -224,27 +397,52 @@ class RunBar(QWidget): self._poll_timer.stop() return state = status.state.value - elapsed = int(time.monotonic() - self._started) - if state in ("Running", "Scheduled", "Requested"): - label = {"Running": "Running", "Scheduled": "Queued", "Requested": "Queued"}[state] - self._set_state(label, kind_color("set")) - self.message.setText(f"{elapsed // 60}:{elapsed % 60:02d} elapsed") + elapsed = _clock(time.monotonic() - self._started) + if state in ("Requested", "Scheduled"): + self._set_state("Queued", kind_color("set")) + self._say(f"Waiting for the sequence to start… {elapsed}") return + if state == "Running": + if self.stop_button.isEnabled(): + self._set_state("Running", kind_color("set")) + progress = self._progress() + self._say(f"{progress.capitalize()} · {elapsed}" if progress else elapsed) + return + self._finish(status, state, elapsed) + + def _finish(self, status, state: str, elapsed: str) -> None: self._poll_timer.stop() + self._follow_scans(False) self._status = None self.run_button.setVisible(True) self.stop_button.setVisible(False) + self.new_button.setEnabled(True) + self.open_button.setEnabled(True) + scans = f"{self._scans_done} scan{'s' if self._scans_done != 1 else ''}" if state == "Success": self._set_state("Finished", theme("ACCENT_SUCCESS", "#59a869")) - self.message.setText(f"Done after {elapsed // 60}:{elapsed % 60:02d}") - else: - self._set_state( - "Stopped" if "Aborted" in str(status.error) else "Failed", - theme("ACCENT_EMERGENCY", "#e0534b"), - ) - self.message.setText(str(status.error or state).splitlines()[-1][:200]) + self._say(f"Done: {scans} in {elapsed}") + self.run_button.setEnabled(not self.problems) + return + error = str(status.error or "") + if "Aborted by user" in error: + self._set_state("Stopped", theme("ACCENT_WARNING", "#eda200")) + where = f"during {self._progress()}" if self._scan_open else f"after {scans}" + self._say(f"Stopped {where}, {elapsed}") + self.run_button.setEnabled(not self.problems) + return + failure = run_failure(error) if error else Problem("", f"The sequence was {state.lower()}") + self._error_text = error or failure.message + self._set_state("Failed", theme("ACCENT_EMERGENCY", "#e0534b")) + self._say(self._where(failure) + failure.message) + self.details_button.setVisible(bool(error)) + self.run_button.setEnabled(True) + if failure.node_id: + self._set_problems([Problem(failure.node_id, f"Failed here: {failure.message}")]) def cleanup(self) -> None: - """Stop timers.""" + """Stop timers and listeners.""" self._check_timer.stop() self._poll_timer.stop() + self._follow_scans(False) + self.backend.close() diff --git a/bec_widgets/widgets/control/sequencer/style.py b/bec_widgets/widgets/control/sequencer/style.py index 205d66d0..97aa9e27 100644 --- a/bec_widgets/widgets/control/sequencer/style.py +++ b/bec_widgets/widgets/control/sequencer/style.py @@ -5,7 +5,7 @@ from __future__ import annotations from bec_qthemes import material_icon from qtpy.QtCore import Qt from qtpy.QtGui import QColor -from qtpy.QtWidgets import QApplication, QFrame, QLabel, QWidget +from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_name, rgba @@ -65,10 +65,14 @@ def kind_icon(kind: str, size: int = 18, color: QColor | str | None = None): def theme(key: str, fallback: str) -> QColor: - """Theme color by key, e.g. ``CARD_BG`` or ``BORDER``.""" + """Theme color by key, e.g. ``CARD_BG`` or ``BORDER``. + + Returns a copy: the theme hands out shared colors, and changing one (e.g. its alpha) would + change it for every widget that uses it later. + """ app = QApplication.instance() if app is not None and hasattr(app, "theme"): - return app.theme.color(key, fallback) + return QColor(app.theme.color(key, fallback)) return QColor(fallback) @@ -82,8 +86,10 @@ def card_qss(selector: str, radius: int = 8) -> str: def chip_qss(color: QColor, selector: str = "QLabel") -> str: """Style sheet for a small tinted pill.""" + # bright accents (yellow, green) need darker text to stay readable on a light background + text = color if is_dark() else QColor(color).darker(150) return ( - f"{selector} {{ background: {rgba(color, 38)}; color: {color.name()};" + f"{selector} {{ background: {rgba(color, 38)}; color: {text.name()};" " border-radius: 9px; padding: 1px 8px; font-weight: 600; }" ) @@ -126,3 +132,47 @@ def hint(text: str, parent: QWidget | None = None) -> QLabel: label.setStyleSheet(f"color: {rgba(fg, 150)};") label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) return label + + +def problem_label(parent: QWidget | None = None) -> QLabel: + """Red text that explains what a step still needs; hidden while empty.""" + label = QLabel(parent) + label.setWordWrap(True) + label.setStyleSheet(f"color: {theme('ACCENT_EMERGENCY', '#e0534b').name()}; font-weight: 600;") + label.setVisible(False) + return label + + +def empty_state(title: str, text: str, parent: QWidget | None = None, icon: str = "playlist_add"): + """A centered message for an empty sequence: icon, title and a hint.""" + widget = QWidget(parent) + widget.setObjectName("SequencerEmptyState") + widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + widget.setStyleSheet( + f"#SequencerEmptyState {{ background: {theme('CARD_BG', '#ffffff').name()};" + f" border: 2px dashed {theme('BORDER', '#d0d0d0').name()}; border-radius: 10px; }}" + ) + layout = QVBoxLayout(widget) + layout.addStretch(1) + picture = QLabel(widget) + muted = theme("FG", "#333333") + muted.setAlpha(110) + picture.setPixmap( + material_icon(icon, size=(44, 44), color=muted.name(QColor.NameFormat.HexArgb)) + ) + picture.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(picture) + name = heading(title, widget, 14) + name.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(name) + explanation = hint(text, widget) + explanation.setAlignment(Qt.AlignmentFlag.AlignCenter) + explanation.setMaximumWidth(420) + # stretches instead of an alignment flag, so the wrapped text gets the height it needs + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(explanation, 4) + row.addStretch(1) + layout.addLayout(row) + layout.addStretch(1) + return widget diff --git a/bec_widgets/widgets/control/sequencer/table/table_editor.py b/bec_widgets/widgets/control/sequencer/table/table_editor.py index fbbc15ee..3a44ea96 100644 --- a/bec_widgets/widgets/control/sequencer/table/table_editor.py +++ b/bec_widgets/widgets/control/sequencer/table/table_editor.py @@ -16,17 +16,18 @@ from qtpy.QtCore import Qt from qtpy.QtGui import QBrush, QColor, QKeySequence from qtpy.QtWidgets import ( QAbstractItemView, + QApplication, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, - QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSpinBox, + QStackedWidget, QTableWidget, QTableWidgetItem, QToolButton, @@ -38,24 +39,20 @@ from bec_widgets.utils.colors import rgba from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.sequencer import model from bec_widgets.widgets.control.sequencer.editor_base import SequenceEditorBase +from bec_widgets.widgets.control.sequencer.fields import device_picker, parse_number from bec_widgets.widgets.control.sequencer.inspector import ScanSettingsDialog from bec_widgets.widgets.control.sequencer.style import ( - Chip, card_qss, + empty_state, heading, hint, kind_color, kind_icon, + problem_label, theme, ) -EXAMPLE_ROWS = [ - ("Sample A", [0.0, 0.0]), - ("Sample B", [2.5, 0.0]), - ("Sample C", [5.0, 0.0]), - ("Reference foil", [0.0, 3.0]), - ("Empty holder", [2.5, 3.0]), -] +FIRST_MOTOR_COLUMN = 2 class GridDialog(QDialog): @@ -63,7 +60,7 @@ class GridDialog(QDialog): def __init__(self, motors: list[str], parent: QWidget | None = None): super().__init__(parent) - self.setWindowTitle("Fill table with a grid") + self.setWindowTitle("Fill the table with a grid") self.motors = motors layout = QVBoxLayout(self) layout.addWidget(hint("One row is created for every combination of positions.", self)) @@ -72,13 +69,15 @@ class GridDialog(QDialog): grid.addWidget(QLabel(f"{text}", self), 0, column) self.inputs = [] for row, motor in enumerate(motors, 1): - start = QDoubleSpinBox(self, decimals=3, minimum=-1e6, maximum=1e6, value=0) - stop = QDoubleSpinBox(self, decimals=3, minimum=-1e6, maximum=1e6, value=5) - points = QSpinBox(self, minimum=1, maximum=1000, value=3) + start = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6) + stop = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6) + points = QSpinBox(self, minimum=1, maximum=1000, value=2) for column, widget in enumerate((QLabel(motor, self), start, stop, points)): grid.addWidget(widget, row, column) self.inputs.append((start, stop, points)) layout.addLayout(grid) + self.count = hint("", self) + layout.addWidget(self.count) self.replace = QComboBox(self) self.replace.addItems(["Replace the rows", "Add below the rows"]) layout.addWidget(self.replace) @@ -88,6 +87,13 @@ class GridDialog(QDialog): buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) + for _start, _stop, points in self.inputs: + points.valueChanged.connect(self._count) + self._count() + + def _count(self, *_args) -> None: + total = int(np.prod([points.value() for _a, _b, points in self.inputs])) + self.count.setText(f"{total} row{'s' if total != 1 else ''}") def rows(self) -> list[list[float]]: """All combinations, first motor changing slowest.""" @@ -98,20 +104,75 @@ class GridDialog(QDialog): return [list(combo) for combo in itertools.product(*axes)] -class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors +def _scan_settings(node: dict) -> tuple: + arguments = {k: v for k, v in (node.get("arguments") or {}).items() if k != "metadata"} + return node.get("name"), node.get("positional") or [], arguments + + +def table_state(doc: dict) -> dict | None: + """Read a sequence back as a table, if it has the shape the table creates. + + Args: + doc (dict): Sequence document. + + Returns: + dict | None: Table state for ``TableEditor.restore_state``, or None when the sequence + is not a list of "move, move, …, scan" rows with the same scan. + """ + steps = model.children_of(doc.get("root", {})) + repeats = 1 + if len(steps) == 1 and steps[0].get("type") == "repeat": + repeats = int(steps[0].get("count", 1)) + steps = model.children_of(steps[0]) + motors: list[str] = [] + rows = [] + scan = None + pending: dict = {} + for node in steps: + kind = node.get("type") + if kind == "set" and node.get("wait", True) and not node.get("bind_status"): + device = node.get("device") or "" + if not device or model.is_ref(node.get("value")) or device in pending: + return None + pending[device] = node.get("value") + if device not in motors: + motors.append(device) + elif kind == "scan_call": + if scan is None: + scan = node + elif _scan_settings(node) != _scan_settings(scan): + return None + name = ((node.get("arguments") or {}).get("metadata") or {}).get("sample_name", "") + rows.append((str(name), pending)) + pending = {} + else: + return None + if pending or scan is None: + return None + name, positional, arguments = _scan_settings(scan) + return { + "motors": motors, + "rows": [[name_, [values.get(m) for m in motors], True] for name_, values in rows], + "scan": model.scan_node(name, positional, arguments), + "repeats": repeats, + } + + +class TableEditor(SequenceEditorBase): """Spreadsheet of measurement points.""" + # pylint: disable=too-many-ancestors, attribute-defined-outside-init + CONCEPT_ID = "table" CONCEPT_TITLE = "Table" CONCEPT_TAGLINE = "A spreadsheet of points: one row per place to measure, paste from Excel." def build_editor(self, host: QWidget) -> None: - self.motors: list[str] = ["samx", "samy"] - self.scan = model.scan_node( - "line_scan", ["samz", -1, 1], {"steps": 10, "exp_time": 0.1, "relative": True} - ) + self.motors: list[str] = [] + self.scan = model.scan_node("") self.repeats = 1 - self._row_nodes: dict[str, int] = {} + self._cells: dict[str, tuple[int, int]] = {} + self._scan_ids: set[str] = set() self._building = False layout = QVBoxLayout(host) @@ -120,8 +181,9 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors layout.addWidget(heading("Measurement points", host)) layout.addWidget( hint( - "Each row is one place to measure. The motors go to the row's positions, then " - "the scan below runs. You can paste rows from Excel (Ctrl+V).", + "Each row is one place to measure: the motors go to the row's positions, then the" + " scan below runs. Leave a cell empty to keep that motor where it is. Paste rows" + " from Excel with Ctrl+V.", host, ) ) @@ -130,86 +192,114 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors card = QWidget(host) card.setObjectName("TableScanCard") card.setStyleSheet(card_qss("#TableScanCard")) - row = QHBoxLayout(card) - row.setContentsMargins(12, 10, 12, 10) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(12, 10, 12, 10) + row = QHBoxLayout() icon = QLabel(card) icon.setPixmap(kind_icon("scan_call", 22).pixmap(22, 22)) row.addWidget(icon) - row.addWidget(QLabel("At every point run", card)) + row.addWidget(QLabel("At every row run", card)) + self.scan_button = QPushButton(card) + self.scan_button.setIcon(material_icon("tune", size=(16, 16), convert_to_pixmap=False)) + self.scan_button.setToolTip("Choose the scan and its settings") + self.scan_button.clicked.connect(self._edit_scan) + row.addWidget(self.scan_button) self.scan_label = QLabel(card) row.addWidget(self.scan_label, 1) - change = QPushButton("Change scan…", card) - change.setIcon(material_icon("tune", size=(16, 16), convert_to_pixmap=False)) - change.clicked.connect(self._edit_scan) - row.addWidget(change) row.addSpacing(16) row.addWidget(QLabel("Measure the whole table", card)) self.repeat_spin = QSpinBox(card, minimum=1, maximum=1000) self.repeat_spin.setSuffix(" ×") self.repeat_spin.valueChanged.connect(self._on_repeats) row.addWidget(self.repeat_spin) + card_layout.addLayout(row) + self.scan_problem = problem_label(card) + card_layout.addWidget(self.scan_problem) layout.addWidget(card) - # toolbar + # motor columns and rows tools = QHBoxLayout() tools.setSpacing(6) - tools.addWidget(QLabel("Motors:", host)) + tools.addWidget(QLabel("Motor columns:", host)) self.motor_chips = QHBoxLayout() self.motor_chips.setSpacing(4) tools.addLayout(self.motor_chips) - self.add_motor = QComboBox(host) - self.add_motor.setPlaceholderText("+ add motor column") - self.add_motor.activated.connect(self._on_add_motor) - tools.addWidget(self.add_motor) + self.motor_picker = device_picker(host, self.client, "Add a motor column…") + self.motor_picker.setMinimumWidth(190) + self.motor_picker.activated.connect(self._on_motor_activated) + self.motor_picker.lineEdit().returnPressed.connect(self._on_motor_entered) + tools.addWidget(self.motor_picker) tools.addStretch(1) - for text, icon_name, slot in ( - ("Add row", "add", self._add_row), - ("Fill grid…", "grid_on", self._fill_grid), - ("Remove rows", "delete", self._remove_rows), + self.add_row_button = QPushButton("Add row", host) + self.add_row_button.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self.add_row_button.clicked.connect(self._add_row) + tools.addWidget(self.add_row_button) + self.grid_button = QPushButton("Fill grid…", host) + self.grid_button.setIcon(material_icon("grid_on", size=(16, 16), convert_to_pixmap=False)) + self.grid_button.setToolTip("Create a row for every combination of motor positions") + self.grid_button.clicked.connect(self._fill_grid) + tools.addWidget(self.grid_button) + self.row_tools: list[QToolButton] = [] + for icon_name, tip, slot in ( + ("arrow_upward", "Move the selected rows up", lambda: self._shift_rows(-1)), + ("arrow_downward", "Move the selected rows down", lambda: self._shift_rows(1)), + ("delete", "Remove the selected rows", self._remove_rows), ): - btn = QPushButton(text, host) - btn.setIcon(material_icon(icon_name, size=(16, 16), convert_to_pixmap=False)) + btn = QToolButton(host) + btn.setIcon(material_icon(icon_name, size=(18, 18), convert_to_pixmap=False)) + btn.setToolTip(tip) + btn.setAutoRaise(True) btn.clicked.connect(slot) tools.addWidget(btn) + self.row_tools.append(btn) layout.addLayout(tools) + self.stack = QStackedWidget(host) + self.empty = empty_state( + "No measurement points yet", + "Add a motor column, then add rows, fill a grid or paste rows from Excel. When the" + " first pasted line holds motor names, they become the columns.", + self.stack, + icon="table_rows", + ) + self.empty.setFocusPolicy(Qt.FocusPolicy.ClickFocus) + self.stack.addWidget(self.empty) self.table = QTableWidget(host) self.table.setAlternatingRowColors(True) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) self.table.verticalHeader().setDefaultSectionSize(30) self.table.itemChanged.connect(self._on_item_changed) - self.table.installEventFilter(self) + self.table.itemSelectionChanged.connect(self._update_buttons) self.table.setStyleSheet( f"QTableWidget {{ border: 1px solid {theme('BORDER', '#d0d0d0').name()};" " border-radius: 8px; gridline-color: transparent; }" ) - layout.addWidget(self.table, 1) + self.stack.addWidget(self.table) + self.empty.installEventFilter(self) + self.table.installEventFilter(self) + layout.addWidget(self.stack, 1) self.footer = hint("", host) layout.addWidget(self.footer) - self._rebuild_table(EXAMPLE_ROWS) + self._rebuild_table([]) # --- table <-> data -------------------------------------------------------------------- - def _columns(self) -> list[str]: - return ["Measure", "Sample name", *self.motors] - - def _rebuild_table(self, rows: list[tuple[str, list]]) -> None: + def _rebuild_table(self, rows: list[tuple]) -> None: + """Show rows of ``(name, positions)`` or ``(name, positions, measure)``.""" self._building = True self.table.clear() - self.table.setColumnCount(len(self._columns())) - self.table.setHorizontalHeaderLabels( - ["", "Sample name", *[f"{m} position" for m in self.motors]] - ) + self.table.setColumnCount(FIRST_MOTOR_COLUMN + len(self.motors)) + self.table.setHorizontalHeaderLabels(["", "Sample name", *self.motors]) self.table.setRowCount(0) - for name, positions in rows: - self._append_row(name, positions) + for row in rows: + self._append_row(*row) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setStretchLastSection(False) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - for column in range(2, self.table.columnCount()): + for column in range(FIRST_MOTOR_COLUMN, self.table.columnCount()): header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive) - self.table.setColumnWidth(column, 170) + self.table.setColumnWidth(column, 150) self._building = False self._refresh_motor_chips() self._compile() @@ -223,10 +313,16 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors check.setToolTip("Untick to skip this row") self.table.setItem(row, 0, check) self.table.setItem(row, 1, QTableWidgetItem(name)) - for column, value in enumerate(positions, 2): - item = QTableWidgetItem(model.format_value(value)) + for column in range(len(self.motors)): + value = positions[column] if column < len(positions) else None + self._set_cell(row, FIRST_MOTOR_COLUMN + column, value) + + def _set_cell(self, row: int, column: int, value) -> None: + text = "" if value is None else model.format_value(value) + item = QTableWidgetItem(str(value).strip() if isinstance(value, str) else text) + if column >= FIRST_MOTOR_COLUMN: item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self.table.setItem(row, column, item) + self.table.setItem(row, column, item) def _rows(self) -> list[tuple[bool, str, list]]: rows = [] @@ -234,17 +330,13 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors check = self.table.item(row, 0) name_item = self.table.item(row, 1) positions = [] - for column in range(2, self.table.columnCount()): + for column in range(FIRST_MOTOR_COLUMN, self.table.columnCount()): item = self.table.item(row, column) - text = item.text().strip() if item else "" - try: - positions.append(float(text) if text else None) - except ValueError: - positions.append(text) + positions.append(parse_number(item.text()) if item else None) rows.append( ( check is None or check.checkState() == Qt.CheckState.Checked, - name_item.text() if name_item else "", + name_item.text().strip() if name_item else "", positions, ) ) @@ -253,67 +345,96 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors def _compile(self) -> None: """Turn the table into the sequence document.""" steps = [] - self._row_nodes = {} - for index, (measure, name, positions) in enumerate(self._rows()): + self._cells = {} + self._scan_ids = set() + rows = self._rows() + for index, (measure, name, positions) in enumerate(rows): if not measure: continue - for motor, value in zip(self.motors, positions): + for column, (motor, value) in enumerate(zip(self.motors, positions)): if value is None: continue - if isinstance(value, float) and value.is_integer(): - value = int(value) node = model.move_node(motor, value) - self._row_nodes[node["id"]] = index + self._cells[node["id"]] = (index, FIRST_MOTOR_COLUMN + column) steps.append(node) scan = model.clone(self.scan) arguments = dict(scan.get("arguments") or {}) if name: arguments["metadata"] = {"sample_name": name} scan["arguments"] = arguments - self._row_nodes[scan["id"]] = index + self._cells[scan["id"]] = (index, 1) + self._scan_ids.add(scan["id"]) steps.append(scan) - doc = model.empty_document("measurement_table") - if self.repeats > 1: - loop = model.repeat_node(self.repeats, steps) - doc["root"]["children"] = [loop] - else: - doc["root"]["children"] = steps + doc = model.empty_document(self.document.doc.get("id", "measurement_table")) + if self.repeats > 1 and steps: + steps = [model.repeat_node(self.repeats, steps)] + doc["root"]["children"] = steps self.document.set_document(doc) - entries = self.scan_entries() - self.scan_label.setText(model.sentence(self.scan, None, entries)[4:].capitalize()) - measured = sum(1 for r in self._rows() if r[0]) + self._update_labels(rows) + + def _update_labels(self, rows: list) -> None: + if self.scan.get("name"): + self.scan_button.setText(model.scan_title(self.scan["name"])) + self.scan_label.setText(model.scan_details(self.scan, self.scan_entries())) + else: + self.scan_button.setText("Choose a scan…") + self.scan_label.setText("") + measured = sum(1 for r in rows if r[0]) self.footer.setText( - f"{self.table.rowCount()} rows, {measured} measured" + f"{len(rows)} row{'s' if len(rows) != 1 else ''}, {measured} measured" + (f", whole table {self.repeats}×" if self.repeats > 1 else "") + ". Rows are measured from top to bottom." ) + self.stack.setCurrentWidget(self.table if self.motors or rows else self.empty) + self._update_buttons() + + @SafeSlot() + def _update_buttons(self) -> None: + self.grid_button.setEnabled(bool(self.motors)) + selected = bool(self.table.selectedIndexes()) + for button in self.row_tools: + button.setEnabled(selected) def _refresh_motor_chips(self) -> None: while self.motor_chips.count(): item = self.motor_chips.takeAt(0) if item.widget(): + item.widget().hide() # gone at once, deleted when Qt gets to it item.widget().deleteLater() + color = kind_color("set") for motor in self.motors: chip = QToolButton(self) chip.setText(f"{motor} ✕") chip.setToolTip(f"Remove the {motor} column") - color = kind_color("set") chip.setStyleSheet( f"QToolButton {{ background: {rgba(color, 38)}; color: {color.name()};" " border: none; border-radius: 10px; padding: 3px 10px; font-weight: 600; }" ) chip.clicked.connect(lambda _=False, m=motor: self._remove_motor(m)) self.motor_chips.addWidget(chip) - self.add_motor.clear() - for name in self.backend.device_names(): - if name not in self.motors: - self.add_motor.addItem(name) - self.add_motor.setCurrentIndex(-1) # --- interaction ----------------------------------------------------------------------- def refresh(self) -> None: """The table owns the data; the document is derived from it.""" + def reset(self) -> None: + self.motors = [] + self.scan = model.scan_node("") + self.repeats = 1 + self.repeat_spin.blockSignals(True) + self.repeat_spin.setValue(1) + self.repeat_spin.blockSignals(False) + self._rebuild_table([]) + + def open_document(self, doc: dict) -> None: + """Show an opened sequence as a table, or hand it to the Recipe editor.""" + state = table_state(doc) + if state is None: + self.open_in_recipe.emit(doc) + return + self.document.doc["id"] = doc.get("id", "measurement_table") + self.restore_state(state) + @SafeSlot(QTableWidgetItem) def _on_item_changed(self, _item) -> None: if not self._building: @@ -325,127 +446,174 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors self._compile() @SafeSlot(int) - def _on_add_motor(self, index: int) -> None: - name = self.add_motor.itemText(index) - if name and name not in self.motors: - rows = [(n, p + [None]) for _m, n, p in self._rows()] - self.motors.append(name) - self._rebuild_table(rows) + def _on_motor_activated(self, index: int) -> None: + self.add_motor(self.motor_picker.itemText(index)) + self.motor_picker.setCurrentText("") + + @SafeSlot() + def _on_motor_entered(self) -> None: + if self.motor_picker.is_valid_input: + self.add_motor(self.motor_picker.currentText()) + self.motor_picker.setCurrentText("") + + def add_motor(self, name: str) -> None: + """Add a motor column.""" + name = name.strip() + if not name or name in self.motors: + return + rows = [(n, p + [None], m) for m, n, p in self._rows()] + self.motors.append(name) + self._rebuild_table(rows) def _remove_motor(self, motor: str) -> None: index = self.motors.index(motor) - rows = [(n, p[:index] + p[index + 1 :]) for _m, n, p in self._rows()] + rows = [(n, p[:index] + p[index + 1 :], m) for m, n, p in self._rows()] self.motors.remove(motor) self._rebuild_table(rows) @SafeSlot() def _add_row(self) -> None: self._building = True - last = self._rows()[-1][2] if self.table.rowCount() else [0] * len(self.motors) - self._append_row(f"Point {self.table.rowCount() + 1}", last) + self._append_row(f"Point {self.table.rowCount() + 1}", []) self._building = False self._compile() - self.table.editItem(self.table.item(self.table.rowCount() - 1, 1)) + row = self.table.rowCount() - 1 + self.table.setCurrentCell(row, FIRST_MOTOR_COLUMN if self.motors else 1) + self.table.editItem(self.table.currentItem()) + + def _selected_rows(self) -> list[int]: + return sorted({i.row() for i in self.table.selectedIndexes()}) @SafeSlot() def _remove_rows(self) -> None: - rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True) self._building = True - for row in rows: + for row in reversed(self._selected_rows()): self.table.removeRow(row) self._building = False self._compile() + def _shift_rows(self, offset: int) -> None: + selected = self._selected_rows() + rows = self._rows() + if not selected or min(selected) + offset < 0 or max(selected) + offset >= len(rows): + return + order = list(range(len(rows))) + for row in selected if offset < 0 else reversed(selected): + order[row], order[row + offset] = order[row + offset], order[row] + self._rebuild_table([(rows[i][1], rows[i][2], rows[i][0]) for i in order]) + for row in selected: + self.table.selectRow(row + offset) + @SafeSlot() def _fill_grid(self) -> None: + if not self.motors: + return dialog = GridDialog(self.motors, self) if not dialog.exec(): return - keep = [] if dialog.replace.currentIndex() == 0 else [(n, p) for _m, n, p in self._rows()] + keep = [] if dialog.replace.currentIndex() == 0 else [(n, p, m) for m, n, p in self._rows()] new = [(f"Point {len(keep) + i + 1}", r) for i, r in enumerate(dialog.rows())] self._rebuild_table(keep + new) @SafeSlot() def _edit_scan(self) -> None: - dialog = ScanSettingsDialog(self.scan, self.client, self) + dialog = ScanSettingsDialog(self.scan, self.client, self.scan_entries(), self) if dialog.exec(): name, positional, arguments = dialog.values() - self.scan.update(name=name, positional=positional, arguments=arguments) - self._compile() + if name: + self.scan.update(name=name, positional=positional, arguments=arguments) + self._compile() def eventFilter(self, obj, event): # pylint: disable=invalid-name - """Paste tab-separated rows (from Excel) into the table.""" - if obj is self.table and event.type() == event.Type.KeyPress: + """Paste rows from Excel and clear cells with the Delete key.""" + if obj in (self.table, self.empty) and event.type() == event.Type.KeyPress: if event.matches(QKeySequence.StandardKey.Paste): - self._paste() + self.paste(QApplication.clipboard().text()) + return True + editing = self.table.state() == QAbstractItemView.State.EditingState + if event.matches(QKeySequence.StandardKey.Delete) and not editing: + self._building = True + for item in self.table.selectedItems(): + if item.column() >= 1: + item.setText("") + self._building = False + self._compile() return True return super().eventFilter(obj, event) - def _paste(self) -> None: - # pylint: disable=import-outside-toplevel - from qtpy.QtWidgets import QApplication + def paste(self, text: str) -> None: + """Paste tab-separated rows, e.g. copied from Excel. - text = QApplication.clipboard().text() - lines = [line.split("\t") for line in text.strip().splitlines() if line.strip()] + When the first line holds motor names after the sample name ("name, samx, samy"), + those motors become columns and every following line becomes a new row. + """ + lines = [line.split("\t") for line in text.strip("\n").splitlines() if line.strip()] if not lines: return - start_row = max(self.table.currentRow(), 0) - start_col = max(self.table.currentColumn(), 1) + known = set(self.backend.device_names()) + header = [cell.strip() for cell in lines[0][1:]] self._building = True - for r, cells in enumerate(lines): - row = start_row + r - if row >= self.table.rowCount(): - self._append_row("", [None] * len(self.motors)) - for c, value in enumerate(cells): - column = start_col + c - if column < self.table.columnCount(): - self.table.setItem(row, column, QTableWidgetItem(value.strip())) + if header and all(cell in known for cell in header): + self._building = False + for name in header: + self.add_motor(name) + self._building = True + columns = [FIRST_MOTOR_COLUMN + self.motors.index(name) for name in header] + first = self.table.rowCount() + for offset, cells in enumerate(lines[1:]): + name = cells[0].strip() if cells else "" + self._append_row(name or f"Point {first + offset + 1}", []) + for column, value in zip(columns, cells[1:]): + self._set_cell(first + offset, column, value) + else: + start_row = max(self.table.currentRow(), 0) + start_col = max(self.table.currentColumn(), 1) + for r, cells in enumerate(lines): + row = start_row + r + if row >= self.table.rowCount(): + self._append_row(f"Point {row + 1}", []) + for c, value in enumerate(cells): + if start_col + c < self.table.columnCount(): + self._set_cell(row, start_col + c, value) self._building = False self._compile() def show_problems(self, problems) -> None: - bad_rows = { - self._row_nodes[p.node_id]: p.message for p in problems if p.node_id in self._row_nodes - } red = theme("ACCENT_EMERGENCY", "#e0534b") + tint = QBrush(QColor(red.red(), red.green(), red.blue(), 50)) + bad: dict[tuple[int, int], str] = {} + general = "" + for problem in problems: + if problem.node_id in self._scan_ids or problem.node_id not in self._cells: + general = general or problem.message + else: + bad.setdefault(self._cells[problem.node_id], problem.message) + self.scan_problem.setText(general) + self.scan_problem.setVisible(bool(general)) self._building = True for row in range(self.table.rowCount()): for column in range(1, self.table.columnCount()): item = self.table.item(row, column) if item is None: continue - if row in bad_rows: - item.setBackground(QBrush(QColor(red.red(), red.green(), red.blue(), 45))) - item.setToolTip(bad_rows[row]) - else: - item.setBackground(QBrush()) - item.setToolTip("") + message = bad.get((row, column), "") + item.setBackground(tint if message else QBrush()) + item.setToolTip(message) self._building = False def save_state(self) -> dict: return { "motors": list(self.motors), - "rows": [(name, positions, measure) for measure, name, positions in self._rows()], + "rows": [[name, positions, measure] for measure, name, positions in self._rows()], "scan": copy.deepcopy(self.scan), "repeats": self.repeats, } def restore_state(self, state: dict) -> None: - self.motors = list(state["motors"]) - self.scan = state["scan"] - self.repeats = state["repeats"] + self.motors = list(state.get("motors", [])) + self.scan = state.get("scan") or model.scan_node("") + self.repeats = int(state.get("repeats", 1)) self.repeat_spin.blockSignals(True) self.repeat_spin.setValue(self.repeats) self.repeat_spin.blockSignals(False) - self._rebuild_table([(name, positions) for name, positions, _m in state["rows"]]) - self._building = True - for row, (_name, _positions, measure) in enumerate(state["rows"]): - self.table.item(row, 0).setCheckState( - Qt.CheckState.Checked if measure else Qt.CheckState.Unchecked - ) - self._building = False - self._compile() - - def screenshot_setup(self) -> None: - """Select a cell for screenshots.""" - self.table.setCurrentCell(1, 2) + self._rebuild_table([tuple(row) for row in state.get("rows", [])]) diff --git a/tests/unit_tests/test_sequencer_concepts.py b/tests/unit_tests/test_sequencer_concepts.py index 868232a7..7191676c 100644 --- a/tests/unit_tests/test_sequencer_concepts.py +++ b/tests/unit_tests/test_sequencer_concepts.py @@ -1,15 +1,32 @@ # pylint: disable=missing-function-docstring, redefined-outer-name, protected-access +import json +from copy import deepcopy + import pytest from bec_lib.endpoints import MessageEndpoints from bec_lib.messages import AvailableResourceMessage +from bec_lib.procedures.helper import ProcedureState +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QToolButton from bec_widgets.applications.views.sequencer_view.concepts import CONCEPTS from bec_widgets.applications.views.sequencer_view.sequencer_view import SequencerView -from bec_widgets.tests.client_mocks import mocked_client # noqa: F401 from bec_widgets.tests.utils import create_widget -from bec_widgets.widgets.control.sequencer import model -from bec_widgets.widgets.control.sequencer.backend import BACKEND_AVAILABLE, friendly_message +from bec_widgets.widgets.control.sequencer import backend as backend_module +from bec_widgets.widgets.control.sequencer import editor_base, model, style +from bec_widgets.widgets.control.sequencer.backend import ( + BACKEND_AVAILABLE, + SequencerBackend, + friendly_message, + run_failure, + sequences_dir, +) +from bec_widgets.widgets.control.sequencer.checks import check from bec_widgets.widgets.control.sequencer.document import SequenceDocument +from bec_widgets.widgets.control.sequencer.fields import device_picker, parse_number, parse_values +from bec_widgets.widgets.control.sequencer.table.table_editor import table_state + +from .test_scan_control import available_scans_message TIME_SCAN = { "class": "TimeScan", @@ -21,41 +38,49 @@ TIME_SCAN = { "arg_bundle_size": {"bundle": 0, "min": None, "max": None}, "doc": "Time scan", "signature": [ - { - "name": "points", - "kind": "POSITIONAL_OR_KEYWORD", - "default": "_empty", - "annotation": "int", - }, - { - "name": "interval", - "kind": "POSITIONAL_OR_KEYWORD", - "default": "_empty", - "annotation": "float", - }, + {"name": "points", "kind": "POSITIONAL_OR_KEYWORD", "default": 1, "annotation": "int"}, + {"name": "interval", "kind": "POSITIONAL_OR_KEYWORD", "default": 1, "annotation": "float"}, ], "gui_visibility": {}, } +HIDDEN = {**TIME_SCAN, "doc": "Move"} @pytest.fixture def client(mocked_client): + resource = deepcopy(available_scans_message.resource) + resource.update({"time_scan": TIME_SCAN, "umv": HIDDEN, "_internal_scan": TIME_SCAN}) mocked_client.connector.set_and_publish( - MessageEndpoints.available_scans(), - AvailableResourceMessage(resource={"time_scan": TIME_SCAN}), + MessageEndpoints.available_scans(), AvailableResourceMessage(resource=resource) ) yield mocked_client -def test_summary_multiplies_through_loops(): - doc = model.example_document() +@pytest.fixture +def backend(client): + return SequencerBackend(client) + + +def two_positions() -> dict: + """Twice: for samx at 1 and -1, move samx and run a time scan.""" + doc = model.empty_document("two_positions") + loop = model.for_each_node( + "position", [1, -1], [model.move_node("samx", model.ref("position"))] + ) + model.children_of(loop).append(model.scan_node("time_scan", [], {"points": 2, "interval": 1})) + doc["root"]["children"] = [model.repeat_node(2, [loop])] + return model.normalize(doc) + + +def root_with(*children) -> dict: + return {"id": "root", "type": "sequence", "children": list(children)} + + +# --- model --------------------------------------------------------------------------------- +def test_summary_and_timeline_unroll_loops(): + doc = two_positions() summary = model.summarize(doc["root"]) - assert (summary.scans, summary.moves) == (4, 4) assert summary.text() == "4 scans · 4 moves" - - -def test_timeline_substitutes_loop_values(): - doc = model.example_document() items = model.timeline(doc["root"]) assert [i.text for i in items if i.kind == "set"] == [ "Move samx to 1", @@ -66,19 +91,42 @@ def test_timeline_substitutes_loop_values(): assert items[0].context == "round 1/2 · position = 1" -def test_while_moving_sentence_names_the_motor(): - start, loop = model.while_moving_nodes("samx", 5, [model.scan_node("time_scan")]) - root = {"id": "root", "type": "sequence", "children": [start, loop]} - assert model.sentence(start) == "Start moving samx to 5 (don't wait)" - assert model.sentence(loop, root) == "While samx is still moving" - assert model.summarize(root).open_ended +def test_new_steps_are_blank_and_read_well(): + move, scan = model.new_step("set")[0], model.new_step("scan_call")[0] + assert (move["device"], move["value"], scan["name"]) == ("", None, "") + assert model.sentence(move) == "Move a motor to …" + assert model.sentence(scan) == "Run a scan" + start, loop = model.new_step("while") + assert model.sentence(loop, root_with(start, loop)) == "While the motor is still moving" +def test_scan_details_leave_out_default_settings(): + entries = {"time_scan": TIME_SCAN} + node = model.scan_node("time_scan", [], {"points": 5, "interval": 1}) + assert model.scan_details(node, entries) == "5 points" + assert model.scan_details(node) == "5 points · 1 s interval" + + +def test_unused_variable_names(): + root = root_with(model.for_each_node("position", [1])) + assert model.unused_variable(root) == "position2" + assert model.unused_variable(root, "energy") == "energy" + + +def test_parse_numbers_and_values(): + assert parse_number("") is None + assert parse_number("2") == 2 and isinstance(parse_number("2"), int) + assert parse_number("2.0") == 2.0 and isinstance(parse_number("2.0"), float) + assert parse_number("1e-3") == 0.001 + assert parse_number("abc") == "abc" + assert parse_values("1, -1; 2.5\n3") == [1, -1, 2.5, 3] + + +# --- document ------------------------------------------------------------------------------ def test_document_delete_while_also_removes_its_move(qtbot): doc = SequenceDocument() - start, loop = model.while_moving_nodes("samx", 5) - doc.add([start, loop]) - doc.delete(loop["id"]) + doc.add(model.while_moving_nodes("samx", 5)) + doc.delete(model.children_of(doc.root)[1]["id"]) assert model.children_of(doc.root) == [] @@ -97,64 +145,381 @@ def test_document_refuses_moving_a_loop_into_itself(qtbot): doc = SequenceDocument() outer = model.repeat_node(2, [model.repeat_node(2)]) doc.add([outer]) - inner = model.children_of(outer)[0] - doc.move(outer["id"], inner["id"], 0) + doc.move(outer["id"], model.children_of(outer)[0]["id"], 0) assert model.children_of(doc.root)[0]["id"] == outer["id"] +def test_new_move_inside_a_loop_goes_to_each_value(qtbot): + doc = SequenceDocument() + doc.add(doc.new_nodes("for_each")) + loop = model.children_of(doc.root)[0] + doc.select(loop["id"]) + doc.add_next_to_selection(doc.new_nodes("set")) + assert model.children_of(loop)[0]["value"] == {"ref": "position"} + assert doc.new_nodes("for_each")[0]["variable"] == "position2" + + +# --- checks -------------------------------------------------------------------------------- +def test_checks_find_every_unfinished_step(backend): + root = root_with( + model.move_node("", None), + model.move_node("samx", None), + model.move_node("nope", 1), + model.move_node("samx", "fast"), + model.scan_node(""), + model.scan_node("line_scan"), + model.scan_node("line_scan", ["", 0, 1]), + model.for_each_node("position", []), + model.repeat_node(2), + model.scan_node("time_scan", [], {"points": 1, "interval": 1}), + ) + messages = [p.message for p in check(root, backend)] + assert messages == [ + "Choose a motor", + "Enter a position", + "There is no device called “nope”", + "samx needs a number", + "Choose a scan", + "Add at least one motor to the scan", + "Choose a motor for the scan", + "Enter at least one value", + "This loop has no steps inside", + ] + + +def test_checks_use_the_motor_limits_for_every_loop_value(backend): + loop = model.for_each_node( + "position", [1, 12], [model.move_node("samx", model.ref("position"))] + ) + problems = check(root_with(loop), backend) + assert [p.message for p in problems] == ["12 is outside the limits of samx (-10 to 10)"] + assert problems[0].node_id == model.children_of(loop)[0]["id"] + assert check(root_with(model.move_node("aptrx", 1e6)), backend) == [] + + +def test_checks_pass_a_finished_sequence(backend): + assert check(two_positions()["root"], backend) == [] + + +# --- backend ------------------------------------------------------------------------------- def test_friendly_messages(): assert ( friendly_message("Undefined Reference: pos") == "“pos” is only known inside its “For each pos” loop" ) assert friendly_message("Unknown device or signal: foo") == "There is no device called “foo”" + assert ( + friendly_message("Non-settable device or signal: eiger") == "“eiger” cannot be moved or set" + ) + assert ( + friendly_message("Device '' specified for scan argument at position 0 was not found") + == "Choose a motor for the scan" + ) -@pytest.mark.parametrize("concept", list(CONCEPTS)) -def test_concepts_build_a_valid_document(qtbot, client, concept): +def test_run_failure_names_the_step_and_the_reason(): + error = ( + "Traceback (most recent call last):\n File ...\n" + "bec_lib.scan_sequencer.core.runtime.ExecutionError: Node root/move_1: An alarm.\n" + "\tAn error occurred during 'set' on device 'samx'.\n\n" + "LimitError: position=1000 not within limits (-50, 50)\n" + ) + failure = run_failure(error) + assert failure.node_id == "move_1" + assert failure.message == "LimitError: position=1000 not within limits (-50, 50)" + assert run_failure("").message == "The sequence failed" + + +def test_scan_entries_hide_moves_and_internal_scans(backend, client): + assert list(backend.scan_entries()) == ["grid_scan", "line_scan", "time_scan"] + client.connector.set_and_publish( + MessageEndpoints.available_scans(), AvailableResourceMessage(resource={}) + ) + assert "time_scan" in backend.scan_entries() # cached until reload + backend.reload() + assert backend.scan_entries() == {} + + +def test_backend_device_information(backend): + assert backend.is_motor("samx") and not backend.is_motor("eiger") + assert backend.limits("samx") == (-10.0, 10.0) + assert backend.limits("aptrx") is None + assert backend.device("missing") is None + assert backend.position("samx") == 2.0 + + +def test_sequences_dir_is_next_to_the_widget_settings(client): + assert sequences_dir(client).name == "sequences" + assert sequences_dir(object()) is None + + +# --- concepts ------------------------------------------------------------------------------ +@pytest.mark.parametrize("concept", [c for c in CONCEPTS if c != "guided"]) +def test_concepts_start_empty(qtbot, client, concept): editor = create_widget(qtbot, CONCEPTS[concept], client=client) - doc = editor.document.doc - assert doc["schema_version"] == 1 - assert model.summarize(doc["root"]).scans > 0 - if BACKEND_AVAILABLE: - from bec_lib.scan_sequencer import Schedule - - Schedule.model_validate(doc) + assert model.children_of(editor.document.root) == [] + assert editor.run_bar.state.text() == "Empty" + assert not editor.run_bar.run_button.isEnabled() -def test_table_compiles_one_scan_per_measured_row(qtbot, client): +def test_guided_starts_with_an_unfilled_sentence(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["guided"], client=client) + assert editor.run_bar.state.text().endswith("to finish") + assert not editor.run_bar.run_button.isEnabled() + nodes = [n for n, _d, _p in model.walk(editor.document.root)] + assert all(n.get("device", "") == "" for n in nodes if n["type"] == "set") + assert all(n.get("name") == "" for n in nodes if n["type"] == "scan_call") + + +@pytest.mark.parametrize("concept", ["recipe", "blocks", "flow"]) +def test_tree_concepts_mark_unfinished_steps_and_get_ready(qtbot, client, concept): + editor = create_widget(qtbot, CONCEPTS[concept], client=client) + editor.document.add(editor.document.new_nodes("set")) + editor.run_bar.check() + move = model.children_of(editor.document.root)[0] + assert editor.problem_for(move["id"]) == "Choose a motor" + assert editor.run_bar.state.text() == "1 step to finish" + editor.document.update(move["id"], device="samx", value=1) + editor.run_bar.check() + assert editor.problems == [] + assert editor.run_bar.state.text() in ("Ready to run", "Editing only") + + +def test_recipe_inspector_edits_the_selected_move(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["recipe"], client=client) + editor.add_step("set") + move = model.children_of(editor.document.root)[0] + inspector = editor.inspector + assert inspector.pages.currentWidget() is inspector.move_page + inspector.device.setCurrentText("samx") + inspector.move_value.number.setText("2.5") + inspector._commit() + assert (move["device"], move["value"]) == ("samx", 2.5) + assert "Now at 2" in inspector.move_info.text() and "-10 to 10" in inspector.move_info.text() + + +def test_theme_colors_are_copies(qtbot): + color = style.theme("FG", "#333333") + color.setAlpha(10) + assert style.theme("FG", "#333333").alpha() == 255 + + +def test_device_picker_is_not_red_while_empty(qtbot, client): + picker = device_picker(None, client) + qtbot.addWidget(picker) + picker.check_validity("") + assert picker.styleSheet() == "" + picker.setCurrentText("nodevice") + picker.check_validity("nodevice") + assert "red" in picker.styleSheet() + + +def test_recipe_inspector_picks_a_scan_with_its_defaults(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["recipe"], client=client) + editor.add_step("scan_call") + scan = model.children_of(editor.document.root)[0] + editor.inspector._on_scan_chosen("line_scan") + assert scan["name"] == "line_scan" + assert editor.inspector.scan_picker.scan() == "line_scan" + assert scan["positional"] == ["", 0.0, 0.0] + assert "metadata" not in scan["arguments"] + editor.run_bar.check() + assert editor.problem_for(scan["id"]) == "Choose a motor for the scan" + + +def test_inspector_keeps_metadata_of_a_scan(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["recipe"], client=client) + scan = model.scan_node("time_scan", [], {"points": 2, "interval": 1, "metadata": {"a": 1}}) + editor.document.add([scan]) + editor.inspector._commit() + assert scan["arguments"]["metadata"] == {"a": 1} + + +def test_recipe_tree_shows_the_problem_next_to_the_step(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["recipe"], client=client) + editor.add_step("set") + editor.run_bar.check() + item = editor.tree.topLevelItem(0) + assert item.text(1) == "Choose a motor" + + +def test_blocks_show_problems_without_rebuilding(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["blocks"], client=client) + editor.append("set") + editor.run_bar.check() + move = model.children_of(editor.document.root)[0] + block = editor._blocks[move["id"]] + assert block.problem == "Choose a motor" + editor.inline_update(move["id"], device="samx", value=1) + editor.run_bar.check() + assert editor._blocks[move["id"]] is block + assert block.problem == "" + + +def test_table_rows_become_moves_and_scans(qtbot, client): editor = create_widget(qtbot, CONCEPTS["table"], client=client) - rows = editor.table.rowCount() - assert model.summarize(editor.document.root).scans == rows - editor.table.item(0, 0).setCheckState(editor.table.item(0, 0).checkState().Unchecked) - assert model.summarize(editor.document.root).scans == rows - 1 + editor.add_motor("samx") + editor.add_motor("samy") + editor.paste("A\t1\t2\nB\t3\t\nC\t4\t5") + editor.scan.update(name="time_scan", positional=[], arguments={"points": 1, "interval": 1}) + editor._compile() + summary = model.summarize(editor.document.root) + assert (summary.scans, summary.moves) == (3, 5) + scans = [n for n, _d, _p in model.walk(editor.document.root) if n["type"] == "scan_call"] + assert scans[0]["arguments"]["metadata"] == {"sample_name": "A"} + editor.table.item(1, 0).setCheckState(Qt.CheckState.Unchecked) + editor.repeat_spin.setValue(2) + assert model.summarize(editor.document.root).scans == 4 + + +def test_table_motor_chips_follow_the_columns(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["table"], client=client) + editor.add_motor("samx") + editor.add_motor("samy") + editor._remove_motor("samx") + chips = [ + editor.motor_chips.itemAt(i).widget().text() for i in range(editor.motor_chips.count()) + ] + assert chips == ["samy ✕"] + # removed chips must disappear at once, not when Qt gets round to deleting them + shown = [b for b in editor.findChildren(QToolButton) if "✕" in b.text() and not b.isHidden()] + assert len(shown) == 1 + + +def test_table_paste_with_a_header_adds_the_motor_columns(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["table"], client=client) + editor.paste("name\tsamx\tsamy\nfoil\t1\t2\nempty\t3\t4\n") + assert editor.motors == ["samx", "samy"] + assert [r[1] for r in editor._rows()] == ["foil", "empty"] + + +def test_table_marks_the_cell_outside_the_limits(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["table"], client=client) + editor.add_motor("samx") + editor.paste("A\t50") + editor.run_bar.check() + item = editor.table.item(0, 2) + assert "outside the limits" in item.toolTip() + + +def test_table_reads_its_own_sequence_back(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["table"], client=client) + editor.add_motor("samx") + editor.paste("A\t1\nB\t2") + editor.scan.update(name="time_scan", positional=[], arguments={"points": 1, "interval": 1}) editor.repeat_spin.setValue(3) - assert model.summarize(editor.document.root).scans == 3 * (rows - 1) + state = table_state(json.loads(json.dumps(editor.document.doc))) + assert state["motors"] == ["samx"] + assert state["rows"] == [["A", [1], True], ["B", [2], True]] + assert state["repeats"] == 3 + assert table_state(two_positions()) is None def test_guided_goals_compile(qtbot, client): editor = create_widget(qtbot, CONCEPTS["guided"], client=client) - expected = {"positions": 4, "values": 8, "grid": 6, "repeat": 5} + scan = model.scan_node("time_scan", [], {"points": 1, "interval": 1}) + memory = dict( + motor="samx", + positions=[1, 2], + repeat=2, + setting="samz", + setting_values=[0.5, 1, 1.5], + slow="samy", + slow_values=[0, 1], + fast="samx", + fast_values=[0, 1, 2], + count=5, + target=3, + ) + expected = {"positions": 4, "values": 6, "grid": 6, "repeat": 5} for goal, scans in expected.items(): - editor._on_goal(goal) + editor.restore_state({"goal": goal, "scan": scan, "memory": memory}) assert model.summarize(editor.document.root).scans == scans, goal - editor._on_goal("during") + editor.run_bar.check() + assert editor.problems == [], goal + editor.restore_state({"goal": "during", "scan": scan, "memory": memory}) assert model.summarize(editor.document.root).open_ended + editor.run_bar.check() + assert editor.problems == [] -def test_view_switches_concepts_and_hands_guided_result_to_recipe(qtbot, client): +def test_guided_points_at_the_missing_field_and_keeps_typed_values(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["guided"], client=client) + editor.run_bar.check() + assert editor.problem.text() == "Enter at least one value" + editor.fields["positions"].setText("1, 2") + editor._compile() + editor._on_goal("during") + editor._on_goal("positions") + assert editor.fields["positions"].values() == [1, 2] + + +def test_flow_inserts_steps_at_a_plus(qtbot, client): + editor = create_widget(qtbot, CONCEPTS["flow"], client=client) + editor.document.add(editor.document.new_nodes("repeat")) + loop = model.children_of(editor.document.root)[0] + editor.document.add(editor.document.new_nodes("scan_call"), loop["id"], 0) + assert model.children_of(loop)[0]["type"] == "scan_call" + assert editor.timeline.count() == 2 + + +# --- view ---------------------------------------------------------------------------------- +def test_view_creates_editors_only_when_opened(qtbot, client): + view = create_widget(qtbot, SequencerView, client=client) + assert view.editors == {} + view.on_enter() + assert list(view.editors) == [next(iter(CONCEPTS))] + + +def test_view_hands_guided_and_foreign_files_to_recipe(qtbot, client): view = create_widget(qtbot, SequencerView, client=client, concept="guided") guided = view.editors["guided"] - guided._on_goal("repeat") + guided.restore_state( + {"goal": "repeat", "scan": model.scan_node("time_scan"), "memory": {"count": 5}} + ) guided.open_in_recipe.emit(guided.document.doc) assert view.stack.currentWidget() is view.editors["recipe"] assert model.summarize(view.editors["recipe"].document.root).scans == 5 + table = view.show_concept("table") + table.open_document(two_positions()) + assert view.stack.currentWidget() is view.editors["recipe"] + assert model.summarize(view.editors["recipe"].document.root).scans == 4 +def test_theme_change_rebuilds_editors_and_keeps_their_work(qtbot, client): + from bec_widgets.utils.colors import apply_theme + + view = create_widget(qtbot, SequencerView, client=client, concept="table") + table = view.editors["table"] + table.add_motor("samx") + table.paste("A\t1\nB\t2") + table.repeat_spin.setValue(2) + view.show_concept("recipe").document.set_document(two_positions()) + view.show_concept("table") + apply_theme("light") + apply_theme("dark") + qtbot.wait(50) + new_table = view.editors["table"] + assert new_table is not table + assert view.stack.currentWidget() is new_table + assert new_table.motors == ["samx"] and new_table.table.rowCount() == 2 + assert model.summarize(new_table.document.root).scans == 4 + assert model.summarize(view.editors["recipe"].document.root).scans == 4 + + +def test_drafts_keep_the_work_after_closing(qtbot, client, tmp_path, monkeypatch): + monkeypatch.setattr(editor_base, "sequences_dir", lambda _client: tmp_path) + first = create_widget(qtbot, CONCEPTS["recipe"], client=client, draft_name="recipe") + first.document.set_document(two_positions()) + first.save_draft() + assert (tmp_path / "drafts" / "recipe.json").exists() + second = create_widget(qtbot, CONCEPTS["recipe"], client=client, draft_name="recipe") + assert model.summarize(second.document.root).scans == 4 + + +# --- run bar ------------------------------------------------------------------------------- class FakeStatus: def __init__(self): - from bec_lib.procedures.helper import ProcedureState - self.state = ProcedureState.SCHEDULED self.error = None self.done = False @@ -164,53 +529,80 @@ class FakeStatus: self.cancelled = True -def test_run_bar_runs_and_reports_the_procedure_state(qtbot, client): - from bec_lib.procedures.helper import ProcedureState - - editor = create_widget(qtbot, CONCEPTS["guided"], client=client) - editor._on_goal("repeat") - bar = editor.run_bar +@pytest.fixture +def ready_editor(qtbot, client, monkeypatch): + monkeypatch.setattr(backend_module, "BACKEND_AVAILABLE", True) + editor = create_widget(qtbot, CONCEPTS["recipe"], client=client) + editor.document.set_document(two_positions()) status = FakeStatus() - bar.backend.submit = lambda doc: status - bar.backend.validate = lambda doc: [] + editor.backend.submit = lambda doc: status + editor.backend.validate = lambda doc: [] + editor.run_bar.check() + assert editor.run_bar.run_button.isEnabled() + return editor, status + + +def test_run_bar_follows_the_scans_until_finished(ready_editor): + editor, status = ready_editor + bar = editor.run_bar bar.run() assert bar.state.text() == "Queued" status.state = ProcedureState.RUNNING + bar._on_scan_status({"status": "open", "info": {"scan_name": "time_scan"}}, {}) bar._poll() assert bar.state.text() == "Running" - bar.stop() - assert status.cancelled + assert bar.message.text().startswith("Scan 1 of 4") + bar._on_scan_status({"status": "closed", "info": {"scan_name": "time_scan"}}, {}) + bar._on_scan_status({"status": "open", "info": {"scan_name": "umv"}}, {}) + bar._on_scan_status({"status": "open", "info": {"scan_name": "time_scan"}}, {}) + bar._poll() + assert bar.message.text().startswith("Scan 2 of 4") status.state = ProcedureState.SUCCESS bar._poll() assert bar.state.text() == "Finished" assert not bar._poll_timer.isActive() + assert bar.run_button.isVisible() or not bar.isVisible() -def test_view_creates_editors_only_when_opened(qtbot, client): - view = create_widget(qtbot, SequencerView, client=client) - assert view.editors == {} - view.on_enter() - assert list(view.editors) == [next(iter(CONCEPTS))] +def test_run_bar_stop(ready_editor): + editor, status = ready_editor + bar = editor.run_bar + bar.run() + status.state = ProcedureState.RUNNING + bar.stop() + assert status.cancelled + assert bar.state.text() == "Stopping" + status.state, status.error = ProcedureState.FAILED, "Aborted by user." + bar._poll() + assert bar.state.text() == "Stopped" -def test_theme_change_rebuilds_editors_and_keeps_their_work(qtbot, client): - from bec_widgets.utils.colors import apply_theme +def test_run_bar_marks_the_step_that_failed(ready_editor): + editor, status = ready_editor + bar = editor.run_bar + move = next(n for n, _d, _p in model.walk(editor.document.root) if n["type"] == "set") + bar.run() + status.state = ProcedureState.FAILED + status.error = f"ExecutionError: Node root/{move['id']}: An alarm.\nLimitError: too far" + bar._poll() + assert bar.state.text() == "Failed" + assert bar.message.text() == "Move: LimitError: too far" + assert editor.problem_for(move["id"]) == "Failed here: LimitError: too far" + assert not bar.details_button.isHidden() - view = create_widget(qtbot, SequencerView, client=client, concept="table") - table = view.editors["table"] - table.repeat_spin.setValue(2) - table._add_row() - rows = table.table.rowCount() - view.show_concept("recipe").document.delete( - model.children_of(view.editors["recipe"].document.root)[0]["id"] - ) - view.show_concept("table") - apply_theme("light") - apply_theme("dark") - qtbot.wait(50) - new_table = view.editors["table"] - assert new_table is not table - assert view.stack.currentWidget() is new_table - assert new_table.table.rowCount() == rows - assert model.summarize(new_table.document.root).scans == 2 * rows - assert model.children_of(view.editors["recipe"].document.root) == [] + +def test_run_bar_without_the_backend_only_edits(qtbot, client, monkeypatch): + monkeypatch.setattr(backend_module, "BACKEND_AVAILABLE", False) + editor = create_widget(qtbot, CONCEPTS["recipe"], client=client) + editor.document.set_document(two_positions()) + editor.run_bar.check() + assert editor.run_bar.state.text() == "Editing only" + assert not editor.run_bar.run_button.isEnabled() + + +@pytest.mark.skipif(not BACKEND_AVAILABLE, reason="needs the scan sequencer backend") +def test_backend_accepts_what_the_concepts_build(qtbot, client): + from bec_lib.scan_sequencer import Schedule + + for doc in (two_positions(),): + Schedule.model_validate(doc)