Add gamer-mode plugin (#168)

* feat: add gamer-mode plugin

Live CPU, RAM, swap, GPU, VRAM, load and network readings in the bar and a
panel, plus a one-click mode that suspends background resource hogs and
restores exactly what it suspended.

* chore: rebuild the thumbnail with the upstream generator

Produced by assets.noctalia.dev/plugins/thumbnail-generator.html with the
title, the Gaming tag, the panel screenshot and the Red accent, rather than
composed by hand at the same dimensions.

* fix(gamer-mode): write the session before suspending anything

Enable suspended targets first and dropped the writeSnapshot return, so
a failed write left processes frozen and units stopped with no record to
restore them from, while the service published gamer mode as off and
switched the power profile.

The snapshot now lands on disk first, and a failed write aborts the
enable with the machine untouched and an error notification.

Writing first can record a target whose suspend command then failed.
That direction is safe: thawing is unconditional and SIGCONT to a
running process is a no-op, and a stop target restarts only after a live
probe says it is still down.
This commit is contained in:
RAMA
2026-07-30 22:32:21 -04:00
committed by GitHub
parent d1a7e72632
commit 0f64148bea
8 changed files with 3242 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
# Gamer Mode
Live CPU, RAM, and GPU readings in the bar, plus a one-click mode that suspends
background resource hogs and restores what was running before.
> Requires Noctalia v5 and plugin API 19. Noctalia v4 uses a different QML
> plugin format and will not list or load this source.
## Plugin
| Field | Value |
| --- | --- |
| ID | `nomadcxx/gamer-mode` |
| Entries | Bar widget: `gamermode`; panel: `main`; service: `service` |
## Requirements
- `pgrep` and `pkill` from procps probe and signal process targets
- `systemctl` controls service and timer targets
- `docker` controls container targets
- `powerprofilesctl` from power-profiles-daemon switches the power profile
Each tool matters only if you target that kind. Without `powerprofilesctl` the
panel hides its power row and the toggle still works.
## Usage
| Gesture | Action |
| --- | --- |
| Left-click | Opens the panel. Set **Left-click action** to `toggle` to toggle instead. |
| Right-click | Toggles gamer mode, whatever **Left-click action** says. |
The glyph takes the accent colour while gamer mode runs.
The tooltip carries the live readings:
```
CPU 25% 59°C | RAM 10.9G | GPU 18% 61°C | VRAM 2.5G
```
The panel shows a bar per reading, the power profile selector, the suspend
profile selector, what the plugin has suspended, and the maintenance actions.
Pick `light` or `heavy` in the panel and press Enable, and that profile applies
for the session. A plugin reads its own settings and cannot write them, so the
choice rides along with the enable rather than changing the **Gamer mode
profile** setting. While gamer mode runs the selector gives way to a label,
because the session already fixed the profile. To change it, disable first.
Drive it from a shell or a keybind:
```sh
noctalia msg plugin nomadcxx/gamer-mode:service all toggle
noctalia msg plugin nomadcxx/gamer-mode:service all enable
noctalia msg plugin nomadcxx/gamer-mode:service all disable
```
Toggle the panel:
```sh
noctalia msg panel-toggle nomadcxx/gamer-mode:main
```
### Maintenance
Three one-shot cleanups sit at the foot of the panel. None of them is part of
gamer mode, and turning gamer mode off does not undo any of them.
| Action | What it does | Needs a password |
| --- | --- | --- |
| Clear shader caches | Deletes the Mesa, NVIDIA `GLCache`, RADV, and Steam shader caches | No |
| Drop page cache | `sync`, then `vm.drop_caches=3` | Yes |
| Reclaim swap | `swapoff -a && swapon -a`, pulling swapped pages back into RAM | Yes |
Clearing shader caches takes two clicks. The first measures and the button
reports the size, the second deletes. Games recompile shaders on their next
launch, so that launch is slower. This is the one to reach for when a driver
update leaves stale shaders behind.
The plugin expands every cache path from a fixed list and drops any that
resolves outside your home directory, so the `rm` only ever sees the paths
above, and only those that exist.
Dropping the page cache frees the RAM the kernel uses to cache files. The kernel
refills it, and the pages it discards are ones it would otherwise have reused, so
this buys less than the number in `free` suggests.
Reclaiming swap only runs when what is swapped out fits in free RAM with a tenth
of total held back. Otherwise it reports that there is no room and does nothing,
because succeeding into an out-of-memory kill would defeat the point.
## Settings
| Setting | Default | Description |
| --- | --- | --- |
| Bar icon | `device-gamepad-2` | Glyph shown in the bar. Names a glyph from the shell's registry. |
| Left-click action | `open_panel` | Opens the panel or toggles gamer mode. Right-click toggles either way. |
| Poll interval | `3` | Seconds between metric updates: 2, 3, or 5. |
| Gamer mode profile | `light` | Selects which target profile a toggle applies. |
| Auto performance profile | On | Switches to the `performance` power profile while gamer mode runs, then hands back the previous one. |
| Show temperatures | On | Includes CPU and GPU temperatures in the tooltip and panel. |
| Suspend targets (JSON) | Empty | Replaces the built-in target list. See below. |
The setting names the profile a bar click applies. The panel selector overrides
it for the enable it is sent with, and the bar's right-click toggle does not see
that selection, so it uses the setting.
## Target list
`targets` takes a JSON array. Each entry needs a `match`, a `kind`, and the
`profiles` it belongs to. `action` is optional.
```json
[
{"match": "awww-daemon", "kind": "process", "action": "freeze", "profiles": ["light", "heavy"]},
{"match": "qbittorrent", "kind": "process", "action": "freeze", "profiles": ["light", "heavy"]},
{"match": "ollama.service", "kind": "system-service", "action": "stop", "profiles": ["light", "heavy"]},
{"match": "fstrim.timer", "kind": "system-timer", "action": "stop", "profiles": ["light", "heavy"]},
{"match": "brave", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
{"match": "jellyfin.service","kind": "system-service", "action": "stop", "profiles": ["heavy"]}
]
```
An empty setting uses the built-in list: 173 entries, 94 of them in `light`.
Breadth costs almost nothing, because a target that is not running probes as
`down`, so the plugin never touches it and never restores it. An entry for
software you do not have costs one `pgrep`.
The plugin falls back to the built-in list when the setting holds invalid JSON or
when every entry in it fails validation, and logs the reason. It drops single bad
entries and honours the rest, so one typo costs you one target.
`match` compares through `pgrep -x`, so it needs the whole name. Substrings and
patterns do not match. The plugin quotes every value for the shell and refuses a
`match` holding a newline, carriage return, or NUL at parse time.
### Actions
`action` defaults to `stop`.
| | `stop` | `freeze` |
| --- | --- | --- |
| Mechanism | `pkill` / `systemctl stop` / `docker stop` | `SIGSTOP` / `docker pause` |
| Probes | read-only, never elevated | read-only, never elevated |
| Frees RAM | Yes | No, pages stay resident |
| Frees VRAM | Yes | No |
| Halts CPU use | Yes | Yes |
| Halts disk I/O | Yes | Yes |
| Keeps state | No, the target shuts down | Yes, the target resumes where it stopped |
| Restarts | Units, timers, and containers | Always |
Pick `freeze` for anything you return to: a browser, an editor, a wallpaper
daemon. Pick `stop` when you need the memory back. A local model runtime such as
`ollama` holds VRAM until the service stops, and freezing keeps every VRAM page
allocated, so those targets use `stop`.
Network connections drop while a target sits frozen. That suits a torrent client
and hurts a chat app.
### Kinds
| `kind` | Probe | `stop` | Start | `freeze` | Thaw |
| --- | --- | --- | --- | --- | --- |
| `process` | `pgrep -x` | `pkill -x` | none, see below | `pkill -STOP -x` | `pkill -CONT -x` |
| `user-service` | `systemctl --user is-active` | `systemctl --user stop` | `systemctl --user start` | `systemctl --user kill --kill-whom=all -s SIGSTOP` | same with `SIGCONT` |
| `system-service` | `systemctl is-active` | `pkexec systemctl stop` | `pkexec systemctl start` | `pkexec systemctl kill --kill-whom=all -s SIGSTOP` | same with `SIGCONT` |
| `user-timer` | `systemctl --user is-active` | `systemctl --user stop` | `systemctl --user start` | invalid | invalid |
| `system-timer` | `systemctl is-active` | `pkexec systemctl stop` | `pkexec systemctl start` | invalid | invalid |
| `container` | `docker inspect -f '{{.State.Running}}'` | `docker stop` | `docker start` | `docker pause` | `docker unpause` |
### Timers
Stopping `foo.service` leaves `foo.timer` free to fire it again five minutes into
your session, so scheduled work needs its own target. A stock Arch install
enables `fstrim.timer`, `smartd.timer`, `paccache.timer`, and the package-cache
timers, and each one stalls disk I/O mid-game.
The timer kinds require the `.timer` suffix on `match`. `systemctl is-active
fstrim` resolves to `fstrim.service`, so the plugin rejects a timer entry without
the suffix at parse time.
### Processes do not restart
`kind: "process"` has no start command. A bare process name carries no argv, no
environment, and no working directory, so the plugin cannot relaunch one. That
makes `process` with `action: "stop"` a one-way trip. The plugin allows it and
logs a warning, and every built-in process entry uses `freeze`. To get something
back, target the unit or container that supervises it.
### Protected targets
The plugin refuses some targets whatever the setting says, because stopping them
ends your session, kills your audio or network, or kills the game gamer mode
serves. It drops such an entry at parse time with a logged reason, and every
command builder refuses it again at action time, so a session file written by an
older version cannot act on one either.
```
session/display niri hyprland sway river wayfire labwc gnome-shell kwin_wayland
plasmashell Xorg Xwayland greetd sddm gdm
the shell noctalia quickshell
audio pipewire pipewire-pulse wireplumber pulseaudio
core IPC systemd systemd-logind dbus-broker dbus-daemon elogind
network NetworkManager wpa_supplicant iwd systemd-networkd
game stack steam steamwebhelper gamescope wine wineserver proton lutris
heroic bottles gamemoded
```
Matching ignores case and any `.service`, `.timer`, or `.socket` suffix, so
`Steam`, `steam`, and `steam.service` all fail. The list takes no override. A
wrong entry here costs you a dead session or a killed game, and an override field
is the one people copy from a forum post without reading. To act on one of these,
use Feral GameMode's `start=` and `end=` script hooks in `gamemode.ini`.
### System units ask for a password once
Stopping or starting a system unit needs authorisation. The plugin runs all the
units of one operation through a single `pkexec /usr/bin/systemctl` call, which
asks once and then does the whole batch as root.
Two simpler approaches were measured first and each cost a password prompt per
unit, seven units meaning seven dialogs:
- One `systemctl` per unit. polkit's `auth_admin_keep` retains an authorisation
against the subject that gave it, and the subject systemd reports is the
calling process, so seven processes are seven subjects with nothing to reuse.
- One `systemctl` naming all seven units. systemctl issues its `StopUnit` calls
in parallel, so every polkit check is outstanding before any of them has an
answer, and again nothing can reuse an authorisation that does not exist yet.
`org.freedesktop.policykit.exec` is `auth_admin` with no retention, so each
`pkexec` prompts. The built-in targets only ever stop system units and never
freeze them, so an enable costs one prompt and a disable costs one. A custom
target list that mixes stops and freezes pays one prompt per operation.
Without `pkexec` the plugin falls back to calling `systemctl` directly, which
still works through systemd's own polkit check at the cost of the prompt-per-unit
behaviour. It logs the downgrade at startup.
You need an authentication agent running for any prompt to appear. Most desktops
start one; standalone compositors often do not. Check with:
```sh
pgrep -af 'polkit.*agent'
```
If nothing is listed, install one such as `mate-polkit`, `polkit-gnome` or
`hyprpolkitagent` and start it with your session. Without an agent the calls fail
and each one is logged.
To skip the prompt entirely, grant the units you target. Scope the rule to those
units: a blanket rule lets anything running as you stop or start any system unit.
In `/etc/polkit-1/rules.d/49-gamermode.rules`:
```javascript
polkit.addRule(function (action, subject) {
var units = ["sonarr.service", "radarr.service", "fstrim.timer"];
if (action.id == "org.freedesktop.systemd1.manage-units"
&& subject.isInGroup("wheel")
&& units.indexOf(action.lookup("unit")) >= 0) {
return polkit.Result.YES;
}
});
```
## Targets left out of the built-in list
Each of these suits someone and makes a poor default. Paste what you want into
`targets`.
**Voice chat.** Freezing these cuts voice during the activity the plugin serves.
```json
{"match": "discord", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
{"match": "vesktop", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
{"match": "slack", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
{"match": "element-desktop", "kind": "process", "action": "freeze", "profiles": ["heavy"]}
```
**Music.** Plenty of people game with music playing.
```json
{"match": "spotify", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
{"match": "spotifyd.service", "kind": "user-service", "action": "stop", "profiles": ["heavy"]},
{"match": "mpd.service", "kind": "user-service", "action": "stop", "profiles": ["heavy"]}
```
**Recording and streaming.** Plenty of people stream the game they play.
```json
{"match": "obs", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
{"match": "gpu-screen-recorder", "kind": "process", "action": "freeze", "profiles": ["heavy"]}
```
**Language runtimes.** `java`, `dotnet`, and `node` burn CPU, and they run games
too. Minecraft and every PrismLauncher or MultiMC instance runs as `java`. Unity
and .NET titles run as `dotnet`. Freezing those freezes the game. Add them only
if nothing you play uses them.
**Container and VM daemons.** Stopping `docker.service` takes down every
container, and starting it again leaves their previous states behind. Stopping
`libvirtd` kills running guests. Target single containers with
`kind: "container"`, which pauses and unpauses through the cgroup freezer.
**Shared databases.** Other services tend to depend on `postgresql`, `mysqld`,
and `redis`. The built-in `heavy` profile does cover `elasticsearch` and
`opensearch`, whose JVM heaps often top the RAM table on a development box.
**VRAM without stopping the daemon.** `ollama` unloads models while staying up.
No target kind covers this, so use a Feral GameMode hook:
```ini
[custom]
start=/usr/bin/ollama stop --all
```
## Restore semantics
Enabling writes a session snapshot to the plugin data directory
(`session.json`). For every target in the active profile it records whether the
target was `running` or `active` or already down, which `action` applied, the
power profile in effect, and the kernel boot ID.
The snapshot lands on disk before anything is suspended. It is the only record
of what was running beforehand, so a target suspended without one is
unrecoverable: disable would read the session, find none, and return. If the
write fails, enabling stops there with the machine untouched and tells you why.
Disable runs two passes, because the two actions need different logic.
The plugin probes each **`stop` target** again and starts it back when it is
still down. So it leaves alone anything you stopped before enabling gamer mode,
and anything you restarted by hand while gamer mode ran. It logs and skips a
missing unit or container, and never fails hard partway.
The plugin thaws every **`freeze` target** without probing. A frozen process
still appears in `pgrep`, so no probe distinguishes "still frozen" from
"running", and `SIGCONT` to a process that is not stopped exits 0 and changes
nothing. Thawing blind beats probing here: it cannot misread a state, cannot
stomp a manual restart, and cannot leave something frozen after a probe fails to
run.
The snapshot sits on disk, so gamer mode survives a shell restart. Reload
Quickshell mid-session and the panel still reports it as on with the same suspend
list, and disable still restores.
Enabling twice does nothing. A second enable would re-probe and record the
targets it had suspended as "was down", losing what it needs to restore them.
### After a reboot
A session file outlives a reboot, so the plugin compares the boot ID at startup.
A different ID means the session went stale: nothing that was frozen still
exists, and units that were stopped may have come back on their own.
A stale session gets a full restore pass before the plugin clears it. A stopped
unit that is not `enabled` is still down after a reboot, and putting it
back is what you were told would happen. Frozen targets died with the reboot, so
their thaw does nothing. This can fire a few `systemctl start` calls
soon after login, and the plugin logs each one.
Within the same boot the plugin always keeps the session, even when everything
looks like it is running, because that is the case where something may still sit
frozen and need thawing. A session written before version 0.2.0 carries no boot
ID, and the plugin treats it as current so it does not abandon targets that may
still be suspended.
## Notes
- The plugin stores its session snapshot in Noctalia's plugin data directory. It
makes no network requests.
- `renice` is absent by design. It looks like the safe middle ground and is not.
With `RLIMIT_NICE=0`, the default, an unprivileged process lowers priority and
never raises it back, so a renice would degrade every process it touched for
the life of that process. `freeze` gives you the reversible option instead.
Feral GameMode needs membership in a `gamemode` group to renice at all for the
same reason.
- No I/O weighting for user units. cgroup v2 delegates `cpu`, `memory`, and
`pids` to the user manager, and not `io`.
- The bar widget carries the readings in its tooltip. Plugin API 19 does hand a
widget `onHover(entered)`, so a richer hover surface is possible and is not
built.
- The panel shows no per-core CPU breakdown and no top-process list.
- Nothing places the widget on your bar for you. The manifest has no field for a
default bar section, and a plugin can read its settings but not write them, so
bar layout stays yours. Add it under **Settings → Bar**.
- VRAM appears where the shell reports it, which means NVML on NVIDIA.
- The plugin toggles no compositor effects. Animations, blur, and shadows belong
to your compositor's own config.
- Feral GameMode exposes a `ClientCount` property that emits changes and a
`GameRegistered` signal on `com.feralinteractive.GameMode`, and `org.scx.Loader`
switches sched_ext schedulers. Arming gamer mode from either one would work and
is not built.
## License
MIT
+60
View File
@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<defs>
<linearGradient id="outerFlame" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#C0392B"/>
<stop offset="100%" stop-color="#E74C3C"/>
</linearGradient>
<linearGradient id="midFlame" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#E67E22"/>
<stop offset="100%" stop-color="#F39C12"/>
</linearGradient>
<linearGradient id="innerFlame" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#F1C40F"/>
<stop offset="100%" stop-color="#FFEB3B"/>
</linearGradient>
</defs>
<path fill="url(#outerFlame)" d="M 120 280 C 100 220, 110 170, 150 130 C 170 110, 180 70, 170 40 C 210 70, 230 110, 240 150 C 260 100, 290 60, 330 80 C 370 100, 360 150, 390 180 C 410 200, 420 240, 390 290 Z"/>
<path fill="url(#midFlame)" d="M 140 280 C 130 230, 145 190, 175 160 C 195 140, 210 100, 210 80 C 235 115, 250 150, 265 175 C 285 130, 315 105, 340 120 C 370 140, 355 185, 375 210 C 390 230, 395 260, 375 290 Z"/>
<path fill="url(#innerFlame)" d="M 170 280 C 160 240, 180 210, 200 185 C 215 165, 225 130, 230 115 C 245 145, 260 175, 275 195 C 290 160, 310 145, 325 155 C 345 170, 335 210, 350 235 C 360 250, 360 270, 345 280 Z"/>
<rect x="90" y="235" width="16" height="175" rx="4" fill="#7F8C8D"/>
<rect x="84" y="245" width="6" height="25" rx="2" fill="#95A5A6"/>
<rect x="84" y="365" width="6" height="25" rx="2" fill="#95A5A6"/>
<rect x="160" y="395" width="170" height="20" rx="2" fill="#1B2631"/>
<path fill="#F1C40F" d="M 170 398 h 8 v 12 h -8 z M 183 398 h 8 v 12 h -8 z M 196 398 h 8 v 12 h -8 z M 209 398 h 8 v 12 h -8 z M 222 398 h 8 v 12 h -8 z M 235 398 h 8 v 12 h -8 z M 253 398 h 8 v 12 h -8 z M 266 398 h 8 v 12 h -8 z M 279 398 h 8 v 12 h -8 z M 292 398 h 8 v 12 h -8 z M 305 398 h 8 v 12 h -8 z M 318 398 h 8 v 12 h -8 z"/>
<rect x="104" y="245" width="290" height="150" rx="14" fill="#2C3E50"/>
<rect x="114" y="255" width="270" height="130" rx="10" fill="#34495E"/>
<path stroke="#1ABC9C" stroke-width="4" stroke-linecap="round" fill="none" d="M 125 267 L 373 267"/>
<path stroke="#1ABC9C" stroke-width="4" stroke-linecap="round" fill="none" d="M 125 373 L 373 373"/>
<circle cx="185" cy="320" r="48" fill="#1A252F"/>
<circle cx="185" cy="320" r="42" fill="#2C3E50"/>
<g fill="#1A252F">
<path d="M 185 320 L 175 282 A 42 42 0 0 1 195 282 Z"/>
<path d="M 185 320 L 223 310 A 42 42 0 0 1 223 330 Z"/>
<path d="M 185 320 L 195 358 A 42 42 0 0 1 175 358 Z"/>
<path d="M 185 320 L 147 330 A 42 42 0 0 1 147 310 Z"/>
</g>
<circle cx="185" cy="320" r="16" fill="#34495E"/>
<circle cx="185" cy="320" r="8" fill="#7F8C8D"/>
<circle cx="313" cy="320" r="48" fill="#1A252F"/>
<circle cx="313" cy="320" r="42" fill="#2C3E50"/>
<g fill="#1A252F">
<path d="M 313 320 L 303 282 A 42 42 0 0 1 323 282 Z"/>
<path d="M 313 320 L 351 310 A 42 42 0 0 1 351 330 Z"/>
<path d="M 313 320 L 323 358 A 42 42 0 0 1 303 358 Z"/>
<path d="M 313 320 L 275 330 A 42 42 0 0 1 275 310 Z"/>
</g>
<circle cx="313" cy="320" r="16" fill="#34495E"/>
<circle cx="313" cy="320" r="8" fill="#7F8C8D"/>
<circle cx="130" cy="110" r="4" fill="#FFEB3B"/>
<circle cx="380" cy="130" r="5" fill="#F39C12"/>
<circle cx="280" cy="50" r="3" fill="#FFEB3B"/>
<circle cx="200" cy="40" r="4" fill="#E74C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+636
View File
@@ -0,0 +1,636 @@
--!nonstrict
--
-- Gamer Mode panel. Like the widget it owns no state: it renders what the service
-- publishes and sends commands back through `noctalia.state`.
--
-- Interactive props (onClick/onChange) must be the *names* of global functions -- the
-- ui bridge resolves handlers by name and cannot call a Lua closure.
local MIB_PER_GIB = 1024
-- The panel is wide enough that a control stretched across it reads badly: a select
-- holding the word "balanced" does not want four hundred pixels. The label takes the left,
-- a spacer eats the slack, and the control keeps a fixed, readable width on the right.
local CONTROL_LABEL_WIDTH = 130
local CONTROL_WIDTH = 200
local M = {}
local metrics = noctalia.state.get("metrics") or { available = false }
local gameMode = noctalia.state.get("game_mode") or { enabled = false, busy = false, suspended = {} }
local power = noctalia.state.get("power") or { available = false, profiles = {} }
local cleanup = noctalia.state.get("cleanup") or {}
local nonceCounter = 0
-- The logo is embedded rather than read from the plugin directory because there is no API
-- that reports where that directory is: the materialised path contains the name of the
-- source the plugin was installed from, which differs per machine. The data directory is
-- reported, so the file is written there once and referenced from disk.
--
-- gamer-mode/logo.svg holds the same bytes and is the copy to edit; tests/logo.lua fails
-- if the two drift apart.
local LOGO_SVG = [==[
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<defs>
<linearGradient id="outerFlame" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#C0392B"/>
<stop offset="100%" stop-color="#E74C3C"/>
</linearGradient>
<linearGradient id="midFlame" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#E67E22"/>
<stop offset="100%" stop-color="#F39C12"/>
</linearGradient>
<linearGradient id="innerFlame" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#F1C40F"/>
<stop offset="100%" stop-color="#FFEB3B"/>
</linearGradient>
</defs>
<path fill="url(#outerFlame)" d="M 120 280 C 100 220, 110 170, 150 130 C 170 110, 180 70, 170 40 C 210 70, 230 110, 240 150 C 260 100, 290 60, 330 80 C 370 100, 360 150, 390 180 C 410 200, 420 240, 390 290 Z"/>
<path fill="url(#midFlame)" d="M 140 280 C 130 230, 145 190, 175 160 C 195 140, 210 100, 210 80 C 235 115, 250 150, 265 175 C 285 130, 315 105, 340 120 C 370 140, 355 185, 375 210 C 390 230, 395 260, 375 290 Z"/>
<path fill="url(#innerFlame)" d="M 170 280 C 160 240, 180 210, 200 185 C 215 165, 225 130, 230 115 C 245 145, 260 175, 275 195 C 290 160, 310 145, 325 155 C 345 170, 335 210, 350 235 C 360 250, 360 270, 345 280 Z"/>
<rect x="90" y="235" width="16" height="175" rx="4" fill="#7F8C8D"/>
<rect x="84" y="245" width="6" height="25" rx="2" fill="#95A5A6"/>
<rect x="84" y="365" width="6" height="25" rx="2" fill="#95A5A6"/>
<rect x="160" y="395" width="170" height="20" rx="2" fill="#1B2631"/>
<path fill="#F1C40F" d="M 170 398 h 8 v 12 h -8 z M 183 398 h 8 v 12 h -8 z M 196 398 h 8 v 12 h -8 z M 209 398 h 8 v 12 h -8 z M 222 398 h 8 v 12 h -8 z M 235 398 h 8 v 12 h -8 z M 253 398 h 8 v 12 h -8 z M 266 398 h 8 v 12 h -8 z M 279 398 h 8 v 12 h -8 z M 292 398 h 8 v 12 h -8 z M 305 398 h 8 v 12 h -8 z M 318 398 h 8 v 12 h -8 z"/>
<rect x="104" y="245" width="290" height="150" rx="14" fill="#2C3E50"/>
<rect x="114" y="255" width="270" height="130" rx="10" fill="#34495E"/>
<path stroke="#1ABC9C" stroke-width="4" stroke-linecap="round" fill="none" d="M 125 267 L 373 267"/>
<path stroke="#1ABC9C" stroke-width="4" stroke-linecap="round" fill="none" d="M 125 373 L 373 373"/>
<circle cx="185" cy="320" r="48" fill="#1A252F"/>
<circle cx="185" cy="320" r="42" fill="#2C3E50"/>
<g fill="#1A252F">
<path d="M 185 320 L 175 282 A 42 42 0 0 1 195 282 Z"/>
<path d="M 185 320 L 223 310 A 42 42 0 0 1 223 330 Z"/>
<path d="M 185 320 L 195 358 A 42 42 0 0 1 175 358 Z"/>
<path d="M 185 320 L 147 330 A 42 42 0 0 1 147 310 Z"/>
</g>
<circle cx="185" cy="320" r="16" fill="#34495E"/>
<circle cx="185" cy="320" r="8" fill="#7F8C8D"/>
<circle cx="313" cy="320" r="48" fill="#1A252F"/>
<circle cx="313" cy="320" r="42" fill="#2C3E50"/>
<g fill="#1A252F">
<path d="M 313 320 L 303 282 A 42 42 0 0 1 323 282 Z"/>
<path d="M 313 320 L 351 310 A 42 42 0 0 1 351 330 Z"/>
<path d="M 313 320 L 323 358 A 42 42 0 0 1 303 358 Z"/>
<path d="M 313 320 L 275 330 A 42 42 0 0 1 275 310 Z"/>
</g>
<circle cx="313" cy="320" r="16" fill="#34495E"/>
<circle cx="313" cy="320" r="8" fill="#7F8C8D"/>
<circle cx="130" cy="110" r="4" fill="#FFEB3B"/>
<circle cx="380" cy="130" r="5" fill="#F39C12"/>
<circle cx="280" cy="50" r="3" fill="#FFEB3B"/>
<circle cx="200" cy="40" r="4" fill="#E74C3C"/>
</svg>
]==]
local function logoPath()
local directory = noctalia.pluginDataDir()
if not directory then
return nil
end
local path = directory .. "/logo.svg"
if not noctalia.fileExists(path) then
if not noctalia.writeFile(path, LOGO_SVG) then
noctalia.log("gamermode: could not write the panel logo")
return nil
end
end
return path
end
local function tr(key, subst)
return noctalia.tr(key, subst)
end
local function clamp(value)
local number = tonumber(value) or 0
return math.max(0, math.min(1, number))
end
local function percent(fraction)
return string.format("%d%%", math.floor(clamp(fraction) * 100 + 0.5))
end
local function gibibytes(mib)
return (tonumber(mib) or 0) / MIB_PER_GIB
end
local function usedOfTotal(usedMib, totalMib)
return string.format("%.1f / %.1f GiB", gibibytes(usedMib), gibibytes(totalMib))
end
local function withTemp(detail, showTemps, temp)
if showTemps and tonumber(temp) then
return detail .. string.format(" %d°C", math.floor(tonumber(temp) + 0.5))
end
return detail
end
-- buildRows produces one entry per reading the machine actually reports. Unsupported
-- readings are absent rather than drawn as an empty bar, which would look like a real
-- idle reading.
function M.buildRows(m, showTemps)
local rows = {}
if type(m) ~= "table" or not m.available then
return rows
end
rows[#rows + 1] = {
label = "CPU",
glyph = "cpu-usage",
progress = clamp(m.cpuPerc),
detail = withTemp(percent(m.cpuPerc), showTemps, m.cpuTemp),
}
rows[#rows + 1] = {
label = "RAM",
glyph = "memory",
progress = clamp(m.memPerc),
detail = usedOfTotal(m.memUsedMb, m.memTotalMb),
}
-- Swap only when the machine has some. A zero total is not a bar at 0%, it is a
-- machine with swap turned off, and drawing it would invite a reading that is not
-- there.
if m.swapTotalMb and m.swapTotalMb > 0 then
rows[#rows + 1] = {
label = "Swap",
glyph = "storage",
progress = clamp(m.swapPerc),
detail = usedOfTotal(m.swapUsedMb, m.swapTotalMb),
}
end
if m.gpuAvailable and m.gpuPerc then
rows[#rows + 1] = {
label = "GPU",
glyph = "gpu-usage",
progress = clamp(m.gpuPerc),
detail = withTemp(percent(m.gpuPerc), showTemps, m.gpuTemp),
}
end
if m.vramUsedMb and m.vramTotalMb then
rows[#rows + 1] = {
label = "VRAM",
glyph = "storage",
progress = clamp(m.vramPerc),
detail = usedOfTotal(m.vramUsedMb, m.vramTotalMb),
}
end
return rows
end
local function perSecond(bytes)
local value = tonumber(bytes) or 0
if value >= 1024 * 1024 then
return string.format("%.1f MB/s", value / (1024 * 1024))
elseif value >= 1024 then
return string.format("%.0f KB/s", value / 1024)
end
return string.format("%.0f B/s", value)
end
-- Readings that are not a proportion of anything. Load average needs a core count to
-- become a percentage and the shell does not report one; a network rate has no ceiling to
-- measure against. Both are shown as figures rather than invented into bars.
function M.buildFigures(m)
local figures = {}
if type(m) ~= "table" or not m.available then
return figures
end
if m.load1 and m.load5 and m.load15 then
figures[#figures + 1] = {
label = tr("panel.load"),
glyph = "performance",
detail = string.format("%.2f %.2f %.2f", m.load1, m.load5, m.load15),
}
end
if m.netRxPerSec and m.netTxPerSec then
figures[#figures + 1] = {
label = tr("panel.network"),
glyph = "antenna-bars-5",
detail = perSecond(m.netRxPerSec) .. " " .. perSecond(m.netTxPerSec),
}
end
return figures
end
function M.suspendedLines(gm)
local lines = {}
for _, target in ipairs((type(gm) == "table" and gm.suspended) or {}) do
-- Frozen and stopped are materially different to a user reading this list: one
-- resumes exactly where it left off, the other was shut down and restarted.
local state = target.action == "freeze" and tr("panel.frozen") or tr("panel.stopped")
lines[#lines + 1] = string.format("%s (%s, %s)", tostring(target.match), tostring(target.kind), state)
end
return lines
end
-- M.powerProfileAt resolves the zero-based index the select reports back to a profile
-- name, or nil when the index no longer matches the published list.
function M.powerProfileAt(index)
local position = (tonumber(index) or -1) + 1
local profiles = (type(power) == "table" and power.profiles) or {}
return profiles[position]
end
-- ── suspend profile ──
local SUSPEND_PROFILES = { "light", "heavy" }
function M.suspendProfiles()
return { SUSPEND_PROFILES[1], SUSPEND_PROFILES[2] }
end
-- The chosen profile travels with the enable command rather than changing the setting,
-- because a plugin reads its own settings and cannot write them. It starts from whatever
-- the setting says and then lives as long as the loaded panel entry, which outlives any
-- one opening of the panel but not a shell restart or a plugin reload.
--
-- The bar's right-click toggle cannot see this: the widget is a separate entry with its
-- own state, so it enables the profile named in the settings. Selecting here and then
-- right-clicking the icon is the one path where the two disagree.
local selectedProfileName = nil
function M.selectedProfile()
if selectedProfileName then
return selectedProfileName
end
local configured = noctalia.getConfig("profile")
return configured == "heavy" and "heavy" or "light"
end
function M.selectSuspendProfile(index)
local name = SUSPEND_PROFILES[(tonumber(index) or -1) + 1]
if not name then
noctalia.log("gamermode: ignoring an out-of-range suspend profile selection")
return false
end
selectedProfileName = name
return true
end
local function selectedSuspendIndex()
local current = M.selectedProfile()
for index, name in ipairs(SUSPEND_PROFILES) do
if name == current then
return index - 1
end
end
return 0
end
local function selectedPowerIndex()
for index, profile in ipairs(power.profiles or {}) do
if profile == power.active then
return index - 1
end
end
return 0
end
-- ── rendering ──
local function metricRow(row)
return ui.column({ gap = 4 }, {
ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = row.glyph, size = 14, color = "on_surface_variant" }),
ui.label({ text = row.label, color = "on_surface_variant", width = 48 }),
-- A spacer pushes the detail right. `align` is not a label prop: setting it
-- left the text unaligned and the shell logged a warning on every render.
ui.spacer({ flexGrow = 1 }),
ui.label({ text = row.detail, color = "on_surface_variant", fontSize = 11 }),
}),
ui.progress({
height = 4,
progress = row.progress,
fill = "primary",
track = "surface_variant",
radius = 2,
}),
})
end
local function figureRow(figure)
return ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = figure.glyph, size = 14, color = "on_surface_variant" }),
ui.label({ text = figure.label, color = "on_surface_variant", width = 48 }),
ui.spacer({ flexGrow = 1 }),
ui.label({ text = figure.detail, color = "on_surface_variant", fontSize = 11 }),
})
end
local function toggleButton()
if gameMode.busy then
return ui.button({ text = tr("panel.working"), enabled = false, variant = "ghost" })
end
return ui.button({
text = gameMode.enabled and tr("panel.disable") or tr("panel.enable"),
glyph = gameMode.enabled and "player-stop-filled" or "player-play-filled",
selected = gameMode.enabled,
onClick = "onToggleGameMode",
})
end
local function header()
local logo = logoPath()
local mark = logo
and ui.image({ path = logo, width = 42, height = 42, fit = "contain" })
-- The glyph is the fallback for the one case that can fail: no plugin data
-- directory to write the logo into.
or ui.glyph({ name = "device-gamepad-2", size = 22, color = "primary" })
return ui.row({ align = "center", gap = 10 }, {
mark,
ui.column({ gap = 0, flexGrow = 1 }, {
ui.label({ text = tr("panel.title"), fontSize = 17, fontWeight = "bold" }),
ui.label({
text = gameMode.enabled and tr("panel.state_on") or tr("panel.state_off"),
fontSize = 11,
color = gameMode.enabled and "primary" or "on_surface_variant",
}),
}),
toggleButton(),
-- The panel also dismisses on an outside click, but a visible control is the one
-- people look for, and every other panel in the shell has one.
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.close"), onClick = "onCloseClicked" }),
})
end
-- ── maintenance ──
-- Deleting the shader caches is the one action here that destroys something, so the button
-- asks first and names the size it is about to remove.
local shadersArmed = false
local function maintenanceSection(children)
children[#children + 1] = ui.separator({})
children[#children + 1] = ui.label({
text = tr("cleanup.title"),
color = "on_surface_variant",
fontSize = 12,
fontWeight = "bold",
})
local working = cleanup.running ~= nil
children[#children + 1] = ui.row({ gap = 8 }, {
ui.button({
-- The size arrives from the service, which measured it when the button armed.
text = shadersArmed and tr("cleanup.shaders_confirm", { size = cleanup.shaderSize or "?" })
or tr("cleanup.shaders"),
glyph = shadersArmed and "alert-triangle" or "storage",
variant = shadersArmed and "primary" or "ghost",
selected = shadersArmed,
enabled = not working,
flexGrow = 1,
onClick = "onClearShaders",
}),
})
children[#children + 1] = ui.row({ gap = 8 }, {
ui.button({
text = tr("cleanup.pagecache"),
glyph = "memory",
variant = "ghost",
enabled = not working,
flexGrow = 1,
onClick = "onDropPageCache",
}),
ui.button({
text = tr("cleanup.swap"),
glyph = "performance",
variant = "ghost",
enabled = not working,
flexGrow = 1,
onClick = "onReclaimSwap",
}),
})
if type(cleanup.message) == "string" and cleanup.message ~= "" then
children[#children + 1] = ui.label({
text = cleanup.message,
fontSize = 11,
color = cleanup.ok == false and "error" or "on_surface_variant",
})
end
end
local function powerRow()
if not power.available then
return ui.label({ text = tr("panel.power_unavailable"), color = "on_surface_variant", fontSize = 11 })
end
return ui.row({ align = "center", gap = 8 }, {
ui.label({ text = tr("panel.power_profile"), color = "on_surface_variant", width = CONTROL_LABEL_WIDTH }),
ui.spacer({ flexGrow = 1 }),
ui.select({
options = power.profiles,
selectedIndex = selectedPowerIndex(),
width = CONTROL_WIDTH,
onChange = "onPowerProfileChanged",
}),
})
end
-- While gamer mode runs, the profile in force is whatever the session recorded, so the
-- selector gives way to a plain label. Picking a different profile means disabling first.
local function modeProfileRow()
if gameMode.enabled then
return ui.row({ align = "center", gap = 8 }, {
ui.label({ text = tr("panel.mode_profile"), color = "on_surface_variant", width = CONTROL_LABEL_WIDTH }),
ui.spacer({ flexGrow = 1 }),
ui.label({ text = tr("panel.profiles." .. tostring(gameMode.profile or "light")), width = CONTROL_WIDTH }),
})
end
local options = {}
for index, name in ipairs(M.suspendProfiles()) do
options[index] = tr("panel.profiles." .. name)
end
return ui.row({ align = "center", gap = 8 }, {
ui.label({ text = tr("panel.mode_profile"), color = "on_surface_variant", width = CONTROL_LABEL_WIDTH }),
ui.spacer({ flexGrow = 1 }),
ui.select({
options = options,
selectedIndex = selectedSuspendIndex(),
width = CONTROL_WIDTH,
onChange = "onSuspendProfileChanged",
}),
})
end
local function sectionLabel(key, count)
local text = tr(key)
if count then
text = text .. " (" .. tostring(count) .. ")"
end
return ui.label({ text = text, color = "on_surface_variant", fontSize = 12, fontWeight = "bold" })
end
local function suspendedSection(children)
if not gameMode.enabled then
return
end
children[#children + 1] = ui.separator({})
local lines = M.suspendedLines(gameMode)
children[#children + 1] = sectionLabel("panel.suspended", #lines > 0 and #lines or nil)
if #lines == 0 then
children[#children + 1] = ui.label({ text = tr("panel.nothing_suspended"), fontSize = 11 })
return
end
for _, line in ipairs(lines) do
children[#children + 1] = ui.label({ text = line, fontSize = 11, color = "on_surface_variant" })
end
end
local function body()
local showTemps = noctalia.getConfig("show_temps") ~= false
local children = {}
local rows = M.buildRows(metrics, showTemps)
if #rows == 0 then
children[#children + 1] = ui.label({
text = tr("panel.metrics_unavailable"),
color = "on_surface_variant",
fontSize = 11,
})
else
children[#children + 1] = sectionLabel("panel.performance")
for _, row in ipairs(rows) do
children[#children + 1] = metricRow(row)
end
for _, figure in ipairs(M.buildFigures(metrics)) do
children[#children + 1] = figureRow(figure)
end
if not metrics.gpuAvailable then
children[#children + 1] = ui.label({
text = tr("panel.gpu_unsupported"),
color = "on_surface_variant",
fontSize = 11,
})
end
end
children[#children + 1] = ui.separator({})
children[#children + 1] = powerRow()
children[#children + 1] = modeProfileRow()
suspendedSection(children)
maintenanceSection(children)
return ui.column({ gap = 12, flexGrow = 1 }, children)
end
local function footer()
return ui.row({ align = "center", gap = 8 }, {
ui.spacer({ flexGrow = 1 }),
ui.button({ text = tr("panel.settings"), glyph = "plugin", variant = "ghost", onClick = "onOpenSettings" }),
})
end
local function render()
panel.render(ui.column({ padding = 20, gap = 14, flexGrow = 1 }, {
header(),
ui.scroll({ flexGrow = 1, gap = 12 }, { body() }),
footer(),
}))
end
local function sendCommand(action, extra)
nonceCounter = nonceCounter + 1
local command = { nonce = noctalia.nowMs() * 1000 + nonceCounter, action = action }
for key, value in pairs(extra or {}) do
command[key] = value
end
noctalia.state.set("command", command)
end
-- ── shell entry points (must be globals) ──
function onOpen()
metrics = noctalia.state.get("metrics") or metrics
gameMode = noctalia.state.get("game_mode") or gameMode
power = noctalia.state.get("power") or power
render()
end
-- The command carries the chosen profile so one button both picks and applies. Disabling
-- ignores it: the session already records which profile was in force.
function onToggleGameMode()
sendCommand("toggle", { profile = M.selectedProfile() })
end
function onSuspendProfileChanged(index)
if M.selectSuspendProfile(index) then
render()
end
end
function onPowerProfileChanged(index)
local profile = M.powerProfileAt(index)
if not profile then
noctalia.log("gamermode: ignoring an out-of-range power profile selection")
return
end
sendCommand("set-power-profile", { profile = profile })
end
function onOpenSettings()
noctalia.openSettings()
end
-- The first click measures and arms; the second deletes. Anything else the user does in
-- the panel is a chance to have changed their mind, so the arming does not persist past a
-- different cleanup being started.
function onClearShaders()
if shadersArmed then
shadersArmed = false
sendCommand("cleanup", { job = "shaders" })
else
shadersArmed = true
sendCommand("cleanup", { job = "shaders-measure" })
end
render()
end
function onDropPageCache()
shadersArmed = false
sendCommand("cleanup", { job = "pagecache" })
render()
end
function onReclaimSwap()
shadersArmed = false
sendCommand("cleanup", { job = "swap" })
render()
end
function onCloseClicked()
panel.close()
end
noctalia.state.watch("metrics", function(value)
metrics = type(value) == "table" and value or { available = false }
render()
end)
noctalia.state.watch("game_mode", function(value)
gameMode = type(value) == "table" and value or { enabled = false, busy = false, suspended = {} }
render()
end)
noctalia.state.watch("power", function(value)
power = type(value) == "table" and value or { available = false, profiles = {} }
render()
end)
noctalia.state.watch("cleanup", function(value)
cleanup = type(value) == "table" and value or {}
render()
end)
return M
+94
View File
@@ -0,0 +1,94 @@
id = "nomadcxx/gamer-mode"
name = "Gamer Mode"
version = "0.6.4"
plugin_api = 19
author = "Nomadcxx"
license = "MIT"
dependencies = ["pgrep", "pkill", "powerprofilesctl", "systemctl"]
tags = ["bar", "panel", "service", "gaming", "system", "hardware"]
icon = "device-gamepad-2"
description = "Live CPU/RAM/GPU metrics and a one-click gamer mode that suspends and restores background resource hogs."
[[setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "device-gamepad-2"
[[setting]]
key = "click_action"
type = "select"
label_key = "settings.click_action.label"
description_key = "settings.click_action.description"
default = "open_panel"
options = [
{ value = "open_panel", label_key = "settings.click_action.options.open_panel" },
{ value = "toggle", label_key = "settings.click_action.options.toggle" },
]
[[setting]]
key = "poll_interval"
type = "select"
label_key = "settings.poll_interval.label"
description_key = "settings.poll_interval.description"
default = "3"
options = [
{ value = "2", label_key = "settings.poll_interval.options.2" },
{ value = "3", label_key = "settings.poll_interval.options.3" },
{ value = "5", label_key = "settings.poll_interval.options.5" },
]
[[setting]]
key = "profile"
type = "select"
label_key = "settings.profile.label"
description_key = "settings.profile.description"
default = "light"
options = [
{ value = "light", label_key = "settings.profile.options.light" },
{ value = "heavy", label_key = "settings.profile.options.heavy" },
]
[[setting]]
key = "auto_performance"
type = "bool"
label_key = "settings.auto_performance.label"
description_key = "settings.auto_performance.description"
default = true
[[setting]]
key = "show_temps"
type = "bool"
label_key = "settings.show_temps.label"
description_key = "settings.show_temps.description"
default = true
[[setting]]
key = "targets"
type = "string"
label_key = "settings.targets.label"
description_key = "settings.targets.description"
default = ""
advanced = true
[[service]]
id = "service"
entry = "service.luau"
[[panel]]
id = "main"
entry = "panel.luau"
width = 560
height = 820
placement = "attached"
position = "auto"
# "on_demand" takes keyboard focus only when you click inside the panel, so opening it
# from the bar leaves focus where it was. "none" would refuse focus entirely, but the
# shell requires dismiss_on_outside_click = false alongside it, which would leave the
# panel on screen until you click the bar icon again.
keyboard_focus = "on_demand"
[[widget]]
id = "gamermode"
entry = "widget.luau"
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+111
View File
@@ -0,0 +1,111 @@
{
"widget": {
"tooltip_loading": "Gamer Mode: loading metrics..."
},
"settings": {
"glyph": {
"label": "Bar icon",
"description": "Glyph shown on the bar."
},
"click_action": {
"label": "Left-click action",
"description": "What left-clicking the bar icon does. Right-click always toggles gamer mode.",
"options": {
"open_panel": "Open panel",
"toggle": "Toggle gamer mode"
}
},
"poll_interval": {
"label": "Poll interval",
"description": "Seconds between metric updates.",
"options": {
"2": "2s",
"3": "3s",
"5": "5s"
}
},
"profile": {
"label": "Gamer mode profile",
"description": "How aggressive the suspend list is.",
"options": {
"light": "Light",
"heavy": "Heavy"
}
},
"auto_performance": {
"label": "Auto performance profile",
"description": "Switch power profile to performance while gamer mode is on."
},
"show_temps": {
"label": "Show temperatures",
"description": "Include CPU/GPU temperatures in tooltip and panel."
},
"targets": {
"label": "Suspend targets (JSON)",
"description": "Advanced: JSON array of {match, kind, profiles}. Kind: process, user-service, system-service, container."
}
},
"panel": {
"close": "Close",
"disable": "Disable",
"enable": "Enable",
"frozen": "frozen",
"gpu_unsupported": "GPU metrics unsupported",
"load": "Load",
"metrics_unavailable": "System metrics unavailable",
"mode_profile": "Suspend profile",
"network": "Net",
"nothing_suspended": "Nothing suspended",
"performance": "Performance",
"power": {
"power-saver": "Power saver",
"balanced": "Balanced",
"performance": "Performance"
},
"power_profile": "Power profile",
"power_unavailable": "powerprofilesctl unavailable",
"profiles": {
"light": "Light",
"heavy": "Heavy"
},
"settings": "Settings",
"state_off": "Off",
"state_on": "Running",
"stopped": "stopped",
"suspended": "Suspended",
"title": "gamer-mode",
"working": "Working..."
},
"notify": {
"enabled_title": "Gamer mode enabled",
"disabled_title": "Gamer mode disabled",
"suspended_count": {
"one": "Suspended 1 target.",
"other": "Suspended {count} targets."
},
"restored_count": {
"one": "Restored 1 target.",
"other": "Restored {count} targets."
},
"nothing_suspended": "No background targets were running.",
"nothing_restored": "Nothing needed restarting.",
"session_failed_title": "Gamer mode could not start",
"session_failed": "The session file could not be written, so nothing was suspended."
},
"cleanup": {
"title": "Maintenance",
"shaders": "Clear shader caches",
"shaders_confirm": "Delete {size}?",
"pagecache": "Drop page cache",
"swap": "Reclaim swap",
"running": "Working...",
"shaders_done": "Cleared {size} of shader cache.",
"shaders_none": "No shader caches found.",
"pagecache_done": "Page cache dropped.",
"swap_done": "Swap reclaimed into RAM.",
"swap_empty": "Nothing is swapped out.",
"swap_unknown": "Swap usage could not be read.",
"swap_no_room": "Not enough free RAM to hold what is swapped out.",
"failed": "That did not work. See the log."
}
}
+116
View File
@@ -0,0 +1,116 @@
--!nonstrict
--
-- Bar widget: a glyph plus a live tooltip. It owns no state -- it renders whatever the
-- service publishes and sends commands back through `noctalia.state`.
local PANEL_ID = "nomadcxx/gamer-mode:main"
local MIB_PER_GIB = 1024
local M = {}
local metrics = noctalia.state.get("metrics") or { available = false }
local gameMode = noctalia.state.get("game_mode") or { enabled = false, suspended = {} }
local nonceCounter = 0
local function gibibytes(mib)
return string.format("%.1fG", (tonumber(mib) or 0) / MIB_PER_GIB)
end
local function percent(fraction)
return string.format("%d%%", math.floor((tonumber(fraction) or 0) * 100 + 0.5))
end
-- withTemp appends a temperature only when there is one to show, so a machine without
-- sensors reads as "GPU 18%" rather than "GPU 18% 0°C".
local function withTemp(label, showTemps, temp)
if showTemps and tonumber(temp) then
return label .. string.format(" %d°C", math.floor(tonumber(temp) + 0.5))
end
return label
end
function M.formatTooltip(m, showTemps, gm)
m = m or {}
if not m.available then
return noctalia.tr("widget.tooltip_loading")
end
local parts = {
withTemp("CPU " .. percent(m.cpuPerc), showTemps, m.cpuTemp),
"RAM " .. gibibytes(m.memUsedMb),
}
-- GPU and VRAM segments are omitted entirely when unsupported: an empty reading is
-- more honest than a zero that looks like an idle GPU.
if m.gpuAvailable and m.gpuPerc then
parts[#parts + 1] = withTemp("GPU " .. percent(m.gpuPerc), showTemps, m.gpuTemp)
end
if m.vramUsedMb then
parts[#parts + 1] = "VRAM " .. gibibytes(m.vramUsedMb)
end
local tooltip = table.concat(parts, " | ")
if gm and gm.enabled then
tooltip = noctalia.tr("notify.enabled_title") .. " | " .. tooltip
end
return tooltip
end
local function render()
barWidget.setGlyph(noctalia.getConfig("glyph") or "device-gamepad-2")
barWidget.setTooltip(M.formatTooltip(metrics, noctalia.getConfig("show_temps") ~= false, gameMode))
-- Accent the glyph while gamer mode is on, so the bar shows the state at a glance
-- without needing the tooltip.
barWidget.setGlyphColor(gameMode.enabled and "primary" or "on_surface")
end
local function sendCommand(action)
nonceCounter = nonceCounter + 1
noctalia.state.set("command", {
nonce = noctalia.nowMs() * 1000 + nonceCounter,
action = action,
})
end
-- Left-click opens the panel by default. A first click should show what the machine is
-- doing, not suspend a list of programs the user has not read yet.
function M.onClick()
if (noctalia.getConfig("click_action") or "open_panel") == "toggle" then
sendCommand("toggle")
else
noctalia.togglePanel(PANEL_ID)
end
end
-- Right-click always toggles, whatever click_action says, so the one-click path stays
-- available without opening the panel first.
function M.onRightClick()
sendCommand("toggle")
end
noctalia.state.watch("metrics", function(value)
metrics = type(value) == "table" and value or { available = false }
render()
end)
noctalia.state.watch("game_mode", function(value)
gameMode = type(value) == "table" and value or { enabled = false, suspended = {} }
render()
end)
-- ── shell entry points (must be globals) ──
function onClick()
M.onClick()
end
function onRightClick()
M.onRightClick()
end
function onConfigChanged()
render()
end
render()
return M