Skip to main content

frankenterm_core/
scrollback.rs

1//! Scrollback buffer: lines that have scrolled off the visible viewport.
2//!
3//! Stores rows as `Vec<Cell>` so that SGR attributes, hyperlinks, and wide-char
4//! flags are preserved through scrollback. Uses a `VecDeque` ring for O(1)
5//! push/pop at both ends.
6
7use std::collections::VecDeque;
8use std::ops::Range;
9
10use crate::cell::Cell;
11
12/// A single line in the scrollback buffer.
13///
14/// Stores the cells that made up the row when it was evicted from the viewport.
15/// The `wrapped` flag records whether the line was a soft-wrap continuation of
16/// the previous line (used by reflow on resize).
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ScrollbackLine {
19    /// The cells of this line (may be shorter than the viewport width if
20    /// trailing blanks were trimmed).
21    pub cells: Vec<Cell>,
22    /// Whether this line was a soft-wrap continuation (as opposed to a hard
23    /// newline / CR+LF). Used by reflow policies.
24    pub wrapped: bool,
25}
26
27/// Computed visible/render window over scrollback for virtualized rendering.
28///
29/// Indexes are in scrollback space (`0 = oldest`, `total_lines = one past newest`).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct ScrollbackWindow {
32    /// Total lines currently stored in scrollback.
33    pub total_lines: usize,
34    /// Maximum legal scroll offset from the newest viewport position.
35    pub max_scroll_offset: usize,
36    /// Clamped scroll offset from the newest viewport position.
37    pub scroll_offset_from_bottom: usize,
38    /// Visible viewport start (inclusive).
39    pub viewport_start: usize,
40    /// Visible viewport end (exclusive).
41    pub viewport_end: usize,
42    /// Render start including overscan (inclusive).
43    pub render_start: usize,
44    /// Render end including overscan (exclusive).
45    pub render_end: usize,
46}
47
48impl ScrollbackWindow {
49    /// Visible viewport range.
50    #[inline]
51    #[must_use]
52    pub fn viewport_range(self) -> Range<usize> {
53        self.viewport_start..self.viewport_end
54    }
55
56    /// Render range including overscan.
57    #[inline]
58    #[must_use]
59    pub fn render_range(self) -> Range<usize> {
60        self.render_start..self.render_end
61    }
62
63    /// Number of visible viewport lines.
64    #[inline]
65    #[must_use]
66    pub fn viewport_len(self) -> usize {
67        self.viewport_end.saturating_sub(self.viewport_start)
68    }
69
70    /// Number of lines in the render range (viewport + overscan).
71    #[inline]
72    #[must_use]
73    pub fn render_len(self) -> usize {
74        self.render_end.saturating_sub(self.render_start)
75    }
76}
77
78impl ScrollbackLine {
79    /// Create a new scrollback line from a cell slice.
80    pub fn new(cells: &[Cell], wrapped: bool) -> Self {
81        Self {
82            cells: cells.to_vec(),
83            wrapped,
84        }
85    }
86
87    /// Number of cells in this line.
88    #[inline]
89    pub fn len(&self) -> usize {
90        self.cells.len()
91    }
92
93    /// Whether this line has zero cells.
94    #[inline]
95    pub fn is_empty(&self) -> bool {
96        self.cells.is_empty()
97    }
98}
99
100/// Scrollback buffer with configurable line capacity.
101///
102/// Uses a `VecDeque` for O(1) push/pop. When over capacity, the oldest line
103/// (front of the deque) is evicted.
104#[derive(Debug, Clone)]
105pub struct Scrollback {
106    lines: VecDeque<ScrollbackLine>,
107    capacity: usize,
108}
109
110impl Scrollback {
111    /// Create a new scrollback with the given line capacity.
112    ///
113    /// A capacity of `0` means scrollback is disabled (all pushes are dropped).
114    #[must_use]
115    pub fn new(capacity: usize) -> Self {
116        Self {
117            lines: VecDeque::with_capacity(capacity.min(4096)),
118            capacity,
119        }
120    }
121
122    /// Maximum number of lines this scrollback can hold.
123    #[inline]
124    #[must_use]
125    pub fn capacity(&self) -> usize {
126        self.capacity
127    }
128
129    /// Change the scrollback capacity.
130    ///
131    /// If the new capacity is smaller than the current line count, the oldest
132    /// lines are evicted.
133    pub fn set_capacity(&mut self, capacity: usize) {
134        self.capacity = capacity;
135        while self.lines.len() > capacity {
136            self.lines.pop_front();
137        }
138    }
139
140    /// Current number of stored lines.
141    #[inline]
142    #[must_use]
143    pub fn len(&self) -> usize {
144        self.lines.len()
145    }
146
147    /// Whether the scrollback is empty.
148    #[inline]
149    #[must_use]
150    pub fn is_empty(&self) -> bool {
151        self.lines.is_empty()
152    }
153
154    /// Push a row (as a cell slice) into scrollback.
155    ///
156    /// `wrapped` indicates whether the row was a soft-wrap continuation.
157    /// If over capacity, the oldest line is evicted.
158    pub fn push_row(&mut self, cells: &[Cell], wrapped: bool) -> Option<ScrollbackLine> {
159        if self.capacity == 0 {
160            return None;
161        }
162        let evicted = if self.lines.len() == self.capacity {
163            self.lines.pop_front()
164        } else {
165            None
166        };
167        self.lines.push_back(ScrollbackLine::new(cells, wrapped));
168        evicted
169    }
170
171    /// Pop the most recent (newest) line from scrollback.
172    ///
173    /// Used when scrolling down to pull lines back into the viewport, or
174    /// when the viewport grows taller and lines are reclaimed.
175    pub fn pop_newest(&mut self) -> Option<ScrollbackLine> {
176        self.lines.pop_back()
177    }
178
179    /// Peek at the most recent (newest) line without removing it.
180    #[inline]
181    #[must_use]
182    pub fn peek_newest(&self) -> Option<&ScrollbackLine> {
183        self.lines.back()
184    }
185
186    /// Get a line by index (0 = oldest).
187    #[inline]
188    #[must_use]
189    pub fn get(&self, index: usize) -> Option<&ScrollbackLine> {
190        self.lines.get(index)
191    }
192
193    /// Iterate over stored lines from oldest to newest.
194    pub fn iter(&self) -> impl Iterator<Item = &ScrollbackLine> {
195        self.lines.iter()
196    }
197
198    /// Iterate over a specific line range (`0 = oldest`).
199    ///
200    /// The range is clamped to valid bounds. This enables viewport
201    /// virtualization without scanning the full history each frame.
202    pub fn iter_range(&self, range: Range<usize>) -> impl Iterator<Item = &ScrollbackLine> {
203        let end = range.end.min(self.lines.len());
204        let start = range.start.min(end);
205        self.lines.range(start..end)
206    }
207
208    /// Iterate over stored lines from newest to oldest.
209    pub fn iter_rev(&self) -> impl Iterator<Item = &ScrollbackLine> {
210        self.lines.iter().rev()
211    }
212
213    /// Clear all stored lines.
214    pub fn clear(&mut self) {
215        self.lines.clear();
216    }
217
218    /// Compute a virtualized scrollback window for viewport rendering.
219    ///
220    /// - `scroll_offset_from_bottom=0` anchors viewport at the newest lines.
221    /// - Larger offsets move viewport toward older lines.
222    /// - `overscan_lines` expands the render range around the viewport.
223    #[must_use]
224    pub fn virtualized_window(
225        &self,
226        scroll_offset_from_bottom: usize,
227        viewport_lines: usize,
228        overscan_lines: usize,
229    ) -> ScrollbackWindow {
230        let total_lines = self.lines.len();
231        let viewport_len = viewport_lines.min(total_lines);
232        let max_scroll_offset = total_lines.saturating_sub(viewport_len);
233        let scroll_offset_from_bottom = scroll_offset_from_bottom.min(max_scroll_offset);
234
235        if viewport_len == 0 {
236            return ScrollbackWindow {
237                total_lines,
238                max_scroll_offset,
239                scroll_offset_from_bottom,
240                viewport_start: total_lines,
241                viewport_end: total_lines,
242                render_start: total_lines,
243                render_end: total_lines,
244            };
245        }
246
247        let newest_viewport_start = total_lines.saturating_sub(viewport_len);
248        let viewport_start = newest_viewport_start.saturating_sub(scroll_offset_from_bottom);
249        let viewport_end = viewport_start.saturating_add(viewport_len);
250        let render_start = viewport_start.saturating_sub(overscan_lines);
251        let render_end = viewport_end.saturating_add(overscan_lines).min(total_lines);
252
253        ScrollbackWindow {
254            total_lines,
255            max_scroll_offset,
256            scroll_offset_from_bottom,
257            viewport_start,
258            viewport_end,
259            render_start,
260            render_end,
261        }
262    }
263}
264
265impl Default for Scrollback {
266    fn default() -> Self {
267        Self::new(0)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::cell::{Color, SgrAttrs, SgrFlags};
275
276    fn make_row(text: &str) -> Vec<Cell> {
277        text.chars().map(Cell::new).collect()
278    }
279
280    fn row_text(cells: &[Cell]) -> String {
281        cells.iter().map(|c| c.content()).collect()
282    }
283
284    #[test]
285    fn capacity_zero_drops_lines() {
286        let mut sb = Scrollback::new(0);
287        let _ = sb.push_row(&make_row("hello"), false);
288        assert!(sb.is_empty());
289    }
290
291    #[test]
292    fn push_and_retrieve() {
293        let mut sb = Scrollback::new(10);
294        let _ = sb.push_row(&make_row("first"), false);
295        let _ = sb.push_row(&make_row("second"), true);
296        assert_eq!(sb.len(), 2);
297
298        let line0 = sb.get(0).unwrap();
299        assert_eq!(row_text(&line0.cells), "first");
300        assert!(!line0.wrapped);
301
302        let line1 = sb.get(1).unwrap();
303        assert_eq!(row_text(&line1.cells), "second");
304        assert!(line1.wrapped);
305    }
306
307    #[test]
308    fn bounded_capacity_evicts_oldest() {
309        let mut sb = Scrollback::new(2);
310        let _ = sb.push_row(&make_row("a"), false);
311        let _ = sb.push_row(&make_row("b"), false);
312        let _ = sb.push_row(&make_row("c"), false);
313        assert_eq!(sb.len(), 2);
314        assert_eq!(row_text(&sb.get(0).unwrap().cells), "b");
315        assert_eq!(row_text(&sb.get(1).unwrap().cells), "c");
316    }
317
318    #[test]
319    fn pop_newest_returns_most_recent() {
320        let mut sb = Scrollback::new(10);
321        let _ = sb.push_row(&make_row("old"), false);
322        let _ = sb.push_row(&make_row("new"), false);
323        let popped = sb.pop_newest().unwrap();
324        assert_eq!(row_text(&popped.cells), "new");
325        assert_eq!(sb.len(), 1);
326    }
327
328    #[test]
329    fn pop_newest_empty_returns_none() {
330        let mut sb = Scrollback::new(10);
331        assert!(sb.pop_newest().is_none());
332    }
333
334    #[test]
335    fn peek_newest() {
336        let mut sb = Scrollback::new(10);
337        let _ = sb.push_row(&make_row("line"), false);
338        assert_eq!(row_text(&sb.peek_newest().unwrap().cells), "line");
339        assert_eq!(sb.len(), 1); // not consumed
340    }
341
342    #[test]
343    fn set_capacity_evicts_excess() {
344        let mut sb = Scrollback::new(10);
345        for i in 0..5 {
346            let _ = sb.push_row(&make_row(&format!("line{i}")), false);
347        }
348        sb.set_capacity(2);
349        assert_eq!(sb.len(), 2);
350        assert_eq!(row_text(&sb.get(0).unwrap().cells), "line3");
351        assert_eq!(row_text(&sb.get(1).unwrap().cells), "line4");
352    }
353
354    #[test]
355    fn iter_oldest_to_newest() {
356        let mut sb = Scrollback::new(10);
357        let _ = sb.push_row(&make_row("a"), false);
358        let _ = sb.push_row(&make_row("b"), false);
359        let _ = sb.push_row(&make_row("c"), false);
360        let texts: Vec<String> = sb.iter().map(|l| row_text(&l.cells)).collect();
361        assert_eq!(texts, vec!["a", "b", "c"]);
362    }
363
364    #[test]
365    fn iter_rev_newest_to_oldest() {
366        let mut sb = Scrollback::new(10);
367        let _ = sb.push_row(&make_row("a"), false);
368        let _ = sb.push_row(&make_row("b"), false);
369        let texts: Vec<String> = sb.iter_rev().map(|l| row_text(&l.cells)).collect();
370        assert_eq!(texts, vec!["b", "a"]);
371    }
372
373    #[test]
374    fn iter_range_is_clamped_and_ordered() {
375        let mut sb = Scrollback::new(10);
376        let _ = sb.push_row(&make_row("a"), false);
377        let _ = sb.push_row(&make_row("b"), false);
378        let _ = sb.push_row(&make_row("c"), false);
379        let _ = sb.push_row(&make_row("d"), false);
380
381        let texts: Vec<String> = sb.iter_range(1..3).map(|l| row_text(&l.cells)).collect();
382        assert_eq!(texts, vec!["b", "c"]);
383
384        let clamped: Vec<String> = sb.iter_range(3..99).map(|l| row_text(&l.cells)).collect();
385        assert_eq!(clamped, vec!["d"]);
386    }
387
388    #[test]
389    fn virtualized_window_from_bottom_with_overscan() {
390        let mut sb = Scrollback::new(32);
391        for i in 0..10 {
392            let _ = sb.push_row(&make_row(&format!("{i}")), false);
393        }
394
395        let window = sb.virtualized_window(0, 4, 1);
396        assert_eq!(window.total_lines, 10);
397        assert_eq!(window.max_scroll_offset, 6);
398        assert_eq!(window.viewport_range(), 6..10);
399        assert_eq!(window.render_range(), 5..10);
400        assert_eq!(window.viewport_len(), 4);
401        assert_eq!(window.render_len(), 5);
402    }
403
404    #[test]
405    fn virtualized_window_clamps_large_scroll_offset() {
406        let mut sb = Scrollback::new(32);
407        for i in 0..10 {
408            let _ = sb.push_row(&make_row(&format!("{i}")), false);
409        }
410
411        let window = sb.virtualized_window(999, 4, 2);
412        assert_eq!(window.scroll_offset_from_bottom, 6);
413        assert_eq!(window.viewport_range(), 0..4);
414        assert_eq!(window.render_range(), 0..6);
415    }
416
417    #[test]
418    fn virtualized_window_handles_small_history() {
419        let mut sb = Scrollback::new(8);
420        let _ = sb.push_row(&make_row("x"), false);
421        let _ = sb.push_row(&make_row("y"), false);
422
423        let window = sb.virtualized_window(3, 10, 5);
424        assert_eq!(window.max_scroll_offset, 0);
425        assert_eq!(window.viewport_range(), 0..2);
426        assert_eq!(window.render_range(), 0..2);
427    }
428
429    #[test]
430    fn clear_empties_buffer() {
431        let mut sb = Scrollback::new(10);
432        let _ = sb.push_row(&make_row("x"), false);
433        sb.clear();
434        assert!(sb.is_empty());
435    }
436
437    #[test]
438    fn preserves_cell_attributes() {
439        let mut sb = Scrollback::new(10);
440        let mut cells = make_row("AB");
441        cells[0].attrs = SgrAttrs {
442            flags: SgrFlags::BOLD,
443            fg: Color::Rgb(255, 0, 0),
444            bg: Color::Default,
445            underline_color: None,
446        };
447        cells[1].hyperlink = 42;
448        let _ = sb.push_row(&cells, false);
449
450        let stored = sb.get(0).unwrap();
451        assert!(stored.cells[0].attrs.flags.contains(SgrFlags::BOLD));
452        assert_eq!(stored.cells[0].attrs.fg, Color::Rgb(255, 0, 0));
453        assert_eq!(stored.cells[1].hyperlink, 42);
454    }
455
456    #[test]
457    fn scrollback_line_len_and_empty() {
458        let line = ScrollbackLine::new(&make_row("abc"), false);
459        assert_eq!(line.len(), 3);
460        assert!(!line.is_empty());
461
462        let empty = ScrollbackLine::new(&[], false);
463        assert_eq!(empty.len(), 0);
464        assert!(empty.is_empty());
465    }
466
467    // --- Edge-case tests (bd-33u5g) ---
468
469    #[test]
470    fn push_row_returns_evicted_line() {
471        let mut sb = Scrollback::new(2);
472        assert!(sb.push_row(&make_row("a"), false).is_none());
473        assert!(sb.push_row(&make_row("b"), false).is_none());
474
475        // Third push evicts "a"
476        let evicted = sb.push_row(&make_row("c"), false);
477        assert!(evicted.is_some());
478        let evicted = evicted.unwrap();
479        assert_eq!(row_text(&evicted.cells), "a");
480        assert!(!evicted.wrapped);
481    }
482
483    #[test]
484    fn push_row_evicted_preserves_wrapped_flag() {
485        let mut sb = Scrollback::new(1);
486        sb.push_row(&make_row("first"), true);
487        let evicted = sb.push_row(&make_row("second"), false);
488        let evicted = evicted.unwrap();
489        assert_eq!(row_text(&evicted.cells), "first");
490        assert!(evicted.wrapped);
491    }
492
493    #[test]
494    fn capacity_one_push_evict_cycle() {
495        let mut sb = Scrollback::new(1);
496        sb.push_row(&make_row("x"), false);
497        assert_eq!(sb.len(), 1);
498        assert_eq!(row_text(&sb.get(0).unwrap().cells), "x");
499
500        let evicted = sb.push_row(&make_row("y"), false);
501        assert_eq!(row_text(&evicted.unwrap().cells), "x");
502        assert_eq!(sb.len(), 1);
503        assert_eq!(row_text(&sb.get(0).unwrap().cells), "y");
504    }
505
506    #[test]
507    fn default_scrollback_is_zero_capacity() {
508        let sb = Scrollback::default();
509        assert_eq!(sb.capacity(), 0);
510        assert!(sb.is_empty());
511    }
512
513    #[test]
514    fn clone_independence() {
515        let mut sb = Scrollback::new(10);
516        sb.push_row(&make_row("hello"), false);
517        let cloned = sb.clone();
518        sb.push_row(&make_row("world"), false);
519
520        assert_eq!(sb.len(), 2);
521        assert_eq!(cloned.len(), 1);
522        assert_eq!(row_text(&cloned.get(0).unwrap().cells), "hello");
523    }
524
525    #[test]
526    fn get_out_of_bounds_returns_none() {
527        let sb = Scrollback::new(10);
528        assert!(sb.get(0).is_none());
529        assert!(sb.get(999).is_none());
530
531        let mut sb2 = Scrollback::new(10);
532        sb2.push_row(&make_row("x"), false);
533        assert!(sb2.get(0).is_some());
534        assert!(sb2.get(1).is_none());
535    }
536
537    #[test]
538    fn set_capacity_to_zero_evicts_all() {
539        let mut sb = Scrollback::new(10);
540        for i in 0..5 {
541            sb.push_row(&make_row(&format!("{i}")), false);
542        }
543        sb.set_capacity(0);
544        assert!(sb.is_empty());
545        assert_eq!(sb.capacity(), 0);
546    }
547
548    #[test]
549    fn set_capacity_growing_preserves_lines() {
550        let mut sb = Scrollback::new(3);
551        for i in 0..3 {
552            sb.push_row(&make_row(&format!("{i}")), false);
553        }
554        sb.set_capacity(10);
555        assert_eq!(sb.len(), 3);
556        assert_eq!(sb.capacity(), 10);
557        assert_eq!(row_text(&sb.get(0).unwrap().cells), "0");
558        assert_eq!(row_text(&sb.get(2).unwrap().cells), "2");
559    }
560
561    #[test]
562    fn set_capacity_same_is_noop() {
563        let mut sb = Scrollback::new(3);
564        sb.push_row(&make_row("a"), false);
565        sb.push_row(&make_row("b"), false);
566        sb.set_capacity(3);
567        assert_eq!(sb.len(), 2);
568    }
569
570    #[test]
571    fn push_row_with_empty_cells() {
572        let mut sb = Scrollback::new(10);
573        sb.push_row(&[], false);
574        assert_eq!(sb.len(), 1);
575        let line = sb.get(0).unwrap();
576        assert!(line.is_empty());
577        assert!(!line.wrapped);
578    }
579
580    #[test]
581    fn multiple_evictions_correct_order() {
582        let mut sb = Scrollback::new(2);
583        // Push a, b (fills to capacity)
584        sb.push_row(&make_row("a"), false);
585        sb.push_row(&make_row("b"), false);
586
587        // Push c → evicts a; push d → evicts b; push e → evicts c
588        let ev_a = sb.push_row(&make_row("c"), false).unwrap();
589        let ev_b = sb.push_row(&make_row("d"), false).unwrap();
590        let ev_c = sb.push_row(&make_row("e"), false).unwrap();
591
592        assert_eq!(row_text(&ev_a.cells), "a");
593        assert_eq!(row_text(&ev_b.cells), "b");
594        assert_eq!(row_text(&ev_c.cells), "c");
595
596        // Buffer now contains d, e
597        assert_eq!(row_text(&sb.get(0).unwrap().cells), "d");
598        assert_eq!(row_text(&sb.get(1).unwrap().cells), "e");
599    }
600
601    #[test]
602    fn iter_range_start_beyond_len() {
603        let mut sb = Scrollback::new(10);
604        sb.push_row(&make_row("a"), false);
605        sb.push_row(&make_row("b"), false);
606        sb.push_row(&make_row("c"), false);
607
608        // start beyond len should clamp to empty
609        let items: Vec<_> = sb.iter_range(10..20).collect();
610        assert!(items.is_empty());
611    }
612
613    #[test]
614    fn iter_range_empty_range() {
615        let mut sb = Scrollback::new(10);
616        sb.push_row(&make_row("a"), false);
617        sb.push_row(&make_row("b"), false);
618
619        // start == end → empty range
620        let items: Vec<_> = sb.iter_range(1..1).collect();
621        assert!(items.is_empty());
622    }
623
624    #[test]
625    fn iter_range_full() {
626        let mut sb = Scrollback::new(10);
627        sb.push_row(&make_row("a"), false);
628        sb.push_row(&make_row("b"), false);
629        sb.push_row(&make_row("c"), false);
630
631        let texts: Vec<String> = sb.iter_range(0..3).map(|l| row_text(&l.cells)).collect();
632        assert_eq!(texts, vec!["a", "b", "c"]);
633    }
634
635    #[test]
636    fn iter_range_far_beyond_len() {
637        let mut sb = Scrollback::new(10);
638        sb.push_row(&make_row("x"), false);
639
640        let texts: Vec<String> = sb.iter_range(0..1000).map(|l| row_text(&l.cells)).collect();
641        assert_eq!(texts, vec!["x"]);
642    }
643
644    #[test]
645    fn pop_all_then_push() {
646        let mut sb = Scrollback::new(3);
647        sb.push_row(&make_row("a"), false);
648        sb.push_row(&make_row("b"), false);
649
650        // Pop all
651        sb.pop_newest();
652        sb.pop_newest();
653        assert!(sb.is_empty());
654        assert!(sb.pop_newest().is_none());
655
656        // Push again
657        sb.push_row(&make_row("c"), false);
658        assert_eq!(sb.len(), 1);
659        assert_eq!(row_text(&sb.get(0).unwrap().cells), "c");
660    }
661
662    #[test]
663    fn peek_newest_does_not_consume() {
664        let mut sb = Scrollback::new(10);
665        sb.push_row(&make_row("a"), false);
666
667        // Peek multiple times
668        assert_eq!(row_text(&sb.peek_newest().unwrap().cells), "a");
669        assert_eq!(row_text(&sb.peek_newest().unwrap().cells), "a");
670        assert_eq!(sb.len(), 1);
671    }
672
673    #[test]
674    fn peek_newest_empty_returns_none() {
675        let sb = Scrollback::new(10);
676        assert!(sb.peek_newest().is_none());
677    }
678
679    #[test]
680    fn virtualized_window_empty_scrollback() {
681        let sb = Scrollback::new(100);
682        let w = sb.virtualized_window(0, 10, 2);
683        assert_eq!(w.total_lines, 0);
684        assert_eq!(w.max_scroll_offset, 0);
685        assert_eq!(w.viewport_start, 0);
686        assert_eq!(w.viewport_end, 0);
687        assert_eq!(w.render_start, 0);
688        assert_eq!(w.render_end, 0);
689        assert_eq!(w.viewport_len(), 0);
690        assert_eq!(w.render_len(), 0);
691    }
692
693    #[test]
694    fn virtualized_window_zero_viewport() {
695        let mut sb = Scrollback::new(10);
696        for i in 0..5 {
697            sb.push_row(&make_row(&format!("{i}")), false);
698        }
699
700        let w = sb.virtualized_window(0, 0, 2);
701        assert_eq!(w.total_lines, 5);
702        assert_eq!(w.viewport_len(), 0);
703    }
704
705    #[test]
706    fn virtualized_window_zero_overscan() {
707        let mut sb = Scrollback::new(20);
708        for i in 0..10 {
709            sb.push_row(&make_row(&format!("{i}")), false);
710        }
711
712        let w = sb.virtualized_window(0, 4, 0);
713        assert_eq!(w.viewport_range(), 6..10);
714        assert_eq!(w.render_range(), 6..10);
715        assert_eq!(w.viewport_range(), w.render_range());
716    }
717
718    #[test]
719    fn virtualized_window_at_max_scroll_offset() {
720        let mut sb = Scrollback::new(20);
721        for i in 0..10 {
722            sb.push_row(&make_row(&format!("{i}")), false);
723        }
724
725        // viewport=4, so max_scroll_offset = 10 - 4 = 6
726        let w = sb.virtualized_window(6, 4, 0);
727        assert_eq!(w.scroll_offset_from_bottom, 6);
728        assert_eq!(w.viewport_range(), 0..4); // oldest lines
729    }
730
731    #[test]
732    fn virtualized_window_overscan_clamped_to_bounds() {
733        let mut sb = Scrollback::new(20);
734        for i in 0..5 {
735            sb.push_row(&make_row(&format!("{i}")), false);
736        }
737
738        // Overscan of 100 should be clamped to buffer bounds
739        let w = sb.virtualized_window(0, 3, 100);
740        assert_eq!(w.render_start, 0); // Can't go below 0
741        assert_eq!(w.render_end, 5); // Can't exceed total_lines
742    }
743
744    #[test]
745    fn virtualized_window_render_contains_viewport() {
746        let mut sb = Scrollback::new(50);
747        for i in 0..20 {
748            sb.push_row(&make_row(&format!("{i}")), false);
749        }
750
751        for offset in [0, 3, 8, 16] {
752            let w = sb.virtualized_window(offset, 5, 2);
753            assert!(w.render_start <= w.viewport_start);
754            assert!(w.render_end >= w.viewport_end);
755        }
756    }
757
758    #[test]
759    fn virtualized_window_single_line() {
760        let mut sb = Scrollback::new(10);
761        sb.push_row(&make_row("only"), false);
762
763        let w = sb.virtualized_window(0, 5, 2);
764        assert_eq!(w.total_lines, 1);
765        assert_eq!(w.viewport_range(), 0..1);
766        assert_eq!(w.max_scroll_offset, 0);
767    }
768
769    #[test]
770    fn scrollback_line_equality() {
771        let line1 = ScrollbackLine::new(&make_row("abc"), false);
772        let line2 = ScrollbackLine::new(&make_row("abc"), false);
773        let line3 = ScrollbackLine::new(&make_row("abc"), true);
774        let line4 = ScrollbackLine::new(&make_row("xyz"), false);
775
776        assert_eq!(line1, line2);
777        assert_ne!(line1, line3); // Different wrapped flag
778        assert_ne!(line1, line4); // Different content
779    }
780
781    #[test]
782    fn scrollback_line_clone() {
783        let line = ScrollbackLine::new(&make_row("test"), true);
784        let cloned = line.clone();
785        assert_eq!(line, cloned);
786        assert!(cloned.wrapped);
787    }
788
789    #[test]
790    fn scrollback_window_copy_semantics() {
791        let w = ScrollbackWindow {
792            total_lines: 10,
793            max_scroll_offset: 6,
794            scroll_offset_from_bottom: 2,
795            viewport_start: 2,
796            viewport_end: 6,
797            render_start: 0,
798            render_end: 8,
799        };
800        let w2 = w; // Copy
801        assert_eq!(w, w2);
802    }
803
804    #[test]
805    fn scrollback_window_viewport_range_and_len_consistent() {
806        let w = ScrollbackWindow {
807            total_lines: 20,
808            max_scroll_offset: 15,
809            scroll_offset_from_bottom: 3,
810            viewport_start: 12,
811            viewport_end: 17,
812            render_start: 10,
813            render_end: 19,
814        };
815        assert_eq!(w.viewport_range(), 12..17);
816        assert_eq!(w.viewport_len(), 5);
817        assert_eq!(w.render_range(), 10..19);
818        assert_eq!(w.render_len(), 9);
819    }
820
821    #[test]
822    fn large_capacity_capped_prealloc() {
823        // Capacity 1_000_000 should not pre-allocate more than 4096
824        let sb = Scrollback::new(1_000_000);
825        assert_eq!(sb.capacity(), 1_000_000);
826        assert!(sb.is_empty());
827        // VecDeque::with_capacity(4096) — not directly testable, but
828        // construction should not OOM
829    }
830
831    #[test]
832    fn clear_then_push() {
833        let mut sb = Scrollback::new(10);
834        sb.push_row(&make_row("a"), false);
835        sb.push_row(&make_row("b"), false);
836        sb.clear();
837        assert!(sb.is_empty());
838
839        sb.push_row(&make_row("c"), false);
840        assert_eq!(sb.len(), 1);
841        assert_eq!(row_text(&sb.get(0).unwrap().cells), "c");
842    }
843
844    #[test]
845    fn debug_format() {
846        let sb = Scrollback::new(5);
847        let dbg = format!("{sb:?}");
848        assert!(dbg.contains("Scrollback"));
849    }
850
851    #[test]
852    fn scrollback_line_debug_format() {
853        let line = ScrollbackLine::new(&make_row("x"), true);
854        let dbg = format!("{line:?}");
855        assert!(dbg.contains("ScrollbackLine"));
856        assert!(dbg.contains("wrapped: true"));
857    }
858}