From 4f0eb7b7ebbe25c478c0f2b646f5bc645f47dbe2 Mon Sep 17 00:00:00 2001 From: ClementTsang Date: Sun, 15 Aug 2021 16:26:13 -0400 Subject: [PATCH] refactor: Create basic widget system --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app.rs | 1 + src/app/event.rs | 94 ++++++++++++++ src/app/widgets/base/mod.rs | 13 ++ src/app/widgets/base/scrollable.rs | 194 +++++++++++++++++++++++++++++ src/app/widgets/base/text_input.rs | 1 + src/app/widgets/base/text_table.rs | 142 +++++++++++++++++++++ src/app/widgets/base/time_graph.rs | 157 +++++++++++++++++++++++ src/app/widgets/mod.rs | 39 +++++- src/bin/main.rs | 2 +- src/lib.rs | 7 +- 12 files changed, 643 insertions(+), 11 deletions(-) create mode 100644 src/app/event.rs create mode 100644 src/app/widgets/base/mod.rs create mode 100644 src/app/widgets/base/scrollable.rs create mode 100644 src/app/widgets/base/text_input.rs create mode 100644 src/app/widgets/base/text_table.rs create mode 100644 src/app/widgets/base/time_graph.rs diff --git a/Cargo.lock b/Cargo.lock index 990dd119..63998907 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,7 +231,7 @@ dependencies = [ [[package]] name = "bottom" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 9a41e0d2..eedb7c45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bottom" -version = "0.6.3" +version = "0.6.4" authors = ["Clement Tsang "] edition = "2018" repository = "https://github.com/ClementTsang/bottom" diff --git a/src/app.rs b/src/app.rs index be767586..8cd9c019 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,6 @@ pub mod data_farmer; pub mod data_harvester; +pub mod event; pub mod filter; pub mod layout_manager; mod process_killer; diff --git a/src/app/event.rs b/src/app/event.rs new file mode 100644 index 00000000..33806673 --- /dev/null +++ b/src/app/event.rs @@ -0,0 +1,94 @@ +use std::time::{Duration, Instant}; + +pub enum EventResult { + Quit, + Redraw, + Continue, +} + +enum MultiKeyState { + Idle, + Waiting { + trigger_instant: Instant, + checked_index: usize, + }, +} + +/// The possible outcomes of calling [`MultiKey::input`] on a [`MultiKey`]. +pub enum MultiKeyResult { + /// Returned when a character was *accepted*, but has not completed the sequence required. + Accepted, + + /// Returned when a character is accepted and completes the sequence. + Completed, + + /// Returned if a character breaks the sequence or if it has already timed out. + Rejected, +} + +/// A struct useful for managing multi-key keybinds. +pub struct MultiKey { + state: MultiKeyState, + pattern: Vec, + timeout: Duration, +} + +impl MultiKey { + pub fn register(pattern: Vec, timeout: Duration) -> Self { + Self { + state: MultiKeyState::Idle, + pattern, + timeout, + } + } + + pub fn reset(&mut self) { + self.state = MultiKeyState::Idle; + } + + pub fn input(&mut self, c: char) -> MultiKeyResult { + match &mut self.state { + MultiKeyState::Idle => { + if let Some(first) = self.pattern.first() { + if *first == c { + self.state = MultiKeyState::Waiting { + trigger_instant: Instant::now(), + checked_index: 0, + }; + + return MultiKeyResult::Accepted; + } + } + + MultiKeyResult::Rejected + } + MultiKeyState::Waiting { + trigger_instant, + checked_index, + } => { + if trigger_instant.elapsed() > self.timeout { + // Just reset and recursively call (putting it into Idle). + self.reset(); + self.input(c) + } else if let Some(next) = self.pattern.get(*checked_index + 1) { + if *next == c { + *checked_index += 1; + + if *checked_index == self.pattern.len() - 1 { + self.reset(); + MultiKeyResult::Completed + } else { + MultiKeyResult::Accepted + } + } else { + self.reset(); + MultiKeyResult::Rejected + } + } else { + self.reset(); + MultiKeyResult::Rejected + } + } + } + } +} diff --git a/src/app/widgets/base/mod.rs b/src/app/widgets/base/mod.rs new file mode 100644 index 00000000..2a833b77 --- /dev/null +++ b/src/app/widgets/base/mod.rs @@ -0,0 +1,13 @@ +//! A collection of basic widgets. + +pub mod text_table; +pub use text_table::TextTable; + +pub mod time_graph; +pub use time_graph::TimeGraph; + +pub mod scrollable; +pub use scrollable::Scrollable; + +pub mod text_input; +pub use text_input::TextInput; diff --git a/src/app/widgets/base/scrollable.rs b/src/app/widgets/base/scrollable.rs new file mode 100644 index 00000000..b3e68a6c --- /dev/null +++ b/src/app/widgets/base/scrollable.rs @@ -0,0 +1,194 @@ +use std::time::Duration; + +use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent}; +use tui::widgets::TableState; + +use crate::app::{ + event::{EventResult, MultiKey, MultiKeyResult}, + Widget, +}; + +pub enum ScrollDirection { + Up, + Down, +} + +/// A "scrollable" [`Widget`] component. Intended for use as part of another [`Widget]]. +pub struct Scrollable { + current_index: usize, + previous_index: usize, + scroll_direction: ScrollDirection, + num_items: usize, + + tui_state: TableState, + gg_manager: MultiKey, +} + +impl Scrollable { + /// Creates a new [`Scrollable`]. + pub fn new(num_items: usize) -> Self { + Self { + current_index: 0, + previous_index: 0, + scroll_direction: ScrollDirection::Down, + num_items, + tui_state: TableState::default(), + gg_manager: MultiKey::register(vec!['g', 'g'], Duration::from_millis(400)), + } + } + + /// Creates a new [`Scrollable`]. Note this will set the associated [`TableState`] to select the first entry. + pub fn new_selected(num_items: usize) -> Self { + let mut scrollable = Scrollable::new(num_items); + scrollable.tui_state.select(Some(0)); + + scrollable + } + + pub fn index(&self) -> usize { + self.current_index + } + + /// Update the index with this! This will automatically update the previous index and scroll direction! + fn update_index(&mut self, new_index: usize) { + use std::cmp::Ordering; + + match new_index.cmp(&self.current_index) { + Ordering::Greater => { + self.previous_index = self.current_index; + self.current_index = new_index; + self.scroll_direction = ScrollDirection::Down; + } + Ordering::Less => { + self.previous_index = self.current_index; + self.current_index = new_index; + self.scroll_direction = ScrollDirection::Up; + } + + Ordering::Equal => {} + } + } + + fn skip_to_first(&mut self) -> EventResult { + if self.current_index != 0 { + self.update_index(0); + + EventResult::Redraw + } else { + EventResult::Continue + } + } + + fn skip_to_last(&mut self) -> EventResult { + let last_index = self.num_items - 1; + if self.current_index != last_index { + self.update_index(last_index); + + EventResult::Redraw + } else { + EventResult::Continue + } + } + + /// Moves *downward* by *incrementing* the current index. + fn move_down(&mut self, change_by: usize) -> EventResult { + let new_index = self.current_index + change_by; + if new_index >= self.num_items { + let last_index = self.num_items - 1; + if self.current_index != last_index { + self.update_index(last_index); + + EventResult::Redraw + } else { + EventResult::Continue + } + } else { + self.update_index(new_index); + EventResult::Redraw + } + } + + /// Moves *upward* by *decrementing* the current index. + fn move_up(&mut self, change_by: usize) -> EventResult { + let new_index = self.current_index.saturating_sub(change_by); + if new_index == 0 { + if self.current_index != 0 { + self.update_index(0); + + EventResult::Redraw + } else { + EventResult::Continue + } + } else { + self.update_index(new_index); + EventResult::Redraw + } + } +} + +impl Widget for Scrollable { + type UpdateState = usize; + + fn handle_key_event(&mut self, event: KeyEvent) -> EventResult { + use crossterm::event::KeyCode::{Char, Down, Up}; + + if event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::SHIFT { + match event.code { + Down if event.modifiers == KeyModifiers::NONE => self.move_down(1), + Up if event.modifiers == KeyModifiers::NONE => self.move_up(1), + Char('j') => self.move_down(1), + Char('k') => self.move_up(1), + Char('g') => match self.gg_manager.input('g') { + MultiKeyResult::Completed => self.skip_to_first(), + MultiKeyResult::Accepted => EventResult::Continue, + MultiKeyResult::Rejected => EventResult::Continue, + }, + Char('G') => self.skip_to_last(), + _ => EventResult::Continue, + } + } else { + EventResult::Continue + } + } + + fn handle_mouse_event(&mut self, event: MouseEvent, _x: u16, y: u16) -> EventResult { + match event.kind { + crossterm::event::MouseEventKind::Down(MouseButton::Left) => { + // This requires a bit of fancy calculation. The main trick is remembering that + // we are using a *visual* index here - not what is the actual index! Luckily, we keep track of that + // inside our linked copy of TableState! + // + // Note that y is assumed to be *relative*; + // we assume that y starts at where the list starts (and there are no gaps or whatever). + + if let Some(selected) = self.tui_state.selected() { + let y = y as usize; + if y > selected { + let offset = y - selected; + return self.move_down(offset); + } else { + let offset = selected - y; + return self.move_up(offset); + } + } + + EventResult::Continue + } + crossterm::event::MouseEventKind::ScrollDown => self.move_down(1), + crossterm::event::MouseEventKind::ScrollUp => self.move_up(1), + _ => EventResult::Continue, + } + } + + fn update(&mut self, new_num_items: usize) { + self.num_items = new_num_items; + + if new_num_items <= self.current_index { + self.current_index = new_num_items - 1; + } + + if new_num_items <= self.previous_index { + self.previous_index = new_num_items - 1; + } + } +} diff --git a/src/app/widgets/base/text_input.rs b/src/app/widgets/base/text_input.rs new file mode 100644 index 00000000..2f9e648e --- /dev/null +++ b/src/app/widgets/base/text_input.rs @@ -0,0 +1 @@ +pub struct TextInput {} diff --git a/src/app/widgets/base/text_table.rs b/src/app/widgets/base/text_table.rs new file mode 100644 index 00000000..885f25b4 --- /dev/null +++ b/src/app/widgets/base/text_table.rs @@ -0,0 +1,142 @@ +use tui::layout::Rect; + +use crate::{ + app::{event::EventResult, Scrollable, Widget}, + constants::TABLE_GAP_HEIGHT_LIMIT, +}; + +struct Column { + name: &'static str, + + // TODO: I would remove these in the future, storing them here feels weird... + desired_column_width: u16, + calculated_column_width: u16, + + x_bounds: (u16, u16), +} + +impl Column {} + +/// The [`Widget::UpdateState`] of a [`TextTable`]. +pub struct TextTableUpdateState { + num_items: Option, + columns: Option>, +} + +/// A sortable, scrollable table with columns. +pub struct TextTable { + /// Controls the scrollable state. + scrollable: Scrollable, + + /// The columns themselves. + columns: Vec, + + /// Whether to show a gap between the column headers and the columns. + show_gap: bool, + + /// The bounding box of the [`TextTable`]. + bounds: Rect, // TODO: I kinda want to remove this... + + /// Which index we're sorting by. + sort_index: usize, +} + +impl TextTable { + pub fn new(num_items: usize, columns: Vec<&'static str>) -> Self { + Self { + scrollable: Scrollable::new(num_items), + columns: columns + .into_iter() + .map(|name| Column { + name, + desired_column_width: 0, + calculated_column_width: 0, + x_bounds: (0, 0), + }) + .collect(), + show_gap: true, + bounds: Rect::default(), + sort_index: 0, + } + } + + pub fn try_show_gap(mut self, show_gap: bool) -> Self { + self.show_gap = show_gap; + self + } + + pub fn sort_index(mut self, sort_index: usize) -> Self { + self.sort_index = sort_index; + self + } + + pub fn update_bounds(&mut self, new_bounds: Rect) { + self.bounds = new_bounds; + } + + pub fn update_calculated_column_bounds(&mut self, calculated_bounds: &[u16]) { + self.columns + .iter_mut() + .zip(calculated_bounds.iter()) + .for_each(|(column, bound)| column.calculated_column_width = *bound); + } + + pub fn desired_column_bounds(&self) -> Vec { + self.columns + .iter() + .map(|column| column.desired_column_width) + .collect() + } + + pub fn column_names(&self) -> Vec<&'static str> { + self.columns.iter().map(|column| column.name).collect() + } + + fn is_drawing_gap(&self) -> bool { + if !self.show_gap { + false + } else { + self.bounds.height >= TABLE_GAP_HEIGHT_LIMIT + } + } +} + +impl Widget for TextTable { + type UpdateState = TextTableUpdateState; + + fn handle_key_event(&mut self, event: crossterm::event::KeyEvent) -> EventResult { + self.scrollable.handle_key_event(event) + } + + fn handle_mouse_event( + &mut self, event: crossterm::event::MouseEvent, x: u16, y: u16, + ) -> EventResult { + if y == 0 { + for (index, column) in self.columns.iter().enumerate() { + let (start, end) = column.x_bounds; + if start >= x && end <= y { + self.sort_index = index; + } + } + + EventResult::Continue + } else if self.is_drawing_gap() { + self.scrollable.handle_mouse_event(event, x, y - 1) + } else { + self.scrollable.handle_mouse_event(event, x, y - 2) + } + } + + fn update(&mut self, update_state: Self::UpdateState) { + if let Some(num_items) = update_state.num_items { + self.scrollable.update(num_items); + } + + if let Some(columns) = update_state.columns { + self.columns = columns; + if self.columns.len() <= self.sort_index { + self.sort_index = self.columns.len() - 1; + } + } + } +} diff --git a/src/app/widgets/base/time_graph.rs b/src/app/widgets/base/time_graph.rs new file mode 100644 index 00000000..981aca15 --- /dev/null +++ b/src/app/widgets/base/time_graph.rs @@ -0,0 +1,157 @@ +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent}; + +use crate::app::{event::EventResult, Widget}; + +pub enum AutohideTimerState { + Hidden, + Running(Instant), +} + +pub enum AutohideTimer { + Disabled, + Enabled { + state: AutohideTimerState, + show_duration: Duration, + }, +} + +impl AutohideTimer { + fn trigger_display_timer(&mut self) { + match self { + AutohideTimer::Disabled => todo!(), + AutohideTimer::Enabled { + state, + show_duration: _, + } => { + *state = AutohideTimerState::Running(Instant::now()); + } + } + } + + pub fn update_display_timer(&mut self) { + match self { + AutohideTimer::Disabled => {} + AutohideTimer::Enabled { + state, + show_duration, + } => match state { + AutohideTimerState::Hidden => {} + AutohideTimerState::Running(trigger_instant) => { + if trigger_instant.elapsed() > *show_duration { + *state = AutohideTimerState::Hidden; + } + } + }, + } + } +} + +/// A graph widget with controllable time ranges along the x-axis. +pub struct TimeGraph { + current_display_time: u64, + autohide_timer: AutohideTimer, + + default_time_value: u64, + + min_duration: u64, + max_duration: u64, + time_interval: u64, +} + +impl TimeGraph { + pub fn new( + start_value: u64, autohide_timer: AutohideTimer, min_duration: u64, max_duration: u64, + time_interval: u64, + ) -> Self { + Self { + current_display_time: start_value, + autohide_timer, + default_time_value: start_value, + min_duration, + max_duration, + time_interval, + } + } + + fn handle_char(&mut self, c: char) -> EventResult { + match c { + '-' => self.zoom_out(), + '+' => self.zoom_in(), + '=' => self.reset_zoom(), + _ => EventResult::Continue, + } + } + + fn zoom_in(&mut self) -> EventResult { + let new_time = self.current_display_time.saturating_sub(self.time_interval); + + if new_time >= self.min_duration { + self.current_display_time = new_time; + self.autohide_timer.trigger_display_timer(); + + EventResult::Redraw + } else if new_time != self.min_duration { + self.current_display_time = self.min_duration; + self.autohide_timer.trigger_display_timer(); + + EventResult::Redraw + } else { + EventResult::Continue + } + } + + fn zoom_out(&mut self) -> EventResult { + let new_time = self.current_display_time + self.time_interval; + + if new_time <= self.max_duration { + self.current_display_time = new_time; + self.autohide_timer.trigger_display_timer(); + + EventResult::Redraw + } else if new_time != self.max_duration { + self.current_display_time = self.max_duration; + self.autohide_timer.trigger_display_timer(); + + EventResult::Redraw + } else { + EventResult::Continue + } + } + + fn reset_zoom(&mut self) -> EventResult { + if self.current_display_time == self.default_time_value { + EventResult::Continue + } else { + self.current_display_time = self.default_time_value; + self.autohide_timer.trigger_display_timer(); + EventResult::Redraw + } + } +} + +impl Widget for TimeGraph { + type UpdateState = (); + + fn handle_key_event(&mut self, event: KeyEvent) -> EventResult { + use crossterm::event::KeyCode::Char; + + if event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::SHIFT { + match event.code { + Char(c) => self.handle_char(c), + _ => EventResult::Continue, + } + } else { + EventResult::Continue + } + } + + fn handle_mouse_event(&mut self, event: MouseEvent, _x: u16, _y: u16) -> EventResult { + match event.kind { + crossterm::event::MouseEventKind::ScrollDown => self.zoom_out(), + crossterm::event::MouseEventKind::ScrollUp => self.zoom_in(), + _ => EventResult::Continue, + } + } +} diff --git a/src/app/widgets/mod.rs b/src/app/widgets/mod.rs index a7b735ac..ae675df3 100644 --- a/src/app/widgets/mod.rs +++ b/src/app/widgets/mod.rs @@ -1,8 +1,15 @@ use std::time::Instant; -use tui::widgets::TableState; +use crossterm::event::{KeyEvent, MouseEvent}; +use tui::{layout::Rect, widgets::TableState}; -use crate::{app::layout_manager::BottomWidgetType, constants}; +use crate::{ + app::{event::EventResult, layout_manager::BottomWidgetType}, + constants, +}; + +pub mod base; +pub use base::*; pub mod process; pub use process::*; @@ -25,6 +32,34 @@ pub use self::battery::*; pub mod temp; pub use temp::*; +#[allow(unused_variables)] +pub trait Widget { + type UpdateState; + + /// Handles a [`KeyEvent`]. + /// + /// Defaults to returning [`EventResult::Continue`], indicating nothing should be done. + fn handle_key_event(&mut self, event: KeyEvent) -> EventResult { + EventResult::Continue + } + + /// Handles a [`MouseEvent`]. `x` and `y` represent *relative* mouse coordinates to the [`Widget`] - those should + /// be used as opposed to the coordinates in the `event` unless you need absolute coordinates for some reason! + /// + /// Defaults to returning [`EventResult::Continue`], indicating nothing should be done. + fn handle_mouse_event(&mut self, event: MouseEvent, x: u16, y: u16) -> EventResult { + EventResult::Continue + } + + /// Updates a [`Widget`]. Defaults to doing nothing. + fn update(&mut self, update_state: Self::UpdateState) {} + + /// Returns a [`Widget`]'s bounding box, if possible. Defaults to returning [`None`]. + fn bounding_box(&self) -> Option { + None + } +} + #[derive(Debug)] pub enum ScrollDirection { // UP means scrolling up --- this usually DECREMENTS diff --git a/src/bin/main.rs b/src/bin/main.rs index d6d81ad6..ede90339 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -4,7 +4,7 @@ #[macro_use] extern crate log; -use bottom::{canvas, constants::*, data_conversion::*, options::*, *}; +use bottom::{app::event::EventResult, canvas, constants::*, data_conversion::*, options::*, *}; use std::{ boxed::Box, diff --git a/src/lib.rs b/src/lib.rs index d3a1ba35..e9b53806 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ use crossterm::{ use app::{ data_harvester::{self, processes::ProcessSorting}, + event::EventResult, layout_manager::{UsedWidgets, WidgetDirection}, AppState, }; @@ -74,12 +75,6 @@ pub enum ThreadControlEvent { UpdateUpdateTime(u64), } -pub enum EventResult { - Quit, - Redraw, - Continue, -} - pub fn handle_mouse_event(event: MouseEvent, app: &mut AppState) -> EventResult { match event.kind { MouseEventKind::Down(MouseButton::Left) => {