Skip to main content

inkferro_core/layout/
taffy_engine.rs

1//! `TaffyEngine` — the Taffy 0.10 backend for `LayoutEngine`.
2//!
3//! See `mod.rs` for measure-seam and rounding rationale.
4//!
5//! ## Id mapping
6//! The dom arena owns u32 ids allocated by the JS reconciler.  `TaffyEngine`
7//! maintains a `HashMap<u32, taffy::NodeId>` (dom id → taffy id).  Each taffy
8//! node carries the corresponding dom id as its `NodeContext` (`TaffyTree<u32>`),
9//! so the measure closure receives it directly as a parameter — no reverse map
10//! needed, no per-calculate clone.
11
12use std::collections::HashMap;
13
14use taffy::{
15    NodeId, TaffyTree, TraversePartialTree,
16    geometry::{Point, Rect as TaffyRect, Size},
17    style::{
18        AlignContent, AlignItems, AlignSelf, AvailableSpace, Dimension, Display as TaffyDisplay,
19        FlexDirection, FlexWrap as TaffyFlexWrap, JustifyContent, LengthPercentage,
20        LengthPercentageAuto, Overflow as TaffyOverflow, Position as TaffyPosition,
21        Style as TaffyStyle,
22    },
23};
24
25use crate::dom::{
26    Align, ContentAlign, Dim, Display, FlexDir, FlexWrap, Lp, Overflow, Position, Style,
27};
28
29use super::engine::{LayoutEngine, MeasureFn, Rect};
30
31// ─── Style mapping ───────────────────────────────────────────────────────────
32
33/// Convert an inkferro `dom::Style` to a `taffy::Style`.
34///
35/// Mirrors the `apply*` functions in ink's `styles.ts`, reading every
36/// group in the same order (styles.ts:415–777).  Divergences from yoga
37/// are noted inline.
38///
39/// ### Percentage scale
40/// ink/yoga use 0–100 (e.g. `parseInt("50%", 10)` → 50).  Taffy wants
41/// 0.0–1.0.  All `Dim::Percent(p)` and `Lp::Percent(p)` values are
42/// divided by 100 exactly once in this function.
43pub fn style_to_taffy(s: &Style) -> TaffyStyle {
44    TaffyStyle {
45        // ── position (styles.ts:415–442) ──────────────────────────────────
46        // Divergence: Yoga has POSITION_TYPE_STATIC; Taffy only has
47        // Relative / Absolute.  `static` maps to Relative. The difference
48        // is visible only when inset is set on a static node: yoga ignores
49        // top/right/bottom/left for static (styles.ts:21), this mapping
50        // honors them. Accepted: ink documents static as ignoring offsets
51        // and Box never defaults to static.
52        position: map_position(s.position),
53
54        // ── inset / top/right/bottom/left (styles.ts:430–442) ─────────────
55        inset: TaffyRect {
56            top: map_dim_lpa(s.top.as_ref()),
57            right: map_dim_lpa(s.right.as_ref()),
58            bottom: map_dim_lpa(s.bottom.as_ref()),
59            left: map_dim_lpa(s.left.as_ref()),
60        },
61
62        // ── margin (styles.ts:444–472) ────────────────────────────────────
63        // Cascade: per-edge > axis shorthand > all shorthand (later yoga call
64        // overrides earlier, so per-edge wins — mirrored with Option::or).
65        margin: TaffyRect {
66            top: map_lpa(
67                s.margin_top
68                    .as_ref()
69                    .or(s.margin_y.as_ref())
70                    .or(s.margin.as_ref()),
71            ),
72            bottom: map_lpa(
73                s.margin_bottom
74                    .as_ref()
75                    .or(s.margin_y.as_ref())
76                    .or(s.margin.as_ref()),
77            ),
78            left: map_lpa(
79                s.margin_left
80                    .as_ref()
81                    .or(s.margin_x.as_ref())
82                    .or(s.margin.as_ref()),
83            ),
84            right: map_lpa(
85                s.margin_right
86                    .as_ref()
87                    .or(s.margin_x.as_ref())
88                    .or(s.margin.as_ref()),
89            ),
90        },
91
92        // ── padding (styles.ts:474–502) ───────────────────────────────────
93        padding: TaffyRect {
94            top: map_lp(
95                s.padding_top
96                    .as_ref()
97                    .or(s.padding_y.as_ref())
98                    .or(s.padding.as_ref()),
99            ),
100            bottom: map_lp(
101                s.padding_bottom
102                    .as_ref()
103                    .or(s.padding_y.as_ref())
104                    .or(s.padding.as_ref()),
105            ),
106            left: map_lp(
107                s.padding_left
108                    .as_ref()
109                    .or(s.padding_x.as_ref())
110                    .or(s.padding.as_ref()),
111            ),
112            right: map_lp(
113                s.padding_right
114                    .as_ref()
115                    .or(s.padding_x.as_ref())
116                    .or(s.padding.as_ref()),
117            ),
118        },
119
120        // ── border (styles.ts:729–763) ────────────────────────────────────
121        // borderWidth = 1 iff borderStyle is set (styles.ts:745).
122        // Each edge = 0 if that edge is explicitly `false` (styles.ts:748-762).
123        // Uses the shared Style::border_edges() helper — single source of truth
124        // shared with the render clip inset in walk.rs.
125        border: {
126            let [top, right, bottom, left] = s.border_edges();
127            TaffyRect {
128                top: LengthPercentage::length(top as f32),
129                right: LengthPercentage::length(right as f32),
130                bottom: LengthPercentage::length(bottom as f32),
131                left: LengthPercentage::length(left as f32),
132            }
133        },
134
135        // ── flex (styles.ts:504–661) ──────────────────────────────────────
136        // Box.tsx defaults (flexDirection:row, flexWrap:nowrap, flexGrow:0,
137        // flexShrink:1) equal taffy's defaults, so None → taffy default is
138        // correct with no special-casing needed.
139        flex_direction: map_flex_dir(s.flex_direction),
140        flex_wrap: map_flex_wrap(s.flex_wrap),
141        flex_grow: s.flex_grow.unwrap_or(0.0),
142        flex_shrink: s.flex_shrink.unwrap_or(1.0),
143        flex_basis: map_dim(s.flex_basis.as_ref()),
144        align_items: s.align_items.map(map_align_items),
145        // alignSelf: taffy has no Auto variant — None encodes auto.
146        align_self: s.align_self.map(map_align_self),
147        // alignContent: Yoga's node default is flex-start (verified empirically
148        // against ink's yoga-layout: getAlignContent() == ALIGN_FLEX_START),
149        // while Taffy's None resolves to stretch. ink never sets alignContent
150        // unless the prop is given (styles.ts), so None must map to FlexStart
151        // or wrapped lines stretch/spread across the container cross axis.
152        align_content: Some(
153            s.align_content
154                .map(map_content_align_content)
155                .unwrap_or(AlignContent::FlexStart),
156        ),
157        justify_content: s.justify_content.map(map_content_align_justify),
158
159        // ── dimensions (styles.ts:663–719) ────────────────────────────────
160        size: Size {
161            width: map_dim(s.width.as_ref()),
162            height: map_dim(s.height.as_ref()),
163        },
164        // Divergence (width only): yoga ignores percent minWidth/maxWidth
165        // (styles.ts:220/231, yoga#872); Taffy honors them. minHeight/
166        // maxHeight percent is honored by BOTH — no divergence there
167        // (styles.ts:225/236).  We map faithfully for all four fields.
168        min_size: Size {
169            width: map_dim(s.min_width.as_ref()),
170            height: map_dim(s.min_height.as_ref()),
171        },
172        max_size: Size {
173            width: map_dim(s.max_width.as_ref()),
174            height: map_dim(s.max_height.as_ref()),
175        },
176        // aspectRatio: faithful f32 passthrough. Yoga-vs-Taffy resolution
177        // order against size constraints is unverified; styles.ts:243
178        // advises use with ≥1 size constraint.
179        aspect_ratio: s.aspect_ratio,
180
181        // ── display (styles.ts:721–727) ───────────────────────────────────
182        display: map_display(s.display),
183
184        // ── gap (styles.ts:765–777) ───────────────────────────────────────
185        // Cascade: per-axis > all shorthand (column_gap/row_gap override gap).
186        gap: Size {
187            width: map_gap(s.column_gap.or(s.gap)),
188            height: map_gap(s.row_gap.or(s.gap)),
189        },
190
191        // ── overflow (styles.ts; Box.tsx resolves shorthand JS-side) ──────
192        overflow: Point {
193            x: map_overflow(s.overflow_x),
194            y: map_overflow(s.overflow_y),
195        },
196
197        // All remaining taffy fields (scrollbar_width, direction, etc.) not
198        // present in ink's Styles type — use taffy defaults.
199        ..Default::default()
200    }
201}
202
203// ─── Mapping helpers (flat — ≤2 indent levels inside each fn) ────────────────
204
205fn map_position(p: Option<Position>) -> TaffyPosition {
206    match p.unwrap_or(Position::Relative) {
207        Position::Absolute => TaffyPosition::Absolute,
208        Position::Relative | Position::Static => TaffyPosition::Relative,
209    }
210}
211
212fn map_dim(d: Option<&Dim>) -> Dimension {
213    match d {
214        None | Some(Dim::Auto) => Dimension::auto(),
215        Some(Dim::Points(v)) => Dimension::length(*v),
216        Some(Dim::Percent(p)) => Dimension::percent(p / 100.0),
217    }
218}
219
220fn map_dim_lpa(d: Option<&Dim>) -> LengthPercentageAuto {
221    match d {
222        None | Some(Dim::Auto) => LengthPercentageAuto::auto(),
223        Some(Dim::Points(v)) => LengthPercentageAuto::length(*v),
224        Some(Dim::Percent(p)) => LengthPercentageAuto::percent(p / 100.0),
225    }
226}
227
228fn map_lp(lp: Option<&Lp>) -> LengthPercentage {
229    match lp {
230        None => LengthPercentage::length(0.0),
231        Some(Lp::Points(v)) => LengthPercentage::length(*v),
232        Some(Lp::Percent(p)) => LengthPercentage::percent(p / 100.0),
233    }
234}
235
236/// Margin edges: `None` → `0` (yoga default for unset margins).
237/// Do NOT use `auto()` here — taffy auto-margins distribute free space,
238/// producing centering that ink never exhibits on unset margins.
239fn map_lpa(lp: Option<&Lp>) -> LengthPercentageAuto {
240    match lp {
241        // Yoga default for unset margins is 0, not auto.  Taffy auto margins
242        // distribute free space (centering effect) — must not use auto for None.
243        None => LengthPercentageAuto::length(0.0),
244        Some(Lp::Points(v)) => LengthPercentageAuto::length(*v),
245        Some(Lp::Percent(p)) => LengthPercentageAuto::percent(p / 100.0),
246    }
247}
248
249fn map_flex_dir(d: Option<FlexDir>) -> FlexDirection {
250    match d.unwrap_or(FlexDir::Row) {
251        FlexDir::Row => FlexDirection::Row,
252        FlexDir::Column => FlexDirection::Column,
253        FlexDir::RowReverse => FlexDirection::RowReverse,
254        FlexDir::ColumnReverse => FlexDirection::ColumnReverse,
255    }
256}
257
258fn map_flex_wrap(w: Option<FlexWrap>) -> TaffyFlexWrap {
259    match w.unwrap_or(FlexWrap::NoWrap) {
260        FlexWrap::NoWrap => TaffyFlexWrap::NoWrap,
261        FlexWrap::Wrap => TaffyFlexWrap::Wrap,
262        FlexWrap::WrapReverse => TaffyFlexWrap::WrapReverse,
263    }
264}
265
266fn map_align_items(a: Align) -> AlignItems {
267    match a {
268        Align::Stretch => AlignItems::Stretch,
269        Align::FlexStart => AlignItems::FlexStart,
270        Align::Center => AlignItems::Center,
271        Align::FlexEnd => AlignItems::FlexEnd,
272        Align::Baseline => AlignItems::Baseline,
273    }
274}
275
276fn map_align_self(a: Align) -> AlignSelf {
277    match a {
278        Align::Stretch => AlignSelf::Stretch,
279        Align::FlexStart => AlignSelf::FlexStart,
280        Align::Center => AlignSelf::Center,
281        Align::FlexEnd => AlignSelf::FlexEnd,
282        Align::Baseline => AlignSelf::Baseline,
283    }
284}
285
286fn map_content_align_content(c: ContentAlign) -> AlignContent {
287    match c {
288        ContentAlign::FlexStart => AlignContent::FlexStart,
289        ContentAlign::Center => AlignContent::Center,
290        ContentAlign::FlexEnd => AlignContent::FlexEnd,
291        ContentAlign::SpaceBetween => AlignContent::SpaceBetween,
292        ContentAlign::SpaceAround => AlignContent::SpaceAround,
293        ContentAlign::SpaceEvenly => AlignContent::SpaceEvenly,
294        ContentAlign::Stretch => AlignContent::Stretch,
295    }
296}
297
298fn map_content_align_justify(c: ContentAlign) -> JustifyContent {
299    match c {
300        ContentAlign::FlexStart => JustifyContent::FlexStart,
301        ContentAlign::Center => JustifyContent::Center,
302        ContentAlign::FlexEnd => JustifyContent::FlexEnd,
303        ContentAlign::SpaceBetween => JustifyContent::SpaceBetween,
304        ContentAlign::SpaceAround => JustifyContent::SpaceAround,
305        ContentAlign::SpaceEvenly => JustifyContent::SpaceEvenly,
306        ContentAlign::Stretch => JustifyContent::Stretch,
307    }
308}
309
310fn map_display(d: Option<Display>) -> TaffyDisplay {
311    match d.unwrap_or(Display::Flex) {
312        Display::Flex => TaffyDisplay::Flex,
313        Display::None => TaffyDisplay::None,
314    }
315}
316
317fn map_gap(g: Option<f32>) -> LengthPercentage {
318    match g {
319        None => LengthPercentage::length(0.0),
320        Some(v) => LengthPercentage::length(v),
321    }
322}
323
324fn map_overflow(o: Option<Overflow>) -> TaffyOverflow {
325    match o.unwrap_or(Overflow::Visible) {
326        Overflow::Visible => TaffyOverflow::Visible,
327        Overflow::Hidden => TaffyOverflow::Hidden,
328    }
329}
330
331// ─── TaffyEngine ─────────────────────────────────────────────────────────────
332
333/// Taffy 0.10 backend.
334///
335/// Invariant: every dom id in `id_map` has a valid `NodeId` in `tree`.
336pub struct TaffyEngine {
337    /// The taffy tree (flexbox layout).  Taffy's built-in rounding is DISABLED
338    /// at construction (`disable_rounding`); `calculate` runs a Yoga-compatible
339    /// pixel-grid post-pass instead (see `round_layout_yoga` / `mod.rs`).
340    /// Each node's context is the dom id (`u32`) so the measure closure can
341    /// dispatch into `measures` without a secondary reverse map.
342    tree: TaffyTree<u32>,
343
344    /// dom id → taffy NodeId.
345    id_map: HashMap<u32, NodeId>,
346
347    /// Measure callbacks keyed by dom id.  Populated by `set_measure`; called
348    /// by the closure passed to `compute_layout_with_measure`.  A registered
349    /// measure marks the node as a Yoga "Text" node for rounding purposes
350    /// (`PixelGrid.cpp` `node->getNodeType() == NodeType::Text`).
351    measures: HashMap<u32, Box<MeasureFn>>,
352
353    /// Rounded, parent-relative rects keyed by dom id, produced by the
354    /// Yoga-compatible post-pass at the end of `calculate`.  `computed` reads
355    /// from here; it falls back to the live (unrounded) taffy layout for ids
356    /// not present (e.g. a node created but never laid out).
357    rounded: HashMap<u32, Rect>,
358
359    /// Rounded, ABSOLUTE (root-relative) rects keyed by dom id — the same
360    /// post-pass also accumulates the rounded parent-relative offsets down the
361    /// recursion (the renderer paints each node at exactly this sum, and the
362    /// jacob314/ink fork's `getBoundingBox` computes the same sum by walking
363    /// `getComputedLeft/Top` up the parent chain).  `computed_absolute` reads
364    /// from here.  ADDITIVE alongside `rounded`: the parent-relative map and
365    /// `computed`'s contract are unchanged.
366    rounded_absolute: HashMap<u32, Rect>,
367}
368
369impl TaffyEngine {
370    /// Create an empty engine.
371    pub fn new() -> Self {
372        let mut tree = TaffyTree::new();
373        // Disable taffy's cumulative round-half-away rounding; we apply Yoga's
374        // pixel-grid rounding in `round_layout_yoga` after each `calculate`, so
375        // tie-breaks and text-node floor/ceil match ink's yoga 3.2.1 exactly.
376        tree.disable_rounding();
377        Self {
378            tree,
379            id_map: HashMap::new(),
380            measures: HashMap::new(),
381            rounded: HashMap::new(),
382            rounded_absolute: HashMap::new(),
383        }
384    }
385
386    /// Resolve `dom_id` to a taffy `NodeId`.
387    fn taffy_id(&self, dom_id: u32) -> Result<NodeId, String> {
388        self.id_map
389            .get(&dom_id)
390            .copied()
391            .ok_or_else(|| format!("layout: unknown dom id {dom_id}"))
392    }
393
394    /// The node's rounded ABSOLUTE (root-relative) rect — `x`/`y` are the sum
395    /// of the rounded parent-relative offsets down the ancestor chain (the
396    /// integer cell where the renderer paints the node); `width`/`height` are
397    /// identical to [`LayoutEngine::computed`]'s.
398    ///
399    /// ADDITIVE companion to `computed` (which stays parent-relative —
400    /// wire/API covenant): produced by the same `round_node` post-pass, so the
401    /// two are coherent per `calculate`.  Returns `None` for unknown, freed,
402    /// or never-calculated ids.  A node detached AFTER the last `calculate`
403    /// (`remove_child` without `destroy`) serves its last-calculated rect
404    /// until the next `calculate` evicts it — the same staleness contract as
405    /// `rounded` (see `destroy`'s eviction note).  No live-taffy fallback
406    /// like `computed`'s: an absolute position requires the ancestor chain
407    /// walked by `calculate`, which a not-yet-calculated node does not have.
408    /// The rendered path (build → `calculate` → read) never hits that case.
409    pub fn computed_absolute(&self, id: u32) -> Option<Rect> {
410        self.rounded_absolute.get(&id).copied()
411    }
412}
413
414// ─── Yoga-compatible pixel-grid rounding (yoga 3.2.1 PixelGrid.cpp) ───────────
415//
416// Ink uses yoga-layout 3.2.1 (ink/package.json: "yoga-layout": "~3.2.1").  Yoga
417// rounds layout results to the pixel grid *after* the flex solve, in
418// `roundLayoutResultsToPixelGrid` (yoga/algorithm/PixelGrid.cpp), using
419// `roundValueToPixelGrid` for the actual half-up / floor / ceil decision.  Taffy
420// 0.10 rounds differently (cumulative round-half-away in `compute::round_layout`,
421// compute/mod.rs:219), which diverges from yoga by ±1 on tie-breaks and on
422// text-node sizing.  We disable taffy rounding and port yoga's algorithm here so
423// our integer rects match ink cell-for-cell.
424//
425// `pointScaleFactor` is 1.0 for terminals (one cell == one "point"); we hardcode
426// it and drop the `* pointScaleFactor` / `/ pointScaleFactor` no-ops.  All math
427// is done in f64 (yoga uses `double` throughout PixelGrid.cpp) and only narrowed
428// to integer cells at the very end.
429
430/// Yoga's `inexactEquals(double, double)` — yoga/numeric/Comparison.h:
431/// `std::abs(a - b) < 0.0001`.  (Both args are always defined here, so the
432/// undefined-handling branch is omitted.)
433fn inexact_equals(a: f64, b: f64) -> bool {
434    (a - b).abs() < 0.0001
435}
436
437/// Port of yoga 3.2.1 `roundValueToPixelGrid` (PixelGrid.cpp), specialized to
438/// `pointScaleFactor == 1.0`.
439///
440/// ```text
441/// double scaledValue = value;            // value * 1.0
442/// double fractial = fmod(scaledValue, 1.0);
443/// if (fractial < 0) ++fractial;          // make fractial in [0,1) even for value<0
444/// if      (inexactEquals(fractial, 0.0)) scaledValue -= fractial;        // already whole
445/// else if (inexactEquals(fractial, 1.0)) scaledValue += 1.0 - fractial;  // ~whole below
446/// else if (forceCeil)  scaledValue += 1.0 - fractial;                    // ceil
447/// else if (forceFloor) scaledValue -= fractial;                          // floor
448/// else                 scaledValue += (fractial > 0.5 || inexactEquals(fractial, 0.5))
449///                                          ? (1.0 - fractial) : -fractial; // round half up
450/// return scaledValue;                    // / 1.0
451/// ```
452fn round_value_to_pixel_grid(value: f64, force_ceil: bool, force_floor: bool) -> f64 {
453    let mut scaled = value;
454    // fmod keeps the sign of the dividend (matches C++ fmod): e.g.
455    // fmod(-2.2, 1.0) == -0.2.  Add 1 so `scaled - fractial == floor(scaled)`
456    // for negatives too (PixelGrid.cpp comment).
457    let mut fractial = scaled % 1.0;
458    if fractial < 0.0 {
459        fractial += 1.0;
460    }
461    // Yoga's PixelGrid.cpp branches in this exact order (order matters — e.g.
462    // `fractial ≈ 1.0` must ceil even when `force_floor` is set):
463    //   1. fractial ≈ 0.0        → floor (already whole):       scaled -= fractial
464    //   2. fractial ≈ 1.0        → ceil  (whole, just below):   scaled += 1 - fractial
465    //   3. forceCeil             → ceil:                        scaled += 1 - fractial
466    //   4. forceFloor            → floor:                       scaled -= fractial
467    //   5. else (round half up)  → +1 iff fractial >= 0.5, else +0
468    //
469    // We preserve that order exactly.  To satisfy clippy's `if_same_then_else`
470    // without reordering, the arithmetic is expressed as a single delta computed
471    // in yoga's priority order, then applied once.
472    let delta = if inexact_equals(fractial, 0.0) {
473        // (1) already whole → floor
474        -fractial
475    } else if inexact_equals(fractial, 1.0) || force_ceil {
476        // (2)/(3) ~whole-just-below or forced ceil → round up
477        1.0 - fractial
478    } else if force_floor {
479        // (4) forced floor (text nodes never round down)
480        -fractial
481    } else if fractial > 0.5 || inexact_equals(fractial, 0.5) {
482        // (5) round half up — exactly-0.5 ties go toward +inf, matching yoga
483        1.0 - fractial
484    } else {
485        // (5) round half down
486        -fractial
487    };
488    scaled += delta;
489    scaled
490}
491
492/// Parent origins threaded through `round_node`'s recursion.
493///
494/// `absolute_left`/`absolute_top` are yoga's `absoluteLeft`/`absoluteTop` —
495/// the *unrounded* absolute offset of the parent, used only for the
496/// edge-rounding of dimensions.  `rounded_left`/`rounded_top` are the sum of
497/// the ROUNDED parent-relative offsets of all ancestors — the integer cell
498/// origin the renderer actually paints the parent at — used to accumulate the
499/// absolute rects (`TaffyEngine::rounded_absolute`).
500struct RoundOrigin {
501    absolute_left: f64,
502    absolute_top: f64,
503    rounded_left: i32,
504    rounded_top: i32,
505}
506
507/// Recursive port of yoga 3.2.1 `roundLayoutResultsToPixelGrid` (PixelGrid.cpp),
508/// specialized to `pointScaleFactor == 1.0`.
509///
510/// `absolute_left` / `absolute_top` are the *unrounded* absolute offsets of this
511/// node's parent (yoga threads these through the recursion).  We compute each
512/// node's rounded parent-relative position and rounded dimension and store the
513/// result keyed by dom id; the renderer re-accumulates absolute offsets from the
514/// parent-relative rects, so storing parent-relative here matches yoga's
515/// `setLayoutPosition` (which overwrites the *relative* position).
516///
517/// `is_text(dom_id)` is true iff the node has a measure fn registered — that is
518/// our equivalent of yoga's `node->getNodeType() == NodeType::Text`, the flag
519/// yoga uses to floor (never round down) text positions/sizes so glyphs are not
520/// truncated.
521fn round_node(
522    tree: &TaffyTree<u32>,
523    is_text: &dyn Fn(u32) -> bool,
524    nid: NodeId,
525    dom_id: u32,
526    origin: RoundOrigin,
527    out: &mut HashMap<u32, Rect>,
528    out_absolute: &mut HashMap<u32, Rect>,
529) {
530    let RoundOrigin {
531        absolute_left,
532        absolute_top,
533        rounded_left,
534        rounded_top,
535    } = origin;
536    // Read the unrounded layout taffy produced for this node.
537    let Ok(layout) = tree.layout(nid) else {
538        return;
539    };
540    let node_left = f64::from(layout.location.x);
541    let node_top = f64::from(layout.location.y);
542    let node_width = f64::from(layout.size.width);
543    let node_height = f64::from(layout.size.height);
544
545    let absolute_node_left = absolute_left + node_left;
546    let absolute_node_top = absolute_top + node_top;
547    let absolute_node_right = absolute_node_left + node_width;
548    let absolute_node_bottom = absolute_node_top + node_height;
549
550    // pointScaleFactor (== 1.0) is never 0, so we always take yoga's rounding
551    // branch.  textRounding: yoga floors text nodes so they never shrink below
552    // their measured glyph extent (PixelGrid.cpp).
553    let text_rounding = is_text(dom_id);
554
555    // Position: parent-relative, rounded from the *relative* nodeLeft/nodeTop
556    // (PixelGrid.cpp: roundValueToPixelGrid(nodeLeft, …, forceFloor=textRounding)).
557    let rx = round_value_to_pixel_grid(node_left, false, text_rounding);
558    let ry = round_value_to_pixel_grid(node_top, false, text_rounding);
559
560    // Dimensions: round(absoluteRight) - round(absoluteLeft) so cumulative edges
561    // line up with neighbors.  For text nodes, ceil when there is a fractional
562    // remainder and floor otherwise (PixelGrid.cpp hasFractionalWidth/Height).
563    let has_fractional_width =
564        !inexact_equals(node_width % 1.0, 0.0) && !inexact_equals(node_width % 1.0, 1.0);
565    let has_fractional_height =
566        !inexact_equals(node_height % 1.0, 0.0) && !inexact_equals(node_height % 1.0, 1.0);
567
568    let rw = round_value_to_pixel_grid(
569        absolute_node_right,
570        text_rounding && has_fractional_width,
571        text_rounding && !has_fractional_width,
572    ) - round_value_to_pixel_grid(absolute_node_left, false, text_rounding);
573    let rh = round_value_to_pixel_grid(
574        absolute_node_bottom,
575        text_rounding && has_fractional_height,
576        text_rounding && !has_fractional_height,
577    ) - round_value_to_pixel_grid(absolute_node_top, false, text_rounding);
578
579    let rect = Rect {
580        x: rx.round() as i32,
581        y: ry.round() as i32,
582        width: rw.max(0.0).round() as u16,
583        height: rh.max(0.0).round() as u16,
584    };
585    out.insert(dom_id, rect);
586
587    // Absolute (root-relative) position: the sum of the ROUNDED parent-relative
588    // offsets down the ancestor chain — exactly where the renderer paints this
589    // node (it re-accumulates the rounded relative rects), and exactly what the
590    // jacob314/ink fork's `getBoundingBox` computes by summing yoga's
591    // `getComputedLeft/Top` (post-pixel-grid values) up `parentNode`.  NOT
592    // `round(absolute_node_left)` — rounding the unrounded absolute can differ
593    // by ±1 from the painted position on fractional ancestors.
594    let abs_x = rounded_left + rect.x;
595    let abs_y = rounded_top + rect.y;
596    out_absolute.insert(
597        dom_id,
598        Rect {
599            x: abs_x,
600            y: abs_y,
601            ..rect
602        },
603    );
604
605    // Recurse with this node's *unrounded* absolute origin (yoga passes
606    // absoluteNodeLeft/absoluteNodeTop, not the rounded values) plus the
607    // rounded painted origin for the absolute-rect accumulation.
608    let child_count = tree.child_count(nid);
609    for index in 0..child_count {
610        let Ok(child_nid) = tree.child_at_index(nid, index) else {
611            continue;
612        };
613        // The dom id is the node's taffy context.
614        let Some(child_dom) = tree.get_node_context(child_nid).copied() else {
615            continue;
616        };
617        round_node(
618            tree,
619            is_text,
620            child_nid,
621            child_dom,
622            RoundOrigin {
623                absolute_left: absolute_node_left,
624                absolute_top: absolute_node_top,
625                rounded_left: abs_x,
626                rounded_top: abs_y,
627            },
628            out,
629            out_absolute,
630        );
631    }
632}
633
634impl Default for TaffyEngine {
635    fn default() -> Self {
636        Self::new()
637    }
638}
639
640impl LayoutEngine for TaffyEngine {
641    fn create(&mut self, id: u32) -> Result<(), String> {
642        // Idempotent: if already present, no-op (mirrors dom Create no-op on
643        // an occupied slot — `arena.rs:30-32`).
644        if self.id_map.contains_key(&id) {
645            return Ok(());
646        }
647        // Store the dom id as the node's context so the measure closure can
648        // read it directly from the `ctx` parameter — no reverse map needed.
649        let nid = self
650            .tree
651            .new_leaf_with_context(TaffyStyle::default(), id)
652            .map_err(|e| e.to_string())?;
653        self.id_map.insert(id, nid);
654        Ok(())
655    }
656
657    fn apply_style(&mut self, id: u32, style: &Style) -> Result<(), String> {
658        let nid = self.taffy_id(id)?;
659        self.tree
660            .set_style(nid, style_to_taffy(style))
661            .map_err(|e| e.to_string())
662    }
663
664    fn set_measure(&mut self, id: u32, f: Box<MeasureFn>) {
665        // Store; dispatched during `calculate`.  The dom id is available via
666        // the node's taffy context (`TaffyTree<u32>`), so no reverse map is
667        // needed here or in `calculate`.
668        self.measures.insert(id, f);
669    }
670
671    fn insert_child(&mut self, parent: u32, child: u32, index: usize) -> Result<(), String> {
672        let pnid = self.taffy_id(parent)?;
673        let cnid = self.taffy_id(child)?;
674
675        let child_count = self.tree.child_count(pnid);
676        if index >= child_count {
677            // Clamp to append — callers with a stale index must not panic.
678            self.tree.add_child(pnid, cnid).map_err(|e| e.to_string())
679        } else {
680            self.tree
681                .insert_child_at_index(pnid, index, cnid)
682                .map_err(|e| e.to_string())
683        }
684    }
685
686    fn remove_child(&mut self, parent: u32, child: u32) -> Result<(), String> {
687        let pnid = self.taffy_id(parent)?;
688        let cnid = self.taffy_id(child)?;
689        self.tree
690            .remove_child(pnid, cnid)
691            .map(|_| ())
692            .map_err(|e| e.to_string())
693    }
694
695    fn destroy(&mut self, id: u32) {
696        // Look up the taffy NodeId; silently no-op if not present.
697        let Some(nid) = self.id_map.remove(&id) else {
698            return;
699        };
700        // Drop any measure function registered for this dom id.
701        self.measures.remove(&id);
702        // Evict from the rounded map so computed() cannot serve a stale rect
703        // after the node is destroyed (violates the destroy→computed-None
704        // contract).  Note: remove_child (detach without destroy) intentionally
705        // does NOT evict — a detached-but-alive node retains its last-calculated
706        // rect until the next calculate(), matching yoga's behavior (yoga also
707        // serves the last layout for detached nodes until recompute).
708        self.rounded.remove(&id);
709        self.rounded_absolute.remove(&id);
710        // Taffy 0.10 `remove` detaches the node from its parent and children
711        // (children become orphan taffy leaves — their Free ops arrive
712        // separately; see Free-no-cascade in dom/mod.rs).  Errors are swallowed
713        // because a missing NodeId is a bug, not a recoverable condition here.
714        let _ = self.tree.remove(nid);
715    }
716
717    fn mark_dirty(&mut self, id: u32) -> Result<(), String> {
718        let nid = self.taffy_id(id)?;
719        self.tree.mark_dirty(nid).map_err(|e| e.to_string())
720    }
721
722    fn calculate(
723        &mut self,
724        root_id: u32,
725        viewport_width: f32,
726        viewport_height: Option<f32>,
727    ) -> Result<(), String> {
728        let root_nid = self.taffy_id(root_id)?;
729
730        // ink render-to-string.ts:62 — rootNode.yogaNode!.setWidth(columns).
731        // The root container is given a DEFINITE width equal to the terminal
732        // columns so no-width children stretch to fill it (instead of the root
733        // shrink-wrapping to content under an auto width).
734        // The other half of ink-root identity (column/stretch defaults) lives
735        // in render::create_layout_nodes, which sees node kinds.
736        if let Ok(mut s) = self.tree.style(root_nid).cloned() {
737            s.size.width = Dimension::length(viewport_width);
738            let _ = self.tree.set_style(root_nid, s);
739        }
740
741        // viewport_height: None → MaxContent (unconstrained, render-to-string.ts:62-68).
742        let available = Size {
743            width: AvailableSpace::Definite(viewport_width),
744            height: match viewport_height {
745                Some(h) => AvailableSpace::Definite(h),
746                None => AvailableSpace::MaxContent,
747            },
748        };
749
750        // Taffy 0.10 passes the node's context (`&mut u32` dom id) directly to
751        // the measure closure — no reverse-map snapshot needed.  `ctx` is a
752        // parameter, not a capture, so borrowing `self.measures` here does not
753        // conflict with the `&mut self.tree` borrow held by taffy.
754        self.tree
755            .compute_layout_with_measure(root_nid, available, |known, avail, _nid, ctx, _style| {
756                // `ctx` is `Option<&mut u32>` — the dom id stored on the node.
757                if let Some(&mut did) = ctx
758                    && let Some(f) = self.measures.get_mut(&did)
759                {
760                    return f(known, avail);
761                }
762                // No measure function registered → zero size (leaf with no
763                // intrinsic size, e.g. a box with only flex children).
764                Size::ZERO
765            })
766            .map_err(|e| e.to_string())?;
767
768        // Yoga-compatible pixel-grid rounding post-pass.  Taffy's own rounding
769        // is disabled (see `new`); we round the unrounded float layout exactly
770        // like yoga 3.2.1 `roundLayoutResultsToPixelGrid` so integer cell rects
771        // match ink.  Rebuild the map each calculate; the root recursion starts
772        // at absolute (0.0, 0.0) (yoga calls it with absoluteLeft/Top = 0).
773        self.rounded.clear();
774        self.rounded_absolute.clear();
775        let is_text = |dom_id: u32| self.measures.contains_key(&dom_id);
776        round_node(
777            &self.tree,
778            &is_text,
779            root_nid,
780            root_id,
781            RoundOrigin {
782                absolute_left: 0.0,
783                absolute_top: 0.0,
784                rounded_left: 0,
785                rounded_top: 0,
786            },
787            &mut self.rounded,
788            &mut self.rounded_absolute,
789        );
790        Ok(())
791    }
792
793    fn computed(&self, id: u32) -> Option<Rect> {
794        // Primary path: the Yoga-compatible rounded rect produced by the
795        // post-pass in `calculate` (see `round_node`).  Parent-relative, integer
796        // cells — same contract as before.
797        if let Some(r) = self.rounded.get(&id) {
798            return Some(*r);
799        }
800
801        // Fallback: a node that was created but never laid out (no `calculate`
802        // covering it yet).  Return the live taffy layout, truncated.  Taffy's
803        // own rounding is disabled, so these floats are unrounded; truncation
804        // here is a best-effort placeholder for the not-yet-calculated case and
805        // is never hit on the rendered path (which always runs `calculate`).
806        // Detached-but-alive nodes (remove_child without destroy) also land here
807        // after the next calculate() evicts them from `rounded` — they retain
808        // their last-calculated rect from `tree.layout()` until recompute, which
809        // matches yoga's behavior of serving the last layout for detached nodes.
810        let nid = self.id_map.get(&id).copied()?;
811        let lay = self.tree.layout(nid).ok()?;
812        Some(Rect {
813            x: lay.location.x as i32,
814            y: lay.location.y as i32,
815            width: lay.size.width as u16,
816            height: lay.size.height as u16,
817        })
818    }
819}
820
821// ─── Tests ───────────────────────────────────────────────────────────────────
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use crate::dom::BorderStyle;
827    use taffy::style::FlexDirection as TFD;
828
829    fn pt(v: f32) -> Dimension {
830        Dimension::length(v)
831    }
832
833    /// Convenience: create a TaffyEngine with `ids` pre-registered.
834    fn engine_with(ids: &[u32]) -> TaffyEngine {
835        let mut e = TaffyEngine::new();
836        for &id in ids {
837            e.create(id).unwrap();
838        }
839        e
840    }
841
842    // ═══════════════════════════════════════════════════════════════════════════
843    // Style mapping unit tests (prop-group by prop-group)
844    // ═══════════════════════════════════════════════════════════════════════════
845
846    // ── M1. position (styles.ts:415–442) ────────────────────────────────────
847    #[test]
848    fn map_position_absolute() {
849        let s = Style {
850            position: Some(Position::Absolute),
851            ..Default::default()
852        };
853        assert_eq!(style_to_taffy(&s).position, TaffyPosition::Absolute);
854    }
855
856    #[test]
857    fn map_position_relative() {
858        let s = Style {
859            position: Some(Position::Relative),
860            ..Default::default()
861        };
862        assert_eq!(style_to_taffy(&s).position, TaffyPosition::Relative);
863    }
864
865    #[test]
866    fn map_position_static_maps_to_relative() {
867        // Divergence: Taffy has no Static; map to Relative.
868        let s = Style {
869            position: Some(Position::Static),
870            ..Default::default()
871        };
872        assert_eq!(style_to_taffy(&s).position, TaffyPosition::Relative);
873    }
874
875    #[test]
876    fn map_inset_points() {
877        let s = Style {
878            top: Some(Dim::Points(2.0)),
879            left: Some(Dim::Points(5.0)),
880            ..Default::default()
881        };
882        let t = style_to_taffy(&s);
883        assert_eq!(t.inset.top, LengthPercentageAuto::length(2.0));
884        assert_eq!(t.inset.left, LengthPercentageAuto::length(5.0));
885        assert_eq!(t.inset.right, LengthPercentageAuto::auto());
886        assert_eq!(t.inset.bottom, LengthPercentageAuto::auto());
887    }
888
889    #[test]
890    fn map_inset_percent() {
891        let s = Style {
892            top: Some(Dim::Percent(50.0)),
893            ..Default::default()
894        };
895        // 50% → 0.5 in taffy
896        assert_eq!(
897            style_to_taffy(&s).inset.top,
898            LengthPercentageAuto::percent(0.5)
899        );
900    }
901
902    // ── M2. margin cascade (styles.ts:444–472) ───────────────────────────────
903    // Hand-derived: margin_all=2, margin_x=4, margin_left=7.
904    // Expected: top=2, bottom=2, right=4, left=7.
905    #[test]
906    fn map_margin_cascade() {
907        let s = Style {
908            margin: Some(Lp::Points(2.0)),
909            margin_x: Some(Lp::Points(4.0)),
910            margin_left: Some(Lp::Points(7.0)),
911            ..Default::default()
912        };
913        let t = style_to_taffy(&s);
914        assert_eq!(t.margin.top, LengthPercentageAuto::length(2.0));
915        assert_eq!(t.margin.bottom, LengthPercentageAuto::length(2.0));
916        assert_eq!(t.margin.right, LengthPercentageAuto::length(4.0));
917        assert_eq!(t.margin.left, LengthPercentageAuto::length(7.0));
918    }
919
920    #[test]
921    fn map_margin_y_shorthand() {
922        let s = Style {
923            margin: Some(Lp::Points(1.0)),
924            margin_y: Some(Lp::Points(3.0)),
925            ..Default::default()
926        };
927        let t = style_to_taffy(&s);
928        assert_eq!(t.margin.top, LengthPercentageAuto::length(3.0));
929        assert_eq!(t.margin.bottom, LengthPercentageAuto::length(3.0));
930        assert_eq!(t.margin.left, LengthPercentageAuto::length(1.0));
931        assert_eq!(t.margin.right, LengthPercentageAuto::length(1.0));
932    }
933
934    #[test]
935    fn map_margin_percent() {
936        // Yoga/ink convention: 50 means 50%; taffy needs 0.5.
937        let s = Style {
938            margin: Some(Lp::Percent(50.0)),
939            ..Default::default()
940        };
941        let t = style_to_taffy(&s);
942        assert_eq!(t.margin.top, LengthPercentageAuto::percent(0.5));
943    }
944
945    // ── M3. padding cascade (styles.ts:474–502) ──────────────────────────────
946    #[test]
947    fn map_padding_cascade() {
948        let s = Style {
949            padding: Some(Lp::Points(2.0)),
950            padding_x: Some(Lp::Points(4.0)),
951            padding_top: Some(Lp::Points(1.0)),
952            ..Default::default()
953        };
954        let t = style_to_taffy(&s);
955        assert_eq!(t.padding.top, LengthPercentage::length(1.0));
956        assert_eq!(t.padding.bottom, LengthPercentage::length(2.0));
957        assert_eq!(t.padding.left, LengthPercentage::length(4.0));
958        assert_eq!(t.padding.right, LengthPercentage::length(4.0));
959    }
960
961    // ── M4. flex (styles.ts:504–661) ─────────────────────────────────────────
962    #[test]
963    fn map_flex_direction_column() {
964        let s = Style {
965            flex_direction: Some(FlexDir::Column),
966            ..Default::default()
967        };
968        assert_eq!(style_to_taffy(&s).flex_direction, FlexDirection::Column);
969    }
970
971    #[test]
972    fn map_flex_direction_row_reverse() {
973        let s = Style {
974            flex_direction: Some(FlexDir::RowReverse),
975            ..Default::default()
976        };
977        assert_eq!(style_to_taffy(&s).flex_direction, FlexDirection::RowReverse);
978    }
979
980    #[test]
981    fn map_flex_wrap() {
982        let s = Style {
983            flex_wrap: Some(FlexWrap::Wrap),
984            ..Default::default()
985        };
986        assert_eq!(style_to_taffy(&s).flex_wrap, TaffyFlexWrap::Wrap);
987    }
988
989    #[test]
990    fn map_flex_grow_shrink() {
991        let s = Style {
992            flex_grow: Some(2.0),
993            flex_shrink: Some(0.5),
994            ..Default::default()
995        };
996        let t = style_to_taffy(&s);
997        assert_eq!(t.flex_grow, 2.0);
998        assert_eq!(t.flex_shrink, 0.5);
999    }
1000
1001    #[test]
1002    fn map_flex_basis_points() {
1003        let s = Style {
1004            flex_basis: Some(Dim::Points(40.0)),
1005            ..Default::default()
1006        };
1007        assert_eq!(style_to_taffy(&s).flex_basis, Dimension::length(40.0));
1008    }
1009
1010    #[test]
1011    fn map_flex_basis_percent() {
1012        // ink passes 50 for "50%"; taffy needs 0.5.
1013        let s = Style {
1014            flex_basis: Some(Dim::Percent(50.0)),
1015            ..Default::default()
1016        };
1017        assert_eq!(style_to_taffy(&s).flex_basis, Dimension::percent(0.5));
1018    }
1019
1020    #[test]
1021    fn map_flex_basis_auto() {
1022        let s = Style {
1023            flex_basis: Some(Dim::Auto),
1024            ..Default::default()
1025        };
1026        assert_eq!(style_to_taffy(&s).flex_basis, Dimension::auto());
1027    }
1028
1029    #[test]
1030    fn map_align_items_center() {
1031        let s = Style {
1032            align_items: Some(Align::Center),
1033            ..Default::default()
1034        };
1035        assert_eq!(style_to_taffy(&s).align_items, Some(AlignItems::Center));
1036    }
1037
1038    #[test]
1039    fn map_align_self_none_is_auto() {
1040        // align_self: None in dom::Style → None in taffy (encodes auto).
1041        let s = Style {
1042            align_self: None,
1043            ..Default::default()
1044        };
1045        assert_eq!(style_to_taffy(&s).align_self, None);
1046    }
1047
1048    #[test]
1049    fn map_align_content_space_between() {
1050        let s = Style {
1051            align_content: Some(ContentAlign::SpaceBetween),
1052            ..Default::default()
1053        };
1054        assert_eq!(
1055            style_to_taffy(&s).align_content,
1056            Some(AlignContent::SpaceBetween)
1057        );
1058    }
1059
1060    #[test]
1061    fn map_justify_content_center() {
1062        let s = Style {
1063            justify_content: Some(ContentAlign::Center),
1064            ..Default::default()
1065        };
1066        assert_eq!(
1067            style_to_taffy(&s).justify_content,
1068            Some(JustifyContent::Center)
1069        );
1070    }
1071
1072    // ── M5. dimensions (styles.ts:663–719) ───────────────────────────────────
1073    // Oracle case 1 (hand-derived): width=80, height=24 → fixed terminal size.
1074    // A flex root with explicit 80×24 should report exactly 80×24 after layout.
1075    #[test]
1076    fn map_width_height_points() {
1077        let s = Style {
1078            width: Some(Dim::Points(80.0)),
1079            height: Some(Dim::Points(24.0)),
1080            ..Default::default()
1081        };
1082        let t = style_to_taffy(&s);
1083        assert_eq!(t.size.width, Dimension::length(80.0));
1084        assert_eq!(t.size.height, Dimension::length(24.0));
1085    }
1086
1087    #[test]
1088    fn map_width_percent() {
1089        let s = Style {
1090            width: Some(Dim::Percent(50.0)),
1091            ..Default::default()
1092        };
1093        assert_eq!(style_to_taffy(&s).size.width, Dimension::percent(0.5));
1094    }
1095
1096    #[test]
1097    fn map_width_auto() {
1098        let s = Style {
1099            width: Some(Dim::Auto),
1100            ..Default::default()
1101        };
1102        assert_eq!(style_to_taffy(&s).size.width, Dimension::auto());
1103    }
1104
1105    #[test]
1106    fn map_min_max_size() {
1107        let s = Style {
1108            min_width: Some(Dim::Points(10.0)),
1109            max_width: Some(Dim::Points(100.0)),
1110            min_height: Some(Dim::Points(5.0)),
1111            max_height: Some(Dim::Points(50.0)),
1112            ..Default::default()
1113        };
1114        let t = style_to_taffy(&s);
1115        assert_eq!(t.min_size.width, Dimension::length(10.0));
1116        assert_eq!(t.max_size.width, Dimension::length(100.0));
1117        assert_eq!(t.min_size.height, Dimension::length(5.0));
1118        assert_eq!(t.max_size.height, Dimension::length(50.0));
1119    }
1120
1121    #[test]
1122    fn map_aspect_ratio() {
1123        let s = Style {
1124            aspect_ratio: Some(16.0 / 9.0),
1125            ..Default::default()
1126        };
1127        let t = style_to_taffy(&s);
1128        assert!((t.aspect_ratio.unwrap() - 16.0 / 9.0).abs() < f32::EPSILON);
1129    }
1130
1131    // ── M6. display (styles.ts:721–727) ──────────────────────────────────────
1132    #[test]
1133    fn map_display_flex() {
1134        let s = Style {
1135            display: Some(Display::Flex),
1136            ..Default::default()
1137        };
1138        assert_eq!(style_to_taffy(&s).display, TaffyDisplay::Flex);
1139    }
1140
1141    #[test]
1142    fn map_display_none() {
1143        let s = Style {
1144            display: Some(Display::None),
1145            ..Default::default()
1146        };
1147        assert_eq!(style_to_taffy(&s).display, TaffyDisplay::None);
1148    }
1149
1150    // ── M7. border (styles.ts:729–763) ───────────────────────────────────────
1151    // Oracle case 2 (hand-derived): borderStyle="single" with no per-edge
1152    // overrides → all four edges = 1 cell.  A 10×5 box with border has
1153    // content area 8×3 (1 cell consumed each side).
1154    #[test]
1155    fn map_border_all_edges_active() {
1156        let s = Style {
1157            border_style: Some(BorderStyle::Named("single".into())),
1158            ..Default::default()
1159        };
1160        let t = style_to_taffy(&s);
1161        assert_eq!(t.border.top, LengthPercentage::length(1.0));
1162        assert_eq!(t.border.right, LengthPercentage::length(1.0));
1163        assert_eq!(t.border.bottom, LengthPercentage::length(1.0));
1164        assert_eq!(t.border.left, LengthPercentage::length(1.0));
1165    }
1166
1167    #[test]
1168    fn map_border_top_disabled() {
1169        let s = Style {
1170            border_style: Some(BorderStyle::Named("single".into())),
1171            border_top: Some(false),
1172            ..Default::default()
1173        };
1174        let t = style_to_taffy(&s);
1175        assert_eq!(t.border.top, LengthPercentage::length(0.0));
1176        assert_eq!(t.border.bottom, LengthPercentage::length(1.0));
1177        assert_eq!(t.border.left, LengthPercentage::length(1.0));
1178        assert_eq!(t.border.right, LengthPercentage::length(1.0));
1179    }
1180
1181    #[test]
1182    fn map_border_none_when_no_border_style() {
1183        let s = Style {
1184            border_style: None,
1185            ..Default::default()
1186        };
1187        let t = style_to_taffy(&s);
1188        assert_eq!(t.border.top, LengthPercentage::length(0.0));
1189        assert_eq!(t.border.right, LengthPercentage::length(0.0));
1190        assert_eq!(t.border.bottom, LengthPercentage::length(0.0));
1191        assert_eq!(t.border.left, LengthPercentage::length(0.0));
1192    }
1193
1194    // ── M8. gap (styles.ts:765–777) ──────────────────────────────────────────
1195    // Oracle case 3 (hand-derived): gap=2 with two 10×5 children in a row
1196    // inside an 80×5 container.  Expected: c1.x=0, c2.x=12 (10+2 gap).
1197    #[test]
1198    fn map_gap_all() {
1199        let s = Style {
1200            gap: Some(2.0),
1201            ..Default::default()
1202        };
1203        let t = style_to_taffy(&s);
1204        assert_eq!(t.gap.width, LengthPercentage::length(2.0));
1205        assert_eq!(t.gap.height, LengthPercentage::length(2.0));
1206    }
1207
1208    #[test]
1209    fn map_gap_per_axis() {
1210        let s = Style {
1211            column_gap: Some(4.0),
1212            row_gap: Some(1.0),
1213            ..Default::default()
1214        };
1215        let t = style_to_taffy(&s);
1216        assert_eq!(t.gap.width, LengthPercentage::length(4.0));
1217        assert_eq!(t.gap.height, LengthPercentage::length(1.0));
1218    }
1219
1220    #[test]
1221    fn map_gap_per_axis_overrides_all() {
1222        // column_gap=4 wins over gap=2 for the horizontal axis.
1223        let s = Style {
1224            gap: Some(2.0),
1225            column_gap: Some(4.0),
1226            ..Default::default()
1227        };
1228        let t = style_to_taffy(&s);
1229        assert_eq!(t.gap.width, LengthPercentage::length(4.0));
1230        assert_eq!(t.gap.height, LengthPercentage::length(2.0));
1231    }
1232
1233    // ── M9. overflow (styles.ts; Box.tsx resolves shorthand JS-side) ──────────
1234    #[test]
1235    fn map_overflow_hidden() {
1236        let s = Style {
1237            overflow_x: Some(Overflow::Hidden),
1238            ..Default::default()
1239        };
1240        assert_eq!(style_to_taffy(&s).overflow.x, TaffyOverflow::Hidden);
1241        assert_eq!(style_to_taffy(&s).overflow.y, TaffyOverflow::Visible);
1242    }
1243
1244    // ═══════════════════════════════════════════════════════════════════════════
1245    // End-to-end layout tests through apply_style (engine trait path)
1246    // ═══════════════════════════════════════════════════════════════════════════
1247
1248    // ── E1. apply_style width/height end-to-end ───────────────────────────────
1249    // Oracle case 1 (hand-derived): root at 80×24 via apply_style.
1250    // Flexbox: a node with explicit size fills exactly that size.
1251    #[test]
1252    fn apply_style_width_height() {
1253        let mut e = TaffyEngine::new();
1254        e.create(0).unwrap();
1255        let s = Style {
1256            width: Some(Dim::Points(80.0)),
1257            height: Some(Dim::Points(24.0)),
1258            ..Default::default()
1259        };
1260        e.apply_style(0, &s).unwrap();
1261        e.calculate(0, 80.0, Some(24.0)).unwrap();
1262        assert_eq!(
1263            e.computed(0).unwrap(),
1264            Rect {
1265                x: 0,
1266                y: 0,
1267                width: 80,
1268                height: 24
1269            }
1270        );
1271    }
1272
1273    // ── E2. apply_style flexDirection end-to-end ──────────────────────────────
1274    // Oracle case 3 (hand-derived): column layout; two 80×12 children at y=0 and y=12.
1275    #[test]
1276    fn apply_style_flex_direction_column() {
1277        let mut e = engine_with(&[0, 1, 2]);
1278        e.insert_child(0, 1, 0).unwrap();
1279        e.insert_child(0, 2, 1).unwrap();
1280
1281        e.apply_style(
1282            0,
1283            &Style {
1284                width: Some(Dim::Points(80.0)),
1285                height: Some(Dim::Points(24.0)),
1286                flex_direction: Some(FlexDir::Column),
1287                ..Default::default()
1288            },
1289        )
1290        .unwrap();
1291        e.apply_style(
1292            1,
1293            &Style {
1294                width: Some(Dim::Points(80.0)),
1295                height: Some(Dim::Points(12.0)),
1296                ..Default::default()
1297            },
1298        )
1299        .unwrap();
1300        e.apply_style(
1301            2,
1302            &Style {
1303                width: Some(Dim::Points(80.0)),
1304                height: Some(Dim::Points(12.0)),
1305                ..Default::default()
1306            },
1307        )
1308        .unwrap();
1309
1310        e.calculate(0, 80.0, Some(24.0)).unwrap();
1311        let r1 = e.computed(1).unwrap();
1312        let r2 = e.computed(2).unwrap();
1313        assert_eq!(
1314            r1,
1315            Rect {
1316                x: 0,
1317                y: 0,
1318                width: 80,
1319                height: 12
1320            }
1321        );
1322        assert_eq!(
1323            r2,
1324            Rect {
1325                x: 0,
1326                y: 12,
1327                width: 80,
1328                height: 12
1329            }
1330        );
1331    }
1332
1333    // ── E3. apply_style gap end-to-end ────────────────────────────────────────
1334    // Oracle case 3 (hand-derived): gap=2, two 10×5 children in a flex row.
1335    // Expected: c1.x=0, c2.x=12 (c1.width + gap = 10 + 2).
1336    #[test]
1337    fn apply_style_gap() {
1338        let mut e = engine_with(&[0, 1, 2]);
1339        e.insert_child(0, 1, 0).unwrap();
1340        e.insert_child(0, 2, 1).unwrap();
1341
1342        e.apply_style(
1343            0,
1344            &Style {
1345                width: Some(Dim::Points(80.0)),
1346                height: Some(Dim::Points(24.0)),
1347                gap: Some(2.0),
1348                align_items: Some(Align::FlexStart),
1349                ..Default::default()
1350            },
1351        )
1352        .unwrap();
1353        e.apply_style(
1354            1,
1355            &Style {
1356                width: Some(Dim::Points(10.0)),
1357                height: Some(Dim::Points(5.0)),
1358                ..Default::default()
1359            },
1360        )
1361        .unwrap();
1362        e.apply_style(
1363            2,
1364            &Style {
1365                width: Some(Dim::Points(10.0)),
1366                height: Some(Dim::Points(5.0)),
1367                ..Default::default()
1368            },
1369        )
1370        .unwrap();
1371
1372        e.calculate(0, 80.0, Some(24.0)).unwrap();
1373        let c1 = e.computed(1).unwrap();
1374        let c2 = e.computed(2).unwrap();
1375        assert_eq!(c1.x, 0);
1376        assert_eq!(c2.x, 12); // 10 (c1 width) + 2 (gap)
1377    }
1378
1379    // ── E4. apply_style padding end-to-end ───────────────────────────────────
1380    // A root with padding=2 and two children: the first child starts at x=2, y=2.
1381    #[test]
1382    fn apply_style_padding() {
1383        let mut e = engine_with(&[0, 1]);
1384        e.insert_child(0, 1, 0).unwrap();
1385
1386        e.apply_style(
1387            0,
1388            &Style {
1389                width: Some(Dim::Points(80.0)),
1390                height: Some(Dim::Points(24.0)),
1391                padding: Some(Lp::Points(2.0)),
1392                align_items: Some(Align::FlexStart),
1393                ..Default::default()
1394            },
1395        )
1396        .unwrap();
1397        e.apply_style(
1398            1,
1399            &Style {
1400                width: Some(Dim::Points(10.0)),
1401                height: Some(Dim::Points(5.0)),
1402                ..Default::default()
1403            },
1404        )
1405        .unwrap();
1406
1407        e.calculate(0, 80.0, Some(24.0)).unwrap();
1408        let child = e.computed(1).unwrap();
1409        assert_eq!(child.x, 2);
1410        assert_eq!(child.y, 2);
1411    }
1412
1413    // ── E5. apply_style border end-to-end ────────────────────────────────────
1414    // Oracle case 2 (hand-derived): a 10×5 box with a full border (all 4 edges
1415    // = 1 cell).  The single child (6×3 explicit size) should start at x=1, y=1
1416    // (pushed inward by the 1-cell border on left and top).
1417    #[test]
1418    fn apply_style_border() {
1419        let mut e = engine_with(&[0, 1]);
1420        e.insert_child(0, 1, 0).unwrap();
1421
1422        e.apply_style(
1423            0,
1424            &Style {
1425                width: Some(Dim::Points(10.0)),
1426                height: Some(Dim::Points(5.0)),
1427                border_style: Some(BorderStyle::Named("single".into())),
1428                align_items: Some(Align::FlexStart),
1429                ..Default::default()
1430            },
1431        )
1432        .unwrap();
1433        e.apply_style(
1434            1,
1435            &Style {
1436                width: Some(Dim::Points(6.0)),
1437                height: Some(Dim::Points(3.0)),
1438                ..Default::default()
1439            },
1440        )
1441        .unwrap();
1442
1443        e.calculate(0, 10.0, Some(5.0)).unwrap();
1444        let child = e.computed(1).unwrap();
1445        // Border = 1 each side → content area origin at (1, 1).
1446        assert_eq!(child.x, 1);
1447        assert_eq!(child.y, 1);
1448    }
1449
1450    // ── E6. computed_absolute on a nested-offset tree (#124, pin A1) ────────
1451    // Three levels with offsets at EVERY level (root padding, middle margin,
1452    // inner margin), hand-computed:
1453    //   root (id 0): padding 2                  → abs (0, 0)
1454    //   middle (id 1): margin-left 3, -top 2    → rel (2+3, 2+2) = (5, 4); abs (5, 4)
1455    //   inner (id 2): margin-left 4, -top 1     → rel (4, 1);              abs (9, 5)
1456    // Mutation guard: the inner node's absolute (9, 5) differs from its
1457    // parent-relative (4, 1) — an implementation returning relative coords
1458    // (the pre-#124 limitation) fails the abs assertions.
1459    #[test]
1460    fn computed_absolute_nested_offsets() {
1461        let mut e = engine_with(&[0, 1, 2]);
1462        e.insert_child(0, 1, 0).unwrap();
1463        e.insert_child(1, 2, 0).unwrap();
1464
1465        e.apply_style(
1466            0,
1467            &Style {
1468                width: Some(Dim::Points(40.0)),
1469                height: Some(Dim::Points(10.0)),
1470                padding: Some(Lp::Points(2.0)),
1471                align_items: Some(Align::FlexStart),
1472                ..Default::default()
1473            },
1474        )
1475        .unwrap();
1476        e.apply_style(
1477            1,
1478            &Style {
1479                width: Some(Dim::Points(20.0)),
1480                height: Some(Dim::Points(6.0)),
1481                margin_left: Some(Lp::Points(3.0)),
1482                margin_top: Some(Lp::Points(2.0)),
1483                align_items: Some(Align::FlexStart),
1484                ..Default::default()
1485            },
1486        )
1487        .unwrap();
1488        e.apply_style(
1489            2,
1490            &Style {
1491                width: Some(Dim::Points(6.0)),
1492                height: Some(Dim::Points(2.0)),
1493                margin_left: Some(Lp::Points(4.0)),
1494                margin_top: Some(Lp::Points(1.0)),
1495                ..Default::default()
1496            },
1497        )
1498        .unwrap();
1499
1500        e.calculate(0, 40.0, Some(10.0)).unwrap();
1501
1502        // Parent-relative truths (unchanged contract — `computed` stays relative).
1503        assert_eq!(
1504            e.computed(1).unwrap(),
1505            Rect {
1506                x: 5,
1507                y: 4,
1508                width: 20,
1509                height: 6
1510            }
1511        );
1512        assert_eq!(
1513            e.computed(2).unwrap(),
1514            Rect {
1515                x: 4,
1516                y: 1,
1517                width: 6,
1518                height: 2
1519            }
1520        );
1521
1522        // Absolute rects: root at origin; nested offsets ACCUMULATE.
1523        assert_eq!(
1524            e.computed_absolute(0).unwrap(),
1525            Rect {
1526                x: 0,
1527                y: 0,
1528                width: 40,
1529                height: 10
1530            }
1531        );
1532        assert_eq!(
1533            e.computed_absolute(1).unwrap(),
1534            Rect {
1535                x: 5,
1536                y: 4,
1537                width: 20,
1538                height: 6
1539            }
1540        );
1541        assert_eq!(
1542            e.computed_absolute(2).unwrap(),
1543            Rect {
1544                x: 9,
1545                y: 5,
1546                width: 6,
1547                height: 2
1548            }
1549        );
1550
1551        // Discrimination: absolute ≠ relative for the doubly-nested node.
1552        assert_ne!(e.computed_absolute(2), e.computed(2));
1553
1554        // Unknown id → None (no fallback; see `computed_absolute` docs).
1555        assert_eq!(e.computed_absolute(99), None);
1556    }
1557
1558    // ═══════════════════════════════════════════════════════════════════════════
1559    // Pre-existing layout tests (kept; bypass apply_style for direct coverage)
1560    // ═══════════════════════════════════════════════════════════════════════════
1561
1562    // ── 1. Single root sized to the viewport ────────────────────────────────
1563    // The root node has an explicit 80×24 size; after calculate its rect must
1564    // exactly match the viewport.
1565    #[test]
1566    fn single_root_fills_viewport() {
1567        let mut e = TaffyEngine::new();
1568        e.create(0).unwrap();
1569
1570        let nid = e.id_map[&0];
1571        e.tree
1572            .set_style(
1573                nid,
1574                TaffyStyle {
1575                    size: taffy::geometry::Size {
1576                        width: pt(80.0),
1577                        height: pt(24.0),
1578                    },
1579                    ..Default::default()
1580                },
1581            )
1582            .unwrap();
1583
1584        e.calculate(0, 80.0, Some(24.0)).unwrap();
1585
1586        let r = e.computed(0).unwrap();
1587        assert_eq!(
1588            r,
1589            Rect {
1590                x: 0,
1591                y: 0,
1592                width: 80,
1593                height: 24
1594            }
1595        );
1596    }
1597
1598    // ── 2. Root + two default-style children ────────────────────────────────
1599    // Default style: display:flex, flex-direction:row (taffy default).
1600    // Give the children explicit widths (10 and 20) that sum well under the
1601    // 80-column root so flex-shrink does not compress them.  The key assertion
1602    // is `c2.x == c1.width`, which pins the row-advance that proves
1603    // flex-direction:row is the default.
1604    #[test]
1605    fn root_two_default_children() {
1606        let mut e = engine_with(&[0, 1, 2]);
1607        e.insert_child(0, 1, 0).unwrap();
1608        e.insert_child(0, 2, 1).unwrap();
1609
1610        let root_nid = e.id_map[&0];
1611        let c1_nid = e.id_map[&1];
1612        let c2_nid = e.id_map[&2];
1613        e.tree
1614            .set_style(
1615                root_nid,
1616                TaffyStyle {
1617                    size: taffy::geometry::Size {
1618                        width: pt(80.0),
1619                        height: pt(24.0),
1620                    },
1621                    ..Default::default()
1622                },
1623            )
1624            .unwrap();
1625        e.tree
1626            .set_style(
1627                c1_nid,
1628                TaffyStyle {
1629                    size: taffy::geometry::Size {
1630                        width: pt(10.0),
1631                        height: pt(24.0),
1632                    },
1633                    ..Default::default()
1634                },
1635            )
1636            .unwrap();
1637        e.tree
1638            .set_style(
1639                c2_nid,
1640                TaffyStyle {
1641                    size: taffy::geometry::Size {
1642                        width: pt(20.0),
1643                        height: pt(24.0),
1644                    },
1645                    ..Default::default()
1646                },
1647            )
1648            .unwrap();
1649
1650        e.calculate(0, 80.0, Some(24.0)).unwrap();
1651
1652        assert_eq!(
1653            e.computed(0).unwrap(),
1654            Rect {
1655                x: 0,
1656                y: 0,
1657                width: 80,
1658                height: 24
1659            }
1660        );
1661
1662        let c1 = e.computed(1).unwrap();
1663        let c2 = e.computed(2).unwrap();
1664        assert_eq!(c1.x, 0);
1665        assert_eq!(c1.y, 0);
1666        assert_eq!(c2.y, 0); // flex-direction:row → both on same row
1667        assert_eq!(c1.width, 10);
1668        assert_eq!(c2.width, 20);
1669        assert_eq!(c2.x, c1.x + i32::from(c1.width));
1670    }
1671
1672    // ── 3. Nested box ────────────────────────────────────────────────────────
1673    // root(80×24) → outer(40×24) → inner(20×24)
1674    #[test]
1675    fn nested_box() {
1676        let mut e = engine_with(&[0, 1, 2]);
1677        e.insert_child(0, 1, 0).unwrap();
1678        e.insert_child(1, 2, 0).unwrap();
1679
1680        let root_nid = e.id_map[&0];
1681        let outer_nid = e.id_map[&1];
1682        let inner_nid = e.id_map[&2];
1683
1684        e.tree
1685            .set_style(
1686                root_nid,
1687                TaffyStyle {
1688                    size: taffy::geometry::Size {
1689                        width: pt(80.0),
1690                        height: pt(24.0),
1691                    },
1692                    ..Default::default()
1693                },
1694            )
1695            .unwrap();
1696        e.tree
1697            .set_style(
1698                outer_nid,
1699                TaffyStyle {
1700                    size: taffy::geometry::Size {
1701                        width: pt(40.0),
1702                        height: pt(24.0),
1703                    },
1704                    ..Default::default()
1705                },
1706            )
1707            .unwrap();
1708        e.tree
1709            .set_style(
1710                inner_nid,
1711                TaffyStyle {
1712                    size: taffy::geometry::Size {
1713                        width: pt(20.0),
1714                        height: pt(24.0),
1715                    },
1716                    ..Default::default()
1717                },
1718            )
1719            .unwrap();
1720
1721        e.calculate(0, 80.0, Some(24.0)).unwrap();
1722
1723        assert_eq!(
1724            e.computed(0).unwrap(),
1725            Rect {
1726                x: 0,
1727                y: 0,
1728                width: 80,
1729                height: 24
1730            }
1731        );
1732        assert_eq!(
1733            e.computed(1).unwrap(),
1734            Rect {
1735                x: 0,
1736                y: 0,
1737                width: 40,
1738                height: 24
1739            }
1740        );
1741        assert_eq!(
1742            e.computed(2).unwrap(),
1743            Rect {
1744                x: 0,
1745                y: 0,
1746                width: 20,
1747                height: 24
1748            }
1749        );
1750    }
1751
1752    // ── 4. Recalculate after apply_style ─────────────────────────────────────
1753    // Resize root from 80×24 to 40×12 via apply_style, then recalculate.
1754    #[test]
1755    fn recalculate_after_style_change() {
1756        let mut e = TaffyEngine::new();
1757        e.create(0).unwrap();
1758
1759        let initial = Style {
1760            width: Some(Dim::Points(80.0)),
1761            height: Some(Dim::Points(24.0)),
1762            ..Default::default()
1763        };
1764        e.apply_style(0, &initial).unwrap();
1765        e.calculate(0, 80.0, Some(24.0)).unwrap();
1766        assert_eq!(
1767            e.computed(0).unwrap(),
1768            Rect {
1769                x: 0,
1770                y: 0,
1771                width: 80,
1772                height: 24
1773            }
1774        );
1775
1776        // Resize via apply_style (mark_dirty is implicit in set_style).
1777        let resized = Style {
1778            width: Some(Dim::Points(40.0)),
1779            height: Some(Dim::Points(12.0)),
1780            ..Default::default()
1781        };
1782        e.apply_style(0, &resized).unwrap();
1783        e.mark_dirty(0).unwrap();
1784        e.calculate(0, 40.0, Some(12.0)).unwrap();
1785        assert_eq!(
1786            e.computed(0).unwrap(),
1787            Rect {
1788                x: 0,
1789                y: 0,
1790                width: 40,
1791                height: 12
1792            }
1793        );
1794    }
1795
1796    // ── 5. computed() on unknown id returns None, not a panic ─────────────────
1797    #[test]
1798    fn computed_unknown_id_returns_none() {
1799        let e = TaffyEngine::new();
1800        assert_eq!(e.computed(999), None);
1801    }
1802
1803    // ── 6. create duplicate id is a no-op ────────────────────────────────────
1804    #[test]
1805    fn create_duplicate_is_noop() {
1806        let mut e = TaffyEngine::new();
1807        e.create(0).unwrap();
1808        let nid_first = e.id_map[&0];
1809        e.create(0).unwrap();
1810        assert_eq!(e.id_map[&0], nid_first);
1811    }
1812
1813    // ── 7. measure function is called for leaf nodes ──────────────────────────
1814    #[test]
1815    fn measure_fn_is_invoked() {
1816        let mut e = TaffyEngine::new();
1817        e.create(0).unwrap();
1818        e.create(1).unwrap();
1819
1820        let root_nid = e.id_map[&0];
1821        e.tree
1822            .set_style(
1823                root_nid,
1824                TaffyStyle {
1825                    size: taffy::geometry::Size {
1826                        width: pt(80.0),
1827                        height: pt(24.0),
1828                    },
1829                    align_items: Some(AlignItems::FlexStart),
1830                    ..Default::default()
1831                },
1832            )
1833            .unwrap();
1834
1835        e.insert_child(0, 1, 0).unwrap();
1836
1837        e.set_measure(
1838            1,
1839            Box::new(|_known, _avail| Size {
1840                width: 10.0,
1841                height: 3.0,
1842            }),
1843        );
1844
1845        e.calculate(0, 80.0, Some(24.0)).unwrap();
1846
1847        let leaf = e.computed(1).unwrap();
1848        assert_eq!(leaf.width, 10);
1849        assert_eq!(leaf.height, 3);
1850    }
1851
1852    // ── 8. insert_child index clamping ───────────────────────────────────────
1853    #[test]
1854    fn insert_child_index_clamp_appends() {
1855        let mut e = engine_with(&[0, 1, 2]);
1856        e.insert_child(0, 1, 0).unwrap();
1857        e.insert_child(0, 2, 9999).unwrap();
1858
1859        let root_nid = e.id_map[&0];
1860        let root_nid_children = e.tree.children(root_nid).unwrap();
1861        assert_eq!(root_nid_children, vec![e.id_map[&1], e.id_map[&2]]);
1862    }
1863
1864    // ── destroy: node lifecycle ───────────────────────────────────────────────
1865
1866    // destroy removes the node from the engine; computed() returns None after.
1867    // Also verifies that a registered measure function is dropped on destroy.
1868    #[test]
1869    fn destroy_removes_node() {
1870        let mut e = TaffyEngine::new();
1871        e.create(0).unwrap();
1872        e.create(1).unwrap();
1873        e.set_measure(
1874            1,
1875            Box::new(|_, _| Size {
1876                width: 5.0,
1877                height: 1.0,
1878            }),
1879        );
1880        // Pre-calculate: computed() falls back to the live taffy layout.
1881        assert!(e.computed(1).is_some());
1882
1883        // Post-calculate: node 1 is inserted under root 0, calculate() fills
1884        // self.rounded.  After destroy(1), computed() must return None — not the
1885        // stale rounded rect (the bug self.rounded.remove(&id) fixes).
1886        e.insert_child(0, 1, 0).unwrap();
1887        let root_s = crate::dom::Style {
1888            width: Some(crate::dom::Dim::Points(80.0)),
1889            height: Some(crate::dom::Dim::Points(24.0)),
1890            ..Default::default()
1891        };
1892        e.apply_style(0, &root_s).unwrap();
1893        e.calculate(0, 80.0, Some(24.0)).unwrap();
1894        // Now served from self.rounded (not the fallback path).
1895        assert!(e.computed(1).is_some());
1896
1897        e.destroy(1);
1898        assert!(e.computed(1).is_none());
1899        // id_map and measures entries must also be gone (no dangling state).
1900        assert!(!e.id_map.contains_key(&1));
1901        assert!(!e.measures.contains_key(&1));
1902    }
1903
1904    // destroy on unknown id is a silent no-op — matches ink's guard-style
1905    // error philosophy (no panic, no change to other nodes).
1906    #[test]
1907    fn destroy_unknown_id_is_noop() {
1908        let mut e = TaffyEngine::new();
1909        e.create(0).unwrap();
1910        e.destroy(999); // must not panic
1911        assert!(e.computed(0).is_some());
1912    }
1913
1914    // destroy cleans up state so re-create + new measure works cleanly.
1915    // Validates the invalidation contract: after destroy, a fresh node at the
1916    // same id has no stale measure function from the previous occupant.
1917    #[test]
1918    fn destroy_then_recreate_clean_state() {
1919        let mut e = TaffyEngine::new();
1920        e.create(0).unwrap();
1921        e.create(1).unwrap();
1922        e.insert_child(0, 1, 0).unwrap();
1923
1924        let root_s = crate::dom::Style {
1925            width: Some(crate::dom::Dim::Points(80.0)),
1926            height: Some(crate::dom::Dim::Points(24.0)),
1927            align_items: Some(crate::dom::Align::FlexStart),
1928            ..Default::default()
1929        };
1930        e.apply_style(0, &root_s).unwrap();
1931        e.set_measure(
1932            1,
1933            Box::new(|_, _| Size {
1934                width: 5.0,
1935                height: 1.0,
1936            }),
1937        );
1938        e.calculate(0, 80.0, Some(24.0)).unwrap();
1939        assert_eq!(e.computed(1).unwrap().width, 5);
1940
1941        e.destroy(1);
1942        e.destroy(0);
1943
1944        // Re-create with different measure — must not inherit old closure.
1945        e.create(0).unwrap();
1946        e.create(1).unwrap();
1947        e.insert_child(0, 1, 0).unwrap();
1948        e.apply_style(0, &root_s).unwrap();
1949        e.set_measure(
1950            1,
1951            Box::new(|_, _| Size {
1952                width: 7.0,
1953                height: 1.0,
1954            }),
1955        );
1956        e.calculate(0, 80.0, Some(24.0)).unwrap();
1957        assert_eq!(e.computed(1).unwrap().width, 7);
1958    }
1959
1960    // ── 9. remove_child detaches without destroying node ─────────────────────
1961    #[test]
1962    fn remove_child_detaches() {
1963        let mut e = engine_with(&[0, 1]);
1964        e.insert_child(0, 1, 0).unwrap();
1965        e.remove_child(0, 1).unwrap();
1966
1967        assert!(e.id_map.contains_key(&1));
1968        let root_nid = e.id_map[&0];
1969        assert_eq!(e.tree.child_count(root_nid), 0);
1970    }
1971
1972    // ── 10. flex column layout ────────────────────────────────────────────────
1973    #[test]
1974    fn flex_column_two_children() {
1975        let mut e = engine_with(&[0, 1, 2]);
1976        e.insert_child(0, 1, 0).unwrap();
1977        e.insert_child(0, 2, 1).unwrap();
1978
1979        let root_nid = e.id_map[&0];
1980        let c1_nid = e.id_map[&1];
1981        let c2_nid = e.id_map[&2];
1982
1983        e.tree
1984            .set_style(
1985                root_nid,
1986                TaffyStyle {
1987                    size: taffy::geometry::Size {
1988                        width: pt(80.0),
1989                        height: pt(24.0),
1990                    },
1991                    flex_direction: TFD::Column,
1992                    ..Default::default()
1993                },
1994            )
1995            .unwrap();
1996        e.tree
1997            .set_style(
1998                c1_nid,
1999                TaffyStyle {
2000                    size: taffy::geometry::Size {
2001                        width: pt(80.0),
2002                        height: pt(12.0),
2003                    },
2004                    ..Default::default()
2005                },
2006            )
2007            .unwrap();
2008        e.tree
2009            .set_style(
2010                c2_nid,
2011                TaffyStyle {
2012                    size: taffy::geometry::Size {
2013                        width: pt(80.0),
2014                        height: pt(12.0),
2015                    },
2016                    ..Default::default()
2017                },
2018            )
2019            .unwrap();
2020
2021        e.calculate(0, 80.0, Some(24.0)).unwrap();
2022
2023        let r1 = e.computed(1).unwrap();
2024        let r2 = e.computed(2).unwrap();
2025
2026        assert_eq!(
2027            r1,
2028            Rect {
2029                x: 0,
2030                y: 0,
2031                width: 80,
2032                height: 12
2033            }
2034        );
2035        assert_eq!(
2036            r2,
2037            Rect {
2038                x: 0,
2039                y: 12,
2040                width: 80,
2041                height: 12
2042            }
2043        );
2044    }
2045
2046    // ═══════════════════════════════════════════════════════════════════════════
2047    // round_value_to_pixel_grid kernel — full branch-matrix unit tests
2048    //
2049    // Each case is hand-derived from the branch order in the source (lines
2050    // 443-458 at the time of writing).  Branch order matters; the most critical
2051    // invariant is that the fractial≈1 ceil fires BEFORE the force_floor guard.
2052    // ═══════════════════════════════════════════════════════════════════════════
2053
2054    // (1) fractial ≈ 0 within epsilon (e.g. 5.00005) → floor to whole number
2055    // regardless of force flags (branch 1 fires first).
2056    #[test]
2057    fn kernel_fractional_near_zero_floors_to_whole() {
2058        // 5.00005 % 1.0 = 0.00005; inexact_equals(0.00005, 0.0) = true (< 0.0001)
2059        assert_eq!(round_value_to_pixel_grid(5.00005, false, false), 5.0);
2060        // force_ceil=true must not override branch 1
2061        assert_eq!(round_value_to_pixel_grid(5.00005, true, false), 5.0);
2062        // force_floor=true must not override branch 1
2063        assert_eq!(round_value_to_pixel_grid(5.00005, false, true), 5.0);
2064    }
2065
2066    // (2) fractial ≈ 1 within epsilon (e.g. 4.99995) → ceil to whole number.
2067    // CRITICAL: this branch fires BEFORE force_floor, so even force_floor=true
2068    // must produce 5.0 (not 4.0).  This is the ordering the review most wants
2069    // guarded.
2070    #[test]
2071    fn kernel_fractional_near_one_ceils_before_force_floor() {
2072        // 4.99995 % 1.0 = 0.99995; inexact_equals(0.99995, 1.0) = true
2073        assert_eq!(round_value_to_pixel_grid(4.99995, false, false), 5.0);
2074        // force_floor=true must NOT override: branch 2 wins over branch 3
2075        assert_eq!(round_value_to_pixel_grid(4.99995, false, true), 5.0);
2076    }
2077
2078    // (3) force_ceil with mid-fractional value → ceil.
2079    #[test]
2080    fn kernel_force_ceil_rounds_up() {
2081        // 4.3 % 1.0 = 0.3; not near 0/1; force_ceil=true → branch 2/3 → 5.0
2082        assert_eq!(round_value_to_pixel_grid(4.3, true, false), 5.0);
2083    }
2084
2085    // (4) force_floor with mid-fractional value → floor.
2086    #[test]
2087    fn kernel_force_floor_rounds_down() {
2088        // 4.7 % 1.0 = 0.7; not near 0/1; force_floor=true → branch 3 → 4.0
2089        assert_eq!(round_value_to_pixel_grid(4.7, false, true), 4.0);
2090    }
2091
2092    // (5) round-half-up: ties go toward +inf.
2093    #[test]
2094    fn kernel_round_half_up_tie() {
2095        // 4.5: fractial=0.5; inexact_equals(0.5, 0.5)=true → branch 4 → 5.0
2096        assert_eq!(round_value_to_pixel_grid(4.5, false, false), 5.0);
2097        // 4.49: fractial=0.49; not near 0/1; 0.49<0.5; inexact(0.49,0.5)=false → branch 5 → 4.0
2098        assert_eq!(round_value_to_pixel_grid(4.49, false, false), 4.0);
2099        // 4.51: fractial=0.51; 0.51>0.5 → branch 4 → 5.0
2100        assert_eq!(round_value_to_pixel_grid(4.51, false, false), 5.0);
2101    }
2102
2103    // (5b) exact 0.5 tie via inexact_equals: 4.49995 → 5.0.
2104    // fractial=0.49995; inexact_equals(0.49995, 0.5) = |−0.00005| < 0.0001 = true → branch 4
2105    #[test]
2106    fn kernel_half_epsilon_tie_ceils() {
2107        assert_eq!(round_value_to_pixel_grid(4.49995, false, false), 5.0);
2108    }
2109
2110    // (6) Negative values: the fractial<0 correction (+=1.0) normalises fmod
2111    // output into [0,1) before the branch tree.
2112    #[test]
2113    fn kernel_negative_values() {
2114        // -4.5 % 1.0 = -0.5 → +1.0 → 0.5; inexact(0.5,0.5)=true → branch 4 → -4.5+0.5 = -4.0
2115        assert_eq!(round_value_to_pixel_grid(-4.5, false, false), -4.0);
2116        // -4.3 % 1.0 = -0.3 → +1.0 → 0.7; 0.7>0.5 → branch 4 → -4.3+0.3 = -4.0
2117        assert_eq!(round_value_to_pixel_grid(-4.3, false, false), -4.0);
2118        // -4.7 % 1.0 = -0.7 → +1.0 → 0.3; 0.3<0.5; inexact(0.3,0.5)=false → branch 5 → -4.7-0.3 = -5.0
2119        assert_eq!(round_value_to_pixel_grid(-4.7, false, false), -5.0);
2120    }
2121
2122    // ── inexact_equals boundary ───────────────────────────────────────────────
2123    // The operator is strict `<` (yoga/Comparison.h, mirrored at line 405).
2124    // |a−b| == 0.0001 exactly is NOT equal; just under is equal.
2125
2126    #[test]
2127    fn inexact_equals_at_threshold_is_not_equal() {
2128        // |0.0001 − 0.0| = 0.0001; 0.0001 < 0.0001 is false
2129        assert!(!inexact_equals(0.0001, 0.0));
2130    }
2131
2132    #[test]
2133    fn inexact_equals_just_under_threshold_is_equal() {
2134        // |0.00009 − 0.0| = 0.00009 < 0.0001 → true
2135        assert!(inexact_equals(0.00009, 0.0));
2136    }
2137}