From b9a2e131f26d9f8c2ab24700ed7189cc0fc65aba Mon Sep 17 00:00:00 2001 From: pm100 Date: Sun, 18 Feb 2024 01:24:18 -0800 Subject: [PATCH] tui textarea (#2051) --- Cargo.lock | 12 + Cargo.toml | 1 + src/components/commit.rs | 8 +- src/components/textinput.rs | 1194 +++++++++++++++++++---------------- src/keys/key_list.rs | 4 + src/strings.rs | 14 +- 6 files changed, 680 insertions(+), 553 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e6f299c..f73df4b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,6 +740,7 @@ dependencies = [ "struct-patch", "syntect", "tempfile", + "tui-textarea", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -1703,6 +1704,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tui-textarea" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width", +] + [[package]] name = "unicode-bidi" version = "0.3.15" diff --git a/Cargo.toml b/Cargo.toml index c69b22c0..0ca31bd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ syntect = { version = "5.2", default-features = false, features = [ "default-themes", "html", ] } +tui-textarea = "0.4.0" unicode-segmentation = "1.11" unicode-truncate = "0.2" unicode-width = "0.1" diff --git a/src/components/commit.rs b/src/components/commit.rs index 60365eee..83e18ea9 100644 --- a/src/components/commit.rs +++ b/src/components/commit.rs @@ -553,6 +553,12 @@ impl Component for CommitComponent { self.options.borrow().has_commit_msg_history(), true, )); + + out.push(CommandInfo::new( + strings::commands::newline(&self.key_config), + true, + true, + )); } visibility_blocking(self) @@ -565,7 +571,7 @@ impl Component for CommitComponent { } if let Event::Key(e) = ev { - if key_match(e, self.key_config.keys.enter) + if key_match(e, self.key_config.keys.commit) && self.can_commit() { try_or_popup!( diff --git a/src/components/textinput.rs b/src/components/textinput.rs index e60737a6..7d8dfc2b 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -1,52 +1,54 @@ use crate::app::Environment; use crate::keys::key_match; -use crate::strings::symbol; use crate::ui::Size; use crate::{ components::{ - popup_paragraph, visibility_blocking, CommandBlocking, - CommandInfo, Component, DrawableComponent, EventState, + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, }, keys::SharedKeyConfig, strings, ui::{self, style::SharedTheme}, }; use anyhow::Result; -use crossterm::event::{Event, KeyCode, KeyModifiers}; -use itertools::Itertools; +use crossterm::event::Event; +use ratatui::widgets::{Block, Borders}; use ratatui::{ backend::Backend, layout::{Alignment, Rect}, - style::Modifier, - text::{Line, Text}, widgets::{Clear, Paragraph}, Frame, }; -use std::{cell::Cell, collections::HashMap, ops::Range}; -use unicode_segmentation::UnicodeSegmentation; - +use std::cell::Cell; +use std::cell::OnceCell; +use std::convert::From; +use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea}; #[derive(PartialEq, Eq)] pub enum InputType { Singleline, Multiline, Password, } - -/// primarily a subcomponet for user input of text (used in `CommitComponent`) +#[derive(PartialEq, Eq)] +enum SelectionState { + Selecting, + NotSelecting, + SelectionEndPending, +} +type TextAreaComponent = TextArea<'static>; pub struct TextInputComponent { title: String, default_msg: String, - msg: String, - visible: bool, selected: Option, + msg: OnceCell, show_char_count: bool, theme: SharedTheme, key_config: SharedKeyConfig, - cursor_position: usize, input_type: InputType, current_area: Cell, embed: bool, - char_count: usize, + textarea: Option, + select_state: SelectionState, } impl TextInputComponent { @@ -58,19 +60,18 @@ impl TextInputComponent { show_char_count: bool, ) -> Self { Self { - msg: String::new(), - visible: false, + msg: OnceCell::default(), theme: env.theme.clone(), key_config: env.key_config.clone(), show_char_count, title: title.to_string(), default_msg: default_msg.to_string(), selected: None, - cursor_position: 0, input_type: InputType::Multiline, current_area: Cell::new(Rect::default()), embed: false, - char_count: 0, + textarea: None, + select_state: SelectionState::NotSelecting, } } @@ -84,14 +85,29 @@ impl TextInputComponent { /// Clear the `msg`. pub fn clear(&mut self) { - self.msg.clear(); - self.update_count(); - self.cursor_position = 0; + self.msg.take(); + if self.is_visible() { + self.show_inner_textarea(); + } } /// Get the `msg`. pub fn get_text(&self) -> &str { - self.msg.as_str() + // the fancy footwork with the OnceCell is to allow + // the reading of msg as a &str. + // tui_textarea returns its lines to the caller as &[String] + // gitui wants &str of \n delimited text + // it would be simple if this was a mut method. You could + // just load up msg from the lines area and return an &str pointing at it + // but its not a mut method. So we need to store the text in a OnceCell + // The methods that change msg call take() on the cell. That makes + // get_or_init run again + + self.msg.get_or_init(|| { + self.textarea + .as_ref() + .map_or_else(String::new, |ta| ta.lines().join("\n")) + }) } /// screen area (last time we got drawn) @@ -109,120 +125,51 @@ impl TextInputComponent { self.selected = Some(enable); } - /// Move the cursor right one char. - fn incr_cursor(&mut self) { - if let Some(pos) = self.next_char_position() { - self.cursor_position = pos; - } - } - - /// Move the cursor left one char. - fn decr_cursor(&mut self) { - let mut index = self.cursor_position.saturating_sub(1); - while index > 0 && !self.msg.is_char_boundary(index) { - index -= 1; - } - self.cursor_position = index; - } - - /// Get the position of the next char, or, if the cursor points - /// to the last char, the `msg.len()`. - /// Returns None when the cursor is already at `msg.len()`. - fn next_char_position(&self) -> Option { - if self.cursor_position >= self.msg.len() { - return None; - } - let mut index = self.cursor_position.saturating_add(1); - while index < self.msg.len() - && !self.msg.is_char_boundary(index) - { - index += 1; - } - Some(index) - } - - /// Helper for `next/previous_word_position`. - fn at_alphanumeric(&self, i: usize) -> bool { - self.msg[i..] - .chars() - .next() - .map_or(false, char::is_alphanumeric) - } - - /// Get the position of the first character of the next word, or, if there - /// isn't a next word, the `msg.len()`. - /// Returns None when the cursor is already at `msg.len()`. - /// - /// A Word is continuous sequence of alphanumeric characters. - fn next_word_position(&self) -> Option { - if self.cursor_position >= self.msg.len() { - return None; - } - - let mut was_in_word = - self.at_alphanumeric(self.cursor_position); - - let mut index = self.cursor_position.saturating_add(1); - while index < self.msg.len() { - if !self.msg.is_char_boundary(index) { - index += 1; - continue; + fn show_inner_textarea(&mut self) { + // create the textarea and then load it with the text + // from self.msg + let lines: Vec = self + .msg + .get() + .unwrap_or(&String::new()) + .split('\n') + .map(ToString::to_string) + .collect(); + self.textarea = Some({ + let style = + self.theme.text(self.selected.unwrap_or(true), false); + let mut text_area = TextArea::new(lines); + if self.input_type == InputType::Password { + text_area.set_mask_char('*'); } - - let is_in_word = self.at_alphanumeric(index); - if !was_in_word && is_in_word { - break; - } - was_in_word = is_in_word; - index += 1; - } - Some(index) - } - - /// Get the position of the first character of the previous word, or, if there - /// isn't a previous word, returns `0`. - /// Returns None when the cursor is already at `0`. - /// - /// A Word is continuous sequence of alphanumeric characters. - fn previous_word_position(&self) -> Option { - if self.cursor_position == 0 { - return None; - } - - let mut was_in_word = false; - - let mut last_pos = self.cursor_position; - let mut index = self.cursor_position; - while index > 0 { - index -= 1; - if !self.msg.is_char_boundary(index) { - continue; - } - - let is_in_word = self.at_alphanumeric(index); - if was_in_word && !is_in_word { - return Some(last_pos); - } - - last_pos = index; - was_in_word = is_in_word; - } - Some(0) - } - - fn backspace(&mut self) { - if self.cursor_position > 0 { - self.decr_cursor(); - self.msg.remove(self.cursor_position); - self.update_count(); - } + 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(style); + text_area.set_style(style); + if !self.embed { + text_area.set_block( + Block::default() + .borders(Borders::ALL) + .border_style( + ratatui::style::Style::default() + .add_modifier( + ratatui::style::Modifier::BOLD, + ), + ) + .title(self.title.clone()), + ); + }; + text_area + }); } /// Set the `msg`. pub fn set_text(&mut self, msg: String) { - self.msg = msg; - self.cursor_position = 0; - self.update_count(); + self.msg = msg.into(); + if self.is_visible() { + self.show_inner_textarea(); + } } /// Set the `title`. @@ -233,96 +180,16 @@ impl TextInputComponent { /// pub fn set_default_msg(&mut self, v: String) { self.default_msg = v; - } - - fn get_draw_text(&self) -> Text { - let style = - self.theme.text(self.selected.unwrap_or(true), false); - - let mut txt = Text::default(); - // The portion of the text before the cursor is added - // if the cursor is not at the first character. - if self.cursor_position > 0 { - let text_before_cursor = - self.get_msg(0..self.cursor_position); - let ends_in_nl = text_before_cursor.ends_with('\n'); - txt = text_append( - txt, - Text::styled(text_before_cursor, style), - ); - if ends_in_nl { - txt.lines.push(Line::default()); - } - } - - let cursor_str = self - .next_char_position() - // if the cursor is at the end of the msg - // a whitespace is used to underline - .map_or(" ".to_owned(), |pos| { - self.get_msg(self.cursor_position..pos) - }); - - let cursor_highlighting = { - let mut h = HashMap::with_capacity(2); - h.insert("\n", "\u{21b5}\r\n\n"); - h.insert(" ", symbol::WHITESPACE); - h - }; - - if let Some(substitute) = - cursor_highlighting.get(cursor_str.as_str()) - { - txt = text_append( - txt, - Text::styled( - substitute.to_owned(), - self.theme - .text(false, false) - .add_modifier(Modifier::UNDERLINED), - ), - ); - } else { - txt = text_append( - txt, - Text::styled( - cursor_str, - style.add_modifier(Modifier::UNDERLINED), - ), - ); - } - - // The final portion of the text is added if there are - // still remaining characters. - if let Some(pos) = self.next_char_position() { - if pos < self.msg.len() { - txt = text_append( - txt, - Text::styled( - self.get_msg(pos..self.msg.len()), - style, - ), - ); - } - } - - txt - } - - fn get_msg(&self, range: Range) -> String { - match self.input_type { - InputType::Password => range.map(|_| "*").join(""), - _ => self.msg[range].to_owned(), + if self.is_visible() { + self.show_inner_textarea(); } } fn draw_char_count(&self, f: &mut Frame, r: Rect) { - if self.char_count > 0 { - let w = Paragraph::new(format!( - "[{} chars]", - self.char_count - )) - .alignment(Alignment::Right); + let count = self.get_text().len(); + if count > 0 { + let w = Paragraph::new(format!("[{count} chars]")) + .alignment(Alignment::Right); let mut rect = { let mut rect = r; @@ -340,29 +207,40 @@ impl TextInputComponent { } } - fn update_count(&mut self) { - self.char_count = self.msg.graphemes(true).count(); - } -} - -// merges last line of `txt` with first of `append` so we do not generate unneeded newlines -fn text_append<'a>(txt: Text<'a>, append: Text<'a>) -> Text<'a> { - let mut txt = txt; - if let Some(last_line) = txt.lines.last_mut() { - if let Some(first_line) = append.lines.first() { - last_line.spans.extend(first_line.spans.clone()); + 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 - if append.lines.len() > 1 { - for line in 1..append.lines.len() { - let spans = append.lines[line].clone(); - txt.lines.push(spans); + 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; } } - } else { - txt = append; } - txt } impl DrawableComponent for TextInputComponent { @@ -371,19 +249,9 @@ impl DrawableComponent for TextInputComponent { f: &mut Frame, rect: Rect, ) -> Result<()> { - if self.visible { - let txt = if self.msg.is_empty() { - Text::styled( - self.default_msg.as_str(), - self.theme.text( - self.selected.unwrap_or_default(), - false, - ), - ) - } else { - self.get_draw_text() - }; - + // this should always be true since draw should only be being called + // is control is visible + if let Some(ta) = &self.textarea { let area = if self.embed { rect } else { @@ -402,16 +270,8 @@ impl DrawableComponent for TextInputComponent { }; f.render_widget(Clear, area); - f.render_widget( - popup_paragraph( - self.title.as_str(), - txt, - &self.theme, - true, - !self.embed, - ), - area, - ); + + f.render_widget(ta.widget(), area); if self.show_char_count { self.draw_char_count(f, area); @@ -419,7 +279,6 @@ impl DrawableComponent for TextInputComponent { self.current_area.set(area); } - Ok(()) } } @@ -434,115 +293,438 @@ impl Component for TextInputComponent { CommandInfo::new( strings::commands::close_popup(&self.key_config), true, - self.visible, + self.is_visible(), ) .order(1), ); visibility_blocking(self) } + // the fixes this clippy wants make the code much harder to follow + #[allow(clippy::unnested_or_patterns)] + // it just has to be this big + #[allow(clippy::too_many_lines)] + fn event(&mut self, ev: &Event) -> Result { - if self.visible { + let input = Input::from(ev.clone()); + self.should_select(&input); + if let Some(ta) = &mut self.textarea { if let Event::Key(e) = ev { if key_match(e, self.key_config.keys.exit_popup) { self.hide(); return Ok(EventState::Consumed); } - let is_ctrl = - e.modifiers.contains(KeyModifiers::CONTROL); + // for a multi line box we want to allow the user to enter new lines + // so test for what might be a different enter to mean 'ok do it' + if self.input_type == InputType::Multiline { + if key_match(e, self.key_config.keys.commit) { + // means pass it back up to the caller to handle + return Ok(EventState::NotConsumed); + } + } else if key_match(e, self.key_config.keys.enter) { + // ditto - we dont want it + return Ok(EventState::NotConsumed); + } - match e.code { - KeyCode::Char(c) if !is_ctrl => { - self.msg.insert(self.cursor_position, c); - self.update_count(); - self.incr_cursor(); - return Ok(EventState::Consumed); - } - KeyCode::Delete if is_ctrl => { - if let Some(pos) = self.next_word_position() { - self.msg.replace_range( - self.cursor_position..pos, - "", - ); - self.update_count(); - } - return Ok(EventState::Consumed); - } - KeyCode::Backspace | KeyCode::Char('w') - if is_ctrl => + // here all 'known' special keys for any textinput call are filtered out + + if key_match(e, self.key_config.keys.toggle_verify) + || key_match(e, self.key_config.keys.commit_amend) + || key_match( + e, + self.key_config.keys.open_commit_editor, + ) || key_match( + e, + self.key_config.keys.commit_history_next, + ) { + return Ok(EventState::NotConsumed); + } + + /* + here we do key handling rather than passing it to textareas input function + - so that we know which keys were handled and which were not + - to get fine control over what each key press does + - allow for key mapping based off key config.... + but in fact the original textinput ignored all key bindings, up,down,right,.... + so they are also ignored here + + */ + + // was the text buffer changed? + + let modified = + if key_match(e, self.key_config.keys.newline) + && self.input_type == InputType::Multiline { - if let Some(pos) = - self.previous_word_position() - { - self.msg.replace_range( - pos..self.cursor_position, - "", - ); - self.cursor_position = pos; - self.update_count(); + ta.insert_newline(); + true + } else { + match input { + Input { + key: Key::Char(c), + ctrl: false, + alt: false, + .. + } => { + ta.insert_char(c); + true + } + + Input { + key: Key::Tab, + ctrl: false, + alt: false, + .. + } => { + ta.insert_tab(); + true + } + Input { + key: Key::Char('h'), + ctrl: true, + alt: false, + .. + } + | Input { + key: Key::Backspace, + ctrl: false, + alt: false, + .. + } => ta.delete_char(), + Input { + key: Key::Char('d'), + ctrl: true, + alt: false, + .. + } + | Input { + key: Key::Delete, + ctrl: false, + alt: false, + .. + } => ta.delete_next_char(), + Input { + key: Key::Char('k'), + ctrl: true, + alt: false, + .. + } => ta.delete_line_by_end(), + Input { + key: Key::Char('j'), + ctrl: true, + alt: false, + .. + } => ta.delete_line_by_head(), + 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(), + Input { + key: Key::Delete, + ctrl: false, + alt: true, + .. + } + | Input { + key: Key::Char('d'), + ctrl: false, + alt: true, + .. + } => ta.delete_next_word(), + Input { + key: Key::Char('n'), + ctrl: true, + alt: false, + .. + } + | Input { + key: Key::Down, + ctrl: false, + alt: false, + .. + } => { + ta.move_cursor(CursorMove::Down); + false + } + Input { + key: Key::Char('p'), + ctrl: true, + alt: false, + .. + } + | Input { + key: Key::Up, + ctrl: false, + alt: false, + .. + } => { + ta.move_cursor(CursorMove::Up); + false + } + Input { + key: Key::Char('f'), + ctrl: true, + alt: false, + .. + } + | Input { + key: Key::Right, + ctrl: false, + alt: false, + .. + } => { + ta.move_cursor(CursorMove::Forward); + false + } + Input { + key: Key::Char('b'), + ctrl: true, + alt: false, + .. + } + | Input { + key: Key::Left, + ctrl: false, + alt: false, + .. + } => { + ta.move_cursor(CursorMove::Back); + false + } + // normally picked up earlier as 'amend' + Input { + key: Key::Char('a'), + ctrl: true, + alt: false, + .. + } + | Input { key: Key::Home, .. } + | Input { + key: Key::Left | Key::Char('b'), + ctrl: true, + alt: true, + .. + } => { + ta.move_cursor(CursorMove::Head); + false + } + // normally picked up earlier as 'invoke editor' + Input { + key: Key::Char('e'), + ctrl: true, + alt: false, + .. + } + | Input { key: Key::End, .. } + | Input { + key: Key::Right | Key::Char('f'), + ctrl: true, + alt: true, + .. + } => { + ta.move_cursor(CursorMove::End); + false + } + Input { + key: Key::Char('<'), + ctrl: false, + alt: true, + .. + } + | Input { + key: Key::Up | Key::Char('p'), + ctrl: true, + alt: true, + .. + } => { + ta.move_cursor(CursorMove::Top); + false + } + Input { + key: Key::Char('>'), + ctrl: false, + alt: true, + .. + } + | Input { + key: Key::Down | Key::Char('n'), + ctrl: true, + alt: true, + .. + } => { + ta.move_cursor(CursorMove::Bottom); + false + } + Input { + key: Key::Char('f'), + ctrl: false, + alt: true, + .. + } + | Input { + key: Key::Right, + ctrl: true, + alt: false, + .. + } => { + ta.move_cursor( + CursorMove::WordForward, + ); + false + } + + Input { + key: Key::Char('b'), + ctrl: false, + alt: true, + .. + } + | Input { + key: Key::Left, + ctrl: true, + alt: false, + .. + } => { + ta.move_cursor(CursorMove::WordBack); + false + } + + 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, + ); + false + } + 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, + ); + false + } + Input { + key: Key::Char('u'), + ctrl: true, + alt: false, + .. + } => ta.undo(), + Input { + key: Key::Char('r'), + ctrl: true, + alt: false, + .. + } => ta.redo(), + Input { + key: Key::Char('y'), + ctrl: true, + alt: false, + .. + } => ta.paste(), + Input { + key: Key::Char('v'), + ctrl: true, + alt: false, + .. + } + | Input { + key: Key::PageDown, .. + } => { + ta.scroll(Scrolling::PageDown); + false + } + + Input { + key: Key::Char('v'), + ctrl: false, + alt: true, + .. + } + | Input { + key: Key::PageUp, .. + } => { + ta.scroll(Scrolling::PageUp); + false + } + _ => return Ok(EventState::NotConsumed), } - return Ok(EventState::Consumed); - } - KeyCode::Left if is_ctrl => { - if let Some(pos) = - self.previous_word_position() - { - self.cursor_position = pos; - } - return Ok(EventState::Consumed); - } - KeyCode::Right if is_ctrl => { - if let Some(pos) = self.next_word_position() { - self.cursor_position = pos; - } - return Ok(EventState::Consumed); - } - KeyCode::Delete => { - if self.cursor_position < self.msg.len() { - self.msg.remove(self.cursor_position); - self.update_count(); - } - return Ok(EventState::Consumed); - } - KeyCode::Backspace => { - self.backspace(); - return Ok(EventState::Consumed); - } - KeyCode::Left => { - self.decr_cursor(); - return Ok(EventState::Consumed); - } - KeyCode::Right => { - self.incr_cursor(); - return Ok(EventState::Consumed); - } - KeyCode::Home => { - self.cursor_position = 0; - return Ok(EventState::Consumed); - } - KeyCode::End => { - self.cursor_position = self.msg.len(); - return Ok(EventState::Consumed); - } - _ => (), - }; + }; + if modified { + self.msg.take(); + } } + if self.select_state + == SelectionState::SelectionEndPending + { + ta.cancel_selection(); + self.select_state = SelectionState::NotSelecting; + } + return Ok(EventState::Consumed); } Ok(EventState::NotConsumed) } - + /* + visible maps to textarea Option + None = > not visible + Some => visible + */ fn is_visible(&self) -> bool { - self.visible + self.textarea.is_some() } fn hide(&mut self) { - self.visible = false; + self.textarea = None; } fn show(&mut self) -> Result<()> { - self.visible = true; - + self.show_inner_textarea(); Ok(()) } } @@ -550,246 +732,158 @@ impl Component for TextInputComponent { #[cfg(test)] mod tests { use super::*; - use ratatui::{style::Style, text::Span}; #[test] fn test_smoke() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); - + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); comp.set_text(String::from("a\nb")); + assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { + assert_eq!(ta.cursor(), (0, 0)); - assert_eq!(comp.cursor_position, 0); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (0, 1)); - comp.incr_cursor(); - assert_eq!(comp.cursor_position, 1); - - comp.decr_cursor(); - assert_eq!(comp.cursor_position, 0); + ta.move_cursor(CursorMove::Back); + assert_eq!(ta.cursor(), (0, 0)); + } } #[test] fn text_cursor_initial_position() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); - let theme = SharedTheme::default(); - let underlined = theme - .text(true, false) - .add_modifier(Modifier::UNDERLINED); - + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); comp.set_text(String::from("a")); - - let txt = comp.get_draw_text(); - - assert_eq!(txt.lines[0].spans.len(), 1); - assert_eq!(get_text(&txt.lines[0].spans[0]), Some("a")); - assert_eq!( - get_style(&txt.lines[0].spans[0]), - Some(&underlined) - ); + assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { + let txt = ta.lines(); + assert_eq!(txt[0].len(), 1); + assert_eq!(txt[0].as_bytes()[0], 'a' as u8); + } } #[test] - fn test_cursor_second_position() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); - let theme = SharedTheme::default(); - let underlined_whitespace = theme - .text(false, false) - .add_modifier(Modifier::UNDERLINED); - - let not_underlined = Style::default(); - - comp.set_text(String::from("a")); - comp.incr_cursor(); - - let txt = comp.get_draw_text(); - - assert_eq!(txt.lines[0].spans.len(), 2); - assert_eq!(get_text(&txt.lines[0].spans[0]), Some("a")); - assert_eq!( - get_style(&txt.lines[0].spans[0]), - Some(¬_underlined) - ); - assert_eq!( - get_text(&txt.lines[0].spans[1]), - Some(symbol::WHITESPACE) - ); - assert_eq!( - get_style(&txt.lines[0].spans[1]), - Some(&underlined_whitespace) - ); - } - - #[test] - fn test_visualize_newline() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); - - let theme = SharedTheme::default(); - let underlined = theme - .text(false, false) - .add_modifier(Modifier::UNDERLINED); - - comp.set_text(String::from("a\nb")); - comp.incr_cursor(); - - let txt = comp.get_draw_text(); - - assert_eq!(txt.lines.len(), 2); - assert_eq!(txt.lines[0].spans.len(), 2); - assert_eq!(txt.lines[1].spans.len(), 2); - assert_eq!(get_text(&txt.lines[0].spans[0]), Some("a")); - assert_eq!( - get_text(&txt.lines[0].spans[1]), - Some("\u{21b5}") - ); - assert_eq!( - get_style(&txt.lines[0].spans[1]), - Some(&underlined) - ); - assert_eq!(get_text(&txt.lines[1].spans[0]), Some("")); - assert_eq!(get_text(&txt.lines[1].spans[1]), Some("b")); - } - - #[test] - fn test_invisible_newline() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); - - let theme = SharedTheme::default(); - let underlined = theme - .text(true, false) - .add_modifier(Modifier::UNDERLINED); - - comp.set_text(String::from("a\nb")); - - let txt = comp.get_draw_text(); - - assert_eq!(txt.lines.len(), 2); - assert_eq!(txt.lines[0].spans.len(), 2); - assert_eq!(txt.lines[1].spans.len(), 1); - assert_eq!(get_text(&txt.lines[0].spans[0]), Some("a")); - assert_eq!(get_text(&txt.lines[0].spans[1]), Some("")); - assert_eq!( - get_style(&txt.lines[0].spans[0]), - Some(&underlined) - ); - assert_eq!(get_text(&txt.lines[1].spans[0]), Some("b")); + fn test_multiline() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + 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"); + assert_eq!(txt[1], "b"); + assert_eq!(txt[2], "c"); + } } #[test] fn test_next_word_position() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); - + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); comp.set_text(String::from("aa b;c")); - // from word start - comp.cursor_position = 0; - assert_eq!(comp.next_word_position(), Some(3)); - // from inside start - comp.cursor_position = 4; - assert_eq!(comp.next_word_position(), Some(5)); - // to string end - comp.cursor_position = 5; - assert_eq!(comp.next_word_position(), Some(6)); - // from string end - comp.cursor_position = 6; - assert_eq!(comp.next_word_position(), None); + 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::Forward); + ta.move_cursor(CursorMove::WordForward); + assert_eq!(ta.cursor(), (0, 5)); + // to string end + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::WordForward); + 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); + } } #[test] fn test_previous_word_position() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); - + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); comp.set_text(String::from(" a bb;c")); - // from string end - comp.cursor_position = 7; - assert_eq!(comp.previous_word_position(), Some(6)); - // from inside word - comp.cursor_position = 4; - assert_eq!(comp.previous_word_position(), Some(3)); - // from word start - comp.cursor_position = 3; - assert_eq!(comp.previous_word_position(), Some(1)); - // to string start - comp.cursor_position = 1; - assert_eq!(comp.previous_word_position(), Some(0)); - // from string start - comp.cursor_position = 0; - assert_eq!(comp.previous_word_position(), None); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + // from string end + 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(), (0, 3)); + // from word start + ta.move_cursor(CursorMove::WordBack); + assert_eq!(ta.cursor(), (0, 1)); + // to string start + ta.move_cursor(CursorMove::WordBack); + assert_eq!(ta.cursor(), (0, 0)); + // from string start + let save_cursor = ta.cursor(); + ta.move_cursor(CursorMove::WordBack); + + assert_eq!(ta.cursor(), save_cursor); + } } #[test] fn test_next_word_multibyte() { - let mut comp = TextInputComponent::new( - &Environment::test_env(), - "", - "", - false, - ); + 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)); + assert!(comp.is_visible()); - comp.cursor_position = 0; - assert_eq!(comp.next_word_position(), Some(2)); - comp.cursor_position = 2; - assert_eq!(comp.next_word_position(), Some(8)); - comp.cursor_position = 8; - assert_eq!(comp.next_word_position(), Some(15)); - comp.cursor_position = 15; - assert_eq!(comp.next_word_position(), Some(16)); - comp.cursor_position = 16; - assert_eq!(comp.next_word_position(), None); + 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); - assert_eq!(comp.previous_word_position(), Some(15)); - comp.cursor_position = 15; - assert_eq!(comp.previous_word_position(), Some(8)); - comp.cursor_position = 8; - assert_eq!(comp.previous_word_position(), Some(2)); - comp.cursor_position = 2; - assert_eq!(comp.previous_word_position(), Some(0)); - comp.cursor_position = 0; - assert_eq!(comp.previous_word_position(), None); - } - - fn get_text<'a>(t: &'a Span) -> Option<&'a str> { - Some(&t.content) - } - - fn get_style<'a>(t: &'a Span) -> Option<&'a Style> { - Some(&t.style) + 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(), (0, 2)); + ta.move_cursor(CursorMove::WordBack); + assert_eq!(ta.cursor(), (0, 0)); + let save_cursor = ta.cursor(); + ta.move_cursor(CursorMove::WordBack); + assert_eq!(ta.cursor(), save_cursor); + } } } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index cc6fc99a..6706b630 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -120,6 +120,8 @@ pub struct KeysList { pub view_submodule_parent: GituiKeyEvent, pub update_submodule: GituiKeyEvent, pub commit_history_next: GituiKeyEvent, + pub commit: GituiKeyEvent, + pub newline: GituiKeyEvent, } #[rustfmt::skip] @@ -209,6 +211,8 @@ impl Default for KeysList { view_submodule_parent: GituiKeyEvent::new(KeyCode::Char('p'), KeyModifiers::empty()), update_submodule: GituiKeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty()), commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + commit: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + newline: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), } } } diff --git a/src/strings.rs b/src/strings.rs index 6c3b061a..3565f688 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -38,7 +38,6 @@ pub static POPUP_SUCCESS_COPY: &str = "Copied Text"; pub static POPUP_COMMIT_SHA_INVALID: &str = "Invalid commit sha"; pub mod symbol { - pub const WHITESPACE: &str = "\u{00B7}"; //· pub const CHECKMARK: &str = "\u{2713}"; //✓ pub const SPACE: &str = "\u{02FD}"; //˽ pub const EMPTY_SPACE: &str = " "; @@ -969,13 +968,24 @@ pub mod commands { CommandText::new( format!( "Commit [{}]", - key_config.get_hint(key_config.keys.enter), + key_config.get_hint(key_config.keys.commit), ), "commit (available when commit message is non-empty)", CMD_GROUP_COMMIT_POPUP, ) .hide_help() } + pub fn newline(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!( + "New line [{}]", + key_config.get_hint(key_config.keys.newline), + ), + "create line break", + CMD_GROUP_COMMIT_POPUP, + ) + .hide_help() + } pub fn toggle_verify( key_config: &SharedKeyConfig, current_verify: bool,