diff options
author | ClementTsang <cjhtsang@uwaterloo.ca> | 2021-08-30 23:48:11 -0400 |
---|---|---|
committer | ClementTsang <cjhtsang@uwaterloo.ca> | 2021-09-05 19:09:11 -0400 |
commit | b1889b09344fea9eef98e5c2b9ce3b269dc8234b (patch) | |
tree | 2f0650e02a5256249fededd249603f5dda539aea /src/app/widgets/base | |
parent | 27736b7fc048a9fbc8621d98ee99a4eb521a8c24 (diff) |
refactor: add text input
Diffstat (limited to 'src/app/widgets/base')
-rw-r--r-- | src/app/widgets/base/scrollable.rs | 27 | ||||
-rw-r--r-- | src/app/widgets/base/sort_menu.rs | 2 | ||||
-rw-r--r-- | src/app/widgets/base/sort_text_table.rs | 10 | ||||
-rw-r--r-- | src/app/widgets/base/text_input.rs | 344 | ||||
-rw-r--r-- | src/app/widgets/base/text_table.rs | 25 | ||||
-rw-r--r-- | src/app/widgets/base/time_graph.rs | 2 |
6 files changed, 321 insertions, 89 deletions
diff --git a/src/app/widgets/base/scrollable.rs b/src/app/widgets/base/scrollable.rs index dc55a717..2d708ece 100644 --- a/src/app/widgets/base/scrollable.rs +++ b/src/app/widgets/base/scrollable.rs @@ -1,3 +1,5 @@ +use std::cmp::Ordering; + use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use tui::{layout::Rect, widgets::TableState}; @@ -83,7 +85,6 @@ impl Scrollable { } else if self.current_index >= num_visible_rows { // Else if the current position past the last element visible in the list, omit // until we can see that element. The +1 is of how indexes start at 0. - self.window_index.index = self.current_index - num_visible_rows + 1; self.window_index.index } else { @@ -96,6 +97,8 @@ impl Scrollable { // If it's past the first element, then show from that element downwards self.window_index.index = self.current_index; } else if self.current_index >= self.window_index.index + num_visible_rows { + // Else, if the current index is off screen (sometimes caused by a sudden size change), + // just put it so that the selected index is the last entry, self.window_index.index = self.current_index - num_visible_rows + 1; } // Else, don't change what our start position is from whatever it is set to! @@ -111,8 +114,6 @@ impl Scrollable { /// Update the index with this! This will automatically update the scroll direction as well! pub fn set_index(&mut self, new_index: usize) { - use std::cmp::Ordering; - match new_index.cmp(&self.current_index) { Ordering::Greater => { self.current_index = new_index; @@ -156,9 +157,7 @@ impl Scrollable { } let new_index = self.current_index + change_by; - if new_index >= self.num_items { - WidgetEventResult::NoRedraw - } else if self.current_index == new_index { + if new_index >= self.num_items || self.current_index == new_index { WidgetEventResult::NoRedraw } else { self.set_index(new_index); @@ -234,12 +233,16 @@ impl Component for Scrollable { let y = usize::from(event.row - self.bounds.top()); if let Some(selected) = self.tui_state.selected() { - if y > selected { - let offset = y - selected; - return self.move_down(offset); - } else if y < selected { - let offset = selected - y; - return self.move_up(offset); + match y.cmp(&selected) { + Ordering::Less => { + let offset = selected - y; + return self.move_up(offset); + } + Ordering::Equal => {} + Ordering::Greater => { + let offset = y - selected; + return self.move_down(offset); + } } } } diff --git a/src/app/widgets/base/sort_menu.rs b/src/app/widgets/base/sort_menu.rs index fc85d0bb..3265cb59 100644 --- a/src/app/widgets/base/sort_menu.rs +++ b/src/app/widgets/base/sort_menu.rs @@ -49,7 +49,7 @@ impl SortMenu { let data = columns .iter() - .map(|c| vec![(c.original_name().clone().into(), None, None)]) + .map(|c| vec![(c.original_name().clone(), None, None)]) .collect::<Vec<_>>(); self.table diff --git a/src/app/widgets/base/sort_text_table.rs b/src/app/widgets/base/sort_text_table.rs index e8f0ff18..2b59f948 100644 --- a/src/app/widgets/base/sort_text_table.rs +++ b/src/app/widgets/base/sort_text_table.rs @@ -11,7 +11,7 @@ use crate::{ canvas::Painter, }; -use super::text_table::{DesiredColumnWidth, SimpleColumn, TableColumn, TextTableData}; +use super::text_table::{DesiredColumnWidth, SimpleColumn, TableColumn, TextTableDataRef}; fn get_shortcut_name(e: &KeyEvent) -> String { let modifier = if e.modifiers.is_empty() { @@ -48,7 +48,7 @@ fn get_shortcut_name(e: &KeyEvent) -> String { KeyCode::Esc => "Esc".into(), }; - format!("({}{})", modifier, key).into() + format!("({}{})", modifier, key) } #[derive(Copy, Clone, Debug)] @@ -346,8 +346,8 @@ where /// Note if the number of columns don't match in the [`SortableTextTable`] and data, /// it will only create as many columns as it can grab data from both sources from. pub fn draw_tui_table<B: Backend>( - &mut self, painter: &Painter, f: &mut Frame<'_, B>, data: &TextTableData, block: Block<'_>, - block_area: Rect, show_selected_entry: bool, + &mut self, painter: &Painter, f: &mut Frame<'_, B>, data: &TextTableDataRef, + block: Block<'_>, block_area: Rect, show_selected_entry: bool, ) { self.table .draw_tui_table(painter, f, data, block, block_area, show_selected_entry); @@ -360,7 +360,7 @@ where { fn handle_key_event(&mut self, event: KeyEvent) -> WidgetEventResult { for (index, column) in self.table.columns.iter().enumerate() { - if let &Some((shortcut, _)) = column.shortcut() { + if let Some((shortcut, _)) = *column.shortcut() { if shortcut == event { self.set_sort_index(index); return WidgetEventResult::Signal(ReturnSignal::Update); diff --git a/src/app/widgets/base/text_input.rs b/src/app/widgets/base/text_input.rs index 597dff6e..a6119c79 100644 --- a/src/app/widgets/base/text_input.rs +++ b/src/app/widgets/base/text_input.rs @@ -1,47 +1,87 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; -use tui::layout::Rect; +use itertools::Itertools; +use tui::{ + backend::Backend, + layout::{Alignment, Rect}, + text::{Span, Spans}, + widgets::Paragraph, + Frame, +}; +use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; +use unicode_width::UnicodeWidthStr; -use crate::app::{ - event::WidgetEventResult::{self}, - Component, +use crate::{ + app::{ + event::{ + ReturnSignal, + WidgetEventResult::{self}, + }, + Component, + }, + canvas::Painter, }; +enum CursorDirection { + Left, + Right, +} + +/// We save the previous window index for future reference, but we must invalidate if the area changes. #[derive(Default)] +struct WindowIndex { + start_index: usize, + cached_area: Rect, +} + /// A single-line component for taking text inputs. pub struct TextInput { text: String, - cursor_index: usize, bounds: Rect, - border_bounds: Rect, + cursor: GraphemeCursor, + + cursor_direction: CursorDirection, + window_index: WindowIndex, } -impl TextInput { - /// Creates a new [`TextInput`]. - pub fn new() -> Self { +impl Default for TextInput { + fn default() -> Self { Self { - ..Default::default() + text: Default::default(), + bounds: Default::default(), + cursor: GraphemeCursor::new(0, 0, true), + cursor_direction: CursorDirection::Right, + window_index: Default::default(), } } +} - fn set_cursor(&mut self, new_cursor_index: usize) -> WidgetEventResult { - if self.cursor_index == new_cursor_index { - WidgetEventResult::NoRedraw - } else { - self.cursor_index = new_cursor_index; - WidgetEventResult::Redraw - } +impl TextInput { + /// Returns a reference to the current query. + pub fn query(&self) -> &str { + &self.text } - fn move_back(&mut self, amount_to_subtract: usize) -> WidgetEventResult { - self.set_cursor(self.cursor_index.saturating_sub(amount_to_subtract)) + fn move_back(&mut self) -> usize { + let current_position = self.cursor.cur_cursor(); + if let Ok(Some(new_position)) = self.cursor.prev_boundary(&self.text[..current_position], 0) + { + self.cursor_direction = CursorDirection::Left; + new_position + } else { + current_position + } } - fn move_forward(&mut self, amount_to_add: usize) -> WidgetEventResult { - let new_cursor = self.cursor_index + amount_to_add; - if new_cursor >= self.text.len() { - self.set_cursor(self.text.len() - 1) + fn move_forward(&mut self) -> usize { + let current_position = self.cursor.cur_cursor(); + if let Ok(Some(new_position)) = self + .cursor + .next_boundary(&self.text[current_position..], current_position) + { + self.cursor_direction = CursorDirection::Right; + new_position } else { - self.set_cursor(new_cursor) + current_position } } @@ -50,38 +90,199 @@ impl TextInput { WidgetEventResult::NoRedraw } else { self.text = String::default(); - self.cursor_index = 0; - WidgetEventResult::Redraw + self.cursor = GraphemeCursor::new(0, 0, true); + self.window_index = Default::default(); + self.cursor_direction = CursorDirection::Left; + WidgetEventResult::Signal(ReturnSignal::Update) } } fn move_word_forward(&mut self) -> WidgetEventResult { - // TODO: Implement this - WidgetEventResult::NoRedraw + let current_index = self.cursor.cur_cursor(); + + for (index, _word) in self.text[current_index..].unicode_word_indices() { + if index > current_index { + self.cursor.set_cursor(index); + self.cursor_direction = CursorDirection::Right; + return WidgetEventResult::Redraw; + } + } + + self.cursor.set_cursor(self.text.len()); + WidgetEventResult::Redraw } fn move_word_back(&mut self) -> WidgetEventResult { - // TODO: Implement this + let current_index = self.cursor.cur_cursor(); + + for (index, _word) in self.text[..current_index].unicode_word_indices().rev() { + if index < current_index { + self.cursor.set_cursor(index); + self.cursor_direction = CursorDirection::Left; + return WidgetEventResult::Redraw; + } + } + WidgetEventResult::NoRedraw } - fn clear_previous_word(&mut self) -> WidgetEventResult { - // TODO: Implement this - WidgetEventResult::NoRedraw + fn clear_word_from_cursor(&mut self) -> WidgetEventResult { + // Fairly simple logic - create the word index iterator, skip the word that intersects with the current + // cursor location, draw the rest, update the string. + let current_index = self.cursor.cur_cursor(); + let mut start_delete_index = 0; + let mut saw_non_whitespace = false; + for (index, word) in self.text[..current_index].split_word_bound_indices().rev() { + if word.trim().is_empty() { + if saw_non_whitespace { + // It's whitespace! Stop! + break; + } + } else { + saw_non_whitespace = true; + start_delete_index = index; + } + } + + if start_delete_index == current_index { + WidgetEventResult::NoRedraw + } else { + self.text.drain(start_delete_index..current_index); + self.cursor = GraphemeCursor::new(start_delete_index, self.text.len(), true); + self.cursor_direction = CursorDirection::Left; + WidgetEventResult::Signal(ReturnSignal::Update) + } } fn clear_previous_grapheme(&mut self) -> WidgetEventResult { - // TODO: Implement this - WidgetEventResult::NoRedraw + let current_index = self.cursor.cur_cursor(); + + if current_index > 0 { + let new_index = self.move_back(); + self.text.drain(new_index..current_index); + + self.cursor = GraphemeCursor::new(new_index, self.text.len(), true); + self.cursor_direction = CursorDirection::Left; + + WidgetEventResult::Signal(ReturnSignal::Update) + } else { + WidgetEventResult::NoRedraw + } } - pub fn update(&mut self, new_text: String) { - self.text = new_text; + fn clear_current_grapheme(&mut self) -> WidgetEventResult { + let current_index = self.cursor.cur_cursor(); + + if current_index < self.text.len() { + let current_index_bound = self.move_forward(); + self.text.drain(current_index..current_index_bound); - if self.cursor_index >= self.text.len() { - self.cursor_index = self.text.len() - 1; + self.cursor = GraphemeCursor::new(current_index, self.text.len(), true); + self.cursor_direction = CursorDirection::Left; + + WidgetEventResult::Signal(ReturnSignal::Update) + } else { + WidgetEventResult::NoRedraw } } + + fn insert_character(&mut self, c: char) -> WidgetEventResult { + let current_index = self.cursor.cur_cursor(); + self.text.insert(current_index, c); + self.cursor = GraphemeCursor::new(current_index, self.text.len(), true); + self.move_forward(); + + WidgetEventResult::Signal(ReturnSignal::Update) + } + + /// Updates the window indexes and returns the start index. + pub fn update_window_index(&mut self, num_visible_columns: usize) -> usize { + if self.window_index.cached_area != self.bounds { + self.window_index.start_index = 0; + self.window_index.cached_area = self.bounds; + } + + let current_index = self.cursor.cur_cursor(); + + match self.cursor_direction { + CursorDirection::Right => { + if current_index < self.window_index.start_index + num_visible_columns { + self.window_index.start_index + } else if current_index >= num_visible_columns { + self.window_index.start_index = current_index - num_visible_columns + 1; + self.window_index.start_index + } else { + 0 + } + } + CursorDirection::Left => { + if current_index <= self.window_index.start_index { + self.window_index.start_index = current_index; + } else if current_index >= self.window_index.start_index + num_visible_columns { + self.window_index.start_index = current_index - num_visible_columns + 1; + } + self.window_index.start_index + } + } + } + + /// Draws the [`TextInput`] on screen. + pub fn draw_text_input<B: Backend>( + &mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool, + ) { + self.set_bounds(area); + + const SEARCH_PROMPT: &str = "> "; + let prompt = if area.width > 5 { SEARCH_PROMPT } else { "" }; + + let num_visible_columns = area.width as usize - prompt.len(); + let start_position = self.update_window_index(num_visible_columns); + let cursor_start = self.cursor.cur_cursor(); + + let mut graphemes = self.text.grapheme_indices(true).peekable(); + let mut current_grapheme_posn = 0; + + graphemes + .peeking_take_while(|(index, _)| *index < start_position) + .for_each(|(_, s)| { + current_grapheme_posn += UnicodeWidthStr::width(s); + }); + + let before_cursor = graphemes + .peeking_take_while(|(index, _)| *index < cursor_start) + .map(|(_, grapheme)| grapheme) + .collect::<String>(); + + let cursor = graphemes + .next() + .map(|(_, grapheme)| grapheme) + .unwrap_or(" "); + + let after_cursor = graphemes.map(|(_, grapheme)| grapheme).collect::<String>(); + + // FIXME: This is NOT done! This is an incomplete (but kinda working) implementation, for now. + + let search_text = vec![Spans::from(vec![ + Span::styled( + prompt, + if selected { + painter.colours.highlighted_border_style + } else { + painter.colours.text_style + }, + ), + Span::styled(before_cursor, painter.colours.text_style), + Span::styled(cursor, painter.colours.currently_selected_text_style), + Span::styled(after_cursor, painter.colours.text_style), + ])]; + + f.render_widget( + Paragraph::new(search_text) + .style(painter.colours.text_style) + .alignment(Alignment::Left), + area, + ); + } } impl Component for TextInput { @@ -93,35 +294,59 @@ impl Component for TextInput { self.bounds = new_bounds; } - fn border_bounds(&self) -> Rect { - self.border_bounds - } - - fn set_border_bounds(&mut self, new_bounds: Rect) { - self.border_bounds = new_bounds; - } - fn handle_key_event(&mut self, event: KeyEvent) -> WidgetEventResult { if event.modifiers.is_empty() { match event.code { - KeyCode::Left => self.move_back(1), - KeyCode::Right => self.move_forward(1), + KeyCode::Left => { + let original_cursor = self.cursor.cur_cursor(); + if self.move_back() == original_cursor { + WidgetEventResult::NoRedraw + } else { + WidgetEventResult::Redraw + } + } + KeyCode::Right => { + let original_cursor = self.cursor.cur_cursor(); + if self.move_forward() == original_cursor { + WidgetEventResult::NoRedraw + } else { + WidgetEventResult::Redraw + } + } KeyCode::Backspace => self.clear_previous_grapheme(), + KeyCode::Delete => self.clear_current_grapheme(), + KeyCode::Char(c) => self.insert_character(c), _ => WidgetEventResult::NoRedraw, } } else if let KeyModifiers::CONTROL = event.modifiers { match event.code { - KeyCode::Char('a') => self.set_cursor(0), - KeyCode::Char('e') => self.set_cursor(self.text.len()), + KeyCode::Char('a') => { + let prev_index = self.cursor.cur_cursor(); + self.cursor.set_cursor(0); + if self.cursor.cur_cursor() == prev_index { + WidgetEventResult::NoRedraw + } else { + WidgetEventResult::Redraw + } + } + KeyCode::Char('e') => { + let prev_index = self.cursor.cur_cursor(); + self.cursor.set_cursor(self.text.len()); + if self.cursor.cur_cursor() == prev_index { + WidgetEventResult::NoRedraw + } else { + WidgetEventResult::Redraw + } + } KeyCode::Char('u') => self.clear_text(), - KeyCode::Char('w') => self.clear_previous_word(), + KeyCode::Char('w') => self.clear_word_from_cursor(), KeyCode::Char('h') => self.clear_previous_grapheme(), _ => WidgetEventResult::NoRedraw, } } else if let KeyModifiers::ALT = event.modifiers { match event.code { - KeyCode::Char('b') => self.move_word_forward(), - KeyCode::Char('f') => self.move_word_back(), + KeyCode::Char('b') => self.move_word_back(), + KeyCode::Char('f') => self.move_word_forward(), _ => WidgetEventResult::NoRedraw, } } else { @@ -133,15 +358,12 @@ impl Component for TextInput { // We are assuming this is within bounds... let x = event.column; - let widget_x = self.bounds.x; - let new_cursor_index = usize::from(x.saturating_sub(widget_x)); - - if new_cursor_index >= self.text.len() { - self.cursor_index = self.text.len() - 1; + let widget_x = self.bounds.x + 2; + if x >= widget_x { + // TODO: do this + WidgetEventResult::Redraw } else { - self.cursor_index = new_cursor_index; + WidgetEventResult::NoRedraw } - - WidgetEventResult::Redraw } } diff --git a/src/app/widgets/base/text_table.rs b/src/app/widgets/base/text_table.rs index 3ce56886..44f6d488 100644 --- a/src/app/widgets/base/text_table.rs +++ b/src/app/widgets/base/text_table.rs @@ -40,6 +40,7 @@ pub trait TableColumn { } pub type TextTableData = Vec<Vec<(Cow<'static, str>, Option<Cow<'static, str>>, Option<Style>)>>; +pub type TextTableDataRef = [Vec<(Cow<'static, str>, Option<Cow<'static, str>>, Option<Style>)>]; /// A [`SimpleColumn`] represents some column in a [`TextTable`]. #[derive(Debug)] @@ -199,7 +200,7 @@ where } pub fn get_desired_column_widths( - columns: &[C], data: &TextTableData, + columns: &[C], data: &TextTableDataRef, ) -> Vec<DesiredColumnWidth> { columns .iter() @@ -237,7 +238,7 @@ where .collect::<Vec<_>>() } - fn get_cache(&mut self, area: Rect, data: &TextTableData) -> Vec<u16> { + fn get_cache(&mut self, area: Rect, data: &TextTableDataRef) -> Vec<u16> { fn calculate_column_widths( left_to_right: bool, mut desired_widths: Vec<DesiredColumnWidth>, total_width: u16, ) -> Vec<u16> { @@ -296,9 +297,18 @@ where column_widths } - // If empty, do NOT save the cache! We have to get it again when it updates. + // If empty, get the cached values if they exist; if they don't, do not cache! if data.is_empty() { - vec![0; self.columns.len()] + match &self.cached_column_widths { + CachedColumnWidths::Uncached => { + let desired_widths = TextTable::get_desired_column_widths(&self.columns, data); + calculate_column_widths(self.left_to_right, desired_widths, area.width) + } + CachedColumnWidths::Cached { + cached_area: _, + cached_data, + } => cached_data.clone(), + } } else { let was_cached: bool; let column_widths = match &mut self.cached_column_widths { @@ -351,12 +361,9 @@ where } /// Draws a [`Table`] on screen corresponding to the [`TextTable`]. - /// - /// Note if the number of columns don't match in the [`TextTable`] and data, - /// it will only create as many columns as it can grab data from both sources from. pub fn draw_tui_table<B: Backend>( - &mut self, painter: &Painter, f: &mut Frame<'_, B>, data: &TextTableData, block: Block<'_>, - block_area: Rect, show_selected_entry: bool, + &mut self, painter: &Painter, f: &mut Frame<'_, B>, data: &TextTableDataRef, + block: Block<'_>, block_area: Rect, show_selected_entry: bool, ) { use tui::widgets::Row; diff --git a/src/app/widgets/base/time_graph.rs b/src/app/widgets/base/time_graph.rs index 1bfd4a8b..3c915361 100644 --- a/src/app/widgets/base/time_graph.rs +++ b/src/app/widgets/base/time_graph.rs @@ -257,7 +257,7 @@ impl TimeGraph { .style(painter.colours.graph_style) .labels( y_bound_labels - .into_iter() + .iter() .map(|label| Span::styled(label.clone(), painter.colours.graph_style)) .collect(), ); |