Skip to main content

flywheel/buffer/
diff.rs

1//! Diffing Engine: Generate minimal ANSI sequences from buffer changes.
2//!
3//! This module implements the core anti-flicker logic:
4//! 1. Compare Current and Next buffers
5//! 2. Generate minimal ANSI escape sequences for changed cells
6//! 3. Optimize cursor movements (skip if adjacent)
7//! 4. Track color state to avoid redundant SGR sequences
8//!
9//! All output is accumulated in a single buffer and flushed with one syscall.
10
11use super::{Buffer, Cell, CellFlags, Modifiers, Rgb};
12use crate::layout::Rect;
13use std::io::Write;
14
15/// State tracker for the diffing algorithm.
16///
17/// This tracks the "current" terminal state (cursor position, colors, modifiers)
18/// to minimize the number of escape sequences we need to emit.
19#[derive(Debug, Clone)]
20pub struct DiffState {
21    /// Last known cursor X position (0-indexed).
22    cursor_x: u16,
23    /// Last known cursor Y position (0-indexed).
24    cursor_y: u16,
25    /// Last emitted foreground color.
26    fg: Option<Rgb>,
27    /// Last emitted background color.
28    bg: Option<Rgb>,
29    /// Last emitted modifiers.
30    modifiers: Option<Modifiers>,
31}
32
33impl Default for DiffState {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl DiffState {
40    /// Create a new diff state with unknown terminal state.
41    pub const fn new() -> Self {
42        Self {
43            cursor_x: 0,
44            cursor_y: 0,
45            fg: None,
46            bg: None,
47            modifiers: None,
48        }
49    }
50
51    /// Reset the state (e.g., after a full screen clear).
52    pub const fn reset(&mut self) {
53        self.fg = None;
54        self.bg = None;
55        self.modifiers = None;
56        // Force cursor move on next write
57        self.cursor_x = u16::MAX;
58        self.cursor_y = u16::MAX;
59    }
60}
61
62/// Result of a diff operation.
63#[derive(Debug, Clone, Default)]
64pub struct DiffResult {
65    /// Number of cells that were different.
66    pub cells_changed: usize,
67    /// Number of cursor move sequences emitted.
68    pub cursor_moves: usize,
69    /// Number of color change sequences emitted.
70    pub color_changes: usize,
71    /// Number of modifier change sequences emitted.
72    pub modifier_changes: usize,
73}
74
75/// Render the difference between two buffers into an ANSI sequence buffer.
76///
77/// This is the core diffing function. It compares `current` and `next` buffers,
78/// generating minimal ANSI escape sequences for only the cells that changed.
79///
80/// # Optimizations
81///
82/// 1. **Cursor movement**: Skips explicit moves when writing adjacent cells
83/// 2. **Color tracking**: Only emits color changes when fg/bg actually differ
84/// 3. **Modifier tracking**: Only emits modifier changes when needed
85/// 4. **Dirty rectangles**: Only iterates over specified dirty regions
86///
87/// # Arguments
88///
89/// * `current` - The currently displayed buffer
90/// * `next` - The buffer to transition to
91/// * `dirty_rects` - Regions to check for changes (empty = full buffer)
92/// * `output` - Buffer to write ANSI sequences to
93/// * `state` - Mutable state tracking cursor/color positions
94///
95/// # Returns
96///
97/// Statistics about the diff operation.
98pub fn render_diff(
99    current: &Buffer,
100    next: &Buffer,
101    dirty_rects: &[Rect],
102    output: &mut Vec<u8>,
103    state: &mut DiffState,
104) -> DiffResult {
105    debug_assert_eq!(current.width(), next.width());
106    debug_assert_eq!(current.height(), next.height());
107
108    let mut result = DiffResult::default();
109    let width = current.width();
110    let height = current.height();
111
112    // If no dirty rects specified, diff the entire buffer
113    let full_rect = Rect::from_size(width, height);
114    let rects: &[Rect] = if dirty_rects.is_empty() {
115        std::slice::from_ref(&full_rect)
116    } else {
117        dirty_rects
118    };
119
120    for rect in rects {
121        diff_rect(current, next, *rect, output, state, &mut result);
122    }
123
124    result
125}
126
127/// Diff a single rectangular region.
128fn diff_rect(
129    current: &Buffer,
130    next: &Buffer,
131    rect: Rect,
132    output: &mut Vec<u8>,
133    state: &mut DiffState,
134    result: &mut DiffResult,
135) {
136    let width = current.width();
137
138    // Clamp rect to buffer bounds
139    let x_end = (rect.x + rect.width).min(width);
140    let y_end = (rect.y + rect.height).min(current.height());
141
142    for y in rect.y..y_end {
143        for x in rect.x..x_end {
144            let idx = (y as usize) * (width as usize) + (x as usize);
145            let current_cell = &current.cells()[idx];
146            let next_cell = &next.cells()[idx];
147
148            // Skip if cells are identical
149            if current_cell == next_cell {
150                continue;
151            }
152
153            // Skip wide-character continuation cells (handled by the main cell)
154            if next_cell.is_wide_continuation() {
155                continue;
156            }
157
158            result.cells_changed += 1;
159
160            // Emit cursor move if not adjacent to last position
161            if state.cursor_y != y || state.cursor_x != x {
162                emit_cursor_move(output, x, y);
163                state.cursor_x = x;
164                state.cursor_y = y;
165                result.cursor_moves += 1;
166            }
167
168            // Handle modifier resets first
169            // If we need to disable any modifiers, we must emit a full reset (\x1b[0m)
170            // which also clears colors.
171            let next_mods = next_cell.modifiers();
172            let current_mods = state.modifiers.unwrap_or(Modifiers::empty());
173            let removed_mods = current_mods.difference(next_mods);
174
175            if !removed_mods.is_empty() {
176                output.extend_from_slice(b"\x1b[0m");
177                state.fg = None;
178                state.bg = None;
179                state.modifiers = None;
180            }
181
182            // Emit color changes if needed
183            if state.fg != Some(next_cell.fg()) {
184                emit_fg_color(output, next_cell.fg());
185                state.fg = Some(next_cell.fg());
186                result.color_changes += 1;
187            }
188
189            if state.bg != Some(next_cell.bg()) {
190                emit_bg_color(output, next_cell.bg());
191                state.bg = Some(next_cell.bg());
192                result.color_changes += 1;
193            }
194
195            // Emit modifier additions if needed
196            if state.modifiers != Some(next_mods) {
197                // Logic here only handles additions because we already handled removals
198                // (if any removal occurred, we reset state.modifiers to None)
199                emit_modifiers(output, next_mods, state.modifiers);
200                state.modifiers = Some(next_mods);
201                result.modifier_changes += 1;
202            }
203
204            // Emit the grapheme
205            emit_grapheme(output, next_cell, next);
206
207            // Update cursor position (advances by display width)
208            let advance = u16::from(next_cell.display_width().max(1));
209            state.cursor_x += advance;
210        }
211    }
212}
213
214/// Emit a cursor move sequence.
215///
216/// Uses the most compact representation:
217/// - `\x1b[H` for home (1,1)
218/// - `\x1b[{row};{col}H` for absolute positioning
219#[inline]
220fn emit_cursor_move(output: &mut Vec<u8>, x: u16, y: u16) {
221    // ANSI uses 1-indexed positions
222    let row = y + 1;
223    let col = x + 1;
224
225    if row == 1 && col == 1 {
226        output.extend_from_slice(b"\x1b[H");
227    } else if col == 1 {
228        // Move to column 1 of row N
229        let _ = write!(output, "\x1b[{row}H");
230    } else {
231        let _ = write!(output, "\x1b[{row};{col}H");
232    }
233}
234
235/// Emit a foreground color sequence (true color).
236#[inline]
237fn emit_fg_color(output: &mut Vec<u8>, color: Rgb) {
238    let _ = write!(output, "\x1b[38;2;{};{};{}m", color.r, color.g, color.b);
239}
240
241/// Emit a background color sequence (true color).
242#[inline]
243fn emit_bg_color(output: &mut Vec<u8>, color: Rgb) {
244    let _ = write!(output, "\x1b[48;2;{};{};{}m", color.r, color.g, color.b);
245}
246
247/// Emit modifier change sequences.
248///
249/// This handles the transition from one set of modifiers to another,
250/// emitting reset + set sequences as needed.
251fn emit_modifiers(output: &mut Vec<u8>, new: Modifiers, old: Option<Modifiers>) {
252    let old = old.unwrap_or(Modifiers::empty());
253
254    // If we're removing modifiers, we need to reset first
255    let removed = old.difference(new);
256    if removed.is_empty() {
257        // Only adding modifiers, no need to reset
258        let added = new.difference(old);
259        emit_modifier_set(output, added);
260    } else {
261        // Reset all attributes, then re-apply what we want
262        output.extend_from_slice(b"\x1b[0m");
263        // Note: After reset, colors are also reset, so caller should
264        // re-emit colors. For now, we emit all new modifiers.
265        emit_modifier_set(output, new);
266    }
267}
268
269/// Emit SGR sequences for a set of modifiers.
270fn emit_modifier_set(output: &mut Vec<u8>, modifiers: Modifiers) {
271    if modifiers.contains(Modifiers::BOLD) {
272        output.extend_from_slice(b"\x1b[1m");
273    }
274    if modifiers.contains(Modifiers::DIM) {
275        output.extend_from_slice(b"\x1b[2m");
276    }
277    if modifiers.contains(Modifiers::ITALIC) {
278        output.extend_from_slice(b"\x1b[3m");
279    }
280    if modifiers.contains(Modifiers::UNDERLINE) {
281        output.extend_from_slice(b"\x1b[4m");
282    }
283    if modifiers.contains(Modifiers::BLINK) {
284        output.extend_from_slice(b"\x1b[5m");
285    }
286    if modifiers.contains(Modifiers::REVERSED) {
287        output.extend_from_slice(b"\x1b[7m");
288    }
289    if modifiers.contains(Modifiers::HIDDEN) {
290        output.extend_from_slice(b"\x1b[8m");
291    }
292    if modifiers.contains(Modifiers::STRIKETHROUGH) {
293        output.extend_from_slice(b"\x1b[9m");
294    }
295}
296
297/// Emit a grapheme to the output buffer.
298#[inline]
299fn emit_grapheme(output: &mut Vec<u8>, cell: &Cell, buffer: &Buffer) {
300    if cell.flags().contains(CellFlags::OVERFLOW) {
301        // Look up in overflow storage
302        if let Some(grapheme) = cell.overflow_index().and_then(|idx| buffer.get_overflow(idx)) {
303            output.extend_from_slice(grapheme.as_bytes());
304            return;
305        }
306        // Fallback: emit a replacement character
307        output.extend_from_slice("�".as_bytes());
308    } else if let Some(grapheme) = cell.grapheme() {
309        output.extend_from_slice(grapheme.as_bytes());
310    } else {
311        // Empty cell, emit space
312        output.push(b' ');
313    }
314}
315
316/// Perform a full buffer diff (convenience function).
317///
318/// This diffs the entire buffer without dirty rect optimization.
319pub fn render_full_diff(
320    current: &Buffer,
321    next: &Buffer,
322    output: &mut Vec<u8>,
323    state: &mut DiffState,
324) -> DiffResult {
325    render_diff(current, next, &[], output, state)
326}
327
328/// Generate a full redraw sequence (no diffing).
329///
330/// This is used for initial render or when the terminal state is unknown.
331pub fn render_full(buffer: &Buffer, output: &mut Vec<u8>) {
332    let width = buffer.width();
333    let height = buffer.height();
334
335    // Hide cursor during redraw
336    output.extend_from_slice(b"\x1b[?25l");
337
338    // Clear entire screen (fixes resize artifacts)
339    output.extend_from_slice(b"\x1b[2J");
340
341    // Move to home
342    output.extend_from_slice(b"\x1b[H");
343
344    let mut last_fg: Option<Rgb> = None;
345    let mut last_bg: Option<Rgb> = None;
346    let mut last_mods: Option<Modifiers> = None;
347
348    for y in 0..height {
349        if y > 0 {
350            // Move to start of next line
351            output.extend_from_slice(b"\r\n");
352        }
353
354        for x in 0..width {
355            let idx = (y as usize) * (width as usize) + (x as usize);
356            let cell = &buffer.cells()[idx];
357
358            // Skip continuation cells
359            if cell.is_wide_continuation() {
360                continue;
361            }
362
363            // Emit colors if changed
364            if last_fg != Some(cell.fg()) {
365                emit_fg_color(output, cell.fg());
366                last_fg = Some(cell.fg());
367            }
368            if last_bg != Some(cell.bg()) {
369                emit_bg_color(output, cell.bg());
370                last_bg = Some(cell.bg());
371            }
372            if last_mods != Some(cell.modifiers()) {
373                emit_modifiers(output, cell.modifiers(), last_mods);
374                last_mods = Some(cell.modifiers());
375            }
376
377            emit_grapheme(output, cell, buffer);
378        }
379    }
380
381    // Reset attributes and show cursor
382    output.extend_from_slice(b"\x1b[0m\x1b[?25h");
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_diff_identical_buffers() {
391        let a = Buffer::new(10, 5);
392        let b = Buffer::new(10, 5);
393        let mut output = Vec::new();
394        let mut state = DiffState::new();
395
396        let result = render_full_diff(&a, &b, &mut output, &mut state);
397
398        assert_eq!(result.cells_changed, 0);
399        assert!(output.is_empty());
400    }
401
402    #[test]
403    fn test_diff_single_cell_change() {
404        let a = Buffer::new(10, 5);
405        let mut b = Buffer::new(10, 5);
406
407        b.set(5, 2, Cell::new('X'));
408
409        let mut output = Vec::new();
410        let mut state = DiffState::new();
411
412        let result = render_full_diff(&a, &b, &mut output, &mut state);
413
414        assert_eq!(result.cells_changed, 1);
415        assert!(!output.is_empty());
416        // Should contain cursor move and the character
417        let output_str = String::from_utf8_lossy(&output);
418        assert!(output_str.contains('X'));
419    }
420
421    #[test]
422    fn test_diff_adjacent_cells_no_cursor_move() {
423        let a = Buffer::new(10, 5);
424        let mut b = Buffer::new(10, 5);
425
426        // Three adjacent cells on same row
427        b.set(0, 0, Cell::new('A'));
428        b.set(1, 0, Cell::new('B'));
429        b.set(2, 0, Cell::new('C'));
430
431        let mut output = Vec::new();
432        let mut state = DiffState::new();
433
434        let result = render_full_diff(&a, &b, &mut output, &mut state);
435
436        assert_eq!(result.cells_changed, 3);
437        // No cursor moves needed: cursor starts at (0,0) and cells are adjacent
438        assert_eq!(result.cursor_moves, 0);
439    }
440
441    #[test]
442    fn test_diff_color_tracking() {
443        let a = Buffer::new(10, 5);
444        let mut b = Buffer::new(10, 5);
445
446        let red = Rgb::new(255, 0, 0);
447        // Both cells have same fg color, but will also emit bg color first time
448        b.set(0, 0, Cell::new('A').with_fg(red));
449        b.set(1, 0, Cell::new('B').with_fg(red)); // Same colors, no additional changes
450
451        let mut output = Vec::new();
452        let mut state = DiffState::new();
453
454        let result = render_full_diff(&a, &b, &mut output, &mut state);
455
456        // Two color changes for first cell (fg and bg), none for second (same colors)
457        assert_eq!(result.color_changes, 2);
458    }
459
460    #[test]
461    fn test_diff_dirty_rect() {
462        let a = Buffer::new(20, 10);
463        let mut b = Buffer::new(20, 10);
464
465        // Changes outside dirty rect
466        b.set(0, 0, Cell::new('X'));
467
468        // Changes inside dirty rect
469        b.set(10, 5, Cell::new('Y'));
470
471        let mut output = Vec::new();
472        let mut state = DiffState::new();
473
474        // Only diff a region that includes (10,5) but not (0,0)
475        let dirty = vec![Rect::new(8, 4, 5, 3)];
476        let result = render_diff(&a, &b, &dirty, &mut output, &mut state);
477
478        // Should only detect the change at (10,5)
479        assert_eq!(result.cells_changed, 1);
480    }
481
482    #[test]
483    fn test_cursor_move_optimization() {
484        let mut output = Vec::new();
485
486        // Home position uses short sequence
487        emit_cursor_move(&mut output, 0, 0);
488        assert_eq!(&output, b"\x1b[H");
489
490        output.clear();
491
492        // Column 1 uses shorter sequence
493        emit_cursor_move(&mut output, 0, 5);
494        assert_eq!(&output, b"\x1b[6H"); // Row 6 (1-indexed)
495
496        output.clear();
497
498        // General position
499        emit_cursor_move(&mut output, 10, 5);
500        assert_eq!(&output, b"\x1b[6;11H"); // Row 6, Col 11 (1-indexed)
501    }
502
503    #[test]
504    fn test_render_full() {
505        let mut buffer = Buffer::new(3, 2);
506        buffer.set(0, 0, Cell::new('A'));
507        buffer.set(1, 0, Cell::new('B'));
508        buffer.set(2, 0, Cell::new('C'));
509
510        let mut output = Vec::new();
511        render_full(&buffer, &mut output);
512
513        let output_str = String::from_utf8_lossy(&output);
514        // Should start with hide cursor, clear screen, and home
515        assert!(output_str.starts_with("\x1b[?25l\x1b[2J\x1b[H"));
516        // Should contain all characters
517        assert!(output_str.contains('A'));
518        assert!(output_str.contains('B'));
519        assert!(output_str.contains('C'));
520        // Should end with reset and show cursor
521        assert!(output_str.ends_with("\x1b[0m\x1b[?25h"));
522    }
523}