new_gui: hybrid manual queue nav + fast in-place reorder + visible arrows

Hybrid manual:
- AppState queue cursor (queue_next/queue_advance); changer 'FROM QUEUE' bar:
  Mount next / Skip; pick any sample as before. Pairs with run-plan + breakpoints
  for run-to-step-then-takeover.

Reorder perf + arrows (user feedback):
- in-place reorder: rows are moved + reindexed (QueueList.reflow, QueueRow.update_item)
  instead of rebuilt -> drag and up/down stay fast even for big queues
- up/down use Qt standard icons (always render; previously empty/'pink' boxes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-26 10:13:42 +02:00
co-authored by Claude Opus 4.8
parent 507182505e
commit 14576216bd
5 changed files with 179 additions and 23 deletions
+9 -1
View File
@@ -115,7 +115,15 @@ Backend contract is documented in the `new-gui-backend-paths` memory.
collect/XRF use YAML defaults (validate collect params against the dose-aware
backend path for production). Queue: **drag rows to reorder** (grip handle),
per-sample protocol chips, ▲▼ fallback. Library: **search** (name/puck/location),
**sort** (Name/Puck/Location/Priority) and **Add all**.
**sort** (Name/Puck/Location/Priority) and **Add all**. Reorder is **in-place**
(rows are moved, not rebuilt) so drag + ↑/↓ stay snappy; the ↑/↓ use Qt
standard icons so they always render.
- **Hybrid manual** (Manual cockpit): a **FROM QUEUE** bar in the changer —
**⊕ Mount next** mounts the next queued sample, **⏭ Skip** advances past it;
pick any sample directly as before. Combined with the run-plan + ⚑ breakpoints
("run to a step, then take over"), the operator can let automation run a sample
up to a chosen stage and finish it by hand, then pull the next from the queue.
A pinned **CURRENTLY MOUNTED** card makes the active sample unmistakable.
- **Bookmarks** (mounted): + Bookmark captures the current smargon position
(shown as numbered markers on the camera, projected via `smargon_to_picture`);
each has an **editable label**; **Go** returns there (`move_smargon` +
+64 -22
View File
@@ -7,7 +7,7 @@ chips are local for now; a pausable protocol-driven automation comes later.
from __future__ import annotations
from PySide6.QtCore import QMimeData, Qt, Signal
from PySide6.QtCore import QMimeData, QSize, Qt, Signal
from PySide6.QtGui import QDrag
from PySide6.QtWidgets import (
QComboBox,
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QLineEdit,
QPushButton,
QScrollArea,
QStyle,
QVBoxLayout,
QWidget,
)
@@ -284,13 +285,13 @@ class QueueRow(QWidget):
handle.setCursor(Qt.OpenHandCursor)
lay.addWidget(handle)
idx = QLabel(str(index + 1))
idx.setFixedWidth(20)
idx.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
idx.setStyleSheet(
self._idx_label = QLabel(str(index + 1))
self._idx_label.setFixedWidth(20)
self._idx_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._idx_label.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; color:{p.text_faint};"
)
lay.addWidget(idx)
lay.addWidget(self._idx_label)
lay.addWidget(_thumb(palette, 38))
@@ -315,16 +316,20 @@ class QueueRow(QWidget):
chips = QHBoxLayout()
chips.setSpacing(5)
self._chip_map: dict[str, Chip] = {}
for key in PROTOCOL_KEYS:
chip = Chip(key.capitalize(), palette, checked=item.protocol.get(key))
chip.clicked.connect(lambda _=False, k=key: self.toggled.emit(self._index, k))
self._chip_map[key] = chip
chips.addWidget(chip)
lay.addLayout(chips)
reorder = QVBoxLayout()
reorder.setSpacing(2)
up = self._reorder_btn("▲", palette)
down = self._reorder_btn("▼", palette)
up = self._reorder_btn(QStyle.SP_ArrowUp, palette)
down = self._reorder_btn(QStyle.SP_ArrowDown, palette)
up.setToolTip("Move up")
down.setToolTip("Move down")
up.clicked.connect(lambda: self.move.emit(self._index, -1))
down.clicked.connect(lambda: self.move.emit(self._index, 1))
reorder.addWidget(up)
@@ -341,17 +346,33 @@ class QueueRow(QWidget):
rm.clicked.connect(lambda: self.remove.emit(self._index))
lay.addWidget(rm)
@staticmethod
def _reorder_btn(glyph: str, p: Palette) -> QPushButton:
b = QPushButton(glyph)
b.setFixedSize(24, 17)
def _reorder_btn(self, std_icon, p: Palette) -> QPushButton:
# Use a Qt standard icon (drawn by the style) so the arrow always shows,
# independent of the system font's glyph coverage.
b = QPushButton()
b.setIcon(self.style().standardIcon(std_icon))
b.setIconSize(QSize(11, 11))
b.setFixedSize(26, 18)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(
f"QPushButton {{ background:{p.app_bg}; border:1px solid {p.border_panel};"
f" border-radius:5px; font-size:9px; color:{p.text_muted}; }}"
f"QPushButton {{ background:{p.chip_off_bg};"
f" border:1px solid {p.border_control}; border-radius:5px; }}"
f" QPushButton:hover {{ background:{p.accent}; border-color:{p.accent}; }}"
)
return b
def update_item(self, index: int, item) -> None:
"""Cheap in-place update for reorder / protocol changes (no rebuild)."""
self._index = index
self._idx_label.setText(str(index + 1))
for key, chip in self._chip_map.items():
on = bool(item.protocol.get(key))
if chip.isChecked() != on:
chip.blockSignals(True)
chip.setChecked(on)
chip.blockSignals(False)
chip._restyle()
# ---- mouse drag to reorder ----
def mousePressEvent(self, event): # noqa: N802
if event.button() == Qt.LeftButton:
@@ -397,6 +418,13 @@ class QueueList(QWidget):
w.setParent(None)
w.deleteLater()
def reflow(self, rows: list) -> None:
"""Re-order existing row widgets in place (no recreate)."""
while self._lay.count() > 1:
self._lay.takeAt(0) # detach layout items, keep widgets alive
for i, row in enumerate(rows):
self._lay.insertWidget(i, row)
def _rows(self) -> list:
return [self._lay.itemAt(i).widget() for i in range(self._lay.count() - 1)]
@@ -433,6 +461,7 @@ class AutomationView(QWidget):
super().__init__(parent)
self._state = state
self._p = palette
self._row_by_id: dict = {}
root = QHBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
@@ -631,15 +660,28 @@ class AutomationView(QWidget):
chip.setChecked(want)
chip.blockSignals(False)
# rebuild queue rows
self._queue_list.clear_rows()
# queue rows: reorder/toggle in place when the set is unchanged (fast),
# only fully rebuild when samples are added/removed.
queue = self._state.queue
for i, item in enumerate(queue):
row = QueueRow(i, item, self._p)
row.toggled.connect(self._state.toggle_row_protocol)
row.move.connect(self._state.queue_move)
row.remove.connect(self._state.queue_remove)
self._queue_list.add_row(row)
new_ids = [item.sample_id for item in queue]
if new_ids and set(new_ids) == set(self._row_by_id) \
and len(new_ids) == len(self._row_by_id):
ordered = []
for i, item in enumerate(queue):
row = self._row_by_id[item.sample_id]
row.update_item(i, item)
ordered.append(row)
self._queue_list.reflow(ordered)
else:
self._queue_list.clear_rows()
self._row_by_id = {}
for i, item in enumerate(queue):
row = QueueRow(i, item, self._p)
row.toggled.connect(self._state.toggle_row_protocol)
row.move.connect(self._state.queue_move)
row.remove.connect(self._state.queue_remove)
self._queue_list.add_row(row)
self._row_by_id[item.sample_id] = row
empty = len(queue) == 0
self._empty.setVisible(empty)
+18
View File
@@ -146,6 +146,9 @@ class ManualView(QWidget):
# changer / mount CTAs
self.changer.sample_selected.connect(s.mount_sample_obj)
self.changer.manual_mount_requested.connect(self._on_manual_mount)
self.changer.mount_next_requested.connect(self._on_mount_next)
self.changer.skip_queued_requested.connect(s.queue_advance)
s.queue_changed.connect(self._refresh_queue_nav)
self.camera.mount_clicked.connect(s.open_picker)
# pipeline interactions
@@ -428,6 +431,20 @@ class ManualView(QWidget):
else:
self.status_message.emit("Grid has no stored position to go to.")
# ------------------------------------------------- hybrid queue nav
def _on_mount_next(self) -> None:
nx = self._state.queue_next()
if nx is None:
return
self._state.mount_sample_obj(nx.sample)
self._state.queue_advance()
def _refresh_queue_nav(self) -> None:
nx = self._state.queue_next()
self.changer.set_queue_nav(
nx.sample if nx is not None else None,
self._state.queue_cursor, len(self._state.queue))
# -------------------------------------------------- manual sample
def _on_manual_mount(self) -> None:
from PySide6.QtWidgets import QDialog
@@ -584,6 +601,7 @@ class ManualView(QWidget):
self._bookmark_loaded_key = None
self.pipeline.bookmarks.set_bookmarks([])
self.camera.set_bookmarks([])
self._refresh_queue_nav()
self.pipeline.update_from_state(self._state)
def _refresh_pipe(self) -> None:
+24
View File
@@ -114,8 +114,31 @@ class AppState(QObject):
self._protocol = default_protocol() # manual run-plan (which stages run)
self._run_status = "idle" # idle | running | paused | breakpoint
self._queue: list[QueueItem] = []
self._queue_cursor = 0 # next queued sample to process
self._defaults = default_protocol()
# ----------------------------------------- hybrid queue navigation
@property
def queue_cursor(self) -> int:
return self._queue_cursor
def queue_next(self):
"""The next queued item to process (at the cursor), or None."""
if 0 <= self._queue_cursor < len(self._queue):
return self._queue[self._queue_cursor]
return None
def queue_advance(self) -> None:
"""Move the cursor to the next queued sample (skip / after-mount)."""
if self._queue_cursor < len(self._queue):
self._queue_cursor += 1
self.queue_changed.emit()
def queue_reset_cursor(self) -> None:
if self._queue_cursor != 0:
self._queue_cursor = 0
self.queue_changed.emit()
# ----------------------------------------------------- run-plan (protocol)
@property
def protocol(self) -> dict[str, bool]:
@@ -339,6 +362,7 @@ class AppState(QObject):
def queue_clear(self) -> None:
if self._queue:
self._queue = []
self._queue_cursor = 0
self.queue_changed.emit()
def toggle_row_protocol(self, index: int, key: str) -> None:
@@ -140,6 +140,8 @@ class SampleChangerPanel(QWidget):
sample_selected = Signal(object, bool) # sample, reference
manual_mount_requested = Signal() # sample on the gonio, not in the DB
mount_next_requested = Signal() # hybrid: mount the next queued sample
skip_queued_requested = Signal() # hybrid: skip the next queued sample
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
@@ -230,6 +232,51 @@ class SampleChangerPanel(QWidget):
outer.addWidget(self._mounted_card)
self._render_mounted_card(None)
# hybrid queue navigation (shown only when a queue exists)
from PySide6.QtWidgets import QPushButton as _QPB
self._queue_nav = QWidget()
self._queue_nav.setStyleSheet(
f"border-bottom:1px solid {palette.border_panel};")
qn = QVBoxLayout(self._queue_nav)
qn.setContentsMargins(16, 8, 16, 8)
qn.setSpacing(6)
qhead = QHBoxLayout()
qhead.setContentsMargins(0, 0, 0, 0)
qhead.addWidget(micro_label("FROM QUEUE"))
qhead.addStretch(1)
self._qn_pos = QLabel("")
self._qn_pos.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:10.5px;"
f" color:{palette.text_faint};")
qhead.addWidget(self._qn_pos)
qn.addLayout(qhead)
self._qn_next = QLabel("")
self._qn_next.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:12px; font-weight:600;"
f" color:{palette.text_secondary};")
qn.addWidget(self._qn_next)
qrow = QHBoxLayout()
qrow.setSpacing(6)
self._qn_mount = _QPB("⊕ Mount next")
self._qn_mount.setCursor(Qt.PointingHandCursor)
self._qn_mount.setStyleSheet(
f"QPushButton {{ background:{palette.accent}; color:{palette.accent_text};"
f" border:none; border-radius:8px; padding:6px 12px; font-size:11.5px;"
f" font-weight:600; }}")
self._qn_mount.clicked.connect(self.mount_next_requested)
self._qn_skip = _QPB("⏭ Skip")
self._qn_skip.setCursor(Qt.PointingHandCursor)
self._qn_skip.setStyleSheet(
f"QPushButton {{ background:transparent; color:{palette.text_secondary};"
f" border:1px solid {palette.border_control}; border-radius:8px;"
f" padding:6px 12px; font-size:11.5px; }}")
self._qn_skip.clicked.connect(self.skip_queued_requested)
qrow.addWidget(self._qn_mount, 1)
qrow.addWidget(self._qn_skip)
qn.addLayout(qrow)
outer.addWidget(self._queue_nav)
self._queue_nav.setVisible(False)
# scrollable list
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
@@ -298,6 +345,23 @@ class SampleChangerPanel(QWidget):
self._mounted_id = mounted_id
self._rebuild()
def set_queue_nav(self, next_sample, pos: int, total: int) -> None:
"""Show/refresh the hybrid 'from queue' bar (hidden when no queue)."""
if total <= 0:
self._queue_nav.setVisible(False)
return
self._queue_nav.setVisible(True)
self._qn_pos.setText(f"{min(pos + 1, total)}/{total}")
if next_sample is not None:
self._qn_next.setText(f"next: {sample_label(next_sample)}")
self._qn_mount.setEnabled(True)
self._qn_skip.setEnabled(True)
self._qn_mount.setText("⊕ Mount next")
else:
self._qn_next.setText("queue finished")
self._qn_mount.setEnabled(False)
self._qn_skip.setEnabled(False)
def set_mounted_sample(self, sample) -> None:
"""Update both the row highlight and the always-visible mounted card."""
self._render_mounted_card(sample)