Terminal system monitor in Gleam — htop × btop
1

Configure Feed

Select the types of activity you want to include in your feed.

Polish TUI meters, mouse handling, and PPID tree grouping.

Muted truecolor gradient meters, ignore mouse motion noise, fix
ANSI truncation of per-core bars, and add tree mode that ranks by
sort value then groups each hot process with its PPID family.

+737 -135
+14 -3
README.md
··· 6 6 |-----------|-----------| 7 7 | Dense process table (PID, USER, PRI, NI, VIRT, RES, S, CPU%, MEM%, TIME+, Command) | Color meters & sparklines for CPU / memory / swap | 8 8 | Sort by column, filter (`/`), kill (`k`) | Per-core CPU bars | 9 - | Keyboard-first navigation | Clean alternate-screen TUI | 9 + | Keyboard + mouse navigation | Clean alternate-screen TUI | 10 10 11 11 ## Requirements 12 12 ··· 30 30 | `PgUp` / `PgDn` | Page | 31 31 | `g` / `G` | Top / bottom | 32 32 | `c` `m` `p` `t` `n` | Sort by CPU / MEM / PID / TIME / name (press again to reverse) | 33 - | `r` | Reverse sort | 34 - | `/` | Filter (substring on command, user, pid) | 33 + | `T` | Toggle tree: sort by value, then group each hot PID with its PPID family | 34 + | `r` | Reverse sort (highest-first by default for CPU/MEM/TIME) | 35 + | `/` | Filter (substring on command, user, pid, ppid) | 35 36 | `k` / `K` | Kill selected process (SIGTERM, confirms) | 36 37 | `Space` | Force refresh | 37 38 | `h` / `?` | Help | 38 39 | `q` / `Esc` | Quit | 40 + 41 + ## Mouse 42 + 43 + | Action | Effect | 44 + |--------|--------| 45 + | Click process row | Select that process | 46 + | Click table header (PID / CPU% / MEM% / TIME+ / Command) | Sort by that column (again to reverse) | 47 + | Scroll wheel | Move selection | 48 + | Right-click process | Select and confirm kill (SIGTERM) | 49 + | Click (help / kill dialog) | Close help · cancel kill confirm | 39 50 40 51 ## Architecture 41 52
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1784796856, 6 + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+115 -10
src/app.gleam
··· 1 - //// Main application loop: sample /proc, render, handle keys. 1 + //// Main application loop: sample /proc, render, handle keys and mouse. 2 2 3 3 import etch/command 4 4 import etch/erlang/input 5 5 import etch/erlang/tty 6 6 import etch/event.{ 7 - type Event, type KeyCode, Backspace, Char, DownArrow, End, Enter, Esc, Home, 8 - Key, PageDown, PageUp, UpArrow, 7 + type Event, type KeyCode, type MouseEvent, Backspace, Char, Down, DownArrow, 8 + Drag, End, Enter, Esc, Home, Key, Left, Mouse, Moved, PageDown, PageUp, Right, 9 + ScrollDown, ScrollLeft, ScrollRight, ScrollUp, Up, UpArrow, 9 10 } 10 11 import etch/stdout 11 12 import etch/terminal ··· 34 35 let assert Ok(_) = tty.enter_raw() 35 36 stdout.execute([ 36 37 command.EnterAlternateScreen, 38 + command.EnableMouseCapture, 37 39 command.HideCursor, 38 40 command.Clear(terminal.All), 39 41 command.DisableLineWrap, ··· 103 105 False -> { 104 106 case input.poll(0) { 105 107 Some(Ok(ev)) -> 106 - case apply_event(snap, view, ev) { 107 - #(snap2, view2, True) -> #(snap2, view2, True) 108 - #(snap2, view2, False) -> { 109 - // Immediate redraw on keypress for snappy feel 110 - stdout.execute(ui.render(snap2, view2)) 111 - handle_input_window_go(snap2, view2, remaining - step, step) 112 - } 108 + // Mouse motion / drag / release spam SGR reports continuously. 109 + // Full clear+redraw on hover was the glitch; ignore those kinds. 110 + case is_mouse_noise(ev) { 111 + True -> handle_input_window_go(snap, view, remaining - step, step) 112 + False -> 113 + case apply_event(snap, view, ev) { 114 + #(snap2, view2, True) -> #(snap2, view2, True) 115 + #(snap2, view2, False) -> { 116 + // Immediate redraw on real input for snappy feel 117 + stdout.execute(ui.render(snap2, view2)) 118 + handle_input_window_go(snap2, view2, remaining - step, step) 119 + } 120 + } 113 121 } 114 122 Some(Error(_)) -> { 115 123 process.sleep(step) ··· 140 148 ) -> #(Snapshot, ViewState, Bool) { 141 149 case ev { 142 150 Key(ke) -> apply_key(snap, view, ke.code, ke.modifiers.control) 151 + Mouse(me) -> apply_mouse(snap, view, me) 143 152 _ -> #(snap, view, False) 144 153 } 145 154 } 146 155 156 + /// Hover / drag / button-up should not mutate state or force a paint. 157 + fn is_mouse_noise(ev: Event) -> Bool { 158 + case ev { 159 + Mouse(me) -> 160 + case me.kind { 161 + Moved | Drag(_) | Up(_) | ScrollLeft | ScrollRight -> True 162 + _ -> False 163 + } 164 + _ -> False 165 + } 166 + } 167 + 168 + fn apply_mouse( 169 + snap: Snapshot, 170 + view: ViewState, 171 + me: MouseEvent, 172 + ) -> #(Snapshot, ViewState, Bool) { 173 + case view.mode { 174 + Help -> 175 + // Only a click dismisses help (not hover — filtered as noise above) 176 + case me.kind { 177 + Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False) 178 + _ -> #(snap, view, False) 179 + } 180 + 181 + ConfirmKill -> 182 + // Click cancels kill (safer than accidental confirm) 183 + case me.kind { 184 + Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False) 185 + _ -> #(snap, view, False) 186 + } 187 + 188 + Filter -> 189 + // Keep filter typing keyboard-only; wheel still scrolls selection 190 + case me.kind { 191 + ScrollUp -> #(snap, move_sel(view, snap, -3), False) 192 + ScrollDown -> #(snap, move_sel(view, snap, 3), False) 193 + _ -> #(snap, view, False) 194 + } 195 + 196 + Normal -> 197 + case me.kind { 198 + ScrollUp -> #(snap, move_sel(view, snap, -3), False) 199 + ScrollDown -> #(snap, move_sel(view, snap, 3), False) 200 + 201 + Down(Left) -> apply_left_click(snap, view, me.column, me.row) 202 + 203 + Down(Right) -> 204 + case ui.process_index_at_row(snap, view, me.row) { 205 + Ok(idx) -> { 206 + let view = ui.ViewState(..view, selected: idx) 207 + case ui.selected_process(view, snap) { 208 + Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False) 209 + Error(_) -> #(snap, view, False) 210 + } 211 + } 212 + Error(_) -> #(snap, view, False) 213 + } 214 + 215 + _ -> #(snap, view, False) 216 + } 217 + } 218 + } 219 + 220 + fn apply_left_click( 221 + snap: Snapshot, 222 + view: ViewState, 223 + col: Int, 224 + row: Int, 225 + ) -> #(Snapshot, ViewState, Bool) { 226 + let lay = ui.layout(snap, view) 227 + case row == lay.table_header_row { 228 + True -> 229 + case ui.sort_key_at_column(col) { 230 + Ok(key) -> #(snap, set_sort(view, key), False) 231 + Error(_) -> #(snap, view, False) 232 + } 233 + False -> 234 + case ui.process_index_at_row(snap, view, row) { 235 + Ok(idx) -> #(snap, ui.ViewState(..view, selected: idx), False) 236 + Error(_) -> #(snap, view, False) 237 + } 238 + } 239 + } 240 + 147 241 fn apply_key( 148 242 snap: Snapshot, 149 243 view: ViewState, ··· 245 339 Char("p") -> #(snap, set_sort(view, SortPid), False) 246 340 Char("t") -> #(snap, set_sort(view, SortTime), False) 247 341 Char("n") -> #(snap, set_sort(view, SortCommand), False) 342 + Char("T") -> #( 343 + snap, 344 + ui.ViewState( 345 + ..view, 346 + tree: !view.tree, 347 + selected: 0, 348 + scroll: 0, 349 + ), 350 + False, 351 + ) 248 352 Char("r") -> #( 249 353 snap, 250 354 ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0), ··· 302 406 303 407 fn cleanup() -> Nil { 304 408 stdout.execute([ 409 + command.DisableMouseCapture, 305 410 command.EnableLineWrap, 306 411 command.ShowCursor, 307 412 command.Clear(terminal.All),
+4 -3
src/format.gleam
··· 75 75 truncate(s, width) |> pad_left(width) 76 76 } 77 77 78 - /// Solid meter: filled blocks + empty blocks. width is total cells. 78 + /// Solid meter: filled blocks + light track. width is total cells. 79 + /// Prefer `ui.colored_meter` for the live TUI (truecolor gradient). 79 80 pub fn meter(percent: Float, width: Int) -> String { 80 81 let p = float.clamp(percent, 0.0, 100.0) 81 82 let filled = float.round(p /. 100.0 *. int.to_float(width)) ··· 83 84 string.repeat("█", filled) <> string.repeat("░", width - filled) 84 85 } 85 86 86 - /// Gradient meter characters for finer resolution (btop-ish). 87 + /// Fine meter with partial left-blocks and a light track for empty cells. 87 88 pub fn meter_fine(percent: Float, width: Int) -> String { 88 89 let p = float.clamp(percent, 0.0, 100.0) 89 90 let cells = p /. 100.0 *. int.to_float(width) ··· 106 107 let len = string.length(body) 107 108 case len >= width { 108 109 True -> string.slice(body, 0, width) 109 - False -> body <> string.repeat(" ", width - len) 110 + False -> body <> string.repeat("░", width - len) 110 111 } 111 112 } 112 113
+1 -1
src/monitor.gleam
··· 2 2 //// 3 3 //// * btop-style CPU/memory meters, sparklines, and color bands 4 4 //// * htop-style process table (PID, USER, PRI, NI, VIRT, RES, S, CPU%, MEM%, TIME+, Command) 5 - //// * sort, filter, kill, keyboard navigation 5 + //// * sort, filter, kill, keyboard + mouse navigation 6 6 7 7 import app 8 8
+8 -3
src/procfs.gleam
··· 34 34 pub type Process { 35 35 Process( 36 36 pid: Int, 37 + ppid: Int, 37 38 user: String, 38 39 priority: Int, 39 40 nice: Int, ··· 336 337 simplifile.read(base <> "/stat"), 337 338 Nil, 338 339 )) 339 - use #(state, priority, nice, utime, stime, vsize, rss, num_threads, comm) <- result.try( 340 + use #(state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm) <- result.try( 340 341 parse_stat(stat_raw), 341 342 ) 342 343 ··· 381 382 Ok(#( 382 383 Process( 383 384 pid: pid, 385 + ppid: ppid, 384 386 user: user, 385 387 priority: priority, 386 388 nice: nice, ··· 398 400 } 399 401 400 402 /// Parse /proc/[pid]/stat — comm is inside parentheses and may contain spaces. 403 + /// Returns state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm. 401 404 fn parse_stat( 402 405 raw: String, 403 - ) -> Result(#(String, Int, Int, Int, Int, Int, Int, Int, String), Nil) { 406 + ) -> Result(#(String, Int, Int, Int, Int, Int, Int, Int, Int, String), Nil) { 404 407 let raw = string.trim(raw) 405 408 use #(_, rest0) <- result.try(split_once_char(raw, "(")) 406 409 use #(comm, after) <- result.try(rsplit_once_char(rest0, ")")) ··· 412 415 // After comm: state(0) ppid(1) ... utime(11) stime(12) ... priority(15) 413 416 // nice(16) num_threads(17) ... vsize(20) rss(21) 414 417 use state <- result.try(list_at(fields, 0)) 418 + let ppid = 419 + result.unwrap(int.parse(result.unwrap(list_at(fields, 1), "0")), 0) 415 420 let priority = 416 421 result.unwrap(int.parse(result.unwrap(list_at(fields, 15), "0")), 0) 417 422 let nice = ··· 426 431 result.unwrap(int.parse(result.unwrap(list_at(fields, 20), "0")), 0) 427 432 let rss = result.unwrap(int.parse(result.unwrap(list_at(fields, 21), "0")), 0) 428 433 429 - Ok(#(state, priority, nice, utime, stime, vsize, rss, num_threads, comm)) 434 + Ok(#(state, ppid, priority, nice, utime, stime, vsize, rss, num_threads, comm)) 430 435 } 431 436 432 437 fn read_uid(status_path: String) -> Int {
+568 -115
src/ui.gleam
··· 4 4 import etch/style 5 5 import etch/terminal 6 6 import format 7 + import gleam/dict 8 + import gleam/float 7 9 import gleam/int 8 10 import gleam/list 9 11 import gleam/order.{type Order, Eq, Gt, Lt} 12 + import gleam/set 10 13 import gleam/string 11 14 import procfs.{type Process, type Snapshot} 12 15 ··· 25 28 ConfirmKill 26 29 } 27 30 31 + /// One process table row; `depth` is tree indentation when grouping by PPID. 32 + pub type ProcessRow { 33 + ProcessRow(process: Process, depth: Int) 34 + } 35 + 28 36 pub type ViewState { 29 37 ViewState( 30 38 selected: Int, 31 39 scroll: Int, 32 40 sort: SortKey, 33 41 reverse: Bool, 42 + /// Group processes under their parent (PPID tree). 43 + tree: Bool, 34 44 filter: String, 35 45 mode: Mode, 36 46 cpu_history: List(Float), ··· 46 56 scroll: 0, 47 57 sort: SortCpu, 48 58 reverse: True, 59 + tree: False, 49 60 filter: "", 50 61 mode: Normal, 51 62 cpu_history: [], ··· 71 82 } 72 83 73 84 pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) { 85 + visible_rows(view, snap) 86 + |> list.map(fn(row) { row.process }) 87 + } 88 + 89 + pub fn visible_rows(view: ViewState, snap: Snapshot) -> List(ProcessRow) { 74 90 let filtered = case view.filter { 75 91 "" -> snap.processes 76 92 f -> { ··· 79 95 string.contains(string.lowercase(p.command), f) 80 96 || string.contains(string.lowercase(p.user), f) 81 97 || string.contains(int.to_string(p.pid), f) 98 + || string.contains(int.to_string(p.ppid), f) 82 99 }) 83 100 } 84 101 } 85 - let sorted = sort_processes(filtered, view.sort) 86 - case view.reverse { 102 + case view.tree { 103 + True -> group_by_ppid(filtered, view.sort, view.reverse) 104 + False -> { 105 + order_siblings(filtered, view.sort, view.reverse) 106 + |> list.map(fn(p) { ProcessRow(process: p, depth: 0) }) 107 + } 108 + } 109 + } 110 + 111 + /// Group by PPID, led by sort value: 112 + /// 1. Rank processes by the active sort key (highest first when reverse). 113 + /// 2. For each process in that order, emit its whole PPID tree (parent root 114 + /// + descendants) so the hot PID brings its family with it. 115 + /// 3. Under each parent, child branches ordered by the hottest process in 116 + /// that subtree (so the path to the hot PID rises). 117 + fn group_by_ppid( 118 + procs: List(Process), 119 + key: SortKey, 120 + reverse: Bool, 121 + ) -> List(ProcessRow) { 122 + let by_pid = 123 + list.fold(procs, dict.new(), fn(acc, p) { dict.insert(acc, p.pid, p) }) 124 + 125 + // Raw children lists (unsorted); order after we know subtree leaders. 126 + let children_raw = 127 + list.fold(procs, dict.new(), fn(acc, p) { 128 + let kids = case dict.get(acc, p.ppid) { 129 + Ok(ks) -> [p, ..ks] 130 + Error(_) -> [p] 131 + } 132 + dict.insert(acc, p.ppid, kids) 133 + }) 134 + |> dict.map_values(fn(_ppid, kids) { list.reverse(kids) }) 135 + 136 + // leader[pid] = process with best sort value in pid's subtree 137 + let leaders = build_subtree_leaders(procs, children_raw, key, reverse) 138 + 139 + let children = 140 + dict.map_values(children_raw, fn(_ppid, kids) { 141 + order_by_leader(kids, leaders, key, reverse) 142 + }) 143 + 144 + // Value-led emission: hottest process first → emit its root tree as a group. 145 + let ranked = order_siblings(procs, key, reverse) 146 + let #(rows, visited) = 147 + list.fold(ranked, #([], set.new()), fn(acc, p) { 148 + let #(out, seen) = acc 149 + case set.contains(seen, p.pid) { 150 + True -> acc 151 + False -> { 152 + let root = find_root(p, by_pid) 153 + case set.contains(seen, root.pid) { 154 + True -> acc 155 + False -> { 156 + let #(branch, seen2) = walk_tree(root, 0, children, seen) 157 + #(list.append(out, branch), seen2) 158 + } 159 + } 160 + } 161 + } 162 + }) 163 + 164 + // Cycles / stragglers not reached from any root still appear. 165 + let leftovers = 166 + list.filter(procs, fn(p) { !set.contains(visited, p.pid) }) 167 + |> order_siblings(key, reverse) 168 + 169 + let #(rows2, _) = 170 + list.fold(leftovers, #(rows, visited), fn(acc, p) { 171 + let #(out, seen) = acc 172 + case set.contains(seen, p.pid) { 173 + True -> acc 174 + False -> { 175 + let #(branch, seen2) = walk_tree(p, 0, children, seen) 176 + #(list.append(out, branch), seen2) 177 + } 178 + } 179 + }) 180 + 181 + rows2 182 + } 183 + 184 + /// Walk up PPID links until the parent is outside the current process set. 185 + fn find_root( 186 + proc: Process, 187 + by_pid: dict.Dict(Int, Process), 188 + ) -> Process { 189 + find_root_go(proc, by_pid, set.new()) 190 + } 191 + 192 + fn find_root_go( 193 + proc: Process, 194 + by_pid: dict.Dict(Int, Process), 195 + seen: set.Set(Int), 196 + ) -> Process { 197 + case set.contains(seen, proc.pid) { 198 + True -> proc 199 + False -> { 200 + let seen = set.insert(seen, proc.pid) 201 + case proc.ppid == proc.pid { 202 + True -> proc 203 + False -> 204 + case dict.get(by_pid, proc.ppid) { 205 + Error(_) -> proc 206 + Ok(parent) -> find_root_go(parent, by_pid, seen) 207 + } 208 + } 209 + } 210 + } 211 + } 212 + 213 + /// For each process, the "leader" is the best-ranked process in its subtree 214 + /// under the current sort (highest CPU, etc. when reverse). 215 + fn build_subtree_leaders( 216 + procs: List(Process), 217 + children: dict.Dict(Int, List(Process)), 218 + key: SortKey, 219 + reverse: Bool, 220 + ) -> dict.Dict(Int, Process) { 221 + list.fold(procs, dict.new(), fn(memo, p) { 222 + let #(_, memo2) = subtree_leader(p, children, key, reverse, memo) 223 + memo2 224 + }) 225 + } 226 + 227 + fn subtree_leader( 228 + proc: Process, 229 + children: dict.Dict(Int, List(Process)), 230 + key: SortKey, 231 + reverse: Bool, 232 + memo: dict.Dict(Int, Process), 233 + ) -> #(Process, dict.Dict(Int, Process)) { 234 + case dict.get(memo, proc.pid) { 235 + Ok(leader) -> #(leader, memo) 236 + Error(_) -> { 237 + let kids = case dict.get(children, proc.pid) { 238 + Ok(ks) -> ks 239 + Error(_) -> [] 240 + } 241 + let #(leader, memo) = 242 + list.fold(kids, #(proc, memo), fn(acc, kid) { 243 + let #(best, memo) = acc 244 + let #(kid_leader, memo) = 245 + subtree_leader(kid, children, key, reverse, memo) 246 + #(pick_leader(best, kid_leader, key, reverse), memo) 247 + }) 248 + #(leader, dict.insert(memo, proc.pid, leader)) 249 + } 250 + } 251 + } 252 + 253 + fn pick_leader( 254 + a: Process, 255 + b: Process, 256 + key: SortKey, 257 + reverse: Bool, 258 + ) -> Process { 259 + // reverse=True → highest value wins; reverse=False → lowest wins 260 + case compare_processes(a, b, key) { 261 + Lt -> 262 + case reverse { 263 + True -> b 264 + False -> a 265 + } 266 + Gt -> 267 + case reverse { 268 + True -> a 269 + False -> b 270 + } 271 + Eq -> a 272 + } 273 + } 274 + 275 + fn order_by_leader( 276 + kids: List(Process), 277 + leaders: dict.Dict(Int, Process), 278 + key: SortKey, 279 + reverse: Bool, 280 + ) -> List(Process) { 281 + list.sort(kids, fn(a, b) { 282 + let la = case dict.get(leaders, a.pid) { 283 + Ok(p) -> p 284 + Error(_) -> a 285 + } 286 + let lb = case dict.get(leaders, b.pid) { 287 + Ok(p) -> p 288 + Error(_) -> b 289 + } 290 + let ord = compare_processes(la, lb, key) 291 + case reverse { 292 + True -> invert_order(ord) 293 + False -> ord 294 + } 295 + }) 296 + } 297 + 298 + fn invert_order(o: Order) -> Order { 299 + case o { 300 + Lt -> Gt 301 + Eq -> Eq 302 + Gt -> Lt 303 + } 304 + } 305 + 306 + fn compare_processes(a: Process, b: Process, key: SortKey) -> Order { 307 + case key { 308 + SortCpu -> float_ord(a.cpu_percent, b.cpu_percent) 309 + SortMem -> float_ord(a.mem_percent, b.mem_percent) 310 + SortPid -> int.compare(a.pid, b.pid) 311 + SortTime -> int.compare(a.time_ticks, b.time_ticks) 312 + SortCommand -> string.compare(a.command, b.command) 313 + } 314 + } 315 + 316 + fn walk_tree( 317 + proc: Process, 318 + depth: Int, 319 + children: dict.Dict(Int, List(Process)), 320 + visited: set.Set(Int), 321 + ) -> #(List(ProcessRow), set.Set(Int)) { 322 + case set.contains(visited, proc.pid) { 323 + True -> #([], visited) 324 + False -> { 325 + let visited = set.insert(visited, proc.pid) 326 + let kids = case dict.get(children, proc.pid) { 327 + Ok(ks) -> ks 328 + Error(_) -> [] 329 + } 330 + let #(nested, visited) = 331 + list.fold(kids, #([], visited), fn(acc, kid) { 332 + let #(rows, seen) = acc 333 + let #(branch, seen2) = walk_tree(kid, depth + 1, children, seen) 334 + #(list.append(rows, branch), seen2) 335 + }) 336 + #([ProcessRow(process: proc, depth: depth), ..nested], visited) 337 + } 338 + } 339 + } 340 + 341 + fn order_siblings( 342 + procs: List(Process), 343 + key: SortKey, 344 + reverse: Bool, 345 + ) -> List(Process) { 346 + let sorted = sort_processes(procs, key) 347 + case reverse { 87 348 True -> list.reverse(sorted) 88 349 False -> sorted 89 350 } ··· 112 373 } 113 374 } 114 375 115 - /// Build a full frame as a list of etch commands. 116 - pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { 376 + /// Screen geometry for the process table (0-based row indices). 377 + pub type Layout { 378 + Layout( 379 + table_header_row: Int, 380 + process_start_row: Int, 381 + process_height: Int, 382 + footer_row: Int, 383 + cols: Int, 384 + rows: Int, 385 + ) 386 + } 387 + 388 + /// Compute layout matching `render` (for hit-testing mouse events). 389 + pub fn layout(snap: Snapshot, view: ViewState) -> Layout { 117 390 let cols = int.max(view.cols, 60) 118 391 let rows = int.max(view.rows, 16) 119 - let procs = visible_processes(view, snap) 120 - let proc_count = list.length(procs) 392 + let core_rows = core_row_count(snap, cols) 393 + // Drawn: header, cpu total, core rows, mem, swap, then table header 394 + let table_header_row = 1 + 1 + core_rows + 2 395 + let process_start_row = table_header_row + 1 396 + let header_lines = 1 + 1 + core_rows + 2 + 1 397 + let footer_lines = 1 398 + let table_header = 1 399 + let available = int.max(rows - header_lines - footer_lines - table_header, 3) 400 + Layout( 401 + table_header_row: table_header_row, 402 + process_start_row: process_start_row, 403 + process_height: available, 404 + footer_row: rows - 1, 405 + cols: cols, 406 + rows: rows, 407 + ) 408 + } 121 409 122 - // Layout: header(1) + cpu block + mem(2) + blank + table header + rows + footer 410 + fn core_row_count(snap: Snapshot, cols: Int) -> Int { 123 411 let core_count = list.length(snap.cores) 124 - // CPU section: 1 line total + ceil(cores/cols_per_row) lines, max ~4 lines 125 412 let cores_per_row = case cols >= 120 { 126 413 True -> 4 127 414 False -> ··· 130 417 False -> 2 131 418 } 132 419 } 133 - let core_rows = case core_count { 420 + case core_count { 134 421 0 -> 0 135 422 n -> { 136 423 let r = { n + cores_per_row - 1 } / cores_per_row 137 424 int.min(r, 4) 138 425 } 139 426 } 140 - let header_lines = 1 + 1 + core_rows + 2 + 1 141 - // header, cpu total, core rows, mem+swap, blank before table 142 - let footer_lines = 1 143 - let table_header = 1 144 - let available = int.max(rows - header_lines - footer_lines - table_header, 3) 427 + } 428 + 429 + fn cores_per_row(cols: Int) -> Int { 430 + case cols >= 120 { 431 + True -> 4 432 + False -> 433 + case cols >= 90 { 434 + True -> 3 435 + False -> 2 436 + } 437 + } 438 + } 439 + 440 + /// Which sort column (if any) was clicked on the table header row. 441 + /// Column widths match `draw_table_header` / `draw_process`. 442 + pub fn sort_key_at_column(col: Int) -> Result(SortKey, Nil) { 443 + // PID(7) USER(9) PRI(3) NI(3) VIRT(7) RES(7) S(1) CPU%(6) MEM%(6) TIME+(9) Command 444 + // with single spaces between fields 445 + case col { 446 + c if c >= 0 && c <= 7 -> Ok(SortPid) 447 + c if c >= 44 && c <= 50 -> Ok(SortCpu) 448 + c if c >= 51 && c <= 57 -> Ok(SortMem) 449 + c if c >= 58 && c <= 67 -> Ok(SortTime) 450 + c if c >= 68 -> Ok(SortCommand) 451 + _ -> Error(Nil) 452 + } 453 + } 454 + 455 + /// Process list index under a screen row, if any. 456 + pub fn process_index_at_row( 457 + snap: Snapshot, 458 + view: ViewState, 459 + row: Int, 460 + ) -> Result(Int, Nil) { 461 + let lay = layout(snap, view) 462 + let n = list.length(visible_processes(view, snap)) 463 + case row >= lay.process_start_row && row < lay.process_start_row + lay.process_height { 464 + True -> { 465 + let idx = view.scroll + { row - lay.process_start_row } 466 + case idx >= 0 && idx < n { 467 + True -> Ok(idx) 468 + False -> Error(Nil) 469 + } 470 + } 471 + False -> Error(Nil) 472 + } 473 + } 474 + 475 + /// Build a full frame as a list of etch commands. 476 + pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { 477 + let lay = layout(snap, view) 478 + let cols = lay.cols 479 + let rows = lay.rows 480 + let rows_list = visible_rows(view, snap) 481 + let proc_count = list.length(rows_list) 482 + let core_rows = core_row_count(snap, cols) 483 + let per_row = cores_per_row(cols) 484 + let available = lay.process_height 145 485 let scroll = clamp_scroll(view.scroll, view.selected, proc_count, available) 146 486 let selected = int.clamp(view.selected, 0, int.max(proc_count - 1, 0)) 147 487 ··· 158 498 list.flatten([ 159 499 [draw_header(snap, view, cols)], 160 500 [draw_cpu_total(snap, view, cols)], 161 - draw_cores(snap, cores_per_row, core_rows, cols), 501 + draw_cores(snap, per_row, core_rows, cols), 162 502 [draw_mem(snap, cols), draw_swap(snap, cols)], 163 503 [draw_table_header(view, cols)], 164 - draw_process_rows(procs, view, available, cols), 165 - pad_to(available - visible_row_count(procs, view, available), cols), 504 + draw_process_rows(rows_list, view, available, cols), 505 + pad_to(available - visible_row_count(proc_count, view, available), cols), 166 506 ]) 167 507 168 508 let body = ··· 176 516 177 517 let overlay = case view.mode { 178 518 Help -> help_overlay(cols, rows) 179 - ConfirmKill -> kill_overlay(procs, view, cols, rows) 519 + ConfirmKill -> kill_overlay(rows_list, view, cols, rows) 180 520 Filter -> [] 181 521 Normal -> [] 182 522 } ··· 208 548 int.clamp(scroll, 0, int.max(0, count - height)) 209 549 } 210 550 211 - fn visible_row_count( 212 - procs: List(Process), 213 - view: ViewState, 214 - height: Int, 215 - ) -> Int { 216 - let n = list.length(procs) 217 - int.min(height, int.max(0, n - view.scroll)) 551 + fn visible_row_count(proc_count: Int, view: ViewState, height: Int) -> Int { 552 + int.min(height, int.max(0, proc_count - view.scroll)) 218 553 } 219 554 220 555 fn pad_to(n: Int, cols: Int) -> List(String) { ··· 225 560 } 226 561 227 562 fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String { 228 - let title = style_bold(style_cyan(" monitor ")) 563 + let title = 564 + style.with_style( 565 + " monitor ", 566 + style.Style( 567 + fg: style.Rgb(140, 170, 200), 568 + bg: style.Default, 569 + attributes: [style.Bold], 570 + ), 571 + ) 229 572 let host = style_dim(snap.hostname) 230 573 let up = "up " <> format.format_uptime(snap.uptime_secs) 231 574 let load = "load " <> format.format_load(snap.load1, snap.load5, snap.load15) ··· 241 584 True -> "↓" 242 585 False -> "↑" 243 586 } 587 + <> case view.tree { 588 + True -> " tree" 589 + False -> "" 590 + } 244 591 let mid = host <> " " <> up <> " " <> load <> " " <> tasks 245 - let right = sort 246 592 let left = title 247 593 // approx without ansi length — simple pad using raw segments 248 594 let raw_left = " monitor " 249 595 let raw_mid = snap.hostname <> " " <> up <> " " <> load <> " " <> tasks 250 - let raw_right = "sort:" <> sort_label(view.sort) <> "↓" 596 + let raw_right = 597 + "sort:" 598 + <> sort_label(view.sort) 599 + <> "↓" 600 + <> case view.tree { 601 + True -> " tree" 602 + False -> "" 603 + } 251 604 let gap = 252 605 int.max( 253 606 1, ··· 264 617 <> " " 265 618 <> style_dim(string.repeat("─", gap - gap / 2)) 266 619 <> " " 267 - <> style_yellow(right) 620 + <> style.with(sort, style.Rgb(186, 168, 112)) 268 621 <> reset() 269 622 |> fit_line(cols) 270 623 } ··· 279 632 format.percent(snap.cpu_total_percent), 280 633 ) 281 634 let spark_w = int.clamp(cols - bar_w - 18, 8, 40) 282 - let spark = style_cyan(format.sparkline(view.cpu_history, spark_w)) 635 + let spark = 636 + style.with( 637 + format.sparkline(view.cpu_history, spark_w), 638 + style.Rgb(108, 140, 168), 639 + ) 283 640 label 284 641 <> bar 285 642 <> " " ··· 298 655 ) -> List(String) { 299 656 let cores = list.take(snap.cores, max_rows * per_row) 300 657 let cell_w = int.max(12, { cols - 1 } / per_row) 658 + // label(3) + bar + pct(5) — never run ANSI-rich bars through format.fit 659 + // (string.length counts escape codes and chops the meter to nothing). 660 + let label_w = 3 661 + let pct_w = 5 662 + let bar_w = int.max(4, cell_w - label_w - pct_w) 663 + let pad_w = int.max(0, cell_w - label_w - bar_w - pct_w) 301 664 list.sized_chunk(cores, per_row) 302 665 |> list.map(fn(row) { 303 666 row 304 667 |> list.map(fn(c) { 305 668 let name = string.replace(c.name, "cpu", "") 306 669 let label = style_dim(format.pad_left(name, 2) <> " ") 307 - let bar_w = int.max(4, cell_w - 9) 308 670 let bar = colored_meter(c.percent, bar_w) 309 671 let pct = 310 672 style_for_band( 311 673 c.percent, 312 - format.fit_right(format.percent_short(c.percent), 5), 674 + format.fit_right(format.percent_short(c.percent), pct_w), 313 675 ) 314 - format.fit(label <> bar <> pct, cell_w) 676 + label <> bar <> pct <> string.repeat(" ", pad_w) <> reset() 315 677 }) 316 678 |> string.concat 317 679 |> fit_line(cols) ··· 365 727 } 366 728 367 729 fn draw_table_header(view: ViewState, cols: Int) -> String { 368 - let mark = fn(key: SortKey, label: String) { 730 + // Style each cell fully (fg+bg+attrs). etch's with/attributes do not reset, 731 + // so a yellow "active sort" otherwise bleeds across the whole header row. 732 + let bg = style.Rgb(30, 30, 46) 733 + let cell = fn(active: Bool, label: String) { 734 + let fg = case active { 735 + True -> style.Rgb(186, 168, 112) 736 + False -> style.Rgb(180, 184, 200) 737 + } 738 + style.with_style( 739 + label, 740 + style.Style(fg: fg, bg: bg, attributes: [style.Bold]), 741 + ) 742 + } 743 + let gap = 744 + style.with_style( 745 + " ", 746 + style.Style(fg: style.BrightWhite, bg: bg, attributes: []), 747 + ) 748 + let arrow = case view.reverse { 749 + True -> "↓" 750 + False -> "↑" 751 + } 752 + // Keep field widths fixed: active sort shows direction inside the cell. 753 + let labeled = fn(key: SortKey, text: String) { 369 754 case view.sort == key { 370 - True -> style_bold(style_yellow(label)) 371 - False -> style_bold(label) 755 + True -> text <> arrow 756 + False -> text 372 757 } 373 758 } 374 759 let line = 375 - mark(SortPid, format.fit_right("PID", 7)) 376 - <> " " 377 - <> format.fit("USER", 9) 378 - <> " " 379 - <> format.fit_right("PRI", 3) 380 - <> " " 381 - <> format.fit_right("NI", 3) 382 - <> " " 383 - <> format.fit_right("VIRT", 7) 384 - <> " " 385 - <> format.fit_right("RES", 7) 386 - <> " " 387 - <> "S" 388 - <> " " 389 - <> mark(SortCpu, format.fit_right("CPU%", 6)) 390 - <> " " 391 - <> mark(SortMem, format.fit_right("MEM%", 6)) 392 - <> " " 393 - <> mark(SortTime, format.fit_right("TIME+", 9)) 394 - <> " " 395 - <> mark(SortCommand, "Command") 396 - style_on_bg(line, style.Rgb(30, 30, 46)) 397 - <> reset() 398 - |> fit_line(cols) 760 + cell(view.sort == SortPid, format.fit_right(labeled(SortPid, "PID"), 7)) 761 + <> gap 762 + <> cell(False, format.fit("USER", 9)) 763 + <> gap 764 + <> cell(False, format.fit_right("PRI", 3)) 765 + <> gap 766 + <> cell(False, format.fit_right("NI", 3)) 767 + <> gap 768 + <> cell(False, format.fit_right("VIRT", 7)) 769 + <> gap 770 + <> cell(False, format.fit_right("RES", 7)) 771 + <> gap 772 + <> cell(False, "S") 773 + <> gap 774 + <> cell( 775 + view.sort == SortCpu, 776 + format.fit_right(labeled(SortCpu, "CPU%"), 6), 777 + ) 778 + <> gap 779 + <> cell( 780 + view.sort == SortMem, 781 + format.fit_right(labeled(SortMem, "MEM%"), 6), 782 + ) 783 + <> gap 784 + <> cell( 785 + view.sort == SortTime, 786 + format.fit_right(labeled(SortTime, "TIME+"), 9), 787 + ) 788 + <> gap 789 + <> cell( 790 + view.sort == SortCommand, 791 + format.fit(labeled(SortCommand, "Command"), 8), 792 + ) 793 + // Fixed columns before Command = 70; Command cell = 8 → pad the rest. 794 + let pad = 795 + style.with_style( 796 + string.repeat(" ", int.max(0, cols - 78)), 797 + style.Style(fg: style.BrightWhite, bg: bg, attributes: []), 798 + ) 799 + line <> pad <> reset() 399 800 } 400 801 401 802 fn draw_process_rows( 402 - procs: List(Process), 803 + rows: List(ProcessRow), 403 804 view: ViewState, 404 805 height: Int, 405 806 cols: Int, 406 807 ) -> List(String) { 407 - procs 808 + rows 408 809 |> list.drop(view.scroll) 409 810 |> list.take(height) 410 - |> list.index_map(fn(p, i) { 811 + |> list.index_map(fn(row, i) { 411 812 let idx = view.scroll + i 412 813 let selected = idx == view.selected 413 - draw_process(p, selected, cols) 814 + draw_process(row.process, row.depth, selected, cols) 414 815 }) 415 816 } 416 817 417 - fn draw_process(p: Process, selected: Bool, cols: Int) -> String { 818 + fn tree_prefix(depth: Int) -> String { 819 + case depth { 820 + 0 -> "" 821 + d -> string.repeat("│ ", d - 1) <> "├─" 822 + } 823 + } 824 + 825 + fn draw_process(p: Process, depth: Int, selected: Bool, cols: Int) -> String { 418 826 let cmd_w = int.max(10, cols - 70) 827 + let cmd = format.fit(tree_prefix(depth) <> p.command, cmd_w) 419 828 let base = 420 829 format.fit_right(int.to_string(p.pid), 7) 421 830 <> " " ··· 443 852 <> " " 444 853 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) 445 854 <> " " 446 - <> format.fit(p.command, cmd_w) 855 + <> cmd 447 856 448 857 case selected { 449 858 True -> 450 - style_on_bg(strip_for_select(p, cmd_w), style.Rgb(69, 71, 90)) 859 + style_on_bg(strip_for_select(p, depth, cmd_w), style.Rgb(69, 71, 90)) 451 860 <> reset() 452 861 |> fit_line(cols) 453 862 False -> base <> reset() |> fit_line(cols) 454 863 } 455 864 } 456 865 457 - fn strip_for_select(p: Process, cmd_w: Int) -> String { 866 + fn strip_for_select(p: Process, depth: Int, cmd_w: Int) -> String { 458 867 format.fit_right(int.to_string(p.pid), 7) 459 868 <> " " 460 869 <> format.fit(p.user, 9) ··· 475 884 <> " " 476 885 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) 477 886 <> " " 478 - <> format.fit(p.command, cmd_w) 887 + <> format.fit(tree_prefix(depth) <> p.command, cmd_w) 479 888 } 480 889 481 890 fn draw_footer( ··· 489 898 ConfirmKill -> " Confirm kill? [y/N]" 490 899 Help -> " Help · press any key to close" 491 900 Normal -> 492 - " [q]uit [↑↓/uj] move [PgUp/Dn] [/]filter [c/m/p/t/n]sort [r]ev [k]ill [h]elp · " 901 + " [q]uit [↑↓/uj·wheel] move [T]ree [click]select [header]sort [R-click]kill [/]filter [h]elp · " 493 902 <> int.to_string(visible) 494 903 <> "/" 495 904 <> int.to_string(snap.process_count) ··· 502 911 503 912 fn help_overlay(cols: Int, rows: Int) -> List(Command) { 504 913 let w = int.min(56, cols - 4) 505 - let h = 14 914 + let h = 16 506 915 let x = int.max(0, { cols - w } / 2) 507 916 let y = int.max(1, { rows - h } / 2) 508 917 let lines = [ ··· 510 919 "│" <> format.fit(" monitor — htop × btop in Gleam", w - 2) <> "│", 511 920 "│" <> format.fit("", w - 2) <> "│", 512 921 "│" <> format.fit(" Navigation", w - 2) <> "│", 513 - "│" <> format.fit(" ↑/u ↓/j move selection", w - 2) <> "│", 514 - "│" <> format.fit(" PgUp/PgDn page · g/G top/end", w - 2) <> "│", 922 + "│" <> format.fit(" ↑/u ↓/j · wheel move selection", w - 2) <> "│", 923 + "│" <> format.fit(" click row · PgUp/Dn · g/G top/end", w - 2) <> "│", 515 924 "│" <> format.fit(" Sorting & filter", w - 2) <> "│", 516 - "│" <> format.fit(" c/m/p/t/n CPU MEM PID TIME name", w - 2) <> "│", 517 - "│" <> format.fit(" r reverse · / filter", w - 2) <> "│", 925 + "│" <> format.fit(" c/m/p/t/n · click header columns", w - 2) <> "│", 926 + "│" <> format.fit(" r reverse · T tree (hot PID + family)", w - 2) <> "│", 518 927 "│" <> format.fit(" Actions", w - 2) <> "│", 519 - "│" <> format.fit(" k kill selected (SIGTERM)", w - 2) <> "│", 520 - "│" <> format.fit(" q / Esc quit", w - 2) <> "│", 928 + "│" <> format.fit(" k · right-click kill (SIGTERM)", w - 2) <> "│", 929 + "│" <> format.fit(" q / Esc quit", w - 2) <> "│", 930 + "│" <> format.fit(" Click anywhere to close this help", w - 2) <> "│", 521 931 "│" <> format.fit(" Meters from btop · table from htop", w - 2) <> "│", 522 932 "└" <> string.repeat("─", w - 2) <> "┘", 523 933 ] ··· 539 949 } 540 950 541 951 fn kill_overlay( 542 - procs: List(Process), 952 + rows: List(ProcessRow), 543 953 view: ViewState, 544 954 cols: Int, 545 - rows: Int, 955 + rows_n: Int, 546 956 ) -> List(Command) { 547 - let msg = case list_at(procs, view.selected) { 548 - Ok(p) -> 957 + let msg = case list_at(rows, view.selected) { 958 + Ok(ProcessRow(process: p, depth: _)) -> 549 959 " Kill PID " 550 960 <> int.to_string(p.pid) 551 961 <> " (" ··· 555 965 } 556 966 let w = int.min(string.length(msg) + 4, cols - 2) 557 967 let x = int.max(0, { cols - w } / 2) 558 - let y = rows / 2 968 + let y = rows_n / 2 559 969 [ 560 970 command.MoveTo(x, y), 561 971 command.SetStyle( 562 - style.Style(fg: style.Black, bg: style.BrightYellow, attributes: [ 563 - style.Bold, 564 - ]), 972 + style.Style( 973 + fg: style.Rgb(24, 24, 37), 974 + bg: style.Rgb(186, 168, 112), 975 + attributes: [style.Bold], 976 + ), 565 977 ), 566 978 command.Print(format.fit(msg, w)), 567 979 command.ResetStyle, ··· 575 987 } 576 988 } 577 989 990 + /// btop-style meter: cool→warm gradient fill over a muted track. 991 + /// Each filled cell is colored by its position, not a single band color. 578 992 fn colored_meter(percent: Float, width: Int) -> String { 579 - let bar = format.meter_fine(percent, width) 580 - style_for_band(percent, bar) 993 + let width = int.max(1, width) 994 + let p = float.clamp(percent, 0.0, 100.0) 995 + let cells = p /. 100.0 *. int.to_float(width) 996 + let full = int.clamp(float.truncate(cells), 0, width) 997 + let frac = cells -. int.to_float(full) 998 + 999 + let filled = 1000 + list.repeat(0, full) 1001 + |> list.index_map(fn(_, i) { 1002 + let t = { int.to_float(i) +. 0.5 } /. int.to_float(width) 1003 + style.with("█", meter_gradient(t)) 1004 + }) 1005 + 1006 + let has_partial = full < width && frac >. 0.0 1007 + let partial = case has_partial { 1008 + True -> { 1009 + let t = { int.to_float(full) +. 0.5 } /. int.to_float(width) 1010 + style.with(partial_block(frac), meter_gradient(t)) 1011 + } 1012 + False -> "" 1013 + } 1014 + 1015 + let empty_n = case has_partial { 1016 + True -> width - full - 1 1017 + False -> width - full 1018 + } 1019 + let empty = case empty_n > 0 { 1020 + True -> style.with(string.repeat("░", empty_n), style.Rgb(49, 50, 68)) 1021 + False -> "" 1022 + } 1023 + 1024 + string.concat(filled) <> partial <> empty 581 1025 } 582 1026 1027 + /// Left-block partials for sub-cell resolution. 1028 + fn partial_block(frac: Float) -> String { 1029 + case frac { 1030 + f if f >=. 0.875 -> "▉" 1031 + f if f >=. 0.75 -> "▊" 1032 + f if f >=. 0.625 -> "▋" 1033 + f if f >=. 0.5 -> "▌" 1034 + f if f >=. 0.375 -> "▍" 1035 + f if f >=. 0.25 -> "▎" 1036 + f if f >. 0.0 -> "▏" 1037 + _ -> "" 1038 + } 1039 + } 1040 + 1041 + /// Muted cool→warm spectrum (steel → teal → gold → copper → rose). 1042 + fn meter_gradient(t: Float) -> style.Color { 1043 + let t = float.clamp(t, 0.0, 1.0) 1044 + case t { 1045 + t if t <. 0.22 -> style.Rgb(110, 156, 204) 1046 + t if t <. 0.40 -> style.Rgb(118, 176, 178) 1047 + t if t <. 0.55 -> style.Rgb(138, 176, 148) 1048 + t if t <. 0.70 -> style.Rgb(176, 172, 120) 1049 + t if t <. 0.82 -> style.Rgb(196, 156, 108) 1050 + t if t <. 0.92 -> style.Rgb(196, 128, 108) 1051 + _ -> style.Rgb(186, 108, 118) 1052 + } 1053 + } 1054 + 1055 + /// Percentage / value tint: muted truecolor, not toy primaries. 583 1056 fn style_for_band(percent: Float, s: String) -> String { 584 1057 case format.band(percent) { 585 - format.Low -> style_green(s) 586 - format.Mid -> style_yellow(s) 587 - format.High -> style_magenta(s) 588 - format.Critical -> style_red(s) 1058 + format.Low -> style.with(s, style.Rgb(140, 170, 200)) 1059 + format.Mid -> style.with(s, style.Rgb(186, 168, 112)) 1060 + format.High -> style.with(s, style.Rgb(196, 132, 108)) 1061 + format.Critical -> style.with(s, style.Rgb(196, 108, 118)) 589 1062 } 590 1063 } 591 1064 592 1065 fn state_color(s: String) -> String { 593 1066 case s { 594 - "R" -> style_green("R") 1067 + "R" -> style.with("R", style.Rgb(138, 176, 148)) 595 1068 "S" -> style_dim("S") 596 - "D" -> style_red("D") 597 - "Z" -> style_red("Z") 598 - "T" | "t" -> style_yellow("T") 1069 + "D" -> style.with("D", style.Rgb(196, 108, 118)) 1070 + "Z" -> style.with("Z", style.Rgb(196, 108, 118)) 1071 + "T" | "t" -> style.with("T", style.Rgb(186, 168, 112)) 599 1072 _ -> s 600 1073 } 601 1074 } ··· 627 1100 628 1101 fn style_dim(s: String) -> String { 629 1102 style.attributes(s, [style.Dim]) 630 - } 631 - 632 - fn style_cyan(s: String) -> String { 633 - style.with(s, style.Cyan) 634 - } 635 - 636 - fn style_green(s: String) -> String { 637 - style.with(s, style.Green) 638 - } 639 - 640 - fn style_yellow(s: String) -> String { 641 - style.with(s, style.Yellow) 642 - } 643 - 644 - fn style_red(s: String) -> String { 645 - style.with(s, style.Red) 646 - } 647 - 648 - fn style_magenta(s: String) -> String { 649 - style.with(s, style.Magenta) 650 1103 } 651 1104 652 1105 fn style_on_bg(s: String, bg: style.Color) -> String {