gamer-mode: 0.7.0 (#331)

* gamer-mode: 0.7.0

Adds a monitor bar widget that renders live readings the way the shell's own
sysmon widgets do, with a flame band beneath them while gamer mode runs.

- Readings warm toward the highlight colour over the shell's own activity and
  critical thresholds, so a monitor sitting beside a sysmon capsule group warms
  in step with it.
- The flame is advected along a wandering wind and sharpened so it reads as
  tongues rather than a smudge; the flare ignites and decays on an ease-out.
- The band's slot is reserved on each side of the readings, so toggling gamer
  mode never moves a digit and the readings stay centred in the pill.
- Panel readings outrank their captions, progress fills warm at the same
  thresholds, and the mark carries a load-reactive halo with a soft ember under
  the header while the mode runs.
- The mark is redrawn and ships a dark and a light ramp.

* gamer-mode: tighten the README prose
This commit is contained in:
RAMA
2026-08-10 20:26:19 -04:00
committed by GitHub
parent ced017593e
commit 12e0501c44
11 changed files with 1410 additions and 262 deletions
+114 -11
View File
@@ -11,7 +11,7 @@ background resource hogs and restores what was running before.
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| ID | `nomadcxx/gamer-mode` | | ID | `nomadcxx/gamer-mode` |
| Entries | Bar widget: `gamermode`; panel: `main`; service: `service` | | Entries | Bar widgets: `gamermode`, `monitor`; panel: `main`; service: `service` |
## Requirements ## Requirements
@@ -30,17 +30,19 @@ panel hides its power row and the toggle still works.
| Left-click | Opens the panel. Set **Left-click action** to `toggle` to toggle instead. | | Left-click | Opens the panel. Set **Left-click action** to `toggle` to toggle instead. |
| Right-click | Toggles gamer mode, whatever **Left-click action** says. | | Right-click | Toggles gamer mode, whatever **Left-click action** says. |
The glyph takes the accent colour while gamer mode runs. The glyph takes the accent colour while gamer mode runs. Its tooltip shows the
live readings in an aligned table and ends with gamer-mode state.
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 The panel shows a bar per reading, the power profile selector, the suspend
profile selector, what the plugin has suspended, and the maintenance actions. profile selector, what the plugin has suspended, and the maintenance actions.
![The panel with gamer mode running](panel.webp)
Running under load. CPU and GPU sit past their thresholds, so their bars have
warmed; RAM and swap have not. The halo rings the mark, the ember sits under the
header, and the suspend profile shows as a label because the session already
recorded one.
Pick `light` or `heavy` in the panel and press Enable, and that profile applies 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 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 choice rides along with the enable rather than changing the **Gamer mode
@@ -61,6 +63,102 @@ Toggle the panel:
noctalia msg panel-toggle nomadcxx/gamer-mode:main noctalia msg panel-toggle nomadcxx/gamer-mode:main
``` ```
## Monitor
Add the `monitor` widget when you want the readings on the bar. Keep the
`gamermode` widget for a compact toggle icon. Both entries use the same click
actions: left-click follows **Left-click action**, and right-click toggles gamer
mode.
The monitor groups related readings in a fixed order. A metric shown on the bar
stays out of its tooltip, so hovering adds the disabled readings instead of
repeating the row. The tooltip always includes gamer-mode state and the number
of suspended targets. Vertical bars stack each reading and omit the flame.
![Monitor widget with gamer mode off above and on below](monitor.webp)
Gamer mode off above, running under load below. The flame is lit, and the
readings past their thresholds have warmed toward the highlight colour.
### The pill
The monitor draws no background of its own, for the same reason the shell's
built-in `sysmon` widgets do not: the bar draws it. Turn it on the way you would
for any widget and the shell frames the readout in the same stadium capsule,
sized and centred by the bar rather than guessed at by the plugin:
```toml
[widget.gamermonitor]
type = "nomadcxx/gamer-mode:monitor"
capsule = true
```
`capsule_fill`, `capsule_padding`, `capsule_radius`, and `capsule_opacity` work
here as they do elsewhere. The monitor can also join a `capsule_group`, sharing
one capsule with the `sysmon` widgets beside it.
Values hug their text by default, so the readout is exactly as wide as its
readings and grows as network rates change. **Reserved value width** trades that
for a steady width: set it to the widest reading you expect and values
right-align inside the reservation instead of pushing their neighbours. It sets
a floor rather than a clamp, so nothing gets truncated.
### Warning colours
Each reading warms towards the theme's error colour as it climbs, holding its
normal colour below an activity threshold and saturating at a critical one. The
thresholds are the shell's own defaults for each metric: 50%/90% for CPU,
60°C/85°C for temperatures, 1/50 MB/s for network. A monitor grouped beside the
built-in `sysmon` widgets therefore warms in step with them.
| Monitor setting | Default | Description |
| --- | --- | --- |
| CPU usage | On | Shows processor load. |
| CPU temperature | On | Shows processor temperature. |
| RAM used | On | Shows used memory in GiB. |
| Swap used | Off | Shows swap percentage when swap exists. |
| GPU usage | On | Shows graphics load when the shell reports a GPU. |
| GPU temperature | On | Shows graphics temperature when available. |
| VRAM used | Off | Shows used video memory when available. |
| Load average | Off | Shows the one-minute load average. |
| Network rates | On | Shows aggregate download and upload rates. |
| Icons | On | Places a glyph beside each reading. Turn it off for numbers only. |
| Reserved value width | `0` | Pixels held for each reading. Zero lets values hug their text. |
| Highlight while gamer mode is on | On | Adds the flame band under the readings. |
| Flame height | `5` | How tall the flame band is, in pixels (2-12). See the note below on why the useful range is small. |
| Flame | `flare` | `off`, a 900 ms `flare`, or `always`. |
| Flame style | `graph` | One graph node, or 28 sharper `bars`. |
The widget holds the band's slot open whether or not it burns, so toggling gamer
mode never shifts your readings. It reserves an equal gap above them to match the
band below, which keeps them centred in the pill instead of pinned to its top
edge.
That is why the band stays small. On a 42px bar at the default capsule thickness
of `0.76`, the pill measures 32px inside and the readings take 22, leaving 10 to
split between the gap and the band. Ask for more than half and the flame lands
outside the pill.
Raise **capsule thickness** in your bar settings if you want a bigger flame. It
sets the pill's height, so a taller pill gives you a taller band: at `0.95` the
pill runs about 40px and fits 9px of flame with the readings still centred.
### While gamer mode is on
A ring wraps the panel mark and thickens as load climbs, and a soft ember pulses
under the header. The panel's vsync frame tick drives each of them, and the shell
stops that tick when you close the panel, so an idle or closed panel costs
nothing.
The mark ships as two files, one dark ramp and one light, and the shell picks
between them by dark mode. A plugin cannot read palette colours, so those stay a
fixed pair instead of following your theme.
`always` holds the widget at about 30 frames per second while gamer mode runs.
Noctalia keeps widget timers running when another window covers the bar, so
this setting consumes CPU during play. `flare` returns to a one-second idle tick
after 900 ms.
### Maintenance ### Maintenance
Three one-shot cleanups sit at the foot of the panel. None of them is part of Three one-shot cleanups sit at the foot of the panel. None of them is part of
@@ -94,6 +192,8 @@ because succeeding into an out-of-memory kill would defeat the point.
| Setting | Default | Description | | Setting | Default | Description |
| --- | --- | --- | | --- | --- | --- |
| Bar icon | `device-gamepad-2` | Glyph shown in the bar. Names a glyph from the shell's registry. | | Bar icon | `device-gamepad-2` | Glyph shown in the bar. Names a glyph from the shell's registry. |
| Custom icon | Empty | SVG or PNG used instead of the glyph. File changes reload in the bar. |
| Custom icon, gamer mode on | Empty | Optional active-state image for the toggle. |
| Left-click action | `open_panel` | Opens the panel or toggles gamer mode. Right-click toggles either way. | | 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. | | Poll interval | `3` | Seconds between metric updates: 2, 3, or 5. |
| Gamer mode profile | `light` | Selects which target profile a toggle applies. | | Gamer mode profile | `light` | Selects which target profile a toggle applies. |
@@ -105,6 +205,10 @@ 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 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. that selection, so it uses the setting.
Noctalia cannot tint a custom image. Set **Custom icon, gamer mode on** if the
toggle needs a distinct active state; otherwise it reuses the resting image and
reports state in the tooltip.
## Target list ## Target list
`targets` takes a JSON array. Each entry needs a `match`, a `kind`, and the `targets` takes a JSON array. Each entry needs a `match`, a `kind`, and the
@@ -377,9 +481,8 @@ still be suspended.
same reason. same reason.
- No I/O weighting for user units. cgroup v2 delegates `cpu`, `memory`, and - No I/O weighting for user units. cgroup v2 delegates `cpu`, `memory`, and
`pids` to the user manager, and not `io`. `pids` to the user manager, and not `io`.
- The bar widget carries the readings in its tooltip. Plugin API 19 does hand a - The toggle widget carries the readings in its tooltip. The monitor shows your
widget `onHover(entered)`, so a richer hover surface is possible and is not selected readings in the bar and puts the rest in its tooltip.
built.
- The panel shows no per-core CPU breakdown and no top-process list. - 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 - 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 default bar section, and a plugin can read its settings but not write them, so
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="100%" height="100%">
<defs>
<linearGradient id="body" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#B3200E"/>
<stop offset="0.45" stop-color="#F0630A"/>
<stop offset="1" stop-color="#FFC02E"/>
</linearGradient>
<linearGradient id="core" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#FFB020"/>
<stop offset="1" stop-color="#FFF3B0"/>
</linearGradient>
</defs>
<path fill="url(#body)" d="M32 4 C 38 18, 50 24, 50 38 A 18 18 0 0 1 14 38 C 14 27, 22 24, 24 14 C 29 19, 30 27, 28 33 C 31 28, 33 16, 32 4 Z"/>
<path fill="url(#core)" d="M32 30 C 36 36, 40 39, 40 44 A 8 8 0 0 1 24 44 C 24 39, 29 36, 32 30 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 759 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="100%" height="100%">
<defs>
<linearGradient id="body" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#8C1408"/>
<stop offset="0.45" stop-color="#D24A05"/>
<stop offset="1" stop-color="#E89100"/>
</linearGradient>
<linearGradient id="core" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#E08A00"/>
<stop offset="1" stop-color="#FFD867"/>
</linearGradient>
</defs>
<path fill="url(#body)" d="M32 4 C 38 18, 50 24, 50 38 A 18 18 0 0 1 14 38 C 14 27, 22 24, 24 14 C 29 19, 30 27, 28 33 C 31 28, 33 16, 32 4 Z"/>
<path fill="url(#core)" d="M32 30 C 36 36, 40 39, 40 44 A 8 8 0 0 1 24 44 C 24 39, 29 36, 32 30 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 759 B

-60
View File
@@ -1,60 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.4 KiB

+701
View File
@@ -0,0 +1,701 @@
--!nonstrict
--
-- Bar monitor: a live metrics readout. It owns no state -- it renders whatever the
-- service publishes and never issues a command of its own except the panel toggle.
--
-- The formatting helpers below are duplicated from widget.luau and panel.luau on
-- purpose. Plugin entries run in separate sandboxed Luau states with no `require`, so
-- there is no module for them to share.
local PANEL_ID = "nomadcxx/gamer-mode:main"
local MIB_PER_GIB = 1024
local PLACEHOLDER = "—"
-- No pill is drawn here. The bar draws it: with `capsule = true` on the widget the shell
-- wraps the rendered content in a stadium shell sized content + 2 * capsule_padding on
-- the main axis and capsule_thickness * bar thickness on the cross axis, radius
-- min(w, h) * 0.5, content centred inside it (shell bar.cpp finalizeCapsules). That is
-- the same capsule the built-in sysmon widgets get, and drawing our own could only ever
-- approximate it -- badly, since a plugin cannot see the bar's thickness or scale.
-- The readout is driven by state.watch, so the tick only exists for animation. It cannot
-- be switched off, so idle is slowed from the 250ms default instead.
local IDLE_INTERVAL_MS = 1000
local FLARE_INTERVAL_MS = 33
local FLARE_DURATION_MS = 900
-- Fire catches. Opening at full intensity is the single thing that made the old flare
-- read as a light switch rather than an ignition.
local IGNITE_MS = 150
local FLAME_COLUMNS = 28
-- The shell's capsule is capsule_thickness (0.76 by default) of the bar -- about 26px on
-- a 34px bar -- and the digits take ~17 of that. Six leaves the band room to burn while
-- the readout still fits, which matters because the bar clips its slots.
-- The band's height in pixels. A plugin cannot read the bar's thickness -- barWidget
-- exposes only isVertical and outputName -- so this cannot be derived and has to be a
-- setting. The default fits a 42px bar (33px capsule) and a 34px bar (30px capsule) over
-- ~17px of digits; a thinner bar needs a smaller band or the slot clips.
-- Measured on a 42px bar at the default capsule_thickness of 0.76: the pill's interior is
-- 32px and the readout box takes 22 of them, leaving 10. The band is reserved
-- symmetrically (see buildTree), so it gets half of what is left. Raising the shell's
-- capsule_thickness is what buys a taller flame: at 0.95 the pill is ~40px and 9 fits.
local FLAME_HEIGHT_DEFAULT = 5
local FLAME_HEIGHT_MIN = 2
local FLAME_HEIGHT_MAX = 12
-- Embers stay alive at idle so the band still reads as lit.
local HEAT_FLOOR = 0.12
-- Fixed, not role-derived. Fire is not a theme colour, and a theme whose primary is green
-- should still get fire.
local FLAME_STOPS = {
{ 0.00, 61, 15, 2 },
{ 0.28, 176, 42, 4 },
{ 0.58, 255, 106, 0 },
{ 0.82, 255, 179, 64 },
{ 1.00, 255, 233, 163 },
}
local M = {}
local initialMetrics = noctalia.state.get("metrics")
local initialGameMode = noctalia.state.get("game_mode")
local metrics = type(initialMetrics) == "table" and initialMetrics or { available = false }
local gameMode = type(initialGameMode) == "table" and initialGameMode or { enabled = false, suspended = {} }
local nonceCounter = 0
local flameField = {}
-- Rendered in the band slot when nothing is burning. Without it the live field would
-- freeze mid-flame the moment a flare ended and sit there as a static fire.
local coldField = {}
for i = 1, FLAME_COLUMNS do
flameField[i] = 0
coldField[i] = 0
end
local flareStartedMs = nil
local wasEnabled = gameMode.enabled == true
local function percent(fraction)
return string.format("%d%%", math.floor((tonumber(fraction) or 0) * 100 + 0.5))
end
local function gibibytes(mib)
return string.format("%.1fG", (tonumber(mib) or 0) / MIB_PER_GIB)
end
local function degrees(temp)
return string.format("%d°", math.floor((tonumber(temp) or 0) + 0.5))
end
-- Bar space is scarce, so rates are unit-suffixed rather than spelled "KB/s". The panel
-- has room for the long form and uses it.
local function perSecond(bytes)
local value = tonumber(bytes) or 0
if value >= 1024 * 1024 * 1024 then
return string.format("%.1fG", value / (1024 * 1024 * 1024))
elseif value >= 1024 * 1024 then
return string.format("%.1fM", value / (1024 * 1024))
elseif value >= 1024 then
return string.format("%.0fK", value / 1024)
end
return string.format("%.0fB", value)
end
-- Display order is fixed by the plugin; the settings only decide which rows appear.
-- `value` returns nil when the machine has nothing to report, which drops the segment
-- rather than printing a zero that reads like an idle device.
local SEGMENTS = {
{
id = "cpu", setting = "show_cpu", group = "cpu", glyph = "cpu-usage",
activity = 0.50, critical = 0.90, heat = function(m) return m.cpuPerc end,
value = function(m) return percent(m.cpuPerc) end,
},
{
id = "cpu_temp", setting = "show_cpu_temp", group = "cpu", glyph = "cpu-temperature",
activity = 60, critical = 85, heat = function(m) return m.cpuTemp end,
value = function(m) return m.cpuTemp and degrees(m.cpuTemp) or nil end,
tip = function(m) return m.cpuTemp and string.format("%d°C", math.floor(m.cpuTemp + 0.5)) or nil end,
},
{
id = "ram", setting = "show_ram", group = "mem", glyph = "memory",
activity = 0.60, critical = 0.90, heat = function(m) return m.memPerc end,
value = function(m) return gibibytes(m.memUsedMb) end,
},
{
id = "swap", setting = "show_swap", group = "mem", glyph = "storage",
activity = 0.20, critical = 0.80, heat = function(m) return m.swapPerc end,
value = function(m)
if m.swapTotalMb and m.swapTotalMb > 0 then
return percent(m.swapPerc)
end
return nil
end,
},
{
id = "gpu", setting = "show_gpu", group = "gpu", glyph = "gpu-usage",
activity = 0.50, critical = 0.95, heat = function(m) return m.gpuPerc end,
value = function(m)
if m.gpuAvailable and m.gpuPerc then
return percent(m.gpuPerc)
end
return nil
end,
},
{
id = "gpu_temp", setting = "show_gpu_temp", group = "gpu", glyph = "temperature",
activity = 60, critical = 85, heat = function(m) return m.gpuTemp end,
value = function(m) return m.gpuTemp and degrees(m.gpuTemp) or nil end,
tip = function(m) return m.gpuTemp and string.format("%d°C", math.floor(m.gpuTemp + 0.5)) or nil end,
},
{
id = "vram", setting = "show_vram", group = "gpu", glyph = "memory",
activity = 0.50, critical = 0.90, heat = function(m) return m.vramPerc end,
value = function(m) return m.vramUsedMb and gibibytes(m.vramUsedMb) or nil end,
},
{
id = "load", setting = "show_load", group = "sys", glyph = "performance",
value = function(m) return m.load1 and string.format("%.2f", m.load1) or nil end,
},
{
id = "net_rx", setting = "show_net", group = "net", glyph = "download",
activity = 1, critical = 50,
heat = function(m) return m.netRxPerSec and m.netRxPerSec / 1000000 or nil end,
value = function(m) return m.netRxPerSec and perSecond(m.netRxPerSec) or nil end,
},
{
id = "net_tx", setting = "show_net", group = "net", glyph = "upload",
activity = 1, critical = 50,
heat = function(m) return m.netTxPerSec and m.netTxPerSec / 1000000 or nil end,
value = function(m) return m.netTxPerSec and perSecond(m.netTxPerSec) or nil end,
},
}
function M.readConfig()
local function bool(key, default)
local value = noctalia.getConfig(key)
if value == nil then
return default
end
return value ~= false
end
return {
show_cpu = bool("show_cpu", true),
show_cpu_temp = bool("show_cpu_temp", true),
show_ram = bool("show_ram", true),
show_swap = bool("show_swap", false),
show_gpu = bool("show_gpu", true),
show_gpu_temp = bool("show_gpu_temp", true),
show_vram = bool("show_vram", false),
show_load = bool("show_load", false),
show_net = bool("show_net", true),
show_glyphs = bool("show_glyphs", true),
highlight_gamer_mode = bool("highlight_gamer_mode", true),
flame = noctalia.getConfig("flame") or "flare",
flame_style = noctalia.getConfig("flame_style") or "graph",
-- Same key and same default as the shell's sysmon widget: zero, meaning the
-- value hugs its text. Reserving width for the widest value a metric can reach
-- is what made this readout permanently as wide as a saturated network link.
label_min_width = math.max(0, tonumber(noctalia.getConfig("label_min_width")) or 0),
flame_height = (function()
local value = tonumber(noctalia.getConfig("flame_height")) or FLAME_HEIGHT_DEFAULT
return math.max(FLAME_HEIGHT_MIN, math.min(FLAME_HEIGHT_MAX, math.floor(value)))
end)(),
}
end
-- The shell tints a sysmon value from its normal colour towards the highlight colour as
-- the metric climbs, holding flat below the activity threshold and saturating at the
-- critical one, with a jump to kActivityOnset the moment it crosses (sysmon_widget.cpp
-- gradientFactor). Same curve here so a grouped plugin readout warms in step with the
-- built-in widgets beside it.
local ACTIVITY_ONSET = 0.25
function M.gradientFactor(value, activity, critical)
if value == nil or activity == nil or critical == nil then
return 0
end
local v = math.max(value, 0)
local top = math.max(critical, 0)
if top <= 0 or v <= 0 then
return 0
end
local floor = math.max(0, math.min(activity, top))
if v <= floor then
return 0
end
if v >= top then
return 1
end
return ACTIVITY_ONSET + (1 - ACTIVITY_ONSET) * (v - floor) / (top - floor)
end
function M.formatSegments(m, config)
m = m or {}
local loading = not m.available
local out = {}
for _, segment in ipairs(SEGMENTS) do
if config[segment.setting] then
-- Before the first sample there is nothing to ask the machine about, so every
-- enabled segment reserves its width with a placeholder. Once a sample lands,
-- a nil value means the hardware is genuinely absent and the segment goes.
local text = loading and PLACEHOLDER or segment.value(m)
if text then
out[#out + 1] = {
id = segment.id,
group = segment.group,
glyph = segment.glyph,
text = text,
-- Nothing has been sampled yet while loading, so nothing is hot.
tint = (not loading) and segment.heat
and M.gradientFactor(segment.heat(m), segment.activity, segment.critical)
or 0,
-- Marked so the renderer can dim them: a dash in the value colour
-- reads as data.
placeholder = loading,
}
end
end
end
return out
end
-- The bar and the tooltip are complements: a segment the user put on the bar is left out
-- of the tooltip, so hovering always adds something.
function M.tooltipRows(m, gm, config)
m = type(m) == "table" and m or {}
local rows = {}
if m.available then
for _, segment in ipairs(SEGMENTS) do
if not config[segment.setting] then
local text = (segment.tip or segment.value)(m)
if text then
rows[#rows + 1] = { key = noctalia.tr("widget." .. segment.id), value = text }
end
end
end
else
rows[#rows + 1] = { key = noctalia.tr("widget.tooltip_loading"), value = PLACEHOLDER }
end
local validGm = type(gm) == "table" and gm or {}
local suspended = type(validGm.suspended) == "table" and validGm.suspended or {}
rows[#rows + 1] = {
key = noctalia.tr("widget.gamer_mode"),
value = validGm.enabled == true
and noctalia.trp("widget.gamer_mode_on", #suspended)
or noctalia.tr("widget.gamer_mode_off"),
}
return rows
end
-- How hard the machine is working, 0..1 -- the thing the flame reports. Usage leads,
-- because that is what a player feels; temperature follows, so a hot card at moderate
-- load still earns a hot flame.
function M.heatOf(m)
if not (m and m.available) then
return 0
end
local gpuUsage = 0
if m.gpuAvailable and tonumber(m.gpuPerc) then
gpuUsage = tonumber(m.gpuPerc)
end
local usage = math.max(tonumber(m.cpuPerc) or 0, gpuUsage)
local hottest = math.max(tonumber(m.cpuTemp) or 0, tonumber(m.gpuTemp) or 0)
local tempHeat = 0
if hottest > 0 then
tempHeat = math.max(0, math.min(1, (hottest - 50) / 40))
end
return math.max(0, math.min(1, usage * 0.7 + tempHeat * 0.3))
end
-- Scratch buffer for one step, reused so a 30fps loop allocates nothing.
local flameScratch = {}
for i = 1, FLAME_COLUMNS do
flameScratch[i] = 0
end
-- Tongues are objects with a life, not one-frame spikes. A spark that is blurred away the
-- frame after it lands can never read as a flame; one that keeps injecting while it drifts
-- does. The pool is fixed and preallocated for the same reason as the scratch buffer.
local FLAME_SEEDS = 6
local flameSeeds = {}
for i = 1, FLAME_SEEDS do
flameSeeds[i] = { pos = 0, life = 0, power = 0 }
end
local flameWind = 0
local flameWindPhase = 0
-- Lets a test start from a known field. Nothing in the widget calls this.
function M.resetFlame()
flameWind = 0
flameWindPhase = 0
for i = 1, FLAME_SEEDS do
local seed = flameSeeds[i]
seed.pos, seed.life, seed.power = 0, 0, 0
end
end
-- Reads outside the band as cold. Cyclic wraparound was why heat used to teleport from
-- one end to the other.
local function sampleField(index)
if index < 1 or index > FLAME_COLUMNS then
return 0
end
return flameScratch[index]
end
-- One step of a 1-D fire. Two sums of slowly moving sines give a wind that wanders
-- without ever repeating on a period short enough to notice; the field is advected along
-- it, blurred with a kernel that leans the same way, then sharpened to put back the peaks
-- the blur flattens. Sharpening is what turns a ridge into tongues.
function M.stepFlame(field, heat, dtMs)
local dt = dtMs or 33
local decay = math.min(1, dt * 0.0042)
flameWindPhase = flameWindPhase + dt * 0.0009
flameWind = math.sin(flameWindPhase) * 0.8 + math.sin(flameWindPhase * 2.3) * 0.35
for i = 1, FLAME_COLUMNS do
flameScratch[i] = field[i] or 0
end
local lean = math.max(-1, math.min(1, flameWind))
local weightLeft = 0.16 + lean * 0.06
local weightRight = 0.16 - lean * 0.06
for i = 1, FLAME_COLUMNS do
-- Fractional read from upwind: this is the advection.
local source = i - flameWind
local base = math.floor(source)
local frac = source - base
local shifted = sampleField(base) * (1 - frac) + sampleField(base + 1) * frac
local blended = shifted * 0.66
+ sampleField(i - 1) * weightLeft
+ sampleField(i + 1) * weightRight
local value = blended - decay * (0.5 + math.random() * 0.7)
if value <= 0 then
field[i] = 0
else
-- Gamma above 1 pushes midtones down and leaves peaks alone, so the tops
-- separate from the body instead of melting into it.
field[i] = math.min(1, value ^ 1.3)
end
end
if heat <= 0 then
return
end
for i = 1, FLAME_SEEDS do
local seed = flameSeeds[i]
if seed.life > 0 then
seed.life = seed.life - dt
seed.pos = seed.pos + flameWind * 0.35
local index = math.floor(seed.pos + 0.5)
if index >= 1 and index <= FLAME_COLUMNS then
field[index] = math.min(1, field[index] + seed.power * dt * 0.02)
end
elseif math.random() < heat * 0.35 then
seed.pos = math.random(1, FLAME_COLUMNS)
seed.life = 120 + math.random() * 260
seed.power = 0.45 + math.random() * 0.55 * heat
end
end
end
local function heatColor(value)
local t = math.max(0, math.min(1, value))
for i = 2, #FLAME_STOPS do
local stop = FLAME_STOPS[i]
if t <= stop[1] then
local previous = FLAME_STOPS[i - 1]
local k = (t - previous[1]) / (stop[1] - previous[1])
return string.format(
"#%02x%02x%02x",
math.floor(previous[2] + (stop[2] - previous[2]) * k),
math.floor(previous[3] + (stop[3] - previous[3]) * k),
math.floor(previous[4] + (stop[4] - previous[4]) * k)
)
end
end
return "#ffe9a3"
end
-- graph is one node whatever the width. bars draws a box per column, which looks crisper
-- close up and costs 28 times as much to reconcile.
function M.flameBand(field, style, height)
local band = height or FLAME_HEIGHT_DEFAULT
if style ~= "bars" then
local outer, inner = {}, {}
for i = 1, FLAME_COLUMNS do
outer[i] = field[i]
inner[i] = field[i] * 0.55
end
return ui.graph({
values = outer,
values2 = inner,
color = "#ff6a00",
color2 = "#ffd27a",
fillOpacity = 1.0,
height = band,
})
end
local bars = {}
for i = 1, FLAME_COLUMNS do
bars[i] = ui.box({
flexGrow = 1,
height = math.max(1, field[i] * band),
fill = heatColor(field[i]),
radius = 1,
})
end
-- align = "end" puts them on a baseline so they grow upward.
return ui.row({ align = "end", gap = 1, height = band }, bars)
end
-- The shell warms a sysmon value from its normal colour towards the highlight colour
-- (`error` by default) as the metric climbs. It lerps in HSV; a plugin can only name
-- colours, so the ramp runs through the role's alpha instead -- the same direction over
-- the same thresholds, on a coarser road.
local function valueColor(segment)
if segment.placeholder then
-- A placeholder in the value colour reads as data; dim it so loading reads as
-- loading.
return "on_surface_variant"
end
if segment.tint <= 0 then
return "on_surface"
end
-- The floor is high on purpose. Alpha is the only ramp available, and a low floor
-- makes the value *dimmer* the moment it crosses its activity threshold -- the
-- opposite of what the threshold is for.
return string.format("error/%.2f", 0.80 + 0.20 * segment.tint)
end
-- Glyph and value share the colour, as they do in the shell: syncValueColor tints both
-- from one currentValueColor unless an explicit icon_color overrides it.
local function segmentNode(segment, config)
local children = {}
if config.show_glyphs then
children[#children + 1] = ui.glyph({ name = segment.glyph, size = 13, color = valueColor(segment) })
end
local label = ui.label({ text = segment.text, color = valueColor(segment) })
if config.label_min_width > 0 then
-- ui.label takes width (a hard clamp) and maxWidth but no minWidth, so the
-- reservation goes on a wrapper flex, which has one. justify = "end" keeps the
-- digits against the right of the reserved space, so they grow leftwards instead
-- of shoving their neighbour.
label = ui.row({ minWidth = config.label_min_width, justify = "end" }, { label })
end
children[#children + 1] = label
return ui.row({ align = "center", gap = 3 }, children)
end
-- Segments of the same group sit close together and groups sit further apart, which is
-- what makes three clusters read as three clusters rather than one run of numbers.
--
-- Nothing here paints a background. The shell's sysmon widget draws no pill either: with
-- `capsule = true` the bar wraps the widget in one, and drawing our own only fought it.
-- The flame band is the whole of what this widget adds on top of a sysmon readout.
function M.buildTree(m, gm, config, vertical, burning)
local segments = M.formatSegments(m, config)
if vertical then
-- A vertical bar is ~26px wide, so a horizontal readout would clip. Segments
-- stack glyph over value, centred.
local rows = {}
for _, segment in ipairs(segments) do
local cell = {}
if config.show_glyphs then
cell[#cell + 1] = ui.glyph({ name = segment.glyph, size = 13, color = valueColor(segment) })
end
cell[#cell + 1] = ui.label({
text = segment.text,
textAlign = "center",
color = valueColor(segment),
})
rows[#rows + 1] = ui.column({ align = "center", gap = 0 }, cell)
end
return ui.column({ align = "center", gap = 2 }, rows)
end
local groups = {}
local currentId = nil
local current = nil
for _, segment in ipairs(segments) do
if segment.group ~= currentId then
current = {}
groups[#groups + 1] = current
currentId = segment.group
end
current[#current + 1] = segmentNode(segment, config)
end
local children = {}
for _, group in ipairs(groups) do
children[#children + 1] = ui.row({ align = "center", gap = 6 }, group)
end
local readout = ui.row({ align = "center", gap = 12 }, children)
-- The band's slot is reserved whenever the flame could appear, not only while it
-- burns, so toggling gamer mode never moves a digit. It is reserved *symmetrically* --
-- an empty spacer above matching the band below -- because a band on one side only
-- pushes the readout off the pill's centre by half its height, and on a 42px bar that
-- put the digits against the top edge with the flame hanging outside the pill.
--
-- The cost is that the band can only be half of whatever the pill has spare, which is
-- why the default is small. The shell's capsule_thickness is the lever: it decides the
-- pill's height, and a taller pill affords a taller flame.
if not (config.highlight_gamer_mode and config.flame ~= "off") then
return readout
end
local lit = gm and gm.enabled and burning ~= nil
return ui.column({ gap = 0 }, {
ui.spacer({ height = config.flame_height }),
readout,
M.flameBand(lit and flameField or coldField, config.flame_style, config.flame_height),
})
end
-- The flare's shape over its 900ms life: ease-in to the peak, then ease-out cubic to
-- nothing. The cubic is what gives the long ember tail -- its slope is steepest right
-- after the peak and almost flat by the end, so the last third of the time covers only a
-- few percent of the intensity.
function M.flareIntensity(elapsedMs, peak)
local t = tonumber(elapsedMs) or 0
local top = math.max(0, tonumber(peak) or 0)
if t < 0 or t >= FLARE_DURATION_MS or top <= 0 then
return 0
end
if t < IGNITE_MS then
local k = t / IGNITE_MS
return top * k * k
end
local k = (t - IGNITE_MS) / (FLARE_DURATION_MS - IGNITE_MS)
local inv = 1 - k
return top * inv * inv * inv
end
local function burningLevel(config)
if barWidget.isVertical() or config.flame == "off" then
return nil
end
if not (config.highlight_gamer_mode and gameMode.enabled) then
return nil
end
local heat = HEAT_FLOOR + M.heatOf(metrics) * (1 - HEAT_FLOOR)
if config.flame == "always" then
return heat
end
if flareStartedMs == nil then
return nil
end
local elapsed = noctalia.nowMs() - flareStartedMs
if elapsed >= FLARE_DURATION_MS then
return nil
end
-- The flare opens hot whatever the load, then follows the envelope down.
return M.flareIntensity(elapsed, math.max(heat, 0.75))
end
local function render()
local config = M.readConfig()
local burning = burningLevel(config)
if burning ~= nil then
M.stepFlame(flameField, burning, FLARE_INTERVAL_MS)
end
barWidget.render(M.buildTree(metrics, gameMode, config, barWidget.isVertical(), burning))
barWidget.setTooltip(M.tooltipRows(metrics, gameMode, config))
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 = {} }
local config = M.readConfig()
local enabled = gameMode.enabled == true
local horizontal = not barWidget.isVertical()
if enabled and not wasEnabled and config.highlight_gamer_mode and horizontal then
if config.flame == "flare" then
flareStartedMs = noctalia.nowMs()
noctalia.setUpdateInterval(FLARE_INTERVAL_MS)
elseif config.flame == "always" then
noctalia.setUpdateInterval(FLARE_INTERVAL_MS)
end
elseif not enabled and wasEnabled then
flareStartedMs = nil
noctalia.setUpdateInterval(IDLE_INTERVAL_MS)
end
wasEnabled = enabled
render()
end)
-- ── shell entry points (must be globals) ──
function onClick()
if (noctalia.getConfig("click_action") or "open_panel") == "toggle" then
nonceCounter = nonceCounter + 1
noctalia.state.set("command", { nonce = noctalia.nowMs() * 1000 + nonceCounter, action = "toggle" })
else
noctalia.togglePanel(PANEL_ID)
end
end
function onRightClick()
nonceCounter = nonceCounter + 1
noctalia.state.set("command", { nonce = noctalia.nowMs() * 1000 + nonceCounter, action = "toggle" })
end
-- Called by the shell on its timer. It does nothing at all unless something is burning,
-- which is what keeps the idle cost to one no-op call a second.
function update()
local config = M.readConfig()
if barWidget.isVertical() then
flareStartedMs = nil
noctalia.setUpdateInterval(IDLE_INTERVAL_MS)
return
end
if config.flame == "always" and gameMode.enabled and config.highlight_gamer_mode then
render()
return
end
if flareStartedMs == nil then
return
end
if noctalia.nowMs() - flareStartedMs >= FLARE_DURATION_MS then
flareStartedMs = nil
noctalia.setUpdateInterval(IDLE_INTERVAL_MS)
end
render()
end
function onConfigChanged()
local config = M.readConfig()
if barWidget.isVertical() or config.flame == "off" or not (gameMode.enabled and config.highlight_gamer_mode) then
flareStartedMs = nil
noctalia.setUpdateInterval(IDLE_INTERVAL_MS)
elseif config.flame == "always" then
noctalia.setUpdateInterval(FLARE_INTERVAL_MS)
elseif flareStartedMs == nil then
noctalia.setUpdateInterval(IDLE_INTERVAL_MS)
end
render()
end
noctalia.setUpdateInterval(IDLE_INTERVAL_MS)
-- `always` is a free-running loop, not an edge: gamer mode may already be on when the
-- widget loads, and no transition will fire to start it.
local bootConfig = M.readConfig()
if not barWidget.isVertical()
and bootConfig.flame == "always"
and gameMode.enabled
and bootConfig.highlight_gamer_mode then
noctalia.setUpdateInterval(FLARE_INTERVAL_MS)
end
render()
return M
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+208 -83
View File
@@ -27,79 +27,65 @@ local nonceCounter = 0
-- source the plugin was installed from, which differs per machine. The data directory is -- 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. -- 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 -- gamer-mode/logo-dark.svg and logo-light.svg hold the same bytes and are the copies to
-- if the two drift apart. -- edit; tests/logo.lua fails if any of them drift apart. Two files rather than one that
local LOGO_SVG = [==[ -- gets rewritten: the shell caches textures by {path, targetSize}, so overwriting a
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%"> -- single path would keep serving the previous theme's raster after a theme flip.
local LOGO_SVG_DARK = [==[
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="100%" height="100%">
<defs> <defs>
<linearGradient id="outerFlame" x1="0%" y1="100%" x2="0%" y2="0%"> <linearGradient id="body" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#C0392B"/> <stop offset="0" stop-color="#B3200E"/>
<stop offset="100%" stop-color="#E74C3C"/> <stop offset="0.45" stop-color="#F0630A"/>
<stop offset="1" stop-color="#FFC02E"/>
</linearGradient> </linearGradient>
<linearGradient id="midFlame" x1="0%" y1="100%" x2="0%" y2="0%"> <linearGradient id="core" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#E67E22"/> <stop offset="0" stop-color="#FFB020"/>
<stop offset="100%" stop-color="#F39C12"/> <stop offset="1" stop-color="#FFF3B0"/>
</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> </linearGradient>
</defs> </defs>
<path fill="url(#body)" d="M32 4 C 38 18, 50 24, 50 38 A 18 18 0 0 1 14 38 C 14 27, 22 24, 24 14 C 29 19, 30 27, 28 33 C 31 28, 33 16, 32 4 Z"/>
<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(#core)" d="M32 30 C 36 36, 40 39, 40 44 A 8 8 0 0 1 24 44 C 24 39, 29 36, 32 30 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> </svg>
]==] ]==]
-- Deeper stops so the mark still has contrast against a light surface. The palette is
-- not readable from a plugin, so this is a fixed pair rather than a derived colour.
local LOGO_SVG_LIGHT = [==[
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="100%" height="100%">
<defs>
<linearGradient id="body" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#8C1408"/>
<stop offset="0.45" stop-color="#D24A05"/>
<stop offset="1" stop-color="#E89100"/>
</linearGradient>
<linearGradient id="core" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#E08A00"/>
<stop offset="1" stop-color="#FFD867"/>
</linearGradient>
</defs>
<path fill="url(#body)" d="M32 4 C 38 18, 50 24, 50 38 A 18 18 0 0 1 14 38 C 14 27, 22 24, 24 14 C 29 19, 30 27, 28 33 C 31 28, 33 16, 32 4 Z"/>
<path fill="url(#core)" d="M32 30 C 36 36, 40 39, 40 44 A 8 8 0 0 1 24 44 C 24 39, 29 36, 32 30 Z"/>
</svg>
]==]
-- Distinct filenames per variant, never one path rewritten: the texture cache is keyed by
-- {path, targetSize}, so overwriting one file would keep serving the old theme's raster.
function M.logoVariant(dark)
return dark and "logo-dark.svg" or "logo-light.svg"
end
local function logoPath() local function logoPath()
local directory = noctalia.pluginDataDir() local directory = noctalia.pluginDataDir()
if not directory then if not directory then
return nil return nil
end end
local path = directory .. "/logo.svg" -- There is no theme-change callback, so the variant is resolved on every render. It is
-- a filename choice and a fileExists in the common case.
local dark = noctalia.isDarkMode() ~= false
local path = directory .. "/" .. M.logoVariant(dark)
if not noctalia.fileExists(path) then if not noctalia.fileExists(path) then
if not noctalia.writeFile(path, LOGO_SVG) then if not noctalia.writeFile(path, dark and LOGO_SVG_DARK or LOGO_SVG_LIGHT) then
noctalia.log("gamermode: could not write the panel logo") noctalia.log("gamermode: could not write the panel logo")
return nil return nil
end end
@@ -138,6 +124,42 @@ end
-- buildRows produces one entry per reading the machine actually reports. Unsupported -- 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 -- readings are absent rather than drawn as an empty bar, which would look like a real
-- idle reading. -- idle reading.
-- The shell tints a sysmon value from its normal colour towards the highlight colour as
-- the metric climbs, holding flat below the activity threshold and saturating at the
-- critical one, with a jump to kActivityOnset the moment it crosses (sysmon_widget.cpp
-- gradientFactor). Duplicated from monitor.luau rather than shared: plugin entries run in
-- separate sandboxed Luau states with no `require`.
local ACTIVITY_ONSET = 0.25
function M.gradientFactor(value, activity, critical)
if value == nil or activity == nil or critical == nil then
return 0
end
local v = math.max(value, 0)
local top = math.max(critical, 0)
if top <= 0 or v <= 0 then
return 0
end
local floor = math.max(0, math.min(activity, top))
if v <= floor then
return 0
end
if v >= top then
return 1
end
return ACTIVITY_ONSET + (1 - ACTIVITY_ONSET) * (v - floor) / (top - floor)
end
-- Every row's `progress` is already the fraction the shell would gradient on, so the bar
-- and the reading cannot disagree.
function M.barFill(row)
local tint = M.gradientFactor(row.progress, row.activity, row.critical)
if tint <= 0 then
return "primary"
end
return string.format("error/%.2f", 0.80 + 0.20 * tint)
end
function M.buildRows(m, showTemps) function M.buildRows(m, showTemps)
local rows = {} local rows = {}
if type(m) ~= "table" or not m.available then if type(m) ~= "table" or not m.available then
@@ -148,12 +170,16 @@ function M.buildRows(m, showTemps)
label = "CPU", label = "CPU",
glyph = "cpu-usage", glyph = "cpu-usage",
progress = clamp(m.cpuPerc), progress = clamp(m.cpuPerc),
activity = 0.50,
critical = 0.90,
detail = withTemp(percent(m.cpuPerc), showTemps, m.cpuTemp), detail = withTemp(percent(m.cpuPerc), showTemps, m.cpuTemp),
} }
rows[#rows + 1] = { rows[#rows + 1] = {
label = "RAM", label = "RAM",
glyph = "memory", glyph = "memory",
progress = clamp(m.memPerc), progress = clamp(m.memPerc),
activity = 0.60,
critical = 0.90,
detail = usedOfTotal(m.memUsedMb, m.memTotalMb), detail = usedOfTotal(m.memUsedMb, m.memTotalMb),
} }
@@ -165,6 +191,8 @@ function M.buildRows(m, showTemps)
label = "Swap", label = "Swap",
glyph = "storage", glyph = "storage",
progress = clamp(m.swapPerc), progress = clamp(m.swapPerc),
activity = 0.20,
critical = 0.80,
detail = usedOfTotal(m.swapUsedMb, m.swapTotalMb), detail = usedOfTotal(m.swapUsedMb, m.swapTotalMb),
} }
end end
@@ -174,6 +202,8 @@ function M.buildRows(m, showTemps)
label = "GPU", label = "GPU",
glyph = "gpu-usage", glyph = "gpu-usage",
progress = clamp(m.gpuPerc), progress = clamp(m.gpuPerc),
activity = 0.50,
critical = 0.95,
detail = withTemp(percent(m.gpuPerc), showTemps, m.gpuTemp), detail = withTemp(percent(m.gpuPerc), showTemps, m.gpuTemp),
} }
end end
@@ -182,6 +212,8 @@ function M.buildRows(m, showTemps)
label = "VRAM", label = "VRAM",
glyph = "storage", glyph = "storage",
progress = clamp(m.vramPerc), progress = clamp(m.vramPerc),
activity = 0.50,
critical = 0.90,
detail = usedOfTotal(m.vramUsedMb, m.vramTotalMb), detail = usedOfTotal(m.vramUsedMb, m.vramTotalMb),
} }
end end
@@ -299,33 +331,90 @@ end
-- ── rendering ── -- ── rendering ──
local function metricRow(row) -- One builder for readings with a bar and readings without. After the hierarchy pass the
-- two differed only by the progress node, and keeping two copies meant every colour
-- decision had to be made twice.
local function readingRow(entry, withBar)
local line = ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = entry.glyph, size = 14, color = "on_surface_variant" }),
ui.label({ text = entry.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 = entry.detail, color = "on_surface", fontSize = 11 }),
})
if not withBar then
return line
end
return ui.column({ gap = 4 }, { return ui.column({ gap = 4 }, {
ui.row({ align = "center", gap = 8 }, { line,
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({ ui.progress({
height = 4, height = 4,
progress = row.progress, progress = entry.progress,
fill = "primary", fill = M.barFill(entry),
track = "surface_variant", track = "surface_variant",
radius = 2, radius = 2,
}), }),
}) })
end end
local function figureRow(figure) -- How hard the machine is working, 0..1, mirroring monitor.luau's heatOf. Usage leads
return ui.row({ align = "center", gap = 8 }, { -- because that is what a player feels; temperature follows. cpuPerc/gpuPerc are 0..1
ui.glyph({ name = figure.glyph, size = 14, color = "on_surface_variant" }), -- fractions, matching what the service publishes.
ui.label({ text = figure.label, color = "on_surface_variant", width = 48 }), local function heatOf(m)
ui.spacer({ flexGrow = 1 }), if not (type(m) == "table" and m.available) then
ui.label({ text = figure.detail, color = "on_surface_variant", fontSize = 11 }), return 0
}) end
local gpuUsage = 0
if m.gpuAvailable and tonumber(m.gpuPerc) then
gpuUsage = tonumber(m.gpuPerc)
end
local usage = math.max(tonumber(m.cpuPerc) or 0, gpuUsage)
local hottest = math.max(tonumber(m.cpuTemp) or 0, tonumber(m.gpuTemp) or 0)
local tempHeat = 0
if hottest > 0 then
tempHeat = math.max(0, math.min(1, (hottest - 50) / 40))
end
return math.max(0, math.min(1, usage * 0.7 + tempHeat * 0.3))
end
local haloPhase = 0
-- A ring, not a bloom. Nothing can be drawn behind the mark -- the tree is pure flexbox
-- with no stack or z-index -- so the halo is ui.image's own border, animated per frame.
-- Hex rather than a role token because fire is not a theme colour.
function M.haloSpec(phase, heat)
local hot = math.max(0, math.min(1, tonumber(heat) or 0))
local pulse = 0.5 + 0.5 * math.sin(tonumber(phase) or 0)
local intensity = 0.25 + 0.75 * hot
local alpha = math.floor((0.20 + 0.55 * intensity * pulse) * 255 + 0.5)
local green = math.floor(90 + 110 * hot + 0.5)
local blue = math.floor(20 + 40 * hot * pulse + 0.5)
return {
border = string.format("#%02x%02x%02x%02x", 255, green, blue, alpha),
borderWidth = 1 + 2 * intensity * pulse,
}
end
-- A soft bar under the header that breathes. ui.box's softness is what keeps it from
-- reading as a second separator; a hard 2px rule there just looks like a mistake.
function M.emberSpec(phase, heat)
local hot = math.max(0, math.min(1, tonumber(heat) or 0))
local pulse = 0.5 + 0.5 * math.sin(tonumber(phase) or 0)
local opacity = 0.18 + 0.42 * (0.3 + 0.7 * hot) * pulse
return {
fill = string.format("#ff%02x1e", math.floor(90 + 110 * hot + 0.5)),
opacity = math.max(0, math.min(1, opacity)),
softness = 2 + 2 * hot,
}
end
-- Vsync ticks are coalesced and stop dead while the panel is closed, but they are still a
-- cost, so they are only asked for while there is something to animate.
local function syncFrameTick()
if panel.setNeedsFrameTick then
panel.setNeedsFrameTick(gameMode.enabled == true)
end
end end
local function toggleButton() local function toggleButton()
@@ -342,11 +431,22 @@ end
local function header() local function header()
local logo = logoPath() local logo = logoPath()
local mark = logo local mark
and ui.image({ path = logo, width = 42, height = 42, fit = "contain" }) if logo then
-- radius is set unconditionally: putting it inside the branch would pop the mark
-- between square and circular every time the mode is toggled.
local props = { path = logo, width = 42, height = 42, fit = "contain", radius = 21 }
if gameMode.enabled then
local halo = M.haloSpec(haloPhase, heatOf(metrics))
props.border = halo.border
props.borderWidth = halo.borderWidth
end
mark = ui.image(props)
else
-- The glyph is the fallback for the one case that can fail: no plugin data -- The glyph is the fallback for the one case that can fail: no plugin data
-- directory to write the logo into. -- directory to write the logo into.
or ui.glyph({ name = "device-gamepad-2", size = 22, color = "primary" }) mark = ui.glyph({ name = "device-gamepad-2", size = 22, color = "primary" })
end
return ui.row({ align = "center", gap = 10 }, { return ui.row({ align = "center", gap = 10 }, {
mark, mark,
@@ -492,6 +592,17 @@ local function body()
local showTemps = noctalia.getConfig("show_temps") ~= false local showTemps = noctalia.getConfig("show_temps") ~= false
local children = {} local children = {}
if gameMode.enabled then
local ember = M.emberSpec(haloPhase, heatOf(metrics))
children[#children + 1] = ui.box({
height = 2,
radius = 1,
fill = ember.fill,
opacity = ember.opacity,
softness = ember.softness,
})
end
local rows = M.buildRows(metrics, showTemps) local rows = M.buildRows(metrics, showTemps)
if #rows == 0 then if #rows == 0 then
children[#children + 1] = ui.label({ children[#children + 1] = ui.label({
@@ -502,10 +613,10 @@ local function body()
else else
children[#children + 1] = sectionLabel("panel.performance") children[#children + 1] = sectionLabel("panel.performance")
for _, row in ipairs(rows) do for _, row in ipairs(rows) do
children[#children + 1] = metricRow(row) children[#children + 1] = readingRow(row, true)
end end
for _, figure in ipairs(M.buildFigures(metrics)) do for _, figure in ipairs(M.buildFigures(metrics)) do
children[#children + 1] = figureRow(figure) children[#children + 1] = readingRow(figure, false)
end end
if not metrics.gpuAvailable then if not metrics.gpuAvailable then
children[#children + 1] = ui.label({ children[#children + 1] = ui.label({
@@ -556,6 +667,19 @@ function onOpen()
gameMode = noctalia.state.get("game_mode") or gameMode gameMode = noctalia.state.get("game_mode") or gameMode
power = noctalia.state.get("power") or power power = noctalia.state.get("power") or power
render() render()
-- Watchers fire on change, so opening the panel while gamer mode is already on would
-- otherwise leave the halo frozen until the next toggle.
syncFrameTick()
end
-- Called at vsync while the panel is open and something is animating. Advancing a phase
-- and re-rendering is the whole of it; the halo and the ember are pure functions of it.
function onFrameTick(deltaMs)
if not gameMode.enabled then
return
end
haloPhase = (haloPhase + (tonumber(deltaMs) or 16) * 0.0025) % (math.pi * 2)
render()
end end
-- The command carries the chosen profile so one button both picks and applies. Disabling -- The command carries the chosen profile so one button both picks and applies. Disabling
@@ -620,6 +744,7 @@ end)
noctalia.state.watch("game_mode", function(value) noctalia.state.watch("game_mode", function(value)
gameMode = type(value) == "table" and value or { enabled = false, busy = false, suspended = {} } gameMode = type(value) == "table" and value or { enabled = false, busy = false, suspended = {} }
syncFrameTick()
render() render()
end) end)
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+134 -1
View File
@@ -1,6 +1,6 @@
id = "nomadcxx/gamer-mode" id = "nomadcxx/gamer-mode"
name = "Gamer Mode" name = "Gamer Mode"
version = "0.6.4" version = "0.7.0"
plugin_api = 19 plugin_api = 19
author = "Nomadcxx" author = "Nomadcxx"
license = "MIT" license = "MIT"
@@ -16,6 +16,22 @@ label_key = "settings.glyph.label"
description_key = "settings.glyph.description" description_key = "settings.glyph.description"
default = "device-gamepad-2" default = "device-gamepad-2"
[[setting]]
key = "icon_file"
type = "file"
label_key = "settings.icon_file.label"
description_key = "settings.icon_file.description"
default = ""
extensions = ["svg", "png"]
[[setting]]
key = "icon_file_active"
type = "file"
label_key = "settings.icon_file_active.label"
description_key = "settings.icon_file_active.description"
default = ""
extensions = ["svg", "png"]
[[setting]] [[setting]]
key = "click_action" key = "click_action"
type = "select" type = "select"
@@ -92,3 +108,120 @@ keyboard_focus = "on_demand"
[[widget]] [[widget]]
id = "gamermode" id = "gamermode"
entry = "widget.luau" entry = "widget.luau"
[[widget]]
id = "monitor"
entry = "monitor.luau"
[[widget.setting]]
key = "show_cpu"
type = "bool"
label_key = "settings.monitor.show_cpu.label"
description_key = "settings.monitor.show_cpu.description"
default = true
[[widget.setting]]
key = "show_cpu_temp"
type = "bool"
label_key = "settings.monitor.show_cpu_temp.label"
default = true
[[widget.setting]]
key = "show_ram"
type = "bool"
label_key = "settings.monitor.show_ram.label"
default = true
[[widget.setting]]
key = "show_swap"
type = "bool"
label_key = "settings.monitor.show_swap.label"
default = false
[[widget.setting]]
key = "show_gpu"
type = "bool"
label_key = "settings.monitor.show_gpu.label"
default = true
[[widget.setting]]
key = "show_gpu_temp"
type = "bool"
label_key = "settings.monitor.show_gpu_temp.label"
default = true
[[widget.setting]]
key = "show_vram"
type = "bool"
label_key = "settings.monitor.show_vram.label"
default = false
[[widget.setting]]
key = "show_load"
type = "bool"
label_key = "settings.monitor.show_load.label"
default = false
[[widget.setting]]
key = "show_net"
type = "bool"
label_key = "settings.monitor.show_net.label"
default = true
[[widget.setting]]
key = "show_glyphs"
type = "bool"
label_key = "settings.monitor.show_glyphs.label"
description_key = "settings.monitor.show_glyphs.description"
default = true
[[widget.setting]]
key = "label_min_width"
type = "int"
label_key = "settings.monitor.label_min_width.label"
description_key = "settings.monitor.label_min_width.description"
default = 0
min = 0
max = 200
[[widget.setting]]
key = "highlight_gamer_mode"
type = "bool"
label_key = "settings.monitor.highlight_gamer_mode.label"
description_key = "settings.monitor.highlight_gamer_mode.description"
default = true
[[widget.setting]]
key = "flame_height"
type = "int"
label_key = "settings.monitor.flame_height.label"
description_key = "settings.monitor.flame_height.description"
default = 5
min = 2
max = 12
visible_when = { key = "highlight_gamer_mode", values = ["true"] }
[[widget.setting]]
key = "flame"
type = "select"
label_key = "settings.monitor.flame.label"
description_key = "settings.monitor.flame.description"
default = "flare"
visible_when = { key = "highlight_gamer_mode", values = ["true"] }
options = [
{ value = "off", label_key = "settings.monitor.flame.options.off" },
{ value = "flare", label_key = "settings.monitor.flame.options.flare" },
{ value = "always", label_key = "settings.monitor.flame.options.always" },
]
[[widget.setting]]
key = "flame_style"
type = "select"
label_key = "settings.monitor.flame_style.label"
description_key = "settings.monitor.flame_style.description"
default = "graph"
visible_when = { key = "highlight_gamer_mode", values = ["true"] }
options = [
{ value = "graph", label_key = "settings.monitor.flame_style.options.graph" },
{ value = "bars", label_key = "settings.monitor.flame_style.options.bars" },
]
+162 -75
View File
@@ -1,34 +1,135 @@
{ {
"cleanup": { "widget": {
"failed": "That did not work. See the log.", "tooltip_loading": "Gamer Mode: loading metrics...",
"pagecache": "Drop page cache", "cpu": "CPU",
"pagecache_done": "Page cache dropped.", "cpu_temp": "CPU temp",
"running": "Working...", "ram": "RAM",
"shaders": "Clear shader caches", "swap": "Swap",
"shaders_confirm": "Delete {size}?", "gpu": "GPU",
"shaders_done": "Cleared {size} of shader cache.", "gpu_temp": "GPU temp",
"shaders_none": "No shader caches found.", "vram": "VRAM",
"swap": "Reclaim swap", "load": "Load",
"swap_done": "Swap reclaimed into RAM.", "net_rx": "Down",
"swap_empty": "Nothing is swapped out.", "net_tx": "Up",
"swap_no_room": "Not enough free RAM to hold what is swapped out.", "gamer_mode": "Gamer mode",
"swap_unknown": "Swap usage could not be read.", "gamer_mode_on": {
"title": "Maintenance" "one": "On, 1 suspended",
}, "other": "On, {count} suspended"
"notify": {
"disabled_title": "Gamer mode disabled",
"enabled_title": "Gamer mode enabled",
"nothing_restored": "Nothing needed restarting.",
"nothing_suspended": "No background targets were running.",
"restored_count": {
"one": "Restored 1 target.",
"other": "Restored {count} targets."
}, },
"session_failed": "The session file could not be written, so nothing was suspended.", "gamer_mode_off": "Off"
"session_failed_title": "Gamer mode could not start", },
"suspended_count": { "settings": {
"one": "Suspended 1 target.", "glyph": {
"other": "Suspended {count} targets." "label": "Bar icon",
"description": "Glyph shown on the bar."
},
"icon_file": {
"label": "Custom icon",
"description": "An SVG or PNG to use instead of the glyph. Edits to the file appear in the bar straight away."
},
"icon_file_active": {
"label": "Custom icon, gamer mode on",
"description": "Shown while gamer mode is on. A custom icon cannot be recoloured, so without this the state is only in the tooltip."
},
"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."
},
"monitor": {
"show_cpu": {
"label": "CPU usage",
"description": "Show processor load in the bar."
},
"show_cpu_temp": {
"label": "CPU temperature"
},
"show_ram": {
"label": "RAM used"
},
"show_swap": {
"label": "Swap used"
},
"show_gpu": {
"label": "GPU usage"
},
"show_gpu_temp": {
"label": "GPU temperature"
},
"show_vram": {
"label": "VRAM used"
},
"show_load": {
"label": "Load average"
},
"show_net": {
"label": "Network rates"
},
"show_glyphs": {
"label": "Icons",
"description": "Show an icon beside each reading. Off gives numbers only."
},
"label_min_width": {
"label": "Reserved value width",
"description": "Pixels to reserve for each reading. Zero lets every value hug its text, so the readout is as wide as it needs to be. Raise it if you would rather the width held steady than stayed tight."
},
"highlight_gamer_mode": {
"label": "Highlight while gamer mode is on",
"description": "Tints the readout instead of adding an icon, so nothing moves when it switches."
},
"flame": {
"label": "Flame",
"description": "Burns while gamer mode is on, as hot as the machine is working. \"Always\" keeps it animating the whole session, which costs CPU while you play.",
"options": {
"off": "Off",
"flare": "Flare on switch",
"always": "Always"
}
},
"flame_style": {
"label": "Flame style",
"description": "How the flame is drawn. \"Graph\" is one node and the cheapest; \"bars\" is crisper up close and costs 28.",
"options": {
"graph": "Graph",
"bars": "Bars"
}
},
"flame_height": {
"label": "Flame height",
"description": "Height of the flame band in pixels. The band is mirrored by an equal space above it so the readings stay centred, so it can only be half of whatever the pill has spare. Raise the bar's capsule thickness for a taller flame."
}
} }
}, },
"panel": { "panel": {
@@ -44,15 +145,15 @@
"nothing_suspended": "Nothing suspended", "nothing_suspended": "Nothing suspended",
"performance": "Performance", "performance": "Performance",
"power": { "power": {
"power-saver": "Power saver",
"balanced": "Balanced", "balanced": "Balanced",
"performance": "Performance", "performance": "Performance"
"power-saver": "Power saver"
}, },
"power_profile": "Power profile", "power_profile": "Power profile",
"power_unavailable": "powerprofilesctl unavailable", "power_unavailable": "powerprofilesctl unavailable",
"profiles": { "profiles": {
"heavy": "Heavy", "light": "Light",
"light": "Light" "heavy": "Heavy"
}, },
"settings": "Settings", "settings": "Settings",
"state_off": "Off", "state_off": "Off",
@@ -62,50 +163,36 @@
"title": "gamer-mode", "title": "gamer-mode",
"working": "Working..." "working": "Working..."
}, },
"settings": { "notify": {
"auto_performance": { "enabled_title": "Gamer mode enabled",
"description": "Switch power profile to performance while gamer mode is on.", "disabled_title": "Gamer mode disabled",
"label": "Auto performance profile" "suspended_count": {
"one": "Suspended 1 target.",
"other": "Suspended {count} targets."
}, },
"click_action": { "restored_count": {
"description": "What left-clicking the bar icon does. Right-click always toggles gamer mode.", "one": "Restored 1 target.",
"label": "Left-click action", "other": "Restored {count} targets."
"options": {
"open_panel": "Open panel",
"toggle": "Toggle gamer mode"
}
}, },
"glyph": { "nothing_suspended": "No background targets were running.",
"description": "Glyph shown on the bar.", "nothing_restored": "Nothing needed restarting.",
"label": "Bar icon" "session_failed_title": "Gamer mode could not start",
}, "session_failed": "The session file could not be written, so nothing was suspended."
"poll_interval": {
"description": "Seconds between metric updates.",
"label": "Poll interval",
"options": {
"2": "2s",
"3": "3s",
"5": "5s"
}
},
"profile": {
"description": "How aggressive the suspend list is.",
"label": "Gamer mode profile",
"options": {
"heavy": "Heavy",
"light": "Light"
}
},
"show_temps": {
"description": "Include CPU/GPU temperatures in tooltip and panel.",
"label": "Show temperatures"
},
"targets": {
"description": "Advanced: JSON array of {match, kind, profiles}. Kind: process, user-service, system-service, container.",
"label": "Suspend targets (JSON)"
}
}, },
"widget": { "cleanup": {
"tooltip_loading": "Gamer Mode: loading metrics..." "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."
} }
} }
+61 -32
View File
@@ -20,47 +20,76 @@ local function percent(fraction)
return string.format("%d%%", math.floor((tonumber(fraction) or 0) * 100 + 0.5)) return string.format("%d%%", math.floor((tonumber(fraction) or 0) * 100 + 0.5))
end end
-- withTemp appends a temperature only when there is one to show, so a machine without -- Rows rather than a joined string: setTooltip renders a list of {key, value} tables as
-- sensors reads as "GPU 18%" rather than "GPU 18% 0°C". -- an aligned two-column block.
local function withTemp(label, showTemps, temp) function M.tooltipRows(m, showTemps, gm)
if showTemps and tonumber(temp) then m = type(m) == "table" and m or {}
return label .. string.format(" %d°C", math.floor(tonumber(temp) + 0.5)) local rows = {}
if not m.available then
rows[#rows + 1] = { key = noctalia.tr("widget.tooltip_loading"), value = "—" }
else
rows[#rows + 1] = { key = noctalia.tr("widget.cpu"), value = percent(m.cpuPerc) }
if showTemps and tonumber(m.cpuTemp) then
rows[#rows + 1] = { key = noctalia.tr("widget.cpu_temp"), value = string.format("%d°C", math.floor(tonumber(m.cpuTemp) + 0.5)) }
end
rows[#rows + 1] = { key = noctalia.tr("widget.ram"), value = gibibytes(m.memUsedMb) }
-- GPU and VRAM rows 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
rows[#rows + 1] = { key = noctalia.tr("widget.gpu"), value = percent(m.gpuPerc) }
end
if showTemps and tonumber(m.gpuTemp) then
rows[#rows + 1] = { key = noctalia.tr("widget.gpu_temp"), value = string.format("%d°C", math.floor(tonumber(m.gpuTemp) + 0.5)) }
end
if m.vramUsedMb then
rows[#rows + 1] = { key = noctalia.tr("widget.vram"), value = gibibytes(m.vramUsedMb) }
end
end end
return label
local validGm = type(gm) == "table" and gm or {}
local suspended = type(validGm.suspended) == "table" and validGm.suspended or {}
rows[#rows + 1] = {
key = noctalia.tr("widget.gamer_mode"),
value = validGm.enabled == true
and noctalia.trp("widget.gamer_mode_on", #suspended)
or noctalia.tr("widget.gamer_mode_off"),
}
return rows
end end
function M.formatTooltip(m, showTemps, gm) -- Trimmed locally on purpose. The real binding is `noctalia.string.trim`, but the test
m = m or {} -- harness exposes a top-level `noctalia.trim`, so code written against the mock would
if not m.available then -- pass the suite and fail in the shell. A Lua pattern depends on neither.
return noctalia.tr("widget.tooltip_loading") local function trimmed(value)
end return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
local parts = { local function iconFile()
withTemp("CPU " .. percent(m.cpuPerc), showTemps, m.cpuTemp), local resting = trimmed(noctalia.getConfig("icon_file"))
"RAM " .. gibibytes(m.memUsedMb), if resting == "" then
} return nil
-- 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 end
if m.vramUsedMb then if gameMode.enabled then
parts[#parts + 1] = "VRAM " .. gibibytes(m.vramUsedMb) local active = trimmed(noctalia.getConfig("icon_file_active"))
if active ~= "" then
return active
end
end end
return resting
local tooltip = table.concat(parts, " | ")
if gm and gm.enabled then
tooltip = noctalia.tr("notify.enabled_title") .. " | " .. tooltip
end
return tooltip
end end
local function render() local function render()
barWidget.setGlyph(noctalia.getConfig("glyph") or "device-gamepad-2") local icon = iconFile()
barWidget.setTooltip(M.formatTooltip(metrics, noctalia.getConfig("show_temps") ~= false, gameMode)) if icon then
-- Accent the glyph while gamer mode is on, so the bar shows the state at a glance -- watch = true, so editing the file updates the bar without a reload. An image
-- without needing the tooltip. -- cannot be tinted, which is what icon_file_active exists to work around.
barWidget.setGlyphColor(gameMode.enabled and "primary" or "on_surface") barWidget.setImage(icon, true, 18)
else
barWidget.setGlyph(noctalia.getConfig("glyph") or "device-gamepad-2")
barWidget.setGlyphColor(gameMode.enabled and "primary" or "on_surface")
end
barWidget.setTooltip(M.tooltipRows(metrics, noctalia.getConfig("show_temps") ~= false, gameMode))
end end
local function sendCommand(action) local function sendCommand(action)