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