scrive_core/movement.rs
1//! Caret movement — the motions and the two combinators that apply them to a
2//! [`SelectionSet`].
3//!
4//! Motions are pure functions of the buffer and a position; the combinator
5//! [`move_selections`] maps one over every selection, either *moving* (collapse
6//! to a caret) or *extending* (drag the head, anchor fixed). Two details worth
7//! knowing: a plain horizontal move over a non-empty selection collapses to its
8//! near edge (not one char further), and vertical motion carries a **goal
9//! column** so travelling across short lines returns to the original column —
10//! the affinity editors are judged on.
11//!
12//! Horizontal positions here are byte columns, but the vertical **goal is a
13//! display cell**: the
14//! caret keeps its *visual* column through tab stops, collapsed inline folds,
15//! and collapsed block placeholders, resolved on landing by the same inverse
16//! projection a mouse click uses.
17
18use crate::buffer::Buffer;
19use crate::coords::{Bias, Point};
20use crate::display_map::{self, BufferRow, DisplayRow};
21use crate::fold_map::FoldMap;
22use crate::selection::SelectionSet;
23
24/// A caret motion.
25#[derive(Copy, Clone, PartialEq, Eq, Debug)]
26pub enum Motion {
27 /// One character left (wraps to the end of the previous line).
28 Left,
29 /// One character right (wraps to the start of the next line).
30 Right,
31 /// One line up, honoring the goal column.
32 Up,
33 /// One line down, honoring the goal column.
34 Down,
35 /// Up by a page — the given number of viewport rows (from the widget's
36 /// layout; the core has no viewport). Honors the goal column; past the top
37 /// lands at the document start.
38 PageUp(u32),
39 /// Down by a page — the given number of viewport rows. Past the bottom lands
40 /// at the document end.
41 PageDown(u32),
42 /// To the start of the word left of the caret.
43 WordLeft,
44 /// To the start/end of the word right of the caret.
45 WordRight,
46 /// Smart line start: toggles between the first non-whitespace column and
47 /// column 0.
48 LineStart,
49 /// To the end of the line.
50 LineEnd,
51 /// To the start of the document.
52 DocStart,
53 /// To the end of the document.
54 DocEnd,
55}
56
57/// Drag/selection granularity — the unit a click-drag extends by: single
58/// characters, whole words, or whole lines.
59#[derive(Copy, Clone, PartialEq, Eq, Debug)]
60pub enum Granularity {
61 /// Character (single-click drag).
62 Char,
63 /// Word (double-click drag).
64 Word,
65 /// Line, including the trailing newline (triple-click drag).
66 Line,
67}
68
69/// A direction for column (box) selection — Ctrl+Shift+Alt+Arrow. Up/
70/// Down move the active row; Left/Right move the active (virtual) column.
71#[derive(Copy, Clone, PartialEq, Eq, Debug)]
72pub enum ColumnDir {
73 /// Extend/shrink the box one row up.
74 Up,
75 /// Extend/shrink the box one row down.
76 Down,
77 /// Move the active column one cell left.
78 Left,
79 /// Move the active column one cell right (may go past a line's end).
80 Right,
81}
82
83/// Apply `motion` to every selection in `set`. With `extend`, each selection's
84/// head moves and its anchor stays (grow/shrink); without, each collapses to a
85/// caret at the motion target. Re-merges afterward (a move can make two
86/// selections coincide). `tab` is the document's tab-stop width — vertical
87/// motion keeps a *visual* goal column, so it needs the display projection.
88pub fn move_selections(set: &mut SelectionSet, buffer: &Buffer, folds: &FoldMap, tab: u32, motion: Motion, extend: bool) {
89 set.map_each(|s| {
90 let (target, goal) = motion_target(buffer, folds, tab, s.head(), s.goal, motion);
91 if extend {
92 s.set_head(target);
93 s.goal = goal;
94 } else if !s.is_empty() && matches!(motion, Motion::Left | Motion::Right) {
95 // Plain horizontal move collapses to the near edge of the selection.
96 let edge = if motion == Motion::Left { s.start() } else { s.end() };
97 s.move_to_caret(edge);
98 } else {
99 s.move_to_caret(target);
100 s.goal = goal;
101 }
102 });
103}
104
105/// Compute a motion's target offset and the goal *display cell* it should
106/// leave behind (`Some` only for vertical motions, which preserve it;
107/// everything else clears it by returning `None`).
108fn motion_target(buffer: &Buffer, folds: &FoldMap, tab: u32, head: u32, goal: Option<u32>, motion: Motion) -> (u32, Option<u32>) {
109 match motion {
110 Motion::Left => (char_left(buffer, folds, head), None),
111 Motion::Right => (char_right(buffer, folds, head), None),
112 Motion::Up => vertical_by(buffer, folds, tab, head, goal, -1),
113 Motion::Down => vertical_by(buffer, folds, tab, head, goal, 1),
114 Motion::PageUp(rows) => vertical_by(buffer, folds, tab, head, goal, -(rows as i32)),
115 Motion::PageDown(rows) => vertical_by(buffer, folds, tab, head, goal, rows as i32),
116 Motion::WordLeft => (skip_fold_left(buffer, folds, word_left(buffer, head)), None),
117 Motion::WordRight => (skip_fold_right(buffer, folds, word_right(buffer, head)), None),
118 Motion::LineStart => (line_start_smart_folded(buffer, folds, head), None),
119 Motion::LineEnd => (line_end_folded(buffer, folds, head), None),
120 Motion::DocStart => (0, None),
121 Motion::DocEnd => (buffer.len(), None),
122 }
123}
124
125/// Previous character boundary over the whole text (crosses `\n`), hopping any
126/// collapsed fold whose gap it would land in.
127fn char_left(buffer: &Buffer, folds: &FoldMap, offset: u32) -> u32 {
128 if offset == 0 {
129 return 0;
130 }
131 let prev = buffer.char_before(offset).expect("offset > 0");
132 skip_fold_left(buffer, folds, offset - prev.len_utf8() as u32)
133}
134
135/// Next character boundary over the whole text (crosses `\n`), hopping any
136/// collapsed fold whose gap it would land in.
137fn char_right(buffer: &Buffer, folds: &FoldMap, offset: u32) -> u32 {
138 if offset >= buffer.len() {
139 return buffer.len();
140 }
141 let next = buffer.char_at(offset).expect("offset < len");
142 skip_fold_right(buffer, folds, offset + next.len_utf8() as u32)
143}
144
145/// The collapsed fold whose *hidden* interior `off` falls in — `Some((header,
146/// last))` only when `off` is genuinely hidden (not on the header line and not in
147/// the visible closing tail, i.e. row `last` at/after its leading whitespace).
148/// `None` for any visible position, so movement over unfolded text is untouched.
149fn hidden_fold(buffer: &Buffer, folds: &FoldMap, off: u32) -> Option<(u32, u32)> {
150 let p = buffer.offset_to_point(off);
151 let (header, last) = folds.fold_containing(BufferRow(p.row))?;
152 // The closing tail (row `last`, col ≥ its leading whitespace) is visible —
153 // a collapsed block renders as `head … tail`, so positions on the tail from
154 // its indent onward are on screen and never count as hidden.
155 if p.row == last.0 && p.col >= crate::row_layout::tail_start_col(&buffer.line(last.0)) {
156 return None;
157 }
158 Some((header.0, last.0))
159}
160
161/// The inline (single-line) fold whose collapsed interior `off` falls strictly
162/// inside ([`InlineFold::hides_caret_at`]): the edges `open+1` and `close` stay
163/// landable, everything between hides.
164fn inline_hidden(folds: &FoldMap, off: u32) -> Option<crate::fold_map::InlineFold> {
165 // The only fold that can hide `off` is the last one opening before it (roots
166 // are disjoint) — an O(log) tree lookup, not an O(inline folds) scan on every
167 // caret motion × N carets.
168 folds.inline_fold_before(off).filter(|f| f.hides_caret_at(off))
169}
170
171/// Snap a left-moving target out of a collapsed gap to the gap's *entry* edge —
172/// a block's header-line end, or an inline fold's left landable edge. A no-op
173/// for visible positions.
174fn skip_fold_left(buffer: &Buffer, folds: &FoldMap, off: u32) -> u32 {
175 if let Some((header, _)) = hidden_fold(buffer, folds, off) {
176 return buffer.point_to_offset(Point::new(header, buffer.line_len(header)));
177 }
178 inline_hidden(folds, off).map_or(off, |f| f.left_edge())
179}
180
181/// Snap a right-moving target out of a collapsed gap to the gap's *exit* edge —
182/// a block's closing-tail first column, or an inline fold's right landable
183/// edge. A no-op for visible positions.
184fn skip_fold_right(buffer: &Buffer, folds: &FoldMap, off: u32) -> u32 {
185 if let Some((_, last)) = hidden_fold(buffer, folds, off) {
186 return buffer.point_to_offset(Point::new(last, crate::row_layout::tail_start_col(&buffer.line(last))));
187 }
188 inline_hidden(folds, off).map_or(off, |f| f.right_edge())
189}
190
191/// Vertical motion by a signed row `delta` (±1 for Up/Down, ±page for the Page
192/// moves), honoring a *visual* goal column — a display cell, not a byte column:
193/// the caret keeps its on-screen column through tab stops, collapsed
194/// inline folds, and a collapsed block's one-line placeholder. Stepping happens
195/// in DISPLAY rows (a folded interior is not a display row, so the caret can
196/// never land inside a fold), and the landing resolves through the standard
197/// inverse projection ([`FoldMap::hit_row`]) — exactly like a click at the goal
198/// cell: a goal in a collapsed header's gap clamps to the header's end, one
199/// over the tail lands on the tail's real offset, one on a chip snaps to its
200/// landable left edge, one mid-tab snaps by bias, and past-EOL clamps.
201/// Overshooting the top lands at the document start, the bottom at the
202/// document end — so a single-row move at an edge lands on the nearest document
203/// end, and page moves that overshoot collapse to those same ends.
204fn vertical_by(buffer: &Buffer, folds: &FoldMap, tab: u32, offset: u32, goal: Option<u32>, delta: i32) -> (u32, Option<u32>) {
205 // The caret's display position (fold/tab aware). A caret can only rest on
206 // a visible slot, but fall back to the raw expansion defensively.
207 let (row, cell) = match folds.display_position(buffer, offset, tab) {
208 Some(p) => (p.row, p.x.cells()),
209 None => {
210 let p = buffer.offset_to_point(offset);
211 (folds.to_display_row(BufferRow(p.row)), display_map::expand(&buffer.line(p.row), p.col, tab) as f32)
212 }
213 };
214 let goal_cell = goal.unwrap_or_else(|| cell.round() as u32);
215 let target = row.0 as i32 + delta;
216 if target < 0 {
217 return (0, Some(goal_cell)); // past the top → document start
218 }
219 let last_display = folds.display_row_count().saturating_sub(1) as i32;
220 if target > last_display {
221 return (buffer.len(), Some(goal_cell)); // past the bottom → document end
222 }
223 let new_row = folds.to_buffer_row(DisplayRow(target as u32));
224 let off = folds.hit_row(buffer, new_row, goal_cell as f32, Bias::Left, tab);
225 (off, Some(goal_cell))
226}
227
228/// The offset one display row above (`delta = -1`) / below (`+1`) `offset`, at
229/// its visual column — `None` when `offset` is already on the first/last
230/// display row (nothing to add there). The landing for add-cursor-above/below:
231/// one call into [`vertical_by`], so tabs, chips, and collapsed headers
232/// resolve exactly as plain vertical movement does.
233pub(crate) fn caret_one_display_row(
234 buffer: &Buffer,
235 folds: &FoldMap,
236 tab: u32,
237 offset: u32,
238 delta: i32,
239) -> Option<u32> {
240 let (off, _) = vertical_by(buffer, folds, tab, offset, None, delta);
241 // vertical_by clamps an over-the-edge step to the doc start/end — which
242 // stays on the source's display row; reject that instead of adding there.
243 let row_of = |o: u32| folds.to_display_row(BufferRow(buffer.offset_to_point(o).row));
244 (row_of(off) != row_of(offset)).then_some(off)
245}
246
247/// The end of the line the caret is on (excludes the `\n`).
248fn line_end(buffer: &Buffer, offset: u32) -> u32 {
249 let row = buffer.offset_to_point(offset).row;
250 buffer.point_to_offset(Point::new(row, buffer.line_len(row)))
251}
252
253/// End of the caret's DISPLAY line: on a collapsed block's header, the
254/// visible line reads `fn main() { … }` — End goes past the closing tail
255/// (the *last* row's end), not to the header text's own end mid-placeholder.
256/// Everywhere else (including the tail itself, whose buffer row IS the last
257/// row) this is the plain line end.
258fn line_end_folded(buffer: &Buffer, folds: &FoldMap, offset: u32) -> u32 {
259 let row = buffer.offset_to_point(offset).row;
260 if let Some(last) = folds.fold_at_header(BufferRow(row)) {
261 return buffer.point_to_offset(Point::new(last.0, buffer.line_len(last.0)));
262 }
263 line_end(buffer, offset)
264}
265
266/// Smart Home: the first non-whitespace column, unless already there, in which
267/// case column 0.
268fn line_start_smart(buffer: &Buffer, offset: u32) -> u32 {
269 let p = buffer.offset_to_point(offset);
270 line_start_smart_on(buffer, p.row, p.col)
271}
272
273/// [`line_start_smart`] against an explicit `(row, col)` — the col decides the
274/// toggle, the row supplies the line (they differ for a collapsed tail, whose
275/// display line is the header's).
276fn line_start_smart_on(buffer: &Buffer, row: u32, col: u32) -> u32 {
277 let line = buffer.line(row);
278 let indent = crate::row_layout::tail_start_col(&line);
279 let line_start = buffer.point_to_offset(Point::new(row, 0));
280 if col == indent {
281 line_start // toggle back to column 0
282 } else {
283 line_start + indent
284 }
285}
286
287/// Start of the caret's DISPLAY line: from a collapsed fold's closing
288/// tail (which rides the header's display line), Home goes to the *header's*
289/// smart start — the visible line's beginning — not the hidden last row's own
290/// indent. Everywhere else this is the plain smart Home.
291fn line_start_smart_folded(buffer: &Buffer, folds: &FoldMap, offset: u32) -> u32 {
292 let p = buffer.offset_to_point(offset);
293 if let Some(hdr) = folds.header_of_tail(BufferRow(p.row)) {
294 // A tail col can never equal the header's indent-col compare target
295 // meaningfully — pass a sentinel col that always lands on the header's
296 // first non-whitespace (the second press, now ON the header, toggles).
297 return line_start_smart_on(buffer, hdr.0, u32::MAX);
298 }
299 line_start_smart(buffer, offset)
300}
301
302/// Character class for word-boundary detection.
303/// Ordered `Whitespace < Punct < Word` so `max(kind(prev), kind(next))` lets a
304/// word char win at a boundary — the tie-break [`surrounding_word`] needs.
305#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
306enum CharKind {
307 Whitespace,
308 Punct,
309 Word,
310}
311
312/// Whether `c` is a word character — alphanumeric or `_`. The ONE owner of the
313/// word-boundary rule: word motion ([`char_kind`]) and the auto-close
314/// quote guard ([`crate::autoclose::is_word_char`], which delegates here) share
315/// it, so they cannot disagree about where a word ends. (Completion's word rule
316/// is deliberately *wider* — it admits `-` for kebab identifiers — and stays
317/// distinct: see [`crate::intel::providers::is_completion_word_char`].)
318pub(crate) fn is_word_char(c: char) -> bool {
319 c.is_alphanumeric() || c == '_'
320}
321
322fn char_kind(c: char) -> CharKind {
323 if c.is_whitespace() {
324 CharKind::Whitespace
325 } else if is_word_char(c) {
326 CharKind::Word
327 } else {
328 CharKind::Punct
329 }
330}
331
332/// Word-left: skip whitespace, then consume a run of one non-whitespace kind.
333fn word_left(buffer: &Buffer, offset: u32) -> u32 {
334 // Single-char reads: the scan never materializes the whole line's text.
335 let prev = |o: usize| buffer.char_before(o as u32);
336 let mut o = offset as usize;
337 // Cross AT MOST one line boundary: at a line start, step to the previous
338 // line's end, then find that line's word. A caret at a line start has `\n`
339 // immediately behind it.
340 if prev(o) == Some('\n') {
341 o -= 1; // → previous line end
342 if o == 0 || prev(o) == Some('\n') {
343 return o as u32; // empty previous line (or doc start) → stop there
344 }
345 }
346 // Same line: skip trailing whitespace, then consume one character-kind run —
347 // never crossing another `\n`.
348 while let Some(c) = prev(o).filter(|&c| c != '\n' && c.is_whitespace()) {
349 o -= c.len_utf8();
350 }
351 if let Some(c) = prev(o).filter(|&c| c != '\n') {
352 let kind = char_kind(c);
353 while let Some(c) = prev(o).filter(|&c| c != '\n' && char_kind(c) == kind) {
354 o -= c.len_utf8();
355 }
356 }
357 o as u32
358}
359
360/// Word-right: skip whitespace, then consume a run of one non-whitespace kind.
361fn word_right(buffer: &Buffer, offset: u32) -> u32 {
362 let len = buffer.len() as usize;
363 let next = |o: usize| buffer.char_at(o as u32);
364 let mut o = offset as usize;
365 // Cross AT MOST one line boundary: at a line end, step to the next line's
366 // start, then find that line's word. A caret at a line end has `\n`
367 // immediately ahead of it.
368 if next(o) == Some('\n') {
369 o += 1; // → next line start
370 if o == len || next(o) == Some('\n') {
371 return o as u32; // empty next line (or doc end) → stop there
372 }
373 }
374 // Same line: skip leading whitespace, then consume one kind-run to its end.
375 while let Some(c) = next(o).filter(|&c| c != '\n' && c.is_whitespace()) {
376 o += c.len_utf8();
377 }
378 if let Some(c) = next(o).filter(|&c| c != '\n') {
379 let kind = char_kind(c);
380 while o < len {
381 match next(o).filter(|&c| c != '\n' && char_kind(c) == kind) {
382 Some(c) => o += c.len_utf8(),
383 None => break,
384 }
385 }
386 }
387 o as u32
388}
389
390/// Word-delete target *left* of `caret` (Ctrl+Backspace), matching the
391/// word-deletion behaviour of mainstream editors:
392///
393/// 1. The newline is a hard stop: at column 0 (char before the caret is `\n`)
394/// return `caret − 1`, deleting *only* the newline, and the scan otherwise
395/// never crosses a `\n`.
396/// 2. **Whitespace heuristic**: if **two or more** non-newline whitespace chars
397/// sit immediately before the caret, delete only that whitespace run — never
398/// the preceding word too. (One or zero → fall through to word deletion,
399/// which does eat the trailing single space.)
400/// 3. Otherwise delete back to the previous word start: the ≤1 whitespace plus
401/// one character-kind run, never crossing `\n`.
402pub(crate) fn word_delete_left(buffer: &Buffer, caret: u32) -> u32 {
403 let mut o = caret as usize;
404 let prev = |o: usize| buffer.char_before(o as u32);
405 if prev(o) == Some('\n') {
406 return (o - 1) as u32; // (1) at column 0, delete just the newline
407 }
408 // Measure the non-newline whitespace run immediately before the caret.
409 let (mut ws, mut ws_chars) = (o, 0u32);
410 while let Some(c) = prev(ws).filter(|&c| c != '\n' && c.is_whitespace()) {
411 ws -= c.len_utf8();
412 ws_chars += 1;
413 }
414 if ws_chars >= 2 {
415 return ws as u32; // (2) whitespace heuristic: delete only the run
416 }
417 // (3) delete the ≤1 whitespace plus one kind-run, never crossing \n.
418 o = ws;
419 if let Some(c) = prev(o).filter(|&c| c != '\n') {
420 let kind = char_kind(c);
421 while let Some(c) = prev(o).filter(|&c| c != '\n' && char_kind(c) == kind) {
422 o -= c.len_utf8();
423 }
424 }
425 o as u32
426}
427
428/// Word-delete target *right* of `caret` (Ctrl+Delete) — the
429/// [`word_delete_left`] mirror: at end of line (`\n` ahead) delete only the
430/// newline; the whitespace heuristic deletes a run of 2+ leading whitespace by
431/// itself; otherwise delete forward to the next word end.
432pub(crate) fn word_delete_right(buffer: &Buffer, caret: u32) -> u32 {
433 let len = buffer.len() as usize;
434 let mut o = caret as usize;
435 let next = |o: usize| buffer.char_at(o as u32);
436 if next(o) == Some('\n') {
437 return (o + 1) as u32; // at EOL, delete just the newline
438 }
439 let (mut ws, mut ws_chars) = (o, 0u32);
440 while let Some(c) = next(ws).filter(|&c| c != '\n' && c.is_whitespace()) {
441 ws += c.len_utf8();
442 ws_chars += 1;
443 }
444 if ws_chars >= 2 {
445 return ws as u32; // whitespace heuristic
446 }
447 o = ws;
448 if let Some(c) = next(o).filter(|&c| c != '\n') {
449 let kind = char_kind(c);
450 while o < len {
451 match next(o).filter(|&c| c != '\n' && char_kind(c) == kind) {
452 Some(c) => o += c.len_utf8(),
453 None => break,
454 }
455 }
456 }
457 o as u32
458}
459
460/// The word range surrounding `offset`: the run of one non-whitespace
461/// [`CharKind`] touching the caret. Kind is `max(kind(prev), kind(next))` so a
462/// caret at a word/punctuation boundary prefers the word; the run never
463/// crosses a `\n` (whitespace, so already a different kind). Returns `None` when
464/// the caret sits in whitespace or the buffer is empty — Ctrl+D then has nothing
465/// to select.
466pub(crate) fn surrounding_word(buffer: &Buffer, offset: u32) -> Option<(u32, u32)> {
467 let prev = |o: usize| buffer.char_before(o as u32);
468 let next = |o: usize| buffer.char_at(o as u32);
469 let o = offset as usize;
470 let kind = match (prev(o).map(char_kind), next(o).map(char_kind)) {
471 (Some(a), Some(b)) => a.max(b),
472 (Some(a), None) => a,
473 (None, Some(b)) => b,
474 (None, None) => return None,
475 };
476 if kind == CharKind::Whitespace {
477 return None;
478 }
479 let mut start = o;
480 while let Some(c) = prev(start) {
481 if char_kind(c) == kind {
482 start -= c.len_utf8();
483 } else {
484 break;
485 }
486 }
487 let mut end = o;
488 while let Some(c) = next(end) {
489 if char_kind(c) == kind {
490 end += c.len_utf8();
491 } else {
492 break;
493 }
494 }
495 Some((start as u32, end as u32))
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::coords::Point;
502
503 fn buf(s: &str) -> Buffer {
504 Buffer::new(s).unwrap()
505 }
506
507 /// `move_selections` with no folds (identity) — the movement tests don't
508 /// exercise folding; the fold-aware vertical path is covered in `fold_map` /
509 /// `document` tests.
510 fn mv(set: &mut SelectionSet, b: &Buffer, motion: Motion, extend: bool) {
511 use crate::fold_map::{FoldMap, FoldSet};
512 move_selections(
513 set,
514 b,
515 &FoldMap::new(&FoldSet::new(), &crate::bracket::Brackets::default(), b),
516 display_map::default_tab_size(),
517 motion,
518 extend,
519 );
520 }
521
522 /// Offset of `(row, col)`.
523 fn at(b: &Buffer, row: u32, col: u32) -> u32 {
524 b.point_to_offset(Point::new(row, col))
525 }
526
527 fn head_point(set: &SelectionSet, b: &Buffer) -> Point {
528 b.offset_to_point(set.all()[0].head())
529 }
530
531 #[test]
532 fn left_right_wrap_across_lines() {
533 let b = buf("ab\ncd");
534 let mut set = SelectionSet::new(at(&b, 1, 0)); // start of "cd"
535 mv(&mut set, &b, Motion::Left, false);
536 assert_eq!(head_point(&set, &b), Point::new(0, 2)); // end of "ab"
537 mv(&mut set, &b, Motion::Right, false);
538 assert_eq!(head_point(&set, &b), Point::new(1, 0)); // back to start of "cd"
539 }
540
541 #[test]
542 fn inline_hidden_binary_search_picks_the_right_fold() {
543 use crate::document::Document;
544 // Three inline pairs on three rows, all collapsed. `inline_hidden` binary-
545 // searches the opener-sorted set: an interior offset resolves to *its own*
546 // fold, and the edges / gaps between folds resolve to None — the
547 // partition_point boundary, not a linear find. Offsets: row0 `[`=1 `]`=5,
548 // row1 `[`=9 `]`=13, row2 `[`=17 `]`=21.
549 let text = "a[bcd]e\nf[ghi]j\nk[lmn]o\n";
550 let mut doc = Document::new(text).unwrap();
551 for open in [1u32, 9, 17] {
552 assert!(doc.toggle_fold_opener(open), "pair at {open} folds");
553 }
554 let fm = doc.fold_map();
555 // Interior of the second fold resolves to *that* fold (open == 9), not the
556 // first or third — the off-by-one a linear `.find` can't get wrong but a
557 // partition_point can.
558 assert_eq!(inline_hidden(&fm, 11).map(|f| f.open), Some(9));
559 assert_eq!(inline_hidden(&fm, 3).map(|f| f.open), Some(1));
560 assert_eq!(inline_hidden(&fm, 19).map(|f| f.open), Some(17));
561 // Before all folds, in a gap after a closer, and at a row start before its
562 // fold all resolve to nothing.
563 assert_eq!(inline_hidden(&fm, 0), None);
564 assert_eq!(inline_hidden(&fm, 6), None);
565 assert_eq!(inline_hidden(&fm, 8), None);
566 }
567
568 #[test]
569 fn plain_horizontal_move_collapses_selection_to_edge() {
570 let b = buf("hello");
571 let mut set = SelectionSet::new(0);
572 set.set_single(crate::Selection::from_anchor(crate::SelectionId(0), 1, 4));
573 mv(&mut set, &b, Motion::Left, false);
574 assert!(set.all()[0].is_empty());
575 assert_eq!(set.all()[0].head(), 1); // near (left) edge, not 0
576 }
577
578 #[test]
579 fn vertical_move_preserves_goal_column_over_short_lines() {
580 // Column 5 on line 0, down through a 2-char line, down to a long line:
581 // the caret returns to column 5.
582 let b = buf("0123456789\nab\nlongenough");
583 let mut set = SelectionSet::new(at(&b, 0, 5));
584 mv(&mut set, &b, Motion::Down, false);
585 assert_eq!(head_point(&set, &b), Point::new(1, 2)); // clamped to "ab"
586 mv(&mut set, &b, Motion::Down, false);
587 assert_eq!(head_point(&set, &b), Point::new(2, 5)); // goal 5 restored
588 }
589
590 #[test]
591 fn page_moves_jump_by_rows_and_clamp_to_the_document_ends() {
592 let b = Buffer::new("l0\nl1\nl2\nl3\nl4").unwrap(); // 5 rows
593 let mut set = SelectionSet::new(0); // caret at (0,0)
594 mv(&mut set, &b, Motion::PageDown(2), false);
595 assert_eq!(b.offset_to_point(set.newest().head()).row, 2);
596 mv(&mut set, &b, Motion::PageDown(10), false); // overshoot ↓
597 assert_eq!(set.newest().head(), b.len(), "clamps to the document end");
598 mv(&mut set, &b, Motion::PageUp(10), false); // overshoot ↑
599 assert_eq!(set.newest().head(), 0, "clamps to the document start");
600 }
601
602 #[test]
603 fn word_delete_targets_stop_at_newlines() {
604 let b = Buffer::new("foo bar\nbaz").unwrap(); // f0..r6 \n7 b8 a9 z10, len 11
605 assert_eq!(word_delete_left(&b, 11), 8); // deletes "baz"
606 assert_eq!(word_delete_left(&b, 8), 7); // at col 0, only the newline
607 assert_eq!(word_delete_right(&b, 0), 3); // deletes "foo"
608 assert_eq!(word_delete_right(&b, 7), 8); // at EOL, only the newline
609 }
610
611 #[test]
612 fn word_delete_eats_only_a_multi_space_run() {
613 // 2+ whitespace before/after the caret → delete ONLY the whitespace run.
614 let b = Buffer::new("foo bar").unwrap(); // f0 o1 o2 sp3 sp4 sp5 b6 a7 r8
615 assert_eq!(word_delete_left(&b, 6), 3); // "foo |bar" → deletes " ", not the word
616 assert_eq!(word_delete_right(&b, 3), 6); // "foo| bar" → deletes " " forward
617 // Exactly one whitespace → fall through to word delete (eats the space too).
618 let b1 = Buffer::new("foo bar").unwrap();
619 assert_eq!(word_delete_left(&b1, 4), 0); // "foo |bar" → deletes "foo "
620 assert_eq!(word_delete_right(&b1, 3), 7); // "foo| bar" → deletes " bar"
621 // Trailing whitespace run at EOL: delete just the run, keep the word.
622 let b2 = Buffer::new("foo ").unwrap();
623 assert_eq!(word_delete_left(&b2, 5), 3); // "foo |" → deletes " "
624 }
625
626 #[test]
627 fn word_motion_stops_at_boundaries() {
628 let b = buf("foo bar_baz qux");
629 let mut set = SelectionSet::new(0);
630 mv(&mut set, &b, Motion::WordRight, false);
631 assert_eq!(set.all()[0].head(), 3); // after "foo"
632 mv(&mut set, &b, Motion::WordRight, false);
633 assert_eq!(set.all()[0].head(), 11); // after "bar_baz" (one word run)
634 mv(&mut set, &b, Motion::WordLeft, false);
635 assert_eq!(set.all()[0].head(), 4); // back to start of "bar_baz"
636 }
637
638 #[test]
639 fn word_motion_crosses_one_line_boundary_then_finds_the_word() {
640 // At a line edge, word motion steps ONE line and finds that line's
641 // word. "foo\nbar" — f0 o1 o2 \n3 b4 a5 r6.
642 let b = buf("foo\nbar");
643 assert_eq!(word_left(&b, 4), 0); // "bar" start → "foo" start (prev line's word)
644 assert_eq!(word_right(&b, 3), 7); // "foo" end → "bar" end (next line's word)
645 }
646
647 #[test]
648 fn word_motion_stops_on_a_blank_line() {
649 // A blank line is crossed at most once: motion STOPS on it (never skips
650 // straight to the far word). "foo\n\nbar" — f0 o1 o2 \n3 \n4 b5 a6 r7.
651 let b = buf("foo\n\nbar");
652 assert_eq!(word_left(&b, 5), 4); // "bar" start → the blank line, not "foo"
653 assert_eq!(word_right(&b, 3), 4); // "foo" end → the blank line, not "bar"
654 }
655
656 #[test]
657 fn smart_home_toggles() {
658 let b = buf(" indented");
659 let mut set = SelectionSet::new(at(&b, 0, 12)); // end of line
660 mv(&mut set, &b, Motion::LineStart, false);
661 assert_eq!(head_point(&set, &b), Point::new(0, 4)); // first non-ws
662 mv(&mut set, &b, Motion::LineStart, false);
663 assert_eq!(head_point(&set, &b), Point::new(0, 0)); // toggle to col 0
664 mv(&mut set, &b, Motion::LineStart, false);
665 assert_eq!(head_point(&set, &b), Point::new(0, 4)); // and back
666 }
667
668 #[test]
669 fn extend_moves_head_keeps_anchor() {
670 let b = buf("hello world");
671 let mut set = SelectionSet::new(0);
672 mv(&mut set, &b, Motion::WordRight, true); // extend over "hello"
673 mv(&mut set, &b, Motion::WordRight, true); // and " world"
674 let s = &set.all()[0];
675 assert_eq!((s.start(), s.end()), (0, 11));
676 assert!(!s.is_empty());
677 }
678
679 #[test]
680 fn surrounding_word_selects_the_word_at_the_caret() {
681 let b = buf("foo bar_baz qux");
682 assert_eq!(surrounding_word(&b, 6), Some((4, 11))); // inside "bar_baz"
683 assert_eq!(surrounding_word(&b, 4), Some((4, 11))); // at its start boundary
684 assert_eq!(surrounding_word(&b, 11), Some((4, 11))); // at its end boundary
685 assert_eq!(surrounding_word(&b, 12), None); // in whitespace → no word
686 }
687
688 #[test]
689 fn surrounding_word_stops_at_newline_and_picks_punct() {
690 let b = buf("ab\n++");
691 assert_eq!(surrounding_word(&b, 2), Some((0, 2))); // "ab", never crossing \n
692 assert_eq!(surrounding_word(&b, 4), Some((3, 5))); // the "++" punctuation run
693 }
694
695 #[test]
696 fn multi_cursor_moves_and_remerges() {
697 let b = buf("a b c");
698 let mut set = SelectionSet::new(0);
699 set.add_caret(2); // carets at 0 and 2
700 set.add_caret(4); // and 4
701 assert_eq!(set.len(), 3);
702 // Move all left: 0→0, 2→1, 4→3 — still distinct.
703 mv(&mut set, &b, Motion::Left, false);
704 assert_eq!(set.len(), 3);
705 // Home collapses every caret to column 0 → they merge into one.
706 mv(&mut set, &b, Motion::LineStart, false);
707 assert_eq!(set.len(), 1);
708 }
709}