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