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 ///
98 /// Out-of-bounds [`Position`] values are silently clamped: an
99 /// out-of-range `row` is pulled to the last row; an out-of-range
100 /// `col` is pulled to the row's char count (one past the last char —
101 /// the insert-mode boundary). See [`Position`] for the full bounds
102 /// contract.
103 ///
104 /// The optional sticky column for `j`/`k` motions is **not** reset by
105 /// this call — it survives `set_cursor` intentionally. Only motion code
106 /// should clear it.
107 pub fn set_cursor(&mut self, pos: Position) {
108 let last_row = self.lines.len().saturating_sub(1);
109 let row = pos.row.min(last_row);
110 let line_chars = self.lines[row].chars().count();
111 let col = pos.col.min(line_chars);
112 self.cursor = Position::new(row, col);
113 }
114
115 /// Bring the cursor into the visible [`Viewport`], scrolling by the
116 /// minimum amount needed. When `viewport.wrap != Wrap::None` and
117 /// `viewport.text_width > 0`, scrolling is screen-line aware:
118 /// `top_row` is advanced one visible doc row at a time until the
119 /// cursor's screen row falls inside the viewport's height.
120 ///
121 /// The [`Viewport`] is an **input** written by the host per render
122 /// frame, not a value derived from the buffer. The host is responsible
123 /// for setting `top_row`, `top_col`, `width`, `height`, `wrap`, and
124 /// `text_width` before calling this method. See [`Viewport`] for the
125 /// full field contract.
126 ///
127 /// 0.0.34 (Patch C-δ.1): the viewport is no longer a buffer field;
128 /// callers thread a `&mut Viewport` (typically owned by the engine
129 /// `Host`).
130 pub fn ensure_cursor_visible(&mut self, viewport: &mut Viewport) {
131 let cursor = self.cursor;
132 let v = *viewport;
133 let wrap_active = !matches!(v.wrap, crate::Wrap::None) && v.text_width > 0;
134 if !wrap_active {
135 viewport.ensure_visible(cursor);
136 return;
137 }
138 if v.height == 0 {
139 return;
140 }
141 // Cursor above the visible region: snap top_row to it.
142 if cursor.row < v.top_row {
143 viewport.top_row = cursor.row;
144 viewport.top_col = 0;
145 return;
146 }
147 let height = v.height as usize;
148 // Push top_row forward (one visible doc row per iteration)
149 // until the cursor's screen row sits inside [0, height).
150 loop {
151 let csr = self.cursor_screen_row_from(viewport, viewport.top_row);
152 match csr {
153 Some(row) if row < height => break,
154 _ => {}
155 }
156 // Advance to the next non-folded doc row up to (but not
157 // past) the cursor row. Stop if we ran out of room.
158 let mut next = viewport.top_row + 1;
159 while next <= cursor.row && self.folds.iter().any(|f| f.hides(next)) {
160 next += 1;
161 }
162 if next > cursor.row {
163 // Last resort — pin top_row to the cursor row so the
164 // cursor lands at the top edge.
165 viewport.top_row = cursor.row;
166 break;
167 }
168 viewport.top_row = next;
169 }
170 viewport.top_col = 0;
171 }
172
173 /// Cursor's screen row offset (0-based) from `viewport.top_row`
174 /// under the current wrap mode + `text_width`. `None` when wrap
175 /// is off, the cursor row is hidden by a fold, or the cursor sits
176 /// above `top_row`. Used by host-side scrolloff math.
177 pub fn cursor_screen_row(&self, viewport: &Viewport) -> Option<usize> {
178 if matches!(viewport.wrap, crate::Wrap::None) || viewport.text_width == 0 {
179 return None;
180 }
181 self.cursor_screen_row_from(viewport, viewport.top_row)
182 }
183
184 /// Number of screen rows the doc range `start..=end` occupies
185 /// under the current wrap mode. Skips fold-hidden rows. Empty /
186 /// past-end ranges return 0. `Wrap::None` returns the visible
187 /// doc-row count (one screen row per doc row).
188 pub fn screen_rows_between(&self, viewport: &Viewport, start: usize, end: usize) -> usize {
189 if start > end {
190 return 0;
191 }
192 let last = self.lines.len().saturating_sub(1);
193 let end = end.min(last);
194 let v = *viewport;
195 let mut total = 0usize;
196 for r in start..=end {
197 if self.folds.iter().any(|f| f.hides(r)) {
198 continue;
199 }
200 if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
201 total += 1;
202 } else {
203 let line = self.lines.get(r).map(String::as_str).unwrap_or("");
204 total += crate::wrap::wrap_segments(line, v.text_width, v.wrap).len();
205 }
206 }
207 total
208 }
209
210 /// Earliest `top_row` such that `screen_rows_between(top, last)`
211 /// is at least `height`. Lets host-side scrolloff math clamp
212 /// `top_row` so the buffer never leaves blank rows below the
213 /// content. When the buffer's total screen rows are smaller than
214 /// `height` this returns 0.
215 pub fn max_top_for_height(&self, viewport: &Viewport, height: usize) -> usize {
216 if height == 0 {
217 return 0;
218 }
219 let last = self.lines.len().saturating_sub(1);
220 let mut total = 0usize;
221 let mut row = last;
222 loop {
223 if !self.folds.iter().any(|f| f.hides(row)) {
224 let v = *viewport;
225 total += if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
226 1
227 } else {
228 let line = self.lines.get(row).map(String::as_str).unwrap_or("");
229 crate::wrap::wrap_segments(line, v.text_width, v.wrap).len()
230 };
231 }
232 if total >= height {
233 return row;
234 }
235 if row == 0 {
236 return 0;
237 }
238 row -= 1;
239 }
240 }
241
242 /// Returns the cursor's screen row (0-based, relative to `top`)
243 /// under the current wrap mode + text width. `None` when the
244 /// cursor row is hidden by a fold or sits above `top`.
245 fn cursor_screen_row_from(&self, viewport: &Viewport, top: usize) -> Option<usize> {
246 let cursor = self.cursor;
247 if cursor.row < top {
248 return None;
249 }
250 let v = *viewport;
251 let mut screen = 0usize;
252 for r in top..=cursor.row {
253 if self.folds.iter().any(|f| f.hides(r)) {
254 continue;
255 }
256 let line = self.lines.get(r).map(String::as_str).unwrap_or("");
257 let segs = crate::wrap::wrap_segments(line, v.text_width, v.wrap);
258 if r == cursor.row {
259 let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
260 return Some(screen + seg_idx);
261 }
262 screen += segs.len();
263 }
264 None
265 }
266
267 /// Clamp `pos` to the buffer's content. Out-of-range row gets
268 /// pulled to the last row; out-of-range col gets pulled to the
269 /// row's char count (one past last char — insertion point).
270 ///
271 /// Used internally by [`Buffer::set_cursor`] and
272 /// [`Buffer::apply_edit`]. Callers can also use it to sanitize a
273 /// [`Position`] derived from external input before handing it to the
274 /// buffer. See [`Position`] for the valid-bounds definition.
275 pub fn clamp_position(&self, pos: Position) -> Position {
276 let last_row = self.lines.len().saturating_sub(1);
277 let row = pos.row.min(last_row);
278 let line_chars = self.lines[row].chars().count();
279 let col = pos.col.min(line_chars);
280 Position::new(row, col)
281 }
282
283 /// Mutable access to the lines. Crate-internal — edit code uses
284 /// this; outside callers go through [`Buffer::apply_edit`].
285 pub(crate) fn lines_mut(&mut self) -> &mut Vec<String> {
286 &mut self.lines
287 }
288
289 /// Bump the render-cache generation. Crate-internal — every
290 /// content mutation calls this so render fingerprints invalidate.
291 pub(crate) fn dirty_gen_bump(&mut self) {
292 self.dirty_gen = self.dirty_gen.wrapping_add(1);
293 }
294
295 /// Replace the buffer's full text in place. Cursor is clamped to
296 /// the new content. Used during the migration off tui-textarea so
297 /// the buffer can mirror the textarea's content after every edit
298 /// without rebuilding the whole struct.
299 pub fn replace_all(&mut self, text: &str) {
300 let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
301 if lines.is_empty() {
302 lines.push(String::new());
303 }
304 self.lines = lines;
305 // Clamp cursor to surviving content.
306 let cursor = self.clamp_position(self.cursor);
307 self.cursor = cursor;
308 self.dirty_gen_bump();
309 }
310
311 /// Concatenate the rows into a single `String` joined by `\n`.
312 /// Inverse of [`Buffer::from_str`] for content built without a
313 /// trailing newline.
314 pub fn as_string(&self) -> String {
315 self.lines.join("\n")
316 }
317
318 /// Number of rows in the buffer. Always `>= 1`.
319 pub fn row_count(&self) -> usize {
320 self.lines.len()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn new_has_one_empty_row() {
330 let b = Buffer::new();
331 assert_eq!(b.row_count(), 1);
332 assert_eq!(b.line(0), Some(""));
333 assert_eq!(b.cursor(), Position::default());
334 }
335
336 #[test]
337 fn from_str_splits_on_newline() {
338 let b = Buffer::from_str("foo\nbar\nbaz");
339 assert_eq!(b.row_count(), 3);
340 assert_eq!(b.line(0), Some("foo"));
341 assert_eq!(b.line(2), Some("baz"));
342 }
343
344 #[test]
345 fn from_str_trailing_newline_keeps_empty_row() {
346 let b = Buffer::from_str("foo\n");
347 assert_eq!(b.row_count(), 2);
348 assert_eq!(b.line(1), Some(""));
349 }
350
351 #[test]
352 fn from_str_empty_input_keeps_one_row() {
353 let b = Buffer::from_str("");
354 assert_eq!(b.row_count(), 1);
355 assert_eq!(b.line(0), Some(""));
356 }
357
358 #[test]
359 fn as_string_round_trips() {
360 let b = Buffer::from_str("a\nb\nc");
361 assert_eq!(b.as_string(), "a\nb\nc");
362 }
363
364 #[test]
365 fn dirty_gen_starts_at_zero() {
366 assert_eq!(Buffer::new().dirty_gen(), 0);
367 }
368
369 fn vp_wrap(width: u16, height: u16) -> Viewport {
370 Viewport {
371 top_row: 0,
372 top_col: 0,
373 width,
374 height,
375 wrap: crate::Wrap::Char,
376 text_width: width,
377 tab_width: 0,
378 }
379 }
380
381 #[test]
382 fn ensure_cursor_visible_wrap_scrolls_when_cursor_below_screen() {
383 let mut b = Buffer::from_str("aaaaaaaaaa\nb\nc");
384 let mut v = vp_wrap(4, 3);
385 // Cursor on row 2 col 0. Doc rows 0-2 occupy 3+1+1=5 screen
386 // rows; only 3 fit. ensure_cursor_visible should advance
387 // top_row past row 0 so cursor lands inside the viewport.
388 b.set_cursor(Position::new(2, 0));
389 b.ensure_cursor_visible(&mut v);
390 assert_eq!(v.top_row, 1);
391 }
392
393 #[test]
394 fn ensure_cursor_visible_wrap_no_scroll_when_visible() {
395 let mut b = Buffer::from_str("aaaaaaaaaa\nb");
396 let mut v = vp_wrap(4, 4);
397 // Cursor in row 0 segment 1 (col 5). Doc row 0 wraps to 3
398 // screen rows; cursor's screen row is 1 (< height). No scroll.
399 b.set_cursor(Position::new(0, 5));
400 b.ensure_cursor_visible(&mut v);
401 assert_eq!(v.top_row, 0);
402 }
403
404 #[test]
405 fn ensure_cursor_visible_wrap_snaps_top_when_cursor_above() {
406 let mut b = Buffer::from_str("a\nb\nc\nd\ne");
407 let mut v = vp_wrap(4, 2);
408 v.top_row = 3;
409 b.set_cursor(Position::new(1, 0));
410 b.ensure_cursor_visible(&mut v);
411 assert_eq!(v.top_row, 1);
412 }
413
414 #[test]
415 fn screen_rows_between_sums_segments_under_wrap() {
416 // 9-char first row + 1-char second row + empty third.
417 let b = Buffer::from_str("aaaaaaaaa\nb\n");
418 let v = vp_wrap(4, 0);
419 // Row 0 wraps to 3 segments; row 1 → 1; row 2 (empty) → 1.
420 assert_eq!(b.screen_rows_between(&v, 0, 0), 3);
421 assert_eq!(b.screen_rows_between(&v, 0, 1), 4);
422 assert_eq!(b.screen_rows_between(&v, 0, 2), 5);
423 assert_eq!(b.screen_rows_between(&v, 1, 2), 2);
424 }
425
426 #[test]
427 fn screen_rows_between_one_per_doc_row_when_wrap_off() {
428 let b = Buffer::from_str("aaaaa\nb\nc");
429 let v = Viewport::default();
430 assert_eq!(b.screen_rows_between(&v, 0, 2), 3);
431 }
432
433 #[test]
434 fn max_top_for_height_walks_back_until_height_reached() {
435 // 5 rows, last row wraps to 3 segments under width 4.
436 let b = Buffer::from_str("a\nb\nc\nd\neeeeeeee");
437 let v = vp_wrap(4, 0);
438 // Last row alone = 2 segments; with row 3 added = 3 screen
439 // rows; with row 2 = 4. height=4 → max_top = row 2.
440 assert_eq!(b.max_top_for_height(&v, 4), 2);
441 // Larger than total rows → 0.
442 assert_eq!(b.max_top_for_height(&v, 99), 0);
443 }
444
445 #[test]
446 fn cursor_screen_row_returns_none_when_wrap_off() {
447 let b = Buffer::from_str("a");
448 let v = Viewport::default();
449 assert!(b.cursor_screen_row(&v).is_none());
450 }
451
452 #[test]
453 fn cursor_screen_row_under_wrap() {
454 let mut b = Buffer::from_str("aaaaaaaaaa\nb");
455 let v = vp_wrap(4, 0);
456 b.set_cursor(Position::new(0, 5));
457 // Cursor on row 0 segment 1 → screen row 1.
458 assert_eq!(b.cursor_screen_row(&v), Some(1));
459 b.set_cursor(Position::new(1, 0));
460 // Row 0 wraps to 3 segments + row 1's first segment = 3.
461 assert_eq!(b.cursor_screen_row(&v), Some(3));
462 }
463
464 #[test]
465 fn ensure_cursor_visible_falls_back_when_wrap_disabled() {
466 let mut b = Buffer::from_str("a\nb\nc\nd\ne");
467 let mut v = Viewport {
468 top_row: 0,
469 top_col: 0,
470 width: 4,
471 height: 2,
472 wrap: crate::Wrap::None,
473 text_width: 4,
474 tab_width: 0,
475 };
476 b.set_cursor(Position::new(4, 0));
477 b.ensure_cursor_visible(&mut v);
478 // Without wrap the existing doc-row math runs: cursor at row 4
479 // with height 2 → top_row = 3.
480 assert_eq!(v.top_row, 3);
481 }
482}