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}