diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md
new file mode 100644
index 00000000..5bfa22b6
--- /dev/null
+++ b/src/aare/gui/new_gui/README.md
@@ -0,0 +1,120 @@
+# new_gui — redesigned AareGUI (PySide6)
+
+A from-scratch PySide6 implementation of the AareGUI redesign (Manual cockpit +
+Automation queue builder), translated from the HTML design handoff. It **reuses
+the existing backend** (`DAQWorker`, `PredictionSubscriber`, `auth`, `cfg_get`)
+without modifying any existing files — everything here is new.
+
+## Run
+
+```bash
+uv run python -m aare.gui.new_gui.app
+# point at a specific server / camera:
+uv run python -m aare.gui.new_gui.app -u https://host -c /path/to.crt -p tcp://host:9091
+```
+
+`BEAMLINE` (X06DA / X10SA / X06SA / unset→SIMULATED) drives all config via
+`cfg_get`, exactly like the existing GUI.
+
+## Layout
+
+| File | Responsibility |
+|------|----------------|
+| `app.py` | Entry point: auth + per-beamline config, launches `MainWindow`. |
+| `main_window.py` | Assembles chrome + body; **the only module that imports the backend**. Wires views ⇄ `DAQWorker`. |
+| `state.py` | `AppState` — the view-model/state machine (mode, mount phase, pipeline, queue). Pure UI state + intent signals; backend-free and unit-testable. |
+| `theme.py` | Design tokens (light + dark palettes), QSS, fixed dimensions. |
+| `fonts.py` | IBM Plex registration (falls back to system fonts if TTFs absent). |
+| `requests.py` | Builders: GUI params + live status → backend request models. |
+| `top_bar.py` / `status_bar.py` | Shared chrome. Status bar reads `DAQStatusModel`; baton chip reads `BatonStatus`. |
+| `manual_view.py` | Manual cockpit (changer · camera · motors) + mount-flow orchestration. |
+| `automation_view.py` | Queue builder (library · queue · start bar). |
+| `widgets/` | `camera`, `pipeline` (tracker), `pipeline_panel`, `sample_changer`, `motors_panel`, `common` (chips, flow layout, cards). |
+
+Backend contract is documented in the `new-gui-backend-paths` memory.
+
+## What is wired to the backend
+
+- **Live status** → status bar (flux/ring/λ/cryo/shutter/state), motors readouts
+ (omega/chi/phi), zoom + light sliders.
+- **Baton** chip ← `baton_status_changed`.
+- **Sample list** ← `spreadsheet` → Sample Changer + Library.
+- **Camera** ← `PredictionSubscriber` (live SAMCAM image, fps, target point).
+- **Mount / Unmount** → `mount(SampleShortInfo)` / `unmount()`.
+- **Motors** → `set_omega_rel`, `move_smargon`, `zoom`, `front_light`, `back_light`,
+ and a staff-only **Energy** control → `change_energy` (keV).
+- **Center** (Loop) → `center_loop()`. **Click-to-centre** is implicit: clicking
+ the live camera moves the goniometer (`picture_to_sample` → `beamline_to_smargon`
+ → `move_smargon`), mirroring the existing GUI.
+- **Raster** → `raster_scan(RasterGridRequest)` (non-auto) from a grid drawn on the
+ camera; `smargon_top_left` is computed from the drawn top-left pixel via the live
+ geometry. (`requests.build_raster_request(auto=True)` keeps the auto-centre path
+ available.)
+- **Collect** → `standard_scan(RotationScanRequest)`.
+- **XRF** → `fluorimeter_spectrum(FluorescenceSpectrumParameterModel)`.
+- **Automation Start** → iterates `automated_scan(sample)` over the local queue,
+ advancing on `automated_scan_done`.
+- **Baton**: top-bar chip shows holder (staff see the username; non-staff see only
+ "Staff has the baton" / "Another user has the baton"). Action button does
+ Grab / Request control / Release / Cancel. Incoming requests pop an accept/refuse
+ dialog (`respond_to_baton_request`); your pending request shows a countdown dialog
+ polling `check_baton_timeout`.
+- **Guest mode**: when you don't hold the baton the sample camera is greyed with a
+ GUEST MODE / VACANT overlay and click/grid interaction is blocked.
+- **Reference tools** (staff): a Samples / Reference-tools toggle in the changer
+ header (from `reference_tools`); selecting one mounts with `reference=True`.
+- **Beamline state**: the status-bar State item is a clickable LED (group-coloured)
+ that opens a transitions menu → the per-state DAQWorker methods
+ (`sample_exchange`, `data_collection`, …), available with no sample mounted.
+ Disabled while `status.busy`.
+- **Mounting**: left-click a sample to *select* it (no immediate mount); then
+ press **⊕ Mount selected** or right-click → Mount. Reference tools mount with
+ `reference=True`.
+- **Pre-mount settings**: the pipeline stages are clickable with no sample
+ mounted so parameters can be pre-set; the "▶ Run … now" buttons are disabled
+ until a sample is mounted.
+- **XRF spectrum** (QtCharts): the XRF stage / camera "XRF spectrum" tab shows the
+ fluorescence spectrum (energy keV vs counts, peak + dead-time) from
+ `fluorimeter_spectrum_update` / live `fluorimeter_update`; Start/Stop/Snapshot →
+ `fluorimeter_start` / `fluorimeter_stop` / `fluorimeter_request_snapshot`.
+- **Rotation / Screening**: the Collect stage has a **Rotation / Screening** mode
+ toggle; Screening exposes the presets (1/2/4 images @ 90°/60°/45°) + image-angle
+ + count-time and builds the screening `RotationScanRequest` (wedge/steps).
+- **Alert banner**: a dismissible strip below the top bar surfaces errors
+ (`http_error`, `auth_error`, `sample_missing`, device/detector errors,
+ automation/manual critical failures, PSS alarm) and completion notices
+ (`standard_scan_completed`, `raster_scan_completed`); all `_note()` messages
+ also route here. Errors persist until dismissed; info/success auto-clear.
+- **Raster results**: `raster_scan_completed (CompletedRasterGrid)` renders a
+ viridis metric heatmap (widgets/heatmap.py) over the drawn grid on the camera;
+ the Raster settings expose a metric selector + opacity slider + a "Grids: N"
+ table (widgets/raster_grids_dialog.py) whose Goto → `move_smargon` to that grid.
+- **Live automation progress**: the Automation view's RUN PROGRESS panel renders
+ the `automation_progress` SSE stream (Mount/Center/Raster/Collect/Final steps +
+ current sample, samples-left, avg time, ETA). Start swaps to **Pause / Stop**;
+ Pause is GUI-side (pauses after the current sample), Stop ends the local loop.
+- **Data-collection toggles** (Raster + Collect): a **Database / User values**
+ toggle — Database makes the fields read-only and fills them from the mounted
+ sample's `aaredb_params`; User makes them editable. Collect additionally has an
+ **Automate parameters** toggle (the simple strategy): it hides the manual cards
+ and routes the run through `smart_params(SimpleScanParameters)` + `standard_scan`.
+
+Scan defaults (exp_time_s / transmission / dtz / osc width) come from the
+beamline YAML (`data_collection_settings.*`).
+
+## Known TODOs / assumptions to confirm
+
+1. **Smargon jog signs** — arrow deltas are treated as a beamline-frame offset and
+ rotated via `beamline_to_smargon` (rotation-aware at any omega/phi/chi). Current
+ mapping: ←/→ = -x/+x, ↑/↓ = -y/+y (camera y down). Flip in `MotorsPanel._jog`
+ if the beamline convention differs.
+2. **XRF energy**: the XRF request carries only count time + transmission; beam
+ energy is set via the staff-only Energy control in the Motors panel
+ (`change_energy`).
+3. **Per-sample automation protocol chips** are local/cosmetic for now —
+ `automated_scan` currently decides the protocol server-side. A pausable,
+ protocol-driven automation is planned next.
+4. **Save as template** is a stub.
+5. **Fonts**: drop IBM Plex TTFs into `assets/fonts/` to match the design exactly;
+ otherwise system fonts are used.
+6. **Dark theme** palette exists in `theme.py` but no toggle is exposed yet.
diff --git a/src/aare/gui/new_gui/ROADMAP.md b/src/aare/gui/new_gui/ROADMAP.md
new file mode 100644
index 00000000..3141f9e8
--- /dev/null
+++ b/src/aare/gui/new_gui/ROADMAP.md
@@ -0,0 +1,99 @@
+# new_gui — feature gap & build plan
+
+Old-GUI features cross-referenced against the new GUI. Updated 2026-06-24.
+
+## Already in the new GUI ✅
+Mode toggle · top bar (logo/baton/avatar) · status bar (flux/ring/λ/cryo/shutter +
+clickable state LED w/ transitions menu) · baton request/accept/decline/release +
+dialogs (staff-gated identity) · sample changer (select→Mount button/right-click,
+staff reference-tools toggle) · live SAMCAM (crosshair, single target-point,
+raster grid-DRAW, guest-mode overlay) · motors (omega, smargon jog, zoom, light,
+staff energy/`change_energy`) · interactive pipeline (Center/Raster/XRF/Collect,
+breakpoints, transport, pre-mount editable settings, Database/User + Automate
+toggles) · automation queue builder (library, per-sample chips, reorder, ETA,
+Start→`automated_scan`).
+
+---
+
+## Gap list (grouped) — with DAQWorker hooks & rough effort
+
+### A. Camera richness (partial today) — MED
+- ML detection boxes/polygons (Crystal/Loop/Pin/Ice/Needle colours) ← `prediction_subscriber.prediction`
+- Beam-centre crosshair marker ← `status.geom.beam_location_pxl`
+- Camera exposure/gain controls + screenshot-to-DB(+metadata) ← `samcam_settings`, `send_screenshot_db`
+- Focus-measure readout ← `prediction_subscriber.focus_measure`
+- Smargon bookmarks (Ctrl+click position markers) ← `models/bookmark.py`
+- Helical start/end markers (only if helical is in scope)
+
+### B. Multi-camera (Gonio / Beamline) — MED
+- Wire the inert Gonio/Beamline tabs to axis IP video ← `threads/axis_video_thread.py` (VideoThread)
+
+### C. Raster results & metrics — HIGH
+- Completed-grid heatmap (viridis by metric: spots/index/ice/res/B-factor…) ← `raster_scan_completed (CompletedRasterGrid)`, `raster_grid_manager`
+- Grid-point table (goto/visibility/copy) ← `widgets/raster_grid_table.py`
+- ML auto-box → raster ← `ml_bounding_box`, `raster_generated_by_ml`
+
+### D. Rotation / data-collection depth — MED/HIGH
+- Screening modes (1/2/3/4 images), dose calc, temperature, wavelength scaling
+- Completed-scan feedback ← `standard_scan_completed`
+- Full smart/dose model behind "Automate parameters" (today we forward seeds only)
+
+### E. Fluorescence (XRF) results — MED
+- Live spectrum plot (energy vs counts), peak/dead-time, marker ← `fluorimeter_spectrum_update`, `fluorimeter_update`, `fluorimeter_start/stop/request_snapshot`
+
+### F. Face detection — LOW/MED
+- Panel (steps/step-size) + result ← `face_detection`, `face_detection_result`
+
+### G. Beam/detector physics (staff) — MED
+- Beam centre/size/mark ← `beam_center`, `beam_size_mm`, `beam_mark_add/clear`
+- ABR tweak (GMX/GMY/GMZ, save/goto) ← `abr_tweak`, `abr_save`, `abr_goto_meas`
+- Monochromator pitch scan ← `mono_pitch_scan` (energy already done)
+
+### H. Recovery + Local-contact admin (staff) — HIGH
+- Recovery: free/takeover/recover/unmount (confirmation codes), anneal, park&dry, TELL dry/blower ← `free_beamline`, `take_over_beamline`, `recover_beamline`, `recovery_unmount_sample`, `anneal`, `park_and_dry`, `tell_dry`, `tell_toggle_blower`
+- Hardware init: smargon/aerotech/detector/BEC, pedestal ← `initialise_*`, `detector_take_pedestal`
+- Local-contact 6-tab (sim toggles, device state, links, BEC macros, metadata resync) ← `load_local_contact_*`, `set_local_contact_simulation`, `restart_local_contact_device`, `bec_*`, `resync_sample`, `resync_local_contact_detector_metadata`
+
+### I. Alerts, errors, preconditions — MED
+- Alert banner (samcam down, automation failure, PSS/hutch alarm) ← `polled_devices_status`, `detector_error`, `automation_critical_failure`, `pss_alarm_changed`
+- Modal precondition checks (ring/shutter/hutch) before risky actions
+- Sample-missing dialog ← `sample_missing`
+- Developer help: error-code registry + last error payloads ← `error_codes_loaded`, `last_error_payloads_changed`
+
+### J. Sessions / pgroups (staff/admin) — MED
+- P-group selector ← `set_pgroup`, `get_all_pgroups`, `staff_pgroups_loaded`
+- Multi-session admin: list/force-close/remove ← `load_gui_sessions`, `request_gui_close`, `force_remove_gui_session`, `report_gui_interaction`
+- Force/end session ← `force_session`, `end_session`
+
+### K. Live automation progress — MED
+- Progress widget driven by `automation_progress` (SSE) + step-through/pause; `automated_scan_done` already handled. Pairs with the planned pausable protocol queue.
+
+### L. File path / metadata — LOW
+- Filename/directory/prefix/run-number builder (+ `run_number_incremented`)
+
+### M. Analysis panels (nice-to-have) — MED
+- Target-stability chart, prediction-metrics, smargon-trace (CSV/charts)
+
+### N. UX / framework — MED
+- Keyboard shortcuts (~18), layout persistence (QSettings), restore-default-layout
+- Tutorials/guided help (F1), splash screen
+- Portrait / compact automation layouts
+
+---
+
+## Proposed phased plan
+
+**Phase 1 — core operator parity (HIGH value):** D (completed-scan feedback +
+screening), C (raster results heatmap+table), E (XRF spectrum), K (live
+automation progress) + the pausable protocol queue. Makes day-to-day collection
+fully usable.
+
+**Phase 2 — camera & feedback:** A (ML overlays, beam centre, screenshot,
+focus), B (gonio/beamline video), I (alert banner + precondition dialogs +
+sample-missing).
+
+**Phase 3 — staff tooling:** G (beam/detector physics), J (pgroups + sessions),
+H (recovery + local-contact admin), developer help.
+
+**Phase 4 — polish:** L (file builder), F (face detection), M (analysis panels),
+N (shortcuts, layout persistence, tutorials, portrait/compact).
diff --git a/src/aare/gui/new_gui/__init__.py b/src/aare/gui/new_gui/__init__.py
new file mode 100644
index 00000000..d13afcb0
--- /dev/null
+++ b/src/aare/gui/new_gui/__init__.py
@@ -0,0 +1 @@
+"""Redesigned AareGUI (PySide6). See app.py for the entry point."""
diff --git a/src/aare/gui/new_gui/app.py b/src/aare/gui/new_gui/app.py
new file mode 100644
index 00000000..a59d949d
--- /dev/null
+++ b/src/aare/gui/new_gui/app.py
@@ -0,0 +1,123 @@
+"""Entry point for the redesigned AareGUI.
+
+Run with::
+
+ uv run python -m aare.gui.new_gui.app
+
+Reuses the existing auth flow and per-beamline config (cfg_get) from the current
+GUI, then launches the redesigned :class:`MainWindow`. CLI flags mirror the old
+GUI's URL / cert / camera options so it can point at any server.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import traceback
+
+from PySide6 import QtGui
+from PySide6.QtCore import QCommandLineOption, QCommandLineParser
+from PySide6.QtWidgets import QApplication, QMessageBox
+
+from aare.common.beamline import MXBeamline, cfg_get, mx_beamline
+from aare.common.logger_config import setup_logger
+from aare.gui.auth import auth
+from aare.gui.new_gui.fonts import load_fonts
+from aare.gui.new_gui.main_window import MainWindow
+
+logger = setup_logger("aareGUI.new")
+
+
+def _beamline_defaults():
+ """Resolve (daq_url, cert_path, pred_zmq) defaults for the active beamline."""
+ match mx_beamline():
+ case MXBeamline.X06DA:
+ return (
+ cfg_get("gui.daq.daq_url", "https://mx-x06da-queue-01.psi.ch"),
+ cfg_get("gui.daq.cert_path", "/sls/x06da/misc/.cert/6d.crt"),
+ cfg_get("gui.cameras.prediction_zmq_url", "tcp://mx-ml:9091"),
+ )
+ case MXBeamline.X10SA:
+ return (
+ cfg_get("gui.daq.daq_url", "https://mx-x10sa-queue-01.psi.ch"),
+ cfg_get("gui.daq.cert_path", "/sls/x10sa/misc/.cert/10s.crt"),
+ cfg_get("gui.cameras.prediction_zmq_url", "tcp://x10sa-spark-01:9091"),
+ )
+ case MXBeamline.X06SA:
+ return (
+ cfg_get("gui.daq.daq_url", "https://mx-x06sa-queue-01.psi.ch"),
+ cfg_get("gui.daq.cert_path", "/sls/x06sa/misc/.cert/6s.crt"),
+ cfg_get("gui.cameras.prediction_zmq_url", ""),
+ )
+ case _:
+ return (
+ cfg_get("gui.daq.daq_url", ""),
+ cfg_get("gui.daq.cert_path", ""),
+ cfg_get("gui.cameras.prediction_zmq_url", ""),
+ )
+
+
+def _clean(value: str | None) -> str | None:
+ if value is None:
+ return None
+ if value == "" or value.lower() == "none":
+ return None
+ return value
+
+
+def main() -> None:
+ app = QApplication(sys.argv)
+ app.setApplicationName("AareGUI")
+ app.setApplicationVersion("0.4.0-new")
+ app.setOrganizationName("PSI")
+
+ basedir = os.path.dirname(os.path.dirname(__file__))
+ icon_path = os.path.join(basedir, "graphics", "aaregui_logo.svg")
+ if os.path.isfile(icon_path):
+ app.setWindowIcon(QtGui.QIcon(icon_path))
+
+ load_fonts()
+
+ default_url, default_cert, default_pred = _beamline_defaults()
+
+ parser = QCommandLineParser()
+ parser.setApplicationDescription("PSI AareGUI (redesign)")
+ parser.addHelpOption()
+ parser.addVersionOption()
+ url_opt = QCommandLineOption(["u", "aaredaq-url"], "Base AareDAQ URL", "url",
+ default_url)
+ cert_opt = QCommandLineOption(["c", "aaredaq-cert-path"],
+ "Server certificate path", "cert", default_cert)
+ pred_opt = QCommandLineOption(["p", "pred-zmq"],
+ "Prediction/SAMCAM ZeroMQ URL", "pred-zmq",
+ default_pred)
+ for opt in (url_opt, cert_opt, pred_opt):
+ parser.addOption(opt)
+ parser.process(app)
+
+ base_url = _clean(parser.value(url_opt))
+ cert_path = _clean(parser.value(cert_opt))
+ pred_zmq = _clean(parser.value(pred_opt))
+
+ try:
+ token = auth(base_url, cert_path)
+ if not token or token.count(".") != 2:
+ raise RuntimeError("Authentication did not return a valid token.")
+ logger.info("Authentication successful")
+ except Exception as exc:
+ logger.error("Cannot connect to AareDAQ server: %s", exc)
+ logger.error(traceback.format_exc())
+ QMessageBox.critical(
+ None, "Authentication Error",
+ f"Cannot connect to AareDAQ server:\n{exc}\n\n"
+ "Please check the server is running and your network connection.",
+ )
+ sys.exit(1)
+
+ win = MainWindow(base_url=base_url, token=token, pred_zmq_addr=pred_zmq)
+ win.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/aare/gui/new_gui/automation_view.py b/src/aare/gui/new_gui/automation_view.py
new file mode 100644
index 00000000..3909ed97
--- /dev/null
+++ b/src/aare/gui/new_gui/automation_view.py
@@ -0,0 +1,530 @@
+"""Automation mode — the queue builder (library · queue · start bar).
+
+The queue is GUI-local state held in :class:`AppState`. ``Start`` iterates
+``automated_scan`` per sample (wired in the main window). Per-sample protocol
+chips are local for now; a pausable protocol-driven automation comes later.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.state import PROTOCOL_KEYS, AppState
+from aare.gui.new_gui.theme import (
+ FONT_MONO_FALLBACK,
+ LIBRARY_W,
+ START_BAR_H,
+ Palette,
+)
+from aare.gui.new_gui.widgets.automation_progress import AutomationProgressPanel
+from aare.gui.new_gui.widgets.common import Chip, micro_label, section_label
+from aare.gui.new_gui.widgets.sample_changer import (
+ sample_id,
+ sample_label,
+ sample_pos,
+ sample_puck,
+)
+
+# Per-sample cost model (minutes), from the prototype; replace with real estimator.
+COST_MIN = {"base": 1, "center": 1, "raster": 3, "xrf": 1, "collect": 8}
+
+
+def _thumb(palette: Palette, size: int = 34) -> QLabel:
+ t = QLabel()
+ t.setFixedSize(size, size)
+ t.setStyleSheet(
+ "border-radius:8px; background: qradialgradient(cx:0.7, cy:0.75,"
+ " radius:1.2, fx:0.7, fy:0.75, stop:0 #3a342d, stop:1 #121113);"
+ )
+ return t
+
+
+def estimate_minutes(queue) -> int:
+ total = 0
+ for item in queue:
+ total += COST_MIN["base"]
+ for k in PROTOCOL_KEYS:
+ if item.protocol.get(k):
+ total += COST_MIN[k]
+ return total
+
+
+def fmt_eta(minutes: int) -> str:
+ h, m = divmod(minutes, 60)
+ return (f"{h}h " if h else "") + f"{m}m"
+
+
+class LibraryRow(QWidget):
+ add_clicked = Signal(object)
+
+ def __init__(self, sample, palette: Palette, added: bool, parent=None):
+ super().__init__(parent)
+ self._sample = sample
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(8, 8, 8, 8)
+ lay.setSpacing(10)
+ lay.addWidget(_thumb(palette))
+ col = QVBoxLayout()
+ col.setSpacing(0)
+ name = QLabel(sample_label(sample))
+ name.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:12px; font-weight:600;"
+ )
+ meta = QLabel(sample_pos(sample))
+ meta.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:10.5px;"
+ f" color:{palette.text_faint};"
+ )
+ col.addWidget(name)
+ col.addWidget(meta)
+ lay.addLayout(col, 1)
+
+ self._add = QPushButton("✓" if added else "+")
+ self._add.setFixedSize(28, 28)
+ self._add.setEnabled(not added)
+ self._add.setCursor(Qt.PointingHandCursor if not added else Qt.ArrowCursor)
+ if added:
+ css = f"background:{palette.success_tint}; color:{palette.success}; font-size:13px;"
+ else:
+ css = f"background:{palette.accent}; color:#fff; font-size:17px;"
+ self._add.setStyleSheet(
+ f"QPushButton {{ {css} border:none; border-radius:8px; font-weight:600; }}"
+ )
+ self._add.clicked.connect(lambda: self.add_clicked.emit(self._sample))
+ lay.addWidget(self._add)
+
+
+class LibraryPanel(QWidget):
+ add_sample = Signal(object)
+ add_puck = Signal(str)
+
+ def __init__(self, state: AppState, palette: Palette, parent=None):
+ super().__init__(parent)
+ self.setObjectName("SidePanel")
+ self.setFixedWidth(LIBRARY_W)
+ self._state = state
+ self._p = palette
+ self._samples: list = []
+
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+
+ header = QWidget()
+ header.setStyleSheet(f"border-bottom:1px solid {palette.border_panel};")
+ hl = QVBoxLayout(header)
+ hl.setContentsMargins(14, 14, 14, 10)
+ hl.setSpacing(10)
+ hl.addWidget(section_label("LIBRARY"))
+ self._search = QLineEdit()
+ self._search.setPlaceholderText("Search samples…")
+ self._search.textChanged.connect(self._rebuild)
+ hl.addWidget(self._search)
+ outer.addWidget(header)
+
+ self._scroll = QScrollArea()
+ self._scroll.setWidgetResizable(True)
+ self._scroll.setFrameShape(QScrollArea.NoFrame)
+ self._list = QWidget()
+ self._list_lay = QVBoxLayout(self._list)
+ self._list_lay.setContentsMargins(8, 8, 8, 8)
+ self._list_lay.setSpacing(2)
+ self._list_lay.addStretch(1)
+ self._scroll.setWidget(self._list)
+ outer.addWidget(self._scroll, 1)
+
+ # ADD WHOLE PUCK footer
+ self._footer = QWidget()
+ self._footer.setStyleSheet(f"border-top:1px solid {palette.border_panel};")
+ self._footer_lay = QVBoxLayout(self._footer)
+ self._footer_lay.setContentsMargins(12, 12, 12, 12)
+ self._footer_lay.setSpacing(6)
+ self._footer_lay.addWidget(micro_label("ADD WHOLE PUCK"))
+ outer.addWidget(self._footer)
+
+ def set_samples(self, samples) -> None:
+ self._samples = list(samples)
+ self._rebuild()
+ self._rebuild_pucks()
+
+ def refresh_added(self) -> None:
+ self._rebuild()
+
+ def _queued_ids(self) -> set:
+ return {item.sample_id for item in self._state.queue}
+
+ def _rebuild(self) -> None:
+ while self._list_lay.count() > 1:
+ w = self._list_lay.takeAt(0).widget()
+ if w:
+ w.setParent(None)
+ w.deleteLater()
+ query = self._search.text().strip().lower()
+ queued = self._queued_ids()
+ for s in self._samples:
+ if query and query not in sample_label(s).lower():
+ continue
+ row = LibraryRow(s, self._p, added=sample_id(s) in queued)
+ row.add_clicked.connect(self.add_sample)
+ self._list_lay.insertWidget(self._list_lay.count() - 1, row)
+
+ def _rebuild_pucks(self) -> None:
+ # clear all but the label
+ while self._footer_lay.count() > 1:
+ w = self._footer_lay.takeAt(1).widget()
+ if w:
+ w.setParent(None)
+ w.deleteLater()
+ pucks: dict[str, int] = {}
+ for s in self._samples:
+ pucks[sample_puck(s)] = pucks.get(sample_puck(s), 0) + 1
+ for puck, n in pucks.items():
+ btn = QPushButton()
+ btn.setCursor(Qt.PointingHandCursor)
+ btn.setText(f"+ {puck} {n}")
+ btn.setStyleSheet(
+ f"QPushButton {{ text-align:left; background:{self._p.surface};"
+ f" border:1px solid {self._p.border_control}; border-radius:8px;"
+ f" padding:9px 12px; font-size:12.5px; color:{self._p.text_secondary}; }}"
+ )
+ btn.clicked.connect(lambda _=False, pk=puck: self.add_puck.emit(pk))
+ self._footer_lay.addWidget(btn)
+
+
+class QueueRow(QWidget):
+ toggled = Signal(int, str) # index, protocol key
+ move = Signal(int, int) # index, direction
+ remove = Signal(int)
+
+ def __init__(self, index: int, item, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._index = index
+ p = palette
+ self.setAttribute(Qt.WA_StyledBackground, True)
+ self.setStyleSheet(
+ f"QueueRow {{ background:{p.surface}; border:1px solid {p.border_panel};"
+ f" border-radius:11px; }}"
+ )
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(14, 11, 14, 11)
+ lay.setSpacing(14)
+
+ handle = QLabel("⠿")
+ handle.setStyleSheet(f"color:{p.pending_node}; font-size:16px;")
+ handle.setCursor(Qt.OpenHandCursor)
+ lay.addWidget(handle)
+
+ idx = QLabel(str(index + 1))
+ idx.setFixedWidth(20)
+ idx.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+ idx.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; color:{p.text_faint};"
+ )
+ lay.addWidget(idx)
+
+ lay.addWidget(_thumb(palette, 38))
+
+ col = QVBoxLayout()
+ col.setSpacing(0)
+ name = QLabel(sample_label(item.sample))
+ name.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:13px; font-weight:600;"
+ )
+ meta = QLabel(f"{sample_puck(item.sample)} · {sample_pos(item.sample)}")
+ meta.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:10.5px; color:{p.text_faint};"
+ )
+ col.addWidget(name)
+ col.addWidget(meta)
+ wrap = QWidget()
+ wrap.setLayout(col)
+ wrap.setMinimumWidth(140)
+ lay.addWidget(wrap)
+
+ lay.addStretch(1)
+
+ chips = QHBoxLayout()
+ chips.setSpacing(5)
+ 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))
+ chips.addWidget(chip)
+ lay.addLayout(chips)
+
+ reorder = QVBoxLayout()
+ reorder.setSpacing(2)
+ up = self._reorder_btn("▲", palette)
+ down = self._reorder_btn("▼", palette)
+ up.clicked.connect(lambda: self.move.emit(self._index, -1))
+ down.clicked.connect(lambda: self.move.emit(self._index, 1))
+ reorder.addWidget(up)
+ reorder.addWidget(down)
+ lay.addLayout(reorder)
+
+ rm = QPushButton("✕")
+ rm.setFixedSize(28, 28)
+ rm.setCursor(Qt.PointingHandCursor)
+ rm.setStyleSheet(
+ f"QPushButton {{ border:none; background:transparent;"
+ f" color:{p.danger}; font-size:14px; }}"
+ )
+ 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)
+ 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}; }}"
+ )
+ return b
+
+
+class AutomationView(QWidget):
+ start_requested = Signal()
+ pause_requested = Signal()
+ stop_requested = Signal()
+ save_template_requested = Signal() # TODO: wire to template persistence later
+
+ def __init__(self, state: AppState, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._state = state
+ self._p = palette
+
+ root = QHBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ # left: library
+ self.library = LibraryPanel(state, palette)
+ self.library.add_sample.connect(self._on_add_sample)
+ self.library.add_puck.connect(self._on_add_puck)
+ root.addWidget(self.library)
+
+ # centre: queue
+ center = QWidget()
+ cl = QVBoxLayout(center)
+ cl.setContentsMargins(0, 0, 0, 0)
+ cl.setSpacing(0)
+ cl.addWidget(self._build_header())
+ cl.addWidget(self._build_queue_area(), 1)
+ cl.addWidget(self._build_start_bar())
+ root.addWidget(center, 1)
+
+ # right: live run progress
+ self.progress = AutomationProgressPanel(palette)
+ root.addWidget(self.progress)
+
+ state.queue_changed.connect(self._refresh)
+ self._refresh()
+
+ def set_running(self, running: bool, paused: bool = False) -> None:
+ """Toggle Start/Pause/Stop affordances while automation runs."""
+ self._start.setVisible(not running)
+ self._pause.setVisible(running)
+ self._stop.setVisible(running)
+ self._pause.setText("▶ Resume" if paused else "❚❚ Pause")
+ if not running:
+ self.progress.set_idle()
+
+ # ---- header ----
+ def _build_header(self) -> QWidget:
+ p = self._p
+ head = QWidget()
+ head.setObjectName("HeaderBlock")
+ lay = QVBoxLayout(head)
+ lay.setContentsMargins(24, 18, 24, 14)
+ lay.setSpacing(14)
+
+ title_row = QHBoxLayout()
+ title_row.setSpacing(12)
+ title = QLabel("Automation queue")
+ title.setStyleSheet("font-size:19px; font-weight:700;")
+ title_row.addWidget(title)
+ self._count_lbl = QLabel("")
+ self._count_lbl.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:13px; color:{p.text_muted};"
+ )
+ title_row.addWidget(self._count_lbl)
+ title_row.addStretch(1)
+ clear = QPushButton("Clear all")
+ clear.setCursor(Qt.PointingHandCursor)
+ clear.setFlat(True)
+ clear.setStyleSheet(
+ f"QPushButton {{ border:none; background:transparent;"
+ f" color:{p.text_faint}; font-size:12px; }}"
+ )
+ clear.clicked.connect(self._state.queue_clear)
+ title_row.addWidget(clear)
+ lay.addLayout(title_row)
+
+ proto_row = QHBoxLayout()
+ proto_row.setSpacing(10)
+ proto_row.addWidget(micro_label("DEFAULT PROTOCOL FOR NEW ADDS"))
+ self._default_chips: dict[str, Chip] = {}
+ for key in PROTOCOL_KEYS:
+ chip = Chip(key.capitalize(), p, checked=self._state.defaults.get(key))
+ chip.clicked.connect(lambda _=False, k=key: self._state.toggle_default(k))
+ self._default_chips[key] = chip
+ proto_row.addWidget(chip)
+ proto_row.addStretch(1)
+ lay.addLayout(proto_row)
+ return head
+
+ # ---- queue area ----
+ def _build_queue_area(self) -> QWidget:
+ self._scroll = QScrollArea()
+ self._scroll.setWidgetResizable(True)
+ self._scroll.setFrameShape(QScrollArea.NoFrame)
+ self._queue_host = QWidget()
+ self._queue_lay = QVBoxLayout(self._queue_host)
+ self._queue_lay.setContentsMargins(24, 14, 24, 14)
+ self._queue_lay.setSpacing(8)
+ self._queue_lay.addStretch(1)
+ self._scroll.setWidget(self._queue_host)
+
+ # empty-state overlay label kept simple as a member
+ self._empty = QWidget()
+ el = QVBoxLayout(self._empty)
+ el.setAlignment(Qt.AlignCenter)
+ plus = QLabel("+")
+ plus.setAlignment(Qt.AlignCenter)
+ plus.setStyleSheet(f"font-size:30px; color:{self._p.text_faint};")
+ t1 = QLabel("Queue is empty")
+ t1.setAlignment(Qt.AlignCenter)
+ t1.setStyleSheet(f"font-size:14px; font-weight:600; color:{self._p.text_faint};")
+ t2 = QLabel("Add samples from the library, or drop in a whole puck.")
+ t2.setAlignment(Qt.AlignCenter)
+ t2.setStyleSheet(f"font-size:12.5px; color:{self._p.text_faint};")
+ el.addWidget(plus)
+ el.addWidget(t1)
+ el.addWidget(t2)
+
+ host = QWidget()
+ hl = QVBoxLayout(host)
+ hl.setContentsMargins(0, 0, 0, 0)
+ hl.setSpacing(0)
+ hl.addWidget(self._empty)
+ hl.addWidget(self._scroll)
+ return host
+
+ # ---- start bar ----
+ def _build_start_bar(self) -> QWidget:
+ p = self._p
+ bar = QWidget()
+ bar.setObjectName("BottomPanel")
+ bar.setFixedHeight(START_BAR_H)
+ lay = QHBoxLayout(bar)
+ lay.setContentsMargins(24, 0, 24, 0)
+ lay.setSpacing(16)
+ col = QVBoxLayout()
+ col.setSpacing(1)
+ self._start_count = QLabel("")
+ self._start_count.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:13px; font-weight:600;"
+ )
+ self._start_sub = QLabel("")
+ self._start_sub.setStyleSheet(f"font-size:11.5px; color:{p.text_muted};")
+ col.addWidget(self._start_count)
+ col.addWidget(self._start_sub)
+ lay.addLayout(col)
+ lay.addStretch(1)
+
+ save = QPushButton("Save as template")
+ save.setCursor(Qt.PointingHandCursor)
+ save.setStyleSheet(
+ f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};"
+ f" border-radius:9px; padding:10px 16px; font-size:12.5px;"
+ f" color:{p.text_secondary}; }}"
+ )
+ save.clicked.connect(self.save_template_requested)
+ lay.addWidget(save)
+
+ self._start = QPushButton("▶ Start automation")
+ self._start.setCursor(Qt.PointingHandCursor)
+ self._start.setStyleSheet(
+ f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
+ f" border-radius:10px; padding:12px 26px; font-size:14px; font-weight:700; }}"
+ )
+ self._start.clicked.connect(self.start_requested)
+ lay.addWidget(self._start)
+
+ self._pause = QPushButton("❚❚ Pause")
+ self._pause.setCursor(Qt.PointingHandCursor)
+ self._pause.setStyleSheet(
+ f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};"
+ f" border-radius:10px; padding:12px 22px; font-size:13px; color:{p.text_secondary}; }}"
+ )
+ self._pause.clicked.connect(self.pause_requested)
+ self._pause.setVisible(False)
+ lay.addWidget(self._pause)
+
+ self._stop = QPushButton("⏹ Stop")
+ self._stop.setCursor(Qt.PointingHandCursor)
+ self._stop.setStyleSheet(
+ f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};"
+ f" border-radius:10px; padding:12px 22px; font-size:13px; color:{p.danger}; }}"
+ )
+ self._stop.clicked.connect(self.stop_requested)
+ self._stop.setVisible(False)
+ lay.addWidget(self._stop)
+ return bar
+
+ # ---- handlers ----
+ def _on_add_sample(self, sample) -> None:
+ self._state.queue_add(sample)
+
+ def _on_add_puck(self, puck: str) -> None:
+ members = [s for s in self.library._samples if sample_puck(s) == puck]
+ self._state.queue_add_many(members)
+
+ def set_samples(self, samples) -> None:
+ self.library.set_samples(samples)
+
+ def _refresh(self) -> None:
+ # sync default chips
+ for key, chip in self._default_chips.items():
+ want = bool(self._state.defaults.get(key))
+ if chip.isChecked() != want:
+ chip.blockSignals(True)
+ chip.setChecked(want)
+ chip.blockSignals(False)
+
+ # rebuild queue rows
+ while self._queue_lay.count() > 1:
+ w = self._queue_lay.takeAt(0).widget()
+ if w:
+ w.setParent(None)
+ w.deleteLater()
+ 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_lay.insertWidget(self._queue_lay.count() - 1, row)
+
+ empty = len(queue) == 0
+ self._empty.setVisible(empty)
+ self._scroll.setVisible(not empty)
+
+ mins = estimate_minutes(queue)
+ eta = fmt_eta(mins)
+ self._count_lbl.setText(f"{len(queue)} samples · est {eta}")
+ self._start_count.setText(f"{len(queue)} samples queued")
+ self._start_sub.setText(f"estimated runtime {eta} · auto-advance on")
+ self._start.setEnabled(not empty)
+
+ self.library.refresh_added()
diff --git a/src/aare/gui/new_gui/fonts.py b/src/aare/gui/new_gui/fonts.py
new file mode 100644
index 00000000..870fd206
--- /dev/null
+++ b/src/aare/gui/new_gui/fonts.py
@@ -0,0 +1,59 @@
+"""Font registration for the redesigned AareGUI.
+
+The design calls for IBM Plex Sans (UI) + IBM Plex Mono (numeric readouts,
+sample names, status bar). If the TTFs are bundled under ``assets/fonts`` they
+are registered with the application; otherwise the QSS fallbacks in
+:mod:`theme` quietly take over so the GUI still runs everywhere.
+"""
+
+from __future__ import annotations
+
+import os
+
+from PySide6.QtGui import QFontDatabase
+
+from aare.common.logger_config import setup_logger
+
+logger = setup_logger("aareGUI.new")
+
+_FONT_DIR = os.path.join(os.path.dirname(__file__), "assets", "fonts")
+
+# File names we look for (Google Fonts / SIL OFL distribution names).
+_FONT_FILES = (
+ "IBMPlexSans-Regular.ttf",
+ "IBMPlexSans-Medium.ttf",
+ "IBMPlexSans-SemiBold.ttf",
+ "IBMPlexSans-Bold.ttf",
+ "IBMPlexMono-Regular.ttf",
+ "IBMPlexMono-Medium.ttf",
+ "IBMPlexMono-SemiBold.ttf",
+)
+
+
+def load_fonts() -> list[str]:
+ """Register bundled IBM Plex fonts. Returns the loaded family names.
+
+ Safe to call once at startup. Missing files are skipped without error so a
+ developer checkout without the TTFs still launches (falling back to system
+ fonts declared in the QSS).
+ """
+ families: list[str] = []
+ if not os.path.isdir(_FONT_DIR):
+ logger.info(
+ "Bundled fonts not found at %s; using system fallbacks.", _FONT_DIR
+ )
+ return families
+
+ for name in _FONT_FILES:
+ path = os.path.join(_FONT_DIR, name)
+ if not os.path.isfile(path):
+ continue
+ font_id = QFontDatabase.addApplicationFont(path)
+ if font_id == -1:
+ logger.warning("Failed to register font %s", path)
+ continue
+ families.extend(QFontDatabase.applicationFontFamilies(font_id))
+
+ if families:
+ logger.info("Registered fonts: %s", sorted(set(families)))
+ return families
\ No newline at end of file
diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py
new file mode 100644
index 00000000..472008f7
--- /dev/null
+++ b/src/aare/gui/new_gui/main_window.py
@@ -0,0 +1,561 @@
+"""Main window for the redesigned AareGUI.
+
+Assembles the shared chrome (top bar / status bar) and a QStackedWidget body
+(Manual / Automation), then wires everything to the existing DAQWorker and the
+SAMCAM PredictionSubscriber. This is the only module that knows about the
+backend; the views speak in intent signals and request objects.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QTimer, Slot
+from PySide6.QtWidgets import (
+ QStackedWidget,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.common.beamline import cfg_get, mx_beamline
+from aare.common.logger_config import setup_logger
+from aare.gui.new_gui.automation_view import AutomationView
+from aare.gui.new_gui.manual_view import ManualView
+from aare.gui.new_gui.state import AppState
+from aare.gui.new_gui.status_bar import StatusBar
+from aare.gui.new_gui.theme import LIGHT, build_qss
+from aare.gui.new_gui.top_bar import TopBar
+from aare.gui.new_gui.widgets.alert_banner import AlertBanner
+
+logger = setup_logger("aareGUI.new")
+
+_BEAMLINE_SUBTITLE = {"X06DA": "PXIII", "X10SA": "PXII", "X06SA": "PXI"}
+
+
+def _collect_defaults() -> dict:
+ """Scan defaults from the beamline YAML (with safe fall-backs)."""
+ rot = "data_collection_settings.default_rotation_settings"
+ ras = "data_collection_settings.default_raster_scan_settings"
+ return {
+ "exp_time_s": cfg_get(f"{rot}.exp_time_s", 0.02),
+ "transmission": cfg_get(f"{rot}.transmission", 1.0),
+ "dtz": cfg_get(f"{rot}.dtz", 200.0),
+ "start_omega_deg": cfg_get(f"{rot}.start_omega_deg", 0.0),
+ "increment_omega_deg": cfg_get(f"{rot}.increment_omega_deg", 0.1),
+ "steps": cfg_get(f"{rot}.steps", 1800),
+ "rotation_exp_time_s": cfg_get(f"{rot}.exp_time_s", 0.02),
+ "rotation_dtz": cfg_get(f"{rot}.dtz", 200.0),
+ "raster_exp_time_s": cfg_get(f"{ras}.exp_time_s", 0.02),
+ "raster_dtz": cfg_get(f"{ras}.dtz", 200.0),
+ }
+
+
+class MainWindow(QWidget):
+ def __init__(self, base_url, token, pred_zmq_addr=None, parent=None):
+ super().__init__(parent)
+ self._base_url = base_url
+ self._token = token
+ self._palette = LIGHT
+ self._defaults = _collect_defaults()
+
+ # automation run bookkeeping
+ self._auto_running: list = []
+ self._auto_index = 0
+ self._auto_paused = False
+
+ # baton / session bookkeeping
+ self._staff = False
+ self._baton = None
+ self._pending_dialog = None
+ self._incoming_dialog = None
+ self._baton_poll = QTimer(self)
+ self._baton_poll.setInterval(1000)
+ self._baton_poll.timeout.connect(self._poll_baton_timeout)
+
+ self.setObjectName("AppRoot")
+ self.setStyleSheet(build_qss(self._palette))
+ self.setWindowTitle("AareGUI")
+ self.resize(1400, 860)
+
+ beamline = str(mx_beamline().name if hasattr(mx_beamline(), "name")
+ else mx_beamline())
+ subtitle = _BEAMLINE_SUBTITLE.get(beamline, "")
+ bl_label = f"{beamline} · {subtitle}" if subtitle else beamline
+
+ self.state = AppState(self)
+
+ # chrome + body
+ self.top_bar = TopBar(self._palette, bl_label)
+ self.alert = AlertBanner(self._palette)
+ self.status_bar = StatusBar(self._palette)
+ self.body = QStackedWidget()
+ self.manual = ManualView(self.state, self._palette, self._defaults)
+ self.automation = AutomationView(self.state, self._palette)
+ self.body.addWidget(self.manual)
+ self.body.addWidget(self.automation)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(self.top_bar)
+ layout.addWidget(self.alert)
+ layout.addWidget(self.body, 1)
+ layout.addWidget(self.status_bar)
+
+ # backend
+ self._build_backend(pred_zmq_addr)
+ self._wire()
+
+ # ------------------------------------------------------------- backend
+ def _build_backend(self, pred_zmq_addr) -> None:
+ from aare.gui.threads.daq_worker import DAQWorker
+
+ self.daq = DAQWorker(base_url=self._base_url, token=self._token)
+
+ self.camera_thread = None
+ if pred_zmq_addr:
+ try:
+ from aare.gui.threads.prediction_subscriber import PredictionSubscriber
+
+ self.camera_thread = PredictionSubscriber(
+ pred_zmq_url=pred_zmq_addr, topic=b""
+ )
+ self.camera_thread.start()
+ except Exception as exc: # pragma: no cover - hardware/runtime path
+ logger.warning("Could not start camera stream: %s", exc)
+ self.camera_thread = None
+
+ # -------------------------------------------------------------- wiring
+ def _wire(self) -> None:
+ s = self.state
+
+ # mode toggle <-> state <-> body
+ self.top_bar.mode_changed.connect(s.set_mode)
+ s.mode_changed.connect(self._on_mode_changed)
+
+ # live status fan-out
+ self.daq.update.connect(self.status_bar.update_daq_status)
+ self.daq.update.connect(self.manual.update_daq_status)
+ self.daq.update.connect(self._on_status)
+ self.daq.baton_status_changed.connect(self.top_bar.update_baton_status)
+ self.daq.baton_status_changed.connect(self._on_baton_status)
+
+ # sample list -> changer + library
+ self.daq.spreadsheet.connect(self._on_spreadsheet)
+ self.daq.reference_tools.connect(self._on_reference_tools)
+
+ # baton controls
+ self.top_bar.request_baton.connect(self.daq.request_baton)
+ self.top_bar.release_baton.connect(self.daq.release_baton)
+ self.top_bar.cancel_baton_request.connect(self._on_cancel_baton)
+ self.daq.baton_request_result.connect(self._on_baton_request_result)
+ self.daq.baton_response_result.connect(self._on_baton_response_result)
+ if hasattr(self.daq, "baton_incoming_request"):
+ self.daq.baton_incoming_request.connect(self._on_baton_incoming)
+ if hasattr(self.daq, "baton_timeout_checked"):
+ self.daq.baton_timeout_checked.connect(self._on_baton_timeout)
+
+ # beamline state switching (clickable LED)
+ self.status_bar.state_change_requested.connect(self._on_state_change)
+
+ # manual intents -> DAQ
+ self.manual.center_loop_requested.connect(self.daq.center_loop)
+ self.manual.center_at_point_requested.connect(self._on_center_at_point)
+ # Non-auto raster: smargon_top_left is computed from the drawn grid.
+ self.manual.raster_scan_requested.connect(self.daq.raster_scan)
+ self.manual.rotation_scan_requested.connect(self.daq.standard_scan)
+ self.manual.smart_params_requested.connect(self._on_smart_params)
+ self.manual.xrf_scan_requested.connect(self.daq.fluorimeter_spectrum)
+ self.manual.raster_goto_requested.connect(self.daq.move_smargon)
+ self.manual.status_message.connect(self._note)
+ if hasattr(self.daq, "raster_scan_completed"):
+ self.daq.raster_scan_completed.connect(self.manual.on_raster_completed)
+
+ # XRF spectrum view <-> fluorimeter
+ spec = self.manual.spectrum
+ spec.start_clicked.connect(self.daq.fluorimeter_start)
+ spec.stop_clicked.connect(self.daq.fluorimeter_stop)
+ spec.snapshot_clicked.connect(self.daq.fluorimeter_request_snapshot)
+ if hasattr(self.daq, "fluorimeter_spectrum_update"):
+ self.daq.fluorimeter_spectrum_update.connect(self._on_spectrum)
+ if hasattr(self.daq, "fluorimeter_update"):
+ self.daq.fluorimeter_update.connect(spec.update_live)
+
+ # state mount intents -> DAQ
+ s.mount_requested.connect(self._on_mount_requested)
+ s.unmount_requested.connect(self.daq.unmount)
+
+ # motors -> DAQ
+ self.manual.motors.omega_rel.connect(self.daq.set_omega_rel)
+ self.manual.motors.zoom_changed.connect(self.daq.zoom)
+ self.manual.motors.front_light_changed.connect(self.daq.front_light)
+ self.manual.motors.back_light_changed.connect(self.daq.back_light)
+ self.manual.motors.smargon_jog.connect(self._on_smargon_jog)
+ self.manual.motors.energy_changed.connect(self.daq.change_energy)
+
+ # automation
+ self.automation.start_requested.connect(self._start_automation)
+ self.automation.pause_requested.connect(self._on_auto_pause)
+ self.automation.stop_requested.connect(self._on_auto_stop)
+ self.automation.save_template_requested.connect(
+ lambda: self._note("Save as template is not wired yet.")
+ )
+ self.daq.automated_scan_done.connect(self._on_auto_done)
+ if hasattr(self.daq, "automation_progress"):
+ self.daq.automation_progress.connect(
+ self.automation.progress.update_progress
+ )
+
+ # errors / alerts -> banner
+ if hasattr(self.daq, "http_error"):
+ self.daq.http_error.connect(lambda msg: self._note(msg, error=True))
+ if hasattr(self.daq, "auth_error"):
+ self.daq.auth_error.connect(self._on_auth_error)
+ if hasattr(self.daq, "sample_missing"):
+ self.daq.sample_missing.connect(lambda msg: self._note(msg, error=True))
+ if hasattr(self.daq, "polled_devices_status"):
+ self.daq.polled_devices_status.connect(self._on_device_status)
+ if hasattr(self.daq, "detector_error"):
+ self.daq.detector_error.connect(self._on_device_status)
+ if hasattr(self.daq, "automation_critical_failure"):
+ self.daq.automation_critical_failure.connect(
+ lambda msg: self._note(msg, error=True))
+ if hasattr(self.daq, "manual_collection_critical_failure"):
+ self.daq.manual_collection_critical_failure.connect(
+ lambda msg: self._note(msg, error=True))
+ if hasattr(self.daq, "pss_alarm_changed"):
+ self.daq.pss_alarm_changed.connect(
+ lambda *a: self.alert.show_message(
+ "PSS alarm — check hutch door / interlocks.", "warning"))
+ # completion notices
+ if hasattr(self.daq, "standard_scan_completed"):
+ self.daq.standard_scan_completed.connect(self._on_rotation_completed)
+ if hasattr(self.daq, "raster_scan_completed"):
+ self.daq.raster_scan_completed.connect(self._on_raster_banner)
+
+ # camera stream
+ if self.camera_thread is not None:
+ cam = self.manual.camera
+ self.camera_thread.image.connect(cam.update_pixmap)
+ if hasattr(self.camera_thread, "fps_measure"):
+ self.camera_thread.fps_measure.connect(cam.update_fps)
+ if hasattr(self.camera_thread, "target_point"):
+ self.camera_thread.target_point.connect(cam.update_target_point)
+ if hasattr(self.camera_thread, "update_daq_status"):
+ self.daq.update.connect(self.camera_thread.update_daq_status)
+
+ # ------------------------------------------------------------ handlers
+ @Slot(str)
+ def _on_mode_changed(self, mode: str) -> None:
+ self.body.setCurrentWidget(
+ self.automation if mode == "automation" else self.manual
+ )
+ self.top_bar.set_mode(mode)
+
+ @Slot(object)
+ def _on_spreadsheet(self, payload) -> None:
+ samples = getattr(payload, "s", payload)
+ try:
+ samples = list(samples)
+ except TypeError:
+ samples = []
+ self.manual.changer.set_samples(samples)
+ self.automation.set_samples(samples)
+
+ @Slot(object, bool)
+ def _on_mount_requested(self, sample, reference: bool = False) -> None:
+ self.daq.mount(sample, reference)
+
+ @Slot(object)
+ def _on_reference_tools(self, payload) -> None:
+ tools = getattr(payload, "s", payload)
+ try:
+ tools = list(tools)
+ except TypeError:
+ tools = []
+ self.manual.set_reference_tools(tools)
+
+ # ---- staff + control (guest) state ----
+ @Slot(object)
+ def _on_status(self, s) -> None:
+ session = getattr(s, "session", None)
+ staff = bool(getattr(session, "staff", False)) if session else False
+ if staff != self._staff:
+ self._staff = staff
+ self.top_bar.set_staff(staff)
+ self.manual.set_staff(staff)
+ self._update_control()
+
+ def _on_baton_status(self, status) -> None:
+ self._baton = status
+ self._update_control()
+ # surface an incoming request even without the dedicated signal
+ if getattr(status, "incoming_request", False) and self._incoming_dialog is None:
+ req = getattr(status, "pending_request", None)
+ who = getattr(req, "requester_username", "Another user") if req else "Another user"
+ timeout = getattr(req, "timeout_seconds", 30) if req else 30
+ self._show_incoming_dialog(who, timeout)
+
+ def _update_control(self) -> None:
+ """Grey the camera + show Guest/Vacant overlay unless we hold the baton."""
+ b = self._baton
+ if b is None:
+ self.manual.set_control_state(True)
+ return
+ if getattr(b, "you_are_holder", False):
+ self.manual.set_control_state(True)
+ return
+ holder = getattr(b, "holder", None)
+ if holder is None:
+ self.manual.set_control_state(
+ False, "VACANT", "Grab the baton to take control of the beamline.")
+ return
+ username = getattr(holder, "username", None)
+ holder_staff = bool(getattr(holder, "is_staff", False))
+ if self._staff and username:
+ sub = f"{username} has control of the beamline."
+ elif holder_staff:
+ sub = "Staff has control of the beamline."
+ else:
+ sub = "Another user has control of the beamline."
+ self.manual.set_control_state(False, "GUEST MODE", sub)
+
+ # ---- baton request / response flow ----
+ @Slot(dict)
+ def _on_baton_request_result(self, result: dict) -> None:
+ if result.get("granted") or result.get("already_holder"):
+ self._close_pending_dialog()
+ self._note("Baton acquired.")
+ elif result.get("error"):
+ self._close_pending_dialog()
+ self._note(result.get("message", "Baton request failed."), error=True)
+ elif result.get("pending") or result.get("queued"):
+ timeout = int(result.get("timeout_seconds", 30) or 30)
+ holder = ""
+ if self._baton is not None and getattr(self._baton, "holder", None):
+ if self._staff:
+ holder = getattr(self._baton.holder, "username", "") or ""
+ self._show_pending_dialog(holder, timeout)
+ if result.get("queued"):
+ self._pending_dialog.set_queued()
+ self._baton_poll.start()
+
+ @Slot(dict)
+ def _on_baton_response_result(self, result: dict) -> None:
+ if result.get("accepted"):
+ self._note("Control transferred.")
+ elif result.get("refused"):
+ self._note("Request declined.")
+ self._close_incoming_dialog()
+
+ def _on_baton_incoming(self, payload: dict) -> None:
+ if self._incoming_dialog is not None:
+ return
+ who = payload.get("requester", "Another user")
+ timeout = int(payload.get("timeout", 30) or 30)
+ self._show_incoming_dialog(who, timeout)
+
+ @Slot(dict)
+ def _on_baton_timeout(self, payload: dict) -> None:
+ if self._pending_dialog is None:
+ return
+ if payload.get("granted"):
+ self._close_pending_dialog()
+ self._note("Baton acquired.")
+ elif payload.get("refused"):
+ self._close_pending_dialog()
+ self._note("Request refused.", error=True)
+ elif payload.get("queued"):
+ self._pending_dialog.set_queued()
+ else:
+ self._pending_dialog.update_remaining(payload.get("remaining_seconds", 0))
+
+ def _poll_baton_timeout(self) -> None:
+ if self._pending_dialog is None:
+ self._baton_poll.stop()
+ return
+ if hasattr(self.daq, "check_baton_timeout"):
+ self.daq.check_baton_timeout()
+
+ def _on_cancel_baton(self) -> None:
+ self._close_pending_dialog()
+ if hasattr(self.daq, "cancel_baton_request"):
+ self.daq.cancel_baton_request()
+
+ def _show_pending_dialog(self, holder: str, timeout: int) -> None:
+ from aare.gui.new_gui.widgets.baton_dialogs import BatonPendingDialog
+
+ self._close_pending_dialog()
+ dlg = BatonPendingDialog(holder, timeout, self._palette, self)
+ dlg.cancelled_signal.connect(self._on_cancel_baton)
+ self._pending_dialog = dlg
+ dlg.show()
+
+ def _close_pending_dialog(self) -> None:
+ self._baton_poll.stop()
+ if self._pending_dialog is not None:
+ self._pending_dialog.close()
+ self._pending_dialog = None
+
+ def _show_incoming_dialog(self, requester: str, timeout: int) -> None:
+ from aare.gui.new_gui.widgets.baton_dialogs import BatonIncomingDialog
+
+ dlg = BatonIncomingDialog(requester, timeout, self._palette, self)
+ dlg.accepted_signal.connect(lambda: self.daq.respond_to_baton_request(True))
+ dlg.refused_signal.connect(lambda: self.daq.respond_to_baton_request(False))
+ dlg.finished.connect(self._close_incoming_dialog)
+ self._incoming_dialog = dlg
+ dlg.show()
+
+ def _close_incoming_dialog(self, *args) -> None:
+ if self._incoming_dialog is not None:
+ self._incoming_dialog = None
+
+ # ---- beamline state switching ----
+ @Slot(str)
+ def _on_state_change(self, key: str) -> None:
+ method = getattr(self.daq, key, None)
+ if callable(method):
+ self._note(f"Switching beamline state: {key}")
+ method()
+ else:
+ self._note(f"No DAQ method for state '{key}'.", error=True)
+
+ def _on_smargon_jog(self, dx_mm: float, dy_mm: float) -> None:
+ """Relative sample-holder jog in beamline (screen) space.
+
+ The delta is a beamline-frame offset; ``beamline_to_smargon`` rotates it
+ through the current omega/phi/chi into an absolute smargon sh_mm target,
+ so the jog stays screen-intuitive at any goniometer angle. Arrow signs:
+ ←/→ = -x/+x, ↑/↓ = -y/+y (camera y points down). Flip in MotorsPanel if
+ the beamline convention differs.
+ """
+ from aare.common.coordinate import Coordinate, SmargonCoordinate
+
+ geom = getattr(self.manual.last_status, "geom", None)
+ if geom is None or not hasattr(geom, "beamline_to_smargon"):
+ self._note("No geometry yet; cannot jog.", error=True)
+ return
+ try:
+ sh = geom.beamline_to_smargon(Coordinate(x=dx_mm, y=dy_mm, z=0.0))
+ except Exception as exc:
+ self._note(f"Could not compute jog target: {exc}", error=True)
+ return
+ self.daq.move_smargon(SmargonCoordinate(sh_mm=sh))
+
+ def _on_spectrum(self, model) -> None:
+ self.manual.spectrum.update_spectrum(model)
+ self.manual.show_spectrum()
+
+ def _on_smart_params(self, sp) -> None:
+ """Automate-parameters (simple strategy): forward seeds, then collect."""
+ from aare.gui.new_gui import requests
+
+ if hasattr(self.daq, "smart_params"):
+ self.daq.smart_params(sp)
+ self.daq.standard_scan(requests.rotation_from_simple(sp))
+
+ def _on_center_at_point(self, img_x: float, img_y: float) -> None:
+ """Click-to-centre: move the clicked camera pixel onto the beam.
+
+ Mirrors the existing GUI: picture -> sample (beamline) -> smargon.
+ """
+ from aare.common.coordinate import Coordinate, SmargonCoordinate
+
+ geom = getattr(self.manual.last_status, "geom", None)
+ if geom is None or not hasattr(geom, "picture_to_sample"):
+ self._note("No geometry yet; cannot centre.", error=True)
+ return
+ try:
+ sample = geom.picture_to_sample(Coordinate(x=img_x, y=img_y))
+ sh = geom.beamline_to_smargon(sample)
+ except Exception as exc:
+ self._note(f"Could not compute centre target: {exc}", error=True)
+ return
+ self.daq.move_smargon(SmargonCoordinate(sh_mm=sh))
+
+ # ---- automation loop (GUI-local; iterates automated_scan, pausable) ----
+ def _start_automation(self) -> None:
+ queue = list(self.state.queue)
+ if not queue:
+ return
+ self._auto_running = queue
+ self._auto_index = 0
+ self._auto_paused = False
+ self.automation.set_running(True, paused=False)
+ if hasattr(self.daq, "start_automation_progress_stream"):
+ self.daq.start_automation_progress_stream()
+ self._note(f"Starting automation: {len(queue)} samples queued.")
+ self._run_next_auto()
+
+ def _run_next_auto(self) -> None:
+ if self._auto_paused:
+ return
+ if self._auto_index >= len(self._auto_running):
+ self._note("Automation queue complete.")
+ self._auto_running = []
+ self.automation.set_running(False)
+ return
+ item = self._auto_running[self._auto_index]
+ self.daq.automated_scan(item.sample)
+
+ @Slot(int, bool, str)
+ def _on_auto_done(self, sample_id: int, success: bool, error: str) -> None:
+ if not self._auto_running:
+ return
+ if not success:
+ self._note(f"Automation stopped: {error}", error=True)
+ self._auto_running = []
+ self.automation.set_running(False)
+ return
+ self._auto_index += 1
+ self._run_next_auto() # no-op while paused; resumes on _on_auto_pause
+
+ def _on_auto_pause(self) -> None:
+ if not self._auto_running:
+ return
+ self._auto_paused = not self._auto_paused
+ self.automation.set_running(True, paused=self._auto_paused)
+ if self._auto_paused:
+ self._note("Automation will pause after the current sample.")
+ else:
+ self._note("Resuming automation.")
+ self._run_next_auto()
+
+ def _on_auto_stop(self) -> None:
+ self._auto_running = []
+ self._auto_paused = False
+ self.automation.set_running(False)
+ self._note("Automation stopped.")
+
+ def _on_auth_error(self) -> None:
+ self._note("Lost the baton / authentication error.", error=True)
+
+ def _note(self, message: str, error: bool = False, level: str | None = None) -> None:
+ if error:
+ logger.error(message)
+ else:
+ logger.info(message)
+ self.alert.show_message(message, level or ("error" if error else "info"))
+
+ def _on_device_status(self, message: str, is_error: bool) -> None:
+ if is_error:
+ self.alert.show_message(message, "error")
+
+ def _on_rotation_completed(self, _completed) -> None:
+ self.alert.show_message("Rotation collection complete.", "success")
+
+ def _on_raster_banner(self, _completed) -> None:
+ self.alert.show_message("Raster scan complete.", "success")
+
+ # --------------------------------------------------------------- close
+ def closeEvent(self, event): # noqa: N802
+ try:
+ if hasattr(self.daq, "cleanup"):
+ self.daq.cleanup()
+ except Exception:
+ pass
+ try:
+ if self.camera_thread is not None and hasattr(self.camera_thread, "stop"):
+ self.camera_thread.stop()
+ except Exception:
+ pass
+ super().closeEvent(event)
diff --git a/src/aare/gui/new_gui/manual_view.py b/src/aare/gui/new_gui/manual_view.py
new file mode 100644
index 00000000..e863ee79
--- /dev/null
+++ b/src/aare/gui/new_gui/manual_view.py
@@ -0,0 +1,339 @@
+"""Manual mode — the Mount-first cockpit (changer · camera · motors)."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QButtonGroup,
+ QHBoxLayout,
+ QPushButton,
+ QStackedWidget,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui import requests
+from aare.gui.new_gui.state import AppState
+from aare.gui.new_gui.theme import CAMERA_TABS_H, Palette
+from aare.gui.new_gui.widgets.camera import CameraViewport
+from aare.gui.new_gui.widgets.motors_panel import MotorsPanel
+from aare.gui.new_gui.widgets.pipeline_panel import PipelinePanel
+from aare.gui.new_gui.widgets.heatmap import build_heatmap_qimage, metric_value
+from aare.gui.new_gui.widgets.raster_grids_dialog import RasterGridsDialog
+from aare.gui.new_gui.widgets.sample_changer import SampleChangerPanel, sample_id
+from aare.gui.new_gui.widgets.spectrum_view import SpectrumView
+
+
+class ManualView(QWidget):
+ """Three-column cockpit. Emits parametrized backend requests for wiring."""
+
+ # backend intents (connected to DAQWorker by the main window)
+ center_loop_requested = Signal()
+ center_at_point_requested = Signal(float, float) # camera image pixel (x, y)
+ raster_scan_requested = Signal(object) # RasterGridRequest
+ rotation_scan_requested = Signal(object) # RotationScanRequest
+ smart_params_requested = Signal(object) # SimpleScanParameters (automate mode)
+ xrf_scan_requested = Signal(object) # FluorescenceSpectrumParameterModel
+ raster_goto_requested = Signal(object) # SmargonCoordinate (goto a grid)
+ status_message = Signal(str) # transient note to surface in UI
+
+ def __init__(self, state: AppState, palette: Palette, defaults: dict, parent=None):
+ super().__init__(parent)
+ self._state = state
+ self._p = palette
+ self._defaults = defaults or {}
+ self._last_status = None
+ self._grid_rect_img: tuple[float, float, float, float] | None = None
+ self._raster_grids: list = []
+ self._grids_dialog = None
+
+ root = QHBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ # --- left: sample changer ---
+ self.changer = SampleChangerPanel(palette)
+ root.addWidget(self.changer)
+
+ # --- centre column ---
+ center = QWidget()
+ cl = QVBoxLayout(center)
+ cl.setContentsMargins(0, 0, 0, 0)
+ cl.setSpacing(0)
+ self.camera = CameraViewport(palette)
+ self.spectrum = SpectrumView(palette)
+ self.center_stack = QStackedWidget()
+ self.center_stack.addWidget(self.camera) # index 0
+ self.center_stack.addWidget(self.spectrum) # index 1
+ cl.addWidget(self._build_camera_tabs())
+ cl.addWidget(self.center_stack, 1)
+
+ # single pipeline panel, usable mounted and not-mounted (pre-set params)
+ self.pipeline = PipelinePanel(palette, self._defaults)
+ cl.addWidget(self.pipeline)
+ root.addWidget(center, 1)
+
+ # --- right: motors ---
+ self.motors = MotorsPanel(palette)
+ root.addWidget(self.motors)
+
+ self._connect()
+ self._refresh_mount()
+ self._refresh_pipe()
+
+ # ------------------------------------------------------------- build
+ def _build_camera_tabs(self) -> QWidget:
+ bar = QWidget()
+ bar.setObjectName("CameraTabs")
+ bar.setFixedHeight(CAMERA_TABS_H)
+ lay = QHBoxLayout(bar)
+ lay.setContentsMargins(14, 0, 14, 0)
+ lay.setSpacing(2)
+ lay.setAlignment(Qt.AlignBottom)
+ self._tab_group = QButtonGroup(self)
+ self._tab_group.setExclusive(True)
+ self._tabs: dict[str, QPushButton] = {}
+ # (label, center-stack index) — Gonio/Beamline still show the camera (Phase 2)
+ for i, (name, idx) in enumerate(
+ (("Sample camera", 0), ("Gonio", 0), ("Beamline", 0), ("XRF spectrum", 1))
+ ):
+ tab = QPushButton(name)
+ tab.setObjectName("CamTab")
+ tab.setCheckable(True)
+ tab.setCursor(Qt.PointingHandCursor)
+ if i == 0:
+ tab.setChecked(True)
+ tab.clicked.connect(lambda _=False, ix=idx: self.center_stack.setCurrentIndex(ix))
+ self._tab_group.addButton(tab)
+ self._tabs[name] = tab
+ lay.addWidget(tab)
+ lay.addStretch(1)
+ return bar
+
+ def show_spectrum(self) -> None:
+ """Switch the centre view to the XRF spectrum and check its tab."""
+ self.center_stack.setCurrentIndex(1)
+ self._tabs["XRF spectrum"].setChecked(True)
+
+ # ----------------------------------------------------------- wiring
+ def _connect(self) -> None:
+ s = self._state
+ # state -> view
+ s.mount_changed.connect(self._refresh_mount)
+ s.pipe_changed.connect(self._refresh_pipe)
+
+ # changer / mount CTAs
+ self.changer.sample_selected.connect(s.mount_sample_obj)
+ self.camera.mount_clicked.connect(s.open_picker)
+
+ # pipeline interactions
+ self.pipeline.stage_clicked.connect(s.pick_stage)
+ self.pipeline.breakpoint_toggled.connect(s.toggle_breakpoint)
+
+ # contextual run actions
+ cs = self.pipeline.settings
+ cs.run_center.connect(self._on_run_center)
+ cs.run_raster.connect(self._on_run_raster)
+ cs.run_xrf.connect(self._on_run_xrf)
+ cs.run_collect.connect(self._on_run_collect)
+ cs.raster_draw_toggled.connect(self.camera.set_grid_draw_enabled)
+ cs.raster_metric_changed.connect(lambda _k: self._render_heatmap())
+ cs.raster_alpha_changed.connect(self.camera.set_heatmap_alpha)
+ cs.raster_grids_clicked.connect(self._open_grids_dialog)
+ self.camera.grid_drawn.connect(self._on_grid_drawn)
+ self.camera.clicked_point.connect(self._on_camera_click)
+
+ # transport
+ t = self.pipeline.transport
+ t.play.connect(s.play)
+ t.pause.connect(s.pause)
+ t.skip.connect(s.skip)
+ t.end.connect(s.end)
+ t.unmount.connect(s.unmount)
+
+ # ------------------------------------------------------- run handlers
+ def _on_run_center(self, method: str) -> None:
+ # Only the automatic loop-centering method remains a button; click-to-
+ # centre is implicit (clicking the camera).
+ self._state.run_now("center")
+ self.center_loop_requested.emit()
+
+ def _on_camera_click(self, img_x: float, img_y: float) -> None:
+ self.center_at_point_requested.emit(img_x, img_y)
+
+ def _on_run_raster(self) -> None:
+ if self._grid_rect_img is None:
+ self.status_message.emit(
+ "Draw a grid on the camera first (✏ Draw grid), then Run raster."
+ )
+ return
+ cs = self.pipeline.settings
+ req = requests.build_raster_request(
+ cell_mm=cs.raster_cell_mm(),
+ transmission=cs.raster_transmission(),
+ grid_rect_img=self._grid_rect_img,
+ status=self._last_status,
+ exp_time_s=float(self._defaults.get("raster_exp_time_s",
+ self._defaults.get("exp_time_s", 0.02))),
+ dtz=self._defaults.get("raster_dtz", self._defaults.get("dtz")),
+ )
+ self._state.run_now("raster")
+ self.raster_scan_requested.emit(req)
+
+ def _on_run_xrf(self) -> None:
+ cs = self.pipeline.settings
+ acq, trans = cs.xrf_params()
+ req = requests.build_xrf_request(acq, trans)
+ self._state.run_now("xrf")
+ self.show_spectrum()
+ self.xrf_scan_requested.emit(req)
+
+ def _on_run_collect(self) -> None:
+ cs = self.pipeline.settings
+ start_omega = 0.0
+ geom = getattr(self._last_status, "geom", None)
+ if geom is not None and getattr(geom, "omega_deg", None) is not None:
+ start_omega = geom.omega_deg
+ self._state.run_now("collect")
+ if cs.collect_mode() == "screening":
+ req = requests.build_screening_request(
+ preset=cs.screening_params()["preset"],
+ image_angle_deg=cs.screening_params()["image_angle"],
+ count_time_s=cs.screening_params()["count_time"],
+ dtz=cs.collect_dtz(),
+ transmission=cs.collect_transmission(),
+ start_omega_deg=start_omega,
+ )
+ self.rotation_scan_requested.emit(req)
+ return
+ if cs.is_automated():
+ # Simple/automate strategy: forward seeds to the server, which refines.
+ sp = requests.build_simple_params(
+ self._defaults, cs.collect_transmission(), start_omega)
+ self.smart_params_requested.emit(sp)
+ return
+ req = requests.build_rotation_request(
+ collect_params=cs.collect_params(),
+ status=self._last_status,
+ exp_time_s=float(self._defaults.get("rotation_exp_time_s",
+ self._defaults.get("exp_time_s", 0.02))),
+ start_omega_deg=start_omega,
+ )
+ self.rotation_scan_requested.emit(req)
+
+ def _on_grid_drawn(self, x: float, y: float, w: float, h: float) -> None:
+ self._grid_rect_img = (x, y, w, h)
+ self.camera.set_heatmap(None) # clear any prior result heatmap
+ nx, ny = requests.grid_cells(
+ self.pipeline.settings.raster_cell_mm(), self._grid_rect_img,
+ self._last_status,
+ )
+ self.camera.set_grid_cells(nx, ny)
+ self.pipeline.settings.set_draw_active(False)
+ self.camera.set_grid_draw_enabled(False)
+
+ # ----------------------------------------------------- raster results
+ def on_raster_completed(self, completed) -> None:
+ self._raster_grids = list(getattr(completed, "r", []) or [])
+ self.pipeline.settings.set_raster_results_count(len(self._raster_grids))
+ self._render_heatmap()
+
+ def _render_heatmap(self) -> None:
+ if not self._raster_grids:
+ self.camera.set_heatmap(None)
+ return
+ elem = self._raster_grids[-1]
+ req = getattr(elem, "request", None)
+ result = getattr(elem, "result", None)
+ images = getattr(result, "images", None)
+ if req is None or not images:
+ return
+ key = self.pipeline.settings.raster_metric()
+ values = [metric_value(img, key) for img in images]
+ image = build_heatmap_qimage(values, req.n_x, req.n_y)
+ if image is None:
+ return
+ rect = self._grid_rect_for(req)
+ if rect is not None:
+ self.camera.set_grid_rect(*rect)
+ self.camera.set_heatmap(image)
+
+ def _grid_rect_for(self, req):
+ """Pixel rect for a completed grid: prefer the drawn rect, else derive."""
+ if self._grid_rect_img is not None:
+ return self._grid_rect_img
+ geom = getattr(self._last_status, "geom", None)
+ if geom is None or not hasattr(geom, "sample_to_picture"):
+ return None
+ try:
+ tl = geom.sample_to_picture(req.start_pxl(geom))
+ cell = req.grid_size_pxl(geom)
+ return (tl.x, tl.y, cell.x * req.n_x, cell.y * req.n_y)
+ except Exception:
+ return None
+
+ def _open_grids_dialog(self) -> None:
+ self._grids_dialog = RasterGridsDialog(self._raster_grids, self._p, self)
+ self._grids_dialog.goto_grid.connect(self._goto_grid)
+ self._grids_dialog.show()
+
+ def _goto_grid(self, index: int) -> None:
+ if 0 <= index < len(self._raster_grids):
+ req = getattr(self._raster_grids[index], "request", None)
+ stl = getattr(req, "smargon_top_left", None)
+ if stl is not None and getattr(stl, "sh_mm", None) is not None:
+ self.raster_goto_requested.emit(stl)
+ else:
+ self.status_message.emit("Grid has no stored position to go to.")
+
+ # ------------------------------------------------------- refreshers
+ def _refresh_mount(self) -> None:
+ phase = self._state.mount_phase
+ # Sample changer is always visible in Manual mode so the available
+ # samples are shown on first open; the "pick to mount" hint appears
+ # whenever nothing is mounted.
+ self.changer.setVisible(True)
+ self.changer.set_picking(phase != "mounted")
+ sample = self._state.mount_sample
+ self.changer.set_mounted_id(sample_id(sample) if sample is not None else None)
+ self.camera.set_mode(phase)
+ self.pipeline.set_mounted(phase == "mounted")
+ if phase == "mounted" and sample is not None:
+ params = getattr(sample, "aaredb_params", None)
+ if params is not None:
+ self.pipeline.settings.set_db_params(params)
+ if phase != "mounted":
+ self.camera.clear_grid()
+ self._grid_rect_img = None
+ self.pipeline.update_from_state(self._state)
+
+ def _refresh_pipe(self) -> None:
+ self.pipeline.update_from_state(self._state)
+ # enable grid-draw affordance only while the raster stage is selected
+ if self._state.stage != "raster":
+ self.pipeline.settings.set_draw_active(False)
+ self.camera.set_grid_draw_enabled(False)
+
+ # ----------------------------------------------------- external setters
+ def set_staff(self, is_staff: bool) -> None:
+ self.changer.set_staff(is_staff)
+ self.motors.set_staff(is_staff)
+
+ def set_reference_tools(self, tools) -> None:
+ self.changer.set_reference_tools(tools)
+
+ def set_control_state(self, in_control: bool, title: str = "GUEST MODE",
+ sub: str = "") -> None:
+ self.camera.set_control_state(in_control, title, sub)
+
+ # ------------------------------------------------------- live status
+ @property
+ def last_status(self):
+ return self._last_status
+
+ def update_daq_status(self, s) -> None:
+ self._last_status = s
+ self.motors.update_daq_status(s)
+ bl = getattr(s, "bl", None)
+ if bl is not None and getattr(bl, "zoom", None) is not None:
+ self.camera.set_zoom(bl.zoom)
diff --git a/src/aare/gui/new_gui/requests.py b/src/aare/gui/new_gui/requests.py
new file mode 100644
index 00000000..b8931f12
--- /dev/null
+++ b/src/aare/gui/new_gui/requests.py
@@ -0,0 +1,198 @@
+"""Builders that turn GUI parameters + live status into backend request models.
+
+Centralising model construction here keeps the views free of backend imports and
+gives one place to adjust defaults/units. Each builder returns the pydantic model
+the matching DAQWorker slot expects.
+
+NOTE (raster): the drawn grid gives us n_x / n_y / grid_size_mm and omega, but
+the absolute ``smargon_top_left`` requires mapping camera pixels -> smargon mm
+via the beam centre, which is beamline-calibration-sensitive. Until that mapping
+is confirmed we leave ``smargon_top_left=None`` (the auto-centre path the backend
+already supports via ``raster_scan_auto``). See new-gui-redesign memory.
+"""
+
+from __future__ import annotations
+
+import math
+
+from aare.common.coordinate import Coordinate, SmargonCoordinate
+from aare.common.models import (
+ FluorescenceSpectrumParameterModel,
+ SimpleScanParameters,
+)
+from aare.common.raster_grid import RasterGridRequest
+from aare.common.rotation_scan import RotationScanRequest
+
+
+def _pixel_in_mm(status) -> float | None:
+ geom = getattr(status, "geom", None)
+ val = getattr(geom, "pixel_in_mm", None) if geom else None
+ return val if val else None
+
+
+def _smargon_top_left(status, top_left_px: tuple[float, float]
+ ) -> SmargonCoordinate | None:
+ """Convert the grid's top-left camera pixel to a SmargonCoordinate.
+
+ Mirrors RasterGridManager.update_active_grid: picture -> beamline (sample)
+ -> smargon, carrying the current phi/chi. Returns None (auto-centre) if the
+ geometry transforms aren't available.
+ """
+ geom = getattr(status, "geom", None)
+ if geom is None or not hasattr(geom, "picture_to_sample"):
+ return None
+ try:
+ x, y = top_left_px
+ sample = geom.picture_to_sample(Coordinate(x=x, y=y))
+ sh = geom.beamline_to_smargon(sample)
+ return SmargonCoordinate(
+ sh_mm=sh,
+ phi_deg=getattr(geom.smargon, "phi_deg", None),
+ chi_deg=getattr(geom.smargon, "chi_deg", None),
+ )
+ except Exception:
+ return None
+
+
+def build_raster_request(
+ cell_mm: tuple[float, float],
+ transmission: float,
+ grid_rect_img: tuple[float, float, float, float],
+ status,
+ exp_time_s: float,
+ dtz: float | None,
+ auto: bool = False,
+) -> RasterGridRequest:
+ """grid_rect_img = (x, y, w, h) in image pixels (from the drawn grid).
+
+ ``auto=True`` leaves smargon_top_left=None (backend auto-centre). Otherwise
+ the top-left is computed from the drawn pixel via the live geometry.
+ """
+ # NB: grid_size_mm is the size of ONE grid element (cell); the number of
+ # cells (n_x/n_y) is the drawn extent divided by the cell size.
+ cx_mm, cy_mm = cell_mm
+ x0, y0, w_px, h_px = grid_rect_img
+ px_mm = _pixel_in_mm(status) or 0.0
+
+ extent_w_mm = w_px * px_mm if px_mm else cx_mm
+ extent_h_mm = h_px * px_mm if px_mm else cy_mm
+ n_x = max(1, round(extent_w_mm / cx_mm)) if cx_mm > 0 else 1
+ n_y = max(1, round(extent_h_mm / cy_mm)) if cy_mm > 0 else 1
+
+ omega = getattr(getattr(status, "geom", None), "omega_deg", 0.0) or 0.0
+ top_left = None if auto else _smargon_top_left(status, (x0, y0))
+
+ return RasterGridRequest(
+ exp_time_s=exp_time_s,
+ n_x=int(n_x),
+ n_y=int(n_y),
+ grid_size_mm=Coordinate(x=max(cx_mm, 1e-4), y=max(cy_mm, 1e-4), z=0.0),
+ smargon_top_left=top_left,
+ omega_deg=float(omega),
+ dtz=dtz,
+ transmission=transmission,
+ visible=True,
+ )
+
+
+def grid_cells(cell_mm: tuple[float, float],
+ grid_rect_img: tuple[float, float, float, float],
+ status) -> tuple[int, int]:
+ """Compute (n_x, n_y) for the camera overlay from a drawn rectangle."""
+ cx_mm, cy_mm = cell_mm
+ _, _, w_px, h_px = grid_rect_img
+ px_mm = _pixel_in_mm(status)
+ if not px_mm or cx_mm <= 0 or cy_mm <= 0:
+ return 0, 0
+ n_x = max(1, math.floor((w_px * px_mm) / cx_mm))
+ n_y = max(1, math.floor((h_px * px_mm) / cy_mm))
+ return n_x, n_y
+
+
+def build_rotation_request(
+ collect_params: dict,
+ status,
+ exp_time_s: float,
+ start_omega_deg: float,
+ file_prefix: str | None = None,
+) -> RotationScanRequest:
+ return RotationScanRequest(
+ exp_time_s=exp_time_s,
+ start_omega_deg=float(start_omega_deg),
+ incr_omega_deg=float(collect_params["incr_omega_deg"]),
+ steps=int(collect_params["steps"]),
+ dtz=collect_params.get("dtz"),
+ transmission=collect_params.get("transmission"),
+ start=None,
+ end=None,
+ screening=False,
+ file_prefix=file_prefix,
+ )
+
+
+def build_xrf_request(acq_time_s: float, transmission: float
+ ) -> FluorescenceSpectrumParameterModel:
+ return FluorescenceSpectrumParameterModel(
+ acq_time_s=float(acq_time_s),
+ transmission=float(transmission),
+ erase=True,
+ )
+
+
+def build_simple_params(defaults: dict, transmission: float,
+ start_omega_deg: float = 0.0) -> SimpleScanParameters:
+ """Seed SimpleScanParameters for the automate-parameters (simple) strategy.
+
+ The server refines these into the final strategy; we forward sensible seeds
+ from the beamline YAML defaults so it has flux/dose context to work with.
+ """
+ return SimpleScanParameters(
+ dtz=float(defaults.get("dtz", 150.0)),
+ exp_time_s=float(defaults.get("rotation_exp_time_s",
+ defaults.get("exp_time_s", 0.02))),
+ start_omega_deg=float(start_omega_deg),
+ incr_omega_deg=float(defaults.get("increment_omega_deg", 0.2)),
+ steps=int(defaults.get("steps", 1800)),
+ transmission=float(transmission),
+ )
+
+
+def build_screening_request(
+ preset: dict, image_angle_deg: float, count_time_s: float,
+ dtz: float | None, transmission: float, start_omega_deg: float = 0.0,
+ file_prefix: str | None = None,
+) -> RotationScanRequest:
+ """Screening preset -> RotationScanRequest.
+
+ preset = {"steps": int, "omega_step_deg": float}. A single image (steps==1,
+ step==0) is a plain 1-image rotation; multi-image sets wedge_omega_deg to the
+ per-image span and incr_omega_deg to the inter-image step (matches the old GUI).
+ """
+ steps = int(preset.get("steps", 1))
+ step = float(preset.get("omega_step_deg", 0))
+ if steps <= 1 and step == 0:
+ return RotationScanRequest(
+ exp_time_s=count_time_s, start_omega_deg=start_omega_deg,
+ incr_omega_deg=image_angle_deg, steps=1, screening=False,
+ dtz=dtz, transmission=transmission, file_prefix=file_prefix,
+ )
+ return RotationScanRequest(
+ exp_time_s=count_time_s, start_omega_deg=start_omega_deg,
+ wedge_omega_deg=image_angle_deg, incr_omega_deg=step, steps=steps,
+ screening=True, dtz=dtz, transmission=transmission, file_prefix=file_prefix,
+ )
+
+
+def rotation_from_simple(sp: SimpleScanParameters) -> RotationScanRequest:
+ """Build the actual RotationScanRequest the server runs for the simple path."""
+ return RotationScanRequest(
+ exp_time_s=sp.exp_time_s,
+ start_omega_deg=sp.start_omega_deg,
+ incr_omega_deg=sp.incr_omega_deg,
+ steps=sp.steps,
+ dtz=sp.dtz,
+ transmission=sp.transmission,
+ start=None,
+ end=None,
+ screening=False,
+ )
diff --git a/src/aare/gui/new_gui/state.py b/src/aare/gui/new_gui/state.py
new file mode 100644
index 00000000..22193d4f
--- /dev/null
+++ b/src/aare/gui/new_gui/state.py
@@ -0,0 +1,282 @@
+"""Window-level view-model for the redesigned AareGUI.
+
+This is a direct port of the state machine described in the design handoff
+("State Management" section). It holds *only* UI state and emits change signals
+so views can refresh, plus high-level *intent* signals (``*_requested``) that the
+main window connects to the real :class:`DAQWorker`. Keeping backend calls out of
+this object makes the state machine unit-testable in isolation.
+
+State shape (mirrors the prototype):
+- ``mode`` : 'manual' | 'automation'
+- ``mount.phase`` : 'empty' | 'picking' | 'mounted'
+- ``mount.sample`` : the selected sample object (SampleShortInfo) or None
+- ``pipe`` : {stage, breaks: {stage: bool}, running: bool, seq_idx}
+- ``qb`` : {queue: [{sample, protocol}], defaults: {...}}
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from PySide6.QtCore import QObject, Signal
+
+
+# Interactive pipeline stages, in order. ``optional`` drives the dashed-node
+# styling; ``interactive`` marks the stages the operator can select/run.
+@dataclass(frozen=True)
+class Stage:
+ key: str
+ label: str # short label under the node
+ action_label: str # human label used by "NEXT ACTION"
+ interactive: bool = False
+ optional: bool = False
+
+
+STAGES: tuple[Stage, ...] = (
+ Stage("mount", "Mount", "Mount"),
+ Stage("center", "Center", "Center", interactive=True),
+ Stage("raster", "Raster", "Raster scan", interactive=True, optional=True),
+ Stage("xrf", "XRF", "XRF spectrum", interactive=True, optional=True),
+ Stage("collect", "Collect", "Collect (Rotation)", interactive=True),
+ Stage("process", "Process", "Process"),
+ Stage("unmount", "Unmount", "Unmount"),
+)
+
+# Stages that make up an automation "protocol" (queue builder chips).
+PROTOCOL_KEYS = ("center", "raster", "xrf", "collect")
+
+
+def default_protocol() -> dict[str, bool]:
+ """The prototype's initial default protocol for new queue adds."""
+ return {"center": True, "raster": False, "xrf": False, "collect": True}
+
+
+@dataclass
+class QueueItem:
+ """One queued sample with its per-sample protocol."""
+
+ sample: object # SampleShortInfo (or test stub)
+ protocol: dict[str, bool] = field(default_factory=default_protocol)
+
+ @property
+ def sample_id(self):
+ return getattr(self.sample, "id", id(self.sample))
+
+
+class AppState(QObject):
+ """Single source of truth for window-level UI state."""
+
+ # --- view-refresh signals ---
+ mode_changed = Signal(str) # 'manual' | 'automation'
+ mount_changed = Signal() # phase and/or sample changed
+ pipe_changed = Signal() # any pipeline field changed
+ queue_changed = Signal() # queue or defaults changed
+
+ # --- backend intent signals (wired to DAQWorker by the main window) ---
+ mount_requested = Signal(object, bool) # SampleShortInfo, reference
+ unmount_requested = Signal()
+ center_requested = Signal()
+ raster_requested = Signal()
+ xrf_requested = Signal()
+ collect_requested = Signal()
+ automation_start_requested = Signal(list) # list[QueueItem]
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._mode = "manual"
+ self._mount_phase = "empty"
+ self._mount_sample: object | None = None
+ self._stage = "center"
+ self._breaks: dict[str, bool] = {}
+ self._running = False
+ self._seq_idx = 0
+ self._queue: list[QueueItem] = []
+ self._defaults = default_protocol()
+
+ # ------------------------------------------------------------------ mode
+ @property
+ def mode(self) -> str:
+ return self._mode
+
+ def set_mode(self, mode: str) -> None:
+ if mode not in ("manual", "automation") or mode == self._mode:
+ return
+ self._mode = mode
+ self.mode_changed.emit(mode)
+
+ # ----------------------------------------------------------------- mount
+ @property
+ def mount_phase(self) -> str:
+ return self._mount_phase
+
+ @property
+ def mount_sample(self):
+ return self._mount_sample
+
+ def open_picker(self) -> None:
+ """empty -> picking: reveal the sample changer."""
+ if self._mount_phase == "mounted":
+ return
+ self._mount_phase = "picking"
+ self.mount_changed.emit()
+
+ def mount_sample_obj(self, sample, reference: bool = False) -> None:
+ """Select a sample to mount: store it, reset the pipeline, request mount."""
+ self._mount_sample = sample
+ self._mount_phase = "mounted"
+ self._reset_pipe()
+ self.mount_changed.emit()
+ self.pipe_changed.emit()
+ self.mount_requested.emit(sample, reference)
+
+ def unmount(self) -> None:
+ """mounted -> empty: clear sample and request unmount."""
+ self._mount_sample = None
+ self._mount_phase = "empty"
+ self.mount_changed.emit()
+ self.unmount_requested.emit()
+
+ # -------------------------------------------------------------- pipeline
+ @property
+ def stage(self) -> str:
+ return self._stage
+
+ @property
+ def running(self) -> bool:
+ return self._running
+
+ @property
+ def seq_idx(self) -> int:
+ return self._seq_idx
+
+ def has_breakpoint(self, key: str) -> bool:
+ return bool(self._breaks.get(key))
+
+ def _reset_pipe(self) -> None:
+ self._stage = "center"
+ self._breaks = {}
+ self._running = False
+ self._seq_idx = 0
+
+ def pick_stage(self, key: str) -> None:
+ if self._stage != key:
+ self._stage = key
+ self.pipe_changed.emit()
+
+ def toggle_breakpoint(self, key: str) -> None:
+ self._breaks[key] = not self._breaks.get(key)
+ self.pipe_changed.emit()
+
+ def _stage_index(self, key: str) -> int:
+ for i, s in enumerate(STAGES):
+ if s.key == key:
+ return i
+ return 0
+
+ def run_now(self, key: str) -> None:
+ """Set the sequence position to ``key``, start running, fire the intent."""
+ self._seq_idx = self._stage_index(key)
+ self._stage = key
+ self._running = True
+ self.pipe_changed.emit()
+ self._emit_stage_intent(key)
+
+ def _emit_stage_intent(self, key: str) -> None:
+ {
+ "center": self.center_requested,
+ "raster": self.raster_requested,
+ "xrf": self.xrf_requested,
+ "collect": self.collect_requested,
+ }.get(key, _NullSignal()).emit()
+
+ # transport
+ def play(self) -> None:
+ self._running = True
+ self.pipe_changed.emit()
+ # Kick off whatever stage the sequence is currently pointing at.
+ self._emit_stage_intent(STAGES[min(self._seq_idx, len(STAGES) - 1)].key)
+
+ def pause(self) -> None:
+ self._running = False
+ self.pipe_changed.emit()
+
+ def skip(self) -> None:
+ self._seq_idx = min(self._seq_idx + 1, len(STAGES) - 1)
+ self.pipe_changed.emit()
+
+ def end(self) -> None:
+ self._running = False
+ self._seq_idx = 0
+ self._stage = "center"
+ self.pipe_changed.emit()
+
+ def next_stage(self) -> Stage:
+ return STAGES[min(self._seq_idx, len(STAGES) - 1)]
+
+ def stopped_at_breakpoint(self) -> bool:
+ return self._running and self.has_breakpoint(self.next_stage().key)
+
+ # ------------------------------------------------------------ queue (qb)
+ @property
+ def queue(self) -> list[QueueItem]:
+ return self._queue
+
+ @property
+ def defaults(self) -> dict[str, bool]:
+ return self._defaults
+
+ def _is_queued(self, sample) -> bool:
+ sid = getattr(sample, "id", id(sample))
+ return any(q.sample_id == sid for q in self._queue)
+
+ def queue_add(self, sample) -> None:
+ if self._is_queued(sample):
+ return
+ self._queue.append(QueueItem(sample, dict(self._defaults)))
+ self.queue_changed.emit()
+
+ def queue_add_many(self, samples) -> None:
+ added = False
+ for s in samples:
+ if not self._is_queued(s):
+ self._queue.append(QueueItem(s, dict(self._defaults)))
+ added = True
+ if added:
+ self.queue_changed.emit()
+
+ def queue_remove(self, index: int) -> None:
+ if 0 <= index < len(self._queue):
+ del self._queue[index]
+ self.queue_changed.emit()
+
+ def queue_move(self, index: int, direction: int) -> None:
+ j = index + direction
+ if 0 <= index < len(self._queue) and 0 <= j < len(self._queue):
+ self._queue[index], self._queue[j] = self._queue[j], self._queue[index]
+ self.queue_changed.emit()
+
+ def queue_clear(self) -> None:
+ if self._queue:
+ self._queue = []
+ self.queue_changed.emit()
+
+ def toggle_row_protocol(self, index: int, key: str) -> None:
+ if 0 <= index < len(self._queue):
+ p = self._queue[index].protocol
+ p[key] = not p.get(key)
+ self.queue_changed.emit()
+
+ def toggle_default(self, key: str) -> None:
+ self._defaults[key] = not self._defaults.get(key)
+ self.queue_changed.emit()
+
+ def start_automation(self) -> None:
+ if self._queue:
+ self.automation_start_requested.emit(list(self._queue))
+
+
+class _NullSignal:
+ """Fallback for stage keys with no intent (e.g. mount/process)."""
+
+ def emit(self, *args) -> None: # noqa: D401 - no-op
+ pass
\ No newline at end of file
diff --git a/src/aare/gui/new_gui/status_bar.py b/src/aare/gui/new_gui/status_bar.py
new file mode 100644
index 00000000..e3f2c59a
--- /dev/null
+++ b/src/aare/gui/new_gui/status_bar.py
@@ -0,0 +1,186 @@
+"""The bottom status bar: live beamline readouts from DAQStatusModel."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal, Slot
+from PySide6.QtGui import QAction
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QMenu, QWidget
+
+from aare.gui.new_gui.theme import STATUSBAR_H, Palette
+from aare.gui.new_gui.widgets.common import ClickableLabel
+
+# (display label, DAQWorker method key) for the clickable state LED menu.
+STATE_OPTIONS: tuple[tuple[str, str], ...] = (
+ ("Dewar transfer", "dewar_exchange"),
+ ("Sample exchange", "sample_exchange"),
+ ("Robot sample exchange", "robot_sample_exchange"),
+ ("Sample alignment", "sample_alignment"),
+ ("Beam location", "beam_location"),
+ ("Beamstop alignment", "beamstop_alignment"),
+ ("Flux measurement", "flux_measurement"),
+ ("Data collection", "data_collection"),
+ ("Crystal snapshot", "xtal_snapshot"),
+ ("X-ray fluorescence", "xray_fluorescence"),
+)
+
+# LED colour by BeamlineStateEnum name (group colours from the old GUI).
+_STATE_COLORS: dict[str, str] = {
+ "DewarTransfer": "#805ad5",
+ "SampleExchange": "#ed8936", "RobotSampleExchange": "#ed8936",
+ "SampleAlignment": "#48bb78", "BeamLocation": "#48bb78",
+ "BeamstopAlignment": "#48bb78", "FluxMeasurement": "#48bb78",
+ "DataCollection": "#ec4899", "XtalSnapshot": "#ec4899",
+ "XrayFluorescence": "#ec4899",
+}
+
+
+class StatusBar(QWidget):
+ """Mono readouts: Flux · Ring · λ · Cryo · Shutter · ... · State LED · SC.
+
+ Values are read straight off the status model using the documented paths
+ (see new-gui-backend-paths). The State item is a clickable LED that opens a
+ beamline-state transition menu.
+ """
+
+ state_change_requested = Signal(str) # DAQWorker method key
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self.setObjectName("StatusBar")
+ self.setFixedHeight(STATUSBAR_H)
+ self._p = palette
+ self._busy = False
+
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(16, 0, 16, 0)
+ lay.setSpacing(18)
+
+ self._flux = self._item(lay)
+ self._ring = self._item(lay)
+ self._lam = self._item(lay)
+ self._cryo = self._item(lay)
+ self._shutter = self._item(lay)
+ lay.addStretch(1)
+
+ # clickable State LED
+ self._state_dot = QLabel()
+ self._state_dot.setFixedSize(8, 8)
+ self._set_state_dot("#a49d8f")
+ self._state = ClickableLabel()
+ self._state.setObjectName("StatusItem")
+ self._state.setTextFormat(Qt.RichText)
+ self._state.setToolTip("Click to switch beamline state")
+ self._state.clicked.connect(self._show_state_menu)
+ lay.addWidget(self._state_dot)
+ lay.addWidget(self._state)
+
+ self._sc = self._item(lay)
+
+ # initial placeholders
+ self._render(self._flux, "Flux", "—")
+ self._render(self._ring, "Ring", "—")
+ self._render(self._lam, "λ", "—")
+ self._render(self._cryo, "Cryo", "—")
+ self._render(self._shutter, "Shutter", "—")
+ self._render(self._state, "State", "—")
+ self._render(self._sc, "SC", "—")
+
+ def _set_state_dot(self, color: str) -> None:
+ self._state_dot.setStyleSheet(f"background:{color}; border-radius:4px;")
+
+ def _show_state_menu(self) -> None:
+ menu = QMenu(self)
+ title = QAction("Switch beamline state", menu)
+ title.setEnabled(False)
+ menu.addAction(title)
+ menu.addSeparator()
+ if self._busy:
+ busy = QAction("Beamline busy — transitions disabled", menu)
+ busy.setEnabled(False)
+ menu.addAction(busy)
+ else:
+ for label, key in STATE_OPTIONS:
+ act = QAction(label, menu)
+ act.triggered.connect(
+ lambda _=False, k=key: self.state_change_requested.emit(k)
+ )
+ menu.addAction(act)
+ menu.exec(self._state.mapToGlobal(self._state.rect().bottomLeft()))
+
+ def _item(self, lay: QHBoxLayout) -> QLabel:
+ lbl = QLabel()
+ lbl.setObjectName("StatusItem")
+ lbl.setTextFormat(Qt.RichText)
+ lay.addWidget(lbl)
+ return lbl
+
+ def _render(self, lbl: QLabel, name: str, value: str,
+ value_color: str | None = None) -> None:
+ col = value_color or self._p.text_primary
+ lbl.setText(
+ f"{name} "
+ f"{value}"
+ )
+
+ @Slot(object)
+ def update_daq_status(self, s) -> None:
+ p = self._p
+ bl = getattr(s, "bl", None)
+
+ # Flux (photons/s) -> scientific-ish
+ flux = getattr(bl, "flux_ph_s", None)
+ if flux is not None:
+ self._render(self._flux, "Flux", self._fmt_flux(flux))
+
+ # Ring current
+ ring = getattr(bl, "ring_current_mA", None)
+ if ring is not None:
+ color = p.danger if ring < 5 else (p.breakpoint if ring < 390 else p.success)
+ self._render(self._ring, "Ring", f"{ring:.0f} mA", color)
+
+ # Wavelength
+ diff = getattr(s, "diffraction", None)
+ lam = getattr(diff, "wavelength_angstrom", None)
+ if lam is not None:
+ self._render(self._lam, "λ", f"{lam:.3f} Å")
+
+ # Cryojet temperature
+ cryo = getattr(bl, "cryojet_K", None)
+ if cryo is not None:
+ color = p.accent if cryo < 110 else (p.breakpoint if cryo < 250 else p.danger)
+ self._render(self._cryo, "Cryo", f"{cryo:.1f} K", color)
+
+ # Shutter
+ shutter = getattr(bl, "shutter_open", None)
+ if shutter is not None:
+ txt = "Open" if shutter else "Closed"
+ color = p.success if shutter else p.text_muted
+ self._render(self._shutter, "Shutter", txt, color)
+
+ # Busy flag gates state transitions
+ self._busy = bool(getattr(s, "busy", False))
+
+ # Beamline state (clickable LED)
+ state = getattr(s, "state", None)
+ if state is not None:
+ label = state.display_name() if hasattr(state, "display_name") else str(state)
+ name = getattr(state, "name", None)
+ self._set_state_dot(_STATE_COLORS.get(name, p.text_faint))
+ self._render(self._state, "State", label, p.text_primary)
+
+ # Sample changer (TELL)
+ tell_ok = getattr(s, "tell_connected", None)
+ if tell_ok is not None:
+ txt = "Ready" if tell_ok else "Offline"
+ color = p.success if tell_ok else p.danger
+ self._render(self._sc, "SC", txt, color)
+
+ @staticmethod
+ def _fmt_flux(flux: float) -> str:
+ if flux <= 0:
+ return "0"
+ from math import floor, log10
+ exp = int(floor(log10(flux)))
+ mant = flux / (10 ** exp)
+ sup = str(exp).translate(str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻"))
+ return f"{mant:.1f}×10{sup}"
diff --git a/src/aare/gui/new_gui/theme.py b/src/aare/gui/new_gui/theme.py
new file mode 100644
index 00000000..fb60ec91
--- /dev/null
+++ b/src/aare/gui/new_gui/theme.py
@@ -0,0 +1,268 @@
+"""Design tokens and Qt Style Sheet for the redesigned AareGUI.
+
+This is the single source of truth for colour, typography, spacing and radii,
+mirroring the "Design Tokens" table in the design handoff README. Widgets should
+read values from :data:`LIGHT` / :data:`DARK` (or use the generated QSS) rather
+than hard-coding hex strings.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Palette:
+ """A single colour theme. Light and dark are 1:1 (see README)."""
+
+ # Surfaces
+ app_bg: str
+ panel_bg: str
+ header_bg: str
+ surface: str # cards / white
+ # Borders
+ border_panel: str
+ border_control: str
+ pending_track: str
+ pending_node: str
+ chip_off_bg: str
+ # Text
+ text_primary: str
+ text_secondary: str
+ text_muted: str
+ text_faint: str # labels
+ # Accent (purple)
+ accent: str
+ accent_text: str # text drawn on top of the accent fill
+ accent_tint_bg: str
+ accent_tint_border: str
+ accent_ring: str
+ # Semantic
+ success: str
+ success_tint: str
+ breakpoint: str
+ danger: str
+ # Camera viewport gradient stops (radial, 75% 80%)
+ cam_stop0: str
+ cam_stop1: str
+ cam_stop2: str
+
+
+LIGHT = Palette(
+ app_bg="#f3f1ea",
+ panel_bg="#fbfaf6",
+ header_bg="#f7f5ef",
+ surface="#ffffff",
+ border_panel="#e5e1d7",
+ border_control="#ddd8cc",
+ pending_track="#e1dccf",
+ pending_node="#c9c2b2",
+ chip_off_bg="#efece3",
+ text_primary="#221f19",
+ text_secondary="#4d473d",
+ text_muted="#7a756a",
+ text_faint="#a49d8f",
+ accent="#6d4bd1",
+ accent_text="#ffffff",
+ accent_tint_bg="#f0e9fb",
+ accent_tint_border="#cabaf2",
+ accent_ring="rgba(109,75,209,.28)",
+ success="#1a7f37",
+ success_tint="#e3f2e8",
+ breakpoint="#c2641f",
+ danger="#cf222e",
+ cam_stop0="#2b2622",
+ cam_stop1="#141312",
+ cam_stop2="#0a0a0b",
+)
+
+DARK = Palette(
+ app_bg="#15121c",
+ panel_bg="#1e1a28",
+ header_bg="#1e1a28",
+ surface="#241f30",
+ border_panel="#332c44",
+ border_control="#332c44",
+ pending_track="#332c44",
+ pending_node="#4a4159",
+ chip_off_bg="#2a2438",
+ text_primary="#ece8f4",
+ text_secondary="#cfc8db",
+ text_muted="#9c95ad",
+ text_faint="#807890",
+ accent="#9b7bff",
+ accent_text="#190f2e",
+ accent_tint_bg="#2c2440",
+ accent_tint_border="#473a63",
+ accent_ring="rgba(155,123,255,.32)",
+ success="#3fb950",
+ success_tint="#16331f",
+ breakpoint="#e0975a",
+ danger="#f0837d",
+ cam_stop0="#2b2622",
+ cam_stop1="#141312",
+ cam_stop2="#0a0a0b",
+)
+
+
+# --- Typography -----------------------------------------------------------
+FONT_SANS = "IBM Plex Sans"
+FONT_MONO = "IBM Plex Mono"
+# Fallbacks used when the bundled TTFs are unavailable.
+FONT_SANS_FALLBACK = f'"{FONT_SANS}", "Segoe UI", system-ui, sans-serif'
+FONT_MONO_FALLBACK = f'"{FONT_MONO}", "Consolas", "DejaVu Sans Mono", monospace'
+
+
+# --- Fixed dimensions from the spec --------------------------------------
+TOPBAR_H = 54
+STATUSBAR_H = 30
+CAMERA_TABS_H = 38
+START_BAR_H = 80
+CHANGER_W = 340
+MOTORS_W = 286
+LIBRARY_W = 288
+
+
+def build_qss(p: Palette) -> str:
+ """Return a global Qt Style Sheet for the given palette.
+
+ Object-name / property selectors keep per-widget styling out of the widget
+ code. Widgets set ``objectName`` or a dynamic ``role`` property to opt in.
+ """
+ return f"""
+ QWidget {{
+ font-family: {FONT_SANS_FALLBACK};
+ font-size: 13px;
+ color: {p.text_primary};
+ }}
+ QWidget#AppRoot {{ background: {p.app_bg}; }}
+
+ /* ---- Top bar ---- */
+ QWidget#TopBar {{
+ background: {p.surface};
+ border-bottom: 1px solid {p.border_panel};
+ }}
+ QLabel#LogoTile {{
+ background: {p.accent};
+ color: {p.accent_text};
+ border-radius: 7px;
+ font-weight: 700;
+ font-size: 14px;
+ }}
+ QLabel#AppName {{ font-weight: 700; font-size: 15px; }}
+ QLabel#BeamlineId {{
+ font-family: {FONT_MONO_FALLBACK};
+ font-size: 11px;
+ color: {p.text_muted};
+ }}
+ QLabel#Avatar {{
+ background: {p.chip_off_bg};
+ color: {p.text_secondary};
+ border-radius: 17px;
+ font-size: 12px;
+ }}
+
+ /* ---- Mode toggle (segmented) ---- */
+ QWidget#ModeToggle {{ background: {p.chip_off_bg}; border-radius: 9px; }}
+ QPushButton#ModeSeg {{
+ border: none;
+ background: transparent;
+ color: {p.text_muted};
+ font-weight: 500;
+ padding: 6px 16px;
+ border-radius: 6px;
+ }}
+ QPushButton#ModeSeg:checked {{
+ background: {p.accent};
+ color: {p.accent_text};
+ font-weight: 600;
+ }}
+
+ /* ---- Baton chip ---- */
+ QWidget#BatonChip {{
+ background: {p.accent_tint_bg};
+ border: 1px solid {p.accent_tint_border};
+ border-radius: 20px;
+ }}
+ QLabel#BatonText {{ font-size: 12px; color: {p.text_primary}; }}
+ QLabel#BatonId {{
+ font-family: {FONT_MONO_FALLBACK};
+ font-size: 11px; color: {p.text_muted};
+ }}
+
+ /* ---- Status bar ---- */
+ QWidget#StatusBar {{
+ background: {p.surface};
+ border-top: 1px solid {p.border_panel};
+ }}
+ QLabel#StatusItem {{
+ font-family: {FONT_MONO_FALLBACK};
+ font-size: 11px; color: {p.text_muted};
+ }}
+
+ /* ---- Panels ---- */
+ QWidget#SidePanel {{ background: {p.panel_bg}; }}
+ QFrame#Divider {{ background: {p.border_panel}; max-height: 1px; min-height: 1px; border: none; }}
+ QLabel#SectionLabel {{
+ font-size: 12px; font-weight: 600; color: {p.text_faint};
+ letter-spacing: 1px;
+ }}
+ QLabel#MicroLabel {{
+ font-size: 10px; font-weight: 700; color: {p.text_faint};
+ letter-spacing: 1px;
+ }}
+
+ /* ---- Search fields / line edits ---- */
+ QLineEdit {{
+ background: {p.surface};
+ border: 1px solid {p.border_panel};
+ border-radius: 8px;
+ padding: 8px 12px;
+ font-size: 12px;
+ color: {p.text_primary};
+ }}
+ QLineEdit::placeholder {{ color: {p.text_faint}; }}
+
+ /* ---- Buttons ---- */
+ QPushButton {{
+ background: {p.surface};
+ border: 1px solid {p.border_control};
+ border-radius: 8px;
+ padding: 8px 14px;
+ font-size: 12px;
+ color: {p.text_secondary};
+ }}
+ QPushButton:hover {{ border-color: {p.accent_tint_border}; }}
+ QPushButton#Primary {{
+ background: {p.accent};
+ color: {p.accent_text};
+ border: none;
+ font-weight: 600;
+ border-radius: 10px;
+ padding: 11px 22px;
+ }}
+ QPushButton#Primary:hover {{ background: {p.accent}; }}
+ QPushButton#Danger {{ color: {p.danger}; }}
+
+ /* ---- Camera tab strip ---- */
+ QWidget#CameraTabs {{ background: {p.app_bg}; border-bottom: 1px solid {p.border_panel}; }}
+ QPushButton#CamTab {{
+ background: transparent; border: none;
+ color: {p.text_muted}; padding: 8px 14px; font-size: 12px;
+ }}
+ QPushButton#CamTab:checked {{
+ background: {p.surface};
+ border: 1px solid {p.border_panel};
+ border-bottom: none;
+ border-top-left-radius: 7px; border-top-right-radius: 7px;
+ color: {p.text_primary}; font-weight: 600;
+ }}
+
+ /* ---- Bottom / pipeline panel ---- */
+ QWidget#BottomPanel {{ background: {p.surface}; border-top: 1px solid {p.border_panel}; }}
+ QWidget#HeaderBlock {{ background: {p.header_bg}; border-bottom: 1px solid {p.border_panel}; }}
+
+ /* ---- Scroll areas transparent so panel bg shows through ---- */
+ QScrollArea {{ background: transparent; border: none; }}
+ QScrollArea > QWidget > QWidget {{ background: transparent; }}
+ """
\ No newline at end of file
diff --git a/src/aare/gui/new_gui/top_bar.py b/src/aare/gui/new_gui/top_bar.py
new file mode 100644
index 00000000..a3ead96c
--- /dev/null
+++ b/src/aare/gui/new_gui/top_bar.py
@@ -0,0 +1,208 @@
+"""The shared top bar: logo, mode toggle, baton chip, avatar."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QButtonGroup,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import TOPBAR_H, Palette
+
+
+class ModeToggle(QWidget):
+ """Segmented control: Manual / Automation (exclusive checkable buttons)."""
+
+ mode_changed = Signal(str)
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self.setObjectName("ModeToggle")
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(3, 3, 3, 3)
+ lay.setSpacing(0)
+ self._group = QButtonGroup(self)
+ self._group.setExclusive(True)
+ self._buttons: dict[str, QPushButton] = {}
+ for key, label in (("manual", "Manual"), ("automation", "Automation")):
+ btn = QPushButton(label)
+ btn.setObjectName("ModeSeg")
+ btn.setCheckable(True)
+ btn.setCursor(Qt.PointingHandCursor)
+ btn.clicked.connect(lambda _=False, k=key: self.mode_changed.emit(k))
+ self._group.addButton(btn)
+ lay.addWidget(btn)
+ self._buttons[key] = btn
+ self._buttons["manual"].setChecked(True)
+
+ def set_mode(self, mode: str) -> None:
+ if mode in self._buttons:
+ self._buttons[mode].setChecked(True)
+
+
+class TopBar(QWidget):
+ """Top chrome strip. ``mode_changed`` is re-exposed from the toggle."""
+
+ mode_changed = Signal(str)
+ request_baton = Signal()
+ release_baton = Signal()
+ cancel_baton_request = Signal()
+
+ def __init__(self, palette: Palette, beamline_label: str, parent=None):
+ super().__init__(parent)
+ self.setObjectName("TopBar")
+ self.setFixedHeight(TOPBAR_H)
+ p = palette
+
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(16, 0, 16, 0)
+ lay.setSpacing(16)
+
+ # left cluster
+ left = QHBoxLayout()
+ left.setSpacing(10)
+ logo = QLabel("Aa")
+ logo.setObjectName("LogoTile")
+ logo.setFixedSize(28, 28)
+ logo.setAlignment(Qt.AlignCenter)
+ name = QLabel("AareGUI")
+ name.setObjectName("AppName")
+ self._beamline = QLabel(beamline_label)
+ self._beamline.setObjectName("BeamlineId")
+ left.addWidget(logo)
+ left.addWidget(name)
+ left.addWidget(self._beamline)
+ lay.addLayout(left)
+
+ # mode toggle
+ self.toggle = ModeToggle(p)
+ self.toggle.mode_changed.connect(self.mode_changed)
+ lay.addWidget(self.toggle)
+
+ lay.addStretch(1)
+
+ # baton chip
+ self._baton = QWidget()
+ self._baton.setObjectName("BatonChip")
+ bl = QHBoxLayout(self._baton)
+ bl.setContentsMargins(12, 6, 12, 6)
+ bl.setSpacing(8)
+ self._dot = QLabel()
+ self._dot.setFixedSize(8, 8)
+ self._set_dot(p.success)
+ self._baton_text = QLabel("You hold the baton")
+ self._baton_text.setObjectName("BatonText")
+ self._baton_id = QLabel("")
+ self._baton_id.setObjectName("BatonId")
+ bl.addWidget(self._dot)
+ bl.addWidget(self._baton_text)
+ bl.addWidget(self._baton_id)
+ lay.addWidget(self._baton)
+
+ # baton action button (Grab / Request / Release / Cancel)
+ self._baton_btn = QPushButton("Grab")
+ self._baton_btn.setCursor(Qt.PointingHandCursor)
+ self._baton_action = "grab"
+ self._baton_btn.clicked.connect(self._on_baton_btn)
+ lay.addWidget(self._baton_btn)
+
+ # avatar
+ self._avatar = QLabel("--")
+ self._avatar.setObjectName("Avatar")
+ self._avatar.setFixedSize(34, 34)
+ self._avatar.setAlignment(Qt.AlignCenter)
+ lay.addWidget(self._avatar)
+
+ self._p = palette
+ self._is_staff = False
+ self._last_baton = None
+ self._style_baton_btn()
+
+ def _set_dot(self, color: str) -> None:
+ self._dot.setStyleSheet(f"background:{color}; border-radius:4px;")
+
+ def set_mode(self, mode: str) -> None:
+ self.toggle.set_mode(mode)
+
+ def set_staff(self, is_staff: bool) -> None:
+ if is_staff != self._is_staff:
+ self._is_staff = is_staff
+ if self._last_baton is not None:
+ self.update_baton_status(self._last_baton)
+
+ def _on_baton_btn(self) -> None:
+ {
+ "grab": self.request_baton,
+ "request": self.request_baton,
+ "release": self.release_baton,
+ "cancel": self.cancel_baton_request,
+ }.get(self._baton_action, self.request_baton).emit()
+
+ def _style_baton_btn(self) -> None:
+ p = self._p if hasattr(self, "_p") else None
+ if p is None:
+ return
+ primary = self._baton_action in ("grab", "request")
+ if primary:
+ css = (f"background:{p.accent}; color:#fff; border:none;")
+ else:
+ css = (f"background:{p.surface}; border:1px solid {p.border_control};"
+ f" color:{p.text_secondary};")
+ self._baton_btn.setStyleSheet(
+ f"QPushButton {{ {css} border-radius:8px; padding:6px 14px;"
+ f" font-size:12px; font-weight:600; }}"
+ )
+
+ def update_baton_status(self, status) -> None:
+ """Reflect a BatonStatus with staff-gated holder identity.
+
+ Staff see the holder's username; non-staff only see whether *another
+ user* or *staff* holds control (no identity leak).
+ """
+ p = self._p
+ self._last_baton = status
+ holder = getattr(status, "holder", None)
+ username = getattr(holder, "username", None) if holder else None
+ holder_is_staff = bool(getattr(holder, "is_staff", False)) if holder else False
+ you_hold = bool(getattr(status, "you_are_holder", False))
+ pending = bool(getattr(status, "you_have_pending_request", False))
+
+ if you_hold:
+ self._baton_text.setText("You hold the baton")
+ self._set_dot(p.success)
+ self._baton_action = "release"
+ self._baton_btn.setText("Release")
+ elif pending:
+ self._baton_text.setText("Requesting control…")
+ self._set_dot(p.breakpoint)
+ self._baton_action = "cancel"
+ self._baton_btn.setText("Cancel")
+ elif holder is not None:
+ if self._is_staff and username:
+ self._baton_text.setText(f"{username} holds the baton")
+ elif holder_is_staff:
+ self._baton_text.setText("Staff has the baton")
+ else:
+ self._baton_text.setText("Another user has the baton")
+ self._set_dot(p.text_faint)
+ self._baton_action = "request"
+ self._baton_btn.setText("Request control")
+ else:
+ self._baton_text.setText("Beamline vacant")
+ self._set_dot(p.breakpoint)
+ self._baton_action = "grab"
+ self._baton_btn.setText("Grab")
+
+ # Holder identity (mono id) is staff-only.
+ self._baton_id.setText(f"· {username}" if (self._is_staff and username) else "")
+ self._style_baton_btn()
+ if self._is_staff and username:
+ self._avatar.setText(username[:2].upper())
+ elif you_hold:
+ self._avatar.setText("ME")
+ else:
+ self._avatar.setText("--")
diff --git a/src/aare/gui/new_gui/widgets/__init__.py b/src/aare/gui/new_gui/widgets/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/aare/gui/new_gui/widgets/alert_banner.py b/src/aare/gui/new_gui/widgets/alert_banner.py
new file mode 100644
index 00000000..ac2ea4f7
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/alert_banner.py
@@ -0,0 +1,68 @@
+"""A dismissible top alert banner for errors, warnings and completion notices."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget
+
+from aare.gui.new_gui.theme import Palette
+
+
+class AlertBanner(QWidget):
+ """A thin coloured strip below the top bar. Hidden when there's no message."""
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self.setAttribute(Qt.WA_StyledBackground, True)
+ self.setVisible(False)
+
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(16, 8, 12, 8)
+ lay.setSpacing(10)
+ self._icon = QLabel("")
+ self._text = QLabel("")
+ self._text.setWordWrap(True)
+ lay.addWidget(self._icon)
+ lay.addWidget(self._text, 1)
+ close = QPushButton("✕")
+ close.setCursor(Qt.PointingHandCursor)
+ close.setFixedSize(22, 22)
+ close.setStyleSheet(
+ "QPushButton { border:none; background:transparent;"
+ f" color:{palette.text_muted}; font-size:13px; }}"
+ )
+ close.clicked.connect(self.clear)
+ lay.addWidget(close)
+
+ self._timer = QTimer(self)
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self.clear)
+
+ def show_message(self, text: str, level: str = "info",
+ auto_clear_ms: int = 8000) -> None:
+ """level: 'info' | 'success' | 'warning' | 'error'."""
+ p = self._p
+ styles = {
+ "info": (p.accent_tint_bg, p.accent_tint_border, p.text_primary, "ℹ"),
+ "success": (p.success_tint, p.success, p.text_primary, "✓"),
+ "warning": (p.chip_off_bg, p.breakpoint, p.text_primary, "⚠"),
+ "error": ("#fbe9e7", p.danger, p.text_primary, "⛔"),
+ }
+ bg, border, fg, icon = styles.get(level, styles["info"])
+ self.setStyleSheet(
+ f"AlertBanner {{ background:{bg}; border-bottom:1px solid {border}; }}"
+ )
+ self._icon.setText(icon)
+ self._icon.setStyleSheet(f"color:{border}; font-size:14px; font-weight:700;")
+ self._text.setText(text)
+ self._text.setStyleSheet(f"color:{fg}; font-size:12.5px;")
+ self.setVisible(True)
+ self._timer.stop()
+ if auto_clear_ms and level not in ("error",):
+ self._timer.start(auto_clear_ms)
+
+ def clear(self) -> None:
+ self._timer.stop()
+ self.setVisible(False)
+ self._text.clear()
diff --git a/src/aare/gui/new_gui/widgets/automation_progress.py b/src/aare/gui/new_gui/widgets/automation_progress.py
new file mode 100644
index 00000000..af26d83f
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/automation_progress.py
@@ -0,0 +1,119 @@
+"""Live automation progress panel, driven by the automation_progress SSE stream.
+
+Renders the per-sample workflow steps (Mount / Center / Raster / Collect / Final)
+with status icons, plus live metrics (current sample, samples left, avg time,
+ETA). Pause/Stop are GUI-side: the controller stops launching the next sample.
+"""
+
+from __future__ import annotations
+
+import time
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
+
+from aare.gui.new_gui.theme import Palette
+from aare.gui.new_gui.widgets.common import hline, section_label
+
+# WorkflowStateKind value/name -> human label
+_STEP_LABELS = {
+ "MOUNT": "Mount", "mount": "Mount",
+ "LOOP_CENTRE": "Center", "loop_centre": "Center", "LOOP_CENTER": "Center",
+ "RASTER": "Raster", "raster": "Raster",
+ "DATA_COLLECTION": "Collect", "data_collection": "Collect",
+ "FINAL": "Final", "final": "Final",
+}
+_STATUS_ICONS = {
+ "PENDING": "⚪", "RUNNING": "🔵", "SUCCESS": "✅",
+ "FAILED": "❌", "SKIPPED": "⏭️", "PAUSED": "⏸️",
+}
+
+
+def _enum_key(v) -> str:
+ return getattr(v, "name", None) or getattr(v, "value", None) or str(v)
+
+
+class AutomationProgressPanel(QWidget):
+ """Right-column panel showing live automation progress."""
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self.setObjectName("SidePanel")
+ self.setFixedWidth(300)
+ self._p = palette
+
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(16, 14, 16, 14)
+ lay.setSpacing(10)
+ lay.addWidget(section_label("RUN PROGRESS"))
+
+ self._stats = QLabel("Idle — start the queue to run.")
+ self._stats.setWordWrap(True)
+ self._stats.setStyleSheet(f"font-size:12px; color:{palette.text_muted};")
+ lay.addWidget(self._stats)
+
+ lay.addWidget(hline(palette))
+
+ self._steps = QLabel("")
+ self._steps.setWordWrap(True)
+ self._steps.setTextFormat(Qt.RichText)
+ self._steps.setStyleSheet("font-size:12.5px;")
+ lay.addWidget(self._steps)
+ lay.addStretch(1)
+
+ def set_idle(self) -> None:
+ self._stats.setText("Idle — start the queue to run.")
+ self._steps.setText("")
+
+ def update_progress(self, progress) -> None:
+ cur = getattr(progress, "current_sample_name", "") or "—"
+ left = getattr(progress, "samples_in_queue", 0)
+ avg = getattr(progress, "avg_time_per_sample", 0.0) or 0.0
+ remaining = avg * max(0, left)
+ lines = [
+ f"Current sample: {cur}",
+ f"Samples left: {left}",
+ f"Avg / sample: {self._fmt(avg)}",
+ f"Queue remaining: {self._fmt(remaining)}",
+ ]
+ if remaining > 0:
+ lines.append(f"Done by: {self._eta(remaining)}")
+ if getattr(progress, "finished", False):
+ ok = getattr(progress, "success", None)
+ lines.append("✅ Finished" if ok else "⛔ Stopped")
+ self._stats.setText("
".join(lines))
+
+ rows = []
+ for st in getattr(progress, "steps", []) or []:
+ title = _STEP_LABELS.get(_enum_key(getattr(st, "step", "")),
+ _enum_key(getattr(st, "step", "")))
+ icon = _STATUS_ICONS.get(_enum_key(getattr(st, "status", "")), "•")
+ dur = ""
+ started = getattr(st, "started_at", None)
+ if started:
+ end = getattr(st, "completed_at", None) or time.time()
+ dur = f" ({self._fmt(end - started)})"
+ msg = getattr(st, "message", "") or ""
+ extra = f" — {msg}" if msg else ""
+ err = getattr(st, "error_code", None)
+ if err:
+ extra += f"
Error: {err}"
+ rows.append(f"{icon} {title}{dur}{extra}")
+ self._steps.setText("
".join(rows))
+
+ @staticmethod
+ def _fmt(seconds: float) -> str:
+ seconds = int(seconds)
+ if seconds < 60:
+ return f"{seconds}s"
+ m, s = divmod(seconds, 60)
+ if m < 60:
+ return f"{m}m {s}s"
+ h, m = divmod(m, 60)
+ return f"{h}h {m}m"
+
+ @staticmethod
+ def _eta(seconds: float) -> str:
+ # Avoid Date.now-style nondeterminism concerns: use local clock for display.
+ t = time.localtime(time.time() + seconds)
+ return time.strftime("%H:%M", t)
diff --git a/src/aare/gui/new_gui/widgets/baton_dialogs.py b/src/aare/gui/new_gui/widgets/baton_dialogs.py
new file mode 100644
index 00000000..b70e5068
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/baton_dialogs.py
@@ -0,0 +1,150 @@
+"""Baton request dialogs: incoming (accept/refuse) and outgoing (pending)."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QTimer, Signal
+from PySide6.QtWidgets import (
+ QDialog,
+ QHBoxLayout,
+ QLabel,
+ QProgressBar,
+ QPushButton,
+ QVBoxLayout,
+)
+
+from aare.gui.new_gui.theme import Palette
+
+
+class BatonIncomingDialog(QDialog):
+ """Shown when a peer requests control from you. Timeout auto-accepts."""
+
+ accepted_signal = Signal()
+ refused_signal = Signal()
+
+ def __init__(self, requester: str, timeout_s: int, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self._remaining = max(1, int(timeout_s))
+ self.setWindowTitle("Control Request")
+ self.setModal(False)
+
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(22, 20, 22, 18)
+ lay.setSpacing(12)
+
+ header = QLabel("🔔 Control Request")
+ header.setStyleSheet("font-size:15px; font-weight:700;")
+ lay.addWidget(header)
+ msg = QLabel(f"{requester} is requesting control of the beamline.")
+ msg.setWordWrap(True)
+ lay.addWidget(msg)
+
+ self._bar = QProgressBar()
+ self._bar.setRange(0, self._remaining)
+ self._bar.setValue(self._remaining)
+ self._bar.setTextVisible(False)
+ self._bar.setFixedHeight(6)
+ lay.addWidget(self._bar)
+
+ warn = QLabel("⚠️ If you don't respond, control transfers automatically.")
+ warn.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
+ lay.addWidget(warn)
+
+ btns = QHBoxLayout()
+ btns.addStretch(1)
+ refuse = QPushButton("✗ Refuse")
+ refuse.setStyleSheet(
+ f"QPushButton {{ background:{palette.surface};"
+ f" border:1px solid {palette.border_control}; border-radius:8px;"
+ f" padding:8px 16px; color:{palette.danger}; font-weight:600; }}"
+ )
+ accept = QPushButton("✓ Accept")
+ accept.setStyleSheet(
+ f"QPushButton {{ background:{palette.success}; color:#fff; border:none;"
+ f" border-radius:8px; padding:8px 16px; font-weight:600; }}"
+ )
+ refuse.clicked.connect(self._on_refuse)
+ accept.clicked.connect(self._on_accept)
+ btns.addWidget(refuse)
+ btns.addWidget(accept)
+ lay.addLayout(btns)
+
+ self._timer = QTimer(self)
+ self._timer.setInterval(1000)
+ self._timer.timeout.connect(self._tick)
+ self._timer.start()
+
+ def _tick(self) -> None:
+ self._remaining -= 1
+ self._bar.setValue(max(0, self._remaining))
+ if self._remaining <= 0:
+ self._timer.stop()
+ self._on_accept() # timeout = auto-accept (matches old GUI)
+
+ def _on_accept(self) -> None:
+ self._timer.stop()
+ self.accepted_signal.emit()
+ self.accept()
+
+ def _on_refuse(self) -> None:
+ self._timer.stop()
+ self.refused_signal.emit()
+ self.reject()
+
+
+class BatonPendingDialog(QDialog):
+ """Shown while waiting for the holder to respond to your request."""
+
+ cancelled_signal = Signal()
+
+ def __init__(self, holder: str, timeout_s: int, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self._total = max(1, int(timeout_s))
+ self.setWindowTitle("Requesting control")
+ self.setModal(False)
+
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(22, 20, 22, 18)
+ lay.setSpacing(12)
+
+ header = QLabel("⏳ Requesting control")
+ header.setStyleSheet("font-size:15px; font-weight:700;")
+ lay.addWidget(header)
+ target = holder or "the current holder"
+ self._msg = QLabel(f"Waiting for {target} to respond…")
+ self._msg.setWordWrap(True)
+ lay.addWidget(self._msg)
+
+ self._bar = QProgressBar()
+ self._bar.setRange(0, self._total)
+ self._bar.setValue(self._total)
+ self._bar.setTextVisible(False)
+ self._bar.setFixedHeight(6)
+ lay.addWidget(self._bar)
+
+ btns = QHBoxLayout()
+ btns.addStretch(1)
+ cancel = QPushButton("Cancel request")
+ cancel.setStyleSheet(
+ f"QPushButton {{ background:{palette.surface};"
+ f" border:1px solid {palette.border_control}; border-radius:8px;"
+ f" padding:8px 16px; color:{palette.text_secondary}; }}"
+ )
+ cancel.clicked.connect(self._on_cancel)
+ btns.addWidget(cancel)
+ lay.addLayout(btns)
+
+ def update_remaining(self, seconds: int) -> None:
+ self._bar.setValue(max(0, min(self._total, int(seconds))))
+
+ def set_queued(self) -> None:
+ self._msg.setText(
+ "The beamline is busy. Your request was accepted and the baton will "
+ "transfer when the current operation completes."
+ )
+ self._bar.setRange(0, 0) # indeterminate
+
+ def _on_cancel(self) -> None:
+ self.cancelled_signal.emit()
+ self.reject()
diff --git a/src/aare/gui/new_gui/widgets/camera.py b/src/aare/gui/new_gui/widgets/camera.py
new file mode 100644
index 00000000..13fc5aed
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/camera.py
@@ -0,0 +1,450 @@
+"""The central camera viewport with crosshair, overlays and a raster grid-draw tool.
+
+Wires to the live SAMCAM feed via :class:`PredictionSubscriber` (``image`` ->
+:meth:`update_pixmap`). When no frame is available it paints the design's dark
+radial gradient so the layout still reads. Empty/picking states and the Mount
+CTA are real child widgets so their buttons are clickable; the crosshair, target
+ring, legend and raster grid are painted.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QPoint, QRect, QRectF, Qt, Signal
+from PySide6.QtGui import (
+ QColor,
+ QFont,
+ QPainter,
+ QPen,
+ QPixmap,
+ QRadialGradient,
+)
+from PySide6.QtWidgets import (
+ QFrame,
+ QLabel,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
+
+
+class CameraViewport(QWidget):
+ """Camera image + overlays. Modes: 'empty' | 'picking' | 'mounted'."""
+
+ mount_clicked = Signal()
+ # Emitted while/after dragging a raster grid. Args are the rectangle in
+ # IMAGE-PIXEL coordinates (x, y, w, h) so the raster controller can convert
+ # to mm using the status pixel_in_mm.
+ grid_drawn = Signal(float, float, float, float)
+ # Left-click on the live image (not in grid-draw mode) in IMAGE-PIXEL coords —
+ # drives implicit click-to-centre.
+ clicked_point = Signal(float, float)
+
+ def __init__(self, palette: Palette, parent: QWidget | None = None):
+ super().__init__(parent)
+ self._p = palette
+ self._pixmap: QPixmap | None = None
+ self._fps: float = 0.0
+ self._zoom: float = 2.0
+ self._mode = "empty"
+ self._target_point: QPoint | None = None # image-pixel coords
+
+ # Raster grid-draw state
+ self._grid_draw_enabled = False
+ self._grid_rect_img: QRectF | None = None # in image-pixel coords
+ self._grid_nx = 0
+ self._grid_ny = 0
+ self._heatmap = None # QImage or None
+ self._heatmap_alpha = 180
+ self._drag_start: QPoint | None = None # widget coords
+ self._drag_cur: QPoint | None = None
+
+ # geometry of the last painted frame (for widget<->image mapping)
+ self._img_w = 1
+ self._img_h = 1
+ self._draw_rect = QRect() # where the frame is drawn in widget space
+
+ # control (baton) state — when False the viewport is a read-only guest view
+ self._in_control = True
+
+ self.setMinimumHeight(220)
+ self.setMouseTracking(True)
+
+ self._build_overlays()
+ self._build_control_overlay()
+ self.set_mode("empty")
+
+ # ------------------------------------------------------------ overlays
+ def _build_overlays(self) -> None:
+ p = self._p
+ # Empty-state modal card
+ self._empty_card = QFrame(self)
+ self._empty_card.setStyleSheet(
+ "QFrame { background: rgba(18,17,16,0.74);"
+ " border:1px solid rgba(255,255,255,0.14); border-radius:16px; }"
+ )
+ ecl = QVBoxLayout(self._empty_card)
+ ecl.setContentsMargins(34, 30, 34, 30)
+ ecl.setSpacing(0)
+ ecl.setAlignment(Qt.AlignHCenter)
+ tile = QLabel("⊕")
+ tile.setAlignment(Qt.AlignCenter)
+ tile.setFixedSize(56, 56)
+ tile.setStyleSheet(
+ f"background:{p.accent}; color:#fff; border-radius:14px; font-size:27px;"
+ )
+ title = QLabel("No sample mounted")
+ title.setAlignment(Qt.AlignCenter)
+ title.setStyleSheet("color:#fff; font-size:17px; font-weight:700;")
+ body = QLabel(
+ "The beamline is idle. Mount a sample from the changer to begin the "
+ "Center → Collect pipeline."
+ )
+ body.setWordWrap(True)
+ body.setAlignment(Qt.AlignCenter)
+ body.setStyleSheet("color:#c7c2bb; font-size:12.5px;")
+ body.setMaximumWidth(280)
+ btn = QPushButton("⊕ Mount sample")
+ btn.setCursor(Qt.PointingHandCursor)
+ btn.setStyleSheet(
+ f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
+ f" border-radius:10px; padding:11px 22px; font-size:14px; font-weight:600; }}"
+ )
+ btn.clicked.connect(self.mount_clicked)
+ ecl.addWidget(tile, 0, Qt.AlignHCenter)
+ ecl.addSpacing(16)
+ ecl.addWidget(title)
+ ecl.addSpacing(6)
+ ecl.addWidget(body)
+ ecl.addSpacing(20)
+ ecl.addWidget(btn, 0, Qt.AlignHCenter)
+ self._empty_card.adjustSize()
+
+ # Picking toast
+ self._toast = QLabel(
+ "← Choose a sample in the Sample Changer to mount it", self
+ )
+ self._toast.setStyleSheet(
+ "background: rgba(18,17,16,0.66); border:1px solid rgba(255,255,255,0.12);"
+ " border-radius:12px; padding:15px 22px; font-size:13px; color:#e7e3db;"
+ )
+ self._toast.adjustSize()
+
+ def _build_control_overlay(self) -> None:
+ """Full-rect dim + message shown when this user does not hold the baton."""
+ self._control_overlay = QFrame(self)
+ self._control_overlay.setStyleSheet(
+ "QFrame { background: rgba(8,9,12,0.62); }"
+ )
+ col = QVBoxLayout(self._control_overlay)
+ col.setAlignment(Qt.AlignCenter)
+ col.setSpacing(6)
+ self._control_title = QLabel("GUEST MODE")
+ self._control_title.setAlignment(Qt.AlignCenter)
+ self._control_title.setStyleSheet(
+ "color:#ece8f4; font-size:18px; font-weight:700; letter-spacing:2px;"
+ " background:transparent;"
+ )
+ self._control_sub = QLabel("")
+ self._control_sub.setAlignment(Qt.AlignCenter)
+ self._control_sub.setStyleSheet(
+ "color:#c7c2bb; font-size:12.5px; background:transparent;"
+ )
+ col.addWidget(self._control_title)
+ col.addWidget(self._control_sub)
+ self._control_overlay.setVisible(False)
+
+ def set_control_state(self, in_control: bool, title: str = "GUEST MODE",
+ sub: str = "") -> None:
+ """in_control=False dims the viewport and blocks interaction."""
+ self._in_control = in_control
+ self._control_title.setText(title)
+ self._control_sub.setText(sub)
+ self._control_overlay.setVisible(not in_control)
+ if not in_control:
+ self._control_overlay.setGeometry(self.rect())
+ self._control_overlay.raise_()
+ self.update()
+
+ # --------------------------------------------------------------- state
+ def set_mode(self, mode: str) -> None:
+ self._mode = mode
+ self._empty_card.setVisible(mode == "empty")
+ self._toast.setVisible(mode == "picking")
+ self._reposition_overlays()
+ self.update()
+
+ def set_grid_draw_enabled(self, enabled: bool) -> None:
+ self._grid_draw_enabled = enabled
+ self.setCursor(Qt.CrossCursor if enabled else Qt.ArrowCursor)
+ if not enabled:
+ self._drag_start = self._drag_cur = None
+ self.update()
+
+ def set_grid_cells(self, n_x: int, n_y: int) -> None:
+ self._grid_nx, self._grid_ny = max(0, n_x), max(0, n_y)
+ self.update()
+
+ def clear_grid(self) -> None:
+ self._grid_rect_img = None
+ self._grid_nx = self._grid_ny = 0
+ self._drag_start = self._drag_cur = None
+ self._heatmap = None
+ self.update()
+
+ def set_heatmap(self, image, alpha: int | None = None) -> None:
+ """Overlay a completed-raster heatmap (QImage) on the current grid rect."""
+ self._heatmap = image
+ if alpha is not None:
+ self._heatmap_alpha = max(0, min(255, alpha))
+ self.update()
+
+ def set_heatmap_alpha(self, alpha: int) -> None:
+ self._heatmap_alpha = max(0, min(255, alpha))
+ self.update()
+
+ def set_grid_rect(self, x: float, y: float, w: float, h: float) -> None:
+ """Set the grid rect in image-pixel coords (used to place a heatmap)."""
+ self._grid_rect_img = QRectF(x, y, w, h)
+ self.update()
+
+ # ------------------------------------------------------------- slots
+ def update_pixmap(self, pm: QPixmap) -> None:
+ self._pixmap = pm
+ if not pm.isNull():
+ self._img_w = pm.width()
+ self._img_h = pm.height()
+ self.update()
+
+ def update_fps(self, fps: float) -> None:
+ self._fps = fps
+ self.update()
+
+ def set_zoom(self, zoom: float) -> None:
+ self._zoom = zoom
+ self.update()
+
+ def update_target_point(self, data: dict) -> None:
+ tp = data.get("target_point") if isinstance(data, dict) else None
+ if tp and "x" in tp and "y" in tp:
+ self._target_point = QPoint(int(tp["x"]), int(tp["y"]))
+ else:
+ self._target_point = None
+ self.update()
+
+ # -------------------------------------------------- coordinate mapping
+ def _widget_to_image(self, pt: QPoint) -> QPoint:
+ if self._draw_rect.width() <= 0 or self._draw_rect.height() <= 0:
+ return QPoint(0, 0)
+ fx = (pt.x() - self._draw_rect.x()) / self._draw_rect.width()
+ fy = (pt.y() - self._draw_rect.y()) / self._draw_rect.height()
+ fx = min(max(fx, 0.0), 1.0)
+ fy = min(max(fy, 0.0), 1.0)
+ return QPoint(int(fx * self._img_w), int(fy * self._img_h))
+
+ def _image_to_widget(self, pt: QPoint) -> QPoint:
+ if self._img_w <= 0 or self._img_h <= 0:
+ return QPoint(0, 0)
+ x = self._draw_rect.x() + pt.x() / self._img_w * self._draw_rect.width()
+ y = self._draw_rect.y() + pt.y() / self._img_h * self._draw_rect.height()
+ return QPoint(int(x), int(y))
+
+ def _image_rect_to_widget(self, r: QRectF) -> QRect:
+ tl = self._image_to_widget(QPoint(int(r.left()), int(r.top())))
+ br = self._image_to_widget(QPoint(int(r.right()), int(r.bottom())))
+ return QRect(tl, br)
+
+ # --------------------------------------------------------- mouse (grid)
+ def mousePressEvent(self, event): # noqa: N802
+ if not self._in_control:
+ super().mousePressEvent(event)
+ return
+ if self._grid_draw_enabled and event.button() == Qt.LeftButton:
+ self._drag_start = event.position().toPoint()
+ self._drag_cur = self._drag_start
+ self.update()
+ elif (event.button() == Qt.LeftButton and self._mode == "mounted"
+ and self._pixmap is not None and not self._pixmap.isNull()):
+ # Implicit click-to-centre: report the clicked point in image pixels.
+ img = self._widget_to_image(event.position().toPoint())
+ self.clicked_point.emit(float(img.x()), float(img.y()))
+ else:
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event): # noqa: N802
+ if self._grid_draw_enabled and self._drag_start is not None:
+ self._drag_cur = event.position().toPoint()
+ self.update()
+ else:
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event): # noqa: N802
+ if (self._grid_draw_enabled and self._drag_start is not None
+ and event.button() == Qt.LeftButton):
+ self._drag_cur = event.position().toPoint()
+ a = self._widget_to_image(self._drag_start)
+ b = self._widget_to_image(self._drag_cur)
+ x, y = min(a.x(), b.x()), min(a.y(), b.y())
+ w, h = abs(b.x() - a.x()), abs(b.y() - a.y())
+ if w > 2 and h > 2:
+ self._grid_rect_img = QRectF(x, y, w, h)
+ self.grid_drawn.emit(float(x), float(y), float(w), float(h))
+ self._drag_start = self._drag_cur = None
+ self.update()
+ else:
+ super().mouseReleaseEvent(event)
+
+ # ------------------------------------------------------------- layout
+ def resizeEvent(self, event): # noqa: N802
+ super().resizeEvent(event)
+ self._reposition_overlays()
+ if hasattr(self, "_control_overlay"):
+ self._control_overlay.setGeometry(self.rect())
+
+ def _reposition_overlays(self) -> None:
+ if self._empty_card.isVisible():
+ self._empty_card.adjustSize()
+ sz = self._empty_card.size()
+ self._empty_card.move(
+ (self.width() - sz.width()) // 2,
+ (self.height() - sz.height()) // 2,
+ )
+ if self._toast.isVisible():
+ self._toast.adjustSize()
+ sz = self._toast.size()
+ self._toast.move(
+ (self.width() - sz.width()) // 2,
+ (self.height() - sz.height()) // 2,
+ )
+
+ # ------------------------------------------------------------- paint
+ def paintEvent(self, event): # noqa: N802
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ r = self.rect()
+
+ if self._pixmap is not None and not self._pixmap.isNull():
+ scaled = self._pixmap.scaled(
+ r.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
+ )
+ x = (r.width() - scaled.width()) // 2
+ y = (r.height() - scaled.height()) // 2
+ self._draw_rect = QRect(x, y, scaled.width(), scaled.height())
+ # letterbox
+ painter.fillRect(r, QColor(self._p.cam_stop2))
+ painter.drawPixmap(x, y, scaled)
+ else:
+ grad = QRadialGradient(r.width() * 0.75, r.height() * 0.80,
+ max(r.width(), r.height()) * 1.0)
+ grad.setColorAt(0.0, QColor(self._p.cam_stop0))
+ grad.setColorAt(0.45, QColor(self._p.cam_stop1))
+ grad.setColorAt(1.0, QColor(self._p.cam_stop2))
+ painter.fillRect(r, grad)
+ self._draw_rect = r
+
+ self._paint_crosshair(painter, r)
+ self._paint_overlay_text(painter, r)
+
+ if self._mode == "mounted":
+ self._paint_mounted_overlays(painter, r)
+
+ self._paint_grid(painter)
+
+ def _paint_crosshair(self, painter: QPainter, r: QRect) -> None:
+ pen = QPen(QColor(255, 255, 255, 46))
+ pen.setWidth(1)
+ painter.setPen(pen)
+ cx, cy = r.width() // 2, r.height() // 2
+ painter.drawLine(cx, cy - 21, cx, cy + 21)
+ painter.drawLine(cx - 21, cy, cx + 21, cy)
+
+ def _paint_overlay_text(self, painter: QPainter, r: QRect) -> None:
+ painter.setPen(QColor("#867f96"))
+ f = QFont(FONT_MONO_FALLBACK.split(",")[0].strip('"'))
+ f.setPixelSize(11)
+ painter.setFont(f)
+ fps = "-" if self._fps != self._fps else f"{self._fps:.0f}" # NaN-safe
+ text = f"SAMCAM · {fps} fps · {self._zoom:.1f}×"
+ painter.drawText(QRect(0, 8, r.width() - 14, 16),
+ Qt.AlignRight | Qt.AlignTop, text)
+
+ def _paint_mounted_overlays(self, painter: QPainter, r: QRect) -> None:
+ # Target ring: at target point if known, else design's bottom-right area.
+ if self._target_point is not None:
+ c = self._image_to_widget(self._target_point)
+ else:
+ c = QPoint(r.width() - 120, r.height() - 90)
+ painter.setBrush(Qt.NoBrush)
+ painter.setPen(QPen(QColor(self._p.accent), 2))
+ painter.drawEllipse(c, 15, 15)
+ halo = QColor(self._p.accent)
+ halo.setAlpha(56)
+ painter.setPen(QPen(halo, 4))
+ painter.drawEllipse(c, 17, 17)
+
+ # Legend card (bottom-left)
+ self._paint_legend(painter, r)
+
+ def _paint_legend(self, painter: QPainter, r: QRect) -> None:
+ items = [("#9b7bff", "Target + coords"),
+ ("#d29922", "Loop / face"),
+ ("#3fb950", "Crystal")]
+ pad, line_h, sw = 11, 18, 9
+ f = QFont(FONT_MONO_FALLBACK.split(",")[0].strip('"'))
+ f.setPixelSize(10)
+ painter.setFont(f)
+ w = 150
+ h = pad * 2 + line_h * len(items)
+ x, y = 14, r.height() - 14 - h
+ card = QRect(x, y, w, h)
+ painter.setBrush(QColor(8, 10, 13, 168))
+ painter.setPen(QPen(QColor("#332c44"), 1))
+ painter.drawRoundedRect(card, 7, 7)
+ for i, (col, label) in enumerate(items):
+ ly = y + pad + i * line_h
+ painter.setBrush(QColor(col))
+ painter.setPen(Qt.NoPen)
+ painter.drawRoundedRect(QRect(x + pad, ly + 2, sw, sw), 2, 2)
+ painter.setPen(QColor("#d9d3e4"))
+ painter.drawText(QRect(x + pad + sw + 7, ly, w - pad * 2 - sw, line_h),
+ Qt.AlignLeft | Qt.AlignVCenter, label)
+
+ def _paint_grid(self, painter: QPainter) -> None:
+ # Live drag rectangle
+ rect = None
+ if self._drag_start is not None and self._drag_cur is not None:
+ rect = QRect(self._drag_start, self._drag_cur).normalized()
+ elif self._grid_rect_img is not None:
+ rect = self._image_rect_to_widget(self._grid_rect_img)
+ if rect is None or rect.width() < 1 or rect.height() < 1:
+ return
+ dragging = self._drag_start is not None and self._drag_cur is not None
+
+ # Completed-result heatmap (blitted, nearest-neighbour, when not dragging).
+ if self._heatmap is not None and not dragging:
+ painter.save()
+ painter.setRenderHint(QPainter.SmoothPixmapTransform, False)
+ painter.setOpacity(self._heatmap_alpha / 255.0)
+ painter.drawImage(rect, self._heatmap)
+ painter.restore()
+
+ painter.setBrush(Qt.NoBrush)
+ painter.setPen(QPen(QColor(self._p.accent), 2))
+ painter.drawRect(rect)
+
+ # Cell gridlines (only while drawing / before a heatmap exists).
+ if self._heatmap is None or dragging:
+ nx = self._grid_nx if self._grid_nx > 0 else 0
+ ny = self._grid_ny if self._grid_ny > 0 else 0
+ cell = QColor(self._p.accent)
+ cell.setAlpha(90)
+ painter.setPen(QPen(cell, 1))
+ if nx > 1:
+ for i in range(1, nx):
+ gx = rect.left() + rect.width() * i / nx
+ painter.drawLine(int(gx), rect.top(), int(gx), rect.bottom())
+ if ny > 1:
+ for j in range(1, ny):
+ gy = rect.top() + rect.height() * j / ny
+ painter.drawLine(rect.left(), int(gy), rect.right(), int(gy))
diff --git a/src/aare/gui/new_gui/widgets/common.py b/src/aare/gui/new_gui/widgets/common.py
new file mode 100644
index 00000000..a21d2155
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/common.py
@@ -0,0 +1,215 @@
+"""Small reusable widgets and helpers shared across the redesigned GUI."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QPoint, QRect, QSize, Qt, Signal
+from PySide6.QtWidgets import (
+ QFrame,
+ QLabel,
+ QLayout,
+ QPushButton,
+ QSizePolicy,
+ QWidget,
+)
+
+
+class ClickableLabel(QLabel):
+ """A QLabel that emits ``clicked`` on left mouse press."""
+
+ clicked = Signal()
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setCursor(Qt.PointingHandCursor)
+
+ def mousePressEvent(self, event): # noqa: N802
+ if event.button() == Qt.LeftButton:
+ self.clicked.emit()
+ super().mousePressEvent(event)
+
+from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
+
+
+def hline(p: Palette) -> QFrame:
+ """A 1px horizontal divider matching the panel border colour."""
+ line = QFrame()
+ line.setObjectName("Divider")
+ line.setFixedHeight(1)
+ line.setStyleSheet(f"background:{p.border_panel}; border:none;")
+ return line
+
+
+def vline(p: Palette, height: int = 30) -> QFrame:
+ line = QFrame()
+ line.setFixedWidth(1)
+ line.setFixedHeight(height)
+ line.setStyleSheet(f"background:{p.border_panel}; border:none;")
+ return line
+
+
+def section_label(text: str) -> QLabel:
+ lbl = QLabel(text)
+ lbl.setObjectName("SectionLabel")
+ return lbl
+
+
+def micro_label(text: str) -> QLabel:
+ lbl = QLabel(text)
+ lbl.setObjectName("MicroLabel")
+ return lbl
+
+
+def mono_label(text: str, size: int = 13, color: str | None = None,
+ weight: int = 500) -> QLabel:
+ lbl = QLabel(text)
+ style = f"font-family:{FONT_MONO_FALLBACK}; font-size:{size}px; font-weight:{weight};"
+ if color:
+ style += f" color:{color};"
+ lbl.setStyleSheet(style)
+ return lbl
+
+
+class Chip(QPushButton):
+ """A small checkable pill used for protocol toggles and zoom levels.
+
+ On = accent fill / white text; off = chip-off bg / faint text. Styling is
+ self-contained so chips can live anywhere without global QSS dependencies.
+ """
+
+ def __init__(self, text: str, palette: Palette, checked: bool = False,
+ mono: bool = True, parent: QWidget | None = None):
+ super().__init__(text, parent)
+ self._p = palette
+ self._mono = mono
+ self.setCheckable(True)
+ self.setChecked(checked)
+ self.setCursor(Qt.PointingHandCursor)
+ self.toggled.connect(lambda _: self._restyle())
+ self._restyle()
+
+ def _restyle(self) -> None:
+ p = self._p
+ fam = f"font-family:{FONT_MONO_FALLBACK};" if self._mono else ""
+ if self.isChecked():
+ fill, color = p.accent, p.accent_text
+ else:
+ fill, color = p.chip_off_bg, p.text_faint
+ self.setStyleSheet(
+ f"QPushButton {{ {fam} font-size:10.5px; padding:4px 10px;"
+ f" border:none; border-radius:11px; background:{fill}; color:{color}; }}"
+ )
+
+
+class FlowLayout(QLayout):
+ """A height-for-width flow layout (wraps items like inline-block).
+
+ Adapted from the canonical Qt flow-layout example; used for chip rows that
+ should wrap rather than clip on narrow panels.
+ """
+
+ def __init__(self, parent: QWidget | None = None, margin: int = 0, spacing: int = 6):
+ super().__init__(parent)
+ self._items: list = []
+ self._spacing = spacing
+ self.setContentsMargins(margin, margin, margin, margin)
+
+ def __del__(self):
+ while self.count():
+ self.takeAt(0)
+
+ def addItem(self, item): # noqa: N802 (Qt override)
+ self._items.append(item)
+
+ def count(self):
+ return len(self._items)
+
+ def itemAt(self, index): # noqa: N802
+ if 0 <= index < len(self._items):
+ return self._items[index]
+ return None
+
+ def takeAt(self, index): # noqa: N802
+ if 0 <= index < len(self._items):
+ return self._items.pop(index)
+ return None
+
+ def expandingDirections(self): # noqa: N802
+ return Qt.Orientations(Qt.Orientation(0))
+
+ def hasHeightForWidth(self): # noqa: N802
+ return True
+
+ def heightForWidth(self, width): # noqa: N802
+ return self._do_layout(QRect(0, 0, width, 0), test_only=True)
+
+ def setGeometry(self, rect): # noqa: N802
+ super().setGeometry(rect)
+ self._do_layout(rect, test_only=False)
+
+ def sizeHint(self): # noqa: N802
+ return self.minimumSize()
+
+ def minimumSize(self): # noqa: N802
+ size = QSize()
+ for item in self._items:
+ size = size.expandedTo(item.minimumSize())
+ m = self.contentsMargins()
+ size += QSize(m.left() + m.right(), m.top() + m.bottom())
+ return size
+
+ def _do_layout(self, rect: QRect, test_only: bool) -> int:
+ x, y = rect.x(), rect.y()
+ line_height = 0
+ for item in self._items:
+ w = item.sizeHint().width()
+ h = item.sizeHint().height()
+ next_x = x + w + self._spacing
+ if next_x - self._spacing > rect.right() and line_height > 0:
+ x = rect.x()
+ y = y + line_height + self._spacing
+ next_x = x + w + self._spacing
+ line_height = 0
+ if not test_only:
+ item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
+ x = next_x
+ line_height = max(line_height, h)
+ return y + line_height - rect.y()
+
+
+class ParamCard(QFrame):
+ """A small two-line read-only card (label over mono value).
+
+ Used in the contextual settings rows (Raster/XRF/Collect parameters). The
+ value label can be updated live via :meth:`set_value`.
+ """
+
+ def __init__(self, label: str, value: str, palette: Palette,
+ highlighted: bool = False, parent: QWidget | None = None):
+ super().__init__(parent)
+ from PySide6.QtWidgets import QVBoxLayout
+ p = palette
+ if highlighted:
+ bg, border, lbl_c, val_c = p.accent_tint_bg, p.accent, p.accent, p.accent
+ else:
+ bg, border, lbl_c, val_c = (
+ p.surface, p.border_control, p.text_faint, p.text_primary,
+ )
+ self.setStyleSheet(
+ f"ParamCard {{ background:{bg}; border:1px solid {border};"
+ f" border-radius:7px; }} QLabel {{ background:transparent; border:none; }}"
+ )
+ self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
+ vb = QVBoxLayout(self)
+ vb.setContentsMargins(11, 5, 11, 5)
+ vb.setSpacing(1)
+ self._lab = QLabel(label)
+ self._lab.setStyleSheet(f"font-size:9px; color:{lbl_c};")
+ self._val = QLabel(value)
+ self._val.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:12px; color:{val_c};"
+ )
+ vb.addWidget(self._lab)
+ vb.addWidget(self._val)
+
+ def set_value(self, value: str) -> None:
+ self._val.setText(value)
diff --git a/src/aare/gui/new_gui/widgets/heatmap.py b/src/aare/gui/new_gui/widgets/heatmap.py
new file mode 100644
index 00000000..4f55f358
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/heatmap.py
@@ -0,0 +1,82 @@
+"""Viridis heatmap + raster-metric extraction for completed raster grids.
+
+Mirrors ``raster_grid_manager``: each grid cell (a ScanResult image) yields a
+metric value; values are normalised and mapped through viridis into an RGBA
+QImage (alpha 0 for invalid/negative cells) which the camera blits over the grid.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PySide6.QtGui import QImage
+
+# 10-stop viridis LUT (purple -> yellow), interpolated per value.
+_VIRIDIS = np.array([
+ [0x44, 0x01, 0x54], [0x48, 0x28, 0x78], [0x3e, 0x4a, 0x89],
+ [0x31, 0x68, 0x8e], [0x26, 0x82, 0x8e], [0x1f, 0x9e, 0x89],
+ [0x35, 0xb7, 0x79], [0x6e, 0xce, 0x58], [0xb5, 0xde, 0x2b],
+ [0xfd, 0xe7, 0x25],
+], dtype=np.float64)
+
+
+# (key, label, extractor) — extractor maps a ScanResult image object to a value.
+METRICS: tuple[tuple[str, str], ...] = (
+ ("spots", "Spots"),
+ ("spots_low_res", "Spots (low-res)"),
+ ("spots_indexed", "Spots indexed"),
+ ("index", "Indexing"),
+ ("res", "Resolution"),
+ ("b", "B-factor"),
+ ("bkg", "Background"),
+ ("spots_ice", "Ice spots"),
+ ("pr", "Profile radius"),
+)
+
+
+def metric_value(img, key: str):
+ """Pull a single metric value off a ScanResult image object."""
+ if key == "pr":
+ spots_low = getattr(img, "spots_low_res", 0) or 0
+ return getattr(img, "index", 0) / max(spots_low, 1)
+ return getattr(img, key, None)
+
+
+def viridis_rgb(norm: np.ndarray) -> np.ndarray:
+ """norm in [0,1] -> (N,3) uint8 via linear interpolation in the LUT."""
+ norm = np.clip(norm, 0.0, 1.0)
+ pos = norm * (len(_VIRIDIS) - 1)
+ lo = np.floor(pos).astype(int)
+ hi = np.minimum(lo + 1, len(_VIRIDIS) - 1)
+ frac = (pos - lo)[:, None]
+ rgb = _VIRIDIS[lo] * (1 - frac) + _VIRIDIS[hi] * frac
+ return rgb.astype(np.uint8)
+
+
+def build_heatmap_qimage(values: list, n_x: int, n_y: int) -> QImage | None:
+ """Build an n_x×n_y RGBA QImage from per-cell metric values (row-major).
+
+ Invalid (None / NaN / negative) cells are fully transparent.
+ """
+ count = n_x * n_y
+ if count <= 0 or not values:
+ return None
+ flat = np.array(
+ [np.nan if v is None else float(v) for v in values[:count]]
+ + [np.nan] * max(0, count - len(values)),
+ dtype=np.float64,
+ )
+ valid = np.isfinite(flat) & (flat >= 0)
+ if valid.any():
+ vmin = float(flat[valid].min())
+ vmax = float(flat[valid].max())
+ else:
+ vmin, vmax = 0.0, 1.0
+ diff = (vmax - vmin) or 1.0
+ norm = np.clip((flat - vmin) / diff, 0.0, 1.0)
+ norm[~valid] = 0.0
+
+ rgba = np.zeros((count, 4), dtype=np.uint8)
+ rgba[:, :3] = viridis_rgb(norm)
+ rgba[:, 3] = np.where(valid, 255, 0).astype(np.uint8)
+ rgba = np.ascontiguousarray(rgba.reshape(n_y, n_x, 4))
+ return QImage(rgba.data, n_x, n_y, 4 * n_x, QImage.Format.Format_RGBA8888).copy()
diff --git a/src/aare/gui/new_gui/widgets/motors_panel.py b/src/aare/gui/new_gui/widgets/motors_panel.py
new file mode 100644
index 00000000..a462d7d5
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/motors_panel.py
@@ -0,0 +1,322 @@
+"""Motors panel (Manual mode, right column): OMEGA, SMARGON, ZOOM, LIGHT.
+
+Readouts update from DAQStatusModel; controls emit intent signals the main
+window wires to DAQWorker (set_omega_rel, move_smargon, zoom, front/back light).
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QButtonGroup,
+ QDoubleSpinBox,
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QSlider,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, MOTORS_W, Palette
+from aare.gui.new_gui.widgets.common import hline, section_label
+
+OMEGA_STEPS = (-90, -45, -10, 10, 45, 90)
+ZOOM_LEVELS = (1.0, 2.0, 3.5, 5.8)
+JOG_STEPS_UM = (10, 50, 100, 500)
+
+
+class MotorsPanel(QWidget):
+ omega_rel = Signal(float) # degrees, relative
+ smargon_jog = Signal(float, float) # dx_mm, dy_mm
+ zoom_changed = Signal(float)
+ front_light_changed = Signal(int)
+ back_light_changed = Signal(int)
+ energy_changed = Signal(float) # keV (staff only)
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self.setObjectName("SidePanel")
+ self.setFixedWidth(MOTORS_W)
+ self._p = palette
+ self._jog_idx = 2 # default 100 µm
+
+ from PySide6.QtWidgets import QScrollArea
+ scroll = QScrollArea(self)
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.NoFrame)
+ body = QWidget()
+ scroll.setWidget(body)
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.addWidget(scroll)
+
+ lay = QVBoxLayout(body)
+ lay.setContentsMargins(14, 14, 14, 14)
+ lay.setSpacing(0)
+
+ # ---- OMEGA ----
+ lay.addWidget(section_label("OMEGA"))
+ lay.addSpacing(10)
+ row = QHBoxLayout()
+ self._omega_val = QLabel("0.00°")
+ self._omega_val.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:22px; font-weight:600;"
+ )
+ row.addWidget(self._omega_val)
+ row.addStretch(1)
+ wsym = QLabel("ω")
+ wsym.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
+ row.addWidget(wsym)
+ lay.addLayout(row)
+ lay.addSpacing(10)
+ grid = QGridLayout()
+ grid.setSpacing(6)
+ for i, step in enumerate(OMEGA_STEPS):
+ btn = QPushButton(f"{step:+d}")
+ btn.setCursor(Qt.PointingHandCursor)
+ btn.clicked.connect(lambda _=False, s=step: self.omega_rel.emit(float(s)))
+ grid.addWidget(btn, i // 3, i % 3)
+ lay.addLayout(grid)
+
+ lay.addSpacing(16)
+ lay.addWidget(hline(palette))
+ lay.addSpacing(16)
+
+ # ---- SMARGON ----
+ lay.addWidget(section_label("SMARGON"))
+ lay.addSpacing(10)
+ readouts = QHBoxLayout()
+ self._chi_val = self._readout(readouts, "Chi", palette)
+ self._phi_val = self._readout(readouts, "Phi", palette)
+ lay.addLayout(readouts)
+ lay.addSpacing(10)
+
+ pad = QGridLayout()
+ pad.setSpacing(6)
+ up = self._jog_btn("↑")
+ down = self._jog_btn("↓")
+ left = self._jog_btn("←")
+ right = self._jog_btn("→")
+ self._center = QPushButton("100µm")
+ self._center.setCursor(Qt.PointingHandCursor)
+ self._center.setStyleSheet(
+ f"QPushButton {{ background:{palette.surface};"
+ f" border:1px solid {palette.border_control}; border-radius:6px;"
+ f" font-size:10px; color:{palette.text_faint}; padding:7px 0; }}"
+ )
+ self._center.clicked.connect(self._cycle_step)
+ up.clicked.connect(lambda: self._jog(0, 1))
+ down.clicked.connect(lambda: self._jog(0, -1))
+ left.clicked.connect(lambda: self._jog(-1, 0))
+ right.clicked.connect(lambda: self._jog(1, 0))
+ pad.addWidget(up, 0, 1)
+ pad.addWidget(left, 1, 0)
+ pad.addWidget(self._center, 1, 1)
+ pad.addWidget(right, 1, 2)
+ pad.addWidget(down, 2, 1)
+ padw = QWidget()
+ padw.setLayout(pad)
+ padw.setFixedWidth(150)
+ center_pad = QHBoxLayout()
+ center_pad.addStretch(1)
+ center_pad.addWidget(padw)
+ center_pad.addStretch(1)
+ lay.addLayout(center_pad)
+
+ lay.addSpacing(16)
+ lay.addWidget(hline(palette))
+ lay.addSpacing(16)
+
+ # ---- ZOOM ----
+ lay.addWidget(section_label("ZOOM"))
+ lay.addSpacing(10)
+ zoom_row = QHBoxLayout()
+ zoom_row.setSpacing(6)
+ self._zoom_group = QButtonGroup(self)
+ self._zoom_group.setExclusive(True)
+ self._zoom_btns: dict[float, QPushButton] = {}
+ for z in ZOOM_LEVELS:
+ b = QPushButton(f"{z:.1f}×")
+ b.setCheckable(True)
+ b.setCursor(Qt.PointingHandCursor)
+ b.clicked.connect(lambda _=False, zz=z: self.zoom_changed.emit(zz))
+ b.toggled.connect(lambda _on, bb=b: self._restyle_zoom(bb))
+ self._zoom_group.addButton(b)
+ self._zoom_btns[z] = b
+ zoom_row.addWidget(b)
+ self._restyle_zoom(b)
+ zoom_row.addStretch(1)
+ lay.addLayout(zoom_row)
+
+ lay.addSpacing(16)
+ lay.addWidget(hline(palette))
+ lay.addSpacing(16)
+
+ # ---- LIGHT ----
+ lay.addWidget(section_label("LIGHT"))
+ lay.addSpacing(10)
+ self._front = self._light_slider(lay, "Front", palette,
+ self.front_light_changed)
+ lay.addSpacing(12)
+ self._back = self._light_slider(lay, "Back", palette,
+ self.back_light_changed)
+
+ # ---- ENERGY (staff only) ----
+ self._energy_section = QWidget()
+ es = QVBoxLayout(self._energy_section)
+ es.setContentsMargins(0, 0, 0, 0)
+ es.setSpacing(0)
+ es.addSpacing(16)
+ es.addWidget(hline(palette))
+ es.addSpacing(16)
+ es.addWidget(section_label("ENERGY"))
+ es.addSpacing(10)
+ erow = QHBoxLayout()
+ erow.setSpacing(6)
+ self._energy_spin = QDoubleSpinBox()
+ self._energy_spin.setDecimals(3)
+ self._energy_spin.setRange(4.0, 25.0)
+ self._energy_spin.setSingleStep(0.01)
+ self._energy_spin.setSuffix(" keV")
+ self._energy_spin.setValue(12.4)
+ self._energy_spin.setStyleSheet(
+ f"QDoubleSpinBox {{ font-family:{FONT_MONO_FALLBACK}; font-size:14px;"
+ f" background:{palette.surface}; border:1px solid {palette.border_control};"
+ f" border-radius:6px; padding:5px 8px; color:{palette.text_primary}; }}"
+ )
+ set_btn = QPushButton("Set")
+ set_btn.setCursor(Qt.PointingHandCursor)
+ set_btn.setStyleSheet(
+ f"QPushButton {{ background:{palette.accent}; color:#fff; border:none;"
+ f" border-radius:6px; padding:6px 14px; font-weight:600; }}"
+ )
+ set_btn.clicked.connect(
+ lambda: self.energy_changed.emit(self._energy_spin.value())
+ )
+ erow.addWidget(self._energy_spin, 1)
+ erow.addWidget(set_btn)
+ es.addLayout(erow)
+ self._energy_section.setVisible(False)
+ lay.addWidget(self._energy_section)
+
+ lay.addStretch(1)
+
+ def set_staff(self, is_staff: bool) -> None:
+ self._energy_section.setVisible(is_staff)
+
+ # -------- helpers --------
+ def _readout(self, lay: QHBoxLayout, name: str, p: Palette) -> QLabel:
+ box = QVBoxLayout()
+ box.setSpacing(0)
+ lab = QLabel(name)
+ lab.setStyleSheet(f"font-size:10.5px; color:{p.text_faint};")
+ val = QLabel("0.0°")
+ val.setStyleSheet(f"font-family:{FONT_MONO_FALLBACK}; font-size:15px;")
+ box.addWidget(lab)
+ box.addWidget(val)
+ wrap = QWidget()
+ wrap.setLayout(box)
+ lay.addWidget(wrap, 1)
+ return val
+
+ def _jog_btn(self, glyph: str) -> QPushButton:
+ b = QPushButton(glyph)
+ b.setCursor(Qt.PointingHandCursor)
+ return b
+
+ def _light_slider(self, lay: QVBoxLayout, name: str, p: Palette,
+ signal) -> QSlider:
+ lbl = QLabel(name)
+ lbl.setStyleSheet(f"font-size:10.5px; color:{p.text_faint};")
+ lay.addWidget(lbl)
+ lay.addSpacing(5)
+ s = QSlider(Qt.Horizontal)
+ s.setRange(0, 100)
+ s.setStyleSheet(self._slider_qss(p))
+ s.valueChanged.connect(signal)
+ lay.addWidget(s)
+ return s
+
+ @staticmethod
+ def _slider_qss(p: Palette) -> str:
+ return (
+ f"QSlider::groove:horizontal {{ height:4px; background:#e9e5db;"
+ f" border-radius:2px; }}"
+ f" QSlider::sub-page:horizontal {{ background:{p.accent};"
+ f" border-radius:2px; }}"
+ f" QSlider::handle:horizontal {{ width:12px; height:12px; margin:-4px 0;"
+ f" border-radius:6px; background:{p.accent}; }}"
+ )
+
+ def _restyle_zoom(self, b: QPushButton) -> None:
+ p = self._p
+ if b.isChecked():
+ b.setStyleSheet(
+ f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
+ f" border-radius:6px; font-size:11px; font-weight:600; padding:5px 10px; }}"
+ )
+ else:
+ b.setStyleSheet(
+ f"QPushButton {{ background:{p.surface};"
+ f" border:1px solid {p.border_control}; border-radius:6px;"
+ f" font-size:11px; padding:5px 10px; }}"
+ )
+
+ def _cycle_step(self) -> None:
+ self._jog_idx = (self._jog_idx + 1) % len(JOG_STEPS_UM)
+ self._center.setText(f"{JOG_STEPS_UM[self._jog_idx]}µm")
+
+ def _jog(self, sx: int, sy: int) -> None:
+ step_mm = JOG_STEPS_UM[self._jog_idx] / 1000.0
+ self.smargon_jog.emit(sx * step_mm, sy * step_mm)
+
+ # -------- live updates --------
+ def update_daq_status(self, s) -> None:
+ geom = getattr(s, "geom", None)
+ if geom is not None:
+ omega = getattr(geom, "omega_deg", None)
+ if omega is not None:
+ self._omega_val.setText(f"{omega:.2f}°")
+ smg = getattr(geom, "smargon", None)
+ if smg is not None:
+ chi = getattr(smg, "chi_deg", None)
+ phi = getattr(smg, "phi_deg", None)
+ if chi is not None:
+ self._chi_val.setText(f"{chi:.1f}°")
+ if phi is not None:
+ self._phi_val.setText(f"{phi:.1f}°")
+ bl = getattr(s, "bl", None)
+ if bl is not None:
+ zoom = getattr(bl, "zoom", None)
+ if zoom is not None:
+ self._sync_zoom(zoom)
+ self._sync_slider(self._front, getattr(bl, "front_light", None))
+ self._sync_slider(self._back, getattr(bl, "back_light", None))
+ diff = getattr(s, "diffraction", None)
+ energy = getattr(diff, "energy_keV", None) if diff else None
+ if energy is not None and not self._energy_spin.hasFocus():
+ self._energy_spin.blockSignals(True)
+ self._energy_spin.setValue(float(energy))
+ self._energy_spin.blockSignals(False)
+
+ def _sync_zoom(self, zoom: float) -> None:
+ # check the nearest level without re-emitting
+ nearest = min(ZOOM_LEVELS, key=lambda z: abs(z - zoom))
+ btn = self._zoom_btns.get(nearest)
+ if btn and not btn.isChecked():
+ btn.blockSignals(True)
+ btn.setChecked(True)
+ btn.blockSignals(False)
+ self._restyle_zoom(btn)
+
+ @staticmethod
+ def _sync_slider(slider: QSlider, value) -> None:
+ if value is None:
+ return
+ iv = int(round(value))
+ if slider.value() != iv and not slider.isSliderDown():
+ slider.blockSignals(True)
+ slider.setValue(iv)
+ slider.blockSignals(False)
diff --git a/src/aare/gui/new_gui/widgets/pipeline.py b/src/aare/gui/new_gui/widgets/pipeline.py
new file mode 100644
index 00000000..3d093c43
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/pipeline.py
@@ -0,0 +1,284 @@
+"""The interactive pipeline stage tracker (Manual mode, mounted).
+
+A horizontal row of stage nodes joined by connector tracks. Interactive nodes
+are clickable (select a stage) and carry a breakpoint flag that toggles
+independently. Visual rules follow the design handoff:
+
+- done -> solid success fill + ✓
+- selected -> solid accent fill + ring
+- pending optional (raster/xrf) -> dashed border
+- collect pending -> solid accent border
+- generic pending (process/unmount) -> solid grey border
+- connector -> success when the preceding node is done, else pending track
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QRect, Qt, Signal
+from PySide6.QtGui import QColor, QFont, QPainter, QPen
+from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
+
+from aare.gui.new_gui.state import STAGES, Stage
+from aare.gui.new_gui.theme import Palette
+
+NODE_W = 70
+NODE_H = 62
+CIRCLE_R = 12
+CIRCLE_CY = 18
+
+
+class _Connector(QWidget):
+ """A 2px horizontal track between two nodes."""
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self._done = False
+ self.setFixedHeight(NODE_H)
+ self.setMinimumWidth(12)
+
+ def set_done(self, done: bool) -> None:
+ if done != self._done:
+ self._done = done
+ self.update()
+
+ def paintEvent(self, event): # noqa: N802
+ painter = QPainter(self)
+ col = self._p.success if self._done else self._p.pending_track
+ y = CIRCLE_CY
+ painter.fillRect(QRect(0, y - 1, self.width(), 2), QColor(col))
+
+
+class StageNode(QWidget):
+ """A single pipeline node: painted circle + label (+ optional caption + flag)."""
+
+ clicked = Signal(str) # stage key
+ breakpoint_toggled = Signal(str)
+
+ def __init__(self, stage: Stage, palette: Palette,
+ breakpoints_enabled: bool = True, preview: bool = False,
+ parent=None):
+ super().__init__(parent)
+ self._stage = stage
+ self._p = palette
+ self._preview = preview
+ self._selected = False
+ # In preview (not-mounted) mode, Mount is the accented "start here" node
+ # rather than a completed step.
+ self._start_here = preview and stage.key == "mount"
+ self._done = (stage.key == "mount") and not preview
+ self._has_break = False
+ self._bp_enabled = breakpoints_enabled and stage.interactive
+ self.setFixedSize(NODE_W, NODE_H)
+ if stage.interactive:
+ self.setCursor(Qt.PointingHandCursor)
+
+ self._flag: QPushButton | None = None
+ if self._bp_enabled:
+ self._flag = QPushButton("⚑", self)
+ self._flag.setFixedSize(18, 18)
+ self._flag.setCursor(Qt.PointingHandCursor)
+ self._flag.setToolTip("Toggle breakpoint")
+ self._flag.move(NODE_W - 18 - 2, 2)
+ self._flag.clicked.connect(self._on_flag)
+ self._restyle_flag()
+
+ # ----- state setters -----
+ def set_selected(self, selected: bool) -> None:
+ if selected != self._selected:
+ self._selected = selected
+ self.update()
+
+ def set_done(self, done: bool) -> None:
+ if self._preview:
+ return
+ if self._stage.key == "mount":
+ done = True
+ if done != self._done:
+ self._done = done
+ self.update()
+
+ def set_breakpoint(self, on: bool) -> None:
+ if on != self._has_break:
+ self._has_break = on
+ self._restyle_flag()
+
+ def set_preview(self, preview: bool) -> None:
+ """Toggle preview (no-sample) styling: Mount becomes 'start here'."""
+ self._preview = preview
+ self._start_here = preview and self._stage.key == "mount"
+ self._done = (self._stage.key == "mount") and not preview
+ if self._flag is not None:
+ self._flag.setVisible(not preview)
+ self.update()
+
+ # ----- interaction -----
+ def _on_flag(self) -> None:
+ # Child button consumes the click, so the node is not also selected.
+ self.breakpoint_toggled.emit(self._stage.key)
+
+ def mousePressEvent(self, event): # noqa: N802
+ if self._stage.interactive and event.button() == Qt.LeftButton:
+ self.clicked.emit(self._stage.key)
+ else:
+ super().mousePressEvent(event)
+
+ def _restyle_flag(self) -> None:
+ if not self._flag:
+ return
+ p = self._p
+ if self._has_break:
+ css = (f"background:{p.breakpoint}; color:#fff;"
+ f" border:1px solid {p.breakpoint};")
+ else:
+ css = (f"background:{p.surface}; color:{p.text_faint};"
+ f" border:1px solid {p.text_faint};")
+ self._flag.setStyleSheet(
+ f"QPushButton {{ {css} border-radius:9px; font-size:9px; padding:0; }}"
+ )
+ # design: off-state flag is 55% opacity
+ self._flag.setWindowOpacity(1.0)
+
+ # ----- paint -----
+ def paintEvent(self, event): # noqa: N802
+ p = self._p
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+
+ # selected wrapper tint
+ if self._selected:
+ painter.setBrush(QColor(p.accent_tint_bg))
+ painter.setPen(Qt.NoPen)
+ painter.drawRoundedRect(self.rect().adjusted(0, 0, -1, -1), 10, 10)
+
+ cx, cy, r = NODE_W // 2, CIRCLE_CY, CIRCLE_R
+ circle = QRect(cx - r, cy - r, 2 * r, 2 * r)
+ glyph = ""
+ glyph_color = "#fff"
+
+ if self._start_here:
+ painter.setBrush(Qt.NoBrush)
+ painter.setPen(QPen(QColor(p.accent), 2))
+ painter.drawEllipse(circle)
+ self._draw_ring(painter, circle)
+ glyph = "⊕"
+ glyph_color = p.accent
+ elif self._selected and not self._done:
+ painter.setBrush(QColor(p.accent))
+ painter.setPen(Qt.NoPen)
+ painter.drawEllipse(circle)
+ self._draw_ring(painter, circle)
+ elif self._done:
+ painter.setBrush(QColor(p.success))
+ painter.setPen(Qt.NoPen)
+ painter.drawEllipse(circle)
+ glyph = "✓"
+ if self._selected:
+ self._draw_ring(painter, circle)
+ else:
+ painter.setBrush(Qt.NoBrush)
+ if self._stage.optional:
+ pen = QPen(QColor(p.pending_node), 2, Qt.DashLine)
+ elif self._stage.key == "collect":
+ pen = QPen(QColor(p.accent), 2)
+ else:
+ pen = QPen(QColor(p.pending_node), 2)
+ painter.setPen(pen)
+ painter.drawEllipse(circle)
+
+ if glyph:
+ painter.setPen(QColor(glyph_color))
+ f = QFont()
+ f.setPixelSize(12)
+ f.setBold(True)
+ painter.setFont(f)
+ painter.drawText(circle, Qt.AlignCenter, glyph)
+
+ # label
+ if self._selected or self._start_here:
+ lbl_color = p.accent
+ elif self._done:
+ lbl_color = p.text_muted
+ elif self._stage.optional:
+ lbl_color = p.text_faint
+ else:
+ lbl_color = p.text_faint if not self._stage.key == "collect" else p.accent
+ painter.setPen(QColor(lbl_color))
+ lf = QFont()
+ lf.setPixelSize(11)
+ if self._stage.key == "collect":
+ lf.setBold(True)
+ painter.setFont(lf)
+ painter.drawText(QRect(0, cy + r + 2, NODE_W, 14),
+ Qt.AlignHCenter | Qt.AlignTop, self._stage.label)
+
+ # caption (optional / start here)
+ caption = "start here" if self._start_here else (
+ "optional" if self._stage.optional else "")
+ if caption:
+ painter.setPen(QColor(p.accent if self._start_here else p.text_faint))
+ cf = QFont()
+ cf.setPixelSize(9)
+ painter.setFont(cf)
+ painter.drawText(QRect(0, cy + r + 16, NODE_W, 12),
+ Qt.AlignHCenter | Qt.AlignTop, caption)
+
+ def _draw_ring(self, painter: QPainter, circle: QRect) -> None:
+ ring = QColor(self._p.accent)
+ ring.setAlpha(72)
+ painter.setBrush(Qt.NoBrush)
+ painter.setPen(QPen(ring, 3))
+ painter.drawEllipse(circle.adjusted(-3, -3, 3, 3))
+
+
+class PipelineTracker(QWidget):
+ """Row of stage nodes + connectors, driven by :class:`AppState`."""
+
+ stage_clicked = Signal(str)
+ breakpoint_toggled = Signal(str)
+
+ def __init__(self, palette: Palette, breakpoints_enabled: bool = True,
+ preview: bool = False, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self._preview = preview
+ self._nodes: dict[str, StageNode] = {}
+ self._connectors: list[_Connector] = []
+
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(0)
+ for i, stage in enumerate(STAGES):
+ node = StageNode(stage, palette, breakpoints_enabled, preview=preview)
+ node.clicked.connect(self.stage_clicked)
+ node.breakpoint_toggled.connect(self.breakpoint_toggled)
+ self._nodes[stage.key] = node
+ lay.addWidget(node)
+ if i < len(STAGES) - 1:
+ conn = _Connector(palette)
+ self._connectors.append(conn)
+ lay.addWidget(conn, 1)
+
+ def set_preview(self, preview: bool) -> None:
+ if preview == self._preview:
+ return
+ self._preview = preview
+ for node in self._nodes.values():
+ node.set_preview(preview)
+
+ def update_from_state(self, state) -> None:
+ """Reflect the current pipeline state (selection, done set, breakpoints)."""
+ seq_idx = state.seq_idx
+ for i, stage in enumerate(STAGES):
+ node = self._nodes[stage.key]
+ node.set_selected(state.stage == stage.key)
+ # done = mount, or any stage already passed in the sequence
+ done = (not self._preview) and (stage.key == "mount" or (i < seq_idx))
+ node.set_done(done)
+ if stage.interactive:
+ node.set_breakpoint(state.has_breakpoint(stage.key))
+ # connectors: green when the node before them is done
+ for i, conn in enumerate(self._connectors):
+ left_key = STAGES[i].key
+ done = (not self._preview) and (left_key == "mount" or (i < seq_idx))
+ conn.set_done(done)
diff --git a/src/aare/gui/new_gui/widgets/pipeline_panel.py b/src/aare/gui/new_gui/widgets/pipeline_panel.py
new file mode 100644
index 00000000..d1c8557e
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/pipeline_panel.py
@@ -0,0 +1,687 @@
+"""Bottom panel of the Manual centre column.
+
+Two states, swapped by the Manual view via a QStackedWidget:
+- NotMountedPanel: a Mount CTA plus the all-pending pipeline preview.
+- MountedPipelinePanel: the interactive stage tracker, a contextual settings row
+ that swaps with the selected stage, and a transport row.
+
+Contextual settings expose editable parameters (defaults sourced from the
+beamline YAML by the caller) and emit semantic run signals. Request objects are
+assembled by the wiring layer, which also owns pixel<->mm geometry.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QComboBox,
+ QDoubleSpinBox,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QSlider,
+ QStackedWidget,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
+from aare.gui.new_gui.widgets.common import (
+ micro_label,
+ mono_label,
+ vline,
+)
+from aare.gui.new_gui.widgets.heatmap import METRICS
+from aare.gui.new_gui.widgets.pipeline import PipelineTracker
+
+
+def _primary_button(text: str, palette: Palette) -> QPushButton:
+ b = QPushButton(text)
+ b.setObjectName("Primary")
+ b.setCursor(Qt.PointingHandCursor)
+ b.setStyleSheet(
+ f"QPushButton {{ background:{palette.accent}; color:#fff; border:none;"
+ f" border-radius:8px; padding:8px 16px; font-size:12.5px; font-weight:600; }}"
+ )
+ return b
+
+
+def _outline_button(text: str, palette: Palette) -> QPushButton:
+ b = QPushButton(text)
+ b.setCursor(Qt.PointingHandCursor)
+ b.setStyleSheet(
+ f"QPushButton {{ background:{palette.surface};"
+ f" border:1px solid {palette.border_control}; border-radius:8px;"
+ f" padding:8px 14px; font-size:12.5px; color:{palette.text_secondary}; }}"
+ )
+ return b
+
+
+class _EditCard(QFrame):
+ """A small card holding an editable numeric value (label over spinbox)."""
+
+ def __init__(self, label: str, palette: Palette, value: float, suffix: str = "",
+ decimals: int = 2, minimum: float = 0.0, maximum: float = 1e6,
+ step: float = 1.0, parent=None):
+ super().__init__(parent)
+ p = palette
+ self.setStyleSheet(
+ f"_EditCard {{ background:{p.surface}; border:1px solid {p.border_control};"
+ f" border-radius:7px; }} QLabel {{ background:transparent; border:none; }}"
+ )
+ vb = QVBoxLayout(self)
+ vb.setContentsMargins(11, 5, 11, 5)
+ vb.setSpacing(1)
+ lab = QLabel(label)
+ lab.setStyleSheet(f"font-size:9px; color:{p.text_faint};")
+ self.spin = QDoubleSpinBox()
+ self.spin.setDecimals(decimals)
+ self.spin.setRange(minimum, maximum)
+ self.spin.setSingleStep(step)
+ self.spin.setValue(value)
+ if suffix:
+ self.spin.setSuffix(f" {suffix}")
+ self.spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
+ self.spin.setStyleSheet(
+ f"QDoubleSpinBox {{ font-family:{FONT_MONO_FALLBACK}; font-size:12px;"
+ f" border:none; background:transparent; color:{p.text_primary}; padding:0; }}"
+ )
+ vb.addWidget(lab)
+ vb.addWidget(self.spin)
+
+ def value(self) -> float:
+ return self.spin.value()
+
+
+# --------------------------------------------------------------------------
+class ContextualSettings(QStackedWidget):
+ """Per-stage settings row. Defaults dict keys: exp_time_s, transmission, dtz,
+ start_omega_deg, increment_omega_deg, steps (from beamline YAML)."""
+
+ run_center = Signal(str) # method: 'loop' | 'xray' | 'click'
+ run_raster = Signal()
+ run_xrf = Signal()
+ run_collect = Signal()
+ raster_draw_toggled = Signal(bool)
+ raster_metric_changed = Signal(str) # completed-grid heatmap metric key
+ raster_alpha_changed = Signal(int) # heatmap opacity 0-255
+ raster_grids_clicked = Signal() # open the grids table
+ automate_toggled = Signal(bool) # rotation simple-strategy toggle
+
+ def __init__(self, palette: Palette, defaults: dict, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self._defaults = defaults or {}
+ self._pages: dict[str, QWidget] = {}
+ self._run_buttons: list[QPushButton] = []
+ # value-source ('database' | 'user') per parametrised panel
+ self._source: dict[str, str] = {"raster": "user", "collect": "user"}
+ self._fields: dict[str, list[_EditCard]] = {"raster": [], "collect": []}
+ self._db_params = None
+ self._automate = False
+ self._build_center()
+ self._build_raster()
+ self._build_xrf()
+ self._build_collect()
+ self.setMinimumHeight(64)
+
+ # -- value source (Database vs User) + automate-parameters toggles --
+ def _make_source_toggle(self, which: str) -> QWidget:
+ from PySide6.QtWidgets import QButtonGroup
+ box = QWidget()
+ box.setObjectName("ModeToggle")
+ bl = QHBoxLayout(box)
+ bl.setContentsMargins(3, 3, 3, 3)
+ bl.setSpacing(0)
+ grp = QButtonGroup(box)
+ grp.setExclusive(True)
+ for key, label in (("database", "Database"), ("user", "User values")):
+ b = QPushButton(label)
+ b.setObjectName("ModeSeg")
+ b.setCheckable(True)
+ b.setCursor(Qt.PointingHandCursor)
+ b.setChecked(key == self._source[which])
+ b.clicked.connect(lambda _=False, w=which, k=key: self._set_source(w, k))
+ grp.addButton(b)
+ bl.addWidget(b)
+ return box
+
+ def _set_source(self, which: str, src: str) -> None:
+ self._source[which] = src
+ self._apply_source(which)
+
+ def _apply_source(self, which: str) -> None:
+ read_only = self._source[which] == "database"
+ for card in self._fields[which]:
+ card.spin.setReadOnly(read_only)
+ card.spin.setDisabled(read_only and which == "collect" and self._automate)
+ if read_only and self._db_params is not None:
+ self._populate_db(which)
+
+ def set_db_params(self, params) -> None:
+ """Feed the mounted sample's aaredb_params (DataCollectionParameters)."""
+ self._db_params = params
+ for which in ("raster", "collect"):
+ if self._source[which] == "database":
+ self._apply_source(which)
+
+ def _populate_db(self, which: str) -> None:
+ p = self._db_params
+ if p is None:
+ return
+ if which == "collect":
+ rng = getattr(p, "totalrange", None) or getattr(p, "totalangle", None)
+ osc = getattr(p, "oscillation", None)
+ trans = getattr(p, "transmission", None)
+ if rng is not None:
+ self._range.spin.setValue(float(rng))
+ if osc is not None:
+ self._osc.spin.setValue(float(osc))
+ if trans is not None:
+ self._collect_trans.spin.setValue(float(trans) * 100.0)
+ else: # raster — only exposure/transmission map cleanly from DB
+ trans = getattr(p, "transmission", None)
+ if trans is not None:
+ self._raster_trans.spin.setValue(float(trans) * 100.0)
+
+ def collect_value_source(self) -> str:
+ return self._source["collect"]
+
+ def is_automated(self) -> bool:
+ return self._automate
+
+ def show_stage(self, key: str) -> None:
+ page = self._pages.get(key)
+ if page is not None:
+ self.setCurrentWidget(page)
+
+ def set_run_enabled(self, enabled: bool) -> None:
+ """Disable the '▶ Run … now' buttons when no sample is mounted."""
+ tip = "" if enabled else "Mount a sample to run this stage"
+ for b in self._run_buttons:
+ b.setEnabled(enabled)
+ b.setToolTip(tip)
+
+ def _row(self, key: str) -> QHBoxLayout:
+ page = QWidget()
+ lay = QHBoxLayout(page)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(8)
+ self.addWidget(page)
+ self._pages[key] = page
+ return lay
+
+ def _build_center(self) -> None:
+ lay = self._row("center")
+ lay.addWidget(micro_label("CENTER · method"))
+ loop = _primary_button("▶ Loop centering (auto)", self._p)
+ loop.clicked.connect(lambda: self.run_center.emit("loop"))
+ self._run_buttons.append(loop)
+ lay.addWidget(loop)
+ hint = QLabel("or click a point on the camera to centre it")
+ hint.setStyleSheet(f"font-size:11px; color:{self._p.text_faint};")
+ lay.addWidget(hint)
+ lay.addStretch(1)
+
+ def _build_raster(self) -> None:
+ lay = self._row("raster")
+ lay.addWidget(micro_label("RASTER"))
+ lay.addWidget(self._make_source_toggle("raster"))
+ self._cell_x = _EditCard("Cell width", self._p, 20.0, "µm", decimals=0,
+ minimum=1, maximum=500, step=1)
+ self._cell_y = _EditCard("Cell height", self._p, 16.0, "µm", decimals=0,
+ minimum=1, maximum=500, step=1)
+ self._raster_trans = _EditCard(
+ "Transmission", self._p,
+ float(self._defaults.get("transmission", 1.0)) * 100.0,
+ "%", decimals=1, minimum=0, maximum=100, step=1,
+ )
+ self._fields["raster"] = [self._cell_x, self._cell_y, self._raster_trans]
+ self._draw_btn = QPushButton("✏ Draw grid")
+ self._draw_btn.setCheckable(True)
+ self._draw_btn.setCursor(Qt.PointingHandCursor)
+ self._draw_btn.toggled.connect(self._on_draw_toggled)
+ self._style_draw_btn(False)
+ self._raster_run = _primary_button("▶ Run raster now", self._p)
+ self._raster_run.clicked.connect(self.run_raster)
+ self._run_buttons.append(self._raster_run)
+ lay.addWidget(self._cell_x)
+ lay.addWidget(self._cell_y)
+ lay.addWidget(self._raster_trans)
+ lay.addWidget(self._draw_btn)
+ lay.addWidget(self._raster_run)
+
+ # --- completed-results controls (hidden until a grid completes) ---
+ self._raster_results = QWidget()
+ rr = QHBoxLayout(self._raster_results)
+ rr.setContentsMargins(0, 0, 0, 0)
+ rr.setSpacing(8)
+ rr.addWidget(vline(self._p, 28))
+ rr.addWidget(micro_label("RESULT"))
+ self._metric_combo = QComboBox()
+ for key, label in METRICS:
+ self._metric_combo.addItem(label, key)
+ self._metric_combo.currentIndexChanged.connect(
+ lambda _i: self.raster_metric_changed.emit(self._metric_combo.currentData())
+ )
+ self._metric_combo.setStyleSheet(
+ f"QComboBox {{ background:{self._p.surface};"
+ f" border:1px solid {self._p.border_control}; border-radius:6px;"
+ f" padding:4px 8px; font-size:12px; }}"
+ )
+ rr.addWidget(self._metric_combo)
+ alpha = QSlider(Qt.Horizontal)
+ alpha.setRange(0, 255)
+ alpha.setValue(180)
+ alpha.setFixedWidth(80)
+ alpha.valueChanged.connect(self.raster_alpha_changed)
+ rr.addWidget(QLabel("opacity"))
+ rr.addWidget(alpha)
+ self._grids_btn = QPushButton("Grids: 0")
+ self._grids_btn.setCursor(Qt.PointingHandCursor)
+ self._grids_btn.setStyleSheet(
+ f"QPushButton {{ background:{self._p.surface};"
+ f" border:1px solid {self._p.border_control}; border-radius:6px;"
+ f" padding:5px 12px; font-size:12px; color:{self._p.text_secondary}; }}"
+ )
+ self._grids_btn.clicked.connect(self.raster_grids_clicked)
+ rr.addWidget(self._grids_btn)
+ self._raster_results.setVisible(False)
+ lay.addWidget(self._raster_results)
+
+ lay.addStretch(1)
+
+ def set_raster_results_count(self, n: int) -> None:
+ self._raster_results.setVisible(n > 0)
+ self._grids_btn.setText(f"Grids: {n}")
+
+ def raster_metric(self) -> str:
+ return self._metric_combo.currentData() or "spots"
+
+ def _build_xrf(self) -> None:
+ lay = self._row("xrf")
+ lay.addWidget(micro_label("XRF"))
+ self._xrf_time = _EditCard("Count time", self._p, 1.0, "s", decimals=2,
+ minimum=0.01, maximum=600, step=0.5)
+ self._xrf_trans = _EditCard(
+ "Transmission", self._p,
+ float(self._defaults.get("transmission", 1.0)) * 100.0,
+ "%", decimals=1, minimum=0, maximum=100, step=1,
+ )
+ run = _primary_button("▶ Run XRF now", self._p)
+ run.clicked.connect(self.run_xrf)
+ self._run_buttons.append(run)
+ lay.addWidget(self._xrf_time)
+ lay.addWidget(self._xrf_trans)
+ lay.addWidget(run)
+ lay.addStretch(1)
+
+ def _build_collect(self) -> None:
+ lay = self._row("collect")
+ lay.addWidget(micro_label("COLLECT"))
+
+ # Rotation vs Screening mode
+ self._collect_mode = "rotation"
+ from PySide6.QtWidgets import QButtonGroup
+ mode_box = QWidget()
+ mode_box.setObjectName("ModeToggle")
+ mb = QHBoxLayout(mode_box)
+ mb.setContentsMargins(3, 3, 3, 3)
+ mb.setSpacing(0)
+ self._mode_group = QButtonGroup(mode_box)
+ self._mode_group.setExclusive(True)
+ self._mode_btns: dict[str, QPushButton] = {}
+ for key, label in (("rotation", "Rotation"), ("screening", "Screening")):
+ b = QPushButton(label)
+ b.setObjectName("ModeSeg")
+ b.setCheckable(True)
+ b.setCursor(Qt.PointingHandCursor)
+ b.setChecked(key == "rotation")
+ b.clicked.connect(lambda _=False, k=key: self._set_collect_mode(k))
+ self._mode_group.addButton(b)
+ mb.addWidget(b)
+ self._mode_btns[key] = b
+ lay.addWidget(mode_box)
+
+ lay.addWidget(self._make_source_toggle("collect"))
+
+ # Automate-parameters (simple data-collection strategy) toggle
+ self._automate_chip = QPushButton("Automate parameters")
+ self._automate_chip.setCheckable(True)
+ self._automate_chip.setCursor(Qt.PointingHandCursor)
+ self._automate_chip.toggled.connect(self._on_automate)
+ self._style_automate_chip(False)
+ lay.addWidget(self._automate_chip)
+
+ self._dtz = _EditCard("Detector", self._p,
+ float(self._defaults.get("dtz", 200.0)), "mm",
+ decimals=0, minimum=50, maximum=1000, step=5)
+ self._range = _EditCard("Range", self._p, 360.0, "°", decimals=1,
+ minimum=0.1, maximum=3600, step=10)
+ self._osc = _EditCard("Osc. width", self._p,
+ float(self._defaults.get("increment_omega_deg", 0.1)),
+ "°", decimals=2, minimum=0.01, maximum=10, step=0.05)
+ self._collect_trans = _EditCard(
+ "Transmission", self._p,
+ float(self._defaults.get("transmission", 1.0)) * 100.0,
+ "%", decimals=1, minimum=0, maximum=100, step=1,
+ )
+ self._fields["collect"] = [
+ self._dtz, self._range, self._osc, self._collect_trans,
+ ]
+ self._auto_note = QLabel("Parameters computed automatically (simple strategy).")
+ self._auto_note.setStyleSheet(f"font-size:11px; color:{self._p.text_faint};")
+ self._auto_note.setVisible(False)
+
+ # Screening widgets (shown only in Screening mode)
+ self._screen_box = QWidget()
+ sb = QHBoxLayout(self._screen_box)
+ sb.setContentsMargins(0, 0, 0, 0)
+ sb.setSpacing(8)
+ self._screen_preset = QComboBox()
+ for label, data in (
+ ("1 image", {"steps": 1, "omega_step_deg": 0}),
+ ("2 images · 90°", {"steps": 2, "omega_step_deg": 90}),
+ ("4 images · 90°", {"steps": 4, "omega_step_deg": 90}),
+ ("3 images · 60°", {"steps": 3, "omega_step_deg": 60}),
+ ("2 images · 45°", {"steps": 2, "omega_step_deg": 45}),
+ ("4 images · 45°", {"steps": 4, "omega_step_deg": 45}),
+ ):
+ self._screen_preset.addItem(label, data)
+ self._screen_preset.setStyleSheet(
+ f"QComboBox {{ background:{self._p.surface};"
+ f" border:1px solid {self._p.border_control}; border-radius:6px;"
+ f" padding:4px 8px; font-size:12px; }}"
+ )
+ self._screen_angle = _EditCard("Image angle", self._p, 1.0, "°",
+ decimals=2, minimum=0.01, maximum=90, step=0.1)
+ self._screen_time = _EditCard("Count time", self._p, 0.1, "s",
+ decimals=2, minimum=0.01, maximum=60, step=0.05)
+ sb.addWidget(self._screen_preset)
+ sb.addWidget(self._screen_angle)
+ sb.addWidget(self._screen_time)
+ self._screen_box.setVisible(False)
+
+ run = _primary_button("▶ Collect now", self._p)
+ run.clicked.connect(self.run_collect)
+ self._run_buttons.append(run)
+ lay.addWidget(self._dtz)
+ lay.addWidget(self._range)
+ lay.addWidget(self._osc)
+ lay.addWidget(self._collect_trans)
+ lay.addWidget(self._screen_box)
+ lay.addWidget(self._auto_note)
+ lay.addWidget(run)
+ lay.addStretch(1)
+
+ def _set_collect_mode(self, mode: str) -> None:
+ self._collect_mode = mode
+ screening = mode == "screening"
+ # rotation-only fields
+ for w in (self._range, self._osc, self._automate_chip):
+ w.setVisible(not screening)
+ if screening and self._automate:
+ self._automate_chip.setChecked(False) # screening overrides automate
+ self._screen_box.setVisible(screening)
+
+ def collect_mode(self) -> str:
+ return self._collect_mode
+
+ def collect_dtz(self) -> float:
+ return self._dtz.value()
+
+ def screening_params(self) -> dict:
+ return {
+ "preset": self._screen_preset.currentData(),
+ "image_angle": self._screen_angle.value(),
+ "count_time": self._screen_time.value(),
+ }
+
+ def _style_automate_chip(self, on: bool) -> None:
+ p = self._p
+ if on:
+ css = f"background:{p.accent}; color:#fff; border:none;"
+ else:
+ css = f"background:{p.chip_off_bg}; color:{p.text_faint}; border:none;"
+ self._automate_chip.setStyleSheet(
+ f"QPushButton {{ {css} border-radius:11px; padding:6px 12px;"
+ f" font-size:11px; font-weight:600; }}"
+ )
+
+ def _on_automate(self, on: bool) -> None:
+ self._automate = on
+ self._style_automate_chip(on)
+ # In automate mode the detailed cards are server-computed -> hide them.
+ for card in self._fields["collect"]:
+ card.setVisible(not on)
+ self._auto_note.setVisible(on)
+ self.automate_toggled.emit(on)
+
+ # --- raster draw toggle styling ---
+ def _on_draw_toggled(self, on: bool) -> None:
+ self._style_draw_btn(on)
+ self.raster_draw_toggled.emit(on)
+
+ def _style_draw_btn(self, on: bool) -> None:
+ p = self._p
+ if on:
+ self._draw_btn.setText("✏ Drawing… (drag on camera)")
+ css = f"background:{p.accent}; color:#fff; border:none;"
+ else:
+ self._draw_btn.setText("✏ Draw grid")
+ css = f"background:{p.surface}; border:1px solid {p.border_control}; color:{p.text_secondary};"
+ self._draw_btn.setStyleSheet(
+ f"QPushButton {{ {css} border-radius:8px; padding:8px 14px; font-size:12.5px; }}"
+ )
+
+ def set_draw_active(self, on: bool) -> None:
+ self._draw_btn.blockSignals(True)
+ self._draw_btn.setChecked(on)
+ self._style_draw_btn(on)
+ self._draw_btn.blockSignals(False)
+
+ # --- parameter accessors (used by the wiring layer) ---
+ def raster_cell_mm(self) -> tuple[float, float]:
+ return self._cell_x.value() / 1000.0, self._cell_y.value() / 1000.0
+
+ def raster_transmission(self) -> float:
+ return self._raster_trans.value() / 100.0
+
+ def xrf_params(self) -> tuple[float, float]:
+ return self._xrf_time.value(), self._xrf_trans.value() / 100.0
+
+ def collect_transmission(self) -> float:
+ return self._collect_trans.value() / 100.0
+
+ def collect_params(self) -> dict:
+ rng = self._range.value()
+ osc = self._osc.value()
+ steps = max(1, round(rng / osc)) if osc > 0 else 1
+ return {
+ "dtz": self._dtz.value(),
+ "incr_omega_deg": osc,
+ "steps": steps,
+ "transmission": self._collect_trans.value() / 100.0,
+ }
+
+
+# --------------------------------------------------------------------------
+class TransportRow(QWidget):
+ """Play / pause / skip / end transport + NEXT ACTION + run status + Unmount."""
+
+ play = Signal()
+ pause = Signal()
+ skip = Signal()
+ end = Signal()
+ unmount = Signal()
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(14)
+
+ btns = QHBoxLayout()
+ btns.setSpacing(6)
+ self._play = self._tbtn("▶")
+ self._pause = self._tbtn("❚❚")
+ self._skip = self._tbtn("⏭")
+ self._end = self._tbtn("⏹", danger=True)
+ self._play.clicked.connect(self.play)
+ self._pause.clicked.connect(self.pause)
+ self._skip.clicked.connect(self.skip)
+ self._end.clicked.connect(self.end)
+ for b in (self._play, self._pause, self._skip, self._end):
+ btns.addWidget(b)
+ lay.addLayout(btns)
+
+ lay.addWidget(vline(palette))
+
+ na = QVBoxLayout()
+ na.setSpacing(1)
+ na.addWidget(micro_label("NEXT ACTION"))
+ self._next = mono_label("Center", 13, palette.text_primary, weight=600)
+ na.addWidget(self._next)
+ lay.addLayout(na)
+
+ self._status = QLabel("Idle")
+ self._status.setStyleSheet(f"font-size:11.5px; color:{palette.text_faint};")
+ lay.addWidget(self._status)
+
+ lay.addStretch(1)
+
+ self._unmount = QPushButton("⏏ Unmount")
+ self._unmount.setCursor(Qt.PointingHandCursor)
+ self._unmount.setStyleSheet(
+ f"QPushButton {{ background:{palette.surface};"
+ f" border:1px solid {palette.border_control}; border-radius:8px;"
+ f" padding:8px 16px; font-size:12.5px; color:{palette.danger}; }}"
+ )
+ self._unmount.clicked.connect(self.unmount)
+ lay.addWidget(self._unmount)
+
+ def _tbtn(self, glyph: str, danger: bool = False) -> QPushButton:
+ b = QPushButton(glyph)
+ b.setFixedSize(34, 34)
+ b.setCursor(Qt.PointingHandCursor)
+ b._danger = danger # type: ignore[attr-defined]
+ self._style_tbtn(b, active=False)
+ return b
+
+ def _style_tbtn(self, b: QPushButton, active: bool) -> None:
+ p = self._p
+ if active:
+ css = f"background:{p.accent}; color:#fff; border:none;"
+ else:
+ color = p.danger if getattr(b, "_danger", False) else p.text_secondary
+ css = f"background:{p.surface}; border:1px solid {p.border_control}; color:{color};"
+ b.setStyleSheet(
+ f"QPushButton {{ {css} border-radius:8px; font-size:11px; }}"
+ )
+
+ def update_from_state(self, state) -> None:
+ running = state.running
+ self._style_tbtn(self._play, active=running)
+ self._style_tbtn(self._pause, active=not running)
+ self._next.setText(state.next_stage().action_label)
+ if state.stopped_at_breakpoint():
+ self._status.setText("⏸ Stopped at breakpoint")
+ self._status.setStyleSheet(
+ f"font-size:11.5px; color:{self._p.breakpoint}; font-weight:600;"
+ )
+ elif running:
+ self._status.setText("● Running")
+ self._status.setStyleSheet(
+ f"font-size:11.5px; color:{self._p.success}; font-weight:600;"
+ )
+ else:
+ self._status.setText("Idle")
+ self._status.setStyleSheet(
+ f"font-size:11.5px; color:{self._p.text_faint};"
+ )
+
+
+# --------------------------------------------------------------------------
+class PipelinePanel(QWidget):
+ """Interactive pipeline panel for BOTH mount states.
+
+ Mounted: full tracker (done states + breakpoints), enabled run buttons,
+ transport + unmount. Not mounted: preview tracker (Mount = start here), the
+ same contextual settings (editable, so parameters can be pre-adjusted) but
+ run buttons disabled and the transport hidden.
+ """
+
+ stage_clicked = Signal(str)
+ breakpoint_toggled = Signal(str)
+
+ def __init__(self, palette: Palette, defaults: dict,
+ breakpoints_enabled: bool = True, parent=None):
+ super().__init__(parent)
+ self.setObjectName("BottomPanel")
+ self._p = palette
+ self._mounted = True
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(22, 14, 22, 14)
+ lay.setSpacing(12)
+
+ # header
+ head = QHBoxLayout()
+ head.setSpacing(10)
+ self._name = mono_label("—", 14, palette.text_primary, weight=700)
+ self._badge = QLabel("MOUNTED")
+ self._badge.setStyleSheet(
+ f"font-size:10.5px; background:{palette.accent_tint_bg};"
+ f" color:{palette.accent}; padding:3px 9px; border-radius:11px; font-weight:600;"
+ )
+ self._meta = mono_label("", 11, palette.text_muted)
+ head.addWidget(self._name)
+ head.addWidget(self._badge)
+ head.addWidget(self._meta)
+ head.addStretch(1)
+ self._hint = QLabel("")
+ self._hint.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
+ head.addWidget(self._hint)
+ lay.addLayout(head)
+
+ # tracker
+ self.tracker = PipelineTracker(palette, breakpoints_enabled)
+ self.tracker.stage_clicked.connect(self.stage_clicked)
+ self.tracker.breakpoint_toggled.connect(self.breakpoint_toggled)
+ lay.addWidget(self.tracker)
+
+ # contextual settings
+ self.settings = ContextualSettings(palette, defaults)
+ lay.addWidget(self.settings)
+
+ # transport
+ self.transport = TransportRow(palette)
+ lay.addWidget(self.transport)
+
+ self.set_mounted(False)
+
+ def set_mounted(self, mounted: bool) -> None:
+ self._mounted = mounted
+ self._badge.setVisible(mounted)
+ self.transport.setVisible(mounted)
+ self.settings.set_run_enabled(mounted)
+ self.tracker.set_preview(not mounted)
+ if mounted:
+ self._hint.setText("↳ click a stage to act · tap its ⚑ to set a breakpoint")
+ else:
+ self._name.setText("No sample mounted")
+ self._name.setStyleSheet(self._name.styleSheet())
+ self._meta.setText("")
+ self._hint.setText("↳ click a stage to pre-set its parameters, then mount a sample")
+
+ def update_from_state(self, state) -> None:
+ self.tracker.update_from_state(state)
+ self.transport.update_from_state(state)
+ self.settings.show_stage(state.stage)
+ sample = state.mount_sample
+ if self._mounted and sample is not None:
+ self._name.setText(getattr(sample, "sample_name", "—"))
+ puck = getattr(sample, "puck_name", "") or ""
+ loc = sample.loc_str() if hasattr(sample, "loc_str") else ""
+ self._meta.setText(f"{puck} · {loc}".strip(" ·"))
diff --git a/src/aare/gui/new_gui/widgets/raster_grids_dialog.py b/src/aare/gui/new_gui/widgets/raster_grids_dialog.py
new file mode 100644
index 00000000..a72e1fba
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/raster_grids_dialog.py
@@ -0,0 +1,70 @@
+"""A small table of completed raster grids with per-row Goto."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QDialog,
+ QHBoxLayout,
+ QHeaderView,
+ QPushButton,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import Palette
+
+
+class RasterGridsDialog(QDialog):
+ """Lists CompletedRasterGridElem rows; emits goto_grid(index)."""
+
+ goto_grid = Signal(int)
+
+ def __init__(self, grids: list, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self.setWindowTitle("Completed raster grids")
+ self.setModal(False)
+ self.resize(460, 280)
+
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(14, 14, 14, 14)
+ self._table = QTableWidget(0, 5)
+ self._table.setHorizontalHeaderLabels(
+ ["#", "Omega", "Chi/Phi", "Grid", "Goto"]
+ )
+ self._table.verticalHeader().setVisible(False)
+ self._table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
+ lay.addWidget(self._table)
+ self.set_grids(grids)
+
+ def set_grids(self, grids: list) -> None:
+ self._table.setRowCount(0)
+ for i, elem in enumerate(grids):
+ req = getattr(elem, "request", None)
+ omega = getattr(req, "omega_deg", None)
+ stl = getattr(req, "smargon_top_left", None)
+ chi = getattr(stl, "chi_deg", None) if stl else None
+ phi = getattr(stl, "phi_deg", None) if stl else None
+ n_x = getattr(req, "n_x", 0)
+ n_y = getattr(req, "n_y", 0)
+ row = self._table.rowCount()
+ self._table.insertRow(row)
+ self._table.setItem(row, 0, QTableWidgetItem(str(i + 1)))
+ self._table.setItem(
+ row, 1, QTableWidgetItem("—" if omega is None else f"{omega:.1f}°"))
+ cp = "—"
+ if chi is not None or phi is not None:
+ cp = f"{chi if chi is not None else 0:.1f}/{phi if phi is not None else 0:.1f}"
+ self._table.setItem(row, 2, QTableWidgetItem(cp))
+ self._table.setItem(row, 3, QTableWidgetItem(f"{n_x}×{n_y}"))
+ btn = QPushButton("Goto")
+ btn.setCursor(Qt.PointingHandCursor)
+ btn.clicked.connect(lambda _=False, ix=i: self.goto_grid.emit(ix))
+ cell = QWidget()
+ cl = QHBoxLayout(cell)
+ cl.setContentsMargins(2, 2, 2, 2)
+ cl.addWidget(btn)
+ self._table.setCellWidget(row, 4, cell)
diff --git a/src/aare/gui/new_gui/widgets/sample_changer.py b/src/aare/gui/new_gui/widgets/sample_changer.py
new file mode 100644
index 00000000..63112a97
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/sample_changer.py
@@ -0,0 +1,337 @@
+"""Sample Changer (TELL) panel for Manual mode.
+
+Populated from the ``spreadsheet`` signal (SampleShortInfoList). Rows are grouped
+by puck; clicking a row mounts that sample. The currently-mounted row is
+highlighted. Status dot: mounted (accent) / collected (success) / ready (ring).
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import CHANGER_W, FONT_MONO_FALLBACK, Palette
+from aare.gui.new_gui.widgets.common import section_label
+
+
+def sample_label(s) -> str:
+ return getattr(s, "sample_name", "—")
+
+
+def sample_pos(s) -> str:
+ fn = getattr(s, "loc_str", None)
+ if callable(fn):
+ try:
+ return fn()
+ except Exception:
+ pass
+ return "—"
+
+
+def sample_puck(s) -> str:
+ return getattr(s, "puck_name", "") or "—"
+
+
+def sample_id(s):
+ return getattr(s, "db_id", id(s))
+
+
+class SampleRow(QWidget):
+ """A sample row. Left-click selects; right-click offers Mount."""
+
+ clicked = Signal(object) # select
+ mount_requested = Signal(object) # right-click -> mount
+
+ def __init__(self, sample, palette: Palette, mounted: bool = False,
+ collected: bool = False, selected: bool = False, parent=None):
+ super().__init__(parent)
+ self._sample = sample
+ self._p = palette
+ self.setAttribute(Qt.WA_StyledBackground, True)
+ self.setCursor(Qt.PointingHandCursor)
+
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(10, 9, 10, 9)
+ lay.setSpacing(10)
+
+ dot = QLabel()
+ dot.setFixedSize(9, 9)
+ if mounted:
+ dot.setStyleSheet(f"background:{palette.accent}; border-radius:4px;")
+ elif collected:
+ dot.setStyleSheet(f"background:{palette.success}; border-radius:4px;")
+ else:
+ dot.setStyleSheet(
+ f"border:2px solid {palette.pending_node}; border-radius:4px;"
+ " background:transparent;"
+ )
+ lay.addWidget(dot)
+
+ name = QLabel(sample_label(sample))
+ weight = 700 if mounted else 500
+ color = palette.text_primary if (mounted or collected) else palette.text_secondary
+ name.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-weight:{weight}; color:{color};"
+ " background:transparent;"
+ )
+ lay.addWidget(name)
+
+ if mounted:
+ badge = QLabel("MOUNTED")
+ badge.setStyleSheet(
+ f"font-size:10px; background:{palette.accent}; color:#fff;"
+ " padding:2px 7px; border-radius:9px; font-weight:600;"
+ )
+ lay.addWidget(badge)
+
+ lay.addStretch(1)
+
+ pos = QLabel(sample_pos(sample))
+ pos.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:11px;"
+ f" color:{palette.text_faint}; background:transparent;"
+ )
+ lay.addWidget(pos)
+
+ if mounted:
+ self.setStyleSheet(
+ f"SampleRow {{ background:{palette.accent_tint_bg};"
+ f" border:1px solid {palette.accent_tint_border}; border-radius:9px; }}"
+ )
+ elif selected:
+ self.setStyleSheet(
+ f"SampleRow {{ background:{palette.chip_off_bg};"
+ f" border:1px solid {palette.accent}; border-radius:9px; }}"
+ )
+ else:
+ self.setStyleSheet(
+ "SampleRow { border-radius:9px; }"
+ f" SampleRow:hover {{ background:{palette.chip_off_bg}; }}"
+ )
+
+ def mousePressEvent(self, event): # noqa: N802
+ if event.button() == Qt.LeftButton:
+ self.clicked.emit(self._sample)
+ elif event.button() == Qt.RightButton:
+ self._show_menu(event)
+ super().mousePressEvent(event)
+
+ def _show_menu(self, event) -> None:
+ from PySide6.QtWidgets import QMenu
+ menu = QMenu(self)
+ act = menu.addAction("Mount sample")
+ act.triggered.connect(lambda: self.mount_requested.emit(self._sample))
+ menu.exec(event.globalPosition().toPoint())
+
+
+class SampleChangerPanel(QWidget):
+ """Left panel in Manual mode. ``picking`` shows the 'pick to mount' hint.
+
+ Staff get a Samples / Reference-tools segmented toggle; reference tools mount
+ with ``reference=True``.
+ """
+
+ sample_selected = Signal(object, bool) # sample, reference
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self.setObjectName("SidePanel")
+ self.setFixedWidth(CHANGER_W)
+ self._p = palette
+ self._samples: list = []
+ self._reference_tools: list = []
+ self._mode = "samples" # 'samples' | 'reference'
+ self._is_staff = False
+ self._mounted_id = None
+ self._selected_sample = None
+
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+
+ # header
+ header = QWidget()
+ header.setStyleSheet(f"border-bottom:1px solid {palette.border_panel};")
+ hl = QVBoxLayout(header)
+ hl.setContentsMargins(16, 14, 16, 12)
+ hl.setSpacing(10)
+ top = QHBoxLayout()
+ top.setContentsMargins(0, 0, 0, 0)
+ top.addWidget(section_label("SAMPLE CHANGER · TELL"))
+ top.addStretch(1)
+ self._hint = QLabel("pick to mount")
+ self._hint.setStyleSheet(
+ f"font-size:11px; color:{palette.accent}; font-weight:600;"
+ )
+ self._hint.setVisible(False)
+ top.addWidget(self._hint)
+ hl.addLayout(top)
+
+ # Samples / Reference-tools toggle (staff only)
+ self._toggle = QWidget()
+ self._toggle.setObjectName("ModeToggle")
+ tl = QHBoxLayout(self._toggle)
+ tl.setContentsMargins(3, 3, 3, 3)
+ tl.setSpacing(0)
+ from PySide6.QtWidgets import QButtonGroup, QPushButton
+ self._seg_group = QButtonGroup(self)
+ self._seg_group.setExclusive(True)
+ self._seg: dict[str, QPushButton] = {}
+ for key, label in (("samples", "Samples"), ("reference", "Reference tools")):
+ b = QPushButton(label)
+ b.setObjectName("ModeSeg")
+ b.setCheckable(True)
+ b.setCursor(Qt.PointingHandCursor)
+ b.clicked.connect(lambda _=False, k=key: self._set_mode(k))
+ self._seg_group.addButton(b)
+ tl.addWidget(b)
+ self._seg[key] = b
+ self._seg["samples"].setChecked(True)
+ self._toggle.setVisible(False)
+ hl.addWidget(self._toggle)
+
+ self._search = QLineEdit()
+ self._search.setPlaceholderText("Search samples…")
+ self._search.textChanged.connect(self._rebuild)
+ hl.addWidget(self._search)
+ outer.addWidget(header)
+
+ # scrollable list
+ self._scroll = QScrollArea()
+ self._scroll.setWidgetResizable(True)
+ self._scroll.setFrameShape(QScrollArea.NoFrame)
+ self._list = QWidget()
+ self._list_lay = QVBoxLayout(self._list)
+ self._list_lay.setContentsMargins(8, 6, 8, 6)
+ self._list_lay.setSpacing(2)
+ self._list_lay.addStretch(1)
+ self._scroll.setWidget(self._list)
+ outer.addWidget(self._scroll, 1)
+
+ # footer: Mount the selected sample
+ footer = QWidget()
+ footer.setStyleSheet(f"border-top:1px solid {palette.border_panel};")
+ fl = QVBoxLayout(footer)
+ fl.setContentsMargins(12, 10, 12, 12)
+ from PySide6.QtWidgets import QPushButton as _QPB
+ self._mount_btn = _QPB("⊕ Mount selected")
+ self._mount_btn.setCursor(Qt.PointingHandCursor)
+ self._mount_btn.setEnabled(False)
+ self._mount_btn.clicked.connect(self._on_mount_btn)
+ self._style_mount_btn()
+ fl.addWidget(self._mount_btn)
+ outer.addWidget(footer)
+
+ def _style_mount_btn(self) -> None:
+ p = self._p
+ if self._mount_btn.isEnabled():
+ css = f"background:{p.accent}; color:#fff; border:none;"
+ else:
+ css = (f"background:{p.chip_off_bg}; color:{p.text_faint};"
+ f" border:none;")
+ self._mount_btn.setStyleSheet(
+ f"QPushButton {{ {css} border-radius:8px; padding:9px 14px;"
+ f" font-size:12.5px; font-weight:600; }}"
+ )
+
+ def _on_row_clicked(self, sample) -> None:
+ self._selected_sample = sample
+ self._mount_btn.setEnabled(True)
+ self._style_mount_btn()
+ self._rebuild()
+
+ def _on_mount_btn(self) -> None:
+ if self._selected_sample is not None:
+ self.sample_selected.emit(self._selected_sample, self._mode == "reference")
+
+ def set_picking(self, picking: bool) -> None:
+ self._hint.setVisible(picking)
+
+ def set_mounted_id(self, mounted_id) -> None:
+ if mounted_id != self._mounted_id:
+ self._mounted_id = mounted_id
+ self._rebuild()
+
+ def set_samples(self, samples) -> None:
+ self._samples = list(samples)
+ self._rebuild()
+
+ def set_reference_tools(self, tools) -> None:
+ self._reference_tools = list(tools)
+ if self._mode == "reference":
+ self._rebuild()
+
+ def set_staff(self, is_staff: bool) -> None:
+ self._is_staff = is_staff
+ self._toggle.setVisible(is_staff)
+ if not is_staff and self._mode != "samples":
+ self._set_mode("samples")
+
+ def _set_mode(self, mode: str) -> None:
+ if mode == self._mode:
+ return
+ self._mode = mode
+ self._selected_sample = None
+ self._mount_btn.setEnabled(False)
+ self._style_mount_btn()
+ self._search.setPlaceholderText(
+ "Search reference tools…" if mode == "reference" else "Search samples…"
+ )
+ self._rebuild()
+
+ def _active_list(self) -> list:
+ return self._reference_tools if self._mode == "reference" else self._samples
+
+ def _clear_list(self) -> None:
+ while self._list_lay.count() > 1: # keep trailing stretch
+ item = self._list_lay.takeAt(0)
+ w = item.widget()
+ if w:
+ w.setParent(None)
+ w.deleteLater()
+
+ def _rebuild(self) -> None:
+ self._clear_list()
+ query = self._search.text().strip().lower()
+ is_ref = self._mode == "reference"
+ current_puck = None
+ count = 0
+ for s in self._active_list():
+ if query and query not in sample_label(s).lower():
+ continue
+ puck = sample_puck(s)
+ if puck != current_puck:
+ current_puck = puck
+ grp = QLabel(puck)
+ grp.setObjectName("MicroLabel")
+ grp.setContentsMargins(8, 8, 8, 4)
+ self._list_lay.insertWidget(self._list_lay.count() - 1, grp)
+ mounted = (self._mounted_id is not None
+ and sample_id(s) == self._mounted_id)
+ selected = (self._selected_sample is not None
+ and sample_id(s) == sample_id(self._selected_sample))
+ collected = getattr(s, "rotation_count", 0) > 0
+ row = SampleRow(s, self._p, mounted=mounted, collected=collected,
+ selected=selected)
+ row.clicked.connect(self._on_row_clicked)
+ row.mount_requested.connect(
+ lambda sm, ref=is_ref: self.sample_selected.emit(sm, ref)
+ )
+ self._list_lay.insertWidget(self._list_lay.count() - 1, row)
+ count += 1
+
+ if count == 0:
+ empty_txt = "No reference tools" if is_ref else "No samples"
+ empty = QLabel(empty_txt if not self._active_list() else "No matches")
+ empty.setAlignment(Qt.AlignCenter)
+ empty.setStyleSheet(
+ f"color:{self._p.text_faint}; font-size:11.5px; padding:12px;"
+ )
+ self._list_lay.insertWidget(self._list_lay.count() - 1, empty)
diff --git a/src/aare/gui/new_gui/widgets/spectrum_view.py b/src/aare/gui/new_gui/widgets/spectrum_view.py
new file mode 100644
index 00000000..43cc398c
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/spectrum_view.py
@@ -0,0 +1,138 @@
+"""Fluorescence (XRF) spectrum view — QtCharts line plot + controls.
+
+Shows the spectrum returned by ``fluorimeter_spectrum`` (energy keV vs counts)
+and live frames from the fluorimeter SSE stream. Controls drive
+fluorimeter_start / stop / request_snapshot on the DAQ.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QPainter
+from PySide6.QtWidgets import (
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import Palette
+from aare.gui.new_gui.widgets.common import micro_label
+
+
+class SpectrumView(QWidget):
+ """Energy/counts spectrum with Start/Stop/Snapshot controls."""
+
+ start_clicked = Signal()
+ stop_clicked = Signal()
+ snapshot_clicked = Signal()
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self.setStyleSheet(f"background:{palette.surface};")
+
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(14, 12, 14, 12)
+ lay.setSpacing(8)
+
+ # header / controls
+ head = QHBoxLayout()
+ head.setSpacing(8)
+ head.addWidget(micro_label("XRF · FLUORESCENCE SPECTRUM"))
+ head.addStretch(1)
+ self._peak = QLabel("Peak: —")
+ self._peak.setStyleSheet(f"font-size:11px; color:{palette.text_muted};")
+ self._dead = QLabel("Dead time: —")
+ self._dead.setStyleSheet(f"font-size:11px; color:{palette.text_muted};")
+ head.addWidget(self._peak)
+ head.addWidget(self._dead)
+ lay.addLayout(head)
+
+ # chart
+ self._series = QLineSeries()
+ self._chart = QChart()
+ self._chart.addSeries(self._series)
+ self._chart.legend().hide()
+ self._axis_x = QValueAxis()
+ self._axis_x.setTitleText("Energy [keV]")
+ self._axis_y = QValueAxis()
+ self._axis_y.setTitleText("Counts")
+ self._chart.addAxis(self._axis_x, Qt.AlignBottom)
+ self._chart.addAxis(self._axis_y, Qt.AlignLeft)
+ self._series.attachAxis(self._axis_x)
+ self._series.attachAxis(self._axis_y)
+ self._view = QChartView(self._chart)
+ self._view.setRenderHint(QPainter.Antialiasing)
+ lay.addWidget(self._view, 1)
+
+ # control buttons
+ ctl = QHBoxLayout()
+ ctl.setSpacing(8)
+ start = self._btn("▶ Start", primary=True)
+ stop = self._btn("❚❚ Stop")
+ snap = self._btn("◍ Snapshot")
+ start.clicked.connect(self.start_clicked)
+ stop.clicked.connect(self.stop_clicked)
+ snap.clicked.connect(self.snapshot_clicked)
+ ctl.addWidget(start)
+ ctl.addWidget(stop)
+ ctl.addWidget(snap)
+ ctl.addStretch(1)
+ lay.addLayout(ctl)
+
+ def _btn(self, text: str, primary: bool = False) -> QPushButton:
+ b = QPushButton(text)
+ b.setCursor(Qt.PointingHandCursor)
+ p = self._p
+ if primary:
+ css = f"background:{p.accent}; color:#fff; border:none;"
+ else:
+ css = (f"background:{p.surface}; border:1px solid {p.border_control};"
+ f" color:{p.text_secondary};")
+ b.setStyleSheet(
+ f"QPushButton {{ {css} border-radius:8px; padding:7px 14px; font-size:12px; }}"
+ )
+ return b
+
+ # ----- updates -----
+ def update_spectrum(self, model) -> None:
+ """From fluorimeter_spectrum_update (FluorescenceSpectrumOutputModel)."""
+ energy = getattr(model, "energy_eV", None)
+ spectrum = getattr(model, "spectrum", None)
+ if not energy or not spectrum or len(energy) != len(spectrum):
+ return
+ x = np.asarray(energy, dtype=float) / 1000.0 # keV
+ y = np.asarray(spectrum, dtype=float)
+ self._plot(x, y)
+ dead = getattr(model, "average_dead_time", None)
+ if dead is not None:
+ self._dead.setText(f"Dead time: {dead * 100.0:.2f}%")
+
+ def update_live(self, data, bkg, status) -> None:
+ """From fluorimeter_update(list, list, int): counts vs channel."""
+ if not data:
+ return
+ y = np.asarray(data, dtype=float)
+ x = np.arange(y.size, dtype=float)
+ self._axis_x.setTitleText("Channel")
+ self._plot(x, y)
+ if status is not None and status >= 0:
+ self._dead.setText(f"Status: {status}")
+
+ def _plot(self, x: np.ndarray, y: np.ndarray) -> None:
+ order = np.argsort(x)
+ x, y = x[order], y[order]
+ self._series.clear()
+ # QLineSeries.append in a tight loop is fine for a few k points
+ pts = [(float(xi), float(yi)) for xi, yi in zip(x, y)]
+ for xi, yi in pts:
+ self._series.append(xi, yi)
+ if x.size:
+ self._axis_x.setRange(float(x.min()), float(x.max()))
+ self._axis_y.setRange(float(min(0.0, y.min())), float(max(1.0, y.max())))
+ mi = int(np.argmax(y))
+ self._peak.setText(f"Peak: {x[mi]:.3f} · {y[mi]:.0f} cts")