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
+13
View File
@@ -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)
+20 -6
View File
@@ -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())
+6
View File
@@ -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.
"""
+28
View File
@@ -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())
+145
View File
@@ -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)
+119
View File
@@ -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'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
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)
+91
View File
@@ -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; }}
"""
+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;")
+37
View File
@@ -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)
+8 -3
View File
@@ -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: