Terminal system monitor in Gleam — htop × btop
1//// Main application loop: sample /proc, render, handle keys and mouse.
2
3import etch/command
4import etch/erlang/input
5import etch/erlang/tty
6import etch/event.{
7 type Event, type KeyCode, type MouseEvent, Backspace, Char, Down, DownArrow,
8 Drag, End, Enter, Esc, Home, Key, Left, Mouse, Moved, PageDown, PageUp, Right,
9 ScrollDown, ScrollLeft, ScrollRight, ScrollUp, Up, UpArrow,
10}
11import etch/stdout
12import etch/terminal
13import gleam/dict
14import gleam/erlang/process
15import gleam/int
16import gleam/list
17import gleam/option.{None, Some}
18import gleam/string
19import procfs.{type Snapshot}
20import ui.{
21 type SortKey, type ViewState, ConfirmKill, Filter, Help, Normal, SortCommand,
22 SortCpu, SortMem, SortPid, SortTime,
23}
24
25const refresh_ms = 1000
26
27@external(erlang, "erlang", "halt")
28fn halt(code: Int) -> Nil
29
30@external(erlang, "os", "cmd")
31fn os_cmd(command: String) -> String
32
33pub fn run() -> Nil {
34 let #(cols, rows) = window_size()
35 let assert Ok(_) = tty.enter_raw()
36 stdout.execute([
37 command.EnterAlternateScreen,
38 command.EnableMouseCapture,
39 command.HideCursor,
40 command.Clear(terminal.All),
41 command.DisableLineWrap,
42 command.SetTitle("monitor"),
43 ])
44
45 // Seed samples so first painted frame has real CPU %
46 let prev_cores = dict.new()
47 let prev_ticks = dict.new()
48 let snap0 = procfs.sample_with_core_history(0, 0, prev_cores, prev_ticks)
49 process.sleep(200)
50 let snap =
51 procfs.sample_with_core_history(
52 snap0.prev_cpu_total,
53 snap0.prev_cpu_idle,
54 procfs.core_history_from(snap0),
55 snap0.prev_proc_ticks,
56 )
57
58 let view =
59 ui.initial_view(cols, rows)
60 |> ui.push_history(snap)
61
62 loop(snap, view)
63}
64
65fn loop(snap: Snapshot, view: ViewState) -> Nil {
66 let #(cols, rows) = window_size()
67 let view = case cols != view.cols || rows != view.rows {
68 True -> ui.ViewState(..view, cols: cols, rows: rows)
69 False -> view
70 }
71
72 stdout.execute(ui.render(snap, view))
73
74 let #(snap2, view2, quit) = handle_input_window(snap, view, refresh_ms)
75 case quit {
76 True -> cleanup()
77 False -> {
78 let view2 = ui.push_history(view2, snap2)
79 loop(snap2, view2)
80 }
81 }
82}
83
84/// Poll for keys up to `budget_ms`, then refresh system stats.
85fn handle_input_window(
86 snap: Snapshot,
87 view: ViewState,
88 budget_ms: Int,
89) -> #(Snapshot, ViewState, Bool) {
90 let step = 50
91 handle_input_window_go(snap, view, budget_ms, step)
92}
93
94fn handle_input_window_go(
95 snap: Snapshot,
96 view: ViewState,
97 remaining: Int,
98 step: Int,
99) -> #(Snapshot, ViewState, Bool) {
100 case remaining <= 0 {
101 True -> {
102 let snap2 = refresh(snap)
103 #(snap2, view, False)
104 }
105 False -> {
106 case input.poll(0) {
107 Some(Ok(ev)) ->
108 // Mouse motion / drag / release spam SGR reports continuously.
109 // Full clear+redraw on hover was the glitch; ignore those kinds.
110 case is_mouse_noise(ev) {
111 True -> handle_input_window_go(snap, view, remaining - step, step)
112 False ->
113 case apply_event(snap, view, ev) {
114 #(snap2, view2, True) -> #(snap2, view2, True)
115 #(snap2, view2, False) -> {
116 // Immediate redraw on real input for snappy feel
117 stdout.execute(ui.render(snap2, view2))
118 handle_input_window_go(snap2, view2, remaining - step, step)
119 }
120 }
121 }
122 Some(Error(_)) -> {
123 process.sleep(step)
124 handle_input_window_go(snap, view, remaining - step, step)
125 }
126 None -> {
127 process.sleep(step)
128 handle_input_window_go(snap, view, remaining - step, step)
129 }
130 }
131 }
132 }
133}
134
135fn refresh(snap: Snapshot) -> Snapshot {
136 procfs.sample_with_core_history(
137 snap.prev_cpu_total,
138 snap.prev_cpu_idle,
139 procfs.core_history_from(snap),
140 snap.prev_proc_ticks,
141 )
142}
143
144fn apply_event(
145 snap: Snapshot,
146 view: ViewState,
147 ev: Event,
148) -> #(Snapshot, ViewState, Bool) {
149 case ev {
150 Key(ke) -> apply_key(snap, view, ke.code, ke.modifiers.control)
151 Mouse(me) -> apply_mouse(snap, view, me)
152 _ -> #(snap, view, False)
153 }
154}
155
156/// Hover / drag / button-up should not mutate state or force a paint.
157fn is_mouse_noise(ev: Event) -> Bool {
158 case ev {
159 Mouse(me) ->
160 case me.kind {
161 Moved | Drag(_) | Up(_) | ScrollLeft | ScrollRight -> True
162 _ -> False
163 }
164 _ -> False
165 }
166}
167
168fn apply_mouse(
169 snap: Snapshot,
170 view: ViewState,
171 me: MouseEvent,
172) -> #(Snapshot, ViewState, Bool) {
173 case view.mode {
174 Help ->
175 // Only a click dismisses help (not hover — filtered as noise above)
176 case me.kind {
177 Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False)
178 _ -> #(snap, view, False)
179 }
180
181 ConfirmKill ->
182 // Click cancels kill (safer than accidental confirm)
183 case me.kind {
184 Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False)
185 _ -> #(snap, view, False)
186 }
187
188 Filter ->
189 // Keep filter typing keyboard-only; wheel still scrolls selection
190 case me.kind {
191 ScrollUp -> #(snap, move_sel(view, snap, -3), False)
192 ScrollDown -> #(snap, move_sel(view, snap, 3), False)
193 _ -> #(snap, view, False)
194 }
195
196 Normal ->
197 case me.kind {
198 ScrollUp -> #(snap, move_sel(view, snap, -3), False)
199 ScrollDown -> #(snap, move_sel(view, snap, 3), False)
200
201 Down(Left) -> apply_left_click(snap, view, me.column, me.row)
202
203 Down(Right) ->
204 case ui.process_index_at_row(snap, view, me.row) {
205 Ok(idx) -> {
206 let view = ui.ViewState(..view, selected: idx)
207 case ui.selected_process(view, snap) {
208 Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False)
209 Error(_) -> #(snap, view, False)
210 }
211 }
212 Error(_) -> #(snap, view, False)
213 }
214
215 _ -> #(snap, view, False)
216 }
217 }
218}
219
220fn apply_left_click(
221 snap: Snapshot,
222 view: ViewState,
223 col: Int,
224 row: Int,
225) -> #(Snapshot, ViewState, Bool) {
226 let lay = ui.layout(snap, view)
227 case row == lay.table_header_row {
228 True ->
229 case ui.sort_key_at_column(col) {
230 Ok(key) -> #(snap, set_sort(view, key), False)
231 Error(_) -> #(snap, view, False)
232 }
233 False ->
234 case ui.process_index_at_row(snap, view, row) {
235 Ok(idx) -> #(snap, ui.ViewState(..view, selected: idx), False)
236 Error(_) -> #(snap, view, False)
237 }
238 }
239}
240
241fn apply_key(
242 snap: Snapshot,
243 view: ViewState,
244 code: KeyCode,
245 ctrl: Bool,
246) -> #(Snapshot, ViewState, Bool) {
247 case view.mode {
248 Help -> #(snap, ui.ViewState(..view, mode: Normal), False)
249
250 ConfirmKill ->
251 case code {
252 Char("y") | Char("Y") -> {
253 case ui.selected_process(view, snap) {
254 Ok(p) -> {
255 let _ =
256 os_cmd("kill -15 " <> int.to_string(p.pid) <> " 2>/dev/null")
257 Nil
258 }
259 Error(_) -> Nil
260 }
261 #(refresh(snap), ui.ViewState(..view, mode: Normal), False)
262 }
263 Char("n") | Char("N") | Esc -> #(
264 snap,
265 ui.ViewState(..view, mode: Normal),
266 False,
267 )
268 _ -> #(snap, view, False)
269 }
270
271 Filter ->
272 case code {
273 Esc -> #(
274 snap,
275 ui.ViewState(..view, mode: Normal, filter: "", selected: 0, scroll: 0),
276 False,
277 )
278 Enter -> #(
279 snap,
280 ui.ViewState(..view, mode: Normal, selected: 0, scroll: 0),
281 False,
282 )
283 Backspace -> {
284 let f = string.drop_end(view.filter, 1)
285 #(
286 snap,
287 ui.ViewState(..view, filter: f, selected: 0, scroll: 0),
288 False,
289 )
290 }
291 Char(c) ->
292 case string.byte_size(c) == 1 && !ctrl && is_printable(c) {
293 True -> #(
294 snap,
295 ui.ViewState(
296 ..view,
297 filter: view.filter <> c,
298 selected: 0,
299 scroll: 0,
300 ),
301 False,
302 )
303 False -> #(snap, view, False)
304 }
305 _ -> #(snap, view, False)
306 }
307
308 Normal ->
309 case code {
310 Char("q") | Char("Q") | Esc -> #(snap, view, True)
311 Char("h") | Char("?") -> #(
312 snap,
313 ui.ViewState(..view, mode: Help),
314 False,
315 )
316 Char("/") -> #(snap, ui.ViewState(..view, mode: Filter), False)
317 Char("k") | Char("K") ->
318 case ui.selected_process(view, snap) {
319 Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False)
320 Error(_) -> #(snap, view, False)
321 }
322 Char("j") | DownArrow -> #(snap, move_sel(view, snap, 1), False)
323 Char("J") -> #(snap, move_sel(view, snap, 10), False)
324 UpArrow | Char("u") -> #(snap, move_sel(view, snap, -1), False)
325 PageDown -> #(snap, move_sel(view, snap, page(view)), False)
326 PageUp -> #(snap, move_sel(view, snap, -page(view)), False)
327 Home | Char("g") -> #(
328 snap,
329 ui.ViewState(..view, selected: 0, scroll: 0),
330 False,
331 )
332 End | Char("G") -> {
333 let n = list.length(ui.visible_processes(view, snap))
334 let sel = int.max(0, n - 1)
335 #(snap, ui.ViewState(..view, selected: sel), False)
336 }
337 Char("c") -> #(snap, set_sort(view, SortCpu), False)
338 Char("m") -> #(snap, set_sort(view, SortMem), False)
339 Char("p") -> #(snap, set_sort(view, SortPid), False)
340 Char("t") -> #(snap, set_sort(view, SortTime), False)
341 Char("n") -> #(snap, set_sort(view, SortCommand), False)
342 Char("T") -> #(
343 snap,
344 ui.ViewState(
345 ..view,
346 tree: !view.tree,
347 selected: 0,
348 scroll: 0,
349 ),
350 False,
351 )
352 Char("r") -> #(
353 snap,
354 ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0),
355 False,
356 )
357 Char(" ") -> #(refresh(snap), view, False)
358 _ -> #(snap, view, False)
359 }
360 }
361}
362
363fn is_printable(c: String) -> Bool {
364 case string.to_utf_codepoints(c) {
365 [cp] -> {
366 let n = string.utf_codepoint_to_int(cp)
367 n >= 32 && n <= 126
368 }
369 _ -> False
370 }
371}
372
373fn set_sort(view: ViewState, key: SortKey) -> ViewState {
374 case view.sort == key {
375 True -> ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0)
376 False ->
377 ui.ViewState(
378 ..view,
379 sort: key,
380 reverse: case key {
381 SortCpu | SortMem | SortTime -> True
382 SortPid | SortCommand -> False
383 },
384 selected: 0,
385 scroll: 0,
386 )
387 }
388}
389
390fn move_sel(view: ViewState, snap: Snapshot, delta: Int) -> ViewState {
391 let n = list.length(ui.visible_processes(view, snap))
392 let sel = int.clamp(view.selected + delta, 0, int.max(0, n - 1))
393 ui.ViewState(..view, selected: sel)
394}
395
396fn page(view: ViewState) -> Int {
397 int.max(5, view.rows / 2)
398}
399
400fn window_size() -> #(Int, Int) {
401 case tty.window_size() {
402 Ok(#(c, r)) -> #(c, r)
403 Error(_) -> #(100, 30)
404 }
405}
406
407fn cleanup() -> Nil {
408 stdout.execute([
409 command.DisableMouseCapture,
410 command.EnableLineWrap,
411 command.ShowCursor,
412 command.Clear(terminal.All),
413 command.LeaveAlternateScreen,
414 ])
415 let _ = tty.exit_raw()
416 halt(0)
417}