diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 968bbaa..ecd8a1e 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, ComputedStyle, Display, LengthOrAuto, StyledNode, TextAlign, TextDecoration, + BorderStyle, ComputedStyle, Display, LengthOrAuto, Position, StyledNode, TextAlign, + TextDecoration, }; use we_text::font::Font; @@ -88,6 +89,10 @@ pub struct LayoutBox { pub line_height: f32, /// For replaced elements (e.g., ``): content dimensions (width, height). pub replaced_size: Option<(f32, f32)>, + /// CSS `position` property. + pub position: Position, + /// Relative position offset (dx, dy) applied after normal flow layout. + pub relative_offset: (f32, f32), } impl LayoutBox { @@ -119,6 +124,8 @@ impl LayoutBox { text_align: style.text_align, line_height: style.line_height, replaced_size: None, + position: style.position, + relative_offset: (0.0, 0.0), } } @@ -182,6 +189,30 @@ fn resolve_length(value: LengthOrAuto) -> f32 { } } +/// Resolve horizontal offset for `position: relative`. +/// If both `left` and `right` are specified, `left` wins (CSS2 §9.4.3, ltr). +fn resolve_relative_horizontal(left: LengthOrAuto, right: LengthOrAuto) -> f32 { + match left { + LengthOrAuto::Length(px) => px, + LengthOrAuto::Auto => match right { + LengthOrAuto::Length(px) => -px, + LengthOrAuto::Auto => 0.0, + }, + } +} + +/// Resolve vertical offset for `position: relative`. +/// If both `top` and `bottom` are specified, `top` wins (CSS2 §9.4.3). +fn resolve_relative_vertical(top: LengthOrAuto, bottom: LengthOrAuto) -> f32 { + match top { + LengthOrAuto::Length(px) => px, + LengthOrAuto::Auto => match bottom { + LengthOrAuto::Length(px) => -px, + LengthOrAuto::Auto => 0.0, + }, + } +} + // --------------------------------------------------------------------------- // Build layout tree from styled tree // --------------------------------------------------------------------------- @@ -280,6 +311,13 @@ fn build_box( b.replaced_size = Some((w, h)); } + // Compute relative position offset. + if style.position == Position::Relative { + let dx = resolve_relative_horizontal(style.left, style.right); + let dy = resolve_relative_vertical(style.top, style.bottom); + b.relative_offset = (dx, dy); + } + Some(b) } NodeData::Text { data } => { @@ -397,6 +435,7 @@ fn compute_layout( // Content width is the minimum of replaced width and available width. b.rect.width = rw.min(content_width); b.rect.height = rh; + apply_relative_offset(b); return; } @@ -412,6 +451,33 @@ fn compute_layout( // Handled by the parent's inline layout. } } + + apply_relative_offset(b); +} + +/// Apply `position: relative` offset to a box and all its descendants. +/// +/// This shifts the visual position without affecting the normal-flow layout +/// of surrounding elements (the original space is preserved). +fn apply_relative_offset(b: &mut LayoutBox) { + let (dx, dy) = b.relative_offset; + if dx == 0.0 && dy == 0.0 { + return; + } + shift_box(b, dx, dy); +} + +/// Recursively shift a box and all its descendants by (dx, dy). +fn shift_box(b: &mut LayoutBox, dx: f32, dy: f32) { + b.rect.x += dx; + b.rect.y += dy; + for line in &mut b.lines { + line.x += dx; + line.y += dy; + } + for child in &mut b.children { + shift_box(child, dx, dy); + } } fn has_block_children(b: &LayoutBox) -> bool { @@ -1441,4 +1507,206 @@ p { margin-top: 50px; margin-bottom: 50px; } ); } } + + // --- Relative positioning tests --- + + #[test] + fn relative_position_top_left() { + let html_str = r#" + + +
Content
+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let div_box = &body_box.children[0]; + + assert_eq!(div_box.position, Position::Relative); + assert_eq!(div_box.relative_offset, (20.0, 10.0)); + + // The div should be shifted from where it would be in normal flow. + // Normal flow position: body.rect.x + margin, body.rect.y + margin. + // With relative offset: shifted by (20, 10). + // Body has 8px margin by default, so content starts at x=8, y=8. + assert!( + (div_box.rect.x - (8.0 + 20.0)).abs() < 0.01, + "div x ({}) should be 28.0 (8 + 20)", + div_box.rect.x + ); + assert!( + (div_box.rect.y - (8.0 + 10.0)).abs() < 0.01, + "div y ({}) should be 18.0 (8 + 10)", + div_box.rect.y + ); + } + + #[test] + fn relative_position_does_not_affect_siblings() { + let html_str = r#" + + + +

First

+

Second

+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let first = &body_box.children[0]; + let second = &body_box.children[1]; + + // The first paragraph is shifted down by 50px visually. + assert_eq!(first.relative_offset, (0.0, 50.0)); + + // But the second paragraph should be at its normal-flow position, + // as if the first paragraph were NOT shifted. The second paragraph + // should come right after the first's normal-flow height. + // Body content starts at y=8 (default body margin). First p has 0 margin. + // Second p should start right after first p's height (without offset). + let first_normal_y = 8.0; // body margin + let first_height = first.rect.height; + let expected_second_y = first_normal_y + first_height; + assert!( + (second.rect.y - expected_second_y).abs() < 1.0, + "second y ({}) should be at normal-flow position ({expected_second_y}), not affected by first's relative offset", + second.rect.y + ); + } + + #[test] + fn relative_position_conflicting_offsets() { + // When both top and bottom are specified, top wins. + // When both left and right are specified, left wins. + let html_str = r#" + + +
Content
+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let div_box = &body_box.children[0]; + + // top wins over bottom: dy = 10 (not -20) + // left wins over right: dx = 30 (not -40) + assert_eq!(div_box.relative_offset, (30.0, 10.0)); + } + + #[test] + fn relative_position_auto_offsets() { + // auto offsets should resolve to 0 (no movement). + let html_str = r#" + + +
Content
+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let div_box = &body_box.children[0]; + + assert_eq!(div_box.position, Position::Relative); + assert_eq!(div_box.relative_offset, (0.0, 0.0)); + } + + #[test] + fn relative_position_bottom_right() { + // bottom: 15px should shift up by 15px (negative direction). + // right: 25px should shift left by 25px (negative direction). + let html_str = r#" + + +
Content
+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let div_box = &body_box.children[0]; + + assert_eq!(div_box.relative_offset, (-25.0, -15.0)); + } + + #[test] + fn relative_position_shifts_text_lines() { + let html_str = r#" + + + +

Hello

+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + assert!(!p_box.lines.is_empty(), "p should have text lines"); + let first_line = &p_box.lines[0]; + + // Text should be shifted by the relative offset. + // Body content starts at x=8, y=8. With offset: x=48, y=38. + assert!( + first_line.x >= 8.0 + 40.0 - 1.0, + "text x ({}) should be shifted by left offset", + first_line.x + ); + assert!( + first_line.y >= 8.0 + 30.0 - 1.0, + "text y ({}) should be shifted by top offset", + first_line.y + ); + } + + #[test] + fn static_position_has_no_offset() { + let html_str = r#" + + +
Normal flow
+ +"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let div_box = &body_box.children[0]; + + assert_eq!(div_box.position, Position::Static); + assert_eq!(div_box.relative_offset, (0.0, 0.0)); + } }