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