refactor: tables V2 (#749)

* refactor: move to new data table implementation

* more work towards refactor

* move disk and temp over, fix longstanding bug with disk and temp if removing the last value and selected

* work towards porting over CPU

work towards porting over CPU

fix typo

partially port over cpu, fix some potentially inefficient concat_string calls

more work towards cpu widget migration

some refactoring

* sortable data

sortable data

more refactoring

some sort refactoring

more refactoringgggg

column refactoring

renaming and reorganizing

more refactoring regarding column logic

add sort arrows again

* move over sort menu

* port over process

port over process

precommit

temp

temp two, remember to squash

work

fix broken ltr calculation and CPU hiding

add back row styling

temp

fix a bunch of issues, get proc working

more fixes around click

fix frozen issues

* fix dd process killing

* revert some of the persistent config changes from #257

* fix colouring for trees

* fix missing entries in tree

* keep columns if there is no data

* add and remove tests

* Fix ellipsis
This commit is contained in:
Clement Tsang 2022-10-12 16:25:38 -04:00 committed by GitHub
parent 1e5f0ea2d9
commit 2a740f48f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 3797 additions and 3505 deletions

10
Cargo.lock generated
View file

@ -225,6 +225,7 @@ dependencies = [
"heim",
"indexmap",
"itertools",
"kstring",
"libc",
"log",
"mach2",
@ -906,6 +907,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
[[package]]
name = "kstring"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3066350882a1cd6d950d055997f379ac37fd39f81cd4d8ed186032eb3c5747"
dependencies = [
"static_assertions",
]
[[package]]
name = "lazy_static"
version = "1.4.0"

View file

@ -67,6 +67,7 @@ futures-timer = "3.0.2"
fxhash = "0.2.1"
indexmap = "1.8.1"
itertools = "0.10.3"
kstring = { version = "2.0.0", features = ["arc"] }
log = { version = "0.4.16", optional = true }
nvml-wrapper = { version = "0.7.0", optional = true }
once_cell = "1.5.2"

View file

@ -1,2 +1,3 @@
cognitive-complexity-threshold = 100
type-complexity-threshold = 500
too-many-arguments-threshold = 8

View file

@ -1,7 +1,6 @@
use std::{
cmp::{max, min},
collections::HashMap,
path::PathBuf,
time::Instant,
};
@ -16,12 +15,8 @@ use layout_manager::*;
pub use states::*;
use crate::{
components::text_table::SortState,
constants,
data_conversion::ConvertedData,
options::Config,
options::ConfigFlags,
options::WidgetIdEnabled,
units::data_units::DataUnit,
utils::error::{BottomError, Result},
Pid,
@ -31,12 +26,15 @@ use self::widgets::{ProcWidget, ProcWidgetMode};
pub mod data_farmer;
pub mod data_harvester;
pub mod frozen_state;
pub mod layout_manager;
mod process_killer;
pub mod query;
pub mod states;
pub mod widgets;
use frozen_state::FrozenState;
const MAX_SEARCH_LENGTH: usize = 200;
#[derive(Debug, Clone)]
@ -45,9 +43,15 @@ pub enum AxisScaling {
Linear,
}
impl Default for AxisScaling {
fn default() -> Self {
AxisScaling::Log
}
}
/// AppConfigFields is meant to cover basic fields that would normally be set
/// by config files or launch options.
#[derive(Debug)]
#[derive(Debug, Default)]
pub struct AppConfigFields {
pub update_rate_in_milliseconds: u64,
pub temperature_type: temperature::TemperatureType,
@ -95,14 +99,15 @@ pub struct App {
#[builder(default, setter(skip))]
second_char: Option<char>,
// FIXME: The way we do deletes is really gross.
#[builder(default, setter(skip))]
pub dd_err: Option<String>,
#[builder(default, setter(skip))]
to_delete_process_list: Option<(String, Vec<Pid>)>,
#[builder(default = false, setter(skip))]
pub is_frozen: bool,
#[builder(default, setter(skip))]
pub frozen_state: FrozenState,
#[builder(default = Instant::now(), setter(skip))]
last_key_press: Instant,
@ -148,8 +153,6 @@ pub struct App {
pub current_widget: BottomWidget,
pub used_widgets: UsedWidgets,
pub filters: DataFilters,
pub config: Config, // TODO: Is this even used...?
pub config_path: Option<PathBuf>, // TODO: Is this even used...?
}
#[cfg(target_os = "windows")]
@ -184,7 +187,7 @@ impl App {
self.dd_err = None;
// Unfreeze.
self.is_frozen = false;
self.frozen_state.thaw();
// Reset zoom
self.reset_cpu_zoom();
@ -293,26 +296,14 @@ impl App {
// Allow usage whilst only in processes
if !self.ignore_normal_keybinds() {
match self.current_widget.widget_type {
BottomWidgetType::Cpu => {
if let Some(cpu_widget_state) = self
.cpu_state
.get_mut_widget_state(self.current_widget.widget_id)
{
cpu_widget_state.is_multi_graph_mode =
!cpu_widget_state.is_multi_graph_mode;
}
}
BottomWidgetType::Proc => {
if let BottomWidgetType::Proc = self.current_widget.widget_type {
if let Some(proc_widget_state) = self
.proc_state
.get_mut_widget_state(self.current_widget.widget_id)
{
proc_widget_state.toggle_tab();
proc_widget_state.on_tab();
}
}
_ => {}
}
}
}
@ -338,7 +329,7 @@ impl App {
}
}
pub fn toggle_sort(&mut self) {
pub fn toggle_sort_menu(&mut self) {
let widget_id = self.current_widget.widget_id
- match &self.current_widget.widget_type {
BottomWidgetType::Proc => 0,
@ -352,12 +343,7 @@ impl App {
// If the sort is now open, move left. Otherwise, if the proc sort was selected, force move right.
if pws.is_sort_open {
if let SortState::Sortable(st) = &pws.table_state.sort_state {
pws.sort_table_state.scroll_bar = 0;
pws.sort_table_state.current_scroll_position = st
.current_index
.clamp(0, pws.num_enabled_columns().saturating_sub(1));
}
pws.sort_table.set_position(pws.table.sort_index());
self.move_widget_selection(&WidgetDirection::Left);
} else if let BottomWidgetType::ProcSort = self.current_widget.widget_type {
self.move_widget_selection(&WidgetDirection::Right);
@ -376,13 +362,9 @@ impl App {
_ => 0,
};
if let Some(proc_widget_state) = self.proc_state.get_mut_widget_state(widget_id) {
if let SortState::Sortable(state) =
&mut proc_widget_state.table_state.sort_state
{
state.toggle_order();
proc_widget_state.force_data_update();
}
if let Some(pws) = self.proc_state.get_mut_widget_state(widget_id) {
pws.table.toggle_order();
pws.force_data_update();
}
}
_ => {}
@ -410,7 +392,6 @@ impl App {
pub fn toggle_ignore_case(&mut self) {
let is_in_search_widget = self.is_in_search_widget();
let mut is_case_sensitive: Option<bool> = None;
if let Some(proc_widget_state) = self
.proc_state
.widget_states
@ -419,48 +400,12 @@ impl App {
if is_in_search_widget && proc_widget_state.is_search_enabled() {
proc_widget_state.proc_search.search_toggle_ignore_case();
proc_widget_state.update_query();
// Remember, it's the opposite (ignoring case is case "in"sensitive)
is_case_sensitive = Some(!proc_widget_state.proc_search.is_ignoring_case);
}
}
// Also toggle it in the config file if we actually changed it.
if let Some(is_ignoring_case) = is_case_sensitive {
if let Some(flags) = &mut self.config.flags {
if let Some(map) = &mut flags.search_case_enabled_widgets_map {
// Just update the map.
let mapping = map.entry(self.current_widget.widget_id - 1).or_default();
*mapping = is_ignoring_case;
flags.search_case_enabled_widgets =
Some(WidgetIdEnabled::create_from_hashmap(map));
} else {
// Map doesn't exist yet... initialize ourselves.
let mut map = HashMap::default();
map.insert(self.current_widget.widget_id - 1, is_ignoring_case);
flags.search_case_enabled_widgets =
Some(WidgetIdEnabled::create_from_hashmap(&map));
flags.search_case_enabled_widgets_map = Some(map);
}
} else {
// Must initialize it ourselves...
let mut map = HashMap::default();
map.insert(self.current_widget.widget_id - 1, is_ignoring_case);
self.config.flags = Some(
ConfigFlags::builder()
.search_case_enabled_widgets(WidgetIdEnabled::create_from_hashmap(&map))
.search_case_enabled_widgets_map(map)
.build(),
);
}
}
}
pub fn toggle_search_whole_word(&mut self) {
let is_in_search_widget = self.is_in_search_widget();
let mut is_searching_whole_word: Option<bool> = None;
if let Some(proc_widget_state) = self
.proc_state
.widget_states
@ -469,52 +414,12 @@ impl App {
if is_in_search_widget && proc_widget_state.is_search_enabled() {
proc_widget_state.proc_search.search_toggle_whole_word();
proc_widget_state.update_query();
is_searching_whole_word =
Some(proc_widget_state.proc_search.is_searching_whole_word);
}
}
// Also toggle it in the config file if we actually changed it.
if let Some(is_searching_whole_word) = is_searching_whole_word {
if let Some(flags) = &mut self.config.flags {
if let Some(map) = &mut flags.search_whole_word_enabled_widgets_map {
// Just update the map.
let mapping = map.entry(self.current_widget.widget_id - 1).or_default();
*mapping = is_searching_whole_word;
flags.search_whole_word_enabled_widgets =
Some(WidgetIdEnabled::create_from_hashmap(map));
} else {
// Map doesn't exist yet... initialize ourselves.
let mut map = HashMap::default();
map.insert(self.current_widget.widget_id - 1, is_searching_whole_word);
flags.search_whole_word_enabled_widgets =
Some(WidgetIdEnabled::create_from_hashmap(&map));
flags.search_whole_word_enabled_widgets_map = Some(map);
}
} else {
// Must initialize it ourselves...
let mut map = HashMap::default();
map.insert(self.current_widget.widget_id - 1, is_searching_whole_word);
self.config.flags = Some(
ConfigFlags::builder()
.search_whole_word_enabled_widgets(WidgetIdEnabled::create_from_hashmap(
&map,
))
.search_whole_word_enabled_widgets_map(map)
.build(),
);
}
// self.did_config_fail_to_save = self.update_config_file().is_err();
}
}
pub fn toggle_search_regex(&mut self) {
let is_in_search_widget = self.is_in_search_widget();
let mut is_searching_with_regex: Option<bool> = None;
if let Some(proc_widget_state) = self
.proc_state
.widget_states
@ -523,41 +428,6 @@ impl App {
if is_in_search_widget && proc_widget_state.is_search_enabled() {
proc_widget_state.proc_search.search_toggle_regex();
proc_widget_state.update_query();
is_searching_with_regex =
Some(proc_widget_state.proc_search.is_searching_with_regex);
}
}
// Also toggle it in the config file if we actually changed it.
if let Some(is_searching_whole_word) = is_searching_with_regex {
if let Some(flags) = &mut self.config.flags {
if let Some(map) = &mut flags.search_regex_enabled_widgets_map {
// Just update the map.
let mapping = map.entry(self.current_widget.widget_id - 1).or_default();
*mapping = is_searching_whole_word;
flags.search_regex_enabled_widgets =
Some(WidgetIdEnabled::create_from_hashmap(map));
} else {
// Map doesn't exist yet... initialize ourselves.
let mut map = HashMap::default();
map.insert(self.current_widget.widget_id - 1, is_searching_whole_word);
flags.search_regex_enabled_widgets =
Some(WidgetIdEnabled::create_from_hashmap(&map));
flags.search_regex_enabled_widgets_map = Some(map);
}
} else {
// Must initialize it ourselves...
let mut map = HashMap::default();
map.insert(self.current_widget.widget_id - 1, is_searching_whole_word);
self.config.flags = Some(
ConfigFlags::builder()
.search_regex_enabled_widgets(WidgetIdEnabled::create_from_hashmap(&map))
.search_regex_enabled_widgets_map(map)
.build(),
);
}
}
}
@ -1191,26 +1061,15 @@ impl App {
.widget_states
.get(&self.current_widget.widget_id)
{
if let Some(table_row) = pws
.table_data
.data
.get(pws.table_state.current_scroll_position)
if let Some(current) = pws.table.current_item() {
let id = current.id.to_string();
if let Some(pids) = pws
.id_pid_map
.get(&id)
.cloned()
.or_else(|| Some(vec![current.pid]))
{
if let Some(col_value) = table_row.row().get(ProcWidget::PROC_NAME_OR_CMD) {
let val = col_value.main_text().to_string();
if pws.is_using_command() {
if let Some(pids) = self.data_collection.process_data.cmd_pid_map.get(&val)
{
let current_process = (val, pids.clone());
self.to_delete_process_list = Some(current_process);
self.delete_dialog_state.is_showing_dd = true;
self.is_determining_widget_boundary = true;
}
} else if let Some(pids) =
self.data_collection.process_data.name_pid_map.get(&val)
{
let current_process = (val, pids.clone());
let current_process = (id, pids);
self.to_delete_process_list = Some(current_process);
self.delete_dialog_state.is_showing_dd = true;
@ -1218,7 +1077,7 @@ impl App {
}
}
}
}
// FIXME: This should handle errors.
}
pub fn on_char_key(&mut self, caught_char: char) {
@ -1382,12 +1241,7 @@ impl App {
'k' => self.on_up_key(),
'j' => self.on_down_key(),
'f' => {
self.is_frozen = !self.is_frozen;
if self.is_frozen {
self.data_collection.freeze();
} else {
self.data_collection.thaw();
}
self.frozen_state.toggle(&self.data_collection);
}
'c' => {
if let BottomWidgetType::Proc = self.current_widget.widget_type {
@ -1452,7 +1306,7 @@ impl App {
'-' => self.on_minus(),
'=' => self.reset_zoom(),
'e' => self.toggle_expand_widget(),
's' => self.toggle_sort(),
's' => self.toggle_sort_menu(),
'I' => self.invert_sort(),
'%' => self.toggle_percentages(),
_ => {}
@ -1979,8 +1833,7 @@ impl App {
.proc_state
.get_mut_widget_state(self.current_widget.widget_id)
{
proc_widget_state.table_state.current_scroll_position = 0;
proc_widget_state.table_state.scroll_direction = ScrollDirection::Up;
proc_widget_state.table.set_first();
}
}
BottomWidgetType::ProcSort => {
@ -1988,8 +1841,7 @@ impl App {
.proc_state
.get_mut_widget_state(self.current_widget.widget_id - 2)
{
proc_widget_state.sort_table_state.current_scroll_position = 0;
proc_widget_state.sort_table_state.scroll_direction = ScrollDirection::Up;
proc_widget_state.sort_table.set_first();
}
}
BottomWidgetType::Temp => {
@ -1997,8 +1849,7 @@ impl App {
.temp_state
.get_mut_widget_state(self.current_widget.widget_id)
{
temp_widget_state.table_state.current_scroll_position = 0;
temp_widget_state.table_state.scroll_direction = ScrollDirection::Up;
temp_widget_state.table.set_first();
}
}
BottomWidgetType::Disk => {
@ -2006,8 +1857,7 @@ impl App {
.disk_state
.get_mut_widget_state(self.current_widget.widget_id)
{
disk_widget_state.table_state.current_scroll_position = 0;
disk_widget_state.table_state.scroll_direction = ScrollDirection::Up;
disk_widget_state.table.set_first();
}
}
BottomWidgetType::CpuLegend => {
@ -2015,8 +1865,7 @@ impl App {
.cpu_state
.get_mut_widget_state(self.current_widget.widget_id - 1)
{
cpu_widget_state.table_state.current_scroll_position = 0;
cpu_widget_state.table_state.scroll_direction = ScrollDirection::Up;
cpu_widget_state.table.set_first();
}
}
@ -2038,9 +1887,7 @@ impl App {
.proc_state
.get_mut_widget_state(self.current_widget.widget_id)
{
proc_widget_state.table_state.current_scroll_position =
proc_widget_state.table_data.data.len().saturating_sub(1);
proc_widget_state.table_state.scroll_direction = ScrollDirection::Down;
proc_widget_state.table.set_last();
}
}
BottomWidgetType::ProcSort => {
@ -2048,9 +1895,7 @@ impl App {
.proc_state
.get_mut_widget_state(self.current_widget.widget_id - 2)
{
proc_widget_state.sort_table_state.current_scroll_position =
proc_widget_state.num_enabled_columns() - 1;
proc_widget_state.sort_table_state.scroll_direction = ScrollDirection::Down;
proc_widget_state.sort_table.set_last();
}
}
BottomWidgetType::Temp => {
@ -2058,11 +1903,7 @@ impl App {
.temp_state
.get_mut_widget_state(self.current_widget.widget_id)
{
if !self.converted_data.temp_sensor_data.data.is_empty() {
temp_widget_state.table_state.current_scroll_position =
self.converted_data.temp_sensor_data.data.len() - 1;
temp_widget_state.table_state.scroll_direction = ScrollDirection::Down;
}
temp_widget_state.table.set_last();
}
}
BottomWidgetType::Disk => {
@ -2070,10 +1911,8 @@ impl App {
.disk_state
.get_mut_widget_state(self.current_widget.widget_id)
{
if !self.converted_data.disk_data.data.is_empty() {
disk_widget_state.table_state.current_scroll_position =
self.converted_data.disk_data.data.len() - 1;
disk_widget_state.table_state.scroll_direction = ScrollDirection::Down;
if !self.converted_data.disk_data.is_empty() {
disk_widget_state.table.set_last();
}
}
}
@ -2082,11 +1921,7 @@ impl App {
.cpu_state
.get_mut_widget_state(self.current_widget.widget_id - 1)
{
let cap = self.converted_data.cpu_data.len();
if cap > 0 {
cpu_widget_state.table_state.current_scroll_position = cap - 1;
cpu_widget_state.table_state.scroll_direction = ScrollDirection::Down;
}
cpu_widget_state.table.set_last();
}
}
_ => {}
@ -2128,10 +1963,9 @@ impl App {
.proc_state
.get_mut_widget_state(self.current_widget.widget_id - 2)
{
let num_entries = proc_widget_state.num_enabled_columns();
proc_widget_state
.sort_table_state
.update_position(num_to_change_by, num_entries);
.sort_table
.increment_position(num_to_change_by);
}
}
@ -2141,9 +1975,7 @@ impl App {
.widget_states
.get_mut(&(self.current_widget.widget_id - 1))
{
cpu_widget_state
.table_state
.update_position(num_to_change_by, self.converted_data.cpu_data.len());
cpu_widget_state.table.increment_position(num_to_change_by);
}
}
@ -2153,9 +1985,7 @@ impl App {
.proc_state
.get_mut_widget_state(self.current_widget.widget_id)
{
proc_widget_state
.table_state
.update_position(num_to_change_by, proc_widget_state.table_data.data.len())
proc_widget_state.table.increment_position(num_to_change_by)
} else {
None
}
@ -2167,10 +1997,7 @@ impl App {
.widget_states
.get_mut(&self.current_widget.widget_id)
{
temp_widget_state.table_state.update_position(
num_to_change_by,
self.converted_data.temp_sensor_data.data.len(),
);
temp_widget_state.table.increment_position(num_to_change_by);
}
}
@ -2180,9 +2007,7 @@ impl App {
.widget_states
.get_mut(&self.current_widget.widget_id)
{
disk_widget_state
.table_state
.update_position(num_to_change_by, self.converted_data.disk_data.data.len());
disk_widget_state.table.increment_position(num_to_change_by);
}
}
@ -2267,7 +2092,7 @@ impl App {
.widget_states
.get_mut(&self.current_widget.widget_id)
{
pws.toggle_tree_branch();
pws.toggle_current_tree_branch_entry();
}
}
@ -2645,45 +2470,34 @@ impl App {
.get_widget_state(self.current_widget.widget_id)
{
if let Some(visual_index) =
proc_widget_state.table_state.table_state.selected()
proc_widget_state.table.tui_selected()
{
// If in tree mode, also check to see if this click is on
// the same entry as the already selected one - if it is,
// then we minimize.
let is_tree_mode = matches!(
proc_widget_state.mode,
ProcWidgetMode::Tree { .. }
);
let change =
offset_clicked_entry as i64 - visual_index as i64;
let previous_scroll_position = proc_widget_state
.table_state
.current_scroll_position;
self.change_process_position(change);
let new_position = self.change_process_position(
offset_clicked_entry as i64 - visual_index as i64,
);
if is_tree_mode {
if let Some(new_position) = new_position {
if previous_scroll_position == new_position {
// If in tree mode, also check to see if this click is on
// the same entry as the already selected one - if it is,
// then we minimize.
if is_tree_mode && change == 0 {
self.toggle_collapsing_process_branch();
}
}
}
}
}
}
BottomWidgetType::ProcSort => {
// TODO: [Feature] This could sort if you double click!
if let Some(proc_widget_state) = self
.proc_state
.get_widget_state(self.current_widget.widget_id - 2)
{
if let Some(visual_index) = proc_widget_state
.sort_table_state
.table_state
.selected()
if let Some(visual_index) =
proc_widget_state.sort_table.tui_selected()
{
self.change_process_sort_position(
offset_clicked_entry as i64 - visual_index as i64,
@ -2697,7 +2511,7 @@ impl App {
.get_widget_state(self.current_widget.widget_id - 1)
{
if let Some(visual_index) =
cpu_widget_state.table_state.table_state.selected()
cpu_widget_state.table.tui_selected()
{
self.change_cpu_legend_position(
offset_clicked_entry as i64 - visual_index as i64,
@ -2711,7 +2525,7 @@ impl App {
.get_widget_state(self.current_widget.widget_id)
{
if let Some(visual_index) =
temp_widget_state.table_state.table_state.selected()
temp_widget_state.table.tui_selected()
{
self.change_temp_position(
offset_clicked_entry as i64 - visual_index as i64,
@ -2725,7 +2539,7 @@ impl App {
.get_widget_state(self.current_widget.widget_id)
{
if let Some(visual_index) =
disk_widget_state.table_state.table_state.selected()
disk_widget_state.table.tui_selected()
{
self.change_disk_position(
offset_clicked_entry as i64 - visual_index as i64,
@ -2744,10 +2558,11 @@ impl App {
.proc_state
.get_mut_widget_state(self.current_widget.widget_id)
{
if let SortState::Sortable(st) =
&mut proc_widget_state.table_state.sort_state
if proc_widget_state
.table
.try_select_location(x, y)
.is_some()
{
if st.try_select_location(x, y).is_some() {
proc_widget_state.force_data_update();
}
}
@ -2755,7 +2570,6 @@ impl App {
}
}
}
}
BottomWidgetType::Battery => {
if let Some(battery_widget_state) = self
.battery_state

View file

@ -33,7 +33,7 @@ use regex::Regex;
pub type TimeOffset = f64;
pub type Value = f64;
#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
pub struct TimedData {
pub rx_data: Value,
pub tx_data: Value,
@ -45,19 +45,11 @@ pub struct TimedData {
pub arc_data: Option<Value>,
}
pub type StringPidMap = FxHashMap<String, Vec<Pid>>;
#[derive(Clone, Debug, Default)]
pub struct ProcessData {
/// A PID to process data map.
pub process_harvest: FxHashMap<Pid, ProcessHarvest>,
/// A mapping from a process name to any PID with that name.
pub name_pid_map: StringPidMap,
/// A mapping from a process command to any PID with that name.
pub cmd_pid_map: StringPidMap,
/// A mapping between a process PID to any children process PIDs.
pub process_parent_mapping: FxHashMap<Pid, Vec<Pid>>,
@ -68,28 +60,10 @@ pub struct ProcessData {
impl ProcessData {
fn ingest(&mut self, list_of_processes: Vec<ProcessHarvest>) {
// TODO: [Optimization] Probably more efficient to all of this in the data collection step, but it's fine for now.
self.name_pid_map.clear();
self.cmd_pid_map.clear();
self.process_parent_mapping.clear();
// Reverse as otherwise the pid mappings are in the wrong order.
list_of_processes.iter().rev().for_each(|process_harvest| {
if let Some(entry) = self.name_pid_map.get_mut(&process_harvest.name) {
entry.push(process_harvest.pid);
} else {
self.name_pid_map
.insert(process_harvest.name.to_string(), vec![process_harvest.pid]);
}
if let Some(entry) = self.cmd_pid_map.get_mut(&process_harvest.command) {
entry.push(process_harvest.pid);
} else {
self.cmd_pid_map.insert(
process_harvest.command.to_string(),
vec![process_harvest.pid],
);
}
if let Some(parent_pid) = process_harvest.parent_pid {
if let Some(entry) = self.process_parent_mapping.get_mut(&parent_pid) {
entry.push(process_harvest.pid);
@ -100,8 +74,6 @@ impl ProcessData {
}
});
self.name_pid_map.shrink_to_fit();
self.cmd_pid_map.shrink_to_fit();
self.process_parent_mapping.shrink_to_fit();
let process_pid_map = list_of_processes
@ -137,14 +109,14 @@ impl ProcessData {
/// collected, and what is needed to convert into a displayable form.
///
/// If the app is *frozen* - that is, we do not want to *display* any changing
/// data, keep updating this, don't convert to canvas displayable data!
/// data, keep updating this. As of 2021-09-08, we just clone the current collection
/// when it freezes to have a snapshot floating around.
///
/// Note that with this method, the *app* thread is responsible for cleaning -
/// not the data collector.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct DataCollection {
pub current_instant: Instant,
pub frozen_instant: Option<Instant>,
pub timed_data_vec: Vec<(Instant, TimedData)>,
pub network_harvest: network::NetworkHarvest,
pub memory_harvest: memory::MemHarvest,
@ -167,7 +139,6 @@ impl Default for DataCollection {
fn default() -> Self {
DataCollection {
current_instant: Instant::now(),
frozen_instant: None,
timed_data_vec: Vec::default(),
network_harvest: network::NetworkHarvest::default(),
memory_harvest: memory::MemHarvest::default(),
@ -210,14 +181,6 @@ impl DataCollection {
}
}
pub fn freeze(&mut self) {
self.frozen_instant = Some(self.current_instant);
}
pub fn thaw(&mut self) {
self.frozen_instant = None;
}
pub fn clean_data(&mut self, max_time_millis: u64) {
let current_time = Instant::now();

View file

@ -16,10 +16,15 @@ cfg_if::cfg_if! {
pub type LoadAvgHarvest = [f32; 3];
#[derive(Default, Debug, Clone)]
#[derive(Debug, Clone, Copy)]
pub enum CpuDataType {
Avg,
Cpu(usize),
}
#[derive(Debug, Clone)]
pub struct CpuData {
pub cpu_prefix: String,
pub cpu_count: Option<usize>,
pub data_type: CpuDataType,
pub cpu_usage: f64,
}

View file

@ -18,7 +18,8 @@ cfg_if::cfg_if! {
}
}
use crate::data_harvester::cpu::{CpuData, CpuHarvest, PastCpuTotal, PastCpuWork};
use crate::data_harvester::cpu::{CpuData, CpuDataType, CpuHarvest, PastCpuTotal, PastCpuWork};
use futures::StreamExt;
use std::collections::VecDeque;
@ -62,8 +63,7 @@ pub async fn get_cpu_data_list(
let present_times = convert_cpu_times(&present);
new_cpu_times.push(present_times);
cpu_deque.push_back(CpuData {
cpu_prefix: "CPU".to_string(),
cpu_count: Some(itx),
data_type: CpuDataType::Cpu(itx),
cpu_usage: calculate_cpu_usage_percentage(
convert_cpu_times(&past),
present_times,
@ -72,8 +72,7 @@ pub async fn get_cpu_data_list(
} else {
new_cpu_times.push((0.0, 0.0));
cpu_deque.push_back(CpuData {
cpu_prefix: "CPU".to_string(),
cpu_count: Some(itx),
data_type: CpuDataType::Cpu(itx),
cpu_usage: 0.0,
});
}
@ -96,8 +95,7 @@ pub async fn get_cpu_data_list(
(
present_times,
CpuData {
cpu_prefix: "CPU".to_string(),
cpu_count: Some(itx),
data_type: CpuDataType::Cpu(itx),
cpu_usage: calculate_cpu_usage_percentage(
(*past_cpu_work, *past_cpu_total),
present_times,
@ -108,8 +106,7 @@ pub async fn get_cpu_data_list(
(
(*past_cpu_work, *past_cpu_total),
CpuData {
cpu_prefix: "CPU".to_string(),
cpu_count: Some(itx),
data_type: CpuDataType::Cpu(itx),
cpu_usage: 0.0,
},
)
@ -147,8 +144,7 @@ pub async fn get_cpu_data_list(
*previous_average_cpu_time = Some(new_average_cpu_time);
cpu_deque.push_front(CpuData {
cpu_prefix: "AVG".to_string(),
cpu_count: None,
data_type: CpuDataType::Avg,
cpu_usage,
})
}

View file

@ -116,7 +116,6 @@ fn get_linux_cpu_usage(
}
}
#[allow(clippy::too_many_arguments)]
fn read_proc(
prev_proc: &PrevProcDetails, stat: &Stat, cpu_usage: f64, cpu_fraction: f64,
use_current_cpu_total: bool, time_difference_in_secs: u64, mem_total_kb: u64,

View file

@ -60,16 +60,16 @@ pub(crate) struct extern_proc {
/// Process identifier.
pub p_pid: pid_t,
/// Save parent pid during ptrace. XXX
/// Save parent pid during ptrace.
pub p_oppid: pid_t,
/// Sideways return value from fdopen. XXX
/// Sideways return value from fdopen.
pub p_dupfd: i32,
/// where user stack was allocated
pub user_stack: caddr_t,
/// XXX Which thread is exiting?
/// Which thread is exiting?
pub exit_thread: *mut c_void,
/// allow to debug

View file

@ -26,7 +26,7 @@ pub struct TempHarvest {
pub temperature: f32,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Copy)]
pub enum TemperatureType {
Celsius,
Kelvin,

44
src/app/frozen_state.rs Normal file
View file

@ -0,0 +1,44 @@
use super::DataCollection;
/// The [`FrozenState`] indicates whether the application state should be frozen. It is either not frozen or
/// frozen and containing a copy of the state at the time.
pub enum FrozenState {
NotFrozen,
Frozen(Box<DataCollection>),
}
impl Default for FrozenState {
fn default() -> Self {
Self::NotFrozen
}
}
pub type IsFrozen = bool;
impl FrozenState {
/// Checks whether the [`FrozenState`] is currently frozen.
pub fn is_frozen(&self) -> IsFrozen {
matches!(self, FrozenState::Frozen(_))
}
/// Freezes the [`FrozenState`].
pub fn freeze(&mut self, data: Box<DataCollection>) {
*self = FrozenState::Frozen(data);
}
/// Unfreezes the [`FrozenState`].
pub fn thaw(&mut self) {
*self = FrozenState::NotFrozen;
}
/// Toggles the [`FrozenState`] and returns whether it is now frozen.
pub fn toggle(&mut self, data: &DataCollection) -> IsFrozen {
if self.is_frozen() {
self.thaw();
false
} else {
self.freeze(Box::new(data.clone()));
true
}
}
}

View file

@ -4,11 +4,10 @@ use unicode_segmentation::GraphemeCursor;
use crate::{
app::{layout_manager::BottomWidgetType, query::*},
components::text_table::{CellContent, TableComponentColumn, TableComponentState, WidthBounds},
constants,
};
use super::widgets::{DiskWidgetState, ProcWidget, TempWidgetState};
use super::widgets::{CpuWidgetState, DiskTableWidget, ProcWidget, TempWidgetState};
#[derive(Debug)]
pub enum ScrollDirection {
@ -30,13 +29,6 @@ pub enum CursorDirection {
Right,
}
/// Meant for canvas operations involving table column widths.
#[derive(Default)]
pub struct CanvasTableWidthState {
pub desired_column_widths: Vec<u16>,
pub calculated_column_widths: Vec<u16>,
}
#[derive(PartialEq, Eq)]
pub enum KillSignal {
Cancel,
@ -184,42 +176,6 @@ impl NetState {
}
}
pub struct CpuWidgetState {
pub current_display_time: u64,
pub is_legend_hidden: bool,
pub autohide_timer: Option<Instant>,
pub table_state: TableComponentState,
pub is_multi_graph_mode: bool,
}
impl CpuWidgetState {
pub fn init(current_display_time: u64, autohide_timer: Option<Instant>) -> Self {
const CPU_LEGEND_HEADER: [&str; 2] = ["CPU", "Use%"];
const WIDTHS: [WidthBounds; CPU_LEGEND_HEADER.len()] = [
WidthBounds::soft_from_str("CPU", Some(0.5)),
WidthBounds::soft_from_str("Use%", Some(0.5)),
];
let table_state = TableComponentState::new(
CPU_LEGEND_HEADER
.iter()
.zip(WIDTHS)
.map(|(c, width)| {
TableComponentColumn::new_custom(CellContent::new(*c, None), width)
})
.collect(),
);
CpuWidgetState {
current_display_time,
is_legend_hidden: false,
autohide_timer,
table_state,
is_multi_graph_mode: false,
}
}
}
pub struct CpuState {
pub force_update: Option<u64>,
pub widget_states: HashMap<u64, CpuWidgetState>,
@ -296,19 +252,19 @@ impl TempState {
}
pub struct DiskState {
pub widget_states: HashMap<u64, DiskWidgetState>,
pub widget_states: HashMap<u64, DiskTableWidget>,
}
impl DiskState {
pub fn init(widget_states: HashMap<u64, DiskWidgetState>) -> Self {
pub fn init(widget_states: HashMap<u64, DiskTableWidget>) -> Self {
DiskState { widget_states }
}
pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut DiskWidgetState> {
pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut DiskTableWidget> {
self.widget_states.get_mut(&widget_id)
}
pub fn get_widget_state(&self, widget_id: u64) -> Option<&DiskWidgetState> {
pub fn get_widget_state(&self, widget_id: u64) -> Option<&DiskTableWidget> {
self.widget_states.get(&widget_id)
}
}

View file

@ -1,8 +1,13 @@
pub mod process_table_widget;
pub use process_table_widget::*;
// FIXME: Move this outside of app, along with components?
pub mod temperature_table_widget;
pub use temperature_table_widget::*;
pub mod process_table;
pub use process_table::*;
pub mod disk_table_widget;
pub use disk_table_widget::*;
pub mod temperature_table;
pub use temperature_table::*;
pub mod disk_table;
pub use disk_table::*;
pub mod cpu_graph;
pub use cpu_graph::*;

View file

@ -0,0 +1,175 @@
use std::{borrow::Cow, time::Instant};
use concat_string::concat_string;
use tui::{style::Style, text::Text, widgets::Row};
use crate::{
app::{data_harvester::cpu::CpuDataType, AppConfigFields},
canvas::{canvas_colours::CanvasColours, Painter},
components::data_table::{
Column, ColumnHeader, DataTable, DataTableColumn, DataTableProps, DataTableStyling,
DataToCell,
},
data_conversion::CpuWidgetData,
utils::gen_util::truncate_text,
};
#[derive(Default)]
pub struct CpuWidgetStyling {
pub all: Style,
pub avg: Style,
pub entries: Vec<Style>,
}
impl CpuWidgetStyling {
fn from_colours(colours: &CanvasColours) -> Self {
let entries = if colours.cpu_colour_styles.is_empty() {
vec![Style::default()]
} else {
colours.cpu_colour_styles.clone()
};
Self {
all: colours.all_colour_style,
avg: colours.avg_colour_style,
entries,
}
}
}
pub enum CpuWidgetColumn {
CPU,
Use,
}
impl ColumnHeader for CpuWidgetColumn {
fn text(&self) -> Cow<'static, str> {
match self {
CpuWidgetColumn::CPU => "CPU".into(),
CpuWidgetColumn::Use => "Use%".into(),
}
}
}
impl DataToCell<CpuWidgetColumn> for CpuWidgetData {
fn to_cell<'a>(&'a self, column: &CpuWidgetColumn, calculated_width: u16) -> Option<Text<'a>> {
const CPU_HIDE_BREAKPOINT: u16 = 5;
// This is a bit of a hack, but apparently we can avoid having to do any fancy checks
// of showing the "All" on a specific column if the other is hidden by just always
// showing it on the CPU (first) column - if there isn't room for it, it will just collapse
// down.
//
// This is the same for the use percentages - we just *always* show them, and *always* hide the CPU column if
// it is too small.
match &self {
CpuWidgetData::All => match column {
CpuWidgetColumn::CPU => Some(truncate_text("All", calculated_width)),
CpuWidgetColumn::Use => None,
},
CpuWidgetData::Entry {
data_type,
data: _,
last_entry,
} => match column {
CpuWidgetColumn::CPU => {
if calculated_width == 0 {
None
} else {
match data_type {
CpuDataType::Avg => Some(truncate_text("AVG", calculated_width)),
CpuDataType::Cpu(index) => {
let index_str = index.to_string();
let text = if calculated_width < CPU_HIDE_BREAKPOINT {
truncate_text(&index_str, calculated_width)
} else {
truncate_text(
&concat_string!("CPU", index_str),
calculated_width,
)
};
Some(text)
}
}
}
}
CpuWidgetColumn::Use => Some(truncate_text(
&format!("{:.0}%", last_entry.round()),
calculated_width,
)),
},
}
}
#[inline(always)]
fn style_row<'a>(&self, row: Row<'a>, painter: &Painter) -> Row<'a> {
let style = match self {
CpuWidgetData::All => painter.colours.all_colour_style,
CpuWidgetData::Entry {
data_type,
data: _,
last_entry: _,
} => match data_type {
CpuDataType::Avg => painter.colours.avg_colour_style,
CpuDataType::Cpu(index) => {
painter.colours.cpu_colour_styles
[index % painter.colours.cpu_colour_styles.len()]
}
},
};
row.style(style)
}
fn column_widths<C: DataTableColumn<CpuWidgetColumn>>(
_data: &[Self], _columns: &[C],
) -> Vec<u16>
where
Self: Sized,
{
vec![1, 3]
}
}
pub struct CpuWidgetState {
pub current_display_time: u64,
pub is_legend_hidden: bool,
pub show_avg: bool,
pub autohide_timer: Option<Instant>,
pub table: DataTable<CpuWidgetData, CpuWidgetColumn>,
pub styling: CpuWidgetStyling,
}
impl CpuWidgetState {
pub fn new(
config: &AppConfigFields, current_display_time: u64, autohide_timer: Option<Instant>,
colours: &CanvasColours,
) -> Self {
const COLUMNS: [Column<CpuWidgetColumn>; 2] = [
Column::soft(CpuWidgetColumn::CPU, Some(0.5)),
Column::soft(CpuWidgetColumn::Use, Some(0.5)),
];
let props = DataTableProps {
title: None,
table_gap: config.table_gap,
left_to_right: false,
is_basic: false,
show_table_scroll_position: false, // TODO: Should this be possible?
show_current_entry_when_unfocused: true,
};
let styling = DataTableStyling::from_colours(colours);
CpuWidgetState {
current_display_time,
is_legend_hidden: false,
show_avg: config.show_average_cpu,
autohide_timer,
table: DataTable::new(COLUMNS, props, styling),
styling: CpuWidgetStyling::from_colours(colours),
}
}
}

View file

@ -0,0 +1,147 @@
use std::{borrow::Cow, cmp::max};
use kstring::KString;
use tui::text::Text;
use crate::{
app::AppConfigFields,
canvas::canvas_colours::CanvasColours,
components::data_table::{
Column, ColumnHeader, DataTable, DataTableColumn, DataTableProps, DataTableStyling,
DataToCell,
},
utils::gen_util::{get_decimal_bytes, truncate_text},
};
#[derive(Clone)]
pub struct DiskWidgetData {
pub name: KString,
pub mount_point: KString,
pub free_bytes: Option<u64>,
pub used_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub io_read: KString,
pub io_write: KString,
}
impl DiskWidgetData {
pub fn free_space(&self) -> KString {
if let Some(free_bytes) = self.free_bytes {
let converted_free_space = get_decimal_bytes(free_bytes);
format!("{:.*}{}", 0, converted_free_space.0, converted_free_space.1).into()
} else {
"N/A".into()
}
}
pub fn total_space(&self) -> KString {
if let Some(total_bytes) = self.total_bytes {
let converted_total_space = get_decimal_bytes(total_bytes);
format!(
"{:.*}{}",
0, converted_total_space.0, converted_total_space.1
)
.into()
} else {
"N/A".into()
}
}
pub fn usage(&self) -> KString {
if let (Some(used_bytes), Some(total_bytes)) = (self.used_bytes, self.total_bytes) {
format!("{:.0}%", used_bytes as f64 / total_bytes as f64 * 100_f64).into()
} else {
"N/A".into()
}
}
}
pub enum DiskWidgetColumn {
Disk,
Mount,
Used,
Free,
Total,
IoRead,
IoWrite,
}
impl ColumnHeader for DiskWidgetColumn {
fn text(&self) -> Cow<'static, str> {
match self {
DiskWidgetColumn::Disk => "Disk",
DiskWidgetColumn::Mount => "Mount",
DiskWidgetColumn::Used => "Used",
DiskWidgetColumn::Free => "Free",
DiskWidgetColumn::Total => "Total",
DiskWidgetColumn::IoRead => "R/s",
DiskWidgetColumn::IoWrite => "W/s",
}
.into()
}
}
impl DataToCell<DiskWidgetColumn> for DiskWidgetData {
fn to_cell<'a>(&'a self, column: &DiskWidgetColumn, calculated_width: u16) -> Option<Text<'a>> {
let text = match column {
DiskWidgetColumn::Disk => truncate_text(&self.name, calculated_width),
DiskWidgetColumn::Mount => truncate_text(&self.mount_point, calculated_width),
DiskWidgetColumn::Used => truncate_text(&self.usage(), calculated_width),
DiskWidgetColumn::Free => truncate_text(&self.free_space(), calculated_width),
DiskWidgetColumn::Total => truncate_text(&self.total_space(), calculated_width),
DiskWidgetColumn::IoRead => truncate_text(&self.io_read, calculated_width),
DiskWidgetColumn::IoWrite => truncate_text(&self.io_write, calculated_width),
};
Some(text)
}
fn column_widths<C: DataTableColumn<DiskWidgetColumn>>(
data: &[Self], _columns: &[C],
) -> Vec<u16>
where
Self: Sized,
{
let mut widths = vec![0; 7];
data.iter().for_each(|row| {
widths[0] = max(widths[0], row.name.len() as u16);
widths[1] = max(widths[1], row.mount_point.len() as u16);
});
widths
}
}
pub struct DiskTableWidget {
pub table: DataTable<DiskWidgetData, DiskWidgetColumn>,
}
impl DiskTableWidget {
pub fn new(config: &AppConfigFields, colours: &CanvasColours) -> Self {
const COLUMNS: [Column<DiskWidgetColumn>; 7] = [
Column::soft(DiskWidgetColumn::Disk, Some(0.2)),
Column::soft(DiskWidgetColumn::Mount, Some(0.2)),
Column::hard(DiskWidgetColumn::Used, 4),
Column::hard(DiskWidgetColumn::Free, 6),
Column::hard(DiskWidgetColumn::Total, 6),
Column::hard(DiskWidgetColumn::IoRead, 7),
Column::hard(DiskWidgetColumn::IoWrite, 7),
];
let props = DataTableProps {
title: Some(" Disks ".into()),
table_gap: config.table_gap,
left_to_right: true,
is_basic: config.use_basic_mode,
show_table_scroll_position: config.show_table_scroll_position,
show_current_entry_when_unfocused: false,
};
let styling = DataTableStyling::from_colours(colours);
Self {
table: DataTable::new(COLUMNS, props, styling),
}
}
}

View file

@ -1,34 +0,0 @@
use crate::components::text_table::{
CellContent, TableComponentColumn, TableComponentState, WidthBounds,
};
pub struct DiskWidgetState {
pub table_state: TableComponentState,
}
impl Default for DiskWidgetState {
fn default() -> Self {
const DISK_HEADERS: [&str; 7] = ["Disk", "Mount", "Used", "Free", "Total", "R/s", "W/s"];
const WIDTHS: [WidthBounds; DISK_HEADERS.len()] = [
WidthBounds::soft_from_str(DISK_HEADERS[0], Some(0.2)),
WidthBounds::soft_from_str(DISK_HEADERS[1], Some(0.2)),
WidthBounds::Hard(4),
WidthBounds::Hard(6),
WidthBounds::Hard(6),
WidthBounds::Hard(7),
WidthBounds::Hard(7),
];
DiskWidgetState {
table_state: TableComponentState::new(
DISK_HEADERS
.iter()
.zip(WIDTHS)
.map(|(header, width)| {
TableComponentColumn::new_custom(CellContent::new(*header, None), width)
})
.collect(),
),
}
}
}

View file

@ -0,0 +1,812 @@
use std::{borrow::Cow, collections::hash_map::Entry};
use crate::{
app::{
data_farmer::{DataCollection, ProcessData},
data_harvester::processes::ProcessHarvest,
query::*,
AppConfigFields, AppSearchState,
},
canvas::canvas_colours::CanvasColours,
components::data_table::{
Column, ColumnHeader, ColumnWidthBounds, DataTable, DataTableColumn, DataTableProps,
DataTableStyling, SortColumn, SortDataTable, SortDataTableProps, SortOrder,
},
Pid,
};
use fxhash::{FxHashMap, FxHashSet};
use itertools::Itertools;
pub mod proc_widget_column;
pub use proc_widget_column::*;
pub mod proc_widget_data;
pub use proc_widget_data::*;
mod sort_table;
use sort_table::SortTableColumn;
/// ProcessSearchState only deals with process' search's current settings and state.
pub struct ProcessSearchState {
pub search_state: AppSearchState,
pub is_ignoring_case: bool,
pub is_searching_whole_word: bool,
pub is_searching_with_regex: bool,
}
impl Default for ProcessSearchState {
fn default() -> Self {
ProcessSearchState {
search_state: AppSearchState::default(),
is_ignoring_case: true,
is_searching_whole_word: false,
is_searching_with_regex: false,
}
}
}
impl ProcessSearchState {
pub fn search_toggle_ignore_case(&mut self) {
self.is_ignoring_case = !self.is_ignoring_case;
}
pub fn search_toggle_whole_word(&mut self) {
self.is_searching_whole_word = !self.is_searching_whole_word;
}
pub fn search_toggle_regex(&mut self) {
self.is_searching_with_regex = !self.is_searching_with_regex;
}
}
#[derive(Clone, Debug)]
pub enum ProcWidgetMode {
Tree { collapsed_pids: FxHashSet<Pid> },
Grouped,
Normal,
}
type ProcessTable = SortDataTable<ProcWidgetData, ProcColumn>;
type SortTable = DataTable<Cow<'static, str>, SortTableColumn>;
type StringPidMap = FxHashMap<String, Vec<Pid>>;
pub struct ProcWidget {
pub mode: ProcWidgetMode,
/// The state of the search box.
pub proc_search: ProcessSearchState,
/// The state of the main table.
pub table: ProcessTable,
/// The stored process data for this specific table.
pub table_data: Vec<ProcWidgetData>,
/// The state of the togglable table that controls sorting.
pub sort_table: SortTable,
/// A name-to-pid mapping.
pub id_pid_map: StringPidMap,
pub is_sort_open: bool,
pub force_rerender: bool,
pub force_update_data: bool,
}
impl ProcWidget {
pub const PID_OR_COUNT: usize = 0;
pub const PROC_NAME_OR_CMD: usize = 1;
pub const CPU: usize = 2;
pub const MEM: usize = 3;
pub const RPS: usize = 4;
pub const WPS: usize = 5;
pub const T_READ: usize = 6;
pub const T_WRITE: usize = 7;
#[cfg(target_family = "unix")]
pub const USER: usize = 8;
#[cfg(target_family = "unix")]
pub const STATE: usize = 9;
#[cfg(not(target_family = "unix"))]
pub const STATE: usize = 8;
fn new_sort_table(config: &AppConfigFields, colours: &CanvasColours) -> SortTable {
const COLUMNS: [Column<SortTableColumn>; 1] = [Column::hard(SortTableColumn, 7)];
let props = DataTableProps {
title: None,
table_gap: config.table_gap,
left_to_right: true,
is_basic: false,
show_table_scroll_position: false,
show_current_entry_when_unfocused: false,
};
let styling = DataTableStyling::from_colours(colours);
DataTable::new(COLUMNS, props, styling)
}
fn new_process_table(
config: &AppConfigFields, colours: &CanvasColours, mode: &ProcWidgetMode, is_count: bool,
is_command: bool, show_memory_as_values: bool,
) -> ProcessTable {
let (default_index, default_order) = if matches!(mode, ProcWidgetMode::Tree { .. }) {
(Self::PID_OR_COUNT, SortOrder::Ascending)
} else {
(Self::CPU, SortOrder::Descending)
};
let columns = {
use ProcColumn::*;
let pid_or_count = SortColumn::new(if is_count { Count } else { Pid });
let name_or_cmd = SortColumn::soft(if is_command { Command } else { Name }, Some(0.3));
let cpu = SortColumn::new(CpuPercent).default_descending();
let mem = SortColumn::new(if show_memory_as_values {
MemoryVal
} else {
MemoryPercent
})
.default_descending();
let rps = SortColumn::hard(ReadPerSecond, 8).default_descending();
let wps = SortColumn::hard(WritePerSecond, 8).default_descending();
let tr = SortColumn::hard(TotalRead, 8).default_descending();
let tw = SortColumn::hard(TotalWrite, 8).default_descending();
let state = SortColumn::hard(State, 7);
vec![
pid_or_count,
name_or_cmd,
cpu,
mem,
rps,
wps,
tr,
tw,
#[cfg(target_family = "unix")]
SortColumn::soft(User, Some(0.05)),
state,
]
};
let inner_props = DataTableProps {
title: Some(" Processes ".into()),
table_gap: config.table_gap,
left_to_right: true,
is_basic: config.use_basic_mode,
show_table_scroll_position: config.show_table_scroll_position,
show_current_entry_when_unfocused: false,
};
let props = SortDataTableProps {
inner: inner_props,
sort_index: default_index,
order: default_order,
};
let styling = DataTableStyling::from_colours(colours);
DataTable::new_sortable(columns, props, styling)
}
pub fn new(
config: &AppConfigFields, mode: ProcWidgetMode, is_case_sensitive: bool,
is_match_whole_word: bool, is_use_regex: bool, show_memory_as_values: bool,
is_command: bool, colours: &CanvasColours,
) -> Self {
let process_search_state = {
let mut pss = ProcessSearchState::default();
if is_case_sensitive {
// By default it's off
pss.search_toggle_ignore_case();
}
if is_match_whole_word {
pss.search_toggle_whole_word();
}
if is_use_regex {
pss.search_toggle_regex();
}
pss
};
let is_count = matches!(mode, ProcWidgetMode::Grouped);
let sort_table = Self::new_sort_table(config, colours);
let table = Self::new_process_table(
config,
colours,
&mode,
is_count,
is_command,
show_memory_as_values,
);
let id_pid_map = FxHashMap::default();
ProcWidget {
proc_search: process_search_state,
table,
table_data: vec![],
sort_table,
id_pid_map,
is_sort_open: false,
mode,
force_rerender: true,
force_update_data: false,
}
}
pub fn is_using_command(&self) -> bool {
self.table
.columns
.get(ProcWidget::PROC_NAME_OR_CMD)
.map(|col| matches!(col.inner(), ProcColumn::Command))
.unwrap_or(false)
}
pub fn is_mem_percent(&self) -> bool {
self.table
.columns
.get(ProcWidget::MEM)
.map(|col| matches!(col.inner(), ProcColumn::MemoryPercent))
.unwrap_or(false)
}
fn get_query(&self) -> &Option<Query> {
if self.proc_search.search_state.is_invalid_or_blank_search() {
&None
} else {
&self.proc_search.search_state.query
}
}
/// This function *only* updates the displayed process data. If there is a need to update the actual *stored* data,
/// call it before this function.
pub fn update_displayed_process_data(&mut self, data_collection: &DataCollection) {
self.table_data = match &self.mode {
ProcWidgetMode::Grouped | ProcWidgetMode::Normal => {
self.get_normal_data(&data_collection.process_data.process_harvest)
}
ProcWidgetMode::Tree { collapsed_pids } => {
self.get_tree_data(collapsed_pids, data_collection)
}
};
}
fn get_tree_data(
&self, collapsed_pids: &FxHashSet<Pid>, data_collection: &DataCollection,
) -> Vec<ProcWidgetData> {
const BRANCH_END: char = '└';
const BRANCH_VERTICAL: char = '│';
const BRANCH_SPLIT: char = '├';
const BRANCH_HORIZONTAL: char = '─';
let search_query = self.get_query();
let is_using_command = self.is_using_command();
let is_mem_percent = self.is_mem_percent();
let ProcessData {
process_harvest,
process_parent_mapping,
orphan_pids,
..
} = &data_collection.process_data;
let kept_pids = data_collection
.process_data
.process_harvest
.iter()
.map(|(pid, process)| {
(
*pid,
search_query
.as_ref()
.map(|q| q.check(process, is_using_command))
.unwrap_or(true),
)
})
.collect::<FxHashMap<_, _>>();
let filtered_tree = {
let mut filtered_tree = FxHashMap::default();
// We do a simple BFS traversal to build our filtered parent-to-tree mappings.
let mut visited_pids = FxHashMap::default();
let mut stack = orphan_pids
.iter()
.filter_map(|process| process_harvest.get(process))
.collect_vec();
while let Some(process) = stack.last() {
let is_process_matching = *kept_pids.get(&process.pid).unwrap_or(&false);
if let Some(children_pids) = process_parent_mapping.get(&process.pid) {
if children_pids
.iter()
.all(|pid| visited_pids.contains_key(pid))
{
let shown_children = children_pids
.iter()
.filter(|pid| visited_pids.get(*pid).copied().unwrap_or(false))
.collect_vec();
let is_shown = is_process_matching || !shown_children.is_empty();
visited_pids.insert(process.pid, is_shown);
if is_shown {
filtered_tree.insert(
process.pid,
shown_children
.into_iter()
.filter_map(|pid| {
process_harvest.get(pid).map(|process| process.pid)
})
.collect_vec(),
);
}
stack.pop();
} else {
children_pids
.iter()
.filter_map(|process| process_harvest.get(process))
.rev()
.for_each(|process| {
stack.push(process);
});
}
} else {
if is_process_matching {
filtered_tree.insert(process.pid, vec![]);
}
visited_pids.insert(process.pid, is_process_matching);
stack.pop();
}
}
filtered_tree
};
let mut data = vec![];
let mut prefixes = vec![];
let mut stack = orphan_pids
.iter()
.filter_map(|pid| {
if filtered_tree.contains_key(pid) {
process_harvest.get(pid).map(|process| {
ProcWidgetData::from_data(process, is_using_command, is_mem_percent)
})
} else {
None
}
})
.collect_vec();
self.try_sort(&mut stack);
let mut length_stack = vec![stack.len()];
while let (Some(process), Some(siblings_left)) = (stack.pop(), length_stack.last_mut()) {
*siblings_left -= 1;
let disabled = !*kept_pids.get(&process.pid).unwrap_or(&false);
let is_last = *siblings_left == 0;
if collapsed_pids.contains(&process.pid) {
let mut summed_process = process.clone();
if let Some(children_pids) = filtered_tree.get(&process.pid) {
let mut sum_queue = children_pids
.iter()
.filter_map(|child| {
process_harvest.get(child).map(|p| {
ProcWidgetData::from_data(p, is_using_command, is_mem_percent)
})
})
.collect_vec();
while let Some(process) = sum_queue.pop() {
summed_process.add(&process);
if let Some(pids) = filtered_tree.get(&process.pid) {
sum_queue.extend(pids.iter().filter_map(|child| {
process_harvest.get(child).map(|p| {
ProcWidgetData::from_data(p, is_using_command, is_mem_percent)
})
}));
}
}
}
let prefix = if prefixes.is_empty() {
"+ ".to_string()
} else {
format!(
"{}{}{} + ",
prefixes.join(""),
if is_last { BRANCH_END } else { BRANCH_SPLIT },
BRANCH_HORIZONTAL
)
};
data.push(summed_process.prefix(Some(prefix)).disabled(disabled));
} else {
let prefix = if prefixes.is_empty() {
String::default()
} else {
format!(
"{}{}{} ",
prefixes.join(""),
if is_last { BRANCH_END } else { BRANCH_SPLIT },
BRANCH_HORIZONTAL
)
};
let pid = process.pid;
data.push(process.prefix(Some(prefix)).disabled(disabled));
if let Some(children_pids) = filtered_tree.get(&pid) {
if prefixes.is_empty() {
prefixes.push(String::default());
} else {
prefixes.push(if is_last {
" ".to_string()
} else {
format!("{} ", BRANCH_VERTICAL)
});
}
let mut children = children_pids
.iter()
.filter_map(|child_pid| {
process_harvest.get(child_pid).map(|p| {
ProcWidgetData::from_data(p, is_using_command, is_mem_percent)
})
})
.collect_vec();
self.try_rev_sort(&mut children);
length_stack.push(children.len());
stack.extend(children);
}
}
while let Some(children_left) = length_stack.last() {
if *children_left == 0 {
length_stack.pop();
prefixes.pop();
} else {
break;
}
}
}
data
}
fn get_normal_data(
&mut self, process_harvest: &FxHashMap<Pid, ProcessHarvest>,
) -> Vec<ProcWidgetData> {
let search_query = self.get_query();
let is_using_command = self.is_using_command();
let is_mem_percent = self.is_mem_percent();
let filtered_iter = process_harvest.values().filter(|process| {
search_query
.as_ref()
.map(|query| query.check(process, is_using_command))
.unwrap_or(true)
});
let mut id_pid_map: FxHashMap<String, Vec<Pid>> = FxHashMap::default();
let mut filtered_data: Vec<ProcWidgetData> = if let ProcWidgetMode::Grouped = self.mode {
let mut id_process_mapping: FxHashMap<String, ProcessHarvest> = FxHashMap::default();
for process in filtered_iter {
let id = if is_using_command {
&process.command
} else {
&process.name
};
let pid = process.pid;
match id_pid_map.entry(id.clone()) {
Entry::Occupied(mut occupied) => {
occupied.get_mut().push(pid);
}
Entry::Vacant(vacant) => {
vacant.insert(vec![pid]);
}
}
if let Some(grouped_process_harvest) = id_process_mapping.get_mut(id) {
grouped_process_harvest.add(process);
} else {
id_process_mapping.insert(id.clone(), process.clone());
}
}
id_process_mapping
.values()
.map(|process| {
let id = if is_using_command {
&process.command
} else {
&process.name
};
let num_similar = id_pid_map.get(id).map(|val| val.len()).unwrap_or(1) as u64;
ProcWidgetData::from_data(process, is_using_command, is_mem_percent)
.num_similar(num_similar)
})
.collect()
} else {
filtered_iter
.map(|process| ProcWidgetData::from_data(process, is_using_command, is_mem_percent))
.collect()
};
self.id_pid_map = id_pid_map;
self.try_sort(&mut filtered_data);
filtered_data
}
#[inline(always)]
fn try_sort(&self, filtered_data: &mut [ProcWidgetData]) {
if let Some(column) = self.table.columns.get(self.table.sort_index()) {
column.sort_by(filtered_data, self.table.order());
}
}
#[inline(always)]
fn try_rev_sort(&self, filtered_data: &mut [ProcWidgetData]) {
if let Some(column) = self.table.columns.get(self.table.sort_index()) {
column.sort_by(
filtered_data,
match self.table.order() {
SortOrder::Ascending => SortOrder::Descending,
SortOrder::Descending => SortOrder::Ascending,
},
);
}
}
#[inline(always)]
fn get_mut_proc_col(&mut self, index: usize) -> Option<&mut ProcColumn> {
self.table.columns.get_mut(index).map(|col| col.inner_mut())
}
pub fn toggle_mem_percentage(&mut self) {
if let Some(mem) = self.get_mut_proc_col(Self::MEM) {
match mem {
ProcColumn::MemoryVal => {
*mem = ProcColumn::MemoryPercent;
}
ProcColumn::MemoryPercent => {
*mem = ProcColumn::MemoryVal;
}
_ => unreachable!(),
}
self.force_data_update();
}
}
/// Forces an update of the data stored.
#[inline]
pub fn force_data_update(&mut self) {
self.force_update_data = true;
}
/// Forces an entire rerender and update of the data stored.
#[inline]
pub fn force_rerender_and_update(&mut self) {
self.force_rerender = true;
self.force_update_data = true;
}
/// Marks the selected column as hidden, and automatically resets the selected column to CPU
/// and descending if that column was selected.
fn hide_column(&mut self, index: usize) {
if let Some(col) = self.table.columns.get_mut(index) {
col.is_hidden = true;
if self.table.sort_index() == index {
self.table.set_sort_index(Self::CPU);
self.table.set_order(SortOrder::Descending);
}
}
}
/// Marks the selected column as shown.
fn show_column(&mut self, index: usize) {
if let Some(col) = self.table.columns.get_mut(index) {
col.is_hidden = false;
}
}
/// Select a column. If the column is already selected, then just toggle the sort order.
pub fn select_column(&mut self, new_sort_index: usize) {
self.table.set_sort_index(new_sort_index);
self.force_data_update();
}
pub fn toggle_current_tree_branch_entry(&mut self) {
if let ProcWidgetMode::Tree { collapsed_pids } = &mut self.mode {
if let Some(process) = self.table.current_item() {
let pid = process.pid;
if !collapsed_pids.remove(&pid) {
collapsed_pids.insert(pid);
}
self.force_data_update();
}
}
}
pub fn toggle_command(&mut self) {
if let Some(col) = self.table.columns.get_mut(Self::PROC_NAME_OR_CMD) {
let inner = col.inner_mut();
match inner {
ProcColumn::Name => {
*inner = ProcColumn::Command;
if let ColumnWidthBounds::Soft { max_percentage, .. } = col.bounds_mut() {
*max_percentage = Some(0.5);
}
}
ProcColumn::Command => {
*inner = ProcColumn::Name;
if let ColumnWidthBounds::Soft { max_percentage, .. } = col.bounds_mut() {
*max_percentage = match self.mode {
ProcWidgetMode::Tree { .. } => Some(0.5),
ProcWidgetMode::Grouped | ProcWidgetMode::Normal => Some(0.3),
};
}
}
_ => unreachable!(),
}
self.force_rerender_and_update();
}
}
/// Toggles the appropriate columns/settings when tab is pressed.
///
/// If count is enabled, we should set the mode to [`ProcWidgetMode::Grouped`], and switch off the User and State
/// columns. We should also move the user off of the columns if they were selected, as those columns are now hidden
/// (handled by internal method calls), and go back to the "defaults".
///
/// Otherwise, if count is disabled, then the User and State columns should be re-enabled, and the mode switched
/// to [`ProcWidgetMode::Normal`].
pub fn on_tab(&mut self) {
if !matches!(self.mode, ProcWidgetMode::Tree { .. }) {
if let Some(sort_col) = self.table.columns.get_mut(Self::PID_OR_COUNT) {
let col = sort_col.inner_mut();
match col {
ProcColumn::Pid => {
*col = ProcColumn::Count;
sort_col.default_order = SortOrder::Descending;
#[cfg(target_family = "unix")]
self.hide_column(Self::USER);
self.hide_column(Self::STATE);
self.mode = ProcWidgetMode::Grouped;
}
ProcColumn::Count => {
*col = ProcColumn::Pid;
sort_col.default_order = SortOrder::Ascending;
#[cfg(target_family = "unix")]
self.show_column(Self::USER);
self.show_column(Self::STATE);
self.mode = ProcWidgetMode::Normal;
}
_ => unreachable!(),
}
self.force_rerender_and_update();
}
}
}
pub fn column_text(&self) -> Vec<Cow<'static, str>> {
self.table
.columns
.iter()
.filter(|c| !c.is_hidden)
.map(|c| c.inner().text())
.collect::<Vec<_>>()
}
pub fn get_search_cursor_position(&self) -> usize {
self.proc_search.search_state.grapheme_cursor.cur_cursor()
}
pub fn get_char_cursor_position(&self) -> usize {
self.proc_search.search_state.char_cursor_position
}
pub fn is_search_enabled(&self) -> bool {
self.proc_search.search_state.is_enabled
}
pub fn get_current_search_query(&self) -> &String {
&self.proc_search.search_state.current_search_query
}
pub fn update_query(&mut self) {
if self
.proc_search
.search_state
.current_search_query
.is_empty()
{
self.proc_search.search_state.is_blank_search = true;
self.proc_search.search_state.is_invalid_search = false;
self.proc_search.search_state.error_message = None;
} else {
match parse_query(
&self.proc_search.search_state.current_search_query,
self.proc_search.is_searching_whole_word,
self.proc_search.is_ignoring_case,
self.proc_search.is_searching_with_regex,
) {
Ok(parsed_query) => {
self.proc_search.search_state.query = Some(parsed_query);
self.proc_search.search_state.is_blank_search = false;
self.proc_search.search_state.is_invalid_search = false;
self.proc_search.search_state.error_message = None;
}
Err(err) => {
self.proc_search.search_state.is_blank_search = false;
self.proc_search.search_state.is_invalid_search = true;
self.proc_search.search_state.error_message = Some(err.to_string());
}
}
}
self.table.state.display_start_index = 0;
self.table.state.current_index = 0;
self.force_data_update();
}
pub fn clear_search(&mut self) {
self.proc_search.search_state.reset();
self.force_data_update();
}
pub fn search_walk_forward(&mut self, start_position: usize) {
self.proc_search
.search_state
.grapheme_cursor
.next_boundary(
&self.proc_search.search_state.current_search_query[start_position..],
start_position,
)
.unwrap();
}
pub fn search_walk_back(&mut self, start_position: usize) {
self.proc_search
.search_state
.grapheme_cursor
.prev_boundary(
&self.proc_search.search_state.current_search_query[..start_position],
0,
)
.unwrap();
}
/// Returns the number of columns *enabled*. Note this differs from *visible* - a column may be enabled but not
/// visible (e.g. off screen).
pub fn num_enabled_columns(&self) -> usize {
self.table.columns.iter().filter(|c| !c.is_hidden).count()
}
/// Sets the [`ProcWidget`]'s current sort index to whatever was in the sort table if possible, then closes the
/// sort table.
pub(crate) fn use_sort_table_value(&mut self) {
self.table.set_sort_index(self.sort_table.current_index());
self.is_sort_open = false;
self.force_rerender_and_update();
}
}

View file

@ -0,0 +1,122 @@
use crate::{
components::data_table::{ColumnHeader, SortsRow},
utils::gen_util::sort_partial_fn,
};
use std::{borrow::Cow, cmp::Reverse};
use super::ProcWidgetData;
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum ProcColumn {
CpuPercent,
MemoryVal,
MemoryPercent,
Pid,
Count,
Name,
Command,
ReadPerSecond,
WritePerSecond,
TotalRead,
TotalWrite,
State,
User,
}
impl ColumnHeader for ProcColumn {
fn text(&self) -> Cow<'static, str> {
match self {
ProcColumn::CpuPercent => "CPU%",
ProcColumn::MemoryVal => "Mem",
ProcColumn::MemoryPercent => "Mem%",
ProcColumn::Pid => "PID",
ProcColumn::Count => "Count",
ProcColumn::Name => "Name",
ProcColumn::Command => "Command",
ProcColumn::ReadPerSecond => "R/s",
ProcColumn::WritePerSecond => "W/s",
ProcColumn::TotalRead => "T.Read",
ProcColumn::TotalWrite => "T.Write",
ProcColumn::State => "State",
ProcColumn::User => "User",
}
.into()
}
fn header(&self) -> Cow<'static, str> {
match self {
ProcColumn::CpuPercent => "CPU%(c)",
ProcColumn::MemoryVal => "Mem(m)",
ProcColumn::MemoryPercent => "Mem%(m)",
ProcColumn::Pid => "PID(p)",
ProcColumn::Count => "Count",
ProcColumn::Name => "Name(n)",
ProcColumn::Command => "Command(n)",
ProcColumn::ReadPerSecond => "R/s",
ProcColumn::WritePerSecond => "W/s",
ProcColumn::TotalRead => "T.Read",
ProcColumn::TotalWrite => "T.Write",
ProcColumn::State => "State",
ProcColumn::User => "User",
}
.into()
}
}
impl SortsRow<ProcWidgetData> for ProcColumn {
fn sort_data(&self, data: &mut [ProcWidgetData], descending: bool) {
match self {
ProcColumn::CpuPercent => {
data.sort_by(|a, b| {
sort_partial_fn(descending)(a.cpu_usage_percent, b.cpu_usage_percent)
});
}
ProcColumn::MemoryVal | ProcColumn::MemoryPercent => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.mem_usage, &b.mem_usage));
}
ProcColumn::Pid => {
data.sort_by(|a, b| sort_partial_fn(descending)(a.pid, b.pid));
}
ProcColumn::Count => {
data.sort_by(|a, b| sort_partial_fn(descending)(a.num_similar, b.num_similar));
}
ProcColumn::Name | ProcColumn::Command => {
if descending {
data.sort_by_cached_key(|pd| Reverse(pd.id.to_lowercase()));
} else {
data.sort_by_cached_key(|pd| pd.id.to_lowercase());
}
}
ProcColumn::ReadPerSecond => {
data.sort_by(|a, b| sort_partial_fn(descending)(a.rps, b.rps));
}
ProcColumn::WritePerSecond => {
data.sort_by(|a, b| sort_partial_fn(descending)(a.wps, b.wps));
}
ProcColumn::TotalRead => {
data.sort_by(|a, b| sort_partial_fn(descending)(a.total_read, b.total_read));
}
ProcColumn::TotalWrite => {
data.sort_by(|a, b| sort_partial_fn(descending)(a.total_write, b.total_write));
}
ProcColumn::State => {
if descending {
data.sort_by_cached_key(|pd| Reverse(pd.process_state.to_lowercase()));
} else {
data.sort_by_cached_key(|pd| pd.process_state.to_lowercase());
}
}
ProcColumn::User => {
#[cfg(target_family = "unix")]
{
if descending {
data.sort_by_cached_key(|pd| Reverse(pd.user.to_lowercase()));
} else {
data.sort_by_cached_key(|pd| pd.user.to_lowercase());
}
}
}
}
}
}

View file

@ -0,0 +1,274 @@
use std::{
cmp::{max, Ordering},
fmt::Display,
};
use concat_string::concat_string;
use tui::{text::Text, widgets::Row};
use crate::{
app::data_harvester::processes::ProcessHarvest,
canvas::Painter,
components::data_table::{DataTableColumn, DataToCell},
data_conversion::{binary_byte_string, dec_bytes_per_second_string, dec_bytes_string},
utils::gen_util::truncate_text,
Pid,
};
use super::proc_widget_column::ProcColumn;
#[derive(Clone)]
enum IdType {
Name(String),
Command(String),
}
#[derive(Clone)]
pub struct Id {
id_type: IdType,
prefix: Option<String>,
}
impl Id {
/// Returns the ID as a lowercase [`String`], with no prefix. This is primarily useful for
/// cases like sorting where we treat everything as the same case (e.g. `Discord` comes before
/// `dkms`).
pub fn to_lowercase(&self) -> String {
match &self.id_type {
IdType::Name(name) => name.to_lowercase(),
IdType::Command(cmd) => cmd.to_lowercase(),
}
}
/// Return the ID as a borrowed [`str`] with no prefix.
pub fn as_str(&self) -> &str {
match &self.id_type {
IdType::Name(name) => name.as_str(),
IdType::Command(cmd) => cmd.as_str(),
}
}
/// Returns the ID as a [`String`] with prefix.
pub fn to_prefixed_string(&self) -> String {
if let Some(prefix) = &self.prefix {
concat_string!(
prefix,
match &self.id_type {
IdType::Name(name) => name,
IdType::Command(cmd) => cmd,
}
)
} else {
match &self.id_type {
IdType::Name(name) => name.to_string(),
IdType::Command(cmd) => cmd.to_string(),
}
}
}
}
impl Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// TODO: Can reduce this to 32 bytes.
#[derive(PartialEq, Clone)]
pub enum MemUsage {
Percent(f64),
Bytes(u64),
}
impl PartialOrd for MemUsage {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match (self, other) {
(MemUsage::Percent(a), MemUsage::Percent(b)) => a.partial_cmp(b),
(MemUsage::Bytes(a), MemUsage::Bytes(b)) => a.partial_cmp(b),
_ => unreachable!(),
}
}
}
impl Display for MemUsage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MemUsage::Percent(percent) => f.write_fmt(format_args!("{:.1}%", percent)),
MemUsage::Bytes(bytes) => f.write_str(&binary_byte_string(*bytes)),
}
}
}
#[derive(Clone)]
pub struct ProcWidgetData {
pub pid: Pid,
pub ppid: Option<Pid>,
pub id: Id,
pub cpu_usage_percent: f64,
pub mem_usage: MemUsage,
pub rps: u64,
pub wps: u64,
pub total_read: u64,
pub total_write: u64,
pub process_state: String,
pub process_char: char,
#[cfg(target_family = "unix")]
pub user: String,
pub num_similar: u64,
pub disabled: bool,
}
impl ProcWidgetData {
pub fn from_data(process: &ProcessHarvest, is_command: bool, is_mem_percent: bool) -> Self {
let id = Id {
id_type: if is_command {
IdType::Command(process.command.clone())
} else {
IdType::Name(process.name.clone())
},
prefix: None,
};
let mem_usage = if is_mem_percent {
MemUsage::Percent(process.mem_usage_percent)
} else {
MemUsage::Bytes(process.mem_usage_bytes)
};
Self {
pid: process.pid,
ppid: process.parent_pid,
id,
cpu_usage_percent: process.cpu_usage_percent,
mem_usage,
rps: process.read_bytes_per_sec,
wps: process.write_bytes_per_sec,
total_read: process.total_read_bytes,
total_write: process.total_write_bytes,
process_state: process.process_state.0.clone(),
process_char: process.process_state.1,
#[cfg(target_family = "unix")]
user: process.user.to_string(),
num_similar: 1,
disabled: false,
}
}
pub fn num_similar(mut self, num_similar: u64) -> Self {
self.num_similar = num_similar;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn prefix(mut self, prefix: Option<String>) -> Self {
self.id.prefix = prefix;
self
}
pub fn add(&mut self, other: &Self) {
self.cpu_usage_percent += other.cpu_usage_percent;
self.mem_usage = match (&self.mem_usage, &other.mem_usage) {
(MemUsage::Percent(a), MemUsage::Percent(b)) => MemUsage::Percent(a + b),
(MemUsage::Bytes(a), MemUsage::Bytes(b)) => MemUsage::Bytes(a + b),
(MemUsage::Percent(_), MemUsage::Bytes(_))
| (MemUsage::Bytes(_), MemUsage::Percent(_)) => {
unreachable!("trying to add together two different memory usage types!")
}
};
self.rps += other.rps;
self.wps += other.wps;
self.total_read += other.total_read;
self.total_write += other.total_write;
}
fn to_string(&self, column: &ProcColumn) -> String {
match column {
ProcColumn::CpuPercent => format!("{:.1}%", self.cpu_usage_percent),
ProcColumn::MemoryVal | ProcColumn::MemoryPercent => self.mem_usage.to_string(),
ProcColumn::Pid => self.pid.to_string(),
ProcColumn::Count => self.num_similar.to_string(),
ProcColumn::Name | ProcColumn::Command => self.id.to_prefixed_string(),
ProcColumn::ReadPerSecond => dec_bytes_per_second_string(self.rps),
ProcColumn::WritePerSecond => dec_bytes_per_second_string(self.wps),
ProcColumn::TotalRead => dec_bytes_string(self.total_read),
ProcColumn::TotalWrite => dec_bytes_string(self.total_write),
ProcColumn::State => self.process_char.to_string(),
ProcColumn::User => {
#[cfg(target_family = "unix")]
{
self.user.clone()
}
#[cfg(not(target_family = "unix"))]
{
"".to_string()
}
}
}
}
}
impl DataToCell<ProcColumn> for ProcWidgetData {
fn to_cell<'a>(&'a self, column: &ProcColumn, calculated_width: u16) -> Option<Text<'a>> {
Some(truncate_text(
&match column {
ProcColumn::CpuPercent => {
format!("{:.1}%", self.cpu_usage_percent)
}
ProcColumn::MemoryVal | ProcColumn::MemoryPercent => self.mem_usage.to_string(),
ProcColumn::Pid => self.pid.to_string(),
ProcColumn::Count => self.num_similar.to_string(),
ProcColumn::Name | ProcColumn::Command => self.id.to_prefixed_string(),
ProcColumn::ReadPerSecond => dec_bytes_per_second_string(self.rps),
ProcColumn::WritePerSecond => dec_bytes_per_second_string(self.wps),
ProcColumn::TotalRead => dec_bytes_string(self.total_read),
ProcColumn::TotalWrite => dec_bytes_string(self.total_write),
ProcColumn::State => {
if calculated_width < 8 {
self.process_char.to_string()
} else {
self.process_state.clone()
}
}
ProcColumn::User => {
#[cfg(target_family = "unix")]
{
self.user.clone()
}
#[cfg(not(target_family = "unix"))]
{
"".to_string()
}
}
},
calculated_width,
))
}
#[inline(always)]
fn style_row<'a>(&self, row: Row<'a>, painter: &Painter) -> Row<'a> {
if self.disabled {
row.style(painter.colours.disabled_text_style)
} else {
row
}
}
fn column_widths<C: DataTableColumn<ProcColumn>>(data: &[Self], columns: &[C]) -> Vec<u16>
where
Self: Sized,
{
let mut widths = vec![0; columns.len()];
for d in data {
for (w, c) in widths.iter_mut().zip(columns) {
*w = max(*w, d.to_string(c.inner()).len() as u16);
}
}
widths
}
}

View file

@ -0,0 +1,42 @@
use std::borrow::Cow;
use tui::text::Text;
use crate::{
components::data_table::{ColumnHeader, DataTableColumn, DataToCell},
utils::gen_util::truncate_text,
};
pub struct SortTableColumn;
impl ColumnHeader for SortTableColumn {
fn text(&self) -> std::borrow::Cow<'static, str> {
"Sort By".into()
}
}
impl DataToCell<SortTableColumn> for &'static str {
fn to_cell<'a>(&'a self, _column: &SortTableColumn, calculated_width: u16) -> Option<Text<'a>> {
Some(truncate_text(self, calculated_width))
}
fn column_widths<C: DataTableColumn<SortTableColumn>>(data: &[Self], _columns: &[C]) -> Vec<u16>
where
Self: Sized,
{
vec![data.iter().map(|d| d.len() as u16).max().unwrap_or(0)]
}
}
impl DataToCell<SortTableColumn> for Cow<'static, str> {
fn to_cell<'a>(&'a self, _column: &SortTableColumn, calculated_width: u16) -> Option<Text<'a>> {
Some(truncate_text(self, calculated_width))
}
fn column_widths<C: DataTableColumn<SortTableColumn>>(data: &[Self], _columns: &[C]) -> Vec<u16>
where
Self: Sized,
{
vec![data.iter().map(|d| d.len() as u16).max().unwrap_or(0)]
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
use std::{borrow::Cow, cmp::max};
use concat_string::concat_string;
use kstring::KString;
use tui::text::Text;
use crate::{
app::{data_harvester::temperature::TemperatureType, AppConfigFields},
canvas::canvas_colours::CanvasColours,
components::data_table::{
Column, ColumnHeader, DataTable, DataTableColumn, DataTableProps, DataTableStyling,
DataToCell,
},
utils::gen_util::truncate_text,
};
#[derive(Clone)]
pub struct TempWidgetData {
pub sensor: KString,
pub temperature_value: u64,
pub temperature_type: TemperatureType,
}
pub enum TempWidgetColumn {
Sensor,
Temp,
}
impl ColumnHeader for TempWidgetColumn {
fn text(&self) -> Cow<'static, str> {
match self {
TempWidgetColumn::Sensor => "Sensor".into(),
TempWidgetColumn::Temp => "Temp".into(),
}
}
}
impl TempWidgetData {
pub fn temperature(&self) -> KString {
let temp_val = self.temperature_value.to_string();
let temp_type = match self.temperature_type {
TemperatureType::Celsius => "°C",
TemperatureType::Kelvin => "K",
TemperatureType::Fahrenheit => "°F",
};
concat_string!(temp_val, temp_type).into()
}
}
impl DataToCell<TempWidgetColumn> for TempWidgetData {
fn to_cell<'a>(&'a self, column: &TempWidgetColumn, calculated_width: u16) -> Option<Text<'a>> {
Some(match column {
TempWidgetColumn::Sensor => truncate_text(&self.sensor, calculated_width),
TempWidgetColumn::Temp => truncate_text(&self.temperature(), calculated_width),
})
}
fn column_widths<C: DataTableColumn<TempWidgetColumn>>(
data: &[TempWidgetData], _columns: &[C],
) -> Vec<u16>
where
Self: Sized,
{
let mut widths = vec![0; 2];
data.iter().for_each(|row| {
widths[0] = max(widths[0], row.sensor.len() as u16);
widths[1] = max(widths[1], row.temperature().len() as u16);
});
widths
}
}
pub struct TempWidgetState {
pub table: DataTable<TempWidgetData, TempWidgetColumn>,
}
impl TempWidgetState {
pub fn new(config: &AppConfigFields, colours: &CanvasColours) -> Self {
const COLUMNS: [Column<TempWidgetColumn>; 2] = [
Column::soft(TempWidgetColumn::Sensor, Some(0.8)),
Column::soft(TempWidgetColumn::Temp, None),
];
let props = DataTableProps {
title: Some(" Temperatures ".into()),
table_gap: config.table_gap,
left_to_right: false,
is_basic: config.use_basic_mode,
show_table_scroll_position: config.show_table_scroll_position,
show_current_entry_when_unfocused: false,
};
let styling = DataTableStyling::from_colours(colours);
Self {
table: DataTable::new(COLUMNS, props, styling),
}
}
}

View file

@ -1,29 +0,0 @@
use crate::components::text_table::{
CellContent, TableComponentColumn, TableComponentState, WidthBounds,
};
pub struct TempWidgetState {
pub table_state: TableComponentState,
}
impl Default for TempWidgetState {
fn default() -> Self {
const TEMP_HEADERS: [&str; 2] = ["Sensor", "Temp"];
const WIDTHS: [WidthBounds; TEMP_HEADERS.len()] = [
WidthBounds::soft_from_str(TEMP_HEADERS[0], Some(0.8)),
WidthBounds::soft_from_str(TEMP_HEADERS[1], None),
];
TempWidgetState {
table_state: TableComponentState::new(
TEMP_HEADERS
.iter()
.zip(WIDTHS)
.map(|(header, width)| {
TableComponentColumn::new_custom(CellContent::new(*header, None), width)
})
.collect(),
),
}
}
}

View file

@ -4,7 +4,13 @@
#[macro_use]
extern crate log;
use bottom::{canvas, constants::*, data_conversion::*, options::*, *};
use bottom::{
canvas::{self, canvas_colours::CanvasColours},
constants::*,
data_conversion::*,
options::*,
*,
};
use std::{
boxed::Box,
@ -47,6 +53,12 @@ fn main() -> Result<()> {
get_widget_layout(&matches, &config)
.context("Found an issue while trying to build the widget layout.")?;
// FIXME: Should move this into build app or config
let colours = {
let colour_scheme = get_color_scheme(&matches, &config)?;
CanvasColours::new(colour_scheme, &config)?
};
// Create "app" struct, which will control most of the program and store settings/state
let mut app = build_app(
&matches,
@ -54,12 +66,11 @@ fn main() -> Result<()> {
&widget_layout,
default_widget_id,
&default_widget_type_option,
config_path,
&colours,
)?;
// Create painter and set colours.
let mut painter =
canvas::Painter::init(widget_layout, &config, get_color_scheme(&matches, &config)?)?;
let mut painter = canvas::Painter::init(widget_layout, colours)?;
// Create termination mutex and cvar
#[allow(clippy::mutex_atomic)]
@ -150,7 +161,7 @@ fn main() -> Result<()> {
app.is_force_redraw = true;
}
if !app.is_frozen {
if !app.frozen_state.is_frozen() {
// Convert all data into tui-compliant components
// Network
@ -177,12 +188,15 @@ fn main() -> Result<()> {
// Disk
if app.used_widgets.use_disk {
app.converted_data.disk_data = convert_disk_row(&app.data_collection);
app.converted_data.ingest_disk_data(&app.data_collection);
}
// Temperatures
if app.used_widgets.use_temp {
app.converted_data.temp_sensor_data = convert_temp_row(&app);
app.converted_data.ingest_temp_data(
&app.data_collection,
app.app_config_fields.temperature_type,
)
}
// Memory
@ -208,13 +222,9 @@ fn main() -> Result<()> {
}
}
if app.used_widgets.use_cpu {
// CPU
convert_cpu_data_points(
&app.data_collection,
&mut app.converted_data.cpu_data,
);
if app.used_widgets.use_cpu {
app.converted_data.ingest_cpu_data(&app.data_collection);
app.converted_data.load_avg_data = app.data_collection.load_avg_harvest;
}

View file

@ -18,12 +18,11 @@ use crate::{
App,
},
constants::*,
options::Config,
utils::error,
utils::error::BottomError,
};
mod canvas_colours;
pub mod canvas_colours;
mod dialogs;
mod drawing_utils;
mod widgets;
@ -77,9 +76,7 @@ pub struct Painter {
}
impl Painter {
pub fn init(
widget_layout: BottomLayout, config: &Config, colour_scheme: ColourScheme,
) -> anyhow::Result<Self> {
pub fn init(widget_layout: BottomLayout, colours: CanvasColours) -> anyhow::Result<Self> {
// Now for modularity; we have to also initialize the base layouts!
// We want to do this ONCE and reuse; after this we can just construct
// based on the console size.
@ -148,7 +145,7 @@ impl Painter {
});
let mut painter = Painter {
colours: CanvasColours::default(),
colours,
height: 0,
width: 0,
styled_help_text: Vec::default(),
@ -161,11 +158,6 @@ impl Painter {
derived_widget_draw_locs: Vec::default(),
};
if let ColourScheme::Custom = colour_scheme {
painter.generate_config_colours(config)?;
} else {
painter.generate_colour_scheme(colour_scheme)?;
}
painter.complete_painter_init();
Ok(painter)
@ -181,47 +173,6 @@ impl Painter {
}
}
fn generate_config_colours(&mut self, config: &Config) -> anyhow::Result<()> {
if let Some(colours) = &config.colors {
self.colours.set_colours_from_palette(colours)?;
}
Ok(())
}
fn generate_colour_scheme(&mut self, colour_scheme: ColourScheme) -> anyhow::Result<()> {
match colour_scheme {
ColourScheme::Default => {
// Don't have to do anything.
}
ColourScheme::DefaultLight => {
self.colours
.set_colours_from_palette(&*DEFAULT_LIGHT_MODE_COLOUR_PALETTE)?;
}
ColourScheme::Gruvbox => {
self.colours
.set_colours_from_palette(&*GRUVBOX_COLOUR_PALETTE)?;
}
ColourScheme::GruvboxLight => {
self.colours
.set_colours_from_palette(&*GRUVBOX_LIGHT_COLOUR_PALETTE)?;
}
ColourScheme::Nord => {
self.colours
.set_colours_from_palette(&*NORD_COLOUR_PALETTE)?;
}
ColourScheme::NordLight => {
self.colours
.set_colours_from_palette(&*NORD_LIGHT_COLOUR_PALETTE)?;
}
ColourScheme::Custom => {
// This case should never occur, just do nothing.
}
}
Ok(())
}
/// Must be run once before drawing, but after setting colours.
/// This is to set some remaining styles and text.
fn complete_painter_init(&mut self) {
@ -275,7 +226,7 @@ impl Painter {
use BottomWidgetType::*;
terminal.draw(|f| {
let (terminal_size, frozen_draw_loc) = if app_state.is_frozen {
let (terminal_size, frozen_draw_loc) = if app_state.frozen_state.is_frozen() {
let split_loc = Layout::default()
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(f.size());
@ -445,14 +396,12 @@ impl Painter {
f,
app_state,
rect[0],
true,
app_state.current_widget.widget_id,
),
Temp => self.draw_temp_table(
f,
app_state,
rect[0],
true,
app_state.current_widget.widget_id,
),
Net => self.draw_network_graph(
@ -548,13 +497,9 @@ impl Painter {
later_widget_id = Some(widget_id);
if vertical_chunks[3].width >= 2 {
match basic_table_widget_state.currently_displayed_widget_type {
Disk => self.draw_disk_table(
f,
app_state,
vertical_chunks[3],
false,
widget_id,
),
Disk => {
self.draw_disk_table(f, app_state, vertical_chunks[3], widget_id)
}
Proc | ProcSort => {
let wid = widget_id
- match basic_table_widget_state.currently_displayed_widget_type
@ -571,13 +516,9 @@ impl Painter {
wid,
);
}
Temp => self.draw_temp_table(
f,
app_state,
vertical_chunks[3],
false,
widget_id,
),
Temp => {
self.draw_temp_table(f, app_state, vertical_chunks[3], widget_id)
}
Battery => self.draw_battery_display(
f,
app_state,
@ -708,12 +649,8 @@ impl Painter {
Cpu => self.draw_cpu(f, app_state, *widget_draw_loc, widget.widget_id),
Mem => self.draw_memory_graph(f, app_state, *widget_draw_loc, widget.widget_id),
Net => self.draw_network(f, app_state, *widget_draw_loc, widget.widget_id),
Temp => {
self.draw_temp_table(f, app_state, *widget_draw_loc, true, widget.widget_id)
}
Disk => {
self.draw_disk_table(f, app_state, *widget_draw_loc, true, widget.widget_id)
}
Temp => self.draw_temp_table(f, app_state, *widget_draw_loc, widget.widget_id),
Disk => self.draw_disk_table(f, app_state, *widget_draw_loc, widget.widget_id),
Proc => self.draw_process_widget(
f,
app_state,

View file

@ -1,7 +1,13 @@
use crate::{options::ConfigColours, utils::error};
use crate::{
constants::*,
options::{Config, ConfigColours},
utils::error,
};
use anyhow::Context;
use colour_utils::*;
use tui::style::{Color, Style};
use super::ColourScheme;
mod colour_utils;
pub struct CanvasColours {
@ -78,6 +84,36 @@ impl Default for CanvasColours {
}
impl CanvasColours {
pub fn new(colour_scheme: ColourScheme, config: &Config) -> anyhow::Result<Self> {
let mut canvas_colours = Self::default();
match colour_scheme {
ColourScheme::Default => {}
ColourScheme::DefaultLight => {
canvas_colours.set_colours_from_palette(&*DEFAULT_LIGHT_MODE_COLOUR_PALETTE)?;
}
ColourScheme::Gruvbox => {
canvas_colours.set_colours_from_palette(&*GRUVBOX_COLOUR_PALETTE)?;
}
ColourScheme::GruvboxLight => {
canvas_colours.set_colours_from_palette(&*GRUVBOX_LIGHT_COLOUR_PALETTE)?;
}
ColourScheme::Nord => {
canvas_colours.set_colours_from_palette(&*NORD_COLOUR_PALETTE)?;
}
ColourScheme::NordLight => {
canvas_colours.set_colours_from_palette(&*NORD_LIGHT_COLOUR_PALETTE)?;
}
ColourScheme::Custom => {
if let Some(colors) = &config.colors {
canvas_colours.set_colours_from_palette(colors)?;
}
}
}
Ok(canvas_colours)
}
pub fn set_colours_from_palette(&mut self, colours: &ConfigColours) -> anyhow::Result<()> {
if let Some(border_color) = &colours.border_color {
self.set_border_colour(border_color)

View file

@ -4,7 +4,7 @@ use crate::{
app::App,
canvas::{drawing_utils::*, Painter},
constants::*,
data_conversion::ConvertedCpuData,
data_conversion::CpuWidgetData,
};
use tui::{
@ -21,7 +21,7 @@ impl Painter {
) {
// Skip the first element, it's the "all" element
if app_state.converted_data.cpu_data.len() > 1 {
let cpu_data: &[ConvertedCpuData] = &app_state.converted_data.cpu_data[1..];
let cpu_data: &[CpuWidgetData] = &app_state.converted_data.cpu_data[1..];
// This is a bit complicated, but basically, we want to draw SOME number
// of columns to draw all CPUs. Ideally, as well, we want to not have
@ -70,69 +70,70 @@ impl Painter {
// We do +4 as if it's too few bars in the bar length, it's kinda pointless.
let cpu_bars = if chunk_width >= COMBINED_SPACING + 4 {
let bar_length = chunk_width - COMBINED_SPACING;
(0..num_cpus)
.map(|cpu_index| {
let use_percentage =
if let Some(cpu_usage) = cpu_data[cpu_index].cpu_data.last() {
cpu_usage.1
} else {
0.0
};
let num_bars = calculate_basic_use_bars(use_percentage, bar_length);
format!(
cpu_data
.iter()
.enumerate()
.filter_map(|(index, cpu)| match &cpu {
CpuWidgetData::All => None,
CpuWidgetData::Entry {
data_type: _,
data: _,
last_entry,
} => {
let num_bars = calculate_basic_use_bars(*last_entry, bar_length);
Some(format!(
"{:3}[{}{}{:3.0}%]",
if app_state.app_config_fields.show_average_cpu {
if cpu_index == 0 {
if index == 0 {
"AVG".to_string()
} else {
(cpu_index - 1).to_string()
(index - 1).to_string()
}
} else {
cpu_index.to_string()
index.to_string()
},
"|".repeat(num_bars),
" ".repeat(bar_length - num_bars),
use_percentage.round(),
)
last_entry.round(),
))
}
})
.collect::<Vec<_>>()
} else if chunk_width >= REDUCED_SPACING {
(0..num_cpus)
.map(|cpu_index| {
let use_percentage =
if let Some(cpu_usage) = cpu_data[cpu_index].cpu_data.last() {
cpu_usage.1
} else {
0.0
};
format!(
cpu_data
.iter()
.enumerate()
.filter_map(|(index, cpu)| match &cpu {
CpuWidgetData::All => None,
CpuWidgetData::Entry {
data_type: _,
data: _,
last_entry,
} => Some(format!(
"{:3} {:3.0}%",
if app_state.app_config_fields.show_average_cpu {
if cpu_index == 0 {
if index == 0 {
"AVG".to_string()
} else {
(cpu_index - 1).to_string()
(index - 1).to_string()
}
} else {
cpu_index.to_string()
index.to_string()
},
use_percentage.round(),
)
last_entry.round(),
)),
})
.collect::<Vec<_>>()
} else {
(0..num_cpus)
.map(|cpu_index| {
let use_percentage =
if let Some(cpu_usage) = cpu_data[cpu_index].cpu_data.last() {
cpu_usage.1
} else {
0.0
};
format!("{:3.0}%", use_percentage.round(),)
cpu_data
.iter()
.filter_map(|cpu| match &cpu {
CpuWidgetData::All => None,
CpuWidgetData::Entry {
data_type: _,
data: _,
last_entry,
} => Some(format!("{:3.0}%", last_entry.round())),
})
.collect::<Vec<_>>()
};

View file

@ -1,18 +1,17 @@
use std::{borrow::Cow, iter};
use std::borrow::Cow;
use crate::{
app::{layout_manager::WidgetDirection, App, CpuWidgetState},
app::{layout_manager::WidgetDirection, widgets::CpuWidgetState, App},
canvas::{drawing_utils::should_hide_x_label, Painter},
components::{
text_table::{CellContent, TextTable},
data_table::{DrawInfo, SelectionState},
time_graph::{GraphData, TimeGraph},
},
data_conversion::{ConvertedCpuData, TableData, TableRow},
data_conversion::CpuWidgetData,
};
use concat_string::concat_string;
use itertools::Either;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
@ -119,19 +118,21 @@ impl Painter {
}
fn generate_points<'a>(
&self, cpu_widget_state: &CpuWidgetState, cpu_data: &'a [ConvertedCpuData],
show_avg_cpu: bool,
&self, cpu_widget_state: &CpuWidgetState, cpu_data: &'a [CpuWidgetData], show_avg_cpu: bool,
) -> Vec<GraphData<'a>> {
let show_avg_offset = if show_avg_cpu { AVG_POSITION } else { 0 };
let current_scroll_position = cpu_widget_state.table_state.current_scroll_position;
let current_scroll_position = cpu_widget_state.table.state.current_index;
if current_scroll_position == ALL_POSITION {
// This case ensures the other cases cannot have the position be equal to 0.
cpu_data
.iter()
.enumerate()
.rev()
.map(|(itx, cpu)| {
.filter_map(|(itx, cpu)| {
match &cpu {
CpuWidgetData::All => None,
CpuWidgetData::Entry { data, .. } => {
let style = if show_avg_cpu && itx == AVG_POSITION {
self.colours.avg_colour_style
} else if itx == ALL_POSITION {
@ -142,14 +143,18 @@ impl Painter {
% self.colours.cpu_colour_styles.len()]
};
GraphData {
points: &cpu.cpu_data[..],
Some(GraphData {
points: &data[..],
style,
name: None,
})
}
}
})
.collect::<Vec<_>>()
} else if let Some(cpu) = cpu_data.get(current_scroll_position) {
} else if let Some(CpuWidgetData::Entry { data, .. }) =
cpu_data.get(current_scroll_position)
{
let style = if show_avg_cpu && current_scroll_position == AVG_POSITION {
self.colours.avg_colour_style
} else {
@ -159,7 +164,7 @@ impl Painter {
};
vec![GraphData {
points: &cpu.cpu_data[..],
points: &data[..],
style,
name: None,
}]
@ -227,86 +232,24 @@ impl Painter {
let recalculate_column_widths = app_state.should_get_widget_bounds();
if let Some(cpu_widget_state) = app_state.cpu_state.widget_states.get_mut(&(widget_id - 1))
{
// TODO: This line (and the one above, see caller) is pretty dumb but I guess needed.
// TODO: This line (and the one above, see caller) is pretty dumb but I guess needed for now. Refactor if possible!
cpu_widget_state.is_legend_hidden = false;
let show_avg_cpu = app_state.app_config_fields.show_average_cpu;
let cpu_data = {
let col_widths = vec![1, 3]; // TODO: Should change this to take const generics (usize) and an array.
let colour_iter = if show_avg_cpu {
Either::Left(
iter::once(&self.colours.all_colour_style)
.chain(iter::once(&self.colours.avg_colour_style))
.chain(self.colours.cpu_colour_styles.iter().cycle()),
)
} else {
Either::Right(
iter::once(&self.colours.all_colour_style)
.chain(self.colours.cpu_colour_styles.iter().cycle()),
)
};
let data = {
let iter = app_state.converted_data.cpu_data.iter().zip(colour_iter);
const CPU_WIDTH_CHECK: u16 = 10; // This is hard-coded, it's terrible.
if draw_loc.width < CPU_WIDTH_CHECK {
Either::Left(iter.map(|(cpu, style)| {
let row = vec![
CellContent::Simple("".into()),
CellContent::Simple(if cpu.legend_value.is_empty() {
cpu.cpu_name.clone().into()
} else {
cpu.legend_value.clone().into()
}),
];
TableRow::Styled(row, *style)
}))
} else {
Either::Right(iter.map(|(cpu, style)| {
let row = vec![
CellContent::HasAlt {
alt: cpu.short_cpu_name.clone().into(),
main: cpu.cpu_name.clone().into(),
},
CellContent::Simple(cpu.legend_value.clone().into()),
];
TableRow::Styled(row, *style)
}))
}
}
.collect();
TableData { data, col_widths }
};
let is_on_widget = widget_id == app_state.current_widget.widget_id;
let border_style = if is_on_widget {
self.colours.highlighted_border_style
} else {
self.colours.border_style
let draw_info = DrawInfo {
loc: draw_loc,
force_redraw: app_state.is_force_redraw,
recalculate_column_widths,
selection_state: SelectionState::new(app_state.is_expanded, is_on_widget),
};
TextTable {
table_gap: app_state.app_config_fields.table_gap,
is_force_redraw: app_state.is_force_redraw,
recalculate_column_widths,
header_style: self.colours.table_header_style,
border_style,
highlighted_text_style: self.colours.currently_selected_text_style, // We always highlight the selected CPU entry... not sure if I like this though.
title: None,
is_on_widget,
draw_border: true,
show_table_scroll_position: app_state.app_config_fields.show_table_scroll_position,
title_style: self.colours.widget_title_style,
text_style: self.colours.text_style,
left_to_right: false,
}
.draw_text_table(
cpu_widget_state.table.draw(
f,
draw_loc,
&mut cpu_widget_state.table_state,
&cpu_data,
None,
&draw_info,
app_state.converted_data.cpu_data.clone(),
app_state.widget_map.get_mut(&widget_id),
self,
);
}
}

View file

@ -1,51 +1,32 @@
use tui::{backend::Backend, layout::Rect, terminal::Frame};
use crate::{
app,
app::{self},
canvas::Painter,
components::text_table::{TextTable, TextTableTitle},
components::data_table::{DrawInfo, SelectionState},
};
impl Painter {
pub fn draw_disk_table<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut app::App, draw_loc: Rect, draw_border: bool,
widget_id: u64,
&self, f: &mut Frame<'_, B>, app_state: &mut app::App, draw_loc: Rect, widget_id: u64,
) {
let recalculate_column_widths = app_state.should_get_widget_bounds();
if let Some(disk_widget_state) = app_state.disk_state.widget_states.get_mut(&widget_id) {
let is_on_widget = app_state.current_widget.widget_id == widget_id;
let (border_style, highlighted_text_style) = if is_on_widget {
(
self.colours.highlighted_border_style,
self.colours.currently_selected_text_style,
)
} else {
(self.colours.border_style, self.colours.text_style)
};
TextTable {
table_gap: app_state.app_config_fields.table_gap,
is_force_redraw: app_state.is_force_redraw,
let draw_info = DrawInfo {
loc: draw_loc,
force_redraw: app_state.is_force_redraw,
recalculate_column_widths,
header_style: self.colours.table_header_style,
border_style,
highlighted_text_style,
title: Some(TextTableTitle {
title: " Disks ".into(),
is_expanded: app_state.is_expanded,
}),
is_on_widget,
draw_border,
show_table_scroll_position: app_state.app_config_fields.show_table_scroll_position,
title_style: self.colours.widget_title_style,
text_style: self.colours.text_style,
left_to_right: true,
}
.draw_text_table(
selection_state: SelectionState::new(app_state.is_expanded, is_on_widget),
};
disk_widget_state.table.draw(
f,
draw_loc,
&mut disk_widget_state.table_state,
&app_state.converted_data.disk_data,
&draw_info,
app_state.converted_data.disk_data.clone(),
app_state.widget_map.get_mut(&widget_id),
self,
);
}
}

View file

@ -1,9 +1,8 @@
use crate::{
app::App,
canvas::{drawing_utils::get_search_start_position, Painter},
components::text_table::{TextTable, TextTableTitle},
components::data_table::{DrawInfo, SelectionState},
constants::*,
data_conversion::{TableData, TableRow},
};
use tui::{
@ -54,10 +53,10 @@ impl Painter {
.split(proc_draw_loc);
proc_draw_loc = processes_chunk[1];
self.draw_sort_table(f, app_state, processes_chunk[0], draw_border, widget_id + 2);
self.draw_sort_table(f, app_state, processes_chunk[0], widget_id + 2);
}
self.draw_processes_table(f, app_state, proc_draw_loc, draw_border, widget_id);
self.draw_processes_table(f, app_state, proc_draw_loc, widget_id);
}
if let Some(proc_widget_state) = app_state.proc_state.widget_states.get_mut(&widget_id) {
@ -73,8 +72,7 @@ impl Painter {
///
/// This should not be directly called.
fn draw_processes_table<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, draw_border: bool,
widget_id: u64,
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
) {
let should_get_widget_bounds = app_state.should_get_widget_bounds();
if let Some(proc_widget_state) = app_state.proc_state.widget_states.get_mut(&widget_id) {
@ -82,47 +80,20 @@ impl Painter {
should_get_widget_bounds || proc_widget_state.force_rerender;
let is_on_widget = widget_id == app_state.current_widget.widget_id;
let (border_style, highlighted_text_style) = if is_on_widget {
(
self.colours.highlighted_border_style,
self.colours.currently_selected_text_style,
)
} else {
(self.colours.border_style, self.colours.text_style)
let draw_info = DrawInfo {
loc: draw_loc,
force_redraw: app_state.is_force_redraw,
recalculate_column_widths,
selection_state: SelectionState::new(app_state.is_expanded, is_on_widget),
};
// TODO: [Refactor] This is an ugly hack to add the disabled style...
// this could be solved by storing style locally to the widget.
for row in &mut proc_widget_state.table_data.data {
if let TableRow::Styled(_, style) = row {
*style = style.patch(self.colours.disabled_text_style);
}
}
TextTable {
table_gap: app_state.app_config_fields.table_gap,
is_force_redraw: app_state.is_force_redraw,
recalculate_column_widths,
header_style: self.colours.table_header_style,
border_style,
highlighted_text_style,
title: Some(TextTableTitle {
title: " Processes ".into(),
is_expanded: app_state.is_expanded,
}),
is_on_widget,
draw_border,
show_table_scroll_position: app_state.app_config_fields.show_table_scroll_position,
title_style: self.colours.widget_title_style,
text_style: self.colours.text_style,
left_to_right: true,
}
.draw_text_table(
proc_widget_state.table.draw(
f,
draw_loc,
&mut proc_widget_state.table_state,
&proc_widget_state.table_data,
&draw_info,
proc_widget_state.table_data.clone(),
app_state.widget_map.get_mut(&widget_id),
self,
);
}
}
@ -141,14 +112,14 @@ impl Painter {
cursor_position: usize, query: &str, currently_selected_text_style: tui::style::Style,
text_style: tui::style::Style,
) -> Vec<Span<'a>> {
let mut current_grapheme_posn = 0;
let mut current_grapheme_pos = 0;
if is_on_widget {
let mut res = grapheme_indices
.filter_map(|grapheme| {
current_grapheme_posn += UnicodeWidthStr::width(grapheme.1);
current_grapheme_pos += UnicodeWidthStr::width(grapheme.1);
if current_grapheme_posn <= start_position {
if current_grapheme_pos <= start_position {
None
} else {
let styled = if grapheme.0 == cursor_position {
@ -338,68 +309,29 @@ impl Painter {
///
/// This should not be directly called.
fn draw_sort_table<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, draw_border: bool,
widget_id: u64,
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
) {
let should_get_widget_bounds = app_state.should_get_widget_bounds();
if let Some(proc_widget_state) =
app_state.proc_state.widget_states.get_mut(&(widget_id - 2))
{
let recalculate_column_widths =
should_get_widget_bounds || proc_widget_state.force_rerender;
if let Some(pws) = app_state.proc_state.widget_states.get_mut(&(widget_id - 2)) {
let recalculate_column_widths = should_get_widget_bounds || pws.force_rerender;
let is_on_widget = widget_id == app_state.current_widget.widget_id;
let (border_style, highlighted_text_style) = if is_on_widget {
(
self.colours.highlighted_border_style,
self.colours.currently_selected_text_style,
)
} else {
(self.colours.border_style, self.colours.text_style)
};
// TODO: [PROC] Perhaps move this generation elsewhere... or leave it as is but look at partial rendering?
let table_data = {
let data = proc_widget_state
.table_state
.columns
.iter()
.filter_map(|col| {
if col.is_hidden {
None
} else {
Some(TableRow::Raw(vec![col.header.text().clone()]))
}
})
.collect();
TableData {
data,
col_widths: vec![usize::from(SORT_MENU_WIDTH)],
}
};
TextTable {
table_gap: app_state.app_config_fields.table_gap,
is_force_redraw: app_state.is_force_redraw,
let draw_info = DrawInfo {
loc: draw_loc,
force_redraw: app_state.is_force_redraw,
recalculate_column_widths,
header_style: self.colours.table_header_style,
border_style,
highlighted_text_style,
title: None,
is_on_widget,
draw_border,
show_table_scroll_position: app_state.app_config_fields.show_table_scroll_position,
title_style: self.colours.widget_title_style,
text_style: self.colours.text_style,
left_to_right: true,
}
.draw_text_table(
selection_state: SelectionState::new(app_state.is_expanded, is_on_widget),
};
let data = pws.column_text();
pws.sort_table.draw(
f,
draw_loc,
&mut proc_widget_state.sort_table_state,
&table_data,
&draw_info,
data,
app_state.widget_map.get_mut(&widget_id),
self,
);
}
}

View file

@ -3,50 +3,30 @@ use tui::{backend::Backend, layout::Rect, terminal::Frame};
use crate::{
app,
canvas::Painter,
components::text_table::{TextTable, TextTableTitle},
components::data_table::{DrawInfo, SelectionState},
};
impl Painter {
pub fn draw_temp_table<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut app::App, draw_loc: Rect, draw_border: bool,
widget_id: u64,
&self, f: &mut Frame<'_, B>, app_state: &mut app::App, draw_loc: Rect, widget_id: u64,
) {
let recalculate_column_widths = app_state.should_get_widget_bounds();
if let Some(temp_widget_state) = app_state.temp_state.widget_states.get_mut(&widget_id) {
let is_on_widget = app_state.current_widget.widget_id == widget_id;
let (border_style, highlighted_text_style) = if is_on_widget {
(
self.colours.highlighted_border_style,
self.colours.currently_selected_text_style,
)
} else {
(self.colours.border_style, self.colours.text_style)
};
TextTable {
table_gap: app_state.app_config_fields.table_gap,
is_force_redraw: app_state.is_force_redraw,
let draw_info = DrawInfo {
loc: draw_loc,
force_redraw: app_state.is_force_redraw,
recalculate_column_widths,
header_style: self.colours.table_header_style,
border_style,
highlighted_text_style,
title: Some(TextTableTitle {
title: " Temperatures ".into(),
is_expanded: app_state.is_expanded,
}),
is_on_widget,
draw_border,
show_table_scroll_position: app_state.app_config_fields.show_table_scroll_position,
title_style: self.colours.widget_title_style,
text_style: self.colours.text_style,
left_to_right: false,
}
.draw_text_table(
selection_state: SelectionState::new(app_state.is_expanded, is_on_widget),
};
temp_widget_state.table.draw(
f,
draw_loc,
&mut temp_widget_state.table_state,
&app_state.converted_data.temp_sensor_data,
&draw_info,
app_state.converted_data.temp_data.clone(),
app_state.widget_map.get_mut(&widget_id),
self,
);
}
}

View file

@ -1,5 +1,4 @@
mod tui_widget;
pub mod data_table;
pub mod time_graph;
pub mod text_table;

View file

@ -0,0 +1,242 @@
use std::{convert::TryInto, marker::PhantomData};
pub mod column;
pub use column::*;
pub mod styling;
pub use styling::*;
pub mod props;
pub use props::DataTableProps;
pub mod state;
pub use state::{DataTableState, ScrollDirection};
pub mod draw;
pub use draw::*;
pub mod data_type;
pub use data_type::*;
pub mod sortable;
pub use sortable::*;
/// A [`DataTable`] is a component that displays data in a tabular form.
///
/// Note that [`DataTable`] takes a generic type `S`, bounded by [`SortType`]. This controls whether this table
/// expects sorted data or not, with two expected types:
///
/// - [`Unsortable`]: The default if otherwise not specified. This table does not expect sorted data.
/// - [`Sortable`]: This table expects sorted data, and there are helper functions to
/// facilitate things like sorting based on a selected column, shortcut column selection support, mouse column
/// selection support, etc.
pub struct DataTable<DataType, Header, S = Unsortable, C = Column<Header>> {
pub columns: Vec<C>,
pub state: DataTableState,
pub props: DataTableProps,
pub styling: DataTableStyling,
data: Vec<DataType>,
sort_type: S,
first_draw: bool,
_pd: PhantomData<(DataType, S, Header)>,
}
impl<DataType: DataToCell<H>, H: ColumnHeader> DataTable<DataType, H, Unsortable, Column<H>> {
pub fn new<C: Into<Vec<Column<H>>>>(
columns: C, props: DataTableProps, styling: DataTableStyling,
) -> Self {
Self {
columns: columns.into(),
state: DataTableState::default(),
props,
styling,
data: vec![],
sort_type: Unsortable,
first_draw: true,
_pd: PhantomData,
}
}
}
impl<DataType: DataToCell<H>, H: ColumnHeader, S: SortType, C: DataTableColumn<H>>
DataTable<DataType, H, S, C>
{
/// Sets the scroll position to the first value.
pub fn set_first(&mut self) {
self.state.current_index = 0;
self.state.scroll_direction = ScrollDirection::Up;
}
/// Sets the scroll position to the last value.
pub fn set_last(&mut self) {
self.state.current_index = self.data.len().saturating_sub(1);
self.state.scroll_direction = ScrollDirection::Down;
}
/// Updates the scroll position to be valid for the number of entries.
fn set_data(&mut self, data: Vec<DataType>) {
self.data = data;
let max_pos = self.data.len().saturating_sub(1);
if self.state.current_index > max_pos {
self.state.current_index = max_pos;
self.state.display_start_index = 0;
self.state.scroll_direction = ScrollDirection::Down;
}
}
/// Increments the scroll position if possible by a positive/negative offset. If there is a
/// valid change, this function will also return the new position wrapped in an [`Option`].
pub fn increment_position(&mut self, change: i64) -> Option<usize> {
let max_index = self.data.len();
let current_index = self.state.current_index;
if change == 0
|| (change > 0 && current_index == max_index)
|| (change < 0 && current_index == 0)
{
return None;
}
let csp: Result<i64, _> = self.state.current_index.try_into();
if let Ok(csp) = csp {
let proposed: Result<usize, _> = (csp + change).try_into();
if let Ok(proposed) = proposed {
if proposed < self.data.len() {
self.state.current_index = proposed;
self.state.scroll_direction = if change < 0 {
ScrollDirection::Up
} else {
ScrollDirection::Down
};
return Some(self.state.current_index);
}
}
}
None
}
/// Updates the scroll position to a selected index.
#[allow(clippy::comparison_chain)]
pub fn set_position(&mut self, new_index: usize) {
let new_index = new_index.clamp(0, self.data.len().saturating_sub(1));
if self.state.current_index < new_index {
self.state.scroll_direction = ScrollDirection::Down;
} else if self.state.current_index > new_index {
self.state.scroll_direction = ScrollDirection::Up;
}
self.state.current_index = new_index;
}
/// Returns the current scroll index.
pub fn current_index(&self) -> usize {
self.state.current_index
}
/// Optionally returns the currently selected item, if there is one.
pub fn current_item(&self) -> Option<&DataType> {
self.data.get(self.state.current_index)
}
/// Returns tui-rs' internal selection.
pub fn tui_selected(&self) -> Option<usize> {
self.state.table_state.selected()
}
}
#[cfg(test)]
mod test {
use super::*;
#[derive(Clone, PartialEq, Eq, Debug)]
struct TestType {
index: usize,
}
impl DataToCell<&'static str> for TestType {
fn to_cell<'a>(
&'a self, _column: &&'static str, _calculated_width: u16,
) -> Option<tui::text::Text<'a>> {
None
}
fn column_widths<C: DataTableColumn<&'static str>>(
_data: &[Self], _columns: &[C],
) -> Vec<u16>
where
Self: Sized,
{
vec![]
}
}
#[test]
fn test_data_table_operations() {
let columns = [Column::hard("a", 10), Column::hard("b", 10)];
let props = DataTableProps {
title: Some("test".into()),
table_gap: 1,
left_to_right: false,
is_basic: false,
show_table_scroll_position: true,
show_current_entry_when_unfocused: false,
};
let styling = DataTableStyling::default();
let mut table = DataTable::new(columns, props, styling);
table.set_data((0..=4).map(|index| TestType { index }).collect::<Vec<_>>());
table.set_last();
assert_eq!(table.current_index(), 4);
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
table.set_first();
assert_eq!(table.current_index(), 0);
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
table.set_position(4);
assert_eq!(table.current_index(), 4);
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
table.set_position(100);
assert_eq!(table.current_index(), 4);
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
table.increment_position(-1);
assert_eq!(table.current_index(), 3);
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
assert_eq!(table.current_item(), Some(&TestType { index: 3 }));
table.increment_position(-3);
assert_eq!(table.current_index(), 0);
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
assert_eq!(table.current_item(), Some(&TestType { index: 0 }));
table.increment_position(-3);
assert_eq!(table.current_index(), 0);
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
assert_eq!(table.current_item(), Some(&TestType { index: 0 }));
table.increment_position(1);
assert_eq!(table.current_index(), 1);
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
assert_eq!(table.current_item(), Some(&TestType { index: 1 }));
table.increment_position(3);
assert_eq!(table.current_index(), 4);
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
table.increment_position(10);
assert_eq!(table.current_index(), 4);
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
table.set_data((0..=2).map(|index| TestType { index }).collect::<Vec<_>>());
assert_eq!(table.current_index(), 2);
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
assert_eq!(table.current_item(), Some(&TestType { index: 2 }));
}
}

View file

@ -0,0 +1,257 @@
use std::{
borrow::Cow,
cmp::{max, min},
};
/// A bound on the width of a column.
#[derive(Clone, Copy, Debug)]
pub enum ColumnWidthBounds {
/// A width of this type is either as long as `min`, but can otherwise shrink and grow up to a point.
Soft {
/// The desired, calculated width. Take this if possible as the base starting width.
desired: u16,
/// The max width, as a percentage of the total width available. If [`None`],
/// then it can grow as desired.
max_percentage: Option<f32>,
},
/// A width of this type is either as long as specified, or does not appear at all.
Hard(u16),
/// A width of this type always resizes to the column header's text width.
FollowHeader,
}
pub trait ColumnHeader {
/// The "text" version of the column header.
fn text(&self) -> Cow<'static, str>;
/// The version displayed when drawing the table. Defaults to [`ColumnHeader::text`].
#[inline(always)]
fn header(&self) -> Cow<'static, str> {
self.text()
}
}
impl ColumnHeader for &'static str {
fn text(&self) -> Cow<'static, str> {
Cow::Borrowed(self)
}
}
impl ColumnHeader for String {
fn text(&self) -> Cow<'static, str> {
Cow::Owned(self.clone())
}
}
pub trait DataTableColumn<H: ColumnHeader> {
fn inner(&self) -> &H;
fn inner_mut(&mut self) -> &mut H;
fn bounds(&self) -> ColumnWidthBounds;
fn bounds_mut(&mut self) -> &mut ColumnWidthBounds;
fn is_hidden(&self) -> bool;
fn set_is_hidden(&mut self, is_hidden: bool);
/// The actually displayed "header".
fn header(&self) -> Cow<'static, str>;
/// The header length, along with any required additional lengths for things like arrows.
/// Defaults to getting the length of [`DataTableColumn::header`].
fn header_len(&self) -> usize {
self.header().len()
}
}
#[derive(Clone, Debug)]
pub struct Column<H> {
/// The inner column header.
inner: H,
/// A restriction on this column's width.
bounds: ColumnWidthBounds,
/// Marks that this column is currently "hidden", and should *always* be skipped.
is_hidden: bool,
}
impl<H: ColumnHeader> DataTableColumn<H> for Column<H> {
#[inline]
fn inner(&self) -> &H {
&self.inner
}
#[inline]
fn inner_mut(&mut self) -> &mut H {
&mut self.inner
}
#[inline]
fn bounds(&self) -> ColumnWidthBounds {
self.bounds
}
#[inline]
fn bounds_mut(&mut self) -> &mut ColumnWidthBounds {
&mut self.bounds
}
#[inline]
fn is_hidden(&self) -> bool {
self.is_hidden
}
#[inline]
fn set_is_hidden(&mut self, is_hidden: bool) {
self.is_hidden = is_hidden;
}
fn header(&self) -> Cow<'static, str> {
self.inner.text()
}
}
impl<H: ColumnHeader> Column<H> {
pub const fn new(inner: H) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::FollowHeader,
is_hidden: false,
}
}
pub const fn hard(inner: H, width: u16) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::Hard(width),
is_hidden: false,
}
}
pub const fn soft(inner: H, max_percentage: Option<f32>) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::Soft {
desired: 0,
max_percentage,
},
is_hidden: false,
}
}
}
pub trait CalculateColumnWidths<H> {
/// Calculates widths for the columns of this table, given the current width when called.
///
/// * `total_width` is the total width on the canvas that the columns can try and work with.
/// * `left_to_right` is whether to size from left-to-right (`true`) or right-to-left (`false`).
fn calculate_column_widths(&self, total_width: u16, left_to_right: bool) -> Vec<u16>;
}
impl<H, C> CalculateColumnWidths<H> for [C]
where
H: ColumnHeader,
C: DataTableColumn<H>,
{
fn calculate_column_widths(&self, total_width: u16, left_to_right: bool) -> Vec<u16> {
use itertools::Either;
let mut total_width_left = total_width;
let mut calculated_widths = vec![0; self.len()];
let columns = if left_to_right {
Either::Left(self.iter().zip(calculated_widths.iter_mut()))
} else {
Either::Right(self.iter().zip(calculated_widths.iter_mut()).rev())
};
let mut num_columns = 0;
for (column, calculated_width) in columns {
if column.is_hidden() {
continue;
}
match &column.bounds() {
ColumnWidthBounds::Soft {
desired,
max_percentage,
} => {
let min_width = column.header_len() as u16;
if min_width > total_width_left {
break;
}
let soft_limit = max(
if let Some(max_percentage) = max_percentage {
// TODO: Rust doesn't have an `into()` or `try_into()` for floats to integers.
((*max_percentage * f32::from(total_width)).ceil()) as u16
} else {
*desired
},
min_width,
);
let space_taken = min(min(soft_limit, *desired), total_width_left);
if min_width > space_taken || min_width == 0 {
break;
} else if space_taken > 0 {
total_width_left = total_width_left.saturating_sub(space_taken + 1);
*calculated_width = space_taken;
num_columns += 1;
}
}
ColumnWidthBounds::Hard(width) => {
let min_width = *width;
if min_width > total_width_left || min_width == 0 {
break;
} else if min_width > 0 {
total_width_left = total_width_left.saturating_sub(min_width + 1);
*calculated_width = min_width;
num_columns += 1;
}
}
ColumnWidthBounds::FollowHeader => {
let min_width = column.header_len() as u16;
if min_width > total_width_left || min_width == 0 {
break;
} else if min_width > 0 {
total_width_left = total_width_left.saturating_sub(min_width + 1);
*calculated_width = min_width;
num_columns += 1;
}
}
}
}
if num_columns > 0 {
// Redistribute remaining.
let mut num_dist = num_columns;
let amount_per_slot = total_width_left / num_dist;
total_width_left %= num_dist;
for width in calculated_widths.iter_mut() {
if num_dist == 0 {
break;
}
if *width > 0 {
if total_width_left > 0 {
*width += amount_per_slot + 1;
total_width_left -= 1;
} else {
*width += amount_per_slot;
}
num_dist -= 1;
}
}
}
calculated_widths
}
}

View file

@ -0,0 +1,26 @@
use tui::{text::Text, widgets::Row};
use crate::canvas::Painter;
use super::{ColumnHeader, DataTableColumn};
pub trait DataToCell<H>
where
H: ColumnHeader,
{
/// Given data, a column, and its corresponding width, return what should be displayed in the [`DataTable`](super::DataTable).
fn to_cell<'a>(&'a self, column: &H, calculated_width: u16) -> Option<Text<'a>>;
/// Apply styling to the generated [`Row`] of cells.
///
/// The default implementation just returns the `row` that is passed in.
#[inline(always)]
fn style_row<'a>(&self, row: Row<'a>, _painter: &Painter) -> Row<'a> {
row
}
/// Returns the desired column widths in light of having seen data.
fn column_widths<C: DataTableColumn<H>>(data: &[Self], columns: &[C]) -> Vec<u16>
where
Self: Sized;
}

View file

@ -0,0 +1,289 @@
use std::{
cmp::{max, min},
iter::once,
};
use concat_string::concat_string;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
text::{Span, Spans, Text},
widgets::{Block, Borders, Row, Table},
Frame,
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
app::layout_manager::BottomWidget,
canvas::Painter,
constants::{SIDE_BORDERS, TABLE_GAP_HEIGHT_LIMIT},
};
use super::{
CalculateColumnWidths, ColumnHeader, ColumnWidthBounds, DataTable, DataTableColumn, DataToCell,
SortType,
};
pub enum SelectionState {
NotSelected,
Selected,
Expanded,
}
impl SelectionState {
pub fn new(is_expanded: bool, is_on_widget: bool) -> Self {
if is_expanded {
SelectionState::Expanded
} else if is_on_widget {
SelectionState::Selected
} else {
SelectionState::NotSelected
}
}
}
/// A [`DrawInfo`] is information required on each draw call.
pub struct DrawInfo {
pub loc: Rect,
pub force_redraw: bool,
pub recalculate_column_widths: bool,
pub selection_state: SelectionState,
}
impl DrawInfo {
pub fn is_on_widget(&self) -> bool {
matches!(self.selection_state, SelectionState::Selected)
|| matches!(self.selection_state, SelectionState::Expanded)
}
pub fn is_expanded(&self) -> bool {
matches!(self.selection_state, SelectionState::Expanded)
}
}
impl<DataType, H, S, C> DataTable<DataType, H, S, C>
where
DataType: DataToCell<H>,
H: ColumnHeader,
S: SortType,
C: DataTableColumn<H>,
{
fn block<'a>(&self, draw_info: &'a DrawInfo, data_len: usize) -> Block<'a> {
let border_style = match draw_info.selection_state {
SelectionState::NotSelected => self.styling.border_style,
SelectionState::Selected | SelectionState::Expanded => {
self.styling.highlighted_border_style
}
};
if !self.props.is_basic {
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if let Some(title) = self.generate_title(draw_info, data_len) {
block.title(title)
} else {
block
}
} else if draw_info.is_on_widget() {
// Implies it is basic mode but selected.
Block::default()
.borders(SIDE_BORDERS)
.border_style(border_style)
} else {
Block::default().borders(Borders::NONE)
}
}
/// Generates a title, given the available space.
pub fn generate_title<'a>(
&self, draw_info: &'a DrawInfo, total_items: usize,
) -> Option<Spans<'a>> {
self.props.title.as_ref().map(|title| {
let current_index = self.state.current_index.saturating_add(1);
let draw_loc = draw_info.loc;
let title_style = self.styling.title_style;
let border_style = if draw_info.is_on_widget() {
self.styling.highlighted_border_style
} else {
self.styling.border_style
};
let title = if self.props.show_table_scroll_position {
let pos = current_index.to_string();
let tot = total_items.to_string();
let title_string = concat_string!(title, "(", pos, " of ", tot, ") ");
if title_string.len() + 2 <= draw_loc.width.into() {
title_string
} else {
title.to_string()
}
} else {
title.to_string()
};
if draw_info.is_expanded() {
let title_base = concat_string!(title, "── Esc to go back ");
let lines = "".repeat(usize::from(draw_loc.width).saturating_sub(
UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2,
));
let esc = concat_string!("", lines, "─ Esc to go back ");
Spans::from(vec![
Span::styled(title, title_style),
Span::styled(esc, border_style),
])
} else {
Spans::from(Span::styled(title, title_style))
}
})
}
pub fn draw<B: Backend>(
&mut self, f: &mut Frame<'_, B>, draw_info: &DrawInfo, data: Vec<DataType>,
widget: Option<&mut BottomWidget>, painter: &Painter,
) {
self.set_data(data);
let draw_horizontal = !self.props.is_basic || draw_info.is_on_widget();
let draw_loc = draw_info.loc;
let margined_draw_loc = Layout::default()
.constraints([Constraint::Percentage(100)])
.horizontal_margin(if draw_horizontal { 0 } else { 1 })
.direction(Direction::Horizontal)
.split(draw_loc)[0];
let block = self.block(draw_info, self.data.len());
let (inner_width, inner_height) = {
let inner_rect = block.inner(margined_draw_loc);
self.state.inner_rect = inner_rect;
(inner_rect.width, inner_rect.height)
};
if inner_width == 0 || inner_height == 0 {
f.render_widget(block, margined_draw_loc);
} else {
// Calculate widths
if draw_info.recalculate_column_widths {
let col_widths = DataType::column_widths(&self.data, &self.columns);
self.columns
.iter_mut()
.zip(&col_widths)
.for_each(|(column, &width)| {
let header_len = column.header_len() as u16;
if let ColumnWidthBounds::Soft {
desired,
max_percentage: _,
} = &mut column.bounds_mut()
{
*desired = max(header_len, width);
}
});
self.state.calculated_widths = self
.columns
.calculate_column_widths(inner_width, self.props.left_to_right);
// Update draw loc in widget map
if let Some(widget) = widget {
widget.top_left_corner = Some((draw_loc.x, draw_loc.y));
widget.bottom_right_corner =
Some((draw_loc.x + draw_loc.width, draw_loc.y + draw_loc.height));
}
}
let show_header = inner_height > 1;
let header_height = if show_header { 1 } else { 0 };
let table_gap = if !show_header || draw_loc.height < TABLE_GAP_HEIGHT_LIMIT {
0
} else {
self.props.table_gap
};
let columns = &self.columns;
if !self.data.is_empty() || !self.first_draw {
self.first_draw = false; // TODO: Doing it this way is fine, but it could be done better (e.g. showing custom no results/entries message)
let rows = {
let num_rows =
usize::from(inner_height.saturating_sub(table_gap + header_height));
self.state
.get_start_position(num_rows, draw_info.force_redraw);
let start = self.state.display_start_index;
let end = min(self.data.len(), start + num_rows);
self.state
.table_state
.select(Some(self.state.current_index.saturating_sub(start)));
self.data[start..end].iter().map(|data_row| {
let row = Row::new(
columns
.iter()
.zip(&self.state.calculated_widths)
.filter_map(|(column, &width)| {
data_row.to_cell(column.inner(), width)
}),
);
data_row.style_row(row, painter)
})
};
let headers = self
.sort_type
.build_header(columns, &self.state.calculated_widths)
.style(self.styling.header_style)
.bottom_margin(table_gap);
let widget = {
let highlight_style = if draw_info.is_on_widget()
|| self.props.show_current_entry_when_unfocused
{
self.styling.highlighted_text_style
} else {
self.styling.text_style
};
let mut table = Table::new(rows)
.block(block)
.highlight_style(highlight_style)
.style(self.styling.text_style);
if show_header {
table = table.header(headers);
}
table
};
let table_state = &mut self.state.table_state;
f.render_stateful_widget(
widget.widths(
&(self
.state
.calculated_widths
.iter()
.filter_map(|&width| {
if width == 0 {
None
} else {
Some(Constraint::Length(width))
}
})
.collect::<Vec<_>>()),
),
margined_draw_loc,
table_state,
);
} else {
let table = Table::new(once(Row::new(Text::raw("No data"))))
.block(block)
.style(self.styling.text_style)
.widths(&[Constraint::Percentage(100)]);
f.render_widget(table, margined_draw_loc);
}
}
}
}

View file

@ -0,0 +1,21 @@
use std::borrow::Cow;
pub struct DataTableProps {
/// An optional title for the table.
pub title: Option<Cow<'static, str>>,
/// The size of the gap between the header and rows.
pub table_gap: u16,
/// Whether this table determines column widths from left to right.
pub left_to_right: bool,
/// Whether this table is a basic table. This affects the borders.
pub is_basic: bool,
/// Whether to show the table scroll position.
pub show_table_scroll_position: bool,
/// Whether to show the current entry as highlighted when not focused.
pub show_current_entry_when_unfocused: bool,
}

View file

@ -0,0 +1,536 @@
use std::{borrow::Cow, marker::PhantomData};
use concat_string::concat_string;
use itertools::Itertools;
use tui::widgets::Row;
use crate::utils::gen_util::truncate_text;
use super::{
ColumnHeader, ColumnWidthBounds, DataTable, DataTableColumn, DataTableProps, DataTableState,
DataTableStyling, DataToCell,
};
/// Denotes the sort order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SortOrder {
Ascending,
Descending,
}
impl Default for SortOrder {
fn default() -> Self {
Self::Ascending
}
}
/// Denotes the [`DataTable`] is unsorted.
pub struct Unsortable;
/// Denotes the [`DataTable`] is sorted.
pub struct Sortable {
/// The currently selected sort index.
pub sort_index: usize,
/// The current sorting order.
pub order: SortOrder,
}
/// The [`SortType`] trait is meant to be used in the typing of a [`DataTable`]
/// to denote whether the table is meant to display/store sorted or unsorted data.
///
/// Note that the trait is [sealed](https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed),
/// and therefore only [`Unsortable`] and [`Sortable`] can implement it.
pub trait SortType: private::Sealed {
/// Constructs the table header.
fn build_header<H, C>(&self, columns: &[C], widths: &[u16]) -> Row<'_>
where
H: ColumnHeader,
C: DataTableColumn<H>,
{
Row::new(columns.iter().zip(widths).filter_map(|(c, &width)| {
if width == 0 {
None
} else {
Some(truncate_text(&c.header(), width))
}
}))
}
}
mod private {
use super::{Sortable, Unsortable};
pub trait Sealed {}
impl Sealed for Unsortable {}
impl Sealed for Sortable {}
}
impl SortType for Unsortable {}
impl SortType for Sortable {
fn build_header<H, C>(&self, columns: &[C], widths: &[u16]) -> Row<'_>
where
H: ColumnHeader,
C: DataTableColumn<H>,
{
const UP_ARROW: &str = "";
const DOWN_ARROW: &str = "";
Row::new(
columns
.iter()
.zip(widths)
.enumerate()
.filter_map(|(index, (c, &width))| {
if width == 0 {
None
} else if index == self.sort_index {
let arrow = match self.order {
SortOrder::Ascending => UP_ARROW,
SortOrder::Descending => DOWN_ARROW,
};
Some(truncate_text(&concat_string!(c.header(), arrow), width))
} else {
Some(truncate_text(&c.header(), width))
}
}),
)
}
}
pub trait SortsRow<DataType> {
/// Sorts data.
fn sort_data(&self, data: &mut [DataType], descending: bool);
}
#[derive(Debug, Clone)]
pub struct SortColumn<DataType, T> {
/// The inner column header.
inner: T,
/// The default sort order.
pub default_order: SortOrder,
/// A restriction on this column's width.
pub bounds: ColumnWidthBounds,
/// Marks that this column is currently "hidden", and should *always* be skipped.
pub is_hidden: bool,
_pd: PhantomData<DataType>,
}
impl<DataType, T> DataTableColumn<T> for SortColumn<DataType, T>
where
T: ColumnHeader + SortsRow<DataType>,
{
#[inline]
fn inner(&self) -> &T {
&self.inner
}
#[inline]
fn inner_mut(&mut self) -> &mut T {
&mut self.inner
}
#[inline]
fn bounds(&self) -> ColumnWidthBounds {
self.bounds
}
#[inline]
fn bounds_mut(&mut self) -> &mut ColumnWidthBounds {
&mut self.bounds
}
#[inline]
fn is_hidden(&self) -> bool {
self.is_hidden
}
#[inline]
fn set_is_hidden(&mut self, is_hidden: bool) {
self.is_hidden = is_hidden;
}
fn header(&self) -> Cow<'static, str> {
self.inner.header()
}
fn header_len(&self) -> usize {
self.header().len() + 1
}
}
impl<DataType, T> SortColumn<DataType, T>
where
T: ColumnHeader + SortsRow<DataType>,
{
/// Creates a new [`SortColumn`] with a width that follows the header width, which has no shortcut and sorts by
/// default in ascending order ([`SortOrder::Ascending`]).
pub fn new(inner: T) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::FollowHeader,
is_hidden: false,
default_order: SortOrder::default(),
_pd: Default::default(),
}
}
/// Creates a new [`SortColumn`] with a hard width, which has no shortcut and sorts by default in
/// ascending order ([`SortOrder::Ascending`]).
pub fn hard(inner: T, width: u16) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::Hard(width),
is_hidden: false,
default_order: SortOrder::default(),
_pd: Default::default(),
}
}
/// Creates a new [`SortColumn`] with a soft width, which has no shortcut and sorts by default in
/// ascending order ([`SortOrder::Ascending`]).
pub fn soft(inner: T, max_percentage: Option<f32>) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::Soft {
desired: 0,
max_percentage,
},
is_hidden: false,
default_order: SortOrder::default(),
_pd: Default::default(),
}
}
/// Sets the default sort order to [`SortOrder::Ascending`].
pub fn default_ascending(mut self) -> Self {
self.default_order = SortOrder::Ascending;
self
}
/// Sets the default sort order to [`SortOrder::Descending`].
pub fn default_descending(mut self) -> Self {
self.default_order = SortOrder::Descending;
self
}
/// Given a [`SortColumn`] and the sort order, sort a mutable slice of associated data.
pub fn sort_by(&self, data: &mut [DataType], order: SortOrder) {
let descending = matches!(order, SortOrder::Descending);
self.inner.sort_data(data, descending);
}
}
pub struct SortDataTableProps {
pub inner: DataTableProps,
pub sort_index: usize,
pub order: SortOrder,
}
/// A type alias for a sortable [`DataTable`].
pub type SortDataTable<DataType, H> = DataTable<DataType, H, Sortable, SortColumn<DataType, H>>;
impl<DataType, H> SortDataTable<DataType, H>
where
DataType: DataToCell<H>,
H: ColumnHeader + SortsRow<DataType>,
{
pub fn new_sortable<C: Into<Vec<SortColumn<DataType, H>>>>(
columns: C, props: SortDataTableProps, styling: DataTableStyling,
) -> Self {
Self {
columns: columns.into(),
state: DataTableState::default(),
props: props.inner,
styling,
sort_type: Sortable {
sort_index: props.sort_index,
order: props.order,
},
first_draw: true,
data: vec![],
_pd: PhantomData,
}
}
/// Sets the current sort order.
pub fn set_order(&mut self, order: SortOrder) {
self.sort_type.order = order;
}
/// Gets the current sort order.
pub fn order(&self) -> SortOrder {
self.sort_type.order
}
/// Toggles the current sort order.
pub fn toggle_order(&mut self) {
self.sort_type.order = match self.sort_type.order {
SortOrder::Ascending => SortOrder::Descending,
SortOrder::Descending => SortOrder::Ascending,
}
}
/// Given some `x` and `y`, if possible, select the corresponding column or toggle the column if already selected,
/// and otherwise do nothing.
///
/// If there was some update, the corresponding column type will be returned. If nothing happens, [`None`] is
/// returned.
pub fn try_select_location(&mut self, x: u16, y: u16) -> Option<usize> {
if self.state.inner_rect.height > 1 && self.state.inner_rect.y == y {
if let Some(index) = self.get_range(x) {
self.set_sort_index(index);
Some(self.sort_type.sort_index)
} else {
None
}
} else {
None
}
}
/// Updates the sort index, and sets the sort order as appropriate.
///
/// If the index is different from the previous one, it will move to the new index and set the sort order
/// to the prescribed default sort order.
///
/// If the index is the same as the previous one, it will simply toggle the current sort order.
pub fn set_sort_index(&mut self, index: usize) {
if self.sort_type.sort_index == index {
self.toggle_order();
} else if let Some(col) = self.columns.get(index) {
self.sort_type.sort_index = index;
self.sort_type.order = col.default_order;
}
}
/// Returns the current sort index.
pub fn sort_index(&self) -> usize {
self.sort_type.sort_index
}
/// Given a `needle` coordinate, select the corresponding index and value.
fn get_range(&self, needle: u16) -> Option<usize> {
let mut start = self.state.inner_rect.x;
let range = self
.state
.calculated_widths
.iter()
.map(|width| {
let entry_start = start;
start += width + 1; // +1 for the gap b/w cols.
entry_start
})
.collect_vec();
match range.binary_search(&needle) {
Ok(index) => Some(index),
Err(index) => index.checked_sub(1),
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[derive(Clone, PartialEq, Eq, Debug)]
struct TestType {
index: usize,
data: u64,
}
enum ColumnType {
Index,
Data,
}
impl DataToCell<ColumnType> for TestType {
fn to_cell<'a>(
&'a self, _column: &ColumnType, _calculated_width: u16,
) -> Option<tui::text::Text<'a>> {
None
}
fn column_widths<C: DataTableColumn<ColumnType>>(_data: &[Self], _columns: &[C]) -> Vec<u16>
where
Self: Sized,
{
vec![]
}
}
impl ColumnHeader for ColumnType {
fn text(&self) -> Cow<'static, str> {
match self {
ColumnType::Index => "Index".into(),
ColumnType::Data => "Data".into(),
}
}
}
impl SortsRow<TestType> for ColumnType {
fn sort_data(&self, data: &mut [TestType], descending: bool) {
match self {
ColumnType::Index => data.sort_by_key(|t| t.index),
ColumnType::Data => data.sort_by_key(|t| t.data),
}
if descending {
data.reverse();
}
}
}
#[test]
fn test_sorting() {
let columns = [
SortColumn::new(ColumnType::Index),
SortColumn::new(ColumnType::Data),
];
let props = {
let inner = DataTableProps {
title: Some("test".into()),
table_gap: 1,
left_to_right: false,
is_basic: false,
show_table_scroll_position: true,
show_current_entry_when_unfocused: false,
};
SortDataTableProps {
inner,
sort_index: 0,
order: SortOrder::Descending,
}
};
let styling = DataTableStyling::default();
let mut table = DataTable::new_sortable(columns, props, styling);
let mut data = vec![
TestType {
index: 4,
data: 100,
},
TestType {
index: 1,
data: 200,
},
TestType {
index: 0,
data: 300,
},
TestType {
index: 3,
data: 400,
},
TestType {
index: 2,
data: 500,
},
];
table
.columns
.get(table.sort_type.sort_index)
.unwrap()
.sort_by(&mut data, SortOrder::Ascending);
assert_eq!(
data,
vec![
TestType {
index: 0,
data: 300,
},
TestType {
index: 1,
data: 200,
},
TestType {
index: 2,
data: 500,
},
TestType {
index: 3,
data: 400,
},
TestType {
index: 4,
data: 100,
},
]
);
table
.columns
.get(table.sort_type.sort_index)
.unwrap()
.sort_by(&mut data, SortOrder::Descending);
assert_eq!(
data,
vec![
TestType {
index: 4,
data: 100,
},
TestType {
index: 3,
data: 400,
},
TestType {
index: 2,
data: 500,
},
TestType {
index: 1,
data: 200,
},
TestType {
index: 0,
data: 300,
},
]
);
table.set_sort_index(1);
table
.columns
.get(table.sort_type.sort_index)
.unwrap()
.sort_by(&mut data, SortOrder::Ascending);
assert_eq!(
data,
vec![
TestType {
index: 4,
data: 100,
},
TestType {
index: 1,
data: 200,
},
TestType {
index: 0,
data: 300,
},
TestType {
index: 3,
data: 400,
},
TestType {
index: 2,
data: 500,
},
]
);
}
}

View file

@ -0,0 +1,90 @@
use tui::{layout::Rect, widgets::TableState};
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ScrollDirection {
// UP means scrolling up --- this usually DECREMENTS
Up,
// DOWN means scrolling down --- this usually INCREMENTS
Down,
}
impl Default for ScrollDirection {
fn default() -> Self {
ScrollDirection::Down
}
}
/// Internal state representation of a [`DataTable`](super::DataTable).
pub struct DataTableState {
/// The index from where to start displaying the rows.
pub display_start_index: usize,
/// The current scroll position.
pub current_index: usize,
/// The direction of the last attempted scroll.
pub scroll_direction: ScrollDirection,
/// tui-rs' internal table state.
pub table_state: TableState,
/// The calculated widths.
pub calculated_widths: Vec<u16>,
/// The current inner [`Rect`].
pub inner_rect: Rect,
}
impl Default for DataTableState {
fn default() -> Self {
Self {
display_start_index: 0,
current_index: 0,
scroll_direction: ScrollDirection::Down,
calculated_widths: vec![],
table_state: TableState::default(),
inner_rect: Rect::default(),
}
}
}
impl DataTableState {
/// Gets the starting position of a table.
pub fn get_start_position(&mut self, num_rows: usize, is_force_redraw: bool) {
let mut start_index = self.display_start_index;
let current_scroll_position = self.current_index;
let scroll_direction = self.scroll_direction;
if is_force_redraw {
start_index = 0;
}
self.display_start_index = match scroll_direction {
ScrollDirection::Down => {
if current_scroll_position < start_index + num_rows {
// If, using previous_scrolled_position, we can see the element
// (so within that and + num_rows) just reuse the current previously scrolled position
start_index
} else if current_scroll_position >= num_rows {
// Else if the current position past the last element visible in the list, omit
// until we can see that element
current_scroll_position - num_rows + 1
} else {
// Else, if it is not past the last element visible, do not omit anything
0
}
}
ScrollDirection::Up => {
if current_scroll_position <= start_index {
// If it's past the first element, then show from that element downwards
current_scroll_position
} else if current_scroll_position >= start_index + num_rows {
current_scroll_position - num_rows + 1
} else {
start_index
}
}
};
}
}

View file

@ -0,0 +1,26 @@
use tui::style::Style;
use crate::canvas::canvas_colours::CanvasColours;
#[derive(Default)]
pub struct DataTableStyling {
pub header_style: Style,
pub border_style: Style,
pub highlighted_border_style: Style,
pub text_style: Style,
pub highlighted_text_style: Style,
pub title_style: Style,
}
impl DataTableStyling {
pub fn from_colours(colours: &CanvasColours) -> Self {
Self {
header_style: colours.table_header_style,
border_style: colours.border_style,
highlighted_border_style: colours.highlighted_border_style,
text_style: colours.text_style,
highlighted_text_style: colours.currently_selected_text_style,
title_style: colours.widget_title_style,
}
}
}

View file

@ -1,5 +0,0 @@
pub mod draw;
pub use draw::*;
pub mod state;
pub use state::*;

View file

@ -1,505 +0,0 @@
use std::{
borrow::Cow,
cmp::{max, min},
};
use concat_string::concat_string;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Span, Spans, Text},
widgets::{Block, Borders, Row, Table},
Frame,
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
app::{self, layout_manager::BottomWidget},
components::text_table::SortOrder,
constants::{SIDE_BORDERS, TABLE_GAP_HEIGHT_LIMIT},
data_conversion::{TableData, TableRow},
};
use super::{
CellContent, SortState, TableComponentColumn, TableComponentHeader, TableComponentState,
WidthBounds,
};
pub struct TextTableTitle<'a> {
pub title: Cow<'a, str>,
pub is_expanded: bool,
}
pub struct TextTable<'a> {
pub table_gap: u16,
pub is_force_redraw: bool, // TODO: Is this force redraw thing needed? Or is there a better way?
pub recalculate_column_widths: bool,
/// The header style.
pub header_style: Style,
/// The border style.
pub border_style: Style,
/// The highlighted text style.
pub highlighted_text_style: Style,
/// The graph title and whether it is expanded (if there is one).
pub title: Option<TextTableTitle<'a>>,
/// Whether this widget is selected.
pub is_on_widget: bool,
/// Whether to draw all borders.
pub draw_border: bool,
/// Whether to show the scroll position.
pub show_table_scroll_position: bool,
/// The title style.
pub title_style: Style,
/// The text style.
pub text_style: Style,
/// Whether to determine widths from left to right.
pub left_to_right: bool,
}
impl<'a> TextTable<'a> {
/// Generates a title for the [`TextTable`] widget, given the available space.
fn generate_title(&self, draw_loc: Rect, pos: usize, total: usize) -> Option<Spans<'_>> {
self.title
.as_ref()
.map(|TextTableTitle { title, is_expanded }| {
let title = if self.show_table_scroll_position {
let title_string = concat_string!(
title,
"(",
pos.to_string(),
" of ",
total.to_string(),
") "
);
if title_string.len() + 2 <= draw_loc.width.into() {
title_string
} else {
title.to_string()
}
} else {
title.to_string()
};
if *is_expanded {
let title_base = concat_string!(title, "── Esc to go back ");
let esc = concat_string!(
"",
"".repeat(usize::from(draw_loc.width).saturating_sub(
UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2
)),
"─ Esc to go back "
);
Spans::from(vec![
Span::styled(title, self.title_style),
Span::styled(esc, self.border_style),
])
} else {
Spans::from(Span::styled(title, self.title_style))
}
})
}
pub fn draw_text_table<B: Backend, H: TableComponentHeader>(
&self, f: &mut Frame<'_, B>, draw_loc: Rect, state: &mut TableComponentState<H>,
table_data: &TableData, btm_widget: Option<&mut BottomWidget>,
) {
// TODO: This is a *really* ugly hack to get basic mode to hide the border when not selected, without shifting everything.
let is_not_basic = self.is_on_widget || self.draw_border;
let margined_draw_loc = Layout::default()
.constraints([Constraint::Percentage(100)])
.horizontal_margin(if is_not_basic { 0 } else { 1 })
.direction(Direction::Horizontal)
.split(draw_loc)[0];
let block = if self.draw_border {
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.border_style);
if let Some(title) = self.generate_title(
draw_loc,
state.current_scroll_position.saturating_add(1),
table_data.data.len(),
) {
block.title(title)
} else {
block
}
} else if self.is_on_widget {
Block::default()
.borders(SIDE_BORDERS)
.border_style(self.border_style)
} else {
Block::default().borders(Borders::NONE)
};
let inner_rect = block.inner(margined_draw_loc);
let (inner_width, inner_height) = { (inner_rect.width, inner_rect.height) };
if inner_width == 0 || inner_height == 0 {
f.render_widget(block, margined_draw_loc);
} else {
let show_header = inner_height > 1;
let header_height = if show_header { 1 } else { 0 };
let table_gap = if !show_header || draw_loc.height < TABLE_GAP_HEIGHT_LIMIT {
0
} else {
self.table_gap
};
let sliced_vec = {
let num_rows = usize::from(inner_height.saturating_sub(table_gap + header_height));
let start = get_start_position(
num_rows,
&state.scroll_direction,
&mut state.scroll_bar,
state.current_scroll_position,
self.is_force_redraw,
);
let end = min(table_data.data.len(), start + num_rows);
state
.table_state
.select(Some(state.current_scroll_position.saturating_sub(start)));
&table_data.data[start..end]
};
// Calculate widths
if self.recalculate_column_widths {
state
.columns
.iter_mut()
.zip(&table_data.col_widths)
.for_each(|(column, data_width)| match &mut column.width_bounds {
WidthBounds::Soft {
min_width: _,
desired,
max_percentage: _,
} => {
*desired = max(
*desired,
max(column.header.header_text().len(), *data_width) as u16,
);
}
WidthBounds::CellWidth => {}
WidthBounds::Hard(_width) => {}
});
state.calculate_column_widths(inner_width, self.left_to_right);
if let SortState::Sortable(st) = &mut state.sort_state {
let row_widths = state
.columns
.iter()
.filter_map(|c| {
if c.calculated_width == 0 {
None
} else {
Some(c.calculated_width)
}
})
.collect::<Vec<_>>();
st.update_visual_index(inner_rect, &row_widths);
}
// Update draw loc in widget map
if let Some(btm_widget) = btm_widget {
btm_widget.top_left_corner = Some((draw_loc.x, draw_loc.y));
btm_widget.bottom_right_corner =
Some((draw_loc.x + draw_loc.width, draw_loc.y + draw_loc.height));
}
}
let columns = &state.columns;
let header = build_header(columns, &state.sort_state)
.style(self.header_style)
.bottom_margin(table_gap);
let table_rows = sliced_vec.iter().map(|row| {
let (row, style) = match row {
TableRow::Raw(row) => (row, None),
TableRow::Styled(row, style) => (row, Some(*style)),
};
Row::new(row.iter().zip(columns).filter_map(|(cell, c)| {
if c.calculated_width == 0 {
None
} else {
Some(truncate_text(cell, c.calculated_width.into(), style))
}
}))
});
if !table_data.data.is_empty() {
let widget = {
let mut table = Table::new(table_rows)
.block(block)
.highlight_style(self.highlighted_text_style)
.style(self.text_style);
if show_header {
table = table.header(header);
}
table
};
f.render_stateful_widget(
widget.widths(
&(columns
.iter()
.filter_map(|c| {
if c.calculated_width == 0 {
None
} else {
Some(Constraint::Length(c.calculated_width))
}
})
.collect::<Vec<_>>()),
),
margined_draw_loc,
&mut state.table_state,
);
} else {
f.render_widget(block, margined_draw_loc);
}
}
}
}
/// Constructs the table header.
fn build_header<'a, H: TableComponentHeader>(
columns: &'a [TableComponentColumn<H>], sort_state: &SortState,
) -> Row<'a> {
use itertools::Either;
const UP_ARROW: &str = "";
const DOWN_ARROW: &str = "";
let iter = match sort_state {
SortState::Unsortable => Either::Left(columns.iter().filter_map(|c| {
if c.calculated_width == 0 {
None
} else {
Some(truncate_text(
c.header.header_text(),
c.calculated_width.into(),
None,
))
}
})),
SortState::Sortable(s) => {
let order = &s.order;
let index = s.current_index;
let arrow = match order {
SortOrder::Ascending => UP_ARROW,
SortOrder::Descending => DOWN_ARROW,
};
Either::Right(columns.iter().enumerate().filter_map(move |(itx, c)| {
if c.calculated_width == 0 {
None
} else if itx == index {
Some(truncate_suffixed_text(
c.header.header_text(),
arrow,
c.calculated_width.into(),
None,
))
} else {
Some(truncate_text(
c.header.header_text(),
c.calculated_width.into(),
None,
))
}
}))
}
};
Row::new(iter)
}
/// Truncates text if it is too long, and adds an ellipsis at the end if needed.
fn truncate_text(content: &CellContent, width: usize, row_style: Option<Style>) -> Text<'_> {
let (main_text, alt_text) = match content {
CellContent::Simple(s) => (s, None),
CellContent::HasAlt {
alt: short,
main: long,
} => (long, Some(short)),
};
let mut text = {
let graphemes: Vec<&str> =
UnicodeSegmentation::graphemes(main_text.as_ref(), true).collect();
if graphemes.len() > width && width > 0 {
if let Some(s) = alt_text {
// If an alternative exists, use that.
Text::raw(s.as_ref())
} else {
// Truncate with ellipsis
let first_n = graphemes[..(width - 1)].concat();
Text::raw(concat_string!(first_n, ""))
}
} else {
Text::raw(main_text.as_ref())
}
};
if let Some(row_style) = row_style {
text.patch_style(row_style);
}
text
}
fn truncate_suffixed_text<'a>(
content: &'a CellContent, suffix: &str, width: usize, row_style: Option<Style>,
) -> Text<'a> {
let (main_text, alt_text) = match content {
CellContent::Simple(s) => (s, None),
CellContent::HasAlt {
alt: short,
main: long,
} => (long, Some(short)),
};
let mut text = {
let suffixed = concat_string!(main_text, suffix);
let graphemes: Vec<&str> =
UnicodeSegmentation::graphemes(suffixed.as_str(), true).collect();
if graphemes.len() > width && width > 1 {
if let Some(alt) = alt_text {
// If an alternative exists, use that + arrow.
Text::raw(concat_string!(alt, suffix))
} else {
// Truncate with ellipsis + arrow.
let first_n = graphemes[..(width - 2)].concat();
Text::raw(concat_string!(first_n, "", suffix))
}
} else {
Text::raw(suffixed)
}
};
if let Some(row_style) = row_style {
text.patch_style(row_style);
}
text
}
/// Gets the starting position of a table.
pub fn get_start_position(
num_rows: usize, scroll_direction: &app::ScrollDirection, scroll_position_bar: &mut usize,
currently_selected_position: usize, is_force_redraw: bool,
) -> usize {
if is_force_redraw {
*scroll_position_bar = 0;
}
match scroll_direction {
app::ScrollDirection::Down => {
if currently_selected_position < *scroll_position_bar + num_rows {
// If, using previous_scrolled_position, we can see the element
// (so within that and + num_rows) just reuse the current previously scrolled position
*scroll_position_bar
} else if currently_selected_position >= num_rows {
// Else if the current position past the last element visible in the list, omit
// until we can see that element
*scroll_position_bar = currently_selected_position - num_rows + 1;
*scroll_position_bar
} else {
// Else, if it is not past the last element visible, do not omit anything
0
}
}
app::ScrollDirection::Up => {
if currently_selected_position <= *scroll_position_bar {
// If it's past the first element, then show from that element downwards
*scroll_position_bar = currently_selected_position;
} else if currently_selected_position >= *scroll_position_bar + num_rows {
*scroll_position_bar = currently_selected_position - num_rows + 1;
}
// Else, don't change what our start position is from whatever it is set to!
*scroll_position_bar
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_get_start_position() {
use crate::app::ScrollDirection::{self, Down, Up};
#[track_caller]
fn test_get(
bar: usize, rows: usize, direction: ScrollDirection, selected: usize, force: bool,
expected_posn: usize, expected_bar: usize,
) {
let mut bar = bar;
assert_eq!(
get_start_position(rows, &direction, &mut bar, selected, force),
expected_posn,
"returned start position should match"
);
assert_eq!(bar, expected_bar, "bar positions should match");
}
// Scrolling down from start
test_get(0, 10, Down, 0, false, 0, 0);
// Simple scrolling down
test_get(0, 10, Down, 1, false, 0, 0);
// Scrolling down from the middle high up
test_get(0, 10, Down, 4, false, 0, 0);
// Scrolling down into boundary
test_get(0, 10, Down, 10, false, 1, 1);
test_get(0, 10, Down, 11, false, 2, 2);
// Scrolling down from the with non-zero bar
test_get(5, 10, Down, 14, false, 5, 5);
// Force redraw scrolling down (e.g. resize)
test_get(5, 15, Down, 14, true, 0, 0);
// Test jumping down
test_get(1, 10, Down, 19, true, 10, 10);
// Scrolling up from bottom
test_get(10, 10, Up, 19, false, 10, 10);
// Simple scrolling up
test_get(10, 10, Up, 18, false, 10, 10);
// Scrolling up from the middle
test_get(10, 10, Up, 10, false, 10, 10);
// Scrolling up into boundary
test_get(10, 10, Up, 9, false, 9, 9);
// Force redraw scrolling up (e.g. resize)
test_get(5, 10, Up, 14, true, 5, 5);
// Test jumping up
test_get(10, 10, Up, 0, false, 0, 0);
}
}

View file

@ -1,678 +0,0 @@
use std::{borrow::Cow, convert::TryInto, ops::Range};
use itertools::Itertools;
use tui::{layout::Rect, widgets::TableState};
use crate::app::ScrollDirection;
/// A bound on the width of a column.
#[derive(Clone, Copy, Debug)]
pub enum WidthBounds {
/// A width of this type is either as long as `min`, but can otherwise shrink and grow up to a point.
Soft {
/// The minimum amount before giving up and hiding.
min_width: u16,
/// The desired, calculated width. Take this if possible as the base starting width.
desired: u16,
/// The max width, as a percentage of the total width available. If [`None`],
/// then it can grow as desired.
max_percentage: Option<f32>,
},
/// A width of this type is either as long as specified, or does not appear at all.
Hard(u16),
/// Always uses the width of the [`CellContent`].
CellWidth,
}
impl WidthBounds {
pub const fn soft_from_str(name: &'static str, max_percentage: Option<f32>) -> WidthBounds {
let len = name.len() as u16;
WidthBounds::Soft {
min_width: len,
desired: len,
max_percentage,
}
}
pub const fn soft_from_str_with_alt(
name: &'static str, alt: &'static str, max_percentage: Option<f32>,
) -> WidthBounds {
WidthBounds::Soft {
min_width: alt.len() as u16,
desired: name.len() as u16,
max_percentage,
}
}
}
/// A [`CellContent`] contains text information for display in a table.
#[derive(Clone, Debug)]
pub enum CellContent {
Simple(Cow<'static, str>),
HasAlt {
alt: Cow<'static, str>,
main: Cow<'static, str>,
},
}
impl CellContent {
/// Creates a new [`CellContent`].
pub fn new<I>(name: I, alt: Option<I>) -> Self
where
I: Into<Cow<'static, str>>,
{
if let Some(alt) = alt {
CellContent::HasAlt {
alt: alt.into(),
main: name.into(),
}
} else {
CellContent::Simple(name.into())
}
}
/// Returns the length of the [`CellContent`]. Note that for a [`CellContent::HasAlt`], it will return
/// the length of the "main" field.
pub fn len(&self) -> usize {
match self {
CellContent::Simple(s) => s.len(),
CellContent::HasAlt { alt: _, main: long } => long.len(),
}
}
/// Whether the [`CellContent`]'s text is empty.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn main_text(&self) -> &Cow<'static, str> {
match self {
CellContent::Simple(main) => main,
CellContent::HasAlt { alt: _, main } => main,
}
}
}
pub trait TableComponentHeader {
fn header_text(&self) -> &CellContent;
}
impl TableComponentHeader for CellContent {
fn header_text(&self) -> &CellContent {
self
}
}
impl From<Cow<'static, str>> for CellContent {
fn from(c: Cow<'static, str>) -> Self {
CellContent::Simple(c)
}
}
impl From<&'static str> for CellContent {
fn from(s: &'static str) -> Self {
CellContent::Simple(s.into())
}
}
impl From<String> for CellContent {
fn from(s: String) -> Self {
CellContent::Simple(s.into())
}
}
pub struct TableComponentColumn<H: TableComponentHeader> {
/// The header of the column.
pub header: H,
/// A restriction on this column's width, if desired.
pub width_bounds: WidthBounds,
/// The calculated width of the column.
pub calculated_width: u16,
/// Marks that this column is currently "hidden", and should *always* be skipped.
pub is_hidden: bool,
}
impl<H: TableComponentHeader> TableComponentColumn<H> {
pub fn new_custom(header: H, width_bounds: WidthBounds) -> Self {
Self {
header,
width_bounds,
calculated_width: 0,
is_hidden: false,
}
}
pub fn new(header: H) -> Self {
Self {
header,
width_bounds: WidthBounds::CellWidth,
calculated_width: 0,
is_hidden: false,
}
}
pub fn new_hard(header: H, width: u16) -> Self {
Self {
header,
width_bounds: WidthBounds::Hard(width),
calculated_width: 0,
is_hidden: false,
}
}
pub fn new_soft(header: H, max_percentage: Option<f32>) -> Self {
let min_width = header.header_text().len() as u16;
Self {
header,
width_bounds: WidthBounds::Soft {
min_width,
desired: min_width,
max_percentage,
},
calculated_width: 0,
is_hidden: false,
}
}
pub fn is_zero_width(&self) -> bool {
self.calculated_width == 0
}
pub fn is_skipped(&self) -> bool {
self.is_zero_width() || self.is_hidden
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum SortOrder {
Ascending,
Descending,
}
impl SortOrder {
pub fn is_descending(&self) -> bool {
matches!(self, SortOrder::Descending)
}
}
/// Represents the current table's sorting state.
#[derive(Debug)]
pub enum SortState {
Unsortable,
Sortable(SortableState),
}
#[derive(Debug)]
pub struct SortableState {
/// The "x locations" of the headers.
visual_mappings: Vec<Range<u16>>,
/// The "y location" of the header row. Since all headers share the same y-location we just set it once here.
y_loc: u16,
/// This is a bit of a lazy hack to handle this for now - ideally the entire [`SortableState`]
/// is instead handled by a separate table struct that also can access the columns and their default sort orderings.
default_sort_orderings: Vec<SortOrder>,
/// The currently selected sort index.
pub current_index: usize,
/// The current sorting order.
pub order: SortOrder,
}
impl SortableState {
/// Creates a new [`SortableState`].
pub fn new(
default_index: usize, default_order: SortOrder, default_sort_orderings: Vec<SortOrder>,
) -> Self {
Self {
visual_mappings: Default::default(),
y_loc: 0,
default_sort_orderings,
current_index: default_index,
order: default_order,
}
}
/// Toggles the current sort order.
pub fn toggle_order(&mut self) {
self.order = match self.order {
SortOrder::Ascending => SortOrder::Descending,
SortOrder::Descending => SortOrder::Ascending,
}
}
/// Updates the visual index.
///
/// This function will create a *sorted* range list - in debug mode,
/// the program will assert this, but it will not do so in release mode!
pub fn update_visual_index(&mut self, draw_loc: Rect, row_widths: &[u16]) {
let mut start = draw_loc.x;
let visual_index = row_widths
.iter()
.map(|width| {
let range_start = start;
let range_end = start + width + 1; // +1 for the gap b/w cols.
start = range_end;
range_start..range_end
})
.collect_vec();
debug_assert!(visual_index.iter().all(|a| { a.start <= a.end }));
debug_assert!(visual_index
.iter()
.tuple_windows()
.all(|(a, b)| { b.start >= a.end }));
self.visual_mappings = visual_index;
self.y_loc = draw_loc.y;
}
/// Given some `x` and `y`, if possible, select the corresponding column or toggle the column if already selected,
/// and otherwise do nothing.
///
/// If there was some update, the corresponding column type will be returned. If nothing happens, [`None`] is
/// returned.
pub fn try_select_location(&mut self, x: u16, y: u16) -> Option<usize> {
if self.y_loc == y {
if let Some(index) = self.get_range(x) {
self.update_sort_index(index);
Some(self.current_index)
} else {
None
}
} else {
None
}
}
/// Updates the sort index, and sets the sort order as appropriate.
///
/// If the index is different from the previous one, it will move to the new index and set the sort order
/// to the prescribed default sort order.
///
/// If the index is the same as the previous one, it will simply toggle the current sort order.
pub fn update_sort_index(&mut self, index: usize) {
if self.current_index == index {
self.toggle_order();
} else {
self.current_index = index;
self.order = self.default_sort_orderings[index];
}
}
/// Given a `needle` coordinate, select the corresponding index and value.
fn get_range(&self, needle: u16) -> Option<usize> {
match self
.visual_mappings
.binary_search_by_key(&needle, |range| range.start)
{
Ok(index) => Some(index),
Err(index) => index.checked_sub(1),
}
.and_then(|index| {
if needle < self.visual_mappings[index].end {
Some(index)
} else {
None
}
})
}
}
/// [`TableComponentState`] deals with fields for a scrollable's current state.
pub struct TableComponentState<H: TableComponentHeader = CellContent> {
pub current_scroll_position: usize,
pub scroll_bar: usize,
pub scroll_direction: ScrollDirection,
pub table_state: TableState,
pub columns: Vec<TableComponentColumn<H>>,
pub sort_state: SortState,
}
impl<H: TableComponentHeader> TableComponentState<H> {
pub fn new(columns: Vec<TableComponentColumn<H>>) -> Self {
Self {
current_scroll_position: 0,
scroll_bar: 0,
scroll_direction: ScrollDirection::Down,
table_state: Default::default(),
columns,
sort_state: SortState::Unsortable,
}
}
pub fn sort_state(mut self, sort_state: SortState) -> Self {
self.sort_state = sort_state;
self
}
/// Calculates widths for the columns for this table.
///
/// * `total_width` is the, well, total width available.
/// * `left_to_right` is a boolean whether to go from left to right if true, or right to left if
/// false.
///
/// **NOTE:** Trailing 0's may break tui-rs, remember to filter them out later!
pub fn calculate_column_widths(&mut self, total_width: u16, left_to_right: bool) {
use itertools::Either;
use std::cmp::{max, min};
let mut total_width_left = total_width;
let columns = if left_to_right {
Either::Left(self.columns.iter_mut())
} else {
Either::Right(self.columns.iter_mut().rev())
};
let arrow_offset = match self.sort_state {
SortState::Unsortable => 0,
SortState::Sortable { .. } => 1,
};
let mut num_columns = 0;
let mut skip_iter = false;
for column in columns {
column.calculated_width = 0;
if column.is_hidden || skip_iter {
continue;
}
match &column.width_bounds {
WidthBounds::Soft {
min_width,
desired,
max_percentage,
} => {
let min_width = *min_width + arrow_offset;
if min_width > total_width_left {
skip_iter = true;
continue;
}
let soft_limit = max(
if let Some(max_percentage) = max_percentage {
// TODO: Rust doesn't have an `into()` or `try_into()` for floats to integers.
((*max_percentage * f32::from(total_width)).ceil()) as u16
} else {
*desired
},
min_width,
);
let space_taken = min(min(soft_limit, *desired), total_width_left);
if min_width > space_taken || min_width == 0 {
skip_iter = true;
} else if space_taken > 0 {
total_width_left = total_width_left.saturating_sub(space_taken + 1);
column.calculated_width = space_taken;
num_columns += 1;
}
}
WidthBounds::CellWidth => {
let width = column.header.header_text().len() as u16;
let min_width = width + arrow_offset;
if min_width > total_width_left || min_width == 0 {
skip_iter = true;
} else if min_width > 0 {
total_width_left = total_width_left.saturating_sub(min_width + 1);
column.calculated_width = min_width;
num_columns += 1;
}
}
WidthBounds::Hard(width) => {
let min_width = *width + arrow_offset;
if min_width > total_width_left || min_width == 0 {
skip_iter = true;
} else if min_width > 0 {
total_width_left = total_width_left.saturating_sub(min_width + 1);
column.calculated_width = min_width;
num_columns += 1;
}
}
}
}
if num_columns > 0 {
// Redistribute remaining.
let mut num_dist = num_columns;
let amount_per_slot = total_width_left / num_dist;
total_width_left %= num_dist;
for column in self.columns.iter_mut() {
if num_dist == 0 {
break;
}
if column.calculated_width > 0 {
if total_width_left > 0 {
column.calculated_width += amount_per_slot + 1;
total_width_left -= 1;
} else {
column.calculated_width += amount_per_slot;
}
num_dist -= 1;
}
}
}
}
/// Updates the position if possible, and if there is a valid change, returns the new position.
pub fn update_position(&mut self, change: i64, num_entries: usize) -> Option<usize> {
let min_index = 0;
let max_index = num_entries.saturating_sub(1);
if change == 0
|| (change > 0 && self.current_scroll_position == max_index)
|| (change < 0 && self.current_scroll_position == min_index)
{
return None;
}
let csp: Result<i64, _> = self.current_scroll_position.try_into();
if let Ok(csp) = csp {
self.current_scroll_position =
(csp + change).clamp(min_index as i64, max_index as i64) as usize;
if change < 0 {
self.scroll_direction = ScrollDirection::Up;
} else {
self.scroll_direction = ScrollDirection::Down;
}
Some(self.current_scroll_position)
} else {
None
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_scroll_update_position() {
#[track_caller]
fn check_scroll_update(
scroll: &mut TableComponentState, change: i64, max: usize, ret: Option<usize>,
new_position: usize,
) {
assert_eq!(scroll.update_position(change, max), ret);
assert_eq!(scroll.current_scroll_position, new_position);
}
let mut scroll = TableComponentState {
current_scroll_position: 5,
scroll_bar: 0,
scroll_direction: ScrollDirection::Down,
table_state: Default::default(),
columns: vec![],
sort_state: SortState::Unsortable,
};
let s = &mut scroll;
// Update by 0. Should not change.
check_scroll_update(s, 0, 15, None, 5);
// Update by 5. Should increment to index 10.
check_scroll_update(s, 5, 15, Some(10), 10);
// Update by 5. Should clamp to max possible scroll index 14.
check_scroll_update(s, 5, 15, Some(14), 14);
// Update by 1. Should do nothing (already at max index 14).
check_scroll_update(s, 1, 15, None, 14);
// Update by -15. Should clamp to index 0.
check_scroll_update(s, -15, 15, Some(0), 0);
// Update by -1. Should do nothing (already at min index 0).
check_scroll_update(s, -15, 15, None, 0);
// Update by 0. Should do nothing.
check_scroll_update(s, 0, 15, None, 0);
// Update by 15. Should clamp to 14.
check_scroll_update(s, 15, 15, Some(14), 14);
// Update by 15 but with a larger bound. Should clamp to 15.
check_scroll_update(s, 15, 16, Some(15), 15);
}
#[test]
fn test_table_width_calculation() {
#[track_caller]
fn test_calculation(state: &mut TableComponentState, width: u16, expected: Vec<u16>) {
state.calculate_column_widths(width, true);
assert_eq!(
state
.columns
.iter()
.filter_map(|c| if c.calculated_width == 0 {
None
} else {
Some(c.calculated_width)
})
.collect::<Vec<_>>(),
expected
)
}
let mut state = TableComponentState::new(vec![
TableComponentColumn::new(CellContent::from("a")),
TableComponentColumn::new_custom(
"a".into(),
WidthBounds::Soft {
min_width: 1,
desired: 10,
max_percentage: Some(0.125),
},
),
TableComponentColumn::new_custom(
"a".into(),
WidthBounds::Soft {
min_width: 2,
desired: 10,
max_percentage: Some(0.5),
},
),
]);
test_calculation(&mut state, 0, vec![]);
test_calculation(&mut state, 1, vec![1]);
test_calculation(&mut state, 2, vec![1]);
test_calculation(&mut state, 3, vec![1, 1]);
test_calculation(&mut state, 4, vec![1, 1]);
test_calculation(&mut state, 5, vec![2, 1]);
test_calculation(&mut state, 6, vec![1, 1, 2]);
test_calculation(&mut state, 7, vec![1, 1, 3]);
test_calculation(&mut state, 8, vec![1, 1, 4]);
test_calculation(&mut state, 14, vec![2, 2, 7]);
test_calculation(&mut state, 20, vec![2, 4, 11]);
test_calculation(&mut state, 100, vec![27, 35, 35]);
state.sort_state = SortState::Sortable(SortableState::new(1, SortOrder::Ascending, vec![]));
test_calculation(&mut state, 0, vec![]);
test_calculation(&mut state, 1, vec![]);
test_calculation(&mut state, 2, vec![2]);
test_calculation(&mut state, 3, vec![2]);
test_calculation(&mut state, 4, vec![3]);
test_calculation(&mut state, 5, vec![2, 2]);
test_calculation(&mut state, 6, vec![2, 2]);
test_calculation(&mut state, 7, vec![3, 2]);
test_calculation(&mut state, 8, vec![3, 3]);
test_calculation(&mut state, 14, vec![2, 2, 7]);
test_calculation(&mut state, 20, vec![3, 4, 10]);
test_calculation(&mut state, 100, vec![27, 35, 35]);
}
#[test]
fn test_visual_index_selection() {
let mut state = SortableState::new(
0,
SortOrder::Ascending,
vec![SortOrder::Ascending, SortOrder::Descending],
);
const X_OFFSET: u16 = 10;
const Y_OFFSET: u16 = 15;
state.update_visual_index(Rect::new(X_OFFSET, Y_OFFSET, 20, 15), &[4, 14]);
#[track_caller]
fn test_selection(
state: &mut SortableState, from_x_offset: u16, from_y_offset: u16,
result: (Option<usize>, SortOrder),
) {
assert_eq!(
state.try_select_location(X_OFFSET + from_x_offset, Y_OFFSET + from_y_offset),
result.0
);
assert_eq!(state.order, result.1);
}
use SortOrder::*;
// Clicking on these don't do anything, so don't show any change.
test_selection(&mut state, 5, 1, (None, Ascending));
test_selection(&mut state, 21, 0, (None, Ascending));
// Clicking on the first column should toggle it as it is already selected.
test_selection(&mut state, 3, 0, (Some(0), Descending));
// Clicking on the first column should toggle it again as it is already selected.
test_selection(&mut state, 4, 0, (Some(0), Ascending));
// Clicking on second column should select and switch to the descending ordering as that is its default.
test_selection(&mut state, 5, 0, (Some(1), Descending));
// Clicking on second column should toggle it.
test_selection(&mut state, 19, 0, (Some(1), Ascending));
// Overshoot, should not do anything.
test_selection(&mut state, 20, 0, (None, Ascending));
// Further overshoot, should not do anything.
test_selection(&mut state, 25, 0, (None, Ascending));
// Go back to first column, should be ascending to match default for index 0.
test_selection(&mut state, 3, 0, (Some(0), Ascending));
// Click on first column should then go to descending as it is already selected and ascending.
test_selection(&mut state, 3, 0, (Some(0), Descending));
}
}

View file

@ -16,7 +16,7 @@ use unicode_segmentation::UnicodeSegmentation;
use super::tui_widget::time_chart::{Axis, Dataset, TimeChart, DEFAULT_LEGEND_CONSTRAINTS};
/// A single graph point.
pub type Point = (f64, f64);
pub type Point = (f64, f64); // FIXME: Move this to tui time chart?
/// Represents the data required by the [`TimeGraph`].
pub struct GraphData<'a> {
@ -70,9 +70,12 @@ impl<'a> TimeGraph<'a> {
if self.hide_x_labels {
Axis::default().bounds(adjusted_x_bounds)
} else {
let xb_one = (self.x_bounds[1] / 1000).to_string();
let xb_zero = (self.x_bounds[0] / 1000).to_string();
let x_labels = vec![
Span::raw(concat_string!((self.x_bounds[1] / 1000).to_string(), "s")),
Span::raw(concat_string!((self.x_bounds[0] / 1000).to_string(), "s")),
Span::raw(concat_string!(xb_one, "s")),
Span::raw(concat_string!(xb_zero, "s")),
];
Axis::default()

View file

@ -1,16 +1,17 @@
//! This mainly concerns converting collected data into things that the canvas
//! can actually handle.
use crate::components::text_table::CellContent;
use crate::components::time_graph::Point;
use crate::{app::AxisScaling, units::data_units::DataUnit, Pid};
use crate::{
app::{data_farmer, data_harvester, App},
utils::gen_util::*,
use crate::app::data_farmer::DataCollection;
use crate::app::data_harvester::cpu::CpuDataType;
use crate::app::{
data_harvester::temperature::TemperatureType,
widgets::{DiskWidgetData, TempWidgetData},
};
use crate::components::time_graph::Point;
use crate::utils::gen_util::*;
use crate::{app::AxisScaling, units::data_units::DataUnit};
use concat_string::concat_string;
use fxhash::FxHashMap;
use kstring::KString;
#[derive(Default, Debug)]
pub struct ConvertedBatteryData {
@ -22,27 +23,6 @@ pub struct ConvertedBatteryData {
pub health: String,
}
#[derive(Default, Debug)]
pub struct TableData {
pub data: Vec<TableRow>,
pub col_widths: Vec<usize>,
}
#[derive(Debug)]
pub enum TableRow {
Raw(Vec<CellContent>),
Styled(Vec<CellContent>, tui::style::Style),
}
impl TableRow {
pub fn row(&self) -> &[CellContent] {
match self {
TableRow::Raw(data) => data,
TableRow::Styled(data, _) => data,
}
}
}
#[derive(Default, Debug)]
pub struct ConvertedNetworkData {
pub rx: Vec<Point>,
@ -60,14 +40,15 @@ pub struct ConvertedNetworkData {
// mean_tx: f64,
}
#[derive(Clone, Default, Debug)]
pub struct ConvertedCpuData {
pub cpu_name: String,
pub short_cpu_name: String,
/// Tuple is time, value
pub cpu_data: Vec<Point>,
/// Represents the value displayed on the legend.
pub legend_value: String,
#[derive(Clone, Debug)]
pub enum CpuWidgetData {
All,
Entry {
data_type: CpuDataType,
/// A point here represents time (x) and value (y).
data: Vec<Point>,
last_entry: f64,
},
}
#[derive(Default)]
@ -78,14 +59,8 @@ pub struct ConvertedData {
pub total_tx_display: String,
pub network_data_rx: Vec<Point>,
pub network_data_tx: Vec<Point>,
pub disk_data: TableData,
pub temp_sensor_data: TableData,
/// A mapping from a process name to any PID with that name.
pub process_name_pid_map: FxHashMap<String, Vec<Pid>>,
/// A mapping from a process command to any PID with that name.
pub process_cmd_pid_map: FxHashMap<String, Vec<Pid>>,
pub disk_data: Vec<DiskWidgetData>,
pub temp_data: Vec<TempWidgetData>,
pub mem_labels: Option<(String, String)>,
pub swap_labels: Option<(String, String)>,
@ -95,199 +70,119 @@ pub struct ConvertedData {
pub swap_data: Vec<Point>,
pub arc_data: Vec<Point>,
pub load_avg_data: [f32; 3],
pub cpu_data: Vec<ConvertedCpuData>,
pub cpu_data: Vec<CpuWidgetData>,
pub battery_data: Vec<ConvertedBatteryData>,
}
pub fn convert_temp_row(app: &App) -> TableData {
let current_data = &app.data_collection;
let temp_type = &app.app_config_fields.temperature_type;
let mut col_widths = vec![0; 2];
impl ConvertedData {
// TODO: Can probably heavily reduce this step to avoid clones.
pub fn ingest_disk_data(&mut self, data: &DataCollection) {
self.disk_data.clear();
let mut sensor_vector: Vec<TableRow> = current_data
.temp_harvest
data.disk_harvest
.iter()
.map(|temp_harvest| {
let row = vec![
CellContent::Simple(temp_harvest.name.clone().into()),
CellContent::Simple(
concat_string!(
(temp_harvest.temperature.ceil() as u64).to_string(),
match temp_type {
data_harvester::temperature::TemperatureType::Celsius => "°C",
data_harvester::temperature::TemperatureType::Kelvin => "K",
data_harvester::temperature::TemperatureType::Fahrenheit => "°F",
}
)
.into(),
),
];
col_widths.iter_mut().zip(&row).for_each(|(curr, r)| {
*curr = std::cmp::max(*curr, r.len());
});
TableRow::Raw(row)
})
.collect();
if sensor_vector.is_empty() {
sensor_vector.push(TableRow::Raw(vec![
CellContent::Simple("No Sensors Found".into()),
CellContent::Simple("".into()),
]));
}
TableData {
data: sensor_vector,
col_widths,
}
}
pub fn convert_disk_row(current_data: &data_farmer::DataCollection) -> TableData {
let mut disk_vector: Vec<TableRow> = Vec::new();
let mut col_widths = vec![0; 8];
current_data
.disk_harvest
.iter()
.zip(&current_data.io_labels)
.zip(&data.io_labels)
.for_each(|(disk, (io_read, io_write))| {
let free_space_fmt = if let Some(free_space) = disk.free_space {
let converted_free_space = get_decimal_bytes(free_space);
format!("{:.*}{}", 0, converted_free_space.0, converted_free_space.1).into()
} else {
"N/A".into()
};
let total_space_fmt = if let Some(total_space) = disk.total_space {
let converted_total_space = get_decimal_bytes(total_space);
format!(
"{:.*}{}",
0, converted_total_space.0, converted_total_space.1
)
.into()
} else {
"N/A".into()
};
let usage_fmt = if let (Some(used_space), Some(total_space)) =
(disk.used_space, disk.total_space)
{
format!("{:.0}%", used_space as f64 / total_space as f64 * 100_f64).into()
} else {
"N/A".into()
};
let row = vec![
CellContent::Simple(disk.name.clone().into()),
CellContent::Simple(disk.mount_point.clone().into()),
CellContent::Simple(usage_fmt),
CellContent::Simple(free_space_fmt),
CellContent::Simple(total_space_fmt),
CellContent::Simple(io_read.clone().into()),
CellContent::Simple(io_write.clone().into()),
];
col_widths.iter_mut().zip(&row).for_each(|(curr, r)| {
*curr = std::cmp::max(*curr, r.len());
self.disk_data.push(DiskWidgetData {
name: KString::from_ref(&disk.name),
mount_point: KString::from_ref(&disk.mount_point),
free_bytes: disk.free_space,
used_bytes: disk.used_space,
total_bytes: disk.total_space,
io_read: io_read.into(),
io_write: io_write.into(),
});
disk_vector.push(TableRow::Raw(row));
});
if disk_vector.is_empty() {
disk_vector.push(TableRow::Raw(vec![
CellContent::Simple("No Disks Found".into()),
CellContent::Simple("".into()),
]));
self.disk_data.shrink_to_fit();
}
TableData {
data: disk_vector,
col_widths,
}
pub fn ingest_temp_data(&mut self, data: &DataCollection, temperature_type: TemperatureType) {
self.temp_data.clear();
data.temp_harvest.iter().for_each(|temp_harvest| {
self.temp_data.push(TempWidgetData {
sensor: KString::from_ref(&temp_harvest.name),
temperature_value: temp_harvest.temperature.ceil() as u64,
temperature_type,
});
});
self.temp_data.shrink_to_fit();
}
pub fn convert_cpu_data_points(
current_data: &data_farmer::DataCollection, existing_cpu_data: &mut Vec<ConvertedCpuData>,
) {
let current_time = if let Some(frozen_instant) = current_data.frozen_instant {
frozen_instant
} else {
current_data.current_instant
};
pub fn ingest_cpu_data(&mut self, current_data: &DataCollection) {
let current_time = current_data.current_instant;
// Initialize cpu_data_vector if the lengths don't match...
// (Re-)initialize the vector if the lengths don't match...
if let Some((_time, data)) = &current_data.timed_data_vec.last() {
if data.cpu_data.len() + 1 != existing_cpu_data.len() {
*existing_cpu_data = vec![ConvertedCpuData {
cpu_name: "All".to_string(),
short_cpu_name: "".to_string(),
cpu_data: vec![],
legend_value: String::new(),
}];
existing_cpu_data.extend(
if data.cpu_data.len() + 1 != self.cpu_data.len() {
self.cpu_data = Vec::with_capacity(data.cpu_data.len() + 1);
self.cpu_data.push(CpuWidgetData::All);
self.cpu_data.extend(
data.cpu_data
.iter()
.enumerate()
.map(|(itx, cpu_usage)| ConvertedCpuData {
cpu_name: if let Some(cpu_harvest) = current_data.cpu_harvest.get(itx) {
if let Some(cpu_count) = cpu_harvest.cpu_count {
format!("{}{}", cpu_harvest.cpu_prefix, cpu_count)
} else {
cpu_harvest.cpu_prefix.to_string()
}
} else {
String::default()
},
short_cpu_name: if let Some(cpu_harvest) = current_data.cpu_harvest.get(itx)
{
if let Some(cpu_count) = cpu_harvest.cpu_count {
cpu_count.to_string()
} else {
cpu_harvest.cpu_prefix.to_string()
}
} else {
String::default()
},
legend_value: format!("{:.0}%", cpu_usage.round()),
cpu_data: vec![],
.zip(&current_data.cpu_harvest)
.map(|(cpu_usage, data)| CpuWidgetData::Entry {
data_type: data.data_type,
data: vec![],
last_entry: *cpu_usage,
})
.collect::<Vec<ConvertedCpuData>>(),
.collect::<Vec<CpuWidgetData>>(),
);
} else {
existing_cpu_data
self.cpu_data
.iter_mut()
.skip(1)
.zip(&data.cpu_data)
.for_each(|(cpu, cpu_usage)| {
cpu.cpu_data = vec![];
cpu.legend_value = format!("{:.0}%", cpu_usage.round());
.for_each(|(mut cpu, cpu_usage)| match &mut cpu {
CpuWidgetData::All => unreachable!(),
CpuWidgetData::Entry {
data_type: _,
data,
last_entry,
} => {
// A bit faster to just update all the times, so we just clear the vector.
data.clear();
*last_entry = *cpu_usage;
}
});
}
}
for (time, data) in &current_data.timed_data_vec {
let time_from_start: f64 = (current_time.duration_since(*time).as_millis() as f64).floor();
// TODO: [Opt] Can probably avoid data deduplication - store the shift + data + original once.
// Now push all the data.
for (itx, mut cpu) in &mut self.cpu_data.iter_mut().skip(1).enumerate() {
match &mut cpu {
CpuWidgetData::All => unreachable!(),
CpuWidgetData::Entry {
data_type: _,
data,
last_entry: _,
} => {
for (time, timed_data) in &current_data.timed_data_vec {
let time_start: f64 =
(current_time.duration_since(*time).as_millis() as f64).floor();
for (itx, cpu) in data.cpu_data.iter().enumerate() {
if let Some(cpu_data) = existing_cpu_data.get_mut(itx + 1) {
cpu_data.cpu_data.push((-time_from_start, *cpu));
}
if let Some(val) = timed_data.cpu_data.get(itx) {
data.push((-time_start, *val));
}
if *time == current_time {
break;
}
}
data.shrink_to_fit();
}
}
}
}
}
pub fn convert_mem_data_points(current_data: &data_farmer::DataCollection) -> Vec<Point> {
pub fn convert_mem_data_points(current_data: &DataCollection) -> Vec<Point> {
let mut result: Vec<Point> = Vec::new();
let current_time = if let Some(frozen_instant) = current_data.frozen_instant {
frozen_instant
} else {
current_data.current_instant
};
let current_time = current_data.current_instant;
for (time, data) in &current_data.timed_data_vec {
if let Some(mem_data) = data.mem_data {
@ -303,13 +198,9 @@ pub fn convert_mem_data_points(current_data: &data_farmer::DataCollection) -> Ve
result
}
pub fn convert_swap_data_points(current_data: &data_farmer::DataCollection) -> Vec<Point> {
pub fn convert_swap_data_points(current_data: &DataCollection) -> Vec<Point> {
let mut result: Vec<Point> = Vec::new();
let current_time = if let Some(frozen_instant) = current_data.frozen_instant {
frozen_instant
} else {
current_data.current_instant
};
let current_time = current_data.current_instant;
for (time, data) in &current_data.timed_data_vec {
if let Some(swap_data) = data.swap_data {
@ -326,7 +217,7 @@ pub fn convert_swap_data_points(current_data: &data_farmer::DataCollection) -> V
}
pub fn convert_mem_labels(
current_data: &data_farmer::DataCollection,
current_data: &DataCollection,
) -> (Option<(String, String)>, Option<(String, String)>) {
/// Returns the unit type and denominator for given total amount of memory in kibibytes.
fn return_unit_and_denominator_for_mem_kib(mem_total_kib: u64) -> (&'static str, f64) {
@ -396,17 +287,13 @@ pub fn convert_mem_labels(
}
pub fn get_rx_tx_data_points(
current_data: &data_farmer::DataCollection, network_scale_type: &AxisScaling,
network_unit_type: &DataUnit, network_use_binary_prefix: bool,
current_data: &DataCollection, network_scale_type: &AxisScaling, network_unit_type: &DataUnit,
network_use_binary_prefix: bool,
) -> (Vec<Point>, Vec<Point>) {
let mut rx: Vec<Point> = Vec::new();
let mut tx: Vec<Point> = Vec::new();
let current_time = if let Some(frozen_instant) = current_data.frozen_instant {
frozen_instant
} else {
current_data.current_instant
};
let current_time = current_data.current_instant;
for (time, data) in &current_data.timed_data_vec {
let time_from_start: f64 = (current_time.duration_since(*time).as_millis() as f64).floor();
@ -447,9 +334,8 @@ pub fn get_rx_tx_data_points(
}
pub fn convert_network_data_points(
current_data: &data_farmer::DataCollection, need_four_points: bool,
network_scale_type: &AxisScaling, network_unit_type: &DataUnit,
network_use_binary_prefix: bool,
current_data: &DataCollection, need_four_points: bool, network_scale_type: &AxisScaling,
network_unit_type: &DataUnit, network_use_binary_prefix: bool,
) -> ConvertedNetworkData {
let (rx, tx) = get_rx_tx_data_points(
current_data,
@ -607,10 +493,19 @@ pub fn dec_bytes_per_second_string(value: u64) -> String {
}
}
/// Returns a string given a value that is converted to the closest SI-variant.
/// If the value is greater than a giga-X, then it will return a decimal place.
pub fn dec_bytes_string(value: u64) -> String {
let converted_values = get_decimal_bytes(value);
if value >= GIGA_LIMIT {
format!("{:.*}{}", 1, converted_values.0, converted_values.1)
} else {
format!("{:.*}{}", 0, converted_values.0, converted_values.1)
}
}
#[cfg(feature = "battery")]
pub fn convert_battery_harvest(
current_data: &data_farmer::DataCollection,
) -> Vec<ConvertedBatteryData> {
pub fn convert_battery_harvest(current_data: &DataCollection) -> Vec<ConvertedBatteryData> {
current_data
.battery_harvest
.iter()
@ -657,7 +552,9 @@ pub fn convert_battery_harvest(
}
#[cfg(feature = "zfs")]
pub fn convert_arc_labels(current_data: &data_farmer::DataCollection) -> Option<(String, String)> {
pub fn convert_arc_labels(
current_data: &crate::app::data_farmer::DataCollection,
) -> Option<(String, String)> {
/// Returns the unit type and denominator for given total amount of memory in kibibytes.
fn return_unit_and_denominator_for_mem_kib(mem_total_kib: u64) -> (&'static str, f64) {
if mem_total_kib < 1024 {
@ -701,13 +598,11 @@ pub fn convert_arc_labels(current_data: &data_farmer::DataCollection) -> Option<
}
#[cfg(feature = "zfs")]
pub fn convert_arc_data_points(current_data: &data_farmer::DataCollection) -> Vec<Point> {
pub fn convert_arc_data_points(
current_data: &crate::app::data_farmer::DataCollection,
) -> Vec<Point> {
let mut result: Vec<Point> = Vec::new();
let current_time = if let Some(frozen_instant) = current_data.frozen_instant {
frozen_instant
} else {
current_data.current_instant
};
let current_time = current_data.current_instant;
for (time, data) in &current_data.timed_data_vec {
if let Some(arc_data) = data.arc_data {

View file

@ -131,7 +131,7 @@ pub fn handle_key_event_or_break(
KeyCode::F(2) => app.toggle_search_whole_word(),
KeyCode::F(3) => app.toggle_search_regex(),
KeyCode::F(5) => app.toggle_tree_mode(),
KeyCode::F(6) => app.toggle_sort(),
KeyCode::F(6) => app.toggle_sort_menu(),
KeyCode::F(9) => app.start_killing_process(),
KeyCode::PageDown => app.on_page_down(),
KeyCode::PageUp => app.on_page_up(),
@ -322,33 +322,40 @@ pub fn panic_hook(panic_info: &PanicInfo<'_>) {
}
pub fn update_data(app: &mut App) {
let data_source = match &app.frozen_state {
app::frozen_state::FrozenState::NotFrozen => &app.data_collection,
app::frozen_state::FrozenState::Frozen(data) => data,
};
for proc in app.proc_state.widget_states.values_mut() {
if proc.force_update_data {
proc.update_displayed_process_data(&app.data_collection);
proc.update_displayed_process_data(data_source);
proc.force_update_data = false;
}
}
// FIXME: Make this CPU force update less terrible.
if app.cpu_state.force_update.is_some() {
convert_cpu_data_points(&app.data_collection, &mut app.converted_data.cpu_data);
app.converted_data.load_avg_data = app.data_collection.load_avg_harvest;
app.converted_data.ingest_cpu_data(data_source);
app.converted_data.load_avg_data = data_source.load_avg_harvest;
app.cpu_state.force_update = None;
}
// TODO: [OPT] Prefer reassignment over new vectors?
if app.mem_state.force_update.is_some() {
app.converted_data.mem_data = convert_mem_data_points(&app.data_collection);
app.converted_data.swap_data = convert_swap_data_points(&app.data_collection);
app.converted_data.mem_data = convert_mem_data_points(data_source);
app.converted_data.swap_data = convert_swap_data_points(data_source);
#[cfg(feature = "zfs")]
{
app.converted_data.arc_data = convert_arc_data_points(&app.data_collection);
app.converted_data.arc_data = convert_arc_data_points(data_source);
}
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,
data_source,
&app.app_config_fields.network_scale_type,
&app.app_config_fields.network_unit_type,
app.app_config_fields.network_use_binary_prefix,
@ -411,7 +418,7 @@ pub fn create_collection_thread(
app_config_fields: &app::AppConfigFields, filters: app::DataFilters,
used_widget_set: UsedWidgets,
) -> std::thread::JoinHandle<()> {
let temp_type = app_config_fields.temperature_type.clone();
let temp_type = app_config_fields.temperature_type;
let use_current_cpu_total = app_config_fields.use_current_cpu_total;
let show_average_cpu = app_config_fields.show_average_cpu;
let update_rate_in_milliseconds = app_config_fields.update_rate_in_milliseconds;
@ -444,7 +451,7 @@ pub fn create_collection_thread(
data_state.data.cleanup();
}
ThreadControlEvent::UpdateConfig(app_config_fields) => {
data_state.set_temperature_type(app_config_fields.temperature_type.clone());
data_state.set_temperature_type(app_config_fields.temperature_type);
data_state
.set_use_current_cpu_total(app_config_fields.use_current_cpu_total);
data_state.set_show_average_cpu(app_config_fields.show_average_cpu);

View file

@ -4,7 +4,6 @@ use std::{
borrow::Cow,
collections::{HashMap, HashSet},
convert::TryInto,
path::PathBuf,
str::FromStr,
time::Instant,
};
@ -12,10 +11,10 @@ use std::{
use crate::{
app::{
layout_manager::*,
widgets::{DiskWidgetState, ProcWidget, ProcWidgetMode, TempWidgetState},
widgets::{CpuWidgetState, DiskTableWidget, ProcWidget, ProcWidgetMode, TempWidgetState},
*,
},
canvas::ColourScheme,
canvas::{canvas_colours::CanvasColours, ColourScheme},
constants::*,
units::data_units::DataUnit,
utils::error::{self, BottomError},
@ -252,7 +251,7 @@ pub struct IgnoreList {
pub fn build_app(
matches: &clap::ArgMatches, config: &mut Config, widget_layout: &BottomLayout,
default_widget_id: u64, default_widget_type_option: &Option<BottomWidgetType>,
config_path: Option<PathBuf>,
colours: &CanvasColours,
) -> Result<App> {
use BottomWidgetType::*;
let autohide_time = get_autohide_time(matches, config);
@ -272,7 +271,7 @@ pub fn build_app(
let mut net_state_map: HashMap<u64, NetWidgetState> = HashMap::new();
let mut proc_state_map: HashMap<u64, ProcWidget> = HashMap::new();
let mut temp_state_map: HashMap<u64, TempWidgetState> = HashMap::new();
let mut disk_state_map: HashMap<u64, DiskWidgetState> = HashMap::new();
let mut disk_state_map: HashMap<u64, DiskTableWidget> = HashMap::new();
let mut battery_state_map: HashMap<u64, BatteryWidgetState> = HashMap::new();
let autohide_timer = if autohide_time {
@ -295,6 +294,37 @@ pub fn build_app(
let network_scale_type = get_network_scale_type(matches, config);
let network_use_binary_prefix = get_network_use_binary_prefix(matches, config);
let app_config_fields = AppConfigFields {
update_rate_in_milliseconds: get_update_rate_in_milliseconds(matches, config)
.context("Update 'rate' in your config file.")?,
temperature_type: get_temperature(matches, config)
.context("Update 'temperature_type' in your config file.")?,
show_average_cpu: get_show_average_cpu(matches, config),
use_dot: get_use_dot(matches, config),
left_legend: get_use_left_legend(matches, config),
use_current_cpu_total: get_use_current_cpu_total(matches, config),
use_basic_mode,
default_time_value,
time_interval: get_time_interval(matches, config)
.context("Update 'time_delta' in your config file.")?,
hide_time: get_hide_time(matches, config),
autohide_time,
use_old_network_legend: get_use_old_network_legend(matches, config),
table_gap: if get_hide_table_gap(matches, config) {
0
} else {
1
},
disable_click: get_disable_click(matches, config),
// no_write: get_no_write(matches, config),
no_write: false,
show_table_scroll_position: get_show_table_scroll_position(matches, config),
is_advanced_kill,
network_scale_type,
network_unit_type,
network_use_binary_prefix,
};
for row in &widget_layout.rows {
for col in &row.children {
for col_row in &col.children {
@ -337,7 +367,12 @@ pub fn build_app(
Cpu => {
cpu_state_map.insert(
widget.widget_id,
CpuWidgetState::init(default_time_value, autohide_timer),
CpuWidgetState::new(
&app_config_fields,
default_time_value,
autohide_timer,
colours,
),
);
}
Mem => {
@ -365,21 +400,29 @@ pub fn build_app(
proc_state_map.insert(
widget.widget_id,
ProcWidget::init(
ProcWidget::new(
&app_config_fields,
mode,
is_case_sensitive,
is_match_whole_word,
is_use_regex,
show_memory_as_values,
is_default_command,
colours,
),
);
}
Disk => {
disk_state_map.insert(widget.widget_id, DiskWidgetState::default());
disk_state_map.insert(
widget.widget_id,
DiskTableWidget::new(&app_config_fields, colours),
);
}
Temp => {
temp_state_map.insert(widget.widget_id, TempWidgetState::default());
temp_state_map.insert(
widget.widget_id,
TempWidgetState::new(&app_config_fields, colours),
);
}
Battery => {
battery_state_map
@ -417,37 +460,6 @@ pub fn build_app(
None
};
let app_config_fields = AppConfigFields {
update_rate_in_milliseconds: get_update_rate_in_milliseconds(matches, config)
.context("Update 'rate' in your config file.")?,
temperature_type: get_temperature(matches, config)
.context("Update 'temperature_type' in your config file.")?,
show_average_cpu: get_show_average_cpu(matches, config),
use_dot: get_use_dot(matches, config),
left_legend: get_use_left_legend(matches, config),
use_current_cpu_total: get_use_current_cpu_total(matches, config),
use_basic_mode,
default_time_value,
time_interval: get_time_interval(matches, config)
.context("Update 'time_delta' in your config file.")?,
hide_time: get_hide_time(matches, config),
autohide_time,
use_old_network_legend: get_use_old_network_legend(matches, config),
table_gap: if get_hide_table_gap(matches, config) {
0
} else {
1
},
disable_click: get_disable_click(matches, config),
// no_write: get_no_write(matches, config),
no_write: false,
show_table_scroll_position: get_show_table_scroll_position(matches, config),
is_advanced_kill,
network_scale_type,
network_unit_type,
network_use_binary_prefix,
};
let used_widgets = UsedWidgets {
use_cpu: used_widget_set.get(&Cpu).is_some() || used_widget_set.get(&BasicCpu).is_some(),
use_mem: used_widget_set.get(&Mem).is_some() || used_widget_set.get(&BasicMem).is_some(),
@ -528,8 +540,6 @@ pub fn build_app(
temp_filter,
net_filter,
})
.config(config.clone())
.config_path(config_path)
.build())
}

View file

@ -3,3 +3,9 @@ pub enum DataUnit {
Byte,
Bit,
}
impl Default for DataUnit {
fn default() -> Self {
DataUnit::Bit
}
}

View file

@ -1,5 +1,9 @@
use std::cmp::Ordering;
use concat_string::concat_string;
use tui::text::Text;
use unicode_segmentation::UnicodeSegmentation;
pub const KILO_LIMIT: u64 = 1000;
pub const MEGA_LIMIT: u64 = 1_000_000;
pub const GIGA_LIMIT: u64 = 1_000_000_000;
@ -92,10 +96,24 @@ pub fn get_decimal_prefix(quantity: u64, unit: &str) -> (f64, String) {
}
}
/// Truncates text if it is too long, and adds an ellipsis at the end if needed.
pub fn truncate_text<'a, U: Into<usize>>(content: &str, width: U) -> Text<'a> {
let width = width.into();
let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(content, true).collect();
if graphemes.len() > width && width > 0 {
// Truncate with ellipsis
let first_n = graphemes[..(width - 1)].concat();
Text::raw(concat_string!(first_n, ""))
} else {
Text::raw(content.to_string())
}
}
#[inline]
pub fn sort_partial_fn<T: std::cmp::PartialOrd>(is_reverse: bool) -> fn(T, T) -> Ordering {
if is_reverse {
partial_ordering_rev
pub fn sort_partial_fn<T: std::cmp::PartialOrd>(is_descending: bool) -> fn(T, T) -> Ordering {
if is_descending {
partial_ordering_desc
} else {
partial_ordering
}
@ -113,7 +131,7 @@ pub fn partial_ordering<T: std::cmp::PartialOrd>(a: T, b: T) -> Ordering {
/// This is simply a wrapper function around [`partial_ordering`] that reverses
/// the result.
#[inline]
pub fn partial_ordering_rev<T: std::cmp::PartialOrd>(a: T, b: T) -> Ordering {
pub fn partial_ordering_desc<T: std::cmp::PartialOrd>(a: T, b: T) -> Ordering {
partial_ordering(a, b).reverse()
}