diff --git a/src/aare/gui/new_gui/automation_view.py b/src/aare/gui/new_gui/automation_view.py index 06579289..738384d8 100644 --- a/src/aare/gui/new_gui/automation_view.py +++ b/src/aare/gui/new_gui/automation_view.py @@ -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) diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py index a8b678b6..fa02def8 100644 --- a/src/aare/gui/new_gui/main_window.py +++ b/src/aare/gui/new_gui/main_window.py @@ -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 diff --git a/src/aare/gui/new_gui/manual_view.py b/src/aare/gui/new_gui/manual_view.py index 3642f0d5..9ef3e07d 100644 --- a/src/aare/gui/new_gui/manual_view.py +++ b/src/aare/gui/new_gui/manual_view.py @@ -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 diff --git a/src/aare/gui/new_gui/staff_view.py b/src/aare/gui/new_gui/staff_view.py index 51ac5d75..d7b50f98 100644 --- a/src/aare/gui/new_gui/staff_view.py +++ b/src/aare/gui/new_gui/staff_view.py @@ -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 diff --git a/src/aare/gui/new_gui/state.py b/src/aare/gui/new_gui/state.py index c0641e6f..658e66e2 100644 --- a/src/aare/gui/new_gui/state.py +++ b/src/aare/gui/new_gui/state.py @@ -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: diff --git a/src/aare/gui/new_gui/theme.py b/src/aare/gui/new_gui/theme.py index 05a5ea34..2b8c2104 100644 --- a/src/aare/gui/new_gui/theme.py +++ b/src/aare/gui/new_gui/theme.py @@ -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 {{ diff --git a/src/aare/gui/new_gui/top_bar.py b/src/aare/gui/new_gui/top_bar.py index c5b78370..98062938 100644 --- a/src/aare/gui/new_gui/top_bar.py +++ b/src/aare/gui/new_gui/top_bar.py @@ -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) diff --git a/src/aare/gui/new_gui/widgets/alert_banner.py b/src/aare/gui/new_gui/widgets/alert_banner.py index 4c897143..572b7882 100644 --- a/src/aare/gui/new_gui/widgets/alert_banner.py +++ b/src/aare/gui/new_gui/widgets/alert_banner.py @@ -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() diff --git a/src/aare/gui/new_gui/widgets/busy_overlay.py b/src/aare/gui/new_gui/widgets/busy_overlay.py new file mode 100644 index 00000000..a3991a49 --- /dev/null +++ b/src/aare/gui/new_gui/widgets/busy_overlay.py @@ -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) diff --git a/src/aare/gui/new_gui/widgets/camera.py b/src/aare/gui/new_gui/widgets/camera.py index cbd83443..c2ff35f0 100644 --- a/src/aare/gui/new_gui/widgets/camera.py +++ b/src/aare/gui/new_gui/widgets/camera.py @@ -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: diff --git a/src/aare/gui/new_gui/widgets/filename_builder.py b/src/aare/gui/new_gui/widgets/filename_builder.py index 692a83ca..49f08402 100644 --- a/src/aare/gui/new_gui/widgets/filename_builder.py +++ b/src/aare/gui/new_gui/widgets/filename_builder.py @@ -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/_001`` (or -``screening/_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 ``//_NNN``; +the backend (jfjoch) prepends ``/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 ``//_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) diff --git a/src/aare/gui/new_gui/widgets/jog_pad.py b/src/aare/gui/new_gui/widgets/jog_pad.py new file mode 100644 index 00000000..9e064f1d --- /dev/null +++ b/src/aare/gui/new_gui/widgets/jog_pad.py @@ -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() diff --git a/src/aare/gui/new_gui/widgets/motors_panel.py b/src/aare/gui/new_gui/widgets/motors_panel.py index 03acdbac..98cb6140 100644 --- a/src/aare/gui/new_gui/widgets/motors_panel.py +++ b/src/aare/gui/new_gui/widgets/motors_panel.py @@ -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) diff --git a/src/aare/gui/new_gui/widgets/pipeline_panel.py b/src/aare/gui/new_gui/widgets/pipeline_panel.py index 3f6bc1a9..a3a1c98d 100644 --- a/src/aare/gui/new_gui/widgets/pipeline_panel.py +++ b/src/aare/gui/new_gui/widgets/pipeline_panel.py @@ -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 "" diff --git a/src/aare/gui/new_gui/widgets/sample_changer.py b/src/aare/gui/new_gui/widgets/sample_changer.py index cce96fdb..62619d18 100644 --- a/src/aare/gui/new_gui/widgets/sample_changer.py +++ b/src/aare/gui/new_gui/widgets/sample_changer.py @@ -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; }}"