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:
co-authored by
Claude Opus 4.8
parent
507182505e
commit
14576216bd
@@ -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` +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user