Compare commits

..

4 Commits

Author SHA1 Message Date
jessey 82f3ea49de Merge pull request 'feat(gui): dashboard history graphs for headline metrics — 0.14.0' (#9) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #9
2026-05-22 06:51:06 +00:00
jessey 8d695227bc feat(gui): dashboard history graphs for headline metrics — 0.14.0
Replace the four headline gauges (GPU temp, GPU load, CPU temp, memory) with
HistoryGraph trend tiles: each plots its session history with the current value,
window min/max, a dashed warn-threshold line, and a kind-colored line (temp band
/ usage / accent). QPainter-drawn, no new dependency. Seeing changes over time is
more useful than the live-only snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:45:20 +02:00
jessey 82bef0a08c Merge pull request 'feat(gui): explain Run Diagnostic + offer to launch the game — 0.13.0' (#8) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #8
2026-05-22 06:43:57 +00:00
jessey 73f347449e feat(gui): explain Run Diagnostic + offer to launch the game — 0.13.0
The recording banner gave no guidance, so it wasn't clear what to do after
clicking Run Diagnostic.

- Start dialog now spells out the flow: play the game, reproduce the crash, then
  Finish & analyze (data survives a hard freeze + reboot), with "Launch game &
  start" (steam.launch_game via steam:// appid URL) or "Start without launching".
- Recording banner now states the next step, not just a sample count.
- steam.launch_game(appid): best-effort Steam launch (steam / xdg-open).
- Fix: escape "&" in button labels (Qt mnemonic) so "Finish & analyze" shows
  correctly instead of "Finish _analyze".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:40:50 +02:00
7 changed files with 228 additions and 31 deletions
+18
View File
@@ -5,6 +5,24 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions).
## [0.14.0] - 2026-05-22
### Changed
- **Dashboard headline tiles are now history trend graphs** instead of single-value gauges —
GPU temp, GPU load, CPU temp, and memory each plot their recent history (with the current
value, window min/max, and a dashed warning-threshold line), so you can see changes over time
rather than only the instantaneous reading. New `HistoryGraph` widget (QPainter, no new deps).
## [0.13.0] - 2026-05-22
### Added
- **Run Diagnostic now explains itself and can launch the game.** Clicking Run Diagnostic shows
what to do — *play the game, reproduce the crash, then Finish & analyze* (and that data
survives a hard freeze + reboot) — and offers **Launch game & start** (asks Steam to run it by
appid) or **Start without launching**. The recording banner now spells out the next step
instead of just showing a sample count.
### Fixed
- Button labels containing "&" (e.g. "Finish & analyze") rendered as "Finish _analyze" because
Qt treated the "&" as a keyboard mnemonic — now escaped so the ampersand shows literally.
## [0.12.0] - 2026-05-22
### Added
- **Guided diagnostic in the GUI.** Each game on the **Games** page now has a **Run Diagnostic**
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.12.0"
version = "0.14.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.12.0"
__version__ = "0.14.0"
+20
View File
@@ -15,6 +15,8 @@ from __future__ import annotations
import json
import os
import shutil
import subprocess
import time
from dataclasses import asdict, dataclass
from pathlib import Path
@@ -351,6 +353,24 @@ def acknowledge_new() -> None:
# --- formatting -----------------------------------------------------------------------
def launch_game(appid: str) -> bool:
"""Best-effort: ask Steam to launch a game by appid (steam:// URL). Non-blocking."""
if not appid:
return False
url = f"steam://rungameid/{appid}"
for cmd in (["steam", url], ["xdg-open", url]):
if shutil.which(cmd[0]):
try:
subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True,
)
return True
except (OSError, subprocess.SubprocessError):
continue
return False
def human_size(num_bytes: int) -> str:
if num_bytes <= 0:
return ""
+17 -17
View File
@@ -17,19 +17,19 @@ from PySide6.QtWidgets import (
from ..core.sample import Sample
from ..render import metric_label
from .widgets import Card, MetricBar, MetricRow, StatGauge
from .widgets import Card, HistoryGraph, MetricBar, MetricRow
_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:
def _tile_card(widget: QWidget) -> QFrame:
card = QFrame()
card.setObjectName("Card")
layout = QVBoxLayout(card)
layout.setContentsMargins(6, 14, 6, 8)
layout.addWidget(gauge)
layout.setContentsMargins(6, 10, 6, 8)
layout.addWidget(widget)
return card
@@ -54,16 +54,16 @@ class Dashboard(QWidget):
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)
# Headline trend graphs (history over the session, not just the live value)
self._g_gpu_temp = HistoryGraph("GPU Temp", "°C", 30, 100, "temp")
self._g_gpu_load = HistoryGraph("GPU Load", "%", 0, 100, "accent")
self._g_cpu_temp = HistoryGraph("CPU Temp", "°C", 30, 100, "temp")
self._g_mem = HistoryGraph("Memory", "%", 0, 100, "usage")
graphs = QHBoxLayout()
graphs.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)
graphs.addWidget(_tile_card(g))
root.addLayout(graphs)
# Per-subsystem cards (scrollable, 2-column grid)
scroll = QScrollArea()
@@ -81,10 +81,10 @@ class Dashboard(QWidget):
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"))
self._g_gpu_temp.add_value(self._val(sample, "gpu", "temp", ""))
self._g_gpu_load.add_value(self._val(sample, "gpu", "util"))
self._g_cpu_temp.add_value(self._cpu_temp(sample))
self._g_mem.add_value(self._val(sample, "memory", "used_pct"))
keys = [r.key for r in sample.readings]
if keys != self._built_keys: # sources appeared/disappeared
+43 -9
View File
@@ -29,7 +29,7 @@ from .diagnostic_dialog import DiagnosticDialog
from .theme import ACCENT, GOOD, MUTED
def _game_row(name: str, sublabel: str, size: str, is_new: bool, on_diagnose=None) -> QFrame:
def _game_row(name: str, sublabel: str, size: str, is_new: bool, appid: str = "", on_diagnose=None) -> QFrame:
card = QFrame()
card.setObjectName("Card")
h = QHBoxLayout(card)
@@ -66,7 +66,7 @@ def _game_row(name: str, sublabel: str, size: str, is_new: bool, on_diagnose=Non
diag_btn = QPushButton("Run Diagnostic")
diag_btn.setObjectName("ActionButton")
diag_btn.setCursor(Qt.CursorShape.PointingHandCursor)
diag_btn.clicked.connect(lambda: on_diagnose(name))
diag_btn.clicked.connect(lambda: on_diagnose(name, appid))
h.addWidget(diag_btn, 0)
return card
@@ -113,9 +113,10 @@ class GamesPage(QWidget):
banner_h.setContentsMargins(16, 10, 16, 10)
banner_h.setSpacing(10)
self._banner_label = QLabel("")
self._banner_label.setWordWrap(True)
self._banner_label.setStyleSheet(f"color: {ACCENT}; font-weight: 700; background: transparent;")
banner_h.addWidget(self._banner_label, 1)
self._finish_btn = QPushButton("Finish & analyze")
self._finish_btn = QPushButton("Finish && analyze") # && → literal & (not a mnemonic)
self._finish_btn.setObjectName("ActionButton")
self._finish_btn.clicked.connect(self._finish_diagnostic)
banner_h.addWidget(self._finish_btn)
@@ -269,29 +270,59 @@ class GamesPage(QWidget):
os.path.basename(g.library.rstrip("/")) or g.library,
steam.human_size(g.size_bytes),
g.appid in new_appids,
appid=g.appid,
on_diagnose=self._start_diagnostic,
))
self._list.addStretch(1)
# --- guided diagnostic (M6/D12) ---------------------------------------------------
def _start_diagnostic(self, name: str) -> None:
from ..core import diagnostic
def _start_diagnostic(self, name: str, appid: str = "") -> None:
from ..core import diagnostic, steam
if diagnostic.is_running():
QMessageBox.information(
self, "RigDoctor",
"A capture is already running — finish or discard it first.")
return
# Tell the user what the flow actually is, and offer to launch the game for them.
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Information)
box.setWindowTitle(f"Run Diagnostic — {name}")
box.setText(f"Record a focused diagnostic while you play {name}?")
box.setInformativeText(
"RigDoctor will capture sensors in the background. Then:\n\n"
"1. Play the game and try to reproduce the freeze / black screen / crash.\n"
"2. When you're done — or after a hard freeze and reboot — come back here and "
"click “Finish & analyze”.\n\n"
"Your readings are saved continuously, so even a hard lock won't lose them."
)
launch_btn = box.addButton("Launch game && start", QMessageBox.ButtonRole.AcceptRole)
start_btn = box.addButton("Start without launching", QMessageBox.ButtonRole.ActionRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
if not appid:
launch_btn.setEnabled(False) # no appid → can't ask Steam to launch it
box.exec()
clicked = box.clickedButton()
if clicked not in (launch_btn, start_btn):
return
if diagnostic.start(game=name) is None:
QMessageBox.warning(self, "RigDoctor", "Couldn't start the capture.")
return
launched = steam.launch_game(appid) if clicked is launch_btn else False
self._diag_game = name
self._banner_label.setText(f"● Recording — {name} · starting…")
self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True)
self._banner.show()
self._diag_timer.start()
self._poll_diag()
if clicked is launch_btn and not launched:
QMessageBox.information(
self, "RigDoctor",
"Recording started, but couldn't launch the game automatically — "
"launch it yourself, then click “Finish & analyze” when you're done.")
def _poll_diag(self) -> None:
from ..core import diagnostic
@@ -301,9 +332,12 @@ class GamesPage(QWidget):
self._diag_timer.stop() # recorder exited on its own
return
samples = status.get("samples", 0)
lost = " · GPU-lost seen" if status.get("gpu_lost") else ""
game = status.get("game") or self._diag_game or ""
self._banner_label.setText(f"● Recording — {game} · {samples} samples{lost}")
lost = " · GPU-lost detected" if status.get("gpu_lost") else ""
game = status.get("game") or self._diag_game or "your game"
self._banner_label.setText(
f"● Recording {game} — play it and reproduce the problem, then click "
f"“Finish & analyze”. ({samples} samples{lost})"
)
def _finish_diagnostic(self) -> None:
self._diag_timer.stop()
+128 -3
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from collections import deque
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import (
QComboBox,
QFrame,
@@ -17,7 +19,19 @@ from PySide6.QtWidgets import (
from ..core.sample import Reading
from ..render import format_value
from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT, TRACK, WARN, gauge_color, temp_color
from .theme import (
ACCENT,
CRIT,
GOOD,
MUTED,
TEMP_WARN,
TEXT,
TRACK,
USAGE_WARN,
WARN,
gauge_color,
temp_color,
)
_SEV = {
"critical": ("CRITICAL", CRIT),
@@ -248,6 +262,117 @@ class StatGauge(QWidget):
p.end()
class HistoryGraph(QWidget):
"""A headline metric as a trend: current value + window min/max + a history line.
Replaces the at-a-glance gauge with changes-over-time. `kind` drives the color
(temp band / usage / accent), matching StatGauge so the dashboard stays consistent.
"""
def __init__(self, title: str, unit: str = "", vmin: float = 0.0, vmax: float = 100.0,
kind: str = "accent", history: int = 180) -> None:
super().__init__()
self._title = title
self._unit = unit
self._min = vmin
self._max = vmax
self._kind = kind # "temp" | "usage" | "accent"
self._values: deque[float | None] = deque(maxlen=history)
self.setMinimumSize(160, 132)
def add_value(self, value: float | None) -> None:
self._values.append(value)
self.update()
def _fmt(self, value: float | None) -> str:
if value is None:
return ""
if self._unit == "°C":
return f"{value:.0f}°"
if self._unit == "%":
return f"{value:.0f}%"
return f"{value:.0f}{self._unit}"
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
pad = 10.0
present = [v for v in self._values if v is not None]
current = next((v for v in reversed(self._values) if v is not None), None)
color = QColor(gauge_color(self._kind, current))
ftitle = QFont()
ftitle.setPointSizeF(10.0)
ftitle.setBold(True)
p.setFont(ftitle)
p.setPen(QColor(MUTED))
p.drawText(QRectF(pad, 6, w - 2 * pad, 18),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._title)
fval = QFont()
fval.setPointSizeF(21.0)
fval.setBold(True)
p.setFont(fval)
p.setPen(color if current is not None else QColor(MUTED))
p.drawText(QRectF(pad, 2, w - 2 * pad, 28),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, self._fmt(current))
if present:
fsm = QFont()
fsm.setPointSizeF(8.5)
p.setFont(fsm)
p.setPen(QColor(MUTED))
p.drawText(QRectF(pad, 27, w - 2 * pad, 14), Qt.AlignmentFlag.AlignLeft,
f"min {self._fmt(min(present))} max {self._fmt(max(present))}")
g_top, g_bot = 48.0, h - pad
g_left, g_right = pad, w - pad
span = self._max - self._min
if g_bot - g_top < 12 or g_right - g_left < 12 or span <= 0:
p.end()
return
def y_of(v: float) -> float:
frac = (max(self._min, min(self._max, v)) - self._min) / span
return g_bot - frac * (g_bot - g_top)
warn = TEMP_WARN if self._kind == "temp" else (USAGE_WARN if self._kind == "usage" else None)
if warn is not None and self._min <= warn <= self._max:
pen = QPen(QColor(TRACK))
pen.setWidthF(1.0)
pen.setStyle(Qt.PenStyle.DashLine)
p.setPen(pen)
yw = y_of(warn)
p.drawLine(QPointF(g_left, yw), QPointF(g_right, yw))
maxlen = self._values.maxlen or 1
step = (g_right - g_left) / max(1, maxlen - 1)
n = len(self._values)
# Build the line newest-at-right; break it where readings are missing.
path = QPainterPath()
drawing = False
for i, v in enumerate(self._values):
if v is None:
drawing = False
continue
x = g_right - (n - 1 - i) * step
y = y_of(v)
if drawing:
path.lineTo(x, y)
else:
path.moveTo(x, y)
drawing = True
if not path.isEmpty():
pen = QPen(color)
pen.setWidthF(2.0)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
p.setPen(pen)
p.drawPath(path)
p.end()
class MetricBar(QWidget):
"""A label + value with a thin progress bar (for 0100% metrics)."""