new_gui: sample-anchored raster grid + direct-manipulation editing

Raster grid now sticks to the sample instead of floating at screen
centre: the drawn grid is pinned in smargon coords (manual_view
_anchor_grid) and re-projected to pixels each status frame
(_project_grid via geom.smargon_to_picture), mirroring the bookmark
pattern; hidden when rotated off the draw orientation.

Grid is edited directly on the camera (removed the "Draw grid" button):
right-drag draws a new grid (and switches the pipeline Center -> Raster),
left-drag moves it, right-drag on an edge resizes. camera.py gains a
_grid_mode state machine, a grid_adjusted signal, is_grid_interacting()
so re-projection doesn't fight a live drag, and hover cursors. Editing
is gated to the mounted Manual camera so the staff view is unaffected.

Also: disabled "Mount selected" button uses the consistent secondary
outline style instead of a washed-out grey that read as a failed mount.

Bundles assorted in-progress new_gui redesign WIP across main_window,
alert_banner, filename_builder, motors_panel, top_bar and others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-30 14:14:47 +02:00
co-authored by Claude Opus 4.8
parent f688ca5319
commit 0f8340d1c3
15 changed files with 1263 additions and 193 deletions
+6
View File
@@ -482,6 +482,7 @@ class AutomationView(QWidget):
cl.addWidget(self._build_header())
cl.addWidget(self._build_queue_area(), 1)
cl.addWidget(self._build_start_bar())
self._center = center
root.addWidget(center, 1)
# right: live run progress
@@ -491,6 +492,11 @@ class AutomationView(QWidget):
state.queue_changed.connect(self._refresh)
self._refresh()
def set_locked(self, locked: bool) -> None:
"""Disable the queue builder while another user holds control."""
for w in (self.library, self._center, self.progress):
w.setEnabled(not locked)
def set_running(self, running: bool, paused: bool = False) -> None:
"""Toggle Start/Pause/Stop affordances while automation runs."""
self._start.setVisible(not running)
+124 -16
View File
@@ -8,7 +8,7 @@ backend; the views speak in intent signals and request objects.
from __future__ import annotations
from PySide6.QtCore import QSettings, QTimer, Slot
from PySide6.QtCore import Qt, QPoint, QRect, QSettings, QTimer, Slot
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QMessageBox,
@@ -115,6 +115,12 @@ class MainWindow(QWidget):
self._root_layout.setSpacing(0)
self._build_chrome_and_body()
# ambient "another user holds the beamline" overlay (paints only; locking
# is done on the real panels). Parented to the window, floats on top.
from aare.gui.new_gui.widgets.busy_overlay import BeamlineBusyOverlay
self.busy_overlay = BeamlineBusyOverlay(self)
self.busy_overlay.setGeometry(self.rect())
# backend
self._build_backend(pred_zmq_addr)
self._wire_persistent()
@@ -183,6 +189,9 @@ class MainWindow(QWidget):
self.status_bar.update_daq_status(self._last_status)
self.manual.update_daq_status(self._last_status)
self.staff.update_daq_status(self._last_status)
# views were rebuilt — re-assert lock/overlay state and float on top
self.busy_overlay.raise_()
self._update_control()
# ------------------------------------------------------------- backend
def _build_backend(self, pred_zmq_addr) -> None:
@@ -316,6 +325,8 @@ class MainWindow(QWidget):
self.manual.smart_params_requested.connect(self._on_smart_params)
self.manual.xrf_scan_requested.connect(self._do_xrf)
self.manual.raster_goto_requested.connect(self.daq.move_smargon)
# scroll-wheel over the sample camera rotates omega (±90°, Shift ±10°)
self.manual.camera.rotate_omega_requested.connect(self.daq.set_omega_rel)
self.manual.bookmark_goto_requested.connect(self._on_bookmark_goto)
if hasattr(self.daq, "sample_manual"):
self.manual.manual_sample_requested.connect(self.daq.sample_manual)
@@ -415,8 +426,47 @@ class MainWindow(QWidget):
m.smargon_angles.connect(self._on_smargon_angles)
m.home_requested.connect(self._on_home)
def _can_move_smargon(self, action: str) -> bool:
"""Gate manual sample-holder moves on the beamline state.
The backend only accepts a smargon move in the ``SampleAlignment`` state
(otherwise it raises "Beamline is not in a proper state"). Rather than
fire a request that the server will reject — and, while a key is held,
flood it — we check the last polled state up front and pop a single
(debounced) notice explaining why the action is unavailable.
"""
from aare.common.models import BeamlineStateEnum
state = getattr(self.manual.last_status, "state", None)
if state is None or state == BeamlineStateEnum.SampleAlignment:
return True
self._warn_wrong_state(action, state)
return False
def _warn_wrong_state(self, action: str, state) -> None:
from time import monotonic
now = monotonic()
if now - getattr(self, "_state_warn_t", 0.0) < 3.0:
return # debounce: one notice per burst, not one per held tick
self._state_warn_t = now
name = state.display_name() if hasattr(state, "display_name") else str(state)
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("Action not available")
box.setText(f"Cant {action} right now.")
box.setInformativeText(
f"The beamline is in “{name}”. Switch to “Sample alignment” "
f"to move the sample holder.")
box.setStandardButtons(QMessageBox.Ok)
box.setWindowModality(Qt.NonModal)
box.show()
self._state_warn_box = box # keep a reference so it isn't GC'd
def _on_home(self) -> None:
from aare.common.coordinate import Coordinate, SmargonCoordinate
if not self._can_move_smargon("go to home"):
return
self.daq.move_smargon(SmargonCoordinate(sh_mm=Coordinate(x=0.0, y=0.0, z=18.0)))
@Slot(str)
@@ -425,6 +475,7 @@ class MainWindow(QWidget):
mode, self.manual)
self.body.setCurrentWidget(page)
self.top_bar.set_mode(mode)
self._push_busy_regions() # camera rect differs per view
@Slot(object)
def _on_spreadsheet(self, payload) -> None:
@@ -593,25 +644,55 @@ class MainWindow(QWidget):
self.staff.camera.set_control_state(in_control, title, sub)
def _update_control(self) -> None:
"""Grey the camera + show Guest/Vacant overlay unless we hold the baton."""
"""When we don't hold the baton: lock the controls and show the ambient
busy overlay (dim wash + aurora edge + status chip). The live camera stays
bright and "Request control" stays enabled — that's how you take over."""
# camera always bright now (no big GUEST/VACANT text — the overlay says it)
self._set_control(True)
b = self._baton
if b is None or getattr(b, "you_are_holder", False):
self._set_control(True)
self._apply_lock(False)
self.busy_overlay.set_busy(False)
return
holder = getattr(b, "holder", None)
if holder is None:
self._set_control(
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."
text = "Beamline vacant — request control to start"
else:
sub = "Another user has control of the beamline."
self._set_control(False, "GUEST MODE", sub)
username = getattr(holder, "username", None)
holder_staff = bool(getattr(holder, "is_staff", False))
if self._staff and username:
text = f"{username} has control — please wait"
elif holder_staff:
text = "Staff has control — please wait"
else:
text = "Beamline busy, please wait"
self._apply_lock(True)
self._push_busy_regions()
self.busy_overlay.set_busy(True, text)
def _apply_lock(self, locked: bool) -> None:
"""Disable every view's control panels (the camera + top bar stay live)."""
for view in (self.manual, self.staff, self.automation):
if hasattr(view, "set_locked"):
view.set_locked(locked)
def _push_busy_regions(self) -> None:
"""Grey the whole window and leave only the Grab-baton button bright (and
breathing), to hint the user to click it before proceeding."""
overlay = getattr(self, "busy_overlay", None)
if overlay is None:
return
dim = QRect(0, 0, self.width(), self.height())
baton = self.top_bar.baton_button()
brect = QRect(baton.mapTo(self, QPoint(0, 0)), baton.size())
overlay.update_regions([dim], [brect], brect)
def resizeEvent(self, e): # noqa: N802
super().resizeEvent(e)
overlay = getattr(self, "busy_overlay", None)
if overlay is not None:
overlay.setGeometry(self.rect())
self._push_busy_regions()
# ---- baton request / response flow ----
@Slot(dict)
@@ -723,18 +804,43 @@ class MainWindow(QWidget):
(camera y points down); focus +/- = +z/-z. Flip in MotorsPanel if the
beamline convention differs.
"""
from time import monotonic
from aare.common.coordinate import Coordinate, SmargonCoordinate
if not self._can_move_smargon("move the sample holder"):
return
# Pace held jogging to the motor. Each move PUT blocks the server until
# the stage settles, so firing a tick every ~90 ms would queue moves
# faster than they complete — they then drain after you release the key
# (the "lag"/overshoot). While a move is in flight (beamline busy) we
# skip the tick; the held key keeps ticking and the next free tick sends.
if getattr(self.manual.last_status, "busy", False):
return
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=dz_mm))
nudge = geom.smargon_nudge(Coordinate(x=dx_mm, y=dy_mm, z=dz_mm))
except Exception as exc:
self._note(f"Could not compute jog target: {exc}", error=True)
return
self.daq.move_smargon(SmargonCoordinate(sh_mm=sh))
# Advance from the last commanded target (decaying back to the real
# position after a short idle, so a fresh tap re-syncs) — guards against
# commanding the same target twice off stale, polled geometry.
now = monotonic()
base = getattr(self, "_jog_target", None)
if base is None or (now - getattr(self, "_jog_target_t", 0.0)) > 0.6:
base = geom.smargon.sh_mm
target = base + nudge
self._jog_target = target
self._jog_target_t = now
self.daq.move_smargon(SmargonCoordinate(sh_mm=target))
# Refresh the busy flag fast (don't wait for the 500 ms poll) so the next
# tick sees this move in flight and the pacing above kicks in.
if hasattr(self.daq, "send_status_request"):
self.daq.send_status_request()
def _on_bookmark_goto(self, coord, omega_deg: float) -> None:
"""Return to a bookmarked position (move smargon, then omega)."""
@@ -746,6 +852,8 @@ class MainWindow(QWidget):
"""Move to absolute chi/phi, preserving the current sample-holder position."""
from aare.common.coordinate import SmargonCoordinate
if not self._can_move_smargon("change the goniometer angles"):
return
geom = getattr(self.manual.last_status, "geom", None)
smg = getattr(geom, "smargon", None) if geom else None
sh = getattr(smg, "sh_mm", None) if smg else None
+104 -8
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QFontMetrics
from PySide6.QtWidgets import (
QButtonGroup,
QHBoxLayout,
@@ -28,6 +29,14 @@ from aare.gui.new_gui.widgets.spectrum_view import SpectrumView
from aare.gui.new_gui.widgets.video_view import VideoView
def _angle_close(a: float | None, b: float | None, tol: float = 0.5) -> bool:
"""True if two angles (deg) match within tol, wrapping at 360°. A missing
value (None) is treated as a match so absent phi/chi never hides the grid."""
if a is None or b is None:
return True
return abs((a - b + 180.0) % 360.0 - 180.0) <= tol
class ManualView(QWidget):
"""Three-column cockpit. Emits parametrized backend requests for wiring."""
@@ -50,6 +59,11 @@ class ManualView(QWidget):
self._defaults = defaults or {}
self._last_status = None
self._grid_rect_img: tuple[float, float, float, float] | None = None
# Sample-space anchor for the drawn grid so it tracks goniometer motion
# (mirrors the bookmark pattern): smargon top-left + extent in mm + the
# orientation it was drawn at. _project_grid() re-derives the on-screen
# pixel rect from the live geometry every status frame.
self._grid_anchor: dict | None = None
self._raster_grids: list = []
self._grids_dialog = None
self._bookmarks: list = [] # [{coord, omega, label}]
@@ -120,6 +134,10 @@ class ManualView(QWidget):
tab.setObjectName("CamTab")
tab.setCheckable(True)
tab.setCursor(Qt.PointingHandCursor)
# reserve width for the bold + bordered checked state (padding 14*2 +
# border + slack) so the label isn't clipped when selected
bf = QFont(tab.font()); bf.setBold(True)
tab.setMinimumWidth(QFontMetrics(bf).horizontalAdvance(name) + 34)
if i == 0:
tab.setChecked(True)
tab.clicked.connect(lambda _=False, ix=idx: self.center_stack.setCurrentIndex(ix))
@@ -161,11 +179,11 @@ class ManualView(QWidget):
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.grid_adjusted.connect(self._on_grid_adjusted)
self.camera.clicked_point.connect(self._on_camera_click)
# transport (drives the pausable auto-run)
@@ -363,15 +381,48 @@ class ManualView(QWidget):
self._stage_done(self._await_stage)
def _on_grid_drawn(self, x: float, y: float, w: float, h: float) -> None:
# A fresh right-drag grid clears any prior result and moves us from the
# centering stage into rastering (the draw gesture is the transition).
self._grid_rect_img = (x, y, w, h)
self.camera.set_heatmap(None) # clear any prior result heatmap
self.camera.set_heatmap(None)
self._recompute_grid_cells()
self._anchor_grid(x, y, w, h)
self._state.pick_stage("raster")
def _on_grid_adjusted(self, x: float, y: float, w: float, h: float) -> None:
# Live move/resize of the existing grid: re-pin it to the sample and
# recompute the cell count; stay on whatever stage we're on.
self._grid_rect_img = (x, y, w, h)
self._recompute_grid_cells()
self._anchor_grid(x, y, w, h)
def _recompute_grid_cells(self) -> None:
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)
def _anchor_grid(self, x: float, y: float, w: float, h: float) -> None:
"""Pin the drawn grid to the sample so it tracks goniometer motion.
Converts the drawn top-left pixel to smargon (motor) coordinates and
remembers the extent in mm plus the orientation it was drawn at. From
then on _project_grid() re-derives the pixel rect from the live geometry
each frame, so the grid stays stuck to the sample instead of the screen.
"""
self._grid_anchor = None
px_mm = requests._pixel_in_mm(self._last_status)
top_left = requests._smargon_top_left(self._last_status, (x, y))
geom = getattr(self._last_status, "geom", None)
if px_mm is None or top_left is None or geom is None:
return
self._grid_anchor = {
"top_left": top_left,
"w_mm": w * px_mm,
"h_mm": h * px_mm,
"omega": float(getattr(geom, "omega_deg", 0.0) or 0.0),
}
# ----------------------------------------------------- raster results
def on_raster_completed(self, completed) -> None:
@@ -570,6 +621,41 @@ class ManualView(QWidget):
items.append((px.x, px.y, i + 1, BOOKMARK_COLORS[i % len(BOOKMARK_COLORS)]))
self.camera.set_bookmarks(items)
def _project_grid(self) -> None:
"""Re-derive the grid's pixel rect from its smargon anchor every frame.
This is what makes the grid stick to the sample: as the goniometer moves
the live geometry changes, so smargon_to_picture maps the stored top-left
to a new on-screen position (same idea as the old GUI re-running
_grid_pixel_geometry on each repaint). The overlay is an axis-aligned
rect that is only valid at the orientation it was drawn at, so it is
hidden when the sample is rotated away (and re-shown on return).
"""
anchor = self._grid_anchor
geom = getattr(self._last_status, "geom", None)
if anchor is None or geom is None or not hasattr(geom, "smargon_to_picture"):
return
if self.camera.is_grid_interacting():
return # don't snap the grid back while the user is dragging it
omega = float(getattr(geom, "omega_deg", 0.0) or 0.0)
sg = getattr(geom, "smargon", None)
tl = anchor["top_left"]
if not (_angle_close(omega, anchor["omega"])
and _angle_close(getattr(sg, "phi_deg", None), tl.phi_deg)
and _angle_close(getattr(sg, "chi_deg", None), tl.chi_deg)):
self.camera.hide_grid_overlay()
return
px_mm = requests._pixel_in_mm(self._last_status)
if not px_mm:
return
try:
corner = geom.smargon_to_picture(tl.sh_mm)
except Exception:
return
rect = (corner.x, corner.y, anchor["w_mm"] / px_mm, anchor["h_mm"] / px_mm)
self._grid_rect_img = rect
self.camera.set_grid_rect(*rect)
# ------------------------------------------------------- refreshers
def _refresh_mount(self) -> None:
phase = self._state.mount_phase
@@ -594,6 +680,7 @@ class ManualView(QWidget):
if phase != "mounted":
self.camera.clear_grid()
self._grid_rect_img = None
self._grid_anchor = None
self._bookmarks = [] # cleared from view (persisted)
self._bookmark_loaded_key = None
self.pipeline.bookmarks.set_bookmarks([])
@@ -602,11 +689,10 @@ class ManualView(QWidget):
self.pipeline.update_from_state(self._state)
def _refresh_pipe(self) -> None:
# Grid editing lives entirely on the camera now (right-drag to draw,
# left-drag to move, right-drag an edge to resize), so there is no
# stage-gated draw affordance to toggle here.
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:
@@ -620,6 +706,11 @@ class ManualView(QWidget):
sub: str = "") -> None:
self.camera.set_control_state(in_control, title, sub)
def set_locked(self, locked: bool) -> None:
"""Disable the interactive panels; the live camera stays bright & visible."""
for w in (self.changer, self.pipeline, self.motors, self.camera_controls):
w.setEnabled(not locked)
# ------------------------------------------------------- live status
@property
def last_status(self):
@@ -627,11 +718,16 @@ class ManualView(QWidget):
def update_daq_status(self, s) -> None:
self._last_status = s
sess = getattr(s, "session", None)
if sess is not None:
self.pipeline.filename.set_pgroup(getattr(sess, "current_pgroup", None))
self._check_busy_edge(s)
self.motors.update_daq_status(s)
self.camera_controls.update_daq_status(s)
if self._bookmarks:
self._project_bookmarks()
if self._grid_anchor is not None:
self._project_grid()
bl = getattr(s, "bl", None)
if bl is not None and getattr(bl, "zoom", None) is not None:
from aare.gui.new_gui.widgets.motors_panel import zoom_label
+6
View File
@@ -73,6 +73,7 @@ class StaffView(QWidget):
self._proc_btns[key] = btn
ll.addWidget(btn)
ll.addStretch(1)
self._left_panel = left
root.addWidget(left)
# --- centre: camera / spectrum + contextual controls ---
@@ -180,6 +181,11 @@ class StaffView(QWidget):
self.state_requested.emit(method)
break
def set_locked(self, locked: bool) -> None:
"""Disable alignment controls; the live camera stays bright & visible."""
for w in (self._left_panel, self.ctx, self.motors):
w.setEnabled(not locked)
# ----- live status -----
def update_daq_status(self, s) -> None:
self._last_status = s
+1 -1
View File
@@ -48,7 +48,7 @@ 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}
return {"center": True, "raster": True, "xrf": False, "collect": True}
class ManualSample:
+2 -2
View File
@@ -208,10 +208,10 @@ def build_qss(p: Palette) -> str:
}}
/* ---- Baton chip ---- */
QWidget#BatonChip {{
QFrame#BatonChip {{
background: {p.accent_tint_bg};
border: 1px solid {p.accent_tint_border};
border-radius: 20px;
border-radius: 8px;
}}
QLabel#BatonText {{ font-size: 12px; color: {p.text_primary}; }}
QLabel#BatonId {{
+16 -4
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QFontMetrics
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMenu,
@@ -35,6 +37,10 @@ class ModeToggle(QWidget):
btn.setObjectName("ModeSeg")
btn.setCheckable(True)
btn.setCursor(Qt.PointingHandCursor)
# reserve width for the *bold* checked state (padding 16*2 + slack) so
# the label isn't clipped when selected
bf = QFont(btn.font()); bf.setBold(True)
btn.setMinimumWidth(QFontMetrics(bf).horizontalAdvance(label) + 36)
btn.clicked.connect(lambda _=False, k=key: self.mode_changed.emit(k))
self._group.addButton(btn)
lay.addWidget(btn)
@@ -93,11 +99,13 @@ class TopBar(QWidget):
lay.addStretch(1)
# baton chip
self._baton = QWidget()
# baton chip — a QFrame (not a bare QWidget) so the QSS border-radius
# actually rounds the corners; fixed height so it matches the Tools button
self._baton = QFrame()
self._baton.setObjectName("BatonChip")
self._baton.setFixedHeight(28)
bl = QHBoxLayout(self._baton)
bl.setContentsMargins(12, 6, 12, 6)
bl.setContentsMargins(12, 4, 12, 4)
bl.setSpacing(8)
self._dot = QLabel()
self._dot.setFixedSize(8, 8)
@@ -109,7 +117,7 @@ class TopBar(QWidget):
bl.addWidget(self._dot)
bl.addWidget(self._baton_text)
bl.addWidget(self._baton_id)
lay.addWidget(self._baton)
lay.addWidget(self._baton, 0, Qt.AlignVCenter)
# baton action button (Grab / Request / Release / Cancel)
self._baton_btn = QPushButton("Grab")
@@ -173,6 +181,10 @@ class TopBar(QWidget):
def _set_dot(self, color: str) -> None:
self._dot.setStyleSheet(f"background:{color}; border-radius:4px;")
def baton_button(self) -> QPushButton:
"""The Grab / Request-control button (for the busy overlay to spotlight)."""
return self._baton_btn
def set_mode(self, mode: str) -> None:
self.toggle.set_mode(mode)
+143 -35
View File
@@ -1,68 +1,176 @@
"""A dismissible top alert banner for errors, warnings and completion notices."""
"""A persistent status strip ("notice board") for errors, warnings and notices.
Unlike a pop-up banner, this always occupies the same fixed-height row so the
surrounding layout never shifts. When there is nothing fresh to report it sits
in a calm resting state that blends into the chrome; an event tints a slim left
stripe and the status dot rather than flooding the whole bar. After an active
notice auto-clears the strip keeps the most recent event greyed out, so the
board always shows what last happened. State changes cross-fade gently.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget
from datetime import datetime
from PySide6.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import (
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QPushButton,
QWidget,
)
from aare.gui.new_gui.theme import Palette
BAR_HEIGHT = 36
FADE_MS = 160
class AlertBanner(QWidget):
"""A thin coloured strip below the top bar. Hidden when there's no message."""
"""A persistent status strip below the top bar. Always visible; the resting
state is quiet, an active notice colours a left stripe and the status dot,
and a cleared notice lingers greyed-out as the most-recent event."""
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self.setAttribute(Qt.WA_StyledBackground, True)
self.setVisible(False)
self.setFixedHeight(BAR_HEIGHT)
self._full_text = ""
# Remember the last active notice so the resting state can show it.
self._last_text = ""
self._last_glyph = ""
self._last_time = ""
lay = QHBoxLayout(self)
lay.setContentsMargins(16, 8, 12, 8)
lay.setContentsMargins(16, 0, 12, 0)
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._dot = QLabel("")
self._text = QLabel("")
self._time = QLabel("")
self._close = QPushButton("")
self._close.setCursor(Qt.PointingHandCursor)
self._close.setFixedSize(20, 20)
self._close.setToolTip("Dismiss")
self._close.clicked.connect(self.clear)
lay.addWidget(self._dot)
lay.addWidget(self._text, 1)
lay.addWidget(self._time)
lay.addWidget(self._close)
# Cross-fade plumbing: one reusable opacity effect, one live animation.
self._opacity = QGraphicsOpacityEffect(self)
self.setGraphicsEffect(self._opacity)
self._fade_anim: QPropertyAnimation | None = None
# Auto-clear returns to the resting state but keeps the last event.
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.clear)
self._timer.timeout.connect(self._rest_keep_last)
self.clear()
# --------------------------------------------------------------- public API
def show_message(self, text: str, level: str = "info",
auto_clear_ms: int = 8000) -> None:
"""level: 'info' | 'success' | 'warning' | 'error'."""
p = self._p
# (stripe/dot colour, glyph). Background stays calm so the colour cue
# is the stripe + dot, never a full-width slab.
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": (p.chip_off_bg, p.danger, p.text_primary, ""),
"info": (p.accent_tint_border, ""),
"success": (p.success, ""),
"warning": (p.breakpoint, ""),
"error": (p.danger, ""),
}
bg, border, fg, icon = styles.get(level, styles["info"])
colour, glyph = styles.get(level, styles["info"])
stamp = datetime.now().strftime("%H:%M")
self.setStyleSheet(
f"AlertBanner {{ background:{bg}; border-bottom:1px solid {border}; }}"
f"AlertBanner {{ background:{p.surface};"
f" border-bottom:1px solid {p.border_panel};"
f" border-left:3px solid {colour}; }}"
)
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._dot.setText(glyph)
self._dot.setStyleSheet(f"color:{colour}; font-size:12px;")
self._set_text(text, f"color:{p.text_primary}; font-size:12.5px;", tip=text)
self._time.setText(stamp)
self._time.setStyleSheet(f"color:{p.text_faint}; font-size:11px;")
self._close.setVisible(True)
self._close.setStyleSheet(
"QPushButton { border:none; background:transparent;"
f" color:{p.text_muted}; font-size:12px; }}"
f" QPushButton:hover {{ color:{p.text_primary}; }}"
)
# Stash for the greyed resting state once this clears.
self._last_text, self._last_glyph, self._last_time = text, glyph, stamp
self._timer.stop()
if auto_clear_ms and level not in ("error",):
# Errors stay until dismissed or superseded; everything else fades back
# to the resting state.
if auto_clear_ms and level != "error":
self._timer.start(auto_clear_ms)
self._fade()
def clear(self) -> None:
"""Fully dismiss: quiet resting state with no recorded event."""
self._last_text = ""
self._render_rest("No active notifications", "", "")
# --------------------------------------------------------------- internals
def _rest_keep_last(self) -> None:
"""Auto-clear: drop to the resting state but keep the last event greyed."""
if self._last_text:
self._render_rest(self._last_text, self._last_glyph, self._last_time)
else:
self.clear()
def _render_rest(self, text: str, glyph: str, time_text: str) -> None:
p = self._p
self._timer.stop()
self.setVisible(False)
self._text.clear()
self.setStyleSheet(
f"AlertBanner {{ background:{p.surface};"
f" border-bottom:1px solid {p.border_panel};"
f" border-left:3px solid {p.border_panel}; }}"
)
self._dot.setText(glyph)
self._dot.setStyleSheet(f"color:{p.text_faint}; font-size:12px;")
self._set_text(text, f"color:{p.text_muted}; font-size:12.5px;",
tip=text if time_text else "")
self._time.setText(time_text)
self._time.setStyleSheet(f"color:{p.text_faint}; font-size:11px;")
self._close.setVisible(False)
self._fade()
def _set_text(self, full: str, style: str, tip: str) -> None:
self._full_text = full
self._text.setStyleSheet(style)
self._text.setToolTip(tip)
self._apply_elided()
def _apply_elided(self) -> None:
"""Keep the message to one line so the bar height never changes."""
fm = QFontMetrics(self._text.font())
self._text.setText(fm.elidedText(self._full_text, Qt.ElideRight,
max(0, self._text.width())))
def _fade(self) -> None:
"""Gentle cross-fade so state changes settle rather than snap."""
if self._fade_anim is not None:
self._fade_anim.stop()
anim = QPropertyAnimation(self._opacity, b"opacity", self)
anim.setDuration(FADE_MS)
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.OutCubic)
anim.start()
self._fade_anim = anim
def resizeEvent(self, event): # noqa: N802 (Qt override)
super().resizeEvent(event)
self._apply_elided()
@@ -0,0 +1,198 @@
"""Ambient "another user holds the beamline" overlay (design concept C, Aurora).
Replaces the old big centred GUEST MODE text. Three calm cues:
1. a soft dim wash over the control panels (the live camera stays bright — it's
subtracted from the wash);
2. a two-colour aurora light drifting slowly around the four window edges;
3. a small bottom-left status chip with a gently pulsing dot.
The widget only *paints* — it is transparent to the mouse. The actual locking is
done by ``setEnabled(False)`` on the real control panels (idiomatic Qt), so the
"Request control" button and the camera can stay live. Parent it to the window,
keep it sized to the window, and feed it the regions to wash via
:meth:`update_regions`.
"""
from __future__ import annotations
import math
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
from PySide6.QtGui import (
QColor,
QFont,
QFontMetrics,
QLinearGradient,
QPainter,
QPen,
QRegion,
)
from PySide6.QtWidgets import QWidget
# ---- palette (approved mock, concept C) ----
ACCENT_A = QColor("#8f90d6") # periwinkle
ACCENT_B = QColor("#6fbcd0") # soft cyan
DIM = QColor(10, 10, 15, 140) # wash over locked panels (~rgba(10,10,15,.55))
CHIP_BG = QColor(18, 18, 26, 217) # ~.85
CHIP_BORDER = QColor(46, 46, 60)
TEXT_COL = QColor("#dcdce6")
DOT_COL = QColor("#7fb6d8")
BORDER_PX = 3 # edge-light thickness
H_PERIOD_MS = 7000 # top/bottom travel period
V_PERIOD_MS = 9000 # left/right travel period (slower = calmer)
DOT_PERIOD_MS = 2600 # status-dot pulse
BREATHE_PERIOD_MS = 2400 # spotlight "breathe" around the Grab-baton button
FPS = 30
class BeamlineBusyOverlay(QWidget):
"""Paint-only overlay; locking is done on the real widgets by the caller."""
def __init__(self, parent):
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self.setAttribute(Qt.WA_NoSystemBackground, True)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self._busy = False
self._text = "Beamline busy, please wait"
self._elapsed = 0 # ms, monotonically increasing
self._dim_rects: list = [] # QRects washed darker
self._clear_rects: list = [] # QRects kept bright (subtracted from the wash)
self._breathe_rect = None # QRect spotlit with a pulsing glow (the baton btn)
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self.hide()
# ---------- public API ----------
def set_busy(self, busy: bool, text: str | None = None) -> None:
busy = bool(busy)
new_text = text or self._text
if busy == self._busy and new_text == self._text:
return # idempotent: avoid re-raising/repainting on every status poll
self._busy = busy
self._text = new_text
if self._busy:
self.raise_()
self.show()
if not self._timer.isActive():
self._timer.start(int(1000 / FPS))
else:
self._timer.stop()
self.hide()
self.update()
def update_regions(self, dim_rects, clear_rects=None, breathe_rect=None) -> None:
"""Rects (in this widget's coords) to wash, rects to keep bright, and an
optional rect to spotlight with a pulsing "breathe" glow (kept bright)."""
self._dim_rects = list(dim_rects or [])
self._clear_rects = list(clear_rects or [])
self._breathe_rect = breathe_rect
self.update()
# ---------- animation ----------
def _tick(self) -> None:
self._elapsed += int(1000 / FPS)
self.update()
# ---------- paint ----------
def paintEvent(self, _e) -> None: # noqa: N802
if not self._busy:
return
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
# (a) dim wash over locked panels, with the camera subtracted so it stays bright
if self._dim_rects:
region = QRegion()
for r in self._dim_rects:
region = region.united(QRegion(r))
for r in self._clear_rects:
region = region.subtracted(QRegion(r))
if not region.isEmpty():
p.save()
p.setClipRegion(region)
p.fillRect(self.rect(), DIM)
p.restore()
# (b) aurora light travelling around the four edges
w, h = self.width(), self.height()
hp = (self._elapsed % H_PERIOD_MS) / H_PERIOD_MS
vp = (self._elapsed % V_PERIOD_MS) / V_PERIOD_MS
self._draw_edge(p, QRectF(0, 0, w, BORDER_PX), hp, True)
self._draw_edge(p, QRectF(0, h - BORDER_PX, w, BORDER_PX), 1.0 - hp, True)
self._draw_edge(p, QRectF(0, 0, BORDER_PX, h), vp, False)
self._draw_edge(p, QRectF(w - BORDER_PX, 0, BORDER_PX, h), 1.0 - vp, False)
# (c) breathing spotlight around the Grab-baton button ("click me first")
self._draw_breathe(p)
# (d) status chip
self._draw_chip(p)
p.end()
def _draw_breathe(self, p: QPainter) -> None:
r = self._breathe_rect
if r is None:
return
t = (self._elapsed % BREATHE_PERIOD_MS) / BREATHE_PERIOD_MS
pulse = 0.5 - 0.5 * math.cos(2 * math.pi * t) # 0..1, calm in-out
base = QRectF(r)
p.setBrush(Qt.NoBrush)
for i, grow in enumerate((2.0, 6.0, 11.0)):
col = QColor(ACCENT_A if i % 2 == 0 else ACCENT_B)
col.setAlphaF(max(0.0, 0.55 * pulse * (1.0 - i / 3.0)))
p.setPen(QPen(col, 2.0))
rr = base.adjusted(-grow, -grow, grow, grow)
p.drawRoundedRect(rr, 9, 9)
def _draw_edge(self, p: QPainter, rect: QRectF, phase: float, horizontal: bool) -> None:
"""A soft highlight that slides along an edge, fading off-screen at the ends."""
c = -0.3 + phase * 1.6 # travel from off-screen to off-screen, no hard pop
if horizontal:
grad = QLinearGradient(rect.left(), 0, rect.right(), 0)
else:
grad = QLinearGradient(0, rect.top(), 0, rect.bottom())
def stop(pos, col):
grad.setColorAt(min(1.0, max(0.0, pos)), col)
clear_a = QColor(ACCENT_A); clear_a.setAlpha(0)
clear_b = QColor(ACCENT_B); clear_b.setAlpha(0)
stop(c - 0.25, clear_a)
stop(c - 0.05, ACCENT_A)
stop(c + 0.05, ACCENT_B)
stop(c + 0.25, clear_b)
p.fillRect(rect, grad)
def _draw_chip(self, p: QPainter) -> None:
font = QFont(self.font())
font.setPixelSize(13)
p.setFont(font)
text = self._text
fm = QFontMetrics(font)
pad_x, pad_y, gap, dot_r = 17, 9, 10, 4.5
tw = fm.horizontalAdvance(text)
chip_w = pad_x * 2 + dot_r * 2 + gap + tw
chip_h = pad_y * 2 + max(fm.height(), dot_r * 2)
x = (self.width() - chip_w) / 2 # centred in the view
y = (self.height() - chip_h) / 2
chip = QRectF(x, y, chip_w, chip_h)
p.setPen(CHIP_BORDER)
p.setBrush(CHIP_BG)
p.drawRoundedRect(chip, chip_h / 2, chip_h / 2)
# pulsing dot
t = (self._elapsed % DOT_PERIOD_MS) / DOT_PERIOD_MS
a = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(2 * math.pi * t))
dot = QColor(DOT_COL); dot.setAlphaF(a)
p.setPen(Qt.NoPen); p.setBrush(dot)
cx = x + pad_x + dot_r
cy = y + chip_h / 2
p.drawEllipse(QPointF(cx, cy), dot_r, dot_r)
# text
p.setPen(TEXT_COL)
p.drawText(QRectF(cx + dot_r + gap, y, tw + 4, chip_h),
Qt.AlignVCenter | Qt.AlignLeft, text)
+208 -38
View File
@@ -9,7 +9,9 @@ ring, legend and raster grid are painted.
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QRectF, Qt, Signal
import math
from PySide6.QtCore import QPoint, QRect, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import (
QColor,
QFont,
@@ -39,13 +41,20 @@ 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.
# Emitted after RIGHT-dragging out a new 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. A fresh draw also moves the pipeline
# into the rastering stage.
grid_drawn = Signal(float, float, float, float)
# Emitted after moving (left-drag) or resizing (right-drag on an edge) the
# existing grid. Same IMAGE-PIXEL (x, y, w, h) payload; does NOT switch stage.
grid_adjusted = 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)
# Mouse-wheel over the live image -> relative omega rotation, in degrees
# (±90 per notch, ±10 with Shift). Mirrors the old GUI's sample-camera wheel.
rotate_omega_requested = Signal(float)
def __init__(self, palette: Palette, parent: QWidget | None = None):
super().__init__(parent)
@@ -63,15 +72,23 @@ class CameraViewport(QWidget):
self._show_detections = True
self._bookmarks: list = [] # [(x_px, y_px, label, color)]
# Raster grid-draw state
self._grid_draw_enabled = False
# Raster grid state. The grid is drawn/edited directly on the image:
# right-drag (empty) -> draw a new grid
# left-drag (on grid) -> move it
# right-drag (on an edge) -> resize from that side
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
# interactive editing state
self._grid_mode: str | None = None # None|'draw'|'move'|'resize'
self._grid_edges: set[str] = set() # active resize edges l/r/t/b
self._drag_start: QPoint | None = None # widget coords (draw)
self._drag_cur: QPoint | None = None
self._press_img: QPoint | None = None # image coords at press
self._rect_press: tuple[float, float, float, float] | None = None # l,t,r,b
self._edge_tol = 8 # px, edge grab zone (widget)
# geometry of the last painted frame (for widget<->image mapping)
self._img_w = 1
@@ -81,6 +98,10 @@ class CameraViewport(QWidget):
# control (baton) state — when False the viewport is a read-only guest view
self._in_control = True
# throttle wheel-driven omega rotation so one flick doesn't over-spin
self._wheel_guard = QTimer(self)
self._wheel_guard.setSingleShot(True)
self.setMinimumHeight(220)
self.setMouseTracking(True)
@@ -188,22 +209,25 @@ class CameraViewport(QWidget):
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 is_grid_interacting(self) -> bool:
"""True while the user is actively drawing/moving/resizing the grid, so
the owner can suspend re-projecting it from the sample anchor."""
return self._grid_mode is not None
def clear_grid(self) -> None:
self._grid_rect_img = None
self._grid_nx = self._grid_ny = 0
self._grid_mode = None
self._grid_edges = set()
self._drag_start = self._drag_cur = None
self._press_img = None
self._rect_press = None
self._heatmap = None
self.setCursor(Qt.ArrowCursor)
self.update()
def set_heatmap(self, image, alpha: int | None = None) -> None:
@@ -222,6 +246,14 @@ class CameraViewport(QWidget):
self._grid_rect_img = QRectF(x, y, w, h)
self.update()
def hide_grid_overlay(self) -> None:
"""Hide the grid outline/heatmap without discarding the cell counts or
heatmap image. Used when the sample is rotated away from the orientation
the grid was drawn at; a later set_grid_rect() re-shows it unchanged."""
if self._grid_rect_img is not None:
self._grid_rect_img = None
self.update()
# ------------------------------------------------------------- slots
def update_pixmap(self, pm: QPixmap) -> None:
self._pixmap = pm
@@ -293,45 +325,179 @@ class CameraViewport(QWidget):
br = self._image_to_widget(QPoint(int(r.right()), int(r.bottom())))
return QRect(tl, br)
# ------------------------------------------------- grid hit-testing
def _grid_widget_rect(self) -> QRect | None:
if self._grid_rect_img is None:
return None
return self._image_rect_to_widget(self._grid_rect_img).normalized()
def _grid_edges_at(self, pt: QPoint) -> set[str]:
"""Which grid edges (l/r/t/b) the widget point is within grab range of."""
gw = self._grid_widget_rect()
if gw is None:
return set()
t = self._edge_tol
edges: set[str] = set()
in_x = gw.left() - t <= pt.x() <= gw.right() + t
in_y = gw.top() - t <= pt.y() <= gw.bottom() + t
if in_y and abs(pt.x() - gw.left()) <= t:
edges.add("l")
if in_y and abs(pt.x() - gw.right()) <= t:
edges.add("r")
if in_x and abs(pt.y() - gw.top()) <= t:
edges.add("t")
if in_x and abs(pt.y() - gw.bottom()) <= t:
edges.add("b")
return edges
def _inside_grid(self, pt: QPoint) -> bool:
gw = self._grid_widget_rect()
return gw is not None and gw.contains(pt)
def _cursor_for_edges(self, edges: set[str]):
if {"l", "t"} <= edges or {"r", "b"} <= edges:
return Qt.SizeFDiagCursor
if {"r", "t"} <= edges or {"l", "b"} <= edges:
return Qt.SizeBDiagCursor
if edges & {"l", "r"}:
return Qt.SizeHorCursor
if edges & {"t", "b"}:
return Qt.SizeVerCursor
return None
def _grid_editable(self) -> bool:
return (self._in_control and self._mode == "mounted"
and self._pixmap is not None and not self._pixmap.isNull())
# --------------------------------------------------------- mouse (grid)
def mousePressEvent(self, event): # noqa: N802
if not self._in_control:
if not self._grid_editable():
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
pt = event.position().toPoint()
if event.button() == Qt.RightButton:
# Right-drag on an existing edge resizes; anywhere else draws a new grid.
edges = self._grid_edges_at(pt)
if edges:
self._grid_mode = "resize"
self._grid_edges = edges
self._press_img = self._widget_to_image(pt)
r = self._grid_rect_img
self._rect_press = (r.left(), r.top(), r.right(), r.bottom())
else:
self._grid_mode = "draw"
self._drag_start = pt
self._drag_cur = pt
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())
event.accept()
return
if event.button() == Qt.LeftButton:
# Left-drag inside the grid moves it; otherwise click-to-centre.
if self._inside_grid(pt):
self._grid_mode = "move"
self._press_img = self._widget_to_image(pt)
r = self._grid_rect_img
self._rect_press = (r.left(), r.top(), r.right(), r.bottom())
self.update()
event.accept()
return
img = self._widget_to_image(pt)
self.clicked_point.emit(float(img.x()), float(img.y()))
else:
super().mousePressEvent(event)
return
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()
pt = event.position().toPoint()
if self._grid_mode == "draw":
self._drag_cur = pt
self.update()
else:
super().mouseMoveEvent(event)
return
if self._grid_mode == "move":
self._apply_move(pt)
self.update()
return
if self._grid_mode == "resize":
self._apply_resize(pt)
self.update()
return
# idle hover: hint move/resize affordances over an existing grid
if self._grid_editable() and self._grid_rect_img is not None:
cur = self._cursor_for_edges(self._grid_edges_at(pt))
if cur is None and self._inside_grid(pt):
cur = Qt.SizeAllCursor
self.setCursor(cur if cur is not None else Qt.ArrowCursor)
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()
mode = self._grid_mode
if mode == "draw" and self._drag_start is not None:
a = self._widget_to_image(self._drag_start)
b = self._widget_to_image(self._drag_cur)
b = self._widget_to_image(event.position().toPoint())
x, y = min(a.x(), b.x()), min(a.y(), b.y())
w, h = abs(b.x() - a.x()), abs(b.y() - a.y())
self._reset_grid_drag()
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)
event.accept()
return
if mode in ("move", "resize"):
self._reset_grid_drag()
r = self._grid_rect_img
if r is not None:
self.grid_adjusted.emit(
float(r.left()), float(r.top()), float(r.width()), float(r.height()))
self.update()
event.accept()
return
super().mouseReleaseEvent(event)
def _reset_grid_drag(self) -> None:
self._grid_mode = None
self._grid_edges = set()
self._drag_start = self._drag_cur = None
self._press_img = None
self._rect_press = None
def _apply_move(self, pt: QPoint) -> None:
if self._rect_press is None or self._press_img is None:
return
img = self._widget_to_image(pt)
dx = img.x() - self._press_img.x()
dy = img.y() - self._press_img.y()
l, t, r, b = self._rect_press
self._grid_rect_img = QRectF(l + dx, t + dy, r - l, b - t)
def _apply_resize(self, pt: QPoint) -> None:
if self._rect_press is None:
return
img = self._widget_to_image(pt)
l, t, r, b = self._rect_press
m = 4.0 # min extent, image px (left/right and top/bottom never cross)
if "l" in self._grid_edges:
l = min(float(img.x()), r - m)
if "r" in self._grid_edges:
r = max(float(img.x()), l + m)
if "t" in self._grid_edges:
t = min(float(img.y()), b - m)
if "b" in self._grid_edges:
b = max(float(img.y()), t + m)
self._grid_rect_img = QRectF(l, t, r - l, b - t)
def wheelEvent(self, event): # noqa: N802
# Scroll over a mounted sample to rotate omega: ±90° per notch, ±10°
# with Shift held. Throttled so a fast flick can't over-spin.
delta = event.angleDelta().y()
if (not self._in_control or self._mode != "mounted" or delta == 0
or self._wheel_guard.isActive()):
super().wheelEvent(event)
return
shift = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
step = 10.0 if shift else 90.0
self.rotate_omega_requested.emit(math.copysign(step, delta))
self._wheel_guard.start(180)
event.accept()
# ------------------------------------------------------------- layout
def resizeEvent(self, event): # noqa: N802
@@ -537,15 +703,19 @@ class CameraViewport(QWidget):
Qt.AlignLeft | Qt.AlignVCenter, label)
def _paint_grid(self, painter: QPainter) -> None:
# Live drag rectangle
# While drawing a fresh grid we paint the raw drag rectangle; once it
# exists (incl. live move/resize, which update _grid_rect_img directly)
# we paint from the image-pixel rect.
rect = None
if self._drag_start is not None and self._drag_cur is not None:
drawing = (self._grid_mode == "draw"
and self._drag_start is not None and self._drag_cur is not None)
if drawing:
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
dragging = drawing
# Completed-result heatmap (blitted, nearest-neighbour, when not dragging).
if self._heatmap is not None and not dragging:
+149 -23
View File
@@ -1,44 +1,170 @@
"""Compact data filename builder: prefix + run number -> scan file_prefix.
"""Dataset path builder: directory + prefix + run -> relative scan ``file_prefix``.
Produces a relative ``file_prefix`` like ``data/<prefix>_001`` (or
``screening/<prefix>_001``), matching the existing GUI's add_data_to_path /
add_screening_to_path convention. Auto-increments the run number when the DAQ
reports a scan started (``run_number_incremented``).
Mirrors the old GUI's FilePathPanel. The Directory and Prefix fields hold
*macros* (``{date}``, ``{puck}``, ``{pos}``, ``{sample}``, ``{sample_id}``,
``{prefix}``) so they stay correct as samples change; a live preview shows the
fully-expanded path that will actually be written.
The Directory default follows the database when the mounted sample carries
``aaredb_params.directory``, otherwise a sensible puck/position (or manual)
layout. The Prefix defaults to ``{sample}`` (the mounted sample's name).
``file_prefix(kind)`` returns ``<kind>/<expanded-dir>/<expanded-prefix>_NNN``;
the backend (jfjoch) prepends ``<pgroup>/raw[/raster|/screening]`` to it.
"""
from __future__ import annotations
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QSpinBox, QWidget
from datetime import datetime
from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
QGridLayout,
QLabel,
QLineEdit,
QSpinBox,
QWidget,
)
from aare.gui.new_gui.theme import Palette
_DIR_TIP = (
"Subdirectory for your files. Macros allowed:\n"
"{date} · {puck} · {pos} · {sample} · {sample_id} · {prefix}"
)
_PREFIX_TIP = (
"File prefix. Macros allowed:\n"
"{date} · {puck} · {pos} · {sample} · {sample_id}"
)
class FilenameBuilder(QWidget):
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
lbl = QLabel("Prefix")
lbl.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
lay.addWidget(lbl)
self._prefix = QLineEdit("sample")
self._prefix.setMaximumWidth(140)
lay.addWidget(self._prefix)
run = QLabel("Run")
run.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
lay.addWidget(run)
self._p = palette
# macro substitutions, refreshed from the mounted sample / DAQ status
self._sample_name = "sample"
self._sample_id = -1
self._puck = "Manual"
self._pos = 99
self._pgroup = "p16371"
self._cur_sample_id = None # tracks identity so edits aren't clobbered
grid = QGridLayout(self)
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(6)
grid.setVerticalSpacing(3)
def cap(text: str) -> QLabel:
lbl = QLabel(text)
lbl.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
return lbl
self._dir = QLineEdit("{date}/{puck}/{pos}")
self._dir.setMinimumWidth(280)
self._dir.setToolTip(_DIR_TIP)
self._prefix = QLineEdit("{sample}")
self._prefix.setMinimumWidth(150)
self._prefix.setToolTip(_PREFIX_TIP)
self._run = QSpinBox()
self._run.setRange(1, 999)
self._run.setValue(1)
lay.addWidget(self._run)
# leftover horizontal space goes to an empty leading column, so the
# fields stay grouped at sensible widths over on the right-hand side
grid.setColumnStretch(0, 1)
grid.addWidget(cap("Dir"), 0, 1)
grid.addWidget(self._dir, 0, 2)
grid.addWidget(cap("Prefix"), 0, 3)
grid.addWidget(self._prefix, 0, 4)
grid.addWidget(cap("Run"), 0, 5)
grid.addWidget(self._run, 0, 6)
# full expanded path; wraps over multiple lines rather than eliding,
# right-aligned to sit under the fields
self._preview = QLabel("")
self._preview.setWordWrap(True)
self._preview.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
self._preview.setStyleSheet(f"font-size:11px; color:{palette.text_muted};")
grid.addWidget(self._preview, 1, 0, 1, 7)
self._dir.textChanged.connect(self._refresh_preview)
self._prefix.textChanged.connect(self._refresh_preview)
self._run.valueChanged.connect(self._refresh_preview)
self._refresh_preview()
# ------------------------------------------------------------- public API
def file_prefix(self, kind: str = "data") -> str:
"""kind is 'data' or 'screening'."""
base = (self._prefix.text().strip() or "sample")
return f"{kind}/{base}_{self._run.value():03d}"
"""kind is 'data' or 'screening' (the leading subfolder the backend
expects). Returns ``<kind>/<expanded-dir>/<expanded-prefix>_NNN``."""
return f"{kind}/{self._expanded_base()}"
def set_sample(self, sample) -> None:
"""Refresh macros + repopulate the directory when the sample changes.
Called on every status tick, so it is a no-op while the same sample
stays mounted — that keeps any manual edits to the fields intact.
"""
sid = getattr(sample, "db_id", None) if sample is not None else None
if sid == self._cur_sample_id:
return
self._cur_sample_id = sid
if sample is None:
self._sample_name = "sample"
self._sample_id = -1
self._puck = "Manual"
self._pos = 99
self._dir.setText("{date}/test")
else:
self._sample_name = getattr(sample, "sample_name", "sample") or "sample"
self._sample_id = getattr(sample, "db_id", -1)
self._puck = getattr(sample, "puck_name", "Manual") or "Manual"
self._pos = getattr(sample, "pin", 99) or 99
self._run.setValue(1)
params = getattr(sample, "aaredb_params", None)
db_dir = getattr(params, "directory", None) if params else None
if db_dir:
self._dir.setText(str(db_dir))
elif getattr(sample, "location", None) is not None:
self._dir.setText("{date}/{puck}/{pos}")
else:
self._dir.setText("{date}/manual/{sample}")
self._refresh_preview()
def set_pgroup(self, pgroup: str | None) -> None:
if pgroup and pgroup != self._pgroup:
self._pgroup = pgroup
self._refresh_preview()
@Slot()
def increment_run(self) -> None:
self._run.setValue(min(999, self._run.value() + 1))
# ------------------------------------------------------------- internals
def _expand_macros(self, text: str) -> str:
return (
text.replace("{date}", datetime.now().strftime("%Y%m%d"))
.replace("{sample}", self._sample_name)
.replace("{puck}", self._puck)
.replace("{pos}", f"{self._pos:02d}")
.replace("{sample_id}", str(self._sample_id))
)
def _expanded_base(self) -> str:
prefix_text = self._prefix.text().strip()
dir_text = self._dir.text().strip().strip("/").replace("{prefix}", prefix_text)
head = f"{dir_text}/" if dir_text else ""
base = f"{head}{prefix_text or 'run'}_{self._run.value():03d}"
return self._expand_macros(base)
def _refresh_preview(self) -> None:
# Indicative full path; the actual subfolder (raster/screening) is added
# per action by the backend — 'data' is shown as the representative case.
full = (
f"→ /sls/mx/data/{self._pgroup}/raw/data/"
f"{self._expanded_base()}_master.h5"
)
self._preview.setText(full)
self._preview.setToolTip(full)
+216
View File
@@ -0,0 +1,216 @@
"""Circular joystick for SMARGON XY jogging (+ Q/E focus).
Adapted (loosely) from the GonioDrive design hand-off: a radial dish with a
crosshair and a glowing knob. Mouse and keyboard feed one shared drive engine:
* a quick **tap** (press + release) fires one nudge — the magnitude is the
current step scaled by how far the knob is pushed (WASD = full push);
* **holding** keeps emitting that nudge on a repeat timer, so the stage keeps
moving in the held direction until you let go;
* the first nudge fires immediately on press for instantaneous feedback.
Keys: **WASD** = X/Y (the knob), **Q/E** = focus Z (+/-). Each tick emits
``jog(dx, dy, dz)`` (components in -1..1, beamline frame: +x right, +y up,
+z focus); the motors panel scales it by the current step. Colours come from the
active Palette. Keys work while the pad has keyboard focus (it grabs focus on
hover/click — the focus ring shows when live).
"""
from __future__ import annotations
import math
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QPainter, QPen, QRadialGradient
from PySide6.QtWidgets import QWidget
from aare.gui.new_gui.theme import Palette
INITIAL_MS = 90 # delay before a held control starts repeating (no dead zone)
REPEAT_MS = 90 # repeat interval while held (~11 nudges/s)
_DEADZONE = 0.04
class JogPad(QWidget):
"""A spring-back joystick (mouse + WASD/QE) that drives relative jogs."""
# dx, dy, dz in -1..1 (beamline frame: +x right, +y up, +z focus)
jog = Signal(float, float, float)
# +1 / -1 / 0 — current Z key direction, for button feedback
z_active = Signal(int)
# screen-space X/Y directions (+y points down on screen)
_XY_DIRS = {
Qt.Key_W: (0.0, -1.0),
Qt.Key_S: (0.0, 1.0),
Qt.Key_A: (-1.0, 0.0),
Qt.Key_D: (1.0, 0.0),
}
_Z_DIRS = {Qt.Key_Q: 1, Qt.Key_E: -1} # Q = focus +, E = focus -
def __init__(self, palette: Palette, diameter: int = 124, parent=None):
super().__init__(parent)
self._p = palette
self._d = diameter
self.setFixedSize(diameter, diameter)
self.setCursor(Qt.OpenHandCursor)
self.setFocusPolicy(Qt.StrongFocus)
self._knob = QPointF(0.0, 0.0) # offset from centre, in px
self._mouse_down = False
self._xy_keys: set[int] = set()
self._z_keys: set[int] = set()
self._timer = QTimer(self)
self._timer.timeout.connect(self._on_timeout)
# -------- geometry --------
def _radius(self) -> float:
return self._d / 2.0 - 10.0 # travel radius (px), leaving a rim margin
def _center(self) -> QPointF:
return QPointF(self.width() / 2.0, self.height() / 2.0)
def _set_knob_from_pos(self, pos) -> None:
c = self._center()
dx, dy = pos.x() - c.x(), pos.y() - c.y()
r = self._radius()
dist = math.hypot(dx, dy)
if dist > r and dist > 0:
dx, dy = dx / dist * r, dy / dist * r
self._knob = QPointF(dx, dy)
self.update()
def _set_knob_from_keys(self) -> None:
sx = sum(self._XY_DIRS[k][0] for k in self._xy_keys)
sy = sum(self._XY_DIRS[k][1] for k in self._xy_keys)
m = math.hypot(sx, sy)
if m:
r = self._radius()
self._knob = QPointF(sx / m * r, sy / m * r)
self.update()
def _z_dir(self) -> int:
return max(-1, min(1, sum(self._Z_DIRS[k] for k in self._z_keys)))
# -------- drive engine (shared by mouse + keyboard) --------
def _start_drive(self) -> None:
if self._timer.isActive():
return
self._emit_tick() # immediate first nudge
self._timer.start(INITIAL_MS)
def _on_timeout(self) -> None:
self._timer.setInterval(REPEAT_MS)
self._emit_tick()
def _maybe_stop(self) -> None:
if not self._mouse_down and not self._xy_keys and not self._z_keys:
self._timer.stop()
def _release_knob(self) -> None:
"""Spring the knob back to centre once no X/Y input is active."""
if not self._mouse_down and not self._xy_keys:
self._knob = QPointF(0.0, 0.0)
self.update()
def _emit_tick(self) -> None:
r = self._radius()
if not r:
return
dx = self._knob.x() / r
dy = -self._knob.y() / r # screen y grows down; jog +y is up
dz = float(self._z_dir())
if abs(dx) > _DEADZONE or abs(dy) > _DEADZONE or dz:
self.jog.emit(dx, dy, dz)
# -------- mouse --------
def mousePressEvent(self, e): # noqa: N802
if e.button() == Qt.LeftButton:
self.setFocus(Qt.MouseFocusReason)
self._mouse_down = True
self.setCursor(Qt.ClosedHandCursor)
self._set_knob_from_pos(e.position())
self._start_drive()
def mouseMoveEvent(self, e): # noqa: N802
if self._mouse_down:
self._set_knob_from_pos(e.position())
def mouseReleaseEvent(self, e): # noqa: N802
if e.button() == Qt.LeftButton and self._mouse_down:
self._mouse_down = False
self.setCursor(Qt.OpenHandCursor)
self._maybe_stop()
self._release_knob()
# -------- keyboard (WASD = XY, Q/E = focus Z) --------
def keyPressEvent(self, e): # noqa: N802
if e.isAutoRepeat():
return # our own timer handles repetition
key = e.key()
if key in self._XY_DIRS:
self._xy_keys.add(key)
self._set_knob_from_keys()
self._start_drive()
elif key in self._Z_DIRS:
self._z_keys.add(key)
self.z_active.emit(self._z_dir())
self._start_drive()
else:
super().keyPressEvent(e)
def keyReleaseEvent(self, e): # noqa: N802
if e.isAutoRepeat():
return
key = e.key()
if key in self._xy_keys:
self._xy_keys.discard(key)
if self._xy_keys:
self._set_knob_from_keys()
else:
self._release_knob()
self._maybe_stop()
elif key in self._z_keys:
self._z_keys.discard(key)
self.z_active.emit(self._z_dir())
self._maybe_stop()
else:
super().keyReleaseEvent(e)
# grab focus on hover so the keys are live without an explicit click
def enterEvent(self, e): # noqa: N802
self.setFocus(Qt.MouseFocusReason)
super().enterEvent(e)
# -------- painting --------
def paintEvent(self, _e): # noqa: N802
p = self._p
qp = QPainter(self)
qp.setRenderHint(QPainter.Antialiasing, True)
c = self._center()
rim = QRectF(2, 2, self.width() - 4, self.height() - 4)
# dish
grad = QRadialGradient(c, self.width() / 2.0)
grad.setColorAt(0.0, QColor(p.surface))
grad.setColorAt(1.0, QColor(p.app_bg))
qp.setBrush(grad)
ring = self.hasFocus()
qp.setPen(QPen(QColor(p.accent if ring else p.border_control), 2 if ring else 1))
qp.drawEllipse(rim)
# crosshair
qp.setPen(QPen(QColor(p.border_panel), 1))
qp.drawLine(QPointF(c.x(), rim.top() + 8), QPointF(c.x(), rim.bottom() - 8))
qp.drawLine(QPointF(rim.left() + 8, c.y()), QPointF(rim.right() - 8, c.y()))
# knob
k = QPointF(c.x() + self._knob.x(), c.y() + self._knob.y())
kr = 17.0
kgrad = QRadialGradient(QPointF(k.x() - 5, k.y() - 6), kr * 1.6)
accent = QColor(p.accent)
kgrad.setColorAt(0.0, accent.lighter(125))
kgrad.setColorAt(1.0, accent)
qp.setBrush(kgrad)
qp.setPen(QPen(QColor(p.accent_ring), 2))
qp.drawEllipse(k, kr, kr)
qp.end()
+68 -31
View File
@@ -17,18 +17,20 @@ from PySide6.QtWidgets import (
QPushButton,
QScrollArea,
QSlider,
QSpinBox,
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
from aare.gui.new_gui.widgets.jog_pad import JogPad
OMEGA_STEPS = (-90, -45, -10, 10, 45, 90)
# (label, zoom-motor value) — the value is a motor position, NOT a magnification.
ZOOM_SETTINGS = (("1.0×", 1.0), ("2.0×", 280.0), ("3.5×", 500.0),
("5.8×", 700.0), ("7.5×", 800.0), ("12.5×", 1000.0))
JOG_STEPS_UM = (10, 50, 100, 500)
DEFAULT_STEP_UM = 100
def zoom_label(value) -> str:
@@ -53,7 +55,6 @@ class MotorsPanel(QWidget):
self.setObjectName("SidePanel")
self.setFixedWidth(MOTORS_W)
self._p = palette
self._jog_idx = 2 # default 100 µm
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
@@ -129,35 +130,35 @@ class MotorsPanel(QWidget):
lay.addWidget(home)
lay.addSpacing(8)
# XY jog pad + focus (Z)
# XY joystick + focus (Z)
padrow = QHBoxLayout()
pad = QGridLayout()
pad.setSpacing(5)
up, down = self._jog_btn(""), self._jog_btn("")
left, right = self._jog_btn(""), self._jog_btn("")
self._center = self._jog_btn("100µm")
self._center.clicked.connect(self._cycle_step)
up.clicked.connect(lambda: self._jog(0, 1, 0))
down.clicked.connect(lambda: self._jog(0, -1, 0))
left.clicked.connect(lambda: self._jog(-1, 0, 0))
right.clicked.connect(lambda: self._jog(1, 0, 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(132)
padrow.addWidget(padw)
# focus (Z) column
padrow.setSpacing(10)
# joystick column: dish + editable step
joycol = QVBoxLayout()
joycol.setSpacing(6)
self._pad = JogPad(palette)
self._pad.setToolTip(
"Tap to nudge, hold to keep moving\nWASD = X/Y, Q/E = focus Z")
self._pad.jog.connect(self._jog_pad)
self._pad.z_active.connect(self._light_focus)
joycol.addWidget(self._pad, 0, Qt.AlignHCenter)
steprow = QHBoxLayout()
steprow.setSpacing(6)
steprow.addWidget(self._small_label("Step"))
self._step = self._step_spin()
steprow.addWidget(self._step)
joycol.addLayout(steprow)
padrow.addLayout(joycol)
# focus (Z) column — Q/E mirror these
foc = QVBoxLayout()
foc.setSpacing(5)
foc.addWidget(self._small_label("Focus (Z)"))
fplus = self._jog_btn("")
fminus = self._jog_btn("")
fplus.clicked.connect(lambda: self._jog(0, 0, 1))
fminus.clicked.connect(lambda: self._jog(0, 0, -1))
foc.addWidget(fplus)
foc.addWidget(fminus)
self._fplus = self._jog_btn(" Q")
self._fminus = self._jog_btn(" E")
self._fplus.clicked.connect(lambda: self._jog(0, 0, 1))
self._fminus.clicked.connect(lambda: self._jog(0, 0, -1))
foc.addWidget(self._fplus)
foc.addWidget(self._fminus)
foc.addStretch(1)
padrow.addLayout(foc)
lay.addLayout(padrow)
@@ -222,6 +223,23 @@ class MotorsPanel(QWidget):
)
return s
def _step_spin(self) -> QSpinBox:
"""Editable jog step (µm). Commits the typed value on Enter / focus-out."""
s = QSpinBox()
s.setRange(1, 5000)
s.setValue(DEFAULT_STEP_UM)
s.setSuffix(" µm")
s.setKeyboardTracking(False) # apply on Enter, not per keystroke
s.setButtonSymbols(QSpinBox.NoButtons)
s.setFixedWidth(78)
s.setToolTip("Type a jog step in µm and press Enter")
s.setStyleSheet(
f"QSpinBox {{ font-family:{FONT_MONO_FALLBACK}; font-size:12px;"
f" background:{self._p.surface}; border:1px solid {self._p.border_control};"
f" border-radius:6px; padding:3px 4px; }}"
)
return s
def _jog_btn(self, glyph: str) -> QPushButton:
b = QPushButton(glyph)
b.setCursor(Qt.PointingHandCursor)
@@ -267,14 +285,33 @@ class MotorsPanel(QWidget):
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 _step_mm(self) -> float:
return self._step.value() / 1000.0
def _jog(self, sx: int, sy: int, sz: int) -> None:
step_mm = JOG_STEPS_UM[self._jog_idx] / 1000.0
step_mm = self._step_mm()
self.smargon_jog.emit(sx * step_mm, sy * step_mm, sz * step_mm)
def _jog_pad(self, dx: float, dy: float, dz: float) -> None:
"""Joystick tick: jog X/Y/Z by the current step, scaled by direction."""
step_mm = self._step_mm()
self.smargon_jog.emit(dx * step_mm, dy * step_mm, dz * step_mm)
def _light_focus(self, z: int) -> None:
"""Highlight the matching focus button while Q/E is held (visual feedback)."""
p = self._p
for btn, on in ((self._fplus, z > 0), (self._fminus, z < 0)):
if on:
btn.setStyleSheet(
f"QPushButton {{ background:{p.accent}; color:{p.accent_text};"
f" border:none; border-radius:6px; font-size:11px;"
f" font-weight:600; padding:6px 0; }}")
else:
btn.setStyleSheet(
f"QPushButton {{ background:{p.surface};"
f" border:1px solid {p.border_control}; border-radius:6px;"
f" font-size:11px; padding:6px 0; color:{p.text_secondary}; }}")
# -------- live updates --------
def update_daq_status(self, s) -> None:
geom = getattr(s, "geom", None)
+18 -33
View File
@@ -102,7 +102,6 @@ class ContextualSettings(QStackedWidget):
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
@@ -237,18 +236,21 @@ class ContextualSettings(QStackedWidget):
"%", 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)
# Grid is drawn directly on the camera now — right-drag to draw, drag to
# move, drag an edge to resize. This label just advertises the gesture.
self._draw_hint = QLabel("✏ Right-drag on camera to draw · drag to move · drag edge to resize")
self._draw_hint.setWordWrap(True)
self._draw_hint.setStyleSheet(
f"color:{self._p.text_secondary}; font-size:11px;"
f" border:1px dashed {self._p.border_control}; border-radius:8px; padding:7px 10px;"
)
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._draw_hint)
lay.addWidget(self._raster_run)
# --- completed-results controls (hidden until a grid completes) ---
@@ -457,29 +459,6 @@ class ContextualSettings(QStackedWidget):
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:{p.accent_text}; 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
@@ -641,14 +620,17 @@ class PipelinePanel(QWidget):
head.addWidget(self._badge)
head.addWidget(self._meta)
head.addStretch(1)
from aare.gui.new_gui.widgets.filename_builder import FilenameBuilder
self.filename = FilenameBuilder(palette)
head.addWidget(self.filename)
self._hint = QLabel("")
self._hint.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
head.addWidget(self._hint)
lay.addLayout(head)
# dataset path builder — own full-width row so the directory field and
# the expanded preview have room to breathe (clipped if wedged in head)
from aare.gui.new_gui.widgets.filename_builder import FilenameBuilder
self.filename = FilenameBuilder(palette)
lay.addWidget(self.filename)
# tracker
self.tracker = PipelineTracker(palette, breakpoints_enabled)
self.tracker.stage_clicked.connect(self.stage_clicked)
@@ -690,6 +672,9 @@ class PipelinePanel(QWidget):
self.transport.update_from_state(state)
self.settings.show_stage(state.stage)
sample = state.mount_sample
# Feed the mounted sample to the filename builder so its directory /
# {sample} prefix track the sample (no-op while the same one stays).
self.filename.set_sample(sample if self._mounted else None)
if self._mounted and sample is not None:
self._name.setText(getattr(sample, "sample_name", ""))
puck = getattr(sample, "puck_name", "") or ""
@@ -320,8 +320,10 @@ class SampleChangerPanel(QWidget):
if self._mount_btn.isEnabled():
css = f"background:{p.accent}; color:{p.accent_text}; border:none;"
else:
css = (f"background:{p.chip_off_bg}; color:{p.text_faint};"
f" border:none;")
# Match the panel's other secondary buttons (Skip / Manual sample)
# instead of a washed-out grey fill, which read as a failed mount.
css = (f"background:transparent; color:{p.text_secondary};"
f" border:1px solid {p.border_control};")
self._mount_btn.setStyleSheet(
f"QPushButton {{ {css} border-radius:8px; padding:9px 14px;"
f" font-size:12.5px; font-weight:600; }}"