diff --git a/Cargo.lock b/Cargo.lock index 43472cf97c..fef623b983 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,7 +1173,6 @@ dependencies = [ "struct-patch", "syntect", "tempfile", - "tui-textarea", "two-face", "unicode-segmentation", "unicode-truncate 2.0.1", @@ -3745,17 +3744,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tui-textarea" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" -dependencies = [ - "crossterm", - "ratatui", - "unicode-width 0.2.0", -] - [[package]] name = "two-face" version = "0.4.4" diff --git a/Cargo.toml b/Cargo.toml index 8f07dd34e4..e4900a2087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ parking_lot_core = "0.9" ratatui = { version = "0.29", default-features = false, features = [ 'crossterm', 'serde', + 'unstable-widget-ref', ] } rayon-core = "1.13" ron = "0.12" @@ -77,7 +78,6 @@ syntect = { version = "5.3", default-features = false, features = [ "plist-load", "html", ] } -tui-textarea = "0.7" two-face = { version = "0.4.4", default-features = false } unicode-segmentation = "1.12" unicode-truncate = "2.0" diff --git a/src/components/mod.rs b/src/components/mod.rs index 51322e18ae..c458472d5d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -169,7 +169,7 @@ pub fn command_pump( } } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum ScrollType { Up, Down, diff --git a/src/components/textinput.rs b/src/components/textinput.rs index e67d19eac9..9ae785ca4f 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -1,4 +1,5 @@ use crate::app::Environment; +use crate::components::VerticalScroll; use crate::keys::key_match; use crate::ui::Size; use crate::{ @@ -11,16 +12,19 @@ use crate::{ ui::{self, style::SharedTheme}, }; use anyhow::Result; -use crossterm::event::Event; -use ratatui::widgets::{Block, Borders}; +use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; use ratatui::{ layout::{Alignment, Rect}, - widgets::{Clear, Paragraph}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, Paragraph, WidgetRef}, Frame, }; -use std::cell::Cell; -use std::cell::OnceCell; -use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea}; +use std::borrow::Cow; +use std::cell::{Cell, OnceCell}; +use std::iter::repeat_n; /// #[derive(PartialEq, Eq)] @@ -30,15 +34,419 @@ pub enum InputType { Password, } -#[derive(PartialEq, Eq)] -enum SelectionState { - Selecting, - NotSelecting, - SelectionEndPending, +#[derive(Clone, Copy)] +enum CursorMove { + Top, + Bottom, + Up, + Down, + Back, + Forward, + Home, + End, + PageUp, + PageDown, +} + +#[derive(Default, PartialEq)] +enum Key { + #[default] + Null, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Backspace, + Delete, + Tab, + Char(char), +} + +#[derive(Default)] +struct Input { + key: Key, + ctrl: bool, + alt: bool, +} + +impl From for Input { + /// Convert [`crossterm::event::Event`] into [`Input`]. + fn from(event: Event) -> Self { + match event { + Event::Key(key) => Self::from(key), + _ => Self::default(), + } + } +} + +impl From for Key { + /// Convert [`crossterm::event::KeyCode`] into [`Key`]. + fn from(code: KeyCode) -> Self { + match code { + KeyCode::Char(c) => Self::Char(c), + KeyCode::Backspace => Self::Backspace, + KeyCode::Left => Self::Left, + KeyCode::Right => Self::Right, + KeyCode::Up => Self::Up, + KeyCode::Down => Self::Down, + KeyCode::Tab => Self::Tab, + KeyCode::Delete => Self::Delete, + KeyCode::Home => Self::Home, + KeyCode::End => Self::End, + KeyCode::PageUp => Self::PageUp, + KeyCode::PageDown => Self::PageDown, + _ => Self::Null, + } + } +} + +impl From for Input { + /// Convert [`crossterm::event::KeyEvent`] into [`Input`]. + fn from(key: KeyEvent) -> Self { + if key.kind == KeyEventKind::Release { + // On Windows or when `crossterm::event::PushKeyboardEnhancementFlags` is set, + // key release event can be reported. Ignore it. (rhysd/tui-textarea#14) + return Self::default(); + } + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + let key = Key::from(key.code); + + Self { key, ctrl, alt } + } +} + +struct TextArea<'a> { + lines: Vec, + block: Option>, + style: Style, + /// 0-based (row, column) + cursor: (usize, usize), + cursor_style: Style, + placeholder: String, + placeholder_style: Style, + mask_char: Option, + theme: SharedTheme, + scroll: VerticalScroll, +} + +impl<'a> TextArea<'a> { + fn new(lines: Vec, theme: SharedTheme) -> Self { + let lines = if lines.is_empty() { + vec![String::new()] + } else { + lines + }; + + Self { + lines, + block: None, + style: Style::default(), + cursor: (0, 0), + cursor_style: Style::default() + .add_modifier(Modifier::REVERSED), + placeholder: String::new(), + placeholder_style: Style::default().fg(Color::DarkGray), + mask_char: None, + theme, + scroll: VerticalScroll::new(), + } + } + + #[cfg(test)] + fn cursor(&mut self) -> (usize, usize) { + self.cursor + } + + fn move_cursor(&mut self, cursor_move: CursorMove) { + let (current_row, current_column) = self.cursor; + + match cursor_move { + CursorMove::Top => { + self.cursor = + (0, current_column.min(self.lines[0].len())); + } + CursorMove::Bottom => { + let last_row = self.lines.len() - 1; + + self.cursor = ( + last_row, + current_column.min(self.lines[last_row].len()), + ); + } + CursorMove::Up => { + let new_row = current_row.saturating_sub(1); + + self.cursor = ( + new_row, + current_column.min(self.lines[new_row].len()), + ); + } + CursorMove::Down => { + let new_row = + (current_row + 1).min(self.lines.len() - 1); + + self.cursor = ( + new_row, + current_column.min(self.lines[new_row].len()), + ); + } + CursorMove::Back => { + self.cursor = + (current_row, current_column.saturating_sub(1)); + } + CursorMove::Forward => { + self.cursor = ( + current_row, + (current_column + 1).min( + self.lines[current_row] + .char_indices() + .count(), + ), + ); + } + CursorMove::Home => self.cursor = (current_row, 0), + CursorMove::End => { + self.cursor = ( + current_row, + self.lines[current_row].char_indices().count(), + ); + } + CursorMove::PageUp => { + let new_row = current_row + .saturating_sub(self.scroll.get_visual_height()); + + self.cursor = ( + new_row, + current_column.min(self.lines[new_row].len()), + ); + } + CursorMove::PageDown => { + let new_row = (current_row + + self.scroll.get_visual_height()) + .min(self.lines.len().saturating_sub(1)); + + self.cursor = ( + new_row, + current_column.min(self.lines[new_row].len()), + ); + } + } + } + + fn delete_next_char(&mut self) { + let (current_row, current_column) = self.cursor; + let current_line = &mut self.lines[current_row]; + + if current_column < current_line.len() { + if let Some((offset, _)) = + current_line.char_indices().nth(current_column) + { + current_line.remove(offset); + } + } else if current_row < self.lines.len().saturating_sub(1) { + let next_line = self.lines.remove(current_row + 1); + self.lines[current_row].push_str(&next_line); + } else { + // We're at the end of the input. Do nothing. + } + } + + fn delete_char(&mut self) { + let (current_row, current_column) = self.cursor; + let current_line = &mut self.lines[current_row]; + + if current_column > 0 { + if let Some((offset, _)) = + current_line.char_indices().nth(current_column - 1) + { + current_line.remove(offset); + self.cursor = (current_row, current_column - 1); + } + } else if current_row > 0 { + let current_line = self.lines.remove(current_row); + + let previous_line = &mut self.lines[current_row - 1]; + let previous_line_length = + previous_line.char_indices().count(); + + previous_line.push_str(¤t_line); + self.cursor = (current_row - 1, previous_line_length); + } else { + // We're at (0, 0), there's no characters to be deleted. Do nothing. + } + } + + fn insert_char(&mut self, char: char) { + let (current_row, current_column) = self.cursor; + let current_line = &mut self.lines[current_row]; + + let offset = current_line + .char_indices() + .nth(current_column) + .map_or_else(|| current_line.len(), |(i, _)| i); + + current_line.insert(offset, char); + self.cursor = (current_row, current_column + 1); + } + + fn set_block(&mut self, block: Block<'a>) { + self.block = Some(block); + } + + fn set_style(&mut self, style: Style) { + self.style = style; + } + + fn set_placeholder_text(&mut self, placeholder: String) { + self.placeholder = placeholder; + } + + fn set_placeholder_style(&mut self, placeholder_style: Style) { + self.placeholder_style = placeholder_style; + } + + fn set_mask_char(&mut self, mask_char: char) { + self.mask_char = Some(mask_char); + } } type TextAreaComponent = TextArea<'static>; +// TODO: +// `TextArea` and `TextAreaComponent` likely can be merged. +impl<'a> TextAreaComponent { + fn insert_newline(&mut self) { + let (current_row, current_column) = self.cursor; + let current_line = &self.lines[current_row]; + + let offset = current_line + .char_indices() + .nth(current_column) + .map_or_else(|| current_line.len(), |(i, _)| i); + + let new_line = current_line[offset..].to_string(); + + self.lines.insert(current_row + 1, new_line); + self.lines[current_row].truncate(offset); + self.cursor = (current_row + 1, 0); + } + + fn lines(&'a self) -> &'a [String] { + &self.lines + } + + fn draw_placeholder(&self, f: &mut Frame, rect: Rect) { + let paragraph = Paragraph::new(Text::from(Line::from( + self.placeholder.clone(), + ))) + .style(self.placeholder_style); + + f.render_widget(paragraph, rect); + } + + fn draw_lines(&self, f: &mut Frame, rect: Rect) { + let (current_row, current_column) = self.cursor; + + let top = self.scroll.update( + current_row, + self.lines.len(), + rect.height.into(), + ); + + let lines: Vec<_> = self + .lines + .iter() + .enumerate() + .skip(top) + .map(|(row, line)| { + let line: Cow<'_, str> = self.mask_char.map_or_else( + || line.into(), + |mask_char| { + repeat_n(mask_char, line.chars().count()) + .collect() + }, + ); + + if row == current_row { + if current_column == line.char_indices().count() { + return Line::from(vec![ + Span::from(line.clone()), + Span::styled(" ", self.cursor_style), + ]); + } + + if let Some((offset, _)) = + line.char_indices().nth(current_column) + { + let (before_cursor, cursor) = + line.split_at(offset); + + if let Some((next_offset, _)) = + cursor.char_indices().nth(1) + { + let (cursor, after_cursor) = + cursor.split_at(next_offset); + + return Line::from(vec![ + Span::from(before_cursor.to_string()), + Span::styled( + cursor.to_string(), + self.cursor_style, + ), + Span::from(after_cursor.to_string()), + ]); + } + + return Line::from(vec![ + Span::from(before_cursor.to_string()), + Span::styled( + cursor.to_string(), + self.cursor_style, + ), + ]); + } + } + + Line::from(line.clone()) + }) + .collect(); + let paragraph = + Paragraph::new(Text::from(lines)).style(self.style); + + f.render_widget(paragraph, rect); + } + + fn is_empty(&self) -> bool { + self.lines == [""] + } +} + +impl DrawableComponent for TextAreaComponent { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + let inner_rect = self.block.as_ref().map_or(rect, |block| { + block.render_ref(rect, f.buffer_mut()); + + block.inner(rect) + }); + + if self.is_empty() && !self.placeholder.is_empty() { + self.draw_placeholder(f, inner_rect); + } else { + self.draw_lines(f, inner_rect); + } + + self.scroll.draw(f, rect, &self.theme); + + Ok(()) + } +} + /// pub struct TextInputComponent { title: String, @@ -52,7 +460,6 @@ pub struct TextInputComponent { current_area: Cell, embed: bool, textarea: Option, - select_state: SelectionState, } impl TextInputComponent { @@ -75,7 +482,6 @@ impl TextInputComponent { current_area: Cell::new(Rect::default()), embed: false, textarea: None, - select_state: SelectionState::NotSelecting, } } @@ -148,13 +554,12 @@ impl TextInputComponent { .collect(); self.textarea = Some({ - let mut text_area = TextArea::new(lines); + let mut text_area = + TextArea::new(lines, self.theme.clone()); if self.input_type == InputType::Password { text_area.set_mask_char('*'); } - text_area - .set_cursor_line_style(self.theme.text(true, false)); text_area.set_placeholder_text(self.default_msg.clone()); text_area.set_placeholder_style( self.theme @@ -224,43 +629,8 @@ impl TextInputComponent { } } - fn should_select(&mut self, input: &Input) { - if input.key == Key::Null { - return; - } - // Should we start selecting text, stop the current selection, or do nothing? - // the end is handled after the ending keystroke - - match (&self.select_state, input.shift) { - (SelectionState::Selecting, true) - | (SelectionState::NotSelecting, false) => { - // continue selecting or not selecting - } - (SelectionState::Selecting, false) => { - // end select - self.select_state = - SelectionState::SelectionEndPending; - } - (SelectionState::NotSelecting, true) => { - // start select - // this should always work since we are only called - // if we have a textarea to get input - if let Some(ta) = &mut self.textarea { - ta.start_selection(); - self.select_state = SelectionState::Selecting; - } - } - (SelectionState::SelectionEndPending, _) => { - // this really should not happen because the end pending state - // should have been picked up in the same pass as it was set - // so lets clear it - self.select_state = SelectionState::NotSelecting; - } - } - } - #[allow(clippy::too_many_lines, clippy::unnested_or_patterns)] - fn process_inputs(ta: &mut TextArea<'_>, input: &Input) -> bool { + fn process_inputs(ta: &mut TextArea, input: &Input) -> bool { match input { Input { key: Key::Char(c), @@ -271,15 +641,6 @@ impl TextInputComponent { ta.insert_char(*c); true } - Input { - key: Key::Tab, - ctrl: false, - alt: false, - .. - } => { - ta.insert_tab(); - true - } Input { key: Key::Char('h'), ctrl: true, @@ -310,60 +671,6 @@ impl TextInputComponent { ta.delete_next_char(); true } - Input { - key: Key::Char('k'), - ctrl: true, - alt: false, - .. - } => { - ta.delete_line_by_end(); - true - } - Input { - key: Key::Char('j'), - ctrl: true, - alt: false, - .. - } => { - ta.delete_line_by_head(); - true - } - Input { - key: Key::Char('w'), - ctrl: true, - alt: false, - .. - } - | Input { - key: Key::Char('h'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Backspace, - ctrl: false, - alt: true, - .. - } => { - ta.delete_word(); - true - } - Input { - key: Key::Delete, - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Char('d'), - ctrl: false, - alt: true, - .. - } => { - ta.delete_next_word(); - true - } Input { key: Key::Char('n'), ctrl: true, @@ -437,7 +744,7 @@ impl TextInputComponent { alt: true, .. } => { - ta.move_cursor(CursorMove::Head); + ta.move_cursor(CursorMove::Home); true } Input { @@ -486,106 +793,7 @@ impl TextInputComponent { ta.move_cursor(CursorMove::Bottom); true } - Input { - key: Key::Char('f'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Right, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::WordForward); - true - } - Input { - key: Key::Char('b'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Left, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::WordBack); - true - } - Input { - key: Key::Char(']'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Char('n'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Down, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::ParagraphForward); - true - } - Input { - key: Key::Char('['), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Char('p'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Up, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::ParagraphBack); - true - } - Input { - key: Key::Char('u'), - ctrl: true, - alt: false, - .. - } => { - ta.undo(); - true - } - Input { - key: Key::Char('r'), - ctrl: true, - alt: false, - .. - } => { - ta.redo(); - true - } - Input { - key: Key::Char('y'), - ctrl: true, - alt: false, - .. - } => { - ta.paste(); - true - } Input { key: Key::Char('v'), ctrl: true, @@ -595,7 +803,7 @@ impl TextInputComponent { | Input { key: Key::PageDown, .. } => { - ta.scroll(Scrolling::PageDown); + ta.move_cursor(CursorMove::PageDown); true } Input { @@ -607,7 +815,7 @@ impl TextInputComponent { | Input { key: Key::PageUp, .. } => { - ta.scroll(Scrolling::PageUp); + ta.move_cursor(CursorMove::PageUp); true } _ => false, @@ -641,7 +849,7 @@ impl DrawableComponent for TextInputComponent { f.render_widget(Clear, area); - f.render_widget(ta, area); + ta.draw(f, area)?; if self.show_char_count { self.draw_char_count(f, area); @@ -675,7 +883,7 @@ impl Component for TextInputComponent { fn event(&mut self, ev: &Event) -> Result { let input = Input::from(ev.clone()); - self.should_select(&input); + if let Some(ta) = &mut self.textarea { let modified = if let Event::Key(e) = ev { if key_match(e, self.key_config.keys.exit_popup) { @@ -695,13 +903,6 @@ impl Component for TextInputComponent { false }; - if self.select_state - == SelectionState::SelectionEndPending - { - ta.cancel_selection(); - self.select_state = SelectionState::NotSelecting; - } - if modified { self.msg.take(); return Ok(EventState::Consumed); @@ -739,8 +940,9 @@ mod tests { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); comp.show_inner_textarea(); - comp.set_text(String::from("a\nb")); + comp.set_text(String::from("ab\nb")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { assert_eq!(ta.cursor(), (0, 0)); @@ -759,6 +961,7 @@ mod tests { comp.show_inner_textarea(); comp.set_text(String::from("a")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { let txt = ta.lines(); assert_eq!(txt[0].len(), 1); @@ -773,6 +976,7 @@ mod tests { comp.show_inner_textarea(); comp.set_text(String::from("a\nb\nc")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { let txt = ta.lines(); assert_eq!(txt[0], "a"); @@ -782,109 +986,326 @@ mod tests { } #[test] - fn test_next_word_position() { + fn test_move_cursor_horizontally() { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); comp.show_inner_textarea(); comp.set_text(String::from("aa b;c")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { - // from word start - ta.move_cursor(CursorMove::Head); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 3)); - // from inside start + ta.move_cursor(CursorMove::Home); + assert_eq!(ta.cursor(), (0, 0)); + ta.move_cursor(CursorMove::Forward); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 5)); - // to string end + assert_eq!(ta.cursor(), (0, 1)); + ta.move_cursor(CursorMove::Forward); - ta.move_cursor(CursorMove::WordForward); + assert_eq!(ta.cursor(), (0, 2)); + + ta.move_cursor(CursorMove::End); assert_eq!(ta.cursor(), (0, 6)); - // from string end - ta.move_cursor(CursorMove::Forward); - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), save_cursor); + ta.move_cursor(CursorMove::Back); + assert_eq!(ta.cursor(), (0, 5)); + + ta.move_cursor(CursorMove::Back); + assert_eq!(ta.cursor(), (0, 4)); } } #[test] - fn test_previous_word_position() { + fn test_move_cursor_vertically() { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); comp.show_inner_textarea(); - comp.set_text(String::from(" a bb;c")); + comp.set_text(String::from("aa \nd\ngitui")); assert!(comp.is_visible()); if let Some(ta) = &mut comp.textarea { - // from string end + ta.move_cursor(CursorMove::Bottom); + assert_eq!(ta.cursor(), (2, 0)); + + ta.move_cursor(CursorMove::Up); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Up); + assert_eq!(ta.cursor(), (0, 0)); + + ta.move_cursor(CursorMove::Bottom); + assert_eq!(ta.cursor(), (2, 0)); + + ta.move_cursor(CursorMove::Top); + assert_eq!(ta.cursor(), (0, 0)); + + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (2, 0)); + + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (2, 0)); + ta.move_cursor(CursorMove::End); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 6)); - // from inside word - ta.move_cursor(CursorMove::Back); - ta.move_cursor(CursorMove::WordBack); + assert_eq!(ta.cursor(), (2, 5)); + + ta.move_cursor(CursorMove::Up); + assert_eq!(ta.cursor(), (1, 1)); + + ta.move_cursor(CursorMove::Bottom); + ta.move_cursor(CursorMove::End); + assert_eq!(ta.cursor(), (2, 5)); + + ta.move_cursor(CursorMove::Top); assert_eq!(ta.cursor(), (0, 3)); - // from word start - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 1)); - // to string start - ta.move_cursor(CursorMove::WordBack); + } + } + + #[test] + fn test_move_cursor_vertically_page_up_down() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from( + "aa \nd\ngitui\nasdf\ndf\ndfsdf\nsdfsdfsdfsdf", + )); + assert!(comp.is_visible()); + + let test_backend = + ratatui::backend::TestBackend::new(100, 100); + let mut terminal = ratatui::Terminal::new(test_backend) + .expect("Unable to set up terminal"); + let mut frame = terminal.get_frame(); + let rect = Rect::new(0, 0, 10, 5); + + // we call draw once before running the actual test as the component only learns its dimensions + // in a `draw` call. It needs to learn its dimensions because `PageUp` and `PageDown` rely on + // them for calculating how far to move the cursor. + comp.draw(&mut frame, rect).expect("draw not to fail"); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::PageDown); + assert_eq!(ta.cursor(), (6, 0)); + + ta.move_cursor(CursorMove::PageUp); assert_eq!(ta.cursor(), (0, 0)); - // from string start - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordBack); + } + } + + #[test] + fn test_insert_newline() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("aa b;c asdf asdf")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (0, 3)); + + ta.insert_newline(); + + assert_eq!(ta.lines(), &["aa ", "b;c asdf asdf"]); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (1, 3)); - assert_eq!(ta.cursor(), save_cursor); + ta.insert_newline(); + + assert_eq!(ta.lines(), &["aa ", "b;c", " asdf asdf"]); + assert_eq!(ta.cursor(), (2, 0)); } } #[test] - fn test_next_word_multibyte() { + fn test_insert_newline_unicode() { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); - // should emojis be word boundaries or not? - // various editors (vs code, vim) do not agree with the - // behavhior of the original textinput here. - // - // tui-textarea agrees with them. - // So these tests are changed to match that behavior - // FYI: this line is "a à ❤ab🤯 a" - - // "01245 89A EFG" - let text = dbg!("a à \u{2764}ab\u{1F92F} a"); comp.show_inner_textarea(); - comp.set_text(String::from(text)); + comp.set_text(String::from("äaä b;ö üü")); assert!(comp.is_visible()); if let Some(ta) = &mut comp.textarea { - ta.move_cursor(CursorMove::Head); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 2)); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 4)); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 9)); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 10)); - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), save_cursor); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (0, 3)); + + ta.insert_newline(); + + assert_eq!(ta.lines(), &["äaä", " b;ö üü"]); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (1, 3)); + + ta.insert_newline(); + + assert_eq!(ta.lines(), &["äaä", " b;", "ö üü"]); + assert_eq!(ta.cursor(), (2, 0)); + } + } + + #[test] + fn test_delete_char() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("aa b;c\ndef sa\ngitui")); + assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Bottom); ta.move_cursor(CursorMove::End); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 9)); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 4)); - ta.move_cursor(CursorMove::WordBack); + assert_eq!(ta.cursor(), (2, 5)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "gitu"]); + assert_eq!(ta.cursor(), (2, 4)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "git"]); + assert_eq!(ta.cursor(), (2, 3)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "gi"]); + assert_eq!(ta.cursor(), (2, 2)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "g"]); + assert_eq!(ta.cursor(), (2, 1)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", ""]); + assert_eq!(ta.cursor(), (2, 0)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa"]); + assert_eq!(ta.cursor(), (1, 6)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def s"]); + assert_eq!(ta.cursor(), (1, 5)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def "]); + assert_eq!(ta.cursor(), (1, 4)); + + ta.insert_char('g'); + assert_eq!(ta.lines(), &["aa b;c", "def g"]); + assert_eq!(ta.cursor(), (1, 5)); + } + } + + #[test] + fn test_delete_char_unicode() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("äÜö")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::End); + assert_eq!(ta.cursor(), (0, 3)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["äÜ"]); assert_eq!(ta.cursor(), (0, 2)); - ta.move_cursor(CursorMove::WordBack); + } + } + + #[test] + fn test_delete_char_cursor_position() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("aasd\nfdfsd\nölkj")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Bottom); + assert_eq!(ta.cursor(), (2, 0)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aasd", "fdfsdölkj"]); + assert_eq!(ta.cursor(), (1, 5)); + } + } + + #[test] + fn test_delete_next_char() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("aa\ndef sa\ngitui")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["a", "def sa", "gitui"]); + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["", "def sa", "gitui"]); + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["def sa", "gitui"]); + assert_eq!(ta.cursor(), (0, 0)); + + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (1, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["def sa", "itui"]); + assert_eq!(ta.cursor(), (1, 0)); + } + } + + #[test] + fn test_delete_next_char_empty() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text("".into()); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &[""]); assert_eq!(ta.cursor(), (0, 0)); - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), save_cursor); + } + } + + #[test] + fn test_delete_next_char_unicode() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text("üäu".into()); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (0, 1)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["üu"]); + assert_eq!(ta.cursor(), (0, 1)); } } } diff --git a/src/components/utils/scroll_vertical.rs b/src/components/utils/scroll_vertical.rs index 51cb05f82f..d50ce41120 100644 --- a/src/components/utils/scroll_vertical.rs +++ b/src/components/utils/scroll_vertical.rs @@ -5,6 +5,7 @@ use crate::{ use ratatui::{layout::Rect, Frame}; use std::cell::Cell; +#[derive(Debug)] pub struct VerticalScroll { top: Cell, max_top: Cell, @@ -24,6 +25,10 @@ impl VerticalScroll { self.top.get() } + pub fn get_visual_height(&self) -> usize { + self.visual_height.get() + } + pub fn reset(&self) { self.top.set(0); }