Files
Panama/config/dot/quickshell/scripts/panama-gaming
T
Gabriel Brown 4cbe3b882a 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
2026-08-19 18:55:10 -04:00

347 lines
13 KiB
Python
Executable File

#!/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:]))