Skip to main content

presentar_terminal/direct/
diff_renderer.rs

1//! Differential renderer for optimized terminal I/O.
2//!
3//! Minimizes terminal escape sequences and syscalls by:
4//! - Only rendering dirty cells
5//! - Batching output to a buffer
6//! - Skipping redundant cursor moves
7//! - Caching current style state
8
9use super::cell_buffer::{CellBuffer, Modifiers};
10use crate::color::ColorMode;
11use crossterm::cursor::MoveTo;
12use crossterm::style::{
13    Attribute, Color as CrosstermColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
14    SetForegroundColor,
15};
16use crossterm::{queue, QueueableCommand};
17use presentar_core::Color;
18use std::io::{self, BufWriter, Write};
19
20/// Current terminal style state.
21#[derive(Clone, Copy, Debug, PartialEq)]
22struct StyleState {
23    fg: Color,
24    bg: Color,
25    modifiers: Modifiers,
26}
27
28impl Default for StyleState {
29    fn default() -> Self {
30        Self {
31            fg: Color::WHITE,
32            bg: Color::BLACK,
33            modifiers: Modifiers::NONE,
34        }
35    }
36}
37
38/// Differential renderer that minimizes terminal I/O.
39///
40/// Tracks the current cursor position and style state to avoid
41/// redundant escape sequences.
42#[derive(Debug)]
43pub struct DiffRenderer {
44    /// Color mode for conversion.
45    color_mode: ColorMode,
46    /// Last known cursor position (`u16::MAX` = unknown).
47    cursor_x: u16,
48    cursor_y: u16,
49    /// Last known style state.
50    last_style: StyleState,
51    /// Statistics: number of cells written.
52    cells_written: usize,
53    /// Statistics: number of cursor moves.
54    cursor_moves: usize,
55    /// Statistics: number of style changes.
56    style_changes: usize,
57}
58
59impl Default for DiffRenderer {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl DiffRenderer {
66    /// Create a new renderer.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            color_mode: ColorMode::detect(),
71            cursor_x: u16::MAX,
72            cursor_y: u16::MAX,
73            last_style: StyleState::default(),
74            cells_written: 0,
75            cursor_moves: 0,
76            style_changes: 0,
77        }
78    }
79
80    /// Create a renderer with specific color mode.
81    #[must_use]
82    pub fn with_color_mode(color_mode: ColorMode) -> Self {
83        Self {
84            color_mode,
85            cursor_x: u16::MAX,
86            cursor_y: u16::MAX,
87            last_style: StyleState::default(),
88            cells_written: 0,
89            cursor_moves: 0,
90            style_changes: 0,
91        }
92    }
93
94    /// Set the color mode.
95    pub fn set_color_mode(&mut self, mode: ColorMode) {
96        self.color_mode = mode;
97    }
98
99    /// Get the color mode.
100    #[must_use]
101    pub const fn color_mode(&self) -> ColorMode {
102        self.color_mode
103    }
104
105    /// Reset renderer state (call after terminal resize or clear).
106    pub fn reset(&mut self) {
107        self.cursor_x = u16::MAX;
108        self.cursor_y = u16::MAX;
109        self.last_style = StyleState::default();
110        self.cells_written = 0;
111        self.cursor_moves = 0;
112        self.style_changes = 0;
113    }
114
115    /// Get cells written in last flush.
116    #[must_use]
117    pub const fn cells_written(&self) -> usize {
118        self.cells_written
119    }
120
121    /// Get cursor moves in last flush.
122    #[must_use]
123    pub const fn cursor_moves(&self) -> usize {
124        self.cursor_moves
125    }
126
127    /// Get style changes in last flush.
128    #[must_use]
129    pub const fn style_changes(&self) -> usize {
130        self.style_changes
131    }
132
133    /// Convert presentar Color to crossterm Color.
134    fn to_crossterm_color(&self, color: Color) -> CrosstermColor {
135        self.color_mode.to_crossterm(color)
136    }
137
138    /// Flush dirty cells to the writer.
139    ///
140    /// Returns the number of cells written.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if writing to the writer fails.
145    pub fn flush<W: Write>(
146        &mut self,
147        buffer: &mut CellBuffer,
148        writer: &mut W,
149    ) -> io::Result<usize> {
150        debug_assert!(buffer.width() > 0, "buffer width must be positive");
151        debug_assert!(buffer.height() > 0, "buffer height must be positive");
152
153        // Reset statistics
154        self.cells_written = 0;
155        self.cursor_moves = 0;
156        self.style_changes = 0;
157
158        // Use buffered writer to batch syscalls
159        let mut buf_writer = BufWriter::with_capacity(8192, writer);
160
161        // Reset colors at start for clean state
162        queue!(buf_writer, ResetColor)?;
163        self.last_style = StyleState::default();
164
165        let width = buffer.width();
166
167        for idx in buffer.iter_dirty() {
168            let (x, y) = buffer.coords(idx);
169            let cell = &buffer.cells()[idx];
170
171            // Skip continuation cells
172            if cell.is_continuation() {
173                continue;
174            }
175
176            // Move cursor if needed
177            if self.cursor_x != x || self.cursor_y != y {
178                queue!(buf_writer, MoveTo(x, y))?;
179                self.cursor_x = x;
180                self.cursor_y = y;
181                self.cursor_moves += 1;
182            }
183
184            // Update style if needed
185            let new_style = StyleState {
186                fg: cell.fg,
187                bg: cell.bg,
188                modifiers: cell.modifiers,
189            };
190
191            if new_style != self.last_style {
192                self.apply_style(&mut buf_writer, new_style)?;
193                self.last_style = new_style;
194                self.style_changes += 1;
195            }
196
197            // Print symbol
198            queue!(buf_writer, Print(&cell.symbol))?;
199
200            // Update cursor position
201            self.cursor_x = self.cursor_x.saturating_add(cell.width() as u16);
202            if self.cursor_x >= width {
203                self.cursor_x = u16::MAX; // Unknown after wrap
204            }
205
206            self.cells_written += 1;
207        }
208
209        // Clear dirty flags
210        buffer.clear_dirty();
211
212        // Final flush
213        buf_writer.flush()?;
214
215        Ok(self.cells_written)
216    }
217
218    /// Apply style changes to the writer.
219    fn apply_style<W: Write>(&self, writer: &mut W, style: StyleState) -> io::Result<()> {
220        // Reset attributes FIRST (before setting colors!)
221        writer.queue(SetAttribute(Attribute::Reset))?;
222
223        // Foreground color
224        let fg = self.to_crossterm_color(style.fg);
225        writer.queue(SetForegroundColor(fg))?;
226
227        // Background color
228        let bg = self.to_crossterm_color(style.bg);
229        writer.queue(SetBackgroundColor(bg))?;
230
231        // Apply modifiers
232        if style.modifiers.contains(Modifiers::BOLD) {
233            writer.queue(SetAttribute(Attribute::Bold))?;
234        }
235        if style.modifiers.contains(Modifiers::ITALIC) {
236            writer.queue(SetAttribute(Attribute::Italic))?;
237        }
238        if style.modifiers.contains(Modifiers::UNDERLINE) {
239            writer.queue(SetAttribute(Attribute::Underlined))?;
240        }
241        if style.modifiers.contains(Modifiers::STRIKETHROUGH) {
242            writer.queue(SetAttribute(Attribute::CrossedOut))?;
243        }
244        if style.modifiers.contains(Modifiers::DIM) {
245            writer.queue(SetAttribute(Attribute::Dim))?;
246        }
247        if style.modifiers.contains(Modifiers::BLINK) {
248            writer.queue(SetAttribute(Attribute::SlowBlink))?;
249        }
250        if style.modifiers.contains(Modifiers::REVERSE) {
251            writer.queue(SetAttribute(Attribute::Reverse))?;
252        }
253        if style.modifiers.contains(Modifiers::HIDDEN) {
254            writer.queue(SetAttribute(Attribute::Hidden))?;
255        }
256
257        Ok(())
258    }
259
260    /// Render a full frame (marks all dirty then flushes).
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if writing fails.
265    pub fn render_full<W: Write>(
266        &mut self,
267        buffer: &mut CellBuffer,
268        writer: &mut W,
269    ) -> io::Result<usize> {
270        contract_pre_render!();
271        buffer.mark_all_dirty();
272        self.flush(buffer, writer)
273    }
274}
275
276#[cfg(test)]
277#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_renderer_creation() {
283        let renderer = DiffRenderer::new();
284        assert_eq!(renderer.cursor_x, u16::MAX);
285        assert_eq!(renderer.cursor_y, u16::MAX);
286    }
287
288    #[test]
289    fn test_renderer_with_color_mode() {
290        let renderer = DiffRenderer::with_color_mode(ColorMode::Color256);
291        assert_eq!(renderer.color_mode(), ColorMode::Color256);
292    }
293
294    #[test]
295    fn test_renderer_set_color_mode() {
296        let mut renderer = DiffRenderer::new();
297        renderer.set_color_mode(ColorMode::Color16);
298        assert_eq!(renderer.color_mode(), ColorMode::Color16);
299    }
300
301    #[test]
302    fn test_renderer_reset() {
303        let mut renderer = DiffRenderer::new();
304        renderer.cursor_x = 10;
305        renderer.cursor_y = 5;
306        renderer.cells_written = 100;
307
308        renderer.reset();
309
310        assert_eq!(renderer.cursor_x, u16::MAX);
311        assert_eq!(renderer.cursor_y, u16::MAX);
312        assert_eq!(renderer.cells_written(), 0);
313    }
314
315    #[test]
316    fn test_renderer_flush_empty() {
317        let mut renderer = DiffRenderer::new();
318        let mut buffer = CellBuffer::new(10, 5);
319        let mut output = Vec::new();
320
321        let count = renderer.flush(&mut buffer, &mut output).unwrap();
322        assert_eq!(count, 0);
323    }
324
325    #[test]
326    #[allow(clippy::len_zero)]
327    fn test_renderer_flush_dirty_cells() {
328        let mut renderer = DiffRenderer::new();
329        let mut buffer = CellBuffer::new(10, 5);
330        buffer.update(5, 2, "X", Color::RED, Color::BLACK, Modifiers::NONE);
331        let mut output = Vec::new();
332
333        let count = renderer.flush(&mut buffer, &mut output).unwrap();
334        assert_eq!(count, 1);
335        assert!(output.len() > 0);
336    }
337
338    #[test]
339    fn test_renderer_flush_multiple_dirty() {
340        let mut renderer = DiffRenderer::new();
341        let mut buffer = CellBuffer::new(10, 5);
342        buffer.update(0, 0, "A", Color::WHITE, Color::BLACK, Modifiers::NONE);
343        buffer.update(5, 2, "B", Color::WHITE, Color::BLACK, Modifiers::NONE);
344        buffer.update(9, 4, "C", Color::WHITE, Color::BLACK, Modifiers::NONE);
345        let mut output = Vec::new();
346
347        let count = renderer.flush(&mut buffer, &mut output).unwrap();
348        assert_eq!(count, 3);
349        assert_eq!(renderer.cursor_moves(), 3);
350    }
351
352    #[test]
353    fn test_renderer_flush_adjacent_cells() {
354        let mut renderer = DiffRenderer::new();
355        let mut buffer = CellBuffer::new(10, 5);
356        // Adjacent cells should minimize cursor moves
357        buffer.update(0, 0, "A", Color::WHITE, Color::BLACK, Modifiers::NONE);
358        buffer.update(1, 0, "B", Color::WHITE, Color::BLACK, Modifiers::NONE);
359        buffer.update(2, 0, "C", Color::WHITE, Color::BLACK, Modifiers::NONE);
360        let mut output = Vec::new();
361
362        let count = renderer.flush(&mut buffer, &mut output).unwrap();
363        assert_eq!(count, 3);
364        // Should only need one cursor move (to start)
365        assert_eq!(renderer.cursor_moves(), 1);
366    }
367
368    #[test]
369    fn test_renderer_style_changes() {
370        let mut renderer = DiffRenderer::new();
371        let mut buffer = CellBuffer::new(10, 5);
372        buffer.update(0, 0, "A", Color::RED, Color::BLACK, Modifiers::NONE);
373        buffer.update(1, 0, "B", Color::BLUE, Color::BLACK, Modifiers::NONE);
374        let mut output = Vec::new();
375
376        renderer.flush(&mut buffer, &mut output).unwrap();
377        assert_eq!(renderer.style_changes(), 2);
378    }
379
380    #[test]
381    fn test_renderer_same_style_no_change() {
382        let mut renderer = DiffRenderer::new();
383        let mut buffer = CellBuffer::new(10, 5);
384        // Use non-default colors to force a style change on first cell
385        buffer.update(0, 0, "A", Color::RED, Color::BLUE, Modifiers::NONE);
386        buffer.update(1, 0, "B", Color::RED, Color::BLUE, Modifiers::NONE);
387        let mut output = Vec::new();
388
389        renderer.flush(&mut buffer, &mut output).unwrap();
390        // First cell triggers style change, second cell has same style = 1 total
391        assert_eq!(renderer.style_changes(), 1);
392    }
393
394    #[test]
395    fn test_renderer_with_modifiers() {
396        let mut renderer = DiffRenderer::new();
397        let mut buffer = CellBuffer::new(10, 5);
398        buffer.update(
399            0,
400            0,
401            "X",
402            Color::WHITE,
403            Color::BLACK,
404            Modifiers::BOLD | Modifiers::ITALIC,
405        );
406        let mut output = Vec::new();
407
408        let count = renderer.flush(&mut buffer, &mut output).unwrap();
409        assert_eq!(count, 1);
410        // Output should contain attribute sequences
411        assert!(output.len() > 5);
412    }
413
414    #[test]
415    fn test_renderer_all_modifiers() {
416        let mut renderer = DiffRenderer::new();
417        let mut buffer = CellBuffer::new(10, 5);
418        let all_mods = Modifiers::BOLD
419            | Modifiers::ITALIC
420            | Modifiers::UNDERLINE
421            | Modifiers::STRIKETHROUGH
422            | Modifiers::DIM
423            | Modifiers::BLINK
424            | Modifiers::REVERSE
425            | Modifiers::HIDDEN;
426        buffer.update(0, 0, "X", Color::WHITE, Color::BLACK, all_mods);
427        let mut output = Vec::new();
428
429        renderer.flush(&mut buffer, &mut output).unwrap();
430        assert!(output.len() > 10);
431    }
432
433    #[test]
434    fn test_renderer_render_full() {
435        let mut renderer = DiffRenderer::new();
436        let mut buffer = CellBuffer::new(10, 5);
437        buffer.clear_dirty();
438
439        let mut output = Vec::new();
440        let count = renderer.render_full(&mut buffer, &mut output).unwrap();
441
442        // All 50 cells should be rendered
443        assert_eq!(count, 50);
444    }
445
446    #[test]
447    fn test_renderer_skip_continuation() {
448        let mut renderer = DiffRenderer::new();
449        let mut buffer = CellBuffer::new(10, 5);
450
451        // Set a wide character
452        buffer.update(0, 0, "日", Color::WHITE, Color::BLACK, Modifiers::NONE);
453        // Mark continuation
454        if let Some(cell) = buffer.get_mut(1, 0) {
455            cell.make_continuation();
456        }
457        buffer.mark_dirty(1, 0);
458
459        let mut output = Vec::new();
460        let count = renderer.flush(&mut buffer, &mut output).unwrap();
461
462        // Only the main cell should be written
463        assert_eq!(count, 1);
464    }
465
466    #[test]
467    fn test_renderer_cursor_wrap() {
468        let mut renderer = DiffRenderer::new();
469        let mut buffer = CellBuffer::new(5, 2);
470        buffer.update(4, 0, "X", Color::WHITE, Color::BLACK, Modifiers::NONE);
471        let mut output = Vec::new();
472
473        renderer.flush(&mut buffer, &mut output).unwrap();
474        // After writing at x=4, cursor should wrap/be unknown
475        assert_eq!(renderer.cursor_x, u16::MAX);
476    }
477
478    #[test]
479    fn test_renderer_statistics() {
480        let mut renderer = DiffRenderer::new();
481        let mut buffer = CellBuffer::new(10, 6);
482        buffer.update(0, 0, "A", Color::RED, Color::BLACK, Modifiers::NONE);
483        buffer.update(5, 5, "B", Color::BLUE, Color::WHITE, Modifiers::BOLD);
484        let mut output = Vec::new();
485
486        renderer.flush(&mut buffer, &mut output).unwrap();
487
488        assert_eq!(renderer.cells_written(), 2);
489        assert!(renderer.cursor_moves() >= 2);
490        assert!(renderer.style_changes() >= 2);
491    }
492
493    #[test]
494    fn test_renderer_default() {
495        let renderer = DiffRenderer::default();
496        assert_eq!(renderer.cursor_x, u16::MAX);
497    }
498
499    #[test]
500    fn test_style_state_default() {
501        let state = StyleState::default();
502        assert_eq!(state.fg, Color::WHITE);
503        assert_eq!(state.bg, Color::BLACK);
504        assert!(state.modifiers.is_empty());
505    }
506
507    #[test]
508    fn test_style_state_equality() {
509        let s1 = StyleState::default();
510        let s2 = StyleState::default();
511        assert_eq!(s1, s2);
512
513        let s3 = StyleState {
514            fg: Color::RED,
515            ..Default::default()
516        };
517        assert_ne!(s1, s3);
518    }
519}