diff --git a/.gitignore b/.gitignore
index 0b5fef3..db49beb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,9 @@ __pycache__/
*.py[cod]
.venv/
venv/
+*.egg-info/
+build/
+dist/
# RigDoctor runtime output
logs/
diff --git a/README.md b/README.md
index 85b93aa..4a9f710 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,21 @@ PYTHONPATH=src python3 -m rigdoctor sources # list detected sensor sources
PYTHONPATH=src python3 -m unittest discover -s tests
```
-Or `pip install -e .` to get a `rigdoctor` command on your PATH.
+### Desktop GUI (M10)
+
+The GUI uses PySide6 (Qt) — the only part of RigDoctor that needs a non-stdlib dep:
+
+```bash
+pip install -e '.[gui]' # core + PySide6, gives `rigdoctor` and `rigdoctor-gui`
+rigdoctor gui # or: rigdoctor-gui
+```
+
+It opens a dark-themed window with sidebar navigation and a **live dashboard** over the
+same sensor core — circular gauges for the headline metrics plus collapsible per-subsystem
+cards (GPU/CPU/memory/storage) with temperature-colored values (icey-blue → green → red).
+The Logs / Health / Inventory sections are placeholders until M3–M5 land.
+
+Without the GUI extra, `pip install -e .` gives just the stdlib-only CLI.
## Start here
diff --git a/docs/MODULES.md b/docs/MODULES.md
index 430eede..2f86cb7 100644
--- a/docs/MODULES.md
+++ b/docs/MODULES.md
@@ -15,7 +15,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ⬜ |
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ⬜ |
| M6 | Gaming env checks | Diagnostics | none | all | P2 | ⬜ |
-| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ⬜ |
+| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 |
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ⬜ |
| M9 | Installer | (meta) | none | all | P1 | ⬜ |
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
@@ -35,7 +35,10 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
suggests the fix command but does not apply it (D9).
- **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11).
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine (dashboard, log
- browser, report viewer, logger controls). Optional; adds the Qt dependency.
+ browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped
+ early (ahead of its Phase 4 slot) at the user's request:* dark-themed window with sidebar
+ nav and a live dashboard (circular gauges + collapsible per-subsystem cards, temperature-
+ colored values); Logs/Health/Inventory are placeholders until M3–M5.
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. Dropdown shows live M1 readouts
(CPU temp, GPU temp, memory used/total, status dot) and is led by a **Run Diagnostic**
action (the guided diagnostic session), plus Open dashboard / Start-Stop recording /
diff --git a/pyproject.toml b/pyproject.toml
index 8be7c81..7d20eba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ gui = ["PySide6"]
[project.scripts]
rigdoctor = "rigdoctor.cli:main"
+rigdoctor-gui = "rigdoctor.gui.app:main"
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py
index 03d13f6..fc73040 100644
--- a/src/rigdoctor/cli.py
+++ b/src/rigdoctor/cli.py
@@ -52,6 +52,18 @@ def cmd_monitor(args) -> int:
return 0
+def cmd_gui(args) -> int:
+ try:
+ from .gui.app import main as gui_main
+ except ImportError as exc:
+ print("The GUI needs PySide6, which isn't installed.")
+ print(" Install it with: pip install 'rigdoctor[gui]'")
+ print(" or on Ubuntu: sudo apt install python3-pyside6")
+ print(f" ({exc})")
+ return 2
+ return gui_main([sys.argv[0]])
+
+
def cmd_record(args) -> int:
print("`record` (M3 crash-capture logger) is not implemented yet — next on the roadmap.")
return 2
@@ -78,6 +90,7 @@ def build_parser() -> argparse.ArgumentParser:
mp.add_argument("-n", "--interval", type=float, default=None, help="refresh interval (s)")
mp.set_defaults(func=cmd_monitor)
+ sub.add_parser("gui", help="launch the desktop GUI (needs PySide6)").set_defaults(func=cmd_gui)
sub.add_parser("sources", help="list detected sensor sources").set_defaults(func=cmd_sources)
sub.add_parser("record", help="crash-capture logger (coming soon)").set_defaults(func=cmd_record)
sub.add_parser("report", help="health report (coming soon)").set_defaults(func=cmd_report)
diff --git a/src/rigdoctor/core/sources/cpu.py b/src/rigdoctor/core/sources/cpu.py
index ab6be90..32c1c10 100644
--- a/src/rigdoctor/core/sources/cpu.py
+++ b/src/rigdoctor/core/sources/cpu.py
@@ -3,12 +3,24 @@
from __future__ import annotations
import os
+import re
from ..hwmon import find_by_name, read_temps
from ..sample import Reading
from .base import Source
+def _temp_sort_key(label: str) -> tuple[int, int]:
+ """Order temps: package first, then cores by ascending number, others last."""
+ low = label.lower()
+ if low.startswith("package") or "tctl" in low or "tdie" in low:
+ return (0, 0)
+ match = re.search(r"\d+", label)
+ if low.startswith("core") and match:
+ return (1, int(match.group()))
+ return (2, 0)
+
+
class CpuSource(Source):
name = "cpu"
@@ -16,17 +28,19 @@ class CpuSource(Source):
# Intel exposes 'coretemp'; AMD exposes 'k10temp'.
return find_by_name("coretemp") or find_by_name("k10temp")
- def probe(self) -> bool:
- return bool(self._hwmons())
-
def read(self) -> list[Reading]:
- readings: list[Reading] = []
+ temps: list[tuple[str, float]] = []
for d in self._hwmons():
- for label, celsius in read_temps(d):
- readings.append(Reading("cpu", "temp", round(celsius, 1), "°C", label))
+ temps.extend(read_temps(d))
+ temps.sort(key=lambda t: _temp_sort_key(t[0]))
+
+ readings = [Reading("cpu", "temp", round(c, 1), "°C", label) for label, c in temps]
try:
load1 = os.getloadavg()[0]
readings.append(Reading("cpu", "load", round(load1, 2), "", "loadavg-1m"))
except (OSError, AttributeError):
pass
return readings
+
+ def probe(self) -> bool:
+ return bool(self._hwmons())
diff --git a/src/rigdoctor/gui/__init__.py b/src/rigdoctor/gui/__init__.py
new file mode 100644
index 0000000..4d74a63
--- /dev/null
+++ b/src/rigdoctor/gui/__init__.py
@@ -0,0 +1,6 @@
+"""RigDoctor desktop GUI (M10) — PySide6/Qt.
+
+This is the only part of RigDoctor that depends on Qt; the core engine, CLI, and
+daemon stay stdlib-only (D2). Import this package lazily so a headless install
+never needs PySide6.
+"""
diff --git a/src/rigdoctor/gui/app.py b/src/rigdoctor/gui/app.py
new file mode 100644
index 0000000..2ddcf77
--- /dev/null
+++ b/src/rigdoctor/gui/app.py
@@ -0,0 +1,28 @@
+"""GUI entry point."""
+
+from __future__ import annotations
+
+import sys
+
+from PySide6.QtWidgets import QApplication
+
+from ..config import load_config
+from .main_window import MainWindow
+from .theme import STYLESHEET
+
+
+def main(argv: list[str] | None = None) -> int:
+ app = QApplication(argv if argv is not None else sys.argv)
+ app.setApplicationName("RigDoctor")
+ app.setApplicationDisplayName("RigDoctor")
+ app.setStyle("Fusion")
+ app.setStyleSheet(STYLESHEET)
+
+ interval = float(load_config().get("interval", 1.0))
+ window = MainWindow(interval=interval)
+ window.show()
+ return app.exec()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/rigdoctor/gui/dashboard.py b/src/rigdoctor/gui/dashboard.py
new file mode 100644
index 0000000..764409f
--- /dev/null
+++ b/src/rigdoctor/gui/dashboard.py
@@ -0,0 +1,145 @@
+"""Live sensor dashboard: headline gauges + collapsible per-subsystem cards."""
+
+from __future__ import annotations
+
+import time
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QFrame,
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
+
+from ..core.sample import Sample
+from ..render import metric_label
+from .widgets import Card, MetricBar, MetricRow, StatGauge
+
+_GROUP_ORDER = ["gpu", "cpu", "memory", "storage"]
+_GROUP_TITLES = {"gpu": "GPU", "cpu": "CPU", "memory": "Memory", "storage": "Storage"}
+_BAR_METRICS = {"util", "mem_util", "fan", "used_pct"}
+
+
+def _gauge_card(gauge: StatGauge) -> QFrame:
+ card = QFrame()
+ card.setObjectName("Card")
+ layout = QVBoxLayout(card)
+ layout.setContentsMargins(6, 14, 6, 8)
+ layout.addWidget(gauge)
+ return card
+
+
+class Dashboard(QWidget):
+ def __init__(self) -> None:
+ super().__init__()
+ self.setObjectName("Page")
+ self._metric_widgets: dict[str, MetricBar | MetricRow] = {}
+ self._built_keys: list[str] | None = None
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(20, 18, 20, 18)
+ root.setSpacing(16)
+
+ header = QHBoxLayout()
+ title = QLabel("Dashboard")
+ title.setObjectName("PageTitle")
+ self._updated = QLabel("starting…")
+ self._updated.setObjectName("Muted")
+ header.addWidget(title)
+ header.addStretch(1)
+ header.addWidget(self._updated)
+ root.addLayout(header)
+
+ # Headline gauges
+ self._g_gpu_temp = StatGauge("GPU Temp", "°C", 100, "temp")
+ self._g_gpu_load = StatGauge("GPU Load", "%", 100, "accent")
+ self._g_cpu_temp = StatGauge("CPU Temp", "°C", 100, "temp")
+ self._g_mem = StatGauge("Memory", "%", 100, "usage")
+ gauges = QHBoxLayout()
+ gauges.setSpacing(14)
+ for g in (self._g_gpu_temp, self._g_gpu_load, self._g_cpu_temp, self._g_mem):
+ gauges.addWidget(_gauge_card(g))
+ root.addLayout(gauges)
+
+ # Per-subsystem cards (scrollable, 2-column grid)
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+ scroll.setStyleSheet("background: transparent;")
+ container = QWidget()
+ self._grid = QGridLayout(container)
+ self._grid.setContentsMargins(0, 0, 0, 0)
+ self._grid.setSpacing(14)
+ self._grid.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self._grid.setColumnStretch(0, 1)
+ self._grid.setColumnStretch(1, 1)
+ scroll.setWidget(container)
+ root.addWidget(scroll, 1)
+
+ def update_sample(self, sample: Sample) -> None:
+ self._g_gpu_temp.set_value(self._val(sample, "gpu", "temp", ""))
+ self._g_gpu_load.set_value(self._val(sample, "gpu", "util"))
+ self._g_cpu_temp.set_value(self._cpu_temp(sample))
+ self._g_mem.set_value(self._val(sample, "memory", "used_pct"))
+
+ keys = [r.key for r in sample.readings]
+ if keys != self._built_keys: # sources appeared/disappeared
+ self._rebuild(sample)
+ self._built_keys = keys
+ for r in sample.readings:
+ widget = self._metric_widgets.get(r.key)
+ if widget is not None:
+ widget.set_reading(r)
+
+ stamp = time.strftime("%H:%M:%S", time.localtime(sample.ts))
+ self._updated.setText(f"Updated {stamp} · {len(sample.readings)} sensors")
+
+ def _rebuild(self, sample: Sample) -> None:
+ while self._grid.count():
+ item = self._grid.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.deleteLater()
+ self._metric_widgets.clear()
+
+ groups = sample.by_source()
+ ordered = [k for k in _GROUP_ORDER if k in groups]
+ ordered += [k for k in groups if k not in _GROUP_ORDER]
+
+ for index, key in enumerate(ordered):
+ readings = groups[key]
+ subtitle = next((r.label for r in readings if r.metric == "name"), None)
+ card = Card(_GROUP_TITLES.get(key, key.title()), subtitle)
+ for r in readings:
+ if r.metric == "name":
+ continue
+ if r.metric in _BAR_METRICS and r.unit == "%":
+ mode = "usage" if r.metric == "used_pct" else "load"
+ widget: MetricBar | MetricRow = MetricBar(metric_label(r), mode)
+ else:
+ widget = MetricRow(metric_label(r))
+ widget.set_reading(r)
+ card.add(widget)
+ self._metric_widgets[r.key] = widget
+ row, col = divmod(index, 2)
+ self._grid.addWidget(card, row, col, Qt.AlignmentFlag.AlignTop)
+
+ @staticmethod
+ def _val(sample: Sample, source: str, metric: str, label: str | None = None) -> float | None:
+ for r in sample.readings:
+ if r.source == source and r.metric == metric and (label is None or r.label == label):
+ return r.value
+ return None
+
+ @staticmethod
+ def _cpu_temp(sample: Sample) -> float | None:
+ temps = [r for r in sample.readings if r.source == "cpu" and r.metric == "temp" and r.value is not None]
+ for r in temps:
+ low = r.label.lower()
+ if low.startswith("package") or "tctl" in low or "tdie" in low:
+ return r.value
+ return max((r.value for r in temps), default=None)
diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py
new file mode 100644
index 0000000..8143903
--- /dev/null
+++ b/src/rigdoctor/gui/main_window.py
@@ -0,0 +1,119 @@
+"""RigDoctor main window — sidebar navigation over a stacked content area."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QButtonGroup,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QMainWindow,
+ QPushButton,
+ QStackedWidget,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .dashboard import Dashboard
+from .theme import ACCENT, MUTED
+from .worker import SamplerWorker
+
+_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Inventory"]
+_PLACEHOLDERS = {
+ "Logs": "Captured crash logs will appear here once the logger (M3) lands.",
+ "Health": "The health report (M4) — log scan + plain-language findings — lands here.",
+ "Inventory": "System inventory (M5) — CPU/GPU/board/RAM/drivers — lands here.",
+}
+
+
+class MainWindow(QMainWindow):
+ def __init__(self, interval: float = 1.0) -> None:
+ super().__init__()
+ self.setWindowTitle("RigDoctor")
+ self.resize(1000, 680)
+
+ central = QWidget()
+ self.setCentralWidget(central)
+ layout = QHBoxLayout(central)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Content stack
+ content = QWidget()
+ content.setObjectName("ContentArea")
+ content_layout = QVBoxLayout(content)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ self._stack = QStackedWidget()
+ self.dashboard = Dashboard()
+ self._stack.addWidget(self.dashboard)
+ for name in _NAV_ITEMS[1:]:
+ self._stack.addWidget(self._placeholder_page(name, _PLACEHOLDERS[name]))
+ content_layout.addWidget(self._stack)
+
+ layout.addWidget(self._build_sidebar())
+ layout.addWidget(content, 1)
+
+ self._worker = SamplerWorker(interval=interval)
+ self._worker.sampled.connect(self.dashboard.update_sample)
+ self._worker.start()
+
+ def _build_sidebar(self) -> QFrame:
+ bar = QFrame()
+ bar.setObjectName("Sidebar")
+ bar.setFixedWidth(208)
+ v = QVBoxLayout(bar)
+ v.setContentsMargins(16, 18, 16, 16)
+ v.setSpacing(4)
+
+ title = QLabel("RigDoctor")
+ title.setObjectName("AppTitle")
+ subtitle = QLabel("Hardware monitor")
+ subtitle.setObjectName("AppSubtitle")
+ v.addWidget(title)
+ v.addWidget(subtitle)
+ v.addSpacing(18)
+
+ group = QButtonGroup(self)
+ group.setExclusive(True)
+ for i, name in enumerate(_NAV_ITEMS):
+ btn = QPushButton(name)
+ btn.setObjectName("NavButton")
+ btn.setCheckable(True)
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.setChecked(i == 0)
+ btn.clicked.connect(lambda _checked, idx=i: self._stack.setCurrentIndex(idx))
+ group.addButton(btn, i)
+ v.addWidget(btn)
+
+ v.addStretch(1)
+ live = QLabel(f'● Live')
+ v.addWidget(live)
+ return bar
+
+ def _placeholder_page(self, title: str, description: str) -> QWidget:
+ page = QWidget()
+ page.setObjectName("Page")
+ v = QVBoxLayout(page)
+ v.setContentsMargins(20, 18, 20, 18)
+ v.setSpacing(16)
+ head = QLabel(title)
+ head.setObjectName("PageTitle")
+ v.addWidget(head)
+
+ card = QFrame()
+ card.setObjectName("Card")
+ cv = QVBoxLayout(card)
+ cv.setContentsMargins(24, 48, 24, 48)
+ msg = QLabel(description)
+ msg.setObjectName("Muted")
+ msg.setWordWrap(True)
+ msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ cv.addWidget(msg)
+ v.addWidget(card)
+ v.addStretch(1)
+ return page
+
+ def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
+ self._worker.stop()
+ super().closeEvent(event)
diff --git a/src/rigdoctor/gui/theme.py b/src/rigdoctor/gui/theme.py
new file mode 100644
index 0000000..7e76057
--- /dev/null
+++ b/src/rigdoctor/gui/theme.py
@@ -0,0 +1,91 @@
+"""Visual theme: color palette, thresholds, and the app stylesheet (QSS)."""
+
+from __future__ import annotations
+
+# Palette (dark)
+BG = "#101216"
+SIDEBAR = "#15181e"
+CARD = "#1b1f26"
+CARD_BORDER = "#2a2f39"
+TRACK = "#2a2f39"
+TEXT = "#e6e8eb"
+MUTED = "#8b929c"
+
+ACCENT = "#38bdf8"
+COLD = "#7dd3fc" # icey-blue
+GOOD = "#4ade80" # green
+WARN = "#fb923c" # orange (warm transition)
+CRIT = "#f87171" # red
+
+# Temperature bands (°C): < COLD = icey-blue, < WARN = green, < CRIT = orange, else red.
+TEMP_COLD = 50.0
+TEMP_WARN = 78.0
+TEMP_CRIT = 88.0
+USAGE_WARN = 85.0
+USAGE_CRIT = 95.0
+
+
+def temp_color(celsius: float) -> str:
+ if celsius >= TEMP_CRIT:
+ return CRIT
+ if celsius >= TEMP_WARN:
+ return WARN
+ if celsius >= TEMP_COLD:
+ return GOOD
+ return COLD
+
+
+def usage_color(pct: float) -> str:
+ if pct >= USAGE_CRIT:
+ return CRIT
+ if pct >= USAGE_WARN:
+ return WARN
+ return ACCENT
+
+
+def gauge_color(kind: str, value: float | None) -> str:
+ if value is None:
+ return MUTED
+ if kind == "temp":
+ return temp_color(value)
+ if kind == "usage":
+ return usage_color(value)
+ return ACCENT
+
+
+STYLESHEET = f"""
+QWidget {{
+ color: {TEXT};
+ font-family: "Inter", "Cantarell", "Ubuntu", "Noto Sans", "DejaVu Sans", sans-serif;
+ font-size: 13px;
+}}
+QMainWindow, #ContentArea, #Page {{ background: {BG}; }}
+QLabel {{ background: transparent; }}
+
+#Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }}
+#AppTitle {{ font-size: 17px; font-weight: 800; }}
+#AppSubtitle {{ color: {MUTED}; font-size: 11px; }}
+
+QPushButton#NavButton {{
+ text-align: left; padding: 9px 12px; border: none; border-radius: 8px;
+ color: {MUTED}; background: transparent;
+}}
+QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
+QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
+
+#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
+QPushButton#CardHeader {{
+ background: transparent; border: none; text-align: left;
+ font-size: 13px; font-weight: 700; color: {TEXT}; padding: 2px 0;
+}}
+QPushButton#CardHeader:hover {{ color: #ffffff; }}
+#PageTitle {{ font-size: 22px; font-weight: 800; }}
+#Muted {{ color: {MUTED}; }}
+
+QScrollArea {{ border: none; background: transparent; }}
+QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
+QScrollBar::handle:vertical {{ background: {CARD_BORDER}; border-radius: 5px; min-height: 28px; }}
+QScrollBar::handle:vertical:hover {{ background: #3a414d; }}
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
+QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }}
+"""
diff --git a/src/rigdoctor/gui/widgets.py b/src/rigdoctor/gui/widgets.py
new file mode 100644
index 0000000..6923e71
--- /dev/null
+++ b/src/rigdoctor/gui/widgets.py
@@ -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;")
diff --git a/src/rigdoctor/gui/worker.py b/src/rigdoctor/gui/worker.py
new file mode 100644
index 0000000..93b6d84
--- /dev/null
+++ b/src/rigdoctor/gui/worker.py
@@ -0,0 +1,37 @@
+"""Background sampling for the GUI.
+
+Sampling shells out to nvidia-smi etc., so it runs on a worker thread to keep the
+UI responsive. Samples are delivered to the GUI via a Qt signal (thread-safe).
+"""
+
+from __future__ import annotations
+
+import threading
+
+from PySide6.QtCore import QObject, Signal
+
+from ..core.sampler import Sampler
+from ..core.sources import available_sources
+
+
+class SamplerWorker(QObject):
+ sampled = Signal(object) # emits a core.sample.Sample
+
+ def __init__(self, interval: float = 1.0):
+ super().__init__()
+ self._interval = interval
+ self._sampler = Sampler(available_sources())
+ self._stop = threading.Event()
+ self._thread = threading.Thread(target=self._run, daemon=True, name="rigdoctor-sampler")
+
+ def start(self) -> None:
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._stop.set()
+
+ def _run(self) -> None:
+ for sample in self._sampler.stream(interval=self._interval):
+ if self._stop.is_set():
+ break
+ self.sampled.emit(sample)
diff --git a/src/rigdoctor/render.py b/src/rigdoctor/render.py
index 77048fb..72c3fa1 100644
--- a/src/rigdoctor/render.py
+++ b/src/rigdoctor/render.py
@@ -8,7 +8,8 @@ _GROUP_ORDER = ["gpu", "cpu", "memory", "storage"]
_GROUP_TITLES = {"gpu": "GPU", "cpu": "CPU", "memory": "Memory", "storage": "Storage"}
-def _fmt_value(r: Reading) -> str:
+def format_value(r: Reading) -> str:
+ """Format a reading's value + unit for display (shared by CLI and GUI)."""
if r.value is None:
return "N/A"
if r.unit == "°C":
@@ -18,11 +19,15 @@ def _fmt_value(r: Reading) -> str:
return f"{r.value:g}"
+def metric_label(r: Reading) -> str:
+ """Human label for a reading's metric (e.g. 'temp memory')."""
+ return f"{r.metric} {r.label}".strip()
+
+
def _fmt(r: Reading) -> str:
if r.metric == "name": # GPU/device identity line
return f" {r.label}"
- name = f"{r.metric} {r.label}".strip()
- return f" {name:<22} {_fmt_value(r)}"
+ return f" {metric_label(r):<22} {format_value(r)}"
def render_snapshot(sample: Sample) -> str: