diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md index cf42fb17..5f13ee3c 100644 --- a/src/aare/gui/new_gui/README.md +++ b/src/aare/gui/new_gui/README.md @@ -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` + diff --git a/src/aare/gui/new_gui/automation_view.py b/src/aare/gui/new_gui/automation_view.py index 67824b1e..06579289 100644 --- a/src/aare/gui/new_gui/automation_view.py +++ b/src/aare/gui/new_gui/automation_view.py @@ -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) diff --git a/src/aare/gui/new_gui/manual_view.py b/src/aare/gui/new_gui/manual_view.py index 39cb75fe..943e5b53 100644 --- a/src/aare/gui/new_gui/manual_view.py +++ b/src/aare/gui/new_gui/manual_view.py @@ -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: diff --git a/src/aare/gui/new_gui/state.py b/src/aare/gui/new_gui/state.py index 46a396c2..362f25fe 100644 --- a/src/aare/gui/new_gui/state.py +++ b/src/aare/gui/new_gui/state.py @@ -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: diff --git a/src/aare/gui/new_gui/widgets/sample_changer.py b/src/aare/gui/new_gui/widgets/sample_changer.py index 5db76dac..cce96fdb 100644 --- a/src/aare/gui/new_gui/widgets/sample_changer.py +++ b/src/aare/gui/new_gui/widgets/sample_changer.py @@ -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)