Skip to main content

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}