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