diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
index 65b171b..5e4d3f0 100644
--- a/crates/layout/src/lib.rs
+++ b/crates/layout/src/lib.rs
@@ -1,12 +1,12 @@
//! Block layout engine: box generation, block/inline layout, and text wrapping.
//!
//! Builds a layout tree from a styled tree (DOM + computed styles) and positions
-//! block-level elements vertically with text wrapping.
+//! block-level elements vertically with proper inline formatting context.
use we_css::values::Color;
use we_dom::{Document, NodeData, NodeId};
use we_style::computed::{
- BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextDecoration,
+ BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextAlign, TextDecoration,
};
use we_text::font::Font;
@@ -41,13 +41,21 @@ pub enum BoxType {
Anonymous,
}
-/// A single line of wrapped text.
+/// A single positioned text fragment with its own styling.
+///
+/// Multiple fragments can share the same y-coordinate when they are
+/// on the same visual line (e.g. `
Hello world
` produces
+/// two fragments at the same y).
#[derive(Debug, Clone, PartialEq)]
pub struct TextLine {
pub text: String,
pub x: f32,
pub y: f32,
pub width: f32,
+ pub font_size: f32,
+ pub color: Color,
+ pub text_decoration: TextDecoration,
+ pub background_color: Color,
}
/// A box in the layout tree with dimensions and child boxes.
@@ -60,7 +68,7 @@ pub struct LayoutBox {
pub border: EdgeSizes,
pub children: Vec,
pub font_size: f32,
- /// Wrapped text lines (populated for boxes with inline content).
+ /// Positioned text fragments (populated for boxes with inline content).
pub lines: Vec,
/// Text color.
pub color: Color,
@@ -72,6 +80,10 @@ pub struct LayoutBox {
pub border_styles: [BorderStyle; 4],
/// Border colors (top, right, bottom, left).
pub border_colors: [Color; 4],
+ /// Text alignment for this box's inline content.
+ pub text_align: TextAlign,
+ /// Computed line height in px.
+ pub line_height: f32,
}
impl LayoutBox {
@@ -100,6 +112,8 @@ impl LayoutBox {
style.border_bottom_color,
style.border_left_color,
],
+ text_align: style.text_align,
+ line_height: style.line_height,
}
}
@@ -130,7 +144,6 @@ impl<'a> Iterator for LayoutBoxIter<'a> {
fn next(&mut self) -> Option<&'a LayoutBox> {
let node = self.stack.pop()?;
- // Push children in reverse so leftmost child is visited first.
for child in node.children.iter().rev() {
self.stack.push(child);
}
@@ -174,8 +187,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option {
match doc.node_data(node) {
NodeData::Document => {
- // Shouldn't reach here since resolve_styles produces element root,
- // but handle gracefully.
let mut children = Vec::new();
for child in &styled.children {
if let Some(child_box) = build_box(child, doc) {
@@ -193,7 +204,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option {
}
}
NodeData::Element { .. } => {
- // display:none is already filtered by resolve_styles, but guard anyway.
if style.display == Display::None {
return None;
}
@@ -210,7 +220,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option {
bottom: style.padding_bottom,
left: style.padding_left,
};
- // Only apply border widths when border-style is not none.
let border = EdgeSizes {
top: if style.border_top_style != BorderStyle::None {
style.border_top_width
@@ -247,7 +256,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option {
Display::None => unreachable!(),
};
- // For block containers, ensure children are uniformly block or inline.
if style.display == Display::Block {
children = normalize_children(children, style);
}
@@ -276,7 +284,7 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option {
}
}
-/// Collapse runs of whitespace to a single space. Preserves non-whitespace content.
+/// Collapse runs of whitespace to a single space.
fn collapse_whitespace(s: &str) -> String {
let mut result = String::new();
let mut in_ws = false;
@@ -303,17 +311,14 @@ fn normalize_children(children: Vec, parent_style: &ComputedStyle) ->
let has_block = children.iter().any(is_block_level);
if !has_block {
- // All inline — parent will do inline layout directly.
return children;
}
let has_inline = children.iter().any(|c| !is_block_level(c));
if !has_inline {
- // All block — no wrapping needed.
return children;
}
- // Mixed: wrap consecutive inline runs in anonymous blocks.
let mut result = Vec::new();
let mut inline_group: Vec = Vec::new();
@@ -348,9 +353,14 @@ fn is_block_level(b: &LayoutBox) -> bool {
// ---------------------------------------------------------------------------
/// Position and size a layout box within `available_width` at position (`x`, `y`).
-///
-/// `x` and `y` mark the top-left corner of the box's margin area.
-fn compute_layout(b: &mut LayoutBox, x: f32, y: f32, available_width: f32, font: &Font) {
+fn compute_layout(
+ b: &mut LayoutBox,
+ x: f32,
+ y: f32,
+ available_width: f32,
+ font: &Font,
+ doc: &Document,
+) {
let content_x = x + b.margin.left + b.border.left + b.padding.left;
let content_y = y + b.margin.top + b.border.top + b.padding.top;
let content_width = (available_width
@@ -369,9 +379,9 @@ fn compute_layout(b: &mut LayoutBox, x: f32, y: f32, available_width: f32, font:
match &b.box_type {
BoxType::Block(_) | BoxType::Anonymous => {
if has_block_children(b) {
- layout_block_children(b, font);
+ layout_block_children(b, font, doc);
} else {
- layout_inline_children(b, font);
+ layout_inline_children(b, font, doc);
}
}
BoxType::TextRun { .. } | BoxType::Inline(_) => {
@@ -385,126 +395,308 @@ fn has_block_children(b: &LayoutBox) -> bool {
}
/// Lay out block-level children: stack them vertically.
-fn layout_block_children(parent: &mut LayoutBox, font: &Font) {
+fn layout_block_children(parent: &mut LayoutBox, font: &Font, doc: &Document) {
let content_x = parent.rect.x;
let content_width = parent.rect.width;
let mut cursor_y = parent.rect.y;
for child in &mut parent.children {
- compute_layout(child, content_x, cursor_y, content_width, font);
+ compute_layout(child, content_x, cursor_y, content_width, font, doc);
cursor_y += child.margin_box_height();
}
parent.rect.height = cursor_y - parent.rect.y;
}
-/// Lay out inline children: collect text, word-wrap, and compute height.
-fn layout_inline_children(parent: &mut LayoutBox, font: &Font) {
- let text = collect_inline_text(&parent.children);
- if text.is_empty() {
- parent.rect.height = 0.0;
- return;
- }
-
- let line_height = parent.font_size * 1.2;
- let wrapped = wrap_text(&text, parent.rect.width, font, parent.font_size);
-
- let mut y = parent.rect.y;
- let mut positioned = Vec::with_capacity(wrapped.len());
- for line in wrapped {
- positioned.push(TextLine {
- text: line.text,
- x: parent.rect.x,
- y,
- width: line.width,
- });
- y += line_height;
- }
+// ---------------------------------------------------------------------------
+// Inline formatting context
+// ---------------------------------------------------------------------------
- parent.rect.height = positioned.len() as f32 * line_height;
- parent.lines = positioned;
+/// An inline item produced by flattening the inline tree.
+enum InlineItemKind {
+ /// A word of text with associated styling.
+ Word {
+ text: String,
+ font_size: f32,
+ color: Color,
+ text_decoration: TextDecoration,
+ background_color: Color,
+ },
+ /// Whitespace between words.
+ Space { font_size: f32 },
+ /// Forced line break (`
`).
+ ForcedBreak,
+ /// Start of an inline box (for margin/padding/border tracking).
+ InlineStart {
+ margin_left: f32,
+ padding_left: f32,
+ border_left: f32,
+ },
+ /// End of an inline box.
+ InlineEnd {
+ margin_right: f32,
+ padding_right: f32,
+ border_right: f32,
+ },
}
-/// Recursively collect all text from inline children.
-fn collect_inline_text(children: &[LayoutBox]) -> String {
- let mut result = String::new();
- collect_text_recursive(children, &mut result);
- result
+/// A pending fragment on the current line.
+struct PendingFragment {
+ text: String,
+ x: f32,
+ width: f32,
+ font_size: f32,
+ color: Color,
+ text_decoration: TextDecoration,
+ background_color: Color,
}
-fn collect_text_recursive(children: &[LayoutBox], result: &mut String) {
+/// Flatten the inline children tree into a sequence of items.
+fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec) {
for child in children {
match &child.box_type {
BoxType::TextRun { text, .. } => {
- result.push_str(text);
+ let words = split_into_words(text);
+ for segment in words {
+ match segment {
+ WordSegment::Word(w) => {
+ items.push(InlineItemKind::Word {
+ text: w,
+ font_size: child.font_size,
+ color: child.color,
+ text_decoration: child.text_decoration,
+ background_color: child.background_color,
+ });
+ }
+ WordSegment::Space => {
+ items.push(InlineItemKind::Space {
+ font_size: child.font_size,
+ });
+ }
+ }
+ }
}
- BoxType::Inline(_) => {
- collect_text_recursive(&child.children, result);
+ BoxType::Inline(node_id) => {
+ if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) {
+ if tag_name == "br" {
+ items.push(InlineItemKind::ForcedBreak);
+ continue;
+ }
+ }
+
+ items.push(InlineItemKind::InlineStart {
+ margin_left: child.margin.left,
+ padding_left: child.padding.left,
+ border_left: child.border.left,
+ });
+
+ flatten_inline_tree(&child.children, doc, items);
+
+ items.push(InlineItemKind::InlineEnd {
+ margin_right: child.margin.right,
+ padding_right: child.padding.right,
+ border_right: child.border.right,
+ });
}
_ => {}
}
}
}
-// ---------------------------------------------------------------------------
-// Text measurement and word wrapping
-// ---------------------------------------------------------------------------
+enum WordSegment {
+ Word(String),
+ Space,
+}
-/// Measure the total advance width of a text string at the given font size.
-fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 {
- let shaped = font.shape_text(text, font_size);
- match shaped.last() {
- Some(last) => last.x_offset + last.x_advance,
- None => 0.0,
+/// Split text into alternating words and spaces.
+fn split_into_words(text: &str) -> Vec {
+ let mut segments = Vec::new();
+ let mut current_word = String::new();
+
+ for ch in text.chars() {
+ if ch == ' ' {
+ if !current_word.is_empty() {
+ segments.push(WordSegment::Word(std::mem::take(&mut current_word)));
+ }
+ segments.push(WordSegment::Space);
+ } else {
+ current_word.push(ch);
+ }
}
+
+ if !current_word.is_empty() {
+ segments.push(WordSegment::Word(current_word));
+ }
+
+ segments
}
-/// Word-wrap text to fit within `max_width`.
-fn wrap_text(text: &str, max_width: f32, font: &Font, font_size: f32) -> Vec {
- let words: Vec<&str> = text.split_whitespace().collect();
- if words.is_empty() {
- return Vec::new();
+/// Lay out inline children using a proper inline formatting context.
+fn layout_inline_children(parent: &mut LayoutBox, font: &Font, doc: &Document) {
+ let available_width = parent.rect.width;
+ let text_align = parent.text_align;
+ let line_height = parent.line_height;
+
+ let mut items = Vec::new();
+ flatten_inline_tree(&parent.children, doc, &mut items);
+
+ if items.is_empty() {
+ parent.rect.height = 0.0;
+ return;
}
- let space_width = measure_text_width(font, " ", font_size);
- let mut lines = Vec::new();
- let mut line_text = String::new();
- let mut line_width: f32 = 0.0;
-
- for word in &words {
- let word_width = measure_text_width(font, word, font_size);
-
- if line_text.is_empty() {
- // First word on line — always accept.
- line_text.push_str(word);
- line_width = word_width;
- } else if line_width + space_width + word_width <= max_width {
- line_text.push(' ');
- line_text.push_str(word);
- line_width += space_width + word_width;
- } else {
- // Emit current line, start new one.
- lines.push(TextLine {
- text: line_text,
- x: 0.0,
- y: 0.0,
- width: line_width,
+ // Process items into line boxes.
+ let mut all_lines: Vec> = Vec::new();
+ let mut current_line: Vec = Vec::new();
+ let mut cursor_x: f32 = 0.0;
+
+ for item in &items {
+ match item {
+ InlineItemKind::Word {
+ text,
+ font_size,
+ color,
+ text_decoration,
+ background_color,
+ } => {
+ let word_width = measure_text_width(font, text, *font_size);
+
+ // If this word doesn't fit and the line isn't empty, break.
+ if cursor_x > 0.0 && cursor_x + word_width > available_width {
+ all_lines.push(std::mem::take(&mut current_line));
+ cursor_x = 0.0;
+ }
+
+ current_line.push(PendingFragment {
+ text: text.clone(),
+ x: cursor_x,
+ width: word_width,
+ font_size: *font_size,
+ color: *color,
+ text_decoration: *text_decoration,
+ background_color: *background_color,
+ });
+ cursor_x += word_width;
+ }
+ InlineItemKind::Space { font_size } => {
+ // Only add space if we have content on the line.
+ if !current_line.is_empty() {
+ let space_width = measure_text_width(font, " ", *font_size);
+ if cursor_x + space_width <= available_width {
+ cursor_x += space_width;
+ }
+ }
+ }
+ InlineItemKind::ForcedBreak => {
+ all_lines.push(std::mem::take(&mut current_line));
+ cursor_x = 0.0;
+ }
+ InlineItemKind::InlineStart {
+ margin_left,
+ padding_left,
+ border_left,
+ } => {
+ cursor_x += margin_left + padding_left + border_left;
+ }
+ InlineItemKind::InlineEnd {
+ margin_right,
+ padding_right,
+ border_right,
+ } => {
+ cursor_x += margin_right + padding_right + border_right;
+ }
+ }
+ }
+
+ // Flush the last line.
+ if !current_line.is_empty() {
+ all_lines.push(current_line);
+ }
+
+ if all_lines.is_empty() {
+ parent.rect.height = 0.0;
+ return;
+ }
+
+ // Position lines vertically and apply text-align.
+ let mut text_lines = Vec::new();
+ let mut y = parent.rect.y;
+ let num_lines = all_lines.len();
+
+ for (line_idx, line_fragments) in all_lines.iter().enumerate() {
+ if line_fragments.is_empty() {
+ y += line_height;
+ continue;
+ }
+
+ // Compute line width from last fragment.
+ let line_width = match line_fragments.last() {
+ Some(last) => last.x + last.width,
+ None => 0.0,
+ };
+
+ // Compute text-align offset.
+ let is_last_line = line_idx == num_lines - 1;
+ let align_offset =
+ compute_align_offset(text_align, available_width, line_width, is_last_line);
+
+ for frag in line_fragments {
+ text_lines.push(TextLine {
+ text: frag.text.clone(),
+ x: parent.rect.x + frag.x + align_offset,
+ y,
+ width: frag.width,
+ font_size: frag.font_size,
+ color: frag.color,
+ text_decoration: frag.text_decoration,
+ background_color: frag.background_color,
});
- line_text = word.to_string();
- line_width = word_width;
}
+
+ y += line_height;
}
- if !line_text.is_empty() {
- lines.push(TextLine {
- text: line_text,
- x: 0.0,
- y: 0.0,
- width: line_width,
- });
+ parent.rect.height = num_lines as f32 * line_height;
+ parent.lines = text_lines;
+}
+
+/// Compute the horizontal offset for text alignment.
+fn compute_align_offset(
+ align: TextAlign,
+ available_width: f32,
+ line_width: f32,
+ is_last_line: bool,
+) -> f32 {
+ let extra_space = (available_width - line_width).max(0.0);
+ match align {
+ TextAlign::Left => 0.0,
+ TextAlign::Center => extra_space / 2.0,
+ TextAlign::Right => extra_space,
+ TextAlign::Justify => {
+ // Don't justify the last line (CSS spec behavior).
+ if is_last_line {
+ 0.0
+ } else {
+ // For justify, we shift the whole line by 0 — the actual distribution
+ // of space between words would need per-word spacing. For now, treat
+ // as left-aligned; full justify support is a future enhancement.
+ 0.0
+ }
+ }
}
+}
+
+// ---------------------------------------------------------------------------
+// Text measurement
+// ---------------------------------------------------------------------------
- lines
+/// Measure the total advance width of a text string at the given font size.
+fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 {
+ let shaped = font.shape_text(text, font_size);
+ match shaped.last() {
+ Some(last) => last.x_offset + last.x_advance,
+ None => 0.0,
+ }
}
// ---------------------------------------------------------------------------
@@ -532,7 +724,7 @@ pub fn layout(
}
};
- compute_layout(&mut root, 0.0, 0.0, viewport_width, font);
+ compute_layout(&mut root, 0.0, 0.0, viewport_width, font, doc);
let height = root.margin_box_height();
LayoutTree {
@@ -548,7 +740,6 @@ mod tests {
use we_dom::Document;
use we_style::computed::{extract_stylesheets, resolve_styles};
- // Helper: load a system font for testing.
fn test_font() -> Font {
let paths = [
"/System/Library/Fonts/Geneva.ttf",
@@ -563,7 +754,6 @@ mod tests {
panic!("no test font found");
}
- // Helper: build a DOM, resolve styles, and lay it out.
fn layout_doc(doc: &Document) -> LayoutTree {
let font = test_font();
let sheets = extract_stylesheets(doc);
@@ -581,7 +771,6 @@ mod tests {
let tree = layout(&styled, &doc, 800.0, 600.0, &font);
assert_eq!(tree.width, 800.0);
}
- // Empty document with no styled root is fine — just produces empty layout.
}
#[test]
@@ -599,21 +788,30 @@ mod tests {
let tree = layout_doc(&doc);
- // Root should be the html element box.
assert!(matches!(tree.root.box_type, BoxType::Block(_)));
- // Find the p box (html > body > p).
let body_box = &tree.root.children[0];
assert!(matches!(body_box.box_type, BoxType::Block(_)));
let p_box = &body_box.children[0];
assert!(matches!(p_box.box_type, BoxType::Block(_)));
- // p should have text lines.
- assert!(!p_box.lines.is_empty(), "p should have wrapped text lines");
- assert_eq!(p_box.lines[0].text, "Hello world");
+ assert!(!p_box.lines.is_empty(), "p should have text fragments");
+
+ // Collect all text on the first visual line.
+ let first_y = p_box.lines[0].y;
+ let line_text: String = p_box
+ .lines
+ .iter()
+ .filter(|l| (l.y - first_y).abs() < 0.01)
+ .map(|l| l.text.as_str())
+ .collect::>()
+ .join(" ");
+ assert!(
+ line_text.contains("Hello") && line_text.contains("world"),
+ "line should contain Hello and world, got: {line_text}"
+ );
- // p should have vertical margins (1em = 16px default from UA stylesheet).
assert_eq!(p_box.margin.top, 16.0);
assert_eq!(p_box.margin.bottom, 16.0);
}
@@ -640,7 +838,6 @@ mod tests {
let first = &body_box.children[0];
let second = &body_box.children[1];
- // Second paragraph should be below the first.
assert!(
second.rect.y > first.rect.y,
"second p (y={}) should be below first p (y={})",
@@ -671,7 +868,6 @@ mod tests {
let h1_box = &body_box.children[0];
let p_box = &body_box.children[1];
- // h1 should have a larger font size (2em = 32px).
assert!(
h1_box.font_size > p_box.font_size,
"h1 font_size ({}) should be > p font_size ({})",
@@ -680,7 +876,6 @@ mod tests {
);
assert_eq!(h1_box.font_size, 32.0);
- // h1 should take more vertical space.
assert!(
h1_box.rect.height > p_box.rect.height,
"h1 height ({}) should be > p height ({})",
@@ -710,7 +905,6 @@ mod tests {
assert_eq!(body_box.margin.bottom, 8.0);
assert_eq!(body_box.margin.left, 8.0);
- // Body content should be offset by 8px from html content edge.
assert_eq!(body_box.rect.x, 8.0);
assert_eq!(body_box.rect.y, 8.0);
}
@@ -732,16 +926,19 @@ mod tests {
let font = test_font();
let sheets = extract_stylesheets(&doc);
let styled = resolve_styles(&doc, &sheets).unwrap();
- // Narrow viewport: 100px (minus body margin 8+8 = 84px content width).
let tree = layout(&styled, &doc, 100.0, 600.0, &font);
let body_box = &tree.root.children[0];
let p_box = &body_box.children[0];
- // With a 84px content width, long text should wrap to multiple lines.
+ // Count distinct y-positions to count visual lines.
+ let mut ys: Vec = p_box.lines.iter().map(|l| l.y).collect();
+ ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
+ ys.dedup_by(|a, b| (*a - *b).abs() < 0.01);
+
assert!(
- p_box.lines.len() > 1,
- "text should wrap to multiple lines, got {} lines",
- p_box.lines.len()
+ ys.len() > 1,
+ "text should wrap to multiple lines, got {} visual lines",
+ ys.len()
);
}
@@ -760,13 +957,11 @@ mod tests {
let tree = layout_doc(&doc);
- // All boxes should have non-negative dimensions.
for b in tree.iter() {
assert!(b.rect.width >= 0.0, "width should be >= 0");
assert!(b.rect.height >= 0.0, "height should be >= 0");
}
- // Overall layout should have positive height.
assert!(tree.height > 0.0, "layout height should be > 0");
}
@@ -791,7 +986,6 @@ mod tests {
let tree = layout_doc(&doc);
- // html should have one child (body), head is display:none.
assert_eq!(
tree.root.children.len(),
1,
@@ -801,7 +995,6 @@ mod tests {
#[test]
fn mixed_block_and_inline() {
- //
let mut doc = Document::new();
let root = doc.root();
let html = doc.create_element("html");
@@ -823,7 +1016,6 @@ mod tests {
let body_box = &tree.root.children[0];
let div_box = &body_box.children[0];
- // div should have 3 children: anonymous(Text), block(p), anonymous(More).
assert_eq!(
div_box.children.len(),
3,
@@ -838,7 +1030,6 @@ mod tests {
#[test]
fn inline_elements_contribute_text() {
- // Hello world!
let mut doc = Document::new();
let root = doc.root();
let html = doc.create_element("html");
@@ -860,9 +1051,20 @@ mod tests {
let body_box = &tree.root.children[0];
let p_box = &body_box.children[0];
- // Text should be collected from inline children.
assert!(!p_box.lines.is_empty());
- assert_eq!(p_box.lines[0].text, "Hello world!");
+
+ let first_y = p_box.lines[0].y;
+ let line_texts: Vec<&str> = p_box
+ .lines
+ .iter()
+ .filter(|l| (l.y - first_y).abs() < 0.01)
+ .map(|l| l.text.as_str())
+ .collect();
+ let combined = line_texts.join("");
+ assert!(
+ combined.contains("Hello") && combined.contains("world") && combined.contains("!"),
+ "line should contain all text, got: {combined}"
+ );
}
#[test]
@@ -893,10 +1095,8 @@ mod tests {
let tree = layout(&styled, &doc, 800.0, 600.0, &font);
let body_box = &tree.root.children[0];
- // body content width = 800 - 8 - 8 = 784
assert_eq!(body_box.rect.width, 784.0);
- // div inside body should also be 784px wide.
let div_box = &body_box.children[0];
assert_eq!(div_box.rect.width, 784.0);
}
@@ -921,14 +1121,12 @@ mod tests {
let tree = layout_doc(&doc);
let body_box = &tree.root.children[0];
- // h1 font_size > h2 font_size > h3 font_size
let h1 = &body_box.children[0];
let h2 = &body_box.children[1];
let h3 = &body_box.children[2];
assert!(h1.font_size > h2.font_size);
assert!(h2.font_size > h3.font_size);
- // All should stack vertically.
assert!(h2.rect.y > h1.rect.y);
assert!(h3.rect.y > h2.rect.y);
}
@@ -953,7 +1151,6 @@ mod tests {
#[test]
fn css_style_affects_layout() {
- // Test that CSS styles from
+Hello world
+