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
17 kB 662 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/int 8import gleam/list 9import gleam/order.{type Order, Eq, Gt, Lt} 10import gleam/string 11import procfs.{type Process, type Snapshot} 12 13pub type SortKey { 14 SortCpu 15 SortMem 16 SortPid 17 SortTime 18 SortCommand 19} 20 21pub type Mode { 22 Normal 23 Filter 24 Help 25 ConfirmKill 26} 27 28pub type ViewState { 29 ViewState( 30 selected: Int, 31 scroll: Int, 32 sort: SortKey, 33 reverse: Bool, 34 filter: String, 35 mode: Mode, 36 cpu_history: List(Float), 37 mem_history: List(Float), 38 cols: Int, 39 rows: Int, 40 ) 41} 42 43pub fn initial_view(cols: Int, rows: Int) -> ViewState { 44 ViewState( 45 selected: 0, 46 scroll: 0, 47 sort: SortCpu, 48 reverse: True, 49 filter: "", 50 mode: Normal, 51 cpu_history: [], 52 mem_history: [], 53 cols: cols, 54 rows: rows, 55 ) 56} 57 58pub fn push_history(view: ViewState, snap: Snapshot) -> ViewState { 59 let max = 60 60 let cpu = list.append(view.cpu_history, [snap.cpu_total_percent]) 61 let mem = list.append(view.mem_history, [snap.mem.used_percent]) 62 let cpu = case list.length(cpu) > max { 63 True -> list.drop(cpu, list.length(cpu) - max) 64 False -> cpu 65 } 66 let mem = case list.length(mem) > max { 67 True -> list.drop(mem, list.length(mem) - max) 68 False -> mem 69 } 70 ViewState(..view, cpu_history: cpu, mem_history: mem) 71} 72 73pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) { 74 let filtered = case view.filter { 75 "" -> snap.processes 76 f -> { 77 let f = string.lowercase(f) 78 list.filter(snap.processes, fn(p) { 79 string.contains(string.lowercase(p.command), f) 80 || string.contains(string.lowercase(p.user), f) 81 || string.contains(int.to_string(p.pid), f) 82 }) 83 } 84 } 85 let sorted = sort_processes(filtered, view.sort) 86 case view.reverse { 87 True -> list.reverse(sorted) 88 False -> sorted 89 } 90} 91 92fn sort_processes(procs: List(Process), key: SortKey) -> List(Process) { 93 list.sort(procs, fn(a, b) { 94 case key { 95 SortCpu -> float_ord(a.cpu_percent, b.cpu_percent) 96 SortMem -> float_ord(a.mem_percent, b.mem_percent) 97 SortPid -> int.compare(a.pid, b.pid) 98 SortTime -> int.compare(a.time_ticks, b.time_ticks) 99 SortCommand -> string.compare(a.command, b.command) 100 } 101 }) 102} 103 104fn float_ord(a: Float, b: Float) -> Order { 105 case a <. b { 106 True -> Lt 107 False -> 108 case a >. b { 109 True -> Gt 110 False -> Eq 111 } 112 } 113} 114 115/// Build a full frame as a list of etch commands. 116pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { 117 let cols = int.max(view.cols, 60) 118 let rows = int.max(view.rows, 16) 119 let procs = visible_processes(view, snap) 120 let proc_count = list.length(procs) 121 122 // Layout: header(1) + cpu block + mem(2) + blank + table header + rows + footer 123 let core_count = list.length(snap.cores) 124 // CPU section: 1 line total + ceil(cores/cols_per_row) lines, max ~4 lines 125 let cores_per_row = case cols >= 120 { 126 True -> 4 127 False -> 128 case cols >= 90 { 129 True -> 3 130 False -> 2 131 } 132 } 133 let core_rows = case core_count { 134 0 -> 0 135 n -> { 136 let r = { n + cores_per_row - 1 } / cores_per_row 137 int.min(r, 4) 138 } 139 } 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) 145 let scroll = clamp_scroll(view.scroll, view.selected, proc_count, available) 146 let selected = int.clamp(view.selected, 0, int.max(proc_count - 1, 0)) 147 148 let view = 149 ViewState( 150 ..view, 151 scroll: scroll, 152 selected: selected, 153 cols: cols, 154 rows: rows, 155 ) 156 157 let lines = 158 list.flatten([ 159 [draw_header(snap, view, cols)], 160 [draw_cpu_total(snap, view, cols)], 161 draw_cores(snap, cores_per_row, core_rows, cols), 162 [draw_mem(snap, cols), draw_swap(snap, cols)], 163 [draw_table_header(view, cols)], 164 draw_process_rows(procs, view, available, cols), 165 pad_to(available - visible_row_count(procs, view, available), cols), 166 ]) 167 168 let body = 169 list.index_map(lines, fn(line, i) { 170 [command.MoveTo(0, i), command.Print(line)] 171 }) 172 |> list.flatten 173 174 let footer_y = rows - 1 175 let footer = draw_footer(snap, view, proc_count, cols) 176 177 let overlay = case view.mode { 178 Help -> help_overlay(cols, rows) 179 ConfirmKill -> kill_overlay(procs, view, cols, rows) 180 Filter -> [] 181 Normal -> [] 182 } 183 184 list.flatten([ 185 [ 186 command.HideCursor, 187 command.MoveTo(0, 0), 188 command.Clear(terminal.All), 189 ], 190 body, 191 [ 192 command.MoveTo(0, footer_y), 193 command.Print(footer), 194 ], 195 overlay, 196 ]) 197} 198 199fn clamp_scroll(scroll: Int, selected: Int, count: Int, height: Int) -> Int { 200 let scroll = case selected < scroll { 201 True -> selected 202 False -> scroll 203 } 204 let scroll = case selected >= scroll + height { 205 True -> selected - height + 1 206 False -> scroll 207 } 208 int.clamp(scroll, 0, int.max(0, count - height)) 209} 210 211fn 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)) 218} 219 220fn pad_to(n: Int, cols: Int) -> List(String) { 221 case n <= 0 { 222 True -> [] 223 False -> list.repeat(string.repeat(" ", cols), n) 224 } 225} 226 227fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String { 228 let title = style_bold(style_cyan(" monitor ")) 229 let host = style_dim(snap.hostname) 230 let up = "up " <> format.format_uptime(snap.uptime_secs) 231 let load = "load " <> format.format_load(snap.load1, snap.load5, snap.load15) 232 let tasks = 233 int.to_string(snap.process_count) 234 <> " procs · " 235 <> int.to_string(snap.thread_count) 236 <> " thr" 237 let sort = 238 "sort:" 239 <> sort_label(view.sort) 240 <> case view.reverse { 241 True -> "" 242 False -> "" 243 } 244 let mid = host <> " " <> up <> " " <> load <> " " <> tasks 245 let right = sort 246 let left = title 247 // approx without ansi length — simple pad using raw segments 248 let raw_left = " monitor " 249 let raw_mid = snap.hostname <> " " <> up <> " " <> load <> " " <> tasks 250 let raw_right = "sort:" <> sort_label(view.sort) <> "" 251 let gap = 252 int.max( 253 1, 254 cols 255 - string.length(raw_left) 256 - string.length(raw_mid) 257 - string.length(raw_right) 258 - 2, 259 ) 260 left 261 <> style_dim(string.repeat("", gap / 2)) 262 <> " " 263 <> style_dim(mid) 264 <> " " 265 <> style_dim(string.repeat("", gap - gap / 2)) 266 <> " " 267 <> style_yellow(right) 268 <> reset() 269 |> fit_line(cols) 270} 271 272fn draw_cpu_total(snap: Snapshot, view: ViewState, cols: Int) -> String { 273 let label = style_bold(" CPU ") 274 let bar_w = int.clamp(cols / 3, 20, 40) 275 let bar = colored_meter(snap.cpu_total_percent, bar_w) 276 let pct = 277 style_for_band( 278 snap.cpu_total_percent, 279 format.percent(snap.cpu_total_percent), 280 ) 281 let spark_w = int.clamp(cols - bar_w - 18, 8, 40) 282 let spark = style_cyan(format.sparkline(view.cpu_history, spark_w)) 283 label 284 <> bar 285 <> " " 286 <> pct 287 <> " " 288 <> spark 289 <> reset() 290 |> fit_line(cols) 291} 292 293fn draw_cores( 294 snap: Snapshot, 295 per_row: Int, 296 max_rows: Int, 297 cols: Int, 298) -> List(String) { 299 let cores = list.take(snap.cores, max_rows * per_row) 300 let cell_w = int.max(12, { cols - 1 } / per_row) 301 list.sized_chunk(cores, per_row) 302 |> list.map(fn(row) { 303 row 304 |> list.map(fn(c) { 305 let name = string.replace(c.name, "cpu", "") 306 let label = style_dim(format.pad_left(name, 2) <> " ") 307 let bar_w = int.max(4, cell_w - 9) 308 let bar = colored_meter(c.percent, bar_w) 309 let pct = 310 style_for_band( 311 c.percent, 312 format.fit_right(format.percent_short(c.percent), 5), 313 ) 314 format.fit(label <> bar <> pct, cell_w) 315 }) 316 |> string.concat 317 |> fit_line(cols) 318 }) 319} 320 321fn draw_mem(snap: Snapshot, cols: Int) -> String { 322 let m = snap.mem 323 let label = style_bold(" MEM ") 324 let bar_w = int.clamp(cols / 3, 20, 40) 325 let bar = colored_meter(m.used_percent, bar_w) 326 let pct = style_for_band(m.used_percent, format.percent(m.used_percent)) 327 let detail = 328 style_dim( 329 format.human_kb(m.used_kb) 330 <> "/" 331 <> format.human_kb(m.total_kb) 332 <> " free " 333 <> format.human_kb(m.free_kb) 334 <> " cache " 335 <> format.human_kb(m.cached_kb), 336 ) 337 label 338 <> bar 339 <> " " 340 <> pct 341 <> " " 342 <> detail 343 <> reset() 344 |> fit_line(cols) 345} 346 347fn draw_swap(snap: Snapshot, cols: Int) -> String { 348 let m = snap.mem 349 let label = style_bold(" SWP ") 350 let bar_w = int.clamp(cols / 3, 20, 40) 351 let bar = colored_meter(m.swap_percent, bar_w) 352 let pct = style_for_band(m.swap_percent, format.percent(m.swap_percent)) 353 let detail = 354 style_dim( 355 format.human_kb(m.swap_used_kb) <> "/" <> format.human_kb(m.swap_total_kb), 356 ) 357 label 358 <> bar 359 <> " " 360 <> pct 361 <> " " 362 <> detail 363 <> reset() 364 |> fit_line(cols) 365} 366 367fn draw_table_header(view: ViewState, cols: Int) -> String { 368 let mark = fn(key: SortKey, label: String) { 369 case view.sort == key { 370 True -> style_bold(style_yellow(label)) 371 False -> style_bold(label) 372 } 373 } 374 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) 399} 400 401fn draw_process_rows( 402 procs: List(Process), 403 view: ViewState, 404 height: Int, 405 cols: Int, 406) -> List(String) { 407 procs 408 |> list.drop(view.scroll) 409 |> list.take(height) 410 |> list.index_map(fn(p, i) { 411 let idx = view.scroll + i 412 let selected = idx == view.selected 413 draw_process(p, selected, cols) 414 }) 415} 416 417fn draw_process(p: Process, selected: Bool, cols: Int) -> String { 418 let cmd_w = int.max(10, cols - 70) 419 let base = 420 format.fit_right(int.to_string(p.pid), 7) 421 <> " " 422 <> format.fit(p.user, 9) 423 <> " " 424 <> format.fit_right(int.to_string(p.priority), 3) 425 <> " " 426 <> format.fit_right(int.to_string(p.nice), 3) 427 <> " " 428 <> format.fit_right(format.human_kb(p.virt_kb), 7) 429 <> " " 430 <> format.fit_right(format.human_kb(p.res_kb), 7) 431 <> " " 432 <> state_color(p.state) 433 <> " " 434 <> style_for_band( 435 p.cpu_percent, 436 format.fit_right(format.percent(p.cpu_percent), 6), 437 ) 438 <> " " 439 <> style_for_band( 440 p.mem_percent, 441 format.fit_right(format.percent(p.mem_percent), 6), 442 ) 443 <> " " 444 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) 445 <> " " 446 <> format.fit(p.command, cmd_w) 447 448 case selected { 449 True -> 450 style_on_bg(strip_for_select(p, cmd_w), style.Rgb(69, 71, 90)) 451 <> reset() 452 |> fit_line(cols) 453 False -> base <> reset() |> fit_line(cols) 454 } 455} 456 457fn strip_for_select(p: Process, cmd_w: Int) -> String { 458 format.fit_right(int.to_string(p.pid), 7) 459 <> " " 460 <> format.fit(p.user, 9) 461 <> " " 462 <> format.fit_right(int.to_string(p.priority), 3) 463 <> " " 464 <> format.fit_right(int.to_string(p.nice), 3) 465 <> " " 466 <> format.fit_right(format.human_kb(p.virt_kb), 7) 467 <> " " 468 <> format.fit_right(format.human_kb(p.res_kb), 7) 469 <> " " 470 <> p.state 471 <> " " 472 <> format.fit_right(format.percent(p.cpu_percent), 6) 473 <> " " 474 <> format.fit_right(format.percent(p.mem_percent), 6) 475 <> " " 476 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) 477 <> " " 478 <> format.fit(p.command, cmd_w) 479} 480 481fn draw_footer( 482 snap: Snapshot, 483 view: ViewState, 484 visible: Int, 485 cols: Int, 486) -> String { 487 let text = case view.mode { 488 Filter -> " Filter: " <> view.filter <> "█ (Enter apply · Esc cancel)" 489 ConfirmKill -> " Confirm kill? [y/N]" 490 Help -> " Help · press any key to close" 491 Normal -> 492 " [q]uit [↑↓/uj] move [PgUp/Dn] [/]filter [c/m/p/t/n]sort [r]ev [k]ill [h]elp · " 493 <> int.to_string(visible) 494 <> "/" 495 <> int.to_string(snap.process_count) 496 <> " shown" 497 } 498 style_on_bg(format.fit(text, cols), style.Rgb(30, 30, 46)) 499 <> reset() 500 |> fit_line(cols) 501} 502 503fn help_overlay(cols: Int, rows: Int) -> List(Command) { 504 let w = int.min(56, cols - 4) 505 let h = 14 506 let x = int.max(0, { cols - w } / 2) 507 let y = int.max(1, { rows - h } / 2) 508 let lines = [ 509 "" <> string.repeat("", w - 2) <> "", 510 "" <> format.fit(" monitor — htop × btop in Gleam", w - 2) <> "", 511 "" <> format.fit("", w - 2) <> "", 512 "" <> 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) <> "", 515 "" <> 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) <> "", 518 "" <> format.fit(" Actions", w - 2) <> "", 519 "" <> format.fit(" k kill selected (SIGTERM)", w - 2) <> "", 520 "" <> format.fit(" q / Esc quit", w - 2) <> "", 521 "" <> format.fit(" Meters from btop · table from htop", w - 2) <> "", 522 "" <> string.repeat("", w - 2) <> "", 523 ] 524 list.index_map(lines, fn(line, i) { 525 [ 526 command.MoveTo(x, y + i), 527 command.SetStyle( 528 style.Style( 529 fg: style.BrightWhite, 530 bg: style.Rgb(24, 24, 37), 531 attributes: [style.Bold], 532 ), 533 ), 534 command.Print(format.fit(line, w)), 535 command.ResetStyle, 536 ] 537 }) 538 |> list.flatten 539} 540 541fn kill_overlay( 542 procs: List(Process), 543 view: ViewState, 544 cols: Int, 545 rows: Int, 546) -> List(Command) { 547 let msg = case list_at(procs, view.selected) { 548 Ok(p) -> 549 " Kill PID " 550 <> int.to_string(p.pid) 551 <> " (" 552 <> format.truncate(p.command, 30) 553 <> ")? [y/N] " 554 Error(_) -> " No process selected " 555 } 556 let w = int.min(string.length(msg) + 4, cols - 2) 557 let x = int.max(0, { cols - w } / 2) 558 let y = rows / 2 559 [ 560 command.MoveTo(x, y), 561 command.SetStyle( 562 style.Style(fg: style.Black, bg: style.BrightYellow, attributes: [ 563 style.Bold, 564 ]), 565 ), 566 command.Print(format.fit(msg, w)), 567 command.ResetStyle, 568 ] 569} 570 571fn list_at(items: List(a), index: Int) -> Result(a, Nil) { 572 case list.drop(items, index) { 573 [x, ..] -> Ok(x) 574 [] -> Error(Nil) 575 } 576} 577 578fn colored_meter(percent: Float, width: Int) -> String { 579 let bar = format.meter_fine(percent, width) 580 style_for_band(percent, bar) 581} 582 583fn style_for_band(percent: Float, s: String) -> String { 584 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) 589 } 590} 591 592fn state_color(s: String) -> String { 593 case s { 594 "R" -> style_green("R") 595 "S" -> style_dim("S") 596 "D" -> style_red("D") 597 "Z" -> style_red("Z") 598 "T" | "t" -> style_yellow("T") 599 _ -> s 600 } 601} 602 603fn sort_label(k: SortKey) -> String { 604 case k { 605 SortCpu -> "CPU" 606 SortMem -> "MEM" 607 SortPid -> "PID" 608 SortTime -> "TIME" 609 SortCommand -> "CMD" 610 } 611} 612 613fn fit_line(s: String, _cols: Int) -> String { 614 // ANSI length ≠ display length; leave padding to the terminal clear. 615 s <> reset() 616} 617 618// ─── ANSI helpers via etch/style ─────────────────────────────────────────── 619 620fn reset() -> String { 621 style.reset_style("") 622} 623 624fn style_bold(s: String) -> String { 625 style.attributes(s, [style.Bold]) 626} 627 628fn style_dim(s: String) -> String { 629 style.attributes(s, [style.Dim]) 630} 631 632fn style_cyan(s: String) -> String { 633 style.with(s, style.Cyan) 634} 635 636fn style_green(s: String) -> String { 637 style.with(s, style.Green) 638} 639 640fn style_yellow(s: String) -> String { 641 style.with(s, style.Yellow) 642} 643 644fn style_red(s: String) -> String { 645 style.with(s, style.Red) 646} 647 648fn style_magenta(s: String) -> String { 649 style.with(s, style.Magenta) 650} 651 652fn style_on_bg(s: String, bg: style.Color) -> String { 653 style.on(s, bg) 654} 655 656pub fn selected_process( 657 view: ViewState, 658 snap: Snapshot, 659) -> Result(Process, Nil) { 660 let procs = visible_processes(view, snap) 661 list_at(procs, view.selected) 662}