ratatui_toolkit/primitives/termtui/
grid.rs

1//! Terminal grid with VecDeque-based scrollback (mprocs architecture)
2
3use crate::primitives::termtui::row::Row;
4use crate::primitives::termtui::size::Size;
5use std::collections::VecDeque;
6
7/// Cursor position
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub struct Pos {
10    pub col: u16,
11    pub row: u16,
12}
13
14impl Pos {
15    pub fn new(col: u16, row: u16) -> Self {
16        Self { col, row }
17    }
18}
19
20/// Terminal grid with scrollback buffer
21///
22/// Uses VecDeque to efficiently manage scrollback history.
23/// The visible rows are at the end of the deque, with scrollback
24/// history at the front.
25#[derive(Clone, Debug)]
26pub struct Grid {
27    /// All rows (scrollback + visible)
28    rows: VecDeque<Row>,
29    /// Current size
30    size: Size,
31    /// Cursor position
32    pos: Pos,
33    /// Maximum scrollback lines
34    scrollback_len: usize,
35    /// Current scrollback offset (0 = showing latest)
36    scrollback_offset: usize,
37    /// Number of rows that have been used
38    used_rows: usize,
39    /// Scroll region top (0-indexed)
40    scroll_top: u16,
41    /// Scroll region bottom (0-indexed, exclusive)
42    scroll_bottom: u16,
43    /// Saved cursor position
44    saved_pos: Option<Pos>,
45}
46
47impl Grid {
48    /// Create a new grid
49    pub fn new(size: Size, scrollback_len: usize) -> Self {
50        let rows = (0..size.rows)
51            .map(|_| Row::new(size.cols))
52            .collect::<VecDeque<_>>();
53
54        Self {
55            rows,
56            size,
57            pos: Pos::default(),
58            scrollback_len,
59            scrollback_offset: 0,
60            used_rows: 0,
61            scroll_top: 0,
62            scroll_bottom: size.rows,
63            saved_pos: None,
64        }
65    }
66
67    /// Get the grid size
68    pub fn size(&self) -> Size {
69        self.size
70    }
71
72    /// Get cursor position
73    pub fn pos(&self) -> Pos {
74        self.pos
75    }
76
77    /// Set cursor position
78    pub fn set_pos(&mut self, pos: Pos) {
79        self.pos = Pos {
80            col: pos.col.min(self.size.cols.saturating_sub(1)),
81            row: pos.row.min(self.size.rows.saturating_sub(1)),
82        };
83    }
84
85    /// Move cursor to column
86    pub fn set_col(&mut self, col: u16) {
87        self.pos.col = col.min(self.size.cols.saturating_sub(1));
88    }
89
90    /// Move cursor to row
91    pub fn set_row(&mut self, row: u16) {
92        self.pos.row = row.min(self.size.rows.saturating_sub(1));
93    }
94
95    /// Save cursor position
96    pub fn save_pos(&mut self) {
97        self.saved_pos = Some(self.pos);
98    }
99
100    /// Restore cursor position
101    pub fn restore_pos(&mut self) {
102        if let Some(pos) = self.saved_pos {
103            self.pos = pos;
104        }
105    }
106
107    /// Get the index where visible rows begin in the deque
108    fn row0(&self) -> usize {
109        self.rows.len().saturating_sub(self.size.rows as usize)
110    }
111
112    /// Get current scrollback offset
113    pub fn scrollback(&self) -> usize {
114        self.scrollback_offset
115    }
116
117    /// Set scrollback offset
118    pub fn set_scrollback(&mut self, offset: usize) {
119        let max_offset = self.row0();
120        self.scrollback_offset = offset.min(max_offset);
121    }
122
123    /// Get available scrollback lines
124    pub fn scrollback_available(&self) -> usize {
125        self.row0()
126    }
127
128    /// Set scroll region
129    pub fn set_scroll_region(&mut self, top: u16, bottom: u16) {
130        self.scroll_top = top.min(self.size.rows.saturating_sub(1));
131        self.scroll_bottom = bottom.min(self.size.rows).max(self.scroll_top + 1);
132    }
133
134    /// Reset scroll region to full screen
135    pub fn reset_scroll_region(&mut self) {
136        self.scroll_top = 0;
137        self.scroll_bottom = self.size.rows;
138    }
139
140    /// Get a visible row (accounting for scrollback offset)
141    pub fn visible_row(&self, row: u16) -> Option<&Row> {
142        let idx = self.row0() + row as usize;
143        let idx = idx.saturating_sub(self.scrollback_offset);
144        self.rows.get(idx)
145    }
146
147    /// Get a drawing row (for writing, ignores scrollback offset)
148    pub fn drawing_row(&self, row: u16) -> Option<&Row> {
149        let idx = self.row0() + row as usize;
150        self.rows.get(idx)
151    }
152
153    /// Get a mutable drawing row
154    pub fn drawing_row_mut(&mut self, row: u16) -> Option<&mut Row> {
155        let idx = self.row0() + row as usize;
156        if row as usize >= self.used_rows {
157            self.used_rows = row as usize + 1;
158        }
159        self.rows.get_mut(idx)
160    }
161
162    /// Get current row (at cursor position)
163    pub fn current_row(&self) -> Option<&Row> {
164        self.drawing_row(self.pos.row)
165    }
166
167    /// Get mutable current row
168    pub fn current_row_mut(&mut self) -> Option<&mut Row> {
169        let row = self.pos.row;
170        self.drawing_row_mut(row)
171    }
172
173    /// Scroll up within scroll region
174    pub fn scroll_up(&mut self, count: usize) {
175        for _ in 0..count {
176            // If scroll region is full screen, add to scrollback
177            if self.scroll_top == 0 && self.scroll_bottom == self.size.rows {
178                // Add new row at the end
179                self.rows.push_back(Row::new(self.size.cols));
180
181                // Trim scrollback if needed
182                while self.rows.len() > self.size.rows as usize + self.scrollback_len {
183                    self.rows.pop_front();
184                }
185            } else {
186                // Scroll within region only
187                let top_idx = self.row0() + self.scroll_top as usize;
188                let bottom_idx = self.row0() + self.scroll_bottom as usize - 1;
189
190                if top_idx < self.rows.len() && bottom_idx < self.rows.len() {
191                    // Remove top row of region
192                    self.rows.remove(top_idx);
193                    // Insert new row at bottom of region
194                    self.rows.insert(bottom_idx, Row::new(self.size.cols));
195                }
196            }
197        }
198    }
199
200    /// Scroll down within scroll region
201    pub fn scroll_down(&mut self, count: usize) {
202        for _ in 0..count {
203            let top_idx = self.row0() + self.scroll_top as usize;
204            let bottom_idx = self.row0() + self.scroll_bottom as usize - 1;
205
206            if top_idx < self.rows.len() && bottom_idx < self.rows.len() {
207                // Remove bottom row of region
208                self.rows.remove(bottom_idx);
209                // Insert new row at top of region
210                self.rows.insert(top_idx, Row::new(self.size.cols));
211            }
212        }
213    }
214
215    /// Clear all rows
216    pub fn clear(&mut self) {
217        for row in self.rows.iter_mut() {
218            row.clear();
219        }
220        self.used_rows = 0;
221    }
222
223    /// Clear from cursor to end of screen
224    pub fn clear_below(&mut self) {
225        let pos_row = self.pos.row;
226        let pos_col = self.pos.col;
227        let cols = self.size.cols;
228        let rows = self.size.rows;
229
230        // Clear current row from cursor
231        if let Some(row) = self.drawing_row_mut(pos_row) {
232            row.erase(pos_col, cols);
233        }
234
235        // Clear all rows below
236        for r in (pos_row + 1)..rows {
237            if let Some(row) = self.drawing_row_mut(r) {
238                row.clear();
239            }
240        }
241    }
242
243    /// Clear from start of screen to cursor
244    pub fn clear_above(&mut self) {
245        let pos_row = self.pos.row;
246        let pos_col = self.pos.col;
247
248        // Clear all rows above
249        for r in 0..pos_row {
250            if let Some(row) = self.drawing_row_mut(r) {
251                row.clear();
252            }
253        }
254
255        // Clear current row up to cursor
256        if let Some(row) = self.drawing_row_mut(pos_row) {
257            row.erase(0, pos_col + 1);
258        }
259    }
260
261    /// Resize the grid
262    pub fn resize(&mut self, new_size: Size) {
263        // Resize existing rows
264        for row in self.rows.iter_mut() {
265            row.resize(new_size.cols);
266        }
267
268        // Add or remove rows as needed
269        while self.rows.len() < new_size.rows as usize {
270            self.rows.push_back(Row::new(new_size.cols));
271        }
272
273        // Update size
274        self.size = new_size;
275
276        // Clamp cursor and scroll region
277        self.pos.col = self.pos.col.min(new_size.cols.saturating_sub(1));
278        self.pos.row = self.pos.row.min(new_size.rows.saturating_sub(1));
279        self.scroll_bottom = new_size.rows;
280    }
281
282    /// Get selected text from coordinates
283    ///
284    /// Handles wrapped lines correctly (no newline for soft-wrapped rows)
285    pub fn get_selected_text(&self, low_x: i32, low_y: i32, high_x: i32, high_y: i32) -> String {
286        let mut contents = String::new();
287
288        let row0 = self.row0() as i32;
289        let start_row = (row0 + low_y).max(0) as usize;
290        let end_row = (row0 + high_y).max(0) as usize;
291
292        for (i, row) in self.rows.iter().enumerate() {
293            if i < start_row || i > end_row {
294                continue;
295            }
296
297            let width = row.width();
298
299            // Determine start and end columns for this row
300            let start_col = if i == start_row {
301                (low_x.max(0) as u16).min(width)
302            } else {
303                0
304            };
305
306            let end_col = if i == end_row {
307                (high_x.max(0) as u16).min(width)
308            } else {
309                width
310            };
311
312            // Extract text from this row
313            row.write_contents(&mut contents, start_col, end_col);
314
315            // Add newline unless this row wraps (soft wrap)
316            if i != end_row && !row.wrapped() {
317                contents.push('\n');
318            }
319        }
320
321        // Trim trailing whitespace from each line
322        contents
323            .lines()
324            .map(|line| line.trim_end())
325            .collect::<Vec<_>>()
326            .join("\n")
327    }
328
329    /// Iterate over visible rows
330    pub fn visible_rows(&self) -> impl Iterator<Item = &Row> {
331        let start = self.row0().saturating_sub(self.scrollback_offset);
332        let end = start + self.size.rows as usize;
333        self.rows.iter().skip(start).take(end - start)
334    }
335
336    /// Iterate over drawing rows (ignoring scrollback offset)
337    pub fn drawing_rows(&self) -> impl Iterator<Item = &Row> {
338        let start = self.row0();
339        self.rows.iter().skip(start).take(self.size.rows as usize)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_grid_new() {
349        let grid = Grid::new(Size::new(80, 24), 1000);
350        assert_eq!(grid.size().cols, 80);
351        assert_eq!(grid.size().rows, 24);
352        assert_eq!(grid.pos().col, 0);
353        assert_eq!(grid.pos().row, 0);
354    }
355
356    #[test]
357    fn test_grid_cursor() {
358        let mut grid = Grid::new(Size::new(80, 24), 1000);
359
360        grid.set_pos(Pos::new(10, 5));
361        assert_eq!(grid.pos(), Pos::new(10, 5));
362
363        // Should clamp to bounds
364        grid.set_pos(Pos::new(100, 50));
365        assert_eq!(grid.pos(), Pos::new(79, 23));
366    }
367
368    #[test]
369    fn test_grid_scroll_up() {
370        let mut grid = Grid::new(Size::new(80, 24), 100);
371
372        // Write something to first row
373        if let Some(row) = grid.drawing_row_mut(0) {
374            if let Some(cell) = row.get_mut(0) {
375                cell.set_text("A");
376            }
377        }
378
379        // Scroll up
380        grid.scroll_up(1);
381
382        // Check scrollback available
383        assert_eq!(grid.scrollback_available(), 1);
384    }
385
386    #[test]
387    fn test_grid_scrollback() {
388        let mut grid = Grid::new(Size::new(80, 24), 100);
389
390        // Fill with some content and scroll
391        for _ in 0..10 {
392            grid.scroll_up(1);
393        }
394
395        assert_eq!(grid.scrollback_available(), 10);
396
397        // Set scrollback offset
398        grid.set_scrollback(5);
399        assert_eq!(grid.scrollback(), 5);
400
401        // Should clamp to available
402        grid.set_scrollback(1000);
403        assert_eq!(grid.scrollback(), 10);
404    }
405
406    #[test]
407    fn test_grid_get_selected_text() {
408        let mut grid = Grid::new(Size::new(80, 24), 100);
409
410        // Write "Hello World" on first row
411        if let Some(row) = grid.drawing_row_mut(0) {
412            for (i, c) in "Hello World".chars().enumerate() {
413                if let Some(cell) = row.get_mut(i as u16) {
414                    cell.set_text(c.to_string());
415                }
416            }
417        }
418
419        let text = grid.get_selected_text(0, 0, 5, 0);
420        assert_eq!(text, "Hello");
421    }
422
423    #[test]
424    fn test_grid_resize() {
425        let mut grid = Grid::new(Size::new(80, 24), 100);
426
427        grid.resize(Size::new(120, 40));
428        assert_eq!(grid.size().cols, 120);
429        assert_eq!(grid.size().rows, 40);
430    }
431}