diff --git a/gamer-mode/README.md b/gamer-mode/README.md
index 5daaed6..105b397 100644
--- a/gamer-mode/README.md
+++ b/gamer-mode/README.md
@@ -11,7 +11,7 @@ background resource hogs and restores what was running before.
| Field | Value |
| --- | --- |
| ID | `nomadcxx/gamer-mode` |
-| Entries | Bar widget: `gamermode`; panel: `main`; service: `service` |
+| Entries | Bar widgets: `gamermode`, `monitor`; panel: `main`; service: `service` |
## 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. |
| 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 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 panel shows a bar per reading, the power profile selector, the suspend
profile selector, what the plugin has suspended, and the maintenance actions.
+
+
+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
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
@@ -61,6 +63,102 @@ Toggle the panel:
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.
+
+
+
+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
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 |
| --- | --- | --- |
| 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. |
| Poll interval | `3` | Seconds between metric updates: 2, 3, or 5. |
| 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
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
`targets` takes a JSON array. Each entry needs a `match`, a `kind`, and the
@@ -377,9 +481,8 @@ still be suspended.
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 toggle widget carries the readings in its tooltip. The monitor shows your
+ selected readings in the bar and puts the rest in its tooltip.
- 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
diff --git a/gamer-mode/logo-dark.svg b/gamer-mode/logo-dark.svg
new file mode 100644
index 0000000..20b685d
--- /dev/null
+++ b/gamer-mode/logo-dark.svg
@@ -0,0 +1,15 @@
+
diff --git a/gamer-mode/logo-light.svg b/gamer-mode/logo-light.svg
new file mode 100644
index 0000000..62d82b6
--- /dev/null
+++ b/gamer-mode/logo-light.svg
@@ -0,0 +1,15 @@
+
diff --git a/gamer-mode/logo.svg b/gamer-mode/logo.svg
deleted file mode 100644
index 6f157ee..0000000
--- a/gamer-mode/logo.svg
+++ /dev/null
@@ -1,60 +0,0 @@
-
diff --git a/gamer-mode/monitor.luau b/gamer-mode/monitor.luau
new file mode 100644
index 0000000..68b71c7
--- /dev/null
+++ b/gamer-mode/monitor.luau
@@ -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
diff --git a/gamer-mode/monitor.webp b/gamer-mode/monitor.webp
new file mode 100644
index 0000000..ad865a1
Binary files /dev/null and b/gamer-mode/monitor.webp differ
diff --git a/gamer-mode/panel.luau b/gamer-mode/panel.luau
index 48a2def..b183939 100644
--- a/gamer-mode/panel.luau
+++ b/gamer-mode/panel.luau
@@ -27,79 +27,65 @@ local nonceCounter = 0
-- 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 = [==[
-