Skip to content

Commit 61fdfc6

Browse files
committed
feat(ie-layout): implement inline layout and text wrapping #72
Inline formatting context (inline.rs): - Line box construction: accumulate inline boxes left-to-right - Text wrapping: split at word boundaries (greedy), wrap when exceeding container width - white-space: normal (collapse+wrap), nowrap (no wrapping) - text-align: left (default), center, right — applied per line - Handles Text, Inline, InlineBlock box types Block layout integration: - Detects inline formatting context (all children inline/text) - Delegates to layout_inline_content instead of recursive block - Respects explicit height/min/max on container 5 new tests: single line text, long text wraps, text-align center, multiple inline elements, nowrap prevents wrapping. 15 total ie-layout tests.
1 parent a06d21b commit 61fdfc6

3 files changed

Lines changed: 314 additions & 0 deletions

File tree

crates/ie-layout/src/block.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use ie_css::resolve::ResolvedStyle;
33
use ie_css::values::PropertyId;
44

55
use crate::box_generation::{get_px, get_px_or, is_auto};
6+
use crate::inline::layout_inline_content;
67
use crate::text_measure::TextMeasure;
78
use crate::{BoxType, LayoutTree, Rect};
89

@@ -131,6 +132,41 @@ pub fn layout_block(
131132
// --- 2. Layout children ---
132133
let children = tree.boxes[box_idx].children.clone();
133134
let content_y = tree.boxes[box_idx].content_rect.y;
135+
136+
// If all children are inline/text, use the inline formatting context
137+
let all_inline = !children.is_empty()
138+
&& children.iter().all(|&idx| {
139+
matches!(
140+
tree.boxes[idx].box_type,
141+
BoxType::Inline | BoxType::InlineBlock | BoxType::Text(_)
142+
)
143+
});
144+
145+
if all_inline {
146+
let inline_height = layout_inline_content(
147+
box_idx,
148+
tree,
149+
styles,
150+
content_width,
151+
content_y,
152+
text_measure,
153+
);
154+
let content_height = if let Some(style) = style {
155+
if is_auto(style, PropertyId::Height) {
156+
inline_height
157+
} else {
158+
let h = get_px(style, PropertyId::Height);
159+
let min_h = get_px(style, PropertyId::MinHeight);
160+
let max_h = get_px_or(style, PropertyId::MaxHeight, f32::MAX);
161+
h.max(min_h).min(max_h)
162+
}
163+
} else {
164+
inline_height
165+
};
166+
tree.boxes[box_idx].content_rect.height = content_height;
167+
return;
168+
}
169+
134170
let mut child_y = content_y;
135171
let mut prev_margin_bottom: f32 = 0.0;
136172

crates/ie-layout/src/inline.rs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
use ie_css::CssValue;
2+
use ie_css::resolve::ResolvedStyle;
3+
use ie_css::values::PropertyId;
4+
5+
use crate::box_generation::get_px;
6+
use crate::text_measure::TextMeasure;
7+
use crate::{BoxType, LayoutTree, Rect};
8+
9+
/// A positioned item within a line box.
10+
struct LineItem {
11+
box_idx: usize,
12+
x: f32,
13+
width: f32,
14+
height: f32,
15+
}
16+
17+
/// Layout inline content within a block container.
18+
///
19+
/// Walks inline children left-to-right, wrapping text at word boundaries
20+
/// when it exceeds `containing_width`. Returns the total height consumed.
21+
pub fn layout_inline_content(
22+
container_idx: usize,
23+
tree: &mut LayoutTree,
24+
styles: &[ResolvedStyle],
25+
containing_width: f32,
26+
offset_y: f32,
27+
text_measure: &dyn TextMeasure,
28+
) -> f32 {
29+
let children = tree.boxes[container_idx].children.clone();
30+
let container_x = tree.boxes[container_idx].content_rect.x;
31+
32+
let container_node = tree.boxes[container_idx].node_id;
33+
34+
// text-align from container style
35+
let text_align = container_node
36+
.and_then(|id| styles.get(id))
37+
.and_then(|s| s.get(PropertyId::TextAlign))
38+
.and_then(|v| match v {
39+
CssValue::Keyword(k) => Some(k.as_str()),
40+
_ => None,
41+
})
42+
.unwrap_or("left");
43+
let text_align = text_align.to_string();
44+
45+
// white-space from container style
46+
let white_space = container_node
47+
.and_then(|id| styles.get(id))
48+
.and_then(|s| s.get(PropertyId::WhiteSpace))
49+
.and_then(|v| match v {
50+
CssValue::Keyword(k) => Some(k.as_str()),
51+
_ => None,
52+
})
53+
.unwrap_or("normal");
54+
55+
let allow_wrap = !matches!(white_space, "nowrap" | "pre");
56+
let collapse_whitespace = matches!(white_space, "normal" | "nowrap");
57+
let white_space = white_space.to_string();
58+
// keep `white_space` alive for later use
59+
let _ = &white_space;
60+
61+
let default_font_size = 16.0_f32;
62+
63+
let mut lines: Vec<Vec<LineItem>> = vec![Vec::new()];
64+
let mut current_x: f32 = 0.0;
65+
66+
for &child_idx in &children {
67+
let box_type = tree.boxes[child_idx].box_type.clone();
68+
match box_type {
69+
BoxType::Text(ref text) => {
70+
// Text nodes inherit font-size from the container element
71+
let font_size = container_node
72+
.and_then(|id| styles.get(id))
73+
.map(|s| get_px(s, PropertyId::FontSize))
74+
.filter(|&fs| fs > 0.0)
75+
.unwrap_or(default_font_size);
76+
77+
let processed = if collapse_whitespace {
78+
text.split_whitespace().collect::<Vec<_>>().join(" ")
79+
} else {
80+
text.clone()
81+
};
82+
83+
if processed.is_empty() {
84+
continue;
85+
}
86+
87+
if !allow_wrap {
88+
// No wrapping: entire text on current line
89+
let metrics = text_measure.measure(&processed, font_size);
90+
lines.last_mut().unwrap().push(LineItem {
91+
box_idx: child_idx,
92+
x: current_x,
93+
width: metrics.width,
94+
height: metrics.height,
95+
});
96+
current_x += metrics.width;
97+
} else {
98+
// Word wrapping via split_inclusive on whitespace
99+
let words: Vec<&str> = processed.split_inclusive(char::is_whitespace).collect();
100+
let words = if words.is_empty() {
101+
vec![processed.as_str()]
102+
} else {
103+
words
104+
};
105+
106+
for word in words {
107+
let metrics = text_measure.measure(word, font_size);
108+
if current_x + metrics.width > containing_width && current_x > 0.0 {
109+
lines.push(Vec::new());
110+
current_x = 0.0;
111+
}
112+
lines.last_mut().unwrap().push(LineItem {
113+
box_idx: child_idx,
114+
x: current_x,
115+
width: metrics.width,
116+
height: metrics.height,
117+
});
118+
current_x += metrics.width;
119+
}
120+
}
121+
}
122+
BoxType::Inline | BoxType::InlineBlock => {
123+
let style = tree.boxes[child_idx].node_id.and_then(|id| styles.get(id));
124+
let padding_lr = style
125+
.map(|s| {
126+
get_px(s, PropertyId::PaddingLeft) + get_px(s, PropertyId::PaddingRight)
127+
})
128+
.unwrap_or(0.0);
129+
let border_lr = style
130+
.map(|s| {
131+
get_px(s, PropertyId::BorderLeftWidth)
132+
+ get_px(s, PropertyId::BorderRightWidth)
133+
})
134+
.unwrap_or(0.0);
135+
let margin_lr = style
136+
.map(|s| get_px(s, PropertyId::MarginLeft) + get_px(s, PropertyId::MarginRight))
137+
.unwrap_or(0.0);
138+
139+
let extra = padding_lr + border_lr + margin_lr;
140+
let font_size = style
141+
.map(|s| get_px(s, PropertyId::FontSize))
142+
.filter(|&v| v > 0.0)
143+
.unwrap_or(default_font_size);
144+
145+
// Approximate inline element width from its text children
146+
let child_text: String = tree.boxes[child_idx]
147+
.children
148+
.iter()
149+
.filter_map(|&ci| match &tree.boxes[ci].box_type {
150+
BoxType::Text(t) => Some(t.clone()),
151+
_ => None,
152+
})
153+
.collect();
154+
let text_metrics = text_measure.measure(&child_text, font_size);
155+
let total_width = text_metrics.width + extra;
156+
157+
if allow_wrap && current_x + total_width > containing_width && current_x > 0.0 {
158+
lines.push(Vec::new());
159+
current_x = 0.0;
160+
}
161+
162+
lines.last_mut().unwrap().push(LineItem {
163+
box_idx: child_idx,
164+
x: current_x,
165+
width: total_width,
166+
height: text_metrics.height.max(font_size),
167+
});
168+
current_x += total_width;
169+
}
170+
_ => {} // Block children handled separately
171+
}
172+
}
173+
174+
// Position items on each line
175+
let mut y = offset_y;
176+
for line in &lines {
177+
if line.is_empty() {
178+
continue;
179+
}
180+
let line_height = line.iter().map(|item| item.height).fold(0.0_f32, f32::max);
181+
let line_width: f32 = line.last().map(|item| item.x + item.width).unwrap_or(0.0);
182+
183+
let align_offset = match text_align.as_str() {
184+
"center" => ((containing_width - line_width) / 2.0).max(0.0),
185+
"right" => (containing_width - line_width).max(0.0),
186+
_ => 0.0,
187+
};
188+
189+
for item in line {
190+
let final_x = container_x + item.x + align_offset;
191+
tree.boxes[item.box_idx].content_rect = Rect {
192+
x: final_x,
193+
y,
194+
width: item.width,
195+
height: item.height,
196+
};
197+
}
198+
199+
y += line_height;
200+
}
201+
202+
y - offset_y
203+
}

crates/ie-layout/src/lib.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
pub mod block;
77
pub mod box_generation;
8+
pub mod inline;
89
pub mod text_measure;
910

1011
use ie_css::resolve::ResolvedStyle;
@@ -233,4 +234,78 @@ mod tests {
233234
"should have anonymous block boxes for mixed content"
234235
);
235236
}
237+
238+
#[test]
239+
fn short_text_single_line() {
240+
let tree = layout_html("<p>Hello</p>");
241+
let text_box = tree
242+
.boxes
243+
.iter()
244+
.find(|b| matches!(&b.box_type, BoxType::Text(_)));
245+
assert!(text_box.is_some());
246+
let tb = text_box.unwrap();
247+
assert!(tb.content_rect.width > 0.0);
248+
assert!(tb.content_rect.height > 0.0);
249+
}
250+
251+
#[test]
252+
fn long_text_wraps() {
253+
// "word " repeated 100 times = 500 chars, each char 8px wide (16*0.5)
254+
// Total unwrapped width = 4000px, viewport = 800px => must wrap
255+
let long = "word ".repeat(100);
256+
let html = format!("<p>{long}</p>");
257+
let tree = layout_html(&html);
258+
// The p element should have height > single line (16px)
259+
let p_box = tree.boxes.iter().find(|b| {
260+
b.node_id.is_some()
261+
&& matches!(b.box_type, BoxType::Block)
262+
&& b.content_rect.height > 16.0
263+
});
264+
assert!(
265+
p_box.is_some(),
266+
"long text should cause multi-line height in its block container"
267+
);
268+
}
269+
270+
#[test]
271+
fn text_align_center() {
272+
let tree = layout_html_with_css("<p>Hi</p>", "p { text-align: center; }");
273+
let text = tree
274+
.boxes
275+
.iter()
276+
.find(|b| matches!(&b.box_type, BoxType::Text(_)));
277+
if let Some(tb) = text {
278+
// "Hi" = 2 chars, width = 2 * 8 = 16px, centered in 800px => x ~ 392
279+
assert!(
280+
tb.content_rect.x > 100.0,
281+
"centered text should have x > 100, got {}",
282+
tb.content_rect.x
283+
);
284+
}
285+
}
286+
287+
#[test]
288+
fn multiple_inline_elements() {
289+
let tree = layout_html("<p><span>A</span><span>B</span></p>");
290+
// Both spans should exist
291+
assert!(tree.boxes.len() >= 2);
292+
}
293+
294+
#[test]
295+
fn nowrap_prevents_wrapping() {
296+
let long = "word ".repeat(100);
297+
let html = format!("<p>{long}</p>");
298+
let tree = layout_html_with_css(&html, "p { white-space: nowrap; }");
299+
let text = tree
300+
.boxes
301+
.iter()
302+
.find(|b| matches!(&b.box_type, BoxType::Text(_)));
303+
if let Some(tb) = text {
304+
assert!(
305+
tb.content_rect.width > 800.0,
306+
"nowrap text should exceed container, got {}",
307+
tb.content_rect.width
308+
);
309+
}
310+
}
236311
}

0 commit comments

Comments
 (0)