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>
This commit is contained in:
2026-05-22 08:40:50 +02:00
parent 934b489fec
commit 73f347449e
5 changed files with 76 additions and 11 deletions
+11
View File
@@ -5,6 +5,17 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions). release tag (so the auto-updater, D18, can compare versions).
## [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 ## [0.12.0] - 2026-05-22
### Added ### Added
- **Guided diagnostic in the GUI.** Each game on the **Games** page now has a **Run Diagnostic** - **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] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.12.0" version = "0.13.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers." description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.12.0" __version__ = "0.13.0"
+20
View File
@@ -15,6 +15,8 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import subprocess
import time import time
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
@@ -351,6 +353,24 @@ def acknowledge_new() -> None:
# --- formatting ----------------------------------------------------------------------- # --- 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: def human_size(num_bytes: int) -> str:
if num_bytes <= 0: if num_bytes <= 0:
return "" return ""
+43 -9
View File
@@ -29,7 +29,7 @@ from .diagnostic_dialog import DiagnosticDialog
from .theme import ACCENT, GOOD, MUTED 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 = QFrame()
card.setObjectName("Card") card.setObjectName("Card")
h = QHBoxLayout(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 = QPushButton("Run Diagnostic")
diag_btn.setObjectName("ActionButton") diag_btn.setObjectName("ActionButton")
diag_btn.setCursor(Qt.CursorShape.PointingHandCursor) 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) h.addWidget(diag_btn, 0)
return card return card
@@ -113,9 +113,10 @@ class GamesPage(QWidget):
banner_h.setContentsMargins(16, 10, 16, 10) banner_h.setContentsMargins(16, 10, 16, 10)
banner_h.setSpacing(10) banner_h.setSpacing(10)
self._banner_label = QLabel("") self._banner_label = QLabel("")
self._banner_label.setWordWrap(True)
self._banner_label.setStyleSheet(f"color: {ACCENT}; font-weight: 700; background: transparent;") self._banner_label.setStyleSheet(f"color: {ACCENT}; font-weight: 700; background: transparent;")
banner_h.addWidget(self._banner_label, 1) 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.setObjectName("ActionButton")
self._finish_btn.clicked.connect(self._finish_diagnostic) self._finish_btn.clicked.connect(self._finish_diagnostic)
banner_h.addWidget(self._finish_btn) banner_h.addWidget(self._finish_btn)
@@ -269,29 +270,59 @@ class GamesPage(QWidget):
os.path.basename(g.library.rstrip("/")) or g.library, os.path.basename(g.library.rstrip("/")) or g.library,
steam.human_size(g.size_bytes), steam.human_size(g.size_bytes),
g.appid in new_appids, g.appid in new_appids,
appid=g.appid,
on_diagnose=self._start_diagnostic, on_diagnose=self._start_diagnostic,
)) ))
self._list.addStretch(1) self._list.addStretch(1)
# --- guided diagnostic (M6/D12) --------------------------------------------------- # --- guided diagnostic (M6/D12) ---------------------------------------------------
def _start_diagnostic(self, name: str) -> None: def _start_diagnostic(self, name: str, appid: str = "") -> None:
from ..core import diagnostic from ..core import diagnostic, steam
if diagnostic.is_running(): if diagnostic.is_running():
QMessageBox.information( QMessageBox.information(
self, "RigDoctor", self, "RigDoctor",
"A capture is already running — finish or discard it first.") "A capture is already running — finish or discard it first.")
return 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: if diagnostic.start(game=name) is None:
QMessageBox.warning(self, "RigDoctor", "Couldn't start the capture.") QMessageBox.warning(self, "RigDoctor", "Couldn't start the capture.")
return return
launched = steam.launch_game(appid) if clicked is launch_btn else False
self._diag_game = name self._diag_game = name
self._banner_label.setText(f"● Recording — {name} · starting…")
self._finish_btn.setEnabled(True) self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True) self._discard_btn.setEnabled(True)
self._banner.show() self._banner.show()
self._diag_timer.start() 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: def _poll_diag(self) -> None:
from ..core import diagnostic from ..core import diagnostic
@@ -301,9 +332,12 @@ class GamesPage(QWidget):
self._diag_timer.stop() # recorder exited on its own self._diag_timer.stop() # recorder exited on its own
return return
samples = status.get("samples", 0) samples = status.get("samples", 0)
lost = " · GPU-lost seen" if status.get("gpu_lost") else "" lost = " · GPU-lost detected" if status.get("gpu_lost") else ""
game = status.get("game") or self._diag_game or "" game = status.get("game") or self._diag_game or "your game"
self._banner_label.setText(f"● Recording — {game} · {samples} samples{lost}") 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: def _finish_diagnostic(self) -> None:
self._diag_timer.stop() self._diag_timer.stop()