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
use crate::{
    graphics::{FontStyle, block_style},
    html::css_parse::{Background, TextAlign},
    layout::{LayoutBox, Rect},
    tdom::node::NodeArena,
};

use super::{
    BackgroundFill, PaintCanvas, background_at, border::paint_borders, intersect_rects,
    text::paint_runs,
};

pub(crate) fn paint_block(
    lb: &LayoutBox,
    arena: &NodeArena,
    canvas: &mut PaintCanvas<'_>,
    my_bg: Option<&Background>,
    parent_bg: Option<BackgroundFill<'_>>,
    clip: Rect,
) {
    let node = &arena[lb.node_id];
    let fg = node.css_attr.get_color();
    let background_clipped_to_text = node.css_attr.background_clips_to_text();
    let style = block_style(&node.css_attr);
    // The resolved alignment comes from the paint style, not the cascade:
    // user-agent defaults the layout phase resolved (`<th>`'s `center`) are
    // not stored in the node's `css_attr`, so the paint style is the single
    // source the paint phase may consult.
    let align = lb.paint_style.text_align;

    let cols = canvas.columns as usize;
    // A box can be wider than the terminal when a parent formatting context
    // (flex, replaced element) hands it a rectangle that overflows the
    // viewport. The canvas is a flat 1-D buffer, so writing past the right
    // edge of a row would wrap into the next row's cells and corrupt the
    // siblings below it — the misalignment seen on very small terminals.
    // Clamp the rect to the canvas width so overflow is clipped at the
    // terminal edge (mirroring browser viewport clipping) instead of
    // wrapping into the next row.
    let r = lb.rect;
    if r.x >= cols || r.width == 0 {
        return;
    }
    let r = Rect::new(r.x, r.y, r.width.min(cols - r.x), r.height);
    // Border glyphs sit on this full border-box — [`paint_borders`] cuts
    // them against `clip` per cell, so the rect itself must stay unshrunk
    // (an intersected rect would relocate the bottom edge to the clip
    // boundary instead of clipping it away).
    let border_rect = r;
    // Inherited clip (ancestors' border-boxes) narrows the paint region
    // further: when a box overflows its parent, the cells beyond the clip are
    // dropped instead of painting over the sibling below.
    let r = match intersect_rects(r, clip) {
        Some(r) => r,
        None => return,
    };
    // Own background wins; otherwise inherit the parent's full fill descriptor
    // so a descendant keeps sampling the ancestor gradient's coordinates.
    let effective_bg = if background_clipped_to_text {
        parent_bg
    } else {
        my_bg
            .map(|background| BackgroundFill {
                background,
                rect: r,
            })
            .or(parent_bg)
    };
    let text_fill = if background_clipped_to_text {
        my_bg.map(|background| BackgroundFill {
            background,
            rect: r,
        })
    } else {
        None
    };
    let cell_start = canvas.cell_index(r.x, r.y);

    // `fill_bg` is a layout-time flag: whether the base cascade gave the
    // element a background. A `:hover` re-cascade (the render thread's `Hover`
    // path) can add a background to an element that had none at layout time
    // without re-laying-out, so the fill must also run when the *current*
    // cascade carries one — otherwise the padding cells of e.g. a bordered
    // `.btn:hover` keep the parent background and the highlight looks hollow.
    let fill_bg = !background_clipped_to_text && (lb.paint_style.fill_bg || my_bg.is_some());
    let spacing_after = lb.paint_style.spacing_after;
    let box_edges = &lb.paint_style.box_edges;
    // Row of the glyph block within its line box. With `line-height > font`
    // the extra rows are split above and below the glyphs; otherwise the
    // glyphs keep the historical centering within the font-sized line box.
    let glyph_row_within_line = crate::html::css_parse::glyph_row_within_line(
        lb.paint_style.font_size_cells,
        lb.paint_style.line_height_cells,
    );

    if fill_bg {
        // Paint the background across every row of the rect that is *not* a
        // trailing spacing row. Only blocks with an explicit `background-color`
        // fill here; the trailing `spacing_after` rows reserved by `<hr>` stay
        // blank.
        //
        // CSS rule: the background-color paints the **padding-box** (the
        // border-box minus the border thickness), not the margin area. We
        // therefore offset `bg_start_x` by `border.left.thickness()` and
        // reduce `bg_width` by `border.horizontal()`. Without this, a block
        // with `margin: 1px 6px; background-color: black` would paint the
        // margin ring as well, which is invisible in graphical browsers
        // but would stand out in the terminal as an extra coloured band
        // on every side of the block.
        let border_horizontal =
            box_edges.border.left.thickness() + box_edges.border.right.thickness();
        let border_top = box_edges.border.top.thickness();
        let border_left = box_edges.border.left.thickness();
        let has_padding = box_edges.padding.top
            + box_edges.padding.right
            + box_edges.padding.bottom
            + box_edges.padding.left
            > 0;
        let text_clamped = !background_clipped_to_text
            && my_bg.is_some()
            && !has_padding
            && lb.paint_style.font_size_cells > 1;
        let text_row_offset: usize = if text_clamped {
            glyph_row_within_line
        } else {
            0
        };
        let (bg_start_x, bg_width) = if text_clamped {
            if let Some(runs) = &lb.paint_style.runs {
                let inner_h = box_edges.inner_horizontal();
                let content_x_offset = box_edges.border.left.thickness() + box_edges.padding.left;
                let content_width = r.width.saturating_sub(inner_h).max(1);
                // `runs` is `Vec<Vec<InlineRun>>` (one entry per visual
                // line after wrapping). The widest visual line drives
                // the text-align offset; the bg only needs to cover
                // that widest line, so flatten across all lines and
                // pick the max per-line width.
                let widest_line_width = runs
                    .iter()
                    .map(|line| {
                        line.iter()
                            .map(|run| run.width_at(lb.paint_style.font_size_cells))
                            .sum::<usize>()
                    })
                    .max()
                    .unwrap_or(0);
                let text_width = widest_line_width.min(content_width);
                let text_width = text_width.min(content_width);
                let offset = match align {
                    TextAlign::Left => 0,
                    TextAlign::Center => content_width.saturating_sub(text_width) / 2,
                    TextAlign::Right => content_width.saturating_sub(text_width),
                };
                let visible_width = text_width.max(1);
                (r.x + content_x_offset + offset, visible_width)
            } else {
                (r.x, r.width)
            }
        } else {
            // Default: bg covers the padding-box (border-box minus border).
            (r.x + border_left, r.width.saturating_sub(border_horizontal))
        };
        let rows_to_fill = r
            .height
            .saturating_sub(spacing_after)
            .saturating_sub(border_top);
        for row_offset in 0..rows_to_fill {
            if text_clamped && row_offset != text_row_offset {
                continue;
            }
            let row_cell_start = cell_start + (row_offset + border_top) * cols;
            for i in 0..bg_width {
                let x = bg_start_x + i;
                let y = r.y + row_offset + border_top;
                let idx = row_cell_start + (bg_start_x - r.x) + i;
                if idx < canvas.layout.len() && canvas.in_region(idx) {
                    // Mutate in place: `clear()` keeps the 4-byte buffer from
                    // `Block::default()`, `push(' ')` reuses it, then the
                    // colour fields flip. This avoids the per-cell
                    // alloc/free of `cells[idx] = Block::new(...)`.
                    let cell = &mut canvas.layout[idx];
                    cell.word.clear();
                    cell.word.push(' ');
                    cell.fcolor = fg;
                    cell.bcolor = background_at(effective_bg, x, y);
                    cell.style = style;
                }
            }
        }
    }

    // Inline content sits in the content-box (rect shrunk by border +
    // padding), not the border-box, so text-align and run positioning
    // work against the inner width.
    if lb.paint_style.column_layout.is_some() {
        paint_column_layout(
            lb,
            canvas,
            clip,
            box_edges,
            align,
            effective_bg,
            text_fill,
            r,
            cell_start,
            style,
            glyph_row_within_line,
        );
    } else if let Some(runs) = &lb.paint_style.runs {
        let inner_h = box_edges.inner_horizontal();
        let border_top = box_edges.border.top.thickness();
        let padding_top = box_edges.padding.top;
        let content_x_offset = box_edges.border.left.thickness() + box_edges.padding.left;
        let content_width = r.width.saturating_sub(inner_h).max(1);
        // When font-size > 1 the box's rect is taller (height = font_size
        // cells) so the text baseline visually sits on the middle row of the
        // rect, with empty cells of the run's background above and below.
        // The extra leading rows are also filled with `effective_bg` by the
        // background loop above, so the box reads as a tall band rather than
        // a 1-row strip.
        let vertical_offset = glyph_row_within_line * cols;
        let scroll_offset = lb.paint_style.scrollbar.map_or(0, |scroll| scroll.scroll_y);
        let content_row = cell_start
            + (border_top.saturating_add(padding_top)) * cols
            + content_x_offset
            + vertical_offset;
        let content_row = content_row.saturating_sub(scroll_offset * cols);
        // The runs of a clipping box (`overflow: hidden` / `auto` / `scroll`
        // / `clip`) are additionally bounded by the content-box (the rect
        // minus border + padding): wrapped text that overflows the allocated
        // content area must not paint onto the padding / border rows (which
        // would break the frame glyphs). The inherited clip still applies on
        // top. A `visible` box skips that bound — overflowing lines paint
        // beyond the box, bounded only by the inherited clip / canvas.
        let border_bottom = box_edges.border.bottom.thickness();
        let padding_bottom = box_edges.padding.bottom;
        let content_clip = Rect::new(
            r.x.saturating_add(content_x_offset),
            r.y.saturating_add(border_top).saturating_add(padding_top),
            content_width,
            r.height
                .saturating_sub(border_top)
                .saturating_sub(padding_top)
                .saturating_sub(border_bottom)
                .saturating_sub(padding_bottom)
                .max(1),
        );
        let overflow_visible =
            node.css_attr.get_overflow() != crate::html::css_parse::Overflow::Clip;
        // Horizontal bound for the box's own glyph stream, in columns from
        // the row start (the paint pass re-bases it on every visual line).
        // - clipping box: the content-box right edge (historic behaviour);
        // - visibly-overflowing **bordered** box: the frame (border-box)
        //   right edge — a long / nowrap word may underlap the right border
        //   column but must not paint past the drawn frame into the
        //   neighbouring area (the terminal's "出框" artefact);
        // - visibly-overflowing borderless box: the row's canvas edge —
        //   browser-faithful `overflow: visible` spill into the page,
        //   still row-bounded so the flat buffer never wraps a row.
        let has_right_frame = box_edges.border.right.thickness() > 0;
        let h_bound_cols = if !overflow_visible {
            (content_row % cols).saturating_add(content_width)
        } else if has_right_frame {
            r.x.saturating_add(r.width).min(cols)
        } else {
            cols
        };
        if overflow_visible {
            paint_runs(
                canvas,
                runs,
                content_row,
                content_width,
                align,
                effective_bg,
                text_fill,
                lb.paint_style.font_size_cells,
                lb.paint_style.line_height_cells,
                clip,
                h_bound_cols,
                lb.paint_style.text_indent,
            );
        } else if let Some(runs_clip) = intersect_rects(content_clip, clip) {
            paint_runs(
                canvas,
                runs,
                content_row,
                content_width,
                align,
                effective_bg,
                text_fill,
                lb.paint_style.font_size_cells,
                lb.paint_style.line_height_cells,
                runs_clip,
                h_bound_cols,
                lb.paint_style.text_indent,
            );
        }
    }

    // Border glyphs overlay the border cells (after fill and content). When
    // no border is configured this is a no-op. Borders use the same fg / bg
    // as the box; individual border colors are layered on top when set. The
    // clamped rect is passed down so edges never extend past the terminal
    // width. Painting last guarantees a frame cell keeps its glyph even if
    // wrapped content reached it.
    paint_borders(lb, border_rect, canvas, fg, effective_bg, clip);
}

/// Paint a multi-column box's fragmented inline content and column rules.
///
/// Each column's wrapped lines are painted at its own column x with the
/// column width (the text may be `text-align`-ed inside the column); column
/// rules (`column-rule`) are vertical `│` glyphs centred in each gap, painted
/// with the rule colour over the box's effective background. All writes stay
/// inside the box's content area and are clipped by the inherited `clip`.
#[allow(clippy::too_many_arguments)]
fn paint_column_layout(
    lb: &LayoutBox,
    canvas: &mut PaintCanvas<'_>,
    clip: Rect,
    box_edges: &crate::html::box_edges::BoxEdges,
    align: TextAlign,
    effective_bg: Option<BackgroundFill<'_>>,
    text_fill: Option<BackgroundFill<'_>>,
    r: Rect,
    cell_start: usize,
    style: FontStyle,
    glyph_row_within_line: usize,
) {
    let Some(columns_layout) = &lb.paint_style.column_layout else {
        return;
    };
    let cols = canvas.columns as usize;
    let font = lb.paint_style.font_size_cells.max(1);
    let line_h = lb.paint_style.line_height_cells.max(1);
    let border_top = box_edges.border.top.thickness();
    let padding_top = box_edges.padding.top;
    let content_top_abs = r.y.saturating_add(border_top).saturating_add(padding_top);
    let content_right = r.x.saturating_add(r.width);
    let vertical_offset = glyph_row_within_line * cols;
    let content_height = columns_layout.height;

    // Each column paints its wrapped lines inside the box's content area.
    for (col, lines) in columns_layout.lines.iter().enumerate() {
        let col_x = columns_layout.col_x[col];
        if col_x >= content_right {
            continue;
        }
        let col_width = (content_right - col_x).min(columns_layout.col_width);
        let col_clip = Rect::new(col_x, content_top_abs, col_width, content_height);
        if let Some(col_clip) = intersect_rects(col_clip, clip) {
            let col_cell_start = cell_start
                .saturating_add(col_x.saturating_sub(r.x))
                .saturating_add(border_top.saturating_add(padding_top).saturating_mul(cols))
                .saturating_add(vertical_offset);
            paint_runs(
                canvas,
                lines,
                col_cell_start,
                col_width,
                align,
                effective_bg,
                text_fill,
                font,
                line_h,
                col_clip,
                // Multicol fragmentation is intrinsic clipping: each column
                // bounds its own lines regardless of the box's overflow.
                (col_cell_start % cols).saturating_add(col_width),
                0,
            );
        }
    }

    // Column rules: vertical `│` glyphs centred in each gap, spanning the
    // content height (clipped by the box's content area and the inherited
    // clip). The colour may be `None` (no declared rule / element colour) —
    // like borders, the cell then keeps the terminal default foreground.
    if !columns_layout.rule_x.is_empty() {
        let rule_color = columns_layout.rule_color;
        let row_top = content_top_abs;
        // Bottom (exclusive) of the content area: the rect minus the bottom
        // border and padding. The top border is already reflected in
        // `content_top_abs`, so it is not subtracted here.
        let content_area_bottom = r.y.saturating_add(
            r.height
                .saturating_sub(box_edges.border.bottom.thickness())
                .saturating_sub(box_edges.padding.bottom),
        );
        let row_bottom = content_top_abs
            .saturating_add(content_height)
            .min(content_area_bottom);
        for &rule_x in &columns_layout.rule_x {
            if rule_x >= content_right {
                continue;
            }
            for y in row_top..row_bottom {
                if y < clip.y || y >= clip.y.saturating_add(clip.height) {
                    continue;
                }
                let idx = canvas.cell_index(rule_x, y);
                if idx < canvas.layout.len() && canvas.in_region(idx) {
                    let cell = &mut canvas.layout[idx];
                    cell.word.clear();
                    cell.word.push('│');
                    cell.fcolor = rule_color;
                    cell.bcolor = background_at(effective_bg, rule_x, y);
                    cell.style = style;
                }
            }
        }
    }
}