Skip to main content

hjkl_buffer/
buffer.rs

1use std::collections::BTreeMap;
2
3use crate::search::SearchState;
4use crate::{Position, Span, Viewport};
5
6/// In-memory text buffer + cursor + viewport + per-row span cache.
7///
8/// This is the core type the rest of `sqeel-buffer` builds on.
9/// Phase 1 of the migration ships construction + getters only;
10/// motion / edit / render APIs land in later phases. The
11/// `lines` invariant — at least one entry, never empty — is
12/// preserved by every later mutation.
13pub struct Buffer {
14    /// One entry per visual row. Always non-empty: a freshly
15    /// constructed `Buffer` holds a single empty `String` so cursor
16    /// positions don't need an "is the buffer empty?" branch.
17    lines: Vec<String>,
18    /// Charwise cursor. `col` is bound by `lines[row].chars().count()`
19    /// in normal mode, one past it in operator-pending / insert.
20    cursor: Position,
21    /// Last vertical-motion column (vim's `curswant`). `None` until
22    /// the first `j`/`k` so the buffer can bootstrap from the live
23    /// cursor column.
24    sticky_col: Option<usize>,
25    /// Where the buffer is scrolled to + how big the visible region
26    /// is. Width / height come from the host on each draw.
27    viewport: Viewport,
28    /// External per-row syntax / marker styling. `spans[row]` is a
29    /// `Vec<Span>` for that row; rows beyond `spans.len()` get no
30    /// styling (host hasn't published them yet).
31    spans: Vec<Vec<Span>>,
32    /// Buffer-local marks (`m{a-z}` / `'{a-z}` / `` `{a-z} ``).
33    /// `BTreeMap` so iteration is deterministic for snapshot tests.
34    marks: BTreeMap<char, Position>,
35    /// Bumps on every mutation; render cache keys against this so a
36    /// per-row Line gets recomputed when its source row changes.
37    dirty_gen: u64,
38    /// Lazy per-row match cache for `/` search. Lives next to the
39    /// other buffer state so the `Buffer` API can drive `n` / `N`
40    /// without the host plumbing a separate index through.
41    search: SearchState,
42    /// Manual folds — closed ranges hide rows in the render path.
43    /// `pub(crate)` so the [`folds`] module can read/write directly.
44    pub(crate) folds: Vec<crate::folds::Fold>,
45}
46
47impl Default for Buffer {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl Buffer {
54    /// Construct an empty buffer with one empty row + cursor at
55    /// `(0, 0)`. Caller publishes a viewport size on first draw.
56    pub fn new() -> Self {
57        Self {
58            lines: vec![String::new()],
59            cursor: Position::default(),
60            sticky_col: None,
61            viewport: Viewport::default(),
62            spans: Vec::new(),
63            marks: BTreeMap::new(),
64            dirty_gen: 0,
65            search: SearchState::new(),
66            folds: Vec::new(),
67        }
68    }
69
70    /// Build a buffer from a flat string. Splits on `\n`; a trailing
71    /// `\n` produces a trailing empty line (matches every text
72    /// editor's behaviour and keeps `from_text(buf.as_string())` an
73    /// identity round-trip in the common case).
74    #[allow(clippy::should_implement_trait)]
75    pub fn from_str(text: &str) -> Self {
76        let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
77        if lines.is_empty() {
78            lines.push(String::new());
79        }
80        Self {
81            lines,
82            cursor: Position::default(),
83            sticky_col: None,
84            viewport: Viewport::default(),
85            spans: Vec::new(),
86            marks: BTreeMap::new(),
87            dirty_gen: 0,
88            search: SearchState::new(),
89            folds: Vec::new(),
90        }
91    }
92
93    pub fn lines(&self) -> &[String] {
94        &self.lines
95    }
96
97    pub fn line(&self, row: usize) -> Option<&str> {
98        self.lines.get(row).map(String::as_str)
99    }
100
101    pub fn cursor(&self) -> Position {
102        self.cursor
103    }
104
105    pub fn sticky_col(&self) -> Option<usize> {
106        self.sticky_col
107    }
108
109    pub fn viewport(&self) -> Viewport {
110        self.viewport
111    }
112
113    pub fn viewport_mut(&mut self) -> &mut Viewport {
114        // Explicit `mut` access — viewport size lives on a separate
115        // axis from buffer mutation, so we don't bump `dirty_gen`.
116        &mut self.viewport
117    }
118
119    pub fn dirty_gen(&self) -> u64 {
120        self.dirty_gen
121    }
122
123    /// Set cursor without scrolling. Caller is responsible for calling
124    /// [`Buffer::ensure_cursor_visible`] when they want viewport
125    /// follow. Clamps `row` and `col` to valid positions so motion
126    /// helpers don't have to repeat the bound check.
127    pub fn set_cursor(&mut self, pos: Position) {
128        let last_row = self.lines.len().saturating_sub(1);
129        let row = pos.row.min(last_row);
130        let line_chars = self.lines[row].chars().count();
131        let col = pos.col.min(line_chars);
132        self.cursor = Position::new(row, col);
133    }
134
135    /// Replace the sticky col (vim's `curswant`). Motion code sets
136    /// this after vertical / horizontal moves; the buffer doesn't
137    /// touch it on its own.
138    pub fn set_sticky_col(&mut self, col: Option<usize>) {
139        self.sticky_col = col;
140    }
141
142    /// Bring the cursor into the visible viewport, scrolling by the
143    /// minimum amount needed. When `viewport.wrap != Wrap::None` and
144    /// `viewport.text_width > 0`, scrolling is screen-line aware:
145    /// `top_row` is advanced one visible doc row at a time until the
146    /// cursor's screen row falls inside the viewport's height.
147    pub fn ensure_cursor_visible(&mut self) {
148        let cursor = self.cursor;
149        let v = self.viewport;
150        let wrap_active = !matches!(v.wrap, crate::Wrap::None) && v.text_width > 0;
151        if !wrap_active {
152            self.viewport.ensure_visible(cursor);
153            return;
154        }
155        if v.height == 0 {
156            return;
157        }
158        // Cursor above the visible region: snap top_row to it.
159        if cursor.row < v.top_row {
160            self.viewport.top_row = cursor.row;
161            self.viewport.top_col = 0;
162            return;
163        }
164        let height = v.height as usize;
165        // Push top_row forward (one visible doc row per iteration)
166        // until the cursor's screen row sits inside [0, height).
167        loop {
168            let csr = self.cursor_screen_row_from(self.viewport.top_row);
169            match csr {
170                Some(row) if row < height => break,
171                _ => {}
172            }
173            // Advance to the next non-folded doc row up to (but not
174            // past) the cursor row. Stop if we ran out of room.
175            let mut next = self.viewport.top_row + 1;
176            while next <= cursor.row && self.folds.iter().any(|f| f.hides(next)) {
177                next += 1;
178            }
179            if next > cursor.row {
180                // Last resort — pin top_row to the cursor row so the
181                // cursor lands at the top edge.
182                self.viewport.top_row = cursor.row;
183                break;
184            }
185            self.viewport.top_row = next;
186        }
187        self.viewport.top_col = 0;
188    }
189
190    /// Cursor's screen row offset (0-based) from `viewport.top_row`
191    /// under the current wrap mode + `text_width`. `None` when wrap
192    /// is off, the cursor row is hidden by a fold, or the cursor sits
193    /// above `top_row`. Used by host-side scrolloff math.
194    pub fn cursor_screen_row(&self) -> Option<usize> {
195        if matches!(self.viewport.wrap, crate::Wrap::None) || self.viewport.text_width == 0 {
196            return None;
197        }
198        self.cursor_screen_row_from(self.viewport.top_row)
199    }
200
201    /// Number of screen rows the doc range `start..=end` occupies
202    /// under the current wrap mode. Skips fold-hidden rows. Empty /
203    /// past-end ranges return 0. `Wrap::None` returns the visible
204    /// doc-row count (one screen row per doc row).
205    pub fn screen_rows_between(&self, start: usize, end: usize) -> usize {
206        if start > end {
207            return 0;
208        }
209        let last = self.lines.len().saturating_sub(1);
210        let end = end.min(last);
211        let v = self.viewport;
212        let mut total = 0usize;
213        for r in start..=end {
214            if self.folds.iter().any(|f| f.hides(r)) {
215                continue;
216            }
217            if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
218                total += 1;
219            } else {
220                let line = self.lines.get(r).map(String::as_str).unwrap_or("");
221                total += crate::wrap::wrap_segments(line, v.text_width, v.wrap).len();
222            }
223        }
224        total
225    }
226
227    /// Earliest `top_row` such that `screen_rows_between(top, last)`
228    /// is at least `height`. Lets host-side scrolloff math clamp
229    /// `top_row` so the buffer never leaves blank rows below the
230    /// content. When the buffer's total screen rows are smaller than
231    /// `height` this returns 0.
232    pub fn max_top_for_height(&self, height: usize) -> usize {
233        if height == 0 {
234            return 0;
235        }
236        let last = self.lines.len().saturating_sub(1);
237        let mut total = 0usize;
238        let mut row = last;
239        loop {
240            if !self.folds.iter().any(|f| f.hides(row)) {
241                let v = self.viewport;
242                total += if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
243                    1
244                } else {
245                    let line = self.lines.get(row).map(String::as_str).unwrap_or("");
246                    crate::wrap::wrap_segments(line, v.text_width, v.wrap).len()
247                };
248            }
249            if total >= height {
250                return row;
251            }
252            if row == 0 {
253                return 0;
254            }
255            row -= 1;
256        }
257    }
258
259    /// Returns the cursor's screen row (0-based, relative to `top`)
260    /// under the current wrap mode + text width. `None` when the
261    /// cursor row is hidden by a fold or sits above `top`.
262    fn cursor_screen_row_from(&self, top: usize) -> Option<usize> {
263        let cursor = self.cursor;
264        if cursor.row < top {
265            return None;
266        }
267        let v = self.viewport;
268        let mut screen = 0usize;
269        for r in top..=cursor.row {
270            if self.folds.iter().any(|f| f.hides(r)) {
271                continue;
272            }
273            let line = self.lines.get(r).map(String::as_str).unwrap_or("");
274            let segs = crate::wrap::wrap_segments(line, v.text_width, v.wrap);
275            if r == cursor.row {
276                let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
277                return Some(screen + seg_idx);
278            }
279            screen += segs.len();
280        }
281        None
282    }
283
284    /// Clamp `pos` to the buffer's content. Out-of-range row gets
285    /// pulled to the last row; out-of-range col gets pulled to the
286    /// row's char count (one past last char — insertion point).
287    pub fn clamp_position(&self, pos: Position) -> Position {
288        let last_row = self.lines.len().saturating_sub(1);
289        let row = pos.row.min(last_row);
290        let line_chars = self.lines[row].chars().count();
291        let col = pos.col.min(line_chars);
292        Position::new(row, col)
293    }
294
295    /// Mutable access to the lines. Crate-internal — edit code uses
296    /// this; outside callers go through [`Buffer::apply_edit`].
297    pub(crate) fn lines_mut(&mut self) -> &mut Vec<String> {
298        &mut self.lines
299    }
300
301    /// Crate-internal accessor for the search state. Search code
302    /// keeps its lazy match cache here; the public surface is
303    /// [`Buffer::set_search_pattern`] etc.
304    pub(crate) fn search_state(&self) -> &SearchState {
305        &self.search
306    }
307    pub(crate) fn search_state_mut(&mut self) -> &mut SearchState {
308        &mut self.search
309    }
310
311    /// Bump the render-cache generation. Crate-internal — every
312    /// content mutation calls this so render fingerprints invalidate.
313    pub(crate) fn dirty_gen_bump(&mut self) {
314        self.dirty_gen = self.dirty_gen.wrapping_add(1);
315    }
316
317    /// Replace the per-row syntax span overlay. Used by the host
318    /// once tree-sitter (or any other producer) has fresh styling
319    /// for the visible window. `spans[row]` corresponds to row
320    /// `row`; rows beyond `spans.len()` get no styling.
321    pub fn set_spans(&mut self, spans: Vec<Vec<crate::Span>>) {
322        self.spans = spans;
323        self.dirty_gen_bump();
324    }
325
326    /// Replace the buffer's full text in place. Cursor + sticky col
327    /// are clamped to the new content; viewport stays put. Used
328    /// during the migration off tui-textarea so the buffer can mirror
329    /// the textarea's content after every edit without rebuilding
330    /// the whole struct.
331    pub fn replace_all(&mut self, text: &str) {
332        let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
333        if lines.is_empty() {
334            lines.push(String::new());
335        }
336        self.lines = lines;
337        // Clamp cursor to surviving content.
338        let cursor = self.clamp_position(self.cursor);
339        self.cursor = cursor;
340        self.dirty_gen_bump();
341    }
342
343    /// Same as [`Buffer::set_spans`] but exposed for in-crate tests
344    /// without crossing the dirty-gen / lifetime boundaries the
345    /// pub method advertises.
346    #[cfg(test)]
347    pub(crate) fn set_spans_for_test(&mut self, spans: Vec<Vec<crate::Span>>) {
348        self.spans = spans;
349    }
350
351    pub fn marks(&self) -> &BTreeMap<char, Position> {
352        &self.marks
353    }
354
355    pub fn spans(&self) -> &[Vec<Span>] {
356        &self.spans
357    }
358
359    /// Concatenate the rows into a single `String` joined by `\n`.
360    /// Inverse of [`Buffer::from_str`] for content built without a
361    /// trailing newline.
362    pub fn as_string(&self) -> String {
363        self.lines.join("\n")
364    }
365
366    /// Number of rows in the buffer. Always `>= 1`.
367    pub fn row_count(&self) -> usize {
368        self.lines.len()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn new_has_one_empty_row() {
378        let b = Buffer::new();
379        assert_eq!(b.row_count(), 1);
380        assert_eq!(b.line(0), Some(""));
381        assert_eq!(b.cursor(), Position::default());
382    }
383
384    #[test]
385    fn from_str_splits_on_newline() {
386        let b = Buffer::from_str("foo\nbar\nbaz");
387        assert_eq!(b.row_count(), 3);
388        assert_eq!(b.line(0), Some("foo"));
389        assert_eq!(b.line(2), Some("baz"));
390    }
391
392    #[test]
393    fn from_str_trailing_newline_keeps_empty_row() {
394        let b = Buffer::from_str("foo\n");
395        assert_eq!(b.row_count(), 2);
396        assert_eq!(b.line(1), Some(""));
397    }
398
399    #[test]
400    fn from_str_empty_input_keeps_one_row() {
401        let b = Buffer::from_str("");
402        assert_eq!(b.row_count(), 1);
403        assert_eq!(b.line(0), Some(""));
404    }
405
406    #[test]
407    fn as_string_round_trips() {
408        let b = Buffer::from_str("a\nb\nc");
409        assert_eq!(b.as_string(), "a\nb\nc");
410    }
411
412    #[test]
413    fn dirty_gen_starts_at_zero() {
414        assert_eq!(Buffer::new().dirty_gen(), 0);
415    }
416
417    #[test]
418    fn ensure_cursor_visible_wrap_scrolls_when_cursor_below_screen() {
419        let mut b = Buffer::from_str("aaaaaaaaaa\nb\nc");
420        {
421            let v = b.viewport_mut();
422            v.height = 3;
423            v.width = 4;
424            v.text_width = 4;
425            v.wrap = crate::Wrap::Char;
426        }
427        // Cursor on row 2 col 0. Doc rows 0-2 occupy 3+1+1=5 screen
428        // rows; only 3 fit. ensure_cursor_visible should advance
429        // top_row past row 0 so cursor lands inside the viewport.
430        b.set_cursor(Position::new(2, 0));
431        b.ensure_cursor_visible();
432        assert_eq!(b.viewport().top_row, 1);
433    }
434
435    #[test]
436    fn ensure_cursor_visible_wrap_no_scroll_when_visible() {
437        let mut b = Buffer::from_str("aaaaaaaaaa\nb");
438        {
439            let v = b.viewport_mut();
440            v.height = 4;
441            v.width = 4;
442            v.text_width = 4;
443            v.wrap = crate::Wrap::Char;
444        }
445        // Cursor in row 0 segment 1 (col 5). Doc row 0 wraps to 3
446        // screen rows; cursor's screen row is 1 (< height). No scroll.
447        b.set_cursor(Position::new(0, 5));
448        b.ensure_cursor_visible();
449        assert_eq!(b.viewport().top_row, 0);
450    }
451
452    #[test]
453    fn ensure_cursor_visible_wrap_snaps_top_when_cursor_above() {
454        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
455        {
456            let v = b.viewport_mut();
457            v.height = 2;
458            v.width = 4;
459            v.text_width = 4;
460            v.wrap = crate::Wrap::Char;
461            v.top_row = 3;
462        }
463        b.set_cursor(Position::new(1, 0));
464        b.ensure_cursor_visible();
465        assert_eq!(b.viewport().top_row, 1);
466    }
467
468    #[test]
469    fn screen_rows_between_sums_segments_under_wrap() {
470        // 9-char first row + 1-char second row + empty third.
471        let mut b = Buffer::from_str("aaaaaaaaa\nb\n");
472        {
473            let v = b.viewport_mut();
474            v.wrap = crate::Wrap::Char;
475            v.text_width = 4;
476        }
477        // Row 0 wraps to 3 segments; row 1 → 1; row 2 (empty) → 1.
478        assert_eq!(b.screen_rows_between(0, 0), 3);
479        assert_eq!(b.screen_rows_between(0, 1), 4);
480        assert_eq!(b.screen_rows_between(0, 2), 5);
481        assert_eq!(b.screen_rows_between(1, 2), 2);
482    }
483
484    #[test]
485    fn screen_rows_between_one_per_doc_row_when_wrap_off() {
486        let b = Buffer::from_str("aaaaa\nb\nc");
487        assert_eq!(b.screen_rows_between(0, 2), 3);
488    }
489
490    #[test]
491    fn max_top_for_height_walks_back_until_height_reached() {
492        // 5 rows, last row wraps to 3 segments under width 4.
493        let mut b = Buffer::from_str("a\nb\nc\nd\neeeeeeee");
494        {
495            let v = b.viewport_mut();
496            v.wrap = crate::Wrap::Char;
497            v.text_width = 4;
498        }
499        // Last row alone = 2 segments; with row 3 added = 3 screen
500        // rows; with row 2 = 4. height=4 → max_top = row 2.
501        assert_eq!(b.max_top_for_height(4), 2);
502        // Larger than total rows → 0.
503        assert_eq!(b.max_top_for_height(99), 0);
504    }
505
506    #[test]
507    fn cursor_screen_row_returns_none_when_wrap_off() {
508        let b = Buffer::from_str("a");
509        assert!(b.cursor_screen_row().is_none());
510    }
511
512    #[test]
513    fn cursor_screen_row_under_wrap() {
514        let mut b = Buffer::from_str("aaaaaaaaaa\nb");
515        {
516            let v = b.viewport_mut();
517            v.wrap = crate::Wrap::Char;
518            v.text_width = 4;
519        }
520        b.set_cursor(Position::new(0, 5));
521        // Cursor on row 0 segment 1 → screen row 1.
522        assert_eq!(b.cursor_screen_row(), Some(1));
523        b.set_cursor(Position::new(1, 0));
524        // Row 0 wraps to 3 segments + row 1's first segment = 3.
525        assert_eq!(b.cursor_screen_row(), Some(3));
526    }
527
528    #[test]
529    fn ensure_cursor_visible_falls_back_when_wrap_disabled() {
530        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
531        {
532            let v = b.viewport_mut();
533            v.height = 2;
534            v.width = 4;
535            v.text_width = 4;
536            v.wrap = crate::Wrap::None;
537        }
538        b.set_cursor(Position::new(4, 0));
539        b.ensure_cursor_visible();
540        // Without wrap the existing doc-row math runs: cursor at row 4
541        // with height 2 → top_row = 3.
542        assert_eq!(b.viewport().top_row, 3);
543    }
544}