Skip to main content

hjkl_engine/
selection_shift.rs

1//! Keep selections valid across an edit (#63).
2//!
3//! Multi-cursor edits cascade: an edit made at selection N moves every position
4//! after it, so the other selections have to be rewritten or they silently point
5//! at the wrong text. This module is that rewrite, as a pure function over
6//! `(Position, Edit)` — no editor, no buffer mutation.
7//!
8//! # Contract
9//!
10//! [`shift_position`] is called with the **pre-edit** buffer geometry, i.e.
11//! before `edit` is applied. It answers: where does this selection end up once
12//! the edit lands?
13//!
14//! It returns `Option`, and `None` means **"this position cannot be tracked
15//! through this edit — drop it"**. That is deliberate. The alternative for an
16//! edit whose geometry we do not model exactly is to guess, and a guessed
17//! position is a selection pointing at the wrong text: the edit still applies,
18//! just somewhere the user did not ask for. Dropping degrades multi-cursor to
19//! single-cursor, which is visible and harmless; guessing corrupts the buffer,
20//! which is neither.
21//!
22//! Today `None` is returned only for `SplitLines`, which is the undo-inverse of
23//! a join: it is emitted when history rewinds, not when a user edits, and undo
24//! restores a whole snapshot without preserving secondary carets anyway.
25//!
26//! `JoinLines`, `InsertBlock` and `DeleteBlockChunks` ARE modelled — they mirror
27//! `hjkl_buffer`'s own geometry, and they matter: vim's `J` is a `JoinLines`, and
28//! visual-block `I`/`A` are the block edits, so dropping carets on those would
29//! make multi-cursor collapse under exactly the operations that need it most.
30//!
31//! # Position semantics
32//!
33//! A position exactly at an insertion point moves right (the text lands before
34//! it). A position strictly inside a deleted range collapses to the range start.
35
36use hjkl_buffer::{Edit, MotionKind, Position};
37
38/// One selection: an `anchor` (the fixed end) and a `head` (the end a motion
39/// moves). Both are **inclusive** char positions — `anchor == head` is a bare
40/// caret, and a selection with extent covers `[start, end]` inclusive of both.
41///
42/// # Units
43///
44/// Char columns, like [`hjkl_buffer::Edit`] and `View::cursor` — NOT the
45/// grapheme columns of [`crate::types::Pos`]. Mixing the two is silently wrong
46/// on multi-byte text.
47///
48/// # Why the engine owns the anchor
49///
50/// A discipline could keep its own `Vec` of anchors beside `Editor`'s secondary
51/// carets, but [`shift_position`] may DROP a caret it cannot track, and a
52/// parallel `Vec` would then desync: anchors and heads would pair up wrong and
53/// the next edit would land on text the user never selected. Keeping both ends
54/// in one struct, shifted together by [`shift_sel`], makes that class of bug
55/// unrepresentable — either the whole selection survives the edit or the whole
56/// selection is dropped.
57///
58/// The **primary** selection is deliberately asymmetric: its head is
59/// `View::cursor` and its anchor lives in the discipline's own state (vim's
60/// `visual_anchor` in `VimState`, helix's `anchor` in `HelixState`). That split
61/// predates multi-cursor and unifying it would rewrite vim's visual mode, so it
62/// stays. Only the *secondary* selections live here.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct Sel {
65    /// The end that stays put while a motion runs.
66    pub anchor: Position,
67    /// The end a motion moves; where an edit is applied.
68    pub head: Position,
69}
70
71impl Sel {
72    /// A selection from `anchor` to `head`.
73    pub fn new(anchor: Position, head: Position) -> Self {
74        Self { anchor, head }
75    }
76
77    /// A zero-width selection: anchor and head on the same position.
78    pub fn caret(p: Position) -> Self {
79        Self { anchor: p, head: p }
80    }
81
82    /// True when the selection has no extent.
83    pub fn is_caret(&self) -> bool {
84        self.anchor == self.head
85    }
86
87    /// The earlier of the two ends, in document order.
88    pub fn start(&self) -> Position {
89        self.anchor.min(self.head)
90    }
91
92    /// The later of the two ends, in document order.
93    pub fn end(&self) -> Position {
94        self.anchor.max(self.head)
95    }
96}
97
98/// Order positions in document order.
99fn key(p: Position) -> (usize, usize) {
100    (p.row, p.col)
101}
102
103/// Where `p` lands after `text` is inserted at `at`.
104fn after_insert(p: Position, at: Position, text: &str) -> Position {
105    if key(p) < key(at) {
106        return p;
107    }
108    let added_rows = text.matches('\n').count();
109    // Chars on the final line of the inserted text — what a position on `at`'s
110    // row gets pushed right by once the newlines have moved it down.
111    let tail = text.rsplit('\n').next().unwrap_or("");
112    let tail_len = tail.chars().count();
113
114    if p.row == at.row {
115        if added_rows == 0 {
116            Position::new(p.row, p.col + text.chars().count())
117        } else {
118            // Everything at/after `at.col` slides onto the last inserted row.
119            Position::new(p.row + added_rows, tail_len + (p.col - at.col))
120        }
121    } else {
122        // Strictly below the insertion: only the row shifts.
123        Position::new(p.row + added_rows, p.col)
124    }
125}
126
127/// Where `p` lands after the charwise range `[start, end)` is deleted.
128fn after_delete_char(p: Position, start: Position, end: Position) -> Position {
129    if key(p) <= key(start) {
130        return p;
131    }
132    if key(p) < key(end) {
133        // Inside the hole — collapse to where the text used to begin.
134        return start;
135    }
136    if p.row == end.row {
137        // Tail of the end row folds up onto the start row.
138        Position::new(start.row, start.col + (p.col - end.col))
139    } else {
140        Position::new(p.row - (end.row - start.row), p.col)
141    }
142}
143
144/// Where `p` lands after whole rows `start_row..=end_row` are deleted.
145fn after_delete_lines(p: Position, start_row: usize, end_row: usize) -> Position {
146    if p.row < start_row {
147        p
148    } else if p.row <= end_row {
149        // The row the selection lived on is gone.
150        Position::new(start_row, 0)
151    } else {
152        Position::new(p.row - (end_row - start_row + 1), p.col)
153    }
154}
155
156/// Where `p` lands after the rectangle `rows × [lo_col, hi_col]` is deleted.
157fn after_delete_block(p: Position, start: Position, end: Position) -> Position {
158    let (lo_row, hi_row) = (start.row.min(end.row), start.row.max(end.row));
159    let (lo_col, hi_col) = (start.col.min(end.col), start.col.max(end.col));
160    if p.row < lo_row || p.row > hi_row {
161        return p;
162    }
163    let width = hi_col - lo_col + 1;
164    if p.col > hi_col {
165        Position::new(p.row, p.col - width)
166    } else if p.col >= lo_col {
167        Position::new(p.row, lo_col)
168    } else {
169        p
170    }
171}
172
173/// Where `p` lands after `count` rows are joined onto `row`.
174///
175/// Mirrors `hjkl_buffer::Edit::JoinLines` exactly: each step drops the `\n`
176/// ending `row` and inserts a single space **only when both sides are
177/// non-empty**. (It does not strip leading whitespace — vim's `J` does, this
178/// buffer's `JoinLines` does not, and guessing the wrong one here would mis-place
179/// every caret on a joined row.)
180///
181/// `line_len` gives the **pre-edit** char length of a row.
182fn after_join(
183    p: Position,
184    row: usize,
185    count: usize,
186    with_space: bool,
187    line_len: &impl Fn(usize) -> usize,
188    rows: usize,
189) -> Position {
190    if p.row < row {
191        return p;
192    }
193    // Walk the joins, tracking where each joined row's text lands in the merged
194    // row. `start_col[k]` is the column original row `row + k` begins at.
195    let mut cur_len = line_len(row);
196    let mut start_col = Vec::with_capacity(count);
197    let mut joined = 0usize;
198    for k in 1..=count.max(1) {
199        if row + k >= rows {
200            break;
201        }
202        let next_len = line_len(row + k);
203        let space = with_space && cur_len > 0 && next_len > 0;
204        start_col.push(cur_len + usize::from(space));
205        cur_len += usize::from(space) + next_len;
206        joined += 1;
207    }
208    if joined == 0 || p.row == row {
209        // The anchor row keeps its columns; text is only appended after it.
210        return p;
211    }
212    if p.row <= row + joined {
213        let k = p.row - row; // 1..=joined
214        Position::new(row, start_col[k - 1] + p.col)
215    } else {
216        Position::new(p.row - joined, p.col)
217    }
218}
219
220/// Where `p` lands after a block insert: `chunks[i]` spliced at
221/// `(at.row + i, at.col)`. Rows shorter than `at.col` are space-padded first,
222/// but every position on such a row sits left of `at.col` and so cannot move.
223fn after_insert_block(p: Position, at: Position, chunks: &[String]) -> Position {
224    if p.row < at.row || p.row >= at.row + chunks.len() || p.col < at.col {
225        return p;
226    }
227    let width = chunks[p.row - at.row].chars().count();
228    Position::new(p.row, p.col + width)
229}
230
231/// Where `p` lands after a block delete: `widths[i]` chars removed at
232/// `(at.row + i, at.col)`.
233fn after_delete_block_chunks(p: Position, at: Position, widths: &[usize]) -> Position {
234    if p.row < at.row || p.row >= at.row + widths.len() || p.col <= at.col {
235        return p;
236    }
237    let w = widths[p.row - at.row];
238    if p.col >= at.col + w {
239        Position::new(p.row, p.col - w)
240    } else {
241        // Inside the removed chunk.
242        Position::new(p.row, at.col)
243    }
244}
245
246/// Rewrite `p` so it still points at the same text after `edit` lands, or
247/// `None` when the edit's geometry is not modelled and the position must be
248/// dropped rather than guessed.
249///
250/// `line_len` returns the **pre-edit** char length of a row, and `rows` the
251/// pre-edit row count. Only the row-restructuring edits consult them.
252///
253/// # Units
254///
255/// Works in **char columns**, which is what [`Edit`] and `View::cursor` both
256/// speak. Deliberately *not* expressed over [`crate::types::Selection`], whose
257/// `Pos::col` counts **graphemes**: doing this arithmetic in grapheme columns
258/// would silently mis-shift every position sitting after a multi-byte
259/// character. Converting between the two units needs the buffer, so it belongs
260/// at the call boundary, not here.
261pub fn shift_position(
262    p: Position,
263    edit: &Edit,
264    line_len: impl Fn(usize) -> usize,
265    rows: usize,
266) -> Option<Position> {
267    match edit {
268        // A `\n` typed as a char restructures rows exactly like the 1-char
269        // string would, so route both through the same insert geometry.
270        Edit::InsertChar { at, ch } => {
271            let mut buf = [0u8; 4];
272            Some(after_insert(p, *at, ch.encode_utf8(&mut buf)))
273        }
274        Edit::InsertStr { at, text } => Some(after_insert(p, *at, text)),
275        Edit::DeleteRange { start, end, kind } => Some(match kind {
276            MotionKind::Char => after_delete_char(p, *start, *end),
277            MotionKind::Line => after_delete_lines(p, start.row, end.row),
278            MotionKind::Block => after_delete_block(p, *start, *end),
279        }),
280        Edit::Replace { start, end, with } => {
281            // Delete then insert at the (now collapsed) start.
282            let deleted = after_delete_char(p, *start, *end);
283            Some(after_insert(deleted, *start, with))
284        }
285        Edit::JoinLines {
286            row,
287            count,
288            with_space,
289        } => Some(after_join(p, *row, *count, *with_space, &line_len, rows)),
290        Edit::InsertBlock { at, chunks } => Some(after_insert_block(p, *at, chunks)),
291        Edit::DeleteBlockChunks { at, widths } => Some(after_delete_block_chunks(p, *at, widths)),
292        // `SplitLines` is the undo-inverse of a join, emitted when history rewinds
293        // rather than when a user edits. Undo restores a whole snapshot and does
294        // not preserve secondary carets anyway, so modelling it would buy nothing
295        // real — drop rather than write geometry no test could justify.
296        Edit::SplitLines { .. } => None,
297    }
298}
299
300/// Rewrite BOTH ends of `sel` so it still covers the same text after `edit`,
301/// or `None` when *either* end is untrackable.
302///
303/// All-or-nothing is the whole point: a selection whose head survived and whose
304/// anchor did not is worse than no selection at all — it would still apply the
305/// next edit, just over a range the user never selected. Half-tracked is not a
306/// state this type can be in.
307///
308/// See [`shift_position`] for the contract on `line_len` / `rows` (both are
309/// **pre-edit** geometry).
310pub fn shift_sel(
311    sel: Sel,
312    edit: &Edit,
313    line_len: impl Fn(usize) -> usize,
314    rows: usize,
315) -> Option<Sel> {
316    let anchor = shift_position(sel.anchor, edit, &line_len, rows)?;
317    let head = shift_position(sel.head, edit, &line_len, rows)?;
318    Some(Sel { anchor, head })
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn p(row: usize, col: usize) -> Position {
326        Position::new(row, col)
327    }
328    fn ins(row: usize, col: usize, text: &str) -> Edit {
329        Edit::InsertStr {
330            at: p(row, col),
331            text: text.to_string(),
332        }
333    }
334    fn del(s: (usize, usize), e: (usize, usize), kind: MotionKind) -> Edit {
335        Edit::DeleteRange {
336            start: p(s.0, s.1),
337            end: p(e.0, e.1),
338            kind,
339        }
340    }
341    /// Shift a bare position against a buffer with no interesting geometry.
342    /// (`line_len` / `rows` only matter for `JoinLines`; the join tests below
343    /// pass real metrics.)
344    fn head(row: usize, col: usize, edit: &Edit) -> Option<Position> {
345        shift_position(p(row, col), edit, |_| 0, 0)
346    }
347
348    /// Shift against explicit pre-edit line lengths.
349    fn head_in(row: usize, col: usize, edit: &Edit, lens: &[usize]) -> Option<Position> {
350        shift_position(p(row, col), edit, |r| lens[r], lens.len())
351    }
352
353    // ── Insert ───────────────────────────────────────────────────────────────
354
355    #[test]
356    fn insert_before_on_same_row_pushes_right() {
357        assert_eq!(head(0, 5, &ins(0, 2, "ab")), Some(p(0, 7)));
358    }
359
360    #[test]
361    fn insert_after_on_same_row_does_not_move() {
362        assert_eq!(head(0, 1, &ins(0, 2, "ab")), Some(p(0, 1)));
363    }
364
365    #[test]
366    fn position_exactly_at_insertion_point_moves_right() {
367        // Text lands *before* the caret, so the caret slides.
368        assert_eq!(head(0, 2, &ins(0, 2, "xy")), Some(p(0, 2 + 2)));
369    }
370
371    #[test]
372    fn insert_on_earlier_row_does_not_move_later_col() {
373        assert_eq!(head(3, 4, &ins(1, 0, "abc")), Some(p(3, 4)));
374    }
375
376    #[test]
377    fn multiline_insert_pushes_later_rows_down() {
378        assert_eq!(head(3, 4, &ins(1, 0, "a\nb\n")), Some(p(5, 4)));
379    }
380
381    #[test]
382    fn multiline_insert_relocates_tail_of_the_insert_row() {
383        // "ab|cd" + insert "X\nY" at col 2 -> row0 "abX", row1 "Ycd";
384        // the caret that was at col 2 is now on row 1 after "Y".
385        assert_eq!(head(0, 2, &ins(0, 2, "X\nY")), Some(p(1, 1)));
386    }
387
388    #[test]
389    fn insert_char_newline_restructures_like_a_string() {
390        let e = Edit::InsertChar {
391            at: p(0, 2),
392            ch: '\n',
393        };
394        assert_eq!(head(0, 5, &e), Some(p(1, 3)));
395    }
396
397    // ── Charwise delete ──────────────────────────────────────────────────────
398
399    #[test]
400    fn delete_before_pulls_left() {
401        assert_eq!(
402            head(0, 9, &del((0, 2), (0, 5), MotionKind::Char)),
403            Some(p(0, 6))
404        );
405    }
406
407    #[test]
408    fn delete_after_does_not_move() {
409        assert_eq!(
410            head(0, 1, &del((0, 2), (0, 5), MotionKind::Char)),
411            Some(p(0, 1))
412        );
413    }
414
415    #[test]
416    fn position_inside_deleted_range_collapses_to_start() {
417        assert_eq!(
418            head(0, 3, &del((0, 2), (0, 5), MotionKind::Char)),
419            Some(p(0, 2))
420        );
421    }
422
423    #[test]
424    fn delete_end_is_exclusive() {
425        // A caret exactly at `end` survives; it is the first char kept.
426        assert_eq!(
427            head(0, 5, &del((0, 2), (0, 5), MotionKind::Char)),
428            Some(p(0, 2))
429        );
430    }
431
432    #[test]
433    fn cross_row_delete_folds_tail_onto_start_row() {
434        // Deleting (1,2)..(3,4): a caret at (3,6) lands at (1, 2 + (6-4)).
435        assert_eq!(
436            head(3, 6, &del((1, 2), (3, 4), MotionKind::Char)),
437            Some(p(1, 4))
438        );
439    }
440
441    #[test]
442    fn row_below_a_cross_row_delete_shifts_up() {
443        assert_eq!(
444            head(7, 3, &del((1, 2), (3, 4), MotionKind::Char)),
445            Some(p(5, 3))
446        );
447    }
448
449    // ── Linewise delete ──────────────────────────────────────────────────────
450
451    #[test]
452    fn linewise_delete_shifts_rows_below_up() {
453        assert_eq!(
454            head(9, 3, &del((2, 0), (4, 0), MotionKind::Line)),
455            Some(p(6, 3))
456        );
457    }
458
459    #[test]
460    fn linewise_delete_of_the_selections_own_row_collapses_it() {
461        assert_eq!(
462            head(3, 7, &del((2, 0), (4, 0), MotionKind::Line)),
463            Some(p(2, 0))
464        );
465    }
466
467    #[test]
468    fn linewise_delete_above_leaves_earlier_rows_alone() {
469        assert_eq!(
470            head(1, 7, &del((2, 0), (4, 0), MotionKind::Line)),
471            Some(p(1, 7))
472        );
473    }
474
475    // ── Block delete ─────────────────────────────────────────────────────────
476
477    #[test]
478    fn block_delete_pulls_columns_right_of_the_rectangle_left() {
479        assert_eq!(
480            head(2, 9, &del((1, 2), (3, 5), MotionKind::Block)),
481            Some(p(2, 5))
482        );
483    }
484
485    #[test]
486    fn block_delete_collapses_columns_inside_the_rectangle() {
487        assert_eq!(
488            head(2, 3, &del((1, 2), (3, 5), MotionKind::Block)),
489            Some(p(2, 2))
490        );
491    }
492
493    #[test]
494    fn block_delete_leaves_rows_outside_the_rectangle_alone() {
495        assert_eq!(
496            head(9, 9, &del((1, 2), (3, 5), MotionKind::Block)),
497            Some(p(9, 9))
498        );
499    }
500
501    // ── Replace ──────────────────────────────────────────────────────────────
502
503    #[test]
504    fn replace_shorter_pulls_left() {
505        let e = Edit::Replace {
506            start: p(0, 2),
507            end: p(0, 6),
508            with: "x".to_string(),
509        };
510        // "ab[cdef]gh" -> "ab x gh": a caret at col 8 moves to 2 + 1 + (8-6) = 5.
511        assert_eq!(head(0, 8, &e), Some(p(0, 5)));
512    }
513
514    #[test]
515    fn replace_longer_pushes_right() {
516        let e = Edit::Replace {
517            start: p(0, 2),
518            end: p(0, 3),
519            with: "xyz".to_string(),
520        };
521        assert_eq!(head(0, 5, &e), Some(p(0, 7)));
522    }
523
524    // ── Untracked edits drop rather than guess ───────────────────────────────
525
526    // ── Join ─────────────────────────────────────────────────────────────────
527
528    #[test]
529    fn join_folds_the_next_row_up_after_the_anchor_plus_a_space() {
530        // rows: "abc"(3) "de"(2). J -> "abc de"; a caret at (1,1) lands at col 4+1.
531        let e = Edit::JoinLines {
532            row: 0,
533            count: 1,
534            with_space: true,
535        };
536        assert_eq!(head_in(1, 1, &e, &[3, 2]), Some(p(0, 5)));
537    }
538
539    #[test]
540    fn join_without_space_folds_flush() {
541        let e = Edit::JoinLines {
542            row: 0,
543            count: 1,
544            with_space: false,
545        };
546        assert_eq!(head_in(1, 1, &e, &[3, 2]), Some(p(0, 4)));
547    }
548
549    #[test]
550    fn join_inserts_no_space_when_a_side_is_empty() {
551        // The buffer only inserts a space when BOTH sides are non-empty.
552        let e = Edit::JoinLines {
553            row: 0,
554            count: 1,
555            with_space: true,
556        };
557        assert_eq!(
558            head_in(1, 1, &e, &[0, 2]),
559            Some(p(0, 1)),
560            "empty prefix -> no space"
561        );
562    }
563
564    #[test]
565    fn join_leaves_the_anchor_rows_own_columns_alone() {
566        let e = Edit::JoinLines {
567            row: 0,
568            count: 1,
569            with_space: true,
570        };
571        assert_eq!(head_in(0, 2, &e, &[3, 2]), Some(p(0, 2)));
572    }
573
574    #[test]
575    fn join_pulls_rows_below_the_joined_span_up() {
576        let e = Edit::JoinLines {
577            row: 0,
578            count: 1,
579            with_space: true,
580        };
581        assert_eq!(head_in(3, 1, &e, &[3, 2, 4, 4]), Some(p(2, 1)));
582    }
583
584    #[test]
585    fn multi_row_join_accumulates_each_rows_offset() {
586        // "ab"(2) "cd"(2) "ef"(2), J J -> "ab cd ef".
587        // row2 col0 -> after "ab"+sp+"cd"+sp = 6.
588        let e = Edit::JoinLines {
589            row: 0,
590            count: 2,
591            with_space: true,
592        };
593        assert_eq!(head_in(2, 0, &e, &[2, 2, 2]), Some(p(0, 6)));
594    }
595
596    // ── Block insert / delete (visual-block I / A / d) ───────────────────────
597
598    #[test]
599    fn block_insert_pushes_columns_at_or_after_the_block_right() {
600        let e = Edit::InsertBlock {
601            at: p(0, 2),
602            chunks: vec!["xx".into(), "xx".into()],
603        };
604        assert_eq!(head(1, 4, &e), Some(p(1, 6)));
605    }
606
607    #[test]
608    fn block_insert_leaves_columns_before_the_block_alone() {
609        let e = Edit::InsertBlock {
610            at: p(0, 2),
611            chunks: vec!["xx".into(), "xx".into()],
612        };
613        assert_eq!(head(1, 1, &e), Some(p(1, 1)));
614    }
615
616    #[test]
617    fn block_insert_leaves_rows_outside_the_block_alone() {
618        let e = Edit::InsertBlock {
619            at: p(0, 2),
620            chunks: vec!["xx".into()],
621        };
622        assert_eq!(head(5, 4, &e), Some(p(5, 4)));
623    }
624
625    #[test]
626    fn block_chunk_delete_pulls_columns_after_the_chunk_left() {
627        let e = Edit::DeleteBlockChunks {
628            at: p(0, 2),
629            widths: vec![2, 2],
630        };
631        assert_eq!(head(1, 6, &e), Some(p(1, 4)));
632    }
633
634    #[test]
635    fn block_chunk_delete_collapses_columns_inside_the_chunk() {
636        let e = Edit::DeleteBlockChunks {
637            at: p(0, 2),
638            widths: vec![3],
639        };
640        assert_eq!(head(0, 3, &e), Some(p(0, 2)));
641    }
642
643    // ── The one edit still dropped ───────────────────────────────────────────
644
645    // ── Selections shift as a unit ───────────────────────────────────────────
646
647    #[test]
648    fn a_selection_shifts_both_of_its_ends() {
649        // "ab|cdef|gh": insert 2 chars at col 0 -> both ends slide right by 2.
650        let s = Sel::new(p(0, 2), p(0, 5));
651        assert_eq!(
652            shift_sel(s, &ins(0, 0, "XY"), |_| 0, 1),
653            Some(Sel::new(p(0, 4), p(0, 7)))
654        );
655    }
656
657    #[test]
658    fn a_backwards_selection_keeps_its_direction() {
659        let s = Sel::new(p(0, 5), p(0, 2));
660        let out = shift_sel(s, &ins(0, 0, "XY"), |_| 0, 1).unwrap();
661        assert_eq!(out.anchor, p(0, 7));
662        assert_eq!(out.head, p(0, 4));
663        assert!(out.anchor > out.head, "direction must survive the shift");
664    }
665
666    #[test]
667    fn a_selection_whose_text_is_deleted_collapses_to_the_hole() {
668        // Deleting [0,2)..(0,6) swallows a selection living inside it.
669        let s = Sel::new(p(0, 3), p(0, 5));
670        assert_eq!(
671            shift_sel(s, &del((0, 2), (0, 6), MotionKind::Char), |_| 0, 1),
672            Some(Sel::caret(p(0, 2))),
673            "both ends collapse to the deletion start — a caret, not a stale range"
674        );
675    }
676
677    #[test]
678    fn a_selection_is_dropped_whole_when_either_end_is_untrackable() {
679        let e = Edit::SplitLines {
680            row: 0,
681            cols: vec![3],
682            inserted_space: true,
683        };
684        assert_eq!(
685            shift_sel(Sel::new(p(1, 0), p(1, 4)), &e, |_| 0, 0),
686            None,
687            "never half-track: a selection with one guessed end edits the wrong text"
688        );
689    }
690
691    #[test]
692    fn split_lines_drops_rather_than_guessing() {
693        // `SplitLines` is the undo-inverse of a join — emitted when history
694        // rewinds, not when a user edits. Undo restores a snapshot and does not
695        // preserve secondary carets anyway, so there is nothing real to model.
696        let e = Edit::SplitLines {
697            row: 0,
698            cols: vec![3],
699            inserted_space: true,
700        };
701        assert_eq!(shift_position(p(5, 0), &e, |_| 0, 0), None);
702    }
703}