htui 0.2.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
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
//! Box-model tests — `padding` / `margin` / `border` resolution through the
//! two-phase layout / paint pipeline.
//!
//! Each test stands up an HTML snippet with a single block-level element and
//! asserts on the resulting `Block` cells in the `TermLayout` canvas:
//!
//! - Padding tests check the bg extends into the padding area and that
//!   inline content shifts into the content-box (rect shrunk by padding).
//! - Margin tests check the box's outer position / inter-sibling spacing.
//! - Border tests check the four Unicode box-drawing edges and corners
//!   appear around the rect.

use crossterm::style::Color;
use htui::html::{css_parse, html_parse};
use htui::layout::layout::TermMap;
use htui::tdom::node::NodeArena;
use htui::tdom::node::NodeId;
use htui::tui::graph_api::TermLayout;

fn init() {
    let _ = env_logger::builder().is_test(true).try_init();
}

fn setup(html: &str, css: &str) -> (NodeId, NodeArena) {
    let mut html_parser = html_parse::Parser::new(html);
    let (root_id, mut arena, embedded) = html_parser.parse();
    let combined = if css.is_empty() {
        embedded
    } else if embedded.is_empty() {
        css.to_string()
    } else {
        format!("{embedded}\n{css}")
    };
    if !combined.trim().is_empty() {
        let mut css_parser = css_parse::Parser::new(&combined);
        let rules = css_parser.parse();
        css_parse::css_into_node(&mut arena, root_id, &rules);
    }
    (root_id, arena)
}

const COLS: u16 = 80;
const ROWS: u16 = 24;

fn mock_term_map() -> TermMap {
    TermMap {
        columns: COLS,
        rows: ROWS,
    }
}

/// Build a fresh canvas and draw the given DOM.
fn draw(html: &str, css: &str) -> (TermMap, TermLayout) {
    let term_map = mock_term_map();
    let (root_id, arena) = setup(html, css);
    let mut tl = TermLayout::new(&term_map, 1.0);
    tl.draw(&arena, root_id, &term_map);
    (term_map, tl)
}

// ============================================================
// padding
// ============================================================

#[test]
fn padding_pushes_inline_content_inward() {
    init();
    // `<div>` with padding-top=2, padding-left=3. The text "Hi" should land
    // at row 2 (below 2 padding rows) and col 3 (after 3 padding cols).
    let (term_map, tl) = draw(
        "<div>Hi</div>",
        "div { padding: 2ch 3ch; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;

    let text_idx = tl
        .layout
        .iter()
        .position(|b| b.word == "H")
        .expect("'Hi' should render");
    let row = text_idx / cols;
    let col = text_idx % cols;
    assert_eq!(row, 2, "padding-top should push text 2 rows down");
    assert_eq!(col, 3, "padding-left should push text 3 cols right");
}

#[test]
fn padding_expands_rect_vertically() {
    init();
    // An empty `<div>` with padding-top=4, padding-bottom=2 should still
    // own a border-box of `1 (own row) + 6 (padding)` = 7 rows.
    let (_term_map, tl) = draw("<div></div>", "div { padding: 4ch 0 2ch 0; }");

    // No bg → no filled cells; we inspect `rendered_rows` which tracks the
    // deepest descendant's bottom row.
    assert!(
        tl.rendered_rows >= 7,
        "padding should grow the div's rect to at least 7 rows, got {}",
        tl.rendered_rows
    );
}

#[test]
fn padding_background_fills_padding_box() {
    init();
    // Padding area carries the bg, not just the content row.
    let (term_map, tl) = draw(
        "<div>X</div>",
        "div { padding: 3ch; background-color: cyan; }",
    );
    let cols = term_map.columns as usize;

    // Row 0 is a padding row — must carry cyan bg.
    let row0 = &tl.layout[0..cols];
    let cyan_count = row0
        .iter()
        .filter(|b| {
            b.bcolor
                == Some(Color::Rgb {
                    r: 0,
                    g: 255,
                    b: 255,
                })
        })
        .count();
    assert_eq!(
        cyan_count, cols,
        "padding-top row must be filled with the bg (full width)"
    );
}

#[test]
fn padding_shrinks_inner_containing_block() {
    init();
    // A nested `<p>` inside `<div>` with padding should sit inside the
    // content area (after padding). padding-top=2 plus the <p>'s 1-row UA
    // margin-top puts the text at row 3.
    let (term_map, tl) = draw(
        "<div><p>Inner</p></div>",
        "div { padding-top: 2ch; padding-left: 5ch; }",
    );
    let cols = term_map.columns as usize;
    let text_idx = tl
        .layout
        .iter()
        .position(|b| b.word == "I")
        .expect("'Inner' should render");
    assert_eq!(
        text_idx / cols,
        3,
        "p starts after padding-top=2 + its UA margin-top=1"
    );
    assert_eq!(text_idx % cols, 5, "p must start after padding-left=5");
}

// ============================================================
// margin
// ============================================================

#[test]
fn margin_left_shifts_box_horizontally() {
    init();
    // margin-left=4 should push the box 4 cols to the right.
    let (term_map, tl) = draw("<div>X</div>", "div { margin-left: 4ch; }");
    let cols = term_map.columns as usize;
    let text_idx = tl
        .layout
        .iter()
        .position(|b| b.word == "X")
        .expect("'X' should render");
    assert_eq!(text_idx % cols, 4, "margin-left should shift the box right");
}

#[test]
fn margin_top_reserves_rows_above() {
    init();
    // Two paragraphs: each `margin-top` row must land above its own box.
    // First sits at row 3 (margin-top=3); Second sits at row 7
    // (= 3 + 1 own + 3 margin-top).
    let (term_map, tl) = draw("<p>First</p><p>Second</p>", "p { margin-top: 3ch; }");
    let cols = term_map.columns as usize;
    let first_idx = tl
        .layout
        .iter()
        .position(|b| b.word == "F")
        .expect("'First' should render");
    let second_idx = tl
        .layout
        .iter()
        .position(|b| b.word == "S")
        .expect("'Second' should render");
    let first_row = first_idx / cols;
    let second_row = second_idx / cols;
    assert_eq!(first_row, 3, "First sits after its own margin-top=3");
    assert_eq!(
        second_row,
        first_row + 1 + 3,
        "Second should sit 1 own-row + 3 margin-top rows below First"
    );
}

#[test]
fn margin_shorthand_two_values_applies_to_top_bottom_and_left_right() {
    init();
    // `margin: 2ch 8ch` → top/bottom=2, left/right=8. Both X and Y carry
    // margin-top=2, so Y sits 1 own-row + the collapsed 2-row margin below
    // X (adjacent vertical margins merge to the larger — CSS collapsing).
    let (term_map, tl) = draw("<p>X</p><p>Y</p>", "p { margin: 2ch 8ch; }");
    let cols = term_map.columns as usize;
    let x_idx = tl.layout.iter().position(|b| b.word == "X").unwrap();
    let y_idx = tl.layout.iter().position(|b| b.word == "Y").unwrap();
    assert_eq!(x_idx % cols, 8, "margin-left=8 shifts X right");
    assert_eq!(y_idx % cols, 8, "margin-left=8 shifts Y right");
    assert_eq!(
        y_idx / cols,
        x_idx / cols + 1 + 2,
        "Y sits 1 own-row + the collapsed 2-row margin below X"
    );
}

#[test]
fn margin_auto_centers_block_horizontally() {
    init();
    // `margin: 0 auto` + explicit width centers the box: both auto margins
    // share the free space. 80-col canvas, width 20 → left = (80-20)/2 = 30.
    let (term_map, tl) = draw(
        "<div>X</div>",
        "div { width: 20ch; margin: 0 auto; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;
    let text_idx = tl.layout.iter().position(|b| b.word == "X").unwrap();
    assert_eq!(
        text_idx % cols,
        30,
        "both auto margins split the free space: (80-20)/2 = 30"
    );
}

#[test]
fn margin_right_auto_pushes_box_to_the_left() {
    init();
    // `margin-right: auto` absorbs the whole remainder: the box stays flush
    // left with the free space on its right.
    let (term_map, tl) = draw("<div>X</div>", "div { width: 20ch; margin-right: auto; }");
    let cols = term_map.columns as usize;
    let text_idx = tl.layout.iter().position(|b| b.word == "X").unwrap();
    assert_eq!(
        text_idx % cols,
        0,
        "left margin stays 0, auto right absorbs"
    );
}

#[test]
fn margin_left_auto_pushes_box_to_the_right() {
    init();
    // `margin-left: auto` absorbs the whole remainder: the box sits flush
    // against the right edge of the containing block.
    let (term_map, tl) = draw(
        "<div>X</div>",
        "div { width: 20ch; margin-left: auto; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;
    let text_idx = tl.layout.iter().position(|b| b.word == "X").unwrap();
    assert_eq!(text_idx % cols, 60, "auto left margin = 80 - 20 = 60");
}

#[test]
fn border_side_component_longhand_renders() {
    init();
    // `border-left-width: 3ch; border-left-style: solid; border-left-color:
    // green;` — the longhands alone must paint a left border edge. The
    // padding-top gives the box a second row so the edge isn't overwritten
    // by the corner glyph on row 0.
    let (_, tl) = draw(
        "<div></div>",
        "div { width: 5ch; padding-top: 1ch; border-left-width: 3ch; border-left-style: solid; border-left-color: green; }",
    );
    // The left edge glyph renders as a vertical bar somewhere in the box.
    let edge = tl
        .layout
        .iter()
        .find(|b| b.word == BOX_V)
        .expect("left edge vertical bar")
        .clone();
    assert_eq!(edge.word, BOX_V, "left edge renders a vertical bar");
    assert_eq!(
        edge.fcolor,
        Some(Color::Rgb { r: 0, g: 128, b: 0 }),
        "longhand color applies"
    );
}

#[test]
fn border_component_longhand_overrides_side_shorthand_in_layout() {
    init();
    // `border: 1ch solid red; border-left-color: blue;` — the component
    // longhand overrides just the left color, others stay red.
    let (_, tl) = draw(
        "<div></div>",
        "div { width: 5ch; border: 1ch solid red; border-left-color: blue; }",
    );
    let left_edge = tl
        .layout
        .iter()
        .find(|b| b.word == BOX_V)
        .expect("left edge")
        .clone();
    let top_edge = tl
        .layout
        .iter()
        .find(|b| b.word == BOX_H)
        .expect("top edge")
        .clone();
    assert_eq!(left_edge.word, BOX_V);
    assert_eq!(
        left_edge.fcolor,
        Some(Color::Rgb { r: 0, g: 0, b: 255 }),
        "component longhand wins"
    );
    assert_eq!(
        top_edge.fcolor,
        Some(Color::Rgb { r: 255, g: 0, b: 0 }),
        "other sides keep shorthand"
    );
}

// ============================================================
// border
// ============================================================

const BOX_H: &str = "─";
const BOX_V: &str = "│";
const BOX_TL: &str = "┌";
const BOX_TR: &str = "┐";
const BOX_BL: &str = "└";
const BOX_BR: &str = "┘";

#[test]
fn border_draws_four_edges_with_corners() {
    init();
    // A 5-col div with padding-top=1, border=1 on all sides, padding on
    // other axes = 0. Rect height = border-top(1) + padding-top(1) +
    // content(1) + padding-bottom(0) + border-bottom(1) = 4 rows.
    let (term_map, tl) = draw(
        "<div>X</div>",
        "div { width: 5ch; padding-top: 1ch; border: 1ch solid red; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;

    // Locate the div's top-left border corner at (0, 0).
    let top_left_idx = tl
        .layout
        .iter()
        .position(|b| b.word == BOX_TL)
        .expect("top-left border corner should render");
    assert_eq!(top_left_idx / cols, 0);
    assert_eq!(top_left_idx % cols, 0);

    // Top edge: horizontal bar across columns 1..4 of row 0.
    let row0 = &tl.layout[0..cols];
    let top_count = row0.iter().filter(|b| b.word == BOX_H).count();
    assert_eq!(
        top_count, 3,
        "top border should span 3 cells between the corners"
    );

    // Bottom edge: same width on row 3 (rect.height - 1).
    let row3 = &tl.layout[3 * cols..4 * cols];
    let bottom_count = row3.iter().filter(|b| b.word == BOX_H).count();
    assert_eq!(bottom_count, 3, "bottom border should span 3 cells");

    // Left + right edges on rows 1 and 2 (between top/bottom corners).
    for row_idx in [1usize, 2] {
        let row = &tl.layout[row_idx * cols..(row_idx + 1) * cols];
        let has_left = row[0].word == BOX_V;
        let has_right = row[4].word == BOX_V;
        assert!(has_left, "left border glyph should appear on row {row_idx}");
        assert!(
            has_right,
            "right border glyph should appear on row {row_idx}"
        );
    }
    // Make sure the four corners are present.
    assert!(
        tl.layout.iter().any(|b| b.word == BOX_TR),
        "top-right corner should render"
    );
    assert!(
        tl.layout.iter().any(|b| b.word == BOX_BL),
        "bottom-left corner should render"
    );
    assert!(
        tl.layout.iter().any(|b| b.word == BOX_BR),
        "bottom-right corner should render"
    );
}

#[test]
fn border_color_overrides_text_color() {
    init();
    // `border: 1ch solid green;` paints the borders green even though the
    // text color (if any) is unset.
    let (_, tl) = draw(
        "<div></div>",
        "div { width: 5ch; padding: 1ch; border: 1ch solid green; }",
    );
    let border_cells: Vec<_> = tl
        .layout
        .iter()
        .filter(|b| {
            b.word == BOX_H
                || b.word == BOX_V
                || matches!(b.word.as_str(), BOX_TL | BOX_TR | BOX_BL | BOX_BR)
        })
        .collect();
    assert!(!border_cells.is_empty(), "border glyphs should render");
    for b in &border_cells {
        assert_eq!(
            b.fcolor,
            Some(Color::Rgb { r: 0, g: 128, b: 0 }),
            "border glyphs should pick up the border's color"
        );
    }
}

#[test]
fn border_per_side_longhand_overrides_universal() {
    init();
    // `border: 1ch solid red; border-top: 2ch solid blue;` — top edge blue,
    // others red. (Width > 1 still renders as 1 cell.)
    let (_, tl) = draw(
        "<div></div>",
        "div { width: 5ch; padding: 1ch; border: 1ch solid red; border-top: 2ch solid blue; }",
    );
    let cols = 80;
    let top_color = tl.layout[0].fcolor;
    let left_color = tl.layout[cols].fcolor;
    assert_eq!(
        top_color,
        Some(Color::Rgb { r: 0, g: 0, b: 255 }),
        "top border should be blue"
    );
    assert_eq!(
        left_color,
        Some(Color::Rgb { r: 255, g: 0, b: 0 }),
        "left border should be red"
    );
}

#[test]
fn border_style_none_hides_the_border() {
    init();
    let (_, tl) = draw(
        "<div></div>",
        "div { width: 5ch; padding: 1ch; border-style: none; border-color: red; }",
    );
    let has_border = tl.layout.iter().any(|b| {
        matches!(
            b.word.as_str(),
            BOX_H | BOX_V | BOX_TL | BOX_TR | BOX_BL | BOX_BR
        )
    });
    assert!(
        !has_border,
        "border-style: none should suppress all border glyphs"
    );
}

#[test]
fn border_wins_over_overflowing_content() {
    init();
    // `max-height` caps the box smaller than its text: the wrapped content
    // would land on the bottom border row. The frame must win the contended
    // cells — the border glyphs stay and the overflow is clipped instead of
    // painting over the border (the "出框" symptom in general form).
    let (term_map, tl) = draw(
        "<div>ABC</div>",
        "div { width: 5ch; border: 1ch solid red; max-height: 2ch; }",
    );
    let cols = term_map.columns as usize;
    let rows: Vec<String> = tl
        .layout
        .chunks(cols)
        .map(|chunk| chunk.iter().map(|b| b.word.as_str()).collect())
        .collect();
    // The 2-row box paints both borders on rows 0 (top) and 1 (bottom); the
    // squeezed "ABC" text must not appear on the border row.
    assert!(
        !rows[1].contains("ABC"),
        "overflowing content must not overwrite the border: {:?}",
        rows[1]
    );
    // The bottom border row keeps a continuous frame (corners + dashes).
    let bottom = &rows[1];
    assert!(
        bottom.contains(BOX_BL) && bottom.contains(BOX_BR) && bottom.contains(BOX_H),
        "bottom border must stay intact: {bottom:?}"
    );
}

// ============================================================
// combined padding + border + margin
// ============================================================

#[test]
fn combined_padding_border_margin_size_correctly() {
    init();
    // padding=2 + border=1 on all sides; content sits in the inner box.
    let (term_map, tl) = draw(
        "<div>Hi</div>",
        "div { width: 10ch; padding: 2ch; border: 1ch solid red; margin: 3ch; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;

    // 'H' should sit at row 3 (margin-top) + 1 (border-top) + 2 (padding-top)
    // = 6, and col 3 (margin-left) + 1 (border-left) + 2 (padding-left) = 6.
    let text_idx = tl
        .layout
        .iter()
        .position(|b| b.word == "H")
        .expect("'Hi' should render");
    assert_eq!(
        text_idx / cols,
        6,
        "row should account for margin+border+padding"
    );
    assert_eq!(
        text_idx % cols,
        6,
        "col should account for margin+border+padding"
    );

    // The border-box rect's total rows should fit margin+border+padding+content
    // + margin-bottom.
    let bg_rows = tl
        .layout
        .iter()
        .enumerate()
        .filter(|(_, b)| {
            b.bcolor
                == Some(Color::Rgb {
                    r: 255,
                    g: 255,
                    b: 0,
                })
        })
        .map(|(i, _)| i / cols)
        .collect::<std::collections::HashSet<_>>();
    // Expected rows: 3 (margin-top) + 1 (border-top) + 2 (padding-top)
    //               + 1 (content) + 2 (padding-bottom) + 1 (border-bottom)
    //             = 10 rows for the border-box.
    for row in 3..=9 {
        assert!(
            bg_rows.contains(&row),
            "row {row} should be yellow (part of border-box bg)"
        );
    }
}

#[test]
fn margin_top_creates_visible_gap_between_siblings() {
    init();
    // Two `<div>`s; the second should land `margin-top` rows below the first.
    let (term_map, tl) = draw(
        "<div>A</div><div>B</div>",
        "div { margin-top: 5ch; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;

    let a_idx = tl.layout.iter().position(|b| b.word == "A").unwrap();
    let a_row = a_idx / cols;
    let b_idx = tl.layout.iter().position(|b| b.word == "B").unwrap();
    let b_row = b_idx / cols;
    assert!(
        b_row >= a_row + 5,
        "B should sit at least margin-top=5 rows below A: a={a_row}, b={b_row}"
    );
}

// ============================================================
// box-sizing
// ============================================================

#[test]
fn box_sizing_content_box_adds_padding_and_border_to_width() {
    init();
    let yellow = Color::Rgb {
        r: 255,
        g: 255,
        b: 0,
    };
    let css = "div { width: 10ch; padding: 2ch; border: 1ch solid red; background-color: yellow; }";

    // Default (border-box): `width` is the full border-box, so the bg
    // (padding-box) covers cols 1..9 (10 minus the 1-col borders). Count the
    // pure bg-fill cells (word == " ") on row 1 — the padding-top row that
    // carries no border glyphs.
    let (term_map, tl) = draw("<div>Hi</div>", css);
    let cols = term_map.columns as usize;
    let bg_count_row1 = tl.layout[cols..2 * cols]
        .iter()
        .filter(|b| b.bcolor == Some(yellow) && b.word == " ")
        .count();
    assert_eq!(bg_count_row1, 8, "border-box width=10 -> 8 bg cells");

    // `box-sizing: content-box`: the 10ch width is the content box, so the
    // border-box grows to 10 + 4 (padding) + 2 (border) = 16 -> 14 bg cells.
    let (_, tl) = draw(
        "<div>Hi</div>",
        "div { width: 10ch; padding: 2ch; border: 1ch solid red; background-color: yellow; box-sizing: content-box; }",
    );
    let bg_count_row1 = tl.layout[cols..2 * cols]
        .iter()
        .filter(|b| b.bcolor == Some(yellow) && b.word == " ")
        .count();
    assert_eq!(bg_count_row1, 14, "content-box width=10 -> 14 bg cells");
}

// ============================================================
// min-width / max-width
// ============================================================

#[test]
fn min_width_overrides_declared_width() {
    init();
    // `min-width` floors the border-box width: 10ch declared, 30ch minimum
    // -> the box spans 30 columns even though its content is narrow.
    let (term_map, tl) = draw(
        "<div>Hi</div>",
        "div { width: 10ch; min-width: 30ch; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;
    let yellow = Color::Rgb {
        r: 255,
        g: 255,
        b: 0,
    };
    let max_bg_col = tl.layout[0..cols]
        .iter()
        .enumerate()
        .filter(|(_, b)| b.bcolor == Some(yellow))
        .map(|(i, _)| i)
        .max()
        .expect("bg should render");
    assert_eq!(max_bg_col, 29, "min-width=30 -> bg spans cols 0..30");
}

#[test]
fn max_width_caps_auto_and_declared_width() {
    init();
    let yellow = Color::Rgb {
        r: 255,
        g: 255,
        b: 0,
    };
    // A block normally fills the whole viewport (80 cols); `max-width` caps
    // it to 30 columns.
    let (term_map, tl) = draw(
        "<div>Hi</div>",
        "div { max-width: 30ch; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;
    let max_bg_col = tl.layout[0..cols]
        .iter()
        .enumerate()
        .filter(|(_, b)| b.bcolor == Some(yellow))
        .map(|(i, _)| i)
        .max()
        .expect("bg should render");
    assert_eq!(max_bg_col, 29, "max-width=30 -> auto width capped to 30");

    // `max-width` also caps an explicit `width`.
    let (_, tl) = draw(
        "<div>Hi</div>",
        "div { width: 60ch; max-width: 30ch; background-color: yellow; }",
    );
    let max_bg_col = tl.layout[0..cols]
        .iter()
        .enumerate()
        .filter(|(_, b)| b.bcolor == Some(yellow))
        .map(|(i, _)| i)
        .max()
        .expect("bg should render");
    assert_eq!(max_bg_col, 29, "width=60 with max-width=30 -> 30");
}

// ============================================================
// min-height
// ============================================================

#[test]
fn min_height_expands_short_box() {
    init();
    // An empty-ish block is 1 row tall by default; `min-height: 4` grows
    // the border-box to 4 rows.
    let (term_map, tl) = draw(
        "<div>X</div>",
        "div { min-height: 4ch; background-color: yellow; }",
    );
    let cols = term_map.columns as usize;
    let yellow = Color::Rgb {
        r: 255,
        g: 255,
        b: 0,
    };
    let bg_rows: std::collections::HashSet<usize> = tl
        .layout
        .iter()
        .enumerate()
        .filter(|(_, b)| b.bcolor == Some(yellow))
        .map(|(i, _)| i / cols)
        .collect();
    for row in 0..4 {
        assert!(bg_rows.contains(&row), "row {row} should carry the bg");
    }
}

// ============================================================
// border-radius
// ============================================================

#[test]
fn border_radius_renders_rounded_corner_glyphs() {
    init();
    // `border-radius` swaps the square corner glyphs (┌ ┐ └ ┘) for the
    // rounded box-drawing set (╭ ╮ ╰ ╯). Rect: width 6, height 3 (content
    // row + top/bottom border).
    let (term_map, tl) = draw(
        "<div>X</div>",
        "div { border: 1ch solid red; border-radius: 2ch; width: 6ch; }",
    );
    let cols = term_map.columns as usize;
    let word_at = |tl: &TermLayout, x: usize, y: usize| tl.layout[y * cols + x].word.clone();
    assert_eq!(word_at(&tl, 0, 0), "╭", "top-left corner");
    assert_eq!(word_at(&tl, 5, 0), "╮", "top-right corner");
    assert_eq!(word_at(&tl, 0, 2), "╰", "bottom-left corner");
    assert_eq!(word_at(&tl, 5, 2), "╯", "bottom-right corner");

    // Control: without `border-radius` the corners stay square.
    let (_, tl) = draw("<div>X</div>", "div { border: 1ch solid red; width: 6ch; }");
    assert_eq!(word_at(&tl, 0, 0), "┌", "square corner without radius");
}

// ============================================================
// CSS `height` — fixed-height boxes clip their text
// ============================================================

#[test]
fn css_height_fixes_the_frame_and_clips_text() {
    init();
    // `height` fixes the border-box height (htui's default box-sizing is
    // `border-box`): a 3-row card with six lines of content keeps its frame
    // and — with `overflow: hidden` — clips the overflow instead of growing.
    // (Without an explicit overflow the CSS default `visible` applies: the
    // frame stays 3 rows but the extra lines paint beyond it, like a
    // browser; the hidden variant is what pins the frame-vs-text rule.)
    let (term_map, tl) = draw(
        r#"<div class="card">line one<br>line two<br>line three<br>line four<br>line five<br>line six</div>"#,
        "body { margin: 0ch; } .card { height: 3ch; border: 1ch solid red; overflow: hidden; }",
    );
    let cols = term_map.columns as usize;
    let row_text = |y: usize| {
        tl.layout[y * cols..(y + 1) * cols]
            .iter()
            .map(|b| b.word.as_str())
            .collect::<String>()
    };
    let top = row_text(0);
    assert_eq!(top.chars().next(), Some('┌'), "top-left corner");
    assert_eq!(top.chars().last(), Some('┐'), "top-right corner");
    let content = row_text(1);
    assert!(
        content.contains("line one"),
        "the first line fits inside the fixed height"
    );
    for clipped in ["line two", "line three", "line six"] {
        assert!(
            !content.contains(clipped),
            "{clipped} must be clipped by the fixed height"
        );
    }
    let bottom = row_text(2);
    assert_eq!(bottom.chars().next(), Some('└'), "bottom-left corner");
    assert_eq!(bottom.chars().last(), Some('┘'), "bottom-right corner");
    // Rows beyond the fixed-height frame stay blank (no spilled text).
    assert!(
        tl.layout[3 * cols..]
            .iter()
            .all(|b| b.word.trim().is_empty()),
        "nothing may paint below the fixed-height frame"
    );
}

#[test]
fn overflow_visible_paints_beyond_the_fixed_height() {
    init();
    // CSS default `overflow: visible`: the fixed-height frame stays 3 rows,
    // but the extra lines paint beyond it (bounded only by the ancestors' /
    // canvas clip, matching browser `overflow: visible` behavior.
    let (term_map, tl) = draw(
        r#"<div class="card">line one<br>line two<br>line three<br>line four</div>"#,
        "body { margin: 0ch; } .card { height: 3ch; border: 1ch solid red; }",
    );
    let cols = term_map.columns as usize;
    let row_text = |y: usize| {
        tl.layout[y * cols..(y + 1) * cols]
            .iter()
            .map(|b| b.word.as_str())
            .collect::<String>()
    };
    assert_eq!(row_text(2).chars().next(), Some('└'), "frame stays 3 rows");
    // The four content lines paint at canvas rows 1..5: lines one and two
    // inside the frame, line three over the bottom border row (the border
    // glyphs paint last and win), line four beyond it.
    assert!(
        row_text(4).contains("line four"),
        "the overflowing line paints below the frame (overflow: visible)"
    );
    assert!(
        !row_text(3).contains("line four") && row_text(3).contains("line three"),
        "the lines stack in order below the frame"
    );
}

#[test]
fn overflow_hidden_clips_block_children_to_the_content_box() {
    init();
    // A clipping parent bounds its block children at its content box: a
    // 3-row child inside a 1-row `overflow: hidden` parent leaves the rows
    // below blank. (The visible control above shows the same document
    // painting through.)
    let (term_map, tl) = draw(
        r#"<div class="wrap"><div>one<br>two<br>three</div></div>"#,
        "body { margin: 0ch; } .wrap { height: 1ch; overflow: hidden; }",
    );
    let cols = term_map.columns as usize;
    assert!(
        tl.layout[cols..].iter().all(|b| b.word.trim().is_empty()),
        "rows below the clipping parent must stay blank"
    );
}

#[test]
fn overflow_visible_lets_nowrap_paint_past_the_box_width() {
    init();
    // Horizontal overflow with `white-space: nowrap`: the line is wider
    // than the box, and `overflow: visible` paints it to the canvas edge
    // instead of clipping at the content-box width (still bounded per row
    // so the flat canvas never wraps into the next row).
    let (term_map, tl) = draw(
        r#"<div class="n">abcdefghijklmnopqrstuvwxyz</div>"#,
        "body { margin: 0ch; } .n { width: 10ch; white-space: nowrap; }",
    );
    let cols = term_map.columns as usize;
    let row0: String = tl.layout[..cols].iter().map(|b| b.word.as_str()).collect();
    assert!(
        row0.contains('z'),
        "the nowrap line paints past the 10-column box (overflow: visible): {row0:?}"
    );
    assert!(
        row0.contains("qrstuvwxyz"),
        "the tail renders contiguously beyond the box: {row0:?}"
    );
}

#[test]
fn overflow_hidden_clips_nowrap_at_the_box_width() {
    init();
    // The clipping counterpart: the same nowrap line stops at the box's
    // content-box width.
    let (term_map, tl) = draw(
        r#"<div class="n">abcdefghijklmnopqrstuvwxyz</div>"#,
        "body { margin: 0ch; } .n { width: 10ch; white-space: nowrap; overflow: hidden; }",
    );
    let cols = term_map.columns as usize;
    let row0: String = tl.layout[..cols].iter().map(|b| b.word.as_str()).collect();
    assert!(
        !row0.contains("k"),
        "the nowrap line clips at the box width: {row0:?}"
    );
    assert!(
        row0.contains("abcdefghij"),
        "the first ten columns render: {row0:?}"
    );
}

#[test]
fn overflow_visible_bordered_box_clips_at_the_frame_edge() {
    init();
    // A visibly-overflowing **bordered** box must not let its own glyphs
    // paint past the drawn frame into the neighbouring area (the terminal
    // "出框" artefact): a long word wider than the content width may
    // underlap the right border column but stops at the border-box right
    // edge. A borderless box keeps the browser-faithful spill (covered by
    // `overflow_visible_lets_nowrap_paint_past_the_box_width` above).
    let (term_map, tl) = draw(
        r#"<div class="n">short supercalifragilistic word</div>"#,
        "body { margin: 0ch; } .n { width: 12ch; border: 1ch solid red; }",
    );
    let cols = term_map.columns as usize;
    let row1: String = tl.layout[cols..2 * cols]
        .iter()
        .map(|b| b.word.as_str())
        .collect();
    // Border-box width 12 = 2 border columns + 10 content columns: the long
    // word fills the whole content row up to the right border.
    assert!(
        row1.contains("short supe"),
        "the long word renders up to the frame edge: {row1:?}"
    );
    assert!(
        row1.trim_end().ends_with('│'),
        "the right border glyph survives: {row1:?}"
    );
    // Nothing renders right of the frame's border-box right edge (col 11).
    let tail: String = row1.chars().skip(12).collect();
    assert!(
        tail.trim().is_empty(),
        "no glyphs escape the frame: {row1:?}"
    );
}

#[test]
fn overflow_hidden_ancestor_clips_descendant_pre_spill() {
    init();
    // A borderless `white-space: pre` child wider than its clipping ancestor
    // (`overflow: hidden` card, the music-player waveform case) must stop at
    // the ancestor's content-box right edge: the frame glyphs survive and
    // nothing paints into the area beyond the card. Before the fix the
    // inherited clip only bounded rows vertically, so the glyph stream ran to
    // the canvas edge and painted over the card's right border.
    let wave = "▁▃▅▇".repeat(12);
    let (term_map, tl) = draw(
        &format!(r#"<div class="card"><div class="wave">{wave}</div></div>"#),
        "body { margin: 0ch; } .card { width: 30ch; box-sizing: content-box; border: 1ch solid #333; padding: 1ch; overflow: hidden; } .wave { white-space: pre; }",
    );
    let cols = term_map.columns as usize;
    // Border-box width 34: border cols 0/33, padding cols 1/32, content cols
    // 2..=31. The 48-glyph wave starts at col 2 and must clip at col 32.
    let row2: Vec<&str> = tl.layout[2 * cols..3 * cols]
        .iter()
        .map(|b| b.word.as_str())
        .collect();
    assert_eq!(row2[0], "│", "the left frame glyph: {row2:?}");
    assert!(
        row2[1].trim().is_empty(),
        "the padding column stays clear: {row2:?}"
    );
    assert!(
        row2[2..32].iter().all(|c| !c.trim().is_empty()),
        "the wave fills exactly the 30 content columns: {row2:?}"
    );
    assert!(
        row2[32].trim().is_empty(),
        "the glyph stream stops at the content-box edge: {row2:?}"
    );
    assert_eq!(row2[33], "│", "the right frame glyph survives: {row2:?}");
    assert!(
        row2[34..].iter().all(|c| c.trim().is_empty()),
        "nothing paints past the frame: {row2:?}"
    );
}