#![warn(rust_2018_idioms)] #[allow(unused_imports)] #[macro_use] extern crate log; use std::{ boxed::Box, io::{stdout, Write}, panic::{self, PanicInfo}, sync::mpsc, thread, time::{Duration, Instant}, }; use clap::*; use crossterm::{ event::{ poll, read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, }, execute, style::Print, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use tui::{backend::CrosstermBackend, Terminal}; use app::{ data_harvester::{self, processes::ProcessSorting}, layout_manager::{UsedWidgets, WidgetDirection}, App, }; use constants::*; use data_conversion::*; use options::*; use utils::error; pub mod app; mod utils { pub mod error; pub mod gen_util; pub mod logging; } mod canvas; mod constants; mod data_conversion; pub mod options; enum BottomEvent { KeyInput(I), MouseInput(J), Update(Box), Clean, } enum ResetEvent { Reset, } fn get_matches() -> clap::ArgMatches<'static> { clap_app!(app => (name: crate_name!()) (version: crate_version!()) (author: crate_authors!()) (about: crate_description!()) (@arg HIDE_AVG_CPU: -a --hide_avg_cpu "Hides the average CPU usage.") (@arg DOT_MARKER: -m --dot_marker "Use a dot marker instead of the default braille marker.") (@group TEMPERATURE_TYPE => (@arg KELVIN : -k --kelvin "Sets the temperature type to Kelvin.") (@arg FAHRENHEIT : -f --fahrenheit "Sets the temperature type to Fahrenheit.") (@arg CELSIUS : -c --celsius "Sets the temperature type to Celsius. This is the default option.") ) (@arg RATE_MILLIS: -r --rate +takes_value "Sets a refresh rate in milliseconds; the minimum is 250ms, defaults to 1000ms. Smaller values may take more resources.") (@arg LEFT_LEGEND: -l --left_legend "Puts external chart legends on the left side rather than the default right side.") (@arg USE_CURR_USAGE: -u --current_usage "Within Linux, sets a process' CPU usage to be based on the total current CPU usage, rather than assuming 100% usage.") (@arg CONFIG_LOCATION: -C --config +takes_value "Sets the location of the config file. Expects a config file in the TOML format. If it doesn't exist, one is created.") (@arg BASIC_MODE: -b --basic "Hides graphs and uses a more basic look") (@arg GROUP_PROCESSES: -g --group "Groups processes with the same name together on launch.") (@arg CASE_SENSITIVE: -S --case_sensitive "Match case when searching by default.") (@arg WHOLE_WORD: -W --whole_word "Match whole word when searching by default.") (@arg REGEX_DEFAULT: -R --regex "Use regex in searching by default.") (@arg DEFAULT_TIME_VALUE: -t --default_time_value +takes_value "Default time value for graphs in milliseconds; minimum is 30s, defaults to 60s.") (@arg TIME_DELTA: -d --time_delta +takes_value "The amount changed upon zooming in/out in milliseconds; minimum is 1s, defaults to 15s.") (@arg HIDE_TIME: --hide_time "Completely hide the time scaling") (@arg AUTOHIDE_TIME: --autohide_time "Automatically hide the time scaling in graphs after being shown for a brief moment when zoomed in/out. If time is disabled via --hide_time then this will have no effect.") (@arg DEFAULT_WIDGET_TYPE: --default_widget_type +takes_value "The default widget type to select by default.") (@arg DEFAULT_WIDGET_COUNT: --default_widget_count +takes_value "Which number of the selected widget type to select, from left to right, top to bottom. Defaults to 1.") (@arg USE_OLD_NETWORK_LEGEND: --use_old_network_legend "Use the older (pre-0.4) network widget legend.") (@arg HIDE_TABLE_GAP: --hide_table_gap "Hides the spacing between the table headers and entries.") (@arg BATTERY: --battery "Shows the battery widget in default or basic mode. No effect on custom layouts.") ) .get_matches() } fn main() -> error::Result<()> { #[cfg(debug_assertions)] { utils::logging::init_logger()?; } let matches = get_matches(); let config: Config = create_config(matches.value_of("CONFIG_LOCATION"))?; // Get widget layout separately let (widget_layout, default_widget_id) = get_widget_layout(&matches, &config)?; // Create "app" struct, which will control most of the program and store settings/state let mut app = build_app(&matches, &config, &widget_layout, default_widget_id)?; // Create painter and set colours. let mut painter = canvas::Painter::init(widget_layout, app.app_config_fields.table_gap); generate_config_colours(&config, &mut painter)?; painter.colours.generate_remaining_cpu_colours(); painter.complete_painter_init(); // Set up input handling let (sender, receiver) = mpsc::channel(); create_input_thread(sender.clone()); // Cleaning loop { let cleaning_sender = sender.clone(); thread::spawn(move || loop { thread::sleep(Duration::from_millis( constants::STALE_MAX_MILLISECONDS + 5000, )); if cleaning_sender.send(BottomEvent::Clean).is_err() { break; } }); } // Event loop let (reset_sender, reset_receiver) = mpsc::channel(); create_event_thread( sender, reset_receiver, app.app_config_fields.use_current_cpu_total, app.app_config_fields.update_rate_in_milliseconds, app.app_config_fields.temperature_type.clone(), app.app_config_fields.show_average_cpu, app.used_widgets.clone(), ); // Set up up tui and crossterm let mut stdout_val = stdout(); execute!(stdout_val, EnterAlternateScreen, EnableMouseCapture)?; enable_raw_mode()?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout_val))?; terminal.hide_cursor()?; // Set panic hook panic::set_hook(Box::new(|info| panic_hook(info))); loop { if let Ok(recv) = receiver.recv_timeout(Duration::from_millis(TICK_RATE_IN_MILLISECONDS)) { match recv { BottomEvent::KeyInput(event) => { if handle_key_event_or_break(event, &mut app, &reset_sender) { break; } handle_force_redraws(&mut app); } BottomEvent::MouseInput(event) => { handle_mouse_event(event, &mut app); handle_force_redraws(&mut app); } BottomEvent::Update(data) => { app.data_collection.eat_data(&data); if !app.is_frozen { // Convert all data into tui-compliant components // Network if app.used_widgets.use_net { let network_data = convert_network_data_points( &app.data_collection, false, app.app_config_fields.use_basic_mode || app.app_config_fields.use_old_network_legend, ); app.canvas_data.network_data_rx = network_data.rx; app.canvas_data.network_data_tx = network_data.tx; app.canvas_data.rx_display = network_data.rx_display; app.canvas_data.tx_display = network_data.tx_display; if let Some(total_rx_display) = network_data.total_rx_display { app.canvas_data.total_rx_display = total_rx_display; } if let Some(total_tx_display) = network_data.total_tx_display { app.canvas_data.total_tx_display = total_tx_display; } } // Disk if app.used_widgets.use_disk { app.canvas_data.disk_data = convert_disk_row(&app.data_collection); } // Temperatures if app.used_widgets.use_temp { app.canvas_data.temp_sensor_data = convert_temp_row(&app); } // Memory if app.used_widgets.use_mem { app.canvas_data.mem_data = convert_mem_data_points(&app.data_collection, false); app.canvas_data.swap_data = convert_swap_data_points(&app.data_collection, false); let memory_and_swap_labels = convert_mem_labels(&app.data_collection); app.canvas_data.mem_label = memory_and_swap_labels.0; app.canvas_data.swap_label = memory_and_swap_labels.1; } if app.used_widgets.use_cpu { // CPU app.canvas_data.cpu_data = convert_cpu_data_points(&app.data_collection, false); } // Processes if app.used_widgets.use_proc { update_all_process_lists(&mut app); } // Battery if app.used_widgets.use_battery { app.canvas_data.battery_data = convert_battery_harvest(&app.data_collection); } } } BottomEvent::Clean => { app.data_collection .clean_data(constants::STALE_MAX_MILLISECONDS); } } } // TODO: [OPT] Should not draw if no change (ie: scroll max) try_drawing(&mut terminal, &mut app, &mut painter)?; } cleanup_terminal(&mut terminal)?; Ok(()) } fn handle_mouse_event(event: MouseEvent, app: &mut App) { match event { MouseEvent::ScrollUp(_x, _y, _modifiers) => app.handle_scroll_up(), MouseEvent::ScrollDown(_x, _y, _modifiers) => app.handle_scroll_down(), _ => {} }; } fn handle_key_event_or_break( event: KeyEvent, app: &mut App, reset_sender: &std::sync::mpsc::Sender, ) -> bool { // debug!("KeyEvent: {:?}", event); // TODO: [PASTE] Note that this does NOT support some emojis like flags. This is due to us // catching PER CHARACTER right now WITH A forced throttle! This means multi-char will not work. // We can solve this (when we do paste probably) while keeping the throttle (mainly meant for movement) // by throttling after *bulk+singular* actions, not just singular ones. if event.modifiers.is_empty() { // Required catch for searching - otherwise you couldn't search with q. if event.code == KeyCode::Char('q') && !app.is_in_search_widget() { return true; } match event.code { KeyCode::End => app.skip_to_last(), KeyCode::Home => app.skip_to_first(), KeyCode::Up => app.on_up_key(), KeyCode::Down => app.on_down_key(), KeyCode::Left => app.on_left_key(), KeyCode::Right => app.on_right_key(), KeyCode::Char(caught_char) => app.on_char_key(caught_char), KeyCode::Esc => app.on_esc(), KeyCode::Enter => app.on_enter(), KeyCode::Tab => app.on_tab(), KeyCode::Backspace => app.on_backspace(), KeyCode::Delete => app.on_delete(), KeyCode::F(1) => app.toggle_ignore_case(), KeyCode::F(2) => app.toggle_search_whole_word(), KeyCode::F(3) => app.toggle_search_regex(), KeyCode::F(6) => app.toggle_sort(), _ => {} } } else { // Otherwise, track the modifier as well... if let KeyModifiers::ALT = event.modifiers { match event.code { KeyCode::Char('c') | KeyCode::Char('C') => app.toggle_ignore_case(), KeyCode::Char('w') | KeyCode::Char('W') => app.toggle_search_whole_word(), KeyCode::Char('r') | KeyCode::Char('R') => app.toggle_search_regex(), KeyCode::Char('h') => app.on_left_key(), KeyCode::Char('l') => app.on_right_key(), _ => {} } } else if let KeyModifiers::CONTROL = event.modifiers { if event.code == KeyCode::Char('c') { return true; } match event.code { KeyCode::Char('f') => app.on_slash(), KeyCode::Left => app.move_widget_selection(&WidgetDirection::Left), KeyCode::Right => app.move_widget_selection(&WidgetDirection::Right), KeyCode::Up => app.move_widget_selection(&WidgetDirection::Up), KeyCode::Down => app.move_widget_selection(&WidgetDirection::Down), KeyCode::Char('r') => { if reset_sender.send(ResetEvent::Reset).is_ok() { app.reset(); } } KeyCode::Char('a') => app.skip_cursor_beginning(), KeyCode::Char('e') => app.skip_cursor_end(), KeyCode::Char('u') => app.clear_search(), // KeyCode::Char('j') => {}, // Move down // KeyCode::Char('k') => {}, // Move up // KeyCode::Char('h') => {}, // Move right // KeyCode::Char('l') => {}, // Move left // Can't do now, CTRL+BACKSPACE doesn't work and graphemes // are hard to iter while truncating last (eloquently). // KeyCode::Backspace => app.skip_word_backspace(), _ => {} } } else if let KeyModifiers::SHIFT = event.modifiers { match event.code { KeyCode::Left => app.move_widget_selection(&WidgetDirection::Left), KeyCode::Right => app.move_widget_selection(&WidgetDirection::Right), KeyCode::Up => app.move_widget_selection(&WidgetDirection::Up), KeyCode::Down => app.move_widget_selection(&WidgetDirection::Down), KeyCode::Char(caught_char) => app.on_char_key(caught_char), _ => {} } } } false } fn create_config(flag_config_location: Option<&str>) -> error::Result { use std::{ffi::OsString, fs}; let config_path = if let Some(conf_loc) = flag_config_location { Some(OsString::from(conf_loc)) } else if cfg!(target_os = "windows") { if let Some(home_path) = dirs::config_dir() { let mut path = home_path; path.push(DEFAULT_CONFIG_FILE_PATH); Some(path.into_os_string()) } else { None } } else if let Some(home_path) = dirs::home_dir() { let mut path = home_path; path.push(".config/"); path.push(DEFAULT_CONFIG_FILE_PATH); if path.exists() { // If it already exists, use the old one. Some(path.into_os_string()) } else { // If it does not, use the new one! if let Some(config_path) = dirs::config_dir() { let mut path = config_path; path.push(DEFAULT_CONFIG_FILE_PATH); Some(path.into_os_string()) } else { None } } } else { None }; if let Some(config_path) = config_path { let path = std::path::Path::new(&config_path); if let Ok(config_string) = fs::read_to_string(path) { Ok(toml::from_str(config_string.as_str())?) } else { if let Some(parent_path) = path.parent() { fs::create_dir_all(parent_path)?; } fs::File::create(path)?.write_all(DEFAULT_CONFIG_CONTENT.as_bytes())?; Ok(toml::from_str(DEFAULT_CONFIG_CONTENT)?) } } else { // Don't write otherwise... Ok(toml::from_str(DEFAULT_CONFIG_CONTENT)?) } } fn try_drawing( terminal: &mut tui::terminal::Terminal>, app: &mut App, painter: &mut canvas::Painter, ) -> error::Result<()> { if let Err(err) = painter.draw_data(terminal, app) { cleanup_terminal(terminal)?; return Err(err); } Ok(()) } fn cleanup_terminal( terminal: &mut tui::terminal::Terminal>, ) -> error::Result<()> { disable_raw_mode()?; execute!( terminal.backend_mut(), DisableMouseCapture, LeaveAlternateScreen )?; terminal.show_cursor()?; Ok(()) } fn generate_config_colours(config: &Config, painter: &mut canvas::Painter) -> error::Result<()> { if let Some(colours) = &config.colors { if let Some(border_color) = &colours.border_color { painter.colours.set_border_colour(border_color)?; } if let Some(highlighted_border_color) = &colours.highlighted_border_color { painter .colours .set_highlighted_border_colour(highlighted_border_color)?; } if let Some(text_color) = &colours.text_color { painter.colours.set_text_colour(text_color)?; } if let Some(avg_cpu_color) = &colours.avg_cpu_color { painter.colours.set_avg_cpu_colour(avg_cpu_color)?; } if let Some(all_cpu_color) = &colours.all_cpu_color { painter.colours.set_all_cpu_colour(all_cpu_color)?; } if let Some(cpu_core_colors) = &colours.cpu_core_colors { painter.colours.set_cpu_colours(cpu_core_colors)?; } if let Some(ram_color) = &colours.ram_color { painter.colours.set_ram_colour(ram_color)?; } if let Some(swap_color) = &colours.swap_color { painter.colours.set_swap_colour(swap_color)?; } if let Some(rx_color) = &colours.rx_color { painter.colours.set_rx_colour(rx_color)?; } if let Some(tx_color) = &colours.tx_color { painter.colours.set_tx_colour(tx_color)?; } // if let Some(rx_total_color) = &colours.rx_total_color { // painter.colours.set_rx_total_colour(rx_total_color)?; // } // if let Some(tx_total_color) = &colours.tx_total_color { // painter.colours.set_tx_total_colour(tx_total_color)?; // } if let Some(table_header_color) = &colours.table_header_color { painter .colours .set_table_header_colour(table_header_color)?; } if let Some(scroll_entry_text_color) = &colours.selected_text_color { painter .colours .set_scroll_entry_text_color(scroll_entry_text_color)?; } if let Some(scroll_entry_bg_color) = &colours.selected_bg_color { painter .colours .set_scroll_entry_bg_color(scroll_entry_bg_color)?; } if let Some(widget_title_color) = &colours.widget_title_color { painter .colours .set_widget_title_colour(widget_title_color)?; } if let Some(graph_color) = &colours.graph_color { painter.colours.set_graph_colour(graph_color)?; } if let Some(battery_colors) = &colours.battery_colors { painter.colours.set_battery_colours(battery_colors)?; } } Ok(()) } /// Based on https://github.com/Rigellute/spotify-tui/blob/master/src/main.rs fn panic_hook(panic_info: &PanicInfo<'_>) { let mut stdout = stdout(); let msg = match panic_info.payload().downcast_ref::<&'static str>() { Some(s) => *s, None => match panic_info.payload().downcast_ref::() { Some(s) => &s[..], None => "Box", }, }; let stacktrace: String = format!("{:?}", backtrace::Backtrace::new()); disable_raw_mode().unwrap(); execute!(stdout, LeaveAlternateScreen, DisableMouseCapture).unwrap(); // Print stack trace. Must be done after! execute!( stdout, Print(format!( "thread '' panicked at '{}', {}\n\r{}", msg, panic_info.location().unwrap(), stacktrace )), ) .unwrap(); } fn handle_force_redraws(app: &mut App) { // Currently we use an Option... because we might want to future-proof this // if we eventually get widget-specific redrawing! if app.proc_state.force_update_all { update_all_process_lists(app); app.proc_state.force_update_all = false; } else if let Some(widget_id) = app.proc_state.force_update { update_final_process_list(app, widget_id); app.proc_state.force_update = None; } if app.cpu_state.force_update.is_some() { app.canvas_data.cpu_data = convert_cpu_data_points(&app.data_collection, app.is_frozen); app.cpu_state.force_update = None; } if app.mem_state.force_update.is_some() { app.canvas_data.mem_data = convert_mem_data_points(&app.data_collection, app.is_frozen); app.canvas_data.swap_data = convert_swap_data_points(&app.data_collection, app.is_frozen); app.mem_state.force_update = None; } if app.net_state.force_update.is_some() { let (rx, tx) = get_rx_tx_data_points(&app.data_collection, app.is_frozen); app.canvas_data.network_data_rx = rx; app.canvas_data.network_data_tx = tx; app.net_state.force_update = None; } } fn update_all_process_lists(app: &mut App) { let widget_ids = app .proc_state .widget_states .keys() .cloned() .collect::>(); widget_ids.into_iter().for_each(|widget_id| { update_final_process_list(app, widget_id); }); } fn update_final_process_list(app: &mut App, widget_id: u64) { let is_invalid_or_blank = match app.proc_state.widget_states.get(&widget_id) { Some(process_state) => process_state .process_search_state .search_state .is_invalid_or_blank_search(), None => false, }; let is_grouped = app.is_grouped(widget_id); if let Some(proc_widget_state) = app.proc_state.get_mut_widget_state(widget_id) { app.canvas_data.process_data = convert_process_data( &app.data_collection, if is_grouped { ProcessGroupingType::Grouped } else { ProcessGroupingType::Ungrouped }, if proc_widget_state.is_using_command { ProcessNamingType::Path } else { ProcessNamingType::Name }, ); } let process_filter = app.get_process_filter(widget_id); let filtered_process_data: Vec = app .canvas_data .process_data .iter() .filter(|process| { if !is_invalid_or_blank { if let Some(process_filter) = process_filter { process_filter.check(&process) } else { true } } else { true } }) .cloned() .collect::>(); // Quick fix for tab updating the table headers if let Some(proc_widget_state) = app.proc_state.get_mut_widget_state(widget_id) { if let data_harvester::processes::ProcessSorting::Pid = proc_widget_state.process_sorting_type { if proc_widget_state.is_grouped { proc_widget_state.process_sorting_type = data_harvester::processes::ProcessSorting::CpuPercent; // Go back to default, negate PID for group proc_widget_state.process_sorting_reverse = true; } } let mut resulting_processes = filtered_process_data; sort_process_data(&mut resulting_processes, proc_widget_state); if proc_widget_state.scroll_state.current_scroll_position >= resulting_processes.len() { proc_widget_state.scroll_state.current_scroll_position = resulting_processes.len().saturating_sub(1); proc_widget_state.scroll_state.previous_scroll_position = 0; proc_widget_state.scroll_state.scroll_direction = app::ScrollDirection::Down; } app.canvas_data .finalized_process_data_map .insert(widget_id, resulting_processes); } } fn sort_process_data( to_sort_vec: &mut Vec, proc_widget_state: &app::ProcWidgetState, ) { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering(&a.name.to_lowercase(), &b.name.to_lowercase(), false) }); match proc_widget_state.process_sorting_type { ProcessSorting::CpuPercent => { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( a.cpu_usage, b.cpu_usage, proc_widget_state.process_sorting_reverse, ) }); } ProcessSorting::Mem => { // TODO: Do when I do mem values in processes } ProcessSorting::MemPercent => { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( a.mem_usage, b.mem_usage, proc_widget_state.process_sorting_reverse, ) }); } ProcessSorting::ProcessName | ProcessSorting::Command => { // Don't repeat if false... it sorts by name by default anyways. if proc_widget_state.process_sorting_reverse { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( &a.name.to_lowercase(), &b.name.to_lowercase(), proc_widget_state.process_sorting_reverse, ) }) } } ProcessSorting::Pid => { if !proc_widget_state.is_grouped { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( a.pid, b.pid, proc_widget_state.process_sorting_reverse, ) }); } } ProcessSorting::ReadPerSecond => { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( a.rps_f64, b.rps_f64, proc_widget_state.process_sorting_reverse, ) }); } ProcessSorting::WritePerSecond => { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( a.wps_f64, b.wps_f64, proc_widget_state.process_sorting_reverse, ) }); } ProcessSorting::TotalRead => { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( a.tr_f64, b.tr_f64, proc_widget_state.process_sorting_reverse, ) }); } ProcessSorting::TotalWrite => { to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( a.tw_f64, b.tw_f64, proc_widget_state.process_sorting_reverse, ) }); } ProcessSorting::State => to_sort_vec.sort_by(|a, b| { utils::gen_util::get_ordering( &a.process_state.to_lowercase(), &b.process_state.to_lowercase(), proc_widget_state.process_sorting_reverse, ) }), } } fn create_input_thread( sender: std::sync::mpsc::Sender< BottomEvent, >, ) { thread::spawn(move || { let mut mouse_timer = Instant::now(); let mut keyboard_timer = Instant::now(); loop { if poll(Duration::from_millis(20)).is_ok() { if let Ok(event) = read() { if let Event::Key(key) = event { if Instant::now().duration_since(keyboard_timer).as_millis() >= 20 { if sender.send(BottomEvent::KeyInput(key)).is_err() { return; } keyboard_timer = Instant::now(); } } else if let Event::Mouse(mouse) = event { if Instant::now().duration_since(mouse_timer).as_millis() >= 20 { if sender.send(BottomEvent::MouseInput(mouse)).is_err() { return; } mouse_timer = Instant::now(); } } } } } }); } fn create_event_thread( sender: std::sync::mpsc::Sender< BottomEvent, >, reset_receiver: std::sync::mpsc::Receiver, use_current_cpu_total: bool, update_rate_in_milliseconds: u64, temp_type: data_harvester::temperature::TemperatureType, show_average_cpu: bool, used_widget_set: UsedWidgets, ) { thread::spawn(move || { let mut data_state = data_harvester::DataCollector::default(); data_state.set_collected_data(used_widget_set); data_state.set_temperature_type(temp_type); data_state.set_use_current_cpu_total(use_current_cpu_total); data_state.set_show_average_cpu(show_average_cpu); data_state.init(); loop { if let Ok(message) = reset_receiver.try_recv() { match message { ResetEvent::Reset => { data_state.data.first_run_cleanup(); } } } futures::executor::block_on(data_state.update_data()); let event = BottomEvent::Update(Box::from(data_state.data)); data_state.data = data_harvester::Data::default(); if sender.send(event).is_err() { break; } thread::sleep(Duration::from_millis(update_rate_in_milliseconds)); } }); }