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: