Skip to main content

hjkl_buffer/
buffer.rs

1use std::sync::{Arc, Mutex, MutexGuard};
2
3use crate::content::Buffer;
4use crate::folds::FoldIndex;
5use crate::{Position, Viewport};
6
7/// Per-window view onto a [`Buffer`].
8///
9/// `View` is the type the rest of `hjkl-buffer` — and all consumers —
10/// use directly. It owns exactly the state that is local to one editor
11/// window:
12///
13/// - `cursor` — the charwise caret for this window.
14///
15/// All document-level state (text rope, dirty generation, folds) lives on
16/// the inner [`Buffer`] and is accessed via `Arc<Mutex<Buffer>>`.
17/// Two `View` instances that share the same `Arc` share text + folds
18/// but carry independent cursors — the Helix Document+View model.
19///
20/// ## `Send` + `Sync`
21///
22/// `Arc<Mutex<Buffer>>` is `Send + Sync`, so `View` remains `Send`.
23/// The engine trait surface requires `View: Send`; this constraint
24/// drove the choice of `Mutex` over `RefCell`. The mutex is never
25/// contended in normal operation (single-threaded app loop), so the
26/// lock cost is negligible (~5 ns uncontested).
27///
28/// ## Mutex-poisoning policy
29///
30/// Every `content` lock here — and the same for `registers`, `global_marks`,
31/// `change_bank`, `search` and `abbrevs` on `hjkl_engine::Editor` — is taken
32/// with `lock().unwrap()`. That is deliberate, not an oversight: a poisoned
33/// lock means a panic unwound while the buffer state was mid-mutation, so the
34/// rope may be torn between an `Edit` and its cursor fixup. There is no
35/// meaningful recovery from that — continuing would hand the user a document
36/// the editor cannot vouch for, including on the save path — so poisoning is
37/// treated as a fatal, unrecoverable state and the panic is allowed to
38/// propagate.
39///
40/// The `unwrap_or_else(|e| e.into_inner())` recovery shape does appear in the
41/// codebase (e.g. `hjkl-app`'s `trash.rs` test helper, guarding an env-var
42/// lock that carries no data). That is not the policy for buffer state: for
43/// anything reachable from a `View`, poisoned means fatal.
44///
45/// ## 0.8.0 migration notes
46///
47/// The existing constructors ([`View::new`], [`View::from_str`],
48/// [`View::replace_all`], etc.) keep the same external signatures.
49/// Callers that do not need multi-window sharing see no behaviour change.
50/// Use [`View::new_view`] to create a second window onto the same
51/// [`Buffer`].
52///
53/// ## Viewport
54///
55/// The rope invariant — at least one line, never empty — is preserved by
56/// every mutation (ropey's empty rope already reports `len_lines() == 1`).
57/// The viewport itself (top_row, top_col, width, height, wrap, text_width)
58/// lives on the engine `Host` adapter; methods that need it take a
59/// `&Viewport` / `&mut Viewport` parameter so the rope-walking math stays
60/// here while runtime state lives there.
61pub struct View {
62    /// Shared per-document state (text rope, dirty gen, folds).
63    pub(crate) content: Arc<Mutex<Buffer>>,
64    /// Charwise cursor. `col` is bound by the char count of `row` in
65    /// normal mode, one past it in operator-pending / insert.
66    cursor: Position,
67}
68
69impl Default for View {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl View {
76    // ── Constructors ──────────────────────────────────────────────
77
78    /// Construct an empty buffer with one empty row + cursor at `(0, 0)`.
79    pub fn new() -> Self {
80        Self {
81            content: Arc::new(Mutex::new(Buffer::new())),
82            cursor: Position::default(),
83        }
84    }
85
86    /// Build a buffer from a flat string. Splits on `\n`; a trailing
87    /// `\n` produces a trailing empty line (matches every text
88    /// editor's behaviour and keeps `from_text(buf.as_string())` an
89    /// identity round-trip in the common case).
90    #[allow(clippy::should_implement_trait)]
91    pub fn from_str(text: &str) -> Self {
92        Self {
93            content: Arc::new(Mutex::new(Buffer::from_str(text))),
94            cursor: Position::default(),
95        }
96    }
97
98    /// Create a second per-window view onto existing [`Buffer`].
99    ///
100    /// The new `View` shares text + folds with every other view on the
101    /// same `Arc`. Its cursor starts at `(0, 0)` independently. This is
102    /// the primary entry point for split-window features.
103    ///
104    /// ```rust
105    /// # use hjkl_buffer::{View, Buffer, Position};
106    /// # use std::sync::Arc;
107    /// # use std::sync::Mutex;
108    /// let a = View::from_str("hello\nworld");
109    /// let content = a.content_arc();
110    /// let mut b = View::new_view(Arc::clone(&content));
111    ///
112    /// // Cursors are independent.
113    /// let mut a = View::new_view(Arc::clone(&content));
114    /// a.set_cursor(Position::new(1, 0));
115    /// assert_eq!(b.cursor(), Position::new(0, 0));
116    /// ```
117    pub fn new_view(content: Arc<Mutex<Buffer>>) -> Self {
118        Self {
119            content,
120            cursor: Position::default(),
121        }
122    }
123
124    /// Return a clone of the `Arc<Mutex<Buffer>>` so callers can
125    /// create additional views with [`View::new_view`].
126    pub fn content_arc(&self) -> Arc<Mutex<Buffer>> {
127        Arc::clone(&self.content)
128    }
129
130    // ── Read-only accessors (delegate to Buffer) ─────────────────
131
132    pub fn cursor(&self) -> Position {
133        self.cursor
134    }
135
136    /// The last cursor `(row, col)` committed on the shared [`Buffer`] by any
137    /// view (see [`View::set_cursor`]). This is the "last-moved cursor across
138    /// all windows" the cross-session cursor store persists — not this view's
139    /// own live cursor. Best-effort; read at write/close/exit.
140    pub fn last_cursor(&self) -> (usize, usize) {
141        self.content.lock().unwrap().last_cursor
142    }
143
144    pub fn dirty_gen(&self) -> u64 {
145        self.content.lock().unwrap().dirty_gen
146    }
147
148    /// Number of rows in the buffer. Always `>= 1`.
149    pub fn row_count(&self) -> usize {
150        self.content.lock().unwrap().text.len_lines()
151    }
152
153    /// Concatenate the rows into a single `String` joined by `\n`.
154    ///
155    /// Equivalent to `rope.to_string()` — ropey's rope-to-string already
156    /// produces `\n`-joined content matching `split('\n').join("\n")`.
157    pub fn as_string(&self) -> String {
158        self.content.lock().unwrap().text.to_string()
159    }
160
161    // ── Cursor ops ────────────────────────────────────────────────
162
163    /// Set cursor without scrolling. Clamps to valid positions.
164    ///
165    /// The optional sticky column for `j`/`k` motions is **not** reset
166    /// by this call — it survives `set_cursor` intentionally.
167    pub fn set_cursor(&mut self, pos: Position) {
168        let mut c = self.content.lock().unwrap();
169        let n = c.text.len_lines();
170        let last_row = n.saturating_sub(1);
171        let row = pos.row.min(last_row);
172        let line_chars = rope_line_char_count(&c.text, row);
173        let col = pos.col.min(line_chars);
174        // Single choke point for cursor moves: record the last-moved cursor on
175        // the shared `Buffer` so the most-recent move across ALL views onto
176        // this document wins. Cheap — no I/O.
177        c.last_cursor = (row, col);
178        drop(c);
179        self.cursor = Position::new(row, col);
180    }
181
182    /// Bring the cursor into the visible [`Viewport`], scrolling by the
183    /// minimum amount needed.
184    pub fn ensure_cursor_visible(&mut self, viewport: &mut Viewport) {
185        let cursor = self.cursor;
186        let v = *viewport;
187        let wrap_active = !matches!(v.wrap, crate::Wrap::None) && v.text_width > 0;
188        if !wrap_active {
189            viewport.ensure_visible(cursor);
190            return;
191        }
192        if v.height == 0 {
193            return;
194        }
195        // Cursor above the visible region: snap top_row to it.
196        if cursor.row < v.top_row {
197            viewport.top_row = cursor.row;
198            viewport.top_col = 0;
199            return;
200        }
201        let height = v.height as usize;
202        // Compute the cursor's screen row once, then push `top_row` down
203        // incrementally: each dropped row reduces the screen row by its own
204        // visible height. O(distance) instead of recomputing
205        // `cursor_screen_row_from` every step (which was O(distance^2) on a
206        // large soft-wrapped jump).
207        // `screen` was summed only up to the cursor row clamped against the
208        // live rope (the clamp `cursor_screen_row_from` applies); a stale
209        // cursor row — another view shrank the shared Buffer since this
210        // view's cursor was last clamped — must not make the loop subtract
211        // past that point.
212        let cursor_row = {
213            let c = self.content.lock().unwrap();
214            cursor.row.min(c.text.len_lines().saturating_sub(1))
215        };
216        let Some(mut screen) = self.cursor_screen_row_from(viewport, viewport.top_row) else {
217            // Two ways to land here, both repaired by snapping `top_row` to the
218            // cursor's row clamped against the live rope:
219            //   - a concurrent view shrink dropped rows, so this view's stale
220            //     cursor row *and* `top_row` are both past the last line;
221            //   - the cursor's row is hidden by a fold, so the walk from
222            //     `top_row` never reaches it.
223            // The clamp is what does the work in the shrink case: `cursor.row
224            // >= top_row` is already known here, so assigning the raw
225            // `cursor.row` would push `top_row` further past the end rather
226            // than pulling it back into the rope.
227            let last_row = {
228                let c = self.content.lock().unwrap();
229                c.text.len_lines().saturating_sub(1)
230            };
231            viewport.top_row = cursor.row.min(last_row);
232            viewport.top_col = 0;
233            return;
234        };
235        while screen >= height {
236            let c = self.content.lock().unwrap();
237            let folds = FoldIndex::new(&c.folds);
238            let mut next = viewport.top_row + 1;
239            while next <= cursor_row && folds.hides_row(next) {
240                next += 1;
241            }
242            if next > cursor_row {
243                drop(c);
244                viewport.top_row = cursor_row;
245                break;
246            }
247            // Removing rows [top_row, next) drops their visible heights (hidden
248            // rows contribute 0). After this, `screen` equals
249            // `cursor_screen_row_from(next)`.
250            for r in viewport.top_row..next {
251                if folds.hides_row(r) {
252                    continue;
253                }
254                let line = rope_line_str(&c.text, r);
255                screen -= crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len();
256            }
257            drop(c);
258            viewport.top_row = next;
259        }
260        viewport.top_col = 0;
261    }
262
263    /// Cursor's screen row offset (0-based) from `viewport.top_row`.
264    pub fn cursor_screen_row(&self, viewport: &Viewport) -> Option<usize> {
265        if matches!(viewport.wrap, crate::Wrap::None) || viewport.text_width == 0 {
266            return None;
267        }
268        self.cursor_screen_row_from(viewport, viewport.top_row)
269    }
270
271    /// Number of screen rows the doc range `start..=end` occupies.
272    pub fn screen_rows_between(&self, viewport: &Viewport, start: usize, end: usize) -> usize {
273        if start > end {
274            return 0;
275        }
276        let c = self.content.lock().unwrap();
277        let folds = FoldIndex::new(&c.folds);
278        let n = c.text.len_lines();
279        let last = n.saturating_sub(1);
280        let end = end.min(last);
281        let v = *viewport;
282        let mut total = 0usize;
283        for r in start..=end {
284            if folds.hides_row(r) {
285                continue;
286            }
287            if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
288                total += 1;
289            } else {
290                let line = rope_line_str(&c.text, r);
291                total += crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len();
292            }
293        }
294        total
295    }
296
297    /// Earliest `top_row` such that `screen_rows_between(top, last)`
298    /// is at least `height`.
299    pub fn max_top_for_height(&self, viewport: &Viewport, height: usize) -> usize {
300        if height == 0 {
301            return 0;
302        }
303        let c = self.content.lock().unwrap();
304        let folds = FoldIndex::new(&c.folds);
305        let n = c.text.len_lines();
306        let last = n.saturating_sub(1);
307        let mut total = 0usize;
308        let mut row = last;
309        loop {
310            if !folds.hides_row(row) {
311                let v = *viewport;
312                total += if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
313                    1
314                } else {
315                    let line = rope_line_str(&c.text, row);
316                    crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len()
317                };
318            }
319            if total >= height {
320                return row;
321            }
322            if row == 0 {
323                return 0;
324            }
325            row -= 1;
326        }
327    }
328
329    /// Clamp `pos` to the buffer's content.
330    pub fn clamp_position(&self, pos: Position) -> Position {
331        let c = self.content.lock().unwrap();
332        let n = c.text.len_lines();
333        let last_row = n.saturating_sub(1);
334        let row = pos.row.min(last_row);
335        let line_chars = rope_line_char_count(&c.text, row);
336        let col = pos.col.min(line_chars);
337        Position::new(row, col)
338    }
339
340    /// Replace the buffer's full text in place. Cursor is clamped to
341    /// the new content.
342    pub fn replace_all(&mut self, text: &str) {
343        let new_cursor = {
344            let mut c = self.content.lock().unwrap();
345            c.text = ropey::Rope::from_str(text);
346            let n = c.text.len_lines();
347            let last_row = n.saturating_sub(1);
348            let row = self.cursor.row.min(last_row);
349            let line_chars = rope_line_char_count(&c.text, row);
350            let col = self.cursor.col.min(line_chars);
351            c.dirty_gen = c.dirty_gen.wrapping_add(1);
352            c.cached_joined = None;
353            c.cached_byte_len = None;
354            Position::new(row, col)
355        };
356        self.cursor = new_cursor;
357    }
358
359    // ── Crate-internal accessors (used by folds.rs) ───────────────
360
361    /// Bump the fold-mutation generation. Crate-internal — every fold
362    /// mutator in [`crate::folds`] calls it (via `folds_changed`) after it
363    /// actually changes the fold set. Deliberately separate from
364    /// [`Self::dirty_gen_bump`]: text edits bump `dirty_gen` on every
365    /// keystroke, and a fold-snapshot cache keyed off that would never hit.
366    pub(crate) fn fold_gen_bump(&mut self) {
367        let mut c = self.content.lock().unwrap();
368        c.fold_gen = c.fold_gen.wrapping_add(1);
369    }
370
371    /// Bump the render-cache generation. Crate-internal.
372    pub(crate) fn dirty_gen_bump(&mut self) {
373        let mut c = self.content.lock().unwrap();
374        c.dirty_gen = c.dirty_gen.wrapping_add(1);
375        c.cached_joined = None;
376        c.cached_byte_len = None;
377    }
378
379    /// Canonical byte length of the document. `Rope::len_bytes()` is O(1)
380    /// and returns the same value as `to_string().len()` (i.e.
381    /// `sum(line_bytes) + (n_lines-1)` separators). Cached against
382    /// `dirty_gen` for API compatibility; the O(1) rope call makes the
383    /// cache essentially free but keeps the invalidation contract identical.
384    pub fn byte_len(&self) -> usize {
385        let mut c = self.content.lock().unwrap();
386        let dg = c.dirty_gen;
387        if let Some((cached_dg, len)) = c.cached_byte_len
388            && cached_dg == dg
389        {
390            return len;
391        }
392        let total = c.text.len_bytes();
393        c.cached_byte_len = Some((dg, total));
394        total
395    }
396
397    /// Return an `Arc<String>` of the full document, cached against
398    /// `dirty_gen`. Multiple per-tick consumers (syntax pipeline, LSP
399    /// notify, git signature, dirty hash) share the same `Arc` for the
400    /// same generation — first caller pays the `rope.to_string()` cost
401    /// (one alloc + one lock), the rest are O(1).
402    ///
403    /// Cache invalidates automatically on every `dirty_gen_bump` and on
404    /// `replace_all`, so callers never need to manage invalidation.
405    pub fn content_joined(&self) -> std::sync::Arc<String> {
406        let mut c = self.content.lock().unwrap();
407        let dg = c.dirty_gen;
408        if let Some((cached_dg, ref s)) = c.cached_joined
409            && cached_dg == dg
410        {
411            return std::sync::Arc::clone(s);
412        }
413        let joined = std::sync::Arc::new(c.text.to_string());
414        c.cached_joined = Some((dg, std::sync::Arc::clone(&joined)));
415        joined
416    }
417
418    /// Borrow the underlying rope. Hot-path consumers (tree-sitter
419    /// streaming parse, byte-range slicing) should use this instead of
420    /// `content_joined()` to avoid materializing the whole document as
421    /// a `String`.
422    ///
423    /// `ropey::Rope::clone` is O(1) — it Arc-clones the root node.
424    /// The clone gives the caller a snapshot they can read without
425    /// holding the content mutex.
426    pub fn rope(&self) -> ropey::Rope {
427        self.content.lock().unwrap().text.clone()
428    }
429
430    /// Shared access to the content guard. Crate-internal.
431    pub(crate) fn content_lock(&self) -> MutexGuard<'_, Buffer> {
432        self.content.lock().unwrap()
433    }
434
435    /// Exclusive access to Buffer. Crate-internal.
436    pub(crate) fn content_lock_mut(&mut self) -> MutexGuard<'_, Buffer> {
437        self.content.lock().unwrap()
438    }
439
440    // ── Screen-row helpers (private) ──────────────────────────────
441
442    fn cursor_screen_row_from(&self, viewport: &Viewport, top: usize) -> Option<usize> {
443        let cursor = self.cursor;
444        if cursor.row < top {
445            return None;
446        }
447        let c = self.content.lock().unwrap();
448        let folds = FoldIndex::new(&c.folds);
449        // Clamp against the live rope: another view sharing this Buffer may
450        // have removed rows since this view's cursor was last clamped, and
451        // `rope.line(r)` panics past the last line.
452        let cursor_row = cursor.row.min(c.text.len_lines().saturating_sub(1));
453        if cursor_row < top {
454            return None;
455        }
456        let v = *viewport;
457        let mut screen = 0usize;
458        for r in top..=cursor_row {
459            if folds.hides_row(r) {
460                continue;
461            }
462            let line = rope_line_str(&c.text, r);
463            let segs = crate::wrap::wrap_segments(&line, v.text_width, v.wrap);
464            if r == cursor_row {
465                let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
466                return Some(screen + seg_idx);
467            }
468            screen += segs.len();
469        }
470        None
471    }
472
473    // ── Per-buffer engine state accessors ─────────────────────────────────
474
475    // ── Undo arena tree accessors (Phase 2a) ─────────────────────────────
476    //
477    // These delegate to the per-document [`crate::UndoTree`]. Their names and
478    // semantics mirror the two-stack API they replace (`undo_stack` /
479    // `redo_stack`), so the engine's undo/redo drivers are untouched apart
480    // from the two `*_step` moves below.
481
482    pub fn undo_stack_is_empty(&self) -> bool {
483        self.content.lock().unwrap().undo.is_at_root()
484    }
485
486    pub fn redo_stack_is_empty(&self) -> bool {
487        !self.content.lock().unwrap().undo.has_redo()
488    }
489
490    pub fn undo_stack_len(&self) -> usize {
491        self.content.lock().unwrap().undo.depth_from_root()
492    }
493
494    /// Commit the pre-edit LIVE state `entry` as an undo boundary: the current
495    /// node takes `entry` and a fresh child becomes current, with the forward
496    /// (redo) branch dropped — the tree form of `undo_stack.push` + `clear_redo`.
497    pub fn push_undo_entry(&self, entry: crate::UndoEntry) {
498        self.content.lock().unwrap().undo.push(entry);
499    }
500
501    /// One undo move. `live` is the current buffer state (rope/cursor/marks) of
502    /// the node being left; returns the parent snapshot to restore, or `None`
503    /// at the root. See [`crate::UndoTree::undo_step`].
504    pub fn undo_step(
505        &self,
506        rope: ropey::Rope,
507        cursor: (usize, usize),
508        marks: crate::MarkSnapshot,
509    ) -> Option<crate::UndoEntry> {
510        self.content
511            .lock()
512            .unwrap()
513            .undo
514            .undo_step(rope, cursor, marks)
515    }
516
517    /// Where the change that the next redo reapplies started (vim's
518    /// `uh_cursor`): the cursor saved in the current undo node.
519    pub fn redo_change_start(&self) -> (usize, usize) {
520        self.content.lock().unwrap().undo.current_cursor()
521    }
522
523    /// One redo move. Symmetric to [`Self::undo_step`]; returns the child
524    /// snapshot to restore, or `None` when there is no forward branch.
525    pub fn redo_step(
526        &self,
527        rope: ropey::Rope,
528        cursor: (usize, usize),
529        marks: crate::MarkSnapshot,
530    ) -> Option<crate::UndoEntry> {
531        self.content
532            .lock()
533            .unwrap()
534            .undo
535            .redo_step(rope, cursor, marks)
536    }
537
538    /// Discard the most-recent undo boundary without moving the live state
539    /// (`undo_stack.pop()`); `false` at the root. See
540    /// [`crate::UndoTree::pop_committed`].
541    pub fn pop_committed(&self) -> bool {
542        self.content.lock().unwrap().undo.pop_committed()
543    }
544
545    /// One `g-` / `:earlier` step: move to the next-lower-`seq` state tree-wide,
546    /// returning its snapshot to restore, or `None` at the lowest state. `live`
547    /// is the current buffer state, stashed into the node being left. See
548    /// [`crate::UndoTree::seq_earlier_step`].
549    pub fn seq_earlier_step(
550        &self,
551        rope: ropey::Rope,
552        cursor: (usize, usize),
553        marks: crate::MarkSnapshot,
554    ) -> Option<crate::UndoEntry> {
555        self.content
556            .lock()
557            .unwrap()
558            .undo
559            .seq_earlier_step(rope, cursor, marks)
560    }
561
562    /// One `g+` / `:later` step: move to the next-higher-`seq` state tree-wide.
563    /// Symmetric to [`Self::seq_earlier_step`].
564    pub fn seq_later_step(
565        &self,
566        rope: ropey::Rope,
567        cursor: (usize, usize),
568        marks: crate::MarkSnapshot,
569    ) -> Option<crate::UndoEntry> {
570        self.content
571            .lock()
572            .unwrap()
573            .undo
574            .seq_later_step(rope, cursor, marks)
575    }
576
577    /// Timestamp of the next-lower-`seq` state (`:earlier Ns` predicate).
578    pub fn seq_earlier_timestamp(&self) -> Option<std::time::SystemTime> {
579        self.content.lock().unwrap().undo.seq_earlier_timestamp()
580    }
581
582    /// Timestamp of the next-higher-`seq` state (`:later Ns` predicate).
583    pub fn seq_later_timestamp(&self) -> Option<std::time::SystemTime> {
584        self.content.lock().unwrap().undo.seq_later_timestamp()
585    }
586
587    /// Undo-tree leaves for `:undolist`, each `(seq, changes/depth, timestamp,
588    /// is_current)`, sorted by `seq`. See [`crate::UndoTree::leaves`].
589    pub fn undo_leaves(&self) -> Vec<(u64, usize, std::time::SystemTime, bool)> {
590        self.content.lock().unwrap().undo.leaves()
591    }
592
593    pub fn peek_undo_timestamp(&self) -> Option<std::time::SystemTime> {
594        self.content.lock().unwrap().undo.parent_timestamp()
595    }
596
597    pub fn peek_redo_timestamp(&self) -> Option<std::time::SystemTime> {
598        self.content.lock().unwrap().undo.child_timestamp()
599    }
600
601    pub fn clear_undo_redo(&self) {
602        self.content.lock().unwrap().undo.clear_all();
603    }
604
605    // ── Undofile persistence (Phase 3b) ───────────────────────────────────
606
607    /// Project this buffer's undo tree into its serializable form for the
608    /// undofile, plus the current node's `seq`. Syncs the current node to the
609    /// live buffer text first, so the on-disk tree's `current` edge is exact
610    /// even when `current` is a fresh (still-stale) leaf.
611    pub fn undo_to_serializable(&self) -> (crate::SerTree, u64) {
612        let mut c = self.content.lock().unwrap();
613        let rope = c.text.clone();
614        c.undo.sync_current(rope);
615        let seq = c.undo.current_node_seq();
616        (c.undo.to_serializable(), seq)
617    }
618
619    /// Replace this buffer's fresh single-node undo tree with one deserialized
620    /// from an undofile. Must run BEFORE the first user edit and after the
621    /// buffer text is populated (the tree's `current` materializes to the loaded
622    /// content). Returns `false` — leaving the fresh tree untouched — if the
623    /// projection is structurally inconsistent.
624    pub fn install_undo_tree(&self, ser: &crate::SerTree) -> bool {
625        match crate::UndoTree::from_serializable(ser) {
626            Some(tree) => {
627                self.content.lock().unwrap().undo = tree;
628                true
629            }
630            None => false,
631        }
632    }
633
634    /// Install an undo tree recovered from a **swap** file (crash path),
635    /// verifying it against the just-recovered
636    /// buffer text before committing. Unlike [`Self::install_undo_tree`] (the
637    /// undofile / clean-close path, whose caller gates on a content hash), the
638    /// swap tail rides an unsaved buffer, so this re-checks consistency itself.
639    ///
640    /// Returns `false` — leaving the fresh single-node tree seeded from the
641    /// recovered content untouched — when the projection is structurally
642    /// invalid, its current node's `seq` differs from `current_seq`, or its
643    /// current node doesn't materialize to the live buffer text. Must run AFTER
644    /// the recovered content is installed (which resets undo to a single node).
645    /// A trailing-newline difference is normalized away, matching how recovery
646    /// installs `body.strip_suffix('\n')`.
647    pub fn install_recovered_undo_tree(&self, ser: &crate::SerTree, current_seq: u64) -> bool {
648        let Some(mut tree) = crate::UndoTree::from_serializable(ser) else {
649            return false;
650        };
651        if tree.current_node_seq() != current_seq {
652            return false;
653        }
654        let mut c = self.content.lock().unwrap();
655        let materialized = tree.current_content();
656        if materialized.to_string().trim_end_matches('\n')
657            != c.text.to_string().trim_end_matches('\n')
658        {
659            return false;
660        }
661        c.undo = tree;
662        true
663    }
664
665    pub fn clear_redo(&self) {
666        self.content.lock().unwrap().undo.clear_redo();
667    }
668
669    /// Whether an undo group is currently open on this content (depth `> 0`).
670    /// Used by the engine's `push_undo` to decide whether to coalesce.
671    pub fn undo_group_active(&self) -> bool {
672        self.content.lock().unwrap().undo_group_active()
673    }
674
675    /// Arm the open group's single snapshot. See [`Buffer::undo_group_arm`].
676    pub fn undo_group_arm(&self) -> bool {
677        self.content.lock().unwrap().undo_group_arm()
678    }
679
680    pub fn cap_undo(&self, cap: usize) {
681        self.content.lock().unwrap().undo.cap(cap);
682    }
683
684    pub fn content_dirty(&self) -> bool {
685        self.content.lock().unwrap().content_dirty
686    }
687
688    pub fn set_content_dirty(&self, v: bool) {
689        self.content.lock().unwrap().content_dirty = v;
690    }
691
692    pub fn mark_content_dirty(&self) {
693        let mut c = self.content.lock().unwrap();
694        c.content_dirty = true;
695        c.cached_editor_content = None;
696    }
697
698    pub fn take_dirty(&self) -> bool {
699        let mut c = self.content.lock().unwrap();
700        let v = c.content_dirty;
701        c.content_dirty = false;
702        v
703    }
704
705    pub fn cached_editor_content(&self) -> Option<std::sync::Arc<String>> {
706        self.content.lock().unwrap().cached_editor_content.clone()
707    }
708
709    pub fn set_cached_editor_content(&self, arc: std::sync::Arc<String>) {
710        self.content.lock().unwrap().cached_editor_content = Some(arc);
711    }
712
713    pub fn push_fold_op(&self, op: crate::FoldOp) {
714        self.content.lock().unwrap().pending_fold_ops.push(op);
715    }
716
717    pub fn take_fold_ops(&self) -> Vec<crate::FoldOp> {
718        std::mem::take(&mut self.content.lock().unwrap().pending_fold_ops)
719    }
720
721    /// Record `edits` into the change log drained by `Editor::take_changes`.
722    ///
723    /// A no-op unless [`Self::set_change_log_enabled`] was called — hjkl has
724    /// no in-tree consumer of the log, so recording defaults off and every
725    /// `mutate_edit` would otherwise pay a payload clone plus a `Vec` build
726    /// for an entry no host reads.
727    pub fn extend_change_log(&self, edits: impl IntoIterator<Item = crate::EngineEdit>) {
728        let mut content = self.content.lock().unwrap();
729        if !content.change_log_enabled {
730            return;
731        }
732        content.change_log.extend(edits);
733    }
734
735    /// Opt in to recording the change log drained by `Editor::take_changes`.
736    /// Default off — see [`Self::extend_change_log`].
737    pub fn set_change_log_enabled(&self, enabled: bool) {
738        self.content.lock().unwrap().change_log_enabled = enabled;
739    }
740
741    /// Whether change-log recording is enabled (see
742    /// [`Self::set_change_log_enabled`]).
743    pub fn change_log_enabled(&self) -> bool {
744        self.content.lock().unwrap().change_log_enabled
745    }
746
747    pub fn take_change_log(&self) -> Vec<crate::EngineEdit> {
748        std::mem::take(&mut self.content.lock().unwrap().change_log)
749    }
750
751    pub fn extend_pending_content_edits(
752        &self,
753        edits: impl IntoIterator<Item = crate::ContentEdit>,
754    ) {
755        self.content
756            .lock()
757            .unwrap()
758            .pending_content_edits
759            .extend(edits);
760    }
761
762    pub fn push_pending_content_edit(&self, edit: crate::ContentEdit) {
763        self.content
764            .lock()
765            .unwrap()
766            .pending_content_edits
767            .push(edit);
768    }
769
770    pub fn take_pending_content_edits(&self) -> Vec<crate::ContentEdit> {
771        std::mem::take(&mut self.content.lock().unwrap().pending_content_edits)
772    }
773
774    pub fn clear_pending_content_edits(&self) {
775        self.content.lock().unwrap().pending_content_edits.clear();
776    }
777
778    pub fn pending_content_reset(&self) -> bool {
779        self.content.lock().unwrap().pending_content_reset
780    }
781
782    pub fn set_pending_content_reset(&self, v: bool) {
783        self.content.lock().unwrap().pending_content_reset = v;
784    }
785
786    pub fn take_pending_content_reset(&self) -> bool {
787        let mut c = self.content.lock().unwrap();
788        let v = c.pending_content_reset;
789        c.pending_content_reset = false;
790        v
791    }
792
793    pub fn mark(&self, c: char) -> Option<(usize, usize)> {
794        self.content_lock().marks.get(&c).copied()
795    }
796    pub fn set_mark(&mut self, c: char, pos: (usize, usize)) {
797        self.content_lock_mut().marks.insert(c, pos);
798    }
799    pub fn clear_mark(&mut self, c: char) {
800        self.content_lock_mut().marks.remove(&c);
801    }
802    pub fn marks_cloned(&self) -> std::collections::BTreeMap<char, (usize, usize)> {
803        self.content_lock().marks.clone()
804    }
805    pub fn set_marks(&mut self, marks: std::collections::BTreeMap<char, (usize, usize)>) {
806        self.content_lock_mut().marks = marks;
807    }
808    /// Drop marks inside `[edit_start, drop_end)` and shift marks at/after
809    /// `shift_threshold` by `delta` rows (clamped to 0). Mirrors the engine's
810    /// edit-coherence pass for the per-buffer mark map (#154).
811    pub fn rebase_marks(
812        &mut self,
813        edit_start: usize,
814        drop_end: usize,
815        shift_threshold: usize,
816        delta: isize,
817    ) {
818        let mut c = self.content_lock_mut();
819        let mut to_drop: Vec<char> = Vec::new();
820        for (ch, (row, _col)) in c.marks.iter_mut() {
821            if (edit_start..drop_end).contains(row) {
822                to_drop.push(*ch);
823            } else if *row >= shift_threshold {
824                *row = ((*row as isize) + delta).max(0) as usize;
825            }
826        }
827        for ch in to_drop {
828            c.marks.remove(&ch);
829        }
830    }
831    pub fn syntax_fold_ranges_cloned(&self) -> Vec<(usize, usize)> {
832        self.content_lock().syntax_fold_ranges.clone()
833    }
834    pub fn set_syntax_fold_ranges(&mut self, ranges: Vec<(usize, usize)>) {
835        self.content_lock_mut().syntax_fold_ranges = ranges;
836    }
837}
838
839// ── Rope line helpers (free functions over &ropey::Rope) ─────────────
840
841/// Largest char-boundary byte index `<= byte_idx` (clamped to rope length).
842///
843/// Byte offsets that arrive from outside the rope — LSP positions clamped to
844/// `len_bytes`, a stale tree-sitter node range — can land in the middle of a
845/// multi-byte char (e.g. a 4-byte emoji whose last byte sits at `N-1` while
846/// the clamp produced `N-3`). `ropey::Rope::byte_slice` panics on a
847/// non-aligned index, so callers must floor first. Uses ropey's own
848/// conversion, which is safe for any byte value `<= len_bytes`:
849/// `byte_to_char` returns the index of the char *containing* a non-boundary
850/// byte. For an already char-aligned, in-bounds index this is the identity.
851pub fn floor_char_boundary(rope: &ropey::Rope, byte_idx: usize) -> usize {
852    let byte_idx = byte_idx.min(rope.len_bytes());
853    rope.char_to_byte(rope.byte_to_char(byte_idx))
854}
855
856/// Absolute byte index where row `row`'s content ends — the first byte of the
857/// separator ropey split on, or `len_bytes()` for the final row.
858///
859/// ropey's default `unicode_lines` feature splits on `\r`, U+000B, U+000C,
860/// U+0085 and U+2028 / U+2029 as well as `\n`, and those are 1–3 bytes wide.
861/// `line_to_byte(row + 1)` points just past the separator, so stepping back a
862/// hard-coded one byte lands *inside* a multi-byte one; flooring to the
863/// enclosing char start snaps to the separator's first byte, which is exactly
864/// the end of this row's content. `\r\n` is unaffected: `\n` begins a char, so
865/// the floor is the identity and a CRLF row keeps its trailing `\r` — the same
866/// rule [`crate::rope_row_range_str`]'s engine-side twin applies.
867fn rope_line_content_end(rope: &ropey::Rope, row: usize) -> usize {
868    if row + 1 >= rope.len_lines() {
869        return rope.len_bytes();
870    }
871    let step_back = rope.line_to_byte(row + 1).saturating_sub(1);
872    floor_char_boundary(rope, step_back)
873}
874
875/// Return logical line `row` as a `String`, stripping the line separator that
876/// ropey includes for non-final lines.
877pub fn rope_line_str(rope: &ropey::Rope, row: usize) -> String {
878    // Preserves the previous contract: ropey panics for `row >= len_lines()`.
879    let start = rope.line_to_byte(row);
880    rope.byte_slice(start..rope_line_content_end(rope, row))
881        .to_string()
882}
883
884/// Byte length of logical line `row` (excluding the line separator).
885///
886/// A `row` at or past `len_lines()` answers 0 rather than panicking: this is
887/// `pub`, so the row can arrive from a caller holding a position that went
888/// stale against a shrunken rope, and `ropey::Rope::line` panics on one.
889pub fn rope_line_bytes(rope: &ropey::Rope, row: usize) -> usize {
890    if row >= rope.len_lines() {
891        return 0;
892    }
893    rope_line_content_end(rope, row).saturating_sub(rope.line_to_byte(row))
894}
895
896/// Char count of logical line `row` (excluding the line separator).
897///
898/// Out of range (`row >= len_lines()`) answers 0, for the same reason as
899/// [`rope_line_bytes`]: a stale row from a `pub` caller must not panic ropey.
900pub fn rope_line_char_count(rope: &ropey::Rope, row: usize) -> usize {
901    if row >= rope.len_lines() {
902        return 0;
903    }
904    let end = rope_line_content_end(rope, row);
905    rope.byte_to_char(end)
906        .saturating_sub(rope.byte_to_char(rope.line_to_byte(row)))
907}
908
909/// Char index from `(row, col)` where `col` is a char index within the line.
910/// Both coordinates are clamped to the rope's bounds so a position that went
911/// stale (e.g. another view shrank the shared `Buffer` between the caller's
912/// clamp and this call) can never panic `line_to_char`.
913pub fn pos_to_char_idx(rope: &ropey::Rope, row: usize, col: usize) -> usize {
914    let row = row.min(rope.len_lines().saturating_sub(1));
915    let line_start = rope.line_to_char(row);
916    let line_char_count = rope_line_char_count(rope, row);
917    line_start + col.min(line_char_count)
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    #[test]
925    fn new_has_one_empty_row() {
926        let b = View::new();
927        assert_eq!(b.row_count(), 1);
928        assert_eq!(rope_line_str(&b.rope(), 0), "");
929        assert_eq!(b.cursor(), Position::default());
930    }
931
932    #[test]
933    fn from_str_splits_on_newline() {
934        let b = View::from_str("foo\nbar\nbaz");
935        assert_eq!(b.row_count(), 3);
936        assert_eq!(rope_line_str(&b.rope(), 0), "foo");
937        assert_eq!(rope_line_str(&b.rope(), 2), "baz");
938    }
939
940    #[test]
941    fn from_str_trailing_newline_keeps_empty_row() {
942        let b = View::from_str("foo\n");
943        assert_eq!(b.row_count(), 2);
944        assert_eq!(rope_line_str(&b.rope(), 1), "");
945    }
946
947    /// ropey's default `unicode_lines` feature splits on more than `\n`, and
948    /// those separators are 1–3 bytes wide. A row helper that strips a literal
949    /// `'\n'` — or subtracts a hard-coded one byte — leaves the separator in
950    /// the row's content, so the row reads one or more chars longer than it is.
951    /// Found by the `handle_key` fuzz target via the curswant invariant.
952    #[test]
953    fn line_helpers_strip_every_line_separator() {
954        // `\r\n` is deliberately absent: ropey makes it one break, and the
955        // established rule (`rope_row_range_str`) keeps the `\r` as content.
956        for sep in ["\r", "\u{0b}", "\u{0c}", "\u{85}", "\u{2028}", "\u{2029}"] {
957            let rope = ropey::Rope::from_str(&format!("ab{sep}cd{sep}ef"));
958            assert_eq!(rope.len_lines(), 3, "separator {sep:?} must split rows");
959            for row in 0..3 {
960                let expect = ["ab", "cd", "ef"][row];
961                assert_eq!(
962                    rope_line_str(&rope, row),
963                    expect,
964                    "rope_line_str kept separator {sep:?} in row {row}"
965                );
966                assert_eq!(
967                    rope_line_bytes(&rope, row),
968                    expect.len(),
969                    "rope_line_bytes counted separator {sep:?} in row {row}"
970                );
971                assert_eq!(
972                    rope_line_char_count(&rope, row),
973                    expect.chars().count(),
974                    "rope_line_char_count counted separator {sep:?} in row {row}"
975                );
976            }
977        }
978    }
979
980    /// The three helpers must agree with each other on every row: they are
981    /// three views of one length, and the curswant invariant compares a column
982    /// derived from one against a clamp derived from another.
983    #[test]
984    fn line_helpers_agree_with_each_other() {
985        let rope = ropey::Rope::from_str("ab\r\ncd\re\u{2028}f\u{85}\u{0c}g\nh");
986        for row in 0..rope.len_lines() {
987            let s = rope_line_str(&rope, row);
988            assert_eq!(
989                rope_line_bytes(&rope, row),
990                s.len(),
991                "bytes differ on {row}"
992            );
993            assert_eq!(
994                rope_line_char_count(&rope, row),
995                s.chars().count(),
996                "char count differs on row {row}"
997            );
998        }
999    }
1000
1001    #[test]
1002    fn from_str_empty_input_keeps_one_row() {
1003        let b = View::from_str("");
1004        assert_eq!(b.row_count(), 1);
1005        assert_eq!(rope_line_str(&b.rope(), 0), "");
1006    }
1007
1008    #[test]
1009    fn as_string_round_trips() {
1010        let b = View::from_str("a\nb\nc");
1011        assert_eq!(b.as_string(), "a\nb\nc");
1012    }
1013
1014    #[test]
1015    fn dirty_gen_starts_at_zero() {
1016        assert_eq!(View::new().dirty_gen(), 0);
1017    }
1018
1019    /// A minimal single-node [`crate::SerTree`] whose root materializes to
1020    /// `base`, tagged with `seq`.
1021    fn single_node_tree(base: &str, seq: u64) -> crate::SerTree {
1022        crate::SerTree {
1023            base: Some(base.to_string()),
1024            nodes: vec![crate::SerNode {
1025                parent: None,
1026                children: Vec::new(),
1027                last_child: None,
1028                delta: None,
1029                cursor: (0, 0),
1030                timestamp_unix_ms: 0,
1031                marks: crate::MarkSnapshot::default(),
1032                seq,
1033            }],
1034            root: 0,
1035            current: 0,
1036            next_seq: seq + 1,
1037        }
1038    }
1039
1040    /// `install_recovered_undo_tree` installs a tree whose current node matches
1041    /// the live buffer content and whose `seq` matches — and rejects (leaves the
1042    /// fresh tree) on a `seq` or content mismatch (the swap consistency guard).
1043    #[test]
1044    fn install_recovered_undo_tree_guards_seq_and_content() {
1045        // Match: content + seq agree → installs.
1046        let v = View::from_str("alpha\nhello");
1047        assert!(v.install_recovered_undo_tree(&single_node_tree("alpha\nhello", 3), 3));
1048
1049        // Seq mismatch → rejected.
1050        let v = View::from_str("alpha\nhello");
1051        assert!(!v.install_recovered_undo_tree(&single_node_tree("alpha\nhello", 3), 4));
1052
1053        // Content mismatch → rejected.
1054        let v = View::from_str("alpha\nhello");
1055        assert!(!v.install_recovered_undo_tree(&single_node_tree("something else", 3), 3));
1056
1057        // A trailing-newline-only difference is normalized away → still installs.
1058        let v = View::from_str("alpha\nhello");
1059        assert!(v.install_recovered_undo_tree(&single_node_tree("alpha\nhello\n", 3), 3));
1060    }
1061
1062    fn vp_wrap(width: u16, height: u16) -> Viewport {
1063        Viewport {
1064            top_row: 0,
1065            top_col: 0,
1066            width,
1067            height,
1068            wrap: crate::Wrap::Char,
1069            text_width: width,
1070            tab_width: 0,
1071        }
1072    }
1073
1074    #[test]
1075    fn ensure_cursor_visible_wrap_scrolls_when_cursor_below_screen() {
1076        let mut b = View::from_str("aaaaaaaaaa\nb\nc");
1077        let mut v = vp_wrap(4, 3);
1078        b.set_cursor(Position::new(2, 0));
1079        b.ensure_cursor_visible(&mut v);
1080        assert_eq!(v.top_row, 1);
1081    }
1082
1083    #[test]
1084    fn ensure_cursor_visible_wrap_no_scroll_when_visible() {
1085        let mut b = View::from_str("aaaaaaaaaa\nb");
1086        let mut v = vp_wrap(4, 4);
1087        b.set_cursor(Position::new(0, 5));
1088        b.ensure_cursor_visible(&mut v);
1089        assert_eq!(v.top_row, 0);
1090    }
1091
1092    #[test]
1093    fn ensure_cursor_visible_wrap_snaps_top_when_cursor_above() {
1094        let mut b = View::from_str("a\nb\nc\nd\ne");
1095        let mut v = vp_wrap(4, 2);
1096        v.top_row = 3;
1097        b.set_cursor(Position::new(1, 0));
1098        b.ensure_cursor_visible(&mut v);
1099        assert_eq!(v.top_row, 1);
1100    }
1101
1102    #[test]
1103    fn screen_rows_between_sums_segments_under_wrap() {
1104        let b = View::from_str("aaaaaaaaa\nb\n");
1105        let v = vp_wrap(4, 0);
1106        assert_eq!(b.screen_rows_between(&v, 0, 0), 3);
1107        assert_eq!(b.screen_rows_between(&v, 0, 1), 4);
1108        assert_eq!(b.screen_rows_between(&v, 0, 2), 5);
1109        assert_eq!(b.screen_rows_between(&v, 1, 2), 2);
1110    }
1111
1112    #[test]
1113    fn screen_rows_between_one_per_doc_row_when_wrap_off() {
1114        let b = View::from_str("aaaaa\nb\nc");
1115        let v = Viewport::default();
1116        assert_eq!(b.screen_rows_between(&v, 0, 2), 3);
1117    }
1118
1119    #[test]
1120    fn max_top_for_height_walks_back_until_height_reached() {
1121        let b = View::from_str("a\nb\nc\nd\neeeeeeee");
1122        let v = vp_wrap(4, 0);
1123        assert_eq!(b.max_top_for_height(&v, 4), 2);
1124        assert_eq!(b.max_top_for_height(&v, 99), 0);
1125    }
1126
1127    #[test]
1128    fn cursor_screen_row_returns_none_when_wrap_off() {
1129        let b = View::from_str("a");
1130        let v = Viewport::default();
1131        assert!(b.cursor_screen_row(&v).is_none());
1132    }
1133
1134    #[test]
1135    fn cursor_screen_row_under_wrap() {
1136        let mut b = View::from_str("aaaaaaaaaa\nb");
1137        let v = vp_wrap(4, 0);
1138        b.set_cursor(Position::new(0, 5));
1139        assert_eq!(b.cursor_screen_row(&v), Some(1));
1140        b.set_cursor(Position::new(1, 0));
1141        assert_eq!(b.cursor_screen_row(&v), Some(3));
1142    }
1143
1144    /// Regression: a view whose cursor went stale after another view shrank
1145    /// the shared Buffer used to panic `rope.line()` inside
1146    /// `cursor_screen_row_from`. The row must clamp to the live rope.
1147    #[test]
1148    fn cursor_screen_row_survives_shrink_from_other_view() {
1149        let a = View::from_str("a\nb\nc\nd\ne");
1150        let arc = a.content_arc();
1151        let mut view_a = View::new_view(Arc::clone(&arc));
1152        let mut view_b = View::new_view(Arc::clone(&arc));
1153        view_b.set_cursor(Position::new(4, 0));
1154        // view_a truncates the document; view_b's cursor row 4 is now stale.
1155        view_a.replace_all("a");
1156        let v = vp_wrap(4, 3);
1157        assert_eq!(view_b.cursor_screen_row(&v), Some(0));
1158        let mut v2 = vp_wrap(4, 3);
1159        view_b.ensure_cursor_visible(&mut v2); // must not panic
1160    }
1161
1162    /// Regression: after another view shrank the shared Buffer, a `top_row`
1163    /// left past the rope's end must be pulled *back* to the clamped cursor
1164    /// row. Assigning the raw stale `cursor.row` would push `top_row` further
1165    /// past the end, leaving the cursor off-screen.
1166    #[test]
1167    fn ensure_cursor_visible_clamps_stale_top_row_after_shrink() {
1168        let a = View::from_str("a\nb\nc\nd\ne");
1169        let arc = a.content_arc();
1170        let mut view_a = View::new_view(Arc::clone(&arc));
1171        let mut view_b = View::new_view(Arc::clone(&arc));
1172        view_b.set_cursor(Position::new(4, 0));
1173        view_a.replace_all("a");
1174        let mut v = vp_wrap(4, 3);
1175        // Stale scroll position: below the cursor's clamped row, above the
1176        // stale cursor row, and past the (now single-line) rope.
1177        v.top_row = 3;
1178        view_b.ensure_cursor_visible(&mut v);
1179        assert_eq!(v.top_row, 0, "top_row must clamp into the live rope");
1180        assert_eq!(v.top_col, 0);
1181    }
1182
1183    /// Regression: a stale cursor ROW past the live rope used to make the
1184    /// scroll loop subtract more wrap segments than `cursor_screen_row_from`
1185    /// had summed — `attempt to subtract with overflow` in debug, a
1186    /// `rope.line()` panic in release. The loop must bound itself by the
1187    /// clamped cursor row, and must not assign the raw stale row to
1188    /// `top_row`.
1189    #[test]
1190    fn ensure_cursor_visible_survives_stale_cursor_row_after_shrink() {
1191        // Row 5 is long enough that `set_cursor` stores col 26 unclamped.
1192        let a = View::from_str(
1193            "aa\nbb\nxxxxxxxxxxxxxxxxxxxxxxxxxxxx\ncc\ndd\nyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
1194        );
1195        let arc = a.content_arc();
1196        let mut view_a = View::new_view(Arc::clone(&arc));
1197        let mut view_b = View::new_view(Arc::clone(&arc));
1198        view_b.set_cursor(Position::new(5, 26));
1199        // view_a truncates the document to 3 rows; view_b's cursor (5, 26)
1200        // is now stale — exactly the concurrent-shrink scenario.
1201        view_a.replace_all("aa\nbb\nxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
1202        let mut v = vp_wrap(4, 6);
1203        view_b.ensure_cursor_visible(&mut v); // must not panic
1204        assert_eq!(
1205            v.top_row, 2,
1206            "top_row must scroll to the clamped cursor row, not past the rope"
1207        );
1208        assert_eq!(v.top_col, 0);
1209    }
1210
1211    /// The other `cursor_screen_row_from` `None` path: the cursor's row is
1212    /// hidden inside a closed fold, so the walk never reaches it. `top_row`
1213    /// still snaps to the cursor row (in-range, so no clamping applies).
1214    #[test]
1215    fn ensure_cursor_visible_snaps_when_cursor_row_folded() {
1216        let mut b = View::from_str("a\nb\nc\nd\ne");
1217        b.set_folds(&[crate::Fold {
1218            start_row: 2,
1219            end_row: 4,
1220            closed: true,
1221            auto_generated: false,
1222        }]);
1223        let mut v = vp_wrap(4, 2);
1224        v.top_row = 1;
1225        b.set_cursor(Position::new(3, 0));
1226        b.ensure_cursor_visible(&mut v);
1227        assert_eq!(v.top_row, 3);
1228    }
1229
1230    #[test]
1231    fn ensure_cursor_visible_falls_back_when_wrap_disabled() {
1232        let mut b = View::from_str("a\nb\nc\nd\ne");
1233        let mut v = Viewport {
1234            top_row: 0,
1235            top_col: 0,
1236            width: 4,
1237            height: 2,
1238            wrap: crate::Wrap::None,
1239            text_width: 4,
1240            tab_width: 0,
1241        };
1242        b.set_cursor(Position::new(4, 0));
1243        b.ensure_cursor_visible(&mut v);
1244        assert_eq!(v.top_row, 3);
1245    }
1246
1247    // ── Per-buffer engine state tests (new in 0.33.0 / Phase B) ──────
1248
1249    /// Undo entries pushed via one `View` view are visible via
1250    /// another view sharing the same `Buffer` — proving that the
1251    /// undo stack lives on `Buffer`, not on the per-window `View`.
1252    #[test]
1253    fn undo_stack_shared_across_views() {
1254        use crate::UndoEntry;
1255        use std::time::SystemTime;
1256
1257        let a = View::from_str("hello");
1258        let arc = a.content_arc();
1259        let view_a = View::new_view(Arc::clone(&arc));
1260        let view_b = View::new_view(Arc::clone(&arc));
1261
1262        assert!(view_a.undo_stack_is_empty());
1263        assert_eq!(view_a.undo_stack_len(), 0);
1264
1265        view_a.push_undo_entry(UndoEntry {
1266            rope: view_a.rope(),
1267            cursor: (0, 0),
1268            timestamp: SystemTime::UNIX_EPOCH,
1269            marks: Default::default(),
1270        });
1271
1272        // Push via view_a is visible via view_b.
1273        assert_eq!(view_b.undo_stack_len(), 1);
1274        assert!(!view_b.undo_stack_is_empty());
1275    }
1276
1277    /// A redo branch created via one view is visible via another — the undo
1278    /// tree lives on the shared `Buffer`, not the per-window `View`. (Phase 2a:
1279    /// re-expressed against the arena-tree API — a redo entry is now a forward
1280    /// child left behind by an undo move, not a `push_redo_entry` onto a Vec.)
1281    #[test]
1282    fn redo_stack_shared_across_views() {
1283        use crate::UndoEntry;
1284        use std::time::SystemTime;
1285
1286        let a = View::from_str("world");
1287        let arc = a.content_arc();
1288        let view_a = View::new_view(Arc::clone(&arc));
1289        let view_b = View::new_view(Arc::clone(&arc));
1290
1291        assert!(view_a.redo_stack_is_empty());
1292
1293        // Commit an undo boundary via view_b, then undo it — that leaves the
1294        // node we left (cursor (0, 2)) as a forward/redo branch on the shared
1295        // Content.
1296        view_b.push_undo_entry(UndoEntry {
1297            rope: view_b.rope(),
1298            cursor: (0, 0),
1299            timestamp: SystemTime::UNIX_EPOCH,
1300            marks: Default::default(),
1301        });
1302        view_b.undo_step(view_b.rope(), (0, 2), Default::default());
1303
1304        // The redo branch is visible + walkable via view_a.
1305        assert!(!view_a.redo_stack_is_empty());
1306        let entry = view_a.redo_step(view_a.rope(), (0, 0), Default::default());
1307        assert!(entry.is_some());
1308        assert_eq!(entry.unwrap().cursor, (0, 2));
1309    }
1310
1311    /// `clear_undo_redo` collapses the shared undo tree to a single node, wiping
1312    /// both directions, and the effect is visible from every view. (Phase 2a:
1313    /// the redo side is now seeded by an undo move rather than a raw
1314    /// `push_redo_entry`.)
1315    #[test]
1316    fn clear_undo_redo_shared_across_views() {
1317        use crate::UndoEntry;
1318        use std::time::SystemTime;
1319
1320        let a = View::from_str("abc");
1321        let arc = a.content_arc();
1322        let view_a = View::new_view(Arc::clone(&arc));
1323        let view_b = View::new_view(Arc::clone(&arc));
1324
1325        // Two boundaries then one undo → undo side AND redo side both populated.
1326        for _ in 0..2 {
1327            view_a.push_undo_entry(UndoEntry {
1328                rope: view_a.rope(),
1329                cursor: (0, 0),
1330                timestamp: SystemTime::UNIX_EPOCH,
1331                marks: Default::default(),
1332            });
1333        }
1334        view_a.undo_step(view_a.rope(), (0, 1), Default::default());
1335        assert!(!view_a.undo_stack_is_empty());
1336        assert!(!view_a.redo_stack_is_empty());
1337
1338        view_b.clear_undo_redo();
1339        assert!(view_a.undo_stack_is_empty());
1340        assert!(view_a.redo_stack_is_empty());
1341    }
1342
1343    /// `content_dirty` flag is shared across views.
1344    #[test]
1345    fn content_dirty_shared_across_views() {
1346        let a = View::from_str("test");
1347        let arc = a.content_arc();
1348        let view_a = View::new_view(Arc::clone(&arc));
1349        let view_b = View::new_view(Arc::clone(&arc));
1350
1351        assert!(!view_a.content_dirty());
1352
1353        view_b.mark_content_dirty();
1354        assert!(view_a.content_dirty());
1355
1356        let taken = view_a.take_dirty();
1357        assert!(taken);
1358        assert!(!view_b.content_dirty());
1359    }
1360
1361    /// `pending_fold_ops` push and take are shared across views.
1362    #[test]
1363    fn pending_fold_ops_shared_across_views() {
1364        let a = View::from_str("a\nb\nc");
1365        let arc = a.content_arc();
1366        let view_a = View::new_view(Arc::clone(&arc));
1367        let view_b = View::new_view(Arc::clone(&arc));
1368
1369        view_a.push_fold_op(crate::FoldOp::Add {
1370            start_row: 0,
1371            end_row: 1,
1372            closed: true,
1373        });
1374
1375        let ops = view_b.take_fold_ops();
1376        assert_eq!(ops.len(), 1);
1377        assert!(matches!(
1378            ops[0],
1379            crate::FoldOp::Add {
1380                start_row: 0,
1381                end_row: 1,
1382                closed: true
1383            }
1384        ));
1385    }
1386
1387    /// `pending_content_reset` flag is shared across views.
1388    #[test]
1389    fn pending_content_reset_shared_across_views() {
1390        let a = View::from_str("x");
1391        let arc = a.content_arc();
1392        let view_a = View::new_view(Arc::clone(&arc));
1393        let view_b = View::new_view(Arc::clone(&arc));
1394
1395        assert!(!view_a.pending_content_reset());
1396        view_b.set_pending_content_reset(true);
1397        assert!(view_a.pending_content_reset());
1398        let taken = view_a.take_pending_content_reset();
1399        assert!(taken);
1400        assert!(!view_b.pending_content_reset());
1401    }
1402
1403    // ── View-split tests (new in 0.8.0) ──────────────────────────
1404
1405    /// Two `View` views sharing one `Buffer` must have independent
1406    /// cursors.
1407    #[test]
1408    fn buffer_views_independent_cursors() {
1409        let a = View::from_str("hello\nworld");
1410        let arc = a.content_arc();
1411        let mut view_a = View::new_view(Arc::clone(&arc));
1412        let mut view_b = View::new_view(Arc::clone(&arc));
1413
1414        view_a.set_cursor(Position::new(1, 3));
1415        // view_b cursor must remain at (0, 0).
1416        assert_eq!(view_b.cursor(), Position::new(0, 0));
1417
1418        view_b.set_cursor(Position::new(0, 2));
1419        // view_a cursor must remain at (1, 3).
1420        assert_eq!(view_a.cursor(), Position::new(1, 3));
1421    }
1422
1423    /// `last_cursor` on the shared `Buffer` reflects the most recent move
1424    /// across two independent `View`s — the "last-moved window wins" contract
1425    /// the cross-session cursor store depends on (docs §6b).
1426    #[test]
1427    fn last_cursor_reflects_most_recent_move_across_views() {
1428        let a = View::from_str("aaaa\nbbbb\ncccc\ndddd");
1429        let arc = a.content_arc();
1430        let mut view_a = View::new_view(Arc::clone(&arc));
1431        let mut view_b = View::new_view(Arc::clone(&arc));
1432
1433        view_a.set_cursor(Position::new(1, 2));
1434        assert_eq!(view_a.last_cursor(), (1, 2));
1435        // Both views see the same shared last_cursor.
1436        assert_eq!(view_b.last_cursor(), (1, 2));
1437
1438        // A later move on view_b wins.
1439        view_b.set_cursor(Position::new(3, 1));
1440        assert_eq!(view_a.last_cursor(), (3, 1));
1441        assert_eq!(view_b.last_cursor(), (3, 1));
1442
1443        // last_cursor stores the CLAMPED landing position, not the request.
1444        view_a.set_cursor(Position::new(99, 99));
1445        assert_eq!(view_a.last_cursor(), (3, 4));
1446    }
1447
1448    /// Cursor-restore clamp contract (docs §6b): a stored row past EOF clamps
1449    /// to the last line, and a col past the line's char count clamps to its
1450    /// length. `clamp_position` is what `build_slot` runs on the stored cursor.
1451    #[test]
1452    fn clamp_position_restore_semantics() {
1453        let b = View::from_str("ab\ncdef\ng");
1454        // Row past the last line (2) → clamped to last line, col to its length.
1455        assert_eq!(b.clamp_position(Position::new(99, 99)), Position::new(2, 1));
1456        // Col past the line's char count → clamped to the char count (4).
1457        assert_eq!(b.clamp_position(Position::new(1, 99)), Position::new(1, 4));
1458        // In-bounds position is unchanged (exact restore on a content match).
1459        assert_eq!(b.clamp_position(Position::new(1, 2)), Position::new(1, 2));
1460    }
1461
1462    /// An edit applied via one view must be visible via the other.
1463    #[test]
1464    fn buffer_views_share_content() {
1465        use crate::edit::Edit;
1466
1467        let a = View::from_str("foo");
1468        let arc = a.content_arc();
1469        let mut view_a = View::new_view(Arc::clone(&arc));
1470        let view_b = View::new_view(Arc::clone(&arc));
1471
1472        view_a.apply_edit(Edit::InsertStr {
1473            at: Position::new(0, 3),
1474            text: "bar".into(),
1475        });
1476
1477        assert_eq!(rope_line_str(&view_a.rope(), 0), "foobar");
1478        assert_eq!(rope_line_str(&view_b.rope(), 0), "foobar");
1479    }
1480
1481    /// The line-measuring helpers are `pub`, so a stale row can reach them from
1482    /// outside; ropey would panic on it. Multi-byte content keeps the two
1483    /// answers distinguishable — a byte count silently used as a char count is
1484    /// the bug the separate helpers exist to prevent.
1485    #[test]
1486    fn line_measures_answer_zero_past_the_last_row() {
1487        let rope = ropey::Rope::from_str("café\nx");
1488        assert_eq!(rope.len_lines(), 2);
1489
1490        // Non-final line: the '\n' is excluded from both measures.
1491        assert_eq!(rope_line_bytes(&rope, 0), 5);
1492        assert_eq!(rope_line_char_count(&rope, 0), 4);
1493
1494        // Last line, no trailing newline: nothing to subtract.
1495        assert_eq!(rope_line_bytes(&rope, 1), 1);
1496        assert_eq!(rope_line_char_count(&rope, 1), 1);
1497
1498        // Out of range: 0, not a panic.
1499        assert_eq!(rope_line_bytes(&rope, 2), 0);
1500        assert_eq!(rope_line_char_count(&rope, 2), 0);
1501        assert_eq!(rope_line_bytes(&rope, usize::MAX), 0);
1502        assert_eq!(rope_line_char_count(&rope, usize::MAX), 0);
1503    }
1504}
1505
1506#[cfg(test)]
1507mod marks_shared_content_tests {
1508    use super::*;
1509
1510    #[test]
1511    fn marks_shared_across_views() {
1512        // Two View views on the same Buffer share marks (#154).
1513        let a = View::from_str("hello\nworld");
1514        let content = a.content_arc();
1515        let mut view_a = View::new_view(std::sync::Arc::clone(&content));
1516        let view_b = View::new_view(std::sync::Arc::clone(&content));
1517
1518        // Set mark 'x' on view_a.
1519        view_a.set_mark('x', (1, 3));
1520
1521        // view_b must see the same mark via shared Buffer.
1522        assert_eq!(view_b.mark('x'), Some((1, 3)));
1523    }
1524
1525    #[test]
1526    fn syntax_fold_ranges_shared_across_views() {
1527        let a = View::from_str("fn foo() {\n  bar();\n}");
1528        let content = a.content_arc();
1529        let mut view_a = View::new_view(std::sync::Arc::clone(&content));
1530        let view_b = View::new_view(std::sync::Arc::clone(&content));
1531
1532        view_a.set_syntax_fold_ranges(vec![(0, 2)]);
1533
1534        assert_eq!(view_b.syntax_fold_ranges_cloned(), vec![(0, 2)]);
1535    }
1536}