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
//! Paint phase.
//!
//! Walks a [`LayoutBox`] forest produced by
//! [`super::layoutbox::inner_formatting_context::flow::layout_tree`] (and
//! flex / grid trees) and writes styled cells into the
//! [`TermLayout`] canvas. Reads no DOM
//! state beyond each box's `node_id`: every paint decision is derived from
//! the box's own `rect`, `kind`, `paint_style`, and the CSS attributes
//! stored on the source node.
//!
//! ## Submodules
//!
//! - `block`: painting for `BoxKind::Normal` — backgrounds, borders, inline
//!   runs.
//! - `border`: the border-glyph rasteriser, shared with `block`.
//! - `image`: rasterising for `BoxKind::Replaced(ReplacedContent::Image)`.
//! - `text`: the inline-run painter, shared with `block`.

mod block;
mod border;
mod button;
mod image;
mod input;
mod select;
mod text;
mod textarea;

use std::collections::HashMap;

use crossterm::style::Color;

use crate::{
    graphics::{Block, FontStyle, ImageCache, InputRenderState, TermLayout},
    html::css_parse::{Background, Position, Visibility},
    layout::{BoxKind, LayoutBox, Rect, ReplacedContent},
    tdom::node::{Node, NodeArena, NodeId, NodeType},
};

use self::block::paint_block;
use self::border::paint_borders;
use self::button::paint_button;
use self::image::paint_image;
use self::input::paint_input;
use self::select::paint_select;
use self::textarea::paint_textarea;

/// Default background for replaced controls (`<input>` / `<button>` /
/// `<select>` / `<textarea>`) when the CSS `background-color` is unset — a
/// neutral grey that reads as a field on both light and dark terminals,
/// mirroring a browser's UA field colour.
pub(super) const CONTROL_DEFAULT_BG: Color = Color::DarkGrey;

/// Highlight colour for the selected character range of an editable control
/// (`<input>` / `<textarea>`) — the same Tokyo Night selection the page
/// selection uses.
pub(super) const SELECTION_BG: Color = Color::Rgb {
    r: 0x3b,
    g: 0x42,
    b: 0x61,
};

/// Shared prelude of the replaced-control painters: clamp the control's rect
/// to the canvas, then intersect with the inherited clip. `None` when the
/// control paints nothing.
pub(super) fn control_clip(lb: &LayoutBox, canvas: &PaintCanvas<'_>, clip: Rect) -> Option<Rect> {
    let r = clamp_rect_to_canvas(lb.rect, canvas.columns as usize)?;
    intersect_rects(r, clip)
}

/// `(fg, bg)` pair painting the caret block: `caret-color` paints the caret
/// with the declared colour; the default is the inverted cell (field
/// background ↔ text colour swap).
pub(super) fn caret_cell(
    node: &Node,
    fg: Option<Color>,
    bg: Option<Color>,
) -> (Option<Color>, Option<Color>) {
    match node.css_attr.get_caret_color() {
        Some(cc) => (Some(bg.unwrap_or(Color::Black)), Some(cc)),
        None => (
            Some(bg.unwrap_or(Color::Black)),
            Some(fg.unwrap_or(Color::White)),
        ),
    }
}

/// Paint a replaced control's shared chrome: the background fill across the
/// whole border-box (the CSS `background-color`, or [`CONTROL_DEFAULT_BG`]
/// when unset), then the CSS `border` frame when one is declared. Returns the
/// content rect — the border-box shrunk by the border thickness — that the
/// caller draws its value / label into (with no border this is the full box),
/// plus the resolved background so callers can sample per-cell colours
/// without resolving it a second time.
pub(super) fn paint_control_chrome(
    lb: &LayoutBox,
    arena: &NodeArena,
    canvas: &mut PaintCanvas<'_>,
    rect: Rect,
    fg: Option<Color>,
) -> (Rect, Background) {
    let node = &arena[lb.node_id];
    let bg = control_background(node);
    let bg_fill = BackgroundFill {
        background: &bg,
        rect,
    };
    let cols = canvas.columns as usize;
    let start = canvas.cell_index(rect.x, rect.y);
    for row_off in 0..rect.height {
        for col_off in 0..rect.width {
            let idx = start.saturating_add(row_off.saturating_mul(cols).saturating_add(col_off));
            if idx < canvas.layout.len() && canvas.in_region(idx) {
                let cell = &mut canvas.layout[idx];
                if cell.word.is_empty() {
                    cell.word.push(' ');
                }
                cell.bcolor = bg_fill.color_at(rect.x + col_off, rect.y + row_off);
            }
        }
    }
    // CSS border opt-in frame (border thickness lives on the paint style,
    // resolved at layout time).
    let sides = &lb.paint_style.box_edges.border;
    if sides.any_visible() {
        // `rect` arrives pre-clipped from the control painters; clipping the
        // glyphs to the rect itself is a no-op that keeps every cell they
        // already painted reachable.
        paint_borders(lb, rect, canvas, fg, Some(bg_fill), rect);
    }
    (
        Rect::new(
            rect.x.saturating_add(sides.left.thickness()),
            rect.y.saturating_add(sides.top.thickness()),
            rect.width
                .saturating_sub(sides.left.thickness())
                .saturating_sub(sides.right.thickness()),
            rect.height
                .saturating_sub(sides.top.thickness())
                .saturating_sub(sides.bottom.thickness()),
        ),
        bg,
    )
}

/// Resolve a replaced control's background, falling back to the UA field
/// colour. Gradients use the same CSS background parser as normal boxes.
pub(super) fn control_background(node: &Node) -> Background {
    node.css_attr
        .get_background()
        .unwrap_or(Background::Color(CONTROL_DEFAULT_BG))
}

/// Resolve a replaced control's background at one absolute canvas cell.
pub(super) fn control_background_at(
    background: &Background,
    x: usize,
    y: usize,
    rect: Rect,
) -> Option<Color> {
    background_at(Some(BackgroundFill { background, rect }), x, y)
}

/// Shared caret-centred value-window computation for `<input>` fields, used by
/// both the painter and the input loop's column→character mapping.
pub(crate) use self::input::{range_is_vertical, range_spec, value_window};
/// Shared `<textarea>` line math (visual lines / caret position / scroll /
/// inverse mapping), used by the painter and the input loop's caret movement
/// and click placement.
pub(crate) use self::textarea::{
    textarea_caret_position, textarea_char_at, textarea_scroll_row, textarea_visual_lines,
};

/// A borrow of the canvas that the painters actually need.
///
/// `paint_layout_tree` builds a single canvas over the whole
/// [`TermLayout::layout`] buffer and reuses it across the forest. The rest
/// of the canvas API (`columns`, row math) is read-only and cheap to copy.
pub(super) struct PaintCanvas<'a> {
    pub layout: &'a mut [Block],
    pub columns: u16,
    /// Image raster cache shared with the owning [`TermLayout`], so repeated
    /// `<img>` paints across frames reuse the decoded grid instead of
    /// re-reading the file.
    pub image_cache: &'a mut ImageCache,
    /// Optional absolute canvas rect restricting cell writes. `None` paints
    /// unrestricted (full frame); `Some` is the incremental-repaint region
    /// where every write outside it is dropped so the rest of the canvas
    /// keeps the previous frame's pixels.
    pub(super) region: Option<Rect>,
    /// `<input>` edit state read by the input painter (caret / value /
    /// focus). Immutable during a paint pass.
    pub input_states: &'a HashMap<NodeId, InputRenderState>,
    /// The `<select>` node whose dropdown is currently open (`None` = none).
    /// Read by the select painter for the open-frame colour.
    pub open_select: Option<NodeId>,
    /// Whether each native scrollbar paints in this pass. Their geometry stays
    /// available for hit-testing while the chrome is hidden.
    pub scrollbars_visible: &'a HashMap<NodeId, bool>,
}

impl<'a> PaintCanvas<'a> {
    pub(super) fn cell_index(&self, x: usize, y: usize) -> usize {
        y.saturating_mul(self.columns as usize).saturating_add(x)
    }

    /// True when the canvas cell at 1-D index `idx` may be written by this
    /// pass. Unrestricted when no repaint region is set.
    #[inline]
    pub(super) fn in_region(&self, idx: usize) -> bool {
        match self.region {
            None => true,
            Some(r) => {
                let cols = self.columns as usize;
                let y = idx / cols;
                let x = idx - y * cols;
                x >= r.x
                    && x < r.x.saturating_add(r.width)
                    && y >= r.y
                    && y < r.y.saturating_add(r.height)
            }
        }
    }
}

/// Map `(x, y)` to a flat canvas index. Shared by the control painters
/// (`input` / `button` / `select` / `textarea`).
pub(super) fn cell_index(canvas: &PaintCanvas<'_>, x: usize, y: usize) -> usize {
    canvas.cell_index(x, y)
}

/// Write one canvas cell — word + fg + bg + plain style — gated to the
/// repaint region. Shared by the control painters.
pub(super) fn put(
    canvas: &mut PaintCanvas<'_>,
    idx: usize,
    word: Option<char>,
    fg: Option<Color>,
    bg: Option<Color>,
) {
    if idx >= canvas.layout.len() || !canvas.in_region(idx) {
        return;
    }
    let cell = &mut canvas.layout[idx];
    cell.word.clear();
    if let Some(c) = word {
        cell.word.push(c);
    }
    cell.fcolor = fg;
    cell.bcolor = bg;
    cell.style = FontStyle::empty();
}

/// Paint every box in `boxes` into `term_layout`.
///
/// Painting is deliberately serial. The top-level forest is a single box
/// for typical documents (root → `<html>`), so task-per-box parallelism
/// would spawn exactly one task and only pay `rayon` dispatch overhead;
/// the workload itself is memory-bound cell writes on a buffer of at most
/// the content height × `columns` cells. Top-level boxes occupy
/// disjoint `rect`s, so a sequential pass writes each cell at most once.
/// Children are painted inside each box — the parent must paint its
/// background before the child reads `effective_bg` for its own cells, so
/// the parent/child chain is inherently sequential anyway.
///
/// Earlier versions tried to parallelise: the original used raw-pointer
/// slicing of the whole buffer (`std::slice::from_raw_parts_mut` plus a
/// lifetime `transmute_copy`), which the project forbids as `unsafe`; a
/// later safe variant fanned out per-top-level-box bands via `split_at_mut`,
/// which measured no speedup for the single-box documents that occur in
/// practice.
pub fn paint_layout_tree(boxes: &[LayoutBox], arena: &NodeArena, term_layout: &mut TermLayout) {
    let mut max_y = 0usize;
    for b in boxes {
        let bottom = b.rect.y.saturating_add(b.rect.height.max(1));
        if bottom > max_y {
            max_y = bottom;
        }
    }
    if max_y as u32 > term_layout.rendered_rows {
        term_layout.rendered_rows = max_y as u32;
    }

    let columns = term_layout.columns;
    let mut canvas = PaintCanvas {
        layout: &mut term_layout.layout,
        columns,
        image_cache: &mut term_layout.image_cache,
        region: None,
        input_states: &term_layout.input_states,
        open_select: term_layout.open_select,
        scrollbars_visible: &term_layout.scrollbars_visible,
    };
    for b in boxes {
        paint_box(b, arena, &mut canvas, None, None);
    }
    fill_canvas_background(&mut canvas, boxes, arena, max_y);
}

/// Repaint only the cells inside `region`, leaving the rest of the canvas
/// untouched.
///
/// Backs the incremental render path: the layout has already been re-run and
/// every box rect verified unchanged against the cached layout, so only the
/// cells the dirty nodes' boxes (and their ancestors' background fills) cover
/// can differ from the previous frame. Boxes whose rect does not intersect
/// `region` are skipped entirely; the rest paint normally, with every cell
/// write gated to the region by `PaintCanvas::in_region`. Because only the
/// region's cells are rewritten, the diff renderer emits exactly the cells
/// that changed.
pub fn paint_layout_tree_region(
    boxes: &[LayoutBox],
    arena: &NodeArena,
    term_layout: &mut TermLayout,
    region: Rect,
) {
    let mut max_y = 0usize;
    for b in boxes {
        let bottom = b.rect.y.saturating_add(b.rect.height.max(1));
        if bottom > max_y {
            max_y = bottom;
        }
    }
    if max_y as u32 > term_layout.rendered_rows {
        term_layout.rendered_rows = max_y as u32;
    }

    let columns = term_layout.columns;
    let mut canvas = PaintCanvas {
        layout: &mut term_layout.layout,
        columns,
        image_cache: &mut term_layout.image_cache,
        region: Some(region),
        input_states: &term_layout.input_states,
        open_select: term_layout.open_select,
        scrollbars_visible: &term_layout.scrollbars_visible,
    };
    for b in boxes {
        if intersect_rects(b.rect, region).is_some() {
            paint_box(b, arena, &mut canvas, None, None);
        }
    }
    fill_canvas_background(&mut canvas, boxes, arena, max_y);
}

/// Paint the canvas background below the content, browser-style.
///
/// In browsers the `<html>` element's `background-color` paints the whole
/// canvas; when `<html>` is transparent the `<body>` background propagates
/// instead. Boxes only fill their own rects, so a short page would otherwise
/// leave the terminal rows below the content unpainted. This fills every
/// still-default cell from the content bottom down to the canvas end with the
/// propagated colour. Cell writes stay gated by [`PaintCanvas::in_region`], so
/// the incremental-repaint path never touches cells outside its region.
fn fill_canvas_background(
    canvas: &mut PaintCanvas<'_>,
    boxes: &[LayoutBox],
    arena: &NodeArena,
    content_bottom: usize,
) {
    let Some(bg) = find_canvas_background(boxes, arena) else {
        return;
    };
    let cols = canvas.columns as usize;
    let rows = canvas.layout.len() / cols;
    let bg_rect = Rect::new(0, 0, cols, rows);
    let start_row = content_bottom.min(rows);
    for y in start_row..rows {
        let row_start = y * cols;
        for i in 0..cols {
            let idx = row_start + i;
            if !canvas.in_region(idx) {
                continue;
            }
            let cell = &mut canvas.layout[idx];
            // Only still-default cells: never clobber content that happens to
            // sit below `content_bottom` (e.g. a positioned child).
            if cell.word == " " && cell.bcolor.is_none() && cell.style.is_empty() {
                cell.bcolor = bg.color_at(i, y, bg_rect.width, bg_rect.height);
            }
        }
    }
}

/// The colour the canvas should paint: the `<html>` background, or the
/// `<body>` background when `<html>` has none (browser propagation). `None`
/// when neither element paints one.
fn find_canvas_background(boxes: &[LayoutBox], arena: &NodeArena) -> Option<Background> {
    for b in boxes {
        if let Some(bg) = box_canvas_background(b, arena) {
            return Some(bg);
        }
    }
    None
}

fn box_canvas_background(lb: &LayoutBox, arena: &NodeArena) -> Option<Background> {
    let node = &arena[lb.node_id];
    if node.node_type == NodeType::Html {
        if !node.css_attr.background_clips_to_text()
            && let Some(bg) = node.css_attr.get_background()
        {
            return Some(bg);
        }
        for child in &lb.children {
            if arena[child.node_id].node_type == NodeType::Body
                && !arena[child.node_id].css_attr.background_clips_to_text()
                && let Some(bg) = arena[child.node_id].css_attr.get_background()
            {
                return Some(bg);
            }
        }
    }
    // Fragment documents may lack an `<html>` wrapper — recurse.
    for child in &lb.children {
        if let Some(bg) = box_canvas_background(child, arena) {
            return Some(bg);
        }
    }
    None
}

/// A background source plus the rectangle its gradient coordinates use.
#[derive(Clone, Copy)]
pub(super) struct BackgroundFill<'a> {
    pub background: &'a Background,
    pub rect: Rect,
}

impl BackgroundFill<'_> {
    pub(super) fn color_at(&self, x: usize, y: usize) -> Option<Color> {
        self.background.color_at(
            x.saturating_sub(self.rect.x),
            y.saturating_sub(self.rect.y),
            self.rect.width,
            self.rect.height,
        )
    }
}

/// Resolve an inherited background at one canvas cell.
pub(super) fn background_at(fill: Option<BackgroundFill<'_>>, x: usize, y: usize) -> Option<Color> {
    fill.and_then(|fill| fill.color_at(x, y))
}

/// Resolve a text glyph's foreground, preferring an explicit solid color and
/// falling back to the per-cell background sample used by
/// `background-clip: text`.
pub(super) fn text_color_at(
    fg: Option<Color>,
    fill: Option<BackgroundFill<'_>>,
    x: usize,
    y: usize,
) -> Option<Color> {
    fg.or_else(|| background_at(fill, x, y))
}

/// Clamp `a` to the intersection with `b`, or return `None` when the two
/// rectangles do not overlap. Both rects are in absolute canvas coordinates.
pub(super) fn intersect_rects(a: Rect, b: Rect) -> Option<Rect> {
    let x0 = a.x.max(b.x);
    let y0 = a.y.max(b.y);
    let x1 = a.x.saturating_add(a.width).min(b.x.saturating_add(b.width));
    let y1 =
        a.y.saturating_add(a.height)
            .min(b.y.saturating_add(b.height));
    if x1 <= x0 || y1 <= y0 {
        None
    } else {
        Some(Rect::new(x0, y0, x1 - x0, y1 - y0))
    }
}

/// Clamp a box rect to the canvas width so its frame / text writes never
/// land past the terminal's right edge, where `cell_index` would wrap into
/// the next row of the flat canvas buffer.
///
/// [`paint_block`] already clamps the rect it paints with; the replaced
/// element painters (`<input>` / `<textarea>` / `<button>` / `<select>`) get
/// the same treatment here, since a flex-placed item can hand them a rect
/// wider than the viewport — without the clamp the frame breaks (missing
/// right side) and the wrapped writes corrupt the rows below.
pub(super) fn clamp_rect_to_canvas(r: Rect, canvas_cols: usize) -> Option<Rect> {
    if r.x >= canvas_cols || r.width == 0 || r.height == 0 {
        return None;
    }
    Some(Rect::new(
        r.x,
        r.y,
        r.width.min(canvas_cols - r.x),
        r.height,
    ))
}

/// Paint one box (and its subtree) into the canvas.
///
/// `clip` is the box's inherited clip region (absolute canvas coordinates):
/// the intersection of every clipping ancestor's content box. Content that
/// would be painted outside the clip is dropped instead of spilling onto
/// rows below or beyond the box — on very small terminals the flexible
/// algorithm can hand a box a rect smaller than its content, and without
/// this clip the wrapped lines / block children would paint over the next
/// sibling.
///
/// The box's own `overflow` decides how much further its subtree is bounded:
/// `visible` (the CSS default) passes the inherited clip through unchanged —
/// overflowing lines and children paint beyond this box, exactly like a
/// browser — while `hidden` / `auto` / `scroll` / `clip` intersect the clip
/// with the box's content area (the container-scroll keywords have no
/// terminal counterpart; the engine scrolls the whole document).
fn paint_box(
    lb: &LayoutBox,
    arena: &NodeArena,
    canvas: &mut PaintCanvas<'_>,
    parent_bg: Option<BackgroundFill<'_>>,
    clip: Option<Rect>,
) {
    let canvas_cols = canvas.columns as usize;
    let overflow_clips =
        arena[lb.node_id].css_attr.get_overflow() == crate::html::css_parse::Overflow::Clip;
    // A box laid out wider than the terminal (e.g. a flex row whose items
    // overflow the viewport) must not paint past the right edge, where the
    // flat canvas wraps writes into the next row. Clamp the rect once here so
    // the content area / children clip derived below also end at the canvas
    // edge — otherwise a child's background would cover the parent's
    // (clamped) border cell and leave the frame with a hole on the right
    // side when the terminal shrinks. A rect beyond the canvas / zero-width
    // keeps its geometry: the zero-area branch below still lets positioned
    // children paint.
    let rect = clamp_rect_to_canvas(lb.rect, canvas_cols).unwrap_or(lb.rect);
    // This box's own clip = inherited clip ∩ own border-box. A box never
    // paints outside its ancestors' borders.
    let own_clip = match clip {
        Some(c) => match intersect_rects(c, rect) {
            Some(r) => r,
            None => return,
        },
        None => rect,
    };
    if own_clip.width == 0 || own_clip.height == 0 {
        // Zero-area box: no chrome of its own, but positioned children are
        // anchored to a containing block (or offset out of this area) and
        // must still paint. Static / relative-in-place children stay clipped
        // away by the zero clip.
        for child in sorted_children(lb) {
            if !matches!(child.positioning.position, Position::Static) {
                paint_box(child, arena, canvas, parent_bg, None);
            }
        }
        return;
    }
    // The clip the box's own painting (background fill / borders / inline
    // runs) is bounded by. A clipping box bounds its runs at its content
    // area ([`paint_block`] intersects further); a `visible` box only
    // inherits its ancestors' clip — at the top level that is the whole
    // canvas, so overflowing lines paint beyond the box, browser-style.
    let own_paint_clip = if overflow_clips {
        own_clip
    } else {
        clip.unwrap_or_else(|| {
            Rect::new(0, 0, canvas_cols, canvas.layout.len() / canvas_cols.max(1))
        })
    };
    let my_bg = match &lb.kind {
        BoxKind::Normal => arena[lb.node_id].css_attr.get_background(),
        BoxKind::Replaced(_) => None,
        BoxKind::Anonymous => None,
    };
    // `visibility: hidden` (inherited, so a child's `visible` re-shows it):
    // the box keeps its layout space but paints nothing of its own chrome /
    // inline content. Children still descend — each node checks its own
    // resolved visibility, exactly like the browser.
    let visibility_hidden = matches!(
        arena[lb.node_id].css_attr.get_visibility(),
        Visibility::Hidden
    );
    if !visibility_hidden {
        match &lb.kind {
            BoxKind::Normal => {
                paint_block(lb, arena, canvas, my_bg.as_ref(), parent_bg, own_paint_clip);
            }
            BoxKind::Replaced(ReplacedContent::Image { cols, rows, .. }) => {
                paint_image(lb, canvas, *cols, *rows, own_clip);
            }
            BoxKind::Replaced(ReplacedContent::Input) => {
                paint_input(lb, arena, canvas, own_clip);
            }
            BoxKind::Replaced(ReplacedContent::Select) => {
                paint_select(lb, arena, canvas, own_clip);
            }
            BoxKind::Replaced(ReplacedContent::Textarea) => {
                paint_textarea(lb, arena, canvas, own_clip);
            }
            BoxKind::Replaced(ReplacedContent::Button) => {
                paint_button(lb, arena, canvas, own_clip);
            }
            BoxKind::Anonymous => {
                // Anonymous boxes have no chrome of their own; their visible
                // background is whatever their children paint, so just descend.
            }
        }
    }
    // The effective bg visible at this box's rect becomes the parent bg for
    // any descendant. If the current box has its own bg, that wins; otherwise
    // we inherit. This is what lets `<aside bg=cyan><p>30col</p></aside>`
    // render the text with cyan bg even though `<p>` itself has no bg.
    let effective_bg = if arena[lb.node_id].css_attr.background_clips_to_text() {
        parent_bg
    } else {
        my_bg
            .as_ref()
            .map(|background| BackgroundFill { background, rect })
            .or(parent_bg)
    };
    // Children of a clipping box are bounded by the content-box (the rect
    // shrunk by border + padding): anything that spills out of it — e.g. a
    // squeezed flex item's wrapped lines — is dropped rather than painted
    // over the padding / border rows of this box or the sibling below. The
    // content area is NOT rounded up to 1 cell: when a box is smaller than
    // its border + padding the true content area is empty and the children
    // are dropped entirely, so no descendant can ever overwrite a frame
    // cell. Children of a `visible` box only inherit the ancestors' clip —
    // overflow paints beyond this box, like a browser.
    let edges = &lb.paint_style.box_edges;
    let content_area = Rect::new(
        rect.x
            .saturating_add(edges.border.left.thickness())
            .saturating_add(edges.padding.left),
        rect.y
            .saturating_add(edges.border.top.thickness())
            .saturating_add(edges.padding.top),
        rect.width.saturating_sub(edges.inner_horizontal()),
        rect.height.saturating_sub(edges.inner_vertical()),
    );
    let children_clip = if overflow_clips {
        Some(intersect_rects(own_clip, content_area).unwrap_or(Rect::new(0, 0, 0, 0)))
    } else {
        clip
    };
    paint_children(lb, arena, canvas, effective_bg, children_clip);
    paint_scrollbar(lb, arena, canvas);
}

/// Paint the native scrollbars after clipped content so they always overlay
/// the container's right gutter (vertical) / bottom edge (horizontal).
fn paint_scrollbar(lb: &LayoutBox, arena: &NodeArena, canvas: &mut PaintCanvas<'_>) {
    if !canvas
        .scrollbars_visible
        .get(&lb.node_id)
        .copied()
        .unwrap_or(false)
    {
        return;
    }
    let Some(scroll) = lb.paint_style.scrollbar else {
        return;
    };
    let fg = arena
        .get(lb.node_id)
        .and_then(|node| node.css_attr.get_color());
    let dim = Some(fg.unwrap_or(crossterm::style::Color::DarkGrey));
    if let Some(track) = clamp_rect_to_canvas(scroll.track, canvas.columns as usize) {
        for y in track.y..track.y.saturating_add(track.height) {
            let inside_thumb =
                y >= scroll.thumb.y && y < scroll.thumb.y.saturating_add(scroll.thumb.height);
            put(
                canvas,
                cell_index(canvas, track.x, y),
                Some(if inside_thumb { '\u{2588}' } else { '\u{2502}' }),
                dim,
                None,
            );
        }
    }
    // Horizontal chrome: one-row bottom track, `─` track cells and a `█`
    // thumb over the viewport columns.
    if let Some(h_track) = clamp_rect_to_canvas(scroll.h_track, canvas.columns as usize)
        && h_track.height > 0
    {
        for x in h_track.x..h_track.x.saturating_add(h_track.width) {
            let inside_thumb =
                x >= scroll.h_thumb.x && x < scroll.h_thumb.x.saturating_add(scroll.h_thumb.width);
            put(
                canvas,
                cell_index(canvas, x, h_track.y),
                Some(if inside_thumb { '\u{2588}' } else { '\u{2500}' }),
                dim,
                None,
            );
        }
    }
}

/// Paint `lb`'s children in stacking order.
///
/// `children_clip` is inherited by static children: a clipping parent hands
/// down its content area (a zero-area rect drops them entirely — the box is
/// smaller than its border + padding), a `visible` parent hands down its own
/// inherited clip (`None` at the top level = unrestricted, so overflow
/// paints beyond the parent). Detached positioned children are not clipped
/// by the parent — they are anchored to a containing block that may extend
/// beyond it — and their own rects clip their descendants via
/// [`paint_box`].
fn paint_children(
    lb: &LayoutBox,
    arena: &NodeArena,
    canvas: &mut PaintCanvas<'_>,
    effective_bg: Option<BackgroundFill<'_>>,
    children_clip: Option<Rect>,
) {
    for child in sorted_children(lb) {
        // Positioned children (absolute / fixed / relative) may paint outside
        // the parent's content area — they are anchored to a containing block
        // or offset out of flow — so they are not clipped by the ancestor
        // chain. Their own rects still clip their descendants via
        // [`paint_box`]. Static children inherit the parent's clip.
        let clip = match child.positioning.position {
            Position::Static => children_clip,
            _ => None,
        };
        paint_box(child, arena, canvas, effective_bg, clip);
    }
}

/// Children in paint stacking order.
///
/// Higher `z-index` paints later (on top); among equal values a positioned
/// box paints above static content (browser rule); the stable sort keeps DOM
/// order within the same level.
///
/// The common case — no positioned child and uniform `z-index` — leaves the
/// sort key identical for every child, so a stable sort cannot reorder
/// anything. [`SortedChildren`] fast-paths that case by iterating the
/// children slice directly (zero allocation); only when the keys actually
/// differ does it collect into a `Vec` and sort.
fn sorted_children(lb: &LayoutBox) -> SortedChildren<'_> {
    let first = lb.children.first().map(stacking_key);
    let needs_sort = lb.children.iter().any(|c| Some(stacking_key(c)) != first);
    if needs_sort {
        let mut children: Vec<&LayoutBox> = lb.children.iter().collect();
        children.sort_by_key(|c| stacking_key(c));
        SortedChildren::Sorted(children.into_iter())
    } else {
        SortedChildren::Straight(lb.children.iter())
    }
}

/// Paint stacking key of one child box: `(z-index, positioned-above-static)`.
///
/// Higher key paints later (on top); the stable sort keeps DOM order within
/// the same level. Shared with the hit-map builder
/// ([`crate::events::HitMap`]) so hit testing resolves overlapping regions in
/// the same stacking order the paint phase draws — the browser rule that the
/// hit target is the element painted on top.
pub(crate) fn stacking_key(c: &LayoutBox) -> (i32, u8) {
    (
        c.positioning.z_index.unwrap_or(0),
        !matches!(c.positioning.position, Position::Static) as u8,
    )
}

/// Iterator over a box's children in paint stacking order — see
/// [`sorted_children`].
enum SortedChildren<'a> {
    /// Sort keys are all equal: iterate the slice as-is (no allocation).
    Straight(std::slice::Iter<'a, LayoutBox>),
    /// Mixed sort keys: a collected, sorted `Vec`.
    Sorted(std::vec::IntoIter<&'a LayoutBox>),
}

impl<'a> Iterator for SortedChildren<'a> {
    type Item = &'a LayoutBox;

    fn next(&mut self) -> Option<Self::Item> {
        match self {
            SortedChildren::Straight(it) => it.next(),
            SortedChildren::Sorted(it) => it.next(),
        }
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        match self {
            SortedChildren::Straight(it) => it.size_hint(),
            SortedChildren::Sorted(it) => it.size_hint(),
        }
    }
}