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()