From 02f33bd3fede85de35ad24882c21b5b4958d3ec7 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 25 Jun 2026 15:50:31 +0200 Subject: [PATCH] new_gui: four Catppuccin flavors + lavender, Mocha default, manual mount, automation beam-rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - theme.py: Latte/Frappe/Macchiato/Mocha registry; accent swapped mauve->lavender across all flavors; Mocha is the default (_saved_palette) - top bar: ◐ theme menu (4 flavors) replacing the binary toggle; theme_selected(str) - manual (unregistered) sample: AppState.manual_mount + ManualSample placeholder + 'Manual sample on gonio' changer button; unlocks the cockpit with no robot mount/unmount (skips mount_requested/unmount_requested) - automation Run-progress panel: vertical beam-path rail (green completed glow, lavender focal bloom on the running step) replacing the emoji list Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aare/gui/new_gui/README.md | 18 ++- src/aare/gui/new_gui/main_window.py | 20 +-- src/aare/gui/new_gui/manual_view.py | 1 + src/aare/gui/new_gui/state.py | 41 ++++- src/aare/gui/new_gui/theme.py | 137 ++++++++++------- src/aare/gui/new_gui/top_bar.py | 33 +++- .../new_gui/widgets/automation_progress.py | 145 +++++++++++++++--- .../gui/new_gui/widgets/sample_changer.py | 13 ++ 8 files changed, 304 insertions(+), 104 deletions(-) diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md index 1d4e302e..ad88cbf6 100644 --- a/src/aare/gui/new_gui/README.md +++ b/src/aare/gui/new_gui/README.md @@ -16,12 +16,18 @@ uv run python -m aare.gui.new_gui.app -u https://host -c /path/to.crt -p tcp://h `BEAMLINE` (X06DA / X10SA / X06SA / unset→SIMULATED) drives all config via `cfg_get`, exactly like the existing GUI. -**Theme:** Catppuccin — **Latte** (light) and **Mocha** (dark), toggled with the -☾/☀ button in the top bar (persisted to QSettings; rebuilds the views live while -keeping the backend connection). `theme.py` is the single source of truth; text -on coloured fills routes through `accent_text` so Mocha's light pastels stay -legible. The Mount→Center→Raster→XRF→Collect tracker is a **beam-path rail** — -the completed length glows green and the active station is a mauve focal bloom. +**Theme:** all four **Catppuccin** flavours — Latte (light), Frappé, Macchiato, +Mocha (dark, default) — chosen from the ◐ menu in the top bar (persisted to +QSettings; rebuilds the views live while keeping the backend connection). Accent +is **lavender**. `theme.py` is the single source of truth; text on coloured fills +routes through `accent_text` so dark flavours' light pastels stay legible. The +Mount→Center→Raster→XRF→Collect tracker — and the automation **Run progress** +panel — are **beam-path rails**: the completed length glows green and the active +station is a lavender focal bloom. + +**Manual sample:** if a sample is on the goniometer by hand (not in the DB), use +**⊕ Manual sample on gonio** in the changer footer — it unlocks the cockpit +without driving the robot (no `mount`/`unmount` calls). **Offline dev mode:** with no server (`base_url is None`, e.g. `BEAMLINE=SIMULATED` and no `-u`), `dev_seed.py` injects a few fake samples + a synthetic live status + diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py index 3f8054de..77faf9c5 100644 --- a/src/aare/gui/new_gui/main_window.py +++ b/src/aare/gui/new_gui/main_window.py @@ -23,7 +23,7 @@ from aare.gui.new_gui.automation_view import AutomationView from aare.gui.new_gui.manual_view import ManualView from aare.gui.new_gui.state import AppState from aare.gui.new_gui.status_bar import StatusBar -from aare.gui.new_gui.theme import DARK, LIGHT, build_qss +from aare.gui.new_gui.theme import DEFAULT_THEME, THEMES, build_qss from aare.gui.new_gui.top_bar import TopBar from aare.gui.new_gui.widgets.alert_banner import AlertBanner from aare.gui.new_gui.widgets import preconditions @@ -34,9 +34,10 @@ _BEAMLINE_SUBTITLE = {"X06DA": "PXIII", "X10SA": "PXII", "X06SA": "PXI"} def _saved_palette(): - """Initial theme from QSettings (defaults to Catppuccin Latte / light).""" + """Initial Catppuccin flavour from QSettings (defaults to Mocha).""" from PySide6.QtCore import QSettings - return DARK if QSettings("PSI", "AareGUI-new").value("theme") == "dark" else LIGHT + name = QSettings("PSI", "AareGUI-new").value("theme") + return THEMES.get(name, THEMES[DEFAULT_THEME]) def _collect_defaults() -> dict: @@ -143,12 +144,13 @@ class MainWindow(QWidget): self._root_layout.addWidget(self.status_bar) # ----------------------------------------------------------- theme switch - def _on_theme_toggle(self) -> None: + def _on_theme_selected(self, name: str) -> None: from PySide6.QtCore import QSettings - new = DARK if self._palette is LIGHT else LIGHT - QSettings("PSI", "AareGUI-new").setValue( - "theme", "dark" if new is DARK else "light") - self.apply_theme(new) + palette = THEMES.get(name) + if palette is None or palette is self._palette: + return + QSettings("PSI", "AareGUI-new").setValue("theme", name) + self.apply_theme(palette) def apply_theme(self, palette) -> None: self._palette = palette @@ -276,7 +278,7 @@ class MainWindow(QWidget): # mode toggle + theme switch self.top_bar.mode_changed.connect(s.set_mode) - self.top_bar.theme_toggle_requested.connect(self._on_theme_toggle) + self.top_bar.theme_selected.connect(self._on_theme_selected) # live status fan-out -> view widgets self.daq.update.connect(self.status_bar.update_daq_status) diff --git a/src/aare/gui/new_gui/manual_view.py b/src/aare/gui/new_gui/manual_view.py index 2f478ff8..bcc25ca5 100644 --- a/src/aare/gui/new_gui/manual_view.py +++ b/src/aare/gui/new_gui/manual_view.py @@ -137,6 +137,7 @@ class ManualView(QWidget): # changer / mount CTAs self.changer.sample_selected.connect(s.mount_sample_obj) + self.changer.manual_mount_requested.connect(s.manual_mount) self.camera.mount_clicked.connect(s.open_picker) # pipeline interactions diff --git a/src/aare/gui/new_gui/state.py b/src/aare/gui/new_gui/state.py index 2867a57e..0abc492a 100644 --- a/src/aare/gui/new_gui/state.py +++ b/src/aare/gui/new_gui/state.py @@ -51,6 +51,27 @@ def default_protocol() -> dict[str, bool]: return {"center": True, "raster": False, "xrf": False, "collect": True} +class ManualSample: + """A sample placed on the goniometer by hand — not in the database. + + Carries the few attributes the views read so the cockpit treats it like any + mounted sample, but it has no ``db_id`` so it never matches a changer row.""" + + db_id = None + sample_name = "Manual sample" + puck_name = "" + pin = 0 + location = None + aaredb_params = None + rotation_count = 0 + mount_count = 0 + raster_count = 0 + screening_count = 0 + + def loc_str(self) -> str: + return "on goniometer" + + @dataclass class QueueItem: """One queued sample with its per-sample protocol.""" @@ -123,18 +144,34 @@ class AppState(QObject): def mount_sample_obj(self, sample, reference: bool = False) -> None: """Select a sample to mount: store it, reset the pipeline, request mount.""" self._mount_sample = sample + self._manual_mount = False self._mount_phase = "mounted" self._reset_pipe() self.mount_changed.emit() self.pipe_changed.emit() self.mount_requested.emit(sample, reference) + def manual_mount(self, sample=None) -> None: + """Mark a sample physically placed on the goniometer (not in the DB). + + Unlocks the cockpit for testing/use without driving the robot — so it + does NOT emit ``mount_requested`` (no ``daq.mount``).""" + self._mount_sample = sample or ManualSample() + self._manual_mount = True + self._mount_phase = "mounted" + self._reset_pipe() + self.mount_changed.emit() + self.pipe_changed.emit() + def unmount(self) -> None: - """mounted -> empty: clear sample and request unmount.""" + """mounted -> empty: clear sample. Robot unmount only for DB samples.""" + manual = getattr(self, "_manual_mount", False) self._mount_sample = None + self._manual_mount = False self._mount_phase = "empty" self.mount_changed.emit() - self.unmount_requested.emit() + if not manual: + self.unmount_requested.emit() # -------------------------------------------------------------- pipeline @property diff --git a/src/aare/gui/new_gui/theme.py b/src/aare/gui/new_gui/theme.py index d1505e95..6fe09abf 100644 --- a/src/aare/gui/new_gui/theme.py +++ b/src/aare/gui/new_gui/theme.py @@ -48,66 +48,91 @@ class Palette: cam_stop2: str -# --- Catppuccin Latte (light) --------------------------------------------- -# https://catppuccin.com — accents are dark enough that white reads on them. -LIGHT = Palette( - app_bg="#e6e9ef", # mantle - panel_bg="#eff1f5", # base - header_bg="#e6e9ef", # mantle - surface="#ffffff", # cards/inputs (lift off base) - border_panel="#ccd0da", # surface0 - border_control="#bcc0cc", # surface1 - pending_track="#ccd0da", - pending_node="#acb0be", # surface2 - chip_off_bg="#dce0e8", # crust - text_primary="#4c4f69", # text - text_secondary="#5c5f77", # subtext1 - text_muted="#6c6f85", # subtext0 - text_faint="#8c8fa1", # overlay1 - accent="#8839ef", # mauve - accent_text="#ffffff", - accent_tint_bg="#f3e9fd", - accent_tint_border="#d2b3f7", - accent_ring="rgba(136,57,239,.28)", - success="#40a02b", # green - success_tint="#e4f1de", - breakpoint="#fe640b", # peach - danger="#d20f39", # red - cam_stop0="#313244", # camera is always dark (mocha surfaces) - cam_stop1="#1e1e2e", - cam_stop2="#11111b", +# --- Catppuccin — four flavours (https://catppuccin.com) ------------------- +# Accent is LAVENDER throughout. On light flavours white reads on the accent; +# on dark flavours the accent is a light pastel, so on-fill text is the base. + +# Latte (light) +LATTE = Palette( + app_bg="#e6e9ef", panel_bg="#eff1f5", header_bg="#e6e9ef", surface="#ffffff", + border_panel="#ccd0da", border_control="#bcc0cc", + pending_track="#ccd0da", pending_node="#acb0be", chip_off_bg="#dce0e8", + text_primary="#4c4f69", text_secondary="#5c5f77", + text_muted="#6c6f85", text_faint="#8c8fa1", + accent="#7287fd", accent_text="#ffffff", # lavender + accent_tint_bg="#e9edfd", accent_tint_border="#bcc6fb", + accent_ring="rgba(114,135,253,.30)", + success="#40a02b", success_tint="#e4f1de", + breakpoint="#fe640b", danger="#d20f39", + cam_stop0="#313244", cam_stop1="#1e1e2e", cam_stop2="#11111b", ) -# --- Catppuccin Mocha (dark) ---------------------------------------------- -# Accents are LIGHT pastels -> foreground on a fill must be the dark base. -DARK = Palette( - app_bg="#1e1e2e", # base - panel_bg="#181825", # mantle - header_bg="#181825", # mantle - surface="#313244", # surface0 - border_panel="#313244", # surface0 - border_control="#45475a", # surface1 - pending_track="#45475a", - pending_node="#6c7086", # overlay0 - chip_off_bg="#313244", # surface0 - text_primary="#cdd6f4", # text - text_secondary="#bac2de", # subtext1 - text_muted="#a6adc8", # subtext0 - text_faint="#7f849c", # overlay1 - accent="#cba6f7", # mauve - accent_text="#1e1e2e", # base — dark text on light mauve - accent_tint_bg="#302d41", - accent_tint_border="#45437a", - accent_ring="rgba(203,166,247,.40)", - success="#a6e3a1", # green - success_tint="#293a2c", - breakpoint="#fab387", # peach - danger="#f38ba8", # red - cam_stop0="#313244", - cam_stop1="#1e1e2e", - cam_stop2="#11111b", +# Frappé (dark, warm) +FRAPPE = Palette( + app_bg="#303446", panel_bg="#292c3c", header_bg="#292c3c", surface="#414559", + border_panel="#414559", border_control="#51576d", + pending_track="#51576d", pending_node="#737994", chip_off_bg="#414559", + text_primary="#c6d0f5", text_secondary="#b5bfe2", + text_muted="#a5adce", text_faint="#838ba7", + accent="#babbf1", accent_text="#303446", # lavender + accent_tint_bg="#3a3d52", accent_tint_border="#5b5f8a", + accent_ring="rgba(186,187,241,.38)", + success="#a6d189", success_tint="#34402f", + breakpoint="#ef9f76", danger="#e78284", + cam_stop0="#414559", cam_stop1="#292c3c", cam_stop2="#232634", ) +# Macchiato (dark) +MACCHIATO = Palette( + app_bg="#24273a", panel_bg="#1e2030", header_bg="#1e2030", surface="#363a4f", + border_panel="#363a4f", border_control="#494d64", + pending_track="#494d64", pending_node="#6e738d", chip_off_bg="#363a4f", + text_primary="#cad3f5", text_secondary="#b8c0e0", + text_muted="#a5adcb", text_faint="#8087a2", + accent="#b7bdf8", accent_text="#24273a", # lavender + accent_tint_bg="#2f3349", accent_tint_border="#494f86", + accent_ring="rgba(183,189,248,.38)", + success="#a6da95", success_tint="#2c3a2b", + breakpoint="#f5a97f", danger="#ed8796", + cam_stop0="#363a4f", cam_stop1="#24273a", cam_stop2="#181926", +) + +# Mocha (dark) +MOCHA = Palette( + app_bg="#1e1e2e", panel_bg="#181825", header_bg="#181825", surface="#313244", + border_panel="#313244", border_control="#45475a", + pending_track="#45475a", pending_node="#6c7086", chip_off_bg="#313244", + text_primary="#cdd6f4", text_secondary="#bac2de", + text_muted="#a6adc8", text_faint="#7f849c", + accent="#b4befe", accent_text="#1e1e2e", # lavender + accent_tint_bg="#2b2b40", accent_tint_border="#3e4377", + accent_ring="rgba(180,190,254,.40)", + success="#a6e3a1", success_tint="#293a2c", + breakpoint="#fab387", danger="#f38ba8", + cam_stop0="#313244", cam_stop1="#1e1e2e", cam_stop2="#11111b", +) + +# Registry. Mocha is the default flavour. +THEMES = { + "latte": LATTE, "frappe": FRAPPE, "macchiato": MACCHIATO, "mocha": MOCHA, +} +THEME_LABELS = { + "latte": "Latte · light", "frappe": "Frappé · dark", + "macchiato": "Macchiato · dark", "mocha": "Mocha · dark", +} +DEFAULT_THEME = "mocha" + +# Back-compat aliases (older imports expect LIGHT / DARK). +LIGHT = LATTE +DARK = MOCHA + + +def theme_name(palette: Palette) -> str: + for name, pal in THEMES.items(): + if pal is palette: + return name + return DEFAULT_THEME + # --- Typography ----------------------------------------------------------- FONT_SANS = "IBM Plex Sans" diff --git a/src/aare/gui/new_gui/top_bar.py b/src/aare/gui/new_gui/top_bar.py index d00da2c8..c5b78370 100644 --- a/src/aare/gui/new_gui/top_bar.py +++ b/src/aare/gui/new_gui/top_bar.py @@ -7,11 +7,12 @@ from PySide6.QtWidgets import ( QButtonGroup, QHBoxLayout, QLabel, + QMenu, QPushButton, QWidget, ) -from aare.gui.new_gui.theme import LIGHT, TOPBAR_H, Palette +from aare.gui.new_gui.theme import THEME_LABELS, TOPBAR_H, Palette, theme_name class ModeToggle(QWidget): @@ -57,7 +58,7 @@ class TopBar(QWidget): release_baton = Signal() cancel_baton_request = Signal() tools_clicked = Signal() - theme_toggle_requested = Signal() + theme_selected = Signal(str) # catppuccin flavour key def __init__(self, palette: Palette, beamline_label: str, parent=None): super().__init__(parent) @@ -129,16 +130,32 @@ class TopBar(QWidget): self._tools_btn.setVisible(False) lay.addWidget(self._tools_btn) - # theme toggle (Latte <-> Mocha) - self._theme_btn = QPushButton("☾" if p is LIGHT else "☀") + # theme picker (four Catppuccin flavours) + self._theme_btn = QPushButton("◐") self._theme_btn.setCursor(Qt.PointingHandCursor) - self._theme_btn.setToolTip("Switch theme (light / dark)") - self._theme_btn.setFixedSize(34, 34) + self._theme_btn.setToolTip("Theme") + self._theme_btn.setFixedSize(44, 34) self._theme_btn.setStyleSheet( f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};" - f" border-radius:8px; font-size:15px; color:{p.text_secondary}; }}" + f" border-radius:8px; font-size:15px; color:{p.text_secondary};" + f" padding-right:4px; }}" + f" QPushButton::menu-indicator {{ width:8px; }}" ) - self._theme_btn.clicked.connect(self.theme_toggle_requested) + menu = QMenu(self._theme_btn) + menu.setStyleSheet( + f"QMenu {{ background:{p.surface}; color:{p.text_primary};" + f" border:1px solid {p.border_control}; border-radius:8px; padding:4px; }}" + f" QMenu::item {{ padding:6px 18px; border-radius:6px; }}" + f" QMenu::item:selected {{ background:{p.accent_tint_bg};" + f" color:{p.text_primary}; }}" + ) + current = theme_name(p) + for name, label in THEME_LABELS.items(): + act = menu.addAction(label) + act.setCheckable(True) + act.setChecked(name == current) + act.triggered.connect(lambda _=False, n=name: self.theme_selected.emit(n)) + self._theme_btn.setMenu(menu) lay.addWidget(self._theme_btn) # avatar diff --git a/src/aare/gui/new_gui/widgets/automation_progress.py b/src/aare/gui/new_gui/widgets/automation_progress.py index af26d83f..a3749886 100644 --- a/src/aare/gui/new_gui/widgets/automation_progress.py +++ b/src/aare/gui/new_gui/widgets/automation_progress.py @@ -1,15 +1,17 @@ """Live automation progress panel, driven by the automation_progress SSE stream. -Renders the per-sample workflow steps (Mount / Center / Raster / Collect / Final) -with status icons, plus live metrics (current sample, samples left, avg time, -ETA). Pause/Stop are GUI-side: the controller stops launching the next sample. +Renders the per-sample workflow as a vertical **beam-path rail** (mirroring the +Manual pipeline): completed steps glow green, the running step is a lavender +focal bloom, upcoming steps sit on the dim track. Plus live metrics (current +sample, samples left, avg time, ETA). Pause/Stop are GUI-side. """ from __future__ import annotations import time -from PySide6.QtCore import Qt +from PySide6.QtCore import QRect, Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPen from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget from aare.gui.new_gui.theme import Palette @@ -23,16 +25,115 @@ _STEP_LABELS = { "DATA_COLLECTION": "Collect", "data_collection": "Collect", "FINAL": "Final", "final": "Final", } -_STATUS_ICONS = { - "PENDING": "⚪", "RUNNING": "🔵", "SUCCESS": "✅", - "FAILED": "❌", "SKIPPED": "⏭️", "PAUSED": "⏸️", -} + +_ROW_H = 40 +_DOT_X = 13 +_DOT_R = 7 +_TEXT_X = 34 def _enum_key(v) -> str: return getattr(v, "name", None) or getattr(v, "value", None) or str(v) +class _StepRail(QWidget): + """Vertical beam-path of workflow steps. ``steps`` = [(title, status, detail)].""" + + def __init__(self, palette: Palette, parent=None): + super().__init__(parent) + self._p = palette + self._steps: list = [] + + def set_steps(self, steps: list) -> None: + self._steps = steps + self.setMinimumHeight(max(1, len(steps)) * _ROW_H) + self.update() + + def paintEvent(self, event): # noqa: N802 + if not self._steps: + return + p = self._p + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + for i, (title, status, detail) in enumerate(self._steps): + cy = i * _ROW_H + _ROW_H // 2 + # beam segment from the previous dot down to this one + if i > 0: + prev_done = self._steps[i - 1][1] == "SUCCESS" + top = (i - 1) * _ROW_H + _ROW_H // 2 + if prev_done: + glow = QColor(p.success) + glow.setAlpha(64) + painter.fillRect(QRect(_DOT_X - 3, top, 6, _ROW_H), glow) + painter.fillRect(QRect(_DOT_X - 1, top, 2, _ROW_H), QColor(p.success)) + else: + painter.fillRect(QRect(_DOT_X - 1, top, 2, _ROW_H), + QColor(p.pending_track)) + self._paint_dot(painter, cy, status) + self._paint_text(painter, cy, title, status, detail) + + def _paint_dot(self, painter: QPainter, cy: int, status: str) -> None: + p = self._p + dot = QRect(_DOT_X - _DOT_R, cy - _DOT_R, 2 * _DOT_R, 2 * _DOT_R) + glyph, glyph_color = "", p.accent_text + if status == "RUNNING": + for grow, alpha in ((3, 90), (6, 40), (10, 16)): + c = QColor(p.accent) + c.setAlpha(alpha) + painter.setBrush(Qt.NoBrush) + painter.setPen(QPen(c, 2)) + painter.drawEllipse(dot.adjusted(-grow, -grow, grow, grow)) + painter.setBrush(QColor(p.accent)) + painter.setPen(Qt.NoPen) + painter.drawEllipse(dot) + elif status == "SUCCESS": + painter.setBrush(QColor(p.success)) + painter.setPen(Qt.NoPen) + painter.drawEllipse(dot) + glyph = "✓" + elif status == "FAILED": + painter.setBrush(QColor(p.danger)) + painter.setPen(Qt.NoPen) + painter.drawEllipse(dot) + glyph = "✕" + elif status == "SKIPPED": + painter.setBrush(Qt.NoBrush) + painter.setPen(QPen(QColor(p.pending_node), 2, Qt.DashLine)) + painter.drawEllipse(dot) + else: # PENDING / PAUSED + painter.setBrush(Qt.NoBrush) + painter.setPen(QPen(QColor(p.pending_node), 2)) + painter.drawEllipse(dot) + if glyph: + painter.setPen(QColor(glyph_color)) + f = QFont() + f.setPixelSize(10) + f.setBold(True) + painter.setFont(f) + painter.drawText(dot, Qt.AlignCenter, glyph) + + def _paint_text(self, painter: QPainter, cy: int, title: str, status: str, + detail: str) -> None: + p = self._p + active = status in ("RUNNING", "SUCCESS") + painter.setPen(QColor(p.accent if status == "RUNNING" + else (p.text_primary if active else p.text_muted))) + f = QFont() + f.setPixelSize(12) + f.setBold(active) + painter.setFont(f) + ty = cy - (12 if detail else 7) + painter.drawText(QRect(_TEXT_X, ty, self.width() - _TEXT_X - 4, 15), + Qt.AlignVCenter | Qt.AlignLeft, title) + if detail: + painter.setPen(QColor(p.text_faint)) + df = QFont() + df.setPixelSize(10) + painter.setFont(df) + painter.drawText(QRect(_TEXT_X, cy + 2, self.width() - _TEXT_X - 4, 13), + Qt.AlignVCenter | Qt.AlignLeft, detail) + + class AutomationProgressPanel(QWidget): """Right-column panel showing live automation progress.""" @@ -49,21 +150,19 @@ class AutomationProgressPanel(QWidget): self._stats = QLabel("Idle — start the queue to run.") self._stats.setWordWrap(True) + self._stats.setTextFormat(Qt.RichText) self._stats.setStyleSheet(f"font-size:12px; color:{palette.text_muted};") lay.addWidget(self._stats) lay.addWidget(hline(palette)) - self._steps = QLabel("") - self._steps.setWordWrap(True) - self._steps.setTextFormat(Qt.RichText) - self._steps.setStyleSheet("font-size:12.5px;") - lay.addWidget(self._steps) + self._rail = _StepRail(palette) + lay.addWidget(self._rail) lay.addStretch(1) def set_idle(self) -> None: self._stats.setText("Idle — start the queue to run.") - self._steps.setText("") + self._rail.set_steps([]) def update_progress(self, progress) -> None: cur = getattr(progress, "current_sample_name", "") or "—" @@ -83,23 +182,24 @@ class AutomationProgressPanel(QWidget): lines.append("✅ Finished" if ok else "⛔ Stopped") self._stats.setText("
".join(lines)) - rows = [] + steps = [] for st in getattr(progress, "steps", []) or []: title = _STEP_LABELS.get(_enum_key(getattr(st, "step", "")), _enum_key(getattr(st, "step", ""))) - icon = _STATUS_ICONS.get(_enum_key(getattr(st, "status", "")), "•") - dur = "" + status = _enum_key(getattr(st, "status", "")).upper() + detail = "" started = getattr(st, "started_at", None) if started: end = getattr(st, "completed_at", None) or time.time() - dur = f" ({self._fmt(end - started)})" + detail = self._fmt(end - started) msg = getattr(st, "message", "") or "" - extra = f" — {msg}" if msg else "" + if msg: + detail = f"{detail} · {msg}" if detail else msg err = getattr(st, "error_code", None) if err: - extra += f"
Error: {err}" - rows.append(f"{icon} {title}{dur}{extra}") - self._steps.setText("
".join(rows)) + detail = f"{detail} · error {err}" if detail else f"error {err}" + steps.append((title, status, detail)) + self._rail.set_steps(steps) @staticmethod def _fmt(seconds: float) -> str: @@ -114,6 +214,5 @@ class AutomationProgressPanel(QWidget): @staticmethod def _eta(seconds: float) -> str: - # Avoid Date.now-style nondeterminism concerns: use local clock for display. t = time.localtime(time.time() + seconds) return time.strftime("%H:%M", t) diff --git a/src/aare/gui/new_gui/widgets/sample_changer.py b/src/aare/gui/new_gui/widgets/sample_changer.py index a9fce1d9..decebfdc 100644 --- a/src/aare/gui/new_gui/widgets/sample_changer.py +++ b/src/aare/gui/new_gui/widgets/sample_changer.py @@ -139,6 +139,7 @@ class SampleChangerPanel(QWidget): """ sample_selected = Signal(object, bool) # sample, reference + manual_mount_requested = Signal() # sample on the gonio, not in the DB def __init__(self, palette: Palette, parent=None): super().__init__(parent) @@ -227,6 +228,18 @@ class SampleChangerPanel(QWidget): self._mount_btn.clicked.connect(self._on_mount_btn) self._style_mount_btn() fl.addWidget(self._mount_btn) + # secondary: declare a sample already placed on the gonio by hand + self._manual_btn = _QPB("⊕ Manual sample on gonio") + self._manual_btn.setCursor(Qt.PointingHandCursor) + self._manual_btn.setToolTip( + "Use a sample you placed on the goniometer that isn't in the database") + self._manual_btn.setStyleSheet( + f"QPushButton {{ background:transparent; color:{palette.text_secondary};" + f" border:1px solid {palette.border_control}; border-radius:8px;" + f" padding:7px 14px; font-size:11.5px; }}" + ) + self._manual_btn.clicked.connect(self.manual_mount_requested) + fl.addWidget(self._manual_btn) outer.addWidget(footer) def _style_mount_btn(self) -> None: