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:
@@ -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 0–100% 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;")
|
||||
Reference in New Issue
Block a user