Something went wrong. Try again.
htop for your network: a real-time IP traffic monitor for the terminal, ported to Zig from iptop
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102// SPDX-FileCopyrightText: © 2026 Francesc Muñoz// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The screen: what is drawn where, and what the keys and the mouse do.//!//! Everything is drawn afresh on every frame from the samples in the//! `Store`, with the store's lock held, so the frame can point straight into//! the samples. Text made up for the frame -- a formatted number, a padded//! name -- comes from a frame arena that is reset after the frame has been//! rendered, since a cell keeps a pointer to its text rather than a copy.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;const vaxis = @import("vaxis");const datetime = @import("datetime");
const braille = @import("braille.zig");const format = @import("format.zig");const model = @import("model.zig");const sampler = @import("sampler.zig");const services = @import("services.zig");const theme = @import("theme.zig");const tint = @import("tint.zig");const addr = @import("addr.zig");const world = @import("worldmap_data.zig");
const Rgb = theme.Rgb;
pub const Style = struct { fg: ?Rgb = null, bg: ?Rgb = null, bold: bool = false,};
fn color(c: Rgb) vaxis.Color { return .{ .rgb = .{ c.r, c.g, c.b } };}
/// A rectangle of the screen to draw in, with the background its cells get/// unless told otherwise. Drawing outside it is clipped.const Painter = struct { win: vaxis.Window, arena: Allocator, bg: Rgb,
fn width(self: Painter) u16 { return self.win.width; }
fn height(self: Painter) u16 { return self.win.height; }
fn style(self: Painter, s: Style) vaxis.Style { return .{ .fg = if (s.fg) |c| color(c) else .default, .bg = color(s.bg orelse self.bg), .bold = s.bold }; }
fn sub(self: Painter, x: u16, y: u16, w: u16, h: u16) Painter { return .{ .win = self.win.child(.{ .x_off = @intCast(x), .y_off = @intCast(y), .width = w, .height = h }), .arena = self.arena, .bg = self.bg, }; }
fn clear(self: Painter) void { self.win.fill(.{ .style = self.style(.{}) }); }
/// Writes `text` at (`col`, `row`) and returns the column after it. fn text(self: Painter, col: u16, row: u16, s: []const u8, st: Style) u16 { const result = self.win.print(&.{.{ .text = s, .style = self.style(st) }}, .{ .col_offset = col, .row_offset = row, .wrap = .none, }); return result.col; }
fn print(self: Painter, col: u16, row: u16, st: Style, comptime fmt: []const u8, args: anytype) u16 { const s = std.fmt.allocPrint(self.arena, fmt, args) catch return col; return self.text(col, row, s, st); }
/// `text` with its right-hand end at the right-hand edge. fn right(self: Painter, row: u16, s: []const u8, st: Style) void { const w = self.win.gwidth(s); _ = self.text(self.width() -| w, row, s, st); }
fn cell(self: Painter, col: u16, row: u16, g: []const u8, st: Style) void { self.win.writeCell(col, row, .{ .char = .{ .grapheme = g, .width = 1 }, .style = self.style(st) }); }
fn repeat(self: Painter, col: u16, row: u16, n: usize, g: []const u8, st: Style) u16 { var c = col; for (0..n) |_| { self.cell(c, row, g, st); c +|= 1; } return c; }
/// A rounded box filling this painter, with `title` set into its top /// edge, returning a painter for what is inside it, with `pad` columns /// left empty on each side. fn box(self: Painter, title: []const u8, border: Rgb, pad_left: u16, pad_right: u16) Painter { const w = self.width(); const h = self.height(); if (w < 2 or h < 2) return self.sub(0, 0, 0, 0); const st: Style = .{ .fg = border }; self.cell(0, 0, "╭", st); self.cell(w - 1, 0, "╮", st); self.cell(0, h - 1, "╰", st); self.cell(w - 1, h - 1, "╯", st); _ = self.repeat(1, 0, w - 2, "─", st); _ = self.repeat(1, h - 1, w - 2, "─", st); for (1..h - 1) |r| { self.cell(0, @intCast(r), "│", st); self.cell(w - 1, @intCast(r), "│", st); } if (title.len > 0) { const clip = self.sub(1, 0, w -| 2, 1); _ = clip.text(0, 0, title, .{ .fg = theme.current.text }); } const inner_w = (w - 2) -| (pad_left + pad_right); return self.sub(1 + pad_left, 1, inner_w, h - 2); }};
/// A gauge's scale that decays rather than snapping down, so that every bar/// does not rescale the moment the busiest row spikes or goes away. The decay/// is per second of wall time rather than per frame, since frames come at/// uneven intervals. A change of `key` -- what is being measured -- starts it/// afresh.const StickyMax = struct { value: f64 = 0, at: ?f64 = null, key: u64 = 0,
fn update(self: *StickyMax, current: f64, now: f64, decay_per_sec: f64, key: u64) f64 { if (key != self.key) { self.value = 0; self.key = key; } const dt = if (self.at) |at| now - at else 0; self.at = now; self.value = @max(current, self.value * std.math.pow(f64, decay_per_sec, dt)); return @max(self.value, 1); }};
/// A list that scrolls: the first row shown, kept in range as the list/// changes length.const Scroll = struct { top: usize = 0,
fn clamp(self: *Scroll, rows: usize, visible: usize) void { self.top = @min(self.top, rows -| visible); }
fn by(self: *Scroll, delta: isize) void { if (delta < 0) self.top -|= @intCast(-delta) else self.top +|= @intCast(delta); }
/// Scrolls just enough that `row` is in view. fn reveal(self: *Scroll, row: usize, visible: usize) void { if (visible == 0) return; if (row < self.top) self.top = row; if (row >= self.top + visible) self.top = row + 1 - visible; }};
const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0,
fn contains(self: Rect, col: i16, row: i16) bool { if (col < 0 or row < 0) return false; const c: u16 = @intCast(col); const r: u16 = @intCast(row); return c >= self.x and c < self.x + self.w and r >= self.y and r < self.y + self.h; }};
pub const Sort = enum { rate, total };
/// The panels that scroll, for routing the mouse wheel to the one under it.const Scrollable = enum { processes, connections, remotes, services, inspector };
pub const Mode = enum { dashboard, map };
const map_height = 14;/// Below this many rows the map is left out, whatever `m` says, so that the/// lists keep enough room to be useful.const map_min_screen = 32;
pub const App = struct { gpa: Allocator, io: Io, store: *sampler.Store, mode: Mode, zone: ?datetime.TimeZone, /// The settings file's directory, to save the theme into. config_dir: ?[]const u8,
sort: Sort = .total, selected_pid: ?u32 = null, inspected_pid: ?u32 = null, show_map: bool = true, quit: bool = false,
theme_open: bool = false, theme_before: usize = 0,
scrolls: std.EnumArray(Scrollable, Scroll) = .initFill(.{}), rects: std.EnumArray(Scrollable, Rect) = .initFill(.{}), /// What the selection moves through: the process list as last drawn. order: std.ArrayList(u32) = .empty, /// Set when the selection moves, so that the next frame scrolls it into /// view; scrolling with the mouse is otherwise free to leave it behind. reveal_selection: bool = false,
process_max: StickyMax = .{}, remote_max: StickyMax = .{}, service_max: StickyMax = .{}, inspector_max: StickyMax = .{},
pub fn deinit(self: *App) void { self.order.deinit(self.gpa); }
fn now(self: *App) f64 { const ns: f64 = @floatFromInt(Io.Clock.awake.now(self.io).nanoseconds); return ns / std.time.ns_per_s; }
// ---- input -------------------------------------------------------
pub fn handleKey(self: *App, key: vaxis.Key) void { if (key.matches('c', .{ .ctrl = true })) { self.quit = true; return; } if (self.mode == .map) { if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{})) self.quit = true; return; }
// The theme picker takes every key while it is open. if (self.theme_open) { const n = theme.themes.len; if (key.matchesAny(&.{ 'j', vaxis.Key.down }, .{})) { theme.apply((theme.index() + 1) % n); } else if (key.matchesAny(&.{ 'k', vaxis.Key.up }, .{})) { theme.apply((theme.index() + n - 1) % n); } else if (key.matchesAny(&.{ vaxis.Key.enter, 't' }, .{})) { self.theme_open = false; @import("config.zig").save(self.gpa, self.io, self.config_dir, "theme", theme.themes[theme.index()].name); } else if (key.matchesAny(&.{ vaxis.Key.escape, 'q' }, .{})) { theme.apply(self.theme_before); self.theme_open = false; } return; }
if (key.matches('t', .{})) { self.theme_before = theme.index(); self.theme_open = true; } else if (key.matches('q', .{})) { self.quit = true; } else if (key.matches(vaxis.Key.escape, .{})) { if (self.inspected_pid != null) self.inspected_pid = null else self.quit = true; } else if (key.matches('s', .{})) { self.sort = if (self.sort == .rate) .total else .rate; } else if (key.matches('m', .{})) { self.show_map = !self.show_map; } else if (key.matches('[', .{})) { const i = self.store.rate_index.load(.monotonic); self.store.rate_index.store(@min(i + 1, sampler.rates.len - 1), .monotonic); } else if (key.matches(']', .{})) { const i = self.store.rate_index.load(.monotonic); self.store.rate_index.store(i -| 1, .monotonic); } else if (key.matchesAny(&.{ 'j', vaxis.Key.down }, .{})) { self.moveSelection(1); } else if (key.matchesAny(&.{ 'k', vaxis.Key.up }, .{})) { self.moveSelection(-1); } else if (key.matches(vaxis.Key.enter, .{})) { if (self.selected_pid) |pid| { self.inspected_pid = pid; self.scrolls.set(.inspector, .{}); } } }
fn moveSelection(self: *App, delta: isize) void { const pids = self.order.items; if (pids.len == 0) return; const at: ?usize = if (self.selected_pid) |sel| std.mem.findScalar(u32, pids, sel) else null; const next: usize = if (at) |i| (if (delta > 0) @min(i + 1, pids.len - 1) else i -| 1) else 0; self.selected_pid = pids[next]; self.reveal_selection = true; }
pub fn handleMouse(self: *App, mouse: vaxis.Mouse) void { const delta: isize = switch (mouse.button) { .wheel_up => -1, .wheel_down => 1, else => return, }; var it = self.rects.iterator(); while (it.next()) |entry| { if (entry.value.contains(mouse.col, mouse.row)) { self.scrolls.getPtr(entry.key).by(delta); return; } } }
// ---- drawing -----------------------------------------------------
pub fn draw(self: *App, win: vaxis.Window, arena: Allocator) void { const p: Painter = .{ .win = win, .arena = arena, .bg = theme.current.bg }; p.clear(); self.rects = .initFill(.{}); switch (self.mode) { .dashboard => self.drawDashboard(p), .map => self.drawMapMode(p), } }
fn clock(self: *App, arena: Allocator) []const u8 { const real = Io.Clock.real.now(self.io).toSeconds(); if (self.zone) |z| { const local = z.atTimestamp(real); return std.fmt.allocPrint(arena, "{d:0>2}:{d:0>2}:{d:0>2}", .{ local.hour, local.minute, local.second }) catch ""; } // No zone to be had, so the clock is UTC and says so. const of_day: u64 = @intCast(@mod(real, std.time.s_per_day)); return std.fmt.allocPrint(arena, "{d:0>2}:{d:0>2}:{d:0>2} UTC", .{ of_day / 3600, of_day / 60 % 60, of_day % 60 }) catch ""; }
fn drawDashboard(self: *App, p: Painter) void { const store = self.store; const w = p.width(); const h = p.height(); if (w < 20 or h < 16) { _ = p.text(0, 0, "ziptop needs a bigger terminal", .{ .fg = theme.current.dim }); return; }
const system = store.system.value; const title = std.fmt.allocPrint(p.arena, " ziptop ─ {s} · ⇄ {s} · gw {s} · {s} ", .{ orDots(system.local_ip), orDots(store.public_ip), orDots(system.gateway), self.clock(p.arena), }) catch ""; const chart_width: u16 = @max(20, w -| 4); self.drawHero(p.sub(0, 0, w, 12), title, chart_width);
const middle_h = h - 13; const left_w = w / 2; const right_w = w - left_w; const half_inner: usize = @max(20, @as(usize, w / 2) -| 4);
// Left: processes over connections, half each. const processes = self.sortedProcesses(p.arena); const proc_h = middle_h / 2; self.drawProcesses(p.sub(0, 12, left_w, proc_h), processes, @max(10, half_inner -| 33), .{ .x = 0, .y = 12, .w = left_w, .h = proc_h }); self.drawConnections( p.sub(0, 12 + proc_h, left_w, middle_h - proc_h), @min(20, @max(12, half_inner -| 38)), .{ .x = 0, .y = 12 + proc_h, .w = left_w, .h = middle_h - proc_h }, );
// Right: the inspector, or the map, the hosts, the services and the // latency, the hosts taking whatever the others leave. const rx: u16 = left_w; if (self.inspected_pid) |pid| { self.drawInspector(p.sub(rx, 12, right_w, middle_h), pid, half_inner, .{ .x = rx, .y = 12, .w = right_w, .h = middle_h }); } else { var y: u16 = 12; var remaining = middle_h; if (self.show_map and h >= map_min_screen) { const mh = @min(map_height, remaining); drawWorldMap(p.sub(rx, y, right_w, mh), store.traffic.value.remotes, null); y += mh; remaining -= mh; } const service_list = services.aggregate(p.arena, store.connections.value, &store.traffic.value) catch &.{}; const service_h: u16 = blk: { const rows: u16 = @intCast(@min(@max(service_list.len, 1), 6)); break :blk (if (service_list.len == 0) 1 else rows + 1) + 2; }; // When there is not room for everything, the latency keeps its // height, the hosts keep a few rows, and the services give way, // being left out altogether before they are drawn as an empty box. const latency_h: u16 = @min(6, remaining); const min_remote_h: u16 = 3; var sh = @min(service_h, remaining -| (latency_h + min_remote_h)); if (sh < 3) sh = 0; const remote_h = remaining -| (sh + latency_h); self.drawRemotes(p.sub(rx, y, right_w, remote_h), @max(10, half_inner -| 20), .{ .x = rx, .y = y, .w = right_w, .h = remote_h }); y += remote_h; if (sh > 0) self.drawServices(p.sub(rx, y, right_w, sh), service_list, .{ .x = rx, .y = y, .w = right_w, .h = sh }); y += sh; self.drawLatency(p.sub(rx, y, right_w, latency_h), half_inner); }
_ = p.print(1, h - 1, .{ .fg = theme.current.dim }, "q quit · j/k select · ⏎ inspect · esc back · s sort · m map · t theme · [/] {d}ms · mouse scrolls", .{store.intervalMs()});
if (self.theme_open) drawThemeDialog(p); }
fn drawMapMode(self: *App, p: Painter) void { const store = self.store; const remotes = store.traffic.value.remotes; var located: usize = 0; for (remotes) |r| located += @intFromBool(r.lat != null); const title = std.fmt.allocPrint(p.arena, " ziptop ─ ▼ {f} · ▲ {f} · {d} destinations ", .{ format.Rate{ .n = store.throughput.value.rx_rate }, format.Rate{ .n = store.throughput.value.tx_rate }, located, }) catch ""; drawWorldMap(p.sub(0, 0, p.width(), p.height() -| 1), remotes, title); _ = p.text(1, p.height() -| 1, "q quit", .{ .fg = theme.current.dim }); }
fn sortedProcesses(self: *App, arena: Allocator) []const model.ProcessRate { const source = self.store.traffic.value.processes; const processes = arena.dupe(model.ProcessRate, source) catch return source; if (self.sort == .total) { std.mem.sort(model.ProcessRate, processes, {}, struct { fn lessThan(_: void, a: model.ProcessRate, b: model.ProcessRate) bool { return a.rx_total + a.tx_total > b.rx_total + b.tx_total; } }.lessThan); } self.order.clearRetainingCapacity(); for (processes) |pr| self.order.append(self.gpa, pr.pid) catch break; return processes; }
// ---- the hero: throughput -----------------------------------------
fn drawHero(self: *App, outer: Painter, title: []const u8, chart_width: u16) void { const t = &theme.current; const p = outer.box(title, t.border, 1, 1); const rx_history = self.store.rx_history.items(); const tx_history = self.store.tx_history.items(); const tp = self.store.throughput.value; const rx_head = t.download.mix(t.head_anchor, 0.8); const tx_head = t.upload.mix(t.head_anchor, 0.8);
statsRow(p, 0, "▼", tp.rx_rate, t.download, rx_history, tp.rx_total); peakMarker(p, 1, rx_history, chart_width, "▾", rx_head); brailleChart(p.sub(0, 2, p.width(), 3), rx_history, chart_width, 3, &t.download_gradient, false, null, null, null, rx_head); brailleChart(p.sub(0, 5, p.width(), 3), tx_history, chart_width, 3, &t.upload_gradient, true, null, "⠉", null, tx_head); peakMarker(p, 8, tx_history, chart_width, "▴", tx_head); statsRow(p, 9, "▲", tp.tx_rate, t.upload, tx_history, tp.tx_total); }
// ---- processes -----------------------------------------------------
fn drawProcesses(self: *App, outer: Painter, processes: []const model.ProcessRate, name_width: usize, rect: Rect) void { const t = &theme.current; const title = std.fmt.allocPrint(outer.arena, " PROCESSES · ▼ ▲ · by {s} ", .{if (self.sort == .rate) "rate" else "Σ total"}) catch ""; const p = outer.box(title, t.border, 1, 1); self.rects.set(.processes, rect);
const is_rate = self.sort == .rate; var busiest: f64 = 1; for (processes) |pr| busiest = @max(busiest, if (is_rate) pr.rx_rate + pr.tx_rate else pr.rx_total + pr.tx_total); const max = self.process_max.update(busiest, self.now(), 0.9, @backingInt(self.sort));
const scroll = self.scrolls.getPtr(.processes); const selected_index: ?usize = if (self.selected_pid) |sel| std.mem.findScalar(u32, self.order.items, sel) else null; if (self.reveal_selection) { if (selected_index) |i| scroll.reveal(i, p.height()); self.reveal_selection = false; } scroll.clamp(processes.len, p.height());
const bar_w = 12; for (processes[@min(scroll.top, processes.len)..], 0..) |pr, i| { if (i >= p.height()) break; const row: u16 = @intCast(i); const rx = if (is_rate) pr.rx_rate else pr.rx_total; const tx = if (is_rate) pr.tx_rate else pr.tx_total; const total = rx + tx; const active = pr.rx_rate + pr.tx_rate >= 1; const selected = selected_index != null and selected_index.? == scroll.top + i; const surface = if (selected) t.track else t.chart_bg; const row_bg: ?Rgb = if (selected) t.track else null; if (selected) _ = p.repeat(0, row, p.width(), " ", .{ .bg = t.track });
// One magenta bar, download solid and upload dithered, its // length the square root of the share so that a process doing a // kilobyte a second stays visible beside one doing a megabyte. const ratio = if (max <= 0 or total <= 0) 0 else @min(1.0, @sqrt(total / max)); const cells: usize = if (total >= 1) @max(1, @as(usize, @intFromFloat(@round(ratio * bar_w)))) else 0; var rx_cells: usize = if (total > 0) @intFromFloat(@round(@as(f64, @floatFromInt(cells)) * (rx / total))) else 0; var tx_cells: usize = cells - rx_cells; if (cells >= 2) { if (rx > 0 and rx_cells == 0) { rx_cells = 1; tx_cells = cells - 1; } else if (tx > 0 and tx_cells == 0) { tx_cells = 1; rx_cells = cells - 1; } }
// The name bright and its pid dim after it, the padding keeping // the bars in a column. const pid_str = std.fmt.allocPrint(p.arena, " ({d})", .{pr.pid}) catch ""; const max_name = @max(1, name_width -| pid_str.len); const name_text = if (format.displayLen(pr.name) > max_name) std.fmt.allocPrint(p.arena, "{f}", .{format.padEnd(pr.name, max_name)}) catch pr.name else pr.name; const pad = name_width -| (format.displayLen(name_text) + pid_str.len);
var c: u16 = 0; c = p.text(c, row, if (selected) "▸ " else if (active) "● " else "○ ", .{ .fg = if (selected) t.accent else if (active) t.green else t.dim, .bg = row_bg }); c = p.text(c, row, name_text, .{ .fg = if (selected or active) t.text else t.dim, .bg = row_bg }); c = p.text(c, row, pid_str, .{ .fg = t.dim, .bg = row_bg }); c +|= @intCast(pad + 1); c = p.repeat(c, row, rx_cells, "█", .{ .fg = t.magenta, .bg = surface }); c = p.repeat(c, row, tx_cells, "▒", .{ .fg = t.magenta, .bg = surface }); c = p.repeat(c, row, bar_w - cells, " ", .{ .bg = surface }); // Rates carry `/s` and totals do not, since `s` changes what the // columns mean and the title alone is easy to miss. c = p.text(c, row, " ▼", .{ .fg = if (active) t.magenta else t.dim, .bg = row_bg }); c = p.text(c, row, self.processNumber(p.arena, rx), .{ .fg = if (active) t.text else t.dim, .bg = row_bg }); c = p.text(c, row, " ▲", .{ .fg = if (active) t.magenta_dim else t.dim, .bg = row_bg }); _ = p.text(c, row, self.processNumber(p.arena, tx), .{ .fg = if (active) t.text else t.dim, .bg = row_bg }); } }
fn processNumber(self: *App, arena: Allocator, v: f64) []const u8 { const compact = std.fmt.allocPrint(arena, "{f}", .{format.Compact{ .n = v }}) catch return ""; if (self.sort == .rate) { const with_unit = std.fmt.allocPrint(arena, "{s}/s", .{compact}) catch return ""; return std.fmt.allocPrint(arena, "{f}", .{format.padStart(with_unit, 7)}) catch ""; } return std.fmt.allocPrint(arena, "{f}", .{format.padStart(compact, 5)}) catch ""; }
// ---- connections ---------------------------------------------------
fn drawConnections(self: *App, outer: Painter, name_width: usize, rect: Rect) void { const t = &theme.current; const all = self.store.connections.value; const tr = &self.store.traffic.value; var established: usize = 0; var listening: usize = 0; for (all) |c| { established += @intFromBool(std.mem.eql(u8, c.state, "ESTABLISHED")); listening += @intFromBool(std.mem.eql(u8, c.state, "LISTEN")); } const title = std.fmt.allocPrint(outer.arena, " CONNECTIONS ● {d} ◌ {d} ", .{ established, listening }) catch ""; const p = outer.box(title, t.border, 1, 1); self.rects.set(.connections, rect);
if (all.len == 0) { _ = p.text(0, 0, "no connections…", .{ .fg = t.dim }); return; }
// Coarse buckets, a factor of four apart, rather than the raw rates: // rows trade places only on a change of magnitude, so the list does // not reshuffle on every sample. The sort is stable, which keeps the // collector's order within a bucket. const Row = struct { c: model.Connection, rate: f64, bucket: i32 }; const rows = p.arena.alloc(Row, all.len) catch return; for (all, rows) |c, *r| { const rate = if (tr.rateOf(c.local, c.remote)) |x| x.sum() else 0; r.* = .{ .c = c, .rate = rate, .bucket = if (rate < 1) 0 else @as(i32, @intFromFloat(@floor(std.math.log2(rate) / 2))) + 1 }; } std.sort.insertion(Row, rows, {}, struct { fn lessThan(_: void, a: Row, b: Row) bool { return a.bucket > b.bucket; } }.lessThan);
// The name column is as wide as the longest name, up to the limit, // so that a short name is not padded out into a gap. var name_w: usize = 4; for (rows) |r| name_w = @max(name_w, format.displayLen(if (r.c.command.len > 0) r.c.command else "—")); name_w = @min(name_w, name_width);
const scroll = self.scrolls.getPtr(.connections); scroll.clamp(rows.len, p.height()); for (rows[scroll.top..], 0..) |r, i| { if (i >= p.height()) break; const row: u16 = @intCast(i); const dot: struct { []const u8, Rgb } = if (std.mem.eql(u8, r.c.state, "ESTABLISHED")) .{ "●", t.green } else if (std.mem.eql(u8, r.c.state, "LISTEN")) .{ "◌", t.accent } else .{ "·", t.dim }; var c = p.text(0, row, dot[0], .{ .fg = dot[1] }); c = p.text(c, row, " ", .{}); // ss leaves out the process of a socket this user does not own. c = p.print(c, row, .{ .fg = if (r.c.command.len > 0) t.text else t.dim }, "{f}", .{format.padEnd(if (r.c.command.len > 0) r.c.command else "—", name_w)}); const compact = std.fmt.allocPrint(p.arena, "{f}", .{format.Compact{ .n = r.rate }}) catch ""; c = p.print(c, row, .{ .fg = if (r.rate > 0) t.cyan else t.dim }, "{f} ", .{format.padStart(compact, 7)}); if (r.c.remote.len > 0) { c = p.text(c, row, "→ ", .{ .fg = t.dim }); _ = p.text(c, row, r.c.remote_display, .{ .fg = t.cyan }); } else { _ = p.text(c, row, r.c.local, .{ .fg = t.dim }); } } }
// ---- remote hosts --------------------------------------------------
fn drawRemotes(self: *App, outer: Painter, name_width: usize, rect: Rect) void { const t = &theme.current; const p = outer.box(" REMOTE HOSTS ", t.border, 1, 1); self.rects.set(.remotes, rect); const remotes = self.store.traffic.value.remotes; if (remotes.len == 0) { _ = p.text(0, 0, "listening for traffic…", .{ .fg = t.dim }); return; } var busiest: f64 = 1; for (remotes) |r| busiest = @max(busiest, r.rx_rate + r.tx_rate); const max = self.remote_max.update(busiest, self.now(), 0.9, 0); const tints = tint.compute(remotes);
const scroll = self.scrolls.getPtr(.remotes); scroll.clamp(remotes.len, p.height()); for (remotes[scroll.top..], 0..) |r, i| { if (i >= p.height()) break; const row: u16 = @intCast(i); const rate = r.rx_rate + r.tx_rate; const ratio = if (max <= 0 or rate <= 0) 0 else @min(1.0, @sqrt(rate / max)); const g = format.gauge(ratio, 1, 8); const country_tint = tints.get(r.country); const name_color = if (country_tint) |ct| ct.color else t.text; // A host on the map gets a dot in the same brightened hue as its // marker there, so that a row can be traced to its blip. const located = r.lat != null and r.lon != null; const marker = if (country_tint) |ct| ct.marker else t.upload; var c = p.text(0, row, if (located) "● " else " ", .{ .fg = if (located) marker else t.dim }); const label = if (!std.mem.eql(u8, r.host, r.ip)) r.host else if (r.org.len > 0) r.org else r.ip; c = p.print(c, row, .{ .fg = name_color }, "{f}", .{format.padEnd(label, name_width -| 2)}); c = p.print(c, row, .{ .fg = if (r.country.len > 0) name_color else t.dim }, "{f}", .{format.padEnd(if (r.country.len > 0) r.country else "—", 3)}); c = gauge(p, c, row, g, t.magenta, t.border, t.chart_bg); const compact = std.fmt.allocPrint(p.arena, "{f}/s", .{format.Compact{ .n = rate }}) catch ""; _ = p.print(c, row, .{ .fg = t.cyan }, "{f}", .{format.padStart(compact, 8)}); } }
// ---- services ------------------------------------------------------
fn drawServices(self: *App, outer: Painter, list: []const services.ServiceRate, rect: Rect) void { const t = &theme.current; const p = outer.box(" SERVICES ", t.border, 1, 1); self.rects.set(.services, rect); if (list.len == 0) { _ = p.text(0, 0, "no active connections…", .{ .fg = t.dim }); return; } // A full half-bar is the largest single-direction flow on screen. var busiest: f64 = 1; for (list) |s| busiest = @max(busiest, @max(s.rx_rate, s.tx_rate)); const max = self.service_max.update(busiest, self.now(), 0.9, 0);
const name_w = 9; const proto_w = 5; const port_w = 6; const gauge_w = 11; const rate_w = 6;
// The legend puts DOWN and UP over the two halves of the gauge, in // their colors, so that the mirror reads without guesswork. var c = p.print(0, 0, .{ .fg = t.dim }, "{f}{f}{f} ", .{ format.padEnd("name", name_w), format.padEnd("proto", proto_w), format.padStart("port", port_w) }); c = p.text(c, 0, " DOWN", .{ .fg = t.download }); c = p.text(c, 0, "┊", .{ .fg = t.dim }); c = p.text(c, 0, "UP ", .{ .fg = t.upload }); _ = p.print(c, 0, .{ .fg = t.dim }, " {f}", .{format.padStart("RATE", rate_w)});
const body = p.sub(0, 1, p.width(), p.height() -| 1); const scroll = self.scrolls.getPtr(.services); scroll.clamp(list.len, body.height()); for (list[scroll.top..], 0..) |s, i| { if (i >= body.height()) break; const row: u16 = @intCast(i); const g = format.mirrorGauge(s.rx_rate, s.tx_rate, max, gauge_w); c = body.print(0, row, .{ .fg = if (s.name.len > 0) t.text else t.dim }, "{f}", .{format.padEnd(if (s.name.len > 0) s.name else "—", name_w)}); const proto = std.ascii.allocLowerString(body.arena, s.protocol) catch s.protocol; c = body.print(c, row, .{ .fg = t.dim }, "{f}", .{format.padEnd(proto, proto_w)}); const port = std.fmt.allocPrint(body.arena, ":{d}", .{s.port}) catch ""; c = body.print(c, row, .{ .fg = t.dim }, "{f} ", .{format.padStart(port, port_w)}); c = body.repeat(c, row, g.left_track, "░", .{ .fg = t.border, .bg = t.chart_bg }); // Inverted, so that the partial block's ink reads as // right-aligned against the fill. if (g.left_partial != 0) { body.cell(c, row, format.fill_eighths[g.left_partial], .{ .fg = t.chart_bg, .bg = t.download }); c += 1; } c = body.repeat(c, row, g.left_fill, "█", .{ .fg = t.download, .bg = t.chart_bg }); body.cell(c, row, "┊", .{ .fg = t.dim, .bg = t.chart_bg }); c += 1; c = body.repeat(c, row, g.right_fill, "█", .{ .fg = t.upload, .bg = t.chart_bg }); if (g.right_partial != 0) { body.cell(c, row, format.fill_eighths[g.right_partial], .{ .fg = t.upload, .bg = t.chart_bg }); c += 1; } c = body.repeat(c, row, g.right_track, "░", .{ .fg = t.border, .bg = t.chart_bg }); const compact = std.fmt.allocPrint(body.arena, "{f}/s", .{format.Compact{ .n = s.rate }}) catch ""; _ = body.print(c, row, .{ .fg = if (s.rate > 0) t.cyan else t.dim }, " {f}", .{format.padStart(compact, rate_w)}); } }
// ---- latency -------------------------------------------------------
fn drawLatency(self: *App, outer: Painter, half_inner: usize) void { const p = outer.box(" LATENCY ", theme.current.border, 2, 1); const lat = self.store.latency.value; const chart_width: u16 = @intCast(@max(8, (half_inner -| 7) / 3)); const card_w = p.width() / 3; latencyCard(p.sub(0, 0, card_w, p.height()), "internet", lat.internet_ms, self.store.internet_history.items(), self.store.internet_loss.items(), chart_width); latencyCard(p.sub(card_w, 0, card_w, p.height()), "gateway", lat.gateway_ms, self.store.gateway_history.items(), self.store.gateway_loss.items(), chart_width); latencyCard(p.sub(card_w * 2, 0, p.width() - card_w * 2, p.height()), "dns", lat.dns_ms, self.store.dns_history.items(), self.store.dns_loss.items(), chart_width); }
// ---- inspector -----------------------------------------------------
fn drawInspector(self: *App, outer: Painter, pid: u32, width: usize, rect: Rect) void { const t = &theme.current; const tr = &self.store.traffic.value; const process: ?model.ProcessRate = for (tr.processes) |pr| { if (pr.pid == pid) break pr; } else null; const history = tr.histories.get(pid) orelse model.History{};
// The chart shows the newest `width * 2` points, so it is scaled to // the peak among those, decaying rather than snapping down so that // the ceiling does not pump on every spike. It plots download only. var window_peak: f64 = 1; for (history.rx[history.rx.len -| width * 2..]) |v| window_peak = @max(window_peak, v); const scale_max = self.inspector_max.update(window_peak, self.now(), 0.9, pid);
const pr = process orelse { const p = outer.box(" INSPECTOR ", t.border, 1, 1); _ = p.text(0, 1, "process exited · esc to go back", .{ .fg = t.dim }); return; };
const title = std.fmt.allocPrint(outer.arena, " ⊙ {s} · {d} ", .{ pr.name, pr.pid }) catch ""; const p = outer.box(title, t.border, 1, 1); self.rects.set(.inspector, rect); const rx_head = t.download.mix(t.head_anchor, 0.8); const tints = tint.compute(tr.remotes);
var c = p.print(0, 0, .{ .fg = t.download, .bold = true }, "▼ {f}", .{format.Rate{ .n = pr.rx_rate }}); _ = p.print(c + 1, 0, .{ .fg = t.upload, .bold = true }, "▲ {f}", .{format.Rate{ .n = pr.tx_rate }}); const rx_total = std.fmt.allocPrint(p.arena, "{f}", .{format.Bytes{ .n = pr.rx_total }}) catch ""; const tx_total = std.fmt.allocPrint(p.arena, "{f}", .{format.Bytes{ .n = pr.tx_total }}) catch ""; const totals_w = format.displayLen("Σ ▼ ") + format.displayLen(rx_total) + format.displayLen(" ▲ ") + format.displayLen(tx_total); c = p.width() -| @as(u16, @intCast(totals_w)); c = p.text(c, 0, "Σ ", .{ .fg = t.dim }); c = p.text(c, 0, "▼", .{ .fg = t.download }); c = p.print(c, 0, .{ .fg = t.dim }, " {s} ", .{rx_total}); c = p.text(c, 0, "▲", .{ .fg = t.upload }); _ = p.print(c, 0, .{ .fg = t.dim }, " {s}", .{tx_total});
brailleChart(p.sub(0, 1, p.width(), 3), history.rx, @intCast(width), 3, &t.download_gradient, false, scale_max, null, "⣀", rx_head);
const conns = tr.process_connections.get(pid) orelse &.{}; // The count on the left and the chart's ceiling on the right, which // gives the chart an axis without spending a row on one. _ = p.print(0, 4, .{ .fg = t.dim }, "CONNECTIONS · {d}", .{conns.len}); p.right(4, std.fmt.allocPrint(p.arena, "┄ peak {f}", .{format.Rate{ .n = scale_max }}) catch "", .{ .fg = t.dim });
const rate_w = 10; const svc_w = 7; const addr_w = @max(12, width -| (4 + 3 + svc_w + 2 * rate_w)); _ = p.print(0, 6, .{ .fg = t.dim }, "{f}{f}{f}{f}{f}", .{ format.padEnd("CC", 3), format.padEnd("SVC", svc_w), format.padEnd("REMOTE", addr_w), format.padStart("▼ RX", rate_w), format.padStart("▲ TX", rate_w), });
const list = p.sub(0, 7, p.width(), p.height() -| 8); const scroll = self.scrolls.getPtr(.inspector); scroll.clamp(conns.len, list.height()); for (conns[scroll.top..], 0..) |conn, i| { if (i >= list.height()) break; const row: u16 = @intCast(i); const port = addr.port(conn.remote); const svc = if (port) |pt| services.name(pt) else ""; const country_color = if (conn.country.len == 0) t.dim else if (tints.get(conn.country)) |ct| ct.color else tint.fallback(conn.country); c = list.print(0, row, .{ .fg = country_color }, "{f}", .{format.padEnd(if (conn.country.len > 0) conn.country else "—", 3)}); const svc_text = if (svc.len > 0) svc else if (port) |pt| std.fmt.allocPrint(list.arena, ":{d}", .{pt}) catch "" else "—"; c = list.print(c, row, .{ .fg = if (svc.len > 0) t.text else t.dim }, "{f}", .{format.padEnd(svc_text, svc_w)}); c = list.print(c, row, .{ .fg = t.cyan }, "{f}", .{format.padEnd(conn.remote_display, addr_w)}); const rx_text = if (conn.rx_rate > 0) std.fmt.allocPrint(list.arena, "{f}", .{format.Rate{ .n = conn.rx_rate }}) catch "" else "—"; const tx_text = if (conn.tx_rate > 0) std.fmt.allocPrint(list.arena, "{f}", .{format.Rate{ .n = conn.tx_rate }}) catch "" else "—"; c = list.print(c, row, .{ .fg = if (conn.rx_rate > 0) t.download else t.dim }, "{f}", .{format.padStart(rx_text, rate_w)}); _ = list.print(c, row, .{ .fg = if (conn.tx_rate > 0) t.upload else t.dim }, "{f}", .{format.padStart(tx_text, rate_w)}); } _ = p.text(0, p.height() -| 1, "esc back", .{ .fg = t.dim }); }};
fn orDots(s: []const u8) []const u8 { return if (s.len == 0) "…" else s;}
fn gauge(p: Painter, col: u16, row: u16, g: format.Gauge, fill: Rgb, track: Rgb, surface: Rgb) u16 { var c = p.repeat(col, row, g.full, "█", .{ .fg = fill, .bg = surface }); if (g.partial != 0) { p.cell(c, row, format.fill_eighths[g.partial], .{ .fg = fill, .bg = surface }); c += 1; } return p.repeat(c, row, g.track, "╌", .{ .fg = track, .bg = surface });}
fn peakAndAverage(history: []const f64) struct { peak: f64, avg: f64 } { if (history.len == 0) return .{ .peak = 0, .avg = 0 }; var peak: f64 = history[0]; var sum: f64 = 0; for (history) |v| { peak = @max(peak, v); sum += v; } return .{ .peak = peak, .avg = sum / @as(f64, @floatFromInt(history.len)) };}
fn statsRow(p: Painter, row: u16, arrow: []const u8, rate: f64, c: Rgb, history: []const f64, total: f64) void { const stats = peakAndAverage(history); _ = p.print(0, row, .{ .fg = c, .bold = true }, "{s} {f}", .{ arrow, format.Rate{ .n = rate } }); const right = std.fmt.allocPrint(p.arena, "peak {f} · avg {f} · Σ {f}", .{ format.Compact{ .n = stats.peak }, format.Compact{ .n = stats.avg }, format.Compact{ .n = total }, }) catch return; p.right(row, right, .{ .fg = theme.current.dim });}
/// A label floating over the peak of the window, ▾ above the download chart/// and ▴ below the upload one. Nothing while there is no traffic, though the/// row is kept so that the panel does not change height.fn peakMarker(p: Painter, row: u16, history: []const f64, chart_width: u16, arrow: []const u8, c: Rgb) void { const dot_width = @as(usize, chart_width) * 2; const visible = history[history.len -| dot_width..]; var value: f64 = 0; var index: usize = 0; for (visible, 0..) |v, i| { if (v > value) { value = v; index = i; } } if (value <= 0) return; const col = (dot_width - visible.len + index) / 2; const label = std.fmt.allocPrint(p.arena, " {f}", .{format.Compact{ .n = value }}) catch return; const left: u16 = @intCast(@min(col, (@as(usize, chart_width) -| (label.len + 1)))); const after = p.text(left, row, arrow, .{ .fg = c }); _ = p.text(after, row, label, .{ .fg = theme.current.dim });}
/// An area chart. `colors` gives each row its color from the top, the last/// one standing in for any rows beyond them; the rightmost cell is drawn in/// `head` when it is lit, so that the live edge stands out.fn brailleChart( p: Painter, data: []const f64, width: u16, height: u16, colors: []const Rgb, mirror: bool, max: ?f64, top_track: ?[]const u8, bottom_track: ?[]const u8, head: ?Rgb,) void { const cells = p.arena.alloc(u8, @as(usize, width) * height) catch return; braille.area(cells, data, width, height, mirror, max); const t = &theme.current; for (0..height) |r| { const row: u16 = @intCast(r); for (0..width) |x| { const bits = cells[r * width + x]; const col: u16 = @intCast(x); if (bits == 0) { var g: []const u8 = braille.blank; if (mirror and r == 0) { if (top_track) |tt| g = tt; } else if (!mirror and r == height - 1) { if (bottom_track) |bt| g = bt; } p.cell(col, row, g, .{ .fg = t.chart_track }); continue; } const fg = if (head != null and x == width - 1) head.? else colors[@min(r, colors.len - 1)]; p.cell(col, row, braille.glyph(bits), .{ .fg = fg }); } }}
fn latencyColor(ms: ?f64, pending: bool) Rgb { const t = &theme.current; if (pending) return t.dim; const v = ms orelse return t.red; if (v < 30) return t.green; if (v < 100) return t.yellow; return t.red;}
fn msText(arena: Allocator, v: f64) []const u8 { return if (v >= 10) std.fmt.allocPrint(arena, "{d:.0}", .{v}) catch "" else std.fmt.allocPrint(arena, "{d:.1}", .{v}) catch "";}
/// One target's card: its latest round trip, a chart of the jitter, and the/// window's average, worst and loss.fn latencyCard(p: Painter, label: []const u8, value: ?f64, history: []const f64, loss_history: []const f64, chart_width: u16) void { const t = &theme.current; var samples: usize = 0; var sum: f64 = 0; var worst: f64 = 0; var best: f64 = std.math.inf(f64); for (history) |v| if (v > 0) { samples += 1; sum += v; worst = @max(worst, v); best = @min(best, v); }; const pending = value == null and samples == 0;
var probes: usize = 0; var lost: f64 = 0; for (loss_history) |v| if (v >= 0) { probes += 1; lost += v; }; const loss_pct: ?f64 = if (probes == 0) null else lost / @as(f64, @floatFromInt(probes)) * 100;
// Sustained loss degrades the link even when the replies are quick. var c = latencyColor(value, pending); if (loss_pct) |l| if (l >= 1 and !c.eql(t.red)) { c = if (l >= 10) t.red else t.yellow; };
var col = p.text(0, 0, "● ", .{ .fg = c }); col = p.print(col, 0, .{ .fg = t.text }, "{s} ", .{label}); const reading = if (pending) "…" else if (value) |v| std.fmt.allocPrint(p.arena, "{s}ms", .{msText(p.arena, v)}) catch "" else "lost"; _ = p.text(col, 0, reading, .{ .fg = c, .bold = true });
// A steady link would be a solid band on an absolute scale, so the chart // plots how far each sample is above the window's best -- the jitter -- // with a floor on the span so that sub-millisecond noise is not blown up // to full height. const base = if (samples > 0) best else 0; const jitter = p.arena.alloc(f64, history.len) catch return; for (history, jitter) |v, *j| j.* = if (v > 0) @max(v - base, 0.001) else 0; const span = @max(worst - base, 5); brailleChart(p.sub(0, 1, p.width(), 2), jitter, chart_width, 2, &.{ c, c }, false, span, null, null, null);
if (samples > 0) { col = p.print(0, 3, .{ .fg = t.dim }, "avg {s} · max {s}", .{ msText(p.arena, sum / @as(f64, @floatFromInt(samples))), msText(p.arena, worst) }); if (loss_pct) |l| if (l > 0) { const pct = if (l >= 1) std.fmt.allocPrint(p.arena, "{d:.0}", .{@round(l)}) catch "" else std.fmt.allocPrint(p.arena, "{d:.1}", .{l}) catch ""; _ = p.print(col, 3, .{ .fg = if (l >= 10) t.red else t.yellow }, " · loss {s}%", .{pct}); }; }}
fn drawWorldMap(outer: Painter, remotes: []const model.RemoteHost, title_override: ?[]const u8) void { const t = &theme.current; var located: usize = 0; for (remotes) |r| located += @intFromBool(r.lat != null); const title = title_override orelse (std.fmt.allocPrint(outer.arena, " WORLD · {d} destinations ", .{located}) catch ""); const p = outer.box(title, t.border, 0, 0); if (p.width() < 4 or p.height() < 3) return;
// A braille dot is roughly square and the bitmap is 2.5 to 1, so the map // keeps that shape whichever way the space is constrained. const avail_rows: usize = @max(3, p.height()); const map_cells: usize = @max(10, @min(p.width(), (avail_rows * 4 * 5 / 2) / 2)); const map_rows: usize = @max(3, @min(avail_rows, @as(usize, @intFromFloat(@round(@as(f64, @floatFromInt(map_cells * 2)) / 2.5 / 4))))); const dot_h = map_rows * 4; const dot_w = map_cells * 2; const tints = tint.compute(remotes);
// Each located host becomes a dot in its country's brightened hue. const Marker = struct { at: usize, color: Rgb }; var markers: std.ArrayList(Marker) = .empty; for (remotes) |r| { const lat = r.lat orelse continue; const lon = r.lon orelse continue; if (lat > world.lat_top or lat < world.lat_bottom) continue; const x: f64 = @floor((lon + 180) / 360 * @as(f64, @floatFromInt(dot_w))); const y: f64 = @floor((world.lat_top - lat) / (world.lat_top - world.lat_bottom) * @as(f64, @floatFromInt(dot_h))); const mx: usize = @intFromFloat(std.math.clamp(x, 0, @as(f64, @floatFromInt(dot_w - 1)))); const my: usize = @intFromFloat(std.math.clamp(y, 0, @as(f64, @floatFromInt(dot_h - 1)))); markers.append(outer.arena, .{ .at = my * dot_w + mx, .color = if (tints.get(r.country)) |ct| ct.marker else t.upload }) catch break; }
const x0: u16 = @intCast((p.width() - map_cells) / 2); const y0: u16 = @intCast((p.height() - map_rows) / 2); for (0..map_rows) |cy| for (0..map_cells) |cx| { var bits: u8 = 0; var marker: ?Rgb = null; var best: ?tint.Tint = null; for (0..2) |dx| for (0..4) |dy| { const x = cx * 2 + dx; const y = cy * 4 + dy; var lit = false; for (markers.items) |m| if (m.at == y * dot_w + x) { marker = m.color; lit = true; }; const wx = x * world.width / dot_w; const wy = y * world.height / dot_h; if (world.land[wy][wx] == '1') { lit = true; const o = world.owner[wy][wx]; if (o != 0) if (tints.get(world.countries[o - 1])) |ct| { if (best == null or ct.level > best.?.level) best = ct; }; } if (lit) bits |= @as(u8, if (dx == 0) ([4]u8{ 0x01, 0x02, 0x04, 0x40 })[dy] else ([4]u8{ 0x08, 0x10, 0x20, 0x80 })[dy]); }; const fg = marker orelse if (best) |b| b.color else t.chart_track; p.cell(x0 + @as(u16, @intCast(cx)), y0 + @as(u16, @intCast(cy)), braille.glyph(bits), .{ .fg = fg }); };}
/// The theme picker, over everything else. A move applies the theme at/// once, so the screen behind it is the preview.fn drawThemeDialog(screen: Painter) void { const t = &theme.current; const swatch_keys = .{ "download", "upload", "accent", "magenta", "yellow", "cyan" }; const hint = "j/k move · ⏎ apply · esc cancel"; var widest: usize = 0; for (theme.themes) |th| widest = @max(widest, format.displayLen(th.name)); const inner_w: u16 = @intCast(@max(format.displayLen(hint), widest + 2 + 1 + 6)); const w = inner_w + 2 + 4; const h: u16 = @intCast(theme.themes.len + 2 + 2 + 2); if (screen.width() < w or screen.height() < h) return; const outer = screen.sub((screen.width() - w) / 2, (screen.height() - h) / 2, w, h); const panel: Painter = .{ .win = outer.win, .arena = outer.arena, .bg = t.panel }; panel.clear(); const p = panel.box(" Theme ", t.accent, 2, 2).sub(0, 1, inner_w, h - 4); for (theme.themes, 0..) |th, i| { const row: u16 = @intCast(i); const selected = i == theme.index(); var c = p.text(0, row, if (selected) "› " else " ", .{ .fg = if (selected) t.accent else t.dim }); _ = p.text(c, row, th.name, .{ .fg = if (selected) t.text else t.dim, .bold = selected }); c = p.width() - 6; inline for (swatch_keys) |k| { p.cell(c, row, "█", .{ .fg = @field(th.palette, k) }); c += 1; } } _ = p.text(0, @intCast(theme.themes.len + 1), hint, .{ .fg = t.dim });}
const testing = std.testing;
test "StickyMax decays per second and resets on a new key" { var m: StickyMax = .{}; try testing.expectEqual(@as(f64, 100), m.update(100, 0, 0.5, 0)); try testing.expectEqual(@as(f64, 50), m.update(10, 1, 0.5, 0)); try testing.expectEqual(@as(f64, 10), m.update(10, 1, 0.5, 1)); try testing.expectEqual(@as(f64, 1), m.update(0, 100, 0.5, 1));}
test "Scroll keeps a row in view and the top in range" { var s: Scroll = .{}; s.reveal(10, 5); try testing.expectEqual(@as(usize, 6), s.top); s.reveal(2, 5); try testing.expectEqual(@as(usize, 2), s.top); s.by(100); s.clamp(8, 5); try testing.expectEqual(@as(usize, 3), s.top); s.by(-100); try testing.expectEqual(@as(usize, 0), s.top);}