Skip to main content

hjkl_buffer/
buffer.rs

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