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:
co-authored by
Claude Opus 4.8
parent
293e28f463
commit
2f55e87f57
@@ -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" <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.")
|
||||
Reference in New Issue
Block a user