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}