diff --git a/crates/css/src/values.rs b/crates/css/src/values.rs index d85220c..18832aa 100644 --- a/crates/css/src/values.rs +++ b/crates/css/src/values.rs @@ -359,10 +359,121 @@ pub fn expand_shorthand( .collect(), ), "background" => Some(expand_background(values, important)), + "flex" => Some(expand_flex(values, important)), + "flex-flow" => Some(expand_flex_flow(values, important)), + "gap" => Some(expand_gap(values, important)), _ => Option::None, } } +/// Expand the `flex` shorthand into `flex-grow`, `flex-shrink`, `flex-basis`. +fn expand_flex(values: &[ComponentValue], important: bool) -> Vec { + let parsed: Vec = values + .iter() + .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) + .map(parse_single_value) + .collect(); + + let (grow, shrink, basis) = match parsed.as_slice() { + [CssValue::None] => (CssValue::Number(0.0), CssValue::Number(0.0), CssValue::Auto), + [CssValue::Auto] => (CssValue::Number(1.0), CssValue::Number(1.0), CssValue::Auto), + [CssValue::Number(g)] => (CssValue::Number(*g), CssValue::Number(1.0), CssValue::Zero), + [CssValue::Zero] => (CssValue::Number(0.0), CssValue::Number(1.0), CssValue::Zero), + [CssValue::Number(g), CssValue::Number(s)] => { + (CssValue::Number(*g), CssValue::Number(*s), CssValue::Zero) + } + [CssValue::Number(g), CssValue::Number(s), basis] => { + (CssValue::Number(*g), CssValue::Number(*s), basis.clone()) + } + _ => (CssValue::Number(0.0), CssValue::Number(1.0), CssValue::Auto), + }; + + vec![ + LonghandDeclaration { + property: "flex-grow".to_string(), + value: grow, + important, + }, + LonghandDeclaration { + property: "flex-shrink".to_string(), + value: shrink, + important, + }, + LonghandDeclaration { + property: "flex-basis".to_string(), + value: basis, + important, + }, + ] +} + +/// Expand the `flex-flow` shorthand into `flex-direction` and `flex-wrap`. +fn expand_flex_flow(values: &[ComponentValue], important: bool) -> Vec { + let parsed: Vec = values + .iter() + .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) + .map(parse_single_value) + .collect(); + + let mut direction = CssValue::Keyword("row".to_string()); + let mut wrap = CssValue::Keyword("nowrap".to_string()); + + for val in &parsed { + if let CssValue::Keyword(k) = val { + match k.as_str() { + "row" | "row-reverse" | "column" | "column-reverse" => { + direction = val.clone(); + } + "nowrap" | "wrap" | "wrap-reverse" => { + wrap = val.clone(); + } + _ => {} + } + } + } + + vec![ + LonghandDeclaration { + property: "flex-direction".to_string(), + value: direction, + important, + }, + LonghandDeclaration { + property: "flex-wrap".to_string(), + value: wrap, + important, + }, + ] +} + +/// Expand the `gap` shorthand into `row-gap` and `column-gap`. +fn expand_gap(values: &[ComponentValue], important: bool) -> Vec { + let parsed: Vec = values + .iter() + .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) + .map(parse_single_value) + .collect(); + + let (row, col) = match parsed.as_slice() { + [single] => (single.clone(), single.clone()), + [r, c] => (r.clone(), c.clone()), + _ => (CssValue::Zero, CssValue::Zero), + }; + + vec![ + LonghandDeclaration { + property: "row-gap".to_string(), + value: row, + important, + }, + LonghandDeclaration { + property: "column-gap".to_string(), + value: col, + important, + }, + ] +} + /// Expand a box-model shorthand (margin, padding) using the 1-to-4 value pattern. fn expand_box_shorthand( prefix: &str, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 6ee8c01..25671f3 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -8,7 +8,8 @@ use std::collections::HashMap; use we_css::values::Color; use we_dom::{Document, NodeData, NodeId}; use we_style::computed::{ - BorderStyle, BoxSizing, ComputedStyle, Display, LengthOrAuto, Overflow, Position, StyledNode, + AlignContent, AlignItems, AlignSelf, BorderStyle, BoxSizing, ComputedStyle, Display, + FlexDirection, FlexWrap, JustifyContent, LengthOrAuto, Overflow, Position, StyledNode, TextAlign, TextDecoration, Visibility, }; use we_text::font::Font; @@ -68,6 +69,7 @@ pub struct TextLine { #[derive(Debug)] pub struct LayoutBox { pub box_type: BoxType, + pub display: Display, pub rect: Rect, pub margin: EdgeSizes, pub padding: EdgeSizes, @@ -115,12 +117,27 @@ pub struct LayoutBox { /// Natural content height before CSS height override. /// Used to determine overflow for scroll containers. pub content_height: f32, + // Flex container properties + pub flex_direction: FlexDirection, + pub flex_wrap: FlexWrap, + pub justify_content: JustifyContent, + pub align_items: AlignItems, + pub align_content: AlignContent, + pub row_gap: f32, + pub column_gap: f32, + // Flex item properties + pub flex_grow: f32, + pub flex_shrink: f32, + pub flex_basis: LengthOrAuto, + pub align_self: AlignSelf, + pub order: i32, } impl LayoutBox { fn new(box_type: BoxType, style: &ComputedStyle) -> Self { LayoutBox { box_type, + display: style.display, rect: Rect::default(), margin: EdgeSizes::default(), padding: EdgeSizes::default(), @@ -167,6 +184,18 @@ impl LayoutBox { css_offsets: [style.top, style.right, style.bottom, style.left], visibility: style.visibility, content_height: 0.0, + flex_direction: style.flex_direction, + flex_wrap: style.flex_wrap, + justify_content: style.justify_content, + align_items: style.align_items, + align_content: style.align_content, + row_gap: style.row_gap, + column_gap: style.column_gap, + flex_grow: style.flex_grow, + flex_shrink: style.flex_shrink, + flex_basis: style.flex_basis, + align_self: style.align_self, + order: style.order, } } @@ -343,7 +372,7 @@ fn build_box( } let box_type = match style.display { - Display::Block => BoxType::Block(node), + Display::Block | Display::Flex | Display::InlineFlex => BoxType::Block(node), Display::Inline => BoxType::Inline(node), Display::None => unreachable!(), }; @@ -539,7 +568,9 @@ fn compute_layout( match &b.box_type { BoxType::Block(_) | BoxType::Anonymous => { - if has_block_children(b) { + if matches!(b.display, Display::Flex | Display::InlineFlex) { + layout_flex_children(b, viewport_height, font, doc); + } else if has_block_children(b) { layout_block_children(b, viewport_height, font, doc); } else { layout_inline_children(b, font, doc); @@ -632,7 +663,7 @@ fn collapse_margins(a: f32, b: f32) -> f32 { /// Returns `true` if this box establishes a new block formatting context, /// which prevents its margins from collapsing with children. fn establishes_bfc(b: &LayoutBox) -> bool { - b.overflow != Overflow::Visible + b.overflow != Overflow::Visible || matches!(b.display, Display::Flex | Display::InlineFlex) } /// Returns `true` if a block box has no in-flow content (empty block). @@ -823,6 +854,635 @@ fn layout_block_children( parent.rect.height = cursor_y - parent.rect.y; } +// --------------------------------------------------------------------------- +// Flex layout +// --------------------------------------------------------------------------- + +/// Measure the intrinsic max-content width of a flex item's content. +/// Returns the width needed to lay out content without wrapping. +fn measure_flex_item_max_content_width(child: &LayoutBox, font: &Font, _doc: &Document) -> f32 { + // Measure the widest text line from all inline content. + let mut max_width = 0.0f32; + measure_box_content_width(child, font, &mut max_width); + max_width +} + +/// Recursively measure max content width of a layout box tree. +fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { + match &b.box_type { + BoxType::TextRun { text, .. } => { + let w = measure_text_width(font, text, b.font_size); + if w > *max_w { + *max_w = w; + } + } + _ => { + let horiz = b.border.left + b.border.right + b.padding.left + b.padding.right; + let mut child_max = 0.0f32; + for child in &b.children { + measure_box_content_width(child, font, &mut child_max); + } + let total = child_max + horiz; + if total > *max_w { + *max_w = total; + } + } + } +} + +/// Lay out children according to the CSS Flexbox algorithm (Level 1 §9). +fn layout_flex_children(parent: &mut LayoutBox, viewport_height: f32, font: &Font, doc: &Document) { + let flex_direction = parent.flex_direction; + let flex_wrap = parent.flex_wrap; + let justify_content = parent.justify_content; + let align_items = parent.align_items; + let align_content = parent.align_content; + let row_gap = parent.row_gap; + let column_gap = parent.column_gap; + + let container_main_size = match flex_direction { + FlexDirection::Row | FlexDirection::RowReverse => parent.rect.width, + FlexDirection::Column | FlexDirection::ColumnReverse => { + match parent.css_height { + LengthOrAuto::Length(h) => h, + LengthOrAuto::Percentage(p) => p / 100.0 * viewport_height, + LengthOrAuto::Auto => viewport_height, // fallback + } + } + }; + let container_cross_size = match flex_direction { + FlexDirection::Row | FlexDirection::RowReverse => match parent.css_height { + LengthOrAuto::Length(h) => Some(h), + LengthOrAuto::Percentage(p) => Some(p / 100.0 * viewport_height), + LengthOrAuto::Auto => None, + }, + FlexDirection::Column | FlexDirection::ColumnReverse => Some(parent.rect.width), + }; + + let is_row = matches!( + flex_direction, + FlexDirection::Row | FlexDirection::RowReverse + ); + let is_reverse = matches!( + flex_direction, + FlexDirection::RowReverse | FlexDirection::ColumnReverse + ); + + if parent.children.is_empty() { + parent.rect.height = 0.0; + return; + } + + // Sort children by `order` (stable sort preserves DOM order for equal values). + let child_count = parent.children.len(); + let mut order_indices: Vec = (0..child_count).collect(); + order_indices.sort_by_key(|&i| parent.children[i].order); + + // Step 1: Determine flex base sizes and hypothetical main sizes. + struct FlexItemInfo { + index: usize, + base_size: f32, + hypothetical_main: f32, + flex_grow: f32, + flex_shrink: f32, + frozen: bool, + target_main: f32, + outer_main: f32, // margin+border+padding on main axis + outer_cross: f32, // margin+border+padding on cross axis + } + + let mut items: Vec = Vec::with_capacity(child_count); + + for &idx in &order_indices { + let child = &mut parent.children[idx]; + + // Resolve margin/padding percentages for the child. + let cb_width = if is_row { + container_main_size + } else { + container_cross_size.unwrap_or(parent.rect.width) + }; + + // Resolve percentage margins and padding against containing block width. + for i in 0..4 { + if matches!(child.css_margin[i], LengthOrAuto::Percentage(_)) { + let resolved = resolve_length_against(child.css_margin[i], cb_width); + match i { + 0 => child.margin.top = resolved, + 1 => child.margin.right = resolved, + 2 => child.margin.bottom = resolved, + 3 => child.margin.left = resolved, + _ => {} + } + } + if matches!(child.css_padding[i], LengthOrAuto::Percentage(_)) { + let resolved = resolve_length_against(child.css_padding[i], cb_width); + match i { + 0 => child.padding.top = resolved, + 1 => child.padding.right = resolved, + 2 => child.padding.bottom = resolved, + 3 => child.padding.left = resolved, + _ => {} + } + } + } + + let margin_main = if is_row { + child.margin.left + child.margin.right + } else { + child.margin.top + child.margin.bottom + }; + let border_padding_main = if is_row { + child.border.left + child.border.right + child.padding.left + child.padding.right + } else { + child.border.top + child.border.bottom + child.padding.top + child.padding.bottom + }; + let margin_cross = if is_row { + child.margin.top + child.margin.bottom + } else { + child.margin.left + child.margin.right + }; + let border_padding_cross = if is_row { + child.border.top + child.border.bottom + child.padding.top + child.padding.bottom + } else { + child.border.left + child.border.right + child.padding.left + child.padding.right + }; + + // Determine flex-basis. + let flex_basis = child.flex_basis; + let specified_main = if is_row { + child.css_width + } else { + child.css_height + }; + + let base_size = match flex_basis { + LengthOrAuto::Length(px) => px, + LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, + LengthOrAuto::Auto => { + // Use specified main size if set, otherwise content size. + match specified_main { + LengthOrAuto::Length(px) => px, + LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, + LengthOrAuto::Auto => { + // Content-based sizing: use max-content size. + if is_row { + measure_flex_item_max_content_width(child, font, doc) + } else { + // For column direction, measure content height. + let avail = container_cross_size.unwrap_or(parent.rect.width); + compute_layout(child, 0.0, 0.0, avail, viewport_height, font, doc); + child.rect.height + } + } + } + } + }; + + let hypothetical_main = base_size.max(0.0); + let outer = margin_main + border_padding_main; + + items.push(FlexItemInfo { + index: idx, + base_size, + hypothetical_main, + flex_grow: child.flex_grow, + flex_shrink: child.flex_shrink, + frozen: false, + target_main: hypothetical_main, + outer_main: outer, + outer_cross: margin_cross + border_padding_cross, + }); + } + + // Step 2: Collect items into flex lines. + let mut lines: Vec> = Vec::new(); // indices into `items` + + if flex_wrap == FlexWrap::Nowrap { + // All items on one line. + lines.push((0..items.len()).collect()); + } else { + let mut current_line: Vec = Vec::new(); + let mut line_main_size = 0.0f32; + + for (i, item) in items.iter().enumerate() { + let item_outer = item.hypothetical_main + item.outer_main; + let gap = if current_line.is_empty() { + 0.0 + } else { + column_gap + }; + + if !current_line.is_empty() && line_main_size + gap + item_outer > container_main_size { + lines.push(std::mem::take(&mut current_line)); + line_main_size = 0.0; + } + + if !current_line.is_empty() { + line_main_size += column_gap; + } + line_main_size += item_outer; + current_line.push(i); + } + + if !current_line.is_empty() { + lines.push(current_line); + } + } + + if flex_wrap == FlexWrap::WrapReverse { + lines.reverse(); + } + + // Step 3: Resolve flexible lengths for each line. + for line in &lines { + // Total hypothetical main sizes + gaps. + let total_gaps = if line.len() > 1 { + (line.len() - 1) as f32 * column_gap + } else { + 0.0 + }; + let total_outer_hypo: f32 = line + .iter() + .map(|&i| items[i].hypothetical_main + items[i].outer_main) + .sum(); + let used_space = total_outer_hypo + total_gaps; + let free_space = container_main_size - used_space; + + // Reset frozen state. + for &i in line { + items[i].frozen = false; + items[i].target_main = items[i].hypothetical_main; + } + + if free_space > 0.0 { + // Grow items. + let total_grow: f32 = line.iter().map(|&i| items[i].flex_grow).sum(); + if total_grow > 0.0 { + for &i in line { + if items[i].flex_grow > 0.0 { + items[i].target_main += free_space * (items[i].flex_grow / total_grow); + } + } + } + } else if free_space < 0.0 { + // Shrink items. + let total_shrink_scaled: f32 = line + .iter() + .map(|&i| items[i].flex_shrink * items[i].base_size) + .sum(); + if total_shrink_scaled > 0.0 { + for &i in line { + let scaled = items[i].flex_shrink * items[i].base_size; + let ratio = scaled / total_shrink_scaled; + items[i].target_main = + (items[i].hypothetical_main + free_space * ratio).max(0.0); + } + } + } + } + + // Step 4: Determine cross sizes of items by laying them out at their target main size. + struct LineCrossInfo { + max_cross: f32, + } + + let mut line_infos: Vec = Vec::new(); + + for line in &lines { + let mut max_cross = 0.0f32; + + for &i in line { + let idx = items[i].index; + let child = &mut parent.children[idx]; + let target_main = items[i].target_main; + + // Set up the child for layout at the resolved main size. + if is_row { + child.css_width = LengthOrAuto::Length(target_main); + compute_layout(child, 0.0, 0.0, target_main, viewport_height, font, doc); + let cross = child.rect.height + items[i].outer_cross; + if cross > max_cross { + max_cross = cross; + } + } else { + let avail = container_cross_size.unwrap_or(parent.rect.width); + compute_layout(child, 0.0, 0.0, avail, viewport_height, font, doc); + child.rect.height = target_main; + let cross = child.rect.width + + child.border.left + + child.border.right + + child.padding.left + + child.padding.right + + child.margin.left + + child.margin.right; + if cross > max_cross { + max_cross = cross; + } + } + } + + line_infos.push(LineCrossInfo { max_cross }); + } + + // Per CSS Flexbox §9.4: If the flex container is single-line and has a + // definite cross size, the cross size of the flex line is the container's + // cross size (clamped to min/max). This ensures alignment works relative + // to the full container. + if lines.len() == 1 { + if let Some(cs) = container_cross_size { + if line_infos[0].max_cross < cs { + line_infos[0].max_cross = cs; + } + } + } + + // Step 5: Handle align-items: stretch — stretch items to fill the line cross size. + for (line_idx, line) in lines.iter().enumerate() { + let line_cross = line_infos[line_idx].max_cross; + + for &i in line { + let idx = items[i].index; + let child = &mut parent.children[idx]; + + let effective_align = match child.align_self { + AlignSelf::Auto => align_items, + AlignSelf::FlexStart => AlignItems::FlexStart, + AlignSelf::FlexEnd => AlignItems::FlexEnd, + AlignSelf::Center => AlignItems::Center, + AlignSelf::Baseline => AlignItems::Baseline, + AlignSelf::Stretch => AlignItems::Stretch, + }; + + if effective_align == AlignItems::Stretch { + let item_cross_space = line_cross - items[i].outer_cross; + if is_row { + if matches!(child.css_height, LengthOrAuto::Auto) { + child.rect.height = item_cross_space.max(0.0); + } + } else if matches!(child.css_width, LengthOrAuto::Auto) { + child.rect.width = item_cross_space.max(0.0); + } + } + } + } + + // Step 6: Position items on main and cross axes. + let total_cross_gaps = if lines.len() > 1 { + (lines.len() - 1) as f32 * row_gap + } else { + 0.0 + }; + let total_line_cross: f32 = line_infos.iter().map(|li| li.max_cross).sum(); + let total_cross_used = total_line_cross + total_cross_gaps; + + // For definite cross size containers, compute align-content offsets. + let cross_free_space = container_cross_size + .map(|cs| (cs - total_cross_used).max(0.0)) + .unwrap_or(0.0); + + let (ac_initial_offset, ac_between_offset) = compute_content_distribution( + align_content_to_justify(align_content), + cross_free_space, + lines.len(), + ); + + let mut cross_cursor = if is_row { parent.rect.y } else { parent.rect.x } + ac_initial_offset; + + for (line_idx, line) in lines.iter().enumerate() { + let line_cross = line_infos[line_idx].max_cross; + + // Main-axis justification. + let total_main_gaps = if line.len() > 1 { + (line.len() - 1) as f32 * column_gap + } else { + 0.0 + }; + let items_main: f32 = line + .iter() + .map(|&i| items[i].target_main + items[i].outer_main) + .sum(); + let main_free = (container_main_size - items_main - total_main_gaps).max(0.0); + + let (jc_initial, jc_between) = + compute_content_distribution(justify_content, main_free, line.len()); + + // Determine the starting main position. + let mut main_cursor = if is_row { parent.rect.x } else { parent.rect.y }; + + if is_reverse { + // For reverse directions, start from the end. + main_cursor += container_main_size; + } + + main_cursor += if is_reverse { -jc_initial } else { jc_initial }; + + let line_items: Vec = if is_reverse { + line.iter().rev().copied().collect() + } else { + line.to_vec() + }; + + for (item_pos, &i) in line_items.iter().enumerate() { + let idx = items[i].index; + let child = &mut parent.children[idx]; + let target_main = items[i].target_main; + + // Main-axis position. + let child_main_margin_start = if is_row { + child.margin.left + } else { + child.margin.top + }; + let child_main_margin_end = if is_row { + child.margin.right + } else { + child.margin.bottom + }; + let child_main_bp_start = if is_row { + child.border.left + child.padding.left + } else { + child.border.top + child.padding.top + }; + let child_main_bp_end = if is_row { + child.border.right + child.padding.right + } else { + child.border.bottom + child.padding.bottom + }; + + let outer_size = child_main_margin_start + + child_main_bp_start + + target_main + + child_main_bp_end + + child_main_margin_end; + + if is_reverse { + main_cursor -= outer_size; + } + + let content_main = main_cursor + child_main_margin_start + child_main_bp_start; + + // Cross-axis position. + let child_cross_margin_start = if is_row { + child.margin.top + } else { + child.margin.left + }; + let child_cross_bp_start = if is_row { + child.border.top + child.padding.top + } else { + child.border.left + child.padding.left + }; + let child_cross_total = if is_row { + child.rect.height + items[i].outer_cross + } else { + child.rect.width + items[i].outer_cross + }; + + let effective_align = match child.align_self { + AlignSelf::Auto => align_items, + AlignSelf::FlexStart => AlignItems::FlexStart, + AlignSelf::FlexEnd => AlignItems::FlexEnd, + AlignSelf::Center => AlignItems::Center, + AlignSelf::Baseline => AlignItems::Baseline, + AlignSelf::Stretch => AlignItems::Stretch, + }; + + let cross_offset = match effective_align { + AlignItems::FlexStart | AlignItems::Stretch | AlignItems::Baseline => 0.0, + AlignItems::FlexEnd => line_cross - child_cross_total, + AlignItems::Center => (line_cross - child_cross_total) / 2.0, + }; + + let content_cross = + cross_cursor + cross_offset + child_cross_margin_start + child_cross_bp_start; + + // Set child position. + if is_row { + child.rect.x = content_main; + child.rect.y = content_cross; + child.rect.width = target_main; + } else { + child.rect.x = content_cross; + child.rect.y = content_main; + child.rect.height = target_main; + } + + // Shift any text lines to match new position. + reposition_lines(child, font, doc); + + if !is_reverse { + main_cursor += outer_size; + } + + // Add gap between items (not after last). + if item_pos < line_items.len() - 1 { + if is_reverse { + main_cursor -= column_gap; + } else { + main_cursor += column_gap; + } + } + + // Also add justify-content between spacing. + if item_pos < line_items.len() - 1 { + if is_reverse { + main_cursor -= jc_between; + } else { + main_cursor += jc_between; + } + } + } + + cross_cursor += line_cross + row_gap + ac_between_offset; + } + + // Set parent height based on children. + if is_row { + if matches!(parent.css_height, LengthOrAuto::Auto) { + parent.rect.height = total_cross_used; + } + } else if matches!(parent.css_height, LengthOrAuto::Auto) { + // For column flex, height is the main axis. + let total_main: f32 = lines + .iter() + .map(|line| { + let items_main: f32 = line + .iter() + .map(|&i| items[i].target_main + items[i].outer_main) + .sum(); + let gaps = if line.len() > 1 { + (line.len() - 1) as f32 * column_gap + } else { + 0.0 + }; + items_main + gaps + }) + .fold(0.0f32, f32::max); + parent.rect.height = total_main; + } +} + +/// Reposition inline text lines inside a layout box after moving it. +/// Re-runs inline layout if the box has inline children. +fn reposition_lines(b: &mut LayoutBox, font: &Font, doc: &Document) { + if !b.lines.is_empty() { + // Re-run inline layout at the new position. + b.lines.clear(); + layout_inline_children(b, font, doc); + } + // Recursively reposition children that have their own inline content. + for child in &mut b.children { + if !child.lines.is_empty() { + child.lines.clear(); + layout_inline_children(child, font, doc); + } + } +} + +/// Convert AlignContent to JustifyContent for reuse of distribution logic. +fn align_content_to_justify(ac: AlignContent) -> JustifyContent { + match ac { + AlignContent::FlexStart | AlignContent::Stretch => JustifyContent::FlexStart, + AlignContent::FlexEnd => JustifyContent::FlexEnd, + AlignContent::Center => JustifyContent::Center, + AlignContent::SpaceBetween => JustifyContent::SpaceBetween, + AlignContent::SpaceAround => JustifyContent::SpaceAround, + } +} + +/// Compute the initial offset and between-item spacing for content distribution. +/// Returns (initial_offset, between_spacing). +fn compute_content_distribution( + mode: JustifyContent, + free_space: f32, + item_count: usize, +) -> (f32, f32) { + if item_count == 0 { + return (0.0, 0.0); + } + match mode { + JustifyContent::FlexStart => (0.0, 0.0), + JustifyContent::FlexEnd => (free_space, 0.0), + JustifyContent::Center => (free_space / 2.0, 0.0), + JustifyContent::SpaceBetween => { + if item_count <= 1 { + (0.0, 0.0) + } else { + (0.0, free_space / (item_count - 1) as f32) + } + } + JustifyContent::SpaceAround => { + let per_item = free_space / item_count as f32; + (per_item / 2.0, per_item) + } + JustifyContent::SpaceEvenly => { + let spacing = free_space / (item_count + 1) as f32; + (spacing, spacing) + } + } +} + // --------------------------------------------------------------------------- // Inline formatting context // --------------------------------------------------------------------------- @@ -2911,4 +3571,402 @@ div { height: 50%; } div_box.rect.height ); } + + // ----------------------------------------------------------------------- + // Flexbox tests + // ----------------------------------------------------------------------- + + #[test] + fn flex_row_default_items_laid_out_horizontally() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + assert_eq!(container.children.len(), 3); + + // Items should be at x=0, 100, 200 + assert!( + (container.children[0].rect.x - 0.0).abs() < 1.0, + "first item x should be 0, got {}", + container.children[0].rect.x + ); + assert!( + (container.children[1].rect.x - 100.0).abs() < 1.0, + "second item x should be 100, got {}", + container.children[1].rect.x + ); + assert!( + (container.children[2].rect.x - 200.0).abs() < 1.0, + "third item x should be 200, got {}", + container.children[2].rect.x + ); + + // All items should have the same y. + let y0 = container.children[0].rect.y; + assert!((container.children[1].rect.y - y0).abs() < 1.0); + assert!((container.children[2].rect.y - y0).abs() < 1.0); + } + + #[test] + fn flex_direction_column() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + assert_eq!(container.children.len(), 3); + + // Items should be stacked vertically at y offsets: 0, 50, 100 (relative to container). + let cy = container.rect.y; + assert!((container.children[0].rect.y - cy).abs() < 1.0); + assert!( + (container.children[1].rect.y - cy - 50.0).abs() < 1.0, + "second item y should be {}, got {}", + cy + 50.0, + container.children[1].rect.y + ); + assert!( + (container.children[2].rect.y - cy - 100.0).abs() < 1.0, + "third item y should be {}, got {}", + cy + 100.0, + container.children[2].rect.y + ); + } + + #[test] + fn flex_grow_distributes_space() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + + // flex-grow 1:2:1 should split 600px as 150:300:150 + let a = &container.children[0]; + let b = &container.children[1]; + let c = &container.children[2]; + + assert!( + (a.rect.width - 150.0).abs() < 1.0, + "item A width should be 150, got {}", + a.rect.width + ); + assert!( + (b.rect.width - 300.0).abs() < 1.0, + "item B width should be 300, got {}", + b.rect.width + ); + assert!( + (c.rect.width - 150.0).abs() < 1.0, + "item C width should be 150, got {}", + c.rect.width + ); + } + + #[test] + fn flex_justify_content_center() { + let html_str = r#" + + +
+
A
+
B
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + + // 600px container, 200px of items, 400px free space, offset = 200. + let a = &container.children[0]; + let b = &container.children[1]; + + assert!( + (a.rect.x - 200.0).abs() < 1.0, + "first item x should be 200, got {}", + a.rect.x + ); + assert!( + (b.rect.x - 300.0).abs() < 1.0, + "second item x should be 300, got {}", + b.rect.x + ); + } + + #[test] + fn flex_justify_content_space_between() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + + // 3 items of 100px each = 300px, 300px free, between = 150 + // Positions: 0, 250, 500 + let a = &container.children[0]; + let b = &container.children[1]; + let c = &container.children[2]; + + assert!( + (a.rect.x - 0.0).abs() < 1.0, + "first item x should be 0, got {}", + a.rect.x + ); + assert!( + (b.rect.x - 250.0).abs() < 1.0, + "second item x should be 250, got {}", + b.rect.x + ); + assert!( + (c.rect.x - 500.0).abs() < 1.0, + "third item x should be 500, got {}", + c.rect.x + ); + } + + #[test] + fn flex_align_items_center() { + let html_str = r#" + + +
+
A
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + let a = &container.children[0]; + + // Container height=200, item height=50, centered at y = container.y + 75 + let expected_y = container.rect.y + 75.0; + assert!( + (a.rect.y - expected_y).abs() < 1.0, + "item y should be {}, got {}", + expected_y, + a.rect.y + ); + } + + #[test] + fn flex_wrap_wraps_to_new_line() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + + // 250px container, 100px items: A and B fit on line 1, C wraps to line 2 + let a = &container.children[0]; + let b = &container.children[1]; + let c = &container.children[2]; + + // A and B should be on the same row + assert!((a.rect.y - b.rect.y).abs() < 1.0); + // C should be on a different row (50px below) + assert!( + (c.rect.y - a.rect.y - 50.0).abs() < 1.0, + "C should be 50px below A, got y diff {}", + c.rect.y - a.rect.y + ); + } + + #[test] + fn flex_gap_adds_spacing() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + + let a = &container.children[0]; + let b = &container.children[1]; + let c = &container.children[2]; + + // With gap: 20px, positions should be: 0, 120, 240 + assert!((a.rect.x - 0.0).abs() < 1.0); + assert!( + (b.rect.x - 120.0).abs() < 1.0, + "B x should be 120, got {}", + b.rect.x + ); + assert!( + (c.rect.x - 240.0).abs() < 1.0, + "C x should be 240, got {}", + c.rect.x + ); + } + + #[test] + fn flex_order_changes_visual_order() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + + // DOM order: A(order:2), B(order:1), C(order:3) + // Visual order: B(1), A(2), C(3) + // B is at index 1 in DOM, A at 0, C at 2. + // B (index 1) should be first visually (x ≈ 0) + // A (index 0) should be second visually (x ≈ 100) + // C (index 2) should be third visually (x ≈ 200) + let a = &container.children[0]; // DOM index 0, order 2 + let b = &container.children[1]; // DOM index 1, order 1 + let c = &container.children[2]; // DOM index 2, order 3 + + assert!( + b.rect.x < a.rect.x, + "B (order:1) should be before A (order:2), B.x={} A.x={}", + b.rect.x, + a.rect.x + ); + assert!( + a.rect.x < c.rect.x, + "A (order:2) should be before C (order:3), A.x={} C.x={}", + a.rect.x, + c.rect.x + ); + } + + #[test] + fn flex_shrink_items() { + let html_str = r#" + + +
+
A
+
B
+
C
+
+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + + // 3 items * 100px = 300px in 200px container, shrink evenly + // Each should be ~66.67px + for (i, child) in container.children.iter().enumerate() { + assert!( + (child.rect.width - 200.0 / 3.0).abs() < 1.0, + "item {} width should be ~66.67, got {}", + i, + child.rect.width + ); + } + } } diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 1997b9a..2b8e069 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -20,6 +20,8 @@ pub enum Display { Block, #[default] Inline, + Flex, + InlineFlex, None, } @@ -126,6 +128,70 @@ pub enum Visibility { Collapse, } +// --------------------------------------------------------------------------- +// Flex enums +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FlexDirection { + #[default] + Row, + RowReverse, + Column, + ColumnReverse, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FlexWrap { + #[default] + Nowrap, + Wrap, + WrapReverse, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum JustifyContent { + #[default] + FlexStart, + FlexEnd, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AlignItems { + #[default] + Stretch, + FlexStart, + FlexEnd, + Center, + Baseline, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AlignContent { + #[default] + Stretch, + FlexStart, + FlexEnd, + Center, + SpaceBetween, + SpaceAround, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AlignSelf { + #[default] + Auto, + FlexStart, + FlexEnd, + Center, + Baseline, + Stretch, +} + // --------------------------------------------------------------------------- // BorderStyle // --------------------------------------------------------------------------- @@ -231,6 +297,22 @@ pub struct ComputedStyle { // Visibility (inherited) pub visibility: Visibility, + + // Flex container properties + pub flex_direction: FlexDirection, + pub flex_wrap: FlexWrap, + pub justify_content: JustifyContent, + pub align_items: AlignItems, + pub align_content: AlignContent, + pub row_gap: f32, + pub column_gap: f32, + + // Flex item properties + pub flex_grow: f32, + pub flex_shrink: f32, + pub flex_basis: LengthOrAuto, + pub align_self: AlignSelf, + pub order: i32, } impl Default for ComputedStyle { @@ -287,6 +369,20 @@ impl Default for ComputedStyle { overflow: Overflow::Visible, visibility: Visibility::Visible, + + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Nowrap, + justify_content: JustifyContent::FlexStart, + align_items: AlignItems::Stretch, + align_content: AlignContent::Stretch, + row_gap: 0.0, + column_gap: 0.0, + + flex_grow: 0.0, + flex_shrink: 1.0, + flex_basis: LengthOrAuto::Auto, + align_self: AlignSelf::Auto, + order: 0, } } } @@ -498,6 +594,8 @@ fn apply_property( CssValue::Keyword(k) => match k.as_str() { "block" => Display::Block, "inline" => Display::Inline, + "flex" => Display::Flex, + "inline-flex" => Display::InlineFlex, _ => Display::Block, }, CssValue::None => Display::None, @@ -790,6 +888,126 @@ fn apply_property( }; } + // Flex container properties + "flex-direction" => { + style.flex_direction = match value { + CssValue::Keyword(k) => match k.as_str() { + "row" => FlexDirection::Row, + "row-reverse" => FlexDirection::RowReverse, + "column" => FlexDirection::Column, + "column-reverse" => FlexDirection::ColumnReverse, + _ => style.flex_direction, + }, + _ => style.flex_direction, + }; + } + "flex-wrap" => { + style.flex_wrap = match value { + CssValue::Keyword(k) => match k.as_str() { + "nowrap" => FlexWrap::Nowrap, + "wrap" => FlexWrap::Wrap, + "wrap-reverse" => FlexWrap::WrapReverse, + _ => style.flex_wrap, + }, + _ => style.flex_wrap, + }; + } + "justify-content" => { + style.justify_content = match value { + CssValue::Keyword(k) => match k.as_str() { + "flex-start" => JustifyContent::FlexStart, + "flex-end" => JustifyContent::FlexEnd, + "center" => JustifyContent::Center, + "space-between" => JustifyContent::SpaceBetween, + "space-around" => JustifyContent::SpaceAround, + "space-evenly" => JustifyContent::SpaceEvenly, + _ => style.justify_content, + }, + _ => style.justify_content, + }; + } + "align-items" => { + style.align_items = match value { + CssValue::Keyword(k) => match k.as_str() { + "stretch" => AlignItems::Stretch, + "flex-start" => AlignItems::FlexStart, + "flex-end" => AlignItems::FlexEnd, + "center" => AlignItems::Center, + "baseline" => AlignItems::Baseline, + _ => style.align_items, + }, + _ => style.align_items, + }; + } + "align-content" => { + style.align_content = match value { + CssValue::Keyword(k) => match k.as_str() { + "stretch" => AlignContent::Stretch, + "flex-start" => AlignContent::FlexStart, + "flex-end" => AlignContent::FlexEnd, + "center" => AlignContent::Center, + "space-between" => AlignContent::SpaceBetween, + "space-around" => AlignContent::SpaceAround, + _ => style.align_content, + }, + _ => style.align_content, + }; + } + "row-gap" => { + if let CssValue::Length(n, unit) = value { + style.row_gap = resolve_length_unit(*n, *unit, current_fs, viewport); + } else if let CssValue::Zero = value { + style.row_gap = 0.0; + } + } + "column-gap" => { + if let CssValue::Length(n, unit) = value { + style.column_gap = resolve_length_unit(*n, *unit, current_fs, viewport); + } else if let CssValue::Zero = value { + style.column_gap = 0.0; + } + } + + // Flex item properties + "flex-grow" => { + if let CssValue::Number(n) = value { + style.flex_grow = *n as f32; + } else if let CssValue::Zero = value { + style.flex_grow = 0.0; + } + } + "flex-shrink" => { + if let CssValue::Number(n) = value { + style.flex_shrink = *n as f32; + } else if let CssValue::Zero = value { + style.flex_shrink = 0.0; + } + } + "flex-basis" => { + style.flex_basis = resolve_layout_length_or_auto(value, current_fs, viewport); + } + "align-self" => { + style.align_self = match value { + CssValue::Keyword(k) => match k.as_str() { + "flex-start" => AlignSelf::FlexStart, + "flex-end" => AlignSelf::FlexEnd, + "center" => AlignSelf::Center, + "baseline" => AlignSelf::Baseline, + "stretch" => AlignSelf::Stretch, + _ => style.align_self, + }, + CssValue::Auto => AlignSelf::Auto, + _ => style.align_self, + }; + } + "order" => { + if let CssValue::Number(n) = value { + style.order = *n as i32; + } else if let CssValue::Zero = value { + style.order = 0; + } + } + _ => {} // Unknown property — ignore } } @@ -859,6 +1077,18 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "background-color" => style.background_color = parent.background_color, "position" => style.position = parent.position, "overflow" => style.overflow = parent.overflow, + "flex-direction" => style.flex_direction = parent.flex_direction, + "flex-wrap" => style.flex_wrap = parent.flex_wrap, + "justify-content" => style.justify_content = parent.justify_content, + "align-items" => style.align_items = parent.align_items, + "align-content" => style.align_content = parent.align_content, + "row-gap" => style.row_gap = parent.row_gap, + "column-gap" => style.column_gap = parent.column_gap, + "flex-grow" => style.flex_grow = parent.flex_grow, + "flex-shrink" => style.flex_shrink = parent.flex_shrink, + "flex-basis" => style.flex_basis = parent.flex_basis, + "align-self" => style.align_self = parent.align_self, + "order" => style.order = parent.order, _ => {} } } @@ -901,6 +1131,18 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "left" => style.left = initial.left, "overflow" => style.overflow = initial.overflow, "visibility" => style.visibility = initial.visibility, + "flex-direction" => style.flex_direction = initial.flex_direction, + "flex-wrap" => style.flex_wrap = initial.flex_wrap, + "justify-content" => style.justify_content = initial.justify_content, + "align-items" => style.align_items = initial.align_items, + "align-content" => style.align_content = initial.align_content, + "row-gap" => style.row_gap = initial.row_gap, + "column-gap" => style.column_gap = initial.column_gap, + "flex-grow" => style.flex_grow = initial.flex_grow, + "flex-shrink" => style.flex_shrink = initial.flex_shrink, + "flex-basis" => style.flex_basis = initial.flex_basis, + "align-self" => style.align_self = initial.align_self, + "order" => style.order = initial.order, _ => {} } }