diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ed9b40b7..b94ec87d97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * submodules support ([#1087](https://github.com/extrawurst/gitui/issues/1087)) * customizable `cmdbar_bg` theme color & screen spanning selected line bg [[@gigitsu](https://github.com/gigitsu)] ([#1299](https://github.com/extrawurst/gitui/pull/1299)) * use filewatcher instead of polling updates ([#1](https://github.com/extrawurst/gitui/issues/1)) +* word motions to text input [[@Rodrigodd](https://github.com/Rodrigodd)] ([#1256](https://github.com/extrawurst/gitui/issues/1256)) ### Fixes * remove insecure dependency `ansi_term` ([#1290](https://github.com/extrawurst/gitui/issues/1290)) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index a387106a78..250c882278 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -130,6 +130,75 @@ impl TextInputComponent { 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; + } + + 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(); @@ -366,6 +435,43 @@ impl Component for TextInputComponent { 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, + "", + ); + } + return Ok(EventState::Consumed); + } + KeyCode::Backspace | KeyCode::Char('w') + if is_ctrl => + { + if let Some(pos) = + self.previous_word_position() + { + self.msg.replace_range( + pos..self.cursor_position, + "", + ); + self.cursor_position = pos; + } + 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); @@ -558,6 +664,96 @@ mod tests { assert_eq!(get_text(&txt.lines[1].0[0]), Some("b")); } + #[test] + fn test_next_word_position() { + let mut comp = TextInputComponent::new( + SharedTheme::default(), + SharedKeyConfig::default(), + "", + "", + false, + ); + + 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); + } + + #[test] + fn test_previous_word_position() { + let mut comp = TextInputComponent::new( + SharedTheme::default(), + SharedKeyConfig::default(), + "", + "", + false, + ); + + 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); + } + + #[test] + fn test_next_word_multibyte() { + let mut comp = TextInputComponent::new( + SharedTheme::default(), + SharedKeyConfig::default(), + "", + "", + false, + ); + + // "01245 89A EFG" + let text = dbg!("a à \u{2764}ab\u{1F92F} a"); + + comp.set_text(String::from(text)); + + 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); + + 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) }