Skip to content

Commit 67fa456

Browse files
authored
Add word motions to text input (#1256)
1 parent 1020346 commit 67fa456

File tree

2 files changed

+197
-0
lines changed

2 files changed

+197
-0
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
* submodules support ([#1087](https://github.com/extrawurst/gitui/issues/1087))
1616
* customizable `cmdbar_bg` theme color & screen spanning selected line bg [[@gigitsu](https://github.com/gigitsu)] ([#1299](https://github.com/extrawurst/gitui/pull/1299))
1717
* use filewatcher instead of polling updates ([#1](https://github.com/extrawurst/gitui/issues/1))
18+
* word motions to text input [[@Rodrigodd](https://github.com/Rodrigodd)] ([#1256](https://github.com/extrawurst/gitui/issues/1256))
1819

1920
### Fixes
2021
* remove insecure dependency `ansi_term` ([#1290](https://github.com/extrawurst/gitui/issues/1290))

Diff for: src/components/textinput.rs

+196
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,75 @@ impl TextInputComponent {
130130
Some(index)
131131
}
132132

133+
/// Helper for `next/previous_word_position`.
134+
fn at_alphanumeric(&self, i: usize) -> bool {
135+
self.msg[i..]
136+
.chars()
137+
.next()
138+
.map_or(false, char::is_alphanumeric)
139+
}
140+
141+
/// Get the position of the first character of the next word, or, if there
142+
/// isn't a next word, the `msg.len()`.
143+
/// Returns None when the cursor is already at `msg.len()`.
144+
///
145+
/// A Word is continuous sequence of alphanumeric characters.
146+
fn next_word_position(&self) -> Option<usize> {
147+
if self.cursor_position >= self.msg.len() {
148+
return None;
149+
}
150+
151+
let mut was_in_word =
152+
self.at_alphanumeric(self.cursor_position);
153+
154+
let mut index = self.cursor_position.saturating_add(1);
155+
while index < self.msg.len() {
156+
if !self.msg.is_char_boundary(index) {
157+
index += 1;
158+
continue;
159+
}
160+
161+
let is_in_word = self.at_alphanumeric(index);
162+
if !was_in_word && is_in_word {
163+
break;
164+
}
165+
was_in_word = is_in_word;
166+
index += 1;
167+
}
168+
Some(index)
169+
}
170+
171+
/// Get the position of the first character of the previous word, or, if there
172+
/// isn't a previous word, returns `0`.
173+
/// Returns None when the cursor is already at `0`.
174+
///
175+
/// A Word is continuous sequence of alphanumeric characters.
176+
fn previous_word_position(&self) -> Option<usize> {
177+
if self.cursor_position == 0 {
178+
return None;
179+
}
180+
181+
let mut was_in_word = false;
182+
183+
let mut last_pos = self.cursor_position;
184+
let mut index = self.cursor_position;
185+
while index > 0 {
186+
index -= 1;
187+
if !self.msg.is_char_boundary(index) {
188+
continue;
189+
}
190+
191+
let is_in_word = self.at_alphanumeric(index);
192+
if was_in_word && !is_in_word {
193+
return Some(last_pos);
194+
}
195+
196+
last_pos = index;
197+
was_in_word = is_in_word;
198+
}
199+
Some(0)
200+
}
201+
133202
fn backspace(&mut self) {
134203
if self.cursor_position > 0 {
135204
self.decr_cursor();
@@ -366,6 +435,43 @@ impl Component for TextInputComponent {
366435
self.incr_cursor();
367436
return Ok(EventState::Consumed);
368437
}
438+
KeyCode::Delete if is_ctrl => {
439+
if let Some(pos) = self.next_word_position() {
440+
self.msg.replace_range(
441+
self.cursor_position..pos,
442+
"",
443+
);
444+
}
445+
return Ok(EventState::Consumed);
446+
}
447+
KeyCode::Backspace | KeyCode::Char('w')
448+
if is_ctrl =>
449+
{
450+
if let Some(pos) =
451+
self.previous_word_position()
452+
{
453+
self.msg.replace_range(
454+
pos..self.cursor_position,
455+
"",
456+
);
457+
self.cursor_position = pos;
458+
}
459+
return Ok(EventState::Consumed);
460+
}
461+
KeyCode::Left if is_ctrl => {
462+
if let Some(pos) =
463+
self.previous_word_position()
464+
{
465+
self.cursor_position = pos;
466+
}
467+
return Ok(EventState::Consumed);
468+
}
469+
KeyCode::Right if is_ctrl => {
470+
if let Some(pos) = self.next_word_position() {
471+
self.cursor_position = pos;
472+
}
473+
return Ok(EventState::Consumed);
474+
}
369475
KeyCode::Delete => {
370476
if self.cursor_position < self.msg.len() {
371477
self.msg.remove(self.cursor_position);
@@ -558,6 +664,96 @@ mod tests {
558664
assert_eq!(get_text(&txt.lines[1].0[0]), Some("b"));
559665
}
560666

667+
#[test]
668+
fn test_next_word_position() {
669+
let mut comp = TextInputComponent::new(
670+
SharedTheme::default(),
671+
SharedKeyConfig::default(),
672+
"",
673+
"",
674+
false,
675+
);
676+
677+
comp.set_text(String::from("aa b;c"));
678+
// from word start
679+
comp.cursor_position = 0;
680+
assert_eq!(comp.next_word_position(), Some(3));
681+
// from inside start
682+
comp.cursor_position = 4;
683+
assert_eq!(comp.next_word_position(), Some(5));
684+
// to string end
685+
comp.cursor_position = 5;
686+
assert_eq!(comp.next_word_position(), Some(6));
687+
// from string end
688+
comp.cursor_position = 6;
689+
assert_eq!(comp.next_word_position(), None);
690+
}
691+
692+
#[test]
693+
fn test_previous_word_position() {
694+
let mut comp = TextInputComponent::new(
695+
SharedTheme::default(),
696+
SharedKeyConfig::default(),
697+
"",
698+
"",
699+
false,
700+
);
701+
702+
comp.set_text(String::from(" a bb;c"));
703+
// from string end
704+
comp.cursor_position = 7;
705+
assert_eq!(comp.previous_word_position(), Some(6));
706+
// from inside word
707+
comp.cursor_position = 4;
708+
assert_eq!(comp.previous_word_position(), Some(3));
709+
// from word start
710+
comp.cursor_position = 3;
711+
assert_eq!(comp.previous_word_position(), Some(1));
712+
// to string start
713+
comp.cursor_position = 1;
714+
assert_eq!(comp.previous_word_position(), Some(0));
715+
// from string start
716+
comp.cursor_position = 0;
717+
assert_eq!(comp.previous_word_position(), None);
718+
}
719+
720+
#[test]
721+
fn test_next_word_multibyte() {
722+
let mut comp = TextInputComponent::new(
723+
SharedTheme::default(),
724+
SharedKeyConfig::default(),
725+
"",
726+
"",
727+
false,
728+
);
729+
730+
// "01245 89A EFG"
731+
let text = dbg!("a à \u{2764}ab\u{1F92F} a");
732+
733+
comp.set_text(String::from(text));
734+
735+
comp.cursor_position = 0;
736+
assert_eq!(comp.next_word_position(), Some(2));
737+
comp.cursor_position = 2;
738+
assert_eq!(comp.next_word_position(), Some(8));
739+
comp.cursor_position = 8;
740+
assert_eq!(comp.next_word_position(), Some(15));
741+
comp.cursor_position = 15;
742+
assert_eq!(comp.next_word_position(), Some(16));
743+
comp.cursor_position = 16;
744+
assert_eq!(comp.next_word_position(), None);
745+
746+
assert_eq!(comp.previous_word_position(), Some(15));
747+
comp.cursor_position = 15;
748+
assert_eq!(comp.previous_word_position(), Some(8));
749+
comp.cursor_position = 8;
750+
assert_eq!(comp.previous_word_position(), Some(2));
751+
comp.cursor_position = 2;
752+
assert_eq!(comp.previous_word_position(), Some(0));
753+
comp.cursor_position = 0;
754+
assert_eq!(comp.previous_word_position(), None);
755+
}
756+
561757
fn get_text<'a>(t: &'a Span) -> Option<&'a str> {
562758
Some(&t.content)
563759
}

0 commit comments

Comments
 (0)