Terminal system monitor in Gleam — htop × btop
1

Configure Feed

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

monitor / src / ui.gleam
30 kB 1115 lines
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}