Skip to main content

hpx_browser/layout/
engine.rs

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                    // Skip Text/Comment nodes — their layout data is not meaningful.
478                    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}