From 934b489fec8d78035f6beba5f2a0f2cb4939520a Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 08:32:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20Run=20Diagnostic=20flow=20on=20the?= =?UTF-8?q?=20Games=20page=20=E2=80=94=200.12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the guided diagnostic (0.11.0 core/CLI) into the GUI: - Each game row gets a "Run Diagnostic" button → starts a focused, game-tagged capture and shows a recording banner (live sample count + GPU-lost indicator) with Finish & analyze / Discard. - Finishing runs core.diagnostic.finish() off the UI thread and opens a results dialog (gui/diagnostic_dialog.py): window-scoped capture summary + findings cards (reusing render_summary + finding_card). - Banner restores on showEvent if a capture is still running (navigate away/back). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++ docs/ROADMAP.md | 10 ++- pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/gui/diagnostic_dialog.py | 81 ++++++++++++++++++ src/rigdoctor/gui/games_page.py | 113 ++++++++++++++++++++++++- 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 src/rigdoctor/gui/diagnostic_dialog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a60f8..66abc40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.12.0] - 2026-05-22 +### Added +- **Guided diagnostic in the GUI.** Each game on the **Games** page now has a **Run Diagnostic** + button → a focused, game-tagged capture starts and a recording banner appears (live sample + count, GPU-lost indicator) with **Finish & analyze** / **Discard**. Finishing opens a results + dialog: the window-scoped capture summary (peak temps/power, events, last samples) plus the + health findings as cards. The banner persists/restores if you navigate away and back while a + capture is running. Shares `core/diagnostic.py` with the CLI (one flow, three front-ends). + ## [0.11.0] - 2026-05-22 ### Added - **Guided diagnostic session (CLI) — the seed use case, end to end.** `rigdoctor diagnose diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ab7309..296bf5f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,10 +41,12 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`). - [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic + supporting actions — D13) - [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings), - shared by tray/GUI/CLI — *core + CLI done* (`core/diagnostic.py`, `rigdoctor diagnose - start/status/finish`): tags a focused capture with the chosen game (own diagnostic log, - window-scoped report) and combines the capture summary with the M4 findings. *Pending:* - the GUI/tray "Run Diagnostic" button, and auto start/stop via the D12 wrapper/watcher. + shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor + diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games + page → recording banner → results dialog with the capture summary + findings). Tags a + focused capture with the chosen game (own diagnostic log, window-scoped report) and + combines the capture summary with the M4 findings. *Pending:* the tray (M11) entry point, + and auto start/stop via the D12 wrapper/watcher. - [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first: `rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher (Steam RunningAppID + /proc) and GameMode hook follow) diff --git a/pyproject.toml b/pyproject.toml index 87a50d5..8fa6fcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.11.0" +version = "0.12.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 03e8ee2..5e0f1c7 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.11.0" +__version__ = "0.12.0" diff --git a/src/rigdoctor/gui/diagnostic_dialog.py b/src/rigdoctor/gui/diagnostic_dialog.py new file mode 100644 index 0000000..6347d38 --- /dev/null +++ b/src/rigdoctor/gui/diagnostic_dialog.py @@ -0,0 +1,81 @@ +"""Results view for a guided diagnostic session (M6/D12): capture summary + findings.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QDialog, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from ..render import render_summary +from .widgets import finding_card + + +class DiagnosticDialog(QDialog): + def __init__(self, result, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic") + self.resize(660, 680) + + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 16) + root.setSpacing(14) + + title = QLabel(f"Diagnostic — {result.game}" if result.game else "Diagnostic") + title.setObjectName("PageTitle") + root.addWidget(title) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent;") + body = QWidget() + col = QVBoxLayout(body) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(10) + col.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Capture window summary (peaks / events / last samples) — monospace for the columns. + cap_head = QLabel("Capture") + cap_head.setStyleSheet("font-weight: 700; background: transparent;") + col.addWidget(cap_head) + summary = QLabel(render_summary(result.summary)) + summary.setObjectName("Report") + summary.setFont(QFont("monospace")) + summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + summary.setWordWrap(False) + summary.setStyleSheet( + "background: #0d0f13; color: #cfd3da; border: 1px solid #2a2f39; " + "border-radius: 8px; padding: 10px;" + ) + col.addWidget(summary) + + find_head = QLabel(f"Findings ({len(result.findings)})") + find_head.setStyleSheet("font-weight: 700; background: transparent;") + col.addWidget(find_head) + if result.findings: + for finding in result.findings: + col.addWidget(finding_card(finding)) + else: + none = QLabel("No findings.") + none.setObjectName("Muted") + col.addWidget(none) + + scroll.setWidget(body) + root.addWidget(scroll, 1) + + buttons = QHBoxLayout() + buttons.addStretch(1) + close = QPushButton("Close") + close.setObjectName("PrimaryButton") + close.clicked.connect(self.accept) + buttons.addWidget(close) + root.addLayout(buttons) diff --git a/src/rigdoctor/gui/games_page.py b/src/rigdoctor/gui/games_page.py index 5641370..d79765b 100644 --- a/src/rigdoctor/gui/games_page.py +++ b/src/rigdoctor/gui/games_page.py @@ -17,6 +17,7 @@ from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, + QMessageBox, QPushButton, QScrollArea, QVBoxLayout, @@ -24,10 +25,11 @@ from PySide6.QtWidgets import ( ) from ..config import load_config, update_config +from .diagnostic_dialog import DiagnosticDialog from .theme import ACCENT, GOOD, MUTED -def _game_row(name: str, sublabel: str, size: str, is_new: bool) -> QFrame: +def _game_row(name: str, sublabel: str, size: str, is_new: bool, on_diagnose=None) -> QFrame: card = QFrame() card.setObjectName("Card") h = QHBoxLayout(card) @@ -59,6 +61,13 @@ def _game_row(name: str, sublabel: str, size: str, is_new: bool) -> QFrame: size_label.setMinimumWidth(80) size_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) h.addWidget(size_label, 0) + + if on_diagnose is not None: + diag_btn = QPushButton("Run Diagnostic") + diag_btn.setObjectName("ActionButton") + diag_btn.setCursor(Qt.CursorShape.PointingHandCursor) + diag_btn.clicked.connect(lambda: on_diagnose(name)) + h.addWidget(diag_btn, 0) return card @@ -66,14 +75,17 @@ class GamesPage(QWidget): _libraries_ready = Signal(object) # list[dict(path, label, count, selected)] _scanned = Signal(object) # steam.ScanResult new_count_changed = Signal(int) # newly-installed game count (for the nav badge) + _diag_done = Signal(object) # DiagnosticResult — focused capture analyzed def __init__(self) -> None: super().__init__() self.setObjectName("Page") self._libraries_ready.connect(self._render_libraries) self._scanned.connect(self._render_games) + self._diag_done.connect(self._on_diag_done) self._busy = False self._new_appids: set[str] = set() + self._diag_game: str | None = None root = QVBoxLayout(self) root.setContentsMargins(20, 18, 20, 18) @@ -93,6 +105,30 @@ class GamesPage(QWidget): header.addWidget(self._rescan_btn) root.addLayout(header) + # In-progress diagnostic banner (hidden until a focused capture is running). + self._banner = QFrame() + self._banner.setObjectName("Card") + self._banner.setStyleSheet(f"#Card {{ border: 1px solid {ACCENT}; }}") + banner_h = QHBoxLayout(self._banner) + banner_h.setContentsMargins(16, 10, 16, 10) + banner_h.setSpacing(10) + self._banner_label = QLabel("") + 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.setObjectName("ActionButton") + self._finish_btn.clicked.connect(self._finish_diagnostic) + banner_h.addWidget(self._finish_btn) + self._discard_btn = QPushButton("Discard") + self._discard_btn.clicked.connect(self._discard_diagnostic) + banner_h.addWidget(self._discard_btn) + self._banner.hide() + root.addWidget(self._banner) + + self._diag_timer = QTimer(self) + self._diag_timer.setInterval(1000) + self._diag_timer.timeout.connect(self._poll_diag) + # Libraries (opt-in checkboxes) lib_card = QFrame() lib_card.setObjectName("Card") @@ -233,9 +269,74 @@ class GamesPage(QWidget): os.path.basename(g.library.rstrip("/")) or g.library, steam.human_size(g.size_bytes), g.appid in new_appids, + on_diagnose=self._start_diagnostic, )) self._list.addStretch(1) + # --- guided diagnostic (M6/D12) --------------------------------------------------- + + def _start_diagnostic(self, name: str) -> None: + from ..core import diagnostic + + if diagnostic.is_running(): + QMessageBox.information( + self, "RigDoctor", + "A capture is already running — finish or discard it first.") + return + if diagnostic.start(game=name) is None: + QMessageBox.warning(self, "RigDoctor", "Couldn't start the capture.") + return + 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() + + def _poll_diag(self) -> None: + from ..core import diagnostic + + status = diagnostic.active() + if not status: + 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}") + + def _finish_diagnostic(self) -> None: + self._diag_timer.stop() + self._finish_btn.setEnabled(False) + self._discard_btn.setEnabled(False) + self._banner_label.setText("Analyzing… (running the health report)") + threading.Thread(target=self._work_finish, daemon=True).start() + + def _work_finish(self) -> None: + from ..core import diagnostic + + try: + result = diagnostic.finish() + except Exception: + result = None + self._diag_done.emit(result) + + def _on_diag_done(self, result) -> None: + self._banner.hide() + self._finish_btn.setEnabled(True) + self._discard_btn.setEnabled(True) + if result is None: + QMessageBox.warning(self, "RigDoctor", "The diagnostic couldn't be analyzed.") + return + DiagnosticDialog(result, self).exec() + + def _discard_diagnostic(self) -> None: + from ..core import reccontrol + + self._diag_timer.stop() + reccontrol.stop_background() + self._banner.hide() + # --- nav badge integration -------------------------------------------------------- def showEvent(self, event) -> None: # noqa: N802 (Qt override) @@ -247,3 +348,13 @@ class GamesPage(QWidget): threading.Thread(target=steam.acknowledge_new, daemon=True).start() self.new_count_changed.emit(0) + + # Reflect a capture that's still running (e.g. started earlier, navigated back). + from ..core import diagnostic + + if diagnostic.is_running(): + status = diagnostic.active() or {} + self._diag_game = status.get("game") or self._diag_game + self._banner.show() + if not self._diag_timer.isActive(): + self._diag_timer.start()