inkferro-rt 0.1.0

Frame composition and diff runtime for inkferro, the Rust-backed, ink-compatible terminal UI renderer.
Documentation
//! inkferro-rt: line-diff transport.
//!
//! A pure `&str` -> bytes port of ink's `createIncremental` render path
//! (`ink/src/log-update.ts`, ~lines 174-310). No terminal IO, no streams, no
//! threads, no cursor-position machinery.
//!
//! Scope (M2-E / M2 flicker policy): incremental rendering is the only mode.
//! The TS `createIncremental` couples to a `Writable` stream only through
//! `.write()` and `cli-cursor`, both pure terminal IO. With `showCursor:true`
//! and no `setCursorPosition`, the cursor branch collapses: `activeCursor`
//! stays `undefined`, `cursorWasShown` stays `false`, so `returnPrefix` and
//! `cursorSuffix` are always `""`. The remaining render logic depends on the
//! input string alone -- no `rows`/`columns` terminal-height dependence -- so
//! the pure `&str -> bytes` contract holds.

use std::ops::Range;

mod escapes;
mod frame;

pub use frame::{
    CursorPos, FrameParams, FrameWriter, bsu, esu, should_clear_terminal_for_frame,
    should_synchronize,
};

// Escape builders are an implementation detail of the transport; kept
// crate-internal so the tests can reach them via `super::*` without widening
// the public API beyond `LineDiff`.
use escapes::{cursor_next_line, cursor_to, cursor_up, erase_end_line, erase_lines};

/// Incremental line-diff transport: a stateful `&str -> Vec<u8>` writer that
/// emits the minimal ANSI byte sequence to transform the previously rendered
/// frame into the next one, mirroring ink's `createIncremental`.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct LineDiff {
    previous_output: String,
    /// Byte ranges of each line in `previous_output` (the spans
    /// `previous_output.split('\n')` would yield). The previous frame's lines
    /// are fully derivable from `previous_output`, so storing owned `String`s
    /// for them was pure duplication — one heap `String` per line per frame.
    /// Ranges index back into the retained `previous_output` instead; a `&str`
    /// field would be self-referential and cannot borrow the sibling `String`.
    previous_lines: Vec<Range<usize>>,
    /// Count of visible lines the LAST [`diff`](Self::diff) actually rewrote
    /// (emitted bytes for). Pure additive telemetry that rides alongside the
    /// byte computation — it is NEVER read while building the transport bytes,
    /// so it cannot perturb a single emitted byte. Downstream pacing (P5.3)
    /// reads it via [`last_changed_lines`](Self::last_changed_lines) to tell a
    /// real-change frame from a no-op timer fire. A zero-byte (unchanged) diff
    /// sets it to 0; a bootstrap/full-repaint sets it to the visible line count.
    last_changed_lines: u32,
}

impl LineDiff {
    /// Create an empty transport (nothing rendered yet).
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Reset to the initial state, as `render.done()` / `render.reset()` do for
    /// the next render's bootstrap branch.
    pub fn reset(&mut self) {
        self.previous_output.clear();
        self.previous_lines.clear();
        self.last_changed_lines = 0;
    }

    /// How many visible lines the LAST [`diff`](Self::diff) rewrote (emitted
    /// bytes for). 0 for a no-op (unchanged) frame; the visible line count for a
    /// bootstrap/full-repaint. Additive telemetry only — reading it never
    /// affects the emitted transport bytes.
    #[must_use]
    pub fn last_changed_lines(&self) -> u32 {
        self.last_changed_lines
    }

    /// The `i`-th line of the previous frame as a `&str` slice of
    /// `previous_output`, or `None` past the end. Equivalent to indexing the old
    /// `Vec<String>` of owned lines: `None` mirrors JS reading `undefined` past
    /// the array end.
    fn previous_line(&self, i: usize) -> Option<&str> {
        self.previous_lines
            .get(i)
            .map(|range| &self.previous_output[range.clone()])
    }

    /// Record `str_value` as the new previous frame **without emitting any
    /// bytes**. Port of `createIncremental`'s `render.sync(str)`
    /// (`log-update.ts` ~lines 344-350) for the pure path (no active cursor):
    /// it sets `previousOutput`/`previousLines` from the argument so the *next*
    /// [`diff`](Self::diff) is computed against this baseline.
    ///
    /// The clear-decision layer calls this after a full-clear write so the next
    /// incremental diff is taken against what is actually on screen.
    pub fn sync(&mut self, str_value: &str) {
        self.previous_output.clear();
        self.previous_output.push_str(str_value);
        line_ranges_into(str_value, &mut self.previous_lines);
    }

    /// Emit the erase sequence that wipes the previously rendered frame and
    /// reset the baseline to empty. Port of `createIncremental`'s
    /// `render.clear()` (`log-update.ts` ~lines 312-323) for the pure path:
    /// `returnPrefix` collapses to `""`, leaving `eraseLines(previousLines.len)`.
    ///
    /// After this the transport is back in its bootstrap state, so the next
    /// [`diff`](Self::diff) re-emits the full frame.
    pub fn clear(&mut self) -> Vec<u8> {
        let out = erase_lines(self.previous_lines.len());
        self.previous_output.clear();
        self.previous_lines.clear();
        out.into_bytes()
    }

    /// Diff `next` against the previously rendered frame and return the bytes to
    /// write. Returns an empty buffer when `next` is byte-identical to the prior
    /// frame (the zero-flicker skip).
    ///
    /// Port of `createIncremental`'s `render(str)` for the pure-transport path
    /// (no active cursor). Mutates internal state to record `next` as the new
    /// previous frame.
    pub fn diff(&mut self, next: &str) -> Vec<u8> {
        // hasChanges: with no active cursor, reduces to a byte-equality check.
        if next == self.previous_output {
            // No-op frame: nothing rewritten. (Telemetry only — this assignment
            // changes no emitted byte; the return is still an empty buffer.)
            self.last_changed_lines = 0;
            return Vec::new();
        }

        // Line spans of `next`, computed once per frame. They serve double
        // duty: borrowed `&next[range]` views during the diff (replacing the
        // old per-frame `Vec<&str>` collect of `split('\n')` — same elements,
        // see `line_ranges`), then MOVED into `previous_lines` as the new
        // baseline instead of being recomputed.
        let next_ranges = line_ranges(next);
        let next_line = |i: usize| next_ranges.get(i).map(|range| &next[range.clone()]);
        let visible_count = visible_line_count(next_ranges.len(), next);
        let previous_visible = visible_line_count(self.previous_lines.len(), &self.previous_output);

        // Bootstrap branch: `str === '\n' || previousOutput.length === 0`.
        // returnPrefix and cursorSuffix are "" in the pure path.
        if next == "\n" || self.previous_output.is_empty() {
            let mut out = erase_lines(self.previous_lines.len());
            out.push_str(next);
            // Bootstrap/full repaint: every visible line is (re)written.
            // (Telemetry only — does not touch `out`.)
            self.last_changed_lines = visible_count as u32;
            self.set_baseline(next, next_ranges);
            return out.into_bytes();
        }

        let has_trailing_newline = next.ends_with('\n');
        let mut buffer = String::new();
        // Telemetry: count of visible lines this frame actually rewrites. Folded
        // in alongside the byte build; never read while emitting, so byte-inert.
        let mut changed = 0u32;

        // `cursor_to(0)` is loop-invariant; build it once per frame instead of
        // once per changed line.
        let cursor_to_col0 = cursor_to(0);

        // Clear extra lines if the current content's line count shrank.
        if visible_count < previous_visible {
            let previous_had_trailing_newline = self.previous_output.ends_with('\n');
            let extra_slot = usize::from(previous_had_trailing_newline);
            buffer.push_str(&erase_lines(previous_visible - visible_count + extra_slot));
            buffer.push_str(&cursor_up(visible_count));
        } else {
            // previous_output is non-empty here (bootstrap handled above), so
            // previous_lines.len() >= 1; saturating_sub guards the invariant.
            buffer.push_str(&cursor_up(self.previous_lines.len().saturating_sub(1)));
        }

        for i in 0..visible_count {
            let is_last_line = i == visible_count - 1;

            // Skip lines whose contents are unchanged (anti-flicker). Comparing
            // `Option<&str>` to `Option<&str>` is the same byte-equality the old
            // `Vec<String>` comparison performed; `None` past either end mirrors
            // JS reading `undefined` rather than panicking.
            if next_line(i) == self.previous_line(i) {
                // Don't move past the last line when there's no trailing
                // newline, or the cursor overshoots the rendered block.
                if !is_last_line || has_trailing_newline {
                    buffer.push_str(cursor_next_line());
                }
                continue;
            }

            // This line differs from the previous frame at slot `i`: it gets
            // rewritten below. Count it (telemetry only — byte-inert).
            changed += 1;
            buffer.push_str(&cursor_to_col0);
            // next_line(i) is always Some for i < visible_count <= next_ranges.len().
            buffer.push_str(next_line(i).unwrap_or(""));
            buffer.push_str(erase_end_line());
            // Don't append newline after the last line when the input has no
            // trailing newline (fullscreen mode). Equivalent to the TS guard
            // `(isLastLine && !hasTrailingNewline ? '' : '\n')`.
            if !is_last_line || has_trailing_newline {
                buffer.push('\n');
            }
        }

        self.last_changed_lines = changed;
        self.set_baseline(next, next_ranges);
        buffer.into_bytes()
    }

    /// Record `next` as the new previous frame, reusing the retained
    /// `previous_output` buffer and taking ownership of the already-computed
    /// line spans (which index into the copy of `next` just stored).
    fn set_baseline(&mut self, next: &str, next_ranges: Vec<Range<usize>>) {
        self.previous_output.clear();
        self.previous_output.push_str(next);
        self.previous_lines = next_ranges;
    }
}

/// Count visible lines, ignoring the trailing empty element that `split('\n')`
/// produces when the string ends with `'\n'`. Port of `visibleLineCount`
/// (log-update.ts ~lines 28-29).
fn visible_line_count(line_count: usize, str_value: &str) -> usize {
    if str_value.ends_with('\n') {
        line_count - 1
    } else {
        line_count
    }
}

/// Byte ranges of each line in `s`, exactly the spans `s.split('\n')` yields.
///
/// Reproduces `split('\n')` semantics span-for-span: `"a\nb"` -> `[0..1, 2..3]`,
/// `"a\n"` -> `[0..1, 2..2]` (trailing empty line), `""` -> `[0..0]` (one empty
/// line). Each returned `&previous_output[range]` is byte-identical to the
/// corresponding `split('\n')` element, so the per-line equality checks the diff
/// performs are unchanged.
fn line_ranges(s: &str) -> Vec<Range<usize>> {
    let mut ranges = Vec::new();
    line_ranges_into(s, &mut ranges);
    ranges
}

/// In-place [`line_ranges`]: clear and refill `ranges`, reusing its allocation.
fn line_ranges_into(s: &str, ranges: &mut Vec<Range<usize>>) {
    ranges.clear();
    let mut start = 0;
    for (idx, _) in s.match_indices('\n') {
        ranges.push(start..idx);
        start = idx + 1;
    }
    ranges.push(start..s.len());
}

#[cfg(test)]
mod tests;