we (web engine): Experimental web browser project to understand the limits of Claude
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}