Terminal system monitor in Gleam — htop × btop
1//// Terminal rendering: btop-style meters + htop-style process table.
2
3import etch/command.{type Command}
4import etch/style
5import etch/terminal
6import format
7import gleam/dict
8import gleam/float
9import gleam/int
10import gleam/list
11import gleam/order.{type Order, Eq, Gt, Lt}
12import gleam/set
13import gleam/string
14import procfs.{type Process, type Snapshot}
15
16pub type SortKey {
17 SortCpu
18 SortMem
19 SortPid
20 SortTime
21 SortCommand
22}
23
24pub type Mode {
25 Normal
26 Filter
27 Help
28 ConfirmKill
29}
30
31/// One process table row; `depth` is tree indentation when grouping by PPID.
32pub type ProcessRow {
33 ProcessRow(process: Process, depth: Int)
34}
35
36pub type ViewState {
37 ViewState(
38 selected: Int,
39 scroll: Int,
40 sort: SortKey,
41 reverse: Bool,
42 /// Group processes under their parent (PPID tree).
43 tree: Bool,
44 filter: String,
45 mode: Mode,
46 cpu_history: List(Float),
47 mem_history: List(Float),
48 cols: Int,
49 rows: Int,
50 )
51}
52
53pub fn initial_view(cols: Int, rows: Int) -> ViewState {
54 ViewState(
55 selected: 0,
56 scroll: 0,
57 sort: SortCpu,
58 reverse: True,
59 tree: False,
60 filter: "",
61 mode: Normal,
62 cpu_history: [],
63 mem_history: [],
64 cols: cols,
65 rows: rows,
66 )
67}
68
69pub fn push_history(view: ViewState, snap: Snapshot) -> ViewState {
70 let max = 60
71 let cpu = list.append(view.cpu_history, [snap.cpu_total_percent])
72 let mem = list.append(view.mem_history, [snap.mem.used_percent])
73 let cpu = case list.length(cpu) > max {
74 True -> list.drop(cpu, list.length(cpu) - max)
75 False -> cpu
76 }
77 let mem = case list.length(mem) > max {
78 True -> list.drop(mem, list.length(mem) - max)
79 False -> mem
80 }
81 ViewState(..view, cpu_history: cpu, mem_history: mem)
82}
83
84pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) {
85 visible_rows(view, snap)
86 |> list.map(fn(row) { row.process })
87}
88
89pub fn visible_rows(view: ViewState, snap: Snapshot) -> List(ProcessRow) {
90 let filtered = case view.filter {
91 "" -> snap.processes
92 f -> {
93 let f = string.lowercase(f)
94 list.filter(snap.processes, fn(p) {
95 string.contains(string.lowercase(p.command), f)
96 || string.contains(string.lowercase(p.user), f)
97 || string.contains(int.to_string(p.pid), f)
98 || string.contains(int.to_string(p.ppid), f)
99 })
100 }
101 }
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).
117fn 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.
185fn 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
192fn 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).
215fn 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
227fn 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
253fn 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
275fn 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
298fn invert_order(o: Order) -> Order {
299 case o {
300 Lt -> Gt
301 Eq -> Eq
302 Gt -> Lt
303 }
304}
305
306fn 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
316fn 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
341fn 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 {
348 True -> list.reverse(sorted)
349 False -> sorted
350 }
351}
352
353fn sort_processes(procs: List(Process), key: SortKey) -> List(Process) {
354 list.sort(procs, fn(a, b) {
355 case key {
356 SortCpu -> float_ord(a.cpu_percent, b.cpu_percent)
357 SortMem -> float_ord(a.mem_percent, b.mem_percent)
358 SortPid -> int.compare(a.pid, b.pid)
359 SortTime -> int.compare(a.time_ticks, b.time_ticks)
360 SortCommand -> string.compare(a.command, b.command)
361 }
362 })
363}
364
365fn float_ord(a: Float, b: Float) -> Order {
366 case a <. b {
367 True -> Lt
368 False ->
369 case a >. b {
370 True -> Gt
371 False -> Eq
372 }
373 }
374}
375
376/// Screen geometry for the process table (0-based row indices).
377pub 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).
389pub fn layout(snap: Snapshot, view: ViewState) -> Layout {
390 let cols = int.max(view.cols, 60)
391 let rows = int.max(view.rows, 16)
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}
409
410fn core_row_count(snap: Snapshot, cols: Int) -> Int {
411 let core_count = list.length(snap.cores)
412 let cores_per_row = case cols >= 120 {
413 True -> 4
414 False ->
415 case cols >= 90 {
416 True -> 3
417 False -> 2
418 }
419 }
420 case core_count {
421 0 -> 0
422 n -> {
423 let r = { n + cores_per_row - 1 } / cores_per_row
424 int.min(r, 4)
425 }
426 }
427}
428
429fn 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`.
442pub 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.
456pub 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.
476pub 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
485 let scroll = clamp_scroll(view.scroll, view.selected, proc_count, available)
486 let selected = int.clamp(view.selected, 0, int.max(proc_count - 1, 0))
487
488 let view =
489 ViewState(
490 ..view,
491 scroll: scroll,
492 selected: selected,
493 cols: cols,
494 rows: rows,
495 )
496
497 let lines =
498 list.flatten([
499 [draw_header(snap, view, cols)],
500 [draw_cpu_total(snap, view, cols)],
501 draw_cores(snap, per_row, core_rows, cols),
502 [draw_mem(snap, cols), draw_swap(snap, cols)],
503 [draw_table_header(view, cols)],
504 draw_process_rows(rows_list, view, available, cols),
505 pad_to(available - visible_row_count(proc_count, view, available), cols),
506 ])
507
508 let body =
509 list.index_map(lines, fn(line, i) {
510 [command.MoveTo(0, i), command.Print(line)]
511 })
512 |> list.flatten
513
514 let footer_y = rows - 1
515 let footer = draw_footer(snap, view, proc_count, cols)
516
517 let overlay = case view.mode {
518 Help -> help_overlay(cols, rows)
519 ConfirmKill -> kill_overlay(rows_list, view, cols, rows)
520 Filter -> []
521 Normal -> []
522 }
523
524 list.flatten([
525 [
526 command.HideCursor,
527 command.MoveTo(0, 0),
528 command.Clear(terminal.All),
529 ],
530 body,
531 [
532 command.MoveTo(0, footer_y),
533 command.Print(footer),
534 ],
535 overlay,
536 ])
537}
538
539fn clamp_scroll(scroll: Int, selected: Int, count: Int, height: Int) -> Int {
540 let scroll = case selected < scroll {
541 True -> selected
542 False -> scroll
543 }
544 let scroll = case selected >= scroll + height {
545 True -> selected - height + 1
546 False -> scroll
547 }
548 int.clamp(scroll, 0, int.max(0, count - height))
549}
550
551fn visible_row_count(proc_count: Int, view: ViewState, height: Int) -> Int {
552 int.min(height, int.max(0, proc_count - view.scroll))
553}
554
555fn pad_to(n: Int, cols: Int) -> List(String) {
556 case n <= 0 {
557 True -> []
558 False -> list.repeat(string.repeat(" ", cols), n)
559 }
560}
561
562fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String {
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 )
572 let host = style_dim(snap.hostname)
573 let up = "up " <> format.format_uptime(snap.uptime_secs)
574 let load = "load " <> format.format_load(snap.load1, snap.load5, snap.load15)
575 let tasks =
576 int.to_string(snap.process_count)
577 <> " procs · "
578 <> int.to_string(snap.thread_count)
579 <> " thr"
580 let sort =
581 "sort:"
582 <> sort_label(view.sort)
583 <> case view.reverse {
584 True -> "↓"
585 False -> "↑"
586 }
587 <> case view.tree {
588 True -> " tree"
589 False -> ""
590 }
591 let mid = host <> " " <> up <> " " <> load <> " " <> tasks
592 let left = title
593 // approx without ansi length — simple pad using raw segments
594 let raw_left = " monitor "
595 let raw_mid = snap.hostname <> " " <> up <> " " <> load <> " " <> tasks
596 let raw_right =
597 "sort:"
598 <> sort_label(view.sort)
599 <> "↓"
600 <> case view.tree {
601 True -> " tree"
602 False -> ""
603 }
604 let gap =
605 int.max(
606 1,
607 cols
608 - string.length(raw_left)
609 - string.length(raw_mid)
610 - string.length(raw_right)
611 - 2,
612 )
613 left
614 <> style_dim(string.repeat("─", gap / 2))
615 <> " "
616 <> style_dim(mid)
617 <> " "
618 <> style_dim(string.repeat("─", gap - gap / 2))
619 <> " "
620 <> style.with(sort, style.Rgb(186, 168, 112))
621 <> reset()
622 |> fit_line(cols)
623}
624
625fn draw_cpu_total(snap: Snapshot, view: ViewState, cols: Int) -> String {
626 let label = style_bold(" CPU ")
627 let bar_w = int.clamp(cols / 3, 20, 40)
628 let bar = colored_meter(snap.cpu_total_percent, bar_w)
629 let pct =
630 style_for_band(
631 snap.cpu_total_percent,
632 format.percent(snap.cpu_total_percent),
633 )
634 let spark_w = int.clamp(cols - bar_w - 18, 8, 40)
635 let spark =
636 style.with(
637 format.sparkline(view.cpu_history, spark_w),
638 style.Rgb(108, 140, 168),
639 )
640 label
641 <> bar
642 <> " "
643 <> pct
644 <> " "
645 <> spark
646 <> reset()
647 |> fit_line(cols)
648}
649
650fn draw_cores(
651 snap: Snapshot,
652 per_row: Int,
653 max_rows: Int,
654 cols: Int,
655) -> List(String) {
656 let cores = list.take(snap.cores, max_rows * per_row)
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)
664 list.sized_chunk(cores, per_row)
665 |> list.map(fn(row) {
666 row
667 |> list.map(fn(c) {
668 let name = string.replace(c.name, "cpu", "")
669 let label = style_dim(format.pad_left(name, 2) <> " ")
670 let bar = colored_meter(c.percent, bar_w)
671 let pct =
672 style_for_band(
673 c.percent,
674 format.fit_right(format.percent_short(c.percent), pct_w),
675 )
676 label <> bar <> pct <> string.repeat(" ", pad_w) <> reset()
677 })
678 |> string.concat
679 |> fit_line(cols)
680 })
681}
682
683fn draw_mem(snap: Snapshot, cols: Int) -> String {
684 let m = snap.mem
685 let label = style_bold(" MEM ")
686 let bar_w = int.clamp(cols / 3, 20, 40)
687 let bar = colored_meter(m.used_percent, bar_w)
688 let pct = style_for_band(m.used_percent, format.percent(m.used_percent))
689 let detail =
690 style_dim(
691 format.human_kb(m.used_kb)
692 <> "/"
693 <> format.human_kb(m.total_kb)
694 <> " free "
695 <> format.human_kb(m.free_kb)
696 <> " cache "
697 <> format.human_kb(m.cached_kb),
698 )
699 label
700 <> bar
701 <> " "
702 <> pct
703 <> " "
704 <> detail
705 <> reset()
706 |> fit_line(cols)
707}
708
709fn draw_swap(snap: Snapshot, cols: Int) -> String {
710 let m = snap.mem
711 let label = style_bold(" SWP ")
712 let bar_w = int.clamp(cols / 3, 20, 40)
713 let bar = colored_meter(m.swap_percent, bar_w)
714 let pct = style_for_band(m.swap_percent, format.percent(m.swap_percent))
715 let detail =
716 style_dim(
717 format.human_kb(m.swap_used_kb) <> "/" <> format.human_kb(m.swap_total_kb),
718 )
719 label
720 <> bar
721 <> " "
722 <> pct
723 <> " "
724 <> detail
725 <> reset()
726 |> fit_line(cols)
727}
728
729fn draw_table_header(view: ViewState, cols: Int) -> 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) {
754 case view.sort == key {
755 True -> text <> arrow
756 False -> text
757 }
758 }
759 let line =
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()
800}
801
802fn draw_process_rows(
803 rows: List(ProcessRow),
804 view: ViewState,
805 height: Int,
806 cols: Int,
807) -> List(String) {
808 rows
809 |> list.drop(view.scroll)
810 |> list.take(height)
811 |> list.index_map(fn(row, i) {
812 let idx = view.scroll + i
813 let selected = idx == view.selected
814 draw_process(row.process, row.depth, selected, cols)
815 })
816}
817
818fn tree_prefix(depth: Int) -> String {
819 case depth {
820 0 -> ""
821 d -> string.repeat("│ ", d - 1) <> "├─"
822 }
823}
824
825fn draw_process(p: Process, depth: Int, selected: Bool, cols: Int) -> String {
826 let cmd_w = int.max(10, cols - 70)
827 let cmd = format.fit(tree_prefix(depth) <> p.command, cmd_w)
828 let base =
829 format.fit_right(int.to_string(p.pid), 7)
830 <> " "
831 <> format.fit(p.user, 9)
832 <> " "
833 <> format.fit_right(int.to_string(p.priority), 3)
834 <> " "
835 <> format.fit_right(int.to_string(p.nice), 3)
836 <> " "
837 <> format.fit_right(format.human_kb(p.virt_kb), 7)
838 <> " "
839 <> format.fit_right(format.human_kb(p.res_kb), 7)
840 <> " "
841 <> state_color(p.state)
842 <> " "
843 <> style_for_band(
844 p.cpu_percent,
845 format.fit_right(format.percent(p.cpu_percent), 6),
846 )
847 <> " "
848 <> style_for_band(
849 p.mem_percent,
850 format.fit_right(format.percent(p.mem_percent), 6),
851 )
852 <> " "
853 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9)
854 <> " "
855 <> cmd
856
857 case selected {
858 True ->
859 style_on_bg(strip_for_select(p, depth, cmd_w), style.Rgb(69, 71, 90))
860 <> reset()
861 |> fit_line(cols)
862 False -> base <> reset() |> fit_line(cols)
863 }
864}
865
866fn strip_for_select(p: Process, depth: Int, cmd_w: Int) -> String {
867 format.fit_right(int.to_string(p.pid), 7)
868 <> " "
869 <> format.fit(p.user, 9)
870 <> " "
871 <> format.fit_right(int.to_string(p.priority), 3)
872 <> " "
873 <> format.fit_right(int.to_string(p.nice), 3)
874 <> " "
875 <> format.fit_right(format.human_kb(p.virt_kb), 7)
876 <> " "
877 <> format.fit_right(format.human_kb(p.res_kb), 7)
878 <> " "
879 <> p.state
880 <> " "
881 <> format.fit_right(format.percent(p.cpu_percent), 6)
882 <> " "
883 <> format.fit_right(format.percent(p.mem_percent), 6)
884 <> " "
885 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9)
886 <> " "
887 <> format.fit(tree_prefix(depth) <> p.command, cmd_w)
888}
889
890fn draw_footer(
891 snap: Snapshot,
892 view: ViewState,
893 visible: Int,
894 cols: Int,
895) -> String {
896 let text = case view.mode {
897 Filter -> " Filter: " <> view.filter <> "█ (Enter apply · Esc cancel)"
898 ConfirmKill -> " Confirm kill? [y/N]"
899 Help -> " Help · press any key to close"
900 Normal ->
901 " [q]uit [↑↓/uj·wheel] move [T]ree [click]select [header]sort [R-click]kill [/]filter [h]elp · "
902 <> int.to_string(visible)
903 <> "/"
904 <> int.to_string(snap.process_count)
905 <> " shown"
906 }
907 style_on_bg(format.fit(text, cols), style.Rgb(30, 30, 46))
908 <> reset()
909 |> fit_line(cols)
910}
911
912fn help_overlay(cols: Int, rows: Int) -> List(Command) {
913 let w = int.min(56, cols - 4)
914 let h = 16
915 let x = int.max(0, { cols - w } / 2)
916 let y = int.max(1, { rows - h } / 2)
917 let lines = [
918 "┌" <> string.repeat("─", w - 2) <> "┐",
919 "│" <> format.fit(" monitor — htop × btop in Gleam", w - 2) <> "│",
920 "│" <> format.fit("", w - 2) <> "│",
921 "│" <> format.fit(" Navigation", 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) <> "│",
924 "│" <> format.fit(" Sorting & 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) <> "│",
927 "│" <> format.fit(" Actions", 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) <> "│",
931 "│" <> format.fit(" Meters from btop · table from htop", w - 2) <> "│",
932 "└" <> string.repeat("─", w - 2) <> "┘",
933 ]
934 list.index_map(lines, fn(line, i) {
935 [
936 command.MoveTo(x, y + i),
937 command.SetStyle(
938 style.Style(
939 fg: style.BrightWhite,
940 bg: style.Rgb(24, 24, 37),
941 attributes: [style.Bold],
942 ),
943 ),
944 command.Print(format.fit(line, w)),
945 command.ResetStyle,
946 ]
947 })
948 |> list.flatten
949}
950
951fn kill_overlay(
952 rows: List(ProcessRow),
953 view: ViewState,
954 cols: Int,
955 rows_n: Int,
956) -> List(Command) {
957 let msg = case list_at(rows, view.selected) {
958 Ok(ProcessRow(process: p, depth: _)) ->
959 " Kill PID "
960 <> int.to_string(p.pid)
961 <> " ("
962 <> format.truncate(p.command, 30)
963 <> ")? [y/N] "
964 Error(_) -> " No process selected "
965 }
966 let w = int.min(string.length(msg) + 4, cols - 2)
967 let x = int.max(0, { cols - w } / 2)
968 let y = rows_n / 2
969 [
970 command.MoveTo(x, y),
971 command.SetStyle(
972 style.Style(
973 fg: style.Rgb(24, 24, 37),
974 bg: style.Rgb(186, 168, 112),
975 attributes: [style.Bold],
976 ),
977 ),
978 command.Print(format.fit(msg, w)),
979 command.ResetStyle,
980 ]
981}
982
983fn list_at(items: List(a), index: Int) -> Result(a, Nil) {
984 case list.drop(items, index) {
985 [x, ..] -> Ok(x)
986 [] -> Error(Nil)
987 }
988}
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.
992fn colored_meter(percent: Float, width: Int) -> String {
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
1025}
1026
1027/// Left-block partials for sub-cell resolution.
1028fn 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).
1042fn 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.
1056fn style_for_band(percent: Float, s: String) -> String {
1057 case format.band(percent) {
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))
1062 }
1063}
1064
1065fn state_color(s: String) -> String {
1066 case s {
1067 "R" -> style.with("R", style.Rgb(138, 176, 148))
1068 "S" -> style_dim("S")
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))
1072 _ -> s
1073 }
1074}
1075
1076fn sort_label(k: SortKey) -> String {
1077 case k {
1078 SortCpu -> "CPU"
1079 SortMem -> "MEM"
1080 SortPid -> "PID"
1081 SortTime -> "TIME"
1082 SortCommand -> "CMD"
1083 }
1084}
1085
1086fn fit_line(s: String, _cols: Int) -> String {
1087 // ANSI length ≠ display length; leave padding to the terminal clear.
1088 s <> reset()
1089}
1090
1091// ─── ANSI helpers via etch/style ───────────────────────────────────────────
1092
1093fn reset() -> String {
1094 style.reset_style("")
1095}
1096
1097fn style_bold(s: String) -> String {
1098 style.attributes(s, [style.Bold])
1099}
1100
1101fn style_dim(s: String) -> String {
1102 style.attributes(s, [style.Dim])
1103}
1104
1105fn style_on_bg(s: String, bg: style.Color) -> String {
1106 style.on(s, bg)
1107}
1108
1109pub fn selected_process(
1110 view: ViewState,
1111 snap: Snapshot,
1112) -> Result(Process, Nil) {
1113 let procs = visible_processes(view, snap)
1114 list_at(procs, view.selected)
1115}