diff options
author | ClementTsang <cjhtsang@uwaterloo.ca> | 2021-08-15 16:26:13 -0400 |
---|---|---|
committer | ClementTsang <cjhtsang@uwaterloo.ca> | 2021-08-23 16:55:32 -0400 |
commit | 4f0eb7b7ebbe25c478c0f2b646f5bc645f47dbe2 (patch) | |
tree | 38a6a032505e5841ba6d0ad1f16bd18968efd8b5 /src/app/widgets/base | |
parent | fceae8d4426eb5e81a883e5ecb32363c8dc77974 (diff) |
refactor: Create basic widget system
Diffstat (limited to 'src/app/widgets/base')
-rw-r--r-- | src/app/widgets/base/mod.rs | 13 | ||||
-rw-r--r-- | src/app/widgets/base/scrollable.rs | 194 | ||||
-rw-r--r-- | src/app/widgets/base/text_input.rs | 1 | ||||
-rw-r--r-- | src/app/widgets/base/text_table.rs | 142 | ||||
-rw-r--r-- | src/app/widgets/base/time_graph.rs | 157 |
5 files changed, 507 insertions, 0 deletions
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<usize>, + columns: Option<Vec<Column>>, +} + +/// A sortable, scrollable table with columns. +pub struct TextTable { + /// Controls the scrollable state. + scrollable: Scrollable, + + /// The columns themselves. + columns: Vec<Column>, + + /// 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<u16> { + 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, + } + } +} |