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 + empty blocks. width is total cells.
79pub fn meter(percent: Float, width: Int) -> String {
80 let p = float.clamp(percent, 0.0, 100.0)
81 let filled = float.round(p /. 100.0 *. int.to_float(width))
82 let filled = int.clamp(filled, 0, width)
83 string.repeat("█", filled) <> string.repeat("░", width - filled)
84}
85
86/// Gradient meter characters for finer resolution (btop-ish).
87pub fn meter_fine(percent: Float, width: Int) -> String {
88 let p = float.clamp(percent, 0.0, 100.0)
89 let cells = p /. 100.0 *. int.to_float(width)
90 let full = float.truncate(cells)
91 let frac = cells -. int.to_float(full)
92 // Left-block partials: ▉ ▊ ▋ ▌ ▍ ▎ ▏
93 let partial = case frac {
94 f if f >=. 0.875 -> "\u{2589}"
95 f if f >=. 0.75 -> "\u{258A}"
96 f if f >=. 0.625 -> "\u{258B}"
97 f if f >=. 0.5 -> "\u{258C}"
98 f if f >=. 0.375 -> "\u{258D}"
99 f if f >=. 0.25 -> "\u{258E}"
100 f if f >=. 0.125 -> "\u{258F}"
101 f if f >. 0.0 -> "\u{258F}"
102 _ -> ""
103 }
104 let full = int.clamp(full, 0, width)
105 let body = string.repeat("█", full) <> partial
106 let len = string.length(body)
107 case len >= width {
108 True -> string.slice(body, 0, width)
109 False -> body <> string.repeat(" ", width - len)
110 }
111}
112
113/// Sparkline from 0–100 history values.
114pub fn sparkline(history: List(Float), width: Int) -> String {
115 let chars = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
116 let n = list.length(chars)
117 let hist = case list.length(history) > width {
118 True -> list.drop(history, list.length(history) - width)
119 False -> history
120 }
121 let padded = case list.length(hist) < width {
122 True -> list.append(list.repeat(0.0, width - list.length(hist)), hist)
123 False -> hist
124 }
125 padded
126 |> list.map(fn(v) {
127 let v = float.clamp(v, 0.0, 100.0)
128 let idx =
129 float.round(v /. 100.0 *. int.to_float(n - 1))
130 |> int.clamp(0, n - 1)
131 case list.drop(chars, idx) {
132 [c, ..] -> c
133 [] -> " "
134 }
135 })
136 |> string.concat
137}
138
139pub fn format_uptime(secs: Float) -> String {
140 let total = float.truncate(secs)
141 let days = total / 86_400
142 let hours = int.remainder(total / 3600, 24) |> result_or_0
143 let mins = int.remainder(total / 60, 60) |> result_or_0
144 case days > 0 {
145 True ->
146 int.to_string(days)
147 <> "d "
148 <> int.to_string(hours)
149 <> "h "
150 <> int.to_string(mins)
151 <> "m"
152 False ->
153 case hours > 0 {
154 True -> int.to_string(hours) <> "h " <> int.to_string(mins) <> "m"
155 False -> int.to_string(mins) <> "m"
156 }
157 }
158}
159
160fn result_or_0(r: Result(Int, Nil)) -> Int {
161 case r {
162 Ok(n) -> n
163 Error(_) -> 0
164 }
165}
166
167pub fn format_load(a: Float, b: Float, c: Float) -> String {
168 one_decimal(a) <> " " <> one_decimal(b) <> " " <> one_decimal(c)
169}
170
171/// Color band for usage: green / yellow / red thresholds.
172pub type Band {
173 Low
174 Mid
175 High
176 Critical
177}
178
179pub fn band(percent: Float) -> Band {
180 case percent >=. 90.0 {
181 True -> Critical
182 False ->
183 case percent >=. 70.0 {
184 True -> High
185 False ->
186 case percent >=. 40.0 {
187 True -> Mid
188 False -> Low
189 }
190 }
191 }
192}