kimun_notes/components/text_editor/rope_buffer.rs
1//! The **edit buffer** over `ropetext`, presenting the surface the rest of the
2//! editor already calls.
3//!
4//! This exists so the engine swap does not have to happen everywhere at once.
5//! It keeps every method name and `(row, col)` signature the `TextArea`-backed
6//! buffer had, so `vim.rs`, `find_bar.rs` and the component compiled against it
7//! unchanged. A differential proptest held it to the incumbent's behaviour
8//! operation by operation until the migration landed; it found seven real
9//! defects, three of them kimün's own, and was removed with the dependency it
10//! needed.
11//!
12//! Nothing here mirrors the text into a second representation. Callers that want
13//! rows ask for them ([`RopeBuffer::rows`]) and pay for them there; the buffer
14//! keeps one copy of the note and no derived copy in step with it.
15//!
16use crate::ropetext::motion::{self, Goal, Words};
17use crate::ropetext::{Change, Column, EditBuffer as Rope, Position, Span, Text};
18
19/// How far one indent step moves a line, in spaces, when `hard_tab_indent` is
20/// off — what Tab, `>>` and the visual `>` add, and what their inverses remove.
21///
22/// Not a tab stop, and deliberately not derived from one. A tab stop is elastic
23/// (a `\t` advances to the next multiple of it, so its width depends on where it
24/// starts) and describes how an existing character *draws*; an indent step is a
25/// fixed amount of text an edit *inserts*. Vim keeps the two apart as `tabstop`
26/// and `shiftwidth`, EditorConfig as `tab_width` and `indent_size`, and
27/// `hard_tab_indent` is exactly the setting under which they must differ: insert
28/// one literal `\t`, still draw it [`crate::ropetext::Metrics::DEFAULT_TAB_WIDTH`]
29/// cells wide. That both are 4 today is a coincidence of defaults.
30const DEFAULT_INDENT_WIDTH: u8 = 4;
31
32/// What one call to [`RopeBuffer::edit`] did, measured rather than predicted.
33///
34/// `#[must_use]` on purpose: the caller still applies these (the revision clock
35/// serves both backends and so stays on the component), and forgetting to is
36/// exactly the failure this type exists to prevent. A warning is a check; a
37/// convention is not.
38#[must_use = "an edit's outcome drives the revision bump and the parse-damage signal"]
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct EditOutcome {
41 /// The buffer's text differs from before the edit. A content comparison —
42 /// never a library return value, which can report `false` after mutating.
43 pub changed: bool,
44 /// The change is not confined to the cursor's row, so the incremental
45 /// parser's cursor damage hint would under-report it.
46 pub bulk: bool,
47 /// Which rows the edits changed, in the new text's numbering — the hull when
48 /// several ran before this was drained.
49 ///
50 /// Told by the engine rather than found by comparing the buffer with a copy
51 /// of its previous self, which is what the revision-tagged rope makes
52 /// possible. The **nvim** backend reports lines and not changes, so it
53 /// leaves this `None` and its consumer falls back to a diff.
54 pub damage: Option<std::ops::Range<usize>>,
55 /// Net rows added (or removed, when negative) by the edits behind `damage`.
56 ///
57 /// Travels with the range because the range is only meaningful in a
58 /// numbering, and a consumer that accumulates reports across several drains
59 /// has to bring the older one forward before it can union them.
60 pub line_delta: isize,
61}
62
63/// `range`, renumbered for a change of `delta` lines starting at `at`.
64///
65/// Rows above the change keep their index; the rest move with it. A `delta` of
66/// zero — every edit that stays within its rows, which is most of them — leaves
67/// the range alone.
68///
69/// Shared because damage is accumulated in two places: here, across the
70/// mutations of one group, and in the view, across the drains between two
71/// frames. Both union ranges recorded against different texts, and both are
72/// wrong in the same way without this.
73pub(super) fn shift_rows(
74 range: std::ops::Range<usize>,
75 at: usize,
76 delta: isize,
77) -> std::ops::Range<usize> {
78 let shift = |row: usize| {
79 if row < at {
80 row
81 } else {
82 row.saturating_add_signed(delta)
83 }
84 };
85 shift(range.start)..shift(range.end)
86}
87
88/// Whether a delete fills the register it removed text from.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum Yank {
91 Keep,
92 Discard,
93}
94
95/// A cursor movement, in the vocabulary the editor already speaks.
96///
97/// Deliberately the incumbent's variant set, so the 145 call sites need no
98/// rewriting — but `Jump` takes `usize` rather than `u16`, because clamping a
99/// row to 65535 is a defect the old widget's contract allowed, and this is the
100/// type where it stops being representable.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CursorMove {
103 Forward,
104 Back,
105 Up,
106 Down,
107 Head,
108 End,
109 Top,
110 Bottom,
111 WordForward,
112 WordBack,
113 WordEnd,
114 /// `W` — a WORD is any run of non-blanks.
115 WordForwardBig,
116 /// `B`.
117 WordBackBig,
118 /// `E`.
119 WordEndBig,
120 /// `ge` / `gE`.
121 WordEndBack {
122 big: bool,
123 },
124 /// `%`. Stays put when there is no bracket ahead on the row, or it is
125 /// unbalanced.
126 MatchingPair,
127 ParagraphForward,
128 ParagraphBack,
129 Jump(usize, usize),
130}
131
132impl CursorMove {
133 /// Whether the movement is vertical, and so keeps the goal column.
134 ///
135 /// `Top` and `Bottom` are row movements but deliberately *not* goal-preserving:
136 /// vim's `gg`/`G` go to a row's first non-blank rather than to a remembered
137 /// column, and making them sticky would be a third behaviour that neither vim
138 /// nor the incumbent has. Change #8 is about `Up`/`Down`.
139 fn is_vertical(self) -> bool {
140 matches!(self, CursorMove::Up | CursorMove::Down)
141 }
142}
143
144/// The open note's text, cursor, selection and history.
145#[derive(Debug)]
146pub struct RopeBuffer {
147 inner: Rope,
148 /// Accumulated since the last drain. The component owns the revision clock
149 /// for as long as the nvim backend has no edit buffer.
150 pending: EditOutcome,
151 /// Nesting depth of [`Self::edit`]. Above zero, a mutation extends the open
152 /// group instead of starting its own.
153 depth: u32,
154 /// Whether the open group has recorded anything yet, so the first mutation
155 /// inside `edit` starts the group and the rest extend it.
156 group_started: bool,
157 /// Set by a backend that has decided the next mutation continues what the
158 /// last one started — a typing run, an insert session. Cleared by using it,
159 /// so continuing is asked for per edit rather than left switched on.
160 continue_group: bool,
161 /// The column a vertical movement is aiming at, which is why walking down
162 /// through a short row and out the other side returns to where it started.
163 goal: Option<Column>,
164 yank: String,
165 search: Option<regex::Regex>,
166 indent_width: u8,
167 hard_tab_indent: bool,
168}
169
170impl Default for RopeBuffer {
171 fn default() -> Self {
172 Self::new(Text::new())
173 }
174}
175
176impl RopeBuffer {
177 pub fn new(text: Text) -> Self {
178 Self {
179 inner: Rope::new(text),
180 pending: EditOutcome::default(),
181 depth: 0,
182 group_started: false,
183 continue_group: false,
184 goal: None,
185 yank: String::new(),
186 search: None,
187 indent_width: DEFAULT_INDENT_WIDTH,
188 hard_tab_indent: false,
189 }
190 }
191
192 /// Replace the whole buffer, dropping the history with it.
193 pub fn replace(&mut self, text: Text) {
194 self.inner.set_text(text);
195 self.pending = EditOutcome::default();
196 self.goal = None;
197 }
198
199 pub fn text(&self) -> &Text {
200 self.inner.text()
201 }
202
203 /// Spaces one indent step inserts. Both backends read this, so `>>` and Tab
204 /// move a line by the same amount.
205 pub fn indent_width(&self) -> u8 {
206 self.indent_width
207 }
208
209 pub fn set_indent_width(&mut self, spaces: u8) {
210 self.indent_width = spaces;
211 }
212
213 pub fn hard_tab_indent(&self) -> bool {
214 self.hard_tab_indent
215 }
216
217 pub fn set_hard_tab_indent(&mut self, hard: bool) {
218 self.hard_tab_indent = hard;
219 }
220
221 pub fn snapshot(&self) -> crate::ropetext::Snapshot {
222 self.inner.snapshot()
223 }
224
225 // ── Reads ────────────────────────────────────────────────────────────────
226
227 /// One row's text, or `None` past the end.
228 pub fn row(&self, row: usize) -> Option<std::borrow::Cow<'_, str>> {
229 self.inner.text().line(row)
230 }
231
232 /// How many rows the buffer has. Never zero.
233 pub fn row_count(&self) -> usize {
234 self.inner.text().line_count()
235 }
236
237 /// Every row, materialised.
238 ///
239 /// Not a cache: nothing is maintained between calls, so the cost lands on the
240 /// caller that wants a vector rather than on every edit. That is the whole
241 /// difference from the shim this replaced.
242 pub fn rows(&self) -> Vec<String> {
243 self.inner.text().lines().map(|l| l.to_string()).collect()
244 }
245
246 /// Rows `first..=last`, joined with newlines.
247 pub fn joined_rows(&self, first: usize, last: usize) -> String {
248 (first..=last)
249 .filter_map(|row| self.row(row))
250 .collect::<Vec<_>>()
251 .join("\n")
252 }
253
254 /// Characters in one row.
255 pub fn row_len(&self, row: usize) -> usize {
256 self.inner.text().line_len_chars(row).unwrap_or(0)
257 }
258
259 pub fn cursor(&self) -> (usize, usize) {
260 let cursor = self.inner.cursor();
261 (cursor.row(), cursor.column().get())
262 }
263
264 pub fn is_empty(&self) -> bool {
265 self.inner.text().len_bytes() == 0
266 }
267
268 pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
269 let span = self.inner.selection()?;
270 Some((rc(span.start()), rc(span.end())))
271 }
272
273 pub fn yank_text(&self) -> String {
274 self.yank.clone()
275 }
276
277 pub fn set_yank_text(&mut self, text: impl Into<String>) {
278 self.yank = text.into();
279 }
280
281 pub fn search_pattern(&self) -> Option<®ex::Regex> {
282 self.search.as_ref()
283 }
284
285 pub fn take_outcome(&mut self) -> EditOutcome {
286 std::mem::take(&mut self.pending)
287 }
288
289 // ── Groups ───────────────────────────────────────────────────────────────
290
291 /// Run `f` as one **undo group**.
292 ///
293 /// Every mutation inside lands in a single history entry, however many
294 /// primitives it takes. Nested calls belong to the outermost group, so a
295 /// compound action built from the single-mutation helpers is still one undo.
296 pub fn edit<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
297 if self.depth > 0 {
298 return f(self);
299 }
300 self.depth = 1;
301 self.group_started = false;
302 let out = f(self);
303 self.depth = 0;
304 self.group_started = false;
305 out
306 }
307
308 /// The next mutation joins the previous group instead of starting one.
309 ///
310 /// The policy is the backend's, because only it knows what the user was doing
311 /// — mid-word against after a pause, inside an Insert session against having
312 /// left it. This is the mechanism; `typing_run` and the vim engine are the two
313 /// callers that hold an opinion.
314 pub fn continue_group(&mut self) {
315 self.continue_group = true;
316 }
317
318 /// Apply one primitive as its own group, or as part of an open one.
319 fn mutate(&mut self, f: impl FnOnce(&mut crate::ropetext::Txn<'_>)) -> bool {
320 let extending =
321 (self.depth > 0 && self.group_started) || std::mem::take(&mut self.continue_group);
322 let mut txn = if extending {
323 self.inner.begin_extending()
324 } else {
325 self.inner.begin()
326 };
327 f(&mut txn);
328 let change = txn.commit();
329 if self.depth > 0 {
330 self.group_started = true;
331 }
332 self.record(change)
333 }
334
335 fn record(&mut self, change: Option<Change>) -> bool {
336 let Some(change) = change else {
337 return false;
338 };
339 self.pending.changed = true;
340 self.pending.bulk |= change.is_bulk();
341 self.pending.line_delta += change.line_delta();
342 self.pending.damage = Some(match self.pending.damage.take() {
343 Some(seen) => {
344 // `seen` was recorded against the text as it stood before *this*
345 // change, which may have moved those rows. Hulling the two
346 // directly unions ranges from two different numberings, and the
347 // result is not a superset of either: an edit high in the buffer
348 // followed by one above it that adds a line leaves the first
349 // edit's row below the hull's end, so it is never re-parsed and
350 // renders stale. Bring it into the current numbering first.
351 let seen = shift_rows(seen, change.rows().start, change.line_delta());
352 seen.start.min(change.rows().start)..seen.end.max(change.rows().end)
353 }
354 None => change.rows(),
355 });
356 true
357 }
358
359 // ── Mutations ────────────────────────────────────────────────────────────
360
361 pub fn insert_str(&mut self, s: impl AsRef<str>) -> bool {
362 let text = s.as_ref().to_string();
363 // The anchor goes whether or not it spanned anything, as it does for a
364 // delete: typing is not a selection gesture either.
365 let span = self.inner.selection().filter(|span| !span.is_empty());
366 self.inner.clear_selection();
367 let cursor = self.inner.cursor();
368 self.goal = None;
369 self.mutate(|txn| match span {
370 Some(span) => {
371 txn.replace(span, &text);
372 }
373 None => {
374 txn.insert(cursor, &text);
375 }
376 })
377 }
378
379 pub fn insert_char(&mut self, c: char) {
380 self.insert_str(c.to_string());
381 }
382
383 pub fn insert_newline(&mut self) {
384 self.insert_str("\n");
385 }
386
387 /// Delete `clusters` grapheme clusters forward, a line break counting as one.
388 ///
389 /// Clusters and not scalars, because a delete may not leave half a character
390 /// behind: `forward_by` steps whole clusters, so a caller counting scalars
391 /// over a flag or a ZWJ emoji spends the difference on the text after it.
392 pub fn delete_str(&mut self, clusters: usize) -> bool {
393 if self.take_selection() {
394 return true;
395 }
396 if clusters == 0 {
397 return false;
398 }
399 let from = self.inner.cursor();
400 let to = self.forward_by(from, clusters);
401 self.delete_between(from, to, Yank::Keep)
402 }
403
404 /// Backspace.
405 pub fn delete_char(&mut self) -> bool {
406 if self.take_selection() {
407 return true;
408 }
409 let to = self.inner.cursor();
410 let from = motion::prev_cluster(self.inner.text(), to);
411 // A backspace does not fill the register; only `delete_str`, the word
412 // deletes and `cut` do. Matching the incumbent, which is also vim: `x`
413 // yanks, but a plain backspace in Insert does not.
414 self.delete_between(from, to, Yank::Discard)
415 }
416
417 /// Forward delete.
418 pub fn delete_next_char(&mut self) -> bool {
419 if self.take_selection() {
420 return true;
421 }
422 let from = self.inner.cursor();
423 let to = motion::next_cluster(self.inner.text(), from);
424 self.delete_between(from, to, Yank::Discard)
425 }
426
427 pub fn delete_word(&mut self) -> bool {
428 if self.take_selection() {
429 return true;
430 }
431 let to = self.inner.cursor();
432 let text = self.inner.text();
433 // The incumbent's cascade, which *is* the contract: a word start on this
434 // row, else the row's start, else the line break before it. The last case
435 // is why this cannot simply be a row-local motion.
436 let candidate = motion::word_start_back(text, to, Words::Small);
437 let (from, yank) = if candidate.row() == to.row() && candidate.byte() < to.byte() {
438 (candidate, Yank::Keep)
439 } else if to.column().get() > 0 {
440 (motion::row_start(text, to), Yank::Keep)
441 } else {
442 // Joining rows goes through the incumbent's `delete_newline`, which
443 // does not fill the register. A pasted newline in place of the last
444 // yanked word is a surprising thing to hand back.
445 (motion::prev_cluster(text, to), Yank::Discard)
446 };
447 self.delete_between(from, to, yank)
448 }
449
450 pub fn delete_next_word(&mut self) -> bool {
451 if self.take_selection() {
452 return true;
453 }
454 let from = self.inner.cursor();
455 let text = self.inner.text();
456 // Mirror of `delete_word`: the end of the word at or after the cursor on
457 // this row, else the row's end, else the line break after it. `word_end_
458 // at_or_after` rather than `word_end_forward`, because deleting to the end
459 // of a word must name the word the cursor is *in* — vim's `e` deliberately
460 // looks past it.
461 let candidate = motion::word_end_at_or_after(text, from, Words::Small);
462 let row_end = motion::row_end(text, from);
463 let (to, yank) = match candidate {
464 Some(end) if end.row() == from.row() && end.byte() > from.byte() => (end, Yank::Keep),
465 _ if from.byte() < row_end.byte() => (row_end, Yank::Keep),
466 _ => (motion::next_cluster(text, from), Yank::Discard),
467 };
468 self.delete_between(from, to, yank)
469 }
470
471 pub fn cut(&mut self) -> bool {
472 // Takes the anchor whether or not it spanned anything, like every other
473 // operation that consumes a selection.
474 let span = self.inner.selection().filter(|span| !span.is_empty());
475 self.inner.clear_selection();
476 let Some(span) = span else {
477 return false;
478 };
479 self.yank = self
480 .inner
481 .text()
482 .slice(span)
483 .map(|text| text.to_string())
484 .unwrap_or_default();
485 self.goal = None;
486 self.mutate(|txn| {
487 txn.delete(span);
488 })
489 }
490
491 /// Copying reads: it leaves the selection where it is, and an empty one leaves
492 /// the register alone rather than emptying it.
493 pub fn copy(&mut self) {
494 if let Some(span) = self.inner.selection().filter(|span| !span.is_empty())
495 && let Some(text) = self.inner.text().slice(span)
496 {
497 self.yank = text.to_string();
498 }
499 }
500
501 pub fn paste(&mut self) -> bool {
502 if self.yank.is_empty() {
503 return false;
504 }
505 let text = std::mem::take(&mut self.yank);
506 let changed = self.insert_str(&text);
507 self.yank = text;
508 changed
509 }
510
511 /// Deleting a selection as a *side effect* of typing or of a forward delete
512 /// does not fill the register; an explicit delete does. That asymmetry is the
513 /// incumbent's and vim's both: `d` fills the unnamed register, typing over a
514 /// selection does not.
515 /// Take the selection and delete it, reporting whether anything went.
516 ///
517 /// Taking it is unconditional: an empty selection is not a range, so the
518 /// caller proceeds as though there were none — but the anchor is gone either
519 /// way. Leaving it alive is how an invisible selection outlives the gesture
520 /// that made it — a defect that cost two notes before it was found.
521 fn take_selection(&mut self) -> bool {
522 let span = self.inner.selection().filter(|span| !span.is_empty());
523 self.inner.clear_selection();
524 let Some(span) = span else {
525 return false;
526 };
527 self.delete_between(span.start(), span.end(), Yank::Discard)
528 }
529
530 fn delete_between(&mut self, from: Position, to: Position, yank: Yank) -> bool {
531 let Some(span) = self.inner.text().span(from, to) else {
532 return false;
533 };
534 if span.is_empty() {
535 return false;
536 }
537 if yank == Yank::Keep
538 && let Some(text) = self.inner.text().slice(span)
539 {
540 self.yank = text.to_string();
541 }
542 self.goal = None;
543 self.mutate(|txn| {
544 txn.delete(span);
545 })
546 }
547
548 // ── History ──────────────────────────────────────────────────────────────
549
550 pub fn undo(&mut self) -> bool {
551 let change = self.inner.undo();
552 self.after_history(change)
553 }
554
555 pub fn redo(&mut self) -> bool {
556 let change = self.inner.redo();
557 self.after_history(change)
558 }
559
560 /// The engine restores the selection an entry began with, which the incumbent
561 /// does not. Dropping it keeps undo behaving as it does today: a selection is
562 /// painted, so putting one back is a visible change, and an engine swap is the
563 /// wrong place to make one. The capability stays in the engine for when it is
564 /// asked for on purpose.
565 fn after_history(&mut self, change: Option<Change>) -> bool {
566 if change.is_none() {
567 // Nothing to undo: an operation that did not happen changes nothing,
568 // the selection included.
569 return false;
570 }
571 self.goal = None;
572 self.inner.clear_selection();
573 self.record(change)
574 }
575
576 // ── Cursor and selection ─────────────────────────────────────────────────
577
578 /// Move the cursor, extending a live selection.
579 ///
580 /// Directional movement keeps the anchor deliberately: that is how vim's
581 /// Visual mode extends.
582 pub fn move_cursor(&mut self, movement: CursorMove) {
583 let text = self.inner.text();
584 let from = self.inner.cursor();
585 let goal = self
586 .goal
587 .filter(|_| movement.is_vertical())
588 .unwrap_or_else(|| from.column());
589
590 let to = match movement {
591 CursorMove::Forward => motion::next_cluster(text, from),
592 CursorMove::Back => motion::prev_cluster(text, from),
593 CursorMove::Up => motion::vertical(text, from, -1, Goal::Column(goal)),
594 CursorMove::Down => motion::vertical(text, from, 1, Goal::Column(goal)),
595 CursorMove::Head => motion::row_start(text, from),
596 CursorMove::End => motion::row_end(text, from),
597 // The first and last *row*, keeping the column — not the start and
598 // end of the text, which is a different place on a non-empty row.
599 CursorMove::Top => {
600 let up = -(from.row() as isize);
601 motion::vertical(text, from, up, Goal::Column(goal))
602 }
603 CursorMove::Bottom => {
604 let down = (text.line_count().saturating_sub(1) as isize) - from.row() as isize;
605 motion::vertical(text, from, down, Goal::Column(goal))
606 }
607 CursorMove::WordForward => motion::word_start_forward(text, from, Words::Small),
608 CursorMove::WordBack => motion::word_start_back(text, from, Words::Small),
609 CursorMove::WordForwardBig => motion::word_start_forward(text, from, Words::Big),
610 CursorMove::WordBackBig => motion::word_start_back(text, from, Words::Big),
611 // Inclusive, like `WordEnd`: a cursor landing on a word's end wants the
612 // last cluster, not the place after it.
613 CursorMove::WordEndBig => match motion::word_end_forward(text, from, Words::Big) {
614 Some(end) => motion::prev_cluster(text, end),
615 None => from,
616 },
617 CursorMove::WordEndBack { big } => {
618 let words = if big { Words::Big } else { Words::Small };
619 match motion::word_end_back(text, from, words) {
620 Some(end) => motion::prev_cluster(text, end),
621 None => from,
622 }
623 }
624 CursorMove::MatchingPair => motion::matching_bracket(text, from).unwrap_or(from),
625 // The crate's word end is exclusive — just past the last cluster —
626 // because that is what an operator range wants. A *cursor* landing on
627 // a word end wants the last cluster itself, as vim's `e` does. This is
628 // the inclusive-to-half-open conversion CONTEXT names under **span
629 // kind**, and the adapter is where it belongs: the engine holds no view
630 // on which convention a caller uses.
631 CursorMove::WordEnd => match motion::word_end_forward(text, from, Words::Small) {
632 Some(end) => motion::prev_cluster(text, end),
633 // Nothing ahead: the incumbent walks to the end of the text rather
634 // than staying put, and vim's `e` on a trailing blank line does the
635 // same.
636 None => motion::text_end(text),
637 },
638 CursorMove::ParagraphForward => motion::paragraph_forward(text, from),
639 CursorMove::ParagraphBack => motion::paragraph_back(text, from),
640 CursorMove::Jump(row, column) => {
641 match text.position(row, Column::new(column)) {
642 Some(position) => position,
643 // Refused, not clamped: a keypress that did nothing is
644 // recoverable in a way one that edited elsewhere is not.
645 None => return,
646 }
647 }
648 };
649
650 self.goal = if movement.is_vertical() {
651 Some(goal)
652 } else {
653 None
654 };
655 self.place(to);
656 }
657
658 /// Move the cursor to `(row, col)`, refusing a position the buffer cannot
659 /// address rather than landing somewhere else.
660 pub fn jump_to(&mut self, row: usize, col: usize) -> bool {
661 let Some(to) = self.inner.text().position(row, Column::new(col)) else {
662 return false;
663 };
664 self.goal = None;
665 self.place(to);
666 true
667 }
668
669 /// Move to a position the caller worked out itself — a visual-line motion,
670 /// which needs a layout the buffer does not have.
671 pub fn move_to(&mut self, to: Position) {
672 if self.inner.text().is_stale(to) {
673 return;
674 }
675 self.goal = None;
676 self.place(to);
677 }
678
679 fn place(&mut self, to: Position) {
680 if self.inner.selection().is_some() {
681 self.inner.extend_to(to);
682 } else {
683 self.inner.set_cursor(to);
684 }
685 }
686
687 pub fn start_selection(&mut self) {
688 // Anchors *here*, even when a selection is already live: starting one is
689 // a fresh gesture, not an extension of the last.
690 let cursor = self.inner.cursor();
691 self.inner.clear_selection();
692 self.inner.extend_to(cursor);
693 }
694
695 pub fn cancel_selection(&mut self) {
696 self.inner.clear_selection();
697 }
698
699 pub fn select_all(&mut self) {
700 let span = self.inner.text().full_span();
701 self.inner.select(span);
702 }
703
704 pub fn set_selection(&mut self, start: (usize, usize), end: (usize, usize)) -> bool {
705 let text = self.inner.text();
706 let Some(from) = text.position(start.0, Column::new(start.1)) else {
707 return false;
708 };
709 let Some(to) = text.position(end.0, Column::new(end.1)) else {
710 return false;
711 };
712 let Some(span) = text.span(from, to) else {
713 return false;
714 };
715 self.inner.select(span);
716 true
717 }
718
719 // ── Search ───────────────────────────────────────────────────────────────
720 //
721 // Not the engine's business: it holds the pattern because vim's
722 // `n`/`N` outlive the find bar, and matches a row at a time because a **find
723 // pattern** can never span a newline.
724
725 pub fn set_search_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
726 if pattern.is_empty() {
727 self.search = None;
728 return Ok(());
729 }
730 self.search = Some(regex::Regex::new(pattern)?);
731 Ok(())
732 }
733
734 /// Move the cursor to the next match. Never extends a selection.
735 pub fn search_forward(&mut self, match_cursor: bool) -> bool {
736 self.step_search(false, match_cursor)
737 }
738
739 /// Move the cursor to the previous match. Never extends a selection.
740 pub fn search_back(&mut self, match_cursor: bool) -> bool {
741 self.step_search(true, match_cursor)
742 }
743
744 /// Repeat the persisted pattern (vim `n` / `N`).
745 pub fn search_repeat(&mut self, backward: bool) -> bool {
746 self.step_search(backward, false)
747 }
748
749 fn step_search(&mut self, backward: bool, match_cursor: bool) -> bool {
750 // A search is not a selection gesture, so the anchor goes first — and
751 // here that is one call rather than an invariant to remember, because
752 // `set_cursor` drops it and `extend_to` keeps it.
753 self.cancel_selection();
754 let Some(found) = self.find_match(backward, match_cursor) else {
755 return false;
756 };
757 self.goal = None;
758 self.inner.set_cursor(found);
759 true
760 }
761
762 fn find_match(&self, backward: bool, match_cursor: bool) -> Option<Position> {
763 let pattern = self.search.as_ref()?;
764 let text = self.inner.text();
765 let cursor = self.inner.cursor();
766 let rows = text.line_count();
767
768 // `0..=rows` visits the cursor's row twice: once at the start, and once
769 // more at the end. That last visit IS the wrap, so the hits the first
770 // visit stepped over — the ones behind the cursor — are exactly what it
771 // is for. Filtering them again there is what made search unable to come
772 // back around to them.
773 for step in 0..=rows {
774 let wrapped = step == rows;
775 let row = if backward {
776 (cursor.row() + rows - (step % rows.max(1))) % rows
777 } else {
778 (cursor.row() + step) % rows
779 };
780 let line = text.line(row)?;
781 let mut hits: Vec<usize> = pattern
782 .find_iter(&line)
783 .map(|found| line[..found.start()].chars().count())
784 .collect();
785 if backward {
786 hits.reverse();
787 }
788 for column in hits {
789 let same_row = row == cursor.row();
790 let beyond = if backward {
791 column < cursor.column().get()
792 } else if match_cursor {
793 column >= cursor.column().get()
794 } else {
795 column > cursor.column().get()
796 };
797 if wrapped || !same_row || beyond {
798 // A match can start inside a grapheme cluster — a regex like
799 // `.` or a search for a scalar that also appears inside a ZWJ
800 // sequence. That start is not addressable, so skip the
801 // candidate; abandoning the whole scan there would report "no
802 // match" while the bar's own count says otherwise.
803 if let Some(at) = text.position(row, Column::new(column)) {
804 return Some(at);
805 }
806 }
807 }
808 }
809 None
810 }
811
812 /// The span of the match starting exactly at the cursor, if any.
813 pub fn match_at_cursor(&self) -> Option<((usize, usize), (usize, usize))> {
814 let pattern = self.search.as_ref()?;
815 let text = self.inner.text();
816 let cursor = self.inner.cursor();
817 let line = text.line(cursor.row())?;
818 let byte = line
819 .char_indices()
820 .nth(cursor.column().get())
821 .map(|(at, _)| at)
822 .unwrap_or(line.len());
823 let found = pattern.find_at(&line, byte)?;
824 if found.start() != byte {
825 return None;
826 }
827 let chars = line[found.range()].chars().count();
828 Some((rc(cursor), (cursor.row(), cursor.column().get() + chars)))
829 }
830
831 // ── Helpers ──────────────────────────────────────────────────────────────
832
833 /// `chars` scalars forward of `from`, clamped to the end of the text.
834 fn forward_by(&self, from: Position, chars: usize) -> Position {
835 let text = self.inner.text();
836 let mut at = from;
837 for _ in 0..chars {
838 let next = motion::next_cluster(text, at);
839 if next.byte() == at.byte() {
840 break;
841 }
842 at = next;
843 }
844 at
845 }
846
847 /// The span between two `(row, col)` pairs, for callers that still speak in
848 /// them.
849 pub fn span_between(&self, start: (usize, usize), end: (usize, usize)) -> Option<Span> {
850 let text = self.inner.text();
851 let from = text.position(start.0, Column::new(start.1))?;
852 let to = text.position(end.0, Column::new(end.1))?;
853 text.span(from, to)
854 }
855}
856
857fn rc(position: Position) -> (usize, usize) {
858 (position.row(), position.column().get())
859}
860
861#[cfg(test)]
862mod search_tests {
863 use super::*;
864 use crate::ropetext::Text;
865
866 fn buffer(text: &str, pattern: &str, cursor: (usize, usize)) -> RopeBuffer {
867 let mut buf = RopeBuffer::new(Text::from(text));
868 buf.set_search_pattern(pattern).expect("valid pattern");
869 buf.move_cursor(CursorMove::Jump(cursor.0, cursor.1));
870 buf
871 }
872
873 #[test]
874 fn a_forward_search_wraps_to_a_match_behind_the_cursor() {
875 // One row, one match, cursor past it. Before the wrap visit stopped
876 // re-filtering, this reported no match while the find bar counted one.
877 let mut buf = buffer("xx foo", "foo", (0, 5));
878 assert!(buf.search_forward(false), "the match is behind the cursor");
879 assert_eq!(buf.cursor(), (0, 3));
880 }
881
882 #[test]
883 fn a_backward_search_wraps_to_a_match_ahead_of_the_cursor() {
884 let mut buf = buffer("xx foo", "foo", (0, 1));
885 assert!(buf.search_back(false));
886 assert_eq!(buf.cursor(), (0, 3));
887 }
888
889 #[test]
890 fn wrapping_crosses_rows_back_to_the_cursors_own_row() {
891 let mut buf = buffer("aaa\nxx foo", "foo", (1, 5));
892 assert!(buf.search_forward(false));
893 assert_eq!(buf.cursor(), (1, 3));
894 }
895
896 #[test]
897 fn the_only_match_is_re_offered_rather_than_reported_missing() {
898 // vim's answer: "search hit BOTTOM, continuing at TOP" lands back on the
899 // same match. Reporting false would paint "no match" over a match that is
900 // highlighted on screen.
901 let mut buf = buffer("xx foo", "foo", (0, 3));
902 assert!(buf.search_forward(false), "the one match is still a match");
903 assert_eq!(
904 buf.cursor(),
905 (0, 3),
906 "and the cursor has nowhere else to go"
907 );
908 }
909
910 #[test]
911 fn a_match_starting_inside_a_cluster_is_skipped_not_fatal() {
912 // "\u{1F469}\u{200D}\u{1F4BB}" is one cluster; the laptop scalar sits at
913 // char column 2, inside it. That start is unaddressable — but the real
914 // match on row 1 is, and abandoning the scan at the first unaddressable
915 // candidate is what made the bar say "no match" beside a count of two.
916 let mut buf = buffer(
917 "\u{1F469}\u{200D}\u{1F4BB}\nx\u{1F4BB}",
918 "\u{1F4BB}",
919 (0, 0),
920 );
921 assert!(buf.search_forward(false), "the row 1 match is reachable");
922 assert_eq!(buf.cursor(), (1, 1));
923 }
924}
925
926#[cfg(test)]
927mod cluster_tests {
928 use super::*;
929 use crate::ropetext::Text;
930
931 #[test]
932 fn delete_str_spends_its_count_on_clusters() {
933 // "[[" plus a regional-indicator flag: 4 scalars, 3 clusters. Three is
934 // what removes exactly `[[` and the flag — a caller counting the four
935 // scalars would take the space after them too.
936 let mut buf = RopeBuffer::new(Text::from("[[\u{1F1EA}\u{1F1F8} rest"));
937 buf.move_cursor(CursorMove::Jump(0, 0));
938 buf.delete_str(3);
939 assert_eq!(buf.rows(), &[" rest"]);
940 }
941
942 #[test]
943 fn inserting_before_a_combining_mark_keeps_the_cursor_addressable() {
944 // A row starting with a lone combining acute — NFD text pasted from
945 // macOS. Typing 'a' in front of it makes "a\u{301}", one cluster, and
946 // the post-edit cursor byte lands inside it.
947 let mut buf = RopeBuffer::new(Text::from("\u{301}f"));
948 buf.move_cursor(CursorMove::Jump(0, 0));
949 buf.insert_char('a');
950 assert_eq!(buf.rows(), &["a\u{301}f"]);
951 }
952}
953
954#[cfg(test)]
955mod damage_tests {
956 use super::*;
957 use crate::ropetext::Text;
958
959 #[test]
960 fn damage_from_several_edits_is_in_one_numbering() {
961 // Two mutations in one group, the second ABOVE the first and changing
962 // the line count — so the first edit's row moves before the group ends.
963 let mut buf = RopeBuffer::new(Text::from("r0\nr1\nr2\nr3\nr4"));
964 buf.edit(|b| {
965 b.move_cursor(CursorMove::Jump(4, 0));
966 b.insert_str("X");
967 b.move_cursor(CursorMove::Jump(0, 0));
968 b.insert_newline();
969 });
970 assert_eq!(buf.rows(), ["", "r0", "r1", "r2", "r3", "Xr4"]);
971
972 let damage = buf.take_outcome().damage.expect("the edits were reported");
973 assert!(
974 damage.contains(&5),
975 "the row edited first is row 5 once the group ends, but the damage \
976 reported was {damage:?} — a range in the older numbering"
977 );
978 }
979}