Skip to main content

termtui/
buffer.rs

1//! Double buffering and cell-level diffing for flicker-free rendering.
2//!
3//! This module implements a double-buffering system that maintains two complete
4//! representations of the terminal screen. By comparing these buffers cell-by-cell,
5//! we can generate minimal updates that eliminate flicker entirely.
6//!
7//! ## Architecture
8//!
9//! ```text
10//!     Current Screen          Next Frame           Diff Result
11//!     ┌─────────────┐      ┌─────────────┐      ┌─────────────┐
12//!     │ Hello World │      │ Hello Rust! │      │      ^^^^   │
13//!     │ Terminal UI │      │ Terminal UI │      │ (no change) │
14//!     └─────────────┘      └─────────────┘      └─────────────┘
15//!        Front Buffer         Back Buffer          Cell Updates
16//! ```
17
18use crate::style::{Color, TextStyle};
19use crate::utils::char_width;
20use std::fmt;
21
22//--------------------------------------------------------------------------------------------------
23// Types
24//--------------------------------------------------------------------------------------------------
25
26/// Represents a single cell in the terminal with its visual properties.
27///
28/// Each cell contains a character and its associated styling information.
29/// This granular representation allows for precise tracking of what has changed.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Cell {
32    /// The character displayed in this cell
33    pub char: char,
34
35    /// Foreground color (text color)
36    pub fg: Option<Color>,
37
38    /// Background color
39    pub bg: Option<Color>,
40
41    /// Additional styling attributes
42    pub style: CellStyle,
43}
44
45/// Style attributes that can be applied to a cell.
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub struct CellStyle {
48    /// Bold text
49    pub bold: bool,
50
51    /// Italic text
52    pub italic: bool,
53
54    /// Underlined text
55    pub underline: bool,
56
57    /// Strikethrough text
58    pub strikethrough: bool,
59}
60
61/// A buffer representing the entire terminal screen as a 2D grid of cells.
62///
63/// This buffer maintains a complete snapshot of what should be displayed
64/// on the terminal, allowing for efficient diffing between frames.
65pub struct ScreenBuffer {
66    /// 2D grid of cells [row ⨉ column]
67    cells: Vec<Vec<Cell>>,
68
69    /// Width in columns
70    width: u16,
71
72    /// Height in rows
73    height: u16,
74}
75
76/// Double buffer system for flicker-free rendering.
77///
78/// Maintains two buffers:
79/// - `front`: What's currently displayed on the terminal
80/// - `back`: What we're rendering for the next frame
81///
82/// After rendering to the back buffer and applying updates,
83/// the buffers are swapped.
84pub struct DoubleBuffer {
85    /// The buffer representing what's currently on screen
86    front: ScreenBuffer,
87
88    /// The buffer we're rendering to for the next frame
89    back: ScreenBuffer,
90}
91
92/// Represents an update to one or more cells.
93///
94/// Updates can be single cells or runs of consecutive cells
95/// with the same styling for efficiency.
96#[derive(Debug)]
97pub enum CellUpdate {
98    /// Update a single cell
99    Single { x: u16, y: u16, cell: Cell },
100
101    /// Update a run of cells with the same style
102    Run { x: u16, y: u16, cells: Vec<Cell> },
103}
104
105//--------------------------------------------------------------------------------------------------
106// Methods
107//--------------------------------------------------------------------------------------------------
108
109impl CellStyle {
110    /// Creates a CellStyle from a TextStyle, applying only the style attributes.
111    pub fn from_text_style(text_style: &TextStyle) -> Self {
112        Self {
113            bold: text_style.bold.unwrap_or(false),
114            italic: text_style.italic.unwrap_or(false),
115            underline: text_style.underline.unwrap_or(false),
116            strikethrough: text_style.strikethrough.unwrap_or(false),
117        }
118    }
119
120    /// Merges this CellStyle with another, taking the other's values where they differ from defaults.
121    pub fn merge_with(self, other: &CellStyle) -> Self {
122        Self {
123            bold: self.bold || other.bold,
124            italic: self.italic || other.italic,
125            underline: self.underline || other.underline,
126            strikethrough: self.strikethrough || other.strikethrough,
127        }
128    }
129}
130
131impl Cell {
132    /// Creates a new cell with default styling.
133    pub fn new(char: char) -> Self {
134        Self {
135            char,
136            fg: None,
137            bg: None,
138            style: CellStyle::default(),
139        }
140    }
141
142    /// Creates an empty cell (space with no styling).
143    pub fn empty() -> Self {
144        Self::new(' ')
145    }
146
147    /// Sets the foreground color.
148    pub fn with_fg(mut self, color: Color) -> Self {
149        self.fg = Some(color);
150        self
151    }
152
153    /// Sets the background color.
154    pub fn with_bg(mut self, color: Color) -> Self {
155        self.bg = Some(color);
156        self
157    }
158
159    /// Sets the style attributes.
160    pub fn with_style(mut self, style: CellStyle) -> Self {
161        self.style = style;
162        self
163    }
164}
165
166impl ScreenBuffer {
167    /// Creates a new screen buffer with the given dimensions.
168    ///
169    /// All cells are initialized as empty (spaces with no styling).
170    pub fn new(width: u16, height: u16) -> Self {
171        let cells = vec![vec![Cell::empty(); width as usize]; height as usize];
172        Self {
173            cells,
174            width,
175            height,
176        }
177    }
178
179    /// Gets a reference to the cell at the given position.
180    ///
181    /// Returns None if the position is out of bounds.
182    pub fn get_cell(&self, x: u16, y: u16) -> Option<&Cell> {
183        if x >= self.width || y >= self.height {
184            return None;
185        }
186        self.cells.get(y as usize)?.get(x as usize)
187    }
188
189    /// Gets a mutable reference to the cell at the given position.
190    ///
191    /// Returns None if the position is out of bounds.
192    pub fn get_cell_mut(&mut self, x: u16, y: u16) -> Option<&mut Cell> {
193        if x >= self.width || y >= self.height {
194            return None;
195        }
196        self.cells.get_mut(y as usize)?.get_mut(x as usize)
197    }
198
199    /// Sets the cell at the given position.
200    ///
201    /// Does nothing if the position is out of bounds.
202    pub fn set_cell(&mut self, x: u16, y: u16, cell: Cell) {
203        if let Some(target) = self.get_cell_mut(x, y) {
204            *target = cell;
205        }
206    }
207
208    /// Clears the buffer by setting all cells to empty.
209    pub fn clear(&mut self) {
210        for row in &mut self.cells {
211            for cell in row {
212                *cell = Cell::empty();
213            }
214        }
215    }
216
217    /// Resizes the buffer to new dimensions.
218    ///
219    /// If the new size is larger, new cells are filled with empty cells.
220    /// If the new size is smaller, cells are truncated.
221    pub fn resize(&mut self, width: u16, height: u16) {
222        let height_usize = height as usize;
223        let width_usize = width as usize;
224
225        // Resize height
226        self.cells
227            .resize(height_usize, vec![Cell::empty(); width_usize]);
228
229        // Resize width of each row
230        for row in &mut self.cells {
231            row.resize(width_usize, Cell::empty());
232        }
233
234        self.width = width;
235        self.height = height;
236    }
237
238    /// Gets the dimensions of the buffer.
239    pub fn dimensions(&self) -> (u16, u16) {
240        (self.width, self.height)
241    }
242
243    /// Fills a rectangular region with the given cell.
244    pub fn fill_rect(&mut self, x: u16, y: u16, width: u16, height: u16, cell: Cell) {
245        for dy in 0..height {
246            for dx in 0..width {
247                self.set_cell(x + dx, y + dy, cell.clone());
248            }
249        }
250    }
251
252    /// Writes a string starting at the given position.
253    ///
254    /// The string is written horizontally. If it extends beyond the buffer width,
255    /// it is truncated. Properly handles wide characters (CJK, emoji) that take 2 columns.
256    pub fn write_str(&mut self, x: u16, y: u16, text: &str, fg: Option<Color>, bg: Option<Color>) {
257        let mut current_x = x;
258
259        for ch in text.chars() {
260            let ch_width = char_width(ch);
261
262            // Check if character fits in remaining space
263            if current_x + ch_width as u16 > self.width {
264                break;
265            }
266
267            // Set the main cell
268            let mut cell = Cell::new(ch);
269            cell.fg = fg;
270            cell.bg = bg;
271            self.set_cell(current_x, y, cell);
272
273            // For wide characters, fill the next cell with a space
274            // This ensures proper rendering in terminals
275            if ch_width == 2 && current_x + 1 < self.width {
276                let mut space_cell = Cell::new(' ');
277                space_cell.fg = fg;
278                space_cell.bg = bg;
279                self.set_cell(current_x + 1, y, space_cell);
280            }
281
282            current_x += ch_width as u16;
283        }
284    }
285
286    /// Writes a string with full text styling starting at the given position.
287    ///
288    /// The string is written horizontally. If it extends beyond the buffer width,
289    /// it is truncated. Properly handles wide characters (CJK, emoji) that take 2 columns.
290    pub fn write_styled_str(&mut self, x: u16, y: u16, text: &str, text_style: Option<&TextStyle>) {
291        let (fg, bg, cell_style) = if let Some(style) = text_style {
292            (
293                style.color,
294                style.background,
295                CellStyle::from_text_style(style),
296            )
297        } else {
298            (None, None, CellStyle::default())
299        };
300
301        let mut current_x = x;
302
303        for ch in text.chars() {
304            let ch_width = char_width(ch);
305
306            // Check if character fits in remaining space
307            if current_x + ch_width as u16 > self.width {
308                break;
309            }
310
311            // Set the main cell
312            let mut cell = Cell::new(ch);
313            cell.fg = fg;
314            cell.bg = bg;
315            cell.style = cell_style.clone();
316            self.set_cell(current_x, y, cell);
317
318            // For wide characters, fill the next cell with a space
319            // This ensures proper rendering in terminals
320            if ch_width == 2 && current_x + 1 < self.width {
321                let mut space_cell = Cell::new(' ');
322                space_cell.fg = fg;
323                space_cell.bg = bg;
324                space_cell.style = cell_style.clone();
325                self.set_cell(current_x + 1, y, space_cell);
326            }
327
328            current_x += ch_width as u16;
329        }
330    }
331}
332
333impl DoubleBuffer {
334    /// Creates a new double buffer with the given dimensions.
335    pub fn new(width: u16, height: u16) -> Self {
336        Self {
337            front: ScreenBuffer::new(width, height),
338            back: ScreenBuffer::new(width, height),
339        }
340    }
341
342    /// Swaps the front and back buffers.
343    ///
344    /// After this operation:
345    /// - The back buffer becomes the front buffer (what's on screen)
346    /// - The front buffer becomes the back buffer (ready for next frame)
347    pub fn swap(&mut self) {
348        std::mem::swap(&mut self.front, &mut self.back);
349    }
350
351    /// Provides mutable access to the back buffer for rendering.
352    pub fn back_buffer_mut(&mut self) -> &mut ScreenBuffer {
353        &mut self.back
354    }
355
356    /// Resizes both buffers to the new dimensions.
357    pub fn resize(&mut self, width: u16, height: u16) {
358        self.front.resize(width, height);
359        self.back.resize(width, height);
360    }
361
362    /// Compares the front and back buffers and returns a list of cell updates.
363    ///
364    /// This is the core of the flicker-free rendering system. By comparing
365    /// buffers cell-by-cell, we can determine exactly what needs to be updated
366    /// on the terminal.
367    pub fn diff(&self) -> Vec<CellUpdate> {
368        let mut updates = Vec::new();
369        let (width, height) = self.front.dimensions();
370
371        for y in 0..height {
372            for x in 0..width {
373                let front_cell = self.front.get_cell(x, y);
374                let back_cell = self.back.get_cell(x, y);
375
376                match (front_cell, back_cell) {
377                    (Some(front), Some(back)) if front != back => {
378                        updates.push(CellUpdate::Single {
379                            x,
380                            y,
381                            cell: back.clone(),
382                        });
383                    }
384                    _ => {}
385                }
386            }
387        }
388
389        updates
390    }
391
392    /// Clears the back buffer.
393    pub fn clear_back(&mut self) {
394        self.back.clear();
395    }
396}
397
398impl fmt::Display for Cell {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        write!(f, "{}", self.char)
401    }
402}
403
404//--------------------------------------------------------------------------------------------------
405// Trait Implementations
406//--------------------------------------------------------------------------------------------------
407
408impl Default for Cell {
409    fn default() -> Self {
410        Self::empty()
411    }
412}
413
414//--------------------------------------------------------------------------------------------------
415// Tests
416//--------------------------------------------------------------------------------------------------
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_double_buffer_diff_empty() {
424        let db = DoubleBuffer::new(10, 5);
425        let updates = db.diff();
426        // Initially both buffers are empty, so no updates
427        assert!(updates.is_empty());
428    }
429
430    #[test]
431    fn test_double_buffer_diff_single_change() {
432        let mut db = DoubleBuffer::new(10, 5);
433
434        // First set both buffers to have the same content
435        db.back_buffer_mut().set_cell(2, 1, Cell::new('A'));
436        db.swap();
437
438        // Copy front to back to start with identical buffers
439        db.back_buffer_mut().set_cell(2, 1, Cell::new('A'));
440
441        // Now modify just one cell in back buffer
442        db.back_buffer_mut()
443            .set_cell(2, 1, Cell::new('H').with_fg(Color::Red));
444
445        // Should detect one update
446        let updates = db.diff();
447        assert_eq!(updates.len(), 1);
448
449        // Swap buffers
450        db.swap();
451
452        // The key insight: after swap, if we render the exact same content
453        // to the back buffer, there should be no updates!
454        db.back_buffer_mut()
455            .set_cell(2, 1, Cell::new('H').with_fg(Color::Red));
456
457        let updates = db.diff();
458        assert_eq!(updates.len(), 0); // No changes!
459    }
460
461    #[test]
462    fn test_screen_buffer_write_str() {
463        let mut buffer = ScreenBuffer::new(20, 5);
464        buffer.write_str(2, 1, "Hello", Some(Color::Green), Some(Color::Black));
465
466        assert_eq!(buffer.get_cell(2, 1).unwrap().char, 'H');
467        assert_eq!(buffer.get_cell(3, 1).unwrap().char, 'e');
468        assert_eq!(buffer.get_cell(6, 1).unwrap().char, 'o');
469        assert_eq!(buffer.get_cell(2, 1).unwrap().fg, Some(Color::Green));
470        assert_eq!(buffer.get_cell(2, 1).unwrap().bg, Some(Color::Black));
471    }
472
473    #[test]
474    fn test_no_flicker_scenario() {
475        let mut db = DoubleBuffer::new(20, 5);
476
477        // Initial render: "Hello World" with blue background
478        for i in 0..11 {
479            db.back_buffer_mut()
480                .set_cell(i, 0, Cell::new(' ').with_bg(Color::Blue));
481        }
482        db.back_buffer_mut()
483            .write_str(0, 0, "Hello World", Some(Color::White), Some(Color::Blue));
484        let updates1 = db.diff();
485        assert_eq!(updates1.len(), 11); // 11 characters changed from empty
486        db.swap();
487
488        // Clear back buffer to simulate the app's behavior
489        db.clear_back();
490
491        // Write new content with same background
492        for i in 0..12 {
493            db.back_buffer_mut()
494                .set_cell(i, 0, Cell::new(' ').with_bg(Color::Blue));
495        }
496        db.back_buffer_mut()
497            .write_str(0, 0, "Hello Rust!", Some(Color::White), Some(Color::Blue));
498
499        let updates2 = db.diff();
500
501        // Even though we cleared and rewrote everything, the double buffer
502        // system ensures only actual changes are sent to terminal
503        // This eliminates flicker because terminal never sees the "cleared" state
504
505        // Count actual changes:
506        // - "Hello World" and "Hello Rust!" both have 11 characters
507        // - But we set 12 cells with blue background (0..12)
508        // - Position 11 is an extra blue background space
509        let mut actual_changes = 0;
510        for update in &updates2 {
511            match update {
512                CellUpdate::Single { .. } => actual_changes += 1,
513                CellUpdate::Run { cells, .. } => actual_changes += cells.len(),
514            }
515        }
516
517        // Changes:
518        // - Positions 6-10: "World" → "Rust!" (5 changes)
519        // - Position 11: empty → blue background space (1 change)
520        // Total: 6 changes
521        assert!(actual_changes == 6);
522    }
523}