1use blitz_traits::shell::Viewport;
2
3use crate::{
4 dom::{Dom, NodeId},
5 layout::query::DOMRect,
6};
7
8pub struct LayoutEngine {
9 dirty: bool,
10 viewport: Viewport,
11}
12
13impl LayoutEngine {
14 pub fn new(viewport: crate::layout::Viewport) -> Self {
15 let blitz_viewport = Viewport {
16 window_size: (viewport.width as u32, viewport.height as u32),
17 ..Default::default()
18 };
19 Self {
20 dirty: true,
21 viewport: blitz_viewport,
22 }
23 }
24
25 pub fn mark_dirty(&mut self) {
26 self.dirty = true;
27 }
28
29 pub fn compute(&mut self, dom: &mut Dom) {
30 let inner = dom.inner_mut();
31 inner.set_viewport(self.viewport.clone());
32 inner.resolve(0.0);
33 self.dirty = false;
34 }
35
36 pub fn ensure_computed(&mut self, dom: &mut Dom) {
37 if self.dirty {
38 self.compute(dom);
39 }
40 }
41
42 pub fn get_bounding_rect(&mut self, dom: &mut Dom, node_id: NodeId) -> DOMRect {
43 self.ensure_computed(dom);
44 let inner = dom.inner();
45 let Some(node) = inner.get_node(node_id.to_blitz()) else {
46 return DOMRect::default();
47 };
48 let layout = node.final_layout;
49 let (abs_x, abs_y) = self.absolute_position(inner, node_id.to_blitz());
50 DOMRect::new(
51 abs_x as f64,
52 abs_y as f64,
53 layout.size.width as f64,
54 layout.size.height as f64,
55 )
56 }
57
58 pub fn get_computed_style(
59 &mut self,
60 _dom: &mut Dom,
61 _node_id: NodeId,
62 ) -> crate::dom::ElementData {
63 crate::dom::ElementData {
64 name: crate::dom::QualName::new(""),
65 attrs: vec![],
66 shadow_root: None,
67 }
68 }
69
70 pub fn get_offset_width(&mut self, dom: &mut Dom, node_id: NodeId) -> f64 {
71 self.ensure_computed(dom);
72 self.node_size(dom, node_id).0
73 }
74
75 pub fn get_offset_height(&mut self, dom: &mut Dom, node_id: NodeId) -> f64 {
76 self.ensure_computed(dom);
77 self.node_size(dom, node_id).1
78 }
79
80 pub fn get_offset_top(&mut self, dom: &mut Dom, node_id: NodeId) -> f64 {
81 self.ensure_computed(dom);
82 self.node_position(dom, node_id).1
83 }
84
85 pub fn get_offset_left(&mut self, dom: &mut Dom, node_id: NodeId) -> f64 {
86 self.ensure_computed(dom);
87 self.node_position(dom, node_id).0
88 }
89
90 fn absolute_position(&self, inner: &blitz_dom::BaseDocument, node_id: usize) -> (f32, f32) {
91 let mut x = 0.0f32;
92 let mut y = 0.0f32;
93 let mut current_id = node_id;
94 while let Some(node) = inner.get_node(current_id) {
95 let layout = node.final_layout;
96 x += layout.location.x;
97 y += layout.location.y;
98 match node.parent {
99 Some(pid) => current_id = pid,
100 None => break,
101 }
102 }
103 (x, y)
104 }
105
106 fn node_size(&self, dom: &Dom, node_id: NodeId) -> (f64, f64) {
107 let inner = dom.inner();
108 let Some(node) = inner.get_node(node_id.to_blitz()) else {
109 return (0.0, 0.0);
110 };
111 let layout = node.final_layout;
112 (layout.size.width as f64, layout.size.height as f64)
113 }
114
115 fn node_position(&self, dom: &Dom, node_id: NodeId) -> (f64, f64) {
116 let inner = dom.inner();
117 let Some(node) = inner.get_node(node_id.to_blitz()) else {
118 return (0.0, 0.0);
119 };
120 let layout = node.final_layout;
121 (layout.location.x as f64, layout.location.y as f64)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use blitz_dom::NodeData as BlitzNodeData;
128
129 use super::*;
130 use crate::dom::{Attribute, Dom, QualName};
131
132 fn make_dom_with_styled_div(style: &str) -> Dom {
133 let mut dom = Dom::new();
134 let html = dom.create_element(QualName::new("html"), vec![]);
135 dom.append_child(dom.document(), html);
136 let body = dom.create_element(QualName::new("body"), vec![]);
137 dom.append_child(html, body);
138 let div = dom.create_element(
139 QualName::new("div"),
140 vec![Attribute {
141 name: QualName::new("style"),
142 value: style.to_string(),
143 }],
144 );
145 dom.append_child(body, div);
146 dom
147 }
148
149 #[test]
150 fn layout_basic_div() {
151 let mut dom = make_dom_with_styled_div("width: 200px; height: 100px");
152 let viewport = crate::layout::Viewport::new(1920.0, 1080.0);
153 let mut engine = LayoutEngine::new(viewport);
154 engine.compute(&mut dom);
155
156 let html = dom.child_elements(dom.document())[0];
157 let body = dom.child_elements(html)[0];
158 let div = dom.child_elements(body)[0];
159
160 let rect = engine.get_bounding_rect(&mut dom, div);
161 assert!(
162 rect.width >= 200.0,
163 "width should be >= 200, got {}",
164 rect.width
165 );
166 }
167
168 #[test]
169 fn dirty_tracking() {
170 let mut dom = make_dom_with_styled_div("width: 100px");
171 let viewport = crate::layout::Viewport::new(1920.0, 1080.0);
172 let mut engine = LayoutEngine::new(viewport);
173
174 assert!(engine.dirty);
175 engine.compute(&mut dom);
176 assert!(!engine.dirty);
177 engine.mark_dirty();
178 assert!(engine.dirty);
179 }
180
181 fn find_child_by_tag(dom: &Dom, parent: NodeId, tag: &str) -> Option<NodeId> {
182 dom.child_elements(parent).into_iter().find(|&id| {
183 dom.get(id)
184 .and_then(|n| n.as_element().cloned())
185 .is_some_and(|e| e.name.local == tag)
186 })
187 }
188
189 fn find_child_by_class(dom: &Dom, parent: NodeId, class_part: &str) -> Option<NodeId> {
190 dom.child_elements(parent).into_iter().find(|&id| {
191 dom.get(id)
192 .and_then(|n| n.as_element().cloned())
193 .is_some_and(|e| {
194 e.attrs
195 .iter()
196 .any(|a| a.name.local == "class" && a.value.contains(class_part))
197 })
198 })
199 }
200
201 #[test]
202 fn full_rendering_pipeline_html_parse_resolve_layout() {
203 let html = r#"<!DOCTYPE html>
204<html>
205<body style="margin: 0; padding: 0;">
206 <div style="display: flex; width: 800px; height: 600px;">
207 <div style="width: 200px; height: 100%;">
208 <p>Nav 1</p>
209 <p>Nav 2</p>
210 </div>
211 <div style="flex-grow: 1; height: 100%;">
212 <div style="width: 100%; height: 80px;">
213 <h1>Page Title</h1>
214 </div>
215 <div style="padding: 20px;">
216 <p>Hello World</p>
217 <p>Second paragraph</p>
218 </div>
219 </div>
220 </div>
221</body>
222</html>"#;
223
224 let mut dom = crate::html_parser::parse_html(html);
225 let viewport = crate::layout::Viewport::new(1920.0, 1080.0);
226 let mut engine = LayoutEngine::new(viewport);
227 engine.compute(&mut dom);
228
229 let body = find_body(&dom);
230
231 let container_id = dom.child_elements(body)[0];
232
233 let container_rect = engine.get_bounding_rect(&mut dom, container_id);
234 assert!(
235 container_rect.width >= 790.0,
236 "container width should be ~800px, got {}",
237 container_rect.width
238 );
239 assert!(
240 container_rect.height >= 590.0,
241 "container height should be ~600px, got {}",
242 container_rect.height
243 );
244
245 let children = dom.child_elements(container_id);
246 assert!(
247 children.len() >= 2,
248 "container should have 2 flex children, got {}",
249 children.len()
250 );
251
252 let sidebar_rect = engine.get_bounding_rect(&mut dom, children[0]);
253 let main_rect = engine.get_bounding_rect(&mut dom, children[1]);
254
255 assert!(
256 sidebar_rect.width >= 190.0,
257 "sidebar width should be ~200px, got {}",
258 sidebar_rect.width
259 );
260 assert!(
261 main_rect.width >= 500.0,
262 "main width should fill remaining ~600px, got {}",
263 main_rect.width
264 );
265 assert!(
266 sidebar_rect.height >= 500.0,
267 "sidebar height should be substantial, got {}",
268 sidebar_rect.height
269 );
270
271 let main_children = dom.child_elements(children[1]);
272 assert!(
273 main_children.len() >= 2,
274 "main should have header + content, got {}",
275 main_children.len()
276 );
277
278 let header_rect = engine.get_bounding_rect(&mut dom, main_children[0]);
279 assert!(
280 header_rect.height >= 70.0,
281 "header height should be ~80px, got {}",
282 header_rect.height
283 );
284
285 let h1_id = dom.child_elements(main_children[0])[0];
286 let h1_text = dom.text_content(h1_id);
287 assert_eq!(h1_text, "Page Title");
288 let h1_rect = engine.get_bounding_rect(&mut dom, h1_id);
289 assert!(
290 h1_rect.height >= 20.0,
291 "h1 should have height, got {}",
292 h1_rect.height
293 );
294
295 let content_id = main_children[1];
296 let paragraphs = dom.get_elements_by_tag_name(content_id, "p");
297 assert_eq!(paragraphs.len(), 2, "should have 2 paragraphs in content");
298 let p1_text = dom.text_content(paragraphs[0]);
299 let p2_text = dom.text_content(paragraphs[1]);
300 assert_eq!(p1_text, "Hello World");
301 assert_eq!(p2_text, "Second paragraph");
302 }
303
304 #[test]
305 fn layout_with_flexbox_grow() {
306 let html = r#"<!DOCTYPE html>
307<html>
308<body>
309 <div style="display: flex; width: 600px; height: 200px;">
310 <div style="width: 100px; height: 100px;">A</div>
311 <div style="flex-grow: 1; height: 100px;">B</div>
312 <div style="width: 150px; height: 100px;">C</div>
313 </div>
314</body>
315</html>"#;
316
317 let mut dom = crate::html_parser::parse_html(html);
318 let viewport = crate::layout::Viewport::new(1920.0, 1080.0);
319 let mut engine = LayoutEngine::new(viewport);
320 engine.compute(&mut dom);
321
322 let body = find_body(&dom);
323 let body_children = dom.child_elements(body);
324
325 let flex_div = body_children
326 .iter()
327 .find(|&&id| {
328 dom.get(id)
329 .and_then(|n| n.as_element().cloned())
330 .is_some_and(|e| {
331 e.attrs
332 .iter()
333 .any(|a| a.name.local == "style" && a.value.contains("flex"))
334 })
335 })
336 .copied()
337 .unwrap_or(body_children[0]);
338
339 let flex_rect = engine.get_bounding_rect(&mut dom, flex_div);
340 assert!(
341 flex_rect.width >= 590.0,
342 "flex container width should be ~600px, got {}",
343 flex_rect.width
344 );
345 assert!(
346 flex_rect.height >= 190.0,
347 "flex container height should be ~200px, got {}",
348 flex_rect.height
349 );
350
351 let children = dom.child_elements(flex_div);
352 assert!(
353 children.len() >= 3,
354 "should have at least 3 flex children, got {}",
355 children.len()
356 );
357
358 let a_rect = engine.get_bounding_rect(&mut dom, children[0]);
359 let b_rect = engine.get_bounding_rect(&mut dom, children[1]);
360 let c_rect = engine.get_bounding_rect(&mut dom, children[2]);
361
362 assert!(
363 a_rect.width >= 90.0,
364 "A width should be ~100px, got {}",
365 a_rect.width
366 );
367 assert!(
368 c_rect.width >= 140.0,
369 "C width should be ~150px, got {}",
370 c_rect.width
371 );
372 assert!(
373 b_rect.width >= 300.0,
374 "B should fill remaining space ~350px, got {}",
375 b_rect.width
376 );
377 assert!(
378 b_rect.x > a_rect.x + a_rect.width - 1.0,
379 "B should be to the right of A"
380 );
381 assert!(
382 c_rect.x > b_rect.x + b_rect.width - 1.0,
383 "C should be to the right of B"
384 );
385 }
386
387 #[test]
388 fn layout_style_block_resolves() {
389 let html = r#"<!DOCTYPE html>
390<html>
391<head>
392 <style>
393 .flex-box { display: flex; width: 400px; height: 200px; }
394 .child-a { width: 100px; }
395 .child-b { flex-grow: 1; }
396 </style>
397</head>
398<body>
399 <div class="flex-box">
400 <div class="child-a">A</div>
401 <div class="child-b">B</div>
402 </div>
403</body>
404</html>"#;
405
406 let mut dom = crate::html_parser::parse_html(html);
407 let viewport = crate::layout::Viewport::new(1920.0, 1080.0);
408 let mut engine = LayoutEngine::new(viewport);
409 engine.compute(&mut dom);
410
411 let body = find_body(&dom);
412 let body_children = dom.child_elements(body);
413 let flex_div = body_children
414 .iter()
415 .find(|&&id| {
416 dom.get(id)
417 .and_then(|n| n.as_element().cloned())
418 .is_some_and(|e| {
419 e.attrs
420 .iter()
421 .any(|a| a.name.local == "class" && a.value.contains("flex-box"))
422 })
423 })
424 .copied();
425
426 if let Some(flex_id) = flex_div {
427 let rect = engine.get_bounding_rect(&mut dom, flex_id);
428 assert!(
429 rect.width >= 390.0,
430 "style block flex container width should be ~400px, got {}",
431 rect.width
432 );
433 } else {
434 panic!("should have flex-box element");
435 }
436 }
437
438 #[test]
439 fn debug_tree_structure() {
440 let html = r#"<!DOCTYPE html>
441<html>
442<body>
443 <div class="container">
444 <div class="sidebar"><p>Nav</p></div>
445 <div class="main"><h1>Title</h1></div>
446 </div>
447</body>
448</html>"#;
449 let mut dom = crate::html_parser::parse_html(html);
450 let viewport = crate::layout::Viewport::new(1920.0, 1080.0);
451 let mut engine = LayoutEngine::new(viewport);
452 engine.compute(&mut dom);
453
454 fn print_tree(dom: &Dom, id: NodeId, depth: usize) {
455 let node = match dom.get(id) {
456 Some(n) => n,
457 None => return,
458 };
459 let indent = " ".repeat(depth);
460 let tag = node
461 .as_element()
462 .map(|e| {
463 format!(
464 "{} [class={:?}]",
465 e.name.local,
466 e.attrs
467 .iter()
468 .find(|a| a.name.local == "class")
469 .map(|a| &a.value)
470 )
471 })
472 .unwrap_or_else(|| format!("{:?}", node.data));
473 let rect = dom
474 .inner()
475 .get_node(id.to_blitz())
476 .map(|n| {
477 if matches!(
479 n.data,
480 BlitzNodeData::Element(_)
481 | BlitzNodeData::AnonymousBlock(_)
482 | BlitzNodeData::Document
483 ) {
484 let l = n.final_layout;
485 format!(
486 "({:.0},{:.0} {:.0}x{:.0})",
487 l.location.x, l.location.y, l.size.width, l.size.height
488 )
489 } else {
490 String::new()
491 }
492 })
493 .unwrap_or_default();
494 println!("{}{} {}", indent, tag, rect);
495 for child_id in dom.children(id) {
496 print_tree(dom, child_id, depth + 1);
497 }
498 }
499
500 print_tree(&dom, dom.document(), 0);
501 }
502
503 fn find_body(dom: &Dom) -> NodeId {
504 let html = dom.child_elements(dom.document())[0];
505 dom.child_elements(html)
506 .iter()
507 .find(|&&id| {
508 dom.get(id)
509 .and_then(|n| n.as_element().cloned())
510 .is_some_and(|e| e.name.local == "body")
511 })
512 .copied()
513 .unwrap_or(html)
514 }
515
516 #[test]
517 fn layout_with_padding_and_margin() {
518 let html = r#"<!DOCTYPE html>
519<html>
520<body>
521 <div style="width: 300px; height: 200px; padding: 10px; margin: 20px;">
522 <div style="width: 100%; height: 100%;"></div>
523 </div>
524</body>
525</html>"#;
526
527 let mut dom = crate::html_parser::parse_html(html);
528 let viewport = crate::layout::Viewport::new(1920.0, 1080.0);
529 let mut engine = LayoutEngine::new(viewport);
530 engine.compute(&mut dom);
531
532 let body = find_body(&dom);
533 let body_children = dom.child_elements(body);
534 let outer_div = body_children
535 .iter()
536 .find(|&&id| {
537 dom.get(id)
538 .and_then(|n| n.as_element().cloned())
539 .is_some_and(|e| {
540 e.attrs
541 .iter()
542 .any(|a| a.name.local == "style" && a.value.contains("300px"))
543 })
544 })
545 .copied()
546 .expect("should find div with 300px style");
547
548 let outer_rect = engine.get_bounding_rect(&mut dom, outer_div);
549 assert!(
550 outer_rect.width > 0.0,
551 "outer div should have non-zero width, got {}",
552 outer_rect.width
553 );
554 assert!(
555 outer_rect.height > 0.0,
556 "outer div should have non-zero height, got {}",
557 outer_rect.height
558 );
559 }
560}