Skip to main content

bottom/
lib.rs

1//! A customizable cross-platform graphical process/system monitor for the
2//! terminal. Supports Linux, macOS, and Windows. Inspired by gtop, gotop, and
3//! htop.
4//!
5//! **Note:** The following documentation is primarily intended for people to
6//! refer to for development purposes rather than the actual usage of the
7//! application. If you are instead looking for documentation regarding the
8//! *usage* of bottom, refer to [here](https://bottom.pages.dev/stable/).
9
10pub(crate) mod app;
11pub(crate) mod components;
12mod utils {
13    pub(crate) mod cancellation_token;
14    pub(crate) mod conversion;
15    pub(crate) mod data_units;
16    pub(crate) mod general;
17    pub(crate) mod input;
18    pub(crate) mod int_hash;
19    pub(crate) mod logging;
20    pub(crate) mod process_killer;
21    pub(crate) mod strings;
22}
23pub(crate) mod canvas;
24pub(crate) mod collection;
25pub(crate) mod constants;
26pub(crate) mod event;
27pub mod options;
28pub mod widgets;
29
30use std::{
31    boxed::Box,
32    io::{Stdout, Write, stderr, stdout},
33    panic::{self, PanicHookInfo},
34    sync::{
35        Arc,
36        mpsc::{self, Receiver, Sender},
37    },
38    thread::{self, JoinHandle},
39    time::{Duration, Instant},
40};
41
42use app::{App, AppConfigFields, DataFilters, layout_manager::UsedWidgets};
43use crossterm::{
44    cursor::Show,
45    event::{
46        DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
47        Event, KeyEventKind, MouseEventKind, poll, read,
48    },
49    execute,
50    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
51};
52use event::{BottomEvent, CollectionThreadEvent, handle_key_event_or_break, handle_mouse_event};
53use options::{args, get_or_create_config, init_app};
54use ratatui::{Terminal, backend::CrosstermBackend, prelude::Backend};
55#[allow(unused_imports, reason = "this is needed if logging is enabled")]
56use utils::logging::*;
57use utils::{cancellation_token::CancellationToken, conversion::*};
58
59use crate::collection::Data;
60
61// Used for heap allocation debugging purposes.
62// #[global_allocator]
63// static ALLOC: dhat::Alloc = dhat::Alloc;
64
65/// Try drawing. If not, clean up the terminal and return an error.
66fn try_drawing(
67    terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &mut App, painter: &mut canvas::Painter,
68) -> anyhow::Result<()> {
69    if let Err(err) = painter.draw_data(terminal, app) {
70        cleanup_terminal(terminal)?;
71        Err(err.into())
72    } else {
73        Ok(())
74    }
75}
76
77/// Clean up the terminal before returning it to the user.
78fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> anyhow::Result<()> {
79    disable_raw_mode()?;
80
81    execute!(
82        terminal.backend_mut(),
83        DisableMouseCapture,
84        DisableBracketedPaste,
85        LeaveAlternateScreen,
86    )?;
87    terminal.show_cursor()?;
88
89    Ok(())
90}
91
92/// Check and report to the user if the current environment is not a terminal.
93fn check_if_terminal() {
94    use crossterm::tty::IsTty;
95
96    if !stdout().is_tty() {
97        eprintln!(
98            "Warning: bottom is not being output to a terminal. Things might not work properly."
99        );
100        eprintln!("If you're stuck, press 'q', 'Q', or 'Ctrl-c' to quit the program.");
101        stderr().flush().expect("should succeed in flushing stderr");
102        thread::sleep(Duration::from_secs(1));
103    }
104}
105
106/// This manually resets stdout back to normal state.
107pub fn reset_stdout() {
108    let mut stdout = stdout();
109    let _ = disable_raw_mode();
110    let _ = execute!(
111        stdout,
112        DisableMouseCapture,
113        DisableBracketedPaste,
114        LeaveAlternateScreen,
115        Show,
116    );
117}
118
119/// A panic hook to properly restore the terminal in the case of a panic.
120/// Originally based on [spotify-tui's implementation](https://github.com/Rigellute/spotify-tui/blob/master/src/main.rs).
121fn panic_hook(panic_info: &PanicHookInfo<'_>) {
122    let msg = match panic_info.payload().downcast_ref::<&'static str>() {
123        Some(s) => *s,
124        None => match panic_info.payload().downcast_ref::<String>() {
125            Some(s) => &s[..],
126            None => "Box<Any>",
127        },
128    };
129
130    let backtrace = format!("{:?}", std::backtrace::Backtrace::capture());
131
132    reset_stdout();
133
134    // Print stack trace. Must be done after!
135    if let Some(panic_info) = panic_info.location() {
136        println!("thread '<unnamed>' panicked at '{msg}', {panic_info}\n\r{backtrace}")
137    }
138
139    // TODO: Might be cleaner in the future to use a cancellation token, but that
140    // causes some fun issues with lifetimes; for now if it panics then shut
141    // down the main program entirely ASAP.
142    std::process::exit(1);
143}
144
145/// Create a thread to poll for user inputs and forward them to the main thread.
146fn create_input_thread(
147    sender: Sender<BottomEvent>, cancellation_token: Arc<CancellationToken>,
148    app_config_fields: &AppConfigFields,
149) -> JoinHandle<()> {
150    let keys_disabled = app_config_fields.disable_keys;
151
152    thread::spawn(move || {
153        let mut mouse_timer = Instant::now();
154
155        loop {
156            // We don't block.
157            if let Some(is_terminated) = cancellation_token.try_check()
158                && is_terminated
159            {
160                break;
161            }
162
163            if let Ok(poll) = poll(Duration::from_millis(20))
164                && poll
165                && let Ok(event) = read()
166            {
167                match event {
168                    Event::Resize(_, _) => {
169                        // TODO: Might want to debounce this in the future, or take into
170                        // account the actual resize values.
171                        // Maybe we want to keep the current implementation in case the
172                        // resize event might not fire...
173                        // not sure.
174
175                        if sender.send(BottomEvent::Resize).is_err() {
176                            break;
177                        }
178                    }
179                    Event::Paste(paste) => {
180                        if sender.send(BottomEvent::PasteEvent(paste)).is_err() {
181                            break;
182                        }
183                    }
184                    Event::Key(key) if !keys_disabled && key.kind == KeyEventKind::Press => {
185                        // For now, we only care about key down events. This may change in
186                        // the future.
187                        if sender.send(BottomEvent::KeyInput(key)).is_err() {
188                            break;
189                        }
190                    }
191                    Event::Mouse(mouse) => match mouse.kind {
192                        MouseEventKind::Moved | MouseEventKind::Drag(..) => {}
193                        MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
194                            if Instant::now().duration_since(mouse_timer).as_millis() >= 20 {
195                                if sender.send(BottomEvent::MouseInput(mouse)).is_err() {
196                                    break;
197                                }
198                                mouse_timer = Instant::now();
199                            }
200                        }
201                        _ => {
202                            if sender.send(BottomEvent::MouseInput(mouse)).is_err() {
203                                break;
204                            }
205                        }
206                    },
207                    Event::Key(_) => {}
208                    Event::FocusGained => {}
209                    Event::FocusLost => {}
210                }
211            }
212        }
213    })
214}
215
216/// Create a thread to handle data collection.
217fn create_collection_thread(
218    sender: Sender<BottomEvent>, control_receiver: Receiver<CollectionThreadEvent>,
219    cancellation_token: Arc<CancellationToken>, app_config_fields: &AppConfigFields,
220    filters: DataFilters, used_widget_set: UsedWidgets,
221) -> JoinHandle<()> {
222    let use_current_cpu_total = app_config_fields.use_current_cpu_total;
223    let unnormalized_cpu = app_config_fields.unnormalized_cpu;
224    let show_average_cpu = app_config_fields.show_average_cpu;
225    let update_sleep = app_config_fields.update_rate;
226    let get_process_threads = app_config_fields.get_process_threads;
227    #[cfg(feature = "zfs")]
228    let get_arc_free = app_config_fields.free_arc;
229    let include_unmounted_disks =
230        app_config_fields.disk_show_unmounted || app_config_fields.disk_io_graph_show_unmounted;
231
232    thread::spawn(move || {
233        let mut data_collector = collection::DataCollector::new(filters);
234
235        data_collector.set_collection(used_widget_set);
236        data_collector.set_use_current_cpu_total(use_current_cpu_total);
237        data_collector.set_unnormalized_cpu(unnormalized_cpu);
238        data_collector.set_show_average_cpu(show_average_cpu);
239        data_collector.set_get_process_threads(get_process_threads);
240        #[cfg(feature = "zfs")]
241        data_collector.set_free_arc_mem(get_arc_free);
242        data_collector.set_include_unmounted_disks(include_unmounted_disks);
243
244        data_collector.update_data();
245        data_collector.data = Data::default();
246
247        // Tiny sleep I guess? To go between the first update above and the first update
248        // in the loop.
249        std::thread::sleep(Duration::from_millis(5));
250
251        loop {
252            // Check once at the very top... don't block though.
253            if let Some(is_terminated) = cancellation_token.try_check()
254                && is_terminated
255            {
256                break;
257            }
258
259            if let Ok(message) = control_receiver.try_recv() {
260                // trace!("Received message in collection thread: {message:?}");
261                match message {
262                    CollectionThreadEvent::Reset => {
263                        data_collector.data.cleanup();
264                    }
265                }
266            }
267
268            data_collector.update_data();
269
270            // Yet another check to bail if needed... do not block!
271            if let Some(is_terminated) = cancellation_token.try_check()
272                && is_terminated
273            {
274                break;
275            }
276
277            let event = BottomEvent::Update(Box::from(data_collector.data));
278            data_collector.data = Data::default();
279
280            if sender.send(event).is_err() {
281                break;
282            }
283
284            // Sleep while allowing for interruptions...
285            if cancellation_token.sleep_with_cancellation(Duration::from_millis(update_sleep)) {
286                break;
287            }
288        }
289    })
290}
291
292/// Main code to call to start bottom.
293#[inline]
294pub fn start_bottom(enable_error_hook: &mut bool) -> anyhow::Result<()> {
295    // let _profiler = dhat::Profiler::new_heap();
296
297    let args = args::get_args();
298
299    #[cfg(feature = "logging")]
300    {
301        if let Err(err) = init_logger(
302            log::LevelFilter::Debug,
303            Some(std::ffi::OsStr::new("debug.log")),
304        ) {
305            println!("Issue initializing logger: {err}");
306        }
307    }
308
309    // Read from config file.
310    let config = get_or_create_config(args.general.config_location.as_deref())?;
311
312    // Create the "app" and initialize a bunch of stuff.
313    let (mut app, widget_layout, styling) = init_app(args, config)?;
314
315    // Create painter and set colours.
316    let mut painter = canvas::Painter::init(widget_layout, styling)?;
317
318    // Check if the current environment is in a terminal.
319    check_if_terminal();
320
321    let cancellation_token = Arc::new(CancellationToken::default());
322    let (sender, receiver) = mpsc::channel();
323
324    // Set up the event loop thread; we set this up early to speed up
325    // first-time-to-data.
326    let (collection_thread_ctrl_sender, collection_thread_ctrl_receiver) = mpsc::channel();
327    let _collection_thread = create_collection_thread(
328        sender.clone(),
329        collection_thread_ctrl_receiver,
330        cancellation_token.clone(),
331        &app.app_config_fields,
332        app.filters.clone(),
333        app.used_widgets,
334    );
335
336    // Set up the input handling loop thread.
337    let _input_thread = create_input_thread(
338        sender.clone(),
339        cancellation_token.clone(),
340        &app.app_config_fields,
341    );
342
343    // Set up the cleaning loop thread.
344    let _cleaning_thread = {
345        let cancellation_token = cancellation_token.clone();
346        let cleaning_sender = sender.clone();
347        let offset_wait = Duration::from_millis(app.app_config_fields.retention_ms + 60000);
348        thread::spawn(move || {
349            loop {
350                if cancellation_token.sleep_with_cancellation(offset_wait) {
351                    break;
352                }
353
354                if cleaning_sender.send(BottomEvent::Clean).is_err() {
355                    break;
356                }
357            }
358        })
359    };
360
361    // Set up tui and crossterm
362    *enable_error_hook = true;
363
364    let mut stdout_val = stdout();
365    execute!(stdout_val, EnterAlternateScreen, EnableBracketedPaste)?;
366    if app.app_config_fields.disable_click {
367        execute!(stdout_val, DisableMouseCapture)?;
368    } else {
369        execute!(stdout_val, EnableMouseCapture)?;
370    }
371    enable_raw_mode()?;
372
373    let mut terminal = Terminal::new(CrosstermBackend::new(stdout_val))?;
374
375    // This may fail in some environments, like tests, since it may fail to get the cursor position.
376    // In that case, fall back to just manually clearing it with backend.
377    if terminal.clear().is_err() {
378        terminal.backend_mut().clear()?;
379    }
380
381    terminal.hide_cursor()?;
382
383    #[cfg(target_os = "freebsd")]
384    let _stderr_fd = {
385        // A really ugly band-aid to suppress stderr warnings on FreeBSD due to sysinfo.
386        // For more information, see https://github.com/ClementTsang/bottom/issues/798.
387        use std::fs::OpenOptions;
388
389        use filedescriptor::{FileDescriptor, StdioDescriptor};
390
391        let path = OpenOptions::new().write(true).open("/dev/null")?;
392        FileDescriptor::redirect_stdio(&path, StdioDescriptor::Stderr)?
393    };
394
395    // Set panic hook
396    panic::set_hook(Box::new(panic_hook));
397
398    // Set termination hook
399    ctrlc::set_handler(move || {
400        // TODO: Consider using signal-hook (https://github.com/vorner/signal-hook) to handle
401        // more types of signals?
402        let _ = sender.send(BottomEvent::Terminate);
403    })?;
404
405    let mut first_run = true;
406
407    // Draw once first to initialize the canvas, so it doesn't feel like it's
408    // frozen.
409    try_drawing(&mut terminal, &mut app, &mut painter)?;
410
411    loop {
412        if let Ok(recv) = receiver.recv() {
413            match recv {
414                BottomEvent::Terminate => break,
415                BottomEvent::Resize => {
416                    try_drawing(&mut terminal, &mut app, &mut painter)?;
417                }
418                BottomEvent::KeyInput(event) => {
419                    if handle_key_event_or_break(event, &mut app, &collection_thread_ctrl_sender) {
420                        break;
421                    }
422                    app.update_data();
423                    try_drawing(&mut terminal, &mut app, &mut painter)?;
424                }
425                BottomEvent::MouseInput(event) => {
426                    handle_mouse_event(event, &mut app);
427                    app.update_data();
428                    try_drawing(&mut terminal, &mut app, &mut painter)?;
429                }
430                BottomEvent::PasteEvent(paste) => {
431                    app.handle_paste(paste);
432                    app.update_data();
433                    try_drawing(&mut terminal, &mut app, &mut painter)?;
434                }
435                BottomEvent::Update(data) => {
436                    app.data_store.eat_data(data, &app.app_config_fields);
437
438                    // This thing is required as otherwise, some widgets can't draw correctly w/o
439                    // some data (or they need to be re-drawn).
440                    if first_run {
441                        first_run = false;
442                        app.is_force_redraw = true;
443                    }
444
445                    if !app.data_store.is_frozen() {
446                        // Convert all data into data for the displayed widgets.
447
448                        if app.used_widgets.use_disk {
449                            for disk in app.states.disk_state.widget_states.values_mut() {
450                                disk.force_data_update();
451                            }
452                        }
453
454                        if app.used_widgets.use_temp {
455                            for temp in app.states.temp_state.widget_states.values_mut() {
456                                temp.force_data_update();
457                            }
458                        }
459
460                        if app.used_widgets.use_proc {
461                            for proc in app.states.proc_state.widget_states.values_mut() {
462                                proc.force_data_update();
463                            }
464                        }
465
466                        if app.used_widgets.use_cpu {
467                            for cpu in app.states.cpu_state.widget_states.values_mut() {
468                                cpu.force_data_update();
469                            }
470                        }
471
472                        app.update_data();
473                        try_drawing(&mut terminal, &mut app, &mut painter)?;
474                    }
475                }
476                BottomEvent::Clean => {
477                    app.data_store
478                        .clean_data(Duration::from_millis(app.app_config_fields.retention_ms));
479                }
480            }
481        }
482    }
483
484    // I think doing it in this order is safe...
485    // TODO: maybe move the cancellation token to the ctrl-c handler?
486    cancellation_token.cancel();
487    cleanup_terminal(&mut terminal)?;
488
489    Ok(())
490}