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 FindCharBefore(char),
69
70 TillCharBefore(char),
72
73 MatchDelimiter,
75}
76
77#[inline]
82pub fn apply_motion(buffer: &TextBuffer, cursor: Pos, motion: Motion) -> Pos {
83 let cursor = buffer.clamp_pos(cursor);
84
85 match motion {
86 Motion::Left => buffer.move_left(cursor),
87 Motion::Right => buffer.move_right(cursor),
88 Motion::Up => buffer.move_up(cursor),
89 Motion::Down => buffer.move_down(cursor),
90
91 Motion::FileStart => {
92 let target_col = if cursor.line == 0 { 0 } else { cursor.col };
93 buffer.clamp_pos(Pos::new(0, target_col))
94 }
95
96 Motion::FileEnd => {
97 let last = buffer.len_lines().saturating_sub(1);
98 buffer.clamp_pos(Pos::new(last, cursor.col))
99 }
100
101 Motion::LineStart => Pos::new(cursor.line, 0),
102
103 Motion::LineFirstNonWhitespace => {
104 let line = buffer.clamp_line(cursor.line);
105 Pos::new(line, buffer.line_first_non_whitespace_col(line))
106 }
107
108 Motion::LineEnd => {
109 let line = buffer.clamp_line(cursor.line);
110 let end_col = buffer.line_len_chars(line);
111 Pos::new(line, end_col)
112 }
113
114 Motion::WordStartBefore => buffer.word_start_before(cursor),
115
116 Motion::WordStartAfter => buffer.word_start_after(cursor),
117
118 Motion::WordEndAfter => buffer.word_end_after(cursor),
119
120 Motion::FindChar(needle) => buffer
121 .find_char_after_on_line(cursor, needle)
122 .unwrap_or(cursor),
123
124 Motion::TillChar(needle) => buffer
125 .find_char_after_on_line(cursor, needle)
126 .map(|target| {
127 if target.col > 0 {
128 Pos::new(target.line, target.col - 1)
129 } else {
130 target
131 }
132 })
133 .unwrap_or(cursor),
134
135 Motion::FindCharBefore(needle) => buffer
136 .find_char_before_on_line(cursor, needle)
137 .unwrap_or(cursor),
138
139 Motion::TillCharBefore(needle) => buffer
140 .find_char_before_on_line(cursor, needle)
141 .map(|target| Pos::new(target.line, target.col.saturating_add(1)))
142 .unwrap_or(cursor),
143
144 Motion::MatchDelimiter => buffer.matching_delimiter(cursor).unwrap_or(cursor),
145 }
146}
147
148pub fn apply_motion_for_operator(
150 buffer: &TextBuffer,
151 cursor: Pos,
152 motion: Motion,
153 count: usize,
154) -> Pos {
155 match motion {
156 Motion::FindChar(needle) => {
157 let mut current = buffer.clamp_pos(cursor);
158 let mut target = None;
159 for _ in 0..count.max(1) {
160 let Some(found) = buffer.find_char_after_on_line(current, needle) else {
161 return cursor;
162 };
163 target = Some(found);
164 current = found;
165 }
166 target
167 .map(|found| buffer.move_right(found))
168 .unwrap_or(cursor)
169 }
170 Motion::TillChar(needle) => {
171 let mut current = buffer.clamp_pos(cursor);
172 let mut target = None;
173 for _ in 0..count.max(1) {
174 let Some(found) = buffer.find_char_after_on_line(current, needle) else {
175 return cursor;
176 };
177 target = Some(found);
178 current = found;
179 }
180 target.unwrap_or(cursor)
181 }
182 Motion::FindCharBefore(needle) => {
183 let mut current = buffer.clamp_pos(cursor);
184 let mut target = None;
185 for _ in 0..count.max(1) {
186 let Some(found) = buffer.find_char_before_on_line(current, needle) else {
187 return cursor;
188 };
189 target = Some(found);
190 current = found;
191 }
192 target.unwrap_or(cursor)
193 }
194 Motion::TillCharBefore(needle) => {
195 let mut current = buffer.clamp_pos(cursor);
196 let mut target = None;
197 for _ in 0..count.max(1) {
198 let Some(found) = buffer.find_char_before_on_line(current, needle) else {
199 return cursor;
200 };
201 let after_found = Pos::new(found.line, found.col.saturating_add(1));
202 target = Some(after_found);
203 current = found;
204 }
205 target.unwrap_or(cursor)
206 }
207 Motion::MatchDelimiter => {
208 let target = apply_motion(buffer, cursor, motion);
209 if target > cursor {
210 buffer.move_right(target)
211 } else {
212 target
213 }
214 }
215 _ => apply_motion_n(buffer, cursor, motion, count),
216 }
217}
218
219pub fn apply_motion_n(buffer: &TextBuffer, cursor: Pos, motion: Motion, count: usize) -> Pos {
224 if motion == Motion::MatchDelimiter {
225 return if count == 0 {
226 buffer.clamp_pos(cursor)
227 } else {
228 apply_motion(buffer, cursor, motion)
229 };
230 }
231
232 let mut cur = buffer.clamp_pos(cursor);
233 for _ in 0..count {
234 let next = apply_motion(buffer, cur, motion);
235 if next == cur {
237 break;
238 }
239 cur = next;
240 }
241 cur
242}
243
244pub mod helpers {
246 use super::{Motion, apply_motion_n};
247 use crate::{Pos, TextBuffer};
248
249 #[inline]
251 pub fn word_forward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
252 apply_motion_n(buffer, cursor, Motion::WordStartAfter, count)
253 }
254
255 #[inline]
257 pub fn word_backward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
258 apply_motion_n(buffer, cursor, Motion::WordStartBefore, count)
259 }
260
261 #[inline]
263 pub fn gg(buffer: &TextBuffer, cursor: Pos) -> Pos {
264 super::apply_motion(buffer, cursor, Motion::FileStart)
265 }
266
267 #[inline]
269 pub fn file_end(buffer: &TextBuffer, cursor: Pos) -> Pos {
270 super::apply_motion(buffer, cursor, Motion::FileEnd)
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn motion_count_zero_is_noop() {
280 let b = TextBuffer::from_str("abc\n");
281 let p = Pos::new(0, 2);
282 assert_eq!(apply_motion_n(&b, p, Motion::Left, 0), p);
283 assert_eq!(apply_motion_n(&b, p, Motion::WordEndAfter, 0), p);
284 }
285
286 #[test]
287 fn gg_goes_to_first_line_and_clamps_column() {
288 let b = TextBuffer::from_str("a\nbb\nccc\n");
289 let p = Pos::new(2, 2);
290 let p2 = apply_motion(&b, p, Motion::FileStart);
291 assert_eq!(p2.line, 0);
292 assert_eq!(p2.col, 1);
294 }
295
296 #[test]
297 fn file_end_goes_to_last_line_and_clamps_column() {
298 let b = TextBuffer::from_str("a\nbb\nccc\n");
301 let p = Pos::new(0, 10);
302 let p2 = apply_motion(&b, p, Motion::FileEnd);
303
304 assert_eq!(p2.line, 3);
306 assert_eq!(p2.col, 0);
307 }
308
309 #[test]
310 fn line_start_and_line_end_work() {
311 let b = TextBuffer::from_str(" hello\nworld!\n");
312 let p = Pos::new(1, 2);
313
314 let start = apply_motion(&b, p, Motion::LineStart);
315 assert_eq!(start, Pos::new(1, 0));
316
317 let end = apply_motion(&b, p, Motion::LineEnd);
318 assert_eq!(end, Pos::new(1, 6));
319
320 let first_non_whitespace = apply_motion(&b, Pos::new(0, 5), Motion::LineFirstNonWhitespace);
321 assert_eq!(first_non_whitespace, Pos::new(0, 2));
322 }
323
324 #[test]
325 fn first_non_whitespace_clamps_to_line_end_for_blank_lines() {
326 let b = TextBuffer::from_str(" \n\t\t\n");
327
328 assert_eq!(
329 apply_motion(&b, Pos::new(0, 0), Motion::LineFirstNonWhitespace),
330 Pos::new(0, 3)
331 );
332 assert_eq!(
333 apply_motion(&b, Pos::new(1, 0), Motion::LineFirstNonWhitespace),
334 Pos::new(1, 2)
335 );
336 }
337
338 #[test]
339 fn left_right_clamp_at_bounds() {
340 let b = TextBuffer::from_str("ab\n");
343 let p0 = Pos::new(0, 0);
344 assert_eq!(apply_motion(&b, p0, Motion::Left), Pos::new(0, 0));
345
346 let p_end = Pos::new(0, 2);
348 assert_eq!(apply_motion(&b, p_end, Motion::Right), Pos::new(1, 0));
349 }
350
351 #[test]
352 fn up_down_preserve_column_when_possible() {
353 let b = TextBuffer::from_str("aaaa\nb\ncccccc\n");
354 let p = Pos::new(0, 3);
355
356 let down = apply_motion(&b, p, Motion::Down);
357 assert_eq!(down, Pos::new(1, 1));
359
360 let down2 = apply_motion(&b, down, Motion::Down);
361 assert_eq!(down2, Pos::new(2, 1));
363
364 let up = apply_motion(&b, down2, Motion::Up);
365 assert_eq!(up, Pos::new(1, 1));
366 }
367
368 #[test]
369 fn word_motions_ascii_smoke() {
370 let b = TextBuffer::from_str("abc def_12!\n");
371 let p = Pos::new(0, 6); let start = apply_motion(&b, p, Motion::WordStartBefore);
374 assert_eq!(start, Pos::new(0, 5));
375
376 let end = apply_motion(&b, start, Motion::WordEndAfter);
377 assert_eq!(end, Pos::new(0, 10));
378 }
379
380 #[test]
381 fn repeated_word_forward_stops_at_eof() {
382 let b = TextBuffer::from_str("a b c\n");
383 let p = Pos::new(0, 0);
384 let p2 = apply_motion_n(&b, p, Motion::WordEndAfter, 100);
385
386 assert_eq!(p2, Pos::new(0, 5));
388 }
389
390 #[test]
391 fn word_start_after_visits_symbol_tokens() {
392 let b = TextBuffer::from_str("(normal/insert/command)\n");
393 let mut p = Pos::new(0, 0);
394
395 p = apply_motion(&b, p, Motion::WordStartAfter);
396 assert_eq!(p, Pos::new(0, 1)); p = apply_motion(&b, p, Motion::WordStartAfter);
399 assert_eq!(p, Pos::new(0, 7)); p = apply_motion(&b, p, Motion::WordStartAfter);
402 assert_eq!(p, Pos::new(0, 8)); p = apply_motion(&b, p, Motion::WordStartAfter);
405 assert_eq!(p, Pos::new(0, 14)); p = apply_motion(&b, p, Motion::WordStartAfter);
408 assert_eq!(p, Pos::new(0, 15)); p = apply_motion(&b, p, Motion::WordStartAfter);
411 assert_eq!(p, Pos::new(0, 22)); }
413
414 #[test]
415 fn word_start_before_stops_on_symbol_token() {
416 let b = TextBuffer::from_str("(normal/insert)\n");
417 let p = Pos::new(0, 15); let p2 = apply_motion(&b, p, Motion::WordStartBefore);
419 assert_eq!(p2, Pos::new(0, 14)); }
421
422 #[test]
423 fn word_end_after_can_land_on_symbol_token() {
424 let b = TextBuffer::from_str("alpha / beta\n");
425
426 let p = Pos::new(0, 0);
427 let p = apply_motion(&b, p, Motion::WordEndAfter);
428 assert_eq!(p, Pos::new(0, 4)); let p = apply_motion(&b, p, Motion::WordEndAfter);
431 assert_eq!(p, Pos::new(0, 6)); }
433
434 #[test]
435 fn find_and_till_char_stay_on_current_line() {
436 let b = TextBuffer::from_str("alpha beta alpha\n");
437 let cursor = Pos::new(0, 0);
438
439 assert_eq!(
440 apply_motion(&b, cursor, Motion::FindChar('b')),
441 Pos::new(0, 6)
442 );
443 assert_eq!(
444 apply_motion(&b, cursor, Motion::TillChar('b')),
445 Pos::new(0, 5)
446 );
447 assert_eq!(
448 apply_motion_n(&b, cursor, Motion::FindChar('a'), 2),
449 Pos::new(0, 9)
450 );
451 }
452
453 #[test]
454 fn backward_find_and_till_char_stay_on_current_line() {
455 let b = TextBuffer::from_str("alpha beta alpha\n");
456 let cursor = Pos::new(0, 15);
457
458 assert_eq!(
459 apply_motion(&b, cursor, Motion::FindCharBefore('b')),
460 Pos::new(0, 6)
461 );
462 assert_eq!(
463 apply_motion(&b, cursor, Motion::TillCharBefore('b')),
464 Pos::new(0, 7)
465 );
466 assert_eq!(
467 apply_motion_n(&b, cursor, Motion::FindCharBefore('a'), 2),
468 Pos::new(0, 9)
469 );
470 }
471
472 #[test]
473 fn match_delimiter_jumps_between_pair_endpoints() {
474 let b = TextBuffer::from_str("fn main() {\n call([x]);\n}\n");
475
476 assert_eq!(
477 apply_motion(&b, Pos::new(0, 7), Motion::MatchDelimiter),
478 Pos::new(0, 8)
479 );
480 assert_eq!(
481 apply_motion(&b, Pos::new(0, 8), Motion::MatchDelimiter),
482 Pos::new(0, 7)
483 );
484 assert_eq!(
485 apply_motion(&b, Pos::new(0, 10), Motion::MatchDelimiter),
486 Pos::new(2, 0)
487 );
488 assert_eq!(
489 apply_motion(&b, Pos::new(1, 9), Motion::MatchDelimiter),
490 Pos::new(1, 11)
491 );
492 }
493
494 #[test]
495 fn match_delimiter_supports_symmetric_and_angle_delimiters() {
496 let b = TextBuffer::from_str("let s = \"a \\\" b\"; let tag = <x>`tick`\n");
497
498 assert_eq!(
499 apply_motion(&b, Pos::new(0, 8), Motion::MatchDelimiter),
500 Pos::new(0, 15)
501 );
502 assert_eq!(
503 apply_motion(&b, Pos::new(0, 15), Motion::MatchDelimiter),
504 Pos::new(0, 8)
505 );
506 assert_eq!(
507 apply_motion(&b, Pos::new(0, 28), Motion::MatchDelimiter),
508 Pos::new(0, 30)
509 );
510 assert_eq!(
511 apply_motion(&b, Pos::new(0, 31), Motion::MatchDelimiter),
512 Pos::new(0, 36)
513 );
514 }
515
516 #[test]
517 fn operator_find_char_includes_the_target_character() {
518 let b = TextBuffer::from_str("alpha beta\n");
519 let cursor = Pos::new(0, 0);
520
521 assert_eq!(
522 apply_motion_for_operator(&b, cursor, Motion::FindChar('b'), 1),
523 Pos::new(0, 7)
524 );
525 assert_eq!(
526 apply_motion_for_operator(&b, cursor, Motion::TillChar('b'), 1),
527 Pos::new(0, 6)
528 );
529 }
530}