Add desktop GUI (M10): modern dark dashboard

PySide6/Qt front-end over the stdlib sensor core (only gui/ imports Qt).
- sidebar navigation + stacked pages (Dashboard live; Logs/Health/Inventory
  placeholders for M3-M5)
- live dashboard: circular gauges (GPU temp/load, CPU temp, memory) plus
  collapsible per-subsystem cards with progress bars and metric rows
- background sampling thread -> Qt signal so the UI stays responsive
- temperature colors: icey-blue (cold) -> green -> orange -> red (hot)
- dark theme via QSS + Fusion

Supporting changes:
- cpu source: order temps as package, then cores numerically (fixes CLI too)
- render: expose format_value/metric_label, shared by CLI and GUI
- cli: `rigdoctor gui` (lazy import; prints install hint if PySide6 missing)
- pyproject: rigdoctor-gui script + [gui] extra (PySide6)
- gitignore: *.egg-info/, build/, dist/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:53:32 +02:00
parent 305b6c4497
commit 2ccf7ca50c
14 changed files with 712 additions and 12 deletions
+221
View File
@@ -0,0 +1,221 @@
"""Reusable GUI widgets: collapsible card, circular gauge, metric bar/row."""
from __future__ import annotations
from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..core.sample import Reading
from ..render import format_value
from .theme import MUTED, TEXT, TRACK, gauge_color, temp_color
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 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;")