Update(procmon-plugin): Add keyboard navigation and reduce CPU sampling (#321)

* Add procmon plugin with live process panel

New Noctalia plugin: bar widget shows CPU/RAM usage and process
count; floating panel has a sortable, searchable process table
with per-process kill. Background service samples ps and /proc
stats and publishes via plugin state.

* Throttle data-driven panel renders

Rebuild table at most every 500ms to stay within the panel update
CPU budget. User interactions still render immediately; only state
watches and the tick go through maybeRender().

* Update procmon deps and fix timestamp precision

Document head, grep, and cat as required commands. Correct refresh
interval default to 1000 ms. Sample refreshedAtMs in milliseconds.

* Add keyboard navigation and reduce CPU sampling

- Panel: arrow keys move cursor, Ctrl+D kills selected, Ctrl+F focuses
  filter
- Service: sample /proc stats every 1s, ps every 2s; 2s render floor
- Widget: handle vertical bars, truncate text, use valid theme color
- Bump version to 0.4.0, plugin_api 13
This commit is contained in:
weinguyen
2026-08-09 13:55:01 -04:00
committed by GitHub
parent 82f1cffd55
commit 44e32f58bc
6 changed files with 274 additions and 111 deletions
+5 -5
View File
@@ -25,14 +25,12 @@ usage (plus the process count). Click the widget to toggle the process panel:
```sh ```sh
noctalia msg panel-toggle weinguyen/procmon:panel noctalia msg panel-toggle weinguyen/procmon:panel
``` ```
The panel shows CPU, RAM and swap bars with the 1/5/15-minute load averages, The panel shows CPU, RAM and swap bars with the 1/5/15-minute load averages,
then a process table. Sort by the column dropdown (PID, CPU%, MEM%, RSS, then a process table. Sort by the column dropdown (PID, CPU%, MEM%, RSS,
COMMAND) and flip asc/desc with the arrow button. Type in the filter box to COMMAND) and flip asc/desc with the arrow button. Type in the filter box to
match a process by name, user or PID. Click the ✕ button on a row to run the match a process by name, user or PID. Click the ✕ button on a row to run the
configured kill command against that PID (default `kill -TERM`). Zombie configured kill command against that PID (default `kill -TERM`). Zombie
processes are tinted with the error color so they stand out. processes are tinted with the error color so they stand out.
The table refreshes on the interval set in the `refresh_interval` setting. The The table refreshes on the interval set in the `refresh_interval` setting. The
panel re-renders automatically as new samples arrive, so the view stays live panel re-renders automatically as new samples arrive, so the view stays live
while it is open. while it is open.
@@ -48,13 +46,15 @@ while it is open.
## Notes ## Notes
- **Spawns processes.** A background service runs `ps` on every refresh - **Spawns processes.** A background service runs `ps` every 2nd refresh
interval and `runAsync` runs the configured `kill_command` when a row's ✕ is interval (the process table changes slowly) and reads `/proc` stats every
interval; `runAsync` runs the configured `kill_command` when a row's ✕ is
clicked. There is no confirmation dialog, so check the PID before clicking. clicked. There is no confirmation dialog, so check the PID before clicking.
- The bar widget only renders data the service publishes; it never runs - The bar widget only renders data the service publishes; it never runs
commands. The panel runs the configured `kill_command` when a row's ✕ is commands. The panel runs the configured `kill_command` when a row's ✕ is
clicked. clicked.
- Requires `plugin_api = 12`. CPU%, RAM%, swap and the 1/5/15-minute load - Requires `plugin_api = 13`. CPU%, RAM%, swap and the 1/5/15-minute load
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`, averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`,
`/proc/loadavg`) by the service, so they work with no separate system-monitor `/proc/loadavg`) by the service, so they work with no separate system-monitor
dependency. dependency.
+150 -14
View File
@@ -5,6 +5,8 @@
local sortKey = "cpu" local sortKey = "cpu"
local sortDir = "desc" local sortDir = "desc"
local filter = "" local filter = ""
local filterRev = 0 -- bumped to reseed the filter input so it can grab focus
local focusFilterOnRender = false -- one-shot: focuses the filter on the next render
-- Per-column display unit: "pct" shows percent, "raw" shows the absolute -- Per-column display unit: "pct" shows percent, "raw" shows the absolute
-- figure. Clicking the %CPU/%MEM header toggles it. MEM raw = RSS in MB; CPU -- figure. Clicking the %CPU/%MEM header toggles it. MEM raw = RSS in MB; CPU
@@ -19,6 +21,11 @@ local memUnit = "pct"
-- processes are always visible. (200 rows * ~7 ui nodes blew the budget.) -- processes are always visible. (200 rows * ~7 ui nodes blew the budget.)
local MAX_ROWS = 80 local MAX_ROWS = 80
-- Keyboard navigation state (btop-style): selectedIdx is the cursor position
-- in the currently visible (filtered/sorted/trimmed) list; 0 means nothing is
-- selected. Ctrl+D kills the selected process directly.
local selectedIdx = 0
-- Cheap fingerprint of everything the table shows, so the 1s tick only rebuilds -- Cheap fingerprint of everything the table shows, so the 1s tick only rebuilds
-- the heavy UI tree when the data actually changed. Rebuilding 200 rows every -- the heavy UI tree when the data actually changed. Rebuilding 200 rows every
-- second exceeded the panel update CPU budget; sampling the first SIG_SAMPLE -- second exceeded the panel update CPU budget; sampling the first SIG_SAMPLE
@@ -29,10 +36,14 @@ local lastSig = nil
-- Throttle data-driven renders. The service can sample faster than the panel can -- Throttle data-driven renders. The service can sample faster than the panel can
-- rebuild its table, and every state.set fires a watch: at 250ms that was ~16 -- rebuild its table, and every state.set fires a watch: at 250ms that was ~16
-- full rebuilds/sec of 80 rows, which blew the panel update CPU budget and got -- full rebuilds/sec of 80 rows, which blew the panel update CPU budget and got
-- the panel disabled. User interactions (toggle/filter/sort) still render() -- the panel disabled. 2000ms bounds rebuilds to 1/2s, well under the budget.
-- immediately; only the data watches and the tick go through maybeRender(). -- The service samples stats every 1s and the process table every 2s, so a 2s
-- rebuild floor keeps the panel in lockstep with the table without re-rendering
-- the whole 80-row tree on every stats tick.
-- User interactions (toggle/filter/sort) still render() immediately; only the
-- data watches and the tick go through maybeRender().
local lastRenderMs = 0 local lastRenderMs = 0
local MIN_RENDER_MS = 500 local MIN_RENDER_MS = 2000
-- Kill column is a fixed width at the end of every row; all other columns are -- Kill column is a fixed width at the end of every row; all other columns are
-- flexGrow shares so the header and data rows always align and never overflow. -- flexGrow shares so the header and data rows always align and never overflow.
@@ -193,7 +204,7 @@ local function toggleMemUnit()
render() render()
end end
local function dataRow(p) local function dataRow(p, idx)
local stat = p.stat or "?" local stat = p.stat or "?"
local statColor = stat:find("Z") and "error" or "on_surface" local statColor = stat:find("Z") and "error" or "on_surface"
local memCell = memUnit == "raw" and fmtMem(p.rssMb) or string.format("%.1f", p.mem) local memCell = memUnit == "raw" and fmtMem(p.rssMb) or string.format("%.1f", p.mem)
@@ -206,6 +217,20 @@ local function dataRow(p)
{ stat, statColor }, { stat, statColor },
} }
-- Cursor highlight mirrors the signal picker: a translucent fill plus the
-- text flipped to primary. Zombie rows keep their error tint so the warning
-- survives selection. The row key bakes in the selection state so a moved
-- cursor rebuilds exactly the rows whose selection flipped -- a stable pid
-- key would leave the fill stuck on rows the cursor passed over.
local selected = idx == selectedIdx
local function cellColor(c)
local color = c[2] or "on_surface"
if selected and color ~= "error" then
return "primary"
end
return color
end
local children = {} local children = {}
for i, c in ipairs(cells) do for i, c in ipairs(cells) do
table.insert(children, ui.label({ table.insert(children, ui.label({
@@ -214,7 +239,7 @@ local function dataRow(p)
textAlign = COLS[i][2], textAlign = COLS[i][2],
fontSize = 11, fontSize = 11,
maxLines = 1, maxLines = 1,
color = c[2] or "on_surface", color = cellColor(c),
})) }))
end end
@@ -225,6 +250,7 @@ local function dataRow(p)
textAlign = "start", textAlign = "start",
fontSize = 11, fontSize = 11,
maxLines = 1, maxLines = 1,
color = selected and "primary" or "on_surface",
})) }))
-- Kill button (fixed trailing width) -- Kill button (fixed trailing width)
@@ -240,7 +266,19 @@ local function dataRow(p)
end, end,
})) }))
return ui.row({ key = tostring(p.pid), gap = 6, align = "center" }, children) return ui.row({
key = tostring(p.pid) .. (selected and "|sel" or ""),
gap = 6,
align = "center",
fill = selected and "primary/0.25" or nil,
radius = 6,
paddingH = 8,
paddingV = 2,
onClick = function()
selectedIdx = idx
render()
end,
}, children)
end end
-- Header: clickable cells use ui.row onClick (keeps layout, so the grid stays -- Header: clickable cells use ui.row onClick (keeps layout, so the grid stays
@@ -285,10 +323,13 @@ end
-- ── main render ───────────────────────────────────────────────────────────── -- ── main render ─────────────────────────────────────────────────────────────
function render() -- The currently visible rows: filtered by `filter`, sorted by the active
-- column, capped at MAX_ROWS. Shared by render() and the keyboard handlers so
-- onKey resolves the cursor position against the exact same list the panel
-- draws (never trust an index computed from a stale render).
local function visibleList()
local procs = st("procs") or {} local procs = st("procs") or {}
local f = filter:lower() local f = filter:lower()
local list = {} local list = {}
for _, p in ipairs(procs) do for _, p in ipairs(procs) do
if f ~= "" then if f ~= "" then
@@ -325,12 +366,29 @@ function render()
end) end)
-- Bound the rendered rows so a 500-process box stays responsive. -- Bound the rendered rows so a 500-process box stays responsive.
local shown = list
if #list > MAX_ROWS then if #list > MAX_ROWS then
shown = {} local shown = {}
for i = 1, MAX_ROWS do for i = 1, MAX_ROWS do
shown[i] = list[i] shown[i] = list[i]
end end
return shown, #list
end
return list, #list
end
function render()
local wantsFocus = focusFilterOnRender
focusFilterOnRender = false
local shown, totalCount = visibleList()
-- Keep the cursor inside the visible list. A shrunk list (filter/sort/data
-- change) clamps up; an empty list clears the cursor.
if #shown == 0 then
selectedIdx = 0
elseif selectedIdx > #shown then
selectedIdx = #shown
elseif selectedIdx == 0 then
selectedIdx = 1
end end
local body local body
@@ -340,8 +398,8 @@ function render()
}) })
else else
local itemRows = {} local itemRows = {}
for _, p in ipairs(shown) do for i, p in ipairs(shown) do
table.insert(itemRows, dataRow(p)) table.insert(itemRows, dataRow(p, i))
end end
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows) body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
end end
@@ -358,11 +416,12 @@ function render()
renderStats(), renderStats(),
ui.row({ gap = 6, align = "center" }, { ui.row({ gap = 6, align = "center" }, {
ui.input({ ui.input({
key = "filter", key = "filter-" .. filterRev,
value = filter, value = filter,
placeholder = tr("panel.search_placeholder"), placeholder = tr("panel.search_placeholder"),
controlSize = "sm", controlSize = "sm",
flexGrow = 1, flexGrow = 1,
focus = wantsFocus,
onChange = function(v) onChange = function(v)
filter = v filter = v
render() render()
@@ -397,7 +456,7 @@ function render()
ui.row({ gap = 6, align = "center" }, buildHeader()), ui.row({ gap = 6, align = "center" }, buildHeader()),
body, body,
ui.row({ gap = 10, align = "center" }, { ui.row({ gap = 10, align = "center" }, {
ui.label({ text = tr("panel.count", { n = #list }), fontSize = 11, color = "on_surface_variant" }), ui.label({ text = tr("panel.count", { n = totalCount }), fontSize = 11, color = "on_surface_variant" }),
ui.row({ gap = 8, align = "center", flexGrow = 1 }, { ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }), ui.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }),
ui.slider({ ui.slider({
@@ -410,6 +469,9 @@ function render()
}), }),
ui.label({ text = err, fontSize = 11, color = "error" }), ui.label({ text = err, fontSize = 11, color = "error" }),
}), }),
ui.row({ gap = 8, align = "center" }, {
ui.label({ text = tr("panel.keys_hint"), fontSize = 10, color = "on_surface_variant" }),
}),
})) }))
lastSig = signature() lastSig = signature()
end end
@@ -452,9 +514,83 @@ function update()
maybeRender() maybeRender()
end end
-- Keyboard control (btop-style). Chords must be listed in the panel's
-- `capture_keys` in plugin.toml, otherwise onKey never fires.
--
-- List mode: up/down move the cursor, Ctrl+D opens the signal picker for the
-- selected process, Ctrl+F focuses the filter box. Menu mode: up/down move the
-- signal highlight, Return sends it, Ctrl+D or Esc closes the menu. Ctrl-modifier
-- chords are used (not bare letters) so they never collide with the filter
-- input's text keys.
--
-- Keyboard chords. The runtime delivers chords as clean down/up pairs and
-- does not tag auto-repeat events, so holding a chord fires repeated presses.
-- The keyHeld guard (release-based, no time gate) blocks those repeats until
-- the release arrives; genuine taps clear on release, so they stay instant.
-- The short TTL only covers a rare missed release, never a tap.
local keyHeld = {}
local KEY_HELD_TTL_MS = 150
local function keyFresh(chord)
local now = noctalia.nowMs()
local heldAt = keyHeld[chord]
if heldAt and now - heldAt < KEY_HELD_TTL_MS then
return false
end
keyHeld[chord] = now
return true
end
function onKey(chord, pressed)
if not pressed then
keyHeld[chord] = nil
return
end
-- Arrow keys repeat freely (holding Down scrolls the table / menu); the
-- guard only covers action chords where an auto-repeat would flip state.
if chord ~= "up" and chord ~= "down" and not keyFresh(chord) then
return
end
if chord == "up" or chord == "down" then
local list = visibleList()
if #list > 0 then
selectedIdx = math.max(1, math.min(#list, selectedIdx + (chord == "down" and 1 or -1)))
render()
end
return
end
if chord == "ctrl+d" then
-- Kill the selected process directly (same configured kill_command the
-- row's ✕ button runs). No confirmation, matching the ✕ button.
local list = visibleList()
local p = list[selectedIdx]
if p then
killPid(p.pid)
end
return
end
if chord == "ctrl+f" then
filterRev += 1
focusFilterOnRender = true
render()
return
end
if chord == "escape" then
-- Swallow Escape so it never closes the whole panel.
return
end
end
function onOpen(_context) function onOpen(_context)
sortKey = cfg("sort_by") or "cpu" sortKey = cfg("sort_by") or "cpu"
sortDir = "desc" sortDir = "desc"
filter = "" filter = ""
-- No auto-focus on the filter: the panel opens in list mode so the arrow
-- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start
-- typing a filter; the box is focused then.
selectedIdx = 0
render() render()
end end
+3 -2
View File
@@ -1,7 +1,7 @@
id = "weinguyen/procmon" id = "weinguyen/procmon"
name = "Process Monitor" name = "Process Monitor"
version = "0.3.0" version = "0.4.0"
plugin_api = 12 plugin_api = 13
author = "weinguyen" author = "weinguyen"
license = "MIT" license = "MIT"
icon = "cpu" icon = "cpu"
@@ -71,6 +71,7 @@ height = 660
placement = "floating" placement = "floating"
position = "center" position = "center"
open_near_click = true open_near_click = true
capture_keys = ["ctrl+f", "ctrl+d", "up", "down", "escape"]
[[service]] [[service]]
id = "service" id = "service"
+36 -26
View File
@@ -36,13 +36,14 @@ end
-- `args` is placed last so the fixed fields are positional and everything -- `args` is placed last so the fixed fields are positional and everything
-- after them is the full command line (which may contain spaces). The shell -- after them is the full command line (which may contain spaces). The shell
-- pre-sorts by CPU and truncates to the top 120 rows so the Luau parse stays -- pre-sorts by CPU and truncates to the top 80 rows so the Luau parse stays
-- tiny: parsing every process blew the async callback's CPU budget even with a -- tiny: parsing every process blew the async callback's CPU budget even with a
-- single-pass gmatch. btm shows a screenful anyway, so top-120 is plenty. -- single-pass gmatch. The panel caps at 80 rows (MAX_ROWS), so parsing more is
-- wasted work -- top-80 is exactly what gets displayed.
-- The trailing ##STAT/##MEM sections carry real total CPU/RAM from /proc so the -- The trailing ##STAT/##MEM sections carry real total CPU/RAM from /proc so the
-- bars read the machine's actual usage -- ps %cpu is a per-process lifetime -- bars read the machine's actual usage -- ps %cpu is a per-process lifetime
-- average, so summing the top rows and capping at 100 always pegged the bar. -- average, so summing the top rows and capping at 100 always pegged the bar.
local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 120" local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 80"
-- Real total CPU/RAM come from /proc in a SEPARATE, tiny command so the async -- Real total CPU/RAM come from /proc in a SEPARATE, tiny command so the async
-- callback that parses it stays far under the CPU budget. Folding the markers -- callback that parses it stays far under the CPU budget. Folding the markers
@@ -170,35 +171,22 @@ local function sampleRam(raw)
return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024) return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024)
end end
local sampling = false -- inFlight counts outstanding async callbacks. sample() only starts when it is
-- 0, so a slow `ps` can never overlap the next tick's subprocesses -- overlapping
-- spawns were a big part of the CPU-budget blowout.
local inFlight = 0
local tick = 0
local function sample() local function sample()
if sampling then if inFlight > 0 then
return -- one in-flight sample at a time return -- one in-flight sample at a time
end end
sampling = true tick += 1
noctalia.runAsync(PS_CMD, function(res)
local ok, perr = pcall(function()
sampling = false
if res == nil or res.timedOut or res.exitCode ~= 0 then
local detail = res and (res.stderr or res.stdout or "") or "no result"
setState("err", detail)
return
end
local procs = parse(res.stdout)
setState("procs", procs)
setState("refreshedAtMs", os.time() * 1000)
end)
if not ok then
sampling = false
log("ps callback error: " .. tostring(perr))
end
end, PS_TIMEOUT_MS)
-- Cheap /proc stats every tick: the bars stay real-time.
inFlight += 1
noctalia.runAsync(STATS_CMD, function(res) noctalia.runAsync(STATS_CMD, function(res)
local ok, perr = pcall(function() local ok, perr = pcall(function()
sampling = false
if res == nil or res.timedOut or res.exitCode ~= 0 then if res == nil or res.timedOut or res.exitCode ~= 0 then
return return
end end
@@ -215,10 +203,32 @@ local function sample()
setState("err", "") setState("err", "")
end) end)
if not ok then if not ok then
sampling = false
log("stats callback error: " .. tostring(perr)) log("stats callback error: " .. tostring(perr))
end end
inFlight -= 1
end, PS_TIMEOUT_MS) end, PS_TIMEOUT_MS)
-- The `ps` subprocess is the biggest CPU cost; the process table changes
-- slowly, so sample it every 2nd tick while stats stay fresh every tick.
if tick % 2 == 1 then
inFlight += 1
noctalia.runAsync(PS_CMD, function(res)
local ok, perr = pcall(function()
if res == nil or res.timedOut or res.exitCode ~= 0 then
local detail = res and (res.stderr or res.stdout or "") or "no result"
setState("err", detail)
return
end
local procs = parse(res.stdout)
setState("procs", procs)
setState("refreshedAtMs", os.time() * 1000)
end)
if not ok then
log("ps callback error: " .. tostring(perr))
end
inFlight -= 1
end, PS_TIMEOUT_MS)
end
end end
-- Seed placeholders so subscribers have something before the first sample. -- Seed placeholders so subscribers have something before the first sample.
+44 -43
View File
@@ -1,59 +1,60 @@
{ {
"panel": {
"col": {
"cmd": "COMMAND",
"cpu": "CPU%",
"cpu_raw": "CPU",
"mem": "MEM%",
"mem_raw": "MEM",
"pid": "PID",
"rss": "RSS",
"stat": "S"
},
"count": "{n} processes",
"kill_tip": "Kill {pid}",
"load": "Load",
"no_processes": "No matching processes",
"refresh": "Refresh",
"refreshed_at": "Updated {time}",
"search_placeholder": "Filter by name, user or PID",
"sort_asc": "asc",
"sort_desc": "desc",
"title": "Processes"
},
"settings": { "settings": {
"glyph": {
"description": "Glyph shown in the bar widget.",
"label": "Widget icon"
},
"icon_only": {
"description": "Show only a CPU icon in the bar widget; details move to the tooltip.",
"label": "Icon only"
},
"kill_command": {
"description": "Command run against a selected PID (the PID is appended). Empty uses kill -TERM.",
"label": "Kill command"
},
"refresh_interval": { "refresh_interval": {
"description": "How often the process list is resampled.", "label": "Refresh interval (ms)",
"label": "Refresh interval (ms)" "description": "How often the process list is resampled."
},
"show_count": {
"description": "Append the number of processes to the bar widget text.",
"label": "Show process count"
}, },
"sort_by": { "sort_by": {
"description": "Column the process table is sorted by when the panel opens.",
"label": "Default sort column", "label": "Default sort column",
"description": "Column the process table is sorted by when the panel opens.",
"options": { "options": {
"cmd": "Command",
"cpu": "CPU", "cpu": "CPU",
"mem": "Memory", "mem": "Memory",
"pid": "PID" "pid": "PID",
"cmd": "Command"
} }
},
"kill_command": {
"label": "Kill command",
"description": "Command run against a selected PID (the PID is appended). Empty uses kill -TERM."
},
"show_count": {
"label": "Show process count",
"description": "Append the number of processes to the bar widget text."
},
"glyph": {
"label": "Widget icon",
"description": "Glyph shown in the bar widget."
},
"icon_only": {
"label": "Icon only",
"description": "Show only a CPU icon in the bar widget; details move to the tooltip."
} }
}, },
"widget": { "widget": {
"tooltip": "CPU {cpu}% RAM {ram}% · {n} processes" "tooltip": "CPU {cpu}% RAM {ram}% · {n} processes"
},
"panel": {
"title": "Processes",
"refreshed_at": "Updated {time}",
"refresh": "Refresh",
"load": "Load",
"search_placeholder": "Filter by name, user or PID",
"sort_asc": "asc",
"sort_desc": "desc",
"col": {
"pid": "PID",
"cpu": "CPU%",
"cpu_raw": "CPU",
"mem": "MEM%",
"mem_raw": "MEM",
"rss": "RSS",
"stat": "S",
"cmd": "COMMAND"
},
"count": "{n} processes",
"no_processes": "No matching processes",
"kill_tip": "Kill {pid}",
"keys_hint": "Ctrl+F filter · Ctrl+D kill selected · arrows move"
} }
} }
+32 -17
View File
@@ -1,7 +1,12 @@
--!nonstrict --!nonstrict
-- Process Monitor — bar widget. Full mode shows "CPU% RAM% · count" text; icon-only -- Process Monitor — bar widget. Horizontal bars show "CPU% RAM%" text (truncated
-- mode shows just a CPU icon colored by load, with the details in the tooltip so the -- to fit); vertical bars are too narrow for text, so they show only a CPU icon
-- bar stays tiny. Clicking opens the panel. -- colored by load. Clicking opens the panel.
-- Read the bar orientation once at load (pomodoro pattern) so the widget renders
-- consistently instead of flickering between icon and text when isVertical() is
-- polled on every update.
local isVertical = barWidget.isVertical()
local function cfg(key) local function cfg(key)
return noctalia.getConfig(key) return noctalia.getConfig(key)
@@ -15,6 +20,14 @@ local function tr(key, subst)
return noctalia.tr(key, subst) return noctalia.tr(key, subst)
end end
-- Cap the text so it fits the bar instead of spilling out the right edge.
local function trunc(s, n)
if #s <= n then
return s
end
return s:sub(1, n - 1) .. "…"
end
function update() function update()
local stats = st("stats") local stats = st("stats")
local cpu = stats and stats.cpu and stats.cpu.usagePercent local cpu = stats and stats.cpu and stats.cpu.usagePercent
@@ -23,28 +36,30 @@ function update()
local cpuInt = math.floor(cpu or 0) local cpuInt = math.floor(cpu or 0)
local tooltip = tr("widget.tooltip", { cpu = cpuInt, ram = math.floor(ram or 0), n = count }) local tooltip = tr("widget.tooltip", { cpu = cpuInt, ram = math.floor(ram or 0), n = count })
barWidget.setGlyph(cfg("glyph") or "cpu") -- Color the icon by CPU load so a glance reads the machine state.
barWidget.setTooltip(tooltip) -- Only real theme tokens (error/secondary/on_surface) — the runtime rejects
-- unknown names like "warning" and logs a warning per glyph per frame.
if cfg("icon_only") then local color = "on_surface"
barWidget.setText("")
-- color the icon by CPU load so a glance reads the machine state
if cpuInt >= 80 then if cpuInt >= 80 then
barWidget.setGlyphColor("error") color = "error"
elseif cpuInt >= 50 then elseif cpuInt >= 50 then
barWidget.setGlyphColor("warning") color = "secondary"
else
barWidget.setGlyphColor("on_surface")
end
return
end end
barWidget.setGlyphColor("on_surface") local children = { ui.glyph({ name = cfg("glyph") or "cpu", size = 14, color = color }) }
-- Vertical bars are too narrow for text; show only the icon so it fits.
if not isVertical and not cfg("icon_only") then
local text = string.format("CPU %d%% RAM %d%%", cpuInt, math.floor(ram or 0)) local text = string.format("CPU %d%% RAM %d%%", cpuInt, math.floor(ram or 0))
if cfg("show_count") then if cfg("show_count") then
text = text .. " · " .. count text = text .. " · " .. count
end end
barWidget.setText(text) table.insert(children, ui.label({ text = text, fontSize = 11 }))
end
local container = isVertical and ui.column or ui.row
barWidget.render(container({ gap = 6, align = "center" }, children))
barWidget.setTooltip(tooltip)
end end
noctalia.state.watch("stats", update) noctalia.state.watch("stats", update)