diff --git a/README.md b/README.md index 3f756e1..5927596 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A terminal system monitor written in **Gleam** — sits between **htop** and **b |-----------|-----------| | Dense process table (PID, USER, PRI, NI, VIRT, RES, S, CPU%, MEM%, TIME+, Command) | Color meters & sparklines for CPU / memory / swap | | Sort by column, filter (`/`), kill (`k`) | Per-core CPU bars | -| Keyboard-first navigation | Clean alternate-screen TUI | +| Keyboard + mouse navigation | Clean alternate-screen TUI | ## Requirements @@ -30,13 +30,24 @@ gleam run | `PgUp` / `PgDn` | Page | | `g` / `G` | Top / bottom | | `c` `m` `p` `t` `n` | Sort by CPU / MEM / PID / TIME / name (press again to reverse) | -| `r` | Reverse sort | -| `/` | Filter (substring on command, user, pid) | +| `T` | Toggle tree: sort by value, then group each hot PID with its PPID family | +| `r` | Reverse sort (highest-first by default for CPU/MEM/TIME) | +| `/` | Filter (substring on command, user, pid, ppid) | | `k` / `K` | Kill selected process (SIGTERM, confirms) | | `Space` | Force refresh | | `h` / `?` | Help | | `q` / `Esc` | Quit | +## Mouse + +| Action | Effect | +|--------|--------| +| Click process row | Select that process | +| Click table header (PID / CPU% / MEM% / TIME+ / Command) | Sort by that column (again to reverse) | +| Scroll wheel | Move selection | +| Right-click process | Select and confirm kill (SIGTERM) | +| Click (help / kill dialog) | Close help · cancel kill confirm | + ## Architecture ``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0b9301e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1784796856, + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/src/app.gleam b/src/app.gleam index e42a1f7..a18f1ed 100644 --- a/src/app.gleam +++ b/src/app.gleam @@ -1,11 +1,12 @@ -//// Main application loop: sample /proc, render, handle keys. +//// Main application loop: sample /proc, render, handle keys and mouse. import etch/command import etch/erlang/input import etch/erlang/tty import etch/event.{ - type Event, type KeyCode, Backspace, Char, DownArrow, End, Enter, Esc, Home, - Key, PageDown, PageUp, UpArrow, + type Event, type KeyCode, type MouseEvent, Backspace, Char, Down, DownArrow, + Drag, End, Enter, Esc, Home, Key, Left, Mouse, Moved, PageDown, PageUp, Right, + ScrollDown, ScrollLeft, ScrollRight, ScrollUp, Up, UpArrow, } import etch/stdout import etch/terminal @@ -34,6 +35,7 @@ pub fn run() -> Nil { let assert Ok(_) = tty.enter_raw() stdout.execute([ command.EnterAlternateScreen, + command.EnableMouseCapture, command.HideCursor, command.Clear(terminal.All), command.DisableLineWrap, @@ -103,13 +105,19 @@ fn handle_input_window_go( False -> { case input.poll(0) { Some(Ok(ev)) -> - case apply_event(snap, view, ev) { - #(snap2, view2, True) -> #(snap2, view2, True) - #(snap2, view2, False) -> { - // Immediate redraw on keypress for snappy feel - stdout.execute(ui.render(snap2, view2)) - handle_input_window_go(snap2, view2, remaining - step, step) - } + // Mouse motion / drag / release spam SGR reports continuously. + // Full clear+redraw on hover was the glitch; ignore those kinds. + case is_mouse_noise(ev) { + True -> handle_input_window_go(snap, view, remaining - step, step) + False -> + case apply_event(snap, view, ev) { + #(snap2, view2, True) -> #(snap2, view2, True) + #(snap2, view2, False) -> { + // Immediate redraw on real input for snappy feel + stdout.execute(ui.render(snap2, view2)) + handle_input_window_go(snap2, view2, remaining - step, step) + } + } } Some(Error(_)) -> { process.sleep(step) @@ -140,10 +148,96 @@ fn apply_event( ) -> #(Snapshot, ViewState, Bool) { case ev { Key(ke) -> apply_key(snap, view, ke.code, ke.modifiers.control) + Mouse(me) -> apply_mouse(snap, view, me) _ -> #(snap, view, False) } } +/// Hover / drag / button-up should not mutate state or force a paint. +fn is_mouse_noise(ev: Event) -> Bool { + case ev { + Mouse(me) -> + case me.kind { + Moved | Drag(_) | Up(_) | ScrollLeft | ScrollRight -> True + _ -> False + } + _ -> False + } +} + +fn apply_mouse( + snap: Snapshot, + view: ViewState, + me: MouseEvent, +) -> #(Snapshot, ViewState, Bool) { + case view.mode { + Help -> + // Only a click dismisses help (not hover — filtered as noise above) + case me.kind { + Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False) + _ -> #(snap, view, False) + } + + ConfirmKill -> + // Click cancels kill (safer than accidental confirm) + case me.kind { + Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False) + _ -> #(snap, view, False) + } + + Filter -> + // Keep filter typing keyboard-only; wheel still scrolls selection + case me.kind { + ScrollUp -> #(snap, move_sel(view, snap, -3), False) + ScrollDown -> #(snap, move_sel(view, snap, 3), False) + _ -> #(snap, view, False) + } + + Normal -> + case me.kind { + ScrollUp -> #(snap, move_sel(view, snap, -3), False) + ScrollDown -> #(snap, move_sel(view, snap, 3), False) + + Down(Left) -> apply_left_click(snap, view, me.column, me.row) + + Down(Right) -> + case ui.process_index_at_row(snap, view, me.row) { + Ok(idx) -> { + let view = ui.ViewState(..view, selected: idx) + case ui.selected_process(view, snap) { + Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False) + Error(_) -> #(snap, view, False) + } + } + Error(_) -> #(snap, view, False) + } + + _ -> #(snap, view, False) + } + } +} + +fn apply_left_click( + snap: Snapshot, + view: ViewState, + col: Int, + row: Int, +) -> #(Snapshot, ViewState, Bool) { + let lay = ui.layout(snap, view) + case row == lay.table_header_row { + True -> + case ui.sort_key_at_column(col) { + Ok(key) -> #(snap, set_sort(view, key), False) + Error(_) -> #(snap, view, False) + } + False -> + case ui.process_index_at_row(snap, view, row) { + Ok(idx) -> #(snap, ui.ViewState(..view, selected: idx), False) + Error(_) -> #(snap, view, False) + } + } +} + fn apply_key( snap: Snapshot, view: ViewState, @@ -245,6 +339,16 @@ fn apply_key( Char("p") -> #(snap, set_sort(view, SortPid), False) Char("t") -> #(snap, set_sort(view, SortTime), False) Char("n") -> #(snap, set_sort(view, SortCommand), False) + Char("T") -> #( + snap, + ui.ViewState( + ..view, + tree: !view.tree, + selected: 0, + scroll: 0, + ), + False, + ) Char("r") -> #( snap, ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0), @@ -302,6 +406,7 @@ fn window_size() -> #(Int, Int) { fn cleanup() -> Nil { stdout.execute([ + command.DisableMouseCapture, command.EnableLineWrap, command.ShowCursor, command.Clear(terminal.All), diff --git a/src/format.gleam b/src/format.gleam index b988cfe..ed0ba35 100644 --- a/src/format.gleam +++ b/src/format.gleam @@ -75,7 +75,8 @@ pub fn fit_right(s: String, width: Int) -> String { truncate(s, width) |> pad_left(width) } -/// Solid meter: filled blocks + empty blocks. width is total cells. +/// Solid meter: filled blocks + light track. width is total cells. +/// Prefer `ui.colored_meter` for the live TUI (truecolor gradient). pub fn meter(percent: Float, width: Int) -> String { let p = float.clamp(percent, 0.0, 100.0) let filled = float.round(p /. 100.0 *. int.to_float(width)) @@ -83,7 +84,7 @@ pub fn meter(percent: Float, width: Int) -> String { string.repeat("█", filled) <> string.repeat("░", width - filled) } -/// Gradient meter characters for finer resolution (btop-ish). +/// Fine meter with partial left-blocks and a light track for empty cells. pub fn meter_fine(percent: Float, width: Int) -> String { let p = float.clamp(percent, 0.0, 100.0) let cells = p /. 100.0 *. int.to_float(width) @@ -106,7 +107,7 @@ pub fn meter_fine(percent: Float, width: Int) -> String { let len = string.length(body) case len >= width { True -> string.slice(body, 0, width) - False -> body <> string.repeat(" ", width - len) + False -> body <> string.repeat("░", width - len) } } diff --git a/src/monitor.gleam b/src/monitor.gleam index deb4b3c..c25a487 100644 --- a/src/monitor.gleam +++ b/src/monitor.gleam @@ -2,7 +2,7 @@ //// //// * btop-style CPU/memory meters, sparklines, and color bands //// * htop-style process table (PID, USER, PRI, NI, VIRT, RES, S, CPU%, MEM%, TIME+, Command) -//// * sort, filter, kill, keyboard navigation +//// * sort, filter, kill, keyboard + mouse navigation import app diff --git a/src/procfs.gleam b/src/procfs.gleam index 36a2b3a..70e6e96 100644 --- a/src/procfs.gleam +++ b/src/procfs.gleam @@ -34,6 +34,7 @@ pub type MemInfo { pub type Process { Process( pid: Int, + ppid: Int, user: String, priority: Int, nice: Int, @@ -336,7 +337,7 @@ fn parse_process( simplifile.read(base <> "/stat"), Nil, )) - use #(state, priority, nice, utime, stime, vsize, rss, num_threads, comm) <- result.try( + use #(state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm) <- result.try( parse_stat(stat_raw), ) @@ -381,6 +382,7 @@ fn parse_process( Ok(#( Process( pid: pid, + ppid: ppid, user: user, priority: priority, nice: nice, @@ -398,9 +400,10 @@ fn parse_process( } /// Parse /proc/[pid]/stat — comm is inside parentheses and may contain spaces. +/// Returns state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm. fn parse_stat( raw: String, -) -> Result(#(String, Int, Int, Int, Int, Int, Int, Int, String), Nil) { +) -> Result(#(String, Int, Int, Int, Int, Int, Int, Int, Int, String), Nil) { let raw = string.trim(raw) use #(_, rest0) <- result.try(split_once_char(raw, "(")) use #(comm, after) <- result.try(rsplit_once_char(rest0, ")")) @@ -412,6 +415,8 @@ fn parse_stat( // After comm: state(0) ppid(1) ... utime(11) stime(12) ... priority(15) // nice(16) num_threads(17) ... vsize(20) rss(21) use state <- result.try(list_at(fields, 0)) + let ppid = + result.unwrap(int.parse(result.unwrap(list_at(fields, 1), "0")), 0) let priority = result.unwrap(int.parse(result.unwrap(list_at(fields, 15), "0")), 0) let nice = @@ -426,7 +431,7 @@ fn parse_stat( result.unwrap(int.parse(result.unwrap(list_at(fields, 20), "0")), 0) let rss = result.unwrap(int.parse(result.unwrap(list_at(fields, 21), "0")), 0) - Ok(#(state, priority, nice, utime, stime, vsize, rss, num_threads, comm)) + Ok(#(state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm)) } fn read_uid(status_path: String) -> Int { diff --git a/src/ui.gleam b/src/ui.gleam index 5fda65a..f4d4195 100644 --- a/src/ui.gleam +++ b/src/ui.gleam @@ -4,9 +4,12 @@ import etch/command.{type Command} import etch/style import etch/terminal import format +import gleam/dict +import gleam/float import gleam/int import gleam/list import gleam/order.{type Order, Eq, Gt, Lt} +import gleam/set import gleam/string import procfs.{type Process, type Snapshot} @@ -25,12 +28,19 @@ pub type Mode { ConfirmKill } +/// One process table row; `depth` is tree indentation when grouping by PPID. +pub type ProcessRow { + ProcessRow(process: Process, depth: Int) +} + pub type ViewState { ViewState( selected: Int, scroll: Int, sort: SortKey, reverse: Bool, + /// Group processes under their parent (PPID tree). + tree: Bool, filter: String, mode: Mode, cpu_history: List(Float), @@ -46,6 +56,7 @@ pub fn initial_view(cols: Int, rows: Int) -> ViewState { scroll: 0, sort: SortCpu, reverse: True, + tree: False, filter: "", mode: Normal, cpu_history: [], @@ -71,6 +82,11 @@ pub fn push_history(view: ViewState, snap: Snapshot) -> ViewState { } pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) { + visible_rows(view, snap) + |> list.map(fn(row) { row.process }) +} + +pub fn visible_rows(view: ViewState, snap: Snapshot) -> List(ProcessRow) { let filtered = case view.filter { "" -> snap.processes f -> { @@ -79,11 +95,256 @@ pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) { string.contains(string.lowercase(p.command), f) || string.contains(string.lowercase(p.user), f) || string.contains(int.to_string(p.pid), f) + || string.contains(int.to_string(p.ppid), f) }) } } - let sorted = sort_processes(filtered, view.sort) - case view.reverse { + case view.tree { + True -> group_by_ppid(filtered, view.sort, view.reverse) + False -> { + order_siblings(filtered, view.sort, view.reverse) + |> list.map(fn(p) { ProcessRow(process: p, depth: 0) }) + } + } +} + +/// Group by PPID, led by sort value: +/// 1. Rank processes by the active sort key (highest first when reverse). +/// 2. For each process in that order, emit its whole PPID tree (parent root +/// + descendants) so the hot PID brings its family with it. +/// 3. Under each parent, child branches ordered by the hottest process in +/// that subtree (so the path to the hot PID rises). +fn group_by_ppid( + procs: List(Process), + key: SortKey, + reverse: Bool, +) -> List(ProcessRow) { + let by_pid = + list.fold(procs, dict.new(), fn(acc, p) { dict.insert(acc, p.pid, p) }) + + // Raw children lists (unsorted); order after we know subtree leaders. + let children_raw = + list.fold(procs, dict.new(), fn(acc, p) { + let kids = case dict.get(acc, p.ppid) { + Ok(ks) -> [p, ..ks] + Error(_) -> [p] + } + dict.insert(acc, p.ppid, kids) + }) + |> dict.map_values(fn(_ppid, kids) { list.reverse(kids) }) + + // leader[pid] = process with best sort value in pid's subtree + let leaders = build_subtree_leaders(procs, children_raw, key, reverse) + + let children = + dict.map_values(children_raw, fn(_ppid, kids) { + order_by_leader(kids, leaders, key, reverse) + }) + + // Value-led emission: hottest process first → emit its root tree as a group. + let ranked = order_siblings(procs, key, reverse) + let #(rows, visited) = + list.fold(ranked, #([], set.new()), fn(acc, p) { + let #(out, seen) = acc + case set.contains(seen, p.pid) { + True -> acc + False -> { + let root = find_root(p, by_pid) + case set.contains(seen, root.pid) { + True -> acc + False -> { + let #(branch, seen2) = walk_tree(root, 0, children, seen) + #(list.append(out, branch), seen2) + } + } + } + } + }) + + // Cycles / stragglers not reached from any root still appear. + let leftovers = + list.filter(procs, fn(p) { !set.contains(visited, p.pid) }) + |> order_siblings(key, reverse) + + let #(rows2, _) = + list.fold(leftovers, #(rows, visited), fn(acc, p) { + let #(out, seen) = acc + case set.contains(seen, p.pid) { + True -> acc + False -> { + let #(branch, seen2) = walk_tree(p, 0, children, seen) + #(list.append(out, branch), seen2) + } + } + }) + + rows2 +} + +/// Walk up PPID links until the parent is outside the current process set. +fn find_root( + proc: Process, + by_pid: dict.Dict(Int, Process), +) -> Process { + find_root_go(proc, by_pid, set.new()) +} + +fn find_root_go( + proc: Process, + by_pid: dict.Dict(Int, Process), + seen: set.Set(Int), +) -> Process { + case set.contains(seen, proc.pid) { + True -> proc + False -> { + let seen = set.insert(seen, proc.pid) + case proc.ppid == proc.pid { + True -> proc + False -> + case dict.get(by_pid, proc.ppid) { + Error(_) -> proc + Ok(parent) -> find_root_go(parent, by_pid, seen) + } + } + } + } +} + +/// For each process, the "leader" is the best-ranked process in its subtree +/// under the current sort (highest CPU, etc. when reverse). +fn build_subtree_leaders( + procs: List(Process), + children: dict.Dict(Int, List(Process)), + key: SortKey, + reverse: Bool, +) -> dict.Dict(Int, Process) { + list.fold(procs, dict.new(), fn(memo, p) { + let #(_, memo2) = subtree_leader(p, children, key, reverse, memo) + memo2 + }) +} + +fn subtree_leader( + proc: Process, + children: dict.Dict(Int, List(Process)), + key: SortKey, + reverse: Bool, + memo: dict.Dict(Int, Process), +) -> #(Process, dict.Dict(Int, Process)) { + case dict.get(memo, proc.pid) { + Ok(leader) -> #(leader, memo) + Error(_) -> { + let kids = case dict.get(children, proc.pid) { + Ok(ks) -> ks + Error(_) -> [] + } + let #(leader, memo) = + list.fold(kids, #(proc, memo), fn(acc, kid) { + let #(best, memo) = acc + let #(kid_leader, memo) = + subtree_leader(kid, children, key, reverse, memo) + #(pick_leader(best, kid_leader, key, reverse), memo) + }) + #(leader, dict.insert(memo, proc.pid, leader)) + } + } +} + +fn pick_leader( + a: Process, + b: Process, + key: SortKey, + reverse: Bool, +) -> Process { + // reverse=True → highest value wins; reverse=False → lowest wins + case compare_processes(a, b, key) { + Lt -> + case reverse { + True -> b + False -> a + } + Gt -> + case reverse { + True -> a + False -> b + } + Eq -> a + } +} + +fn order_by_leader( + kids: List(Process), + leaders: dict.Dict(Int, Process), + key: SortKey, + reverse: Bool, +) -> List(Process) { + list.sort(kids, fn(a, b) { + let la = case dict.get(leaders, a.pid) { + Ok(p) -> p + Error(_) -> a + } + let lb = case dict.get(leaders, b.pid) { + Ok(p) -> p + Error(_) -> b + } + let ord = compare_processes(la, lb, key) + case reverse { + True -> invert_order(ord) + False -> ord + } + }) +} + +fn invert_order(o: Order) -> Order { + case o { + Lt -> Gt + Eq -> Eq + Gt -> Lt + } +} + +fn compare_processes(a: Process, b: Process, key: SortKey) -> Order { + case key { + SortCpu -> float_ord(a.cpu_percent, b.cpu_percent) + SortMem -> float_ord(a.mem_percent, b.mem_percent) + SortPid -> int.compare(a.pid, b.pid) + SortTime -> int.compare(a.time_ticks, b.time_ticks) + SortCommand -> string.compare(a.command, b.command) + } +} + +fn walk_tree( + proc: Process, + depth: Int, + children: dict.Dict(Int, List(Process)), + visited: set.Set(Int), +) -> #(List(ProcessRow), set.Set(Int)) { + case set.contains(visited, proc.pid) { + True -> #([], visited) + False -> { + let visited = set.insert(visited, proc.pid) + let kids = case dict.get(children, proc.pid) { + Ok(ks) -> ks + Error(_) -> [] + } + let #(nested, visited) = + list.fold(kids, #([], visited), fn(acc, kid) { + let #(rows, seen) = acc + let #(branch, seen2) = walk_tree(kid, depth + 1, children, seen) + #(list.append(rows, branch), seen2) + }) + #([ProcessRow(process: proc, depth: depth), ..nested], visited) + } + } +} + +fn order_siblings( + procs: List(Process), + key: SortKey, + reverse: Bool, +) -> List(Process) { + let sorted = sort_processes(procs, key) + case reverse { True -> list.reverse(sorted) False -> sorted } @@ -112,16 +373,42 @@ fn float_ord(a: Float, b: Float) -> Order { } } -/// Build a full frame as a list of etch commands. -pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { +/// Screen geometry for the process table (0-based row indices). +pub type Layout { + Layout( + table_header_row: Int, + process_start_row: Int, + process_height: Int, + footer_row: Int, + cols: Int, + rows: Int, + ) +} + +/// Compute layout matching `render` (for hit-testing mouse events). +pub fn layout(snap: Snapshot, view: ViewState) -> Layout { let cols = int.max(view.cols, 60) let rows = int.max(view.rows, 16) - let procs = visible_processes(view, snap) - let proc_count = list.length(procs) + let core_rows = core_row_count(snap, cols) + // Drawn: header, cpu total, core rows, mem, swap, then table header + let table_header_row = 1 + 1 + core_rows + 2 + let process_start_row = table_header_row + 1 + let header_lines = 1 + 1 + core_rows + 2 + 1 + let footer_lines = 1 + let table_header = 1 + let available = int.max(rows - header_lines - footer_lines - table_header, 3) + Layout( + table_header_row: table_header_row, + process_start_row: process_start_row, + process_height: available, + footer_row: rows - 1, + cols: cols, + rows: rows, + ) +} - // Layout: header(1) + cpu block + mem(2) + blank + table header + rows + footer +fn core_row_count(snap: Snapshot, cols: Int) -> Int { let core_count = list.length(snap.cores) - // CPU section: 1 line total + ceil(cores/cols_per_row) lines, max ~4 lines let cores_per_row = case cols >= 120 { True -> 4 False -> @@ -130,18 +417,71 @@ pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { False -> 2 } } - let core_rows = case core_count { + case core_count { 0 -> 0 n -> { let r = { n + cores_per_row - 1 } / cores_per_row int.min(r, 4) } } - let header_lines = 1 + 1 + core_rows + 2 + 1 - // header, cpu total, core rows, mem+swap, blank before table - let footer_lines = 1 - let table_header = 1 - let available = int.max(rows - header_lines - footer_lines - table_header, 3) +} + +fn cores_per_row(cols: Int) -> Int { + case cols >= 120 { + True -> 4 + False -> + case cols >= 90 { + True -> 3 + False -> 2 + } + } +} + +/// Which sort column (if any) was clicked on the table header row. +/// Column widths match `draw_table_header` / `draw_process`. +pub fn sort_key_at_column(col: Int) -> Result(SortKey, Nil) { + // PID(7) USER(9) PRI(3) NI(3) VIRT(7) RES(7) S(1) CPU%(6) MEM%(6) TIME+(9) Command + // with single spaces between fields + case col { + c if c >= 0 && c <= 7 -> Ok(SortPid) + c if c >= 44 && c <= 50 -> Ok(SortCpu) + c if c >= 51 && c <= 57 -> Ok(SortMem) + c if c >= 58 && c <= 67 -> Ok(SortTime) + c if c >= 68 -> Ok(SortCommand) + _ -> Error(Nil) + } +} + +/// Process list index under a screen row, if any. +pub fn process_index_at_row( + snap: Snapshot, + view: ViewState, + row: Int, +) -> Result(Int, Nil) { + let lay = layout(snap, view) + let n = list.length(visible_processes(view, snap)) + case row >= lay.process_start_row && row < lay.process_start_row + lay.process_height { + True -> { + let idx = view.scroll + { row - lay.process_start_row } + case idx >= 0 && idx < n { + True -> Ok(idx) + False -> Error(Nil) + } + } + False -> Error(Nil) + } +} + +/// Build a full frame as a list of etch commands. +pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { + let lay = layout(snap, view) + let cols = lay.cols + let rows = lay.rows + let rows_list = visible_rows(view, snap) + let proc_count = list.length(rows_list) + let core_rows = core_row_count(snap, cols) + let per_row = cores_per_row(cols) + let available = lay.process_height let scroll = clamp_scroll(view.scroll, view.selected, proc_count, available) let selected = int.clamp(view.selected, 0, int.max(proc_count - 1, 0)) @@ -158,11 +498,11 @@ pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { list.flatten([ [draw_header(snap, view, cols)], [draw_cpu_total(snap, view, cols)], - draw_cores(snap, cores_per_row, core_rows, cols), + draw_cores(snap, per_row, core_rows, cols), [draw_mem(snap, cols), draw_swap(snap, cols)], [draw_table_header(view, cols)], - draw_process_rows(procs, view, available, cols), - pad_to(available - visible_row_count(procs, view, available), cols), + draw_process_rows(rows_list, view, available, cols), + pad_to(available - visible_row_count(proc_count, view, available), cols), ]) let body = @@ -176,7 +516,7 @@ pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { let overlay = case view.mode { Help -> help_overlay(cols, rows) - ConfirmKill -> kill_overlay(procs, view, cols, rows) + ConfirmKill -> kill_overlay(rows_list, view, cols, rows) Filter -> [] Normal -> [] } @@ -208,13 +548,8 @@ fn clamp_scroll(scroll: Int, selected: Int, count: Int, height: Int) -> Int { int.clamp(scroll, 0, int.max(0, count - height)) } -fn visible_row_count( - procs: List(Process), - view: ViewState, - height: Int, -) -> Int { - let n = list.length(procs) - int.min(height, int.max(0, n - view.scroll)) +fn visible_row_count(proc_count: Int, view: ViewState, height: Int) -> Int { + int.min(height, int.max(0, proc_count - view.scroll)) } fn pad_to(n: Int, cols: Int) -> List(String) { @@ -225,7 +560,15 @@ fn pad_to(n: Int, cols: Int) -> List(String) { } fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String { - let title = style_bold(style_cyan(" monitor ")) + let title = + style.with_style( + " monitor ", + style.Style( + fg: style.Rgb(140, 170, 200), + bg: style.Default, + attributes: [style.Bold], + ), + ) let host = style_dim(snap.hostname) let up = "up " <> format.format_uptime(snap.uptime_secs) let load = "load " <> format.format_load(snap.load1, snap.load5, snap.load15) @@ -241,13 +584,23 @@ fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String { True -> "↓" False -> "↑" } + <> case view.tree { + True -> " tree" + False -> "" + } let mid = host <> " " <> up <> " " <> load <> " " <> tasks - let right = sort let left = title // approx without ansi length — simple pad using raw segments let raw_left = " monitor " let raw_mid = snap.hostname <> " " <> up <> " " <> load <> " " <> tasks - let raw_right = "sort:" <> sort_label(view.sort) <> "↓" + let raw_right = + "sort:" + <> sort_label(view.sort) + <> "↓" + <> case view.tree { + True -> " tree" + False -> "" + } let gap = int.max( 1, @@ -264,7 +617,7 @@ fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String { <> " " <> style_dim(string.repeat("─", gap - gap / 2)) <> " " - <> style_yellow(right) + <> style.with(sort, style.Rgb(186, 168, 112)) <> reset() |> fit_line(cols) } @@ -279,7 +632,11 @@ fn draw_cpu_total(snap: Snapshot, view: ViewState, cols: Int) -> String { format.percent(snap.cpu_total_percent), ) let spark_w = int.clamp(cols - bar_w - 18, 8, 40) - let spark = style_cyan(format.sparkline(view.cpu_history, spark_w)) + let spark = + style.with( + format.sparkline(view.cpu_history, spark_w), + style.Rgb(108, 140, 168), + ) label <> bar <> " " @@ -298,20 +655,25 @@ fn draw_cores( ) -> List(String) { let cores = list.take(snap.cores, max_rows * per_row) let cell_w = int.max(12, { cols - 1 } / per_row) + // label(3) + bar + pct(5) — never run ANSI-rich bars through format.fit + // (string.length counts escape codes and chops the meter to nothing). + let label_w = 3 + let pct_w = 5 + let bar_w = int.max(4, cell_w - label_w - pct_w) + let pad_w = int.max(0, cell_w - label_w - bar_w - pct_w) list.sized_chunk(cores, per_row) |> list.map(fn(row) { row |> list.map(fn(c) { let name = string.replace(c.name, "cpu", "") let label = style_dim(format.pad_left(name, 2) <> " ") - let bar_w = int.max(4, cell_w - 9) let bar = colored_meter(c.percent, bar_w) let pct = style_for_band( c.percent, - format.fit_right(format.percent_short(c.percent), 5), + format.fit_right(format.percent_short(c.percent), pct_w), ) - format.fit(label <> bar <> pct, cell_w) + label <> bar <> pct <> string.repeat(" ", pad_w) <> reset() }) |> string.concat |> fit_line(cols) @@ -365,57 +727,104 @@ fn draw_swap(snap: Snapshot, cols: Int) -> String { } fn draw_table_header(view: ViewState, cols: Int) -> String { - let mark = fn(key: SortKey, label: String) { + // Style each cell fully (fg+bg+attrs). etch's with/attributes do not reset, + // so a yellow "active sort" otherwise bleeds across the whole header row. + let bg = style.Rgb(30, 30, 46) + let cell = fn(active: Bool, label: String) { + let fg = case active { + True -> style.Rgb(186, 168, 112) + False -> style.Rgb(180, 184, 200) + } + style.with_style( + label, + style.Style(fg: fg, bg: bg, attributes: [style.Bold]), + ) + } + let gap = + style.with_style( + " ", + style.Style(fg: style.BrightWhite, bg: bg, attributes: []), + ) + let arrow = case view.reverse { + True -> "↓" + False -> "↑" + } + // Keep field widths fixed: active sort shows direction inside the cell. + let labeled = fn(key: SortKey, text: String) { case view.sort == key { - True -> style_bold(style_yellow(label)) - False -> style_bold(label) + True -> text <> arrow + False -> text } } let line = - mark(SortPid, format.fit_right("PID", 7)) - <> " " - <> format.fit("USER", 9) - <> " " - <> format.fit_right("PRI", 3) - <> " " - <> format.fit_right("NI", 3) - <> " " - <> format.fit_right("VIRT", 7) - <> " " - <> format.fit_right("RES", 7) - <> " " - <> "S" - <> " " - <> mark(SortCpu, format.fit_right("CPU%", 6)) - <> " " - <> mark(SortMem, format.fit_right("MEM%", 6)) - <> " " - <> mark(SortTime, format.fit_right("TIME+", 9)) - <> " " - <> mark(SortCommand, "Command") - style_on_bg(line, style.Rgb(30, 30, 46)) - <> reset() - |> fit_line(cols) + cell(view.sort == SortPid, format.fit_right(labeled(SortPid, "PID"), 7)) + <> gap + <> cell(False, format.fit("USER", 9)) + <> gap + <> cell(False, format.fit_right("PRI", 3)) + <> gap + <> cell(False, format.fit_right("NI", 3)) + <> gap + <> cell(False, format.fit_right("VIRT", 7)) + <> gap + <> cell(False, format.fit_right("RES", 7)) + <> gap + <> cell(False, "S") + <> gap + <> cell( + view.sort == SortCpu, + format.fit_right(labeled(SortCpu, "CPU%"), 6), + ) + <> gap + <> cell( + view.sort == SortMem, + format.fit_right(labeled(SortMem, "MEM%"), 6), + ) + <> gap + <> cell( + view.sort == SortTime, + format.fit_right(labeled(SortTime, "TIME+"), 9), + ) + <> gap + <> cell( + view.sort == SortCommand, + format.fit(labeled(SortCommand, "Command"), 8), + ) + // Fixed columns before Command = 70; Command cell = 8 → pad the rest. + let pad = + style.with_style( + string.repeat(" ", int.max(0, cols - 78)), + style.Style(fg: style.BrightWhite, bg: bg, attributes: []), + ) + line <> pad <> reset() } fn draw_process_rows( - procs: List(Process), + rows: List(ProcessRow), view: ViewState, height: Int, cols: Int, ) -> List(String) { - procs + rows |> list.drop(view.scroll) |> list.take(height) - |> list.index_map(fn(p, i) { + |> list.index_map(fn(row, i) { let idx = view.scroll + i let selected = idx == view.selected - draw_process(p, selected, cols) + draw_process(row.process, row.depth, selected, cols) }) } -fn draw_process(p: Process, selected: Bool, cols: Int) -> String { +fn tree_prefix(depth: Int) -> String { + case depth { + 0 -> "" + d -> string.repeat("│ ", d - 1) <> "├─" + } +} + +fn draw_process(p: Process, depth: Int, selected: Bool, cols: Int) -> String { let cmd_w = int.max(10, cols - 70) + let cmd = format.fit(tree_prefix(depth) <> p.command, cmd_w) let base = format.fit_right(int.to_string(p.pid), 7) <> " " @@ -443,18 +852,18 @@ fn draw_process(p: Process, selected: Bool, cols: Int) -> String { <> " " <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) <> " " - <> format.fit(p.command, cmd_w) + <> cmd case selected { True -> - style_on_bg(strip_for_select(p, cmd_w), style.Rgb(69, 71, 90)) + style_on_bg(strip_for_select(p, depth, cmd_w), style.Rgb(69, 71, 90)) <> reset() |> fit_line(cols) False -> base <> reset() |> fit_line(cols) } } -fn strip_for_select(p: Process, cmd_w: Int) -> String { +fn strip_for_select(p: Process, depth: Int, cmd_w: Int) -> String { format.fit_right(int.to_string(p.pid), 7) <> " " <> format.fit(p.user, 9) @@ -475,7 +884,7 @@ fn strip_for_select(p: Process, cmd_w: Int) -> String { <> " " <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) <> " " - <> format.fit(p.command, cmd_w) + <> format.fit(tree_prefix(depth) <> p.command, cmd_w) } fn draw_footer( @@ -489,7 +898,7 @@ fn draw_footer( ConfirmKill -> " Confirm kill? [y/N]" Help -> " Help · press any key to close" Normal -> - " [q]uit [↑↓/uj] move [PgUp/Dn] [/]filter [c/m/p/t/n]sort [r]ev [k]ill [h]elp · " + " [q]uit [↑↓/uj·wheel] move [T]ree [click]select [header]sort [R-click]kill [/]filter [h]elp · " <> int.to_string(visible) <> "/" <> int.to_string(snap.process_count) @@ -502,7 +911,7 @@ fn draw_footer( fn help_overlay(cols: Int, rows: Int) -> List(Command) { let w = int.min(56, cols - 4) - let h = 14 + let h = 16 let x = int.max(0, { cols - w } / 2) let y = int.max(1, { rows - h } / 2) let lines = [ @@ -510,14 +919,15 @@ fn help_overlay(cols: Int, rows: Int) -> List(Command) { "│" <> format.fit(" monitor — htop × btop in Gleam", w - 2) <> "│", "│" <> format.fit("", w - 2) <> "│", "│" <> format.fit(" Navigation", w - 2) <> "│", - "│" <> format.fit(" ↑/u ↓/j move selection", w - 2) <> "│", - "│" <> format.fit(" PgUp/PgDn page · g/G top/end", w - 2) <> "│", + "│" <> format.fit(" ↑/u ↓/j · wheel move selection", w - 2) <> "│", + "│" <> format.fit(" click row · PgUp/Dn · g/G top/end", w - 2) <> "│", "│" <> format.fit(" Sorting & filter", w - 2) <> "│", - "│" <> format.fit(" c/m/p/t/n CPU MEM PID TIME name", w - 2) <> "│", - "│" <> format.fit(" r reverse · / filter", w - 2) <> "│", + "│" <> format.fit(" c/m/p/t/n · click header columns", w - 2) <> "│", + "│" <> format.fit(" r reverse · T tree (hot PID + family)", w - 2) <> "│", "│" <> format.fit(" Actions", w - 2) <> "│", - "│" <> format.fit(" k kill selected (SIGTERM)", w - 2) <> "│", - "│" <> format.fit(" q / Esc quit", w - 2) <> "│", + "│" <> format.fit(" k · right-click kill (SIGTERM)", w - 2) <> "│", + "│" <> format.fit(" q / Esc quit", w - 2) <> "│", + "│" <> format.fit(" Click anywhere to close this help", w - 2) <> "│", "│" <> format.fit(" Meters from btop · table from htop", w - 2) <> "│", "└" <> string.repeat("─", w - 2) <> "┘", ] @@ -539,13 +949,13 @@ fn help_overlay(cols: Int, rows: Int) -> List(Command) { } fn kill_overlay( - procs: List(Process), + rows: List(ProcessRow), view: ViewState, cols: Int, - rows: Int, + rows_n: Int, ) -> List(Command) { - let msg = case list_at(procs, view.selected) { - Ok(p) -> + let msg = case list_at(rows, view.selected) { + Ok(ProcessRow(process: p, depth: _)) -> " Kill PID " <> int.to_string(p.pid) <> " (" @@ -555,13 +965,15 @@ fn kill_overlay( } let w = int.min(string.length(msg) + 4, cols - 2) let x = int.max(0, { cols - w } / 2) - let y = rows / 2 + let y = rows_n / 2 [ command.MoveTo(x, y), command.SetStyle( - style.Style(fg: style.Black, bg: style.BrightYellow, attributes: [ - style.Bold, - ]), + style.Style( + fg: style.Rgb(24, 24, 37), + bg: style.Rgb(186, 168, 112), + attributes: [style.Bold], + ), ), command.Print(format.fit(msg, w)), command.ResetStyle, @@ -575,27 +987,88 @@ fn list_at(items: List(a), index: Int) -> Result(a, Nil) { } } +/// btop-style meter: cool→warm gradient fill over a muted track. +/// Each filled cell is colored by its position, not a single band color. fn colored_meter(percent: Float, width: Int) -> String { - let bar = format.meter_fine(percent, width) - style_for_band(percent, bar) + let width = int.max(1, width) + let p = float.clamp(percent, 0.0, 100.0) + let cells = p /. 100.0 *. int.to_float(width) + let full = int.clamp(float.truncate(cells), 0, width) + let frac = cells -. int.to_float(full) + + let filled = + list.repeat(0, full) + |> list.index_map(fn(_, i) { + let t = { int.to_float(i) +. 0.5 } /. int.to_float(width) + style.with("█", meter_gradient(t)) + }) + + let has_partial = full < width && frac >. 0.0 + let partial = case has_partial { + True -> { + let t = { int.to_float(full) +. 0.5 } /. int.to_float(width) + style.with(partial_block(frac), meter_gradient(t)) + } + False -> "" + } + + let empty_n = case has_partial { + True -> width - full - 1 + False -> width - full + } + let empty = case empty_n > 0 { + True -> style.with(string.repeat("░", empty_n), style.Rgb(49, 50, 68)) + False -> "" + } + + string.concat(filled) <> partial <> empty +} + +/// Left-block partials for sub-cell resolution. +fn partial_block(frac: Float) -> String { + case frac { + f if f >=. 0.875 -> "▉" + f if f >=. 0.75 -> "▊" + f if f >=. 0.625 -> "▋" + f if f >=. 0.5 -> "▌" + f if f >=. 0.375 -> "▍" + f if f >=. 0.25 -> "▎" + f if f >. 0.0 -> "▏" + _ -> "" + } +} + +/// Muted cool→warm spectrum (steel → teal → gold → copper → rose). +fn meter_gradient(t: Float) -> style.Color { + let t = float.clamp(t, 0.0, 1.0) + case t { + t if t <. 0.22 -> style.Rgb(110, 156, 204) + t if t <. 0.40 -> style.Rgb(118, 176, 178) + t if t <. 0.55 -> style.Rgb(138, 176, 148) + t if t <. 0.70 -> style.Rgb(176, 172, 120) + t if t <. 0.82 -> style.Rgb(196, 156, 108) + t if t <. 0.92 -> style.Rgb(196, 128, 108) + _ -> style.Rgb(186, 108, 118) + } } +/// Percentage / value tint: muted truecolor, not toy primaries. fn style_for_band(percent: Float, s: String) -> String { case format.band(percent) { - format.Low -> style_green(s) - format.Mid -> style_yellow(s) - format.High -> style_magenta(s) - format.Critical -> style_red(s) + format.Low -> style.with(s, style.Rgb(140, 170, 200)) + format.Mid -> style.with(s, style.Rgb(186, 168, 112)) + format.High -> style.with(s, style.Rgb(196, 132, 108)) + format.Critical -> style.with(s, style.Rgb(196, 108, 118)) } } fn state_color(s: String) -> String { case s { - "R" -> style_green("R") + "R" -> style.with("R", style.Rgb(138, 176, 148)) "S" -> style_dim("S") - "D" -> style_red("D") - "Z" -> style_red("Z") - "T" | "t" -> style_yellow("T") + "D" -> style.with("D", style.Rgb(196, 108, 118)) + "Z" -> style.with("Z", style.Rgb(196, 108, 118)) + "T" | "t" -> style.with("T", style.Rgb(186, 168, 112)) _ -> s } } @@ -629,26 +1102,6 @@ fn style_dim(s: String) -> String { style.attributes(s, [style.Dim]) } -fn style_cyan(s: String) -> String { - style.with(s, style.Cyan) -} - -fn style_green(s: String) -> String { - style.with(s, style.Green) -} - -fn style_yellow(s: String) -> String { - style.with(s, style.Yellow) -} - -fn style_red(s: String) -> String { - style.with(s, style.Red) -} - -fn style_magenta(s: String) -> String { - style.with(s, style.Magenta) -} - fn style_on_bg(s: String, bg: style.Color) -> String { style.on(s, bg) }