From 691398d9da04b315099900d5dce49abe0dbe2518 Mon Sep 17 00:00:00 2001 From: weinguyen Date: Thu, 13 Aug 2026 09:41:55 +0700 Subject: [PATCH] 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. --- procmon/README.md | 12 ++++-- procmon/panel.luau | 103 +++++++++++++++++++++++++++++++++++++++++--- procmon/plugin.toml | 2 +- 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/procmon/README.md b/procmon/README.md index 6be8334..9d3067a 100644 --- a/procmon/README.md +++ b/procmon/README.md @@ -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. diff --git a/procmon/panel.luau b/procmon/panel.luau index a4a3084..46ea8f4 100644 --- a/procmon/panel.luau +++ b/procmon/panel.luau @@ -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 diff --git a/procmon/plugin.toml b/procmon/plugin.toml index 0b177b4..ede308c 100644 --- a/procmon/plugin.toml +++ b/procmon/plugin.toml @@ -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"