From 2f55e87f57419bd349759490d42c7513b8e42a67 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 11:22:11 +0200 Subject: [PATCH] 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) --- src/aare/gui/new_gui/ROADMAP.md | 8 + .../gui/new_gui/widgets/sample_history.py | 187 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/aare/gui/new_gui/widgets/sample_history.py diff --git a/src/aare/gui/new_gui/ROADMAP.md b/src/aare/gui/new_gui/ROADMAP.md index aaada39f..a95c4e6a 100644 --- a/src/aare/gui/new_gui/ROADMAP.md +++ b/src/aare/gui/new_gui/ROADMAP.md @@ -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 diff --git a/src/aare/gui/new_gui/widgets/sample_history.py b/src/aare/gui/new_gui/widgets/sample_history.py new file mode 100644 index 00000000..cd22cc8c --- /dev/null +++ b/src/aare/gui/new_gui/widgets/sample_history.py @@ -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"{run.get('label','Run')} · {run.get('type','')}" + f"   {run.get('when','')}" + f"{(' · ' + run['res']) if run.get('res') else ''}") + 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.")