Skip to main content

hjkl_buffer/
buffer.rs

1use crate::{Position, Viewport};
2
3/// In-memory text buffer + cursor.
4///
5/// This is the core type the rest of `hjkl-buffer` builds on. The
6/// runtime viewport state the host publishes per render frame
7/// (top_row, top_col, width, height, wrap, text_width) lived on this
8/// struct prior to 0.0.34 (Patch C-δ.1); it now lives on the engine
9/// `Host` adapter. Methods that need viewport input (e.g.
10/// [`Buffer::ensure_cursor_visible`], [`Buffer::cursor_screen_row`])
11/// take a `&Viewport` / `&mut Viewport` parameter so the rope-walking
12/// math stays here while the runtime state moves out.
13///
14/// The `lines` invariant — at least one entry, never empty — is
15/// preserved by every mutation.
16///
17/// 0.0.37: the per-row syntax span cache + the `/` search FSM state
18/// (`pattern`, per-row match cache, `wrapscan`) moved off `Buffer` per
19/// step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. Spans now flow
20/// through the engine's `Editor::buffer_spans` (populated from
21/// `Host::syntax_highlights` / `install_syntax_spans`) and pass into
22/// [`crate::BufferView`] as a slice parameter. Search state lives on
23/// `Editor::search_state`; the renderer takes the active pattern as a
24/// parameter.
25pub struct Buffer {
26    /// One entry per visual row. Always non-empty: a freshly
27    /// constructed `Buffer` holds a single empty `String` so cursor
28    /// positions don't need an "is the buffer empty?" branch.
29    lines: Vec<String>,
30    /// Charwise cursor. `col` is bound by `lines[row].chars().count()`
31    /// in normal mode, one past it in operator-pending / insert.
32    cursor: Position,
33    /// Bumps on every mutation; render cache keys against this so a
34    /// per-row Line gets recomputed when its source row changes.
35    dirty_gen: u64,
36    /// Manual folds — closed ranges hide rows in the render path.
37    /// `pub(crate)` so the [`folds`] module can read/write directly.
38    pub(crate) folds: Vec<crate::folds::Fold>,
39}
40
41impl Default for Buffer {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl Buffer {
48    /// Construct an empty buffer with one empty row + cursor at
49    /// `(0, 0)`. Caller publishes a viewport size on first draw.
50    pub fn new() -> Self {
51        Self {
52            lines: vec![String::new()],
53            cursor: Position::default(),
54            dirty_gen: 0,
55            folds: Vec::new(),
56        }
57    }
58
59    /// Build a buffer from a flat string. Splits on `\n`; a trailing
60    /// `\n` produces a trailing empty line (matches every text
61    /// editor's behaviour and keeps `from_text(buf.as_string())` an
62    /// identity round-trip in the common case).
63    #[allow(clippy::should_implement_trait)]
64    pub fn from_str(text: &str) -> Self {
65        let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
66        if lines.is_empty() {
67            lines.push(String::new());
68        }
69        Self {
70            lines,
71            cursor: Position::default(),
72            dirty_gen: 0,
73            folds: Vec::new(),
74        }
75    }
76
77    pub fn lines(&self) -> &[String] {
78        &self.lines
79    }
80
81    pub fn line(&self, row: usize) -> Option<&str> {
82        self.lines.get(row).map(String::as_str)
83    }
84
85    pub fn cursor(&self) -> Position {
86        self.cursor
87    }
88
89    pub fn dirty_gen(&self) -> u64 {
90        self.dirty_gen
91    }
92
93    /// Set cursor without scrolling. Caller is responsible for calling
94    /// [`Buffer::ensure_cursor_visible`] when they want viewport
95    /// follow. Clamps `row` and `col` to valid positions so motion
96    /// helpers don't have to repeat the bound check.
97    pub fn set_cursor(&mut self, pos: Position) {
98        let last_row = self.lines.len().saturating_sub(1);
99        let row = pos.row.min(last_row);
100        let line_chars = self.lines[row].chars().count();
101        let col = pos.col.min(line_chars);
102        self.cursor = Position::new(row, col);
103    }
104
105    /// Bring the cursor into the visible viewport, scrolling by the
106    /// minimum amount needed. When `viewport.wrap != Wrap::None` and
107    /// `viewport.text_width > 0`, scrolling is screen-line aware:
108    /// `top_row` is advanced one visible doc row at a time until the
109    /// cursor's screen row falls inside the viewport's height.
110    ///
111    /// 0.0.34 (Patch C-δ.1): the viewport is no longer a buffer field;
112    /// callers thread a `&mut Viewport` (typically owned by the engine
113    /// `Host`).
114    pub fn ensure_cursor_visible(&mut self, viewport: &mut Viewport) {
115        let cursor = self.cursor;
116        let v = *viewport;
117        let wrap_active = !matches!(v.wrap, crate::Wrap::None) && v.text_width > 0;
118        if !wrap_active {
119            viewport.ensure_visible(cursor);
120            return;
121        }
122        if v.height == 0 {
123            return;
124        }
125        // Cursor above the visible region: snap top_row to it.
126        if cursor.row < v.top_row {
127            viewport.top_row = cursor.row;
128            viewport.top_col = 0;
129            return;
130        }
131        let height = v.height as usize;
132        // Push top_row forward (one visible doc row per iteration)
133        // until the cursor's screen row sits inside [0, height).
134        loop {
135            let csr = self.cursor_screen_row_from(viewport, viewport.top_row);
136            match csr {
137                Some(row) if row < height => break,
138                _ => {}
139            }
140            // Advance to the next non-folded doc row up to (but not
141            // past) the cursor row. Stop if we ran out of room.
142            let mut next = viewport.top_row + 1;
143            while next <= cursor.row && self.folds.iter().any(|f| f.hides(next)) {
144                next += 1;
145            }
146            if next > cursor.row {
147                // Last resort — pin top_row to the cursor row so the
148                // cursor lands at the top edge.
149                viewport.top_row = cursor.row;
150                break;
151            }
152            viewport.top_row = next;
153        }
154        viewport.top_col = 0;
155    }
156
157    /// Cursor's screen row offset (0-based) from `viewport.top_row`
158    /// under the current wrap mode + `text_width`. `None` when wrap
159    /// is off, the cursor row is hidden by a fold, or the cursor sits
160    /// above `top_row`. Used by host-side scrolloff math.
161    pub fn cursor_screen_row(&self, viewport: &Viewport) -> Option<usize> {
162        if matches!(viewport.wrap, crate::Wrap::None) || viewport.text_width == 0 {
163            return None;
164        }
165        self.cursor_screen_row_from(viewport, viewport.top_row)
166    }
167
168    /// Number of screen rows the doc range `start..=end` occupies
169    /// under the current wrap mode. Skips fold-hidden rows. Empty /
170    /// past-end ranges return 0. `Wrap::None` returns the visible
171    /// doc-row count (one screen row per doc row).
172    pub fn screen_rows_between(&self, viewport: &Viewport, start: usize, end: usize) -> usize {
173        if start > end {
174            return 0;
175        }
176        let last = self.lines.len().saturating_sub(1);
177        let end = end.min(last);
178        let v = *viewport;
179        let mut total = 0usize;
180        for r in start..=end {
181            if self.folds.iter().any(|f| f.hides(r)) {
182                continue;
183            }
184            if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
185                total += 1;
186            } else {
187                let line = self.lines.get(r).map(String::as_str).unwrap_or("");
188                total += crate::wrap::wrap_segments(line, v.text_width, v.wrap).len();
189            }
190        }
191        total
192    }
193
194    /// Earliest `top_row` such that `screen_rows_between(top, last)`
195    /// is at least `height`. Lets host-side scrolloff math clamp
196    /// `top_row` so the buffer never leaves blank rows below the
197    /// content. When the buffer's total screen rows are smaller than
198    /// `height` this returns 0.
199    pub fn max_top_for_height(&self, viewport: &Viewport, height: usize) -> usize {
200        if height == 0 {
201            return 0;
202        }
203        let last = self.lines.len().saturating_sub(1);
204        let mut total = 0usize;
205        let mut row = last;
206        loop {
207            if !self.folds.iter().any(|f| f.hides(row)) {
208                let v = *viewport;
209                total += if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
210                    1
211                } else {
212                    let line = self.lines.get(row).map(String::as_str).unwrap_or("");
213                    crate::wrap::wrap_segments(line, v.text_width, v.wrap).len()
214                };
215            }
216            if total >= height {
217                return row;
218            }
219            if row == 0 {
220                return 0;
221            }
222            row -= 1;
223        }
224    }
225
226    /// Returns the cursor's screen row (0-based, relative to `top`)
227    /// under the current wrap mode + text width. `None` when the
228    /// cursor row is hidden by a fold or sits above `top`.
229    fn cursor_screen_row_from(&self, viewport: &Viewport, top: usize) -> Option<usize> {
230        let cursor = self.cursor;
231        if cursor.row < top {
232            return None;
233        }
234        let v = *viewport;
235        let mut screen = 0usize;
236        for r in top..=cursor.row {
237            if self.folds.iter().any(|f| f.hides(r)) {
238                continue;
239            }
240            let line = self.lines.get(r).map(String::as_str).unwrap_or("");
241            let segs = crate::wrap::wrap_segments(line, v.text_width, v.wrap);
242            if r == cursor.row {
243                let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
244                return Some(screen + seg_idx);
245            }
246            screen += segs.len();
247        }
248        None
249    }
250
251    /// Clamp `pos` to the buffer's content. Out-of-range row gets
252    /// pulled to the last row; out-of-range col gets pulled to the
253    /// row's char count (one past last char — insertion point).
254    pub fn clamp_position(&self, pos: Position) -> Position {
255        let last_row = self.lines.len().saturating_sub(1);
256        let row = pos.row.min(last_row);
257        let line_chars = self.lines[row].chars().count();
258        let col = pos.col.min(line_chars);
259        Position::new(row, col)
260    }
261
262    /// Mutable access to the lines. Crate-internal — edit code uses
263    /// this; outside callers go through [`Buffer::apply_edit`].
264    pub(crate) fn lines_mut(&mut self) -> &mut Vec<String> {
265        &mut self.lines
266    }
267
268    /// Bump the render-cache generation. Crate-internal — every
269    /// content mutation calls this so render fingerprints invalidate.
270    pub(crate) fn dirty_gen_bump(&mut self) {
271        self.dirty_gen = self.dirty_gen.wrapping_add(1);
272    }
273
274    /// Replace the buffer's full text in place. Cursor is clamped to
275    /// the new content. Used during the migration off tui-textarea so
276    /// the buffer can mirror the textarea's content after every edit
277    /// without rebuilding the whole struct.
278    pub fn replace_all(&mut self, text: &str) {
279        let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
280        if lines.is_empty() {
281            lines.push(String::new());
282        }
283        self.lines = lines;
284        // Clamp cursor to surviving content.
285        let cursor = self.clamp_position(self.cursor);
286        self.cursor = cursor;
287        self.dirty_gen_bump();
288    }
289
290    /// Concatenate the rows into a single `String` joined by `\n`.
291    /// Inverse of [`Buffer::from_str`] for content built without a
292    /// trailing newline.
293    pub fn as_string(&self) -> String {
294        self.lines.join("\n")
295    }
296
297    /// Number of rows in the buffer. Always `>= 1`.
298    pub fn row_count(&self) -> usize {
299        self.lines.len()
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn new_has_one_empty_row() {
309        let b = Buffer::new();
310        assert_eq!(b.row_count(), 1);
311        assert_eq!(b.line(0), Some(""));
312        assert_eq!(b.cursor(), Position::default());
313    }
314
315    #[test]
316    fn from_str_splits_on_newline() {
317        let b = Buffer::from_str("foo\nbar\nbaz");
318        assert_eq!(b.row_count(), 3);
319        assert_eq!(b.line(0), Some("foo"));
320        assert_eq!(b.line(2), Some("baz"));
321    }
322
323    #[test]
324    fn from_str_trailing_newline_keeps_empty_row() {
325        let b = Buffer::from_str("foo\n");
326        assert_eq!(b.row_count(), 2);
327        assert_eq!(b.line(1), Some(""));
328    }
329
330    #[test]
331    fn from_str_empty_input_keeps_one_row() {
332        let b = Buffer::from_str("");
333        assert_eq!(b.row_count(), 1);
334        assert_eq!(b.line(0), Some(""));
335    }
336
337    #[test]
338    fn as_string_round_trips() {
339        let b = Buffer::from_str("a\nb\nc");
340        assert_eq!(b.as_string(), "a\nb\nc");
341    }
342
343    #[test]
344    fn dirty_gen_starts_at_zero() {
345        assert_eq!(Buffer::new().dirty_gen(), 0);
346    }
347
348    fn vp_wrap(width: u16, height: u16) -> Viewport {
349        Viewport {
350            top_row: 0,
351            top_col: 0,
352            width,
353            height,
354            wrap: crate::Wrap::Char,
355            text_width: width,
356        }
357    }
358
359    #[test]
360    fn ensure_cursor_visible_wrap_scrolls_when_cursor_below_screen() {
361        let mut b = Buffer::from_str("aaaaaaaaaa\nb\nc");
362        let mut v = vp_wrap(4, 3);
363        // Cursor on row 2 col 0. Doc rows 0-2 occupy 3+1+1=5 screen
364        // rows; only 3 fit. ensure_cursor_visible should advance
365        // top_row past row 0 so cursor lands inside the viewport.
366        b.set_cursor(Position::new(2, 0));
367        b.ensure_cursor_visible(&mut v);
368        assert_eq!(v.top_row, 1);
369    }
370
371    #[test]
372    fn ensure_cursor_visible_wrap_no_scroll_when_visible() {
373        let mut b = Buffer::from_str("aaaaaaaaaa\nb");
374        let mut v = vp_wrap(4, 4);
375        // Cursor in row 0 segment 1 (col 5). Doc row 0 wraps to 3
376        // screen rows; cursor's screen row is 1 (< height). No scroll.
377        b.set_cursor(Position::new(0, 5));
378        b.ensure_cursor_visible(&mut v);
379        assert_eq!(v.top_row, 0);
380    }
381
382    #[test]
383    fn ensure_cursor_visible_wrap_snaps_top_when_cursor_above() {
384        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
385        let mut v = vp_wrap(4, 2);
386        v.top_row = 3;
387        b.set_cursor(Position::new(1, 0));
388        b.ensure_cursor_visible(&mut v);
389        assert_eq!(v.top_row, 1);
390    }
391
392    #[test]
393    fn screen_rows_between_sums_segments_under_wrap() {
394        // 9-char first row + 1-char second row + empty third.
395        let b = Buffer::from_str("aaaaaaaaa\nb\n");
396        let v = vp_wrap(4, 0);
397        // Row 0 wraps to 3 segments; row 1 → 1; row 2 (empty) → 1.
398        assert_eq!(b.screen_rows_between(&v, 0, 0), 3);
399        assert_eq!(b.screen_rows_between(&v, 0, 1), 4);
400        assert_eq!(b.screen_rows_between(&v, 0, 2), 5);
401        assert_eq!(b.screen_rows_between(&v, 1, 2), 2);
402    }
403
404    #[test]
405    fn screen_rows_between_one_per_doc_row_when_wrap_off() {
406        let b = Buffer::from_str("aaaaa\nb\nc");
407        let v = Viewport::default();
408        assert_eq!(b.screen_rows_between(&v, 0, 2), 3);
409    }
410
411    #[test]
412    fn max_top_for_height_walks_back_until_height_reached() {
413        // 5 rows, last row wraps to 3 segments under width 4.
414        let b = Buffer::from_str("a\nb\nc\nd\neeeeeeee");
415        let v = vp_wrap(4, 0);
416        // Last row alone = 2 segments; with row 3 added = 3 screen
417        // rows; with row 2 = 4. height=4 → max_top = row 2.
418        assert_eq!(b.max_top_for_height(&v, 4), 2);
419        // Larger than total rows → 0.
420        assert_eq!(b.max_top_for_height(&v, 99), 0);
421    }
422
423    #[test]
424    fn cursor_screen_row_returns_none_when_wrap_off() {
425        let b = Buffer::from_str("a");
426        let v = Viewport::default();
427        assert!(b.cursor_screen_row(&v).is_none());
428    }
429
430    #[test]
431    fn cursor_screen_row_under_wrap() {
432        let mut b = Buffer::from_str("aaaaaaaaaa\nb");
433        let v = vp_wrap(4, 0);
434        b.set_cursor(Position::new(0, 5));
435        // Cursor on row 0 segment 1 → screen row 1.
436        assert_eq!(b.cursor_screen_row(&v), Some(1));
437        b.set_cursor(Position::new(1, 0));
438        // Row 0 wraps to 3 segments + row 1's first segment = 3.
439        assert_eq!(b.cursor_screen_row(&v), Some(3));
440    }
441
442    #[test]
443    fn ensure_cursor_visible_falls_back_when_wrap_disabled() {
444        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
445        let mut v = Viewport {
446            top_row: 0,
447            top_col: 0,
448            width: 4,
449            height: 2,
450            wrap: crate::Wrap::None,
451            text_width: 4,
452        };
453        b.set_cursor(Position::new(4, 0));
454        b.ensure_cursor_visible(&mut v);
455        // Without wrap the existing doc-row math runs: cursor at row 4
456        // with height 2 → top_row = 3.
457        assert_eq!(v.top_row, 3);
458    }
459}