we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement position: relative layout offsetting

Add relative positioning support to the layout engine. After normal-flow
layout, elements with position: relative are visually shifted by their
top/right/bottom/left offset values without affecting surrounding elements.

- Add position and relative_offset fields to LayoutBox
- Resolve offset conflicts per CSS spec (top wins over bottom, left over right)
- Recursively shift box rect, text lines, and all descendants
- Add 7 tests covering offsets, sibling independence, conflicts, and auto values

authored by

Pierre Le Fevre and committed by tangled.org 615f1554 06073d02

+269 -1
+269 -1
crates/layout/src/lib.rs
··· 8 8 use we_css::values::Color; 9 9 use we_dom::{Document, NodeData, NodeId}; 10 10 use we_style::computed::{ 11 - BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextAlign, TextDecoration, 11 + BorderStyle, ComputedStyle, Display, LengthOrAuto, Position, StyledNode, TextAlign, 12 + TextDecoration, 12 13 }; 13 14 use we_text::font::Font; 14 15 ··· 88 89 pub line_height: f32, 89 90 /// For replaced elements (e.g., `<img>`): content dimensions (width, height). 90 91 pub replaced_size: Option<(f32, f32)>, 92 + /// CSS `position` property. 93 + pub position: Position, 94 + /// Relative position offset (dx, dy) applied after normal flow layout. 95 + pub relative_offset: (f32, f32), 91 96 } 92 97 93 98 impl LayoutBox { ··· 119 124 text_align: style.text_align, 120 125 line_height: style.line_height, 121 126 replaced_size: None, 127 + position: style.position, 128 + relative_offset: (0.0, 0.0), 122 129 } 123 130 } 124 131 ··· 182 189 } 183 190 } 184 191 192 + /// Resolve horizontal offset for `position: relative`. 193 + /// If both `left` and `right` are specified, `left` wins (CSS2 §9.4.3, ltr). 194 + fn resolve_relative_horizontal(left: LengthOrAuto, right: LengthOrAuto) -> f32 { 195 + match left { 196 + LengthOrAuto::Length(px) => px, 197 + LengthOrAuto::Auto => match right { 198 + LengthOrAuto::Length(px) => -px, 199 + LengthOrAuto::Auto => 0.0, 200 + }, 201 + } 202 + } 203 + 204 + /// Resolve vertical offset for `position: relative`. 205 + /// If both `top` and `bottom` are specified, `top` wins (CSS2 §9.4.3). 206 + fn resolve_relative_vertical(top: LengthOrAuto, bottom: LengthOrAuto) -> f32 { 207 + match top { 208 + LengthOrAuto::Length(px) => px, 209 + LengthOrAuto::Auto => match bottom { 210 + LengthOrAuto::Length(px) => -px, 211 + LengthOrAuto::Auto => 0.0, 212 + }, 213 + } 214 + } 215 + 185 216 // --------------------------------------------------------------------------- 186 217 // Build layout tree from styled tree 187 218 // --------------------------------------------------------------------------- ··· 280 311 b.replaced_size = Some((w, h)); 281 312 } 282 313 314 + // Compute relative position offset. 315 + if style.position == Position::Relative { 316 + let dx = resolve_relative_horizontal(style.left, style.right); 317 + let dy = resolve_relative_vertical(style.top, style.bottom); 318 + b.relative_offset = (dx, dy); 319 + } 320 + 283 321 Some(b) 284 322 } 285 323 NodeData::Text { data } => { ··· 397 435 // Content width is the minimum of replaced width and available width. 398 436 b.rect.width = rw.min(content_width); 399 437 b.rect.height = rh; 438 + apply_relative_offset(b); 400 439 return; 401 440 } 402 441 ··· 411 450 BoxType::TextRun { .. } | BoxType::Inline(_) => { 412 451 // Handled by the parent's inline layout. 413 452 } 453 + } 454 + 455 + apply_relative_offset(b); 456 + } 457 + 458 + /// Apply `position: relative` offset to a box and all its descendants. 459 + /// 460 + /// This shifts the visual position without affecting the normal-flow layout 461 + /// of surrounding elements (the original space is preserved). 462 + fn apply_relative_offset(b: &mut LayoutBox) { 463 + let (dx, dy) = b.relative_offset; 464 + if dx == 0.0 && dy == 0.0 { 465 + return; 466 + } 467 + shift_box(b, dx, dy); 468 + } 469 + 470 + /// Recursively shift a box and all its descendants by (dx, dy). 471 + fn shift_box(b: &mut LayoutBox, dx: f32, dy: f32) { 472 + b.rect.x += dx; 473 + b.rect.y += dy; 474 + for line in &mut b.lines { 475 + line.x += dx; 476 + line.y += dy; 477 + } 478 + for child in &mut b.children { 479 + shift_box(child, dx, dy); 414 480 } 415 481 } 416 482 ··· 1440 1506 "line spacing ({gap}) should be ~30px from line-height" 1441 1507 ); 1442 1508 } 1509 + } 1510 + 1511 + // --- Relative positioning tests --- 1512 + 1513 + #[test] 1514 + fn relative_position_top_left() { 1515 + let html_str = r#"<!DOCTYPE html> 1516 + <html> 1517 + <body> 1518 + <div style="position: relative; top: 10px; left: 20px;">Content</div> 1519 + </body> 1520 + </html>"#; 1521 + let doc = we_html::parse_html(html_str); 1522 + let font = test_font(); 1523 + let sheets = extract_stylesheets(&doc); 1524 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1525 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1526 + 1527 + let body_box = &tree.root.children[0]; 1528 + let div_box = &body_box.children[0]; 1529 + 1530 + assert_eq!(div_box.position, Position::Relative); 1531 + assert_eq!(div_box.relative_offset, (20.0, 10.0)); 1532 + 1533 + // The div should be shifted from where it would be in normal flow. 1534 + // Normal flow position: body.rect.x + margin, body.rect.y + margin. 1535 + // With relative offset: shifted by (20, 10). 1536 + // Body has 8px margin by default, so content starts at x=8, y=8. 1537 + assert!( 1538 + (div_box.rect.x - (8.0 + 20.0)).abs() < 0.01, 1539 + "div x ({}) should be 28.0 (8 + 20)", 1540 + div_box.rect.x 1541 + ); 1542 + assert!( 1543 + (div_box.rect.y - (8.0 + 10.0)).abs() < 0.01, 1544 + "div y ({}) should be 18.0 (8 + 10)", 1545 + div_box.rect.y 1546 + ); 1547 + } 1548 + 1549 + #[test] 1550 + fn relative_position_does_not_affect_siblings() { 1551 + let html_str = r#"<!DOCTYPE html> 1552 + <html> 1553 + <head><style> 1554 + p { margin: 0; } 1555 + </style></head> 1556 + <body> 1557 + <p id="first" style="position: relative; top: 50px;">First</p> 1558 + <p id="second">Second</p> 1559 + </body> 1560 + </html>"#; 1561 + let doc = we_html::parse_html(html_str); 1562 + let font = test_font(); 1563 + let sheets = extract_stylesheets(&doc); 1564 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1565 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1566 + 1567 + let body_box = &tree.root.children[0]; 1568 + let first = &body_box.children[0]; 1569 + let second = &body_box.children[1]; 1570 + 1571 + // The first paragraph is shifted down by 50px visually. 1572 + assert_eq!(first.relative_offset, (0.0, 50.0)); 1573 + 1574 + // But the second paragraph should be at its normal-flow position, 1575 + // as if the first paragraph were NOT shifted. The second paragraph 1576 + // should come right after the first's normal-flow height. 1577 + // Body content starts at y=8 (default body margin). First p has 0 margin. 1578 + // Second p should start right after first p's height (without offset). 1579 + let first_normal_y = 8.0; // body margin 1580 + let first_height = first.rect.height; 1581 + let expected_second_y = first_normal_y + first_height; 1582 + assert!( 1583 + (second.rect.y - expected_second_y).abs() < 1.0, 1584 + "second y ({}) should be at normal-flow position ({expected_second_y}), not affected by first's relative offset", 1585 + second.rect.y 1586 + ); 1587 + } 1588 + 1589 + #[test] 1590 + fn relative_position_conflicting_offsets() { 1591 + // When both top and bottom are specified, top wins. 1592 + // When both left and right are specified, left wins. 1593 + let html_str = r#"<!DOCTYPE html> 1594 + <html> 1595 + <body> 1596 + <div style="position: relative; top: 10px; bottom: 20px; left: 30px; right: 40px;">Content</div> 1597 + </body> 1598 + </html>"#; 1599 + let doc = we_html::parse_html(html_str); 1600 + let font = test_font(); 1601 + let sheets = extract_stylesheets(&doc); 1602 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1603 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1604 + 1605 + let body_box = &tree.root.children[0]; 1606 + let div_box = &body_box.children[0]; 1607 + 1608 + // top wins over bottom: dy = 10 (not -20) 1609 + // left wins over right: dx = 30 (not -40) 1610 + assert_eq!(div_box.relative_offset, (30.0, 10.0)); 1611 + } 1612 + 1613 + #[test] 1614 + fn relative_position_auto_offsets() { 1615 + // auto offsets should resolve to 0 (no movement). 1616 + let html_str = r#"<!DOCTYPE html> 1617 + <html> 1618 + <body> 1619 + <div style="position: relative;">Content</div> 1620 + </body> 1621 + </html>"#; 1622 + let doc = we_html::parse_html(html_str); 1623 + let font = test_font(); 1624 + let sheets = extract_stylesheets(&doc); 1625 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1626 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1627 + 1628 + let body_box = &tree.root.children[0]; 1629 + let div_box = &body_box.children[0]; 1630 + 1631 + assert_eq!(div_box.position, Position::Relative); 1632 + assert_eq!(div_box.relative_offset, (0.0, 0.0)); 1633 + } 1634 + 1635 + #[test] 1636 + fn relative_position_bottom_right() { 1637 + // bottom: 15px should shift up by 15px (negative direction). 1638 + // right: 25px should shift left by 25px (negative direction). 1639 + let html_str = r#"<!DOCTYPE html> 1640 + <html> 1641 + <body> 1642 + <div style="position: relative; bottom: 15px; right: 25px;">Content</div> 1643 + </body> 1644 + </html>"#; 1645 + let doc = we_html::parse_html(html_str); 1646 + let font = test_font(); 1647 + let sheets = extract_stylesheets(&doc); 1648 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1649 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1650 + 1651 + let body_box = &tree.root.children[0]; 1652 + let div_box = &body_box.children[0]; 1653 + 1654 + assert_eq!(div_box.relative_offset, (-25.0, -15.0)); 1655 + } 1656 + 1657 + #[test] 1658 + fn relative_position_shifts_text_lines() { 1659 + let html_str = r#"<!DOCTYPE html> 1660 + <html> 1661 + <head><style>p { margin: 0; }</style></head> 1662 + <body> 1663 + <p style="position: relative; top: 30px; left: 40px;">Hello</p> 1664 + </body> 1665 + </html>"#; 1666 + let doc = we_html::parse_html(html_str); 1667 + let font = test_font(); 1668 + let sheets = extract_stylesheets(&doc); 1669 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1670 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1671 + 1672 + let body_box = &tree.root.children[0]; 1673 + let p_box = &body_box.children[0]; 1674 + 1675 + assert!(!p_box.lines.is_empty(), "p should have text lines"); 1676 + let first_line = &p_box.lines[0]; 1677 + 1678 + // Text should be shifted by the relative offset. 1679 + // Body content starts at x=8, y=8. With offset: x=48, y=38. 1680 + assert!( 1681 + first_line.x >= 8.0 + 40.0 - 1.0, 1682 + "text x ({}) should be shifted by left offset", 1683 + first_line.x 1684 + ); 1685 + assert!( 1686 + first_line.y >= 8.0 + 30.0 - 1.0, 1687 + "text y ({}) should be shifted by top offset", 1688 + first_line.y 1689 + ); 1690 + } 1691 + 1692 + #[test] 1693 + fn static_position_has_no_offset() { 1694 + let html_str = r#"<!DOCTYPE html> 1695 + <html> 1696 + <body> 1697 + <div>Normal flow</div> 1698 + </body> 1699 + </html>"#; 1700 + let doc = we_html::parse_html(html_str); 1701 + let font = test_font(); 1702 + let sheets = extract_stylesheets(&doc); 1703 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1704 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1705 + 1706 + let body_box = &tree.root.children[0]; 1707 + let div_box = &body_box.children[0]; 1708 + 1709 + assert_eq!(div_box.position, Position::Static); 1710 + assert_eq!(div_box.relative_offset, (0.0, 0.0)); 1443 1711 } 1444 1712 }