update(procmon): fix windowed table follow-scroll & row sizing` (#340)

* update(procmon): fix windowed table follow-scroll & row sizing`

* Update plugin.toml

* Update README.md

* fix(procmon): always render list body as scroll to stop bottom clipping

0-result search switched the body from ui.scroll to a bare ui.row; when
results returned the row->scroll switch left the flexGrow viewport stuck
short, clipping bottom rows. Use ui.scroll with a shared key in both
branches so the tree keeps one stable scroll node.
This commit is contained in:
weinguyen
2026-08-12 22:41:55 -04:00
committed by GitHub
parent a4ae9c8ab4
commit 691398d9da
3 changed files with 105 additions and 12 deletions
+8 -4
View File
@@ -25,6 +25,7 @@ usage (plus the process count). Click the widget to toggle the process panel:
```sh
noctalia msg panel-toggle weinguyen/procmon:panel
```
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,
COMMAND) and flip asc/desc with the arrow button. Type in the filter box to
@@ -54,7 +55,10 @@ while it is open.
- 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
clicked.
- Requires `plugin_api = 13`. CPU%, RAM%, swap and the 1/5/15-minute load
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`,
`/proc/loadavg`) by the service, so they work with no separate system-monitor
dependency.
- Requires `plugin_api = 13`. The panel renders a windowed slice of the
process table and follows the keyboard cursor with edge-follow scrolling
(window slides only when the cursor pushes past an edge, so scrolling up
doesn't collapse the page until the top edge is reached). CPU%, RAM%, swap
and the 1/5/15-minute load averages are sampled from `/proc` (`/proc/stat`,
`/proc/meminfo`, `/proc/loadavg`) by the service, so they work with no
separate system-monitor dependency.
+96 -7
View File
@@ -26,6 +26,23 @@ local MAX_ROWS = 80
-- selected. Ctrl+D kills the selected process directly.
local selectedIdx = 0
-- Windowed table rendering, edge-follow (htop/btop style). The panel renders
-- only a VIEW_CAP-row slice [winStart..winEnd] that fits the table viewport.
-- Rows ~30px (kill button height=26 + paddingV 2), so 10 fill the 660px panel
-- without clipping the last one (tuned so no dead gap sits below the list).
-- The cursor moves freely INSIDE the window; the window slides only when the
-- cursor pushes past an edge — down at the bottom edge, up at the top edge. So
-- scrolling back up from the bottom does not collapse the page: the rows above
-- (6,7,8,9) stay in view while the highlight climbs, and only reaching the
-- window's top edge starts following up again. There is no host "scroll to row
-- N" (API 21 only jumps to absolute bottom), so the slice is what the panel
-- draws and the window IS the visible page.
-- ponytail: VIEW_CAP is tuned to the panel's table height; could derive it from
-- a scroll fill-measure if the API ever exposes available height.
local VIEW_CAP = 10
local winStart = 1
local winEnd = VIEW_CAP
-- 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
@@ -253,12 +270,14 @@ local function dataRow(p, idx)
color = selected and "primary" or "on_surface",
}))
-- Kill button (fixed trailing width)
-- Kill button (fixed trailing width). Explicit height keeps the row short
-- enough that VIEW_CAP rows fit without clipping the last one.
table.insert(children, ui.button({
glyph = "square-x",
glyphSize = 14,
variant = "ghost",
controlSize = "sm",
height = 26,
width = KILL_W,
tooltip = tr("panel.kill_tip", { pid = p.pid }),
onClick = function()
@@ -376,6 +395,43 @@ local function visibleList()
return list, #list
end
-- Keep the window [winStart..winEnd] covering the cursor and inside the list.
-- Called from render() after the cursor is clamped, so it also heals the window
-- when the list shrank (filter/sort/data) and left the cursor or window out of
-- bounds. The lazy up-follow during navigation lives in onKey, not here (this
-- only heals shrink/regrow).
local function clampWindow(n)
if n == 0 then
winStart, winEnd = 1, 0
return
end
-- Cursor below the window (list regrew or follow-down persisted): extend.
if selectedIdx > winEnd then
winEnd = selectedIdx
winStart = math.max(1, winEnd - VIEW_CAP + 1)
-- Cursor above the window: the list shrank (a filter) or re-sorted and left the
-- cursor outside. Flatten back to the top page — cleared search shows row 1.
elseif selectedIdx < winStart then
winStart = 1
winEnd = math.min(VIEW_CAP, n)
end
-- Never let the window extend past the list nor below row 1.
if winEnd > n then
winEnd = n
winStart = math.max(1, winEnd - VIEW_CAP + 1)
end
if winStart < 1 then
winStart = 1
end
-- Re-expand a below-capacity window to a full page. Only fires when the list
-- shrank (filter) then regrew: otherwise the window would stay a few rows wide
-- and grow one row per Down press. Filling back to VIEW_CAP makes a cleared
-- search show the whole page again. No-op during normal navigation.
if n >= VIEW_CAP and winEnd - winStart + 1 < VIEW_CAP then
winEnd = math.min(winStart + VIEW_CAP - 1, n)
end
end
function render()
local wantsFocus = focusFilterOnRender
focusFilterOnRender = false
@@ -393,15 +449,28 @@ function render()
local body
if #shown == 0 then
body = ui.row({ align = "center", justify = "center", flexGrow = 1 }, {
ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }),
-- Keep the body a ui.scroll in BOTH states so the flexGrow container keeps
-- a stable height. Rendering a bare ui.row here (and switching back to a
-- scroll once results return) left the scroll container stuck short, which
-- clipped the bottom of the list after a 0-result search.
winStart, winEnd = 1, 0
-- Same stable key as the results scroll so the UI tree reconciles this as
-- one persistent scroll node across the 0-result <-> results transition,
-- keeping the flexGrow viewport height stable (avoids bottom clipping).
body = ui.scroll({ key = "proc-list", flexGrow = 1 }, {
ui.row({ align = "center", justify = "center", flexGrow = 1 }, {
ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }),
}),
})
else
clampWindow(#shown)
local itemRows = {}
for i, p in ipairs(shown) do
table.insert(itemRows, dataRow(p, i))
for i = winStart, winEnd do
if shown[i] then
table.insert(itemRows, dataRow(shown[i], i))
end
end
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
body = ui.scroll({ key = "proc-list", flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
end
local refreshedAt = (st("refreshedAtMs") or 0) / 1000
@@ -554,7 +623,25 @@ function onKey(chord, pressed)
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)))
if chord == "down" then
selectedIdx = math.min(#list, selectedIdx + 1)
-- Cursor pushed past the bottom edge: extend the window one row and
-- slide its top so the new row is revealed below the cursor.
if selectedIdx > winEnd then
winEnd = selectedIdx
winStart = math.max(1, winEnd - VIEW_CAP + 1)
end
else
selectedIdx = math.max(1, selectedIdx - 1)
-- Cursor rose past the top edge: only now follow up — slide the window's
-- top to the cursor and refill down. Rows inside the window (6,7,8,9
-- while stepping down from 10) never shift the window; reaching below the
-- top edge (5) is what starts scrolling up again.
if selectedIdx < winStart then
winStart = selectedIdx
winEnd = math.min(winStart + VIEW_CAP - 1, #list)
end
end
render()
end
return
@@ -592,5 +679,7 @@ function onOpen(_context)
-- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start
-- typing a filter; the box is focused then.
selectedIdx = 0
winStart = 1
winEnd = VIEW_CAP
render()
end
+1 -1
View File
@@ -1,6 +1,6 @@
id = "weinguyen/procmon"
name = "Process Monitor"
version = "0.4.0"
version = "0.5.0"
plugin_api = 13
author = "weinguyen"
license = "MIT"