dotmax/animation/
differential.rs

1//! Differential rendering for animations.
2//!
3//! This module provides optimized rendering that outputs only changed cells between frames,
4//! reducing terminal I/O by 60-80% for typical animations with small moving objects on
5//! static backgrounds.
6//!
7//! # Overview
8//!
9//! Traditional animation rendering redraws every cell every frame, which can be expensive
10//! for terminal I/O (1920 escape codes for an 80x24 terminal). Differential rendering
11//! compares each frame to the previous frame and only outputs cells that have changed,
12//! dramatically reducing I/O overhead.
13//!
14//! # Performance
15//!
16//! For a typical animation with 5% of cells changing per frame:
17//! - **Full render**: 80×24 = 1920 cells → 1920 escape codes
18//! - **Differential**: ~96 cells → ~96 escape codes
19//! - **I/O reduction**: ~95% (exceeds the 60-80% target)
20//!
21//! # Example
22//!
23//! ```no_run
24//! use dotmax::animation::DifferentialRenderer;
25//! use dotmax::{BrailleGrid, TerminalRenderer};
26//!
27//! # fn main() -> Result<(), dotmax::DotmaxError> {
28//! let mut diff_renderer = DifferentialRenderer::new();
29//! let mut terminal = TerminalRenderer::new()?;
30//!
31//! // First frame renders fully (no previous frame to compare)
32//! let frame1 = BrailleGrid::new(80, 24)?;
33//! diff_renderer.render_diff(&frame1, &mut terminal)?;
34//!
35//! // Subsequent frames render only changes
36//! let mut frame2 = BrailleGrid::new(80, 24)?;
37//! frame2.set_dot(10, 10)?;  // Only one cell changed
38//! diff_renderer.render_diff(&frame2, &mut terminal)?;  // Only outputs that one cell!
39//!
40//! // Force full render after resize
41//! diff_renderer.invalidate();
42//! diff_renderer.render_diff(&frame2, &mut terminal)?;  // Full render again
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! # When to Use Differential Rendering
48//!
49//! Differential rendering is most effective when:
50//! - Animations have static backgrounds with small moving elements
51//! - High frame rates are needed (30-60+ fps)
52//! - Terminal I/O bandwidth is a concern
53//!
54//! It's less beneficial when:
55//! - Most of the screen changes every frame (e.g., full-screen scrolling)
56//! - Frame rate is already low (< 10 fps)
57
58use crate::error::DotmaxError;
59use crate::grid::BrailleGrid;
60use crate::render::TerminalRenderer;
61use crossterm::{cursor::MoveTo, QueueableCommand};
62use std::io::Write;
63use tracing::debug;
64
65/// Optimized renderer that only outputs changed cells.
66///
67/// `DifferentialRenderer` compares the current frame to the previous frame
68/// and renders only the cells that have changed. For typical animations with
69/// small moving objects on static backgrounds, this reduces terminal I/O
70/// by 60-80% or more.
71///
72/// # Performance
73///
74/// - Full render at 80×24: 1920 cells, ~1920 escape codes
75/// - Differential with 5% changes: ~96 cells, ~96 escape codes
76/// - Typical I/O reduction: 60-95%
77///
78/// # Example
79///
80/// ```no_run
81/// use dotmax::animation::DifferentialRenderer;
82/// use dotmax::{BrailleGrid, TerminalRenderer};
83///
84/// # fn main() -> Result<(), dotmax::DotmaxError> {
85/// let mut diff_renderer = DifferentialRenderer::new();
86/// let mut terminal = TerminalRenderer::new()?;
87///
88/// // First frame renders fully (no previous frame)
89/// let frame1 = BrailleGrid::new(80, 24)?;
90/// diff_renderer.render_diff(&frame1, &mut terminal)?;
91///
92/// // Subsequent frames render only changes
93/// let mut frame2 = BrailleGrid::new(80, 24)?;
94/// frame2.set_dot(10, 10)?;  // One changed cell
95/// diff_renderer.render_diff(&frame2, &mut terminal)?;  // Only outputs one cell!
96/// # Ok(())
97/// # }
98/// ```
99#[derive(Debug)]
100pub struct DifferentialRenderer {
101    /// The last frame rendered, used for comparison.
102    /// `None` if no frame has been rendered yet or after `invalidate()`.
103    last_frame: Option<BrailleGrid>,
104}
105
106impl DifferentialRenderer {
107    /// Creates a new differential renderer.
108    ///
109    /// The first call to [`render_diff()`](Self::render_diff) will render the full frame
110    /// since there's no previous frame to compare against.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use dotmax::animation::DifferentialRenderer;
116    ///
117    /// let renderer = DifferentialRenderer::new();
118    /// // First render_diff() call will render the entire frame
119    /// ```
120    #[must_use]
121    pub const fn new() -> Self {
122        Self { last_frame: None }
123    }
124
125    /// Renders only the cells that changed since the last frame.
126    ///
127    /// Compares `current` to the stored previous frame and outputs
128    /// only the changed cells using ANSI cursor positioning.
129    ///
130    /// # Behavior
131    ///
132    /// - **First call**: Renders entire grid (no previous frame)
133    /// - **Dimension mismatch**: Renders entire grid (auto-invalidates)
134    /// - **Normal operation**: Renders only changed cells
135    ///
136    /// # Arguments
137    ///
138    /// * `current` - The current frame to render
139    /// * `renderer` - The terminal renderer for full-frame fallback
140    ///
141    /// # Returns
142    ///
143    /// * `Ok(())` - Render completed successfully
144    /// * `Err(DotmaxError)` - Terminal I/O error
145    ///
146    /// # Errors
147    ///
148    /// Returns `DotmaxError::Terminal` if terminal I/O operations fail
149    /// (cursor positioning or character output).
150    ///
151    /// # Examples
152    ///
153    /// ```no_run
154    /// use dotmax::animation::DifferentialRenderer;
155    /// use dotmax::{BrailleGrid, TerminalRenderer};
156    ///
157    /// # fn main() -> Result<(), dotmax::DotmaxError> {
158    /// let mut diff = DifferentialRenderer::new();
159    /// let mut terminal = TerminalRenderer::new()?;
160    ///
161    /// let frame = BrailleGrid::new(80, 24)?;
162    /// diff.render_diff(&frame, &mut terminal)?;
163    /// # Ok(())
164    /// # }
165    /// ```
166    pub fn render_diff(
167        &mut self,
168        current: &BrailleGrid,
169        renderer: &mut TerminalRenderer,
170    ) -> Result<(), DotmaxError> {
171        // Check for dimension mismatch or no previous frame
172        let should_full_render = self
173            .last_frame
174            .as_ref()
175            .map_or(true, |last| {
176                if last.width() != current.width() || last.height() != current.height() {
177                    debug!(
178                        old_width = last.width(),
179                        old_height = last.height(),
180                        new_width = current.width(),
181                        new_height = current.height(),
182                        "Dimension mismatch - performing full frame render"
183                    );
184                    true
185                } else {
186                    false
187                }
188            });
189
190        // Get reference to last frame for comparison, or render full if None
191        let last = match (should_full_render, &self.last_frame) {
192            (true, _) | (_, None) => {
193                debug!("First render or dimension change - performing full frame render");
194                // Full render using the terminal renderer
195                renderer.render(current)?;
196                self.last_frame = Some(current.clone());
197                return Ok(());
198            }
199            (false, Some(last)) => last,
200        };
201
202        // Differential render
203        let mut stdout = std::io::stdout();
204        let mut changed_count = 0;
205
206        for y in 0..current.height() {
207            for x in 0..current.width() {
208                if Self::cells_differ(current, last, x, y) {
209                    // Move cursor to position
210                    // Safe to truncate: terminal dimensions fit in u16
211                    #[allow(clippy::cast_possible_truncation)]
212                    stdout.queue(MoveTo(x as u16, y as u16))?;
213
214                    // Get the character to render
215                    let ch = current.get_char(x, y);
216
217                    // Apply color if present
218                    if let Some(color) = current.get_color(x, y) {
219                        write!(
220                            stdout,
221                            "\x1b[38;2;{};{};{}m{}\x1b[0m",
222                            color.r, color.g, color.b, ch
223                        )?;
224                    } else {
225                        write!(stdout, "{ch}")?;
226                    }
227                    changed_count += 1;
228                }
229            }
230        }
231
232        stdout.flush()?;
233        debug!(changed_cells = changed_count, "Differential render complete");
234        self.last_frame = Some(current.clone());
235        Ok(())
236    }
237
238    /// Forces a full render on the next [`render_diff()`](Self::render_diff) call.
239    ///
240    /// Use this after terminal resize, mode changes, or when the entire screen
241    /// needs to be refreshed.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use dotmax::animation::DifferentialRenderer;
247    ///
248    /// let mut renderer = DifferentialRenderer::new();
249    /// // ... after some renders ...
250    /// renderer.invalidate();  // Next render_diff() will render full frame
251    /// ```
252    pub fn invalidate(&mut self) {
253        debug!("Invalidating differential renderer - next render will be full");
254        self.last_frame = None;
255    }
256
257    /// Compares cells at (x, y) between current and last frames.
258    ///
259    /// Returns `true` if the cells differ (dots or colors).
260    fn cells_differ(current: &BrailleGrid, last: &BrailleGrid, x: usize, y: usize) -> bool {
261        // Compare dot patterns (using raw access for efficiency)
262        let current_patterns = current.get_raw_patterns();
263        let last_patterns = last.get_raw_patterns();
264        let index = y * current.width() + x;
265
266        if current_patterns[index] != last_patterns[index] {
267            return true;
268        }
269
270        // Compare colors
271        if current.get_color(x, y) != last.get_color(x, y) {
272            return true;
273        }
274
275        false
276    }
277
278    /// Returns the number of cells that would change between two frames.
279    ///
280    /// This is useful for benchmarking and debugging to understand
281    /// how much I/O reduction differential rendering provides.
282    ///
283    /// # Arguments
284    ///
285    /// * `current` - The current frame
286    /// * `previous` - The previous frame to compare against
287    ///
288    /// # Returns
289    ///
290    /// The number of cells that differ between the two frames.
291    ///
292    /// # Examples
293    ///
294    /// ```
295    /// use dotmax::animation::DifferentialRenderer;
296    /// use dotmax::BrailleGrid;
297    ///
298    /// let renderer = DifferentialRenderer::new();
299    ///
300    /// let mut frame1 = BrailleGrid::new(80, 24).unwrap();
301    /// let mut frame2 = BrailleGrid::new(80, 24).unwrap();
302    /// frame2.set_dot(10, 10).unwrap();
303    ///
304    /// let changed = renderer.count_changed_cells(&frame2, &frame1);
305    /// assert_eq!(changed, 1);  // Only one cell changed
306    /// ```
307    #[must_use]
308    pub fn count_changed_cells(&self, current: &BrailleGrid, previous: &BrailleGrid) -> usize {
309        if current.width() != previous.width() || current.height() != previous.height() {
310            // Different dimensions = all cells changed
311            return current.width() * current.height();
312        }
313
314        let mut count = 0;
315        for y in 0..current.height() {
316            for x in 0..current.width() {
317                if Self::cells_differ(current, previous, x, y) {
318                    count += 1;
319                }
320            }
321        }
322        count
323    }
324
325    /// Returns whether the renderer has a cached previous frame.
326    ///
327    /// # Returns
328    ///
329    /// * `true` - A previous frame is cached (differential rendering enabled)
330    /// * `false` - No previous frame (next render will be full)
331    ///
332    /// # Examples
333    ///
334    /// ```
335    /// use dotmax::animation::DifferentialRenderer;
336    ///
337    /// let renderer = DifferentialRenderer::new();
338    /// assert!(!renderer.has_previous_frame());
339    /// ```
340    #[must_use]
341    pub const fn has_previous_frame(&self) -> bool {
342        self.last_frame.is_some()
343    }
344}
345
346impl Default for DifferentialRenderer {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352impl Clone for DifferentialRenderer {
353    fn clone(&self) -> Self {
354        Self {
355            last_frame: self.last_frame.clone(),
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_new_creates_renderer_with_no_last_frame() {
366        let renderer = DifferentialRenderer::new();
367        assert!(!renderer.has_previous_frame());
368    }
369
370    #[test]
371    fn test_default_same_as_new() {
372        let renderer1 = DifferentialRenderer::new();
373        let renderer2 = DifferentialRenderer::default();
374        assert!(!renderer1.has_previous_frame());
375        assert!(!renderer2.has_previous_frame());
376    }
377
378    #[test]
379    fn test_invalidate_clears_last_frame() {
380        let mut renderer = DifferentialRenderer::new();
381        // Simulate having a last frame by creating and cloning a grid
382        renderer.last_frame = Some(BrailleGrid::new(10, 10).unwrap());
383        assert!(renderer.has_previous_frame());
384
385        renderer.invalidate();
386        assert!(!renderer.has_previous_frame());
387    }
388
389    #[test]
390    fn test_count_changed_cells_identical_frames() {
391        let renderer = DifferentialRenderer::new();
392        let frame1 = BrailleGrid::new(10, 10).unwrap();
393        let frame2 = BrailleGrid::new(10, 10).unwrap();
394
395        let changed = renderer.count_changed_cells(&frame1, &frame2);
396        assert_eq!(changed, 0);
397    }
398
399    #[test]
400    fn test_count_changed_cells_single_change() {
401        let renderer = DifferentialRenderer::new();
402        let frame1 = BrailleGrid::new(10, 10).unwrap();
403        let mut frame2 = BrailleGrid::new(10, 10).unwrap();
404        frame2.set_dot(0, 0).unwrap();
405
406        let changed = renderer.count_changed_cells(&frame2, &frame1);
407        assert_eq!(changed, 1);
408    }
409
410    #[test]
411    fn test_count_changed_cells_dimension_mismatch() {
412        let renderer = DifferentialRenderer::new();
413        let frame1 = BrailleGrid::new(10, 10).unwrap();
414        let frame2 = BrailleGrid::new(20, 20).unwrap();
415
416        let changed = renderer.count_changed_cells(&frame2, &frame1);
417        // Different dimensions = all cells count as changed
418        assert_eq!(changed, 400); // 20 * 20
419    }
420
421    #[test]
422    fn test_cells_differ_detects_dot_change() {
423        let frame1 = BrailleGrid::new(10, 10).unwrap();
424        let mut frame2 = BrailleGrid::new(10, 10).unwrap();
425        frame2.set_dot(0, 0).unwrap(); // Set dot in cell (0, 0)
426
427        assert!(DifferentialRenderer::cells_differ(&frame2, &frame1, 0, 0));
428        assert!(!DifferentialRenderer::cells_differ(&frame2, &frame1, 1, 1));
429    }
430
431    #[test]
432    fn test_cells_differ_detects_color_change() {
433        use crate::grid::Color;
434
435        let mut frame1 = BrailleGrid::new(10, 10).unwrap();
436        let mut frame2 = BrailleGrid::new(10, 10).unwrap();
437
438        // Set same dots but different colors
439        frame1.set_dot(0, 0).unwrap();
440        frame2.set_dot(0, 0).unwrap();
441        frame2.set_cell_color(0, 0, Color::rgb(255, 0, 0)).unwrap();
442
443        assert!(DifferentialRenderer::cells_differ(&frame2, &frame1, 0, 0));
444    }
445
446    #[test]
447    fn test_clone() {
448        let mut renderer = DifferentialRenderer::new();
449        renderer.last_frame = Some(BrailleGrid::new(10, 10).unwrap());
450
451        let cloned = renderer.clone();
452        assert!(cloned.has_previous_frame());
453    }
454}