* 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
702 lines
27 KiB
Luau
702 lines
27 KiB
Luau
--!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
|