diff --git a/CHANGELOG.md b/CHANGELOG.md
index 344e2e0..74be366 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,19 @@ 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.20.0] - 2026-05-22
+### Changed
+- **Reorganized navigation** into grouped sidebar sections — **Monitor** (Dashboard) ·
+ **Diagnose** (Games, Recordings, System Health, Tuning) · **System** (Inventory) · **App**
+ (Settings, Share) — so it's clear where to go.
+- **Renames for clarity:** *Health → System Health* (it's the overall 7-day system scan, not
+ per-game), *Environment → Tuning* (gaming tunables + fixes), *Logs → Recordings*,
+ *Setup → Settings*.
+- **Settings** absorbed **Notifications** (alerts) — app configuration (components/deps, alerts,
+ account access, uninstall) now lives in one page; Notifications is no longer a separate item.
+- **Recordings** is now a hub: pick which captured log to view (always-on capture, last
+ diagnostic, or a preserved crash), **Analyze crash** in place, alongside the recorder controls.
+
## [0.19.0] - 2026-05-22
### Added
- **System-tray applet (M11, D13).** A tray icon whose menu shows live **CPU / GPU temp** and
diff --git a/docs/MODULES.md b/docs/MODULES.md
index ce0fddc..59300bd 100644
--- a/docs/MODULES.md
+++ b/docs/MODULES.md
@@ -67,12 +67,13 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
(`pending_crash`/`analyze_crash` + `health.check_previous_boot`). *Pending:* non-Steam
launchers (Lutris/Heroic), GPU power-profile (PowerMizer) checks, and the zero-config watcher.
- **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. *Bootstrapped
- early (ahead of its Phase 4 slot) at the user's request:* dark-themed window with sidebar
- nav, a live dashboard (circular gauges + collapsible per-subsystem cards, temperature-
- colored values), and a **Recording/Logs page** with full M3 controls (start/stop/status +
- post-crash report). Health/Inventory remain placeholders until M4/M5. GUI-first per D17.
+- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine. Optional; adds the
+ Qt dependency. Dark-themed window with a **grouped sidebar** (Monitor / Diagnose / System /
+ App) over: **Dashboard** (live history graphs + per-subsystem cards), **Games** (M6 detection
+ + Run Diagnostic), **Recordings** (recorder controls + view/report any captured log + analyze
+ a crash), **System Health** (M4 scan), **Tuning** (M6 gaming tunables + fixes), **Inventory**
+ (M5), **Settings** (components/deps + alerts + account + uninstall), and **Share** (M12). A
+ global recording badge shows on every page. GUI-first per D17.
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. *Implemented (`gui/tray.py`, D13):*
the menu shows live M1 readouts (CPU temp, GPU temp, memory used/total) + a status line
(Normal / Hot / GPU not responding), led by a **Run Diagnostic** submenu (per detected game →
diff --git a/pyproject.toml b/pyproject.toml
index 0354214..a9574f6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
-version = "0.19.0"
+version = "0.20.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 d9d095c..3aaf6a4 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.19.0"
+__version__ = "0.20.0"
diff --git a/src/rigdoctor/gui/environment_page.py b/src/rigdoctor/gui/environment_page.py
index 3e04b85..ca239da 100644
--- a/src/rigdoctor/gui/environment_page.py
+++ b/src/rigdoctor/gui/environment_page.py
@@ -46,7 +46,7 @@ class EnvironmentPage(QWidget):
root.setSpacing(16)
header = QHBoxLayout()
- title = QLabel("Environment")
+ title = QLabel("Tuning")
title.setObjectName("PageTitle")
header.addWidget(title)
header.addStretch(1)
diff --git a/src/rigdoctor/gui/health_page.py b/src/rigdoctor/gui/health_page.py
index 13a6d42..b3a3dd0 100644
--- a/src/rigdoctor/gui/health_page.py
+++ b/src/rigdoctor/gui/health_page.py
@@ -32,7 +32,7 @@ class HealthPage(QWidget):
root.setSpacing(16)
header = QHBoxLayout()
- title = QLabel("Health")
+ title = QLabel("System Health")
title.setObjectName("PageTitle")
header.addWidget(title)
header.addStretch(1)
diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py
index 76ae2d3..221356e 100644
--- a/src/rigdoctor/gui/main_window.py
+++ b/src/rigdoctor/gui/main_window.py
@@ -35,7 +35,6 @@ from .environment_page import EnvironmentPage
from .games_page import GamesPage
from .health_page import HealthPage
from .inventory_page import InventoryPage
-from .notifications_page import NotificationsPage
from .recorder_page import RecorderPage
from .setup_page import SetupPage
from .share_page import SharePage
@@ -43,7 +42,15 @@ from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT
from .tray import TrayIcon
from .worker import SamplerWorker
-_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Inventory", "Setup", "Notifications", "Share"]
+# Sidebar grouped by intent. Each page name maps to a widget built in __init__; the stack is
+# filled in this order, so _PAGES.index(name) is the stack index.
+_NAV = [
+ ("Monitor", ["Dashboard"]),
+ ("Diagnose", ["Games", "Recordings", "System Health", "Tuning"]),
+ ("System", ["Inventory"]),
+ ("App", ["Settings", "Share"]),
+]
+_PAGES = [name for _section, names in _NAV for name in names]
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
@@ -79,18 +86,21 @@ class MainWindow(QMainWindow):
self.environment_page = EnvironmentPage()
self.inventory_page = InventoryPage()
self.setup_page = SetupPage()
- self.notifications_page = NotificationsPage()
- self.notifications_page.changed.connect(self._apply_alert_settings)
+ self.setup_page.changed.connect(self._apply_alert_settings)
self.share_page = SharePage()
- self._stack.addWidget(self.dashboard) # 0 Dashboard
- self._stack.addWidget(self.recorder_page) # 1 Logs
- self._stack.addWidget(self.health_page) # 2 Health
- self._stack.addWidget(self.games_page) # 3 Games
- self._stack.addWidget(self.environment_page) # 4 Environment
- self._stack.addWidget(self.inventory_page) # 5 Inventory
- self._stack.addWidget(self.setup_page) # 6 Setup
- self._stack.addWidget(self.notifications_page) # 7 Notifications
- self._stack.addWidget(self.share_page) # 8 Share
+ # Page name → widget; the stack is filled in _PAGES order so indices line up.
+ self._pages = {
+ "Dashboard": self.dashboard,
+ "Games": self.games_page,
+ "Recordings": self.recorder_page,
+ "System Health": self.health_page,
+ "Tuning": self.environment_page,
+ "Inventory": self.inventory_page,
+ "Settings": self.setup_page,
+ "Share": self.share_page,
+ }
+ for name in _PAGES:
+ self._stack.addWidget(self._pages[name])
content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar())
@@ -186,16 +196,22 @@ class MainWindow(QMainWindow):
group = QButtonGroup(self)
group.setExclusive(True)
self._nav_buttons: dict[str, QPushButton] = {}
- 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)
- self._nav_buttons[name] = btn
+ for section, names in _NAV:
+ header = QLabel(section.upper())
+ header.setObjectName("NavSection")
+ v.addSpacing(8)
+ v.addWidget(header)
+ for name in names:
+ idx = _PAGES.index(name)
+ btn = QPushButton(name)
+ btn.setObjectName("NavButton")
+ btn.setCheckable(True)
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.setChecked(idx == 0)
+ btn.clicked.connect(lambda _checked, i=idx: self._stack.setCurrentIndex(i))
+ group.addButton(btn, idx)
+ v.addWidget(btn)
+ self._nav_buttons[name] = btn
v.addStretch(1)
live = QLabel(f'● Live')
@@ -287,7 +303,7 @@ class MainWindow(QMainWindow):
def show_page(self, name: str) -> None:
"""Bring the window forward on a given page (used by the tray)."""
if name in self._nav_buttons:
- self._stack.setCurrentIndex(_NAV_ITEMS.index(name))
+ self._stack.setCurrentIndex(_PAGES.index(name))
self._nav_buttons[name].setChecked(True)
self.showNormal()
self.raise_()
diff --git a/src/rigdoctor/gui/notifications_page.py b/src/rigdoctor/gui/notifications_page.py
deleted file mode 100644
index 1539888..0000000
--- a/src/rigdoctor/gui/notifications_page.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""Notifications page (M8 config): user-configurable alert settings."""
-
-from __future__ import annotations
-
-from PySide6.QtCore import Qt, Signal
-from PySide6.QtWidgets import (
- QCheckBox,
- QDoubleSpinBox,
- QFrame,
- QGridLayout,
- QHBoxLayout,
- QLabel,
- QPushButton,
- QVBoxLayout,
- QWidget,
-)
-
-from ..config import load_config, update_config
-from ..core import alerts
-
-
-class NotificationsPage(QWidget):
- changed = Signal() # settings saved — main window re-applies them live
-
- def __init__(self) -> None:
- super().__init__()
- self.setObjectName("Page")
- root = QVBoxLayout(self)
- root.setContentsMargins(20, 18, 20, 18)
- root.setSpacing(16)
-
- title = QLabel("Notifications")
- title.setObjectName("PageTitle")
- root.addWidget(title)
-
- card = QFrame()
- card.setObjectName("Card")
- v = QVBoxLayout(card)
- v.setContentsMargins(16, 14, 16, 14)
- v.setSpacing(10)
- head = QLabel("Alerts")
- head.setStyleSheet("font-weight: 700; background: transparent;")
- v.addWidget(head)
-
- self._enabled = QCheckBox("Enable desktop notifications")
- v.addWidget(self._enabled)
-
- grid = QGridLayout()
- grid.setHorizontalSpacing(12)
- grid.setColumnStretch(2, 1)
- self._gpu = self._spin()
- self._cpu = self._spin()
- grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
- grid.addWidget(self._gpu, 0, 1)
- grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
- grid.addWidget(self._cpu, 1, 1)
- v.addLayout(grid)
-
- note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
- note.setObjectName("Muted")
- note.setWordWrap(True)
- v.addWidget(note)
-
- buttons = QHBoxLayout()
- save = QPushButton("Save")
- save.setObjectName("PrimaryButton")
- save.clicked.connect(self._save)
- test = QPushButton("Send test")
- test.clicked.connect(self._test)
- buttons.addWidget(save)
- buttons.addWidget(test)
- buttons.addStretch(1)
- v.addLayout(buttons)
- self._status = QLabel("")
- self._status.setObjectName("Muted")
- v.addWidget(self._status)
-
- root.addWidget(card)
- root.addStretch(1)
- self._load()
-
- @staticmethod
- def _spin() -> QDoubleSpinBox:
- spin = QDoubleSpinBox()
- spin.setRange(40, 110)
- spin.setDecimals(0)
- spin.setSingleStep(1)
- spin.setSuffix(" °C")
- return spin
-
- def _load(self) -> None:
- cfg = load_config()
- self._enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
- self._gpu.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
- self._cpu.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
-
- def _save(self) -> None:
- update_config(
- alerts_enabled=self._enabled.isChecked(),
- gpu_temp_alert=self._gpu.value(),
- cpu_temp_alert=self._cpu.value(),
- )
- self.changed.emit()
- self._status.setText("Saved.")
-
- def _test(self) -> None:
- ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
- self._status.setText("Test notification sent." if ok else "notify-send not found — install libnotify-bin (Setup).")
diff --git a/src/rigdoctor/gui/recorder_page.py b/src/rigdoctor/gui/recorder_page.py
index efcf50c..0744395 100644
--- a/src/rigdoctor/gui/recorder_page.py
+++ b/src/rigdoctor/gui/recorder_page.py
@@ -1,16 +1,19 @@
-"""Recording & Logs page (M3 in the GUI): start/stop/status + post-crash report.
+"""Recordings page (M3 in the GUI): recorder controls + view/report any captured log.
-Drives the same background recorder as the CLI via core.reccontrol, so the GUI and
-`rigdoctor record …` are interchangeable.
+Drives the same background recorder as the CLI via core.reccontrol, and surfaces the
+captured data — the always-on log, the last guided diagnostic, and a preserved hard-crash
+(which can be analyzed in place). One place to see what was captured and what it means.
"""
from __future__ import annotations
+import threading
import time
-from PySide6.QtCore import Qt, QTimer, QUrl
+from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QFont
from PySide6.QtWidgets import (
+ QComboBox,
QDoubleSpinBox,
QFrame,
QHBoxLayout,
@@ -25,6 +28,7 @@ from .. import config
from ..core import reccontrol
from ..core.crashlog import summarize
from ..render import format_headline, render_summary
+from .diagnostic_dialog import DiagnosticDialog
from .theme import GOOD, MUTED, WARN
@@ -45,31 +49,30 @@ def _fmt_time(value, fmt="%Y-%m-%d %H:%M:%S") -> str:
class RecorderPage(QWidget):
+ _analyzed = Signal(object) # DiagnosticResult from a crash analysis
+
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
+ self._analyzed.connect(self._show_analysis)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
- title = QLabel("Recording")
+ title = QLabel("Recordings")
title.setObjectName("PageTitle")
root.addWidget(title)
# --- Status + controls -------------------------------------------------
status_card, status_layout = _panel("Status")
-
self._state = QLabel("○ Not recording")
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
status_layout.addWidget(self._state)
-
self._info = QLabel("")
self._info.setObjectName("Muted")
status_layout.addWidget(self._info)
-
self._latest = QLabel("")
status_layout.addWidget(self._latest)
-
self._warn = QLabel("")
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
self._warn.setVisible(False)
@@ -97,19 +100,20 @@ class RecorderPage(QWidget):
status_layout.addLayout(controls)
root.addWidget(status_card)
- # --- Report ------------------------------------------------------------
- report_card = QFrame()
- report_card.setObjectName("Card")
- report_layout = QVBoxLayout(report_card)
- report_layout.setContentsMargins(16, 14, 16, 14)
- report_layout.setSpacing(10)
+ # --- Captured logs -----------------------------------------------------
+ report_card, report_layout = _panel("Captured logs")
header = QHBoxLayout()
- report_title = QLabel("Post-crash report")
- report_title.setStyleSheet("font-weight: 700; background: transparent;")
- header.addWidget(report_title)
- header.addStretch(1)
+ header.addWidget(QLabel("Show:"))
+ self._source = QComboBox()
+ self._source.currentIndexChanged.connect(self._load_report)
+ header.addWidget(self._source, 1)
+ self._analyze_btn = QPushButton("Analyze crash")
+ self._analyze_btn.setObjectName("ActionButton")
+ self._analyze_btn.clicked.connect(self._analyze_crash)
+ self._analyze_btn.setVisible(False)
+ header.addWidget(self._analyze_btn)
refresh_btn = QPushButton("Refresh")
- refresh_btn.clicked.connect(self._load_report)
+ refresh_btn.clicked.connect(self._refresh_sources)
header.addWidget(refresh_btn)
report_layout.addLayout(header)
@@ -121,13 +125,12 @@ class RecorderPage(QWidget):
report_layout.addWidget(self._report)
root.addWidget(report_card, 1)
- # Poll recorder status once a second (reflects CLI-driven sessions too).
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.timeout.connect(self._refresh_status)
self._timer.start()
self._refresh_status()
- self._load_report()
+ self._refresh_sources()
# --- actions ---------------------------------------------------------------
def _on_start(self) -> None:
@@ -139,12 +142,56 @@ class RecorderPage(QWidget):
self._stop_btn.setEnabled(False)
reccontrol.stop_background()
QTimer.singleShot(600, self._refresh_status)
- QTimer.singleShot(900, self._load_report)
+ QTimer.singleShot(900, self._refresh_sources)
def _open_folder(self) -> None:
config.LOG_DIR.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR)))
+ # --- captured logs ---------------------------------------------------------
+ def _refresh_sources(self) -> None:
+ from ..core import diagnostic
+
+ current = self._source.currentData()
+ self._source.blockSignals(True)
+ self._source.clear()
+ self._source.addItem("Always-on capture", str(config.LOG_FILE))
+ if config.DIAG_LOG.exists():
+ self._source.addItem("Last diagnostic", str(config.DIAG_LOG))
+ if config.DIAG_CRASH.exists():
+ self._source.addItem("Crash (unanalyzed)", str(config.DIAG_CRASH))
+ # keep the previous selection if it's still present
+ idx = self._source.findData(current) if current else -1
+ self._source.setCurrentIndex(idx if idx >= 0 else 0)
+ self._source.blockSignals(False)
+ self._analyze_btn.setVisible(diagnostic.pending_crash() is not None)
+ self._load_report()
+
+ def _load_report(self) -> None:
+ path = self._source.currentData() or str(config.LOG_FILE)
+ summary = summarize(path, last_n=10)
+ self._report.setPlainText(render_summary(summary, log_path=path))
+
+ def _analyze_crash(self) -> None:
+ self._analyze_btn.setEnabled(False)
+ self._report.setPlainText("Analyzing the crash (final readings + system logs)…")
+ threading.Thread(target=self._work_analyze, daemon=True).start()
+
+ def _work_analyze(self) -> None:
+ from ..core import diagnostic
+
+ try:
+ result = diagnostic.analyze_crash()
+ except Exception:
+ result = None
+ self._analyzed.emit(result)
+
+ def _show_analysis(self, result) -> None:
+ self._analyze_btn.setEnabled(True)
+ if result is not None:
+ DiagnosticDialog(result, self).exec()
+ self._refresh_sources()
+
# --- refresh ---------------------------------------------------------------
def _refresh_status(self) -> None:
pid = reccontrol.running_pid()
@@ -162,8 +209,10 @@ class RecorderPage(QWidget):
self._interval.setEnabled(not running)
if status:
+ game = status.get("game")
+ game_line = f"Game: {game} " if game else ""
self._info.setText(
- f"Samples: {status.get('samples', 0)} "
+ f"{game_line}Samples: {status.get('samples', 0)} "
f"Started: {_fmt_time(status.get('started'))} "
f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
f"Log: {status.get('log', config.LOG_FILE)}"
@@ -179,7 +228,3 @@ class RecorderPage(QWidget):
self._info.setText("No recording yet. Press “Start recording”.")
self._latest.setText("")
self._warn.setVisible(False)
-
- def _load_report(self) -> None:
- summary = summarize(config.LOG_FILE, last_n=10)
- self._report.setPlainText(render_summary(summary, log_path=config.LOG_FILE))
diff --git a/src/rigdoctor/gui/setup_page.py b/src/rigdoctor/gui/setup_page.py
index dcfb254..b72d790 100644
--- a/src/rigdoctor/gui/setup_page.py
+++ b/src/rigdoctor/gui/setup_page.py
@@ -1,4 +1,4 @@
-"""Setup page (M9 in the GUI): show environment + optional components, install missing."""
+"""Settings page: components/deps, alerts (M8), account access (token), and uninstall."""
from __future__ import annotations
@@ -8,7 +8,10 @@ from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QApplication,
+ QCheckBox,
+ QDoubleSpinBox,
QFrame,
+ QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
)
from .. import config
-from ..core import installer, sysenv, uninstall, updates
+from ..core import alerts, installer, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN
@@ -49,6 +52,7 @@ _BACKEND_DESC = {
class SetupPage(QWidget):
_installed = Signal(int, str)
_upd_state = Signal(object)
+ changed = Signal() # alert settings saved — main window re-applies them live
def __init__(self) -> None:
super().__init__()
@@ -60,7 +64,7 @@ class SetupPage(QWidget):
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
- title = QLabel("Setup")
+ title = QLabel("Settings")
title.setObjectName("PageTitle")
root.addWidget(title)
@@ -70,7 +74,7 @@ class SetupPage(QWidget):
env_layout.addWidget(self._env)
root.addWidget(env_card)
- comp_card, comp_layout = _panel("Optional components")
+ comp_card, comp_layout = _panel("Components & dependencies")
self._components = QVBoxLayout()
self._components.setSpacing(6)
comp_layout.addLayout(self._components)
@@ -86,6 +90,39 @@ class SetupPage(QWidget):
comp_layout.addLayout(controls)
root.addWidget(comp_card)
+ # Alerts (M8) — folded in from the old Notifications page.
+ alerts_card, alerts_layout = _panel("Notifications")
+ self._alerts_enabled = QCheckBox("Enable desktop notifications")
+ alerts_layout.addWidget(self._alerts_enabled)
+ grid = QGridLayout()
+ grid.setHorizontalSpacing(12)
+ grid.setColumnStretch(2, 1)
+ self._gpu_alert = self._spin()
+ self._cpu_alert = self._spin()
+ grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
+ grid.addWidget(self._gpu_alert, 0, 1)
+ grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
+ grid.addWidget(self._cpu_alert, 1, 1)
+ alerts_layout.addLayout(grid)
+ alerts_note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
+ alerts_note.setObjectName("Muted")
+ alerts_note.setWordWrap(True)
+ alerts_layout.addWidget(alerts_note)
+ alerts_buttons = QHBoxLayout()
+ save_alerts = QPushButton("Save")
+ save_alerts.setObjectName("PrimaryButton")
+ save_alerts.clicked.connect(self._save_alerts)
+ test_alerts = QPushButton("Send test")
+ test_alerts.clicked.connect(self._test_alerts)
+ alerts_buttons.addWidget(save_alerts)
+ alerts_buttons.addWidget(test_alerts)
+ alerts_buttons.addStretch(1)
+ self._alerts_status = QLabel("")
+ self._alerts_status.setObjectName("Muted")
+ alerts_buttons.addWidget(self._alerts_status)
+ alerts_layout.addLayout(alerts_buttons)
+ root.addWidget(alerts_card)
+
# Account access (M13/M12): one Gitea token gates updates and session sharing.
upd_card, upd_layout = _panel("Account access")
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
@@ -115,7 +152,7 @@ class SetupPage(QWidget):
self._output = QTextEdit()
self._output.setObjectName("Report")
self._output.setReadOnly(True)
- self._output.setMinimumHeight(180)
+ self._output.setMinimumHeight(160)
self._output.setVisible(False)
root.addWidget(self._output)
root.addStretch(1)
@@ -129,8 +166,39 @@ class SetupPage(QWidget):
root.addLayout(danger)
self._refresh()
+ self._load_alerts()
self._refresh_update_status()
+ # --- alerts (M8) ----------------------------------------------------------
+ @staticmethod
+ def _spin() -> QDoubleSpinBox:
+ spin = QDoubleSpinBox()
+ spin.setRange(40, 110)
+ spin.setDecimals(0)
+ spin.setSingleStep(1)
+ spin.setSuffix(" °C")
+ return spin
+
+ def _load_alerts(self) -> None:
+ cfg = config.load_config()
+ self._alerts_enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
+ self._gpu_alert.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
+ self._cpu_alert.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
+
+ def _save_alerts(self) -> None:
+ config.update_config(
+ alerts_enabled=self._alerts_enabled.isChecked(),
+ gpu_temp_alert=self._gpu_alert.value(),
+ cpu_temp_alert=self._cpu_alert.value(),
+ )
+ self.changed.emit()
+ self._alerts_status.setText("Saved.")
+
+ def _test_alerts(self) -> None:
+ ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
+ self._alerts_status.setText(
+ "Test sent." if ok else "notify-send not found — install libnotify-bin above.")
+
def _uninstall(self) -> None:
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning)
diff --git a/src/rigdoctor/gui/theme.py b/src/rigdoctor/gui/theme.py
index ea23be9..a5f79c6 100644
--- a/src/rigdoctor/gui/theme.py
+++ b/src/rigdoctor/gui/theme.py
@@ -77,6 +77,7 @@ QPushButton#NavButton {{
}}
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
+QLabel#NavSection {{ color: {MUTED}; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 12px 0; }}
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
QPushButton#CardHeader {{
diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py
index a65a9cb..786c45a 100644
--- a/tests/test_gui_smoke.py
+++ b/tests/test_gui_smoke.py
@@ -36,7 +36,8 @@ class GuiSmokeTests(unittest.TestCase):
mock.patch.object(updates, "update_state", return_value=(updates.UP_TO_DATE, None, "")):
window = mw.MainWindow()
try:
- self.assertEqual(len(window._nav_buttons), len(mw._NAV_ITEMS))
+ self.assertEqual(len(window._nav_buttons), len(mw._PAGES))
+ self.assertEqual(set(window._nav_buttons), set(mw._PAGES))
finally:
window._worker.stop()