summaryrefslogtreecommitdiffstats
path: root/src/app/widgets/base
diff options
context:
space:
mode:
authorClementTsang <cjhtsang@uwaterloo.ca>2021-08-15 16:26:13 -0400
committerClementTsang <cjhtsang@uwaterloo.ca>2021-08-23 16:55:32 -0400
commit4f0eb7b7ebbe25c478c0f2b646f5bc645f47dbe2 (patch)
tree38a6a032505e5841ba6d0ad1f16bd18968efd8b5 /src/app/widgets/base
parentfceae8d4426eb5e81a883e5ecb32363c8dc77974 (diff)
refactor: Create basic widget system
Diffstat (limited to 'src/app/widgets/base')
-rw-r--r--src/app/widgets/base/mod.rs13
-rw-r--r--src/app/widgets/base/scrollable.rs194
-rw-r--r--src/app/widgets/base/text_input.rs1
-rw-r--r--src/app/widgets/base/text_table.rs142
-rw-r--r--src/app/widgets/base/time_graph.rs157
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,
+ }
+ }
+}