//// Terminal rendering: btop-style meters + htop-style process table. import etch/command.{type Command} import etch/style import etch/terminal import format import gleam/int import gleam/list import gleam/order.{type Order, Eq, Gt, Lt} import gleam/string import procfs.{type Process, type Snapshot} pub type SortKey { SortCpu SortMem SortPid SortTime SortCommand } pub type Mode { Normal Filter Help ConfirmKill } pub type ViewState { ViewState( selected: Int, scroll: Int, sort: SortKey, reverse: Bool, filter: String, mode: Mode, cpu_history: List(Float), mem_history: List(Float), cols: Int, rows: Int, ) } pub fn initial_view(cols: Int, rows: Int) -> ViewState { ViewState( selected: 0, scroll: 0, sort: SortCpu, reverse: True, filter: "", mode: Normal, cpu_history: [], mem_history: [], cols: cols, rows: rows, ) } pub fn push_history(view: ViewState, snap: Snapshot) -> ViewState { let max = 60 let cpu = list.append(view.cpu_history, [snap.cpu_total_percent]) let mem = list.append(view.mem_history, [snap.mem.used_percent]) let cpu = case list.length(cpu) > max { True -> list.drop(cpu, list.length(cpu) - max) False -> cpu } let mem = case list.length(mem) > max { True -> list.drop(mem, list.length(mem) - max) False -> mem } ViewState(..view, cpu_history: cpu, mem_history: mem) } pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) { let filtered = case view.filter { "" -> snap.processes f -> { let f = string.lowercase(f) list.filter(snap.processes, fn(p) { string.contains(string.lowercase(p.command), f) || string.contains(string.lowercase(p.user), f) || string.contains(int.to_string(p.pid), f) }) } } let sorted = sort_processes(filtered, view.sort) case view.reverse { True -> list.reverse(sorted) False -> sorted } } fn sort_processes(procs: List(Process), key: SortKey) -> List(Process) { list.sort(procs, fn(a, b) { case key { SortCpu -> float_ord(a.cpu_percent, b.cpu_percent) SortMem -> float_ord(a.mem_percent, b.mem_percent) SortPid -> int.compare(a.pid, b.pid) SortTime -> int.compare(a.time_ticks, b.time_ticks) SortCommand -> string.compare(a.command, b.command) } }) } fn float_ord(a: Float, b: Float) -> Order { case a <. b { True -> Lt False -> case a >. b { True -> Gt False -> Eq } } } /// Build a full frame as a list of etch commands. pub fn render(snap: Snapshot, view: ViewState) -> List(Command) { let cols = int.max(view.cols, 60) let rows = int.max(view.rows, 16) let procs = visible_processes(view, snap) let proc_count = list.length(procs) // Layout: header(1) + cpu block + mem(2) + blank + table header + rows + footer let core_count = list.length(snap.cores) // CPU section: 1 line total + ceil(cores/cols_per_row) lines, max ~4 lines let cores_per_row = case cols >= 120 { True -> 4 False -> case cols >= 90 { True -> 3 False -> 2 } } let core_rows = case core_count { 0 -> 0 n -> { let r = { n + cores_per_row - 1 } / cores_per_row int.min(r, 4) } } let header_lines = 1 + 1 + core_rows + 2 + 1 // header, cpu total, core rows, mem+swap, blank before table let footer_lines = 1 let table_header = 1 let available = int.max(rows - header_lines - footer_lines - table_header, 3) let scroll = clamp_scroll(view.scroll, view.selected, proc_count, available) let selected = int.clamp(view.selected, 0, int.max(proc_count - 1, 0)) let view = ViewState( ..view, scroll: scroll, selected: selected, cols: cols, rows: rows, ) let lines = list.flatten([ [draw_header(snap, view, cols)], [draw_cpu_total(snap, view, cols)], draw_cores(snap, cores_per_row, core_rows, cols), [draw_mem(snap, cols), draw_swap(snap, cols)], [draw_table_header(view, cols)], draw_process_rows(procs, view, available, cols), pad_to(available - visible_row_count(procs, view, available), cols), ]) let body = list.index_map(lines, fn(line, i) { [command.MoveTo(0, i), command.Print(line)] }) |> list.flatten let footer_y = rows - 1 let footer = draw_footer(snap, view, proc_count, cols) let overlay = case view.mode { Help -> help_overlay(cols, rows) ConfirmKill -> kill_overlay(procs, view, cols, rows) Filter -> [] Normal -> [] } list.flatten([ [ command.HideCursor, command.MoveTo(0, 0), command.Clear(terminal.All), ], body, [ command.MoveTo(0, footer_y), command.Print(footer), ], overlay, ]) } fn clamp_scroll(scroll: Int, selected: Int, count: Int, height: Int) -> Int { let scroll = case selected < scroll { True -> selected False -> scroll } let scroll = case selected >= scroll + height { True -> selected - height + 1 False -> scroll } int.clamp(scroll, 0, int.max(0, count - height)) } fn visible_row_count( procs: List(Process), view: ViewState, height: Int, ) -> Int { let n = list.length(procs) int.min(height, int.max(0, n - view.scroll)) } fn pad_to(n: Int, cols: Int) -> List(String) { case n <= 0 { True -> [] False -> list.repeat(string.repeat(" ", cols), n) } } fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String { let title = style_bold(style_cyan(" monitor ")) let host = style_dim(snap.hostname) let up = "up " <> format.format_uptime(snap.uptime_secs) let load = "load " <> format.format_load(snap.load1, snap.load5, snap.load15) let tasks = int.to_string(snap.process_count) <> " procs · " <> int.to_string(snap.thread_count) <> " thr" let sort = "sort:" <> sort_label(view.sort) <> case view.reverse { True -> "↓" False -> "↑" } let mid = host <> " " <> up <> " " <> load <> " " <> tasks let right = sort let left = title // approx without ansi length — simple pad using raw segments let raw_left = " monitor " let raw_mid = snap.hostname <> " " <> up <> " " <> load <> " " <> tasks let raw_right = "sort:" <> sort_label(view.sort) <> "↓" let gap = int.max( 1, cols - string.length(raw_left) - string.length(raw_mid) - string.length(raw_right) - 2, ) left <> style_dim(string.repeat("─", gap / 2)) <> " " <> style_dim(mid) <> " " <> style_dim(string.repeat("─", gap - gap / 2)) <> " " <> style_yellow(right) <> reset() |> fit_line(cols) } fn draw_cpu_total(snap: Snapshot, view: ViewState, cols: Int) -> String { let label = style_bold(" CPU ") let bar_w = int.clamp(cols / 3, 20, 40) let bar = colored_meter(snap.cpu_total_percent, bar_w) let pct = style_for_band( snap.cpu_total_percent, format.percent(snap.cpu_total_percent), ) let spark_w = int.clamp(cols - bar_w - 18, 8, 40) let spark = style_cyan(format.sparkline(view.cpu_history, spark_w)) label <> bar <> " " <> pct <> " " <> spark <> reset() |> fit_line(cols) } fn draw_cores( snap: Snapshot, per_row: Int, max_rows: Int, cols: Int, ) -> List(String) { let cores = list.take(snap.cores, max_rows * per_row) let cell_w = int.max(12, { cols - 1 } / per_row) list.sized_chunk(cores, per_row) |> list.map(fn(row) { row |> list.map(fn(c) { let name = string.replace(c.name, "cpu", "") let label = style_dim(format.pad_left(name, 2) <> " ") let bar_w = int.max(4, cell_w - 9) let bar = colored_meter(c.percent, bar_w) let pct = style_for_band( c.percent, format.fit_right(format.percent_short(c.percent), 5), ) format.fit(label <> bar <> pct, cell_w) }) |> string.concat |> fit_line(cols) }) } fn draw_mem(snap: Snapshot, cols: Int) -> String { let m = snap.mem let label = style_bold(" MEM ") let bar_w = int.clamp(cols / 3, 20, 40) let bar = colored_meter(m.used_percent, bar_w) let pct = style_for_band(m.used_percent, format.percent(m.used_percent)) let detail = style_dim( format.human_kb(m.used_kb) <> "/" <> format.human_kb(m.total_kb) <> " free " <> format.human_kb(m.free_kb) <> " cache " <> format.human_kb(m.cached_kb), ) label <> bar <> " " <> pct <> " " <> detail <> reset() |> fit_line(cols) } fn draw_swap(snap: Snapshot, cols: Int) -> String { let m = snap.mem let label = style_bold(" SWP ") let bar_w = int.clamp(cols / 3, 20, 40) let bar = colored_meter(m.swap_percent, bar_w) let pct = style_for_band(m.swap_percent, format.percent(m.swap_percent)) let detail = style_dim( format.human_kb(m.swap_used_kb) <> "/" <> format.human_kb(m.swap_total_kb), ) label <> bar <> " " <> pct <> " " <> detail <> reset() |> fit_line(cols) } fn draw_table_header(view: ViewState, cols: Int) -> String { let mark = fn(key: SortKey, label: String) { case view.sort == key { True -> style_bold(style_yellow(label)) False -> style_bold(label) } } let line = mark(SortPid, format.fit_right("PID", 7)) <> " " <> format.fit("USER", 9) <> " " <> format.fit_right("PRI", 3) <> " " <> format.fit_right("NI", 3) <> " " <> format.fit_right("VIRT", 7) <> " " <> format.fit_right("RES", 7) <> " " <> "S" <> " " <> mark(SortCpu, format.fit_right("CPU%", 6)) <> " " <> mark(SortMem, format.fit_right("MEM%", 6)) <> " " <> mark(SortTime, format.fit_right("TIME+", 9)) <> " " <> mark(SortCommand, "Command") style_on_bg(line, style.Rgb(30, 30, 46)) <> reset() |> fit_line(cols) } fn draw_process_rows( procs: List(Process), view: ViewState, height: Int, cols: Int, ) -> List(String) { procs |> list.drop(view.scroll) |> list.take(height) |> list.index_map(fn(p, i) { let idx = view.scroll + i let selected = idx == view.selected draw_process(p, selected, cols) }) } fn draw_process(p: Process, selected: Bool, cols: Int) -> String { let cmd_w = int.max(10, cols - 70) let base = format.fit_right(int.to_string(p.pid), 7) <> " " <> format.fit(p.user, 9) <> " " <> format.fit_right(int.to_string(p.priority), 3) <> " " <> format.fit_right(int.to_string(p.nice), 3) <> " " <> format.fit_right(format.human_kb(p.virt_kb), 7) <> " " <> format.fit_right(format.human_kb(p.res_kb), 7) <> " " <> state_color(p.state) <> " " <> style_for_band( p.cpu_percent, format.fit_right(format.percent(p.cpu_percent), 6), ) <> " " <> style_for_band( p.mem_percent, format.fit_right(format.percent(p.mem_percent), 6), ) <> " " <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) <> " " <> format.fit(p.command, cmd_w) case selected { True -> style_on_bg(strip_for_select(p, cmd_w), style.Rgb(69, 71, 90)) <> reset() |> fit_line(cols) False -> base <> reset() |> fit_line(cols) } } fn strip_for_select(p: Process, cmd_w: Int) -> String { format.fit_right(int.to_string(p.pid), 7) <> " " <> format.fit(p.user, 9) <> " " <> format.fit_right(int.to_string(p.priority), 3) <> " " <> format.fit_right(int.to_string(p.nice), 3) <> " " <> format.fit_right(format.human_kb(p.virt_kb), 7) <> " " <> format.fit_right(format.human_kb(p.res_kb), 7) <> " " <> p.state <> " " <> format.fit_right(format.percent(p.cpu_percent), 6) <> " " <> format.fit_right(format.percent(p.mem_percent), 6) <> " " <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9) <> " " <> format.fit(p.command, cmd_w) } fn draw_footer( snap: Snapshot, view: ViewState, visible: Int, cols: Int, ) -> String { let text = case view.mode { Filter -> " Filter: " <> view.filter <> "█ (Enter apply · Esc cancel)" ConfirmKill -> " Confirm kill? [y/N]" Help -> " Help · press any key to close" Normal -> " [q]uit [↑↓/uj] move [PgUp/Dn] [/]filter [c/m/p/t/n]sort [r]ev [k]ill [h]elp · " <> int.to_string(visible) <> "/" <> int.to_string(snap.process_count) <> " shown" } style_on_bg(format.fit(text, cols), style.Rgb(30, 30, 46)) <> reset() |> fit_line(cols) } fn help_overlay(cols: Int, rows: Int) -> List(Command) { let w = int.min(56, cols - 4) let h = 14 let x = int.max(0, { cols - w } / 2) let y = int.max(1, { rows - h } / 2) let lines = [ "┌" <> string.repeat("─", w - 2) <> "┐", "│" <> format.fit(" monitor — htop × btop in Gleam", w - 2) <> "│", "│" <> format.fit("", w - 2) <> "│", "│" <> format.fit(" Navigation", w - 2) <> "│", "│" <> format.fit(" ↑/u ↓/j move selection", w - 2) <> "│", "│" <> format.fit(" PgUp/PgDn page · g/G top/end", w - 2) <> "│", "│" <> format.fit(" Sorting & filter", w - 2) <> "│", "│" <> format.fit(" c/m/p/t/n CPU MEM PID TIME name", w - 2) <> "│", "│" <> format.fit(" r reverse · / filter", w - 2) <> "│", "│" <> format.fit(" Actions", w - 2) <> "│", "│" <> format.fit(" k kill selected (SIGTERM)", w - 2) <> "│", "│" <> format.fit(" q / Esc quit", w - 2) <> "│", "│" <> format.fit(" Meters from btop · table from htop", w - 2) <> "│", "└" <> string.repeat("─", w - 2) <> "┘", ] list.index_map(lines, fn(line, i) { [ command.MoveTo(x, y + i), command.SetStyle( style.Style( fg: style.BrightWhite, bg: style.Rgb(24, 24, 37), attributes: [style.Bold], ), ), command.Print(format.fit(line, w)), command.ResetStyle, ] }) |> list.flatten } fn kill_overlay( procs: List(Process), view: ViewState, cols: Int, rows: Int, ) -> List(Command) { let msg = case list_at(procs, view.selected) { Ok(p) -> " Kill PID " <> int.to_string(p.pid) <> " (" <> format.truncate(p.command, 30) <> ")? [y/N] " Error(_) -> " No process selected " } let w = int.min(string.length(msg) + 4, cols - 2) let x = int.max(0, { cols - w } / 2) let y = rows / 2 [ command.MoveTo(x, y), command.SetStyle( style.Style(fg: style.Black, bg: style.BrightYellow, attributes: [ style.Bold, ]), ), command.Print(format.fit(msg, w)), command.ResetStyle, ] } fn list_at(items: List(a), index: Int) -> Result(a, Nil) { case list.drop(items, index) { [x, ..] -> Ok(x) [] -> Error(Nil) } } fn colored_meter(percent: Float, width: Int) -> String { let bar = format.meter_fine(percent, width) style_for_band(percent, bar) } fn style_for_band(percent: Float, s: String) -> String { case format.band(percent) { format.Low -> style_green(s) format.Mid -> style_yellow(s) format.High -> style_magenta(s) format.Critical -> style_red(s) } } fn state_color(s: String) -> String { case s { "R" -> style_green("R") "S" -> style_dim("S") "D" -> style_red("D") "Z" -> style_red("Z") "T" | "t" -> style_yellow("T") _ -> s } } fn sort_label(k: SortKey) -> String { case k { SortCpu -> "CPU" SortMem -> "MEM" SortPid -> "PID" SortTime -> "TIME" SortCommand -> "CMD" } } fn fit_line(s: String, _cols: Int) -> String { // ANSI length ≠ display length; leave padding to the terminal clear. s <> reset() } // ─── ANSI helpers via etch/style ─────────────────────────────────────────── fn reset() -> String { style.reset_style("") } fn style_bold(s: String) -> String { style.attributes(s, [style.Bold]) } fn style_dim(s: String) -> String { style.attributes(s, [style.Dim]) } fn style_cyan(s: String) -> String { style.with(s, style.Cyan) } fn style_green(s: String) -> String { style.with(s, style.Green) } fn style_yellow(s: String) -> String { style.with(s, style.Yellow) } fn style_red(s: String) -> String { style.with(s, style.Red) } fn style_magenta(s: String) -> String { style.with(s, style.Magenta) } fn style_on_bg(s: String, bg: style.Color) -> String { style.on(s, bg) } pub fn selected_process( view: ViewState, snap: Snapshot, ) -> Result(Process, Nil) { let procs = visible_processes(view, snap) list_at(procs, view.selected) }