diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f51bd..2d1c280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index f603bd6..4cc73ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.13.0" +version = "0.14.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 26dfd23..824b7ac 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.13.0" +__version__ = "0.14.0" diff --git a/src/rigdoctor/gui/dashboard.py b/src/rigdoctor/gui/dashboard.py index 764409f..be73f52 100644 --- a/src/rigdoctor/gui/dashboard.py +++ b/src/rigdoctor/gui/dashboard.py @@ -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 diff --git a/src/rigdoctor/gui/widgets.py b/src/rigdoctor/gui/widgets.py index e2bf639..602e22a 100644 --- a/src/rigdoctor/gui/widgets.py +++ b/src/rigdoctor/gui/widgets.py @@ -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 0–100% metrics)."""