From 4cbe3b882af076d084212484ea4daf196c06ffae Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 19 Aug 2026 18:55:10 -0400 Subject: [PATCH] Add a Gaming page, and let the desktop react to games Live first, because unlike every other page here this one has a live dimension: card temperature, power draw, whether Game Mode actually engaged. It polls only while it is open, since a settings page nobody is looking at has no business waking the CPU. The part that makes it Panama's page rather than a gamemode config editor is the hook. gamemode runs a script when a game asks for it and another when the game exits, so the power profile switches to performance and notifications go quiet for exactly the duration of a game -- and afterwards both go back to what they WERE, not to a default. A Do Not Disturb someone set by hand survives a game; a power profile someone chose is restored rather than replaced. Verified against real gamemode activation, not merely by calling the hook. Two things the page reports rather than hides. Game Mode's headline trick is switching the CPU governor to performance, and this machine already runs performance, so it says so instead of implying it helps. And Proton builds are listed but never chosen: Steam picks the runtime per game, and a control here would claim an authority this page does not have. The hook first called a notifications function that did not exist, and the one that did was a TOGGLE -- the wrong primitive entirely, since toggling at game start would unsilence notifications that were already silent. The shell gained an explicit setter and reader. search-routing-contract kept its own hand-written list of every page, which made adding one fail as "not a known page" -- a sixth place to register a page and a sixth chance to forget. It now derives the mapping from the shell, which already knows it. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/PreferenceSchema.qml | 21 ++ .../modules/settings/GamingPage.qml | 240 ++++++++++++ .../modules/settings/SettingsShell.qml | 2 + .../modules/settings/SettingsSidebar.qml | 1 + config/dot/quickshell/modules/settings/qmldir | 1 + config/dot/quickshell/scripts/panama-gaming | 346 ++++++++++++++++++ config/dot/quickshell/services/Gaming.qml | 129 +++++++ .../quickshell/services/SettingsSearch.qml | 7 +- config/dot/quickshell/services/ShellState.qml | 2 +- config/dot/quickshell/shell.qml | 10 + .../share/vicinae/scripts/settings-gaming.sh | 10 + docs/settings.md | 12 +- .../plans/2026-08-19-settings-beyond-gnome.md | 23 ++ tests/quickshell/gaming-contract.sh | 96 +++++ tests/quickshell/search-routing-contract.sh | 41 +-- 15 files changed, 915 insertions(+), 26 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/GamingPage.qml create mode 100755 config/dot/quickshell/scripts/panama-gaming create mode 100644 config/dot/quickshell/services/Gaming.qml create mode 100755 config/local/share/vicinae/scripts/settings-gaming.sh create mode 100755 tests/quickshell/gaming-contract.sh diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index d2074cb..1d5a373 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -724,6 +724,27 @@ Singleton { hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" } }, + // ── Gaming ────────────────────────────────────────────────────────── + // What Panama does while a game runs. gamemode tells us when that + // starts and stops through its own hook scripts, so these are real + // behaviours rather than hints -- and each one is undone afterwards to + // whatever it was before, not to a default. + { + key: "gamingPerformanceProfile", type: "bool", def: true, group: "gaming", + label: "Use the performance power profile", + detail: "Switches while a game runs and switches back when it exits" + }, + { + key: "gamingSilenceNotifications", type: "bool", def: true, group: "gaming", + label: "Silence notifications", + detail: "Do Not Disturb for the duration, so nothing steals focus mid-game. A Do Not Disturb you set yourself is left alone." + }, + { + key: "gamingNotifyOnStart", type: "bool", def: false, group: "gaming", + label: "Say when Game Mode engages", + detail: "A notification when a game requests it, which is otherwise invisible" + }, + // ── Night light ───────────────────────────────────────────────────── { key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", diff --git a/config/dot/quickshell/modules/settings/GamingPage.qml b/config/dot/quickshell/modules/settings/GamingPage.qml new file mode 100644 index 0000000..bbed160 --- /dev/null +++ b/config/dot/quickshell/modules/settings/GamingPage.qml @@ -0,0 +1,240 @@ +// Gaming: what the machine is doing now, and what it should do while you play. +// +// Live first, because unlike every other page here this one has a genuinely +// live dimension -- "is Game Mode actually on, and how hot is the card" is the +// question that brings someone here mid-session. +// +// The part that makes this Panama's page rather than a gamemode config editor +// is "While a game is running": gamemode runs a script when a game starts and +// another when it exits, so the power profile and Do Not Disturb can follow the +// game and be put back afterwards -- back to what they were, not to a default. + +import Quickshell +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + objectName: "gaming" + title: "Gaming" + lede: "What this machine is doing, and how it should behave while you play." + + readonly property var gpu: Gaming.primaryGpu + + // Polling only while this page is on screen. + Component.onCompleted: { + Gaming.refresh(); + Gaming.watching = true; + } + Component.onDestruction: Gaming.watching = false + + TextRow { + visible: Gaming.lastError !== "" + label: "Gaming needs attention" + detail: Gaming.lastError + value: "" + divider: false + } + + // ── Right now ──────────────────────────────────────────────────────────── + + SettingsCard { + Column { + width: parent.width + spacing: 8 + + Row { + width: parent.width + spacing: 12 + + Text { + text: Gaming.active ? "Playing" : "Idle" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeTitle + font.weight: Font.DemiBold + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Gaming.active + ? "Game Mode is engaged" + : "No game has requested Game Mode" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + } + + Repeater { + model: Gaming.gpus + + delegate: TextRow { + required property var modelData + required property int index + width: parent.width + label: modelData.discrete ? "Graphics card" : "Integrated graphics" + detail: String(modelData.driver ?? "") + + (modelData.discrete ? " · the one games use" : " · idle unless something asks for it") + value: Gaming.gpuSummary(modelData) + divider: true + } + } + + TextRow { + label: "CPU governor" + detail: "What the cores are scaling to right now" + value: String(Gaming.gameMode?.governorNow ?? "unknown") + divider: false + } + } + + // ── What Panama does about it ──────────────────────────────────────────── + + SettingsCard { + title: "While a game is running" + subtitle: Gaming.gameMode?.hooksInstalled === true + ? "Panama reacts when Game Mode engages, and puts everything back when the game exits." + : "Panama can react when Game Mode engages. This needs a hook in gamemode's configuration." + + ActionRow { + visible: Gaming.gameMode?.hooksInstalled !== true + label: "Let Panama react to games" + detail: "Adds a start and end hook to your gamemode configuration. Nothing else in that file is touched." + action: "Enable" + enabled: !Gaming.busy && Gaming.gameMode?.available === true + onTriggered: Gaming.installHooks() + } + + ToggleRow { + visible: Gaming.gameMode?.hooksInstalled === true + setting: "gamingPerformanceProfile" + } + + ToggleRow { + visible: Gaming.gameMode?.hooksInstalled === true + setting: "gamingSilenceNotifications" + } + + ToggleRow { + visible: Gaming.gameMode?.hooksInstalled === true + setting: "gamingNotifyOnStart" + } + + ActionRow { + visible: Gaming.gameMode?.hooksInstalled === true + label: "Stop reacting to games" + detail: "Removes the hooks. The settings above are kept." + action: "Disable" + enabled: !Gaming.busy + divider: false + onTriggered: Gaming.removeHooks() + } + } + + // ── Game Mode itself ───────────────────────────────────────────────────── + + SettingsCard { + title: "Game Mode" + subtitle: Gaming.gameMode?.available === true + ? "Applied by gamemode to a game while it runs, then undone." + : "gamemode is not installed." + + TextRow { + label: "Daemon" + detail: Gaming.gameMode?.daemonRunning === true + ? "Running, waiting for a game to ask" + : "Not running, so no game can request it" + value: Gaming.gameMode?.daemonRunning === true ? "Running" : "Stopped" + } + + // Said plainly rather than implied: on a machine already running the + // governor gamemode would switch to, its headline effect is nothing. + TextRow { + label: "Governor while gaming" + detail: Gaming.governorAlreadyThere + ? "This machine already runs that governor, so Game Mode changes nothing here" + : "Switched for the duration of the game" + value: String(Gaming.gameMode?.governorWhileGaming ?? "unknown") + divider: false + } + } + + // ── Overlay ────────────────────────────────────────────────────────────── + + SettingsCard { + title: "Performance overlay" + subtitle: Gaming.overlay?.installed === true + ? "MangoHud, drawn on top of the game." + : "MangoHud is not installed." + + SwitchRow { + label: "Show the overlay in games" + detail: "Takes effect for games launched after your next sign-in, because it is read from the session environment" + checked: Gaming.overlay?.globallyEnabled === true + enabled: !Gaming.busy && Gaming.overlay?.installed === true + onToggled: value => Gaming.setOverlay(value) + } + + SegmentRow { + label: "What it shows" + detail: "Frame rate alone, or the full readout with GPU and CPU" + options: [ + { value: "fps", label: "Frame rate" }, + { value: "detailed", label: "Detailed" } + ] + value: String(Gaming.overlay?.preset ?? "fps") + enabled: !Gaming.busy && Gaming.overlay?.installed === true + onSelected: value => Gaming.setOverlayPreset(value) + } + + TextRow { + label: "Toggle in game" + detail: "Shows and hides the overlay without leaving the game" + value: String(Gaming.overlay?.toggleKey ?? "") + divider: false + } + } + + // ── Library ────────────────────────────────────────────────────────────── + + SettingsCard { + title: "Library" + subtitle: "Where games live, and what can run them." + + TextRow { + label: "Installed games" + detail: String(Gaming.library?.path ?? "") + value: String(Gaming.library?.games ?? 0) + } + + // Listed, not chosen. Steam picks the runtime per game in its own + // properties, and a control here would be claiming an authority this + // page does not have. + Repeater { + model: Gaming.library?.protonBuilds ?? [] + + delegate: TextRow { + required property var modelData + required property int index + width: parent.width + label: String(modelData.name ?? "") + detail: modelData.community === true + ? "Community build · chosen per game in Steam" + : "Valve · chosen per game in Steam" + value: "Installed" + divider: true + } + } + + TextRow { + label: "gamescope" + detail: "Micro-compositor for scaling and frame limiting, used per game from Steam" + value: Gaming.library?.gamescope === true ? "Available" : "Not installed" + divider: false + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 1522a7c..52e29c5 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -109,6 +109,7 @@ Rectangle { case "home-phone": return homePhonePage; case "desktop": return desktopPage; case "sound": return soundPage; + case "gaming": return gamingPage; case "notifications": return notificationsPage; case "screen-intelligence": return screenIntelligencePage; case "shortcuts": return shortcutsPage; @@ -179,6 +180,7 @@ Rectangle { Component { id: homePhonePage; HomePhonePage {} } Component { id: desktopPage; DesktopPage {} } Component { id: soundPage; SoundPage {} } + Component { id: gamingPage; GamingPage {} } Component { id: notificationsPage; NotificationsPage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: shortcutsPage; ShortcutsPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index c11930a..db21ac5 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -31,6 +31,7 @@ Rectangle { { page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" }, { page: "sound", label: "Sound", icon: "\u{F057E}" }, + { page: "gaming", label: "Gaming", icon: "\u{F0297}" }, { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index b0cd7d9..0a0e4c7 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -3,6 +3,7 @@ AboutPage 1.0 AboutPage.qml AppearancePage 1.0 AppearancePage.qml AvatarPicker 1.0 AvatarPicker.qml ConnectivityPage 1.0 ConnectivityPage.qml +GamingPage 1.0 GamingPage.qml HomePhonePage 1.0 HomePhonePage.qml HomeFavoriteCard 1.0 HomeFavoriteCard.qml AvailableLightRow 1.0 AvailableLightRow.qml diff --git a/config/dot/quickshell/scripts/panama-gaming b/config/dot/quickshell/scripts/panama-gaming new file mode 100755 index 0000000..0b5b5bf --- /dev/null +++ b/config/dot/quickshell/scripts/panama-gaming @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 + +"""Gaming: what the machine is doing, and what it should do while you play. + +The interesting part is not reporting gamemode's state -- it is reacting to it. +gamemode can run a script when a game asks for it and another when the game +exits, so `hook start` and `hook end` are what let Panama switch the power +profile and silence notifications for exactly the duration of a game, and put +both back afterwards. Everything else here is honest reporting. + + panama-gaming snapshot + panama-gaming set-overlay true|false + panama-gaming set-overlay-preset fps|detailed + panama-gaming install-hooks | remove-hooks + panama-gaming hook start|end (called by gamemode, not by a person) +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +MANGOHUD_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "MangoHud" / "MangoHud.conf" +GAMEMODE_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "gamemode.ini" +ENVIRONMENT_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "environment.d" / "panama-mangohud.conf" + +# What the overlay shows. Deliberately two presets rather than exposing every +# MangoHud key: this is a settings page, not a config file with a nicer font. +PRESETS = { + "fps": ["fps", "frametime=0", "no_display=0", "position=top-left", + "font_size=22", "background_alpha=0.4", "toggle_hud=Shift_R+F12"], + "detailed": ["fps", "frametime", "gpu_stats", "gpu_temp", "gpu_power", + "cpu_stats", "cpu_temp", "ram", "vram", "position=top-left", + "font_size=20", "background_alpha=0.4", "toggle_hud=Shift_R+F12"], +} + + +class BoundaryError(RuntimeError): + """A user-visible failure.""" + + +def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess: + try: + return subprocess.run(command, capture_output=True, text=True, + timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise BoundaryError(f"{command[0]} did not answer.") from error + + +def read_int(path: Path) -> int | None: + try: + return int(path.read_text().strip()) + except (OSError, ValueError): + return None + + +def gpus() -> list[dict]: + """Every AMD/Intel GPU with a hwmon node, warmest first. + + Read from sysfs rather than a tool: it costs nothing, needs no daemon, and + a page that polls while it is open should not be spawning processes. + """ + found = [] + for hwmon in sorted(Path("/sys/class/hwmon").glob("hwmon*")): + try: + name = (hwmon / "name").read_text().strip() + except OSError: + continue + if name not in ("amdgpu", "i915", "xe", "nouveau"): + continue + device = (hwmon / "device").resolve() + temperature = read_int(hwmon / "temp1_input") + power = read_int(hwmon / "power1_average") + used = read_int(device / "mem_info_vram_used") + total = read_int(device / "mem_info_vram_total") + model = "" + try: + model = (device / "product_name").read_text().strip() + except OSError: + model = "" + found.append({ + "driver": name, + "model": model, + "temperatureC": round(temperature / 1000, 1) if temperature else None, + "watts": round(power / 1000000, 1) if power else None, + "vramUsedBytes": used or 0, + "vramTotalBytes": total or 0, + # A card with no VRAM reported is the integrated one sharing system + # memory; saying "0 of 0 GB" would look broken. + "discrete": bool(total and total > 1073741824), + }) + found.sort(key=lambda entry: (not entry["discrete"], entry["driver"])) + return found + + +def governor() -> str: + try: + return Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").read_text().strip() + except OSError: + return "" + + +def game_mode() -> dict: + available = bool(shutil.which("gamemoded")) + state = {"available": available, "active": False, "daemonRunning": False, + "governorWhileGaming": "", "governorNow": governor()} + if not available: + return state + status = run(["gamemoded", "-s"]).stdout.strip() + state["active"] = "is active" in status + state["daemonRunning"] = "gamemode is" in status + + # What it would switch the governor to, from its own configuration. + for path in (GAMEMODE_CONFIG, Path("/usr/share/gamemode/gamemode.ini")): + try: + text = path.read_text() + except OSError: + continue + found = re.search(r"^\s*desiredgov\s*=\s*(\S+)", text, re.M) + if found: + state["governorWhileGaming"] = found.group(1) + break + return state + + +def hooks_installed() -> bool: + try: + text = GAMEMODE_CONFIG.read_text() + except OSError: + return False + return "panama-gaming hook start" in text + + +def overlay() -> dict: + installed = bool(shutil.which("mangohud")) + preset = "" + if MANGOHUD_CONFIG.is_file(): + try: + body = MANGOHUD_CONFIG.read_text() + preset = "detailed" if "gpu_stats" in body else "fps" + except OSError: + preset = "" + return { + "installed": installed, + "configured": MANGOHUD_CONFIG.is_file(), + "preset": preset or "fps", + # Global enablement is an environment variable read at session start, so + # a change here does not affect anything already running. + "globallyEnabled": ENVIRONMENT_CONFIG.is_file(), + "toggleKey": "Shift_R+F12", + } + + +def library() -> dict: + root = Path.home() / ".local/share/Steam" + games = len(list((root / "steamapps").glob("*.acf"))) if (root / "steamapps").is_dir() else 0 + tools = [] + for directory in (root / "compatibilitytools.d", root / "steamapps/common"): + if not directory.is_dir(): + continue + for entry in sorted(directory.iterdir()): + if entry.is_dir() and entry.name.lower().startswith("proton"): + tools.append({ + "name": entry.name, + "community": directory.name == "compatibilitytools.d", + }) + return { + "path": str(root), + "games": games, + "protonBuilds": tools, + "gamescope": bool(shutil.which("gamescope")), + "steam": bool(shutil.which("steam")), + } + + +def snapshot() -> dict: + return { + "gameMode": {**game_mode(), "hooksInstalled": hooks_installed()}, + "gpus": gpus(), + "overlay": overlay(), + "library": library(), + "error": "", + } + + +def set_overlay_preset(preset: str) -> None: + if preset not in PRESETS: + raise BoundaryError("That is not an overlay preset.") + MANGOHUD_CONFIG.parent.mkdir(parents=True, exist_ok=True) + header = ("# Written by Panama's Gaming settings.\n" + "# Edits here are replaced when the preset changes.\n") + MANGOHUD_CONFIG.write_text(header + "\n".join(PRESETS[preset]) + "\n", encoding="utf-8") + + +def set_overlay(enabled: bool) -> None: + if not shutil.which("mangohud"): + raise BoundaryError("MangoHud is not installed.") + if enabled: + if not MANGOHUD_CONFIG.is_file(): + set_overlay_preset("fps") + ENVIRONMENT_CONFIG.parent.mkdir(parents=True, exist_ok=True) + ENVIRONMENT_CONFIG.write_text( + "# Written by Panama's Gaming settings.\n" + "# Read when the session starts, so this reaches applications\n" + "# launched afterwards rather than ones already running.\n" + "MANGOHUD=1\n", encoding="utf-8") + else: + try: + ENVIRONMENT_CONFIG.unlink() + except OSError: + pass + + +def install_hooks() -> None: + """Point gamemode's start and end hooks at this script. + + Written by editing rather than replacing: gamemode.ini is the user's file + and may hold settings this page does not manage. + """ + script = str(Path(__file__).resolve()) + lines = [] + if GAMEMODE_CONFIG.is_file(): + lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines() + if "panama-gaming hook" not in line] + text = "\n".join(lines) + if "[custom]" not in text: + text += "\n\n[custom]\n" + text = re.sub(r"\[custom\]\n", + f"[custom]\nstart={script} hook start\nend={script} hook end\n", + text, count=1) + GAMEMODE_CONFIG.parent.mkdir(parents=True, exist_ok=True) + GAMEMODE_CONFIG.write_text(text.strip() + "\n", encoding="utf-8") + + +def remove_hooks() -> None: + if not GAMEMODE_CONFIG.is_file(): + return + lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines() + if "panama-gaming hook" not in line] + GAMEMODE_CONFIG.write_text("\n".join(lines).strip() + "\n", encoding="utf-8") + + +def hook(phase: str) -> None: + """Called by gamemode when a game starts and when it stops. + + Reads what the user asked for from the settings file directly: the shell may + not be running, and a hook that depends on a running shell would silently do + nothing for someone who restarted it mid-session. + """ + if phase not in ("start", "end"): + raise BoundaryError("That is not a hook phase.") + + settings_path = (Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + / "panama" / "settings.json") + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + settings = {} + + scripts = Path(__file__).resolve().parent + # What was true before the game started, so ending it restores that rather + # than imposing a default. Without this, finishing a game would silently + # undo a power profile or a Do Not Disturb the user had chosen themselves. + state_path = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-gaming.json" + + if phase == "start": + before = {} + if settings.get("gamingPerformanceProfile", True): + current = run([str(scripts / "panama-power-profile"), "list"], timeout=15) + try: + before["profile"] = json.loads(current.stdout or "{}").get("active", "") + except json.JSONDecodeError: + before["profile"] = "" + run([str(scripts / "panama-power-profile"), "set", "performance"], timeout=15) + + if settings.get("gamingSilenceNotifications", True) and shutil.which("qs"): + was_silent = run(["qs", "ipc", "call", "notifications", "dndState"], + timeout=10).stdout.strip() == "true" + before["silenced"] = was_silent + if not was_silent: + run(["qs", "ipc", "call", "notifications", "setDnd", "true"], timeout=10) + + try: + state_path.write_text(json.dumps(before), encoding="utf-8") + except OSError: + pass + + if settings.get("gamingNotifyOnStart", False) and shutil.which("notify-send"): + run(["notify-send", "-a", "Panama", "Game Mode", + "Performance profile engaged, notifications silenced."], timeout=10) + return + + # end + try: + before = json.loads(state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + before = {} + if before.get("profile"): + run([str(scripts / "panama-power-profile"), "set", before["profile"]], timeout=15) + # Only un-silence if this turned it on. + if before.get("silenced") is False and shutil.which("qs"): + run(["qs", "ipc", "call", "notifications", "setDnd", "false"], timeout=10) + try: + state_path.unlink() + except OSError: + pass + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["snapshot"]: + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + if len(arguments) == 2 and arguments[0] == "hook": + hook(arguments[1]) + return 0 + if len(arguments) == 2 and arguments[0] == "set-overlay": + set_overlay(arguments[1] == "true") + elif len(arguments) == 2 and arguments[0] == "set-overlay-preset": + set_overlay_preset(arguments[1]) + elif arguments == ["install-hooks"]: + install_hooks() + elif arguments == ["remove-hooks"]: + remove_hooks() + else: + raise BoundaryError( + "Usage: panama-gaming snapshot | set-overlay true|false | " + "set-overlay-preset fps|detailed | install-hooks | remove-hooks | " + "hook start|end") + except BoundaryError as error: + state = snapshot() + state["error"] = str(error) + print(json.dumps(state, separators=(",", ":"))) + return 0 + + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/services/Gaming.qml b/config/dot/quickshell/services/Gaming.qml new file mode 100644 index 0000000..226ff9d --- /dev/null +++ b/config/dot/quickshell/services/Gaming.qml @@ -0,0 +1,129 @@ +pragma Singleton + +// Gaming: what the machine is doing, and what it should do while you play. +// +// The reporting half is cheap -- GPU sensors come from sysfs, gamemode from its +// own daemon -- so this can poll while its page is open. It only polls then: +// a settings page nobody is looking at has no business waking the CPU twice a +// second. +// +// The acting half is not in this file at all. gamemode runs a script when a +// game starts and another when it exits, and that script reads the preferences +// directly, because the shell may have been restarted since the game launched. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gaming" + + property var gameMode: ({}) + property var gpus: [] + property var overlay: ({}) + property var library: ({}) + property bool scanned: false + property string lastError: "" + + // Set by the page while it is visible. Nothing polls otherwise. + property bool watching: false + + readonly property bool busy: query.running || mutation.running + + readonly property bool active: root.gameMode?.active === true + readonly property var primaryGpu: { + for (const gpu of root.gpus) { + if (gpu.discrete) + return gpu; + } + return root.gpus.length > 0 ? root.gpus[0] : null; + } + + // The honest version: gamemode's headline trick is switching the governor, + // and it does nothing if the machine already runs that governor. + readonly property bool governorAlreadyThere: + String(root.gameMode?.governorNow ?? "") !== "" + && root.gameMode?.governorNow === root.gameMode?.governorWhileGaming + + function formatBytes(bytes: real): string { + if (!(bytes > 0)) + return "0 GB"; + return (bytes / 1073741824).toFixed(bytes < 10737418240 ? 1 : 0) + " GB"; + } + + function gpuSummary(gpu: var): string { + if (!gpu) + return ""; + const parts = []; + if (gpu.temperatureC !== null && gpu.temperatureC !== undefined) + parts.push(gpu.temperatureC + " °C"); + if (gpu.watts !== null && gpu.watts !== undefined && gpu.watts > 0) + parts.push(gpu.watts + " W"); + if (Number(gpu.vramTotalBytes ?? 0) > 0) + parts.push(root.formatBytes(gpu.vramUsedBytes) + " / " + + root.formatBytes(gpu.vramTotalBytes)); + return parts.join(" · "); + } + + function refresh(): void { + if (query.running) + return; + query.command = [root.helperPath, "snapshot"]; + query.running = true; + } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.gameMode = parsed.gameMode ?? ({}); + root.gpus = Array.isArray(parsed.gpus) ? parsed.gpus : []; + root.overlay = parsed.overlay ?? ({}); + root.library = parsed.library ?? ({}); + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.lastError = "Could not read the gaming helper's answer."; + console.warn("Gaming: could not parse helper output:", error); + } + root.scanned = true; + } + + function run(arguments: var): void { + if (mutation.running) + return; + root.lastError = ""; + mutation.command = [root.helperPath].concat(arguments); + mutation.running = true; + } + + function setOverlay(enabled: bool): void { root.run(["set-overlay", enabled ? "true" : "false"]); } + function setOverlayPreset(preset: string): void { root.run(["set-overlay-preset", preset]); } + function installHooks(): void { root.run(["install-hooks"]); } + function removeHooks(): void { root.run(["remove-hooks"]); } + + Process { + id: query + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + Process { + id: mutation + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + // Three seconds: fast enough that a temperature reading feels live, slow + // enough that it is not a background task of its own. + Timer { + running: root.watching + interval: 3000 + repeat: true + onTriggered: root.refresh() + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 50ba46b..ffcf40d 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -51,7 +51,8 @@ Singleton { "notices": "desktop", "weather": "home", "notifications": "notifications", - "capture": "screen-intelligence" + "capture": "screen-intelligence", + "gaming": "gaming" }) // Settings that are real but have no schema entry, because the system owns @@ -78,6 +79,10 @@ Singleton { { label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" }, { label: "Network name", detail: "The name other machines see", page: "sharing" }, { label: "File sharing", detail: "Share folders on the network", page: "sharing" }, + { label: "Game Mode", detail: "What happens while a game is running", page: "gaming" }, + { label: "Performance overlay", detail: "Frame rate and sensors on top of the game", page: "gaming" }, + { label: "Proton", detail: "Compatibility tools available to Steam", page: "gaming" }, + { label: "Graphics card", detail: "Temperature, power draw, and video memory", page: "gaming" }, { label: "Software update", detail: "Packages, applications, and firmware", page: "updates" }, { label: "Updates", detail: "What is waiting to be installed", page: "updates" }, { label: "Firmware", detail: "Updates for the hardware itself", page: "updates" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 31cc9a6..8997ba5 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "printers", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "printers", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index cc2546c..daa430c 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -467,6 +467,16 @@ ShellRoot { Notifs.doNotDisturb = !Notifs.doNotDisturb; return Notifs.doNotDisturb; } + + // Explicit set and read, which a script needs. A toggle is the wrong + // primitive for "silence while a game runs": if notifications were + // already silenced, toggling at game start would UNsilence them. + function setDnd(enabled: bool): bool { + Notifs.doNotDisturb = enabled; + return Notifs.doNotDisturb; + } + + function dndState(): bool { return Notifs.doNotDisturb; } } IpcHandler { diff --git a/config/local/share/vicinae/scripts/settings-gaming.sh b/config/local/share/vicinae/scripts/settings-gaming.sh new file mode 100755 index 0000000..464db03 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-gaming.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: Gaming +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open Gaming in Settings. +# @vicinae.keywords ["settings", "use the performance power profile", "silence notifications", "say when game mode engages", "game mode", "performance overlay", "proton", "graphics card"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page gaming diff --git a/docs/settings.md b/docs/settings.md index 07af177..043f140 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -128 settings across 26 groups. 67 of them are applied to the compositor and confirmed by reading the value back. +131 settings across 27 groups. 67 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -108,6 +108,16 @@ Found on **Desktop & Dock**. |---|---|---| | **Focus session length**
`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. | +## gaming + +Found on **gaming**. + +| Setting | Default | What it does | +|---|---|---| +| **Use the performance power profile**
`gamingPerformanceProfile` | true | Switches while a game runs and switches back when it exits | +| **Silence notifications**
`gamingSilenceNotifications` | true | Do Not Disturb for the duration, so nothing steals focus mid-game. A Do Not Disturb you set yourself is left alone. | +| **Say when Game Mode engages**
`gamingNotifyOnStart` | false | A notification when a game requests it, which is otherwise invisible | + ## idle Found on **Power & Lock**. diff --git a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md index 6e4bc10..a547426 100644 --- a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md +++ b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md @@ -200,6 +200,29 @@ where the library lives. should own the handful of things that are actually settings and link out for the rest. +**Landed 2026-08-19.** Live-first, because unlike every other page here this one +has a genuinely live dimension: card temperature, power draw, whether Game Mode +actually engaged. It polls only while it is open. + +The part that makes it Panama's page rather than a gamemode config editor is the +hook. gamemode runs a script when a game asks for it and another when the game +exits, so the power profile switches to performance and notifications go quiet +for exactly the duration of a game -- and afterwards **both go back to what they +were, not to a default**. A Do Not Disturb someone set by hand survives a game; +a power profile someone chose is restored rather than replaced with "balanced". +Verified with real gamemode activation, not just by calling the hook. + +Two honest reports the page makes rather than hides. Game Mode's headline trick +is switching the CPU governor to performance, and this machine already runs +performance, so it says the change does nothing here. And Proton builds are +listed but never chosen: Steam picks the runtime per game, and a control here +would claim an authority the page does not have. + +One bug worth recording. The hook first called a notifications IPC function that +did not exist, and the one that did exist was a TOGGLE -- which is the wrong +primitive entirely, because toggling at game start would unsilence notifications +that were already silenced. The shell gained an explicit setter and reader. + --- ## Batch 4 — The developer surface diff --git a/tests/quickshell/gaming-contract.sh b/tests/quickshell/gaming-contract.sh new file mode 100755 index 0000000..a083a42 --- /dev/null +++ b/tests/quickshell/gaming-contract.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +# What Panama does while a game runs must be undone afterwards -- to what was +# there before, not to a default. +# +# That distinction is the whole feature. If ending a game forced Do Not Disturb +# off, it would silently undo a Do Not Disturb someone set by hand; if it forced +# the power profile to "balanced", it would undo a deliberate choice. Both are +# worse than doing nothing at all, because both look like the desktop +# misbehaving rather than a setting being wrong. +# +# Read-only: it reads gaming state and never engages Game Mode. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-gaming" +service="$repo_dir/config/dot/quickshell/services/Gaming.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml" +shell_file="$repo_dir/config/dot/quickshell/shell.qml" + +fail() { + printf 'gaming contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page" "$shell_file"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-gaming is not executable' + +# ── The hook restores, it does not impose ─────────────────────────────────── +hook_body="$(sed -n '/^def hook/,/^def /p' "$helper")" +[[ -n "$hook_body" ]] || fail 'the hook is missing' +grep -q 'state_path' <<<"$hook_body" \ + || fail 'the hook records nothing about the state before a game, so it cannot restore it' +grep -qE 'before\.get\("profile"\)' <<<"$hook_body" \ + || fail 'the power profile is not restored to what it was' +grep -q 'before.get("silenced") is False' <<<"$hook_body" \ + || fail 'Do Not Disturb is cleared unconditionally, which would undo one the user set themselves' +grep -qE 'set.*"balanced"' <<<"$hook_body" \ + && fail 'the hook restores a hardcoded profile rather than the previous one' + +# ── The shell can be told, not only toggled ───────────────────────────────── +# A toggle is the wrong primitive here: if notifications were already silenced, +# toggling at game start would unsilence them. +grep -q 'function setDnd(enabled: bool)' "$shell_file" \ + || fail 'there is no explicit way to set Do Not Disturb, only a toggle' +grep -q 'function dndState()' "$shell_file" \ + || fail 'there is no way to read Do Not Disturb, so the hook cannot know what to restore' +grep -qE '"notifications",\s*$' <<<"$(grep -A1 'qs", "ipc", "call"' "$helper")" >/dev/null 2>&1 || true +grep -q '"setDnd"' "$helper" \ + || fail 'the hook does not use the explicit setter' + +# ── The hook does not depend on the shell being up ────────────────────────── +# A game can start after a shell restart; a hook that asked the shell for its +# settings would silently do nothing. +grep -q 'settings.json' <<<"$hook_body" \ + || fail 'the hook reads its settings from somewhere other than the settings file' + +# ── Honest reporting ──────────────────────────────────────────────────────── +grep -q 'governorAlreadyThere' "$service" \ + || fail 'the service cannot tell when Game Mode would change nothing' +page_code="$(grep -vE '^\s*//' "$page")" +grep -q 'already runs that governor' <<<"$page_code" \ + || fail 'the page does not say when Game Mode has no effect on this machine' +# Proton is listed, never chosen: Steam owns that per game. +grep -qiE 'setProton|selectProton|chooseProton' <<<"$page_code" \ + && fail 'the page claims to choose the Proton build, which Steam owns per game' + +# ── Polling stops when nobody is looking ──────────────────────────────────── +grep -q 'running: root.watching' "$service" \ + || fail 'the poll timer runs regardless of whether the page is open' +grep -q 'Gaming.watching = false' "$page" \ + || fail 'the page never stops the poll timer, so it would poll forever after being closed' + +command -v jq >/dev/null 2>&1 || { printf 'gaming contract: SKIP (no jq)\n'; exit 0; } + +state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' +jq -e '(.gpus | type == "array") and (.gameMode | type == "object") and (.library | type == "object")' \ + <<<"$state" >/dev/null || fail 'the snapshot is incomplete' +jq -e '.gameMode | has("active") and has("daemonRunning") and has("hooksInstalled")' <<<"$state" >/dev/null \ + || fail 'Game Mode state is incomplete' +# An integrated GPU reporting no video memory must not be described as discrete. +jq -e '[.gpus[] | select(.discrete) | .vramTotalBytes > 0] | all' <<<"$state" >/dev/null \ + || fail 'a card with no video memory is reported as the discrete one' + +[[ -n "$("$helper" set-overlay-preset nonsense 2>/dev/null | jq -r '.error // ""')" ]] \ + || fail 'an unknown overlay preset was accepted' +[[ -n "$("$helper" hook nonsense 2>/dev/null | jq -r '.error // ""')" ]] \ + || fail 'an unknown hook phase was accepted' + +printf 'gaming contract: PASS (%s GPU(s), %s games, hooks %s)\n' \ + "$(jq '.gpus | length' <<<"$state")" \ + "$(jq -r '.library.games' <<<"$state")" \ + "$(jq -r 'if .gameMode.hooksInstalled then "installed" else "not installed" end' <<<"$state")" diff --git a/tests/quickshell/search-routing-contract.sh b/tests/quickshell/search-routing-contract.sh index c49605f..7481c1c 100755 --- a/tests/quickshell/search-routing-contract.sh +++ b/tests/quickshell/search-routing-contract.sh @@ -25,6 +25,7 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" pages_dir="$repo_dir/config/dot/quickshell/modules/settings" +shell_file="$pages_dir/SettingsShell.qml" fail() { printf 'search routing contract: %s\n' "$1" >&2 @@ -36,29 +37,23 @@ routes="$(grep -oE '"[a-zA-Z]+": "[a-z-]+"' "$search" | tr -d '"' | tr ':' ' ')" # page id -> Page component file, as SettingsShell maps them. page_file() { - case "$1" in - home) printf 'HomePage.qml' ;; - appearance) printf 'AppearancePage.qml' ;; - displays) printf 'DisplaysPage.qml' ;; - connectivity) printf 'ConnectivityPage.qml' ;; - home-phone) printf 'HomePhonePage.qml' ;; - desktop) printf 'DesktopPage.qml' ;; - sound) printf 'SoundPage.qml' ;; - notifications) printf 'NotificationsPage.qml' ;; - screen-intelligence) printf 'ScreenIntelligencePage.qml' ;; - shortcuts) printf 'ShortcutsPage.qml' ;; - mouse) printf 'MousePage.qml' ;; - privacy) printf 'PrivacyPage.qml' ;; - region) printf 'RegionPage.qml' ;; - accounts) printf 'OnlineAccountsPage.qml' ;; - accessibility) printf 'AccessibilityPage.qml' ;; - power) printf 'PowerPage.qml' ;; - datetime) printf 'DateTimePage.qml' ;; - applications) printf 'ApplicationsPage.qml' ;; - services) printf 'HealthPage.qml' ;; - about) printf 'AboutPage.qml' ;; - *) printf '' ;; - esac + # Derived from SettingsShell rather than restated here. This was a + # hand-written list of every page, which meant adding one made this contract + # fail with "not a known page" -- a sixth place to register a page, and the + # sixth chance to forget. The shell already maps page -> component id and + # component id -> type, and the type names its file. + local page="$1" component type + component="$(grep -oE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \ + | sed -E 's/.*return ([a-zA-Z]+);/\1/' | head -1)" + if [[ -z "$component" ]]; then + # Home is the switch's default arm rather than a case. + [[ "$page" == "home" ]] || { printf ''; return; } + component="homePage" + fi + type="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file" \ + | sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' | head -1)" + [[ -n "$type" ]] || { printf ''; return; } + printf '%s.qml' "$type" } violations=0