Skip to main content

inkferro_core/render/
grid.rs

1//! Char grid — plain-frame output assembly.
2//!
3//! Port of ink's `output.ts` (Output class) — **plain-frame slice only**:
4//! no SGR transformers, no style application. The grid holds raw grapheme
5//! clusters and produces a trimmed string on `get()`.
6//!
7//! # Design decisions
8//!
9//! ## Cell representation
10//! ink's `StyledChar` (output.ts:141-151) stores `{type,value,fullWidth,styles}`.
11//! The plain slice drops `styles` (no SGR) and folds `fullWidth` into a
12//! `wide_placeholder` bool: a cell with `value == ""` is a trailing placeholder
13//! for the preceding wide character.
14//!
15//! ## Wide-char cleanup (output.ts:263-299 citation)
16//! When a write lands at a column that is currently a wide-char placeholder
17//! (cell.value == "" and cell[x-1] has visual width > 1), ink blanks the
18//! **leader** cell before writing (output.ts:263-270). After writing, if the
19//! cell immediately after the last written cell is a placeholder, ink blanks it
20//! too (output.ts:296-299). We mirror both cleanups exactly.
21//!
22//! ## Clip-rectangle stack
23//! ink uses an operation queue with `clip` / `unclip` ops (output.ts:24-48,
24//! 158-226). The plain slice keeps a `Vec<Clip>` stack that is pushed/popped
25//! synchronously at write time — same semantics, simpler code.
26//!
27//! ## Trailing-whitespace trimming (output.ts:305-312 citation)
28//! ```ts
29//! return styledCharsToString(lineWithoutEmptyItems).trimEnd();
30//! ```
31//! Each row is right-trimmed before joining with `\n`. We do the same with
32//! `trim_end_matches(is_js_trim_end_whitespace)` — JS `trimEnd`'s exact
33//! whitespace set, NOT Rust's `char::is_whitespace` (task #123; see the
34//! helper's doc for the two-char delta).
35
36use std::rc::Rc;
37
38use compact_str::CompactString;
39
40use crate::text::ansi_tokenize::{
41    AnsiToken, StyledChar, empty_styles, styled_chars_from_plain, styled_chars_from_tokens,
42    styled_chars_to_string_into, tokenize,
43};
44use crate::text::slice_ansi::slice_ansi;
45use crate::text::string_width::string_width;
46
47/// A post-clip text transformer: `(line, line_index) -> line`.
48///
49/// Mirrors ink's `OutputTransformer = (s, index) => string`
50/// (render-node-to-output.ts:30). The render/napi layer builds the chain
51/// `[own, ...inherited]` (render-node-to-output.ts:134-138) — in inkferro the
52/// per-node `own` transform (Text.tsx's `colorize`/chalk closure) stays JS-side
53/// and is mapped onto this type by the napi boundary. The walk merely THREADS an
54/// inherited slice parent→child→write; the write path applies the chain
55/// innermost-first AFTER the clip-slice (output.ts:238-240). Borrowed `dyn Fn`
56/// refs dodge the `Clone` problem (closures are not `Clone`); the chain is a
57/// slice of references rebuilt at each level.
58pub type Transformer<'a> = &'a dyn Fn(&str, usize) -> String;
59
60// ─── Clip ────────────────────────────────────────────────────────────────────
61
62/// Axis-independent clip boundary.
63///
64/// Mirrors ink's `Clip` type (output.ts:39-44).
65/// `None` means "no clip on this axis" (output.ts:177-178:
66/// `typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'`).
67#[derive(Debug, Clone, Copy, Default)]
68pub struct Clip {
69    pub x1: Option<i32>,
70    pub x2: Option<i32>,
71    pub y1: Option<i32>,
72    pub y2: Option<i32>,
73}
74
75// ─── Cell ────────────────────────────────────────────────────────────────────
76
77/// One terminal cell in the grid.
78///
79/// `ch` holds the cell's [`StyledChar`] (grapheme `value` + accumulated
80/// `styles`). A `value == ""` signals a wide-char trailing placeholder
81/// (output.ts:282-290: value='', fullWidth=false, styles=lead.styles).
82///
83/// `hole` models a JS sparse-array gap. ink's `Output.get()` pre-fills a row
84/// with exactly `width` space cells, but writes assign `currentLine[offsetX]`
85/// directly — for `offsetX >= width` this *grows* the backing JS array,
86/// leaving any skipped index as `undefined` (a hole). At assembly, ink does
87/// `line.filter(item => item !== undefined)` (output.ts:308) before
88/// `trimEnd`, which DROPS those holes mid-row. A space cell, by contrast,
89/// survives the filter and is only removed by `trimEnd` if trailing.
90///
91/// This distinction is load-bearing for the out-of-bounds artifact: a
92/// width-12 box in a width-10 grid materializes a 12-wide top/bottom row
93/// (every column written) but 11-wide interior rows (col 10 never written →
94/// hole → filtered, while the right border at col 11 survives).
95#[derive(Debug, Clone)]
96struct Cell {
97    /// The styled grapheme in this cell. `value == ""` = wide-char placeholder.
98    ch: StyledChar,
99    /// True if this cell is a JS sparse-array hole (`undefined`): never
100    /// written, beyond the pre-initialized width. Dropped by `get()`'s filter.
101    hole: bool,
102}
103
104impl Cell {
105    /// A space cell, ink's `spaceCell` (output.ts:251-256:
106    /// `{value:' ', fullWidth:false, styles:[]}`).
107    fn space() -> Self {
108        Self {
109            ch: StyledChar {
110                // `const_new` inlines the 1-byte fill at compile time: a
111                // colorless frame's ~900 space cells allocate nothing.
112                value: CompactString::const_new(" "),
113                full_width: false,
114                // Shared empty-style sentinel (Rc::clone, zero heap) so the
115                // ~900 space cells stay allocation-free under the Rc styles.
116                styles: empty_styles(),
117            },
118            hole: false,
119        }
120    }
121
122    /// A wide-char trailing placeholder inheriting the lead char's `styles`
123    /// (output.ts:284-289: `{value:'', fullWidth:false, styles:character.styles}`).
124    fn placeholder(styles: Rc<[AnsiToken]>) -> Self {
125        Self {
126            ch: StyledChar {
127                value: CompactString::const_new(""),
128                full_width: false,
129                styles,
130            },
131            hole: false,
132        }
133    }
134
135    /// A JS sparse-array gap (`undefined`): exists only to pad a row out to a
136    /// written index past the pre-initialized width. Filtered out by `get()`.
137    fn hole() -> Self {
138        Self {
139            ch: StyledChar {
140                value: CompactString::const_new(""),
141                full_width: false,
142                styles: empty_styles(),
143            },
144            hole: true,
145        }
146    }
147
148    fn is_placeholder(&self) -> bool {
149        // A hole is not a wide-char placeholder; only a real ''-valued cell is.
150        !self.hole && self.ch.value.is_empty()
151    }
152}
153
154// ─── Grid ────────────────────────────────────────────────────────────────────
155
156/// Plain-frame character grid.
157///
158/// Initialized to spaces (output.ts:141-156: each cell = `{value:' ', fullWidth:false}`).
159/// Rows are indexed from top (row 0 = topmost visible line).
160///
161/// `cols` is the *initial* row width (ink's `Output.width`). Individual rows
162/// may grow past `cols` when a write lands at a column beyond the initial
163/// width — mirroring JS sparse-array growth in `Output.get()`. Skipped indices
164/// become holes ([`Cell::hole`]) and are dropped at assembly.
165pub struct Grid {
166    rows: usize,
167    cells: Vec<Vec<Cell>>,
168    clip_stack: Vec<Clip>,
169}
170
171impl Grid {
172    /// Create a `rows × cols` grid filled with spaces.
173    ///
174    /// Mirrors `Output` constructor (output.ts:98-103) + the `get()` pre-fill
175    /// loop (output.ts:141-156).
176    pub fn new(rows: usize, cols: usize) -> Self {
177        let cells = (0..rows)
178            .map(|_| (0..cols).map(|_| Cell::space()).collect())
179            .collect();
180        Self {
181            rows,
182            cells,
183            clip_stack: Vec::new(),
184        }
185    }
186
187    // ── Clip stack (output.ts:126-137) ───────────────────────────────────────
188
189    /// Push a clip rectangle (output.ts:126-131).
190    pub fn push_clip(&mut self, clip: Clip) {
191        self.clip_stack.push(clip);
192    }
193
194    /// Pop the most recent clip rectangle (output.ts:133-137).
195    pub fn pop_clip(&mut self) {
196        self.clip_stack.pop();
197    }
198
199    // ── Write (output.ts:105-124, 169-302) ───────────────────────────────────
200
201    /// Write `text` at grid position `(x, y)`, applying the current clip.
202    ///
203    /// Plain (no-transformer) entry point — equivalent to
204    /// `write_styled(x, y, text, &[])`. Used by `render_border` and any caller
205    /// that has no SGR transform to thread.
206    pub fn write(&mut self, x: i32, y: i32, text: &str) {
207        self.write_styled(x, y, text, &[]);
208    }
209
210    /// Write `text` at grid position `(x, y)`, applying the current clip then
211    /// the `transformers` chain (innermost-first), then blitting styled chars.
212    ///
213    /// `text` may contain `\n` to write multiple lines; each line is placed at
214    /// `(x, y + line_index)`.
215    ///
216    /// Mirrors `Output.write` (output.ts:105-124) + the per-operation handler
217    /// inside `get()` (output.ts:169-302). In the plain slice, write is
218    /// synchronous (no operation queue).
219    pub fn write_styled(&mut self, x: i32, y: i32, text: &str, transformers: &[Transformer<'_>]) {
220        // output.ts:113-115: skip empty text.
221        if text.is_empty() {
222            return;
223        }
224
225        let clip = self.clip_stack.last().copied();
226        // Owned lines: the horizontal-clip `slice_ansi` produces `String`s, and
227        // the per-line transformer chain rebinds each line to a fresh `String`.
228        let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
229
230        // ── Clip pre-checks (output.ts:175-225) ─────────────────────────────
231        if let Some(clip) = clip {
232            let clip_h = clip.x1.is_some() && clip.x2.is_some();
233            let clip_v = clip.y1.is_some() && clip.y2.is_some();
234
235            // output.ts:185-199: skip if entirely outside clip region.
236            if clip_h {
237                // widest-line width (output.ts:188)
238                let w = lines
239                    .iter()
240                    .map(|l| string_width(l) as i32)
241                    .max()
242                    .unwrap_or(0);
243                let x1 = clip.x1.unwrap();
244                let x2 = clip.x2.unwrap();
245                if x + w < x1 || x > x2 {
246                    return;
247                }
248            }
249            if clip_v {
250                let height = lines.len() as i32;
251                let y1 = clip.y1.unwrap();
252                let y2 = clip.y2.unwrap();
253                if y + height < y1 || y > y2 {
254                    return;
255                }
256            }
257        }
258
259        // Mutable copies of x/y for clip-adjusted position (output.ts:171).
260        let mut eff_x = x;
261        let mut eff_y = y;
262
263        if let Some(clip) = clip {
264            let clip_h = clip.x1.is_some() && clip.x2.is_some();
265            let clip_v = clip.y1.is_some() && clip.y2.is_some();
266
267            // output.ts:201-213: horizontal clip — slice each line.
268            if clip_h {
269                let x1 = clip.x1.unwrap();
270                let x2 = clip.x2.unwrap();
271                lines = lines
272                    .iter()
273                    .map(|line| {
274                        // output.ts:202-208: sliceAnsi(line, from, to) by visible width.
275                        let from = if x < x1 { (x1 - x) as usize } else { 0 };
276                        let line_w = string_width(line) as i32;
277                        let to = if x + line_w > x2 {
278                            (x2 - x) as usize
279                        } else {
280                            line_w as usize
281                        };
282                        slice_ansi(line, from, Some(to))
283                    })
284                    .collect();
285                if x < x1 {
286                    eff_x = x1;
287                }
288            }
289
290            // output.ts:215-225: vertical clip — trim lines.
291            if clip_v {
292                let y1 = clip.y1.unwrap();
293                let y2 = clip.y2.unwrap();
294                let from = if eff_y < y1 { (y1 - eff_y) as usize } else { 0 };
295                let height = lines.len() as i32;
296                let to = if eff_y + height > y2 {
297                    (y2 - eff_y) as usize
298                } else {
299                    lines.len()
300                };
301                // output.ts:220 `lines.slice(from, to)`: JS slice CLAMPS — it
302                // returns [] when from > to (degenerate/inverted clip, e.g.
303                // y1 > y2 with a partially spanning write, where the
304                // pre-checks above still pass). Clamp the Rust range the same
305                // way instead of panicking; for every valid clip from ≤ to,
306                // so this is behavior-neutral there.
307                let to = to.min(lines.len());
308                lines = lines[from.min(to)..to].to_vec();
309                if eff_y < y1 {
310                    eff_y = y1;
311                }
312            }
313        }
314
315        // ── Place each line into the grid (output.ts:228-302) ────────────────
316        for (line_idx, line) in lines.iter().enumerate() {
317            let row_y = eff_y + line_idx as i32;
318            if row_y < 0 || row_y as usize >= self.rows {
319                continue; // output.ts:231-233: skip if row missing.
320            }
321
322            // output.ts:238-240: apply the transformer chain innermost-first.
323            // Each transformer takes `(line, index)`; `index` is the per-write
324            // line position (`line_idx`), matching ink's `lines.entries()`.
325            let mut transformed = line.clone();
326            for transformer in transformers {
327                transformed = transformer(&transformed, line_idx);
328            }
329
330            // output.ts:242: tokenize the (post-transform) line into StyledChars.
331            //
332            // Fast path: a line with no SGR/OSC opener (no ESC U+001B / C1 CSI
333            // U+009B) tokenizes to pure `Token::Char`s with empty styles, so the
334            // fused `styled_chars_from_plain` builds the same `Vec<StyledChar>`
335            // in one grapheme walk — skipping the intermediate `Vec<Token>` and
336            // the per-grapheme `CharToken.value` String. Most grid writes (plain
337            // text content, unstyled fills) take this path; styled lines (border
338            // SGR, JS-side colorize transforms) fall back to the full tokenizer.
339            let chars = if transformed.contains(['\u{1B}', '\u{9B}']) {
340                styled_chars_from_tokens(&tokenize(&transformed, None))
341            } else {
342                styled_chars_from_plain(&transformed)
343            };
344
345            // output.ts:246-249: nothing to write (e.g. clipped/transformed away).
346            if chars.is_empty() {
347                continue;
348            }
349
350            let row = &mut self.cells[row_y as usize];
351            let mut offset_x = eff_x;
352
353            // output.ts:263-270: wide-char leader cleanup before first write.
354            // If we are about to write into a placeholder cell, blank the
355            // preceding leader so the terminal never renders half a wide char.
356            if offset_x > 0 {
357                let col = offset_x as usize;
358                if col < row.len()
359                    && row[col].is_placeholder()
360                    && string_width(&row[col - 1].ch.value) > 1
361                {
362                    row[col - 1] = Cell::space();
363                }
364            }
365
366            // Write each styled char.
367            //
368            // output.ts assigns `currentLine[offsetX] = character` with NO
369            // upper bound: in JS this grows the row array, leaving any skipped
370            // index as a hole. We mirror that with `grow_to`, which extends the
371            // row with `Cell::hole` so a write past the initial width never
372            // clips — it materializes the column (and any gap before it).
373            for ch in chars {
374                // output.ts:276-279: printed width via string-width on the
375                // VALUE (not the `full_width` flag) to align with measurement.
376                let char_w = string_width(&ch.value).max(1);
377
378                if offset_x < 0 {
379                    // Advance without writing (clipped horizontally).
380                    offset_x += char_w as i32;
381                    continue;
382                }
383                let col = offset_x as usize;
384
385                // output.ts:272-273: place the styled char.
386                grow_to(row, col);
387
388                // output.ts:282-291: fill trailing placeholder cells for wide
389                // chars (e.g. CJK), inheriting the lead char's styles.
390                //
391                // The lead styles only feed the `1..char_w` placeholder loop,
392                // which is empty for width-1 graphemes (every ASCII/box-drawing
393                // glyph). Snapshot them ONLY when there are placeholders to fill
394                // so the common width-1 path never clones the style Vec.
395                if char_w > 1 {
396                    // Share the lead char's style run into each placeholder by
397                    // `Rc::clone` (refcount bump, zero heap) instead of a deep
398                    // `Vec<AnsiToken>` copy.
399                    let lead_styles = Rc::clone(&ch.styles);
400                    row[col] = Cell { ch, hole: false };
401                    for extra in 1..char_w {
402                        let next_col = col + extra;
403                        grow_to(row, next_col);
404                        row[next_col] = Cell::placeholder(Rc::clone(&lead_styles));
405                    }
406                } else {
407                    row[col] = Cell { ch, hole: false };
408                }
409
410                offset_x += char_w as i32;
411            }
412
413            // output.ts:296-299: wide-char trailer cleanup after last write.
414            // If the cell immediately after what we wrote is a placeholder,
415            // blank it (the wide char it belonged to was overwritten).
416            let after = offset_x as usize;
417            if after < row.len() && row[after].is_placeholder() {
418                row[after] = Cell::space();
419            }
420        }
421    }
422
423    // ── Get (output.ts:139-318) ───────────────────────────────────────────────
424
425    /// Serialize the grid to a string.
426    ///
427    /// Each row is right-trimmed (output.ts:309-310:
428    /// `styledCharsToString(lineWithoutEmptyItems).trimEnd()`),
429    /// then rows are joined with `\n`.
430    ///
431    /// Returns `(output_string, height)` mirroring ink's `{output, height}`.
432    /// Height is always `self.rows` (the pre-initialized row count —
433    /// output.ts:315-316).
434    pub fn get(&self) -> (String, usize) {
435        // Single reused buffer across every row. Each row is serialized in place
436        // and its trailing spaces truncated before the next `\n` separator, so
437        // the whole frame is built in ONE growing allocation instead of a fresh
438        // trimmed `String` per row (~one alloc per grid row, every frame).
439        let mut output = String::new();
440        for (row_idx, row) in self.cells.iter().enumerate() {
441            // Rows are joined with `\n` (output.ts joins lines): emit the
442            // separator BEFORE every row but the first.
443            if row_idx != 0 {
444                output.push('\n');
445            }
446            // Mark where this row's serialized segment begins so we trim only
447            // its own trailing spaces (never the prior row or the separator).
448            let row_start = output.len();
449
450            // output.ts:308: `line.filter(item => item !== undefined)` —
451            // drop JS sparse-array holes (cells past the initial width that
452            // were never written). Wide-char placeholders (value "") are
453            // NOT holes and survive the filter, exactly as in ink.
454            let survivors = row.iter().filter(|c| !c.hole).map(|c| &c.ch);
455            // output.ts:310: styledCharsToString(...).trimEnd().
456            //
457            // No-byte-movement invariant: for a colorless row every cell's
458            // `styles` is empty, so the serializer degenerates to a plain
459            // concatenation of `.value` (no SGR opened or closed) —
460            // byte-identical to the old plain path. The trailing trim below
461            // uses JS `trimEnd`'s exact whitespace set (see
462            // [`is_js_trim_end_whitespace`]), collapsing the styled trailing
463            // spaces a colored frame would otherwise carry (a styled tail
464            // ends in an SGR close byte, which is not whitespace, so the
465            // serialize-then-trim order keeps the #119 contract intact).
466            //
467            // Borrowed serialization: `survivors` yields `&StyledChar` straight
468            // from the grid cells, so the per-cell `ch.clone()` is gone — the
469            // serializer appends the borrows directly into the shared buffer.
470            styled_chars_to_string_into(survivors, &mut output);
471
472            // In-place trim of this row's segment with JS `trimEnd` semantics
473            // (probe-verified: ink passes `\t`/NBSP/U+3000/thin-space through
474            // layout untouched — "A\tB" survives interior — and `trimEnd` at
475            // output.ts:310 then strips them from the tail, so a 0x20-only
476            // trim diverged byte-wise; task #123). `trim_end_matches` removes
477            // whole `char`s, so the boundary always lands on a UTF-8 char
478            // boundary and `truncate` is sound. Never trims past `row_start`,
479            // so an empty / all-space row collapses to "" exactly as the
480            // oracle's, leaving earlier rows untouched.
481            let trimmed_len = output[row_start..]
482                .trim_end_matches(is_js_trim_end_whitespace)
483                .len();
484            output.truncate(row_start + trimmed_len);
485        }
486        (output, self.rows)
487    }
488}
489
490// ─── Test-only accessors ─────────────────────────────────────────────────────
491
492#[cfg(test)]
493impl Grid {
494    /// Return the raw content (grapheme value) of the cell at `(row, col)`.
495    ///
496    /// Exposed only for tests that need to inspect cell state before `get()`
497    /// applies `trimEnd` (e.g. to verify wide-char trailer cleanup).
498    pub fn cell_content(&self, row: usize, col: usize) -> &str {
499        &self.cells[row][col].ch.value
500    }
501
502    /// Return the accumulated `styles` of the cell at `(row, col)`.
503    ///
504    /// Exposed only for tests asserting style inheritance (e.g. wide-char
505    /// trailing placeholders inherit the lead char's styles).
506    pub fn cell_styles(&self, row: usize, col: usize) -> &[crate::text::ansi_tokenize::AnsiToken] {
507        &self.cells[row][col].ch.styles
508    }
509}
510
511// ─── Helpers ─────────────────────────────────────────────────────────────────
512
513/// JS `String.prototype.trimEnd`'s exact whitespace set (ECMA-262
514/// `TrimString`: WhiteSpace ∪ LineTerminator) — the set ink's row trim at
515/// output.ts:310 uses. Probe-enumerated against Node (task #123):
516/// `\t \n \v \f \r 0x20 U+00A0 U+1680 U+2000–U+200A U+2028 U+2029 U+202F
517/// U+205F U+3000 U+FEFF` are trimmed; `U+0085` (NEL), `U+180E`, `U+200B`
518/// are kept.
519///
520/// Rust's `char::is_whitespace` (Unicode `White_Space`) differs from that
521/// oracle set in exactly two chars: it INCLUDES U+0085 (NEL, not JS
522/// whitespace) and EXCLUDES U+FEFF (BOM/ZWNBSP, which JS trims). We match
523/// the ORACLE bytes, not either spec ideal.
524fn is_js_trim_end_whitespace(c: char) -> bool {
525    c == '\u{FEFF}' || (c != '\u{0085}' && c.is_whitespace())
526}
527
528/// Extend `row` with holes so index `col` is addressable.
529///
530/// Mirrors JS sparse-array growth in `Output.get()`: assigning
531/// `currentLine[offsetX]` for `offsetX >= row.len()` grows the array, leaving
532/// any skipped index as `undefined`. We materialize those skipped indices as
533/// [`Cell::hole`] so they can be filtered out at assembly. No-op when `col`
534/// is already in bounds.
535fn grow_to(row: &mut Vec<Cell>, col: usize) {
536    while row.len() <= col {
537        row.push(Cell::hole());
538    }
539}
540
541// ─── Tests ───────────────────────────────────────────────────────────────────
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    // ── grid primitives ──────────────────────────────────────────────────────
548
549    // A fresh 3×5 grid: all spaces, trimEnd gives empty per row.
550    // output.ts:141-156: initialized to spaces; output.ts:309-310: trimEnd.
551    #[test]
552    fn empty_grid_trims_to_empty_lines() {
553        let g = Grid::new(3, 5);
554        let (out, h) = g.get();
555        assert_eq!(h, 3);
556        // Each of 3 rows is all spaces → trimEnd → empty → "\n\n"
557        assert_eq!(out, "\n\n");
558    }
559
560    // ── styled-char grid (M2-D) ──────────────────────────────────────────────
561
562    // (a) Two-deep transformer chain: own-before-inherited with the correct
563    // per-line index. Oracle-pinned via ink's Output class:
564    //   out.write(0,0,'ab\ncd',{transformers:[inner,outer]}) →
565    //   "O0{I0{ab}}\nO1{I1{cd}}" (scratch probe in /home/alpha/rewrite/ink,
566    //   deleted after). The chain is `[inner, outer]` and applied
567    //   `for t in transformers { line = t(line, index) }`, so the innermost
568    //   (own) transform runs FIRST and `index` is the per-write line position.
569    #[test]
570    fn transformer_chain_own_before_inherited_with_index() {
571        let inner = |s: &str, i: usize| format!("I{i}{{{s}}}");
572        let outer = |s: &str, i: usize| format!("O{i}{{{s}}}");
573        let chain: [Transformer<'_>; 2] = [&inner, &outer];
574
575        let mut g = Grid::new(2, 40);
576        g.write_styled(0, 0, "ab\ncd", &chain);
577        let (out, _) = g.get();
578        assert_eq!(out, "O0{I0{ab}}\nO1{I1{cd}}");
579    }
580
581    // (b) Wide-char trailing placeholder inherits the lead char's styles
582    // (output.ts:284-289: `{value:'', styles: character.styles}`). Write a red
583    // "中" (width 2): col 0 is the red lead, col 1 the trailing placeholder —
584    // its `styles` must equal the lead's (the red SGR token), NOT empty.
585    #[test]
586    fn wide_char_placeholder_inherits_lead_styles() {
587        let mut g = Grid::new(1, 6);
588        g.write(0, 0, "\x1b[31m中\x1b[39m");
589        // Lead at col 0: value "中", one red style.
590        assert_eq!(g.cell_content(0, 0), "中");
591        let lead_styles = g.cell_styles(0, 0).to_vec();
592        assert_eq!(lead_styles.len(), 1, "lead carries the red SGR");
593        assert_eq!(lead_styles[0].code, "\x1b[31m");
594        // Placeholder at col 1: value "", styles inherited from the lead.
595        assert_eq!(g.cell_content(0, 1), "", "col 1 is a wide-char placeholder");
596        assert_eq!(
597            g.cell_styles(0, 1),
598            lead_styles.as_slice(),
599            "placeholder inherits the lead char's styles (output.ts:288)"
600        );
601    }
602
603    // (c) A styled trailing-space line trims byte-identically to the plain path.
604    // The colorized CONTENT ("Hi" in red) is followed by the grid's own
605    // UNSTYLED pad spaces (Cell::space, styles=[]); `get()`'s trimEnd-set
606    // trim removes those trailing spaces, leaving exactly the
607    // closed red span — byte-identical to ink (no dangling trailing spaces, no
608    // SGR re-open over the pad). This is the real grid state: trailing spaces
609    // are unstyled, so they collapse just like the plain frame.
610    #[test]
611    fn styled_trailing_spaces_trim_byte_identical() {
612        let red = |s: &str, _i: usize| format!("\x1b[31m{s}\x1b[39m");
613        let chain: [Transformer<'_>; 1] = [&red];
614
615        let mut g = Grid::new(1, 10);
616        g.write_styled(0, 0, "Hi", &chain); // cols 2..9 stay unstyled spaces
617        let (out, _) = g.get();
618        // Closed red span, no trailing spaces, no SGR over the pad.
619        assert_eq!(out, "\x1b[31mHi\x1b[39m");
620    }
621
622    // (d) Task #119: a trailing space that CARRIES styling is CONTENT — it must
623    // survive `get()`'s trim. Oracle (output.ts:310): `styledCharsToString(...)
624    // .trimEnd()` serializes FIRST, so a style-bearing space ends in its SGR
625    // close code (`\x1b[27m`), never in a literal space — `trimEnd` cannot
626    // reach it. This is ink-text-input's inverse-video cursor cell
627    // (`chalk.inverse(' ')` after the value). Oracle-captured (live ink 7.0.5,
628    // fake non-TTY stdout): `AB` + inverse space → "AB\x1b[7m \x1b[27m"
629    // byte-exact, even with unstyled pad cells after it.
630    //
631    // MUTATION (verified): trimming trailing space CELLS before serialization
632    // regardless of `styles` collapses this to "AB" and flips both asserts,
633    // while (c)'s unstyled-pad control above stays green.
634    #[test]
635    fn styled_trailing_space_survives_trim() {
636        // Grid pad cells (cols 5..9) are unstyled spaces: trimmed as usual.
637        let mut g = Grid::new(1, 10);
638        g.write(0, 0, "AB\x1b[7m \x1b[27m");
639        let (out, _) = g.get();
640        assert_eq!(
641            out, "AB\x1b[7m \x1b[27m",
642            "style-bearing trailing space survives; unstyled pad is trimmed"
643        );
644
645        // Mixed tail: explicit UNSTYLED spaces written after the styled one
646        // are still trimmed — the trim rule keys on styling, not position.
647        let mut g2 = Grid::new(1, 10);
648        g2.write(0, 0, "AB\x1b[7m \x1b[27m  ");
649        let (out2, _) = g2.get();
650        assert_eq!(
651            out2, "AB\x1b[7m \x1b[27m",
652            "unstyled spaces after the styled space are trimmed exactly as before"
653        );
654    }
655
656    // (e) Task #123: the tail trim uses JS `trimEnd`'s WHOLE whitespace set,
657    // not just 0x20. Oracle-captured (live ink 7.0.5 build, fake non-TTY
658    // stdout, /tmp/t123 probes): `<Text>` tails of `\t`, NBSP (U+00A0),
659    // ideographic space (U+3000), thin space (U+2009), the mixed `"  \t"`
660    // tail, and ZWNBSP/BOM (U+FEFF) ALL wrote exactly "AB\n" — and the
661    // interior control `"A\tB"` survived ink's layout verbatim ("A\tB\n"),
662    // proving the chars reach the composed row and are removed by
663    // output.ts:310's `.trimEnd()`, not normalized earlier.
664    //
665    // MUTATION (verified): reverting the trim to `trim_end_matches(' ')`
666    // keeps every one of these tails (the mixed case even keeps the 0x20s
667    // BEFORE the `\t`) and flips all six asserts.
668    #[test]
669    fn row_tail_trims_full_js_trim_end_set() {
670        let cases: [(&str, &str); 6] = [
671            ("AB\t", "tab"),
672            ("AB\u{a0}", "nbsp"),
673            ("AB\u{3000}", "ideographic space"),
674            ("AB\u{2009}", "thin space"),
675            ("AB  \t", "mixed space+tab tail"),
676            (
677                "AB\u{feff}",
678                "ZWNBSP/BOM (JS trims; Rust is_whitespace does NOT)",
679            ),
680        ];
681        for (text, name) in cases {
682            let mut g = Grid::new(1, 20);
683            g.write(0, 0, text);
684            let (out, _) = g.get();
685            assert_eq!(out, "AB", "oracle trims the {name} tail to \"AB\"");
686        }
687    }
688
689    // (f) Task #123 inverse controls: chars JS `trimEnd` KEEPS must survive.
690    // Node-enumerated oracle set (probe /tmp/t123): U+200B ZWSP and U+0085 NEL
691    // are NOT JS whitespace — `"AB\u{200b}".trimEnd()` keeps both. The
692    // end-to-end oracle keeps ZWSP ("AB\u{200b}\n"); NEL never reaches the
693    // row in ink AT ALL (sanitize-ansi.ts strips standalone C1 controls at
694    // the squash boundary — an upstream seam, not the trim; at THIS seam the
695    // trim must mirror `trimEnd` and keep it).
696    //
697    // MUTATION (verified): trimming with plain `char::is_whitespace` (Unicode
698    // White_Space) removes NEL — flipping the NEL assert — while (e)'s U+FEFF
699    // case flips under the same mutation in the other direction (kept when it
700    // must be trimmed). Together (e)+(f) pin the exact two-char delta between
701    // the JS set and Rust's.
702    #[test]
703    fn row_tail_keeps_non_js_whitespace() {
704        let cases: [(&str, &str, &str); 2] = [
705            ("AB\u{200b}", "AB\u{200b}", "ZWSP"),
706            ("AB\u{85}", "AB\u{85}", "NEL (C1; JS trimEnd keeps it)"),
707        ];
708        for (text, expected, name) in cases {
709            let mut g = Grid::new(1, 20);
710            g.write(0, 0, text);
711            let (out, _) = g.get();
712            assert_eq!(out, expected, "{name} tail is NOT JS whitespace — kept");
713        }
714    }
715
716    // (g) Task #123 × #119: a STYLED non-0x20 whitespace tail is content.
717    // Oracle-captured (live ink 7.0.5, FORCE_COLOR=3, fake non-TTY stdout):
718    // `<Text>AB<Text inverse>{NBSP}</Text></Text>` wrote exactly
719    // "AB\x1b[7m\u{a0}\x1b[27m\n" — serialize-then-trim means the styled NBSP
720    // ends in the SGR close byte, out of `trimEnd`'s reach, identical to the
721    // #119 styled-space contract.
722    #[test]
723    fn styled_trailing_nbsp_survives_trim() {
724        let mut g = Grid::new(1, 10);
725        g.write(0, 0, "AB\x1b[7m\u{a0}\x1b[27m");
726        let (out, _) = g.get();
727        assert_eq!(
728            out, "AB\x1b[7m\u{a0}\x1b[27m",
729            "style-bearing trailing NBSP survives; unstyled pad is trimmed"
730        );
731    }
732
733    // Write "Hi" at (0,0) and check it appears.
734    #[test]
735    fn write_simple_text() {
736        let mut g = Grid::new(2, 10);
737        g.write(0, 0, "Hi");
738        let (out, _) = g.get();
739        let lines: Vec<&str> = out.split('\n').collect();
740        assert_eq!(lines[0], "Hi");
741    }
742
743    // Write at x=2 to check offset.
744    #[test]
745    fn write_at_offset_x() {
746        let mut g = Grid::new(1, 10);
747        g.write(2, 0, "AB");
748        let (out, _) = g.get();
749        assert_eq!(out, "  AB");
750    }
751
752    // Write two lines via \n.
753    #[test]
754    fn write_multiline() {
755        let mut g = Grid::new(3, 10);
756        g.write(0, 0, "line1\nline2");
757        let (out, _) = g.get();
758        let lines: Vec<&str> = out.split('\n').collect();
759        assert_eq!(lines[0], "line1");
760        assert_eq!(lines[1], "line2");
761    }
762
763    // ── wide-char cleanup (output.ts:263-299 citations) ─────────────────────
764
765    // Place a CJK char "中" (width 2) at col 0; then write "X" at col 1.
766    // output.ts:263-270: writing at col 1 finds placeholder at col 1 and
767    // blanks the leader at col 0.
768    // ink:output.ts: "if (currentLine[offsetX]?.value === '' && offsetX > 0 &&
769    //   this.caches.getStringWidth(currentLine[offsetX - 1]?.value ?? '') > 1)"
770    //   → currentLine[offsetX - 1] = spaceCell;
771    #[test]
772    fn wide_char_leader_blanked_on_overwrite() {
773        let mut g = Grid::new(1, 6);
774        g.write(0, 0, "中"); // "中" occupies cols 0 and 1 (width 2)
775        g.write(1, 0, "X"); // writes at placeholder col 1 → leader col 0 must become ' '
776        let (out, _) = g.get();
777        // col 0 → ' ' (blanked leader), col 1 → 'X'
778        assert_eq!(&out[..2], " X");
779    }
780
781    // Place "中" at col 2 then write "Y" at col 2 (overwriting the leader).
782    // Before writing: col 2 holds "中" (not a placeholder), so leader-blank
783    // does NOT fire. After writing "Y" (width 1), the next cell at col 3 is
784    // still the old placeholder → trailer-blank fires: output.ts:296-299.
785    // Assert against the raw cells (not get() output) to avoid trimEnd
786    // consuming the blanked trailing space at col 3 before we can check it.
787    // output.ts:296-299: "if (currentLine[offsetX]?.value === '') currentLine[offsetX] = spaceCell"
788    #[test]
789    fn wide_char_trailer_blanked_after_write() {
790        let mut g = Grid::new(1, 8);
791        g.write(2, 0, "中"); // "中" = cols 2 (leader), 3 (placeholder)
792        // Write "Y" at col 2 — overwrites leader; col 3 placeholder remains.
793        // The trailer-blank (output.ts:296-299) must convert col 3 from
794        // placeholder ("") to space (" ").
795        g.write(2, 0, "Y");
796        // Inspect raw cells via the test accessor to bypass trimEnd.
797        assert_eq!(
798            g.cell_content(0, 2),
799            "Y",
800            "col 2 must hold the written char"
801        );
802        assert_eq!(
803            g.cell_content(0, 3),
804            " ",
805            "col 3 placeholder must be blanked to space"
806        );
807    }
808
809    // CJK char at clip boundary: "中" spans cols 4-5 in a grid clipped at x2=5.
810    // The right edge of the clip cuts through the wide char, leaving only the
811    // leader visible. The trailing placeholder at col 5 gets blanked by the
812    // clip logic (the write is clipped at col 5 so col 5 placeholder is not
813    // filled with the wide char's placeholder, it stays space).
814    // Hand-derived: grid width=8, clip x1=0 x2=5, write "中" at x=4.
815    // sliceAnsi("中", from=0, to=1) → "" (first grapheme has width 2 > 1 col).
816    // The char is wider than the remaining clip space → nothing placed past x2.
817    #[test]
818    fn wide_char_clipped_at_boundary() {
819        let mut g = Grid::new(1, 8);
820        g.push_clip(Clip {
821            x1: Some(0),
822            x2: Some(5),
823            y1: None,
824            y2: None,
825        });
826        g.write(4, 0, "中"); // "中" needs 2 cols, only 1 remains within clip
827        g.pop_clip();
828        let (out, _) = g.get();
829        // col 4 should remain ' ' (can't fit width-2 char in 1-col space)
830        // or "中" is placed but clipped to 1 col — either way col 4-5 are not
831        // a half-rendered wide char.
832        // slice_ansi("中", 0, Some(1)) returns "" because the grapheme's
833        // width 2 overshoots to=1, so we get empty string: col 4 stays space.
834        assert!(!out.contains('\0'), "no null bytes");
835        // At minimum, the grid must not contain a dangling placeholder.
836        let cells: Vec<&str> = g.cells[0].iter().map(|c| c.ch.value.as_str()).collect();
837        assert!(
838            !cells[5].is_empty() || cells[4] != "中",
839            "no dangling wide-char placeholder at boundary"
840        );
841    }
842
843    // ── clip stack ───────────────────────────────────────────────────────────
844
845    // Write outside the horizontal clip region: nothing should appear.
846    // output.ts:185-199 — skip if entirely outside clip.
847    #[test]
848    fn clip_horizontal_skips_entirely_outside() {
849        let mut g = Grid::new(1, 20);
850        g.push_clip(Clip {
851            x1: Some(5),
852            x2: Some(10),
853            y1: None,
854            y2: None,
855        });
856        g.write(15, 0, "hello"); // entirely to the right of x2=10
857        g.pop_clip();
858        let (out, _) = g.get();
859        assert_eq!(out, ""); // all spaces → trimEnd → ""
860    }
861
862    // Write that straddles the left edge of the clip: trimmed on the left.
863    // output.ts:201-213: clip from x1.
864    #[test]
865    fn clip_horizontal_trims_left() {
866        let mut g = Grid::new(1, 20);
867        g.push_clip(Clip {
868            x1: Some(3),
869            x2: Some(10),
870            y1: None,
871            y2: None,
872        });
873        g.write(1, 0, "ABCDE"); // starts at x=1; clip starts at x1=3
874        // Visible: from = 3-1 = 2 → "CDE"
875        g.pop_clip();
876        let (out, _) = g.get();
877        let expected: String = "   CDE".to_owned(); // 3 spaces then "CDE"
878        assert_eq!(out, expected);
879    }
880
881    // Vertical clip skips rows outside range.
882    // output.ts:215-225.
883    #[test]
884    fn clip_vertical_skips_rows_outside() {
885        let mut g = Grid::new(4, 10);
886        g.push_clip(Clip {
887            x1: None,
888            x2: None,
889            y1: Some(1),
890            y2: Some(2),
891        });
892        g.write(0, 0, "row0\nrow1\nrow2\nrow3"); // all 4 rows
893        g.pop_clip();
894        let (out, _) = g.get();
895        let lines: Vec<&str> = out.split('\n').collect();
896        assert_eq!(lines[0], ""); // row 0: outside clip, stays empty
897        assert_eq!(lines[1], "row1"); // inside clip
898        assert_eq!(lines[2], ""); // row 2: y2=2 means exclusive, so row2 is outside
899        assert_eq!(lines[3], ""); // row 3: outside
900    }
901
902    // Pop clip restores previous state.
903    #[test]
904    fn clip_push_pop_restores() {
905        let mut g = Grid::new(1, 20);
906        g.push_clip(Clip {
907            x1: Some(5),
908            x2: Some(10),
909            y1: None,
910            y2: None,
911        });
912        g.pop_clip();
913        // After pop, no clip — write should succeed anywhere.
914        g.write(0, 0, "hello");
915        let (out, _) = g.get();
916        assert!(out.starts_with("hello"));
917    }
918
919    // ── trimEnd semantics (output.ts:309-310) ────────────────────────────────
920
921    // A row with trailing spaces must be trimmed.
922    #[test]
923    fn get_trims_trailing_spaces() {
924        let mut g = Grid::new(1, 10);
925        g.write(0, 0, "Hi"); // cols 2-9 remain spaces
926        let (out, _) = g.get();
927        assert_eq!(out, "Hi");
928    }
929
930    // A row with only spaces trims to empty string.
931    #[test]
932    fn get_all_spaces_trims_to_empty() {
933        let g = Grid::new(1, 5);
934        let (out, _) = g.get();
935        assert_eq!(out, "");
936    }
937
938    // ── off-grid clip behavior ────────────────────────────────────────────────
939
940    // write(-1, 0, "AB"): x starts at -1 so "A" (width 1) is consumed off-left
941    // without being placed; offset_x advances to 0 and "B" lands at col 0.
942    // Pins: off-left graphemes are clipped (skipped), not panicked or dropped.
943    #[test]
944    fn write_negative_x_clips_left_edge() {
945        let mut g = Grid::new(1, 5);
946        g.write(-1, 0, "AB");
947        // "A" consumed off-left (x=-1 → advance to 0), "B" placed at col 0.
948        assert_eq!(g.cell_content(0, 0), "B", "B must land at col 0");
949        // col 1 onwards untouched — remains space.
950        assert_eq!(g.cell_content(0, 1), " ", "col 1 must stay space");
951    }
952
953    // write longer than cols: ink's Output.get() has NO right-edge clip — a
954    // write past the initial width grows the JS row array. 1×3 grid, write
955    // "ABCDE" at x=0 → row grows to 5 cells, all written, get() gives "ABCDE".
956    // This is the content-extent (off-grid) semantics: the grid materializes
957    // every written column, never clips at the initial width.
958    // ink:output.ts:272-273 `currentLine[offsetX] = character` (unbounded).
959    #[test]
960    fn write_over_width_grows_row_no_right_clip() {
961        let mut g = Grid::new(1, 3);
962        g.write(0, 0, "ABCDE"); // 5 chars into 3-wide grid — grows to 5 cols
963        assert_eq!(g.cell_content(0, 0), "A");
964        assert_eq!(g.cell_content(0, 4), "E");
965        let (out, _) = g.get();
966        assert_eq!(out, "ABCDE");
967    }
968
969    // ── off-grid overlap: last-writer-wins (output.ts:272-273) ───────────────
970
971    // Two overlapping writes: the later write's cells overwrite the earlier
972    // one cell-for-cell (output.ts assigns `currentLine[offsetX] = character`
973    // unconditionally, so the last write at a column wins).
974    #[test]
975    fn off_grid_overlap_last_writer_wins() {
976        let mut g = Grid::new(1, 6);
977        g.write(0, 0, "ABCDEF");
978        g.write(2, 0, "xy"); // overwrites cols 2,3
979        let (out, _) = g.get();
980        assert_eq!(out, "ABxyEF");
981    }
982
983    // ── jagged out-of-bounds row shape (byte match to ink artifact) ──────────
984
985    // Reproduce ink's overflow.tsx "out of bounds writes do not crash" row
986    // shape WITHOUT a width formula — purely via the real blit + hole filter.
987    // Grid is width=10 (the viewport), height=3. Border writes (from
988    // render-border.ts) for a width-12 box:
989    //   top    "╭──────────╮" (12 chars) at (0,0)  → cols 0..=11 all written
990    //   left   "│"            at (0,1)             → col 0
991    //   right  "│"            at (11,1)            → col 11; col 10 SKIPPED → hole
992    //   bottom "╰──────────╯" (12 chars) at (0,2)  → cols 0..=11 all written
993    // get() drops the col-10 hole on the interior row → 11 chars, but keeps
994    // every (written) column on top/bottom → 12 chars. ASYMMETRIC by artifact,
995    // not by formula.
996    #[test]
997    fn jagged_oob_row_shape_byte_match() {
998        let mut g = Grid::new(3, 10);
999        g.write(0, 0, "╭──────────╮"); // top: 12 wide
1000        g.write(0, 1, "│"); // left border, interior row
1001        g.write(11, 1, "│"); // right border at col 11 → col 10 is a hole
1002        g.write(0, 2, "╰──────────╯"); // bottom: 12 wide
1003        let (out, _) = g.get();
1004        let lines: Vec<&str> = out.split('\n').collect();
1005        assert_eq!(lines[0], "╭──────────╮", "top row materializes 12 cols");
1006        assert_eq!(
1007            lines[1], "│         │",
1008            "interior row drops the col-10 hole → 11 cols (│ + 9 spaces + │)"
1009        );
1010        assert_eq!(lines[2], "╰──────────╯", "bottom row materializes 12 cols");
1011        // The interior right border survives at col 11 (the col-10 hole is
1012        // filtered, so it renders as the 11th visible char).
1013        assert_eq!(lines[1].chars().count(), 11);
1014        assert_eq!(lines[0].chars().count(), 12);
1015    }
1016
1017    // ── innermost-clip-only nesting (output.ts:174 `clips.at(-1)`) ───────────
1018
1019    // ink applies ONLY the innermost clip; the active clip is always the top
1020    // of stack. Push outer {0,8} then inner {0,2}; a write under the inner
1021    // clip yields "AB". After popping the inner clip, the SAME write now sees
1022    // only the outer {0,8} → "CDEFGH" survives where the inner clip had
1023    // dropped it — proving pop restores the ancestor as the active (sole) clip.
1024    #[test]
1025    fn innermost_clip_is_top_of_stack_after_pop() {
1026        let mut g = Grid::new(2, 10);
1027        g.push_clip(Clip {
1028            x1: Some(0),
1029            x2: Some(8),
1030            y1: None,
1031            y2: None,
1032        });
1033        g.push_clip(Clip {
1034            x1: Some(0),
1035            x2: Some(2),
1036            y1: None,
1037            y2: None,
1038        });
1039        g.write(0, 0, "ABCDEFGH"); // innermost {0,2} → row 0 = "AB"
1040        g.pop_clip();
1041        g.write(0, 1, "ABCDEFGH"); // now outer {0,8} → row 1 = "ABCDEFGH"
1042        g.pop_clip();
1043        let (out, _) = g.get();
1044        let lines: Vec<&str> = out.split('\n').collect();
1045        assert_eq!(lines[0], "AB", "under inner clip {{0,2}}");
1046        assert_eq!(
1047            lines[1], "ABCDEFGH",
1048            "after pop, outer {{0,8}} alone is the active clip"
1049        );
1050    }
1051
1052    // Companion: prove the innermost clip can be WIDER than an ancestor — an
1053    // intersection model would wrongly narrow it. Outer {0,2}, inner {0,8};
1054    // a write spanning 0..8 must yield the full 8 chars (innermost wins),
1055    // NOT 2 (which an ancestor-intersection would force).
1056    #[test]
1057    fn innermost_clip_wider_than_ancestor_wins() {
1058        let mut g = Grid::new(1, 10);
1059        g.push_clip(Clip {
1060            x1: Some(0),
1061            x2: Some(2),
1062            y1: None,
1063            y2: None,
1064        });
1065        g.push_clip(Clip {
1066            x1: Some(0),
1067            x2: Some(8),
1068            y1: None,
1069            y2: None,
1070        });
1071        g.write(0, 0, "ABCDEFGH");
1072        g.pop_clip();
1073        g.pop_clip();
1074        let (out, _) = g.get();
1075        assert_eq!(
1076            out, "ABCDEFGH",
1077            "innermost {{0,8}} wins over wider ancestor {{0,2}}"
1078        );
1079    }
1080
1081    // ── degenerate / inverted clip rects (direct Grid API hardening) ─────────
1082    //
1083    // Oracle: live ink Output probe (/tmp/ink-degenerate-clip-probe.mjs against
1084    // /home/alpha/rewrite/ink/build/output.js, 2026-06-10). Every degenerate
1085    // shape below produced `output="\n\n\n\n"` (a 5-row grid with NOTHING
1086    // written) and no throw, while positive controls (valid clip / no clip)
1087    // wrote normally. Source derivation: output.ts:220 `lines.slice(from, to)`
1088    // — JS Array.prototype.slice returns [] when from > to; output.ts:207
1089    // `sliceAnsi(line, from, to)` returns '' when from > to. Degenerate clip
1090    // ⇒ empty visible region ⇒ the write contributes nothing.
1091
1092    // y-inverted clip (y1=2 > y2=1) partially spanned by the write: the exact
1093    // pre-fix panic shape. With y=1, height=3 the pre-checks pass
1094    // (y+height=4 ≥ y1, y=1 ≤ y2), then from = y1-y = 1, to = y2-y = 0 and
1095    // `lines[1..0]` panicked: "slice index starts at 1 but ends at 0".
1096    // Discriminates: the vertical-clip range must clamp like JS slice
1097    // (output.ts:220 → []) instead of panicking. PANICS pre-fix.
1098    #[test]
1099    fn clip_y_inverted_partial_span_writes_nothing() {
1100        let mut g = Grid::new(5, 10);
1101        g.push_clip(Clip {
1102            x1: None,
1103            x2: None,
1104            y1: Some(2),
1105            y2: Some(1),
1106        });
1107        g.write(0, 1, "aa\nbb\ncc");
1108        g.pop_clip();
1109        let (out, h) = g.get();
1110        assert_eq!(h, 5);
1111        // ink probe: output="\n\n\n\n" — nothing written.
1112        assert_eq!(out, "\n\n\n\n");
1113    }
1114
1115    // x-inverted clip (x1=5 > x2=2) partially spanned by the write (x=2,
1116    // width=4 passes both pre-checks). from = x1-x = 3 > to = x2-x = 0;
1117    // sliceAnsi(line, 3, 0) === '' (output.ts:207; slice-ansi returns '' for
1118    // begin > end) → every line empties → nothing placed.
1119    // Discriminates: a horizontal-clip regression that underflowed/swapped the
1120    // slice bounds and emitted text (or panicked) instead of an empty line.
1121    #[test]
1122    fn clip_x_inverted_partial_span_writes_nothing() {
1123        let mut g = Grid::new(5, 10);
1124        g.push_clip(Clip {
1125            x1: Some(5),
1126            x2: Some(2),
1127            y1: None,
1128            y2: None,
1129        });
1130        g.write(2, 0, "abcd");
1131        g.pop_clip();
1132        let (out, _) = g.get();
1133        // ink probe: output="\n\n\n\n" — nothing written.
1134        assert_eq!(out, "\n\n\n\n");
1135    }
1136
1137    // Valid (non-inverted) clip lying entirely OUTSIDE the grid bounds
1138    // (y1=100..y2=200 on a 5-row grid), write straddling its top edge.
1139    // The clip slice keeps lines but bumps y to y1=100 (output.ts:222-223);
1140    // every target row is missing → per-row skip (output.ts:231-233 /
1141    // grid.rs row bounds check) → nothing written, no panic.
1142    // Discriminates: out-of-grid clip-adjusted rows must be skipped, not
1143    // indexed (a direct `self.cells[row_y]` without the bounds check panics).
1144    #[test]
1145    fn clip_fully_outside_grid_writes_nothing() {
1146        let mut g = Grid::new(5, 10);
1147        g.push_clip(Clip {
1148            x1: None,
1149            x2: None,
1150            y1: Some(100),
1151            y2: Some(200),
1152        });
1153        g.write(0, 99, "aa\nbb\ncc");
1154        g.pop_clip();
1155        let (out, _) = g.get();
1156        // ink probe: output="\n\n\n\n" — nothing written.
1157        assert_eq!(out, "\n\n\n\n");
1158    }
1159
1160    // Zero-area clips: x1==x2, y1==y2, and both. ink's clip bounds behave
1161    // half-open here: from == to on each axis → sliceAnsi(line, n, n) === ''
1162    // and lines.slice(n, n) === [] → empty visible region.
1163    // Discriminates: an off-by-one in the clamp (e.g. treating x1==x2 / y1==y2
1164    // as a 1-cell/1-row window) would write a column or row where ink writes
1165    // nothing.
1166    #[test]
1167    fn clip_zero_area_writes_nothing() {
1168        // x1==x2==2 (ink probe: nothing written).
1169        let mut g = Grid::new(5, 10);
1170        g.push_clip(Clip {
1171            x1: Some(2),
1172            x2: Some(2),
1173            y1: None,
1174            y2: None,
1175        });
1176        g.write(0, 0, "abcd");
1177        g.pop_clip();
1178        assert_eq!(g.get().0, "\n\n\n\n", "zero-area x clip");
1179
1180        // y1==y2==1 (ink probe: nothing written).
1181        let mut g = Grid::new(5, 10);
1182        g.push_clip(Clip {
1183            x1: None,
1184            x2: None,
1185            y1: Some(1),
1186            y2: Some(1),
1187        });
1188        g.write(0, 0, "aa\nbb\ncc");
1189        g.pop_clip();
1190        assert_eq!(g.get().0, "\n\n\n\n", "zero-area y clip");
1191
1192        // Both axes zero-area (ink probe: nothing written).
1193        let mut g = Grid::new(5, 10);
1194        g.push_clip(Clip {
1195            x1: Some(2),
1196            x2: Some(2),
1197            y1: Some(1),
1198            y2: Some(1),
1199        });
1200        g.write(0, 0, "abcd\nefgh\nijkl");
1201        g.pop_clip();
1202        assert_eq!(g.get().0, "\n\n\n\n", "zero-area x+y clip");
1203    }
1204}