retach 0.10.0

Persistent terminal sessions with native scrollback passthrough
Documentation
//! Consumer-side change detection for state-reading frontends.

use crate::screen::cell::Row;
use crate::screen::traits::TerminalEmulator;

use super::core::hash_row;

/// Sentinel marking a slot "never hashed". Matches the historical render
/// cache fill; a real hash colliding with it is astronomically unlikely and
/// would make a fresh slot read as clean — causing one MISSED first paint of
/// that row (not an extra repaint).
const DIRTY_SENTINEL: u64 = u64::MAX;

/// Tracks which visible rows changed between calls, using the same row
/// hashing as [`AnsiRenderer`](super::AnsiRenderer)'s internal cache: a row
/// is "dirty" when its content hash changed since this tracker's previous
/// call.
///
/// `DirtyTracker` keeps its own independent cache. It does not observe
/// renderer events (full redraws, scrollback injection), so its dirty set
/// is NOT guaranteed to match what a separately-driven `AnsiRenderer`
/// re-emits — pick one mechanism per consumer. If you force a full repaint
/// yourself, call [`invalidate`](Self::invalidate) so the next
/// [`dirty_rows`](Self::dirty_rows) reflects it.
///
/// Tracks **visible row content only**: cursor position/visibility, title,
/// and modes are not tracked (read them each frame — they are cheap), and
/// scrollback is not tracked (it is append-only; consume it incrementally
/// via `take_pending_scrollback`).
///
/// # Example
///
/// ```rust
/// use retach::screen::{DirtyTracker, Screen};
///
/// let mut screen = Screen::new(80, 24, 1000);
/// let mut tracker = DirtyTracker::new();
///
/// screen.process(b"hello");
/// let first = tracker.dirty_rows(&screen);
/// assert_eq!(first.len(), 24); // first call: everything is dirty
///
/// screen.process(b"\r\nworld");
/// let dirty = tracker.dirty_rows(&screen);
/// assert_eq!(dirty, vec![1]); // only the row "world" landed on
/// ```
#[derive(Default)]
pub struct DirtyTracker {
    row_hashes: Vec<u64>,
}

impl DirtyTracker {
    /// Create a tracker; the first [`dirty_rows`](Self::dirty_rows) call
    /// reports all rows.
    pub fn new() -> Self {
        Self {
            row_hashes: Vec::new(),
        }
    }

    /// Forget all stored hashes; the next call reports all rows.
    pub fn invalidate(&mut self) {
        self.row_hashes.clear();
    }

    /// Resize the hash store to `rows`, preserving existing prefix hashes —
    /// surviving rows stay clean, new slots start dirty. Call once per frame
    /// before [`check_row`](Self::check_row). (Contrast with
    /// [`dirty_rows`](Self::dirty_rows), which treats a row-count change as
    /// everything-dirty — two policies over one shared mechanism.) This is the
    /// same row-hash dirty-tracking mechanism
    /// [`AnsiRenderer`](super::AnsiRenderer) uses internally on the production
    /// render path.
    pub fn ensure_len(&mut self, rows: usize) {
        self.row_hashes.resize(rows, DIRTY_SENTINEL);
    }

    /// Hash `row`, store the hash, and return whether it changed since the
    /// stored value. Single-pass primitive for custom renderers; `y` must
    /// be less than the length given to [`ensure_len`](Self::ensure_len).
    /// This is the same row-hash dirty-tracking mechanism
    /// [`AnsiRenderer`](super::AnsiRenderer) uses internally on the production
    /// render path.
    ///
    /// # Panics
    ///
    /// Panics if `y` is not less than the length passed to the most recent
    /// [`ensure_len`](Self::ensure_len) call (debug builds show an assertion
    /// message; release builds panic on slice indexing).
    pub fn check_row(&mut self, y: usize, row: &Row) -> bool {
        debug_assert!(
            y < self.row_hashes.len(),
            "DirtyTracker::check_row: y={y} out of bounds; call ensure_len first"
        );
        let hash = hash_row(row);
        let dirty = self.row_hashes[y] != hash;
        self.row_hashes[y] = hash;
        dirty
    }

    /// Whether any row hashes are stored. Used by render-cache tests to assert
    /// the cache is populated / cleared.
    #[cfg(test)]
    pub(in crate::screen) fn is_empty(&self) -> bool {
        self.row_hashes.is_empty()
    }

    /// Indices of visible rows whose content changed since the last call.
    /// The first call — and any call after the row count changed (resize) —
    /// reports all rows. Stored hashes are updated.
    pub fn dirty_rows<E: TerminalEmulator + ?Sized>(&mut self, emu: &E) -> Vec<u16> {
        let rows = emu.rows() as usize;
        let all_dirty = self.row_hashes.len() != rows;
        if all_dirty {
            self.row_hashes.clear();
            self.row_hashes.resize(rows, 0);
        }
        let mut dirty = Vec::new();
        for y in 0..rows {
            let hash = hash_row(emu.visible_row(y as u16));
            if all_dirty || self.row_hashes[y] != hash {
                dirty.push(y as u16);
                self.row_hashes[y] = hash;
            }
        }
        dirty
    }
}

#[cfg(test)]
mod tests {
    use super::DirtyTracker;
    use crate::screen::Screen;

    #[test]
    fn first_call_reports_all_rows() {
        let s = Screen::new(10, 4, 0);
        let mut t = DirtyTracker::new();
        assert_eq!(t.dirty_rows(&s), vec![0, 1, 2, 3]);
    }

    #[test]
    fn unchanged_screen_reports_nothing() {
        let s = Screen::new(10, 4, 0);
        let mut t = DirtyTracker::new();
        t.dirty_rows(&s);
        assert!(t.dirty_rows(&s).is_empty());
    }

    #[test]
    fn single_row_change_reports_exactly_that_row() {
        let mut s = Screen::new(10, 4, 0);
        let mut t = DirtyTracker::new();
        s.process(b"a\r\nb");
        t.dirty_rows(&s);
        s.process(b"\x1b[1;5Hx"); // write into row 0 only
        assert_eq!(t.dirty_rows(&s), vec![0]);
    }

    #[test]
    fn resize_reports_all_rows() {
        let mut s = Screen::new(10, 4, 0);
        let mut t = DirtyTracker::new();
        t.dirty_rows(&s);
        s.resize(10, 6);
        assert_eq!(t.dirty_rows(&s), vec![0, 1, 2, 3, 4, 5]);
    }

    #[test]
    fn invalidate_reports_all_rows() {
        let s = Screen::new(10, 4, 0);
        let mut t = DirtyTracker::new();
        t.dirty_rows(&s);
        t.invalidate();
        assert_eq!(t.dirty_rows(&s), vec![0, 1, 2, 3]);
    }

    #[test]
    fn ensure_len_preserves_prefix_and_new_slots_start_dirty() {
        let mut s = Screen::new(10, 2, 0);
        s.process(b"a\r\nb");
        let mut t = DirtyTracker::new();
        t.dirty_rows(&s); // hashes stored for rows 0,1
        t.ensure_len(4);
        let rows: Vec<_> = s.visible_rows().collect();
        assert!(!t.check_row(0, rows[0]), "prefix hash preserved -> clean");
        assert!(!t.check_row(1, rows[1]), "prefix hash preserved -> clean");
        assert!(t.check_row(2, rows[0]), "new slot starts dirty");
        assert!(t.check_row(3, rows[1]), "new slot starts dirty");
    }

    #[test]
    fn check_row_detects_change_and_updates() {
        let mut s = Screen::new(10, 2, 0);
        let mut t = DirtyTracker::new();
        t.ensure_len(2);
        {
            let rows: Vec<_> = s.visible_rows().collect();
            assert!(t.check_row(0, rows[0]), "fresh slot is dirty");
            assert!(!t.check_row(0, rows[0]), "hash stored -> clean");
        }
        s.process(b"x");
        let rows: Vec<_> = s.visible_rows().collect();
        assert!(t.check_row(0, rows[0]), "content changed -> dirty");
    }

    #[test]
    fn invalidate_then_ensure_len_marks_all_dirty() {
        let s = Screen::new(10, 3, 0);
        let mut t = DirtyTracker::new();
        t.dirty_rows(&s);
        t.invalidate();
        t.ensure_len(3);
        for (y, row) in s.visible_rows().enumerate() {
            assert!(t.check_row(y, row), "row {y} must be dirty after invalidate");
        }
    }

    #[test]
    fn agrees_with_ansi_renderer_dirty_decision() {
        // Rows DirtyTracker reports must be the rows an incremental render re-emits.
        let mut s = Screen::new(10, 4, 0);
        let mut t = DirtyTracker::new();
        let mut r = crate::screen::AnsiRenderer::new();
        s.process(b"aaa\r\nbbb\r\nccc");
        t.dirty_rows(&s);
        r.render(&s, true);
        s.process(b"\x1b[2;1HXXX"); // mutate row 1 (0-based)
        assert_eq!(t.dirty_rows(&s), vec![1]);
        let delta = r.render(&s, false);
        let delta_str = String::from_utf8_lossy(&delta);
        assert!(
            delta_str.contains("\x1b[2;1H"),
            "renderer re-emits row 1 (CUP row 2): {delta_str:?}"
        );
        assert!(
            !delta_str.contains("\x1b[1;1H"),
            "row 0 must not be re-emitted: {delta_str:?}"
        );
    }
}