rdom-tui 0.3.10

Terminal rendering layer for rdom-core — flexbox layout, TUI styles, key/mouse events. Use rdom-core directly for headless DOM manipulation.
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
//! Intrinsic sizing — what size does an element want along
//! `direction`, given a `cross_budget` on the perpendicular axis?
//!
//! Used by the flex layout to resolve `Size::Auto`:
//!
//! - Text nodes → widest line (Row) / line count (Column), via
//!   `unicode-width`.
//! - Elements with explicit `Size::Fixed(n)` → `n` (short-circuit).
//! - IFC blocks → inline content width (unwrapped sum) on the Row
//!   axis; line count at `cross_budget` on the Column axis.
//! - Everything else → recursive fit of children +
//!   padding/border/gap costs.

use rdom_core::{Dom, NodeId, NodeType};
use unicode_width::UnicodeWidthStr;

use crate::ext::TuiExt;
use crate::layout::{Direction, Size};
use crate::node::TuiNodeExt;
use crate::render::inline::compute_inline_layout;
use crate::style::ComputedStyle;

use super::ifc::is_ifc_block;

/// Measure an element's intrinsic size along `direction`. Used to
/// resolve `Size::Auto`. `cross_budget` is the container's
/// perpendicular dimension — consulted by IFC blocks to decide how
/// many lines their content wraps to.
pub(crate) fn intrinsic_size(
    dom: &Dom<TuiExt>,
    id: NodeId,
    direction: Direction,
    cross_budget: u16,
) -> u16 {
    intrinsic_size_inner(dom, id, direction, cross_budget, IntrinsicMode::BoxSize)
}

/// Measure an element's **content** intrinsic size along `direction` —
/// i.e. the min-content size of its actual children/text, ignoring
/// any explicit `Size::Fixed` declared on the element itself. Used
/// by CSS Flexbox §4.5's "content size suggestion" half of the
/// auto-min computation, where we need to know how small the content
/// can be regardless of the box's declared size.
pub(super) fn content_min_size(
    dom: &Dom<TuiExt>,
    id: NodeId,
    direction: Direction,
    cross_budget: u16,
) -> u16 {
    intrinsic_size_inner(dom, id, direction, cross_budget, IntrinsicMode::ContentOnly)
}

/// How `intrinsic_size_inner` interprets the element's declared
/// size.
///
/// Two callers in the layout pass need subtly different things:
///
/// 1. **Flex layout asking "how big does this child want to be on
///    the main axis?"** — wants the box's declared size when set
///    (`width: 30` means "I want 30"). Pick `BoxSize`. Used by
///    `Size::Auto` resolution in `layout_flex_children`'s natural-
///    size computation and by intrinsic measurement of grow-
///    children's cross-axis suggestions.
///
/// 2. **Flex layout computing CSS Flexbox §4.5 content size
///    suggestion** — wants the min-content of the actual content
///    (text + children), even when the element has a declared
///    size that's larger or smaller. Pick `ContentOnly`. Without
///    this, an empty `<a width=100 max-width=30>` would report
///    intrinsic = 100 (from the short-circuit) instead of 0
///    (its actual content), and `max-width: 30` would never get
///    a chance to clamp the box down.
///
/// Recursive descent always uses `BoxSize` for children — the
/// content-only mode only skips the short-circuit at the TOP of
/// the call stack. The auto-min rule applies to the box being
/// measured, not its descendants.
#[derive(Copy, Clone, PartialEq, Eq)]
pub(super) enum IntrinsicMode {
    /// Honor an explicit `Size::Fixed` on the element (short-
    /// circuit to that value). "Size of the box as it wants to
    /// appear in layout."
    BoxSize,
    /// Ignore any declared `Size::Fixed`; always measure children
    /// plus text. "Size of the content irrespective of the box's
    /// declaration." CSS Flexbox §4.5's content size suggestion.
    ContentOnly,
}

fn intrinsic_size_inner(
    dom: &Dom<TuiExt>,
    id: NodeId,
    direction: Direction,
    cross_budget: u16,
    mode: IntrinsicMode,
) -> u16 {
    let kind = dom.node(id).node_type();
    match kind {
        NodeType::Text => intrinsic_text(dom, id, direction),
        NodeType::Element | NodeType::Fragment => {
            intrinsic_element(dom, id, direction, cross_budget, mode)
        }
        NodeType::Comment => 0,
    }
}

fn intrinsic_text(dom: &Dom<TuiExt>, id: NodeId, direction: Direction) -> u16 {
    let text = dom.text_content(id);
    match direction {
        Direction::Row => {
            // Widest line (in case text has newlines).
            text.lines()
                .map(|line| UnicodeWidthStr::width(line) as u16)
                .max()
                .unwrap_or(0)
        }
        Direction::Column => text.lines().count().max(1) as u16,
    }
}

fn intrinsic_element(
    dom: &Dom<TuiExt>,
    id: NodeId,
    direction: Direction,
    cross_budget: u16,
    mode: IntrinsicMode,
) -> u16 {
    let computed = dom
        .node(id)
        .computed()
        .cloned()
        .unwrap_or_else(ComputedStyle::initial);

    // BoxSize mode: if the element has an explicit Fixed size along
    // `direction`, that wins over child measurement — matches CSS
    // min-content + explicit width.
    //
    // ContentOnly mode: skip the short-circuit. The CSS Flexbox §4.5
    // "content size suggestion" needs the size of the actual content,
    // not the declared box size, so the auto-min floor doesn't
    // mistake a `width: 100` declaration for "this box must be 100
    // cells of content" — empty boxes need to be allowed to shrink
    // toward 0 to honor a smaller `max-width`.
    if mode == IntrinsicMode::BoxSize {
        // TABLE-COLSYNC-1: a table cell's resolved column width is its used
        // main size (a width → Row axis), so a table measures to its laid-out
        // column widths (e.g. when it's a flex item being sized by a scroll
        // wrapper) — same short-circuit as an explicit `Fixed`.
        if direction == Direction::Row
            && let Some(w) = dom.node(id).ext().and_then(|e| e.table_used_width)
        {
            return w;
        }
        let declared = match direction {
            Direction::Row => &computed.width,
            Direction::Column => &computed.height,
        };
        if let Size::Fixed(n) = declared {
            return *n;
        }
    }

    // Padding + border cost on the main axis. Padding-with-percent
    // resolves against the containing-block width on BOTH axes
    // per CSS 2.1 §8.4 — `cross_budget` here is the available
    // width for the parent's content area, which is the correct
    // basis. Calc that mixes percent with cells resolves at this
    // point in the layout pass; constant calcs were resolved at
    // parse time.
    let cb_w_for_pad = cross_budget;
    let pad_main = match direction {
        Direction::Row => {
            computed.padding.left.resolve(cb_w_for_pad)
                + computed.padding.right.resolve(cb_w_for_pad)
        }
        Direction::Column => {
            computed.padding.top.resolve(cb_w_for_pad)
                + computed.padding.bottom.resolve(cb_w_for_pad)
        }
    };
    let border_main = border_main_cost(&computed, direction);

    // ::before / ::after generated content (Row only) — paints
    // inline alongside the children's first / last line. Contributes
    // to the row's intrinsic width sum but not to column height
    // (single-line pseudo content joins the existing inline row).
    let pseudo_main = match direction {
        Direction::Row => pseudo_content_width(dom, id),
        Direction::Column => 0,
    };

    // IFC block: inline content. Width = max-content (unwrapped sum
    // of text widths). Height = line count at the available content
    // width.
    if is_ifc_block(dom, id) {
        let content = match direction {
            Direction::Row => inline_content_width(dom, id),
            Direction::Column => {
                // We're asked for height given cross_budget (= width
                // the container can give us). Subtract this block's
                // own padding/border on the width axis to get the
                // content width inline layout should pack against.
                //
                // BUT: if the block has an explicit Fixed width,
                // THAT's the width we'll actually be laid out at —
                // not the container's cross_budget. Respect it, or
                // height will be computed for a wrong width and the
                // wrap-count will lie. Caught by hit-test tests
                // that set narrow Fixed widths on IFC blocks.
                let outer_width = match &computed.width {
                    Size::Fixed(n) => *n,
                    _ => cross_budget,
                };
                let row_pad = computed.padding.left.resolve(outer_width)
                    + computed.padding.right.resolve(outer_width);
                let row_border = border_main_cost(&computed, Direction::Row);
                let content_width = outer_width
                    .saturating_sub(row_pad)
                    .saturating_sub(row_border);
                let layout = compute_inline_layout(dom, id, content_width);
                layout.height().max(1)
            }
        };
        return content
            .saturating_add(pseudo_main)
            .saturating_add(pad_main)
            .saturating_add(border_main);
    }

    // Recursive fit of children. We walk **element** children only —
    // the same set `flex::layout_children` actually distributes
    // space over. Walking all child_nodes would double-count text
    // between block siblings (a single `"\n    "` between two
    // `<card>` items would summate as 2 rows on the Column axis),
    // making intrinsic measurement disagree with layout about what
    // counts as a "child."
    //
    // CSS 2.1 §9.3 + §9.5: out-of-flow children (`display: none`,
    // `position: absolute|fixed`) take no space in their parent's
    // in-flow content extent. They MUST be filtered here too —
    // otherwise the parent's intrinsic includes hidden / floated
    // content that doesn't actually occupy any cells, inflating
    // it. Specifically caught the chrome bug where a closed
    // `<details>` element's hidden `<pre>` body inflated the
    // intrinsic from ~1 row (summary) to ~15, starving the
    // sibling `flex: 1` panel of its share of the main axis.
    use crate::layout::{Display, Position};
    let children: Vec<NodeId> = super::element_children_of(dom, id)
        .into_iter()
        .filter(|&c| {
            let c_computed = dom.node(c).ext().and_then(|e| e.computed.as_ref());
            match c_computed {
                Some(s) => {
                    s.display != Display::None
                        && !matches!(s.position, Position::Absolute | Position::Fixed)
                }
                None => true,
            }
        })
        .collect();

    if children.is_empty() {
        // No element children. Two cases:
        //
        // (a) The element has non-whitespace text content (e.g.
        //     `<note>only text</note>`). It's not IFC per the
        //     predicate (see `ifc.rs` for why pure-text blocks stay
        //     non-IFC: paint routing for `::before`/`::after`), but
        //     its intrinsic main-axis size still depends on how the
        //     text wraps. Measure via `compute_inline_layout` at
        //     `cross_budget` so wrap is respected — matching what
        //     paint sees when `paint_inline_content` renders the
        //     same text.
        //
        // (b) No text (or whitespace-only text). Just `::before` /
        //     `::after` chrome on the Row axis plus padding/border.
        if has_non_whitespace_text(dom, id) {
            let content = match direction {
                Direction::Row => inline_content_width(dom, id),
                Direction::Column => {
                    let outer_width = match &computed.width {
                        Size::Fixed(n) => *n,
                        _ => cross_budget,
                    };
                    let row_pad = computed.padding.left.resolve(outer_width)
                        + computed.padding.right.resolve(outer_width);
                    let row_border = border_main_cost(&computed, Direction::Row);
                    let content_width = outer_width
                        .saturating_sub(row_pad)
                        .saturating_sub(row_border);
                    let layout = compute_inline_layout(dom, id, content_width);
                    layout.height().max(1)
                }
            };
            return content
                .saturating_add(pseudo_main)
                .saturating_add(pad_main)
                .saturating_add(border_main);
        }
        return pseudo_main
            .saturating_add(pad_main)
            .saturating_add(border_main);
    }

    // Cross budget to forward to children. They'll be laid out
    // inside our content area; for IFC measurement at the child
    // level this is what determines wrap.
    let child_cross_budget = match direction {
        Direction::Row => cross_budget.saturating_sub(
            (computed.padding.top.resolve(cross_budget)
                + computed.padding.bottom.resolve(cross_budget))
            .saturating_add(border_main_cost(&computed, Direction::Column)),
        ),
        Direction::Column => cross_budget.saturating_sub(
            (computed.padding.left.resolve(cross_budget)
                + computed.padding.right.resolve(cross_budget))
            .saturating_add(border_main_cost(&computed, Direction::Row)),
        ),
    };

    let intrinsic_children: u16 = if computed.direction == direction {
        // Children flow along the queried axis — sum their main
        // sizes plus gaps.
        let gap_total = computed
            .gap
            .saturating_mul((children.len() as u16).saturating_sub(1));
        let children_main: u16 = children
            .iter()
            .map(|&c| intrinsic_size(dom, c, direction, child_cross_budget))
            .fold(0u16, |acc, n| acc.saturating_add(n));
        children_main.saturating_add(gap_total)
    } else {
        // Children stack across the queried axis — take the max.
        children
            .iter()
            .map(|&c| intrinsic_size(dom, c, direction, child_cross_budget))
            .max()
            .unwrap_or(0)
    };

    intrinsic_children
        .saturating_add(pseudo_main)
        .saturating_add(pad_main)
        .saturating_add(border_main)
}

/// Sum of visible cell widths of an element's `::before` and `::after`
/// generated content. Mirrors what `paint_pass/inline_paint.rs` writes
/// inline alongside the element's own content; without including this
/// here, an auto-width element with pseudo chrome (e.g. `<button>` with
/// bracketed `::before` / `::after`) would size to its text content
/// only and clip the pseudos at paint time.
fn pseudo_content_width(dom: &Dom<TuiExt>, id: NodeId) -> u16 {
    let mut acc: u32 = 0;
    if let Some(before) = dom.node(id).computed_before()
        && let Some(text) = before.content.as_deref()
    {
        acc = acc.saturating_add(UnicodeWidthStr::width(text) as u32);
    }
    if let Some(after) = dom.node(id).computed_after()
        && let Some(text) = after.content.as_deref()
    {
        acc = acc.saturating_add(UnicodeWidthStr::width(text) as u32);
    }
    acc.min(u16::MAX as u32) as u16
}

/// True iff `id` has at least one direct text child whose contents
/// contain a non-whitespace character. Pure-whitespace text between
/// element siblings is treated as ignorable in intrinsic measurement
/// (matches CSS anonymous-block-around-inline collapse for empty
/// inline runs).
pub(super) fn has_non_whitespace_text(dom: &Dom<TuiExt>, id: NodeId) -> bool {
    for child in dom.node(id).child_nodes() {
        if child.node_type() == NodeType::Text
            && let Some(text) = child.node_value()
            && !text.chars().all(char::is_whitespace)
        {
            return true;
        }
    }
    false
}

pub(super) fn border_main_cost(computed: &ComputedStyle, direction: Direction) -> u16 {
    let b = computed.border;
    match direction {
        Direction::Row => b.left.cells() + b.right.cells(),
        Direction::Column => b.top.cells() + b.bottom.cells(),
    }
}

/// Sum of visible cell widths of all text in an IFC block's inline
/// subtree. Walks text nodes and descends into inline element
/// children. Used as the intrinsic max-content width for IFC blocks.
pub(super) fn inline_content_width(dom: &Dom<TuiExt>, id: NodeId) -> u16 {
    fn walk(dom: &Dom<TuiExt>, id: NodeId, acc: &mut u32) {
        use crate::layout::{Display, Position};
        for child in dom.node(id).child_nodes() {
            match child.node_type() {
                NodeType::Text => {
                    let text = child.node_value().unwrap_or("");
                    *acc = acc.saturating_add(UnicodeWidthStr::width(text) as u32);
                }
                NodeType::Element => {
                    // Out-of-flow descendants (`display: none`,
                    // `position: absolute|fixed`) generate no in-flow box
                    // and so add nothing to their ancestor's max-content
                    // inline width. Skip them — otherwise a text-leaf with
                    // an absolutely-positioned child (e.g. a chip with an
                    // absolute dropdown) inflates its intrinsic width by
                    // the hidden child's text. Mirrors the same filter in
                    // `intrinsic_element` and the IFC walk in
                    // `render::inline::walk_subtree`.
                    let (display, position) = child
                        .ext()
                        .and_then(|e| e.computed.as_ref())
                        .map(|c| (c.display, c.position))
                        .unwrap_or((Display::Block, Position::Static));
                    if display == Display::None
                        || matches!(position, Position::Absolute | Position::Fixed)
                    {
                        continue;
                    }
                    walk(dom, child.id(), acc);
                }
                _ => {}
            }
        }
    }
    let mut acc: u32 = 0;
    walk(dom, id, &mut acc);
    acc.min(u16::MAX as u32) as u16
}