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:
co-authored by
Claude Opus 4.8
parent
f688ca5319
commit
0f8340d1c3
@@ -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)
|
||||
|
||||
@@ -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"Can’t {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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {{
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }}"
|
||||
|
||||
Reference in New Issue
Block a user