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:
@@ -3,6 +3,9 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# RigDoctor runtime output
|
||||
logs/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+5
-2
@@ -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 /
|
||||
|
||||
@@ -17,6 +17,7 @@ gui = ["PySide6"]
|
||||
|
||||
[project.scripts]
|
||||
rigdoctor = "rigdoctor.cli:main"
|
||||
rigdoctor-gui = "rigdoctor.gui.app:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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; }}
|
||||
"""
|
||||
@@ -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;")
|
||||
@@ -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,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:
|
||||
|
||||
Reference in New Issue
Block a user