new_gui: four Catppuccin flavors + lavender, Mocha default, manual mount, automation beam-rail
- 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ebd60d4c5d
commit
02f33bd3fe
@@ -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 +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<b>✅ Finished</b>" if ok else "<b>⛔ Stopped</b>")
|
||||
self._stats.setText("<br/>".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"<br/><small>Error: {err}</small>"
|
||||
rows.append(f"{icon} <b>{title}</b>{dur}{extra}")
|
||||
self._steps.setText("<br/>".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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user