Skip to main content

jev_repl/
words.rs

1//! Word-wise motion, and the keys that ask for it.
2//!
3//! Alt-← and Alt-→ are what a terminal user reaches for to cross a word, but terminals spell them
4//! in more than one way: some send a modified arrow, some report the same keypress with the Meta
5//! or Super bit instead of Alt, and some send the readline bindings `Alt-b` and `Alt-f`. All of
6//! them mean the same thing here — along with Ctrl-←/→, which is the other common spelling.
7
8use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10/// Alt, however the terminal reported it.
11///
12/// `Esc [ 1;3A` is Alt-Up, and `Esc [ 1;9A` is the same keypress from a terminal that reports Alt
13/// as Meta — which crossterm hands over as `SUPER`. Reading only `ALT` is what made Alt-arrow look
14/// dead in half the terminals it was tried in.
15pub fn is_alt(key: &KeyEvent) -> bool {
16    key.modifiers
17        .intersects(KeyModifiers::ALT | KeyModifiers::META | KeyModifiers::SUPER)
18}
19
20fn is_ctrl(key: &KeyEvent) -> bool {
21    key.modifiers.contains(KeyModifiers::CONTROL)
22}
23
24/// Alt-←, Ctrl-←, or Alt-b: move to the start of the word before the cursor.
25pub fn is_word_left(key: &KeyEvent) -> bool {
26    match key.code {
27        KeyCode::Left => is_alt(key) || is_ctrl(key),
28        KeyCode::Char('b' | 'B') => is_alt(key) && !is_ctrl(key),
29        _ => false,
30    }
31}
32
33/// Alt-→, Ctrl-→, or Alt-f: move past the end of the word after the cursor.
34pub fn is_word_right(key: &KeyEvent) -> bool {
35    match key.code {
36        KeyCode::Right => is_alt(key) || is_ctrl(key),
37        KeyCode::Char('f' | 'F') => is_alt(key) && !is_ctrl(key),
38        _ => false,
39    }
40}
41
42/// Alt-Backspace: delete the word before the cursor.
43pub fn is_delete_word_left(key: &KeyEvent) -> bool {
44    matches!(key.code, KeyCode::Backspace) && is_alt(key)
45}
46
47/// A character key that should be typed, rather than one carrying a modifier we did not bind.
48pub fn is_typed(key: &KeyEvent) -> bool {
49    !is_alt(key) && !is_ctrl(key)
50}
51
52/// The index the cursor lands on moving left by a word: over any spaces, then over the word.
53pub fn word_left(chars: &[char], cursor: usize) -> usize {
54    let mut i = cursor.min(chars.len());
55    while i > 0 && chars[i - 1].is_whitespace() {
56        i -= 1;
57    }
58    while i > 0 && !chars[i - 1].is_whitespace() {
59        i -= 1;
60    }
61    i
62}
63
64/// The index the cursor lands on moving right by a word: over any spaces, then over the word.
65pub fn word_right(chars: &[char], cursor: usize) -> usize {
66    let mut i = cursor.min(chars.len());
67    while i < chars.len() && chars[i].is_whitespace() {
68        i += 1;
69    }
70    while i < chars.len() && !chars[i].is_whitespace() {
71        i += 1;
72    }
73    i
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn chars(s: &str) -> Vec<char> {
81        s.chars().collect()
82    }
83
84    #[test]
85    fn crosses_one_word_at_a_time() {
86        let c = chars("  the payout failed");
87        assert_eq!(word_left(&c, c.len()), 13);
88        assert_eq!(word_left(&c, 13), 6);
89        assert_eq!(word_left(&c, 6), 2);
90        assert_eq!(word_left(&c, 2), 0);
91        assert_eq!(word_left(&c, 0), 0);
92
93        assert_eq!(word_right(&c, 0), 5);
94        assert_eq!(word_right(&c, 5), 12);
95        assert_eq!(word_right(&c, 12), c.len());
96        assert_eq!(word_right(&c, c.len()), c.len());
97    }
98
99    #[test]
100    fn answers_to_every_spelling_of_alt_arrow() {
101        let left = |m| KeyEvent::new(KeyCode::Left, m);
102        assert!(is_word_left(&left(KeyModifiers::ALT)));
103        assert!(is_word_left(&left(KeyModifiers::CONTROL)));
104        // The Meta bit some terminals report the same keypress with.
105        assert!(is_word_left(&left(KeyModifiers::SUPER)));
106        assert!(is_word_left(&left(KeyModifiers::META)));
107        assert!(is_word_left(&KeyEvent::new(
108            KeyCode::Char('b'),
109            KeyModifiers::ALT
110        )));
111        assert!(is_word_right(&KeyEvent::new(
112            KeyCode::Right,
113            KeyModifiers::ALT
114        )));
115        assert!(is_word_right(&KeyEvent::new(
116            KeyCode::Char('f'),
117            KeyModifiers::ALT
118        )));
119        assert!(is_delete_word_left(&KeyEvent::new(
120            KeyCode::Backspace,
121            KeyModifiers::ALT
122        )));
123
124        assert!(!is_word_left(&left(KeyModifiers::NONE)));
125        assert!(!is_word_right(&KeyEvent::new(
126            KeyCode::Char('f'),
127            KeyModifiers::NONE
128        )));
129        assert!(!is_delete_word_left(&KeyEvent::new(
130            KeyCode::Backspace,
131            KeyModifiers::NONE
132        )));
133        assert!(is_typed(&KeyEvent::new(
134            KeyCode::Char('f'),
135            KeyModifiers::NONE
136        )));
137        assert!(!is_typed(&KeyEvent::new(
138            KeyCode::Char('f'),
139            KeyModifiers::ALT
140        )));
141    }
142}