From c49d7afb55db22cea4b666805de00b32e30146cf Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 13 Jul 2026 13:27:22 +0200 Subject: [PATCH] feat(bec_widgets): sort-mode reorder buttons and row tooltips in TomoQueueDialog Adds a checkable "Sort queue..." mode: other queue-mutating buttons disable, the table goes single-select, and Move up/down swap the selected row with an adjacent one -- but only between two pending/incomplete rows, so a running or done neighbour blocks the swap (gets the running-job floor and done-row pinning from TOMO_QUEUE_COMMAND_JOBS_PLAN.md sections 6.3/6.4 without drag-and-drop math). Also gives every row a full-detail tooltip (label, status, steps/params, added-at) so hovering surfaces what the Label/Details columns truncate at typical dialog widths. Co-Authored-By: Claude Sonnet 5 --- .../widgets/tomo_params/tomo_params.py | 135 ++++++++++++++++-- 1 file changed, 123 insertions(+), 12 deletions(-) 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 a1054d2..d80c3f1 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -951,9 +951,16 @@ class TomoQueueDialog(QDialog): _POLL_MS = 2000 + # Statuses a job must have to be reorderable in sort mode. "running" and + # "done" are excluded: running is a hard floor (plan section 6.4), done + # is pinned for readability (plan section 6.3) even though moving it + # would be harmless. + _MOVABLE_STATUSES = ("pending", "incomplete") + def __init__(self, client, parent=None): super().__init__(parent) self._client = client + self._sort_mode = False self.setWindowTitle("FlOMNI – Tomo Queue Control") self.setMinimumSize(720, 360) self._build_ui() @@ -1017,23 +1024,40 @@ class TomoQueueDialog(QDialog): self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._table.itemSelectionChanged.connect(self._update_sort_buttons) vbox.addWidget(self._table) 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") + self._btn_add = QPushButton("Add current params to queue") + self._btn_add_cmd = QPushButton("Add command…") + self._btn_del = QPushButton("Delete selected") + self._btn_clr = QPushButton("Clear all") + self._btn_sort = QPushButton("Sort queue…") + self._btn_sort.setCheckable(True) + self._btn_sort.setToolTip( + "Reorder pending/incomplete jobs. The running job (if any) and " + "done jobs stay put." + ) + self._btn_move_up = QPushButton("▲ Move up") + self._btn_move_down = QPushButton("▼ Move down") + self._btn_move_up.setVisible(False) + self._btn_move_down.setVisible(False) 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) + self._btn_add.clicked.connect(self.add_to_queue) + self._btn_add_cmd.clicked.connect(self.add_command_to_queue) + self._btn_del.clicked.connect(self._delete_selected) + self._btn_clr.clicked.connect(self._clear_queue) + self._btn_sort.toggled.connect(self._toggle_sort_mode) + self._btn_move_up.clicked.connect(lambda: self._move_selected(-1)) + self._btn_move_down.clicked.connect(lambda: self._move_selected(1)) 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.addWidget(self._btn_add) + btn_row.addWidget(self._btn_add_cmd) + btn_row.addWidget(self._btn_del) + btn_row.addWidget(self._btn_clr) + btn_row.addWidget(self._btn_sort) + btn_row.addWidget(self._btn_move_up) + btn_row.addWidget(self._btn_move_down) btn_row.addStretch() btn_row.addWidget(btn_exe) vbox.addLayout(btn_row) @@ -1071,11 +1095,13 @@ class TomoQueueDialog(QDialog): "", job.get("added_at", ""), ] + tooltip = _job_tooltip(job) for col, text in enumerate(cells): item = QTableWidgetItem(text) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) if col == 2: item.setForeground(_color_from_hex(color)) + item.setToolTip(tooltip) self._table.setItem(row, col, item) # ── queue actions ───────────────────────────────────────────────────────── @@ -1114,6 +1140,69 @@ class TomoQueueDialog(QDialog): self._save_queue(jobs) self._refresh() + # ── sort mode ───────────────────────────────────────────────────────────── + + def _toggle_sort_mode(self, checked: bool) -> None: + """Enter/exit sort mode (plan section 6.4's mid-run reorder, as a + focused mode rather than free-form drag-and-drop -- see + AI_docs/TOMO_QUEUE_GUI_TESTING.md section 7 for why up/down buttons + were chosen over drag&drop for this first iteration). + + Other queue-mutating actions are disabled while sorting so the table + doesn't shift out from under the operator mid-reorder. + """ + self._sort_mode = checked + for btn in (self._btn_add, self._btn_add_cmd, self._btn_del, self._btn_clr): + btn.setEnabled(not checked) + self._btn_move_up.setVisible(checked) + self._btn_move_down.setVisible(checked) + self._table.setSelectionMode( + QTableWidget.SelectionMode.SingleSelection + if checked + else QTableWidget.SelectionMode.ExtendedSelection + ) + self._table.clearSelection() + self._update_sort_buttons() + + def _selected_single_row(self) -> int: + rows = sorted({idx.row() for idx in self._table.selectedIndexes()}) + return rows[0] if len(rows) == 1 else -1 + + def _update_sort_buttons(self) -> None: + if not self._sort_mode: + return + row = self._selected_single_row() + jobs = self._load_queue() + + def movable(r: int) -> bool: + return 0 <= r < len(jobs) and jobs[r].get("status", "pending") in self._MOVABLE_STATUSES + + is_selected_movable = row >= 0 and movable(row) + self._btn_move_up.setEnabled(is_selected_movable and movable(row - 1)) + self._btn_move_down.setEnabled(is_selected_movable and movable(row + 1)) + + def _move_selected(self, direction: int) -> None: + """Swap the selected row with the adjacent one. Only ever swaps two + movable (pending/incomplete) rows -- a running or done neighbour + blocks the swap, which is what keeps the running job as a hard + floor (plan section 6.4) and done jobs pinned (section 6.3) without + needing separate index-range bookkeeping. + """ + row = self._selected_single_row() + jobs = self._load_queue() + other = row + direction + if not (0 <= row < len(jobs) and 0 <= other < len(jobs)): + return + if jobs[row].get("status", "pending") not in self._MOVABLE_STATUSES: + return + if jobs[other].get("status", "pending") not in self._MOVABLE_STATUSES: + return + jobs[row], jobs[other] = jobs[other], jobs[row] + self._save_queue(jobs) + self._refresh() + self._table.selectRow(other) + self._update_sort_buttons() + def _delete_selected(self) -> None: rows = sorted({idx.row() for idx in self._table.selectedIndexes()}, reverse=True) if not rows: @@ -1508,6 +1597,28 @@ def _format_command_summary(job: dict) -> str: return " > ".join(parts) + f" [{idem}]" +def _job_tooltip(job: dict) -> str: + """Full-detail multi-line description of a queue job, applied to every + cell in its row so hovering anywhere on the row shows what the Label + and Details columns truncate at typical dialog widths.""" + lines = [f"Label: {job.get('label', '')}", f"Status: {job.get('status', 'pending')}"] + if _job_kind(job) == "command": + lines.append(f"Idempotent: {'yes' if job.get('idempotent') else 'no'}") + lines.append("Steps:") + for i, step in enumerate(job.get("steps", []), start=1): + kwargs = step.get("kwargs") or {} + step_str = f"{step['action']}({kwargs})" if kwargs else step["action"] + lines.append(f" {i}. {step_str}") + else: + lines.append("Params:") + params = job.get("params", {}) + for name in QUEUE_PARAM_NAMES: + if name in params: + lines.append(f" {name}: {params[name]}") + lines.append(f"Added: {job.get('added_at', '')}") + return "\n".join(lines) + + def _fmt_num(val) -> str: """Compact numeric formatting for table cells; blank for None.""" if val is None: