Terminal system monitor in Gleam — htop × btop
1//// Human-readable sizes, percentages, meter bars, and sparklines.
2//// Visual style inspired by btop; table density inspired by htop.
3
4import gleam/float
5import gleam/int
6import gleam/list
7import gleam/string
8
9/// Format kibibytes as human size (K/M/G/T).
10pub fn human_kb(kb: Int) -> String {
11 let b = int.to_float(kb)
12 case kb < 1024 {
13 True -> int.to_string(kb) <> "K"
14 False ->
15 case kb < 1024 * 1024 {
16 True -> one_decimal(b /. 1024.0) <> "M"
17 False ->
18 case kb < 1024 * 1024 * 1024 {
19 True -> one_decimal(b /. 1_048_576.0) <> "G"
20 False -> one_decimal(b /. 1_073_741_824.0) <> "T"
21 }
22 }
23 }
24}
25
26pub fn one_decimal(x: Float) -> String {
27 let x = float.to_precision(x, 1)
28 float.to_string(x)
29}
30
31pub fn percent(p: Float) -> String {
32 let p = float.clamp(p, 0.0, 999.9)
33 case p <. 10.0 {
34 True -> pad_start(one_decimal(p), 4) <> "%"
35 False ->
36 case p <. 100.0 {
37 True -> pad_start(one_decimal(p), 4) <> "%"
38 False -> pad_start(int.to_string(float.round(p)), 4) <> "%"
39 }
40 }
41}
42
43pub fn percent_short(p: Float) -> String {
44 one_decimal(float.clamp(p, 0.0, 100.0)) <> "%"
45}
46
47fn pad_start(s: String, width: Int) -> String {
48 string.pad_start(s, to: width, with: " ")
49}
50
51pub fn pad_end(s: String, width: Int) -> String {
52 string.pad_end(s, to: width, with: " ")
53}
54
55pub fn pad_left(s: String, width: Int) -> String {
56 string.pad_start(s, to: width, with: " ")
57}
58
59pub fn truncate(s: String, width: Int) -> String {
60 case string.length(s) <= width {
61 True -> s
62 False ->
63 case width <= 1 {
64 True -> string.slice(s, 0, width)
65 False -> string.slice(s, 0, width - 1) <> "…"
66 }
67 }
68}
69
70pub fn fit(s: String, width: Int) -> String {
71 truncate(s, width) |> pad_end(width)
72}
73
74pub fn fit_right(s: String, width: Int) -> String {
75 truncate(s, width) |> pad_left(width)
76}
77
78/// Solid meter: filled blocks + light track. width is total cells.
79/// Prefer `ui.colored_meter` for the live TUI (truecolor gradient).
80pub fn meter(percent: Float, width: Int) -> String {
81 let p = float.clamp(percent, 0.0, 100.0)
82 let filled = float.round(p /. 100.0 *. int.to_float(width))
83 let filled = int.clamp(filled, 0, width)
84 string.repeat("█", filled) <> string.repeat("░", width - filled)
85}
86
87/// Fine meter with partial left-blocks and a light track for empty cells.
88pub fn meter_fine(percent: Float, width: Int) -> String {
89 let p = float.clamp(percent, 0.0, 100.0)
90 let cells = p /. 100.0 *. int.to_float(width)
91 let full = float.truncate(cells)
92 let frac = cells -. int.to_float(full)
93 // Left-block partials: ▉ ▊ ▋ ▌ ▍ ▎ ▏
94 let partial = case frac {
95 f if f >=. 0.875 -> "\u{2589}"
96 f if f >=. 0.75 -> "\u{258A}"
97 f if f >=. 0.625 -> "\u{258B}"
98 f if f >=. 0.5 -> "\u{258C}"
99 f if f >=. 0.375 -> "\u{258D}"
100 f if f >=. 0.25 -> "\u{258E}"
101 f if f >=. 0.125 -> "\u{258F}"
102 f if f >. 0.0 -> "\u{258F}"
103 _ -> ""
104 }
105 let full = int.clamp(full, 0, width)
106 let body = string.repeat("█", full) <> partial
107 let len = string.length(body)
108 case len >= width {
109 True -> string.slice(body, 0, width)
110 False -> body <> string.repeat("░", width - len)
111 }
112}
113
114/// Sparkline from 0–100 history values.
115pub fn sparkline(history: List(Float), width: Int) -> String {
116 let chars = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
117 let n = list.length(chars)
118 let hist = case list.length(history) > width {
119 True -> list.drop(history, list.length(history) - width)
120 False -> history
121 }
122 let padded = case list.length(hist) < width {
123 True -> list.append(list.repeat(0.0, width - list.length(hist)), hist)
124 False -> hist
125 }
126 padded
127 |> list.map(fn(v) {
128 let v = float.clamp(v, 0.0, 100.0)
129 let idx =
130 float.round(v /. 100.0 *. int.to_float(n - 1))
131 |> int.clamp(0, n - 1)
132 case list.drop(chars, idx) {
133 [c, ..] -> c
134 [] -> " "
135 }
136 })
137 |> string.concat
138}
139
140pub fn format_uptime(secs: Float) -> String {
141 let total = float.truncate(secs)
142 let days = total / 86_400
143 let hours = int.remainder(total / 3600, 24) |> result_or_0
144 let mins = int.remainder(total / 60, 60) |> result_or_0
145 case days > 0 {
146 True ->
147 int.to_string(days)
148 <> "d "
149 <> int.to_string(hours)
150 <> "h "
151 <> int.to_string(mins)
152 <> "m"
153 False ->
154 case hours > 0 {
155 True -> int.to_string(hours) <> "h " <> int.to_string(mins) <> "m"
156 False -> int.to_string(mins) <> "m"
157 }
158 }
159}
160
161fn result_or_0(r: Result(Int, Nil)) -> Int {
162 case r {
163 Ok(n) -> n
164 Error(_) -> 0
165 }
166}
167
168pub fn format_load(a: Float, b: Float, c: Float) -> String {
169 one_decimal(a) <> " " <> one_decimal(b) <> " " <> one_decimal(c)
170}
171
172/// Color band for usage: green / yellow / red thresholds.
173pub type Band {
174 Low
175 Mid
176 High
177 Critical
178}
179
180pub fn band(percent: Float) -> Band {
181 case percent >=. 90.0 {
182 True -> Critical
183 False ->
184 case percent >=. 70.0 {
185 True -> High
186 False ->
187 case percent >=. 40.0 {
188 True -> Mid
189 False -> Low
190 }
191 }
192 }
193}