Skip to main content

par_term/copy_mode/
motion.rs

1//! Word and line navigation helpers for the copy mode state machine.
2
3use super::types::CopyModeState;
4use crate::smart_selection::is_word_char;
5
6impl CopyModeState {
7    // ========================================================================
8    // Word motions
9    // ========================================================================
10
11    /// Move forward to start of next word
12    pub fn move_word_forward(&mut self, line_text: &str, word_chars: &str) {
13        let count = self.effective_count();
14        let chars: Vec<char> = line_text.chars().collect();
15        let mut col = self.cursor_col;
16
17        for _ in 0..count {
18            if col >= chars.len() {
19                break;
20            }
21            // Skip current word characters
22            while col < chars.len() && is_word_char(chars[col], word_chars) {
23                col += 1;
24            }
25            // Skip non-word characters (whitespace/punctuation)
26            while col < chars.len() && !is_word_char(chars[col], word_chars) {
27                col += 1;
28            }
29        }
30
31        self.cursor_col = col.min(self.cols.saturating_sub(1));
32    }
33
34    /// Move backward to start of previous word
35    pub fn move_word_backward(&mut self, line_text: &str, word_chars: &str) {
36        let count = self.effective_count();
37        let chars: Vec<char> = line_text.chars().collect();
38        // `cursor_col` is clamped to the grid width, which exceeds the character
39        // count whenever the row holds wide characters — `Grid::row_text` drops
40        // their spacer cells. `col` only decreases below, so this seed clamp is
41        // what keeps every `chars[..]` access in bounds.
42        let mut col = self.cursor_col.min(chars.len().saturating_sub(1));
43
44        for _ in 0..count {
45            if col == 0 {
46                break;
47            }
48            col = col.saturating_sub(1);
49            // Skip non-word characters backward
50            while col > 0 && !is_word_char(chars[col], word_chars) {
51                col -= 1;
52            }
53            // Skip word characters backward to find start
54            while col > 0 && is_word_char(chars[col - 1], word_chars) {
55                col -= 1;
56            }
57        }
58
59        self.cursor_col = col;
60    }
61
62    /// Move forward to end of current/next word
63    pub fn move_word_end(&mut self, line_text: &str, word_chars: &str) {
64        let count = self.effective_count();
65        let chars: Vec<char> = line_text.chars().collect();
66        let mut col = self.cursor_col;
67
68        for _ in 0..count {
69            if col >= chars.len().saturating_sub(1) {
70                break;
71            }
72            col += 1;
73            // Skip non-word characters
74            while col < chars.len() && !is_word_char(chars[col], word_chars) {
75                col += 1;
76            }
77            // Move to end of word
78            while col < chars.len().saturating_sub(1) && is_word_char(chars[col + 1], word_chars) {
79                col += 1;
80            }
81        }
82
83        self.cursor_col = col.min(self.cols.saturating_sub(1));
84    }
85
86    /// Move forward to start of next WORD (whitespace-delimited)
87    pub fn move_big_word_forward(&mut self, line_text: &str) {
88        let count = self.effective_count();
89        let chars: Vec<char> = line_text.chars().collect();
90        let mut col = self.cursor_col;
91
92        for _ in 0..count {
93            // Skip non-whitespace
94            while col < chars.len() && !chars[col].is_whitespace() {
95                col += 1;
96            }
97            // Skip whitespace
98            while col < chars.len() && chars[col].is_whitespace() {
99                col += 1;
100            }
101        }
102
103        self.cursor_col = col.min(self.cols.saturating_sub(1));
104    }
105
106    /// Move backward to start of previous WORD (whitespace-delimited)
107    pub fn move_big_word_backward(&mut self, line_text: &str) {
108        let count = self.effective_count();
109        let chars: Vec<char> = line_text.chars().collect();
110        // Seed clamp keeps `chars[..]` in bounds; see `move_word_backward`.
111        let mut col = self.cursor_col.min(chars.len().saturating_sub(1));
112
113        for _ in 0..count {
114            if col == 0 {
115                break;
116            }
117            col = col.saturating_sub(1);
118            // Skip whitespace backward
119            while col > 0 && chars[col].is_whitespace() {
120                col -= 1;
121            }
122            // Skip non-whitespace backward
123            while col > 0 && !chars[col - 1].is_whitespace() {
124                col -= 1;
125            }
126        }
127
128        self.cursor_col = col;
129    }
130
131    /// Move forward to end of current/next WORD (whitespace-delimited)
132    pub fn move_big_word_end(&mut self, line_text: &str) {
133        let count = self.effective_count();
134        let chars: Vec<char> = line_text.chars().collect();
135        let mut col = self.cursor_col;
136
137        for _ in 0..count {
138            if col >= chars.len().saturating_sub(1) {
139                break;
140            }
141            col += 1;
142            // Skip whitespace
143            while col < chars.len() && chars[col].is_whitespace() {
144                col += 1;
145            }
146            // Move to end of WORD
147            while col < chars.len().saturating_sub(1) && !chars[col + 1].is_whitespace() {
148                col += 1;
149            }
150        }
151
152        self.cursor_col = col.min(self.cols.saturating_sub(1));
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::CopyModeState;
159
160    /// A CJK row: `Grid::row_text` drops the wide-char spacer cells, so the
161    /// text has far fewer characters than the grid has columns.
162    const CJK_LINE: &str = "日本語 test";
163    const ACCENTED_LINE: &str = "café au lait";
164    const EMOJI_LINE: &str = "😀 😁 done";
165
166    fn at_line_end(cols: usize) -> CopyModeState {
167        let mut cm = CopyModeState::new();
168        cm.enter(0, 0, cols, 24, 0);
169        cm.move_to_line_end();
170        cm
171    }
172
173    #[test]
174    fn word_backward_from_line_end_on_non_ascii_line() {
175        // `$` parks the cursor at column 79 while these lines hold at most a
176        // dozen characters, which used to index the char vector out of bounds.
177        for line in [CJK_LINE, ACCENTED_LINE, EMOJI_LINE] {
178            let mut cm = at_line_end(80);
179            cm.move_word_backward(line, "");
180            assert!(
181                cm.cursor_col < line.chars().count(),
182                "{line:?} left cursor at {}",
183                cm.cursor_col
184            );
185
186            let mut cm = at_line_end(80);
187            cm.move_big_word_backward(line);
188            assert!(
189                cm.cursor_col < line.chars().count(),
190                "{line:?} left cursor at {}",
191                cm.cursor_col
192            );
193        }
194    }
195
196    #[test]
197    fn word_backward_lands_on_last_word_start() {
198        let mut cm = at_line_end(80);
199        cm.move_word_backward(CJK_LINE, "");
200        // "日本語 test" — characters 4..8 are "test".
201        assert_eq!(cm.cursor_col, 4);
202
203        let mut cm = at_line_end(80);
204        cm.move_big_word_backward(ACCENTED_LINE);
205        // "café au lait" — characters 8..12 are "lait".
206        assert_eq!(cm.cursor_col, 8);
207    }
208
209    #[test]
210    fn word_backward_handles_empty_and_short_lines() {
211        let mut cm = at_line_end(80);
212        cm.move_word_backward("", "");
213        assert_eq!(cm.cursor_col, 0);
214
215        let mut cm = at_line_end(80);
216        cm.move_big_word_backward("é");
217        assert_eq!(cm.cursor_col, 0);
218    }
219}