Skip to main content

lb_rs/model/text/
buffer.rs

1use super::offset_types::{Byte, Grapheme, Graphemes, RangeExt};
2use super::operation_types::{InverseOperation, Operation, Replace};
3use super::unicode_segs::UnicodeSegs;
4use super::{diff, unicode_segs};
5use std::ops::Index;
6use unicode_segmentation::UnicodeSegmentation as _;
7use web_time::{Duration, Instant};
8
9/// Long-lived state of the editor's text buffer. Factored into sub-structs for borrow-checking.
10/// # Operation algebra
11/// Operations are created based on a version of the buffer. This version is called the operation's base and is
12/// identified with a sequence number. When the base of an operation is equal to the buffer's current sequence number,
13/// the operation can be applied and increments the buffer's sequence number.
14///
15/// When multiple operations are created based on the same version of the buffer, such as when a user types a few
16/// keystrokes in one frame or issues a command like indenting multiple list items, the operations all have the same
17/// base. Once the first operation is applied and the buffer's sequence number is incremented, the base of the
18/// remaining operations must be incremented by using the first operation to transform them before they can be applied.
19/// This corresponds to the reality that the buffer state has changed since the operation was created and the operation
20/// must be re-interpreted. For example, if text is typed at the beginning then end of a buffer in one frame, the
21/// position of the text typed at the end of the buffer is greater when it is applied than it was when it was typed.
22///
23/// External changes are merged into the buffer by creating a set of operations that would transform the buffer from
24/// the last external state to the current state. These operations, based on the version of the buffer at the last
25/// successful save or load, must be transformed by all operations that have been applied since (this means we must
26/// preserve the undo history for at least that long; if this presents performance issues, we can always save). Each
27/// operation that is transforming the new operations will match the base of the new operations at the time of
28/// transformation. Finally, the operations will need to transform each other just like any other set of operations
29/// made in a single frame/made based on the same version of the buffer.
30///
31/// # Undo (work in progress)
32/// Undo should revert local changes only, leaving external changes in-tact, so that when all local changes are undone,
33/// the buffer is in a state reflecting external changes only. This is complicated by the fact that external changes
34/// may have been based on local changes that were synced to another client. To undo an operation that had an external
35/// change based on it, we have to interpret the external change in the absence of local changes that were present when
36/// it was created. This is the opposite of interpreting the external change in the presence of local changes that were
37/// not present when it was created i.e. the normal flow of merging external changes. Here, we are removing a local
38/// operation from the middle of the chain of operations that led to the current state of the buffer.
39///
40/// To do this, we perform the dance of transforming operations in reverse, taking a chain of operations each based on
41/// the prior and transforming them into a set of operations based on the same base as the operation to be undone. Then
42/// we remove the operation to be undone and apply the remaining operations with the forward transformation flow.
43///
44/// Operations are not invertible i.e. you cannot construct an inverse operation that will perfectly cancel out the
45/// effect of another operation regardless of the time of interpretation. For example, with a text replacement, you can
46/// construct an inverse text replacement that replaces the new range with the original text, but when operations are
47/// undone from the middle of the chain, it may affect the original text. The operation will be re-interpreted based on
48/// a new state of the buffer at its time of application. The replaced text has no fixed value by design.
49///
50/// However, it is possible to undo the specific application of an operation in the context of the state of the buffer
51/// when it was applied. We store information necessary to undo applied operations alongside the operations themselves
52/// i.e. the text replaced in the application. When the operation is transformed for any reason, this undo information
53/// is invalidated.
54#[derive(Default)]
55pub struct Buffer {
56    /// Current contents of the buffer (what should be displayed in the editor). Todo: hide behind a read-only accessor
57    pub current: Snapshot,
58
59    /// Snapshot of the buffer at the earliest undoable state. Operations are compacted into this as they overflow the
60    /// undo limit.
61    base: Snapshot,
62
63    /// Operations received by the buffer. Used for undo/redo and for merging external changes.
64    ops: Ops,
65
66    /// State for tracking out-of-editor changes
67    external: External,
68}
69
70#[derive(Debug, Default)]
71pub struct Snapshot {
72    pub text: String,
73    pub segs: UnicodeSegs,
74    pub selection: (Grapheme, Grapheme),
75    pub seq: usize,
76}
77
78impl Snapshot {
79    fn apply_select(&mut self, range: (Grapheme, Grapheme)) -> Response {
80        self.selection = range;
81        Response { selection_user_moved: true, ..Response::default() }
82    }
83
84    fn apply_replace(&mut self, replace: &Replace) -> (Response, Graphemes) {
85        let Replace { range, text } = replace;
86        let byte_range = self.segs.range_to_byte(*range);
87
88        // Capture pre-apply segs so `Graphemes::measure_replace` can compute
89        // the buffer delta. It's the only construction path for an
90        // OT-correct grapheme count — bypassing it (e.g.
91        // `text.graphemes(true).count()`) would produce a `usize`, not a
92        // `Graphemes`, so any caller of this function wouldn't compile.
93        let old_segs = self.segs.clone();
94
95        self.text
96            .replace_range(byte_range.start().0..byte_range.end().0, text);
97        self.segs = unicode_segs::calc(&self.text);
98
99        let actual_len = Graphemes::measure_replace(&old_segs, &self.segs, *range);
100
101        adjust_subsequent_range(*range, actual_len, false, &mut self.selection);
102
103        (Response { text_updated: true, ..Default::default() }, actual_len)
104    }
105
106    /// Captures the inverse-relevant state from the buffer *before* an op is
107    /// applied. Combined with the actual replacement length (only knowable
108    /// post-apply) by `PartialInverse::finalize` to produce the full inverse.
109    fn invert_pre(&self, op: &Operation) -> PartialInverse {
110        let mut partial = PartialInverse { select: self.selection, replace: None };
111        if let Operation::Replace(Replace { range, text: _ }) = op {
112            let byte_range = self.segs.range_to_byte(*range);
113            let replaced_text = self[byte_range].into();
114            partial.replace = Some((range.start(), replaced_text));
115        }
116        partial
117    }
118}
119
120struct PartialInverse {
121    select: (Grapheme, Grapheme),
122    /// (start, replaced_text) — the inverse range's end is `start +
123    /// actual_len`, which `finalize` fills in once apply has measured the
124    /// buffer delta.
125    replace: Option<(Grapheme, String)>,
126}
127
128impl PartialInverse {
129    fn finalize(self, actual_len: Graphemes) -> InverseOperation {
130        InverseOperation {
131            select: self.select,
132            replace: self.replace.map(|(start, replaced_text)| Replace {
133                range: (start, start + actual_len),
134                text: replaced_text,
135            }),
136        }
137    }
138}
139
140#[derive(Default)]
141struct Ops {
142    /// Operations that have been received by the buffer
143    all: Vec<Operation>,
144    meta: Vec<OpMeta>,
145
146    /// Sequence number of the first unapplied operation. Operations starting with this one are queued for processing.
147    processed_seq: usize,
148
149    /// Operations that have been applied to the buffer and already transformed, in order of application. Each of these
150    /// operations is based on the previous operation in this list, with the first based on the history base. Derived
151    /// from other data and invalidated by some undo/redo flows.
152    transformed: Vec<Operation>,
153
154    /// Operations representing the inverse of the operations in `transformed_ops`, in order of application. Useful for
155    /// undoing operations. The data model differs because an operation that replaces text containing the cursor needs
156    /// two operations to revert the text and cursor. Derived from other data and invalidated by some undo/redo flows.
157    transformed_inverted: Vec<InverseOperation>,
158
159    /// Actual graphemes contributed by each `transformed` op once spliced
160    /// into the buffer. The `Graphemes` newtype enforces this distinction
161    /// at the type level — populating this field requires a value from
162    /// `Graphemes::measure_replace`, not `text.graphemes(true).count()`,
163    /// which would account for in-isolation counts only and miss seam fusion
164    /// (Devanagari spacing marks, ZWJ sequences). Always 0 for
165    /// `Operation::Select`.
166    transformed_actual_len: Vec<Graphemes>,
167}
168
169impl Ops {
170    fn len(&self) -> usize {
171        self.all.len()
172    }
173
174    /// Returns the half-open `[start, end)` index range of the frame
175    /// containing `idx` (a frame is a contiguous run of ops sharing
176    /// the same `base`, set by a single `queue` call).
177    fn frame_at(&self, idx: usize) -> (usize, usize) {
178        let base = self.meta[idx].base;
179        let mut start = idx;
180        while start > 0 && self.meta[start - 1].base == base {
181            start -= 1;
182        }
183        let mut end = idx + 1;
184        while end < self.len() && self.meta[end].base == base {
185            end += 1;
186        }
187        (start, end)
188    }
189
190    /// A frame is "typing-shaped" if it represents a single keystroke-
191    /// scale text edit: exactly one `Replace` whose text is at most one
192    /// grapheme (so single-char insertion, backspace from cursor, plain
193    /// `\n`), plus at most one trailing `Select`. Adjacent typing-
194    /// shaped frames within 500ms group into one undo unit.
195    fn frame_is_typing(&self, start: usize, end: usize) -> bool {
196        let mut replaces = 0usize;
197        let mut text_graphemes = 0usize;
198        let mut selects = 0usize;
199        for op in &self.all[start..end] {
200            match op {
201                Operation::Replace(Replace { text, .. }) => {
202                    replaces += 1;
203                    text_graphemes += text.graphemes(true).count();
204                }
205                Operation::Select(_) => selects += 1,
206            }
207        }
208        replaces == 1 && text_graphemes <= 1 && selects <= 1
209    }
210
211    /// The frame's one replacement, if it has exactly one.
212    fn frame_replace(&self, start: usize, end: usize) -> Option<&Replace> {
213        let mut replaces = self.all[start..end].iter().filter_map(|op| match op {
214            Operation::Replace(replace) => Some(replace),
215            Operation::Select(_) => None,
216        });
217        let replace = replaces.next()?;
218        replaces.next().is_none().then_some(replace)
219    }
220
221    /// Whether the current frame replaces exactly the text the previous one
222    /// inserted, as an input method's composition does with each update and
223    /// its commit. Ranges are comparable only if the current frame was based
224    /// on the state right after the previous one (selects aside).
225    fn frame_revises(&self, (prev_start, prev_end): (usize, usize), curr_start: usize) -> bool {
226        let curr_end = self.frame_at(curr_start).1;
227        let (Some(prev), Some(curr)) =
228            (self.frame_replace(prev_start, prev_end), self.frame_replace(curr_start, curr_end))
229        else {
230            return false;
231        };
232        let based_after =
233            self.meta[curr_start].base - self.meta[prev_start].base == curr_start - prev_start;
234        let inserted = prev.text.graphemes(true).count();
235        let start = prev.range.start();
236        based_after
237            && inserted > 0
238            && curr.range.start() == start
239            && curr.range.end() == Grapheme(start.0 + inserted)
240    }
241
242    fn frame_has_replace(&self, start: usize, end: usize) -> bool {
243        self.all[start..end]
244            .iter()
245            .any(|op| matches!(op, Operation::Replace(_)))
246    }
247
248    fn is_undo_checkpoint(&self, idx: usize) -> bool {
249        // boundaries
250        if idx == 0 || idx == self.len() {
251            return true;
252        }
253
254        // within a single frame, never a checkpoint
255        if self.meta[idx].base == self.meta[idx - 1].base {
256            return false;
257        }
258
259        // current-frame analysis
260        let (curr_start, curr_end) = self.frame_at(idx);
261        if !self.frame_has_replace(curr_start, curr_end) {
262            // select-only frame absorbs into the prev unit
263            return false;
264        }
265
266        // walk back through any select-only frames to find the prev
267        // real frame. Selects absorb backward, so an undo of *this*
268        // unit doesn't revert the cursor placement (click) that came
269        // before it.
270        let mut walk = curr_start;
271        let prev_real = loop {
272            if walk == 0 {
273                break None;
274            }
275            let (s, e) = self.frame_at(walk - 1);
276            if self.frame_has_replace(s, e) {
277                break Some((s, e));
278            }
279            walk = s;
280        };
281
282        // a composition's updates and commit revise one insertion
283        if prev_real.is_some_and(|prev| self.frame_revises(prev, curr_start)) {
284            return false;
285        }
286
287        // typing-grouping: rapid single-keystroke edits stay in one
288        // unit so a burst of typing undoes together
289        if let Some((prev_start, prev_end)) = prev_real {
290            let curr_typing = self.frame_is_typing(curr_start, curr_end);
291            let prev_typing = self.frame_is_typing(prev_start, prev_end);
292            if curr_typing && prev_typing {
293                let gap = self.meta[curr_start].timestamp - self.meta[prev_end - 1].timestamp;
294                if gap <= Duration::from_millis(500) {
295                    return false;
296                }
297            }
298        }
299
300        true
301    }
302}
303
304#[derive(Default)]
305struct External {
306    /// Text last loaded into the editor. Used as a reference point for merging out-of-editor changes with in-editor
307    /// changes, similar to a base in a 3-way merge. May be a state that never appears in the buffer's history.
308    text: String,
309
310    /// Index of the last external operation referenced when merging changes. May be ahead of current.seq if there has
311    /// not been a call to `update()` (updates current.seq) since the last call to `reload()` (assigns new greatest seq
312    /// to `external_seq`).
313    seq: usize,
314}
315
316#[derive(Default)]
317pub struct Response {
318    pub text_updated: bool,
319    /// True iff the selection was set by an explicit `Select` op or by
320    /// `undo`/`redo` restoring a historical selection. False when the
321    /// selection moved only because a `Replace` op OT-shifted its
322    /// offsets — that's a side effect of editing, not a user navigation.
323    /// Callers use this to gate scroll-to-cursor: programmatic edits
324    /// (e.g. fold-button taps) shouldn't pull the viewport.
325    pub selection_user_moved: bool,
326    pub open_camera: bool,
327    /// Sequence range of operations applied this frame. Use
328    /// `buffer.replacements_since(seq_before)` to get the edits.
329    pub seq_before: usize,
330    pub seq_after: usize,
331}
332
333impl std::ops::BitOrAssign for Response {
334    fn bitor_assign(&mut self, other: Response) {
335        self.text_updated |= other.text_updated;
336        self.selection_user_moved |= other.selection_user_moved;
337        self.open_camera |= other.open_camera;
338        // keep the earliest seq_before and latest seq_after
339        if self.seq_before == self.seq_after {
340            self.seq_before = other.seq_before;
341        }
342        self.seq_after = other.seq_after;
343    }
344}
345
346/// Additional metadata tracked alongside operations internally.
347#[derive(Clone, Debug)]
348struct OpMeta {
349    /// At what time was this operation applied? Affects undo units.
350    pub timestamp: Instant,
351
352    /// What version of the buffer was the modifier looking at when they made this operation? Used for operational
353    /// transformation, both when applying multiple operations in one frame and when merging out-of-editor changes.
354    /// The magic happens here.
355    pub base: usize,
356}
357
358impl Buffer {
359    /// Push a series of operations onto the buffer's input queue; operations will be undone/redone atomically. Useful
360    /// for batches of internal operations produced from a single input event e.g. multi-line list identation.
361    pub fn queue(&mut self, mut ops: Vec<Operation>) {
362        let timestamp = Instant::now();
363        let base = self.current.seq;
364
365        // combine adjacent replacements
366        let mut combined_ops = Vec::new();
367        ops.sort_by_key(|op| match op {
368            Operation::Select(range) | Operation::Replace(Replace { range, .. }) => range.start(),
369        });
370        for op in ops.into_iter() {
371            match &op {
372                Operation::Replace(Replace { range: op_range, text: op_text }) => {
373                    if let Some(Operation::Replace(Replace {
374                        range: last_op_range,
375                        text: last_op_text,
376                    })) = combined_ops.last_mut()
377                    {
378                        if last_op_range.end() == op_range.start() {
379                            last_op_range.1 = op_range.1;
380                            last_op_text.push_str(op_text);
381                        } else {
382                            combined_ops.push(op);
383                        }
384                    } else {
385                        combined_ops.push(op);
386                    }
387                }
388                Operation::Select(_) => combined_ops.push(op),
389            }
390        }
391
392        self.ops
393            .meta
394            .extend(combined_ops.iter().map(|_| OpMeta { timestamp, base }));
395        self.ops.all.extend(combined_ops);
396    }
397
398    /// Loads a new string into the buffer, merging out-of-editor changes made since last load with in-editor changes
399    /// made since last load. The buffer's undo history is preserved; undo'ing will revert in-editor changes only.
400    /// Exercising undo's may put the buffer in never-before-seen states and exercising all undo's will revert the
401    /// buffer to the most recently loaded state (undo limit permitting).
402    /// Note: undo behavior described here is aspirational and not yet implemented.
403    pub fn reload(&mut self, text: String) {
404        let timestamp = Instant::now();
405        let base = self.external.seq;
406        let ops = diff(&self.external.text, &text);
407
408        self.ops
409            .meta
410            .extend(ops.iter().map(|_| OpMeta { timestamp, base }));
411        self.ops.all.extend(ops.into_iter().map(Operation::Replace));
412
413        self.external.text = text;
414        self.external.seq = self.base.seq + self.ops.all.len();
415    }
416
417    /// Indicates to the buffer the changes that have been saved outside the editor. This will serve as the new base
418    /// for merging external changes. The sequence number should be taken from `current.seq` of the buffer when the
419    /// buffer's contents are read for saving.
420    pub fn saved(&mut self, external_seq: usize, external_text: String) {
421        self.external.text = external_text;
422        self.external.seq = external_seq;
423    }
424
425    pub fn merge(mut self, external_text_a: String, external_text_b: String) -> String {
426        let ops_a = diff(&self.external.text, &external_text_a);
427        let ops_b = diff(&self.external.text, &external_text_b);
428
429        let timestamp = Instant::now();
430        let base = self.external.seq;
431        self.ops
432            .meta
433            .extend(ops_a.iter().map(|_| OpMeta { timestamp, base }));
434        self.ops
435            .meta
436            .extend(ops_b.iter().map(|_| OpMeta { timestamp, base }));
437
438        self.ops
439            .all
440            .extend(ops_a.into_iter().map(Operation::Replace));
441        self.ops
442            .all
443            .extend(ops_b.into_iter().map(Operation::Replace));
444
445        self.update();
446        self.current.text
447    }
448
449    /// Applies all operations in the buffer's input queue
450    pub fn update(&mut self) -> Response {
451        // clear redo stack
452        //             v base        v current    v processed
453        // ops before: |<- applied ->|<- undone ->|<- queued ->|
454        // ops after:  |<- applied ->|<- queued ->|
455        let queue_len = self.base.seq + self.ops.len() - self.ops.processed_seq;
456        if queue_len > 0 {
457            let drain_range = self.current.seq..self.ops.processed_seq;
458            self.ops.all.drain(drain_range.clone());
459            self.ops.meta.drain(drain_range.clone());
460            self.ops.transformed.drain(drain_range.clone());
461            self.ops.transformed_inverted.drain(drain_range.clone());
462            self.ops.transformed_actual_len.drain(drain_range.clone());
463            self.ops.processed_seq = self.current.seq;
464        } else {
465            return Response::default();
466        }
467
468        // transform & apply
469        let mut result = Response { seq_before: self.current.seq, ..Default::default() };
470        for idx in self.current_idx()..self.current_idx() + queue_len {
471            let mut op = self.ops.all[idx].clone();
472            let meta = &self.ops.meta[idx];
473            self.transform(&mut op, meta);
474            // Capture inverse-relevant state before apply (it needs the
475            // pre-apply text); finalize once redo's apply has measured the
476            // actual contribution and stored it in `transformed_actual_len`.
477            let partial_inverse = self.current.invert_pre(&op);
478            self.ops.transformed.push(op.clone());
479            self.ops.transformed_actual_len.push(Graphemes::default());
480            self.ops.processed_seq += 1;
481
482            result |= self.redo();
483
484            let actual_len = *self.ops.transformed_actual_len.last().unwrap();
485            self.ops
486                .transformed_inverted
487                .push(partial_inverse.finalize(actual_len));
488        }
489
490        result.seq_after = self.current.seq;
491        result
492    }
493
494    fn transform(&self, op: &mut Operation, meta: &OpMeta) {
495        let base_idx = meta.base - self.base.seq;
496        for transforming_idx in base_idx..self.ops.processed_seq {
497            let preceding_op = &self.ops.transformed[transforming_idx];
498            let preceding_actual_len = self.ops.transformed_actual_len[transforming_idx];
499            if let Operation::Replace(Replace {
500                range: preceding_replaced_range,
501                text: _preceding_replacement_text,
502            }) = preceding_op
503            {
504                if let Operation::Replace(Replace { range: transformed_range, text }) = op {
505                    if preceding_replaced_range.intersects(transformed_range, false)
506                        && !(preceding_replaced_range.is_empty() && transformed_range.is_empty())
507                    {
508                        // concurrent replacements to intersecting ranges choose the first/local edit as the winner
509                        // this doesn't create self-conflicts during merge because merge combines adjacent replacements
510                        // this doesn't create self-conflicts for same-frame editor changes because our final condition
511                        // is that we don't simultaneously insert text for both operations, which creates un-ideal
512                        // behavior (see test buffer_merge_insert)
513                        *text = "".into();
514                        transformed_range.1 = transformed_range.0;
515                    }
516                }
517
518                match op {
519                    Operation::Replace(Replace { range: transformed_range, .. })
520                    | Operation::Select(transformed_range) => {
521                        adjust_subsequent_range(
522                            *preceding_replaced_range,
523                            preceding_actual_len,
524                            true,
525                            transformed_range,
526                        );
527                    }
528                }
529            }
530        }
531    }
532
533    pub fn can_redo(&self) -> bool {
534        self.current.seq < self.ops.processed_seq
535    }
536
537    pub fn can_undo(&self) -> bool {
538        self.current.seq > self.base.seq
539    }
540
541    pub fn redo(&mut self) -> Response {
542        let mut response = Response::default();
543        while self.can_redo() {
544            let idx = self.current_idx();
545            // Clone so we can mutate `self` (storing actual_len) without
546            // holding a borrow of `self.ops.transformed`.
547            let op = self.ops.transformed[idx].clone();
548
549            self.current.seq += 1;
550
551            let actual_len = match &op {
552                Operation::Replace(replace) => {
553                    let (resp, len) = self.current.apply_replace(replace);
554                    response |= resp;
555                    len
556                }
557                Operation::Select(range) => {
558                    response |= self.current.apply_select(*range);
559                    Graphemes::default()
560                }
561            };
562            self.ops.transformed_actual_len[idx] = actual_len;
563
564            if self.ops.is_undo_checkpoint(self.current_idx()) {
565                break;
566            }
567        }
568        response
569    }
570
571    pub fn undo(&mut self) -> Response {
572        let mut response = Response::default();
573        while self.can_undo() {
574            self.current.seq -= 1;
575            // Clone so we can mutate `self.current` while reading the inverse.
576            let op = self.ops.transformed_inverted[self.current_idx()].clone();
577
578            if let Some(replace) = &op.replace {
579                let (resp, _) = self.current.apply_replace(replace);
580                response |= resp;
581            }
582            response |= self.current.apply_select(op.select);
583
584            if self.ops.is_undo_checkpoint(self.current_idx()) {
585                break;
586            }
587        }
588        response
589    }
590
591    fn current_idx(&self) -> usize {
592        self.current.seq - self.base.seq
593    }
594
595    /// Transforms a range through all replacements applied between
596    /// `since_seq` and the current sequence. Returns `None` if the range
597    /// intersected a replacement (i.e. the content it referred to was
598    /// modified).
599    pub fn transform_range(&self, since_seq: usize, range: &mut (Grapheme, Grapheme)) -> bool {
600        let start = since_seq.saturating_sub(self.base.seq);
601        let end = self.current_idx();
602        for (i, op) in self.ops.transformed[start..end].iter().enumerate() {
603            if let Operation::Replace(replace) = op {
604                if range.intersects(&replace.range, true)
605                    && !(range.is_empty() && replace.range.is_empty())
606                {
607                    return false;
608                }
609                let replacement_len = self.ops.transformed_actual_len[start + i];
610                adjust_subsequent_range(replace.range, replacement_len, false, range);
611            }
612        }
613        true
614    }
615
616    /// Reports whether the buffer's current text is empty.
617    pub fn is_empty(&self) -> bool {
618        self.current.text.is_empty()
619    }
620
621    pub fn selection_text(&self) -> String {
622        self[self.current.selection].to_string()
623    }
624}
625
626impl From<&str> for Buffer {
627    fn from(value: &str) -> Self {
628        let mut result = Self::default();
629        result.current.text = value.to_string();
630        result.current.segs = unicode_segs::calc(value);
631        result.external.text = value.to_string();
632        result
633    }
634}
635
636/// Adjust a range based on a text replacement. Positions before the replacement generally are not adjusted,
637/// positions after the replacement generally are, and positions within the replacement are adjusted to the end of
638/// the replacement if `prefer_advance` is true or are adjusted to the start of the replacement otherwise.
639pub fn adjust_subsequent_range(
640    replaced_range: (Grapheme, Grapheme), replacement_len: Graphemes, prefer_advance: bool,
641    range: &mut (Grapheme, Grapheme),
642) {
643    for position in [&mut range.0, &mut range.1] {
644        adjust_subsequent_position(replaced_range, replacement_len, prefer_advance, position);
645    }
646}
647
648/// Adjust a position based on a text replacement. Positions before the replacement generally are not adjusted,
649/// positions after the replacement generally are, and positions within the replacement are adjusted to the end of
650/// the replacement if `prefer_advance` is true or are adjusted to the start of the replacement otherwise.
651fn adjust_subsequent_position(
652    replaced_range: (Grapheme, Grapheme), replacement_len: Graphemes, prefer_advance: bool,
653    position: &mut Grapheme,
654) {
655    let replaced_len = replaced_range.len();
656    let replacement_start = replaced_range.start();
657    let replacement_end = replacement_start + replacement_len;
658
659    enum Mode {
660        Insert,
661        Replace,
662    }
663    let mode = if replaced_range.is_empty() { Mode::Insert } else { Mode::Replace };
664
665    let sorted_bounds = {
666        let mut bounds = vec![replaced_range.start(), replaced_range.end(), *position];
667        bounds.sort();
668        bounds
669    };
670    let bind = |start: &Grapheme, end: &Grapheme, pos: &Grapheme| {
671        start == &replaced_range.start() && end == &replaced_range.end() && pos == &*position
672    };
673
674    *position = match (mode, &sorted_bounds[..]) {
675        // case 1: position at point of text insertion
676        //                       text before replacement: * *
677        //                        range of replaced text:  |
678        //          range of subsequent cursor selection:  |
679        //                        text after replacement: * X *
680        // advance:
681        // adjusted range of subsequent cursor selection:    |
682        // don't advance:
683        // adjusted range of subsequent cursor selection:  |
684        (Mode::Insert, [start, end, pos]) if bind(start, end, pos) && end == pos => {
685            if prefer_advance {
686                replacement_end
687            } else {
688                replacement_start
689            }
690        }
691
692        // case 2: position at start of text replacement
693        //                       text before replacement: * * * *
694        //                        range of replaced text:  |<->|
695        //          range of subsequent cursor selection:  |
696        //                        text after replacement: * X *
697        // adjusted range of subsequent cursor selection:  |
698        (Mode::Replace, [start, pos, end]) if bind(start, end, pos) && start == pos => {
699            if prefer_advance {
700                replacement_end
701            } else {
702                replacement_start
703            }
704        }
705
706        // case 3: position at end of text replacement
707        //                       text before replacement: * * * *
708        //                        range of replaced text:  |<->|
709        //          range of subsequent cursor selection:      |
710        //                        text after replacement: * X *
711        // adjusted range of subsequent cursor selection:    |
712        (Mode::Replace, [start, end, pos]) if bind(start, end, pos) && end == pos => {
713            if prefer_advance {
714                replacement_end
715            } else {
716                replacement_start
717            }
718        }
719
720        // case 4: position before point/start of text insertion/replacement
721        //                       text before replacement: * * * * *
722        //       (possibly empty) range of replaced text:    |<->|
723        //          range of subsequent cursor selection:  |
724        //                        text after replacement: * * X *
725        // adjusted range of subsequent cursor selection:  |
726        (_, [pos, start, end]) if bind(start, end, pos) => *position,
727
728        // case 5: position within text replacement
729        //                       text before replacement: * * * *
730        //                        range of replaced text:  |<->|
731        //          range of subsequent cursor selection:    |
732        //                        text after replacement: * X *
733        // advance:
734        // adjusted range of subsequent cursor selection:    |
735        // don't advance:
736        // adjusted range of subsequent cursor selection:  |
737        (Mode::Replace, [start, pos, end]) if bind(start, end, pos) => {
738            if prefer_advance {
739                replacement_end
740            } else {
741                replacement_start
742            }
743        }
744
745        // case 6: position after point/end of text insertion/replacement
746        //                       text before replacement: * * * * *
747        //       (possibly empty) range of replaced text:  |<->|
748        //          range of subsequent cursor selection:        |
749        //                        text after replacement: * X * *
750        // adjusted range of subsequent cursor selection:      |
751        (_, [start, end, pos]) if bind(start, end, pos) => {
752            *position + replacement_len - replaced_len
753        }
754        _ => unreachable!(),
755    }
756}
757
758impl Index<(Byte, Byte)> for Snapshot {
759    type Output = str;
760
761    fn index(&self, index: (Byte, Byte)) -> &Self::Output {
762        &self.text[index.start().0..index.end().0]
763    }
764}
765
766impl Index<(Grapheme, Grapheme)> for Snapshot {
767    type Output = str;
768
769    fn index(&self, index: (Grapheme, Grapheme)) -> &Self::Output {
770        let index = self.segs.range_to_byte(index);
771        &self.text[index.start().0..index.end().0]
772    }
773}
774
775impl Index<(Byte, Byte)> for Buffer {
776    type Output = str;
777
778    fn index(&self, index: (Byte, Byte)) -> &Self::Output {
779        &self.current[index]
780    }
781}
782
783impl Index<(Grapheme, Grapheme)> for Buffer {
784    type Output = str;
785
786    fn index(&self, index: (Grapheme, Grapheme)) -> &Self::Output {
787        &self.current[index]
788    }
789}
790
791#[cfg(test)]
792mod test {
793    use super::Buffer;
794    use crate::model::text::offset_types::{Grapheme, RangeExt as _};
795    use crate::model::text::operation_types::{Operation, Replace};
796    use unicode_segmentation::UnicodeSegmentation;
797
798    fn type_into_selection(buffer: &mut Buffer, text: &str) {
799        let range = buffer.current.selection;
800        buffer.queue(vec![
801            Operation::Replace(Replace { range, text: text.into() }),
802            Operation::Select((range.start(), range.start())),
803        ]);
804        buffer.update();
805    }
806
807    #[test]
808    fn type_into_forward_selection() {
809        let mut buffer = Buffer::from("hello");
810        buffer.current.selection = (Grapheme(0), Grapheme(5));
811        type_into_selection(&mut buffer, "X");
812        assert_eq!(buffer.current.text, "X");
813        assert_eq!(buffer.current.selection, (Grapheme(1), Grapheme(1)));
814    }
815
816    #[test]
817    fn type_into_backward_selection() {
818        let mut buffer = Buffer::from("hello");
819        buffer.current.selection = (Grapheme(5), Grapheme(0));
820        type_into_selection(&mut buffer, "X");
821        assert_eq!(buffer.current.text, "X");
822        assert_eq!(buffer.current.selection, (Grapheme(1), Grapheme(1)));
823    }
824
825    #[test]
826    fn buffer_merge_nonintersecting_replace() {
827        let base_content = "base content base";
828        let local_content = "local content base";
829        let remote_content = "base content remote";
830
831        assert_eq!(
832            Buffer::from(base_content).merge(local_content.into(), remote_content.into()),
833            "local content remote"
834        );
835        assert_eq!(
836            Buffer::from(base_content).merge(remote_content.into(), local_content.into()),
837            "local content remote"
838        );
839    }
840
841    #[test]
842    fn buffer_merge_prefix_replace() {
843        let base_content = "base content";
844        let local_content = "local content";
845        let remote_content = "remote content";
846
847        assert_eq!(
848            Buffer::from(base_content).merge(local_content.into(), remote_content.into()),
849            "local content"
850        );
851    }
852
853    #[test]
854    fn buffer_merge_infix_replace() {
855        let base_content = "con base tent";
856        let local_content = "con local tent";
857        let remote_content = "con remote tent";
858
859        assert_eq!(
860            Buffer::from(base_content).merge(local_content.into(), remote_content.into()),
861            "con local tent"
862        );
863        assert_eq!(
864            Buffer::from(base_content).merge(remote_content.into(), local_content.into()),
865            "con remote tent"
866        );
867    }
868
869    #[test]
870    fn buffer_merge_postfix_replace() {
871        let base_content = "content base";
872        let local_content = "content local";
873        let remote_content = "content remote";
874
875        assert_eq!(
876            Buffer::from(base_content).merge(local_content.into(), remote_content.into()),
877            "content local"
878        );
879        assert_eq!(
880            Buffer::from(base_content).merge(remote_content.into(), local_content.into()),
881            "content remote"
882        );
883    }
884
885    #[test]
886    fn buffer_merge_insert() {
887        let base_content = "content";
888        let local_content = "content local";
889        let remote_content = "content remote";
890
891        assert_eq!(
892            Buffer::from(base_content).merge(local_content.into(), remote_content.into()),
893            "content local remote"
894        );
895        assert_eq!(
896            Buffer::from(base_content).merge(remote_content.into(), local_content.into()),
897            "content remote local"
898        );
899    }
900
901    #[test]
902    // this test case documents behavior moreso than asserting target state
903    fn buffer_merge_insert_replace() {
904        let base_content = "content";
905        let local_content = "content local";
906        let remote_content = "remote";
907
908        assert_eq!(
909            Buffer::from(base_content).merge(local_content.into(), remote_content.into()),
910            "remote"
911        );
912        assert_eq!(
913            Buffer::from(base_content).merge(remote_content.into(), local_content.into()),
914            "remote local"
915        );
916    }
917
918    #[test]
919    // this test case used to crash `merge`
920    fn buffer_merge_crash() {
921        let base_content = "con tent";
922        let local_content = "cont tent locallocal";
923        let remote_content = "cont remote tent";
924
925        let _ = Buffer::from(base_content).merge(local_content.into(), remote_content.into());
926        let _ = Buffer::from(base_content).merge(remote_content.into(), local_content.into());
927    }
928
929    // ── Fuzz ──────────────────────────────────────────────────────────────
930
931    use rand::prelude::*;
932
933    /// Grapheme clusters for fuzz testing. Includes ASCII, multi-byte, multi-codepoint emoji
934    /// (ZWJ sequences, skin tones, flags), and combining characters.
935    const POOL: &[&str] = &[
936        "a",
937        "b",
938        "z",
939        " ",
940        "\n",
941        "\t",
942        "é",
943        "ñ",
944        "ü",
945        "日",
946        "本",
947        "語",
948        "👋",
949        "🎉",
950        "🔥",
951        "❤️",
952        "👨‍👩‍👧‍👦",
953        "🏳️‍🌈",
954        "👍🏽",
955        "🇺🇸",
956        "🇯🇵",
957        "e\u{0301}",
958        "a\u{0308}", // combining sequences: é, ä
959    ];
960
961    /// Generate a random grapheme-level edit of a document. Picks uniformly from:
962    /// - 0: Delete 1-5 consecutive graphemes at a random position
963    /// - 1: Insert 1-5 random graphemes from POOL at a random position
964    /// - 2: Replace 1-5 consecutive graphemes with 1-3 random graphemes from POOL
965    /// - 3: Clear everything
966    ///
967    /// When the document is empty, cases 0/2/3 fall through to insert (the _ arm).
968    fn random_edit(rng: &mut StdRng, doc: &str) -> String {
969        let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(doc, true).collect();
970        let len = graphemes.len();
971
972        let mut g: Vec<String> = graphemes.iter().map(|s| s.to_string()).collect();
973
974        match rng.gen_range(0..4) {
975            0 if len > 0 => {
976                let pos = rng.gen_range(0..len);
977                let del = rng.gen_range(1..=(len - pos).min(5));
978                g.drain(pos..pos + del);
979            }
980            1 => {
981                let pos = rng.gen_range(0..=len);
982                let n = rng.gen_range(1..=5);
983                for j in 0..n {
984                    g.insert(pos + j, POOL[rng.gen_range(0..POOL.len())].into());
985                }
986            }
987            2 if len > 0 => {
988                let pos = rng.gen_range(0..len);
989                let del = rng.gen_range(1..=(len - pos).min(5));
990                let ins: Vec<String> = (0..rng.gen_range(1..=3))
991                    .map(|_| POOL[rng.gen_range(0..POOL.len())].into())
992                    .collect();
993                g.splice(pos..pos + del, ins);
994            }
995            3 if len > 0 => {
996                g.clear();
997            }
998            _ => {
999                let n = rng.gen_range(1..=5);
1000                for _ in 0..n {
1001                    g.push(POOL[rng.gen_range(0..POOL.len())].into());
1002                }
1003            }
1004        }
1005        g.concat()
1006    }
1007
1008    #[test]
1009    fn buffer_merge_fuzz() {
1010        let mut rng = StdRng::seed_from_u64(42);
1011        let bases = ["hello world", "👨‍👩‍👧‍👦🇺🇸🔥", "café ñoño 日本語", ""];
1012        for _ in 0..10_000 {
1013            let base = bases[rng.gen_range(0..bases.len())];
1014            let a = random_edit(&mut rng, base);
1015            let b = random_edit(&mut rng, base);
1016
1017            // must not panic
1018            let _ = Buffer::from(base).merge(a.clone(), b.clone());
1019            let _ = Buffer::from(base).merge(b, a);
1020        }
1021    }
1022
1023    // ── Chain convergence ──
1024
1025    /// Simulates a sync channel between two adjacent nodes. Holds the last-agreed-upon
1026    /// document text, which serves as the 3-way merge base. When two nodes sync, their
1027    /// documents are merged against this base, both nodes adopt the result, and the base
1028    /// advances. This mirrors how lockbook's sync works: each client keeps a base (last
1029    /// synced state) and merges local vs remote changes against it.
1030    struct SyncLink {
1031        base: String,
1032    }
1033
1034    impl SyncLink {
1035        fn new(base: &str) -> Self {
1036            Self { base: base.into() }
1037        }
1038
1039        fn sync(&mut self, left: &mut String, right: &mut String) {
1040            let merged = Buffer::from(self.base.as_str()).merge(left.clone(), right.clone());
1041            *left = merged.clone();
1042            *right = merged.clone();
1043            self.base = merged;
1044        }
1045    }
1046
1047    /// Sync all adjacent pairs in both directions until the chain stabilizes.
1048    /// With N nodes and N-1 links, 2*N passes ensures changes propagate end-to-end.
1049    fn full_sync(nodes: &mut [String], links: &mut [SyncLink]) {
1050        for _ in 0..nodes.len() * 2 {
1051            for i in 0..links.len() {
1052                let (left, right) = nodes.split_at_mut(i + 1);
1053                links[i].sync(&mut left[i], &mut right[0]);
1054            }
1055            for i in (0..links.len()).rev() {
1056                let (left, right) = nodes.split_at_mut(i + 1);
1057                links[i].sync(&mut left[i], &mut right[0]);
1058            }
1059        }
1060    }
1061
1062    fn partial_sync(nodes: &mut [String], links: &mut [SyncLink], rng: &mut StdRng) {
1063        for _ in 0..3 {
1064            for i in 0..links.len() {
1065                if rng.gen_bool(0.5) {
1066                    let (left, right) = nodes.split_at_mut(i + 1);
1067                    links[i].sync(&mut left[i], &mut right[0]);
1068                }
1069            }
1070        }
1071    }
1072
1073    fn assert_converged(nodes: &[String]) {
1074        for (i, node) in nodes.iter().enumerate().skip(1) {
1075            assert_eq!(
1076                &nodes[0], node,
1077                "node 0 and node {} diverged: {:?} vs {:?}",
1078                i, nodes[0], node
1079            );
1080        }
1081    }
1082
1083    #[test]
1084    fn buffer_merge_fuzz_chain_2() {
1085        let mut rng = StdRng::seed_from_u64(42);
1086        for _ in 0..10_000 {
1087            let init = if rng.gen_bool(0.5) { "hello 👋🏽" } else { "" };
1088            let mut nodes: Vec<String> = (0..2).map(|_| init.into()).collect();
1089            let mut links: Vec<SyncLink> = (0..1).map(|_| SyncLink::new(init)).collect();
1090
1091            for _ in 0..rng.gen_range(1..=4) {
1092                for _ in 0..rng.gen_range(1..=3) {
1093                    let i = rng.gen_range(0..2);
1094                    nodes[i] = random_edit(&mut rng, &nodes[i]);
1095                }
1096                if rng.gen_bool(0.5) {
1097                    partial_sync(&mut nodes, &mut links, &mut rng);
1098                }
1099            }
1100
1101            full_sync(&mut nodes, &mut links);
1102            assert_converged(&nodes);
1103        }
1104    }
1105
1106    #[test]
1107    fn buffer_merge_fuzz_chain_5() {
1108        let mut rng = StdRng::seed_from_u64(77);
1109        for _ in 0..5_000 {
1110            let init = if rng.gen_bool(0.5) { "café 日本語 🇯🇵" } else { "abc" };
1111            let mut nodes: Vec<String> = (0..5).map(|_| init.into()).collect();
1112            let mut links: Vec<SyncLink> = (0..4).map(|_| SyncLink::new(init)).collect();
1113
1114            for _ in 0..rng.gen_range(1..=3) {
1115                for _ in 0..rng.gen_range(1..=5) {
1116                    let i = rng.gen_range(0..5);
1117                    nodes[i] = random_edit(&mut rng, &nodes[i]);
1118                }
1119                if rng.gen_bool(0.5) {
1120                    partial_sync(&mut nodes, &mut links, &mut rng);
1121                }
1122            }
1123
1124            full_sync(&mut nodes, &mut links);
1125            assert_converged(&nodes);
1126        }
1127    }
1128}
1129
1130#[cfg(test)]
1131mod undo_unit_tests {
1132    //! Unit-grouping behavior of `undo`. Each `frame` call simulates
1133    //! one `queue() + update()` (one editor frame). After a sequence
1134    //! of frames we count how many `undo()` calls it takes to reach a
1135    //! given state — that count *is* the number of undo units.
1136
1137    use super::Buffer;
1138    use crate::model::text::offset_types::{Grapheme, IntoRangeExt as _, RangeExt as _};
1139    use crate::model::text::operation_types::{Operation, Replace};
1140    use std::thread::sleep;
1141    use std::time::Duration;
1142
1143    fn frame(buf: &mut Buffer, ops: Vec<Operation>) {
1144        buf.queue(ops);
1145        buf.update();
1146    }
1147
1148    fn type_str(buf: &mut Buffer, s: &str) {
1149        let cursor = buf.current.selection.start();
1150        // Select is at the pre-insert cursor; OT advances it to the
1151        // end of the inserted text via `prefer_advance`.
1152        frame(
1153            buf,
1154            vec![
1155                Operation::Replace(Replace { range: cursor.into_range(), text: s.into() }),
1156                Operation::Select(cursor.into_range()),
1157            ],
1158        );
1159    }
1160
1161    /// Plain Enter at cursor — single `\n` Replace, like the editor
1162    /// produces when no auto-prefix kicks in.
1163    fn enter(buf: &mut Buffer) {
1164        type_str(buf, "\n");
1165    }
1166
1167    fn backspace(buf: &mut Buffer) {
1168        let cursor = buf.current.selection.start();
1169        if cursor.0 == 0 {
1170            return;
1171        }
1172        let prev = Grapheme(cursor.0 - 1);
1173        frame(
1174            buf,
1175            vec![
1176                Operation::Replace(Replace { range: (prev, cursor), text: "".into() }),
1177                Operation::Select(prev.into_range()),
1178            ],
1179        );
1180    }
1181
1182    fn click(buf: &mut Buffer, pos: usize) {
1183        frame(buf, vec![Operation::Select(Grapheme(pos).into_range())]);
1184    }
1185
1186    /// Multi-Replace frame, the shape produced by tab/shift-tab
1187    /// cascades — explicitly not typing-shaped.
1188    fn cmd_indent_lines(buf: &mut Buffer, line_starts: &[usize]) {
1189        let mut ops: Vec<Operation> = line_starts
1190            .iter()
1191            .map(|&s| {
1192                Operation::Replace(Replace { range: Grapheme(s).into_range(), text: "  ".into() })
1193            })
1194            .collect();
1195        ops.push(Operation::Select(Grapheme(line_starts[0] + 2).into_range()));
1196        frame(buf, ops);
1197    }
1198
1199    fn cmd_deindent_lines(buf: &mut Buffer, line_starts: &[usize]) {
1200        let mut ops: Vec<Operation> = line_starts
1201            .iter()
1202            .map(|&s| {
1203                Operation::Replace(Replace {
1204                    range: (Grapheme(s), Grapheme(s + 2)),
1205                    text: "".into(),
1206                })
1207            })
1208            .collect();
1209        ops.push(Operation::Select(Grapheme(line_starts[0]).into_range()));
1210        frame(buf, ops);
1211    }
1212
1213    // ─── A: single command undoes in one step ──────────────────────
1214    #[test]
1215    fn single_command_one_unit() {
1216        let mut buf = Buffer::from("ab\ncd\nef\n");
1217        cmd_indent_lines(&mut buf, &[0, 3, 6]);
1218        assert_eq!(buf.current.text, "  ab\n  cd\n  ef\n");
1219        buf.undo();
1220        assert_eq!(buf.current.text, "ab\ncd\nef\n");
1221    }
1222
1223    // ─── B: two commands undo independently ────────────────────────
1224    #[test]
1225    fn two_commands_two_units() {
1226        let mut buf = Buffer::from("ab\ncd\nef\n");
1227        cmd_indent_lines(&mut buf, &[0, 3, 6]);
1228        cmd_indent_lines(&mut buf, &[2, 7, 12]);
1229        let after_two = buf.current.text.clone();
1230        buf.undo();
1231        assert_ne!(buf.current.text, after_two);
1232        let after_one_undo = buf.current.text.clone();
1233        assert_eq!(after_one_undo, "  ab\n  cd\n  ef\n");
1234        buf.undo();
1235        assert_eq!(buf.current.text, "ab\ncd\nef\n");
1236    }
1237
1238    // ─── C: click only doesn't form an undoable unit ───────────────
1239    #[test]
1240    fn click_only_doesnt_create_unit() {
1241        let mut buf = Buffer::from("hello");
1242        click(&mut buf, 3);
1243        // No real edits ever — undo() can technically undo the
1244        // selection move, but that's the only thing in history.
1245        // Important: it doesn't create a checkpoint that would block
1246        // a subsequent edit's undo from reaching past it.
1247        type_str(&mut buf, "X");
1248        assert_eq!(buf.current.text, "helXlo");
1249        buf.undo();
1250        assert_eq!(buf.current.text, "hello");
1251    }
1252
1253    // ─── D: click between two commands, click absorbs ──────────────
1254    #[test]
1255    fn click_between_commands_absorbs() {
1256        let mut buf = Buffer::from("ab\ncd\nef\n");
1257        cmd_indent_lines(&mut buf, &[0, 3, 6]);
1258        click(&mut buf, 0);
1259        cmd_indent_lines(&mut buf, &[2, 7, 12]);
1260        buf.undo();
1261        assert_eq!(buf.current.text, "  ab\n  cd\n  ef\n");
1262        buf.undo();
1263        assert_eq!(buf.current.text, "ab\ncd\nef\n");
1264    }
1265
1266    // ─── E: rapid typing groups into one unit ──────────────────────
1267    #[test]
1268    fn rapid_typing_one_unit() {
1269        let mut buf = Buffer::from("");
1270        type_str(&mut buf, "a");
1271        type_str(&mut buf, "b");
1272        type_str(&mut buf, "c");
1273        assert_eq!(buf.current.text, "abc");
1274        buf.undo();
1275        assert_eq!(buf.current.text, "");
1276    }
1277
1278    // ─── F: typing with a long pause splits at the gap ─────────────
1279    #[test]
1280    fn typing_long_pause_splits() {
1281        let mut buf = Buffer::from("");
1282        type_str(&mut buf, "a");
1283        type_str(&mut buf, "b");
1284        sleep(Duration::from_millis(600));
1285        type_str(&mut buf, "c");
1286        type_str(&mut buf, "d");
1287        assert_eq!(buf.current.text, "abcd");
1288        buf.undo();
1289        assert_eq!(buf.current.text, "ab");
1290        buf.undo();
1291        assert_eq!(buf.current.text, "");
1292    }
1293
1294    // ─── G: type / cmd / type → 3 units ────────────────────────────
1295    #[test]
1296    fn type_cmd_type_three_units() {
1297        let mut buf = Buffer::from("xx\nyy\n");
1298        // pre-position cursor at end so type_str appends after the doc
1299        buf.current.selection = (Grapheme(6), Grapheme(6));
1300        type_str(&mut buf, "a");
1301        type_str(&mut buf, "b");
1302        cmd_indent_lines(&mut buf, &[0, 3]);
1303        type_str(&mut buf, "c");
1304        type_str(&mut buf, "d");
1305        let final_text = buf.current.text.clone();
1306        buf.undo();
1307        assert_ne!(buf.current.text, final_text); // typing 'cd' undone
1308        assert_eq!(buf.current.text, "  xx\n  yy\nab");
1309        buf.undo();
1310        assert_eq!(buf.current.text, "xx\nyy\nab"); // cmd undone
1311        buf.undo();
1312        assert_eq!(buf.current.text, "xx\nyy\n"); // typing 'ab' undone
1313    }
1314
1315    // ─── H: type / Enter / type → 1 unit (plain Enter is "\n") ─────
1316    #[test]
1317    fn type_enter_type_one_unit() {
1318        let mut buf = Buffer::from("");
1319        type_str(&mut buf, "a");
1320        type_str(&mut buf, "b");
1321        enter(&mut buf);
1322        type_str(&mut buf, "c");
1323        type_str(&mut buf, "d");
1324        assert_eq!(buf.current.text, "ab\ncd");
1325        buf.undo();
1326        assert_eq!(buf.current.text, "");
1327    }
1328
1329    // ─── I: backspace counts as typing ─────────────────────────────
1330    #[test]
1331    fn backspace_groups_with_typing() {
1332        let mut buf = Buffer::from("");
1333        type_str(&mut buf, "a");
1334        type_str(&mut buf, "b");
1335        backspace(&mut buf);
1336        type_str(&mut buf, "c");
1337        assert_eq!(buf.current.text, "ac");
1338        buf.undo();
1339        assert_eq!(buf.current.text, "");
1340    }
1341
1342    // ─── J: paste (>1 grapheme insert) is its own unit ─────────────
1343    #[test]
1344    fn paste_own_unit() {
1345        let mut buf = Buffer::from("");
1346        type_str(&mut buf, "a");
1347        type_str(&mut buf, "b");
1348        // paste = single Replace with multi-grapheme text
1349        type_str(&mut buf, "PASTED");
1350        type_str(&mut buf, "c");
1351        type_str(&mut buf, "d");
1352        assert_eq!(buf.current.text, "abPASTEDcd");
1353        buf.undo();
1354        assert_eq!(buf.current.text, "abPASTED"); // 'cd' undone
1355        buf.undo();
1356        assert_eq!(buf.current.text, "ab"); // PASTE undone
1357        buf.undo();
1358        assert_eq!(buf.current.text, ""); // 'ab' undone
1359    }
1360
1361    // ─── K: select-then-overwrite-with-1-char groups with typing ───
1362    #[test]
1363    fn select_overwrite_groups() {
1364        let mut buf = Buffer::from("hello");
1365        // simulate user selecting "ell" and typing "X" → one frame
1366        // with one Replace of 3-char range with 1-char text
1367        buf.current.selection = (Grapheme(1), Grapheme(4));
1368        frame(
1369            &mut buf,
1370            vec![
1371                Operation::Replace(Replace { range: (Grapheme(1), Grapheme(4)), text: "X".into() }),
1372                Operation::Select(Grapheme(2).into_range()),
1373            ],
1374        );
1375        type_str(&mut buf, "Y");
1376        assert_eq!(buf.current.text, "hXYo");
1377        buf.undo();
1378        assert_eq!(buf.current.text, "hello");
1379    }
1380
1381    // ─── selects absorb backward, not forward — undoing the next
1382    //     unit doesn't drag the cursor back across the click and
1383    //     scroll the viewport
1384    #[test]
1385    fn click_then_edit_undoes_to_post_click_cursor() {
1386        let mut buf = Buffer::from("hello world");
1387        click(&mut buf, 6); // cursor between "hello " and "world"
1388        type_str(&mut buf, "X");
1389        assert_eq!(buf.current.text, "hello Xworld");
1390        buf.undo();
1391        assert_eq!(buf.current.text, "hello world");
1392        // Cursor stays at the click position, not at wherever the
1393        // buffer's initial cursor was before the click.
1394        assert_eq!(buf.current.selection, (Grapheme(6), Grapheme(6)));
1395    }
1396
1397    // ─── round-trip repro: shift-tab then tab undoes as 2 units ────
1398    #[test]
1399    fn round_trip_two_units() {
1400        let mut buf = Buffer::from("  foo\n  bar\n");
1401        click(&mut buf, 2);
1402        cmd_deindent_lines(&mut buf, &[0, 6]);
1403        let mid = buf.current.text.clone();
1404        assert_eq!(mid, "foo\nbar\n");
1405        cmd_indent_lines(&mut buf, &[0, 4]);
1406        assert_eq!(buf.current.text, "  foo\n  bar\n");
1407        buf.undo();
1408        assert_eq!(buf.current.text, mid); // tab reverted
1409        buf.undo();
1410        assert_eq!(buf.current.text, "  foo\n  bar\n"); // shift-tab reverted
1411    }
1412}