Skip to main content

inkferro_rt/
lib.rs

1//! inkferro-rt: line-diff transport.
2//!
3//! A pure `&str` -> bytes port of ink's `createIncremental` render path
4//! (`ink/src/log-update.ts`, ~lines 174-310). No terminal IO, no streams, no
5//! threads, no cursor-position machinery.
6//!
7//! Scope (M2-E / M2 flicker policy): incremental rendering is the only mode.
8//! The TS `createIncremental` couples to a `Writable` stream only through
9//! `.write()` and `cli-cursor`, both pure terminal IO. With `showCursor:true`
10//! and no `setCursorPosition`, the cursor branch collapses: `activeCursor`
11//! stays `undefined`, `cursorWasShown` stays `false`, so `returnPrefix` and
12//! `cursorSuffix` are always `""`. The remaining render logic depends on the
13//! input string alone -- no `rows`/`columns` terminal-height dependence -- so
14//! the pure `&str -> bytes` contract holds.
15
16use std::ops::Range;
17
18mod escapes;
19mod frame;
20
21pub use frame::{
22    CursorPos, FrameParams, FrameWriter, bsu, esu, should_clear_terminal_for_frame,
23    should_synchronize,
24};
25
26// Escape builders are an implementation detail of the transport; kept
27// crate-internal so the tests can reach them via `super::*` without widening
28// the public API beyond `LineDiff`.
29use escapes::{cursor_next_line, cursor_to, cursor_up, erase_end_line, erase_lines};
30
31/// Incremental line-diff transport: a stateful `&str -> Vec<u8>` writer that
32/// emits the minimal ANSI byte sequence to transform the previously rendered
33/// frame into the next one, mirroring ink's `createIncremental`.
34#[derive(Debug, Default, Clone, PartialEq)]
35pub struct LineDiff {
36    previous_output: String,
37    /// Byte ranges of each line in `previous_output` (the spans
38    /// `previous_output.split('\n')` would yield). The previous frame's lines
39    /// are fully derivable from `previous_output`, so storing owned `String`s
40    /// for them was pure duplication — one heap `String` per line per frame.
41    /// Ranges index back into the retained `previous_output` instead; a `&str`
42    /// field would be self-referential and cannot borrow the sibling `String`.
43    previous_lines: Vec<Range<usize>>,
44    /// Count of visible lines the LAST [`diff`](Self::diff) actually rewrote
45    /// (emitted bytes for). Pure additive telemetry that rides alongside the
46    /// byte computation — it is NEVER read while building the transport bytes,
47    /// so it cannot perturb a single emitted byte. Downstream pacing (P5.3)
48    /// reads it via [`last_changed_lines`](Self::last_changed_lines) to tell a
49    /// real-change frame from a no-op timer fire. A zero-byte (unchanged) diff
50    /// sets it to 0; a bootstrap/full-repaint sets it to the visible line count.
51    last_changed_lines: u32,
52}
53
54impl LineDiff {
55    /// Create an empty transport (nothing rendered yet).
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Reset to the initial state, as `render.done()` / `render.reset()` do for
62    /// the next render's bootstrap branch.
63    pub fn reset(&mut self) {
64        self.previous_output.clear();
65        self.previous_lines.clear();
66        self.last_changed_lines = 0;
67    }
68
69    /// How many visible lines the LAST [`diff`](Self::diff) rewrote (emitted
70    /// bytes for). 0 for a no-op (unchanged) frame; the visible line count for a
71    /// bootstrap/full-repaint. Additive telemetry only — reading it never
72    /// affects the emitted transport bytes.
73    #[must_use]
74    pub fn last_changed_lines(&self) -> u32 {
75        self.last_changed_lines
76    }
77
78    /// The `i`-th line of the previous frame as a `&str` slice of
79    /// `previous_output`, or `None` past the end. Equivalent to indexing the old
80    /// `Vec<String>` of owned lines: `None` mirrors JS reading `undefined` past
81    /// the array end.
82    fn previous_line(&self, i: usize) -> Option<&str> {
83        self.previous_lines
84            .get(i)
85            .map(|range| &self.previous_output[range.clone()])
86    }
87
88    /// Record `str_value` as the new previous frame **without emitting any
89    /// bytes**. Port of `createIncremental`'s `render.sync(str)`
90    /// (`log-update.ts` ~lines 344-350) for the pure path (no active cursor):
91    /// it sets `previousOutput`/`previousLines` from the argument so the *next*
92    /// [`diff`](Self::diff) is computed against this baseline.
93    ///
94    /// The clear-decision layer calls this after a full-clear write so the next
95    /// incremental diff is taken against what is actually on screen.
96    pub fn sync(&mut self, str_value: &str) {
97        self.previous_output.clear();
98        self.previous_output.push_str(str_value);
99        line_ranges_into(str_value, &mut self.previous_lines);
100    }
101
102    /// Emit the erase sequence that wipes the previously rendered frame and
103    /// reset the baseline to empty. Port of `createIncremental`'s
104    /// `render.clear()` (`log-update.ts` ~lines 312-323) for the pure path:
105    /// `returnPrefix` collapses to `""`, leaving `eraseLines(previousLines.len)`.
106    ///
107    /// After this the transport is back in its bootstrap state, so the next
108    /// [`diff`](Self::diff) re-emits the full frame.
109    pub fn clear(&mut self) -> Vec<u8> {
110        let out = erase_lines(self.previous_lines.len());
111        self.previous_output.clear();
112        self.previous_lines.clear();
113        out.into_bytes()
114    }
115
116    /// Diff `next` against the previously rendered frame and return the bytes to
117    /// write. Returns an empty buffer when `next` is byte-identical to the prior
118    /// frame (the zero-flicker skip).
119    ///
120    /// Port of `createIncremental`'s `render(str)` for the pure-transport path
121    /// (no active cursor). Mutates internal state to record `next` as the new
122    /// previous frame.
123    pub fn diff(&mut self, next: &str) -> Vec<u8> {
124        // hasChanges: with no active cursor, reduces to a byte-equality check.
125        if next == self.previous_output {
126            // No-op frame: nothing rewritten. (Telemetry only — this assignment
127            // changes no emitted byte; the return is still an empty buffer.)
128            self.last_changed_lines = 0;
129            return Vec::new();
130        }
131
132        // Line spans of `next`, computed once per frame. They serve double
133        // duty: borrowed `&next[range]` views during the diff (replacing the
134        // old per-frame `Vec<&str>` collect of `split('\n')` — same elements,
135        // see `line_ranges`), then MOVED into `previous_lines` as the new
136        // baseline instead of being recomputed.
137        let next_ranges = line_ranges(next);
138        let next_line = |i: usize| next_ranges.get(i).map(|range| &next[range.clone()]);
139        let visible_count = visible_line_count(next_ranges.len(), next);
140        let previous_visible = visible_line_count(self.previous_lines.len(), &self.previous_output);
141
142        // Bootstrap branch: `str === '\n' || previousOutput.length === 0`.
143        // returnPrefix and cursorSuffix are "" in the pure path.
144        if next == "\n" || self.previous_output.is_empty() {
145            let mut out = erase_lines(self.previous_lines.len());
146            out.push_str(next);
147            // Bootstrap/full repaint: every visible line is (re)written.
148            // (Telemetry only — does not touch `out`.)
149            self.last_changed_lines = visible_count as u32;
150            self.set_baseline(next, next_ranges);
151            return out.into_bytes();
152        }
153
154        let has_trailing_newline = next.ends_with('\n');
155        let mut buffer = String::new();
156        // Telemetry: count of visible lines this frame actually rewrites. Folded
157        // in alongside the byte build; never read while emitting, so byte-inert.
158        let mut changed = 0u32;
159
160        // `cursor_to(0)` is loop-invariant; build it once per frame instead of
161        // once per changed line.
162        let cursor_to_col0 = cursor_to(0);
163
164        // Clear extra lines if the current content's line count shrank.
165        if visible_count < previous_visible {
166            let previous_had_trailing_newline = self.previous_output.ends_with('\n');
167            let extra_slot = usize::from(previous_had_trailing_newline);
168            buffer.push_str(&erase_lines(previous_visible - visible_count + extra_slot));
169            buffer.push_str(&cursor_up(visible_count));
170        } else {
171            // previous_output is non-empty here (bootstrap handled above), so
172            // previous_lines.len() >= 1; saturating_sub guards the invariant.
173            buffer.push_str(&cursor_up(self.previous_lines.len().saturating_sub(1)));
174        }
175
176        for i in 0..visible_count {
177            let is_last_line = i == visible_count - 1;
178
179            // Skip lines whose contents are unchanged (anti-flicker). Comparing
180            // `Option<&str>` to `Option<&str>` is the same byte-equality the old
181            // `Vec<String>` comparison performed; `None` past either end mirrors
182            // JS reading `undefined` rather than panicking.
183            if next_line(i) == self.previous_line(i) {
184                // Don't move past the last line when there's no trailing
185                // newline, or the cursor overshoots the rendered block.
186                if !is_last_line || has_trailing_newline {
187                    buffer.push_str(cursor_next_line());
188                }
189                continue;
190            }
191
192            // This line differs from the previous frame at slot `i`: it gets
193            // rewritten below. Count it (telemetry only — byte-inert).
194            changed += 1;
195            buffer.push_str(&cursor_to_col0);
196            // next_line(i) is always Some for i < visible_count <= next_ranges.len().
197            buffer.push_str(next_line(i).unwrap_or(""));
198            buffer.push_str(erase_end_line());
199            // Don't append newline after the last line when the input has no
200            // trailing newline (fullscreen mode). Equivalent to the TS guard
201            // `(isLastLine && !hasTrailingNewline ? '' : '\n')`.
202            if !is_last_line || has_trailing_newline {
203                buffer.push('\n');
204            }
205        }
206
207        self.last_changed_lines = changed;
208        self.set_baseline(next, next_ranges);
209        buffer.into_bytes()
210    }
211
212    /// Record `next` as the new previous frame, reusing the retained
213    /// `previous_output` buffer and taking ownership of the already-computed
214    /// line spans (which index into the copy of `next` just stored).
215    fn set_baseline(&mut self, next: &str, next_ranges: Vec<Range<usize>>) {
216        self.previous_output.clear();
217        self.previous_output.push_str(next);
218        self.previous_lines = next_ranges;
219    }
220}
221
222/// Count visible lines, ignoring the trailing empty element that `split('\n')`
223/// produces when the string ends with `'\n'`. Port of `visibleLineCount`
224/// (log-update.ts ~lines 28-29).
225fn visible_line_count(line_count: usize, str_value: &str) -> usize {
226    if str_value.ends_with('\n') {
227        line_count - 1
228    } else {
229        line_count
230    }
231}
232
233/// Byte ranges of each line in `s`, exactly the spans `s.split('\n')` yields.
234///
235/// Reproduces `split('\n')` semantics span-for-span: `"a\nb"` -> `[0..1, 2..3]`,
236/// `"a\n"` -> `[0..1, 2..2]` (trailing empty line), `""` -> `[0..0]` (one empty
237/// line). Each returned `&previous_output[range]` is byte-identical to the
238/// corresponding `split('\n')` element, so the per-line equality checks the diff
239/// performs are unchanged.
240fn line_ranges(s: &str) -> Vec<Range<usize>> {
241    let mut ranges = Vec::new();
242    line_ranges_into(s, &mut ranges);
243    ranges
244}
245
246/// In-place [`line_ranges`]: clear and refill `ranges`, reusing its allocation.
247fn line_ranges_into(s: &str, ranges: &mut Vec<Range<usize>>) {
248    ranges.clear();
249    let mut start = 0;
250    for (idx, _) in s.match_indices('\n') {
251        ranges.push(start..idx);
252        start = idx + 1;
253    }
254    ranges.push(start..s.len());
255}
256
257#[cfg(test)]
258mod tests;