feat(decky): the stats overlay gets an off switch in Gaming Mode

Field report: "as of version 0.23 of this plugin, there is no setting to
toggle off the stat overlay." Correct, and it never had one — no commit in
`clients/decky` has ever touched a stats key. Every other client does:
the GTK dialog, the Windows page, the Apple app, and the console's own
settings screen all carry the four-tier picker.

The tier defaults to on. `Settings::default` is `show_stats: true` and
`stats_verbosity: None`, which `Settings::stats_verbosity` resolves to
Normal — so a Deck that has only ever been configured through this panel
streams with the overlay up and no way here to put it down. What escapes
exist are not discoverable: Ctrl+Alt+Shift+S wants a keyboard, and the
three-finger touchscreen tap is documented in `docs/stats`, not on the
glass. The console's picker is reachable (X on console home), but that is
a different shortcut than the one-tap stream this panel launches, and a
user editing stream settings here has no reason to look there.

So the row lands here, last in the section, matching the console's
wording. It writes `stats_verbosity` AND the legacy `show_stats` in the
same pairing `Settings::set_stats_verbosity` keeps, so a client too old
for the tiers still honours an Off chosen here; it reads them back the
way `Settings::stats_verbosity` does, so a pre-tier file — including
every file this plugin wrote before today — shows the Normal the stream
actually runs at.

`set_settings` stops replacing the file and merges onto it instead. This
JSON is shared with the desktop client and the console, and holds many
more keys than this panel models (decoder, GPU, profiles, touch/mouse
model). The panel reads it once when it mounts, so a wholesale write
posts a snapshot that predates anything another editor stored while it
sat open — silently reverting it. That was invisible until 0.23.0:
`9c5af8d7` fixed the GTK shell handing the session a spec built from
`Settings::default()`, and only since then does this file reach a stream
at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 23:53:57 +02:00
co-authored by Claude Opus 5
parent 0de161e29b
commit 8af6e2dd02
4 changed files with 63 additions and 4 deletions
+19 -3
View File
@@ -1044,20 +1044,36 @@ class Plugin:
try:
return json.loads(_settings_path().read_text())
except (OSError, json.JSONDecodeError):
# The client's own defaults (native display, host-default bitrate, auto pad).
# The client's own defaults (native display, host-default bitrate, auto pad,
# stats overlay at Normal — `Settings::default` is `show_stats: true`).
return {
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto",
"gamepad_forwarding": True, "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False,
"stats_verbosity": "normal", "show_stats": True,
}
async def set_settings(self, settings: dict) -> dict:
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
"""Write the stream settings JSON the (sandboxed) client reads on launch.
MERGED onto whatever is on disk, never a wholesale replace: this file is shared with
the desktop client and the console's settings screen, and it holds far more keys than
this panel models (decoder, GPU, profiles, touch/mouse model…). The panel reads it once
when it mounts, so a straight write would post a snapshot that predates anything those
other editors stored in the meantime — silently reverting it.
"""
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
_settings_path().write_text(json.dumps(settings, indent=2))
try:
on_disk = json.loads(_settings_path().read_text())
if not isinstance(on_disk, dict):
on_disk = {}
except (OSError, json.JSONDecodeError):
on_disk = {} # no file yet (or an unreadable one): this write creates it
on_disk.update(settings)
_settings_path().write_text(json.dumps(on_disk, indent=2))
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write settings")
+7
View File
@@ -122,6 +122,13 @@ export interface StreamSettings {
// here would be a dead one. The desktop client's row still edits this same file.
inhibit_shortcuts: boolean;
mic_enabled: boolean;
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
// which resolves through `show_stats` — read both the way the client's
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
stats_verbosity?: string;
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
// tiers still honours an Off chosen here.
show_stats?: boolean;
}
export interface UpdateInfo {
+35
View File
@@ -55,6 +55,15 @@ const COMPOSITOR_LABELS: Record<string, string> = {
mutter: "GNOME (Mutter)",
gamescope: "gamescope",
};
// The stats-overlay tiers, in the cycle order every other client's picker uses
// (punktfunk_core `StatsVerbosity::ALL`). Stored lowercase — the enum is `rename_all`.
const STATS_TIERS = ["off", "compact", "normal", "detailed"];
const STATS_LABELS: Record<string, string> = {
off: "Off",
compact: "Compact",
normal: "Normal",
detailed: "Detailed",
};
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
@@ -74,6 +83,12 @@ export const SettingsSection: FC = () => {
if (!s) return <Spinner style={{ height: "1.5em" }} />;
// Mirrors `Settings::stats_verbosity`: an absent tier is a pre-tier store, which resolves
// through the legacy `show_stats` bool — and an absent bool is the client's serde default
// (true), so a file this plugin wrote before it carried either key reads as Normal, exactly
// as the stream sees it.
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
@@ -206,6 +221,26 @@ export const SettingsSection: FC = () => {
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
<Field
label="Statistics overlay"
description="The fps / latency / bitrate panel in the stream. Each tier is a superset of the one before; a three-finger tap on the touchscreen cycles them mid-stream."
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={STATS_TIERS.map((t) => ({ data: t, label: STATS_LABELS[t] ?? t }))}
selectedOption={statsTier}
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
// client too old for the tier still reads the on/off it understands.
onChange={(o) => {
const tier = o.data as string;
patch({ stats_verbosity: tier, show_stats: tier !== "off" });
}}
/>
</div>
</RowActions>
</Field>
</>
);
};
+2 -1
View File
@@ -197,7 +197,8 @@ when you return to the host list. iPhone, iPad, Apple TV and Android have no equ
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky
plugin has no stats setting. The shortcuts, and every number in the overlay, are in
plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the
overlay, are in
[Understanding the stats overlay](/docs/stats).
## Settings that are facts about your device