Files
rigdoctor/src/rigdoctor/gui/widgets.py
T
jessey 8d695227bc feat(gui): dashboard history graphs for headline metrics — 0.14.0
Replace the four headline gauges (GPU temp, GPU load, CPU temp, memory) with
HistoryGraph trend tiles: each plots its session history with the current value,
window min/max, a dashed warn-threshold line, and a kind-colored line (temp band
/ usage / accent). QPainter-drawn, no new dependency. Seeing changes over time is
more useful than the live-only snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:45:20 +02:00

447 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reusable GUI widgets: collapsible card, circular gauge, metric bar/row."""
from __future__ import annotations
from collections import deque
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..core.sample import Reading
from ..render import format_value
from .theme import (
ACCENT,
CRIT,
GOOD,
MUTED,
TEMP_WARN,
TEXT,
TRACK,
USAGE_WARN,
WARN,
gauge_color,
temp_color,
)
_SEV = {
"critical": ("CRITICAL", CRIT),
"warning": ("WARNING", WARN),
"info": ("INFO", MUTED),
"ok": ("OK", GOOD),
}
def finding_card(finding, on_install=None, on_apply=None) -> QFrame:
"""A card for one M4/M6 Finding (severity-colored title, detail, suggested fix).
If the finding names an installable catalog component (``finding.action``) and an
``on_install(component)`` callback is given, an "Install" button is shown — so a
"tool not installed" finding becomes one click instead of a copy-pasted apt command.
If the finding names a runtime tunable (``finding.fix``) and an ``on_apply(fix_id,
value)`` callback is given, a dropdown of the live options + an Apply button is shown
(M6 live fixes — D22).
"""
label, color = _SEV.get(finding.severity, ("?", MUTED))
card = QFrame()
card.setObjectName("Card")
v = QVBoxLayout(card)
v.setContentsMargins(16, 12, 16, 12)
v.setSpacing(4)
head = QLabel(f"{label} · {finding.category}: {finding.title}")
head.setStyleSheet(f"color: {color}; font-weight: 700; background: transparent;")
head.setWordWrap(True)
v.addWidget(head)
if finding.detail:
detail = QLabel(finding.detail)
detail.setObjectName("Muted")
detail.setWordWrap(True)
v.addWidget(detail)
if finding.suggestion:
suggestion = QLabel(f"{finding.suggestion}")
suggestion.setStyleSheet(f"color: {ACCENT}; background: transparent;")
suggestion.setWordWrap(True)
v.addWidget(suggestion)
component = _installable_component(finding) if on_install else None
if component is not None:
row = QHBoxLayout()
row.addStretch(1)
btn = QPushButton(f"Install {component.name}")
btn.setObjectName("ActionButton")
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.clicked.connect(lambda: on_install(component))
row.addWidget(btn)
v.addLayout(row)
tunable = _tunable(finding) if on_apply else None
if tunable is not None and tunable.options:
row = QHBoxLayout()
name = QLabel(f"{tunable.label}:")
name.setObjectName("Muted")
combo = QComboBox()
combo.addItems(tunable.options)
if tunable.current in tunable.options:
combo.setCurrentText(tunable.current)
combo.setCursor(Qt.CursorShape.PointingHandCursor)
apply_btn = QPushButton("Apply")
apply_btn.setObjectName("ActionButton")
apply_btn.setCursor(Qt.CursorShape.PointingHandCursor)
apply_btn.clicked.connect(lambda: on_apply(tunable.id, combo.currentText()))
row.addWidget(name)
row.addWidget(combo, 1)
row.addWidget(apply_btn)
v.addLayout(row)
if tunable.note:
note = QLabel(tunable.note)
note.setObjectName("Muted")
v.addWidget(note)
return card
def _tunable(finding):
"""The runtime tunable a finding can apply, if any."""
fix = getattr(finding, "fix", "")
if not fix:
return None
from ..core import fixes
return fixes.get_tunable(fix)
def _installable_component(finding):
"""The catalog component a finding offers to install, if any and if apt is usable."""
action = getattr(finding, "action", "")
if not action:
return None
from ..core import catalog, sysenv
if sysenv.package_manager() != "apt":
return None # apt-only (D15) — no one-click install elsewhere
return catalog.by_id(action)
class Card(QFrame):
"""A titled panel whose body collapses when the header is clicked."""
def __init__(self, title: str, subtitle: str | None = None) -> None:
super().__init__()
self.setObjectName("Card")
self._title = title
self._open = True
outer = QVBoxLayout(self)
outer.setContentsMargins(16, 12, 16, 12)
outer.setSpacing(8)
self._header = QPushButton()
self._header.setObjectName("CardHeader")
self._header.setFlat(True)
self._header.setCheckable(True)
self._header.setChecked(True)
self._header.setCursor(Qt.CursorShape.PointingHandCursor)
self._header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._header.clicked.connect(self._toggle)
outer.addWidget(self._header)
self._body = QWidget()
self._body_layout = QVBoxLayout(self._body)
self._body_layout.setContentsMargins(0, 2, 0, 0)
self._body_layout.setSpacing(7)
if subtitle:
sub = QLabel(subtitle)
sub.setObjectName("Muted")
self._body_layout.addWidget(sub)
outer.addWidget(self._body)
self._refresh_header()
def add(self, widget: QWidget) -> None:
self._body_layout.addWidget(widget)
def _toggle(self) -> None:
self._open = self._header.isChecked()
self._body.setVisible(self._open)
self._refresh_header()
def _refresh_header(self) -> None:
chevron = "" if self._open else ""
self._header.setText(f"{chevron} {self._title}")
class StatGauge(QWidget):
"""A circular gauge for a single headline metric."""
def __init__(self, title: str, unit: str = "", vmax: float = 100.0, kind: str = "accent") -> None:
super().__init__()
self._title = title
self._unit = unit
self._max = vmax
self._kind = kind # "temp" | "usage" | "accent"
self._value: float | None = None
self.setMinimumSize(118, 140)
def set_value(self, value: float | None) -> None:
self._value = value
self.update()
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
title_h = 20
side = min(w, h - title_h)
if side < 20:
p.end()
return
pen_w = max(7.0, side * 0.11)
inset = pen_w / 2 + 1
x = (w - side) / 2.0
arc = QRectF(x + inset, inset, side - 2 * inset, side - 2 * inset)
start = 225 * 16
full = -270 * 16
color = QColor(gauge_color(self._kind, self._value))
pen = QPen(QColor(TRACK))
pen.setWidthF(pen_w)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
p.setPen(pen)
p.drawArc(arc, start, full)
frac = 0.0 if self._value is None else max(0.0, min(1.0, self._value / self._max))
if frac > 0:
pen.setColor(color)
p.setPen(pen)
p.drawArc(arc, start, int(full * frac))
# Value (colored to match the arc)
value_text = "" if self._value is None else f"{self._value:.0f}"
fval = QFont()
fval.setPointSizeF(max(12.0, side * 0.21))
fval.setBold(True)
p.setFont(fval)
p.setPen(color if self._value is not None else QColor(MUTED))
p.drawText(QRectF(x, side * 0.20, side, side * 0.42), Qt.AlignmentFlag.AlignCenter, value_text)
# Unit
if self._unit and self._value is not None:
funit = QFont()
funit.setPointSizeF(max(8.0, side * 0.095))
p.setFont(funit)
p.setPen(QColor(MUTED))
p.drawText(
QRectF(x, side * 0.56, side, side * 0.18),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop,
self._unit,
)
# Title under the arc
ftitle = QFont()
ftitle.setPointSizeF(10.0)
ftitle.setBold(True)
p.setFont(ftitle)
p.setPen(QColor(MUTED))
p.drawText(
QRectF(0, side - 2, w, title_h + 4),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
self._title,
)
p.end()
class HistoryGraph(QWidget):
"""A headline metric as a trend: current value + window min/max + a history line.
Replaces the at-a-glance gauge with changes-over-time. `kind` drives the color
(temp band / usage / accent), matching StatGauge so the dashboard stays consistent.
"""
def __init__(self, title: str, unit: str = "", vmin: float = 0.0, vmax: float = 100.0,
kind: str = "accent", history: int = 180) -> None:
super().__init__()
self._title = title
self._unit = unit
self._min = vmin
self._max = vmax
self._kind = kind # "temp" | "usage" | "accent"
self._values: deque[float | None] = deque(maxlen=history)
self.setMinimumSize(160, 132)
def add_value(self, value: float | None) -> None:
self._values.append(value)
self.update()
def _fmt(self, value: float | None) -> str:
if value is None:
return ""
if self._unit == "°C":
return f"{value:.0f}°"
if self._unit == "%":
return f"{value:.0f}%"
return f"{value:.0f}{self._unit}"
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
pad = 10.0
present = [v for v in self._values if v is not None]
current = next((v for v in reversed(self._values) if v is not None), None)
color = QColor(gauge_color(self._kind, current))
ftitle = QFont()
ftitle.setPointSizeF(10.0)
ftitle.setBold(True)
p.setFont(ftitle)
p.setPen(QColor(MUTED))
p.drawText(QRectF(pad, 6, w - 2 * pad, 18),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._title)
fval = QFont()
fval.setPointSizeF(21.0)
fval.setBold(True)
p.setFont(fval)
p.setPen(color if current is not None else QColor(MUTED))
p.drawText(QRectF(pad, 2, w - 2 * pad, 28),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, self._fmt(current))
if present:
fsm = QFont()
fsm.setPointSizeF(8.5)
p.setFont(fsm)
p.setPen(QColor(MUTED))
p.drawText(QRectF(pad, 27, w - 2 * pad, 14), Qt.AlignmentFlag.AlignLeft,
f"min {self._fmt(min(present))} max {self._fmt(max(present))}")
g_top, g_bot = 48.0, h - pad
g_left, g_right = pad, w - pad
span = self._max - self._min
if g_bot - g_top < 12 or g_right - g_left < 12 or span <= 0:
p.end()
return
def y_of(v: float) -> float:
frac = (max(self._min, min(self._max, v)) - self._min) / span
return g_bot - frac * (g_bot - g_top)
warn = TEMP_WARN if self._kind == "temp" else (USAGE_WARN if self._kind == "usage" else None)
if warn is not None and self._min <= warn <= self._max:
pen = QPen(QColor(TRACK))
pen.setWidthF(1.0)
pen.setStyle(Qt.PenStyle.DashLine)
p.setPen(pen)
yw = y_of(warn)
p.drawLine(QPointF(g_left, yw), QPointF(g_right, yw))
maxlen = self._values.maxlen or 1
step = (g_right - g_left) / max(1, maxlen - 1)
n = len(self._values)
# Build the line newest-at-right; break it where readings are missing.
path = QPainterPath()
drawing = False
for i, v in enumerate(self._values):
if v is None:
drawing = False
continue
x = g_right - (n - 1 - i) * step
y = y_of(v)
if drawing:
path.lineTo(x, y)
else:
path.moveTo(x, y)
drawing = True
if not path.isEmpty():
pen = QPen(color)
pen.setWidthF(2.0)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
p.setPen(pen)
p.drawPath(path)
p.end()
class MetricBar(QWidget):
"""A label + value with a thin progress bar (for 0100% metrics)."""
def __init__(self, name: str, mode: str = "load") -> None:
super().__init__()
self._name = name
self._mode = mode # "load" (accent) | "usage" (threshold-colored)
self._text = "N/A"
self._frac = 0.0
self._color = MUTED
self.setMinimumHeight(36)
def set_reading(self, r: Reading) -> None:
self._text = format_value(r)
if r.value is None:
self._frac = 0.0
self._color = MUTED
else:
self._frac = max(0.0, min(1.0, r.value / 100.0))
self._color = gauge_color("usage" if self._mode == "usage" else "accent", r.value)
self.update()
def paintEvent(self, event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w = self.width()
fname = QFont()
fname.setPointSizeF(10.5)
p.setFont(fname)
p.setPen(QColor(MUTED))
p.drawText(QRectF(0, 0, w * 0.6, 18), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._name)
fval = QFont()
fval.setPointSizeF(11.0)
fval.setBold(True)
p.setFont(fval)
p.setPen(QColor(self._color))
p.drawText(QRectF(w * 0.4, 0, w * 0.6, 18), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._text)
bar_y, bar_h = 23.0, 6.0
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(TRACK))
p.drawRoundedRect(QRectF(0, bar_y, w, bar_h), 3, 3)
if self._frac > 0:
p.setBrush(QColor(self._color))
p.drawRoundedRect(QRectF(0, bar_y, w * self._frac, bar_h), 3, 3)
p.end()
class MetricRow(QWidget):
"""A simple label/value row; values in °C are colored by temperature."""
def __init__(self, name: str) -> None:
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
name_label = QLabel(name)
name_label.setObjectName("Muted")
self._value = QLabel("N/A")
self._value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(name_label)
layout.addStretch(1)
layout.addWidget(self._value)
def set_reading(self, r: Reading) -> None:
self._value.setText(format_value(r))
if r.unit == "°C" and r.value is not None:
self._value.setStyleSheet(f"color: {temp_color(r.value)}; font-weight: 600; background: transparent;")
else:
self._value.setStyleSheet(f"color: {TEXT}; font-weight: 600; background: transparent;")