diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 8c112b1..a1054d2 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -41,6 +41,7 @@ explicit hint for executing it from the CLI. from __future__ import annotations import datetime +import uuid from typing import Any, Optional from bec_lib import bec_logger @@ -51,6 +52,7 @@ from qtpy.QtWidgets import ( QCheckBox, QComboBox, QDialog, + QDialogButtonBox, QDoubleSpinBox, QFormLayout, QFrame, @@ -60,6 +62,7 @@ from qtpy.QtWidgets import ( QInputDialog, QLabel, QLineEdit, + QListWidget, QMessageBox, QPushButton, QScrollArea, @@ -997,9 +1000,19 @@ class TomoQueueDialog(QDialog): vbox = QVBoxLayout(self) vbox.setSpacing(6) - self._table = QTableWidget(0, 8) + self._table = QTableWidget(0, 9) self._table.setHorizontalHeaderLabels( - ["#", "Label", "Status", "Type", "Projections", "Exp (s)", "Step (µm)", "Added at"] + [ + "#", + "Label", + "Status", + "Type", + "Projections", + "Exp (s)", + "Step (µm)", + "Details", + "Added at", + ] ) self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) @@ -1008,14 +1021,17 @@ class TomoQueueDialog(QDialog): btn_row = QHBoxLayout() btn_add = QPushButton("Add current params to queue") + btn_add_cmd = QPushButton("Add command…") btn_del = QPushButton("Delete selected") btn_clr = QPushButton("Clear all") btn_exe = QPushButton("Execute queue…") btn_add.clicked.connect(self.add_to_queue) + btn_add_cmd.clicked.connect(self.add_command_to_queue) btn_del.clicked.connect(self._delete_selected) btn_clr.clicked.connect(self._clear_queue) btn_exe.clicked.connect(self._show_execute_hint) btn_row.addWidget(btn_add) + btn_row.addWidget(btn_add_cmd) btn_row.addWidget(btn_del) btn_row.addWidget(btn_clr) btn_row.addStretch() @@ -1028,19 +1044,33 @@ class TomoQueueDialog(QDialog): jobs = self._load_queue() self._table.setRowCount(len(jobs)) for row, job in enumerate(jobs): - params = job.get("params", {}) status = job.get("status", "pending") color = STATUS_COLORS.get(status, "#888888") - cells = [ - str(row), - job.get("label", f"job_{row}"), - status, - str(params.get("tomo_type", "?")), - _format_projections(params), - _fmt_num(params.get("tomo_countingtime")), - _fmt_num(params.get("tomo_shellstep")), - job.get("added_at", ""), - ] + if _job_kind(job) == "command": + cells = [ + str(row), + job.get("label", f"job_{row}"), + status, + "CMD", + "", + "", + "", + _format_command_summary(job), + job.get("added_at", ""), + ] + else: + params = job.get("params", {}) + cells = [ + str(row), + job.get("label", f"job_{row}"), + status, + str(params.get("tomo_type", "?")), + _format_projections(params), + _fmt_num(params.get("tomo_countingtime")), + _fmt_num(params.get("tomo_shellstep")), + "", + job.get("added_at", ""), + ] for col, text in enumerate(cells): item = QTableWidgetItem(text) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) @@ -1060,6 +1090,8 @@ class TomoQueueDialog(QDialog): label = f"job_{len(jobs)}" jobs.append( { + "kind": "tomo", + "id": uuid.uuid4().hex, "label": label.strip(), "params": params, "status": "pending", @@ -1069,10 +1101,32 @@ class TomoQueueDialog(QDialog): self._save_queue(jobs) self._refresh() + def add_command_to_queue(self) -> None: + """Open the structured command-job builder and, on confirm, append + the resulting job -- in exactly the format the CLI writes, so CLI + and GUI stay interchangeable (see TOMO_QUEUE_COMMAND_JOBS_PLAN.md + section 6.5). + """ + dlg = CommandJobBuilderDialog(self._client, parent=self) + if dlg.exec() == QDialog.DialogCode.Accepted and dlg.job is not None: + jobs = self._load_queue() + jobs.append(dlg.job) + self._save_queue(jobs) + self._refresh() + def _delete_selected(self) -> None: rows = sorted({idx.row() for idx in self._table.selectedIndexes()}, reverse=True) if not rows: return + jobs = self._load_queue() + if any(row < len(jobs) and jobs[row].get("status") == "running" for row in rows): + QMessageBox.warning( + self, + "Cannot delete", + "A running job can't be deleted from here. Stop the current " + "measurement first (it becomes 'incomplete'), then delete it.", + ) + return reply = QMessageBox.question( self, "Delete jobs", @@ -1081,7 +1135,6 @@ class TomoQueueDialog(QDialog): ) if reply != QMessageBox.StandardButton.Yes: return - jobs = self._load_queue() for row in rows: if row < len(jobs): jobs.pop(row) @@ -1089,6 +1142,15 @@ class TomoQueueDialog(QDialog): self._refresh() def _clear_queue(self) -> None: + jobs = self._load_queue() + if any(job.get("status") == "running" for job in jobs): + QMessageBox.warning( + self, + "Cannot clear", + "A job is currently running. Stop it first (it becomes " + "'incomplete'), then clear the queue.", + ) + return reply = QMessageBox.question( self, "Clear queue", @@ -1124,6 +1186,249 @@ class TomoQueueDialog(QDialog): super().showEvent(event) +# ── CommandJobBuilderDialog ────────────────────────────────────────────────── + + +class CommandJobBuilderDialog(QDialog): + """Structured builder for a tomo-queue "command" job: an ordered list of + named actions, each with only the fields its own params schema declares + -- there is nothing free-text to type wrong (TOMO_QUEUE_COMMAND_JOBS_PLAN.md + section 0's rejected alternative was a code box precisely because + syntax-valid is a false safety signal; this builder is the safer, + friendlier alternative it settled on). + + Built entirely from the live ``tomo_queue_actions`` global var (published + by ``Flomni.__init__``, see ``flomni.py``'s ``_describe_tomo_queue_actions()``) + -- no hardcoded mirror of the action registry or device allow-list to + drift out of sync with flomni.py, the same problem QUEUE_PARAM_NAMES + already has. + + On accept, ``self.job`` holds a job dict in exactly the format the CLI's + ``tomo_queue_add_command()`` writes (``kind="command"``, a fresh id, the + assembled ``steps`` list) so CLI and GUI stay interchangeable. The caller + is responsible for appending it to the queue -- this dialog only builds + the dict, it never touches the queue global var itself. + """ + + def __init__(self, client, parent=None): + super().__init__(parent) + self._client = client + self.setWindowTitle("Add command job") + self.setMinimumSize(520, 480) + self.job: Optional[dict] = None + + registry = self._client.get_global_var("tomo_queue_actions") or {} + self._actions: dict = registry.get("actions", {}) + self._move_devices: dict = registry.get("move_devices", {}) + + self._steps: list[dict] = [] + self._step_field_widgets: dict[str, tuple[QWidget, dict]] = {} + self._idem_user_overridden = False + + self._build_ui() + + if not self._actions: + QMessageBox.warning( + self, + "No actions available", + "The tomo_queue_actions registry is empty or unavailable -- " + "is a Flomni CLI session running? Command jobs can't be " + "built without it.", + ) + + def _build_ui(self): + vbox = QVBoxLayout(self) + + vbox.addWidget(QLabel("Steps (run in order):")) + self._step_list = QListWidget() + vbox.addWidget(self._step_list) + + step_btn_row = QHBoxLayout() + btn_remove = QPushButton("Remove step") + btn_up = QPushButton("Move up") + btn_down = QPushButton("Move down") + btn_remove.clicked.connect(self._on_remove_step) + btn_up.clicked.connect(lambda: self._on_move_step(-1)) + btn_down.clicked.connect(lambda: self._on_move_step(1)) + step_btn_row.addWidget(btn_remove) + step_btn_row.addWidget(btn_up) + step_btn_row.addWidget(btn_down) + step_btn_row.addStretch() + vbox.addLayout(step_btn_row) + vbox.addWidget(_hline()) + + add_box = QGroupBox("Add a step") + add_vbox = QVBoxLayout(add_box) + action_row = QHBoxLayout() + action_row.addWidget(QLabel("Action:")) + self._action_combo = QComboBox() + for name, spec in self._actions.items(): + self._action_combo.addItem(name, name) + idx = self._action_combo.count() - 1 + self._action_combo.setItemData(idx, spec.get("help", ""), Qt.ItemDataRole.ToolTipRole) + self._action_combo.currentIndexChanged.connect(self._on_action_changed) + action_row.addWidget(self._action_combo, stretch=1) + add_vbox.addLayout(action_row) + + self._field_form = QFormLayout() + add_vbox.addLayout(self._field_form) + + btn_add_step = QPushButton("Add step") + btn_add_step.clicked.connect(self._on_add_step) + add_vbox.addWidget(btn_add_step) + vbox.addWidget(add_box) + + meta_form = QFormLayout() + self._label_edit = QLineEdit() + meta_form.addRow("Label:", self._label_edit) + self._idem_check = QCheckBox("Idempotent (safe to silently re-run after a crash)") + self._idem_check.stateChanged.connect(self._on_idem_toggled_by_user) + meta_form.addRow("", self._idem_check) + vbox.addLayout(meta_form) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self._on_accept) + buttons.rejected.connect(self.reject) + vbox.addWidget(buttons) + + if self._actions: + self._on_action_changed(0) + + # ── dynamic per-action fields ───────────────────────────────────────── + + def _on_action_changed(self, _index: int) -> None: + while self._field_form.rowCount(): + self._field_form.removeRow(0) + self._step_field_widgets = {} + + action_name = self._action_combo.currentData() + spec = self._actions.get(action_name, {}) + for param_name, field in spec.get("params", {}).items(): + widget = self._build_field_widget(field) + self._step_field_widgets[param_name] = (widget, field) + label = field.get("label", param_name) + unit = field.get("unit") + if unit: + label = f"{label} ({unit})" + self._field_form.addRow(f"{label}:", widget) + + def _build_field_widget(self, field: dict) -> QWidget: + field_type = field.get("type") + if field_type == "float": + w = QDoubleSpinBox() + w.setDecimals(4) + w.setRange(field.get("min", -1e9), field.get("max", 1e9)) + w.setValue(field.get("default", 0.0)) + return w + if field_type == "device_positions": + row = QWidget() + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + device_combo = QComboBox() + for name, meta in self._move_devices.items(): + text = f"{name} - {meta.get('label', name)}" + if meta.get("unit"): + text += f" ({meta['unit']})" + device_combo.addItem(text, name) + target_spin = QDoubleSpinBox() + target_spin.setDecimals(4) + target_spin.setRange(-1e6, 1e6) + layout.addWidget(device_combo) + layout.addWidget(target_spin) + row._device_combo = device_combo # stashed for _read_field_value + row._target_spin = target_spin + return row + # Unknown param type -- degrade gracefully rather than crash. The + # registry lets a new *action* appear with zero widget changes (see + # the class docstring), but a genuinely new *field* type still needs + # one; this is that seam. + return QLabel(f"(unsupported param type: {field_type!r})") + + def _read_field_value(self, widget: QWidget, field: dict) -> Any: + field_type = field.get("type") + if field_type == "float": + return widget.value() + if field_type == "device_positions": + device = widget._device_combo.currentData() + target = widget._target_spin.value() + return {device: target} if device else {} + return None + + # ── step list management ────────────────────────────────────────────── + + def _on_add_step(self) -> None: + action_name = self._action_combo.currentData() + if action_name is None: + return + kwargs = {} + for param_name, (widget, field) in self._step_field_widgets.items(): + value = self._read_field_value(widget, field) + if field.get("type") == "device_positions" and not value: + QMessageBox.warning(self, "Missing device", "Select a device to move.") + return + kwargs[param_name] = value + + self._steps.append({"action": action_name, "kwargs": kwargs}) + summary = f"{action_name}{kwargs}" if kwargs else action_name + self._step_list.addItem(summary) + self._recompute_idempotent_default() + + def _on_remove_step(self) -> None: + row = self._step_list.currentRow() + if row < 0: + return + self._step_list.takeItem(row) + del self._steps[row] + self._recompute_idempotent_default() + + def _on_move_step(self, direction: int) -> None: + row = self._step_list.currentRow() + new_row = row + direction + if row < 0 or not (0 <= new_row < len(self._steps)): + return + self._steps[row], self._steps[new_row] = self._steps[new_row], self._steps[row] + item = self._step_list.takeItem(row) + self._step_list.insertItem(new_row, item) + self._step_list.setCurrentRow(new_row) + + def _recompute_idempotent_default(self) -> None: + """Idempotent checkbox defaults to "every step idempotent" (ANDed, + mirroring tomo_queue_add_command()'s own default), until the + operator explicitly overrides it -- then their choice sticks. + """ + if self._idem_user_overridden or not self._steps: + return + all_idem = all( + self._actions.get(step["action"], {}).get("idempotent", False) for step in self._steps + ) + self._idem_check.blockSignals(True) + self._idem_check.setChecked(all_idem) + self._idem_check.blockSignals(False) + + def _on_idem_toggled_by_user(self, _state: int) -> None: + self._idem_user_overridden = True + + # ── accept ───────────────────────────────────────────────────────────── + + def _on_accept(self) -> None: + if not self._steps: + QMessageBox.warning(self, "No steps", "Add at least one step before confirming.") + return + label = self._label_edit.text().strip() or f"cmd_{uuid.uuid4().hex[:6]}" + self.job = { + "kind": "command", + "id": uuid.uuid4().hex, + "label": label, + "steps": list(self._steps), + "idempotent": self._idem_check.isChecked(), + "status": "pending", + "added_at": datetime.datetime.now().isoformat(timespec="seconds"), + } + self.accept() + + # ── module-level helpers ───────────────────────────────────────────────────── @@ -1185,6 +1490,24 @@ def _format_projections(params: dict) -> str: return "?" +def _job_kind(job: dict) -> str: + """A job with no "kind" key predates command jobs and is a tomogram -- + same back-compat default as flomni.py's tomo_queue_execute()/show().""" + return job.get("kind", "tomo") + + +def _format_command_summary(job: dict) -> str: + """Mirrors flomni.py's tomo_queue_show() command-job rendering exactly, + so the CLI and this table read as the same list.""" + steps = job.get("steps", []) + parts = [ + f"{step['action']}{step['kwargs']}" if step.get("kwargs") else step["action"] + for step in steps + ] + idem = "idem" if job.get("idempotent") else "NOT idem" + return " > ".join(parts) + f" [{idem}]" + + def _fmt_num(val) -> str: """Compact numeric formatting for table cells; blank for None.""" if val is None: