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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bba2f8a4c4
commit
235559249a
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1 @@
|
||||
"""Redesigned AareGUI (PySide6). See app.py for the entry point."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"<span style='color:{self._p.text_muted};'>{name} </span>"
|
||||
f"<span style='color:{col};'>{value}</span>"
|
||||
)
|
||||
|
||||
@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}"
|
||||
@@ -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; }}
|
||||
"""
|
||||
@@ -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("--")
|
||||
@@ -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()
|
||||
@@ -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"<b>Current sample:</b> {cur}",
|
||||
f"<b>Samples left:</b> {left}",
|
||||
f"<b>Avg / sample:</b> {self._fmt(avg)}",
|
||||
f"<b>Queue remaining:</b> {self._fmt(remaining)}",
|
||||
]
|
||||
if remaining > 0:
|
||||
lines.append(f"<b>Done by:</b> {self._eta(remaining)}")
|
||||
if getattr(progress, "finished", False):
|
||||
ok = getattr(progress, "success", None)
|
||||
lines.append("<b>✅ Finished</b>" if ok else "<b>⛔ Stopped</b>")
|
||||
self._stats.setText("<br/>".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"<br/><small>Error: {err}</small>"
|
||||
rows.append(f"{icon} <b>{title}</b>{dur}{extra}")
|
||||
self._steps.setText("<br/>".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)
|
||||
@@ -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"<b>{requester}</b> 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 <b>{target}</b> 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()
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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(" ·"))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user