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: