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;