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}