From 235559249ad3f629f064b5bf39fac34fbb92a900 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 15:36:52 +0200 Subject: [PATCH] new_gui: redesigned PySide6 GUI (manual cockpit + automation, Phase 1) Self-contained new package under src/aare/gui/new_gui reusing the existing DAQWorker/PredictionSubscriber/auth without modifying them. Manual cockpit, automation queue builder, baton controls + guest overlay, reference tools, state LED, energy control, XRF spectrum, raster heatmap, rotation screening, live automation progress + pause, alert banner. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aare/gui/new_gui/README.md | 120 +++ src/aare/gui/new_gui/ROADMAP.md | 99 +++ src/aare/gui/new_gui/__init__.py | 1 + src/aare/gui/new_gui/app.py | 123 ++++ src/aare/gui/new_gui/automation_view.py | 530 ++++++++++++++ src/aare/gui/new_gui/fonts.py | 59 ++ src/aare/gui/new_gui/main_window.py | 561 ++++++++++++++ src/aare/gui/new_gui/manual_view.py | 339 +++++++++ src/aare/gui/new_gui/requests.py | 198 +++++ src/aare/gui/new_gui/state.py | 282 +++++++ src/aare/gui/new_gui/status_bar.py | 186 +++++ src/aare/gui/new_gui/theme.py | 268 +++++++ src/aare/gui/new_gui/top_bar.py | 208 ++++++ src/aare/gui/new_gui/widgets/__init__.py | 0 src/aare/gui/new_gui/widgets/alert_banner.py | 68 ++ .../new_gui/widgets/automation_progress.py | 119 +++ src/aare/gui/new_gui/widgets/baton_dialogs.py | 150 ++++ src/aare/gui/new_gui/widgets/camera.py | 450 ++++++++++++ src/aare/gui/new_gui/widgets/common.py | 215 ++++++ src/aare/gui/new_gui/widgets/heatmap.py | 82 +++ src/aare/gui/new_gui/widgets/motors_panel.py | 322 ++++++++ src/aare/gui/new_gui/widgets/pipeline.py | 284 ++++++++ .../gui/new_gui/widgets/pipeline_panel.py | 687 ++++++++++++++++++ .../new_gui/widgets/raster_grids_dialog.py | 70 ++ .../gui/new_gui/widgets/sample_changer.py | 337 +++++++++ src/aare/gui/new_gui/widgets/spectrum_view.py | 138 ++++ 26 files changed, 5896 insertions(+) create mode 100644 src/aare/gui/new_gui/README.md create mode 100644 src/aare/gui/new_gui/ROADMAP.md create mode 100644 src/aare/gui/new_gui/__init__.py create mode 100644 src/aare/gui/new_gui/app.py create mode 100644 src/aare/gui/new_gui/automation_view.py create mode 100644 src/aare/gui/new_gui/fonts.py create mode 100644 src/aare/gui/new_gui/main_window.py create mode 100644 src/aare/gui/new_gui/manual_view.py create mode 100644 src/aare/gui/new_gui/requests.py create mode 100644 src/aare/gui/new_gui/state.py create mode 100644 src/aare/gui/new_gui/status_bar.py create mode 100644 src/aare/gui/new_gui/theme.py create mode 100644 src/aare/gui/new_gui/top_bar.py create mode 100644 src/aare/gui/new_gui/widgets/__init__.py create mode 100644 src/aare/gui/new_gui/widgets/alert_banner.py create mode 100644 src/aare/gui/new_gui/widgets/automation_progress.py create mode 100644 src/aare/gui/new_gui/widgets/baton_dialogs.py create mode 100644 src/aare/gui/new_gui/widgets/camera.py create mode 100644 src/aare/gui/new_gui/widgets/common.py create mode 100644 src/aare/gui/new_gui/widgets/heatmap.py create mode 100644 src/aare/gui/new_gui/widgets/motors_panel.py create mode 100644 src/aare/gui/new_gui/widgets/pipeline.py create mode 100644 src/aare/gui/new_gui/widgets/pipeline_panel.py create mode 100644 src/aare/gui/new_gui/widgets/raster_grids_dialog.py create mode 100644 src/aare/gui/new_gui/widgets/sample_changer.py create mode 100644 src/aare/gui/new_gui/widgets/spectrum_view.py 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")