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:
+150
-14
@@ -5,6 +5,8 @@
|
||||
local sortKey = "cpu"
|
||||
local sortDir = "desc"
|
||||
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
|
||||
-- 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.)
|
||||
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
|
||||
-- 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
|
||||
@@ -29,10 +36,14 @@ local lastSig = nil
|
||||
-- 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
|
||||
-- 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()
|
||||
-- immediately; only the data watches and the tick go through maybeRender().
|
||||
-- the panel disabled. 2000ms bounds rebuilds to 1/2s, well under the budget.
|
||||
-- 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 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
|
||||
-- flexGrow shares so the header and data rows always align and never overflow.
|
||||
@@ -193,7 +204,7 @@ local function toggleMemUnit()
|
||||
render()
|
||||
end
|
||||
|
||||
local function dataRow(p)
|
||||
local function dataRow(p, idx)
|
||||
local stat = p.stat or "?"
|
||||
local statColor = stat:find("Z") and "error" or "on_surface"
|
||||
local memCell = memUnit == "raw" and fmtMem(p.rssMb) or string.format("%.1f", p.mem)
|
||||
@@ -206,6 +217,20 @@ local function dataRow(p)
|
||||
{ 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 = {}
|
||||
for i, c in ipairs(cells) do
|
||||
table.insert(children, ui.label({
|
||||
@@ -214,7 +239,7 @@ local function dataRow(p)
|
||||
textAlign = COLS[i][2],
|
||||
fontSize = 11,
|
||||
maxLines = 1,
|
||||
color = c[2] or "on_surface",
|
||||
color = cellColor(c),
|
||||
}))
|
||||
end
|
||||
|
||||
@@ -225,6 +250,7 @@ local function dataRow(p)
|
||||
textAlign = "start",
|
||||
fontSize = 11,
|
||||
maxLines = 1,
|
||||
color = selected and "primary" or "on_surface",
|
||||
}))
|
||||
|
||||
-- Kill button (fixed trailing width)
|
||||
@@ -240,7 +266,19 @@ local function dataRow(p)
|
||||
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
|
||||
|
||||
-- Header: clickable cells use ui.row onClick (keeps layout, so the grid stays
|
||||
@@ -285,10 +323,13 @@ end
|
||||
|
||||
-- ── 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 f = filter:lower()
|
||||
|
||||
local list = {}
|
||||
for _, p in ipairs(procs) do
|
||||
if f ~= "" then
|
||||
@@ -325,12 +366,29 @@ function render()
|
||||
end)
|
||||
|
||||
-- Bound the rendered rows so a 500-process box stays responsive.
|
||||
local shown = list
|
||||
if #list > MAX_ROWS then
|
||||
shown = {}
|
||||
local shown = {}
|
||||
for i = 1, MAX_ROWS do
|
||||
shown[i] = list[i]
|
||||
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
|
||||
|
||||
local body
|
||||
@@ -340,8 +398,8 @@ function render()
|
||||
})
|
||||
else
|
||||
local itemRows = {}
|
||||
for _, p in ipairs(shown) do
|
||||
table.insert(itemRows, dataRow(p))
|
||||
for i, p in ipairs(shown) do
|
||||
table.insert(itemRows, dataRow(p, i))
|
||||
end
|
||||
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
|
||||
end
|
||||
@@ -358,11 +416,12 @@ function render()
|
||||
renderStats(),
|
||||
ui.row({ gap = 6, align = "center" }, {
|
||||
ui.input({
|
||||
key = "filter",
|
||||
key = "filter-" .. filterRev,
|
||||
value = filter,
|
||||
placeholder = tr("panel.search_placeholder"),
|
||||
controlSize = "sm",
|
||||
flexGrow = 1,
|
||||
focus = wantsFocus,
|
||||
onChange = function(v)
|
||||
filter = v
|
||||
render()
|
||||
@@ -397,7 +456,7 @@ function render()
|
||||
ui.row({ gap = 6, align = "center" }, buildHeader()),
|
||||
body,
|
||||
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.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }),
|
||||
ui.slider({
|
||||
@@ -410,6 +469,9 @@ function render()
|
||||
}),
|
||||
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()
|
||||
end
|
||||
@@ -452,9 +514,83 @@ function update()
|
||||
maybeRender()
|
||||
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)
|
||||
sortKey = cfg("sort_by") or "cpu"
|
||||
sortDir = "desc"
|
||||
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()
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user