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#"
+
+
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#" + + +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#" + + +