1use crate::{Pos, TextBuffer};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Motion {
28 Left,
30 Right,
32 Up,
34 Down,
36
37 FileStart,
39
40 FileEnd,
42
43 LineStart,
45
46 LineFirstNonWhitespace,
48
49 LineEnd,
51
52 WordStartBefore,
54
55 WordStartAfter,
57
58 WordEndAfter,
60
61 FindChar(char),
63
64 TillChar(char),
66}
67
68#[inline]
73pub fn apply_motion(buffer: &TextBuffer, cursor: Pos, motion: Motion) -> Pos {
74 let cursor = buffer.clamp_pos(cursor);
75
76 match motion {
77 Motion::Left => buffer.move_left(cursor),
78 Motion::Right => buffer.move_right(cursor),
79 Motion::Up => buffer.move_up(cursor),
80 Motion::Down => buffer.move_down(cursor),
81
82 Motion::FileStart => {
83 let target_col = if cursor.line == 0 { 0 } else { cursor.col };
84 buffer.clamp_pos(Pos::new(0, target_col))
85 }
86
87 Motion::FileEnd => {
88 let last = buffer.len_lines().saturating_sub(1);
89 buffer.clamp_pos(Pos::new(last, cursor.col))
90 }
91
92 Motion::LineStart => Pos::new(cursor.line, 0),
93
94 Motion::LineFirstNonWhitespace => {
95 let line = buffer.clamp_line(cursor.line);
96 Pos::new(line, buffer.line_first_non_whitespace_col(line))
97 }
98
99 Motion::LineEnd => {
100 let line = buffer.clamp_line(cursor.line);
101 let end_col = buffer.line_len_chars(line);
102 Pos::new(line, end_col)
103 }
104
105 Motion::WordStartBefore => buffer.word_start_before(cursor),
106
107 Motion::WordStartAfter => buffer.word_start_after(cursor),
108
109 Motion::WordEndAfter => buffer.word_end_after(cursor),
110
111 Motion::FindChar(needle) => buffer
112 .find_char_after_on_line(cursor, needle)
113 .unwrap_or(cursor),
114
115 Motion::TillChar(needle) => buffer
116 .find_char_after_on_line(cursor, needle)
117 .map(|target| {
118 if target.col > 0 {
119 Pos::new(target.line, target.col - 1)
120 } else {
121 target
122 }
123 })
124 .unwrap_or(cursor),
125 }
126}
127
128pub fn apply_motion_for_operator(
130 buffer: &TextBuffer,
131 cursor: Pos,
132 motion: Motion,
133 count: usize,
134) -> Pos {
135 match motion {
136 Motion::FindChar(needle) => {
137 let mut current = buffer.clamp_pos(cursor);
138 let mut target = None;
139 for _ in 0..count.max(1) {
140 let Some(found) = buffer.find_char_after_on_line(current, needle) else {
141 return cursor;
142 };
143 target = Some(found);
144 current = found;
145 }
146 target
147 .map(|found| buffer.move_right(found))
148 .unwrap_or(cursor)
149 }
150 Motion::TillChar(needle) => {
151 let mut current = buffer.clamp_pos(cursor);
152 let mut target = None;
153 for _ in 0..count.max(1) {
154 let Some(found) = buffer.find_char_after_on_line(current, needle) else {
155 return cursor;
156 };
157 target = Some(found);
158 current = found;
159 }
160 target.unwrap_or(cursor)
161 }
162 _ => apply_motion_n(buffer, cursor, motion, count),
163 }
164}
165
166pub fn apply_motion_n(buffer: &TextBuffer, cursor: Pos, motion: Motion, count: usize) -> Pos {
171 let mut cur = buffer.clamp_pos(cursor);
172 for _ in 0..count {
173 let next = apply_motion(buffer, cur, motion);
174 if next == cur {
176 break;
177 }
178 cur = next;
179 }
180 cur
181}
182
183pub mod helpers {
185 use super::{Motion, apply_motion_n};
186 use crate::{Pos, TextBuffer};
187
188 #[inline]
190 pub fn word_forward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
191 apply_motion_n(buffer, cursor, Motion::WordStartAfter, count)
192 }
193
194 #[inline]
196 pub fn word_backward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
197 apply_motion_n(buffer, cursor, Motion::WordStartBefore, count)
198 }
199
200 #[inline]
202 pub fn gg(buffer: &TextBuffer, cursor: Pos) -> Pos {
203 super::apply_motion(buffer, cursor, Motion::FileStart)
204 }
205
206 #[inline]
208 pub fn file_end(buffer: &TextBuffer, cursor: Pos) -> Pos {
209 super::apply_motion(buffer, cursor, Motion::FileEnd)
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn motion_count_zero_is_noop() {
219 let b = TextBuffer::from_str("abc\n");
220 let p = Pos::new(0, 2);
221 assert_eq!(apply_motion_n(&b, p, Motion::Left, 0), p);
222 assert_eq!(apply_motion_n(&b, p, Motion::WordEndAfter, 0), p);
223 }
224
225 #[test]
226 fn gg_goes_to_first_line_and_clamps_column() {
227 let b = TextBuffer::from_str("a\nbb\nccc\n");
228 let p = Pos::new(2, 2);
229 let p2 = apply_motion(&b, p, Motion::FileStart);
230 assert_eq!(p2.line, 0);
231 assert_eq!(p2.col, 1);
233 }
234
235 #[test]
236 fn file_end_goes_to_last_line_and_clamps_column() {
237 let b = TextBuffer::from_str("a\nbb\nccc\n");
240 let p = Pos::new(0, 10);
241 let p2 = apply_motion(&b, p, Motion::FileEnd);
242
243 assert_eq!(p2.line, 3);
245 assert_eq!(p2.col, 0);
246 }
247
248 #[test]
249 fn line_start_and_line_end_work() {
250 let b = TextBuffer::from_str(" hello\nworld!\n");
251 let p = Pos::new(1, 2);
252
253 let start = apply_motion(&b, p, Motion::LineStart);
254 assert_eq!(start, Pos::new(1, 0));
255
256 let end = apply_motion(&b, p, Motion::LineEnd);
257 assert_eq!(end, Pos::new(1, 6));
258
259 let first_non_whitespace = apply_motion(&b, Pos::new(0, 5), Motion::LineFirstNonWhitespace);
260 assert_eq!(first_non_whitespace, Pos::new(0, 2));
261 }
262
263 #[test]
264 fn first_non_whitespace_clamps_to_line_end_for_blank_lines() {
265 let b = TextBuffer::from_str(" \n\t\t\n");
266
267 assert_eq!(
268 apply_motion(&b, Pos::new(0, 0), Motion::LineFirstNonWhitespace),
269 Pos::new(0, 3)
270 );
271 assert_eq!(
272 apply_motion(&b, Pos::new(1, 0), Motion::LineFirstNonWhitespace),
273 Pos::new(1, 2)
274 );
275 }
276
277 #[test]
278 fn left_right_clamp_at_bounds() {
279 let b = TextBuffer::from_str("ab\n");
282 let p0 = Pos::new(0, 0);
283 assert_eq!(apply_motion(&b, p0, Motion::Left), Pos::new(0, 0));
284
285 let p_end = Pos::new(0, 2);
287 assert_eq!(apply_motion(&b, p_end, Motion::Right), Pos::new(1, 0));
288 }
289
290 #[test]
291 fn up_down_preserve_column_when_possible() {
292 let b = TextBuffer::from_str("aaaa\nb\ncccccc\n");
293 let p = Pos::new(0, 3);
294
295 let down = apply_motion(&b, p, Motion::Down);
296 assert_eq!(down, Pos::new(1, 1));
298
299 let down2 = apply_motion(&b, down, Motion::Down);
300 assert_eq!(down2, Pos::new(2, 1));
302
303 let up = apply_motion(&b, down2, Motion::Up);
304 assert_eq!(up, Pos::new(1, 1));
305 }
306
307 #[test]
308 fn word_motions_ascii_smoke() {
309 let b = TextBuffer::from_str("abc def_12!\n");
310 let p = Pos::new(0, 6); let start = apply_motion(&b, p, Motion::WordStartBefore);
313 assert_eq!(start, Pos::new(0, 5));
314
315 let end = apply_motion(&b, start, Motion::WordEndAfter);
316 assert_eq!(end, Pos::new(0, 10));
317 }
318
319 #[test]
320 fn repeated_word_forward_stops_at_eof() {
321 let b = TextBuffer::from_str("a b c\n");
322 let p = Pos::new(0, 0);
323 let p2 = apply_motion_n(&b, p, Motion::WordEndAfter, 100);
324
325 assert_eq!(p2, Pos::new(0, 5));
327 }
328
329 #[test]
330 fn word_start_after_visits_symbol_tokens() {
331 let b = TextBuffer::from_str("(normal/insert/command)\n");
332 let mut p = Pos::new(0, 0);
333
334 p = apply_motion(&b, p, Motion::WordStartAfter);
335 assert_eq!(p, Pos::new(0, 1)); p = apply_motion(&b, p, Motion::WordStartAfter);
338 assert_eq!(p, Pos::new(0, 7)); p = apply_motion(&b, p, Motion::WordStartAfter);
341 assert_eq!(p, Pos::new(0, 8)); p = apply_motion(&b, p, Motion::WordStartAfter);
344 assert_eq!(p, Pos::new(0, 14)); p = apply_motion(&b, p, Motion::WordStartAfter);
347 assert_eq!(p, Pos::new(0, 15)); p = apply_motion(&b, p, Motion::WordStartAfter);
350 assert_eq!(p, Pos::new(0, 22)); }
352
353 #[test]
354 fn word_start_before_stops_on_symbol_token() {
355 let b = TextBuffer::from_str("(normal/insert)\n");
356 let p = Pos::new(0, 15); let p2 = apply_motion(&b, p, Motion::WordStartBefore);
358 assert_eq!(p2, Pos::new(0, 14)); }
360
361 #[test]
362 fn word_end_after_can_land_on_symbol_token() {
363 let b = TextBuffer::from_str("alpha / beta\n");
364
365 let p = Pos::new(0, 0);
366 let p = apply_motion(&b, p, Motion::WordEndAfter);
367 assert_eq!(p, Pos::new(0, 4)); let p = apply_motion(&b, p, Motion::WordEndAfter);
370 assert_eq!(p, Pos::new(0, 6)); }
372
373 #[test]
374 fn find_and_till_char_stay_on_current_line() {
375 let b = TextBuffer::from_str("alpha beta alpha\n");
376 let cursor = Pos::new(0, 0);
377
378 assert_eq!(
379 apply_motion(&b, cursor, Motion::FindChar('b')),
380 Pos::new(0, 6)
381 );
382 assert_eq!(
383 apply_motion(&b, cursor, Motion::TillChar('b')),
384 Pos::new(0, 5)
385 );
386 assert_eq!(
387 apply_motion_n(&b, cursor, Motion::FindChar('a'), 2),
388 Pos::new(0, 9)
389 );
390 }
391
392 #[test]
393 fn operator_find_char_includes_the_target_character() {
394 let b = TextBuffer::from_str("alpha beta\n");
395 let cursor = Pos::new(0, 0);
396
397 assert_eq!(
398 apply_motion_for_operator(&b, cursor, Motion::FindChar('b'), 1),
399 Pos::new(0, 7)
400 );
401 assert_eq!(
402 apply_motion_for_operator(&b, cursor, Motion::TillChar('b'), 1),
403 Pos::new(0, 6)
404 );
405 }
406}