we (web engine): Experimental web browser project to understand the limits of Claude
at sticky-positioning 4743 lines 164 kB view raw
1//! Block layout engine: box generation, block/inline layout, and text wrapping. 2//! 3//! Builds a layout tree from a styled tree (DOM + computed styles) and positions 4//! block-level elements vertically with proper inline formatting context. 5 6use std::collections::HashMap; 7 8use we_css::values::Color; 9use we_dom::{Document, NodeData, NodeId}; 10use we_style::computed::{ 11 AlignContent, AlignItems, AlignSelf, BorderStyle, BoxSizing, ComputedStyle, Display, 12 FlexDirection, FlexWrap, JustifyContent, LengthOrAuto, Overflow, Position, StyledNode, 13 TextAlign, TextDecoration, Visibility, 14}; 15use we_text::font::Font; 16 17/// Width of scroll bars in pixels. 18pub const SCROLLBAR_WIDTH: f32 = 15.0; 19 20/// Edge sizes for box model (margin, padding, border). 21#[derive(Debug, Clone, Copy, Default, PartialEq)] 22pub struct EdgeSizes { 23 pub top: f32, 24 pub right: f32, 25 pub bottom: f32, 26 pub left: f32, 27} 28 29/// A positioned rectangle with content area dimensions. 30#[derive(Debug, Clone, Copy, Default, PartialEq)] 31pub struct Rect { 32 pub x: f32, 33 pub y: f32, 34 pub width: f32, 35 pub height: f32, 36} 37 38/// The type of layout box. 39#[derive(Debug)] 40pub enum BoxType { 41 /// Block-level box from an element. 42 Block(NodeId), 43 /// Inline-level box from an element. 44 Inline(NodeId), 45 /// A run of text from a text node. 46 TextRun { node: NodeId, text: String }, 47 /// Anonymous block wrapping inline content within a block container. 48 Anonymous, 49} 50 51/// A single positioned text fragment with its own styling. 52/// 53/// Multiple fragments can share the same y-coordinate when they are 54/// on the same visual line (e.g. `<p>Hello <em>world</em></p>` produces 55/// two fragments at the same y). 56#[derive(Debug, Clone, PartialEq)] 57pub struct TextLine { 58 pub text: String, 59 pub x: f32, 60 pub y: f32, 61 pub width: f32, 62 pub font_size: f32, 63 pub color: Color, 64 pub text_decoration: TextDecoration, 65 pub background_color: Color, 66} 67 68/// A box in the layout tree with dimensions and child boxes. 69#[derive(Debug)] 70pub struct LayoutBox { 71 pub box_type: BoxType, 72 pub display: Display, 73 pub rect: Rect, 74 pub margin: EdgeSizes, 75 pub padding: EdgeSizes, 76 pub border: EdgeSizes, 77 pub children: Vec<LayoutBox>, 78 pub font_size: f32, 79 /// Positioned text fragments (populated for boxes with inline content). 80 pub lines: Vec<TextLine>, 81 /// Text color. 82 pub color: Color, 83 /// Background color. 84 pub background_color: Color, 85 /// Text decoration (underline, etc.). 86 pub text_decoration: TextDecoration, 87 /// Border styles (top, right, bottom, left). 88 pub border_styles: [BorderStyle; 4], 89 /// Border colors (top, right, bottom, left). 90 pub border_colors: [Color; 4], 91 /// Text alignment for this box's inline content. 92 pub text_align: TextAlign, 93 /// Computed line height in px. 94 pub line_height: f32, 95 /// For replaced elements (e.g., `<img>`): content dimensions (width, height). 96 pub replaced_size: Option<(f32, f32)>, 97 /// CSS `position` property. 98 pub position: Position, 99 /// CSS `z-index` property (None = auto). 100 pub z_index: Option<i32>, 101 /// Relative position offset (dx, dy) applied after normal flow layout. 102 pub relative_offset: (f32, f32), 103 /// CSS `overflow` property. 104 pub overflow: Overflow, 105 /// CSS `box-sizing` property. 106 pub box_sizing: BoxSizing, 107 /// CSS `width` property (explicit or auto, may contain percentage). 108 pub css_width: LengthOrAuto, 109 /// CSS `height` property (explicit or auto, may contain percentage). 110 pub css_height: LengthOrAuto, 111 /// CSS margin values (may contain percentages for layout resolution). 112 pub css_margin: [LengthOrAuto; 4], 113 /// CSS padding values (may contain percentages for layout resolution). 114 pub css_padding: [LengthOrAuto; 4], 115 /// CSS position offset values (top, right, bottom, left) for relative/sticky positioning. 116 pub css_offsets: [LengthOrAuto; 4], 117 /// For `position: sticky`: the containing block's content rect in document 118 /// coordinates. Paint-time logic clamps the element within this rectangle. 119 pub sticky_constraint: Option<Rect>, 120 /// CSS `visibility` property. 121 pub visibility: Visibility, 122 /// Natural content height before CSS height override. 123 /// Used to determine overflow for scroll containers. 124 pub content_height: f32, 125 // Flex container properties 126 pub flex_direction: FlexDirection, 127 pub flex_wrap: FlexWrap, 128 pub justify_content: JustifyContent, 129 pub align_items: AlignItems, 130 pub align_content: AlignContent, 131 pub row_gap: f32, 132 pub column_gap: f32, 133 // Flex item properties 134 pub flex_grow: f32, 135 pub flex_shrink: f32, 136 pub flex_basis: LengthOrAuto, 137 pub align_self: AlignSelf, 138 pub order: i32, 139} 140 141impl LayoutBox { 142 fn new(box_type: BoxType, style: &ComputedStyle) -> Self { 143 LayoutBox { 144 box_type, 145 display: style.display, 146 rect: Rect::default(), 147 margin: EdgeSizes::default(), 148 padding: EdgeSizes::default(), 149 border: EdgeSizes::default(), 150 children: Vec::new(), 151 font_size: style.font_size, 152 lines: Vec::new(), 153 color: style.color, 154 background_color: style.background_color, 155 text_decoration: style.text_decoration, 156 border_styles: [ 157 style.border_top_style, 158 style.border_right_style, 159 style.border_bottom_style, 160 style.border_left_style, 161 ], 162 border_colors: [ 163 style.border_top_color, 164 style.border_right_color, 165 style.border_bottom_color, 166 style.border_left_color, 167 ], 168 text_align: style.text_align, 169 line_height: style.line_height, 170 replaced_size: None, 171 position: style.position, 172 z_index: style.z_index, 173 relative_offset: (0.0, 0.0), 174 overflow: style.overflow, 175 box_sizing: style.box_sizing, 176 css_width: style.width, 177 css_height: style.height, 178 css_margin: [ 179 style.margin_top, 180 style.margin_right, 181 style.margin_bottom, 182 style.margin_left, 183 ], 184 css_padding: [ 185 style.padding_top, 186 style.padding_right, 187 style.padding_bottom, 188 style.padding_left, 189 ], 190 css_offsets: [style.top, style.right, style.bottom, style.left], 191 sticky_constraint: None, 192 visibility: style.visibility, 193 content_height: 0.0, 194 flex_direction: style.flex_direction, 195 flex_wrap: style.flex_wrap, 196 justify_content: style.justify_content, 197 align_items: style.align_items, 198 align_content: style.align_content, 199 row_gap: style.row_gap, 200 column_gap: style.column_gap, 201 flex_grow: style.flex_grow, 202 flex_shrink: style.flex_shrink, 203 flex_basis: style.flex_basis, 204 align_self: style.align_self, 205 order: style.order, 206 } 207 } 208 209 /// Total height including margin, border, and padding. 210 pub fn margin_box_height(&self) -> f32 { 211 self.margin.top 212 + self.border.top 213 + self.padding.top 214 + self.rect.height 215 + self.padding.bottom 216 + self.border.bottom 217 + self.margin.bottom 218 } 219 220 /// Iterate over all boxes in depth-first pre-order. 221 pub fn iter(&self) -> LayoutBoxIter<'_> { 222 LayoutBoxIter { stack: vec![self] } 223 } 224} 225 226/// Depth-first pre-order iterator over layout boxes. 227pub struct LayoutBoxIter<'a> { 228 stack: Vec<&'a LayoutBox>, 229} 230 231impl<'a> Iterator for LayoutBoxIter<'a> { 232 type Item = &'a LayoutBox; 233 234 fn next(&mut self) -> Option<&'a LayoutBox> { 235 let node = self.stack.pop()?; 236 for child in node.children.iter().rev() { 237 self.stack.push(child); 238 } 239 Some(node) 240 } 241} 242 243/// The result of laying out a document. 244#[derive(Debug)] 245pub struct LayoutTree { 246 pub root: LayoutBox, 247 pub width: f32, 248 pub height: f32, 249} 250 251impl LayoutTree { 252 /// Iterate over all layout boxes in depth-first pre-order. 253 pub fn iter(&self) -> LayoutBoxIter<'_> { 254 self.root.iter() 255 } 256} 257 258// --------------------------------------------------------------------------- 259// Resolve LengthOrAuto to f32 260// --------------------------------------------------------------------------- 261 262/// Resolve a `LengthOrAuto` to px. Percentages are resolved against 263/// `reference` (typically the containing block width). Auto resolves to 0. 264fn resolve_length_against(value: LengthOrAuto, reference: f32) -> f32 { 265 match value { 266 LengthOrAuto::Length(px) => px, 267 LengthOrAuto::Percentage(p) => p / 100.0 * reference, 268 LengthOrAuto::Auto => 0.0, 269 } 270} 271 272/// Resolve horizontal offset for `position: relative`. 273/// If both `left` and `right` are specified, `left` wins (CSS2 §9.4.3, ltr). 274fn resolve_relative_horizontal(left: LengthOrAuto, right: LengthOrAuto, cb_width: f32) -> f32 { 275 match left { 276 LengthOrAuto::Length(px) => px, 277 LengthOrAuto::Percentage(p) => p / 100.0 * cb_width, 278 LengthOrAuto::Auto => match right { 279 LengthOrAuto::Length(px) => -px, 280 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_width), 281 LengthOrAuto::Auto => 0.0, 282 }, 283 } 284} 285 286/// Resolve vertical offset for `position: relative`. 287/// If both `top` and `bottom` are specified, `top` wins (CSS2 §9.4.3). 288fn resolve_relative_vertical(top: LengthOrAuto, bottom: LengthOrAuto, cb_height: f32) -> f32 { 289 match top { 290 LengthOrAuto::Length(px) => px, 291 LengthOrAuto::Percentage(p) => p / 100.0 * cb_height, 292 LengthOrAuto::Auto => match bottom { 293 LengthOrAuto::Length(px) => -px, 294 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_height), 295 LengthOrAuto::Auto => 0.0, 296 }, 297 } 298} 299 300// --------------------------------------------------------------------------- 301// Build layout tree from styled tree 302// --------------------------------------------------------------------------- 303 304fn build_box( 305 styled: &StyledNode, 306 doc: &Document, 307 image_sizes: &HashMap<NodeId, (f32, f32)>, 308) -> Option<LayoutBox> { 309 let node = styled.node; 310 let style = &styled.style; 311 312 match doc.node_data(node) { 313 NodeData::Document => { 314 let mut children = Vec::new(); 315 for child in &styled.children { 316 if let Some(child_box) = build_box(child, doc, image_sizes) { 317 children.push(child_box); 318 } 319 } 320 if children.len() == 1 { 321 children.into_iter().next() 322 } else if children.is_empty() { 323 None 324 } else { 325 let mut b = LayoutBox::new(BoxType::Anonymous, style); 326 b.children = children; 327 Some(b) 328 } 329 } 330 NodeData::Element { .. } => { 331 if style.display == Display::None { 332 return None; 333 } 334 335 // Margin and padding: resolve absolute lengths now; percentages 336 // will be re-resolved in compute_layout against containing block. 337 // Use 0.0 as a placeholder reference for percentages — they'll be 338 // resolved properly in compute_layout. 339 let margin = EdgeSizes { 340 top: resolve_length_against(style.margin_top, 0.0), 341 right: resolve_length_against(style.margin_right, 0.0), 342 bottom: resolve_length_against(style.margin_bottom, 0.0), 343 left: resolve_length_against(style.margin_left, 0.0), 344 }; 345 let padding = EdgeSizes { 346 top: resolve_length_against(style.padding_top, 0.0), 347 right: resolve_length_against(style.padding_right, 0.0), 348 bottom: resolve_length_against(style.padding_bottom, 0.0), 349 left: resolve_length_against(style.padding_left, 0.0), 350 }; 351 let border = EdgeSizes { 352 top: if style.border_top_style != BorderStyle::None { 353 style.border_top_width 354 } else { 355 0.0 356 }, 357 right: if style.border_right_style != BorderStyle::None { 358 style.border_right_width 359 } else { 360 0.0 361 }, 362 bottom: if style.border_bottom_style != BorderStyle::None { 363 style.border_bottom_width 364 } else { 365 0.0 366 }, 367 left: if style.border_left_style != BorderStyle::None { 368 style.border_left_width 369 } else { 370 0.0 371 }, 372 }; 373 374 let mut children = Vec::new(); 375 for child in &styled.children { 376 if let Some(child_box) = build_box(child, doc, image_sizes) { 377 children.push(child_box); 378 } 379 } 380 381 let box_type = match style.display { 382 Display::Block | Display::Flex | Display::InlineFlex => BoxType::Block(node), 383 Display::Inline => BoxType::Inline(node), 384 Display::None => unreachable!(), 385 }; 386 387 if style.display == Display::Block { 388 children = normalize_children(children, style); 389 } 390 391 let mut b = LayoutBox::new(box_type, style); 392 b.margin = margin; 393 b.padding = padding; 394 b.border = border; 395 b.children = children; 396 397 // Check for replaced element (e.g., <img>). 398 if let Some(&(w, h)) = image_sizes.get(&node) { 399 b.replaced_size = Some((w, h)); 400 } 401 402 // Relative position offsets are resolved in compute_layout 403 // where the containing block dimensions are known. 404 405 Some(b) 406 } 407 NodeData::Text { data } => { 408 let collapsed = collapse_whitespace(data); 409 if collapsed.is_empty() { 410 return None; 411 } 412 Some(LayoutBox::new( 413 BoxType::TextRun { 414 node, 415 text: collapsed, 416 }, 417 style, 418 )) 419 } 420 NodeData::Comment { .. } => None, 421 } 422} 423 424/// Collapse runs of whitespace to a single space. 425fn collapse_whitespace(s: &str) -> String { 426 let mut result = String::new(); 427 let mut in_ws = false; 428 for ch in s.chars() { 429 if ch.is_whitespace() { 430 if !in_ws { 431 result.push(' '); 432 } 433 in_ws = true; 434 } else { 435 in_ws = false; 436 result.push(ch); 437 } 438 } 439 result 440} 441 442/// If a block container has a mix of block-level and inline-level children, 443/// wrap consecutive inline runs in anonymous block boxes. 444/// Out-of-flow elements (absolute/fixed) are excluded from the determination 445/// and placed at the top level without affecting normalization. 446fn normalize_children(children: Vec<LayoutBox>, parent_style: &ComputedStyle) -> Vec<LayoutBox> { 447 if children.is_empty() { 448 return children; 449 } 450 451 // Only consider in-flow children for block/inline normalization. 452 let has_block = children.iter().any(|c| is_in_flow(c) && is_block_level(c)); 453 if !has_block { 454 return children; 455 } 456 457 let has_inline = children.iter().any(|c| is_in_flow(c) && !is_block_level(c)); 458 if !has_inline { 459 return children; 460 } 461 462 let mut result = Vec::new(); 463 let mut inline_group: Vec<LayoutBox> = Vec::new(); 464 465 for child in children { 466 if !is_in_flow(&child) || is_block_level(&child) { 467 // Out-of-flow or block-level: flush any pending inline group first. 468 if !inline_group.is_empty() { 469 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 470 anon.children = std::mem::take(&mut inline_group); 471 result.push(anon); 472 } 473 result.push(child); 474 } else { 475 inline_group.push(child); 476 } 477 } 478 479 if !inline_group.is_empty() { 480 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 481 anon.children = inline_group; 482 result.push(anon); 483 } 484 485 result 486} 487 488fn is_block_level(b: &LayoutBox) -> bool { 489 matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) 490} 491 492/// Returns `true` if this box is in normal flow (not absolutely or fixed positioned). 493fn is_in_flow(b: &LayoutBox) -> bool { 494 b.position != Position::Absolute && b.position != Position::Fixed 495} 496 497// --------------------------------------------------------------------------- 498// Layout algorithm 499// --------------------------------------------------------------------------- 500 501/// Position and size a layout box within `available_width` at position (`x`, `y`). 502/// 503/// `available_width` is the containing block width — used as the reference for 504/// percentage widths, margins, and paddings (per CSS spec, even vertical margins/ 505/// padding resolve against the containing block width). 506/// 507/// `abs_cb` is the padding box of the nearest positioned ancestor, used as the 508/// containing block for absolutely positioned descendants. 509#[allow(clippy::too_many_arguments)] 510fn compute_layout( 511 b: &mut LayoutBox, 512 x: f32, 513 y: f32, 514 available_width: f32, 515 viewport_width: f32, 516 viewport_height: f32, 517 font: &Font, 518 doc: &Document, 519 abs_cb: Rect, 520) { 521 // Resolve percentage margins against containing block width. 522 // Only re-resolve percentages — absolute margins may have been modified 523 // by margin collapsing and must not be overwritten. 524 if matches!(b.css_margin[0], LengthOrAuto::Percentage(_)) { 525 b.margin.top = resolve_length_against(b.css_margin[0], available_width); 526 } 527 if matches!(b.css_margin[1], LengthOrAuto::Percentage(_)) { 528 b.margin.right = resolve_length_against(b.css_margin[1], available_width); 529 } 530 if matches!(b.css_margin[2], LengthOrAuto::Percentage(_)) { 531 b.margin.bottom = resolve_length_against(b.css_margin[2], available_width); 532 } 533 if matches!(b.css_margin[3], LengthOrAuto::Percentage(_)) { 534 b.margin.left = resolve_length_against(b.css_margin[3], available_width); 535 } 536 537 // Resolve percentage padding against containing block width. 538 if matches!(b.css_padding[0], LengthOrAuto::Percentage(_)) { 539 b.padding.top = resolve_length_against(b.css_padding[0], available_width); 540 } 541 if matches!(b.css_padding[1], LengthOrAuto::Percentage(_)) { 542 b.padding.right = resolve_length_against(b.css_padding[1], available_width); 543 } 544 if matches!(b.css_padding[2], LengthOrAuto::Percentage(_)) { 545 b.padding.bottom = resolve_length_against(b.css_padding[2], available_width); 546 } 547 if matches!(b.css_padding[3], LengthOrAuto::Percentage(_)) { 548 b.padding.left = resolve_length_against(b.css_padding[3], available_width); 549 } 550 551 let content_x = x + b.margin.left + b.border.left + b.padding.left; 552 let content_y = y + b.margin.top + b.border.top + b.padding.top; 553 554 let horizontal_extra = b.border.left + b.border.right + b.padding.left + b.padding.right; 555 556 // Resolve content width: explicit CSS width (adjusted for box-sizing) or auto. 557 let content_width = match b.css_width { 558 LengthOrAuto::Length(w) => match b.box_sizing { 559 BoxSizing::ContentBox => w.max(0.0), 560 BoxSizing::BorderBox => (w - horizontal_extra).max(0.0), 561 }, 562 LengthOrAuto::Percentage(p) => { 563 let resolved = p / 100.0 * available_width; 564 match b.box_sizing { 565 BoxSizing::ContentBox => resolved.max(0.0), 566 BoxSizing::BorderBox => (resolved - horizontal_extra).max(0.0), 567 } 568 } 569 LengthOrAuto::Auto => { 570 (available_width - b.margin.left - b.margin.right - horizontal_extra).max(0.0) 571 } 572 }; 573 574 b.rect.x = content_x; 575 b.rect.y = content_y; 576 b.rect.width = content_width; 577 578 // For overflow:scroll, reserve space for vertical scrollbar. 579 if b.overflow == Overflow::Scroll { 580 b.rect.width = (b.rect.width - SCROLLBAR_WIDTH).max(0.0); 581 } 582 583 // Replaced elements (e.g., <img>) have intrinsic dimensions. 584 if let Some((rw, rh)) = b.replaced_size { 585 b.rect.width = rw.min(b.rect.width); 586 b.rect.height = rh; 587 set_sticky_constraints(b); 588 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); 589 apply_relative_offset(b, available_width, viewport_height); 590 return; 591 } 592 593 match &b.box_type { 594 BoxType::Block(_) | BoxType::Anonymous => { 595 if matches!(b.display, Display::Flex | Display::InlineFlex) { 596 layout_flex_children(b, viewport_width, viewport_height, font, doc, abs_cb); 597 } else if has_block_children(b) { 598 layout_block_children(b, viewport_width, viewport_height, font, doc, abs_cb); 599 } else { 600 layout_inline_children(b, font, doc); 601 } 602 } 603 BoxType::TextRun { .. } | BoxType::Inline(_) => { 604 // Handled by the parent's inline layout. 605 } 606 } 607 608 // Save the natural content height before CSS height override. 609 b.content_height = b.rect.height; 610 611 // Apply explicit CSS height (adjusted for box-sizing), overriding auto height. 612 match b.css_height { 613 LengthOrAuto::Length(h) => { 614 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 615 b.rect.height = match b.box_sizing { 616 BoxSizing::ContentBox => h.max(0.0), 617 BoxSizing::BorderBox => (h - vertical_extra).max(0.0), 618 }; 619 } 620 LengthOrAuto::Percentage(p) => { 621 // Height percentage resolves against containing block height. 622 // For the root element, use viewport height. 623 let cb_height = viewport_height; 624 let resolved = p / 100.0 * cb_height; 625 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 626 b.rect.height = match b.box_sizing { 627 BoxSizing::ContentBox => resolved.max(0.0), 628 BoxSizing::BorderBox => (resolved - vertical_extra).max(0.0), 629 }; 630 } 631 LengthOrAuto::Auto => {} 632 } 633 634 // Set sticky constraint rects now that this box's dimensions are final. 635 set_sticky_constraints(b); 636 637 // Layout absolutely and fixed positioned children after this box's 638 // dimensions are fully resolved. 639 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); 640 641 apply_relative_offset(b, available_width, viewport_height); 642} 643 644/// For each direct child with `position: sticky`, record the parent's content 645/// rect as the sticky constraint rectangle. This is called after the parent's 646/// dimensions are fully resolved so that the rect is accurate. 647fn set_sticky_constraints(parent: &mut LayoutBox) { 648 let content_rect = parent.rect; 649 for child in &mut parent.children { 650 if child.position == Position::Sticky { 651 child.sticky_constraint = Some(content_rect); 652 } 653 } 654} 655 656/// Apply `position: relative` offset to a box and all its descendants. 657/// 658/// Resolves the CSS position offsets (which may contain percentages) and 659/// shifts the visual position without affecting the normal-flow layout. 660fn apply_relative_offset(b: &mut LayoutBox, cb_width: f32, cb_height: f32) { 661 if b.position != Position::Relative { 662 return; 663 } 664 let [top, right, bottom, left] = b.css_offsets; 665 let dx = resolve_relative_horizontal(left, right, cb_width); 666 let dy = resolve_relative_vertical(top, bottom, cb_height); 667 b.relative_offset = (dx, dy); 668 if dx == 0.0 && dy == 0.0 { 669 return; 670 } 671 shift_box(b, dx, dy); 672} 673 674/// Recursively shift a box and all its descendants by (dx, dy). 675fn shift_box(b: &mut LayoutBox, dx: f32, dy: f32) { 676 b.rect.x += dx; 677 b.rect.y += dy; 678 for line in &mut b.lines { 679 line.x += dx; 680 line.y += dy; 681 } 682 for child in &mut b.children { 683 shift_box(child, dx, dy); 684 } 685} 686 687// --------------------------------------------------------------------------- 688// Absolute / fixed positioning 689// --------------------------------------------------------------------------- 690 691/// Resolve a `LengthOrAuto` offset to an optional pixel value. 692/// Returns `None` for `Auto` (offset not specified). 693fn resolve_offset(value: LengthOrAuto, reference: f32) -> Option<f32> { 694 match value { 695 LengthOrAuto::Length(px) => Some(px), 696 LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), 697 LengthOrAuto::Auto => None, 698 } 699} 700 701/// Compute the padding box rectangle for a layout box. 702fn padding_box_rect(b: &LayoutBox) -> Rect { 703 Rect { 704 x: b.rect.x - b.padding.left, 705 y: b.rect.y - b.padding.top, 706 width: b.rect.width + b.padding.left + b.padding.right, 707 height: b.rect.height + b.padding.top + b.padding.bottom, 708 } 709} 710 711/// Lay out all absolutely and fixed positioned children of `parent`. 712/// 713/// `abs_cb` is the padding box of the nearest positioned ancestor passed from 714/// further up the tree. If `parent` itself is positioned (not `static`), its 715/// padding box becomes the new containing block for absolute descendants. 716fn layout_abspos_children( 717 parent: &mut LayoutBox, 718 abs_cb: Rect, 719 viewport_width: f32, 720 viewport_height: f32, 721 font: &Font, 722 doc: &Document, 723) { 724 let viewport_cb = Rect { 725 x: 0.0, 726 y: 0.0, 727 width: viewport_width, 728 height: viewport_height, 729 }; 730 731 // If this box is positioned, it becomes the containing block for absolute 732 // descendants. 733 let new_abs_cb = if parent.position != Position::Static { 734 padding_box_rect(parent) 735 } else { 736 abs_cb 737 }; 738 739 for i in 0..parent.children.len() { 740 if parent.children[i].position == Position::Absolute { 741 layout_absolute_child( 742 &mut parent.children[i], 743 new_abs_cb, 744 viewport_width, 745 viewport_height, 746 font, 747 doc, 748 ); 749 } else if parent.children[i].position == Position::Fixed { 750 layout_absolute_child( 751 &mut parent.children[i], 752 viewport_cb, 753 viewport_width, 754 viewport_height, 755 font, 756 doc, 757 ); 758 } 759 } 760} 761 762/// Lay out a single absolutely or fixed positioned element. 763/// 764/// `cb` is the containing block (padding box of nearest positioned ancestor 765/// for absolute, or the viewport for fixed). 766fn layout_absolute_child( 767 child: &mut LayoutBox, 768 cb: Rect, 769 viewport_width: f32, 770 viewport_height: f32, 771 font: &Font, 772 doc: &Document, 773) { 774 let [css_top, css_right, css_bottom, css_left] = child.css_offsets; 775 776 // Resolve margins against containing block width. 777 child.margin = EdgeSizes { 778 top: resolve_length_against(child.css_margin[0], cb.width), 779 right: resolve_length_against(child.css_margin[1], cb.width), 780 bottom: resolve_length_against(child.css_margin[2], cb.width), 781 left: resolve_length_against(child.css_margin[3], cb.width), 782 }; 783 784 // Resolve padding against containing block width. 785 child.padding = EdgeSizes { 786 top: resolve_length_against(child.css_padding[0], cb.width), 787 right: resolve_length_against(child.css_padding[1], cb.width), 788 bottom: resolve_length_against(child.css_padding[2], cb.width), 789 left: resolve_length_against(child.css_padding[3], cb.width), 790 }; 791 792 let horiz_extra = 793 child.border.left + child.border.right + child.padding.left + child.padding.right; 794 let vert_extra = 795 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom; 796 797 // Resolve offsets. 798 let left_offset = resolve_offset(css_left, cb.width); 799 let right_offset = resolve_offset(css_right, cb.width); 800 let top_offset = resolve_offset(css_top, cb.height); 801 let bottom_offset = resolve_offset(css_bottom, cb.height); 802 803 // --- Resolve content width --- 804 let content_width = match child.css_width { 805 LengthOrAuto::Length(w) => match child.box_sizing { 806 BoxSizing::ContentBox => w.max(0.0), 807 BoxSizing::BorderBox => (w - horiz_extra).max(0.0), 808 }, 809 LengthOrAuto::Percentage(p) => { 810 let resolved = p / 100.0 * cb.width; 811 match child.box_sizing { 812 BoxSizing::ContentBox => resolved.max(0.0), 813 BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0), 814 } 815 } 816 LengthOrAuto::Auto => { 817 // If both left and right are specified, stretch to fill. 818 if let (Some(l), Some(r)) = (left_offset, right_offset) { 819 (cb.width - l - r - child.margin.left - child.margin.right - horiz_extra).max(0.0) 820 } else { 821 // Use available width minus margins. 822 (cb.width - child.margin.left - child.margin.right - horiz_extra).max(0.0) 823 } 824 } 825 }; 826 827 child.rect.width = content_width; 828 829 // --- Resolve horizontal position --- 830 child.rect.x = if let Some(l) = left_offset { 831 cb.x + l + child.margin.left + child.border.left + child.padding.left 832 } else if let Some(r) = right_offset { 833 cb.x + cb.width 834 - r 835 - child.margin.right 836 - child.border.right 837 - child.padding.right 838 - content_width 839 } else { 840 // Static position: containing block origin. 841 cb.x + child.margin.left + child.border.left + child.padding.left 842 }; 843 844 // Set a temporary y for child content layout. 845 child.rect.y = cb.y; 846 847 // For overflow:scroll, reserve scrollbar width. 848 if child.overflow == Overflow::Scroll { 849 child.rect.width = (child.rect.width - SCROLLBAR_WIDTH).max(0.0); 850 } 851 852 // --- Layout child's own content --- 853 if let Some((rw, rh)) = child.replaced_size { 854 child.rect.width = rw.min(child.rect.width); 855 child.rect.height = rh; 856 } else { 857 let child_abs_cb = if child.position != Position::Static { 858 padding_box_rect(child) 859 } else { 860 cb 861 }; 862 match &child.box_type { 863 BoxType::Block(_) | BoxType::Anonymous => { 864 if matches!(child.display, Display::Flex | Display::InlineFlex) { 865 layout_flex_children( 866 child, 867 viewport_width, 868 viewport_height, 869 font, 870 doc, 871 child_abs_cb, 872 ); 873 } else if has_block_children(child) { 874 layout_block_children( 875 child, 876 viewport_width, 877 viewport_height, 878 font, 879 doc, 880 child_abs_cb, 881 ); 882 } else { 883 layout_inline_children(child, font, doc); 884 } 885 } 886 _ => {} 887 } 888 } 889 890 // Save natural content height. 891 child.content_height = child.rect.height; 892 893 // --- Resolve content height --- 894 match child.css_height { 895 LengthOrAuto::Length(h) => { 896 child.rect.height = match child.box_sizing { 897 BoxSizing::ContentBox => h.max(0.0), 898 BoxSizing::BorderBox => (h - vert_extra).max(0.0), 899 }; 900 } 901 LengthOrAuto::Percentage(p) => { 902 let resolved = p / 100.0 * cb.height; 903 child.rect.height = match child.box_sizing { 904 BoxSizing::ContentBox => resolved.max(0.0), 905 BoxSizing::BorderBox => (resolved - vert_extra).max(0.0), 906 }; 907 } 908 LengthOrAuto::Auto => { 909 // If both top and bottom are specified, stretch. 910 if let (Some(t), Some(b_val)) = (top_offset, bottom_offset) { 911 child.rect.height = 912 (cb.height - t - b_val - child.margin.top - child.margin.bottom - vert_extra) 913 .max(0.0); 914 } 915 // Otherwise keep content height. 916 } 917 } 918 919 // --- Resolve vertical position --- 920 let final_y = if let Some(t) = top_offset { 921 cb.y + t + child.margin.top + child.border.top + child.padding.top 922 } else if let Some(b_val) = bottom_offset { 923 cb.y + cb.height 924 - b_val 925 - child.margin.bottom 926 - child.border.bottom 927 - child.padding.bottom 928 - child.rect.height 929 } else { 930 // Static position: containing block origin. 931 cb.y + child.margin.top + child.border.top + child.padding.top 932 }; 933 934 // Shift from temporary y to final y. 935 let dy = final_y - child.rect.y; 936 if dy != 0.0 { 937 shift_box(child, 0.0, dy); 938 } 939 940 // Set sticky constraints now that this child's dimensions are final. 941 set_sticky_constraints(child); 942 943 // Recursively lay out any absolutely positioned grandchildren. 944 let new_abs_cb = if child.position != Position::Static { 945 padding_box_rect(child) 946 } else { 947 cb 948 }; 949 layout_abspos_children( 950 child, 951 new_abs_cb, 952 viewport_width, 953 viewport_height, 954 font, 955 doc, 956 ); 957} 958 959fn has_block_children(b: &LayoutBox) -> bool { 960 b.children 961 .iter() 962 .any(|c| is_in_flow(c) && is_block_level(c)) 963} 964 965/// Collapse two adjoining margins per CSS2 §8.3.1. 966/// 967/// Both non-negative → use the larger. 968/// Both negative → use the more negative. 969/// Mixed → sum the largest positive and most negative. 970fn collapse_margins(a: f32, b: f32) -> f32 { 971 if a >= 0.0 && b >= 0.0 { 972 a.max(b) 973 } else if a < 0.0 && b < 0.0 { 974 a.min(b) 975 } else { 976 a + b 977 } 978} 979 980/// Returns `true` if this box establishes a new block formatting context, 981/// which prevents its margins from collapsing with children. 982fn establishes_bfc(b: &LayoutBox) -> bool { 983 b.overflow != Overflow::Visible || matches!(b.display, Display::Flex | Display::InlineFlex) 984} 985 986/// Returns `true` if a block box has no in-flow content (empty block). 987fn is_empty_block(b: &LayoutBox) -> bool { 988 b.children.is_empty() 989 && b.lines.is_empty() 990 && b.replaced_size.is_none() 991 && matches!(b.css_height, LengthOrAuto::Auto) 992} 993 994/// Pre-collapse parent-child margins (CSS2 §8.3.1). 995/// 996/// When a parent has no border/padding/BFC separating it from its first/last 997/// child, the child's margin collapses into the parent's margin. This must 998/// happen *before* positioning so the parent is placed using the collapsed 999/// value. The function walks bottom-up: children are pre-collapsed first, then 1000/// their (possibly enlarged) margins are folded into the parent. 1001fn pre_collapse_margins(b: &mut LayoutBox) { 1002 // Recurse into in-flow block children first (bottom-up). 1003 for child in &mut b.children { 1004 if is_in_flow(child) && is_block_level(child) { 1005 pre_collapse_margins(child); 1006 } 1007 } 1008 1009 if !matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) { 1010 return; 1011 } 1012 if establishes_bfc(b) { 1013 return; 1014 } 1015 if !has_block_children(b) { 1016 return; 1017 } 1018 1019 // --- Top: collapse with first non-empty child --- 1020 if b.border.top == 0.0 && b.padding.top == 0.0 { 1021 if let Some(child_top) = first_block_top_margin(&b.children) { 1022 b.margin.top = collapse_margins(b.margin.top, child_top); 1023 } 1024 } 1025 1026 // --- Bottom: collapse with last non-empty child --- 1027 if b.border.bottom == 0.0 && b.padding.bottom == 0.0 { 1028 if let Some(child_bottom) = last_block_bottom_margin(&b.children) { 1029 b.margin.bottom = collapse_margins(b.margin.bottom, child_bottom); 1030 } 1031 } 1032} 1033 1034/// Top margin of the first non-empty in-flow block child (already pre-collapsed). 1035fn first_block_top_margin(children: &[LayoutBox]) -> Option<f32> { 1036 for child in children { 1037 if is_in_flow(child) && is_block_level(child) { 1038 if is_empty_block(child) { 1039 continue; 1040 } 1041 return Some(child.margin.top); 1042 } 1043 } 1044 // All in-flow block children empty — fold all their collapsed margins. 1045 let mut m = 0.0f32; 1046 for child in children 1047 .iter() 1048 .filter(|c| is_in_flow(c) && is_block_level(c)) 1049 { 1050 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 1051 } 1052 if m != 0.0 { 1053 Some(m) 1054 } else { 1055 None 1056 } 1057} 1058 1059/// Bottom margin of the last non-empty in-flow block child (already pre-collapsed). 1060fn last_block_bottom_margin(children: &[LayoutBox]) -> Option<f32> { 1061 for child in children.iter().rev() { 1062 if is_in_flow(child) && is_block_level(child) { 1063 if is_empty_block(child) { 1064 continue; 1065 } 1066 return Some(child.margin.bottom); 1067 } 1068 } 1069 let mut m = 0.0f32; 1070 for child in children 1071 .iter() 1072 .filter(|c| is_in_flow(c) && is_block_level(c)) 1073 { 1074 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 1075 } 1076 if m != 0.0 { 1077 Some(m) 1078 } else { 1079 None 1080 } 1081} 1082 1083/// Lay out block-level children with vertical margin collapsing (CSS2 §8.3.1). 1084/// 1085/// Handles adjacent-sibling collapsing, empty-block collapsing, and 1086/// parent-child internal spacing (the parent's external margins were already 1087/// updated by `pre_collapse_margins`). 1088fn layout_block_children( 1089 parent: &mut LayoutBox, 1090 viewport_width: f32, 1091 viewport_height: f32, 1092 font: &Font, 1093 doc: &Document, 1094 abs_cb: Rect, 1095) { 1096 let content_x = parent.rect.x; 1097 let content_width = parent.rect.width; 1098 let mut cursor_y = parent.rect.y; 1099 1100 let parent_top_open = 1101 parent.border.top == 0.0 && parent.padding.top == 0.0 && !establishes_bfc(parent); 1102 let parent_bottom_open = 1103 parent.border.bottom == 0.0 && parent.padding.bottom == 0.0 && !establishes_bfc(parent); 1104 1105 // Pending bottom margin from the previous sibling. 1106 let mut pending_margin: Option<f32> = None; 1107 let child_count = parent.children.len(); 1108 // Track whether we've seen any in-flow children (for parent_top_open). 1109 let mut first_in_flow = true; 1110 1111 for i in 0..child_count { 1112 // Skip out-of-flow children (absolute/fixed) — they are laid out 1113 // separately in layout_abspos_children. 1114 if !is_in_flow(&parent.children[i]) { 1115 continue; 1116 } 1117 1118 let child_top_margin = parent.children[i].margin.top; 1119 let child_bottom_margin = parent.children[i].margin.bottom; 1120 1121 // --- Empty block: top+bottom margins self-collapse --- 1122 if is_empty_block(&parent.children[i]) { 1123 let self_collapsed = collapse_margins(child_top_margin, child_bottom_margin); 1124 pending_margin = Some(match pending_margin { 1125 Some(prev) => collapse_margins(prev, self_collapsed), 1126 None => self_collapsed, 1127 }); 1128 // Position at cursor_y with zero height. 1129 let child = &mut parent.children[i]; 1130 child.rect.x = content_x + child.border.left + child.padding.left; 1131 child.rect.y = cursor_y + child.border.top + child.padding.top; 1132 child.rect.width = (content_width 1133 - child.border.left 1134 - child.border.right 1135 - child.padding.left 1136 - child.padding.right) 1137 .max(0.0); 1138 child.rect.height = 0.0; 1139 first_in_flow = false; 1140 continue; 1141 } 1142 1143 // --- Compute effective top spacing --- 1144 let collapsed_top = if let Some(prev_bottom) = pending_margin.take() { 1145 // Sibling collapsing: previous bottom vs this top. 1146 collapse_margins(prev_bottom, child_top_margin) 1147 } else if first_in_flow && parent_top_open { 1148 // First child, parent top open: margin was already pulled into 1149 // parent by pre_collapse_margins — no internal spacing. 1150 0.0 1151 } else { 1152 child_top_margin 1153 }; 1154 1155 // `compute_layout` adds `child.margin.top` internally, so compensate. 1156 let y_for_child = cursor_y + collapsed_top - child_top_margin; 1157 compute_layout( 1158 &mut parent.children[i], 1159 content_x, 1160 y_for_child, 1161 content_width, 1162 viewport_width, 1163 viewport_height, 1164 font, 1165 doc, 1166 abs_cb, 1167 ); 1168 1169 let child = &parent.children[i]; 1170 // Use the normal-flow position (before relative offset) so that 1171 // `position: relative` does not affect sibling placement. 1172 let (_, rel_dy) = child.relative_offset; 1173 cursor_y = (child.rect.y - rel_dy) 1174 + child.rect.height 1175 + child.padding.bottom 1176 + child.border.bottom; 1177 pending_margin = Some(child_bottom_margin); 1178 first_in_flow = false; 1179 } 1180 1181 // Trailing margin. 1182 if let Some(trailing) = pending_margin { 1183 if !parent_bottom_open { 1184 // Parent has border/padding at bottom — margin stays inside. 1185 cursor_y += trailing; 1186 } 1187 // If parent_bottom_open, the margin was already pulled into the 1188 // parent by pre_collapse_margins. 1189 } 1190 1191 parent.rect.height = cursor_y - parent.rect.y; 1192} 1193 1194// --------------------------------------------------------------------------- 1195// Flex layout 1196// --------------------------------------------------------------------------- 1197 1198/// Measure the intrinsic max-content width of a flex item's content. 1199/// Returns the width needed to lay out content without wrapping. 1200fn measure_flex_item_max_content_width(child: &LayoutBox, font: &Font, _doc: &Document) -> f32 { 1201 // Measure the widest text line from all inline content. 1202 let mut max_width = 0.0f32; 1203 measure_box_content_width(child, font, &mut max_width); 1204 max_width 1205} 1206 1207/// Recursively measure max content width of a layout box tree. 1208fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { 1209 match &b.box_type { 1210 BoxType::TextRun { text, .. } => { 1211 let w = measure_text_width(font, text, b.font_size); 1212 if w > *max_w { 1213 *max_w = w; 1214 } 1215 } 1216 _ => { 1217 let horiz = b.border.left + b.border.right + b.padding.left + b.padding.right; 1218 let mut child_max = 0.0f32; 1219 for child in &b.children { 1220 measure_box_content_width(child, font, &mut child_max); 1221 } 1222 let total = child_max + horiz; 1223 if total > *max_w { 1224 *max_w = total; 1225 } 1226 } 1227 } 1228} 1229 1230/// Lay out children according to the CSS Flexbox algorithm (Level 1 §9). 1231fn layout_flex_children( 1232 parent: &mut LayoutBox, 1233 viewport_width: f32, 1234 viewport_height: f32, 1235 font: &Font, 1236 doc: &Document, 1237 abs_cb: Rect, 1238) { 1239 let flex_direction = parent.flex_direction; 1240 let flex_wrap = parent.flex_wrap; 1241 let justify_content = parent.justify_content; 1242 let align_items = parent.align_items; 1243 let align_content = parent.align_content; 1244 let container_main_size = match flex_direction { 1245 FlexDirection::Row | FlexDirection::RowReverse => parent.rect.width, 1246 FlexDirection::Column | FlexDirection::ColumnReverse => { 1247 match parent.css_height { 1248 LengthOrAuto::Length(h) => h, 1249 LengthOrAuto::Percentage(p) => p / 100.0 * viewport_height, 1250 LengthOrAuto::Auto => viewport_height, // fallback 1251 } 1252 } 1253 }; 1254 let container_cross_size = match flex_direction { 1255 FlexDirection::Row | FlexDirection::RowReverse => match parent.css_height { 1256 LengthOrAuto::Length(h) => Some(h), 1257 LengthOrAuto::Percentage(p) => Some(p / 100.0 * viewport_height), 1258 LengthOrAuto::Auto => None, 1259 }, 1260 FlexDirection::Column | FlexDirection::ColumnReverse => Some(parent.rect.width), 1261 }; 1262 1263 let is_row = matches!( 1264 flex_direction, 1265 FlexDirection::Row | FlexDirection::RowReverse 1266 ); 1267 let is_reverse = matches!( 1268 flex_direction, 1269 FlexDirection::RowReverse | FlexDirection::ColumnReverse 1270 ); 1271 1272 // Per CSS Box Alignment §8, row-gap applies between rows and column-gap 1273 // between columns. In a row flex container the main axis is horizontal 1274 // (column-gap between items, row-gap between lines). In a column flex 1275 // container the axes are swapped. 1276 let (main_gap, cross_gap) = if is_row { 1277 (parent.column_gap, parent.row_gap) 1278 } else { 1279 (parent.row_gap, parent.column_gap) 1280 }; 1281 1282 if parent.children.is_empty() { 1283 parent.rect.height = 0.0; 1284 return; 1285 } 1286 1287 // Sort children by `order` (stable sort preserves DOM order for equal values). 1288 let child_count = parent.children.len(); 1289 let mut order_indices: Vec<usize> = (0..child_count).collect(); 1290 order_indices.sort_by_key(|&i| parent.children[i].order); 1291 1292 // Step 1: Determine flex base sizes and hypothetical main sizes. 1293 struct FlexItemInfo { 1294 index: usize, 1295 base_size: f32, 1296 hypothetical_main: f32, 1297 flex_grow: f32, 1298 flex_shrink: f32, 1299 frozen: bool, 1300 target_main: f32, 1301 outer_main: f32, // margin+border+padding on main axis 1302 outer_cross: f32, // margin+border+padding on cross axis 1303 } 1304 1305 let mut items: Vec<FlexItemInfo> = Vec::with_capacity(child_count); 1306 1307 for &idx in &order_indices { 1308 // Skip out-of-flow children (absolute/fixed). 1309 if !is_in_flow(&parent.children[idx]) { 1310 continue; 1311 } 1312 let child = &mut parent.children[idx]; 1313 1314 // Resolve margin/padding percentages for the child. 1315 let cb_width = if is_row { 1316 container_main_size 1317 } else { 1318 container_cross_size.unwrap_or(parent.rect.width) 1319 }; 1320 1321 // Resolve percentage margins and padding against containing block width. 1322 for i in 0..4 { 1323 if matches!(child.css_margin[i], LengthOrAuto::Percentage(_)) { 1324 let resolved = resolve_length_against(child.css_margin[i], cb_width); 1325 match i { 1326 0 => child.margin.top = resolved, 1327 1 => child.margin.right = resolved, 1328 2 => child.margin.bottom = resolved, 1329 3 => child.margin.left = resolved, 1330 _ => {} 1331 } 1332 } 1333 if matches!(child.css_padding[i], LengthOrAuto::Percentage(_)) { 1334 let resolved = resolve_length_against(child.css_padding[i], cb_width); 1335 match i { 1336 0 => child.padding.top = resolved, 1337 1 => child.padding.right = resolved, 1338 2 => child.padding.bottom = resolved, 1339 3 => child.padding.left = resolved, 1340 _ => {} 1341 } 1342 } 1343 } 1344 1345 let margin_main = if is_row { 1346 child.margin.left + child.margin.right 1347 } else { 1348 child.margin.top + child.margin.bottom 1349 }; 1350 let border_padding_main = if is_row { 1351 child.border.left + child.border.right + child.padding.left + child.padding.right 1352 } else { 1353 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom 1354 }; 1355 let margin_cross = if is_row { 1356 child.margin.top + child.margin.bottom 1357 } else { 1358 child.margin.left + child.margin.right 1359 }; 1360 let border_padding_cross = if is_row { 1361 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom 1362 } else { 1363 child.border.left + child.border.right + child.padding.left + child.padding.right 1364 }; 1365 1366 // Determine flex-basis. 1367 let flex_basis = child.flex_basis; 1368 let specified_main = if is_row { 1369 child.css_width 1370 } else { 1371 child.css_height 1372 }; 1373 1374 let base_size = match flex_basis { 1375 LengthOrAuto::Length(px) => px, 1376 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, 1377 LengthOrAuto::Auto => { 1378 // Use specified main size if set, otherwise content size. 1379 match specified_main { 1380 LengthOrAuto::Length(px) => px, 1381 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, 1382 LengthOrAuto::Auto => { 1383 // Content-based sizing: use max-content size. 1384 if is_row { 1385 measure_flex_item_max_content_width(child, font, doc) 1386 } else { 1387 // For column direction, measure content height. 1388 let avail = container_cross_size.unwrap_or(parent.rect.width); 1389 compute_layout( 1390 child, 1391 0.0, 1392 0.0, 1393 avail, 1394 viewport_width, 1395 viewport_height, 1396 font, 1397 doc, 1398 abs_cb, 1399 ); 1400 child.rect.height 1401 } 1402 } 1403 } 1404 } 1405 }; 1406 1407 let hypothetical_main = base_size.max(0.0); 1408 let outer = margin_main + border_padding_main; 1409 1410 items.push(FlexItemInfo { 1411 index: idx, 1412 base_size, 1413 hypothetical_main, 1414 flex_grow: child.flex_grow, 1415 flex_shrink: child.flex_shrink, 1416 frozen: false, 1417 target_main: hypothetical_main, 1418 outer_main: outer, 1419 outer_cross: margin_cross + border_padding_cross, 1420 }); 1421 } 1422 1423 // Step 2: Collect items into flex lines. 1424 let mut lines: Vec<Vec<usize>> = Vec::new(); // indices into `items` 1425 1426 if flex_wrap == FlexWrap::Nowrap { 1427 // All items on one line. 1428 lines.push((0..items.len()).collect()); 1429 } else { 1430 let mut current_line: Vec<usize> = Vec::new(); 1431 let mut line_main_size = 0.0f32; 1432 1433 for (i, item) in items.iter().enumerate() { 1434 let item_outer = item.hypothetical_main + item.outer_main; 1435 let gap = if current_line.is_empty() { 1436 0.0 1437 } else { 1438 main_gap 1439 }; 1440 1441 if !current_line.is_empty() && line_main_size + gap + item_outer > container_main_size { 1442 lines.push(std::mem::take(&mut current_line)); 1443 line_main_size = 0.0; 1444 } 1445 1446 if !current_line.is_empty() { 1447 line_main_size += main_gap; 1448 } 1449 line_main_size += item_outer; 1450 current_line.push(i); 1451 } 1452 1453 if !current_line.is_empty() { 1454 lines.push(current_line); 1455 } 1456 } 1457 1458 if flex_wrap == FlexWrap::WrapReverse { 1459 lines.reverse(); 1460 } 1461 1462 // Step 3: Resolve flexible lengths for each line. 1463 for line in &lines { 1464 // Total hypothetical main sizes + gaps. 1465 let total_gaps = if line.len() > 1 { 1466 (line.len() - 1) as f32 * main_gap 1467 } else { 1468 0.0 1469 }; 1470 let total_outer_hypo: f32 = line 1471 .iter() 1472 .map(|&i| items[i].hypothetical_main + items[i].outer_main) 1473 .sum(); 1474 let used_space = total_outer_hypo + total_gaps; 1475 let free_space = container_main_size - used_space; 1476 1477 // Reset frozen state. 1478 for &i in line { 1479 items[i].frozen = false; 1480 items[i].target_main = items[i].hypothetical_main; 1481 } 1482 1483 if free_space > 0.0 { 1484 // Grow items. 1485 let total_grow: f32 = line.iter().map(|&i| items[i].flex_grow).sum(); 1486 if total_grow > 0.0 { 1487 for &i in line { 1488 if items[i].flex_grow > 0.0 { 1489 items[i].target_main += free_space * (items[i].flex_grow / total_grow); 1490 } 1491 } 1492 } 1493 } else if free_space < 0.0 { 1494 // Shrink items. 1495 let total_shrink_scaled: f32 = line 1496 .iter() 1497 .map(|&i| items[i].flex_shrink * items[i].base_size) 1498 .sum(); 1499 if total_shrink_scaled > 0.0 { 1500 for &i in line { 1501 let scaled = items[i].flex_shrink * items[i].base_size; 1502 let ratio = scaled / total_shrink_scaled; 1503 items[i].target_main = 1504 (items[i].hypothetical_main + free_space * ratio).max(0.0); 1505 } 1506 } 1507 } 1508 } 1509 1510 // Step 4: Determine cross sizes of items by laying them out at their target main size. 1511 struct LineCrossInfo { 1512 max_cross: f32, 1513 } 1514 1515 let mut line_infos: Vec<LineCrossInfo> = Vec::new(); 1516 1517 for line in &lines { 1518 let mut max_cross = 0.0f32; 1519 1520 for &i in line { 1521 let idx = items[i].index; 1522 let child = &mut parent.children[idx]; 1523 let target_main = items[i].target_main; 1524 1525 // Set up the child for layout at the resolved main size. 1526 if is_row { 1527 child.css_width = LengthOrAuto::Length(target_main); 1528 compute_layout( 1529 child, 1530 0.0, 1531 0.0, 1532 target_main, 1533 viewport_width, 1534 viewport_height, 1535 font, 1536 doc, 1537 abs_cb, 1538 ); 1539 let cross = child.rect.height + items[i].outer_cross; 1540 if cross > max_cross { 1541 max_cross = cross; 1542 } 1543 } else { 1544 let avail = container_cross_size.unwrap_or(parent.rect.width); 1545 compute_layout( 1546 child, 1547 0.0, 1548 0.0, 1549 avail, 1550 viewport_width, 1551 viewport_height, 1552 font, 1553 doc, 1554 abs_cb, 1555 ); 1556 child.rect.height = target_main; 1557 let cross = child.rect.width 1558 + child.border.left 1559 + child.border.right 1560 + child.padding.left 1561 + child.padding.right 1562 + child.margin.left 1563 + child.margin.right; 1564 if cross > max_cross { 1565 max_cross = cross; 1566 } 1567 } 1568 } 1569 1570 line_infos.push(LineCrossInfo { max_cross }); 1571 } 1572 1573 // Per CSS Flexbox §9.4: If the flex container is single-line and has a 1574 // definite cross size, the cross size of the flex line is the container's 1575 // cross size (clamped to min/max). This ensures alignment works relative 1576 // to the full container. 1577 if lines.len() == 1 { 1578 if let Some(cs) = container_cross_size { 1579 if line_infos[0].max_cross < cs { 1580 line_infos[0].max_cross = cs; 1581 } 1582 } 1583 } 1584 1585 // Step 5: Handle align-items: stretch — stretch items to fill the line cross size. 1586 for (line_idx, line) in lines.iter().enumerate() { 1587 let line_cross = line_infos[line_idx].max_cross; 1588 1589 for &i in line { 1590 let idx = items[i].index; 1591 let child = &mut parent.children[idx]; 1592 1593 let effective_align = match child.align_self { 1594 AlignSelf::Auto => align_items, 1595 AlignSelf::FlexStart => AlignItems::FlexStart, 1596 AlignSelf::FlexEnd => AlignItems::FlexEnd, 1597 AlignSelf::Center => AlignItems::Center, 1598 AlignSelf::Baseline => AlignItems::Baseline, 1599 AlignSelf::Stretch => AlignItems::Stretch, 1600 }; 1601 1602 if effective_align == AlignItems::Stretch { 1603 let item_cross_space = line_cross - items[i].outer_cross; 1604 if is_row { 1605 if matches!(child.css_height, LengthOrAuto::Auto) { 1606 child.rect.height = item_cross_space.max(0.0); 1607 } 1608 } else if matches!(child.css_width, LengthOrAuto::Auto) { 1609 child.rect.width = item_cross_space.max(0.0); 1610 } 1611 } 1612 } 1613 } 1614 1615 // Step 6: Position items on main and cross axes. 1616 let total_cross_gaps = if lines.len() > 1 { 1617 (lines.len() - 1) as f32 * cross_gap 1618 } else { 1619 0.0 1620 }; 1621 let total_line_cross: f32 = line_infos.iter().map(|li| li.max_cross).sum(); 1622 let total_cross_used = total_line_cross + total_cross_gaps; 1623 1624 // For definite cross size containers, compute align-content offsets. 1625 let cross_free_space = container_cross_size 1626 .map(|cs| (cs - total_cross_used).max(0.0)) 1627 .unwrap_or(0.0); 1628 1629 let (ac_initial_offset, ac_between_offset) = compute_content_distribution( 1630 align_content_to_justify(align_content), 1631 cross_free_space, 1632 lines.len(), 1633 ); 1634 1635 let mut cross_cursor = if is_row { parent.rect.y } else { parent.rect.x } + ac_initial_offset; 1636 1637 for (line_idx, line) in lines.iter().enumerate() { 1638 let line_cross = line_infos[line_idx].max_cross; 1639 1640 // Main-axis justification. 1641 let total_main_gaps = if line.len() > 1 { 1642 (line.len() - 1) as f32 * main_gap 1643 } else { 1644 0.0 1645 }; 1646 let items_main: f32 = line 1647 .iter() 1648 .map(|&i| items[i].target_main + items[i].outer_main) 1649 .sum(); 1650 let main_free = (container_main_size - items_main - total_main_gaps).max(0.0); 1651 1652 let (jc_initial, jc_between) = 1653 compute_content_distribution(justify_content, main_free, line.len()); 1654 1655 // Determine the starting main position. 1656 let mut main_cursor = if is_row { parent.rect.x } else { parent.rect.y }; 1657 1658 if is_reverse { 1659 // For reverse directions, start from the end. 1660 main_cursor += container_main_size; 1661 } 1662 1663 main_cursor += if is_reverse { -jc_initial } else { jc_initial }; 1664 1665 let line_items: Vec<usize> = if is_reverse { 1666 line.iter().rev().copied().collect() 1667 } else { 1668 line.to_vec() 1669 }; 1670 1671 for (item_pos, &i) in line_items.iter().enumerate() { 1672 let idx = items[i].index; 1673 let child = &mut parent.children[idx]; 1674 let target_main = items[i].target_main; 1675 1676 // Main-axis position. 1677 let child_main_margin_start = if is_row { 1678 child.margin.left 1679 } else { 1680 child.margin.top 1681 }; 1682 let child_main_margin_end = if is_row { 1683 child.margin.right 1684 } else { 1685 child.margin.bottom 1686 }; 1687 let child_main_bp_start = if is_row { 1688 child.border.left + child.padding.left 1689 } else { 1690 child.border.top + child.padding.top 1691 }; 1692 let child_main_bp_end = if is_row { 1693 child.border.right + child.padding.right 1694 } else { 1695 child.border.bottom + child.padding.bottom 1696 }; 1697 1698 let outer_size = child_main_margin_start 1699 + child_main_bp_start 1700 + target_main 1701 + child_main_bp_end 1702 + child_main_margin_end; 1703 1704 if is_reverse { 1705 main_cursor -= outer_size; 1706 } 1707 1708 let content_main = main_cursor + child_main_margin_start + child_main_bp_start; 1709 1710 // Cross-axis position. 1711 let child_cross_margin_start = if is_row { 1712 child.margin.top 1713 } else { 1714 child.margin.left 1715 }; 1716 let child_cross_bp_start = if is_row { 1717 child.border.top + child.padding.top 1718 } else { 1719 child.border.left + child.padding.left 1720 }; 1721 let child_cross_total = if is_row { 1722 child.rect.height + items[i].outer_cross 1723 } else { 1724 child.rect.width + items[i].outer_cross 1725 }; 1726 1727 let effective_align = match child.align_self { 1728 AlignSelf::Auto => align_items, 1729 AlignSelf::FlexStart => AlignItems::FlexStart, 1730 AlignSelf::FlexEnd => AlignItems::FlexEnd, 1731 AlignSelf::Center => AlignItems::Center, 1732 AlignSelf::Baseline => AlignItems::Baseline, 1733 AlignSelf::Stretch => AlignItems::Stretch, 1734 }; 1735 1736 let cross_offset = match effective_align { 1737 AlignItems::FlexStart | AlignItems::Stretch | AlignItems::Baseline => 0.0, 1738 AlignItems::FlexEnd => line_cross - child_cross_total, 1739 AlignItems::Center => (line_cross - child_cross_total) / 2.0, 1740 }; 1741 1742 let content_cross = 1743 cross_cursor + cross_offset + child_cross_margin_start + child_cross_bp_start; 1744 1745 // Set child position. 1746 if is_row { 1747 child.rect.x = content_main; 1748 child.rect.y = content_cross; 1749 child.rect.width = target_main; 1750 } else { 1751 child.rect.x = content_cross; 1752 child.rect.y = content_main; 1753 child.rect.height = target_main; 1754 } 1755 1756 // Shift any text lines to match new position. 1757 reposition_lines(child, font, doc); 1758 1759 if !is_reverse { 1760 main_cursor += outer_size; 1761 } 1762 1763 // Add gap between items (not after last). 1764 if item_pos < line_items.len() - 1 { 1765 if is_reverse { 1766 main_cursor -= main_gap; 1767 } else { 1768 main_cursor += main_gap; 1769 } 1770 } 1771 1772 // Also add justify-content between spacing. 1773 if item_pos < line_items.len() - 1 { 1774 if is_reverse { 1775 main_cursor -= jc_between; 1776 } else { 1777 main_cursor += jc_between; 1778 } 1779 } 1780 } 1781 1782 cross_cursor += line_cross + cross_gap + ac_between_offset; 1783 } 1784 1785 // Set parent height based on children. 1786 if is_row { 1787 if matches!(parent.css_height, LengthOrAuto::Auto) { 1788 parent.rect.height = total_cross_used; 1789 } 1790 } else if matches!(parent.css_height, LengthOrAuto::Auto) { 1791 // For column flex, height is the main axis. 1792 let total_main: f32 = lines 1793 .iter() 1794 .map(|line| { 1795 let items_main: f32 = line 1796 .iter() 1797 .map(|&i| items[i].target_main + items[i].outer_main) 1798 .sum(); 1799 let gaps = if line.len() > 1 { 1800 (line.len() - 1) as f32 * main_gap 1801 } else { 1802 0.0 1803 }; 1804 items_main + gaps 1805 }) 1806 .fold(0.0f32, f32::max); 1807 parent.rect.height = total_main; 1808 } 1809} 1810 1811/// Reposition inline text lines inside a layout box after moving it. 1812/// Re-runs inline layout if the box has inline children. 1813fn reposition_lines(b: &mut LayoutBox, font: &Font, doc: &Document) { 1814 if !b.lines.is_empty() { 1815 // Re-run inline layout at the new position. 1816 b.lines.clear(); 1817 layout_inline_children(b, font, doc); 1818 } 1819 // Recursively reposition children that have their own inline content. 1820 for child in &mut b.children { 1821 if !child.lines.is_empty() { 1822 child.lines.clear(); 1823 layout_inline_children(child, font, doc); 1824 } 1825 } 1826} 1827 1828/// Convert AlignContent to JustifyContent for reuse of distribution logic. 1829fn align_content_to_justify(ac: AlignContent) -> JustifyContent { 1830 match ac { 1831 AlignContent::FlexStart | AlignContent::Stretch => JustifyContent::FlexStart, 1832 AlignContent::FlexEnd => JustifyContent::FlexEnd, 1833 AlignContent::Center => JustifyContent::Center, 1834 AlignContent::SpaceBetween => JustifyContent::SpaceBetween, 1835 AlignContent::SpaceAround => JustifyContent::SpaceAround, 1836 } 1837} 1838 1839/// Compute the initial offset and between-item spacing for content distribution. 1840/// Returns (initial_offset, between_spacing). 1841fn compute_content_distribution( 1842 mode: JustifyContent, 1843 free_space: f32, 1844 item_count: usize, 1845) -> (f32, f32) { 1846 if item_count == 0 { 1847 return (0.0, 0.0); 1848 } 1849 match mode { 1850 JustifyContent::FlexStart => (0.0, 0.0), 1851 JustifyContent::FlexEnd => (free_space, 0.0), 1852 JustifyContent::Center => (free_space / 2.0, 0.0), 1853 JustifyContent::SpaceBetween => { 1854 if item_count <= 1 { 1855 (0.0, 0.0) 1856 } else { 1857 (0.0, free_space / (item_count - 1) as f32) 1858 } 1859 } 1860 JustifyContent::SpaceAround => { 1861 let per_item = free_space / item_count as f32; 1862 (per_item / 2.0, per_item) 1863 } 1864 JustifyContent::SpaceEvenly => { 1865 let spacing = free_space / (item_count + 1) as f32; 1866 (spacing, spacing) 1867 } 1868 } 1869} 1870 1871// --------------------------------------------------------------------------- 1872// Inline formatting context 1873// --------------------------------------------------------------------------- 1874 1875/// An inline item produced by flattening the inline tree. 1876enum InlineItemKind { 1877 /// A word of text with associated styling. 1878 Word { 1879 text: String, 1880 font_size: f32, 1881 color: Color, 1882 text_decoration: TextDecoration, 1883 background_color: Color, 1884 }, 1885 /// Whitespace between words. 1886 Space { font_size: f32 }, 1887 /// Forced line break (`<br>`). 1888 ForcedBreak, 1889 /// Start of an inline box (for margin/padding/border tracking). 1890 InlineStart { 1891 margin_left: f32, 1892 padding_left: f32, 1893 border_left: f32, 1894 }, 1895 /// End of an inline box. 1896 InlineEnd { 1897 margin_right: f32, 1898 padding_right: f32, 1899 border_right: f32, 1900 }, 1901} 1902 1903/// A pending fragment on the current line. 1904struct PendingFragment { 1905 text: String, 1906 x: f32, 1907 width: f32, 1908 font_size: f32, 1909 color: Color, 1910 text_decoration: TextDecoration, 1911 background_color: Color, 1912} 1913 1914/// Flatten the inline children tree into a sequence of items. 1915fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec<InlineItemKind>) { 1916 for child in children { 1917 // Skip out-of-flow children (absolute/fixed positioned). 1918 if !is_in_flow(child) { 1919 continue; 1920 } 1921 match &child.box_type { 1922 BoxType::TextRun { text, .. } => { 1923 let words = split_into_words(text); 1924 for segment in words { 1925 match segment { 1926 WordSegment::Word(w) => { 1927 items.push(InlineItemKind::Word { 1928 text: w, 1929 font_size: child.font_size, 1930 color: child.color, 1931 text_decoration: child.text_decoration, 1932 background_color: child.background_color, 1933 }); 1934 } 1935 WordSegment::Space => { 1936 items.push(InlineItemKind::Space { 1937 font_size: child.font_size, 1938 }); 1939 } 1940 } 1941 } 1942 } 1943 BoxType::Inline(node_id) => { 1944 if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) { 1945 if tag_name == "br" { 1946 items.push(InlineItemKind::ForcedBreak); 1947 continue; 1948 } 1949 } 1950 1951 items.push(InlineItemKind::InlineStart { 1952 margin_left: child.margin.left, 1953 padding_left: child.padding.left, 1954 border_left: child.border.left, 1955 }); 1956 1957 flatten_inline_tree(&child.children, doc, items); 1958 1959 items.push(InlineItemKind::InlineEnd { 1960 margin_right: child.margin.right, 1961 padding_right: child.padding.right, 1962 border_right: child.border.right, 1963 }); 1964 } 1965 _ => {} 1966 } 1967 } 1968} 1969 1970enum WordSegment { 1971 Word(String), 1972 Space, 1973} 1974 1975/// Split text into alternating words and spaces. 1976fn split_into_words(text: &str) -> Vec<WordSegment> { 1977 let mut segments = Vec::new(); 1978 let mut current_word = String::new(); 1979 1980 for ch in text.chars() { 1981 if ch == ' ' { 1982 if !current_word.is_empty() { 1983 segments.push(WordSegment::Word(std::mem::take(&mut current_word))); 1984 } 1985 segments.push(WordSegment::Space); 1986 } else { 1987 current_word.push(ch); 1988 } 1989 } 1990 1991 if !current_word.is_empty() { 1992 segments.push(WordSegment::Word(current_word)); 1993 } 1994 1995 segments 1996} 1997 1998/// Lay out inline children using a proper inline formatting context. 1999fn layout_inline_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 2000 let available_width = parent.rect.width; 2001 let text_align = parent.text_align; 2002 let line_height = parent.line_height; 2003 2004 let mut items = Vec::new(); 2005 flatten_inline_tree(&parent.children, doc, &mut items); 2006 2007 if items.is_empty() { 2008 parent.rect.height = 0.0; 2009 return; 2010 } 2011 2012 // Process items into line boxes. 2013 let mut all_lines: Vec<Vec<PendingFragment>> = Vec::new(); 2014 let mut current_line: Vec<PendingFragment> = Vec::new(); 2015 let mut cursor_x: f32 = 0.0; 2016 2017 for item in &items { 2018 match item { 2019 InlineItemKind::Word { 2020 text, 2021 font_size, 2022 color, 2023 text_decoration, 2024 background_color, 2025 } => { 2026 let word_width = measure_text_width(font, text, *font_size); 2027 2028 // If this word doesn't fit and the line isn't empty, break. 2029 if cursor_x > 0.0 && cursor_x + word_width > available_width { 2030 all_lines.push(std::mem::take(&mut current_line)); 2031 cursor_x = 0.0; 2032 } 2033 2034 current_line.push(PendingFragment { 2035 text: text.clone(), 2036 x: cursor_x, 2037 width: word_width, 2038 font_size: *font_size, 2039 color: *color, 2040 text_decoration: *text_decoration, 2041 background_color: *background_color, 2042 }); 2043 cursor_x += word_width; 2044 } 2045 InlineItemKind::Space { font_size } => { 2046 // Only add space if we have content on the line. 2047 if !current_line.is_empty() { 2048 let space_width = measure_text_width(font, " ", *font_size); 2049 if cursor_x + space_width <= available_width { 2050 cursor_x += space_width; 2051 } 2052 } 2053 } 2054 InlineItemKind::ForcedBreak => { 2055 all_lines.push(std::mem::take(&mut current_line)); 2056 cursor_x = 0.0; 2057 } 2058 InlineItemKind::InlineStart { 2059 margin_left, 2060 padding_left, 2061 border_left, 2062 } => { 2063 cursor_x += margin_left + padding_left + border_left; 2064 } 2065 InlineItemKind::InlineEnd { 2066 margin_right, 2067 padding_right, 2068 border_right, 2069 } => { 2070 cursor_x += margin_right + padding_right + border_right; 2071 } 2072 } 2073 } 2074 2075 // Flush the last line. 2076 if !current_line.is_empty() { 2077 all_lines.push(current_line); 2078 } 2079 2080 if all_lines.is_empty() { 2081 parent.rect.height = 0.0; 2082 return; 2083 } 2084 2085 // Position lines vertically and apply text-align. 2086 let mut text_lines = Vec::new(); 2087 let mut y = parent.rect.y; 2088 let num_lines = all_lines.len(); 2089 2090 for (line_idx, line_fragments) in all_lines.iter().enumerate() { 2091 if line_fragments.is_empty() { 2092 y += line_height; 2093 continue; 2094 } 2095 2096 // Compute line width from last fragment. 2097 let line_width = match line_fragments.last() { 2098 Some(last) => last.x + last.width, 2099 None => 0.0, 2100 }; 2101 2102 // Compute text-align offset. 2103 let is_last_line = line_idx == num_lines - 1; 2104 let align_offset = 2105 compute_align_offset(text_align, available_width, line_width, is_last_line); 2106 2107 for frag in line_fragments { 2108 text_lines.push(TextLine { 2109 text: frag.text.clone(), 2110 x: parent.rect.x + frag.x + align_offset, 2111 y, 2112 width: frag.width, 2113 font_size: frag.font_size, 2114 color: frag.color, 2115 text_decoration: frag.text_decoration, 2116 background_color: frag.background_color, 2117 }); 2118 } 2119 2120 y += line_height; 2121 } 2122 2123 parent.rect.height = num_lines as f32 * line_height; 2124 parent.lines = text_lines; 2125} 2126 2127/// Compute the horizontal offset for text alignment. 2128fn compute_align_offset( 2129 align: TextAlign, 2130 available_width: f32, 2131 line_width: f32, 2132 is_last_line: bool, 2133) -> f32 { 2134 let extra_space = (available_width - line_width).max(0.0); 2135 match align { 2136 TextAlign::Left => 0.0, 2137 TextAlign::Center => extra_space / 2.0, 2138 TextAlign::Right => extra_space, 2139 TextAlign::Justify => { 2140 // Don't justify the last line (CSS spec behavior). 2141 if is_last_line { 2142 0.0 2143 } else { 2144 // For justify, we shift the whole line by 0 — the actual distribution 2145 // of space between words would need per-word spacing. For now, treat 2146 // as left-aligned; full justify support is a future enhancement. 2147 0.0 2148 } 2149 } 2150 } 2151} 2152 2153// --------------------------------------------------------------------------- 2154// Text measurement 2155// --------------------------------------------------------------------------- 2156 2157/// Measure the total advance width of a text string at the given font size. 2158fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { 2159 let shaped = font.shape_text(text, font_size); 2160 match shaped.last() { 2161 Some(last) => last.x_offset + last.x_advance, 2162 None => 0.0, 2163 } 2164} 2165 2166// --------------------------------------------------------------------------- 2167// Public API 2168// --------------------------------------------------------------------------- 2169 2170/// Build and lay out from a styled tree (produced by `resolve_styles`). 2171/// 2172/// Returns a `LayoutTree` with positioned boxes ready for rendering. 2173pub fn layout( 2174 styled_root: &StyledNode, 2175 doc: &Document, 2176 viewport_width: f32, 2177 viewport_height: f32, 2178 font: &Font, 2179 image_sizes: &HashMap<NodeId, (f32, f32)>, 2180) -> LayoutTree { 2181 let mut root = match build_box(styled_root, doc, image_sizes) { 2182 Some(b) => b, 2183 None => { 2184 return LayoutTree { 2185 root: LayoutBox::new(BoxType::Anonymous, &ComputedStyle::default()), 2186 width: viewport_width, 2187 height: 0.0, 2188 }; 2189 } 2190 }; 2191 2192 // Pre-collapse parent-child margins before positioning. 2193 pre_collapse_margins(&mut root); 2194 2195 // The initial containing block for absolutely positioned elements is the viewport. 2196 let viewport_cb = Rect { 2197 x: 0.0, 2198 y: 0.0, 2199 width: viewport_width, 2200 height: viewport_height, 2201 }; 2202 compute_layout( 2203 &mut root, 2204 0.0, 2205 0.0, 2206 viewport_width, 2207 viewport_width, 2208 viewport_height, 2209 font, 2210 doc, 2211 viewport_cb, 2212 ); 2213 2214 let height = root.margin_box_height(); 2215 LayoutTree { 2216 root, 2217 width: viewport_width, 2218 height, 2219 } 2220} 2221 2222#[cfg(test)] 2223mod tests { 2224 use super::*; 2225 use we_dom::Document; 2226 use we_style::computed::{extract_stylesheets, resolve_styles}; 2227 2228 fn test_font() -> Font { 2229 let paths = [ 2230 "/System/Library/Fonts/Geneva.ttf", 2231 "/System/Library/Fonts/Monaco.ttf", 2232 ]; 2233 for path in &paths { 2234 let p = std::path::Path::new(path); 2235 if p.exists() { 2236 return Font::from_file(p).expect("failed to parse font"); 2237 } 2238 } 2239 panic!("no test font found"); 2240 } 2241 2242 fn layout_doc(doc: &Document) -> LayoutTree { 2243 let font = test_font(); 2244 let sheets = extract_stylesheets(doc); 2245 let styled = resolve_styles(doc, &sheets, (800.0, 600.0)).unwrap(); 2246 layout(&styled, doc, 800.0, 600.0, &font, &HashMap::new()) 2247 } 2248 2249 #[test] 2250 fn empty_document() { 2251 let doc = Document::new(); 2252 let font = test_font(); 2253 let sheets = extract_stylesheets(&doc); 2254 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)); 2255 if let Some(styled) = styled { 2256 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2257 assert_eq!(tree.width, 800.0); 2258 } 2259 } 2260 2261 #[test] 2262 fn single_paragraph() { 2263 let mut doc = Document::new(); 2264 let root = doc.root(); 2265 let html = doc.create_element("html"); 2266 let body = doc.create_element("body"); 2267 let p = doc.create_element("p"); 2268 let text = doc.create_text("Hello world"); 2269 doc.append_child(root, html); 2270 doc.append_child(html, body); 2271 doc.append_child(body, p); 2272 doc.append_child(p, text); 2273 2274 let tree = layout_doc(&doc); 2275 2276 assert!(matches!(tree.root.box_type, BoxType::Block(_))); 2277 2278 let body_box = &tree.root.children[0]; 2279 assert!(matches!(body_box.box_type, BoxType::Block(_))); 2280 2281 let p_box = &body_box.children[0]; 2282 assert!(matches!(p_box.box_type, BoxType::Block(_))); 2283 2284 assert!(!p_box.lines.is_empty(), "p should have text fragments"); 2285 2286 // Collect all text on the first visual line. 2287 let first_y = p_box.lines[0].y; 2288 let line_text: String = p_box 2289 .lines 2290 .iter() 2291 .filter(|l| (l.y - first_y).abs() < 0.01) 2292 .map(|l| l.text.as_str()) 2293 .collect::<Vec<_>>() 2294 .join(" "); 2295 assert!( 2296 line_text.contains("Hello") && line_text.contains("world"), 2297 "line should contain Hello and world, got: {line_text}" 2298 ); 2299 2300 assert_eq!(p_box.margin.top, 16.0); 2301 assert_eq!(p_box.margin.bottom, 16.0); 2302 } 2303 2304 #[test] 2305 fn blocks_stack_vertically() { 2306 let mut doc = Document::new(); 2307 let root = doc.root(); 2308 let html = doc.create_element("html"); 2309 let body = doc.create_element("body"); 2310 let p1 = doc.create_element("p"); 2311 let t1 = doc.create_text("First"); 2312 let p2 = doc.create_element("p"); 2313 let t2 = doc.create_text("Second"); 2314 doc.append_child(root, html); 2315 doc.append_child(html, body); 2316 doc.append_child(body, p1); 2317 doc.append_child(p1, t1); 2318 doc.append_child(body, p2); 2319 doc.append_child(p2, t2); 2320 2321 let tree = layout_doc(&doc); 2322 let body_box = &tree.root.children[0]; 2323 let first = &body_box.children[0]; 2324 let second = &body_box.children[1]; 2325 2326 assert!( 2327 second.rect.y > first.rect.y, 2328 "second p (y={}) should be below first p (y={})", 2329 second.rect.y, 2330 first.rect.y 2331 ); 2332 } 2333 2334 #[test] 2335 fn heading_larger_than_body() { 2336 let mut doc = Document::new(); 2337 let root = doc.root(); 2338 let html = doc.create_element("html"); 2339 let body = doc.create_element("body"); 2340 let h1 = doc.create_element("h1"); 2341 let h1_text = doc.create_text("Title"); 2342 let p = doc.create_element("p"); 2343 let p_text = doc.create_text("Text"); 2344 doc.append_child(root, html); 2345 doc.append_child(html, body); 2346 doc.append_child(body, h1); 2347 doc.append_child(h1, h1_text); 2348 doc.append_child(body, p); 2349 doc.append_child(p, p_text); 2350 2351 let tree = layout_doc(&doc); 2352 let body_box = &tree.root.children[0]; 2353 let h1_box = &body_box.children[0]; 2354 let p_box = &body_box.children[1]; 2355 2356 assert!( 2357 h1_box.font_size > p_box.font_size, 2358 "h1 font_size ({}) should be > p font_size ({})", 2359 h1_box.font_size, 2360 p_box.font_size 2361 ); 2362 assert_eq!(h1_box.font_size, 32.0); 2363 2364 assert!( 2365 h1_box.rect.height > p_box.rect.height, 2366 "h1 height ({}) should be > p height ({})", 2367 h1_box.rect.height, 2368 p_box.rect.height 2369 ); 2370 } 2371 2372 #[test] 2373 fn body_has_default_margin() { 2374 let mut doc = Document::new(); 2375 let root = doc.root(); 2376 let html = doc.create_element("html"); 2377 let body = doc.create_element("body"); 2378 let p = doc.create_element("p"); 2379 let text = doc.create_text("Test"); 2380 doc.append_child(root, html); 2381 doc.append_child(html, body); 2382 doc.append_child(body, p); 2383 doc.append_child(p, text); 2384 2385 let tree = layout_doc(&doc); 2386 let body_box = &tree.root.children[0]; 2387 2388 // body default margin is 8px, but it collapses with p's 16px margin 2389 // (parent-child collapsing: no border/padding on body). 2390 assert_eq!(body_box.margin.top, 16.0); 2391 assert_eq!(body_box.margin.right, 8.0); 2392 assert_eq!(body_box.margin.bottom, 16.0); 2393 assert_eq!(body_box.margin.left, 8.0); 2394 2395 assert_eq!(body_box.rect.x, 8.0); 2396 // body.rect.y = collapsed margin (16) from viewport top. 2397 assert_eq!(body_box.rect.y, 16.0); 2398 } 2399 2400 #[test] 2401 fn text_wraps_at_container_width() { 2402 let mut doc = Document::new(); 2403 let root = doc.root(); 2404 let html = doc.create_element("html"); 2405 let body = doc.create_element("body"); 2406 let p = doc.create_element("p"); 2407 let text = 2408 doc.create_text("The quick brown fox jumps over the lazy dog and more words to wrap"); 2409 doc.append_child(root, html); 2410 doc.append_child(html, body); 2411 doc.append_child(body, p); 2412 doc.append_child(p, text); 2413 2414 let font = test_font(); 2415 let sheets = extract_stylesheets(&doc); 2416 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2417 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 2418 let body_box = &tree.root.children[0]; 2419 let p_box = &body_box.children[0]; 2420 2421 // Count distinct y-positions to count visual lines. 2422 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 2423 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 2424 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 2425 2426 assert!( 2427 ys.len() > 1, 2428 "text should wrap to multiple lines, got {} visual lines", 2429 ys.len() 2430 ); 2431 } 2432 2433 #[test] 2434 fn layout_produces_positive_dimensions() { 2435 let mut doc = Document::new(); 2436 let root = doc.root(); 2437 let html = doc.create_element("html"); 2438 let body = doc.create_element("body"); 2439 let div = doc.create_element("div"); 2440 let text = doc.create_text("Content"); 2441 doc.append_child(root, html); 2442 doc.append_child(html, body); 2443 doc.append_child(body, div); 2444 doc.append_child(div, text); 2445 2446 let tree = layout_doc(&doc); 2447 2448 for b in tree.iter() { 2449 assert!(b.rect.width >= 0.0, "width should be >= 0"); 2450 assert!(b.rect.height >= 0.0, "height should be >= 0"); 2451 } 2452 2453 assert!(tree.height > 0.0, "layout height should be > 0"); 2454 } 2455 2456 #[test] 2457 fn head_is_hidden() { 2458 let mut doc = Document::new(); 2459 let root = doc.root(); 2460 let html = doc.create_element("html"); 2461 let head = doc.create_element("head"); 2462 let title = doc.create_element("title"); 2463 let title_text = doc.create_text("Page Title"); 2464 let body = doc.create_element("body"); 2465 let p = doc.create_element("p"); 2466 let p_text = doc.create_text("Visible"); 2467 doc.append_child(root, html); 2468 doc.append_child(html, head); 2469 doc.append_child(head, title); 2470 doc.append_child(title, title_text); 2471 doc.append_child(html, body); 2472 doc.append_child(body, p); 2473 doc.append_child(p, p_text); 2474 2475 let tree = layout_doc(&doc); 2476 2477 assert_eq!( 2478 tree.root.children.len(), 2479 1, 2480 "html should have 1 child (body), head should be hidden" 2481 ); 2482 } 2483 2484 #[test] 2485 fn mixed_block_and_inline() { 2486 let mut doc = Document::new(); 2487 let root = doc.root(); 2488 let html = doc.create_element("html"); 2489 let body = doc.create_element("body"); 2490 let div = doc.create_element("div"); 2491 let text1 = doc.create_text("Text"); 2492 let p = doc.create_element("p"); 2493 let p_text = doc.create_text("Block"); 2494 let text2 = doc.create_text("More"); 2495 doc.append_child(root, html); 2496 doc.append_child(html, body); 2497 doc.append_child(body, div); 2498 doc.append_child(div, text1); 2499 doc.append_child(div, p); 2500 doc.append_child(p, p_text); 2501 doc.append_child(div, text2); 2502 2503 let tree = layout_doc(&doc); 2504 let body_box = &tree.root.children[0]; 2505 let div_box = &body_box.children[0]; 2506 2507 assert_eq!( 2508 div_box.children.len(), 2509 3, 2510 "div should have 3 children (anon, block, anon), got {}", 2511 div_box.children.len() 2512 ); 2513 2514 assert!(matches!(div_box.children[0].box_type, BoxType::Anonymous)); 2515 assert!(matches!(div_box.children[1].box_type, BoxType::Block(_))); 2516 assert!(matches!(div_box.children[2].box_type, BoxType::Anonymous)); 2517 } 2518 2519 #[test] 2520 fn inline_elements_contribute_text() { 2521 let mut doc = Document::new(); 2522 let root = doc.root(); 2523 let html = doc.create_element("html"); 2524 let body = doc.create_element("body"); 2525 let p = doc.create_element("p"); 2526 let t1 = doc.create_text("Hello "); 2527 let em = doc.create_element("em"); 2528 let t2 = doc.create_text("world"); 2529 let t3 = doc.create_text("!"); 2530 doc.append_child(root, html); 2531 doc.append_child(html, body); 2532 doc.append_child(body, p); 2533 doc.append_child(p, t1); 2534 doc.append_child(p, em); 2535 doc.append_child(em, t2); 2536 doc.append_child(p, t3); 2537 2538 let tree = layout_doc(&doc); 2539 let body_box = &tree.root.children[0]; 2540 let p_box = &body_box.children[0]; 2541 2542 assert!(!p_box.lines.is_empty()); 2543 2544 let first_y = p_box.lines[0].y; 2545 let line_texts: Vec<&str> = p_box 2546 .lines 2547 .iter() 2548 .filter(|l| (l.y - first_y).abs() < 0.01) 2549 .map(|l| l.text.as_str()) 2550 .collect(); 2551 let combined = line_texts.join(""); 2552 assert!( 2553 combined.contains("Hello") && combined.contains("world") && combined.contains("!"), 2554 "line should contain all text, got: {combined}" 2555 ); 2556 } 2557 2558 #[test] 2559 fn collapse_whitespace_works() { 2560 assert_eq!(collapse_whitespace("hello world"), "hello world"); 2561 assert_eq!(collapse_whitespace(" spaces "), " spaces "); 2562 assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs "); 2563 assert_eq!(collapse_whitespace("no-extra"), "no-extra"); 2564 assert_eq!(collapse_whitespace(" "), " "); 2565 } 2566 2567 #[test] 2568 fn content_width_respects_body_margin() { 2569 let mut doc = Document::new(); 2570 let root = doc.root(); 2571 let html = doc.create_element("html"); 2572 let body = doc.create_element("body"); 2573 let div = doc.create_element("div"); 2574 let text = doc.create_text("Content"); 2575 doc.append_child(root, html); 2576 doc.append_child(html, body); 2577 doc.append_child(body, div); 2578 doc.append_child(div, text); 2579 2580 let font = test_font(); 2581 let sheets = extract_stylesheets(&doc); 2582 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2583 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2584 let body_box = &tree.root.children[0]; 2585 2586 assert_eq!(body_box.rect.width, 784.0); 2587 2588 let div_box = &body_box.children[0]; 2589 assert_eq!(div_box.rect.width, 784.0); 2590 } 2591 2592 #[test] 2593 fn multiple_heading_levels() { 2594 let mut doc = Document::new(); 2595 let root = doc.root(); 2596 let html = doc.create_element("html"); 2597 let body = doc.create_element("body"); 2598 doc.append_child(root, html); 2599 doc.append_child(html, body); 2600 2601 let tags = ["h1", "h2", "h3"]; 2602 for tag in &tags { 2603 let h = doc.create_element(tag); 2604 let t = doc.create_text(tag); 2605 doc.append_child(body, h); 2606 doc.append_child(h, t); 2607 } 2608 2609 let tree = layout_doc(&doc); 2610 let body_box = &tree.root.children[0]; 2611 2612 let h1 = &body_box.children[0]; 2613 let h2 = &body_box.children[1]; 2614 let h3 = &body_box.children[2]; 2615 assert!(h1.font_size > h2.font_size); 2616 assert!(h2.font_size > h3.font_size); 2617 2618 assert!(h2.rect.y > h1.rect.y); 2619 assert!(h3.rect.y > h2.rect.y); 2620 } 2621 2622 #[test] 2623 fn layout_tree_iteration() { 2624 let mut doc = Document::new(); 2625 let root = doc.root(); 2626 let html = doc.create_element("html"); 2627 let body = doc.create_element("body"); 2628 let p = doc.create_element("p"); 2629 let text = doc.create_text("Test"); 2630 doc.append_child(root, html); 2631 doc.append_child(html, body); 2632 doc.append_child(body, p); 2633 doc.append_child(p, text); 2634 2635 let tree = layout_doc(&doc); 2636 let count = tree.iter().count(); 2637 assert!(count >= 3, "should have at least html, body, p boxes"); 2638 } 2639 2640 #[test] 2641 fn css_style_affects_layout() { 2642 let html_str = r#"<!DOCTYPE html> 2643<html> 2644<head> 2645<style> 2646p { margin-top: 50px; margin-bottom: 50px; } 2647</style> 2648</head> 2649<body> 2650<p>First</p> 2651<p>Second</p> 2652</body> 2653</html>"#; 2654 let doc = we_html::parse_html(html_str); 2655 let font = test_font(); 2656 let sheets = extract_stylesheets(&doc); 2657 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2658 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2659 2660 let body_box = &tree.root.children[0]; 2661 let first = &body_box.children[0]; 2662 let second = &body_box.children[1]; 2663 2664 assert_eq!(first.margin.top, 50.0); 2665 assert_eq!(first.margin.bottom, 50.0); 2666 2667 // Adjacent sibling margins collapse: gap = max(50, 50) = 50, not 100. 2668 let gap = second.rect.y - (first.rect.y + first.rect.height); 2669 assert!( 2670 (gap - 50.0).abs() < 1.0, 2671 "collapsed margin gap should be ~50px, got {gap}" 2672 ); 2673 } 2674 2675 #[test] 2676 fn inline_style_affects_layout() { 2677 let html_str = r#"<!DOCTYPE html> 2678<html> 2679<body> 2680<div style="padding-top: 20px; padding-bottom: 20px;"> 2681<p>Content</p> 2682</div> 2683</body> 2684</html>"#; 2685 let doc = we_html::parse_html(html_str); 2686 let font = test_font(); 2687 let sheets = extract_stylesheets(&doc); 2688 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2689 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2690 2691 let body_box = &tree.root.children[0]; 2692 let div_box = &body_box.children[0]; 2693 2694 assert_eq!(div_box.padding.top, 20.0); 2695 assert_eq!(div_box.padding.bottom, 20.0); 2696 } 2697 2698 #[test] 2699 fn css_color_propagates_to_layout() { 2700 let html_str = r#"<!DOCTYPE html> 2701<html> 2702<head><style>p { color: red; background-color: blue; }</style></head> 2703<body><p>Colored</p></body> 2704</html>"#; 2705 let doc = we_html::parse_html(html_str); 2706 let font = test_font(); 2707 let sheets = extract_stylesheets(&doc); 2708 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2709 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2710 2711 let body_box = &tree.root.children[0]; 2712 let p_box = &body_box.children[0]; 2713 2714 assert_eq!(p_box.color, Color::rgb(255, 0, 0)); 2715 assert_eq!(p_box.background_color, Color::rgb(0, 0, 255)); 2716 } 2717 2718 // --- New inline layout tests --- 2719 2720 #[test] 2721 fn inline_elements_have_per_fragment_styling() { 2722 let html_str = r#"<!DOCTYPE html> 2723<html> 2724<head><style>em { color: red; }</style></head> 2725<body><p>Hello <em>world</em></p></body> 2726</html>"#; 2727 let doc = we_html::parse_html(html_str); 2728 let font = test_font(); 2729 let sheets = extract_stylesheets(&doc); 2730 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2731 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2732 2733 let body_box = &tree.root.children[0]; 2734 let p_box = &body_box.children[0]; 2735 2736 let colors: Vec<Color> = p_box.lines.iter().map(|l| l.color).collect(); 2737 assert!( 2738 colors.iter().any(|c| *c == Color::rgb(0, 0, 0)), 2739 "should have black text" 2740 ); 2741 assert!( 2742 colors.iter().any(|c| *c == Color::rgb(255, 0, 0)), 2743 "should have red text from <em>" 2744 ); 2745 } 2746 2747 #[test] 2748 fn br_element_forces_line_break() { 2749 let html_str = r#"<!DOCTYPE html> 2750<html> 2751<body><p>Line one<br>Line two</p></body> 2752</html>"#; 2753 let doc = we_html::parse_html(html_str); 2754 let font = test_font(); 2755 let sheets = extract_stylesheets(&doc); 2756 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2757 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2758 2759 let body_box = &tree.root.children[0]; 2760 let p_box = &body_box.children[0]; 2761 2762 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 2763 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 2764 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 2765 2766 assert!( 2767 ys.len() >= 2, 2768 "<br> should produce 2 visual lines, got {}", 2769 ys.len() 2770 ); 2771 } 2772 2773 #[test] 2774 fn text_align_center() { 2775 let html_str = r#"<!DOCTYPE html> 2776<html> 2777<head><style>p { text-align: center; }</style></head> 2778<body><p>Hi</p></body> 2779</html>"#; 2780 let doc = we_html::parse_html(html_str); 2781 let font = test_font(); 2782 let sheets = extract_stylesheets(&doc); 2783 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2784 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2785 2786 let body_box = &tree.root.children[0]; 2787 let p_box = &body_box.children[0]; 2788 2789 assert!(!p_box.lines.is_empty()); 2790 let first = &p_box.lines[0]; 2791 // Center-aligned: text should be noticeably offset from content x. 2792 assert!( 2793 first.x > p_box.rect.x + 10.0, 2794 "center-aligned text x ({}) should be offset from content x ({})", 2795 first.x, 2796 p_box.rect.x 2797 ); 2798 } 2799 2800 #[test] 2801 fn text_align_right() { 2802 let html_str = r#"<!DOCTYPE html> 2803<html> 2804<head><style>p { text-align: right; }</style></head> 2805<body><p>Hi</p></body> 2806</html>"#; 2807 let doc = we_html::parse_html(html_str); 2808 let font = test_font(); 2809 let sheets = extract_stylesheets(&doc); 2810 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2811 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2812 2813 let body_box = &tree.root.children[0]; 2814 let p_box = &body_box.children[0]; 2815 2816 assert!(!p_box.lines.is_empty()); 2817 let first = &p_box.lines[0]; 2818 let right_edge = p_box.rect.x + p_box.rect.width; 2819 assert!( 2820 (first.x + first.width - right_edge).abs() < 1.0, 2821 "right-aligned text end ({}) should be near right edge ({})", 2822 first.x + first.width, 2823 right_edge 2824 ); 2825 } 2826 2827 #[test] 2828 fn inline_padding_offsets_text() { 2829 let html_str = r#"<!DOCTYPE html> 2830<html> 2831<head><style>span { padding-left: 20px; padding-right: 20px; }</style></head> 2832<body><p>A<span>B</span>C</p></body> 2833</html>"#; 2834 let doc = we_html::parse_html(html_str); 2835 let font = test_font(); 2836 let sheets = extract_stylesheets(&doc); 2837 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2838 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2839 2840 let body_box = &tree.root.children[0]; 2841 let p_box = &body_box.children[0]; 2842 2843 // Should have at least 3 fragments: A, B, C 2844 assert!( 2845 p_box.lines.len() >= 3, 2846 "should have fragments for A, B, C, got {}", 2847 p_box.lines.len() 2848 ); 2849 2850 // B should be offset by the span's padding. 2851 let a_frag = &p_box.lines[0]; 2852 let b_frag = &p_box.lines[1]; 2853 let gap = b_frag.x - (a_frag.x + a_frag.width); 2854 // Gap should include the 20px padding-left from the span. 2855 assert!( 2856 gap >= 19.0, 2857 "gap between A and B ({gap}) should include span padding-left (20px)" 2858 ); 2859 } 2860 2861 #[test] 2862 fn text_fragments_have_correct_font_size() { 2863 let html_str = r#"<!DOCTYPE html> 2864<html> 2865<body><h1>Big</h1><p>Small</p></body> 2866</html>"#; 2867 let doc = we_html::parse_html(html_str); 2868 let font = test_font(); 2869 let sheets = extract_stylesheets(&doc); 2870 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2871 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2872 2873 let body_box = &tree.root.children[0]; 2874 let h1_box = &body_box.children[0]; 2875 let p_box = &body_box.children[1]; 2876 2877 assert!(!h1_box.lines.is_empty()); 2878 assert!(!p_box.lines.is_empty()); 2879 assert_eq!(h1_box.lines[0].font_size, 32.0); 2880 assert_eq!(p_box.lines[0].font_size, 16.0); 2881 } 2882 2883 #[test] 2884 fn line_height_from_computed_style() { 2885 let html_str = r#"<!DOCTYPE html> 2886<html> 2887<head><style>p { line-height: 30px; }</style></head> 2888<body><p>Line one Line two Line three</p></body> 2889</html>"#; 2890 let doc = we_html::parse_html(html_str); 2891 let font = test_font(); 2892 let sheets = extract_stylesheets(&doc); 2893 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2894 // Narrow viewport to force wrapping. 2895 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 2896 2897 let body_box = &tree.root.children[0]; 2898 let p_box = &body_box.children[0]; 2899 2900 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 2901 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 2902 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 2903 2904 if ys.len() >= 2 { 2905 let gap = ys[1] - ys[0]; 2906 assert!( 2907 (gap - 30.0).abs() < 1.0, 2908 "line spacing ({gap}) should be ~30px from line-height" 2909 ); 2910 } 2911 } 2912 2913 // --- Relative positioning tests --- 2914 2915 #[test] 2916 fn relative_position_top_left() { 2917 let html_str = r#"<!DOCTYPE html> 2918<html> 2919<body> 2920<div style="position: relative; top: 10px; left: 20px;">Content</div> 2921</body> 2922</html>"#; 2923 let doc = we_html::parse_html(html_str); 2924 let font = test_font(); 2925 let sheets = extract_stylesheets(&doc); 2926 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2927 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2928 2929 let body_box = &tree.root.children[0]; 2930 let div_box = &body_box.children[0]; 2931 2932 assert_eq!(div_box.position, Position::Relative); 2933 assert_eq!(div_box.relative_offset, (20.0, 10.0)); 2934 2935 // The div should be shifted from where it would be in normal flow. 2936 // Normal flow position: body.rect.x + margin, body.rect.y + margin. 2937 // With relative offset: shifted by (20, 10). 2938 // Body has 8px margin by default, so content starts at x=8, y=8. 2939 assert!( 2940 (div_box.rect.x - (8.0 + 20.0)).abs() < 0.01, 2941 "div x ({}) should be 28.0 (8 + 20)", 2942 div_box.rect.x 2943 ); 2944 assert!( 2945 (div_box.rect.y - (8.0 + 10.0)).abs() < 0.01, 2946 "div y ({}) should be 18.0 (8 + 10)", 2947 div_box.rect.y 2948 ); 2949 } 2950 2951 #[test] 2952 fn relative_position_does_not_affect_siblings() { 2953 let html_str = r#"<!DOCTYPE html> 2954<html> 2955<head><style> 2956p { margin: 0; } 2957</style></head> 2958<body> 2959<p id="first" style="position: relative; top: 50px;">First</p> 2960<p id="second">Second</p> 2961</body> 2962</html>"#; 2963 let doc = we_html::parse_html(html_str); 2964 let font = test_font(); 2965 let sheets = extract_stylesheets(&doc); 2966 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2967 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2968 2969 let body_box = &tree.root.children[0]; 2970 let first = &body_box.children[0]; 2971 let second = &body_box.children[1]; 2972 2973 // The first paragraph is shifted down by 50px visually. 2974 assert_eq!(first.relative_offset, (0.0, 50.0)); 2975 2976 // But the second paragraph should be at its normal-flow position, 2977 // as if the first paragraph were NOT shifted. The second paragraph 2978 // should come right after the first's normal-flow height. 2979 // Body content starts at y=8 (default body margin). First p has 0 margin. 2980 // Second p should start right after first p's height (without offset). 2981 let first_normal_y = 8.0; // body margin 2982 let first_height = first.rect.height; 2983 let expected_second_y = first_normal_y + first_height; 2984 assert!( 2985 (second.rect.y - expected_second_y).abs() < 1.0, 2986 "second y ({}) should be at normal-flow position ({expected_second_y}), not affected by first's relative offset", 2987 second.rect.y 2988 ); 2989 } 2990 2991 #[test] 2992 fn relative_position_conflicting_offsets() { 2993 // When both top and bottom are specified, top wins. 2994 // When both left and right are specified, left wins. 2995 let html_str = r#"<!DOCTYPE html> 2996<html> 2997<body> 2998<div style="position: relative; top: 10px; bottom: 20px; left: 30px; right: 40px;">Content</div> 2999</body> 3000</html>"#; 3001 let doc = we_html::parse_html(html_str); 3002 let font = test_font(); 3003 let sheets = extract_stylesheets(&doc); 3004 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3005 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3006 3007 let body_box = &tree.root.children[0]; 3008 let div_box = &body_box.children[0]; 3009 3010 // top wins over bottom: dy = 10 (not -20) 3011 // left wins over right: dx = 30 (not -40) 3012 assert_eq!(div_box.relative_offset, (30.0, 10.0)); 3013 } 3014 3015 #[test] 3016 fn relative_position_auto_offsets() { 3017 // auto offsets should resolve to 0 (no movement). 3018 let html_str = r#"<!DOCTYPE html> 3019<html> 3020<body> 3021<div style="position: relative;">Content</div> 3022</body> 3023</html>"#; 3024 let doc = we_html::parse_html(html_str); 3025 let font = test_font(); 3026 let sheets = extract_stylesheets(&doc); 3027 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3028 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3029 3030 let body_box = &tree.root.children[0]; 3031 let div_box = &body_box.children[0]; 3032 3033 assert_eq!(div_box.position, Position::Relative); 3034 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 3035 } 3036 3037 #[test] 3038 fn relative_position_bottom_right() { 3039 // bottom: 15px should shift up by 15px (negative direction). 3040 // right: 25px should shift left by 25px (negative direction). 3041 let html_str = r#"<!DOCTYPE html> 3042<html> 3043<body> 3044<div style="position: relative; bottom: 15px; right: 25px;">Content</div> 3045</body> 3046</html>"#; 3047 let doc = we_html::parse_html(html_str); 3048 let font = test_font(); 3049 let sheets = extract_stylesheets(&doc); 3050 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3051 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3052 3053 let body_box = &tree.root.children[0]; 3054 let div_box = &body_box.children[0]; 3055 3056 assert_eq!(div_box.relative_offset, (-25.0, -15.0)); 3057 } 3058 3059 #[test] 3060 fn relative_position_shifts_text_lines() { 3061 let html_str = r#"<!DOCTYPE html> 3062<html> 3063<head><style>p { margin: 0; }</style></head> 3064<body> 3065<p style="position: relative; top: 30px; left: 40px;">Hello</p> 3066</body> 3067</html>"#; 3068 let doc = we_html::parse_html(html_str); 3069 let font = test_font(); 3070 let sheets = extract_stylesheets(&doc); 3071 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3072 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3073 3074 let body_box = &tree.root.children[0]; 3075 let p_box = &body_box.children[0]; 3076 3077 assert!(!p_box.lines.is_empty(), "p should have text lines"); 3078 let first_line = &p_box.lines[0]; 3079 3080 // Text should be shifted by the relative offset. 3081 // Body content starts at x=8, y=8. With offset: x=48, y=38. 3082 assert!( 3083 first_line.x >= 8.0 + 40.0 - 1.0, 3084 "text x ({}) should be shifted by left offset", 3085 first_line.x 3086 ); 3087 assert!( 3088 first_line.y >= 8.0 + 30.0 - 1.0, 3089 "text y ({}) should be shifted by top offset", 3090 first_line.y 3091 ); 3092 } 3093 3094 #[test] 3095 fn static_position_has_no_offset() { 3096 let html_str = r#"<!DOCTYPE html> 3097<html> 3098<body> 3099<div>Normal flow</div> 3100</body> 3101</html>"#; 3102 let doc = we_html::parse_html(html_str); 3103 let font = test_font(); 3104 let sheets = extract_stylesheets(&doc); 3105 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3106 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3107 3108 let body_box = &tree.root.children[0]; 3109 let div_box = &body_box.children[0]; 3110 3111 assert_eq!(div_box.position, Position::Static); 3112 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 3113 } 3114 3115 // --- Margin collapsing tests --- 3116 3117 #[test] 3118 fn adjacent_sibling_margins_collapse() { 3119 // Two <p> elements each with margin 16px: gap should be 16px (max), not 32px (sum). 3120 let html_str = r#"<!DOCTYPE html> 3121<html> 3122<head><style> 3123body { margin: 0; border-top: 1px solid black; } 3124p { margin-top: 16px; margin-bottom: 16px; } 3125</style></head> 3126<body> 3127<p>First</p> 3128<p>Second</p> 3129</body> 3130</html>"#; 3131 let doc = we_html::parse_html(html_str); 3132 let font = test_font(); 3133 let sheets = extract_stylesheets(&doc); 3134 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3135 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3136 3137 let body_box = &tree.root.children[0]; 3138 let first = &body_box.children[0]; 3139 let second = &body_box.children[1]; 3140 3141 // Gap between first's bottom border-box and second's top border-box 3142 // should be the collapsed margin: max(16, 16) = 16. 3143 let first_bottom = 3144 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3145 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3146 assert!( 3147 (gap - 16.0).abs() < 1.0, 3148 "collapsed sibling margin should be ~16px, got {gap}" 3149 ); 3150 } 3151 3152 #[test] 3153 fn sibling_margins_collapse_unequal() { 3154 // p1 bottom-margin 20, p2 top-margin 30: gap should be 30 (max). 3155 let html_str = r#"<!DOCTYPE html> 3156<html> 3157<head><style> 3158body { margin: 0; border-top: 1px solid black; } 3159.first { margin-top: 0; margin-bottom: 20px; } 3160.second { margin-top: 30px; margin-bottom: 0; } 3161</style></head> 3162<body> 3163<p class="first">First</p> 3164<p class="second">Second</p> 3165</body> 3166</html>"#; 3167 let doc = we_html::parse_html(html_str); 3168 let font = test_font(); 3169 let sheets = extract_stylesheets(&doc); 3170 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3171 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3172 3173 let body_box = &tree.root.children[0]; 3174 let first = &body_box.children[0]; 3175 let second = &body_box.children[1]; 3176 3177 let first_bottom = 3178 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3179 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3180 assert!( 3181 (gap - 30.0).abs() < 1.0, 3182 "collapsed margin should be max(20, 30) = 30, got {gap}" 3183 ); 3184 } 3185 3186 #[test] 3187 fn parent_first_child_margin_collapsing() { 3188 // Parent with no padding/border: first child's top margin collapses. 3189 let html_str = r#"<!DOCTYPE html> 3190<html> 3191<head><style> 3192body { margin: 0; border-top: 1px solid black; } 3193.parent { margin-top: 10px; } 3194.child { margin-top: 20px; } 3195</style></head> 3196<body> 3197<div class="parent"><p class="child">Child</p></div> 3198</body> 3199</html>"#; 3200 let doc = we_html::parse_html(html_str); 3201 let font = test_font(); 3202 let sheets = extract_stylesheets(&doc); 3203 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3204 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3205 3206 let body_box = &tree.root.children[0]; 3207 let parent_box = &body_box.children[0]; 3208 3209 // Parent margin collapses with child's: max(10, 20) = 20. 3210 assert_eq!(parent_box.margin.top, 20.0); 3211 } 3212 3213 #[test] 3214 fn negative_margin_collapsing() { 3215 // One positive (20) and one negative (-10): collapsed = 20 + (-10) = 10. 3216 let html_str = r#"<!DOCTYPE html> 3217<html> 3218<head><style> 3219body { margin: 0; border-top: 1px solid black; } 3220.first { margin-top: 0; margin-bottom: 20px; } 3221.second { margin-top: -10px; margin-bottom: 0; } 3222</style></head> 3223<body> 3224<p class="first">First</p> 3225<p class="second">Second</p> 3226</body> 3227</html>"#; 3228 let doc = we_html::parse_html(html_str); 3229 let font = test_font(); 3230 let sheets = extract_stylesheets(&doc); 3231 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3232 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3233 3234 let body_box = &tree.root.children[0]; 3235 let first = &body_box.children[0]; 3236 let second = &body_box.children[1]; 3237 3238 let first_bottom = 3239 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3240 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3241 // 20 + (-10) = 10 3242 assert!( 3243 (gap - 10.0).abs() < 1.0, 3244 "positive + negative margin collapse should be 10, got {gap}" 3245 ); 3246 } 3247 3248 #[test] 3249 fn both_negative_margins_collapse() { 3250 // Both negative: use the more negative value. 3251 let html_str = r#"<!DOCTYPE html> 3252<html> 3253<head><style> 3254body { margin: 0; border-top: 1px solid black; } 3255.first { margin-top: 0; margin-bottom: -10px; } 3256.second { margin-top: -20px; margin-bottom: 0; } 3257</style></head> 3258<body> 3259<p class="first">First</p> 3260<p class="second">Second</p> 3261</body> 3262</html>"#; 3263 let doc = we_html::parse_html(html_str); 3264 let font = test_font(); 3265 let sheets = extract_stylesheets(&doc); 3266 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3267 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3268 3269 let body_box = &tree.root.children[0]; 3270 let first = &body_box.children[0]; 3271 let second = &body_box.children[1]; 3272 3273 let first_bottom = 3274 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3275 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3276 // Both negative: min(-10, -20) = -20 3277 assert!( 3278 (gap - (-20.0)).abs() < 1.0, 3279 "both-negative margin collapse should be -20, got {gap}" 3280 ); 3281 } 3282 3283 #[test] 3284 fn border_blocks_margin_collapsing() { 3285 // When border separates margins, they don't collapse. 3286 let html_str = r#"<!DOCTYPE html> 3287<html> 3288<head><style> 3289body { margin: 0; border-top: 1px solid black; } 3290.first { margin-top: 0; margin-bottom: 20px; border-bottom: 1px solid black; } 3291.second { margin-top: 20px; border-top: 1px solid black; } 3292</style></head> 3293<body> 3294<p class="first">First</p> 3295<p class="second">Second</p> 3296</body> 3297</html>"#; 3298 let doc = we_html::parse_html(html_str); 3299 let font = test_font(); 3300 let sheets = extract_stylesheets(&doc); 3301 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3302 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3303 3304 let body_box = &tree.root.children[0]; 3305 let first = &body_box.children[0]; 3306 let second = &body_box.children[1]; 3307 3308 // Borders are on the elements themselves, but the MARGINS are still 3309 // between the border boxes — sibling margins still collapse regardless 3310 // of borders on the elements. The margin gap = max(20, 20) = 20. 3311 let first_bottom = 3312 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3313 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3314 assert!( 3315 (gap - 20.0).abs() < 1.0, 3316 "sibling margins collapse even with borders on elements, gap should be 20, got {gap}" 3317 ); 3318 } 3319 3320 #[test] 3321 fn padding_blocks_parent_child_collapsing() { 3322 // Parent with padding-top prevents margin collapsing with first child. 3323 let html_str = r#"<!DOCTYPE html> 3324<html> 3325<head><style> 3326body { margin: 0; border-top: 1px solid black; } 3327.parent { margin-top: 10px; padding-top: 5px; } 3328.child { margin-top: 20px; } 3329</style></head> 3330<body> 3331<div class="parent"><p class="child">Child</p></div> 3332</body> 3333</html>"#; 3334 let doc = we_html::parse_html(html_str); 3335 let font = test_font(); 3336 let sheets = extract_stylesheets(&doc); 3337 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3338 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3339 3340 let body_box = &tree.root.children[0]; 3341 let parent_box = &body_box.children[0]; 3342 3343 // Parent has padding-top, so no collapsing: margin stays at 10. 3344 assert_eq!(parent_box.margin.top, 10.0); 3345 } 3346 3347 #[test] 3348 fn empty_block_margins_collapse() { 3349 // An empty div's top and bottom margins collapse with adjacent margins. 3350 let html_str = r#"<!DOCTYPE html> 3351<html> 3352<head><style> 3353body { margin: 0; border-top: 1px solid black; } 3354.spacer { margin-top: 10px; margin-bottom: 10px; } 3355p { margin-top: 5px; margin-bottom: 5px; } 3356</style></head> 3357<body> 3358<p>Before</p> 3359<div class="spacer"></div> 3360<p>After</p> 3361</body> 3362</html>"#; 3363 let doc = we_html::parse_html(html_str); 3364 let font = test_font(); 3365 let sheets = extract_stylesheets(&doc); 3366 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3367 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3368 3369 let body_box = &tree.root.children[0]; 3370 let before = &body_box.children[0]; 3371 let after = &body_box.children[2]; // [0]=p, [1]=empty div, [2]=p 3372 3373 // Empty div's margins (10+10) self-collapse to max(10,10)=10. 3374 // Then collapse with before's bottom (5) and after's top (5): 3375 // collapse(5, collapse(10, 10)) = collapse(5, 10) = 10 3376 // Then collapse(10, 5) = 10. 3377 // So total gap between before and after = 10. 3378 let before_bottom = 3379 before.rect.y + before.rect.height + before.padding.bottom + before.border.bottom; 3380 let gap = after.rect.y - after.border.top - after.padding.top - before_bottom; 3381 assert!( 3382 (gap - 10.0).abs() < 1.0, 3383 "empty block margin collapse gap should be ~10px, got {gap}" 3384 ); 3385 } 3386 3387 #[test] 3388 fn collapse_margins_unit() { 3389 // Unit tests for the collapse_margins helper. 3390 assert_eq!(collapse_margins(10.0, 20.0), 20.0); 3391 assert_eq!(collapse_margins(20.0, 10.0), 20.0); 3392 assert_eq!(collapse_margins(0.0, 15.0), 15.0); 3393 assert_eq!(collapse_margins(-5.0, -10.0), -10.0); 3394 assert_eq!(collapse_margins(20.0, -5.0), 15.0); 3395 assert_eq!(collapse_margins(-5.0, 20.0), 15.0); 3396 assert_eq!(collapse_margins(0.0, 0.0), 0.0); 3397 } 3398 3399 // --- Box-sizing tests --- 3400 3401 #[test] 3402 fn content_box_default_width_applies_to_content() { 3403 // Default box-sizing (content-box): width = content width only. 3404 let html_str = r#"<!DOCTYPE html> 3405<html> 3406<head><style> 3407body { margin: 0; } 3408div { width: 200px; padding: 10px; border: 5px solid black; } 3409</style></head> 3410<body> 3411<div>Content</div> 3412</body> 3413</html>"#; 3414 let doc = we_html::parse_html(html_str); 3415 let font = test_font(); 3416 let sheets = extract_stylesheets(&doc); 3417 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3418 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3419 3420 let body_box = &tree.root.children[0]; 3421 let div_box = &body_box.children[0]; 3422 3423 // content-box: rect.width = 200 (the specified width IS the content) 3424 assert_eq!(div_box.rect.width, 200.0); 3425 assert_eq!(div_box.padding.left, 10.0); 3426 assert_eq!(div_box.padding.right, 10.0); 3427 assert_eq!(div_box.border.left, 5.0); 3428 assert_eq!(div_box.border.right, 5.0); 3429 } 3430 3431 #[test] 3432 fn border_box_width_includes_padding_and_border() { 3433 // box-sizing: border-box: width includes padding and border. 3434 let html_str = r#"<!DOCTYPE html> 3435<html> 3436<head><style> 3437body { margin: 0; } 3438div { box-sizing: border-box; width: 200px; padding: 10px; border: 5px solid black; } 3439</style></head> 3440<body> 3441<div>Content</div> 3442</body> 3443</html>"#; 3444 let doc = we_html::parse_html(html_str); 3445 let font = test_font(); 3446 let sheets = extract_stylesheets(&doc); 3447 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3448 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3449 3450 let body_box = &tree.root.children[0]; 3451 let div_box = &body_box.children[0]; 3452 3453 // border-box: content width = 200 - 10*2 (padding) - 5*2 (border) = 170 3454 assert_eq!(div_box.rect.width, 170.0); 3455 assert_eq!(div_box.padding.left, 10.0); 3456 assert_eq!(div_box.padding.right, 10.0); 3457 assert_eq!(div_box.border.left, 5.0); 3458 assert_eq!(div_box.border.right, 5.0); 3459 } 3460 3461 #[test] 3462 fn border_box_padding_exceeds_width_clamps_to_zero() { 3463 // border-box with padding+border > specified width: content clamps to 0. 3464 let html_str = r#"<!DOCTYPE html> 3465<html> 3466<head><style> 3467body { margin: 0; } 3468div { box-sizing: border-box; width: 20px; padding: 15px; border: 5px solid black; } 3469</style></head> 3470<body> 3471<div>X</div> 3472</body> 3473</html>"#; 3474 let doc = we_html::parse_html(html_str); 3475 let font = test_font(); 3476 let sheets = extract_stylesheets(&doc); 3477 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3478 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3479 3480 let body_box = &tree.root.children[0]; 3481 let div_box = &body_box.children[0]; 3482 3483 // border-box: content = 20 - 15*2 - 5*2 = 20 - 40 = -20 → clamped to 0 3484 assert_eq!(div_box.rect.width, 0.0); 3485 } 3486 3487 #[test] 3488 fn box_sizing_is_not_inherited() { 3489 // box-sizing is not inherited: child should use default content-box. 3490 let html_str = r#"<!DOCTYPE html> 3491<html> 3492<head><style> 3493body { margin: 0; } 3494.parent { box-sizing: border-box; width: 300px; padding: 10px; border: 5px solid black; } 3495.child { width: 100px; padding: 10px; border: 5px solid black; } 3496</style></head> 3497<body> 3498<div class="parent"><div class="child">Inner</div></div> 3499</body> 3500</html>"#; 3501 let doc = we_html::parse_html(html_str); 3502 let font = test_font(); 3503 let sheets = extract_stylesheets(&doc); 3504 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3505 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3506 3507 let body_box = &tree.root.children[0]; 3508 let parent_box = &body_box.children[0]; 3509 let child_box = &parent_box.children[0]; 3510 3511 // Parent: border-box → content = 300 - 20 - 10 = 270 3512 assert_eq!(parent_box.rect.width, 270.0); 3513 // Child: default content-box → content = 100 (not reduced by padding/border) 3514 assert_eq!(child_box.rect.width, 100.0); 3515 } 3516 3517 #[test] 3518 fn border_box_height() { 3519 // box-sizing: border-box also applies to height. 3520 let html_str = r#"<!DOCTYPE html> 3521<html> 3522<head><style> 3523body { margin: 0; } 3524div { box-sizing: border-box; height: 100px; padding: 10px; border: 5px solid black; } 3525</style></head> 3526<body> 3527<div>Content</div> 3528</body> 3529</html>"#; 3530 let doc = we_html::parse_html(html_str); 3531 let font = test_font(); 3532 let sheets = extract_stylesheets(&doc); 3533 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3534 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3535 3536 let body_box = &tree.root.children[0]; 3537 let div_box = &body_box.children[0]; 3538 3539 // border-box: content height = 100 - 10*2 (padding) - 5*2 (border) = 70 3540 assert_eq!(div_box.rect.height, 70.0); 3541 } 3542 3543 #[test] 3544 fn content_box_explicit_height() { 3545 // content-box: height applies to content only. 3546 let html_str = r#"<!DOCTYPE html> 3547<html> 3548<head><style> 3549body { margin: 0; } 3550div { height: 100px; padding: 10px; border: 5px solid black; } 3551</style></head> 3552<body> 3553<div>Content</div> 3554</body> 3555</html>"#; 3556 let doc = we_html::parse_html(html_str); 3557 let font = test_font(); 3558 let sheets = extract_stylesheets(&doc); 3559 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3560 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3561 3562 let body_box = &tree.root.children[0]; 3563 let div_box = &body_box.children[0]; 3564 3565 // content-box: rect.height = 100 (specified height IS content height) 3566 assert_eq!(div_box.rect.height, 100.0); 3567 } 3568 3569 // --- Visibility / display:none tests --- 3570 3571 #[test] 3572 fn display_none_excludes_from_layout_tree() { 3573 let html_str = r#"<!DOCTYPE html> 3574<html> 3575<head><style> 3576body { margin: 0; } 3577.hidden { display: none; } 3578p { margin: 0; } 3579</style></head> 3580<body> 3581<p>First</p> 3582<div class="hidden">Hidden content</div> 3583<p>Second</p> 3584</body> 3585</html>"#; 3586 let doc = we_html::parse_html(html_str); 3587 let font = test_font(); 3588 let sheets = extract_stylesheets(&doc); 3589 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3590 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3591 3592 let body_box = &tree.root.children[0]; 3593 // display:none element is excluded — body should have only 2 children. 3594 assert_eq!(body_box.children.len(), 2); 3595 3596 let first = &body_box.children[0]; 3597 let second = &body_box.children[1]; 3598 // Second paragraph should be directly below first (no gap for hidden). 3599 assert!( 3600 second.rect.y == first.rect.y + first.rect.height, 3601 "display:none should not occupy space" 3602 ); 3603 } 3604 3605 #[test] 3606 fn visibility_hidden_preserves_layout_space() { 3607 let html_str = r#"<!DOCTYPE html> 3608<html> 3609<head><style> 3610body { margin: 0; } 3611.hidden { visibility: hidden; height: 50px; margin: 0; } 3612p { margin: 0; } 3613</style></head> 3614<body> 3615<p>First</p> 3616<div class="hidden">Hidden</div> 3617<p>Second</p> 3618</body> 3619</html>"#; 3620 let doc = we_html::parse_html(html_str); 3621 let font = test_font(); 3622 let sheets = extract_stylesheets(&doc); 3623 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3624 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3625 3626 let body_box = &tree.root.children[0]; 3627 // visibility:hidden still in layout tree — body has 3 children. 3628 assert_eq!(body_box.children.len(), 3); 3629 3630 let hidden_box = &body_box.children[1]; 3631 assert_eq!(hidden_box.visibility, Visibility::Hidden); 3632 assert_eq!(hidden_box.rect.height, 50.0); 3633 3634 let second = &body_box.children[2]; 3635 // Second paragraph should be below hidden div (it occupies 50px). 3636 assert!( 3637 second.rect.y >= hidden_box.rect.y + 50.0, 3638 "visibility:hidden should preserve layout space" 3639 ); 3640 } 3641 3642 #[test] 3643 fn visibility_inherited_by_children() { 3644 let html_str = r#"<!DOCTYPE html> 3645<html> 3646<head><style> 3647body { margin: 0; } 3648.parent { visibility: hidden; } 3649</style></head> 3650<body> 3651<div class="parent"><p>Child</p></div> 3652</body> 3653</html>"#; 3654 let doc = we_html::parse_html(html_str); 3655 let font = test_font(); 3656 let sheets = extract_stylesheets(&doc); 3657 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3658 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3659 3660 let body_box = &tree.root.children[0]; 3661 let parent_box = &body_box.children[0]; 3662 let child_box = &parent_box.children[0]; 3663 assert_eq!(parent_box.visibility, Visibility::Hidden); 3664 assert_eq!(child_box.visibility, Visibility::Hidden); 3665 } 3666 3667 #[test] 3668 fn visibility_visible_overrides_hidden_parent() { 3669 let html_str = r#"<!DOCTYPE html> 3670<html> 3671<head><style> 3672body { margin: 0; } 3673.parent { visibility: hidden; } 3674.child { visibility: visible; } 3675</style></head> 3676<body> 3677<div class="parent"><p class="child">Visible child</p></div> 3678</body> 3679</html>"#; 3680 let doc = we_html::parse_html(html_str); 3681 let font = test_font(); 3682 let sheets = extract_stylesheets(&doc); 3683 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3684 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3685 3686 let body_box = &tree.root.children[0]; 3687 let parent_box = &body_box.children[0]; 3688 let child_box = &parent_box.children[0]; 3689 assert_eq!(parent_box.visibility, Visibility::Hidden); 3690 assert_eq!(child_box.visibility, Visibility::Visible); 3691 } 3692 3693 #[test] 3694 fn visibility_collapse_on_non_table_treated_as_hidden() { 3695 let html_str = r#"<!DOCTYPE html> 3696<html> 3697<head><style> 3698body { margin: 0; } 3699div { visibility: collapse; height: 50px; } 3700</style></head> 3701<body> 3702<div>Collapsed</div> 3703</body> 3704</html>"#; 3705 let doc = we_html::parse_html(html_str); 3706 let font = test_font(); 3707 let sheets = extract_stylesheets(&doc); 3708 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3709 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3710 3711 let body_box = &tree.root.children[0]; 3712 let div_box = &body_box.children[0]; 3713 assert_eq!(div_box.visibility, Visibility::Collapse); 3714 // Still occupies space (non-table collapse = hidden behavior). 3715 assert_eq!(div_box.rect.height, 50.0); 3716 } 3717 3718 // --- Viewport units and percentage resolution tests --- 3719 3720 #[test] 3721 fn width_50_percent_resolves_to_half_containing_block() { 3722 let html_str = r#"<!DOCTYPE html> 3723<html> 3724<head><style> 3725body { margin: 0; } 3726div { width: 50%; } 3727</style></head> 3728<body><div>Half width</div></body> 3729</html>"#; 3730 let doc = we_html::parse_html(html_str); 3731 let font = test_font(); 3732 let sheets = extract_stylesheets(&doc); 3733 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3734 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3735 3736 let body_box = &tree.root.children[0]; 3737 let div_box = &body_box.children[0]; 3738 assert!( 3739 (div_box.rect.width - 400.0).abs() < 0.01, 3740 "width: 50% should be 400px on 800px viewport, got {}", 3741 div_box.rect.width 3742 ); 3743 } 3744 3745 #[test] 3746 fn margin_10_percent_resolves_against_containing_block_width() { 3747 let html_str = r#"<!DOCTYPE html> 3748<html> 3749<head><style> 3750body { margin: 0; } 3751div { margin: 10%; width: 100px; height: 50px; } 3752</style></head> 3753<body><div>Box</div></body> 3754</html>"#; 3755 let doc = we_html::parse_html(html_str); 3756 let font = test_font(); 3757 let sheets = extract_stylesheets(&doc); 3758 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3759 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3760 3761 let body_box = &tree.root.children[0]; 3762 let div_box = &body_box.children[0]; 3763 // All margins (including top/bottom) resolve against containing block WIDTH. 3764 assert!( 3765 (div_box.margin.left - 80.0).abs() < 0.01, 3766 "margin-left: 10% should be 80px on 800px viewport, got {}", 3767 div_box.margin.left 3768 ); 3769 assert!( 3770 (div_box.margin.top - 80.0).abs() < 0.01, 3771 "margin-top: 10% should be 80px (against width, not height), got {}", 3772 div_box.margin.top 3773 ); 3774 } 3775 3776 #[test] 3777 fn width_50vw_resolves_to_half_viewport() { 3778 let html_str = r#"<!DOCTYPE html> 3779<html> 3780<head><style> 3781body { margin: 0; } 3782div { width: 50vw; } 3783</style></head> 3784<body><div>Half viewport</div></body> 3785</html>"#; 3786 let doc = we_html::parse_html(html_str); 3787 let font = test_font(); 3788 let sheets = extract_stylesheets(&doc); 3789 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3790 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3791 3792 let body_box = &tree.root.children[0]; 3793 let div_box = &body_box.children[0]; 3794 assert!( 3795 (div_box.rect.width - 400.0).abs() < 0.01, 3796 "width: 50vw should be 400px on 800px viewport, got {}", 3797 div_box.rect.width 3798 ); 3799 } 3800 3801 #[test] 3802 fn height_100vh_resolves_to_full_viewport() { 3803 let html_str = r#"<!DOCTYPE html> 3804<html> 3805<head><style> 3806body { margin: 0; } 3807div { height: 100vh; } 3808</style></head> 3809<body><div>Full height</div></body> 3810</html>"#; 3811 let doc = we_html::parse_html(html_str); 3812 let font = test_font(); 3813 let sheets = extract_stylesheets(&doc); 3814 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3815 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3816 3817 let body_box = &tree.root.children[0]; 3818 let div_box = &body_box.children[0]; 3819 assert!( 3820 (div_box.rect.height - 600.0).abs() < 0.01, 3821 "height: 100vh should be 600px on 600px viewport, got {}", 3822 div_box.rect.height 3823 ); 3824 } 3825 3826 #[test] 3827 fn font_size_5vmin_resolves_to_smaller_dimension() { 3828 let html_str = r#"<!DOCTYPE html> 3829<html> 3830<head><style> 3831body { margin: 0; } 3832p { font-size: 5vmin; } 3833</style></head> 3834<body><p>Text</p></body> 3835</html>"#; 3836 let doc = we_html::parse_html(html_str); 3837 let font = test_font(); 3838 let sheets = extract_stylesheets(&doc); 3839 // viewport 800x600 → vmin = 600 3840 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3841 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3842 3843 let body_box = &tree.root.children[0]; 3844 let p_box = &body_box.children[0]; 3845 // 5vmin = 5% of min(800, 600) = 5% of 600 = 30px 3846 assert!( 3847 (p_box.font_size - 30.0).abs() < 0.01, 3848 "font-size: 5vmin should be 30px, got {}", 3849 p_box.font_size 3850 ); 3851 } 3852 3853 #[test] 3854 fn nested_percentage_widths_compound() { 3855 let html_str = r#"<!DOCTYPE html> 3856<html> 3857<head><style> 3858body { margin: 0; } 3859.outer { width: 50%; } 3860.inner { width: 50%; } 3861</style></head> 3862<body> 3863<div class="outer"><div class="inner">Nested</div></div> 3864</body> 3865</html>"#; 3866 let doc = we_html::parse_html(html_str); 3867 let font = test_font(); 3868 let sheets = extract_stylesheets(&doc); 3869 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3870 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3871 3872 let body_box = &tree.root.children[0]; 3873 let outer_box = &body_box.children[0]; 3874 let inner_box = &outer_box.children[0]; 3875 // outer = 50% of 800 = 400 3876 assert!( 3877 (outer_box.rect.width - 400.0).abs() < 0.01, 3878 "outer width should be 400px, got {}", 3879 outer_box.rect.width 3880 ); 3881 // inner = 50% of 400 = 200 3882 assert!( 3883 (inner_box.rect.width - 200.0).abs() < 0.01, 3884 "inner width should be 200px (50% of 400), got {}", 3885 inner_box.rect.width 3886 ); 3887 } 3888 3889 #[test] 3890 fn padding_percent_resolves_against_width() { 3891 let html_str = r#"<!DOCTYPE html> 3892<html> 3893<head><style> 3894body { margin: 0; } 3895div { padding: 5%; width: 400px; } 3896</style></head> 3897<body><div>Content</div></body> 3898</html>"#; 3899 let doc = we_html::parse_html(html_str); 3900 let font = test_font(); 3901 let sheets = extract_stylesheets(&doc); 3902 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3903 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3904 3905 let body_box = &tree.root.children[0]; 3906 let div_box = &body_box.children[0]; 3907 // padding: 5% resolves against containing block width (800px) 3908 assert!( 3909 (div_box.padding.top - 40.0).abs() < 0.01, 3910 "padding-top: 5% should be 40px (5% of 800), got {}", 3911 div_box.padding.top 3912 ); 3913 assert!( 3914 (div_box.padding.left - 40.0).abs() < 0.01, 3915 "padding-left: 5% should be 40px (5% of 800), got {}", 3916 div_box.padding.left 3917 ); 3918 } 3919 3920 #[test] 3921 fn vmax_uses_larger_dimension() { 3922 let html_str = r#"<!DOCTYPE html> 3923<html> 3924<head><style> 3925body { margin: 0; } 3926div { width: 10vmax; } 3927</style></head> 3928<body><div>Content</div></body> 3929</html>"#; 3930 let doc = we_html::parse_html(html_str); 3931 let font = test_font(); 3932 let sheets = extract_stylesheets(&doc); 3933 // viewport 800x600 → vmax = 800 3934 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3935 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3936 3937 let body_box = &tree.root.children[0]; 3938 let div_box = &body_box.children[0]; 3939 // 10vmax = 10% of max(800, 600) = 10% of 800 = 80px 3940 assert!( 3941 (div_box.rect.width - 80.0).abs() < 0.01, 3942 "width: 10vmax should be 80px, got {}", 3943 div_box.rect.width 3944 ); 3945 } 3946 3947 #[test] 3948 fn height_50_percent_resolves_against_viewport() { 3949 let html_str = r#"<!DOCTYPE html> 3950<html> 3951<head><style> 3952body { margin: 0; } 3953div { height: 50%; } 3954</style></head> 3955<body><div>Half height</div></body> 3956</html>"#; 3957 let doc = we_html::parse_html(html_str); 3958 let font = test_font(); 3959 let sheets = extract_stylesheets(&doc); 3960 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3961 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3962 3963 let body_box = &tree.root.children[0]; 3964 let div_box = &body_box.children[0]; 3965 // height: 50% resolves against viewport height (600) 3966 assert!( 3967 (div_box.rect.height - 300.0).abs() < 0.01, 3968 "height: 50% should be 300px on 600px viewport, got {}", 3969 div_box.rect.height 3970 ); 3971 } 3972 3973 // ----------------------------------------------------------------------- 3974 // Flexbox tests 3975 // ----------------------------------------------------------------------- 3976 3977 #[test] 3978 fn flex_row_default_items_laid_out_horizontally() { 3979 let html_str = r#"<!DOCTYPE html> 3980<html><head><style> 3981body { margin: 0; } 3982.container { display: flex; width: 600px; } 3983.item { width: 100px; height: 50px; } 3984</style></head> 3985<body> 3986<div class="container"> 3987 <div class="item">A</div> 3988 <div class="item">B</div> 3989 <div class="item">C</div> 3990</div> 3991</body></html>"#; 3992 let doc = we_html::parse_html(html_str); 3993 let tree = layout_doc(&doc); 3994 3995 let body = &tree.root.children[0]; 3996 let container = &body.children[0]; 3997 assert_eq!(container.children.len(), 3); 3998 3999 // Items should be at x=0, 100, 200 4000 assert!( 4001 (container.children[0].rect.x - 0.0).abs() < 1.0, 4002 "first item x should be 0, got {}", 4003 container.children[0].rect.x 4004 ); 4005 assert!( 4006 (container.children[1].rect.x - 100.0).abs() < 1.0, 4007 "second item x should be 100, got {}", 4008 container.children[1].rect.x 4009 ); 4010 assert!( 4011 (container.children[2].rect.x - 200.0).abs() < 1.0, 4012 "third item x should be 200, got {}", 4013 container.children[2].rect.x 4014 ); 4015 4016 // All items should have the same y. 4017 let y0 = container.children[0].rect.y; 4018 assert!((container.children[1].rect.y - y0).abs() < 1.0); 4019 assert!((container.children[2].rect.y - y0).abs() < 1.0); 4020 } 4021 4022 #[test] 4023 fn flex_direction_column() { 4024 let html_str = r#"<!DOCTYPE html> 4025<html><head><style> 4026body { margin: 0; } 4027.container { display: flex; flex-direction: column; width: 600px; } 4028.item { height: 50px; } 4029</style></head> 4030<body> 4031<div class="container"> 4032 <div class="item">A</div> 4033 <div class="item">B</div> 4034 <div class="item">C</div> 4035</div> 4036</body></html>"#; 4037 let doc = we_html::parse_html(html_str); 4038 let tree = layout_doc(&doc); 4039 4040 let body = &tree.root.children[0]; 4041 let container = &body.children[0]; 4042 assert_eq!(container.children.len(), 3); 4043 4044 // Items should be stacked vertically at y offsets: 0, 50, 100 (relative to container). 4045 let cy = container.rect.y; 4046 assert!((container.children[0].rect.y - cy).abs() < 1.0); 4047 assert!( 4048 (container.children[1].rect.y - cy - 50.0).abs() < 1.0, 4049 "second item y should be {}, got {}", 4050 cy + 50.0, 4051 container.children[1].rect.y 4052 ); 4053 assert!( 4054 (container.children[2].rect.y - cy - 100.0).abs() < 1.0, 4055 "third item y should be {}, got {}", 4056 cy + 100.0, 4057 container.children[2].rect.y 4058 ); 4059 } 4060 4061 #[test] 4062 fn flex_grow_distributes_space() { 4063 let html_str = r#"<!DOCTYPE html> 4064<html><head><style> 4065body { margin: 0; } 4066.container { display: flex; width: 600px; } 4067.a { flex-grow: 1; flex-basis: 0; height: 50px; } 4068.b { flex-grow: 2; flex-basis: 0; height: 50px; } 4069.c { flex-grow: 1; flex-basis: 0; height: 50px; } 4070</style></head> 4071<body> 4072<div class="container"> 4073 <div class="a">A</div> 4074 <div class="b">B</div> 4075 <div class="c">C</div> 4076</div> 4077</body></html>"#; 4078 let doc = we_html::parse_html(html_str); 4079 let tree = layout_doc(&doc); 4080 4081 let body = &tree.root.children[0]; 4082 let container = &body.children[0]; 4083 4084 // flex-grow 1:2:1 should split 600px as 150:300:150 4085 let a = &container.children[0]; 4086 let b = &container.children[1]; 4087 let c = &container.children[2]; 4088 4089 assert!( 4090 (a.rect.width - 150.0).abs() < 1.0, 4091 "item A width should be 150, got {}", 4092 a.rect.width 4093 ); 4094 assert!( 4095 (b.rect.width - 300.0).abs() < 1.0, 4096 "item B width should be 300, got {}", 4097 b.rect.width 4098 ); 4099 assert!( 4100 (c.rect.width - 150.0).abs() < 1.0, 4101 "item C width should be 150, got {}", 4102 c.rect.width 4103 ); 4104 } 4105 4106 #[test] 4107 fn flex_justify_content_center() { 4108 let html_str = r#"<!DOCTYPE html> 4109<html><head><style> 4110body { margin: 0; } 4111.container { display: flex; width: 600px; justify-content: center; } 4112.item { width: 100px; height: 50px; } 4113</style></head> 4114<body> 4115<div class="container"> 4116 <div class="item">A</div> 4117 <div class="item">B</div> 4118</div> 4119</body></html>"#; 4120 let doc = we_html::parse_html(html_str); 4121 let tree = layout_doc(&doc); 4122 4123 let body = &tree.root.children[0]; 4124 let container = &body.children[0]; 4125 4126 // 600px container, 200px of items, 400px free space, offset = 200. 4127 let a = &container.children[0]; 4128 let b = &container.children[1]; 4129 4130 assert!( 4131 (a.rect.x - 200.0).abs() < 1.0, 4132 "first item x should be 200, got {}", 4133 a.rect.x 4134 ); 4135 assert!( 4136 (b.rect.x - 300.0).abs() < 1.0, 4137 "second item x should be 300, got {}", 4138 b.rect.x 4139 ); 4140 } 4141 4142 #[test] 4143 fn flex_justify_content_space_between() { 4144 let html_str = r#"<!DOCTYPE html> 4145<html><head><style> 4146body { margin: 0; } 4147.container { display: flex; width: 600px; justify-content: space-between; } 4148.item { width: 100px; height: 50px; } 4149</style></head> 4150<body> 4151<div class="container"> 4152 <div class="item">A</div> 4153 <div class="item">B</div> 4154 <div class="item">C</div> 4155</div> 4156</body></html>"#; 4157 let doc = we_html::parse_html(html_str); 4158 let tree = layout_doc(&doc); 4159 4160 let body = &tree.root.children[0]; 4161 let container = &body.children[0]; 4162 4163 // 3 items of 100px each = 300px, 300px free, between = 150 4164 // Positions: 0, 250, 500 4165 let a = &container.children[0]; 4166 let b = &container.children[1]; 4167 let c = &container.children[2]; 4168 4169 assert!( 4170 (a.rect.x - 0.0).abs() < 1.0, 4171 "first item x should be 0, got {}", 4172 a.rect.x 4173 ); 4174 assert!( 4175 (b.rect.x - 250.0).abs() < 1.0, 4176 "second item x should be 250, got {}", 4177 b.rect.x 4178 ); 4179 assert!( 4180 (c.rect.x - 500.0).abs() < 1.0, 4181 "third item x should be 500, got {}", 4182 c.rect.x 4183 ); 4184 } 4185 4186 #[test] 4187 fn flex_align_items_center() { 4188 let html_str = r#"<!DOCTYPE html> 4189<html><head><style> 4190body { margin: 0; } 4191.container { display: flex; width: 600px; height: 200px; align-items: center; } 4192.item { width: 100px; height: 50px; } 4193</style></head> 4194<body> 4195<div class="container"> 4196 <div class="item">A</div> 4197</div> 4198</body></html>"#; 4199 let doc = we_html::parse_html(html_str); 4200 let tree = layout_doc(&doc); 4201 4202 let body = &tree.root.children[0]; 4203 let container = &body.children[0]; 4204 let a = &container.children[0]; 4205 4206 // Container height=200, item height=50, centered at y = container.y + 75 4207 let expected_y = container.rect.y + 75.0; 4208 assert!( 4209 (a.rect.y - expected_y).abs() < 1.0, 4210 "item y should be {}, got {}", 4211 expected_y, 4212 a.rect.y 4213 ); 4214 } 4215 4216 #[test] 4217 fn flex_wrap_wraps_to_new_line() { 4218 let html_str = r#"<!DOCTYPE html> 4219<html><head><style> 4220body { margin: 0; } 4221.container { display: flex; flex-wrap: wrap; width: 250px; } 4222.item { width: 100px; height: 50px; } 4223</style></head> 4224<body> 4225<div class="container"> 4226 <div class="item">A</div> 4227 <div class="item">B</div> 4228 <div class="item">C</div> 4229</div> 4230</body></html>"#; 4231 let doc = we_html::parse_html(html_str); 4232 let tree = layout_doc(&doc); 4233 4234 let body = &tree.root.children[0]; 4235 let container = &body.children[0]; 4236 4237 // 250px container, 100px items: A and B fit on line 1, C wraps to line 2 4238 let a = &container.children[0]; 4239 let b = &container.children[1]; 4240 let c = &container.children[2]; 4241 4242 // A and B should be on the same row 4243 assert!((a.rect.y - b.rect.y).abs() < 1.0); 4244 // C should be on a different row (50px below) 4245 assert!( 4246 (c.rect.y - a.rect.y - 50.0).abs() < 1.0, 4247 "C should be 50px below A, got y diff {}", 4248 c.rect.y - a.rect.y 4249 ); 4250 } 4251 4252 #[test] 4253 fn flex_gap_adds_spacing() { 4254 let html_str = r#"<!DOCTYPE html> 4255<html><head><style> 4256body { margin: 0; } 4257.container { display: flex; width: 600px; gap: 20px; } 4258.item { width: 100px; height: 50px; } 4259</style></head> 4260<body> 4261<div class="container"> 4262 <div class="item">A</div> 4263 <div class="item">B</div> 4264 <div class="item">C</div> 4265</div> 4266</body></html>"#; 4267 let doc = we_html::parse_html(html_str); 4268 let tree = layout_doc(&doc); 4269 4270 let body = &tree.root.children[0]; 4271 let container = &body.children[0]; 4272 4273 let a = &container.children[0]; 4274 let b = &container.children[1]; 4275 let c = &container.children[2]; 4276 4277 // With gap: 20px, positions should be: 0, 120, 240 4278 assert!((a.rect.x - 0.0).abs() < 1.0); 4279 assert!( 4280 (b.rect.x - 120.0).abs() < 1.0, 4281 "B x should be 120, got {}", 4282 b.rect.x 4283 ); 4284 assert!( 4285 (c.rect.x - 240.0).abs() < 1.0, 4286 "C x should be 240, got {}", 4287 c.rect.x 4288 ); 4289 } 4290 4291 #[test] 4292 fn flex_order_changes_visual_order() { 4293 let html_str = r#"<!DOCTYPE html> 4294<html><head><style> 4295body { margin: 0; } 4296.container { display: flex; width: 600px; } 4297.a { width: 100px; height: 50px; order: 2; } 4298.b { width: 100px; height: 50px; order: 1; } 4299.c { width: 100px; height: 50px; order: 3; } 4300</style></head> 4301<body> 4302<div class="container"> 4303 <div class="a">A</div> 4304 <div class="b">B</div> 4305 <div class="c">C</div> 4306</div> 4307</body></html>"#; 4308 let doc = we_html::parse_html(html_str); 4309 let tree = layout_doc(&doc); 4310 4311 let body = &tree.root.children[0]; 4312 let container = &body.children[0]; 4313 4314 // DOM order: A(order:2), B(order:1), C(order:3) 4315 // Visual order: B(1), A(2), C(3) 4316 // B is at index 1 in DOM, A at 0, C at 2. 4317 // B (index 1) should be first visually (x ≈ 0) 4318 // A (index 0) should be second visually (x ≈ 100) 4319 // C (index 2) should be third visually (x ≈ 200) 4320 let a = &container.children[0]; // DOM index 0, order 2 4321 let b = &container.children[1]; // DOM index 1, order 1 4322 let c = &container.children[2]; // DOM index 2, order 3 4323 4324 assert!( 4325 b.rect.x < a.rect.x, 4326 "B (order:1) should be before A (order:2), B.x={} A.x={}", 4327 b.rect.x, 4328 a.rect.x 4329 ); 4330 assert!( 4331 a.rect.x < c.rect.x, 4332 "A (order:2) should be before C (order:3), A.x={} C.x={}", 4333 a.rect.x, 4334 c.rect.x 4335 ); 4336 } 4337 4338 #[test] 4339 fn flex_shrink_items() { 4340 let html_str = r#"<!DOCTYPE html> 4341<html><head><style> 4342body { margin: 0; } 4343.container { display: flex; width: 200px; } 4344.item { width: 100px; height: 50px; flex-shrink: 1; } 4345</style></head> 4346<body> 4347<div class="container"> 4348 <div class="item">A</div> 4349 <div class="item">B</div> 4350 <div class="item">C</div> 4351</div> 4352</body></html>"#; 4353 let doc = we_html::parse_html(html_str); 4354 let tree = layout_doc(&doc); 4355 4356 let body = &tree.root.children[0]; 4357 let container = &body.children[0]; 4358 4359 // 3 items * 100px = 300px in 200px container, shrink evenly 4360 // Each should be ~66.67px 4361 for (i, child) in container.children.iter().enumerate() { 4362 assert!( 4363 (child.rect.width - 200.0 / 3.0).abs() < 1.0, 4364 "item {} width should be ~66.67, got {}", 4365 i, 4366 child.rect.width 4367 ); 4368 } 4369 } 4370 4371 // ----------------------------------------------------------------------- 4372 // Absolute positioning tests 4373 // ----------------------------------------------------------------------- 4374 4375 /// Helper to create: <html><body>...content...</body></html> and return 4376 /// (doc, html_id, body_id). 4377 fn make_html_body(doc: &mut Document) -> (NodeId, NodeId, NodeId) { 4378 let root = doc.root(); 4379 let html = doc.create_element("html"); 4380 let body = doc.create_element("body"); 4381 doc.append_child(root, html); 4382 doc.append_child(html, body); 4383 (root, html, body) 4384 } 4385 4386 #[test] 4387 fn absolute_positioned_with_top_left() { 4388 let mut doc = Document::new(); 4389 let (_, _, body) = make_html_body(&mut doc); 4390 // <div style="position: relative; width: 400px; height: 300px;"> 4391 // <div style="position: absolute; top: 10px; left: 20px; width: 100px; height: 50px;"></div> 4392 // </div> 4393 let container = doc.create_element("div"); 4394 let abs_child = doc.create_element("div"); 4395 doc.append_child(body, container); 4396 doc.append_child(container, abs_child); 4397 doc.set_attribute( 4398 container, 4399 "style", 4400 "position: relative; width: 400px; height: 300px;", 4401 ); 4402 doc.set_attribute( 4403 abs_child, 4404 "style", 4405 "position: absolute; top: 10px; left: 20px; width: 100px; height: 50px;", 4406 ); 4407 4408 let tree = layout_doc(&doc); 4409 let body_box = &tree.root.children[0]; 4410 let container_box = &body_box.children[0]; 4411 let abs_box = &container_box.children[0]; 4412 4413 assert_eq!(abs_box.position, Position::Absolute); 4414 assert_eq!(abs_box.rect.width, 100.0); 4415 assert_eq!(abs_box.rect.height, 50.0); 4416 4417 // The containing block is the container's padding box. 4418 // Container content starts at container_box.rect.x, container_box.rect.y. 4419 // Padding box x = container_box.rect.x - container_box.padding.left. 4420 let cb_x = container_box.rect.x - container_box.padding.left; 4421 let cb_y = container_box.rect.y - container_box.padding.top; 4422 4423 // abs_box content area x = cb_x + 20 (left) + 0 (margin) + 0 (border) + 0 (padding) 4424 assert!( 4425 (abs_box.rect.x - (cb_x + 20.0)).abs() < 0.01, 4426 "abs x should be cb_x + 20, got {} (cb_x={})", 4427 abs_box.rect.x, 4428 cb_x, 4429 ); 4430 assert!( 4431 (abs_box.rect.y - (cb_y + 10.0)).abs() < 0.01, 4432 "abs y should be cb_y + 10, got {} (cb_y={})", 4433 abs_box.rect.y, 4434 cb_y, 4435 ); 4436 } 4437 4438 #[test] 4439 fn absolute_does_not_affect_sibling_layout() { 4440 let mut doc = Document::new(); 4441 let (_, _, body) = make_html_body(&mut doc); 4442 // <div style="position: relative;"> 4443 // <div style="position: absolute; top: 0; left: 0; width: 100px; height: 100px;"></div> 4444 // <p>Normal text</p> 4445 // </div> 4446 let container = doc.create_element("div"); 4447 let abs_child = doc.create_element("div"); 4448 let p = doc.create_element("p"); 4449 let text = doc.create_text("Normal text"); 4450 doc.append_child(body, container); 4451 doc.append_child(container, abs_child); 4452 doc.append_child(container, p); 4453 doc.append_child(p, text); 4454 doc.set_attribute(container, "style", "position: relative;"); 4455 doc.set_attribute( 4456 abs_child, 4457 "style", 4458 "position: absolute; top: 0; left: 0; width: 100px; height: 100px;", 4459 ); 4460 4461 let tree = layout_doc(&doc); 4462 let body_box = &tree.root.children[0]; 4463 let container_box = &body_box.children[0]; 4464 4465 // The <p> should start at the top of the container (abs child doesn't 4466 // push it down). Find the first in-flow child. 4467 let p_box = container_box 4468 .children 4469 .iter() 4470 .find(|c| c.position != Position::Absolute && c.position != Position::Fixed) 4471 .expect("should have an in-flow child"); 4472 4473 // p's y should be at (or very near) the container's content y. 4474 let expected_y = container_box.rect.y; 4475 assert!( 4476 (p_box.rect.y - expected_y).abs() < 1.0, 4477 "p should start near container top: p.y={}, expected={}", 4478 p_box.rect.y, 4479 expected_y, 4480 ); 4481 } 4482 4483 #[test] 4484 fn fixed_positioned_relative_to_viewport() { 4485 let mut doc = Document::new(); 4486 let (_, _, body) = make_html_body(&mut doc); 4487 // <div style="position: fixed; top: 5px; left: 10px; width: 50px; height: 30px;"></div> 4488 let fixed = doc.create_element("div"); 4489 doc.append_child(body, fixed); 4490 doc.set_attribute( 4491 fixed, 4492 "style", 4493 "position: fixed; top: 5px; left: 10px; width: 50px; height: 30px;", 4494 ); 4495 4496 let tree = layout_doc(&doc); 4497 let body_box = &tree.root.children[0]; 4498 let fixed_box = &body_box.children[0]; 4499 4500 assert_eq!(fixed_box.position, Position::Fixed); 4501 assert_eq!(fixed_box.rect.width, 50.0); 4502 assert_eq!(fixed_box.rect.height, 30.0); 4503 4504 // Fixed should be relative to viewport (0,0). 4505 assert!( 4506 (fixed_box.rect.x - 10.0).abs() < 0.01, 4507 "fixed x should be 10, got {}", 4508 fixed_box.rect.x, 4509 ); 4510 assert!( 4511 (fixed_box.rect.y - 5.0).abs() < 0.01, 4512 "fixed y should be 5, got {}", 4513 fixed_box.rect.y, 4514 ); 4515 } 4516 4517 #[test] 4518 fn z_index_ordering() { 4519 let mut doc = Document::new(); 4520 let (_, _, body) = make_html_body(&mut doc); 4521 // Create a positioned container with two abs children at different z-index. 4522 let container = doc.create_element("div"); 4523 let low = doc.create_element("div"); 4524 let high = doc.create_element("div"); 4525 doc.append_child(body, container); 4526 doc.append_child(container, low); 4527 doc.append_child(container, high); 4528 doc.set_attribute( 4529 container, 4530 "style", 4531 "position: relative; width: 200px; height: 200px;", 4532 ); 4533 doc.set_attribute( 4534 low, 4535 "style", 4536 "position: absolute; z-index: 1; top: 0; left: 0; width: 50px; height: 50px;", 4537 ); 4538 doc.set_attribute( 4539 high, 4540 "style", 4541 "position: absolute; z-index: 5; top: 0; left: 0; width: 50px; height: 50px;", 4542 ); 4543 4544 let tree = layout_doc(&doc); 4545 let body_box = &tree.root.children[0]; 4546 let container_box = &body_box.children[0]; 4547 4548 // Both should have correct z-index stored. 4549 let low_box = &container_box.children[0]; 4550 let high_box = &container_box.children[1]; 4551 assert_eq!(low_box.z_index, Some(1)); 4552 assert_eq!(high_box.z_index, Some(5)); 4553 } 4554 4555 #[test] 4556 fn absolute_stretching_left_right() { 4557 let mut doc = Document::new(); 4558 let (_, _, body) = make_html_body(&mut doc); 4559 // <div style="position: relative; width: 400px; height: 300px;"> 4560 // <div style="position: absolute; left: 10px; right: 20px; height: 50px;"></div> 4561 // </div> 4562 let container = doc.create_element("div"); 4563 let abs_child = doc.create_element("div"); 4564 doc.append_child(body, container); 4565 doc.append_child(container, abs_child); 4566 doc.set_attribute( 4567 container, 4568 "style", 4569 "position: relative; width: 400px; height: 300px;", 4570 ); 4571 doc.set_attribute( 4572 abs_child, 4573 "style", 4574 "position: absolute; left: 10px; right: 20px; height: 50px;", 4575 ); 4576 4577 let tree = layout_doc(&doc); 4578 let body_box = &tree.root.children[0]; 4579 let container_box = &body_box.children[0]; 4580 let abs_box = &container_box.children[0]; 4581 4582 // Width should be: container_width - left - right = 400 - 10 - 20 = 370 4583 assert!( 4584 (abs_box.rect.width - 370.0).abs() < 0.01, 4585 "stretched width should be 370, got {}", 4586 abs_box.rect.width, 4587 ); 4588 } 4589 4590 #[test] 4591 fn absolute_bottom_right_positioning() { 4592 let mut doc = Document::new(); 4593 let (_, _, body) = make_html_body(&mut doc); 4594 // <div style="position: relative; width: 400px; height: 300px;"> 4595 // <div style="position: absolute; bottom: 10px; right: 20px; width: 50px; height: 30px;"></div> 4596 // </div> 4597 let container = doc.create_element("div"); 4598 let abs_child = doc.create_element("div"); 4599 doc.append_child(body, container); 4600 doc.append_child(container, abs_child); 4601 doc.set_attribute( 4602 container, 4603 "style", 4604 "position: relative; width: 400px; height: 300px;", 4605 ); 4606 doc.set_attribute( 4607 abs_child, 4608 "style", 4609 "position: absolute; bottom: 10px; right: 20px; width: 50px; height: 30px;", 4610 ); 4611 4612 let tree = layout_doc(&doc); 4613 let body_box = &tree.root.children[0]; 4614 let container_box = &body_box.children[0]; 4615 let abs_box = &container_box.children[0]; 4616 4617 let cb_x = container_box.rect.x - container_box.padding.left; 4618 let cb_y = container_box.rect.y - container_box.padding.top; 4619 let cb_w = 4620 container_box.rect.width + container_box.padding.left + container_box.padding.right; 4621 let cb_h = 4622 container_box.rect.height + container_box.padding.top + container_box.padding.bottom; 4623 4624 // x = cb_x + cb_w - right - width = cb_x + cb_w - 20 - 50 4625 let expected_x = cb_x + cb_w - 20.0 - 50.0; 4626 // y = cb_y + cb_h - bottom - height = cb_y + cb_h - 10 - 30 4627 let expected_y = cb_y + cb_h - 10.0 - 30.0; 4628 4629 assert!( 4630 (abs_box.rect.x - expected_x).abs() < 0.01, 4631 "abs x with right should be {}, got {}", 4632 expected_x, 4633 abs_box.rect.x, 4634 ); 4635 assert!( 4636 (abs_box.rect.y - expected_y).abs() < 0.01, 4637 "abs y with bottom should be {}, got {}", 4638 expected_y, 4639 abs_box.rect.y, 4640 ); 4641 } 4642 4643 // ----------------------------------------------------------------------- 4644 // Sticky positioning tests 4645 // ----------------------------------------------------------------------- 4646 4647 #[test] 4648 fn sticky_element_laid_out_in_normal_flow() { 4649 // A sticky element should participate in normal flow just like a 4650 // static or relative element — its siblings should be positioned 4651 // as if sticky doesn't exist. 4652 let mut doc = Document::new(); 4653 let (_, _, body) = make_html_body(&mut doc); 4654 let container = doc.create_element("div"); 4655 let before = doc.create_element("div"); 4656 let sticky = doc.create_element("div"); 4657 let after = doc.create_element("div"); 4658 doc.append_child(body, container); 4659 doc.append_child(container, before); 4660 doc.append_child(container, sticky); 4661 doc.append_child(container, after); 4662 4663 doc.set_attribute(container, "style", "width: 400px;"); 4664 doc.set_attribute(before, "style", "height: 50px;"); 4665 doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 30px;"); 4666 doc.set_attribute(after, "style", "height: 60px;"); 4667 4668 let tree = layout_doc(&doc); 4669 let body_box = &tree.root.children[0]; 4670 let container_box = &body_box.children[0]; 4671 4672 // All three children should be present (sticky is in-flow). 4673 let in_flow: Vec<&LayoutBox> = container_box.children.iter().collect(); 4674 assert!( 4675 in_flow.len() >= 3, 4676 "expected 3 children, got {}", 4677 in_flow.len() 4678 ); 4679 4680 let before_box = &in_flow[0]; 4681 let sticky_box = &in_flow[1]; 4682 let after_box = &in_flow[2]; 4683 4684 assert_eq!(sticky_box.position, Position::Sticky); 4685 4686 // Sticky element should be right after 'before' (at y = before.y + 50). 4687 let expected_sticky_y = before_box.rect.y + 50.0; 4688 assert!( 4689 (sticky_box.rect.y - expected_sticky_y).abs() < 1.0, 4690 "sticky y should be ~{}, got {}", 4691 expected_sticky_y, 4692 sticky_box.rect.y, 4693 ); 4694 4695 // 'after' should follow the sticky element (at y = sticky.y + 30). 4696 let expected_after_y = sticky_box.rect.y + 30.0; 4697 assert!( 4698 (after_box.rect.y - expected_after_y).abs() < 1.0, 4699 "after y should be ~{}, got {}", 4700 expected_after_y, 4701 after_box.rect.y, 4702 ); 4703 } 4704 4705 #[test] 4706 fn sticky_constraint_rect_is_set() { 4707 // The layout engine should set `sticky_constraint` to the parent's 4708 // content rect for sticky children. 4709 let mut doc = Document::new(); 4710 let (_, _, body) = make_html_body(&mut doc); 4711 let container = doc.create_element("div"); 4712 let sticky = doc.create_element("div"); 4713 doc.append_child(body, container); 4714 doc.append_child(container, sticky); 4715 4716 doc.set_attribute(container, "style", "width: 400px; height: 300px;"); 4717 doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 50px;"); 4718 4719 let tree = layout_doc(&doc); 4720 let body_box = &tree.root.children[0]; 4721 let container_box = &body_box.children[0]; 4722 let sticky_box = &container_box.children[0]; 4723 4724 assert_eq!(sticky_box.position, Position::Sticky); 4725 let constraint = sticky_box 4726 .sticky_constraint 4727 .expect("sticky element should have a constraint rect"); 4728 4729 // Constraint should match the container's content rect. 4730 assert!( 4731 (constraint.width - container_box.rect.width).abs() < 0.01, 4732 "constraint width should match container: {} vs {}", 4733 constraint.width, 4734 container_box.rect.width, 4735 ); 4736 assert!( 4737 (constraint.height - container_box.rect.height).abs() < 0.01, 4738 "constraint height should match container: {} vs {}", 4739 constraint.height, 4740 container_box.rect.height, 4741 ); 4742 } 4743}