web engine - experimental web browser

Integrate computed styles into layout and render pipeline

Replace hardcoded tag-based defaults in the layout crate with CSS
computed styles from the style crate. The layout engine now accepts
a StyledNode tree instead of walking the DOM directly.

Layout crate changes:
- layout() takes &StyledNode + &Document instead of &Document alone
- Remove hardcoded display_type(), default_font_size(), default_margin()
- Read display, margin, padding, border, font-size from ComputedStyle
- Carry color, background-color, text-decoration, border styles/colors
on LayoutBox for the renderer to use

Render crate changes:
- Use CSS color for text (instead of hardcoded black)
- Use CSS background-color for box backgrounds (skip transparent)
- Render borders with correct width, style, and color
- Support text-decoration: underline
- Replace render-local Color type with we_css::values::Color

Browser pipeline:
- Full: HTML → DOM → extract CSS → resolve styles → layout → render

6 new tests across layout (3) and render (3) crates verifying CSS
properties flow through to layout positions and paint commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

authored by pierrelf.com

Claude Opus 4.6 and committed by tangled.org 3218f658 671d128b

+461 -285
+3
Cargo.lock
··· 62 62 dependencies = [ 63 63 "we-css", 64 64 "we-dom", 65 + "we-html", 65 66 "we-style", 66 67 "we-text", 67 68 ] ··· 84 85 dependencies = [ 85 86 "we-css", 86 87 "we-dom", 88 + "we-html", 87 89 "we-image", 88 90 "we-layout", 89 91 "we-platform", 92 + "we-style", 90 93 "we-text", 91 94 ] 92 95
+6 -3
crates/browser/src/main.rs
··· 54 54 55 55 // Extract CSS from <style> elements and resolve computed styles. 56 56 let stylesheets = extract_stylesheets(&doc); 57 - let _styled = resolve_styles(&doc, &stylesheets); 57 + let styled = match resolve_styles(&doc, &stylesheets) { 58 + Some(s) => s, 59 + None => return, 60 + }; 58 61 59 - // Layout using DOM (style-driven layout is a separate integration step). 60 - let tree = layout(&doc, width as f32, height as f32, font); 62 + // Layout using styled tree (CSS-driven). 63 + let tree = layout(&styled, &doc, width as f32, height as f32, font); 61 64 62 65 let mut renderer = Renderer::new(width, height); 63 66 renderer.paint(&tree, font);
+3
crates/layout/Cargo.toml
··· 12 12 we-style = { path = "../style" } 13 13 we-css = { path = "../css" } 14 14 we-text = { path = "../text" } 15 + 16 + [dev-dependencies] 17 + we-html = { path = "../html" }
+228 -175
crates/layout/src/lib.rs
··· 1 1 //! Block layout engine: box generation, block/inline layout, and text wrapping. 2 2 //! 3 - //! Builds a layout tree from a DOM document and positions block-level elements 4 - //! vertically with text wrapping. Uses hardcoded default styles (no CSS yet). 3 + //! Builds a layout tree from a styled tree (DOM + computed styles) and positions 4 + //! block-level elements vertically with text wrapping. 5 5 6 + use we_css::values::Color; 6 7 use we_dom::{Document, NodeData, NodeId}; 8 + use we_style::computed::{ 9 + BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextDecoration, 10 + }; 7 11 use we_text::font::Font; 8 12 9 13 /// Edge sizes for box model (margin, padding, border). ··· 58 62 pub font_size: f32, 59 63 /// Wrapped text lines (populated for boxes with inline content). 60 64 pub lines: Vec<TextLine>, 65 + /// Text color. 66 + pub color: Color, 67 + /// Background color. 68 + pub background_color: Color, 69 + /// Text decoration (underline, etc.). 70 + pub text_decoration: TextDecoration, 71 + /// Border styles (top, right, bottom, left). 72 + pub border_styles: [BorderStyle; 4], 73 + /// Border colors (top, right, bottom, left). 74 + pub border_colors: [Color; 4], 61 75 } 62 76 63 77 impl LayoutBox { 64 - fn new(box_type: BoxType, font_size: f32) -> Self { 78 + fn new(box_type: BoxType, style: &ComputedStyle) -> Self { 65 79 LayoutBox { 66 80 box_type, 67 81 rect: Rect::default(), ··· 69 83 padding: EdgeSizes::default(), 70 84 border: EdgeSizes::default(), 71 85 children: Vec::new(), 72 - font_size, 86 + font_size: style.font_size, 73 87 lines: Vec::new(), 88 + color: style.color, 89 + background_color: style.background_color, 90 + text_decoration: style.text_decoration, 91 + border_styles: [ 92 + style.border_top_style, 93 + style.border_right_style, 94 + style.border_bottom_style, 95 + style.border_left_style, 96 + ], 97 + border_colors: [ 98 + style.border_top_color, 99 + style.border_right_color, 100 + style.border_bottom_color, 101 + style.border_left_color, 102 + ], 74 103 } 75 104 } 76 105 ··· 125 154 } 126 155 127 156 // --------------------------------------------------------------------------- 128 - // Display type classification 157 + // Resolve LengthOrAuto to f32 129 158 // --------------------------------------------------------------------------- 130 159 131 - #[derive(Debug, Clone, Copy, PartialEq)] 132 - enum DisplayType { 133 - Block, 134 - Inline, 135 - None, 136 - } 137 - 138 - fn display_type(tag: &str) -> DisplayType { 139 - match tag { 140 - "html" | "body" | "div" | "p" | "pre" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" 141 - | "ol" | "li" | "blockquote" | "section" | "article" | "nav" | "header" | "footer" 142 - | "main" | "hr" => DisplayType::Block, 143 - 144 - "span" | "a" | "em" | "strong" | "b" | "i" | "u" | "code" | "small" | "sub" | "sup" 145 - | "br" => DisplayType::Inline, 146 - 147 - "head" | "title" | "script" | "style" | "link" | "meta" => DisplayType::None, 148 - 149 - _ => DisplayType::Block, 160 + fn resolve_length(value: LengthOrAuto) -> f32 { 161 + match value { 162 + LengthOrAuto::Length(px) => px, 163 + LengthOrAuto::Auto => 0.0, 150 164 } 151 165 } 152 166 153 167 // --------------------------------------------------------------------------- 154 - // Default styles (hardcoded for Phase 3) 168 + // Build layout tree from styled tree 155 169 // --------------------------------------------------------------------------- 156 170 157 - fn default_font_size(tag: &str, parent_size: f32) -> f32 { 158 - match tag { 159 - "h1" => parent_size * 2.0, 160 - "h2" => parent_size * 1.5, 161 - "h3" => parent_size * 1.17, 162 - "h4" => parent_size, 163 - "h5" => parent_size * 0.83, 164 - "h6" => parent_size * 0.67, 165 - _ => parent_size, 166 - } 167 - } 168 - 169 - fn default_margin(tag: &str, font_size: f32) -> EdgeSizes { 170 - match tag { 171 - "body" => EdgeSizes { 172 - top: 8.0, 173 - right: 8.0, 174 - bottom: 8.0, 175 - left: 8.0, 176 - }, 177 - "p" => EdgeSizes { 178 - top: font_size, 179 - bottom: font_size, 180 - ..EdgeSizes::default() 181 - }, 182 - "h1" => EdgeSizes { 183 - top: font_size * 0.67, 184 - bottom: font_size * 0.67, 185 - ..EdgeSizes::default() 186 - }, 187 - "h2" => EdgeSizes { 188 - top: font_size * 0.83, 189 - bottom: font_size * 0.83, 190 - ..EdgeSizes::default() 191 - }, 192 - "h3" | "h4" => EdgeSizes { 193 - top: font_size, 194 - bottom: font_size, 195 - ..EdgeSizes::default() 196 - }, 197 - "h5" | "h6" => EdgeSizes { 198 - top: font_size * 1.67, 199 - bottom: font_size * 1.67, 200 - ..EdgeSizes::default() 201 - }, 202 - _ => EdgeSizes::default(), 203 - } 204 - } 171 + fn build_box(styled: &StyledNode, doc: &Document) -> Option<LayoutBox> { 172 + let node = styled.node; 173 + let style = &styled.style; 205 174 206 - // --------------------------------------------------------------------------- 207 - // Build layout tree from DOM 208 - // --------------------------------------------------------------------------- 209 - 210 - fn build_box(doc: &Document, node: NodeId, parent_font_size: f32) -> Option<LayoutBox> { 211 175 match doc.node_data(node) { 212 176 NodeData::Document => { 177 + // Shouldn't reach here since resolve_styles produces element root, 178 + // but handle gracefully. 213 179 let mut children = Vec::new(); 214 - for child in doc.children(node) { 215 - if let Some(child_box) = build_box(doc, child, parent_font_size) { 180 + for child in &styled.children { 181 + if let Some(child_box) = build_box(child, doc) { 216 182 children.push(child_box); 217 183 } 218 184 } 219 - // Unwrap single root element (typically <html>). 220 185 if children.len() == 1 { 221 186 children.into_iter().next() 222 187 } else if children.is_empty() { 223 188 None 224 189 } else { 225 - let mut b = LayoutBox::new(BoxType::Anonymous, parent_font_size); 190 + let mut b = LayoutBox::new(BoxType::Anonymous, style); 226 191 b.children = children; 227 192 Some(b) 228 193 } 229 194 } 230 - NodeData::Element { tag_name, .. } => { 231 - let dt = display_type(tag_name); 232 - if dt == DisplayType::None { 195 + NodeData::Element { .. } => { 196 + // display:none is already filtered by resolve_styles, but guard anyway. 197 + if style.display == Display::None { 233 198 return None; 234 199 } 235 200 236 - let font_size = default_font_size(tag_name, parent_font_size); 237 - let margin = default_margin(tag_name, font_size); 201 + let margin = EdgeSizes { 202 + top: resolve_length(style.margin_top), 203 + right: resolve_length(style.margin_right), 204 + bottom: resolve_length(style.margin_bottom), 205 + left: resolve_length(style.margin_left), 206 + }; 207 + let padding = EdgeSizes { 208 + top: style.padding_top, 209 + right: style.padding_right, 210 + bottom: style.padding_bottom, 211 + left: style.padding_left, 212 + }; 213 + // Only apply border widths when border-style is not none. 214 + let border = EdgeSizes { 215 + top: if style.border_top_style != BorderStyle::None { 216 + style.border_top_width 217 + } else { 218 + 0.0 219 + }, 220 + right: if style.border_right_style != BorderStyle::None { 221 + style.border_right_width 222 + } else { 223 + 0.0 224 + }, 225 + bottom: if style.border_bottom_style != BorderStyle::None { 226 + style.border_bottom_width 227 + } else { 228 + 0.0 229 + }, 230 + left: if style.border_left_style != BorderStyle::None { 231 + style.border_left_width 232 + } else { 233 + 0.0 234 + }, 235 + }; 238 236 239 237 let mut children = Vec::new(); 240 - for child in doc.children(node) { 241 - if let Some(child_box) = build_box(doc, child, font_size) { 238 + for child in &styled.children { 239 + if let Some(child_box) = build_box(child, doc) { 242 240 children.push(child_box); 243 241 } 244 242 } 245 243 246 - let box_type = match dt { 247 - DisplayType::Block => BoxType::Block(node), 248 - DisplayType::Inline => BoxType::Inline(node), 249 - DisplayType::None => unreachable!(), 244 + let box_type = match style.display { 245 + Display::Block => BoxType::Block(node), 246 + Display::Inline => BoxType::Inline(node), 247 + Display::None => unreachable!(), 250 248 }; 251 249 252 250 // For block containers, ensure children are uniformly block or inline. 253 - if dt == DisplayType::Block { 254 - children = normalize_children(children, font_size); 251 + if style.display == Display::Block { 252 + children = normalize_children(children, style); 255 253 } 256 254 257 - let mut b = LayoutBox::new(box_type, font_size); 255 + let mut b = LayoutBox::new(box_type, style); 258 256 b.margin = margin; 257 + b.padding = padding; 258 + b.border = border; 259 259 b.children = children; 260 260 Some(b) 261 261 } ··· 269 269 node, 270 270 text: collapsed, 271 271 }, 272 - parent_font_size, 272 + style, 273 273 )) 274 274 } 275 275 NodeData::Comment { .. } => None, ··· 296 296 297 297 /// If a block container has a mix of block-level and inline-level children, 298 298 /// wrap consecutive inline runs in anonymous block boxes. 299 - fn normalize_children(children: Vec<LayoutBox>, font_size: f32) -> Vec<LayoutBox> { 299 + fn normalize_children(children: Vec<LayoutBox>, parent_style: &ComputedStyle) -> Vec<LayoutBox> { 300 300 if children.is_empty() { 301 301 return children; 302 302 } ··· 320 320 for child in children { 321 321 if is_block_level(&child) { 322 322 if !inline_group.is_empty() { 323 - let mut anon = LayoutBox::new(BoxType::Anonymous, font_size); 323 + let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 324 324 anon.children = std::mem::take(&mut inline_group); 325 325 result.push(anon); 326 326 } ··· 331 331 } 332 332 333 333 if !inline_group.is_empty() { 334 - let mut anon = LayoutBox::new(BoxType::Anonymous, font_size); 334 + let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 335 335 anon.children = inline_group; 336 336 result.push(anon); 337 337 } ··· 511 511 // Public API 512 512 // --------------------------------------------------------------------------- 513 513 514 - const BASE_FONT_SIZE: f32 = 16.0; 515 - 516 - /// Build and lay out a DOM document. 514 + /// Build and lay out from a styled tree (produced by `resolve_styles`). 517 515 /// 518 516 /// Returns a `LayoutTree` with positioned boxes ready for rendering. 519 517 pub fn layout( 520 - document: &Document, 518 + styled_root: &StyledNode, 519 + doc: &Document, 521 520 viewport_width: f32, 522 521 _viewport_height: f32, 523 522 font: &Font, 524 523 ) -> LayoutTree { 525 - let mut root = match build_box(document, document.root(), BASE_FONT_SIZE) { 524 + let mut root = match build_box(styled_root, doc) { 526 525 Some(b) => b, 527 526 None => { 528 527 return LayoutTree { 529 - root: LayoutBox::new(BoxType::Anonymous, BASE_FONT_SIZE), 528 + root: LayoutBox::new(BoxType::Anonymous, &ComputedStyle::default()), 530 529 width: viewport_width, 531 530 height: 0.0, 532 531 }; ··· 547 546 mod tests { 548 547 use super::*; 549 548 use we_dom::Document; 549 + use we_style::computed::{extract_stylesheets, resolve_styles}; 550 550 551 551 // Helper: load a system font for testing. 552 552 fn test_font() -> Font { ··· 563 563 panic!("no test font found"); 564 564 } 565 565 566 - // Helper: build a simple DOM and lay it out. 567 - fn layout_simple_html(html_element: NodeId, doc: &Document) -> LayoutTree { 568 - let _ = html_element; // doc.root() already wraps it 566 + // Helper: build a DOM, resolve styles, and lay it out. 567 + fn layout_doc(doc: &Document) -> LayoutTree { 569 568 let font = test_font(); 570 - layout(doc, 800.0, 600.0, &font) 569 + let sheets = extract_stylesheets(doc); 570 + let styled = resolve_styles(doc, &sheets).unwrap(); 571 + layout(&styled, doc, 800.0, 600.0, &font) 571 572 } 572 573 573 574 #[test] 574 575 fn empty_document() { 575 576 let doc = Document::new(); 576 577 let font = test_font(); 577 - let tree = layout(&doc, 800.0, 600.0, &font); 578 - // Empty document should produce a minimal layout. 579 - assert_eq!(tree.width, 800.0); 578 + let sheets = extract_stylesheets(&doc); 579 + let styled = resolve_styles(&doc, &sheets); 580 + if let Some(styled) = styled { 581 + let tree = layout(&styled, &doc, 800.0, 600.0, &font); 582 + assert_eq!(tree.width, 800.0); 583 + } 584 + // Empty document with no styled root is fine — just produces empty layout. 580 585 } 581 586 582 587 #[test] 583 588 fn single_paragraph() { 584 - // Build: <html><body><p>Hello world</p></body></html> 585 589 let mut doc = Document::new(); 586 590 let root = doc.root(); 587 591 let html = doc.create_element("html"); ··· 593 597 doc.append_child(body, p); 594 598 doc.append_child(p, text); 595 599 596 - let tree = layout_simple_html(html, &doc); 600 + let tree = layout_doc(&doc); 597 601 598 602 // Root should be the html element box. 599 603 assert!(matches!(tree.root.box_type, BoxType::Block(_))); ··· 609 613 assert!(!p_box.lines.is_empty(), "p should have wrapped text lines"); 610 614 assert_eq!(p_box.lines[0].text, "Hello world"); 611 615 612 - // p should have vertical margins (1em = 16px default). 616 + // p should have vertical margins (1em = 16px default from UA stylesheet). 613 617 assert_eq!(p_box.margin.top, 16.0); 614 618 assert_eq!(p_box.margin.bottom, 16.0); 615 619 } 616 620 617 621 #[test] 618 622 fn blocks_stack_vertically() { 619 - // <html><body><p>First</p><p>Second</p></body></html> 620 623 let mut doc = Document::new(); 621 624 let root = doc.root(); 622 625 let html = doc.create_element("html"); ··· 632 635 doc.append_child(body, p2); 633 636 doc.append_child(p2, t2); 634 637 635 - let tree = layout_simple_html(html, &doc); 638 + let tree = layout_doc(&doc); 636 639 let body_box = &tree.root.children[0]; 637 640 let first = &body_box.children[0]; 638 641 let second = &body_box.children[1]; ··· 648 651 649 652 #[test] 650 653 fn heading_larger_than_body() { 651 - // <html><body><h1>Title</h1><p>Text</p></body></html> 652 654 let mut doc = Document::new(); 653 655 let root = doc.root(); 654 656 let html = doc.create_element("html"); ··· 664 666 doc.append_child(body, p); 665 667 doc.append_child(p, p_text); 666 668 667 - let tree = layout_simple_html(html, &doc); 669 + let tree = layout_doc(&doc); 668 670 let body_box = &tree.root.children[0]; 669 671 let h1_box = &body_box.children[0]; 670 672 let p_box = &body_box.children[1]; ··· 700 702 doc.append_child(body, p); 701 703 doc.append_child(p, text); 702 704 703 - let tree = layout_simple_html(html, &doc); 705 + let tree = layout_doc(&doc); 704 706 let body_box = &tree.root.children[0]; 705 707 706 708 assert_eq!(body_box.margin.top, 8.0); ··· 715 717 716 718 #[test] 717 719 fn text_wraps_at_container_width() { 718 - // Use a narrow viewport to force wrapping. 719 720 let mut doc = Document::new(); 720 721 let root = doc.root(); 721 722 let html = doc.create_element("html"); ··· 729 730 doc.append_child(p, text); 730 731 731 732 let font = test_font(); 733 + let sheets = extract_stylesheets(&doc); 734 + let styled = resolve_styles(&doc, &sheets).unwrap(); 732 735 // Narrow viewport: 100px (minus body margin 8+8 = 84px content width). 733 - let tree = layout(&doc, 100.0, 600.0, &font); 736 + let tree = layout(&styled, &doc, 100.0, 600.0, &font); 734 737 let body_box = &tree.root.children[0]; 735 738 let p_box = &body_box.children[0]; 736 739 ··· 755 758 doc.append_child(body, div); 756 759 doc.append_child(div, text); 757 760 758 - let tree = layout_simple_html(html, &doc); 761 + let tree = layout_doc(&doc); 759 762 760 763 // All boxes should have non-negative dimensions. 761 764 for b in tree.iter() { ··· 786 789 doc.append_child(body, p); 787 790 doc.append_child(p, p_text); 788 791 789 - let tree = layout_simple_html(html, &doc); 792 + let tree = layout_doc(&doc); 790 793 791 794 // html should have one child (body), head is display:none. 792 795 assert_eq!( ··· 816 819 doc.append_child(p, p_text); 817 820 doc.append_child(div, text2); 818 821 819 - let tree = layout_simple_html(html, &doc); 822 + let tree = layout_doc(&doc); 820 823 let body_box = &tree.root.children[0]; 821 824 let div_box = &body_box.children[0]; 822 825 ··· 853 856 doc.append_child(em, t2); 854 857 doc.append_child(p, t3); 855 858 856 - let tree = layout_simple_html(html, &doc); 859 + let tree = layout_doc(&doc); 857 860 let body_box = &tree.root.children[0]; 858 861 let p_box = &body_box.children[0]; 859 862 ··· 869 872 assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs "); 870 873 assert_eq!(collapse_whitespace("no-extra"), "no-extra"); 871 874 assert_eq!(collapse_whitespace(" "), " "); 872 - } 873 - 874 - #[test] 875 - fn display_type_classification() { 876 - assert_eq!(display_type("div"), DisplayType::Block); 877 - assert_eq!(display_type("p"), DisplayType::Block); 878 - assert_eq!(display_type("h1"), DisplayType::Block); 879 - assert_eq!(display_type("span"), DisplayType::Inline); 880 - assert_eq!(display_type("a"), DisplayType::Inline); 881 - assert_eq!(display_type("head"), DisplayType::None); 882 - assert_eq!(display_type("script"), DisplayType::None); 883 - assert_eq!(display_type("unknown-tag"), DisplayType::Block); 884 - } 885 - 886 - #[test] 887 - fn default_font_sizes() { 888 - assert_eq!(default_font_size("h1", 16.0), 32.0); 889 - assert_eq!(default_font_size("h2", 16.0), 24.0); 890 - assert_eq!(default_font_size("p", 16.0), 16.0); 891 - assert_eq!(default_font_size("div", 16.0), 16.0); 892 - } 893 - 894 - #[test] 895 - fn heading_margins() { 896 - let m = default_margin("h1", 32.0); 897 - let expected = 32.0 * 0.67; 898 - assert!((m.top - expected).abs() < 0.01); 899 - assert!((m.bottom - expected).abs() < 0.01); 900 - } 901 - 902 - #[test] 903 - fn layout_tree_iteration() { 904 - let mut doc = Document::new(); 905 - let root = doc.root(); 906 - let html = doc.create_element("html"); 907 - let body = doc.create_element("body"); 908 - let p = doc.create_element("p"); 909 - let text = doc.create_text("Test"); 910 - doc.append_child(root, html); 911 - doc.append_child(html, body); 912 - doc.append_child(body, p); 913 - doc.append_child(p, text); 914 - 915 - let tree = layout_simple_html(html, &doc); 916 - let count = tree.iter().count(); 917 - assert!(count >= 3, "should have at least html, body, p boxes"); 918 875 } 919 876 920 877 #[test] ··· 931 888 doc.append_child(div, text); 932 889 933 890 let font = test_font(); 934 - let tree = layout(&doc, 800.0, 600.0, &font); 891 + let sheets = extract_stylesheets(&doc); 892 + let styled = resolve_styles(&doc, &sheets).unwrap(); 893 + let tree = layout(&styled, &doc, 800.0, 600.0, &font); 935 894 let body_box = &tree.root.children[0]; 936 895 937 896 // body content width = 800 - 8 - 8 = 784 ··· 959 918 doc.append_child(h, t); 960 919 } 961 920 962 - let tree = layout_simple_html(html, &doc); 921 + let tree = layout_doc(&doc); 963 922 let body_box = &tree.root.children[0]; 964 923 965 924 // h1 font_size > h2 font_size > h3 font_size ··· 972 931 // All should stack vertically. 973 932 assert!(h2.rect.y > h1.rect.y); 974 933 assert!(h3.rect.y > h2.rect.y); 934 + } 935 + 936 + #[test] 937 + fn layout_tree_iteration() { 938 + let mut doc = Document::new(); 939 + let root = doc.root(); 940 + let html = doc.create_element("html"); 941 + let body = doc.create_element("body"); 942 + let p = doc.create_element("p"); 943 + let text = doc.create_text("Test"); 944 + doc.append_child(root, html); 945 + doc.append_child(html, body); 946 + doc.append_child(body, p); 947 + doc.append_child(p, text); 948 + 949 + let tree = layout_doc(&doc); 950 + let count = tree.iter().count(); 951 + assert!(count >= 3, "should have at least html, body, p boxes"); 952 + } 953 + 954 + #[test] 955 + fn css_style_affects_layout() { 956 + // Test that CSS styles from <style> elements affect layout. 957 + let html_str = r#"<!DOCTYPE html> 958 + <html> 959 + <head> 960 + <style> 961 + p { margin-top: 50px; margin-bottom: 50px; } 962 + </style> 963 + </head> 964 + <body> 965 + <p>First</p> 966 + <p>Second</p> 967 + </body> 968 + </html>"#; 969 + let doc = we_html::parse_html(html_str); 970 + let font = test_font(); 971 + let sheets = extract_stylesheets(&doc); 972 + let styled = resolve_styles(&doc, &sheets).unwrap(); 973 + let tree = layout(&styled, &doc, 800.0, 600.0, &font); 974 + 975 + let body_box = &tree.root.children[0]; 976 + let first = &body_box.children[0]; 977 + let second = &body_box.children[1]; 978 + 979 + // p has 50px top margin from CSS. 980 + assert_eq!(first.margin.top, 50.0); 981 + assert_eq!(first.margin.bottom, 50.0); 982 + 983 + // Second p should be well below first. 984 + assert!(second.rect.y > first.rect.y + 100.0); 985 + } 986 + 987 + #[test] 988 + fn inline_style_affects_layout() { 989 + let html_str = r#"<!DOCTYPE html> 990 + <html> 991 + <body> 992 + <div style="padding-top: 20px; padding-bottom: 20px;"> 993 + <p>Content</p> 994 + </div> 995 + </body> 996 + </html>"#; 997 + let doc = we_html::parse_html(html_str); 998 + let font = test_font(); 999 + let sheets = extract_stylesheets(&doc); 1000 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1001 + let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1002 + 1003 + let body_box = &tree.root.children[0]; 1004 + let div_box = &body_box.children[0]; 1005 + 1006 + assert_eq!(div_box.padding.top, 20.0); 1007 + assert_eq!(div_box.padding.bottom, 20.0); 1008 + } 1009 + 1010 + #[test] 1011 + fn css_color_propagates_to_layout() { 1012 + let html_str = r#"<!DOCTYPE html> 1013 + <html> 1014 + <head><style>p { color: red; background-color: blue; }</style></head> 1015 + <body><p>Colored</p></body> 1016 + </html>"#; 1017 + let doc = we_html::parse_html(html_str); 1018 + let font = test_font(); 1019 + let sheets = extract_stylesheets(&doc); 1020 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1021 + let tree = layout(&styled, &doc, 800.0, 600.0, &font); 1022 + 1023 + let body_box = &tree.root.children[0]; 1024 + let p_box = &body_box.children[0]; 1025 + 1026 + assert_eq!(p_box.color, Color::rgb(255, 0, 0)); 1027 + assert_eq!(p_box.background_color, Color::rgb(0, 0, 255)); 975 1028 } 976 1029 }
+4
crates/render/Cargo.toml
··· 12 12 we-layout = { path = "../layout" } 13 13 we-dom = { path = "../dom" } 14 14 we-css = { path = "../css" } 15 + we-style = { path = "../style" } 15 16 we-text = { path = "../text" } 16 17 we-image = { path = "../image" } 18 + 19 + [dev-dependencies] 20 + we-html = { path = "../html" }
+217 -107
crates/render/src/lib.rs
··· 3 3 //! Walks a layout tree, generates paint commands, and rasterizes them 4 4 //! into a BGRA pixel buffer suitable for display via CoreGraphics. 5 5 6 - use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine}; 6 + use we_css::values::Color; 7 + use we_layout::{LayoutBox, LayoutTree, TextLine}; 8 + use we_style::computed::{BorderStyle, TextDecoration}; 7 9 use we_text::font::Font; 8 10 9 - /// An RGBA color with 8-bit components. 10 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 - pub struct Color { 12 - pub r: u8, 13 - pub g: u8, 14 - pub b: u8, 15 - pub a: u8, 16 - } 17 - 18 - impl Color { 19 - pub const BLACK: Color = Color { 20 - r: 0, 21 - g: 0, 22 - b: 0, 23 - a: 255, 24 - }; 25 - pub const WHITE: Color = Color { 26 - r: 255, 27 - g: 255, 28 - b: 255, 29 - a: 255, 30 - }; 31 - } 32 - 33 11 /// A paint command in the display list. 34 12 #[derive(Debug)] 35 13 pub enum PaintCommand { ··· 55 33 /// Build a display list from a layout tree. 56 34 /// 57 35 /// Walks the tree in depth-first pre-order (painter's order): 58 - /// backgrounds first, then text on top. 36 + /// backgrounds first, then borders, then text on top. 59 37 pub fn build_display_list(tree: &LayoutTree) -> DisplayList { 60 38 let mut list = DisplayList::new(); 61 39 paint_box(&tree.root, &mut list); ··· 63 41 } 64 42 65 43 fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { 66 - // Paint background for block-level boxes. 67 44 paint_background(layout_box, list); 68 - 69 - // Paint text lines (inline content). 45 + paint_borders(layout_box, list); 70 46 paint_text(layout_box, list); 71 47 72 48 // Recurse into children. ··· 76 52 } 77 53 78 54 fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { 79 - match &layout_box.box_type { 80 - BoxType::Block(_) | BoxType::Anonymous => { 81 - // Paint a white background for block boxes. 82 - // Only emit background if the box has non-zero area. 83 - if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { 84 - list.push(PaintCommand::FillRect { 85 - x: layout_box.rect.x, 86 - y: layout_box.rect.y, 87 - width: layout_box.rect.width, 88 - height: layout_box.rect.height, 89 - color: Color::WHITE, 90 - }); 91 - } 92 - } 93 - BoxType::Inline(_) | BoxType::TextRun { .. } => {} 55 + let bg = layout_box.background_color; 56 + // Only paint if the background is not fully transparent and the box has area. 57 + if bg.a == 0 { 58 + return; 59 + } 60 + if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { 61 + // Background covers the padding box (content + padding), not including border. 62 + list.push(PaintCommand::FillRect { 63 + x: layout_box.rect.x, 64 + y: layout_box.rect.y, 65 + width: layout_box.rect.width, 66 + height: layout_box.rect.height, 67 + color: bg, 68 + }); 69 + } 70 + } 71 + 72 + fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { 73 + let b = &layout_box.border; 74 + let r = &layout_box.rect; 75 + let styles = &layout_box.border_styles; 76 + let colors = &layout_box.border_colors; 77 + 78 + // Border box starts at content origin minus padding and border. 79 + let bx = r.x - layout_box.padding.left - b.left; 80 + let by = r.y - layout_box.padding.top - b.top; 81 + let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right; 82 + let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom; 83 + 84 + // Top border 85 + if b.top > 0.0 && styles[0] != BorderStyle::None && styles[0] != BorderStyle::Hidden { 86 + list.push(PaintCommand::FillRect { 87 + x: bx, 88 + y: by, 89 + width: bw, 90 + height: b.top, 91 + color: colors[0], 92 + }); 93 + } 94 + // Right border 95 + if b.right > 0.0 && styles[1] != BorderStyle::None && styles[1] != BorderStyle::Hidden { 96 + list.push(PaintCommand::FillRect { 97 + x: bx + bw - b.right, 98 + y: by, 99 + width: b.right, 100 + height: bh, 101 + color: colors[1], 102 + }); 103 + } 104 + // Bottom border 105 + if b.bottom > 0.0 && styles[2] != BorderStyle::None && styles[2] != BorderStyle::Hidden { 106 + list.push(PaintCommand::FillRect { 107 + x: bx, 108 + y: by + bh - b.bottom, 109 + width: bw, 110 + height: b.bottom, 111 + color: colors[2], 112 + }); 113 + } 114 + // Left border 115 + if b.left > 0.0 && styles[3] != BorderStyle::None && styles[3] != BorderStyle::Hidden { 116 + list.push(PaintCommand::FillRect { 117 + x: bx, 118 + y: by, 119 + width: b.left, 120 + height: bh, 121 + color: colors[3], 122 + }); 94 123 } 95 124 } 96 125 97 126 fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { 127 + let color = layout_box.color; 128 + let underline = layout_box.text_decoration == TextDecoration::Underline; 129 + 98 130 for line in &layout_box.lines { 99 131 list.push(PaintCommand::DrawGlyphs { 100 132 line: line.clone(), 101 133 font_size: layout_box.font_size, 102 - color: Color::BLACK, 134 + color, 103 135 }); 136 + 137 + // Draw underline as a 1px line below the baseline. 138 + if underline && line.width > 0.0 { 139 + let baseline_y = line.y + layout_box.font_size; 140 + let underline_y = baseline_y + 2.0; // 2px below baseline 141 + list.push(PaintCommand::FillRect { 142 + x: line.x, 143 + y: underline_y, 144 + width: line.width, 145 + height: 1.0, 146 + color, 147 + }); 148 + } 104 149 } 105 150 } 106 151 ··· 173 218 } 174 219 175 220 /// Fill a rectangle with a solid color. 176 - fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { 221 + pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { 177 222 let x0 = (x as i32).max(0) as u32; 178 223 let y0 = (y as i32).max(0) as u32; 179 224 let x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; 180 225 let y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; 181 226 182 - for py in y0..y1 { 183 - for px in x0..x1 { 184 - self.set_pixel(px, py, color); 227 + if color.a == 255 { 228 + // Fully opaque — direct write. 229 + for py in y0..y1 { 230 + for px in x0..x1 { 231 + self.set_pixel(px, py, color); 232 + } 233 + } 234 + } else if color.a > 0 { 235 + // Semi-transparent — alpha blend. 236 + let alpha = color.a as u32; 237 + let inv_alpha = 255 - alpha; 238 + for py in y0..y1 { 239 + for px in x0..x1 { 240 + let offset = ((py * self.width + px) * 4) as usize; 241 + let dst_b = self.buffer[offset] as u32; 242 + let dst_g = self.buffer[offset + 1] as u32; 243 + let dst_r = self.buffer[offset + 2] as u32; 244 + self.buffer[offset] = 245 + ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; 246 + self.buffer[offset + 1] = 247 + ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; 248 + self.buffer[offset + 2] = 249 + ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; 250 + self.buffer[offset + 3] = 255; 251 + } 185 252 } 186 253 } 187 254 } ··· 272 339 mod tests { 273 340 use super::*; 274 341 use we_dom::Document; 342 + use we_style::computed::{extract_stylesheets, resolve_styles}; 275 343 use we_text::font::Font; 276 344 277 345 fn test_font() -> Font { ··· 288 356 panic!("no test font found"); 289 357 } 290 358 359 + fn layout_doc(doc: &Document) -> we_layout::LayoutTree { 360 + let font = test_font(); 361 + let sheets = extract_stylesheets(doc); 362 + let styled = resolve_styles(doc, &sheets).unwrap(); 363 + we_layout::layout(&styled, doc, 800.0, 600.0, &font) 364 + } 365 + 291 366 #[test] 292 367 fn renderer_new_white_background() { 293 368 let r = Renderer::new(10, 10); ··· 303 378 #[test] 304 379 fn fill_rect_basic() { 305 380 let mut r = Renderer::new(20, 20); 306 - let red = Color { 307 - r: 255, 308 - g: 0, 309 - b: 0, 310 - a: 255, 311 - }; 381 + let red = Color::new(255, 0, 0, 255); 312 382 r.fill_rect(5.0, 5.0, 10.0, 10.0, red); 313 383 314 384 // Pixel at (7, 7) should be red (BGRA: 0, 0, 255, 255). ··· 328 398 #[test] 329 399 fn fill_rect_clipping() { 330 400 let mut r = Renderer::new(10, 10); 331 - let blue = Color { 332 - r: 0, 333 - g: 0, 334 - b: 255, 335 - a: 255, 336 - }; 401 + let blue = Color::new(0, 0, 255, 255); 337 402 // Rect extends beyond the buffer — should not panic. 338 403 r.fill_rect(-5.0, -5.0, 20.0, 20.0, blue); 339 404 ··· 344 409 } 345 410 346 411 #[test] 347 - fn color_constants() { 348 - assert_eq!( 349 - Color::BLACK, 350 - Color { 351 - r: 0, 352 - g: 0, 353 - b: 0, 354 - a: 255 355 - } 356 - ); 357 - assert_eq!( 358 - Color::WHITE, 359 - Color { 360 - r: 255, 361 - g: 255, 362 - b: 255, 363 - a: 255 364 - } 365 - ); 366 - } 367 - 368 - #[test] 369 412 fn display_list_from_empty_layout() { 370 - let font = test_font(); 371 413 let doc = Document::new(); 372 - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 373 - let list = build_display_list(&tree); 374 - // Empty document should produce no paint commands (or just a background). 375 - // Just check it doesn't panic. 376 - assert!(list.len() <= 1); 414 + let font = test_font(); 415 + let sheets = extract_stylesheets(&doc); 416 + let styled = resolve_styles(&doc, &sheets); 417 + if let Some(styled) = styled { 418 + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 419 + let list = build_display_list(&tree); 420 + assert!(list.len() <= 1); 421 + } 377 422 } 378 423 379 424 #[test] 380 425 fn display_list_has_background_and_text() { 381 - let font = test_font(); 382 426 let mut doc = Document::new(); 383 427 let root = doc.root(); 384 428 let html = doc.create_element("html"); ··· 390 434 doc.append_child(body, p); 391 435 doc.append_child(p, text); 392 436 393 - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 437 + let tree = layout_doc(&doc); 394 438 let list = build_display_list(&tree); 395 439 396 - let has_fill = list 397 - .iter() 398 - .any(|c| matches!(c, PaintCommand::FillRect { .. })); 399 440 let has_text = list 400 441 .iter() 401 442 .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); 402 443 403 - assert!(has_fill, "should have at least one FillRect"); 404 444 assert!(has_text, "should have at least one DrawGlyphs"); 405 445 } 406 446 407 447 #[test] 408 448 fn paint_simple_page() { 409 - let font = test_font(); 410 449 let mut doc = Document::new(); 411 450 let root = doc.root(); 412 451 let html = doc.create_element("html"); ··· 418 457 doc.append_child(body, p); 419 458 doc.append_child(p, text); 420 459 421 - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 460 + let font = test_font(); 461 + let tree = layout_doc(&doc); 422 462 let mut renderer = Renderer::new(800, 600); 423 463 renderer.paint(&tree, &font); 424 464 ··· 435 475 #[test] 436 476 fn bgra_format_correct() { 437 477 let mut r = Renderer::new(1, 1); 438 - let color = Color { 439 - r: 100, 440 - g: 150, 441 - b: 200, 442 - a: 255, 443 - }; 478 + let color = Color::new(100, 150, 200, 255); 444 479 r.set_pixel(0, 0, color); 445 480 let pixels = r.pixels(); 446 481 // BGRA format. ··· 452 487 453 488 #[test] 454 489 fn paint_heading_produces_larger_glyphs() { 455 - let font = test_font(); 456 490 let mut doc = Document::new(); 457 491 let root = doc.root(); 458 492 let html = doc.create_element("html"); ··· 468 502 doc.append_child(body, p); 469 503 doc.append_child(p, p_text); 470 504 471 - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 505 + let tree = layout_doc(&doc); 472 506 let list = build_display_list(&tree); 473 507 474 508 // There should be DrawGlyphs commands with different font sizes. ··· 501 535 #[test] 502 536 fn glyph_compositing_anti_aliased() { 503 537 // Render text and verify we get anti-aliased (partially transparent) pixels. 504 - let font = test_font(); 505 538 let mut doc = Document::new(); 506 539 let root = doc.root(); 507 540 let html = doc.create_element("html"); ··· 513 546 doc.append_child(body, p); 514 547 doc.append_child(p, text); 515 548 516 - let tree = we_layout::layout(&doc, 800.0, 600.0, &font); 549 + let font = test_font(); 550 + let tree = layout_doc(&doc); 517 551 let mut renderer = Renderer::new(800, 600); 518 552 renderer.paint(&tree, &font); 519 553 ··· 535 569 has_intermediate, 536 570 "should have anti-aliased (gray) pixels from glyph compositing" 537 571 ); 572 + } 573 + 574 + #[test] 575 + fn css_color_renders_correctly() { 576 + let html_str = r#"<!DOCTYPE html> 577 + <html> 578 + <head><style>p { color: red; }</style></head> 579 + <body><p>Red text</p></body> 580 + </html>"#; 581 + let doc = we_html::parse_html(html_str); 582 + let font = test_font(); 583 + let sheets = extract_stylesheets(&doc); 584 + let styled = resolve_styles(&doc, &sheets).unwrap(); 585 + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 586 + 587 + let list = build_display_list(&tree); 588 + let text_colors: Vec<&Color> = list 589 + .iter() 590 + .filter_map(|c| match c { 591 + PaintCommand::DrawGlyphs { color, .. } => Some(color), 592 + _ => None, 593 + }) 594 + .collect(); 595 + 596 + assert!(!text_colors.is_empty()); 597 + // Text should be red. 598 + assert_eq!(*text_colors[0], Color::rgb(255, 0, 0)); 599 + } 600 + 601 + #[test] 602 + fn css_background_color_renders() { 603 + let html_str = r#"<!DOCTYPE html> 604 + <html> 605 + <head><style>div { background-color: yellow; }</style></head> 606 + <body><div>Content</div></body> 607 + </html>"#; 608 + let doc = we_html::parse_html(html_str); 609 + let font = test_font(); 610 + let sheets = extract_stylesheets(&doc); 611 + let styled = resolve_styles(&doc, &sheets).unwrap(); 612 + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 613 + 614 + let list = build_display_list(&tree); 615 + let fill_colors: Vec<&Color> = list 616 + .iter() 617 + .filter_map(|c| match c { 618 + PaintCommand::FillRect { color, .. } => Some(color), 619 + _ => None, 620 + }) 621 + .collect(); 622 + 623 + // Should have a yellow fill rect for the div background. 624 + assert!(fill_colors.iter().any(|c| **c == Color::rgb(255, 255, 0))); 625 + } 626 + 627 + #[test] 628 + fn border_rendering() { 629 + let html_str = r#"<!DOCTYPE html> 630 + <html> 631 + <head><style>div { border: 2px solid red; }</style></head> 632 + <body><div>Bordered</div></body> 633 + </html>"#; 634 + let doc = we_html::parse_html(html_str); 635 + let font = test_font(); 636 + let sheets = extract_stylesheets(&doc); 637 + let styled = resolve_styles(&doc, &sheets).unwrap(); 638 + let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font); 639 + 640 + let list = build_display_list(&tree); 641 + let red_fills: Vec<_> = list 642 + .iter() 643 + .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0))) 644 + .collect(); 645 + 646 + // Should have 4 border fills (top, right, bottom, left). 647 + assert_eq!(red_fills.len(), 4, "should have 4 border edges"); 538 648 } 539 649 }