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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user