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}