inkferro-core 0.1.0

Layout, text measurement, ANSI render, and frame-diff engine for inkferro — a Rust-backed, byte-for-byte drop-in for the ink terminal UI library.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
//! Char grid — plain-frame output assembly.
//!
//! Port of ink's `output.ts` (Output class) — **plain-frame slice only**:
//! no SGR transformers, no style application. The grid holds raw grapheme
//! clusters and produces a trimmed string on `get()`.
//!
//! # Design decisions
//!
//! ## Cell representation
//! ink's `StyledChar` (output.ts:141-151) stores `{type,value,fullWidth,styles}`.
//! The plain slice drops `styles` (no SGR) and folds `fullWidth` into a
//! `wide_placeholder` bool: a cell with `value == ""` is a trailing placeholder
//! for the preceding wide character.
//!
//! ## Wide-char cleanup (output.ts:263-299 citation)
//! When a write lands at a column that is currently a wide-char placeholder
//! (cell.value == "" and cell[x-1] has visual width > 1), ink blanks the
//! **leader** cell before writing (output.ts:263-270). After writing, if the
//! cell immediately after the last written cell is a placeholder, ink blanks it
//! too (output.ts:296-299). We mirror both cleanups exactly.
//!
//! ## Clip-rectangle stack
//! ink uses an operation queue with `clip` / `unclip` ops (output.ts:24-48,
//! 158-226). The plain slice keeps a `Vec<Clip>` stack that is pushed/popped
//! synchronously at write time — same semantics, simpler code.
//!
//! ## Trailing-whitespace trimming (output.ts:305-312 citation)
//! ```ts
//! return styledCharsToString(lineWithoutEmptyItems).trimEnd();
//! ```
//! Each row is right-trimmed before joining with `\n`. We do the same with
//! `trim_end_matches(is_js_trim_end_whitespace)` — JS `trimEnd`'s exact
//! whitespace set, NOT Rust's `char::is_whitespace` (task #123; see the
//! helper's doc for the two-char delta).

use std::rc::Rc;

use compact_str::CompactString;

use crate::text::ansi_tokenize::{
    AnsiToken, StyledChar, empty_styles, styled_chars_from_plain, styled_chars_from_tokens,
    styled_chars_to_string_into, tokenize,
};
use crate::text::slice_ansi::slice_ansi;
use crate::text::string_width::string_width;

/// A post-clip text transformer: `(line, line_index) -> line`.
///
/// Mirrors ink's `OutputTransformer = (s, index) => string`
/// (render-node-to-output.ts:30). The render/napi layer builds the chain
/// `[own, ...inherited]` (render-node-to-output.ts:134-138) — in inkferro the
/// per-node `own` transform (Text.tsx's `colorize`/chalk closure) stays JS-side
/// and is mapped onto this type by the napi boundary. The walk merely THREADS an
/// inherited slice parent→child→write; the write path applies the chain
/// innermost-first AFTER the clip-slice (output.ts:238-240). Borrowed `dyn Fn`
/// refs dodge the `Clone` problem (closures are not `Clone`); the chain is a
/// slice of references rebuilt at each level.
pub type Transformer<'a> = &'a dyn Fn(&str, usize) -> String;

// ─── Clip ────────────────────────────────────────────────────────────────────

/// Axis-independent clip boundary.
///
/// Mirrors ink's `Clip` type (output.ts:39-44).
/// `None` means "no clip on this axis" (output.ts:177-178:
/// `typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'`).
#[derive(Debug, Clone, Copy, Default)]
pub struct Clip {
    pub x1: Option<i32>,
    pub x2: Option<i32>,
    pub y1: Option<i32>,
    pub y2: Option<i32>,
}

// ─── Cell ────────────────────────────────────────────────────────────────────

/// One terminal cell in the grid.
///
/// `ch` holds the cell's [`StyledChar`] (grapheme `value` + accumulated
/// `styles`). A `value == ""` signals a wide-char trailing placeholder
/// (output.ts:282-290: value='', fullWidth=false, styles=lead.styles).
///
/// `hole` models a JS sparse-array gap. ink's `Output.get()` pre-fills a row
/// with exactly `width` space cells, but writes assign `currentLine[offsetX]`
/// directly — for `offsetX >= width` this *grows* the backing JS array,
/// leaving any skipped index as `undefined` (a hole). At assembly, ink does
/// `line.filter(item => item !== undefined)` (output.ts:308) before
/// `trimEnd`, which DROPS those holes mid-row. A space cell, by contrast,
/// survives the filter and is only removed by `trimEnd` if trailing.
///
/// This distinction is load-bearing for the out-of-bounds artifact: a
/// width-12 box in a width-10 grid materializes a 12-wide top/bottom row
/// (every column written) but 11-wide interior rows (col 10 never written →
/// hole → filtered, while the right border at col 11 survives).
#[derive(Debug, Clone)]
struct Cell {
    /// The styled grapheme in this cell. `value == ""` = wide-char placeholder.
    ch: StyledChar,
    /// True if this cell is a JS sparse-array hole (`undefined`): never
    /// written, beyond the pre-initialized width. Dropped by `get()`'s filter.
    hole: bool,
}

impl Cell {
    /// A space cell, ink's `spaceCell` (output.ts:251-256:
    /// `{value:' ', fullWidth:false, styles:[]}`).
    fn space() -> Self {
        Self {
            ch: StyledChar {
                // `const_new` inlines the 1-byte fill at compile time: a
                // colorless frame's ~900 space cells allocate nothing.
                value: CompactString::const_new(" "),
                full_width: false,
                // Shared empty-style sentinel (Rc::clone, zero heap) so the
                // ~900 space cells stay allocation-free under the Rc styles.
                styles: empty_styles(),
            },
            hole: false,
        }
    }

    /// A wide-char trailing placeholder inheriting the lead char's `styles`
    /// (output.ts:284-289: `{value:'', fullWidth:false, styles:character.styles}`).
    fn placeholder(styles: Rc<[AnsiToken]>) -> Self {
        Self {
            ch: StyledChar {
                value: CompactString::const_new(""),
                full_width: false,
                styles,
            },
            hole: false,
        }
    }

    /// A JS sparse-array gap (`undefined`): exists only to pad a row out to a
    /// written index past the pre-initialized width. Filtered out by `get()`.
    fn hole() -> Self {
        Self {
            ch: StyledChar {
                value: CompactString::const_new(""),
                full_width: false,
                styles: empty_styles(),
            },
            hole: true,
        }
    }

    fn is_placeholder(&self) -> bool {
        // A hole is not a wide-char placeholder; only a real ''-valued cell is.
        !self.hole && self.ch.value.is_empty()
    }
}

// ─── Grid ────────────────────────────────────────────────────────────────────

/// Plain-frame character grid.
///
/// Initialized to spaces (output.ts:141-156: each cell = `{value:' ', fullWidth:false}`).
/// Rows are indexed from top (row 0 = topmost visible line).
///
/// `cols` is the *initial* row width (ink's `Output.width`). Individual rows
/// may grow past `cols` when a write lands at a column beyond the initial
/// width — mirroring JS sparse-array growth in `Output.get()`. Skipped indices
/// become holes ([`Cell::hole`]) and are dropped at assembly.
pub struct Grid {
    rows: usize,
    cells: Vec<Vec<Cell>>,
    clip_stack: Vec<Clip>,
}

impl Grid {
    /// Create a `rows × cols` grid filled with spaces.
    ///
    /// Mirrors `Output` constructor (output.ts:98-103) + the `get()` pre-fill
    /// loop (output.ts:141-156).
    pub fn new(rows: usize, cols: usize) -> Self {
        let cells = (0..rows)
            .map(|_| (0..cols).map(|_| Cell::space()).collect())
            .collect();
        Self {
            rows,
            cells,
            clip_stack: Vec::new(),
        }
    }

    // ── Clip stack (output.ts:126-137) ───────────────────────────────────────

    /// Push a clip rectangle (output.ts:126-131).
    pub fn push_clip(&mut self, clip: Clip) {
        self.clip_stack.push(clip);
    }

    /// Pop the most recent clip rectangle (output.ts:133-137).
    pub fn pop_clip(&mut self) {
        self.clip_stack.pop();
    }

    // ── Write (output.ts:105-124, 169-302) ───────────────────────────────────

    /// Write `text` at grid position `(x, y)`, applying the current clip.
    ///
    /// Plain (no-transformer) entry point — equivalent to
    /// `write_styled(x, y, text, &[])`. Used by `render_border` and any caller
    /// that has no SGR transform to thread.
    pub fn write(&mut self, x: i32, y: i32, text: &str) {
        self.write_styled(x, y, text, &[]);
    }

    /// Write `text` at grid position `(x, y)`, applying the current clip then
    /// the `transformers` chain (innermost-first), then blitting styled chars.
    ///
    /// `text` may contain `\n` to write multiple lines; each line is placed at
    /// `(x, y + line_index)`.
    ///
    /// Mirrors `Output.write` (output.ts:105-124) + the per-operation handler
    /// inside `get()` (output.ts:169-302). In the plain slice, write is
    /// synchronous (no operation queue).
    pub fn write_styled(&mut self, x: i32, y: i32, text: &str, transformers: &[Transformer<'_>]) {
        // output.ts:113-115: skip empty text.
        if text.is_empty() {
            return;
        }

        let clip = self.clip_stack.last().copied();
        // Owned lines: the horizontal-clip `slice_ansi` produces `String`s, and
        // the per-line transformer chain rebinds each line to a fresh `String`.
        let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();

        // ── Clip pre-checks (output.ts:175-225) ─────────────────────────────
        if let Some(clip) = clip {
            let clip_h = clip.x1.is_some() && clip.x2.is_some();
            let clip_v = clip.y1.is_some() && clip.y2.is_some();

            // output.ts:185-199: skip if entirely outside clip region.
            if clip_h {
                // widest-line width (output.ts:188)
                let w = lines
                    .iter()
                    .map(|l| string_width(l) as i32)
                    .max()
                    .unwrap_or(0);
                let x1 = clip.x1.unwrap();
                let x2 = clip.x2.unwrap();
                if x + w < x1 || x > x2 {
                    return;
                }
            }
            if clip_v {
                let height = lines.len() as i32;
                let y1 = clip.y1.unwrap();
                let y2 = clip.y2.unwrap();
                if y + height < y1 || y > y2 {
                    return;
                }
            }
        }

        // Mutable copies of x/y for clip-adjusted position (output.ts:171).
        let mut eff_x = x;
        let mut eff_y = y;

        if let Some(clip) = clip {
            let clip_h = clip.x1.is_some() && clip.x2.is_some();
            let clip_v = clip.y1.is_some() && clip.y2.is_some();

            // output.ts:201-213: horizontal clip — slice each line.
            if clip_h {
                let x1 = clip.x1.unwrap();
                let x2 = clip.x2.unwrap();
                lines = lines
                    .iter()
                    .map(|line| {
                        // output.ts:202-208: sliceAnsi(line, from, to) by visible width.
                        let from = if x < x1 { (x1 - x) as usize } else { 0 };
                        let line_w = string_width(line) as i32;
                        let to = if x + line_w > x2 {
                            (x2 - x) as usize
                        } else {
                            line_w as usize
                        };
                        slice_ansi(line, from, Some(to))
                    })
                    .collect();
                if x < x1 {
                    eff_x = x1;
                }
            }

            // output.ts:215-225: vertical clip — trim lines.
            if clip_v {
                let y1 = clip.y1.unwrap();
                let y2 = clip.y2.unwrap();
                let from = if eff_y < y1 { (y1 - eff_y) as usize } else { 0 };
                let height = lines.len() as i32;
                let to = if eff_y + height > y2 {
                    (y2 - eff_y) as usize
                } else {
                    lines.len()
                };
                // output.ts:220 `lines.slice(from, to)`: JS slice CLAMPS — it
                // returns [] when from > to (degenerate/inverted clip, e.g.
                // y1 > y2 with a partially spanning write, where the
                // pre-checks above still pass). Clamp the Rust range the same
                // way instead of panicking; for every valid clip from ≤ to,
                // so this is behavior-neutral there.
                let to = to.min(lines.len());
                lines = lines[from.min(to)..to].to_vec();
                if eff_y < y1 {
                    eff_y = y1;
                }
            }
        }

        // ── Place each line into the grid (output.ts:228-302) ────────────────
        for (line_idx, line) in lines.iter().enumerate() {
            let row_y = eff_y + line_idx as i32;
            if row_y < 0 || row_y as usize >= self.rows {
                continue; // output.ts:231-233: skip if row missing.
            }

            // output.ts:238-240: apply the transformer chain innermost-first.
            // Each transformer takes `(line, index)`; `index` is the per-write
            // line position (`line_idx`), matching ink's `lines.entries()`.
            let mut transformed = line.clone();
            for transformer in transformers {
                transformed = transformer(&transformed, line_idx);
            }

            // output.ts:242: tokenize the (post-transform) line into StyledChars.
            //
            // Fast path: a line with no SGR/OSC opener (no ESC U+001B / C1 CSI
            // U+009B) tokenizes to pure `Token::Char`s with empty styles, so the
            // fused `styled_chars_from_plain` builds the same `Vec<StyledChar>`
            // in one grapheme walk — skipping the intermediate `Vec<Token>` and
            // the per-grapheme `CharToken.value` String. Most grid writes (plain
            // text content, unstyled fills) take this path; styled lines (border
            // SGR, JS-side colorize transforms) fall back to the full tokenizer.
            let chars = if transformed.contains(['\u{1B}', '\u{9B}']) {
                styled_chars_from_tokens(&tokenize(&transformed, None))
            } else {
                styled_chars_from_plain(&transformed)
            };

            // output.ts:246-249: nothing to write (e.g. clipped/transformed away).
            if chars.is_empty() {
                continue;
            }

            let row = &mut self.cells[row_y as usize];
            let mut offset_x = eff_x;

            // output.ts:263-270: wide-char leader cleanup before first write.
            // If we are about to write into a placeholder cell, blank the
            // preceding leader so the terminal never renders half a wide char.
            if offset_x > 0 {
                let col = offset_x as usize;
                if col < row.len()
                    && row[col].is_placeholder()
                    && string_width(&row[col - 1].ch.value) > 1
                {
                    row[col - 1] = Cell::space();
                }
            }

            // Write each styled char.
            //
            // output.ts assigns `currentLine[offsetX] = character` with NO
            // upper bound: in JS this grows the row array, leaving any skipped
            // index as a hole. We mirror that with `grow_to`, which extends the
            // row with `Cell::hole` so a write past the initial width never
            // clips — it materializes the column (and any gap before it).
            for ch in chars {
                // output.ts:276-279: printed width via string-width on the
                // VALUE (not the `full_width` flag) to align with measurement.
                let char_w = string_width(&ch.value).max(1);

                if offset_x < 0 {
                    // Advance without writing (clipped horizontally).
                    offset_x += char_w as i32;
                    continue;
                }
                let col = offset_x as usize;

                // output.ts:272-273: place the styled char.
                grow_to(row, col);

                // output.ts:282-291: fill trailing placeholder cells for wide
                // chars (e.g. CJK), inheriting the lead char's styles.
                //
                // The lead styles only feed the `1..char_w` placeholder loop,
                // which is empty for width-1 graphemes (every ASCII/box-drawing
                // glyph). Snapshot them ONLY when there are placeholders to fill
                // so the common width-1 path never clones the style Vec.
                if char_w > 1 {
                    // Share the lead char's style run into each placeholder by
                    // `Rc::clone` (refcount bump, zero heap) instead of a deep
                    // `Vec<AnsiToken>` copy.
                    let lead_styles = Rc::clone(&ch.styles);
                    row[col] = Cell { ch, hole: false };
                    for extra in 1..char_w {
                        let next_col = col + extra;
                        grow_to(row, next_col);
                        row[next_col] = Cell::placeholder(Rc::clone(&lead_styles));
                    }
                } else {
                    row[col] = Cell { ch, hole: false };
                }

                offset_x += char_w as i32;
            }

            // output.ts:296-299: wide-char trailer cleanup after last write.
            // If the cell immediately after what we wrote is a placeholder,
            // blank it (the wide char it belonged to was overwritten).
            let after = offset_x as usize;
            if after < row.len() && row[after].is_placeholder() {
                row[after] = Cell::space();
            }
        }
    }

    // ── Get (output.ts:139-318) ───────────────────────────────────────────────

    /// Serialize the grid to a string.
    ///
    /// Each row is right-trimmed (output.ts:309-310:
    /// `styledCharsToString(lineWithoutEmptyItems).trimEnd()`),
    /// then rows are joined with `\n`.
    ///
    /// Returns `(output_string, height)` mirroring ink's `{output, height}`.
    /// Height is always `self.rows` (the pre-initialized row count —
    /// output.ts:315-316).
    pub fn get(&self) -> (String, usize) {
        // Single reused buffer across every row. Each row is serialized in place
        // and its trailing spaces truncated before the next `\n` separator, so
        // the whole frame is built in ONE growing allocation instead of a fresh
        // trimmed `String` per row (~one alloc per grid row, every frame).
        let mut output = String::new();
        for (row_idx, row) in self.cells.iter().enumerate() {
            // Rows are joined with `\n` (output.ts joins lines): emit the
            // separator BEFORE every row but the first.
            if row_idx != 0 {
                output.push('\n');
            }
            // Mark where this row's serialized segment begins so we trim only
            // its own trailing spaces (never the prior row or the separator).
            let row_start = output.len();

            // output.ts:308: `line.filter(item => item !== undefined)` —
            // drop JS sparse-array holes (cells past the initial width that
            // were never written). Wide-char placeholders (value "") are
            // NOT holes and survive the filter, exactly as in ink.
            let survivors = row.iter().filter(|c| !c.hole).map(|c| &c.ch);
            // output.ts:310: styledCharsToString(...).trimEnd().
            //
            // No-byte-movement invariant: for a colorless row every cell's
            // `styles` is empty, so the serializer degenerates to a plain
            // concatenation of `.value` (no SGR opened or closed) —
            // byte-identical to the old plain path. The trailing trim below
            // uses JS `trimEnd`'s exact whitespace set (see
            // [`is_js_trim_end_whitespace`]), collapsing the styled trailing
            // spaces a colored frame would otherwise carry (a styled tail
            // ends in an SGR close byte, which is not whitespace, so the
            // serialize-then-trim order keeps the #119 contract intact).
            //
            // Borrowed serialization: `survivors` yields `&StyledChar` straight
            // from the grid cells, so the per-cell `ch.clone()` is gone — the
            // serializer appends the borrows directly into the shared buffer.
            styled_chars_to_string_into(survivors, &mut output);

            // In-place trim of this row's segment with JS `trimEnd` semantics
            // (probe-verified: ink passes `\t`/NBSP/U+3000/thin-space through
            // layout untouched — "A\tB" survives interior — and `trimEnd` at
            // output.ts:310 then strips them from the tail, so a 0x20-only
            // trim diverged byte-wise; task #123). `trim_end_matches` removes
            // whole `char`s, so the boundary always lands on a UTF-8 char
            // boundary and `truncate` is sound. Never trims past `row_start`,
            // so an empty / all-space row collapses to "" exactly as the
            // oracle's, leaving earlier rows untouched.
            let trimmed_len = output[row_start..]
                .trim_end_matches(is_js_trim_end_whitespace)
                .len();
            output.truncate(row_start + trimmed_len);
        }
        (output, self.rows)
    }
}

// ─── Test-only accessors ─────────────────────────────────────────────────────

#[cfg(test)]
impl Grid {
    /// Return the raw content (grapheme value) of the cell at `(row, col)`.
    ///
    /// Exposed only for tests that need to inspect cell state before `get()`
    /// applies `trimEnd` (e.g. to verify wide-char trailer cleanup).
    pub fn cell_content(&self, row: usize, col: usize) -> &str {
        &self.cells[row][col].ch.value
    }

    /// Return the accumulated `styles` of the cell at `(row, col)`.
    ///
    /// Exposed only for tests asserting style inheritance (e.g. wide-char
    /// trailing placeholders inherit the lead char's styles).
    pub fn cell_styles(&self, row: usize, col: usize) -> &[crate::text::ansi_tokenize::AnsiToken] {
        &self.cells[row][col].ch.styles
    }
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

/// JS `String.prototype.trimEnd`'s exact whitespace set (ECMA-262
/// `TrimString`: WhiteSpace ∪ LineTerminator) — the set ink's row trim at
/// output.ts:310 uses. Probe-enumerated against Node (task #123):
/// `\t \n \v \f \r 0x20 U+00A0 U+1680 U+2000–U+200A U+2028 U+2029 U+202F
/// U+205F U+3000 U+FEFF` are trimmed; `U+0085` (NEL), `U+180E`, `U+200B`
/// are kept.
///
/// Rust's `char::is_whitespace` (Unicode `White_Space`) differs from that
/// oracle set in exactly two chars: it INCLUDES U+0085 (NEL, not JS
/// whitespace) and EXCLUDES U+FEFF (BOM/ZWNBSP, which JS trims). We match
/// the ORACLE bytes, not either spec ideal.
fn is_js_trim_end_whitespace(c: char) -> bool {
    c == '\u{FEFF}' || (c != '\u{0085}' && c.is_whitespace())
}

/// Extend `row` with holes so index `col` is addressable.
///
/// Mirrors JS sparse-array growth in `Output.get()`: assigning
/// `currentLine[offsetX]` for `offsetX >= row.len()` grows the array, leaving
/// any skipped index as `undefined`. We materialize those skipped indices as
/// [`Cell::hole`] so they can be filtered out at assembly. No-op when `col`
/// is already in bounds.
fn grow_to(row: &mut Vec<Cell>, col: usize) {
    while row.len() <= col {
        row.push(Cell::hole());
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── grid primitives ──────────────────────────────────────────────────────

    // A fresh 3×5 grid: all spaces, trimEnd gives empty per row.
    // output.ts:141-156: initialized to spaces; output.ts:309-310: trimEnd.
    #[test]
    fn empty_grid_trims_to_empty_lines() {
        let g = Grid::new(3, 5);
        let (out, h) = g.get();
        assert_eq!(h, 3);
        // Each of 3 rows is all spaces → trimEnd → empty → "\n\n"
        assert_eq!(out, "\n\n");
    }

    // ── styled-char grid (M2-D) ──────────────────────────────────────────────

    // (a) Two-deep transformer chain: own-before-inherited with the correct
    // per-line index. Oracle-pinned via ink's Output class:
    //   out.write(0,0,'ab\ncd',{transformers:[inner,outer]}) →
    //   "O0{I0{ab}}\nO1{I1{cd}}" (scratch probe in /home/alpha/rewrite/ink,
    //   deleted after). The chain is `[inner, outer]` and applied
    //   `for t in transformers { line = t(line, index) }`, so the innermost
    //   (own) transform runs FIRST and `index` is the per-write line position.
    #[test]
    fn transformer_chain_own_before_inherited_with_index() {
        let inner = |s: &str, i: usize| format!("I{i}{{{s}}}");
        let outer = |s: &str, i: usize| format!("O{i}{{{s}}}");
        let chain: [Transformer<'_>; 2] = [&inner, &outer];

        let mut g = Grid::new(2, 40);
        g.write_styled(0, 0, "ab\ncd", &chain);
        let (out, _) = g.get();
        assert_eq!(out, "O0{I0{ab}}\nO1{I1{cd}}");
    }

    // (b) Wide-char trailing placeholder inherits the lead char's styles
    // (output.ts:284-289: `{value:'', styles: character.styles}`). Write a red
    // "中" (width 2): col 0 is the red lead, col 1 the trailing placeholder —
    // its `styles` must equal the lead's (the red SGR token), NOT empty.
    #[test]
    fn wide_char_placeholder_inherits_lead_styles() {
        let mut g = Grid::new(1, 6);
        g.write(0, 0, "\x1b[31m中\x1b[39m");
        // Lead at col 0: value "中", one red style.
        assert_eq!(g.cell_content(0, 0), "");
        let lead_styles = g.cell_styles(0, 0).to_vec();
        assert_eq!(lead_styles.len(), 1, "lead carries the red SGR");
        assert_eq!(lead_styles[0].code, "\x1b[31m");
        // Placeholder at col 1: value "", styles inherited from the lead.
        assert_eq!(g.cell_content(0, 1), "", "col 1 is a wide-char placeholder");
        assert_eq!(
            g.cell_styles(0, 1),
            lead_styles.as_slice(),
            "placeholder inherits the lead char's styles (output.ts:288)"
        );
    }

    // (c) A styled trailing-space line trims byte-identically to the plain path.
    // The colorized CONTENT ("Hi" in red) is followed by the grid's own
    // UNSTYLED pad spaces (Cell::space, styles=[]); `get()`'s trimEnd-set
    // trim removes those trailing spaces, leaving exactly the
    // closed red span — byte-identical to ink (no dangling trailing spaces, no
    // SGR re-open over the pad). This is the real grid state: trailing spaces
    // are unstyled, so they collapse just like the plain frame.
    #[test]
    fn styled_trailing_spaces_trim_byte_identical() {
        let red = |s: &str, _i: usize| format!("\x1b[31m{s}\x1b[39m");
        let chain: [Transformer<'_>; 1] = [&red];

        let mut g = Grid::new(1, 10);
        g.write_styled(0, 0, "Hi", &chain); // cols 2..9 stay unstyled spaces
        let (out, _) = g.get();
        // Closed red span, no trailing spaces, no SGR over the pad.
        assert_eq!(out, "\x1b[31mHi\x1b[39m");
    }

    // (d) Task #119: a trailing space that CARRIES styling is CONTENT — it must
    // survive `get()`'s trim. Oracle (output.ts:310): `styledCharsToString(...)
    // .trimEnd()` serializes FIRST, so a style-bearing space ends in its SGR
    // close code (`\x1b[27m`), never in a literal space — `trimEnd` cannot
    // reach it. This is ink-text-input's inverse-video cursor cell
    // (`chalk.inverse(' ')` after the value). Oracle-captured (live ink 7.0.5,
    // fake non-TTY stdout): `AB` + inverse space → "AB\x1b[7m \x1b[27m"
    // byte-exact, even with unstyled pad cells after it.
    //
    // MUTATION (verified): trimming trailing space CELLS before serialization
    // regardless of `styles` collapses this to "AB" and flips both asserts,
    // while (c)'s unstyled-pad control above stays green.
    #[test]
    fn styled_trailing_space_survives_trim() {
        // Grid pad cells (cols 5..9) are unstyled spaces: trimmed as usual.
        let mut g = Grid::new(1, 10);
        g.write(0, 0, "AB\x1b[7m \x1b[27m");
        let (out, _) = g.get();
        assert_eq!(
            out, "AB\x1b[7m \x1b[27m",
            "style-bearing trailing space survives; unstyled pad is trimmed"
        );

        // Mixed tail: explicit UNSTYLED spaces written after the styled one
        // are still trimmed — the trim rule keys on styling, not position.
        let mut g2 = Grid::new(1, 10);
        g2.write(0, 0, "AB\x1b[7m \x1b[27m  ");
        let (out2, _) = g2.get();
        assert_eq!(
            out2, "AB\x1b[7m \x1b[27m",
            "unstyled spaces after the styled space are trimmed exactly as before"
        );
    }

    // (e) Task #123: the tail trim uses JS `trimEnd`'s WHOLE whitespace set,
    // not just 0x20. Oracle-captured (live ink 7.0.5 build, fake non-TTY
    // stdout, /tmp/t123 probes): `<Text>` tails of `\t`, NBSP (U+00A0),
    // ideographic space (U+3000), thin space (U+2009), the mixed `"  \t"`
    // tail, and ZWNBSP/BOM (U+FEFF) ALL wrote exactly "AB\n" — and the
    // interior control `"A\tB"` survived ink's layout verbatim ("A\tB\n"),
    // proving the chars reach the composed row and are removed by
    // output.ts:310's `.trimEnd()`, not normalized earlier.
    //
    // MUTATION (verified): reverting the trim to `trim_end_matches(' ')`
    // keeps every one of these tails (the mixed case even keeps the 0x20s
    // BEFORE the `\t`) and flips all six asserts.
    #[test]
    fn row_tail_trims_full_js_trim_end_set() {
        let cases: [(&str, &str); 6] = [
            ("AB\t", "tab"),
            ("AB\u{a0}", "nbsp"),
            ("AB\u{3000}", "ideographic space"),
            ("AB\u{2009}", "thin space"),
            ("AB  \t", "mixed space+tab tail"),
            (
                "AB\u{feff}",
                "ZWNBSP/BOM (JS trims; Rust is_whitespace does NOT)",
            ),
        ];
        for (text, name) in cases {
            let mut g = Grid::new(1, 20);
            g.write(0, 0, text);
            let (out, _) = g.get();
            assert_eq!(out, "AB", "oracle trims the {name} tail to \"AB\"");
        }
    }

    // (f) Task #123 inverse controls: chars JS `trimEnd` KEEPS must survive.
    // Node-enumerated oracle set (probe /tmp/t123): U+200B ZWSP and U+0085 NEL
    // are NOT JS whitespace — `"AB\u{200b}".trimEnd()` keeps both. The
    // end-to-end oracle keeps ZWSP ("AB\u{200b}\n"); NEL never reaches the
    // row in ink AT ALL (sanitize-ansi.ts strips standalone C1 controls at
    // the squash boundary — an upstream seam, not the trim; at THIS seam the
    // trim must mirror `trimEnd` and keep it).
    //
    // MUTATION (verified): trimming with plain `char::is_whitespace` (Unicode
    // White_Space) removes NEL — flipping the NEL assert — while (e)'s U+FEFF
    // case flips under the same mutation in the other direction (kept when it
    // must be trimmed). Together (e)+(f) pin the exact two-char delta between
    // the JS set and Rust's.
    #[test]
    fn row_tail_keeps_non_js_whitespace() {
        let cases: [(&str, &str, &str); 2] = [
            ("AB\u{200b}", "AB\u{200b}", "ZWSP"),
            ("AB\u{85}", "AB\u{85}", "NEL (C1; JS trimEnd keeps it)"),
        ];
        for (text, expected, name) in cases {
            let mut g = Grid::new(1, 20);
            g.write(0, 0, text);
            let (out, _) = g.get();
            assert_eq!(out, expected, "{name} tail is NOT JS whitespace — kept");
        }
    }

    // (g) Task #123 × #119: a STYLED non-0x20 whitespace tail is content.
    // Oracle-captured (live ink 7.0.5, FORCE_COLOR=3, fake non-TTY stdout):
    // `<Text>AB<Text inverse>{NBSP}</Text></Text>` wrote exactly
    // "AB\x1b[7m\u{a0}\x1b[27m\n" — serialize-then-trim means the styled NBSP
    // ends in the SGR close byte, out of `trimEnd`'s reach, identical to the
    // #119 styled-space contract.
    #[test]
    fn styled_trailing_nbsp_survives_trim() {
        let mut g = Grid::new(1, 10);
        g.write(0, 0, "AB\x1b[7m\u{a0}\x1b[27m");
        let (out, _) = g.get();
        assert_eq!(
            out, "AB\x1b[7m\u{a0}\x1b[27m",
            "style-bearing trailing NBSP survives; unstyled pad is trimmed"
        );
    }

    // Write "Hi" at (0,0) and check it appears.
    #[test]
    fn write_simple_text() {
        let mut g = Grid::new(2, 10);
        g.write(0, 0, "Hi");
        let (out, _) = g.get();
        let lines: Vec<&str> = out.split('\n').collect();
        assert_eq!(lines[0], "Hi");
    }

    // Write at x=2 to check offset.
    #[test]
    fn write_at_offset_x() {
        let mut g = Grid::new(1, 10);
        g.write(2, 0, "AB");
        let (out, _) = g.get();
        assert_eq!(out, "  AB");
    }

    // Write two lines via \n.
    #[test]
    fn write_multiline() {
        let mut g = Grid::new(3, 10);
        g.write(0, 0, "line1\nline2");
        let (out, _) = g.get();
        let lines: Vec<&str> = out.split('\n').collect();
        assert_eq!(lines[0], "line1");
        assert_eq!(lines[1], "line2");
    }

    // ── wide-char cleanup (output.ts:263-299 citations) ─────────────────────

    // Place a CJK char "中" (width 2) at col 0; then write "X" at col 1.
    // output.ts:263-270: writing at col 1 finds placeholder at col 1 and
    // blanks the leader at col 0.
    // ink:output.ts: "if (currentLine[offsetX]?.value === '' && offsetX > 0 &&
    //   this.caches.getStringWidth(currentLine[offsetX - 1]?.value ?? '') > 1)"
    //   → currentLine[offsetX - 1] = spaceCell;
    #[test]
    fn wide_char_leader_blanked_on_overwrite() {
        let mut g = Grid::new(1, 6);
        g.write(0, 0, ""); // "中" occupies cols 0 and 1 (width 2)
        g.write(1, 0, "X"); // writes at placeholder col 1 → leader col 0 must become ' '
        let (out, _) = g.get();
        // col 0 → ' ' (blanked leader), col 1 → 'X'
        assert_eq!(&out[..2], " X");
    }

    // Place "中" at col 2 then write "Y" at col 2 (overwriting the leader).
    // Before writing: col 2 holds "中" (not a placeholder), so leader-blank
    // does NOT fire. After writing "Y" (width 1), the next cell at col 3 is
    // still the old placeholder → trailer-blank fires: output.ts:296-299.
    // Assert against the raw cells (not get() output) to avoid trimEnd
    // consuming the blanked trailing space at col 3 before we can check it.
    // output.ts:296-299: "if (currentLine[offsetX]?.value === '') currentLine[offsetX] = spaceCell"
    #[test]
    fn wide_char_trailer_blanked_after_write() {
        let mut g = Grid::new(1, 8);
        g.write(2, 0, ""); // "中" = cols 2 (leader), 3 (placeholder)
        // Write "Y" at col 2 — overwrites leader; col 3 placeholder remains.
        // The trailer-blank (output.ts:296-299) must convert col 3 from
        // placeholder ("") to space (" ").
        g.write(2, 0, "Y");
        // Inspect raw cells via the test accessor to bypass trimEnd.
        assert_eq!(
            g.cell_content(0, 2),
            "Y",
            "col 2 must hold the written char"
        );
        assert_eq!(
            g.cell_content(0, 3),
            " ",
            "col 3 placeholder must be blanked to space"
        );
    }

    // CJK char at clip boundary: "中" spans cols 4-5 in a grid clipped at x2=5.
    // The right edge of the clip cuts through the wide char, leaving only the
    // leader visible. The trailing placeholder at col 5 gets blanked by the
    // clip logic (the write is clipped at col 5 so col 5 placeholder is not
    // filled with the wide char's placeholder, it stays space).
    // Hand-derived: grid width=8, clip x1=0 x2=5, write "中" at x=4.
    // sliceAnsi("中", from=0, to=1) → "" (first grapheme has width 2 > 1 col).
    // The char is wider than the remaining clip space → nothing placed past x2.
    #[test]
    fn wide_char_clipped_at_boundary() {
        let mut g = Grid::new(1, 8);
        g.push_clip(Clip {
            x1: Some(0),
            x2: Some(5),
            y1: None,
            y2: None,
        });
        g.write(4, 0, ""); // "中" needs 2 cols, only 1 remains within clip
        g.pop_clip();
        let (out, _) = g.get();
        // col 4 should remain ' ' (can't fit width-2 char in 1-col space)
        // or "中" is placed but clipped to 1 col — either way col 4-5 are not
        // a half-rendered wide char.
        // slice_ansi("中", 0, Some(1)) returns "" because the grapheme's
        // width 2 overshoots to=1, so we get empty string: col 4 stays space.
        assert!(!out.contains('\0'), "no null bytes");
        // At minimum, the grid must not contain a dangling placeholder.
        let cells: Vec<&str> = g.cells[0].iter().map(|c| c.ch.value.as_str()).collect();
        assert!(
            !cells[5].is_empty() || cells[4] != "",
            "no dangling wide-char placeholder at boundary"
        );
    }

    // ── clip stack ───────────────────────────────────────────────────────────

    // Write outside the horizontal clip region: nothing should appear.
    // output.ts:185-199 — skip if entirely outside clip.
    #[test]
    fn clip_horizontal_skips_entirely_outside() {
        let mut g = Grid::new(1, 20);
        g.push_clip(Clip {
            x1: Some(5),
            x2: Some(10),
            y1: None,
            y2: None,
        });
        g.write(15, 0, "hello"); // entirely to the right of x2=10
        g.pop_clip();
        let (out, _) = g.get();
        assert_eq!(out, ""); // all spaces → trimEnd → ""
    }

    // Write that straddles the left edge of the clip: trimmed on the left.
    // output.ts:201-213: clip from x1.
    #[test]
    fn clip_horizontal_trims_left() {
        let mut g = Grid::new(1, 20);
        g.push_clip(Clip {
            x1: Some(3),
            x2: Some(10),
            y1: None,
            y2: None,
        });
        g.write(1, 0, "ABCDE"); // starts at x=1; clip starts at x1=3
        // Visible: from = 3-1 = 2 → "CDE"
        g.pop_clip();
        let (out, _) = g.get();
        let expected: String = "   CDE".to_owned(); // 3 spaces then "CDE"
        assert_eq!(out, expected);
    }

    // Vertical clip skips rows outside range.
    // output.ts:215-225.
    #[test]
    fn clip_vertical_skips_rows_outside() {
        let mut g = Grid::new(4, 10);
        g.push_clip(Clip {
            x1: None,
            x2: None,
            y1: Some(1),
            y2: Some(2),
        });
        g.write(0, 0, "row0\nrow1\nrow2\nrow3"); // all 4 rows
        g.pop_clip();
        let (out, _) = g.get();
        let lines: Vec<&str> = out.split('\n').collect();
        assert_eq!(lines[0], ""); // row 0: outside clip, stays empty
        assert_eq!(lines[1], "row1"); // inside clip
        assert_eq!(lines[2], ""); // row 2: y2=2 means exclusive, so row2 is outside
        assert_eq!(lines[3], ""); // row 3: outside
    }

    // Pop clip restores previous state.
    #[test]
    fn clip_push_pop_restores() {
        let mut g = Grid::new(1, 20);
        g.push_clip(Clip {
            x1: Some(5),
            x2: Some(10),
            y1: None,
            y2: None,
        });
        g.pop_clip();
        // After pop, no clip — write should succeed anywhere.
        g.write(0, 0, "hello");
        let (out, _) = g.get();
        assert!(out.starts_with("hello"));
    }

    // ── trimEnd semantics (output.ts:309-310) ────────────────────────────────

    // A row with trailing spaces must be trimmed.
    #[test]
    fn get_trims_trailing_spaces() {
        let mut g = Grid::new(1, 10);
        g.write(0, 0, "Hi"); // cols 2-9 remain spaces
        let (out, _) = g.get();
        assert_eq!(out, "Hi");
    }

    // A row with only spaces trims to empty string.
    #[test]
    fn get_all_spaces_trims_to_empty() {
        let g = Grid::new(1, 5);
        let (out, _) = g.get();
        assert_eq!(out, "");
    }

    // ── off-grid clip behavior ────────────────────────────────────────────────

    // write(-1, 0, "AB"): x starts at -1 so "A" (width 1) is consumed off-left
    // without being placed; offset_x advances to 0 and "B" lands at col 0.
    // Pins: off-left graphemes are clipped (skipped), not panicked or dropped.
    #[test]
    fn write_negative_x_clips_left_edge() {
        let mut g = Grid::new(1, 5);
        g.write(-1, 0, "AB");
        // "A" consumed off-left (x=-1 → advance to 0), "B" placed at col 0.
        assert_eq!(g.cell_content(0, 0), "B", "B must land at col 0");
        // col 1 onwards untouched — remains space.
        assert_eq!(g.cell_content(0, 1), " ", "col 1 must stay space");
    }

    // write longer than cols: ink's Output.get() has NO right-edge clip — a
    // write past the initial width grows the JS row array. 1×3 grid, write
    // "ABCDE" at x=0 → row grows to 5 cells, all written, get() gives "ABCDE".
    // This is the content-extent (off-grid) semantics: the grid materializes
    // every written column, never clips at the initial width.
    // ink:output.ts:272-273 `currentLine[offsetX] = character` (unbounded).
    #[test]
    fn write_over_width_grows_row_no_right_clip() {
        let mut g = Grid::new(1, 3);
        g.write(0, 0, "ABCDE"); // 5 chars into 3-wide grid — grows to 5 cols
        assert_eq!(g.cell_content(0, 0), "A");
        assert_eq!(g.cell_content(0, 4), "E");
        let (out, _) = g.get();
        assert_eq!(out, "ABCDE");
    }

    // ── off-grid overlap: last-writer-wins (output.ts:272-273) ───────────────

    // Two overlapping writes: the later write's cells overwrite the earlier
    // one cell-for-cell (output.ts assigns `currentLine[offsetX] = character`
    // unconditionally, so the last write at a column wins).
    #[test]
    fn off_grid_overlap_last_writer_wins() {
        let mut g = Grid::new(1, 6);
        g.write(0, 0, "ABCDEF");
        g.write(2, 0, "xy"); // overwrites cols 2,3
        let (out, _) = g.get();
        assert_eq!(out, "ABxyEF");
    }

    // ── jagged out-of-bounds row shape (byte match to ink artifact) ──────────

    // Reproduce ink's overflow.tsx "out of bounds writes do not crash" row
    // shape WITHOUT a width formula — purely via the real blit + hole filter.
    // Grid is width=10 (the viewport), height=3. Border writes (from
    // render-border.ts) for a width-12 box:
    //   top    "╭──────────╮" (12 chars) at (0,0)  → cols 0..=11 all written
    //   left   "│"            at (0,1)             → col 0
    //   right  "│"            at (11,1)            → col 11; col 10 SKIPPED → hole
    //   bottom "╰──────────╯" (12 chars) at (0,2)  → cols 0..=11 all written
    // get() drops the col-10 hole on the interior row → 11 chars, but keeps
    // every (written) column on top/bottom → 12 chars. ASYMMETRIC by artifact,
    // not by formula.
    #[test]
    fn jagged_oob_row_shape_byte_match() {
        let mut g = Grid::new(3, 10);
        g.write(0, 0, "╭──────────╮"); // top: 12 wide
        g.write(0, 1, ""); // left border, interior row
        g.write(11, 1, ""); // right border at col 11 → col 10 is a hole
        g.write(0, 2, "╰──────────╯"); // bottom: 12 wide
        let (out, _) = g.get();
        let lines: Vec<&str> = out.split('\n').collect();
        assert_eq!(lines[0], "╭──────────╮", "top row materializes 12 cols");
        assert_eq!(
            lines[1], "│         │",
            "interior row drops the col-10 hole → 11 cols (│ + 9 spaces + │)"
        );
        assert_eq!(lines[2], "╰──────────╯", "bottom row materializes 12 cols");
        // The interior right border survives at col 11 (the col-10 hole is
        // filtered, so it renders as the 11th visible char).
        assert_eq!(lines[1].chars().count(), 11);
        assert_eq!(lines[0].chars().count(), 12);
    }

    // ── innermost-clip-only nesting (output.ts:174 `clips.at(-1)`) ───────────

    // ink applies ONLY the innermost clip; the active clip is always the top
    // of stack. Push outer {0,8} then inner {0,2}; a write under the inner
    // clip yields "AB". After popping the inner clip, the SAME write now sees
    // only the outer {0,8} → "CDEFGH" survives where the inner clip had
    // dropped it — proving pop restores the ancestor as the active (sole) clip.
    #[test]
    fn innermost_clip_is_top_of_stack_after_pop() {
        let mut g = Grid::new(2, 10);
        g.push_clip(Clip {
            x1: Some(0),
            x2: Some(8),
            y1: None,
            y2: None,
        });
        g.push_clip(Clip {
            x1: Some(0),
            x2: Some(2),
            y1: None,
            y2: None,
        });
        g.write(0, 0, "ABCDEFGH"); // innermost {0,2} → row 0 = "AB"
        g.pop_clip();
        g.write(0, 1, "ABCDEFGH"); // now outer {0,8} → row 1 = "ABCDEFGH"
        g.pop_clip();
        let (out, _) = g.get();
        let lines: Vec<&str> = out.split('\n').collect();
        assert_eq!(lines[0], "AB", "under inner clip {{0,2}}");
        assert_eq!(
            lines[1], "ABCDEFGH",
            "after pop, outer {{0,8}} alone is the active clip"
        );
    }

    // Companion: prove the innermost clip can be WIDER than an ancestor — an
    // intersection model would wrongly narrow it. Outer {0,2}, inner {0,8};
    // a write spanning 0..8 must yield the full 8 chars (innermost wins),
    // NOT 2 (which an ancestor-intersection would force).
    #[test]
    fn innermost_clip_wider_than_ancestor_wins() {
        let mut g = Grid::new(1, 10);
        g.push_clip(Clip {
            x1: Some(0),
            x2: Some(2),
            y1: None,
            y2: None,
        });
        g.push_clip(Clip {
            x1: Some(0),
            x2: Some(8),
            y1: None,
            y2: None,
        });
        g.write(0, 0, "ABCDEFGH");
        g.pop_clip();
        g.pop_clip();
        let (out, _) = g.get();
        assert_eq!(
            out, "ABCDEFGH",
            "innermost {{0,8}} wins over wider ancestor {{0,2}}"
        );
    }

    // ── degenerate / inverted clip rects (direct Grid API hardening) ─────────
    //
    // Oracle: live ink Output probe (/tmp/ink-degenerate-clip-probe.mjs against
    // /home/alpha/rewrite/ink/build/output.js, 2026-06-10). Every degenerate
    // shape below produced `output="\n\n\n\n"` (a 5-row grid with NOTHING
    // written) and no throw, while positive controls (valid clip / no clip)
    // wrote normally. Source derivation: output.ts:220 `lines.slice(from, to)`
    // — JS Array.prototype.slice returns [] when from > to; output.ts:207
    // `sliceAnsi(line, from, to)` returns '' when from > to. Degenerate clip
    // ⇒ empty visible region ⇒ the write contributes nothing.

    // y-inverted clip (y1=2 > y2=1) partially spanned by the write: the exact
    // pre-fix panic shape. With y=1, height=3 the pre-checks pass
    // (y+height=4 ≥ y1, y=1 ≤ y2), then from = y1-y = 1, to = y2-y = 0 and
    // `lines[1..0]` panicked: "slice index starts at 1 but ends at 0".
    // Discriminates: the vertical-clip range must clamp like JS slice
    // (output.ts:220 → []) instead of panicking. PANICS pre-fix.
    #[test]
    fn clip_y_inverted_partial_span_writes_nothing() {
        let mut g = Grid::new(5, 10);
        g.push_clip(Clip {
            x1: None,
            x2: None,
            y1: Some(2),
            y2: Some(1),
        });
        g.write(0, 1, "aa\nbb\ncc");
        g.pop_clip();
        let (out, h) = g.get();
        assert_eq!(h, 5);
        // ink probe: output="\n\n\n\n" — nothing written.
        assert_eq!(out, "\n\n\n\n");
    }

    // x-inverted clip (x1=5 > x2=2) partially spanned by the write (x=2,
    // width=4 passes both pre-checks). from = x1-x = 3 > to = x2-x = 0;
    // sliceAnsi(line, 3, 0) === '' (output.ts:207; slice-ansi returns '' for
    // begin > end) → every line empties → nothing placed.
    // Discriminates: a horizontal-clip regression that underflowed/swapped the
    // slice bounds and emitted text (or panicked) instead of an empty line.
    #[test]
    fn clip_x_inverted_partial_span_writes_nothing() {
        let mut g = Grid::new(5, 10);
        g.push_clip(Clip {
            x1: Some(5),
            x2: Some(2),
            y1: None,
            y2: None,
        });
        g.write(2, 0, "abcd");
        g.pop_clip();
        let (out, _) = g.get();
        // ink probe: output="\n\n\n\n" — nothing written.
        assert_eq!(out, "\n\n\n\n");
    }

    // Valid (non-inverted) clip lying entirely OUTSIDE the grid bounds
    // (y1=100..y2=200 on a 5-row grid), write straddling its top edge.
    // The clip slice keeps lines but bumps y to y1=100 (output.ts:222-223);
    // every target row is missing → per-row skip (output.ts:231-233 /
    // grid.rs row bounds check) → nothing written, no panic.
    // Discriminates: out-of-grid clip-adjusted rows must be skipped, not
    // indexed (a direct `self.cells[row_y]` without the bounds check panics).
    #[test]
    fn clip_fully_outside_grid_writes_nothing() {
        let mut g = Grid::new(5, 10);
        g.push_clip(Clip {
            x1: None,
            x2: None,
            y1: Some(100),
            y2: Some(200),
        });
        g.write(0, 99, "aa\nbb\ncc");
        g.pop_clip();
        let (out, _) = g.get();
        // ink probe: output="\n\n\n\n" — nothing written.
        assert_eq!(out, "\n\n\n\n");
    }

    // Zero-area clips: x1==x2, y1==y2, and both. ink's clip bounds behave
    // half-open here: from == to on each axis → sliceAnsi(line, n, n) === ''
    // and lines.slice(n, n) === [] → empty visible region.
    // Discriminates: an off-by-one in the clamp (e.g. treating x1==x2 / y1==y2
    // as a 1-cell/1-row window) would write a column or row where ink writes
    // nothing.
    #[test]
    fn clip_zero_area_writes_nothing() {
        // x1==x2==2 (ink probe: nothing written).
        let mut g = Grid::new(5, 10);
        g.push_clip(Clip {
            x1: Some(2),
            x2: Some(2),
            y1: None,
            y2: None,
        });
        g.write(0, 0, "abcd");
        g.pop_clip();
        assert_eq!(g.get().0, "\n\n\n\n", "zero-area x clip");

        // y1==y2==1 (ink probe: nothing written).
        let mut g = Grid::new(5, 10);
        g.push_clip(Clip {
            x1: None,
            x2: None,
            y1: Some(1),
            y2: Some(1),
        });
        g.write(0, 0, "aa\nbb\ncc");
        g.pop_clip();
        assert_eq!(g.get().0, "\n\n\n\n", "zero-area y clip");

        // Both axes zero-area (ink probe: nothing written).
        let mut g = Grid::new(5, 10);
        g.push_clip(Clip {
            x1: Some(2),
            x2: Some(2),
            y1: Some(1),
            y2: Some(1),
        });
        g.write(0, 0, "abcd\nefgh\nijkl");
        g.pop_clip();
        assert_eq!(g.get().0, "\n\n\n\n", "zero-area x+y clip");
    }
}