Skip to main content

flywheel/widget/
scroll_buffer.rs

1//! Scroll buffer: Ring buffer for storing scrollback history.
2//!
3//! This provides efficient storage for text content that may scroll
4//! off the visible area, with O(1) append and scroll operations.
5
6use std::collections::VecDeque;
7use crate::buffer::Cell;
8
9/// A line of text with associated style information.
10#[derive(Debug, Clone)]
11pub struct StyledLine {
12    /// The text content of the line.
13    pub content: Vec<Cell>,
14    /// Whether this line was soft-wrapped (vs. hard newline).
15    pub wrapped: bool,
16}
17
18impl StyledLine {
19    /// Create a new styled line.
20    pub const fn new(content: Vec<Cell>, wrapped: bool) -> Self {
21        Self { content, wrapped }
22    }
23
24    /// Create an empty line.
25    pub const fn empty() -> Self {
26        Self {
27            content: Vec::new(),
28            wrapped: false,
29        }
30    }
31}
32
33/// Ring buffer for storing lines with scrollback.
34///
35/// The scroll buffer maintains a fixed number of lines in memory,
36/// automatically discarding old lines when capacity is exceeded.
37#[derive(Debug)]
38pub struct ScrollBuffer {
39    /// Lines stored in the buffer.
40    lines: VecDeque<StyledLine>,
41    /// Maximum number of lines to retain.
42    max_lines: usize,
43    /// Current scroll offset from the bottom (0 = at bottom).
44    scroll_offset: usize,
45}
46
47impl ScrollBuffer {
48    /// Create a new scroll buffer with the given capacity.
49    pub fn new(max_lines: usize) -> Self {
50        let mut lines = VecDeque::with_capacity(max_lines);
51        lines.push_back(StyledLine::empty());
52
53        Self {
54            lines,
55            max_lines,
56            scroll_offset: 0,
57        }
58    }
59
60    /// Get the total number of lines in the buffer.
61    pub fn len(&self) -> usize {
62        self.lines.len()
63    }
64
65    /// Check if the buffer is empty.
66    pub fn is_empty(&self) -> bool {
67        self.lines.is_empty()
68    }
69
70    /// Get the current line (the line being appended to).
71    ///
72    /// # Panics
73    ///
74    /// Panics if the buffer is empty (which should never happen).
75    pub fn current_line(&self) -> &StyledLine {
76        self.lines.back().expect("Buffer should never be empty")
77    }
78
79    /// Get a mutable reference to the current line.
80    ///
81    /// # Panics
82    ///
83    /// Panics if the buffer is empty (which should never happen).
84    pub fn current_line_mut(&mut self) -> &mut StyledLine {
85        self.lines.back_mut().expect("Buffer should never be empty")
86    }
87
88    /// Append cells to the current line.
89    pub fn append(&mut self, cells: impl IntoIterator<Item = Cell>) {
90        self.current_line_mut().content.extend(cells);
91    }
92
93    /// Start a new line.
94    ///
95    /// # Arguments
96    ///
97    /// * `wrapped` - Whether the new line is due to soft wrapping.
98    pub fn newline(&mut self, wrapped: bool) {
99        // Trim excess lines if at capacity
100        while self.lines.len() >= self.max_lines {
101            self.lines.pop_front();
102        }
103
104        self.lines.push_back(StyledLine::new(Vec::new(), wrapped));
105    }
106
107    /// Get a line by index from the top of the buffer.
108    pub fn get(&self, index: usize) -> Option<&StyledLine> {
109        self.lines.get(index)
110    }
111
112    /// Get visible lines for a given viewport height.
113    ///
114    /// Returns an iterator over lines that should be visible,
115    /// accounting for scroll offset.
116    pub fn visible_lines(&self, viewport_height: usize) -> impl Iterator<Item = &StyledLine> {
117        let total = self.lines.len();
118        let end = total.saturating_sub(self.scroll_offset);
119        let start = end.saturating_sub(viewport_height);
120
121        self.lines.range(start..end)
122    }
123
124    /// Scroll up by the given number of lines.
125    pub fn scroll_up(&mut self, lines: usize) {
126        let max_offset = self.lines.len().saturating_sub(1);
127        self.scroll_offset = (self.scroll_offset + lines).min(max_offset);
128    }
129
130    /// Scroll down by the given number of lines.
131    pub const fn scroll_down(&mut self, lines: usize) {
132        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
133    }
134
135    /// Scroll to the bottom (latest content).
136    pub const fn scroll_to_bottom(&mut self) {
137        self.scroll_offset = 0;
138    }
139
140    /// Check if we're scrolled to the bottom.
141    pub const fn at_bottom(&self) -> bool {
142        self.scroll_offset == 0
143    }
144
145    /// Clear all content.
146    pub fn clear(&mut self) {
147        self.lines.clear();
148        self.lines.push_back(StyledLine::empty());
149        self.scroll_offset = 0;
150    }
151
152    /// Get the length of the current line in characters.
153    pub fn current_line_len(&self) -> usize {
154        self.current_line().content.len()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn text_to_cells(text: &str) -> Vec<Cell> {
163        text.chars().map(Cell::from_char).collect()
164    }
165
166    #[test]
167    fn test_scroll_buffer_new() {
168        let buf = ScrollBuffer::new(100);
169        assert_eq!(buf.len(), 1);
170        assert!(buf.current_line().content.is_empty());
171    }
172
173    #[test]
174    fn test_scroll_buffer_append() {
175        let mut buf = ScrollBuffer::new(100);
176        buf.append(text_to_cells("Hello"));
177        buf.append(text_to_cells(", world!"));
178        
179        let content: String = buf.current_line().content.iter()
180            .map(|c| c.grapheme().unwrap_or(""))
181            .collect();
182        assert_eq!(content, "Hello, world!");
183    }
184
185    #[test]
186    fn test_scroll_buffer_newline() {
187        let mut buf = ScrollBuffer::new(100);
188        buf.append(text_to_cells("Line 1"));
189        buf.newline(false);
190        buf.append(text_to_cells("Line 2"));
191        assert_eq!(buf.len(), 2);
192        
193        let l1: String = buf.get(0).unwrap().content.iter().map(|c| c.grapheme().unwrap_or("")).collect();
194        assert_eq!(l1, "Line 1");
195    }
196
197    #[test]
198    fn test_scroll_buffer_capacity() {
199        let mut buf = ScrollBuffer::new(3);
200        buf.append(text_to_cells("Line 1"));
201        buf.newline(false);
202        buf.append(text_to_cells("Line 2"));
203        buf.newline(false);
204        buf.append(text_to_cells("Line 3"));
205        buf.newline(false);
206        buf.append(text_to_cells("Line 4"));
207
208        assert_eq!(buf.len(), 3);
209        // Line 1 should have been discarded
210        let l0: String = buf.get(0).unwrap().content.iter().map(|c| c.grapheme().unwrap_or("")).collect();
211        assert_eq!(l0, "Line 2");
212    }
213
214    #[test]
215    fn test_scroll_buffer_scroll() {
216        let mut buf = ScrollBuffer::new(100);
217        for i in 0..10 {
218            buf.append(text_to_cells(&format!("Line {i}")));
219            buf.newline(false);
220        }
221
222        assert!(buf.at_bottom());
223
224        buf.scroll_up(3);
225        assert!(!buf.at_bottom());
226        assert_eq!(buf.scroll_offset, 3);
227
228        buf.scroll_down(1);
229        assert_eq!(buf.scroll_offset, 2);
230
231        buf.scroll_to_bottom();
232        assert!(buf.at_bottom());
233    }
234}