new_gui: sample run-history mockup widget (6-LED stage strips)

widgets/sample_history.py: RunLEDStrip (per-run stage LEDs: ok/error/warn/skip,
+ compact per-row variant with grey=unknown) + _RunCard + SampleHistoryPanel
(set_history payload). Design for click->inline-expand under the row with a
compact LED summary per sample. Not wired (no backend run-history endpoint yet);
kept as a reviewable mockup. ROADMAP M2 notes the blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-26 11:22:11 +02:00
co-authored by Claude Opus 4.8
parent 293e28f463
commit 2f55e87f57
2 changed files with 195 additions and 0 deletions
+8
View File
@@ -74,6 +74,14 @@ Start→`automated_scan`).
### M. Analysis panels (nice-to-have) — MED
- Target-stability chart, prediction-metrics, smargon-trace (CSV/charts)
### M2. Sample run-history (mockup, pending backend) — MED
- `widgets/sample_history.py` (RunLEDStrip + SampleHistoryPanel) renders per-run
outcome as the 6-stage LED strip (✓ ok / ✕ error / ! warn / hollow skip), with
a compact per-row variant (grey=unknown · green/orange/red). Design approved;
plan: per-row compact LEDs + **inline expand** under the clicked sample.
BLOCKED on a backend run-history endpoint (server only exposes counts today),
so it's kept as a mockup — `set_history(name, summary, runs)` is ready to wire.
### N. UX / framework — MED
- Keyboard shortcuts (~18), layout persistence (QSettings), restore-default-layout
- Tutorials/guided help (F1), splash screen
@@ -0,0 +1,187 @@
"""Sample history — per-run outcome shown as a stage-LED strip.
Clicking a sample in the mount panel reveals its previous runs. Each run is a row
of stage LEDs (Mount · Center · Raster · XRF · Collect · Process) coloured by
outcome (green ✓ ok, red ✕ error, peach ! warning, hollow = not part of the run),
mirroring the workflow LED style. Errors are spelled out under the strip.
``set_history`` takes plain dicts so it can be fed from a backend history endpoint
later; for now the Manual view can derive a summary from SampleShortInfo counts.
"""
from __future__ import annotations
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from PySide6.QtWidgets import (
QLabel,
QScrollArea,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
STAGE_NAMES = ("Mount", "Center", "Raster", "XRF", "Collect", "Process")
# status -> (key for colour, glyph)
_GLYPH = {"ok": "", "error": "", "warn": "!", "skip": ""}
class RunLEDStrip(QWidget):
"""A single run rendered as a row of stage LEDs."""
def __init__(self, stages: list, palette: Palette, compact: bool = False,
parent=None):
super().__init__(parent)
self._p = palette
self._stages = stages # [(name, status)]
self._compact = compact # row summary: small dots, no labels
if compact:
self.setFixedSize(118, 16)
else:
self.setFixedHeight(52)
self.setMinimumWidth(330)
def _color(self, status: str) -> QColor:
p = self._p
return QColor({"ok": p.success, "error": p.danger,
"warn": p.breakpoint, "unknown": p.pending_node}
.get(status, p.pending_track))
def paintEvent(self, event): # noqa: N802
p = self._p
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
n = len(self._stages)
step_w = self.width() / n
if self._compact:
self._paint_compact(painter, n, step_w)
return
r = 9
cy = 26
f = QFont()
f.setPixelSize(9)
for i, (name, status) in enumerate(self._stages):
cx = step_w * i + step_w / 2
# connector to the next LED
if i < n - 1:
nxt = self._stages[i + 1][1]
done = status in ("ok", "warn") and nxt != "skip"
painter.setPen(QPen(QColor(p.success if done else p.pending_track), 2))
painter.drawLine(QPointF(cx + r + 2, cy),
QPointF(step_w * (i + 1) + step_w / 2 - r - 2, cy))
col = self._color(status)
painter.setPen(Qt.NoPen)
if status == "skip":
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QColor(p.pending_node), 2))
painter.drawEllipse(QPointF(cx, cy), r, r)
else:
painter.setBrush(col)
painter.drawEllipse(QPointF(cx, cy), r, r)
painter.setPen(QColor(p.accent_text))
painter.setFont(QFont(FONT_MONO_FALLBACK, 8, QFont.Bold))
painter.drawText(QRectF(cx - r, cy - r, 2 * r, 2 * r),
Qt.AlignCenter, _GLYPH.get(status, ""))
# label
painter.setPen(QColor(p.text_faint if status == "skip" else p.text_muted))
painter.setFont(f)
painter.drawText(QRectF(cx - step_w / 2, cy + r + 1, step_w, 13),
Qt.AlignHCenter | Qt.AlignTop, name)
def _paint_compact(self, painter: QPainter, n: int, step_w: float) -> None:
cy, r = self.height() / 2, 5
for i, (_name, status) in enumerate(self._stages):
cx = step_w * i + step_w / 2
painter.setPen(Qt.NoPen)
painter.setBrush(self._color(status))
painter.drawEllipse(QPointF(cx, cy), r, r)
class _RunCard(QWidget):
def __init__(self, run: dict, palette: Palette, parent=None):
super().__init__(parent)
p = palette
self.setAttribute(Qt.WA_StyledBackground, True)
err = bool(run.get("error"))
self.setStyleSheet(
f"_RunCard {{ background:{p.surface};"
f" border:1px solid {p.danger if err else p.border_panel};"
f" border-radius:10px; }}")
lay = QVBoxLayout(self)
lay.setContentsMargins(12, 9, 12, 9)
lay.setSpacing(4)
head = QLabel(
f"<b>{run.get('label','Run')}</b> · {run.get('type','')}"
f" &nbsp; <span style='color:{p.text_faint}'>{run.get('when','')}"
f"{(' · ' + run['res']) if run.get('res') else ''}</span>")
head.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:11.5px; color:{p.text_primary};")
lay.addWidget(head)
lay.addWidget(RunLEDStrip(run["stages"], palette))
if err:
e = QLabel(f"{run['error']}")
e.setWordWrap(True)
e.setStyleSheet(f"font-size:11px; color:{p.danger};")
lay.addWidget(e)
class SampleHistoryPanel(QWidget):
"""Header + scrollable list of run cards for one sample."""
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self.setObjectName("SidePanel")
self._p = palette
self.setMinimumWidth(360)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
head = QWidget()
head.setStyleSheet(f"border-bottom:1px solid {palette.border_panel};")
hl = QVBoxLayout(head)
hl.setContentsMargins(16, 14, 16, 12)
hl.setSpacing(2)
self._title = QLabel("Sample history")
self._title.setStyleSheet("font-size:15px; font-weight:700;")
hl.addWidget(self._title)
self._summary = QLabel("")
self._summary.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:11px; color:{palette.text_muted};")
hl.addWidget(self._summary)
outer.addWidget(head)
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QScrollArea.NoFrame)
self._list = QWidget()
self._list_lay = QVBoxLayout(self._list)
self._list_lay.setContentsMargins(12, 12, 12, 12)
self._list_lay.setSpacing(8)
self._list_lay.addStretch(1)
self._scroll.setWidget(self._list)
outer.addWidget(self._scroll, 1)
self._empty = QLabel("Select a sample to see its run history.")
self._empty.setAlignment(Qt.AlignCenter)
self._empty.setStyleSheet(f"color:{palette.text_faint}; font-size:12px;")
outer.addWidget(self._empty)
self._scroll.setVisible(False)
def set_history(self, name: str, summary: str, runs: list) -> None:
self._title.setText(name or "Sample history")
self._summary.setText(summary or "")
while self._list_lay.count() > 1:
w = self._list_lay.takeAt(0).widget()
if w:
w.setParent(None)
w.deleteLater()
for run in runs:
self._list_lay.insertWidget(self._list_lay.count() - 1,
_RunCard(run, self._p))
has = bool(runs)
self._scroll.setVisible(has)
self._empty.setVisible(not has)
if not has and name:
self._empty.setText(f"No runs recorded for {name} yet.")