Skip to main content

rustmotion_core/css/
taffy_bridge.rs

1//! `CssStyle` → `taffy::Style` converter.
2//!
3//! Inspired by `stylo_taffy` (Servo) but kept minimal: we only translate the
4//! layout-affecting properties. Paint properties (color, background, transform,
5//! filter, etc.) are read separately by the paint pass.
6//!
7//! Currently a stub — fleshed out in step 3 of the migration plan.
8
9use taffy::prelude as tf;
10
11use super::style::{
12    AlignContent, AlignItems, AlignSelf, BoxSizing, CssStyle, Display, Edges, FlexDirection,
13    FlexWrap, Gap, GridAutoFlow, GridLine, GridLineEnd, GridTrack, GridTrackKeyword,
14    JustifyContent, JustifyItems, JustifySelf, Overflow, Position, Size,
15};
16use super::units::{LengthContext, LengthPercentage, ParsedLength};
17
18#[derive(Debug, Clone, Copy, Default)]
19pub struct ConversionContext {
20    pub length: LengthContext,
21}
22
23/// Convert a [`CssStyle`] into a [`taffy::Style`]. Properties not relevant to
24/// layout are ignored. Unsupported / unset properties fall back to taffy
25/// defaults (which match CSS initial values).
26pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style {
27    let mut style = tf::Style::DEFAULT;
28
29    // Display
30    style.display = match css.display {
31        Some(Display::None) => tf::Display::None,
32        Some(Display::Block) => tf::Display::Block,
33        Some(Display::Flex) => tf::Display::Flex,
34        Some(Display::Grid) => tf::Display::Grid,
35        // inline-block / contents → fall back to block in our scope
36        _ => tf::Display::Block,
37    };
38
39    // Position
40    style.position = match css.position {
41        Some(Position::Absolute) => tf::Position::Absolute,
42        _ => tf::Position::Relative,
43    };
44
45    // Inset
46    style.inset = tf::Rect {
47        top: lp_to_lp_auto(css.top.as_ref(), ctx),
48        right: lp_to_lp_auto(css.right.as_ref(), ctx),
49        bottom: lp_to_lp_auto(css.bottom.as_ref(), ctx),
50        left: lp_to_lp_auto(css.left.as_ref(), ctx),
51    };
52
53    // Sizing
54    style.size = tf::Size {
55        width: size_to_dim(css.width.as_ref(), ctx),
56        height: size_to_dim(css.height.as_ref(), ctx),
57    };
58    style.min_size = tf::Size {
59        width: size_to_dim(css.min_width.as_ref(), ctx),
60        height: size_to_dim(css.min_height.as_ref(), ctx),
61    };
62    style.max_size = tf::Size {
63        width: size_to_dim(css.max_width.as_ref(), ctx),
64        height: size_to_dim(css.max_height.as_ref(), ctx),
65    };
66    style.aspect_ratio = css.aspect_ratio;
67
68    // `box-sizing` (round 4 audit, lot LAYOUT, constat 3): taffy supports it
69    // natively (`Style::box_sizing`, default `BorderBox`) — schema-valid but
70    // untranslated before this fix, so `content-box` was silently ignored
71    // and every sized box behaved as `border-box` regardless of what the
72    // author declared.
73    if let Some(bs) = css.box_sizing {
74        style.box_sizing = match bs {
75            BoxSizing::ContentBox => tf::BoxSizing::ContentBox,
76            BoxSizing::BorderBox => tf::BoxSizing::BorderBox,
77        };
78    }
79
80    // Margin / padding / border (border WIDTH only — border style/color are paint props)
81    style.margin = edges_to_rect_lpa(css.margin.as_ref(), ctx);
82    style.padding = edges_to_rect_lp(css.padding.as_ref(), ctx);
83    style.border = border_widths(css.border.as_ref(), ctx);
84
85    // Flex
86    if let Some(d) = css.flex_direction {
87        style.flex_direction = match d {
88            FlexDirection::Row => tf::FlexDirection::Row,
89            FlexDirection::RowReverse => tf::FlexDirection::RowReverse,
90            FlexDirection::Column => tf::FlexDirection::Column,
91            FlexDirection::ColumnReverse => tf::FlexDirection::ColumnReverse,
92        };
93    }
94    if let Some(w) = css.flex_wrap {
95        style.flex_wrap = match w {
96            FlexWrap::Nowrap => tf::FlexWrap::NoWrap,
97            FlexWrap::Wrap => tf::FlexWrap::Wrap,
98            FlexWrap::WrapReverse => tf::FlexWrap::WrapReverse,
99        };
100    }
101    if let Some(j) = css.justify_content {
102        style.justify_content = Some(match j {
103            JustifyContent::FlexStart | JustifyContent::Start => tf::JustifyContent::Start,
104            JustifyContent::FlexEnd | JustifyContent::End => tf::JustifyContent::End,
105            JustifyContent::Center => tf::JustifyContent::Center,
106            JustifyContent::SpaceBetween => tf::JustifyContent::SpaceBetween,
107            JustifyContent::SpaceAround => tf::JustifyContent::SpaceAround,
108            JustifyContent::SpaceEvenly => tf::JustifyContent::SpaceEvenly,
109        });
110    }
111    if let Some(a) = css.align_items {
112        style.align_items = Some(align_items_to_taffy(a));
113    }
114    if let Some(a) = css.align_self {
115        style.align_self = align_self_to_taffy(a);
116    }
117    if let Some(a) = css.align_content {
118        style.align_content = Some(align_content_to_taffy(a));
119    }
120    if let Some(grow) = css.flex_grow {
121        style.flex_grow = grow;
122    }
123    if let Some(shrink) = css.flex_shrink {
124        style.flex_shrink = shrink;
125    }
126    if let Some(basis) = css.flex_basis.as_ref() {
127        style.flex_basis = size_to_dim(Some(basis), ctx);
128    }
129    // `order` (round 4 audit, lot LAYOUT, constat 3): schema-valid but has no
130    // taffy equivalent — taffy has no flex/grid item-reordering primitive
131    // (its internal `order` on `Layout` is source order, assigned during
132    // layout, not settable via `Style`). Translating is not possible, so —
133    // per the same "fail loud instead of a silent no-op" contract this
134    // module's `Length`/`LengthPercentage` parsing already uses (see
135    // `units.rs`'s `px_or_warn` / `parse_length_or_warn`) — warn instead of
136    // dropping it without a trace. Reorder the JSON `children` array itself
137    // to get the equivalent effect.
138    // Emitted at most once per process: `to_taffy_style` runs per node per
139    // layout pass, and layout runs per frame — an unguarded `eprintln!` here
140    // would print the same line a thousand times over a single render and
141    // slow it down while doing so.
142    if css.order.is_some() {
143        static WARNED_ORDER: std::sync::Once = std::sync::Once::new();
144        WARNED_ORDER.call_once(|| {
145            eprintln!(
146                "Warning: `order` is not supported by the layout engine (no flex/grid item \
147                 reordering primitive) — it is ignored. Reorder the component's JSON `children` \
148                 array instead to change paint/layout order."
149            );
150        });
151    }
152
153    // Gap
154    if let Some(gap) = css.gap.as_ref() {
155        let (row, col) = match gap {
156            Gap::Uniform(v) => (lp_to_lp(v, ctx), lp_to_lp(v, ctx)),
157            Gap::RowColumn { row, column } => (lp_to_lp(row, ctx), lp_to_lp(column, ctx)),
158        };
159        style.gap = tf::Size {
160            width: col,
161            height: row,
162        };
163    }
164
165    // Grid
166    if let Some(tracks) = css.grid_template_columns.as_ref() {
167        style.grid_template_columns = tracks
168            .iter()
169            .map(|t| tf::GridTemplateComponent::Single(grid_track_sizing(t, ctx)))
170            .collect();
171    }
172    if let Some(tracks) = css.grid_template_rows.as_ref() {
173        style.grid_template_rows = tracks
174            .iter()
175            .map(|t| tf::GridTemplateComponent::Single(grid_track_sizing(t, ctx)))
176            .collect();
177    }
178    if let Some(flow) = css.grid_auto_flow {
179        style.grid_auto_flow = match flow {
180            GridAutoFlow::Row => tf::GridAutoFlow::Row,
181            GridAutoFlow::Column => tf::GridAutoFlow::Column,
182            GridAutoFlow::RowDense => tf::GridAutoFlow::RowDense,
183            GridAutoFlow::ColumnDense => tf::GridAutoFlow::ColumnDense,
184        };
185    }
186    if let Some(gc) = css.grid_column.as_ref() {
187        style.grid_column = grid_placement_line(gc);
188    }
189    if let Some(gr) = css.grid_row.as_ref() {
190        style.grid_row = grid_placement_line(gr);
191    }
192    // `justify-items` / `justify-self` (round 4 audit, lot LAYOUT, constat 3):
193    // taffy supports both natively for grid children, reusing the same
194    // `AlignItems`/`AlignSelf` types as `align-items`/`align-self` (the
195    // block-axis equivalents) — same untranslated-but-schema-valid gap as
196    // `box-sizing` above.
197    if let Some(ji) = css.justify_items {
198        style.justify_items = Some(justify_items_to_taffy(ji));
199    }
200    if let Some(js) = css.justify_self {
201        style.justify_self = justify_self_to_taffy(js);
202    }
203
204    // Overflow
205    if let Some(o) = css.overflow {
206        let v = overflow_to_taffy(o);
207        style.overflow = taffy::Point { x: v, y: v };
208    }
209    if let Some(o) = css.overflow_x {
210        style.overflow.x = overflow_to_taffy(o);
211    }
212    if let Some(o) = css.overflow_y {
213        style.overflow.y = overflow_to_taffy(o);
214    }
215
216    style
217}
218
219fn align_items_to_taffy(a: AlignItems) -> tf::AlignItems {
220    match a {
221        AlignItems::Stretch => tf::AlignItems::Stretch,
222        AlignItems::FlexStart | AlignItems::Start => tf::AlignItems::Start,
223        AlignItems::FlexEnd | AlignItems::End => tf::AlignItems::End,
224        AlignItems::Center => tf::AlignItems::Center,
225        AlignItems::Baseline => tf::AlignItems::Baseline,
226    }
227}
228
229fn align_self_to_taffy(a: AlignSelf) -> Option<tf::AlignSelf> {
230    Some(match a {
231        AlignSelf::Auto => return None,
232        AlignSelf::Stretch => tf::AlignSelf::Stretch,
233        AlignSelf::FlexStart | AlignSelf::Start => tf::AlignSelf::Start,
234        AlignSelf::FlexEnd | AlignSelf::End => tf::AlignSelf::End,
235        AlignSelf::Center => tf::AlignSelf::Center,
236        AlignSelf::Baseline => tf::AlignSelf::Baseline,
237    })
238}
239
240fn justify_items_to_taffy(j: JustifyItems) -> tf::AlignItems {
241    match j {
242        JustifyItems::Stretch => tf::AlignItems::Stretch,
243        JustifyItems::Start => tf::AlignItems::Start,
244        JustifyItems::End => tf::AlignItems::End,
245        JustifyItems::Center => tf::AlignItems::Center,
246        // `legacy` (old CSS2-era grid keyword, only meaningful combined with
247        // `left`/`right`/`center` which this schema doesn't expose) has no
248        // taffy analog; `Start` is the closest normal-flow behaviour and
249        // matches this bridge's own `Auto`-ish fallbacks elsewhere.
250        JustifyItems::Legacy => tf::AlignItems::Start,
251    }
252}
253
254fn justify_self_to_taffy(j: JustifySelf) -> Option<tf::AlignSelf> {
255    Some(match j {
256        // `auto` computes to the parent's `justify-items` — `None` is
257        // exactly how this bridge already models `align-self: auto`
258        // inheriting `align-items` above.
259        JustifySelf::Auto => return None,
260        JustifySelf::Stretch => tf::AlignSelf::Stretch,
261        JustifySelf::Start => tf::AlignSelf::Start,
262        JustifySelf::End => tf::AlignSelf::End,
263        JustifySelf::Center => tf::AlignSelf::Center,
264    })
265}
266
267fn align_content_to_taffy(a: AlignContent) -> tf::AlignContent {
268    match a {
269        AlignContent::Stretch => tf::AlignContent::Stretch,
270        AlignContent::FlexStart | AlignContent::Start => tf::AlignContent::Start,
271        AlignContent::FlexEnd | AlignContent::End => tf::AlignContent::End,
272        AlignContent::Center => tf::AlignContent::Center,
273        AlignContent::SpaceBetween => tf::AlignContent::SpaceBetween,
274        AlignContent::SpaceAround => tf::AlignContent::SpaceAround,
275        AlignContent::SpaceEvenly => tf::AlignContent::SpaceEvenly,
276    }
277}
278
279fn overflow_to_taffy(o: Overflow) -> taffy::Overflow {
280    match o {
281        Overflow::Visible => taffy::Overflow::Visible,
282        Overflow::Hidden | Overflow::Clip => taffy::Overflow::Hidden,
283        Overflow::Auto | Overflow::Scroll => taffy::Overflow::Scroll,
284    }
285}
286
287/// Convert `LengthPercentage` → taffy `LengthPercentage`.
288fn lp_to_lp(v: &LengthPercentage, ctx: &ConversionContext) -> tf::LengthPercentage {
289    match v.parse() {
290        ParsedLength::Px(p) => tf::LengthPercentage::length(p),
291        ParsedLength::Percent(p) => tf::LengthPercentage::percent(p / 100.0),
292        ParsedLength::Em(em) => tf::LengthPercentage::length(em * ctx.length.font_size),
293        ParsedLength::Rem(r) => tf::LengthPercentage::length(r * ctx.length.root_font_size),
294        ParsedLength::Vw(p) => tf::LengthPercentage::length(p / 100.0 * ctx.length.viewport_width),
295        ParsedLength::Vh(p) => tf::LengthPercentage::length(p / 100.0 * ctx.length.viewport_height),
296        ParsedLength::Fr(_) | ParsedLength::Auto => tf::LengthPercentage::length(0.0),
297    }
298}
299
300/// Convert `LengthPercentage` → taffy `LengthPercentageAuto`. None → auto.
301fn lp_to_lp_auto(
302    v: Option<&LengthPercentage>,
303    ctx: &ConversionContext,
304) -> tf::LengthPercentageAuto {
305    let Some(v) = v else {
306        return tf::LengthPercentageAuto::auto();
307    };
308    match v.parse() {
309        ParsedLength::Auto => tf::LengthPercentageAuto::auto(),
310        ParsedLength::Px(p) => tf::LengthPercentageAuto::length(p),
311        ParsedLength::Percent(p) => tf::LengthPercentageAuto::percent(p / 100.0),
312        ParsedLength::Em(em) => tf::LengthPercentageAuto::length(em * ctx.length.font_size),
313        ParsedLength::Rem(r) => tf::LengthPercentageAuto::length(r * ctx.length.root_font_size),
314        ParsedLength::Vw(p) => {
315            tf::LengthPercentageAuto::length(p / 100.0 * ctx.length.viewport_width)
316        }
317        ParsedLength::Vh(p) => {
318            tf::LengthPercentageAuto::length(p / 100.0 * ctx.length.viewport_height)
319        }
320        ParsedLength::Fr(_) => tf::LengthPercentageAuto::auto(),
321    }
322}
323
324/// Convert `Size` → taffy `Dimension`.
325fn size_to_dim(s: Option<&Size>, ctx: &ConversionContext) -> tf::Dimension {
326    let Some(s) = s else {
327        return tf::Dimension::auto();
328    };
329    match s {
330        Size::Auto(_) => tf::Dimension::auto(),
331        Size::Length(lp) => match lp.parse() {
332            ParsedLength::Auto => tf::Dimension::auto(),
333            ParsedLength::Px(p) => tf::Dimension::length(p),
334            ParsedLength::Percent(p) => tf::Dimension::percent(p / 100.0),
335            ParsedLength::Em(em) => tf::Dimension::length(em * ctx.length.font_size),
336            ParsedLength::Rem(r) => tf::Dimension::length(r * ctx.length.root_font_size),
337            ParsedLength::Vw(p) => tf::Dimension::length(p / 100.0 * ctx.length.viewport_width),
338            ParsedLength::Vh(p) => tf::Dimension::length(p / 100.0 * ctx.length.viewport_height),
339            ParsedLength::Fr(_) => tf::Dimension::auto(),
340        },
341        Size::Keyword(_) => {
342            // taffy 0.10 supports max-content / min-content / fit-content via Dimension.
343            // We map them to `auto` for now; refine later if needed.
344            tf::Dimension::auto()
345        }
346    }
347}
348
349fn edges_to_rect_lp(e: Option<&Edges>, ctx: &ConversionContext) -> tf::Rect<tf::LengthPercentage> {
350    let Some(e) = e else {
351        return tf::Rect {
352            top: tf::LengthPercentage::length(0.0),
353            right: tf::LengthPercentage::length(0.0),
354            bottom: tf::LengthPercentage::length(0.0),
355            left: tf::LengthPercentage::length(0.0),
356        };
357    };
358    let (top, right, bottom, left) = e.resolve();
359    tf::Rect {
360        top: lp_to_lp(&top, ctx),
361        right: lp_to_lp(&right, ctx),
362        bottom: lp_to_lp(&bottom, ctx),
363        left: lp_to_lp(&left, ctx),
364    }
365}
366
367fn edges_to_rect_lpa(
368    e: Option<&Edges>,
369    ctx: &ConversionContext,
370) -> tf::Rect<tf::LengthPercentageAuto> {
371    let Some(e) = e else {
372        return tf::Rect {
373            top: tf::LengthPercentageAuto::length(0.0),
374            right: tf::LengthPercentageAuto::length(0.0),
375            bottom: tf::LengthPercentageAuto::length(0.0),
376            left: tf::LengthPercentageAuto::length(0.0),
377        };
378    };
379    let (top, right, bottom, left) = e.resolve();
380    tf::Rect {
381        top: lp_to_lp_auto(Some(&top), ctx),
382        right: lp_to_lp_auto(Some(&right), ctx),
383        bottom: lp_to_lp_auto(Some(&bottom), ctx),
384        left: lp_to_lp_auto(Some(&left), ctx),
385    }
386}
387
388fn border_widths(
389    b: Option<&super::style::BorderEdges>,
390    ctx: &ConversionContext,
391) -> tf::Rect<tf::LengthPercentage> {
392    let Some(b) = b else {
393        return tf::Rect {
394            top: tf::LengthPercentage::length(0.0),
395            right: tf::LengthPercentage::length(0.0),
396            bottom: tf::LengthPercentage::length(0.0),
397            left: tf::LengthPercentage::length(0.0),
398        };
399    };
400    // Per-side overrides take precedence over the uniform `width`.
401    let uniform = b.width.as_ref().map(|e| e.resolve());
402    let pick_side = |side: Option<&super::style::BorderSide>, idx: usize| -> tf::LengthPercentage {
403        if let Some(side) = side {
404            if let Some(w) = side.width.as_ref() {
405                let lp = LengthPercentage::Px(w.resolve(&ctx.length));
406                return lp_to_lp(&lp, ctx);
407            }
408        }
409        if let Some((t, r, btm, l)) = uniform.as_ref() {
410            let pick = match idx {
411                0 => t,
412                1 => r,
413                2 => btm,
414                3 => l,
415                _ => t,
416            };
417            return lp_to_lp(pick, ctx);
418        }
419        tf::LengthPercentage::length(0.0)
420    };
421    tf::Rect {
422        top: pick_side(b.top.as_ref(), 0),
423        right: pick_side(b.right.as_ref(), 1),
424        bottom: pick_side(b.bottom.as_ref(), 2),
425        left: pick_side(b.left.as_ref(), 3),
426    }
427}
428
429/// Convert a [`GridTrack`] into a taffy `TrackSizingFunction` (used for both
430/// the min and max sizing function, except `fr` which uses taffy's `flex()`
431/// helper — `minmax(0, Nfr)`. This gives *exactly* evenly-sized tracks
432/// regardless of child content, matching the `flex-direction: row` control
433/// (`flex-grow: 1` siblings) rather than CSS's stricter `minmax(auto, Nfr)`
434/// default, which lets content push a track wider than its fair share.
435fn grid_track_sizing(t: &GridTrack, ctx: &ConversionContext) -> tf::TrackSizingFunction {
436    match t {
437        GridTrack::Fr(n) => tf::flex(*n),
438        GridTrack::Keyword(k) => grid_keyword_sizing(*k),
439        GridTrack::Length(lp) => grid_length_sizing(lp, ctx),
440        GridTrack::Minmax { min, max } => {
441            tf::minmax(grid_track_min(min, ctx), grid_track_max(max, ctx))
442        }
443    }
444}
445
446fn grid_keyword_sizing(k: GridTrackKeyword) -> tf::TrackSizingFunction {
447    match k {
448        GridTrackKeyword::Auto => tf::auto(),
449        GridTrackKeyword::MinContent => tf::min_content(),
450        GridTrackKeyword::MaxContent => tf::max_content(),
451    }
452}
453
454fn grid_length_sizing(lp: &LengthPercentage, ctx: &ConversionContext) -> tf::TrackSizingFunction {
455    match lp.parse() {
456        ParsedLength::Fr(n) => tf::flex(n),
457        ParsedLength::Auto => tf::auto(),
458        ParsedLength::Px(p) => tf::length(p),
459        ParsedLength::Percent(p) => tf::percent(p / 100.0),
460        ParsedLength::Em(em) => tf::length(em * ctx.length.font_size),
461        ParsedLength::Rem(r) => tf::length(r * ctx.length.root_font_size),
462        ParsedLength::Vw(p) => tf::length(p / 100.0 * ctx.length.viewport_width),
463        ParsedLength::Vh(p) => tf::length(p / 100.0 * ctx.length.viewport_height),
464    }
465}
466
467/// `min` side of an explicit `minmax(min, max)`. `fr` is not a valid CSS
468/// minimum sizing function, so it falls back to `auto` (matches taffy's own
469/// `MaxTrackSizingFunction -> MinTrackSizingFunction` conversion for `fr`).
470fn grid_track_min(t: &GridTrack, ctx: &ConversionContext) -> tf::MinTrackSizingFunction {
471    match t {
472        GridTrack::Fr(_) => tf::auto(),
473        GridTrack::Keyword(GridTrackKeyword::Auto) => tf::auto(),
474        GridTrack::Keyword(GridTrackKeyword::MinContent) => tf::min_content(),
475        GridTrack::Keyword(GridTrackKeyword::MaxContent) => tf::max_content(),
476        GridTrack::Length(lp) => grid_length_min(lp, ctx),
477        // A `minmax` nested inside a `minmax` isn't valid CSS; degrade
478        // gracefully by taking the inner track's own min side.
479        GridTrack::Minmax { min, .. } => grid_track_min(min, ctx),
480    }
481}
482
483fn grid_length_min(lp: &LengthPercentage, ctx: &ConversionContext) -> tf::MinTrackSizingFunction {
484    match lp.parse() {
485        ParsedLength::Fr(_) | ParsedLength::Auto => tf::auto(),
486        ParsedLength::Px(p) => tf::length(p),
487        ParsedLength::Percent(p) => tf::percent(p / 100.0),
488        ParsedLength::Em(em) => tf::length(em * ctx.length.font_size),
489        ParsedLength::Rem(r) => tf::length(r * ctx.length.root_font_size),
490        ParsedLength::Vw(p) => tf::length(p / 100.0 * ctx.length.viewport_width),
491        ParsedLength::Vh(p) => tf::length(p / 100.0 * ctx.length.viewport_height),
492    }
493}
494
495/// `max` side of an explicit `minmax(min, max)`.
496fn grid_track_max(t: &GridTrack, ctx: &ConversionContext) -> tf::MaxTrackSizingFunction {
497    match t {
498        GridTrack::Fr(n) => tf::fr(*n),
499        GridTrack::Keyword(GridTrackKeyword::Auto) => tf::auto(),
500        GridTrack::Keyword(GridTrackKeyword::MinContent) => tf::min_content(),
501        GridTrack::Keyword(GridTrackKeyword::MaxContent) => tf::max_content(),
502        GridTrack::Length(lp) => grid_length_max(lp, ctx),
503        GridTrack::Minmax { max, .. } => grid_track_max(max, ctx),
504    }
505}
506
507fn grid_length_max(lp: &LengthPercentage, ctx: &ConversionContext) -> tf::MaxTrackSizingFunction {
508    match lp.parse() {
509        ParsedLength::Fr(n) => tf::fr(n),
510        ParsedLength::Auto => tf::auto(),
511        ParsedLength::Px(p) => tf::length(p),
512        ParsedLength::Percent(p) => tf::percent(p / 100.0),
513        ParsedLength::Em(em) => tf::length(em * ctx.length.font_size),
514        ParsedLength::Rem(r) => tf::length(r * ctx.length.root_font_size),
515        ParsedLength::Vw(p) => tf::length(p / 100.0 * ctx.length.viewport_width),
516        ParsedLength::Vh(p) => tf::length(p / 100.0 * ctx.length.viewport_height),
517    }
518}
519
520/// Convert a `grid-column` / `grid-row` placement (`{ start, end, span }`)
521/// into a taffy `Line<GridPlacement>`. Named lines / grid areas are out of
522/// scope (issue #105) — only numeric line indices and spans are supported.
523fn grid_placement_line(g: &GridLine) -> tf::Line<tf::GridPlacement> {
524    let start = g.start.map(|GridLineEnd::Index(i)| i as i16);
525    let end = g.end.map(|GridLineEnd::Index(i)| i as i16);
526    match (start, end, g.span) {
527        (Some(s), Some(e), _) => tf::Line {
528            start: tf::line(s),
529            end: tf::line(e),
530        },
531        (Some(s), None, Some(n)) => tf::Line {
532            start: tf::line(s),
533            end: tf::span(n),
534        },
535        (Some(s), None, None) => tf::Line {
536            start: tf::line(s),
537            end: tf::auto(),
538        },
539        (None, Some(e), Some(n)) => tf::Line {
540            start: tf::span(n),
541            end: tf::line(e),
542        },
543        (None, Some(e), None) => tf::Line {
544            start: tf::auto(),
545            end: tf::line(e),
546        },
547        (None, None, Some(n)) => tf::Line {
548            start: tf::span(n),
549            end: tf::auto(),
550        },
551        (None, None, None) => tf::Line::auto(),
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::css::style::*;
559
560    fn ctx() -> ConversionContext {
561        ConversionContext::default()
562    }
563
564    #[test]
565    fn empty_style_yields_taffy_default() {
566        let css = CssStyle::default();
567        let s = to_taffy_style(&css, &ctx());
568        assert_eq!(s.display, tf::Display::Block);
569        assert_eq!(s.flex_grow, 0.0);
570    }
571
572    #[test]
573    fn flex_column_with_gap() {
574        let css = CssStyle {
575            display: Some(Display::Flex),
576            flex_direction: Some(FlexDirection::Column),
577            gap: Some(Gap::Uniform(LengthPercentage::Px(16.0))),
578            align_items: Some(AlignItems::Center),
579            ..Default::default()
580        };
581        let s = to_taffy_style(&css, &ctx());
582        assert_eq!(s.display, tf::Display::Flex);
583        assert_eq!(s.flex_direction, tf::FlexDirection::Column);
584        assert_eq!(s.align_items, Some(tf::AlignItems::Center));
585        assert_eq!(s.gap.height, tf::LengthPercentage::length(16.0));
586    }
587
588    #[test]
589    fn padding_uniform_resolved() {
590        let css = CssStyle {
591            padding: Some(Edges::Uniform(LengthPercentage::Px(24.0))),
592            ..Default::default()
593        };
594        let s = to_taffy_style(&css, &ctx());
595        assert_eq!(s.padding.top, tf::LengthPercentage::length(24.0));
596        assert_eq!(s.padding.left, tf::LengthPercentage::length(24.0));
597    }
598
599    #[test]
600    fn width_percent() {
601        let css = CssStyle {
602            width: Some(Size::Length(LengthPercentage::String("50%".into()))),
603            ..Default::default()
604        };
605        let s = to_taffy_style(&css, &ctx());
606        assert_eq!(s.size.width, tf::Dimension::percent(0.5));
607    }
608
609    #[test]
610    fn position_absolute_inset() {
611        let css = CssStyle {
612            position: Some(Position::Absolute),
613            top: Some(LengthPercentage::Px(10.0)),
614            left: Some(LengthPercentage::Px(20.0)),
615            ..Default::default()
616        };
617        let s = to_taffy_style(&css, &ctx());
618        assert_eq!(s.position, tf::Position::Absolute);
619        assert_eq!(s.inset.top, tf::LengthPercentageAuto::length(10.0));
620        assert_eq!(s.inset.left, tf::LengthPercentageAuto::length(20.0));
621    }
622
623    #[test]
624    fn flex_grow_shrink_basis() {
625        let css = CssStyle {
626            flex_grow: Some(2.0),
627            flex_shrink: Some(0.5),
628            flex_basis: Some(Size::Length(LengthPercentage::Px(100.0))),
629            ..Default::default()
630        };
631        let s = to_taffy_style(&css, &ctx());
632        assert_eq!(s.flex_grow, 2.0);
633        assert_eq!(s.flex_shrink, 0.5);
634        assert_eq!(s.flex_basis, tf::Dimension::length(100.0));
635    }
636
637    #[test]
638    fn overflow_hidden() {
639        let css = CssStyle {
640            overflow: Some(Overflow::Hidden),
641            ..Default::default()
642        };
643        let s = to_taffy_style(&css, &ctx());
644        assert_eq!(s.overflow.x, taffy::Overflow::Hidden);
645        assert_eq!(s.overflow.y, taffy::Overflow::Hidden);
646    }
647
648    // ---- Grid (issue #105) ----
649
650    #[test]
651    fn grid_display_translated() {
652        let css = CssStyle {
653            display: Some(Display::Grid),
654            ..Default::default()
655        };
656        let s = to_taffy_style(&css, &ctx());
657        assert_eq!(s.display, tf::Display::Grid);
658    }
659
660    #[test]
661    fn grid_template_columns_fr_tracks_translated() {
662        let css = CssStyle {
663            grid_template_columns: Some(vec![GridTrack::Fr(1.0), GridTrack::Fr(2.0)]),
664            ..Default::default()
665        };
666        let s = to_taffy_style(&css, &ctx());
667        assert_eq!(s.grid_template_columns.len(), 2);
668    }
669
670    #[test]
671    fn grid_template_rows_string_fr_tracks_translated() {
672        // Same string-encoded form used by examples/mega-showcase.json.
673        let css = CssStyle {
674            grid_template_rows: Some(vec![
675                GridTrack::Length(LengthPercentage::String("1fr".into())),
676                GridTrack::Length(LengthPercentage::String("1fr".into())),
677            ]),
678            ..Default::default()
679        };
680        let s = to_taffy_style(&css, &ctx());
681        assert_eq!(s.grid_template_rows.len(), 2);
682    }
683
684    #[test]
685    fn grid_auto_flow_column_translated() {
686        let css = CssStyle {
687            grid_auto_flow: Some(GridAutoFlow::Column),
688            ..Default::default()
689        };
690        let s = to_taffy_style(&css, &ctx());
691        assert_eq!(s.grid_auto_flow, tf::GridAutoFlow::Column);
692    }
693
694    #[test]
695    fn grid_column_start_and_span_translated() {
696        let css = CssStyle {
697            grid_column: Some(GridLine {
698                start: Some(GridLineEnd::Index(2)),
699                span: Some(3),
700                ..Default::default()
701            }),
702            ..Default::default()
703        };
704        let s = to_taffy_style(&css, &ctx());
705        assert!(matches!(s.grid_column.start, tf::GridPlacement::Line(_)));
706        assert!(matches!(s.grid_column.end, tf::GridPlacement::Span(3)));
707    }
708
709    #[test]
710    fn grid_row_start_end_translated() {
711        let css = CssStyle {
712            grid_row: Some(GridLine {
713                start: Some(GridLineEnd::Index(1)),
714                end: Some(GridLineEnd::Index(3)),
715                ..Default::default()
716            }),
717            ..Default::default()
718        };
719        let s = to_taffy_style(&css, &ctx());
720        assert!(matches!(s.grid_row.start, tf::GridPlacement::Line(_)));
721        assert!(matches!(s.grid_row.end, tf::GridPlacement::Line(_)));
722    }
723
724    #[test]
725    fn grid_gap_applies_to_both_axes() {
726        let css = CssStyle {
727            display: Some(Display::Grid),
728            gap: Some(Gap::Uniform(LengthPercentage::Px(24.0))),
729            ..Default::default()
730        };
731        let s = to_taffy_style(&css, &ctx());
732        assert_eq!(s.gap.width, tf::LengthPercentage::length(24.0));
733        assert_eq!(s.gap.height, tf::LengthPercentage::length(24.0));
734    }
735
736    /// The audit's core repro: a grid with three `1fr` columns must lay its
737    /// children out side-by-side (distinct x-offsets), matching a
738    /// `flex-direction: row` control — NOT stacked as three full-width rows,
739    /// which is what the engine did before `grid-template-columns` was wired
740    /// through to taffy (grid fell back to taffy's single-implicit-column
741    /// default because it never received any track definitions).
742    #[test]
743    fn grid_three_fr_columns_produce_distinct_x_offsets() {
744        let root_css = CssStyle {
745            display: Some(Display::Grid),
746            // The grid container needs a definite size: an `auto`-sized root
747            // (the CssStyle default) has no intrinsic content of its own, so
748            // it collapses to 0×0 and every column ends up at x=0.
749            width: Some(Size::Length(LengthPercentage::Px(900.0))),
750            height: Some(Size::Length(LengthPercentage::Px(300.0))),
751            grid_template_columns: Some(vec![
752                GridTrack::Fr(1.0),
753                GridTrack::Fr(1.0),
754                GridTrack::Fr(1.0),
755            ]),
756            ..Default::default()
757        };
758        let root_style = to_taffy_style(&root_css, &ctx());
759        let leaf_style = to_taffy_style(&CssStyle::default(), &ctx());
760
761        let mut tree: tf::TaffyTree = tf::TaffyTree::new();
762        let c1 = tree.new_leaf(leaf_style.clone()).unwrap();
763        let c2 = tree.new_leaf(leaf_style.clone()).unwrap();
764        let c3 = tree.new_leaf(leaf_style).unwrap();
765        let root = tree.new_with_children(root_style, &[c1, c2, c3]).unwrap();
766
767        tree.compute_layout(
768            root,
769            tf::Size {
770                width: tf::AvailableSpace::Definite(900.0),
771                height: tf::AvailableSpace::Definite(300.0),
772            },
773        )
774        .unwrap();
775
776        let x1 = tree.layout(c1).unwrap().location.x;
777        let x2 = tree.layout(c2).unwrap().location.x;
778        let x3 = tree.layout(c3).unwrap().location.x;
779        let w1 = tree.layout(c1).unwrap().size.width;
780
781        assert_ne!(x1, x2, "columns 1 and 2 must not share an x-offset");
782        assert_ne!(x2, x3, "columns 2 and 3 must not share an x-offset");
783        assert!(
784            (x1 - 0.0).abs() < 0.5,
785            "column 1 should start at x=0, got {x1}"
786        );
787        assert!(
788            (x2 - 300.0).abs() < 1.0,
789            "column 2 should start at ~300px, got {x2}"
790        );
791        assert!(
792            (x3 - 600.0).abs() < 1.0,
793            "column 3 should start at ~600px, got {x3}"
794        );
795        assert!(
796            (w1 - 300.0).abs() < 1.0,
797            "each 1fr column should be ~300px wide, got {w1}"
798        );
799    }
800
801    // ── Round 4 audit, lot LAYOUT, constat 3: box-sizing / justify-items /
802    // justify-self are schema-valid but were never translated to taffy. ────
803
804    #[test]
805    fn box_sizing_content_box_is_translated() {
806        let css = CssStyle {
807            box_sizing: Some(BoxSizing::ContentBox),
808            ..Default::default()
809        };
810        let s = to_taffy_style(&css, &ctx());
811        assert_eq!(s.box_sizing, tf::BoxSizing::ContentBox);
812    }
813
814    #[test]
815    fn box_sizing_defaults_to_border_box() {
816        let css = CssStyle::default();
817        let s = to_taffy_style(&css, &ctx());
818        assert_eq!(s.box_sizing, tf::BoxSizing::BorderBox);
819    }
820
821    #[test]
822    fn justify_items_is_translated_for_grid_children() {
823        let css = CssStyle {
824            display: Some(Display::Grid),
825            justify_items: Some(JustifyItems::Center),
826            ..Default::default()
827        };
828        let s = to_taffy_style(&css, &ctx());
829        assert_eq!(s.justify_items, Some(tf::AlignItems::Center));
830    }
831
832    #[test]
833    fn justify_self_is_translated() {
834        let css = CssStyle {
835            justify_self: Some(JustifySelf::End),
836            ..Default::default()
837        };
838        let s = to_taffy_style(&css, &ctx());
839        assert_eq!(s.justify_self, Some(tf::AlignSelf::End));
840    }
841
842    #[test]
843    fn justify_self_auto_falls_back_to_parent_justify_items() {
844        // `auto` computes to the parent's `justify-items` — taffy models
845        // this the same way `align-self: auto` models inheriting
846        // `align-items`: `None`, not an explicit `Start`.
847        let css = CssStyle {
848            justify_self: Some(JustifySelf::Auto),
849            ..Default::default()
850        };
851        let s = to_taffy_style(&css, &ctx());
852        assert_eq!(s.justify_self, None);
853    }
854}