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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-13 13:27:22 +02:00
co-authored by Claude Sonnet 5
parent 2755f54073
commit c49d7afb55
@@ -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: