we (web engine): Experimental web browser project to understand the limits of Claude
1//! Display list, software rasterizer, Metal GPU compositor.
2//!
3//! Walks a layout tree, generates paint commands, and rasterizes them
4//! into a BGRA pixel buffer suitable for display via CoreGraphics.
5
6use std::collections::HashMap;
7
8use we_css::values::Color;
9use we_dom::NodeId;
10use we_image::pixel::Image;
11use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine};
12use we_style::computed::{BorderStyle, TextDecoration};
13use we_text::font::Font;
14
15/// A paint command in the display list.
16#[derive(Debug)]
17pub enum PaintCommand {
18 /// Fill a rectangle with a solid color.
19 FillRect {
20 x: f32,
21 y: f32,
22 width: f32,
23 height: f32,
24 color: Color,
25 },
26 /// Draw a text fragment at a position with styling.
27 DrawGlyphs {
28 line: TextLine,
29 font_size: f32,
30 color: Color,
31 },
32 /// Draw an image at a position with given display dimensions.
33 DrawImage {
34 x: f32,
35 y: f32,
36 width: f32,
37 height: f32,
38 node_id: NodeId,
39 },
40}
41
42/// A flat list of paint commands in painter's order.
43pub type DisplayList = Vec<PaintCommand>;
44
45/// Build a display list from a layout tree.
46///
47/// Walks the tree in depth-first pre-order (painter's order):
48/// backgrounds first, then borders, then text on top.
49pub fn build_display_list(tree: &LayoutTree) -> DisplayList {
50 let mut list = DisplayList::new();
51 paint_box(&tree.root, &mut list);
52 list
53}
54
55fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) {
56 paint_background(layout_box, list);
57 paint_borders(layout_box, list);
58
59 // Emit image paint command for replaced elements.
60 if let Some((rw, rh)) = layout_box.replaced_size {
61 if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) {
62 list.push(PaintCommand::DrawImage {
63 x: layout_box.rect.x,
64 y: layout_box.rect.y,
65 width: rw,
66 height: rh,
67 node_id,
68 });
69 }
70 }
71
72 paint_text(layout_box, list);
73
74 // Recurse into children.
75 for child in &layout_box.children {
76 paint_box(child, list);
77 }
78}
79
80/// Extract the NodeId from a BoxType, if it has one.
81fn node_id_from_box_type(box_type: &BoxType) -> Option<NodeId> {
82 match box_type {
83 BoxType::Block(id) | BoxType::Inline(id) => Some(*id),
84 BoxType::TextRun { node, .. } => Some(*node),
85 BoxType::Anonymous => None,
86 }
87}
88
89fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) {
90 let bg = layout_box.background_color;
91 // Only paint if the background is not fully transparent and the box has area.
92 if bg.a == 0 {
93 return;
94 }
95 if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 {
96 // Background covers the padding box (content + padding), not including border.
97 list.push(PaintCommand::FillRect {
98 x: layout_box.rect.x,
99 y: layout_box.rect.y,
100 width: layout_box.rect.width,
101 height: layout_box.rect.height,
102 color: bg,
103 });
104 }
105}
106
107fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) {
108 let b = &layout_box.border;
109 let r = &layout_box.rect;
110 let styles = &layout_box.border_styles;
111 let colors = &layout_box.border_colors;
112
113 // Border box starts at content origin minus padding and border.
114 let bx = r.x - layout_box.padding.left - b.left;
115 let by = r.y - layout_box.padding.top - b.top;
116 let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right;
117 let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom;
118
119 // Top border
120 if b.top > 0.0 && styles[0] != BorderStyle::None && styles[0] != BorderStyle::Hidden {
121 list.push(PaintCommand::FillRect {
122 x: bx,
123 y: by,
124 width: bw,
125 height: b.top,
126 color: colors[0],
127 });
128 }
129 // Right border
130 if b.right > 0.0 && styles[1] != BorderStyle::None && styles[1] != BorderStyle::Hidden {
131 list.push(PaintCommand::FillRect {
132 x: bx + bw - b.right,
133 y: by,
134 width: b.right,
135 height: bh,
136 color: colors[1],
137 });
138 }
139 // Bottom border
140 if b.bottom > 0.0 && styles[2] != BorderStyle::None && styles[2] != BorderStyle::Hidden {
141 list.push(PaintCommand::FillRect {
142 x: bx,
143 y: by + bh - b.bottom,
144 width: bw,
145 height: b.bottom,
146 color: colors[2],
147 });
148 }
149 // Left border
150 if b.left > 0.0 && styles[3] != BorderStyle::None && styles[3] != BorderStyle::Hidden {
151 list.push(PaintCommand::FillRect {
152 x: bx,
153 y: by,
154 width: b.left,
155 height: bh,
156 color: colors[3],
157 });
158 }
159}
160
161fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) {
162 for line in &layout_box.lines {
163 let color = line.color;
164 let font_size = line.font_size;
165
166 // Record index before pushing glyphs so we can insert the background
167 // before the text in painter's order.
168 let glyph_idx = list.len();
169
170 list.push(PaintCommand::DrawGlyphs {
171 line: line.clone(),
172 font_size,
173 color,
174 });
175
176 // Draw underline as a 1px line below the baseline.
177 if line.text_decoration == TextDecoration::Underline && line.width > 0.0 {
178 let baseline_y = line.y + font_size;
179 let underline_y = baseline_y + 2.0;
180 list.push(PaintCommand::FillRect {
181 x: line.x,
182 y: underline_y,
183 width: line.width,
184 height: 1.0,
185 color,
186 });
187 }
188
189 // Draw inline background if not transparent.
190 if line.background_color.a > 0 && line.width > 0.0 {
191 list.insert(
192 glyph_idx,
193 PaintCommand::FillRect {
194 x: line.x,
195 y: line.y,
196 width: line.width,
197 height: font_size * 1.2,
198 color: line.background_color,
199 },
200 );
201 }
202 }
203}
204
205/// Software renderer that paints a display list into a BGRA pixel buffer.
206pub struct Renderer {
207 width: u32,
208 height: u32,
209 /// BGRA pixel data, row-major, top-to-bottom.
210 buffer: Vec<u8>,
211}
212
213impl Renderer {
214 /// Create a new renderer with the given dimensions.
215 /// The buffer is initialized to white.
216 pub fn new(width: u32, height: u32) -> Renderer {
217 let size = (width as usize) * (height as usize) * 4;
218 let mut buffer = vec![0u8; size];
219 // Fill with white (BGRA: B=255, G=255, R=255, A=255).
220 for pixel in buffer.chunks_exact_mut(4) {
221 pixel[0] = 255; // B
222 pixel[1] = 255; // G
223 pixel[2] = 255; // R
224 pixel[3] = 255; // A
225 }
226 Renderer {
227 width,
228 height,
229 buffer,
230 }
231 }
232
233 /// Paint a layout tree into the pixel buffer.
234 pub fn paint(
235 &mut self,
236 layout_tree: &LayoutTree,
237 font: &Font,
238 images: &HashMap<NodeId, &Image>,
239 ) {
240 let display_list = build_display_list(layout_tree);
241 for cmd in &display_list {
242 match cmd {
243 PaintCommand::FillRect {
244 x,
245 y,
246 width,
247 height,
248 color,
249 } => {
250 self.fill_rect(*x, *y, *width, *height, *color);
251 }
252 PaintCommand::DrawGlyphs {
253 line,
254 font_size,
255 color,
256 } => {
257 self.draw_text_line(line, *font_size, *color, font);
258 }
259 PaintCommand::DrawImage {
260 x,
261 y,
262 width,
263 height,
264 node_id,
265 } => {
266 if let Some(image) = images.get(node_id) {
267 self.draw_image(*x, *y, *width, *height, image);
268 }
269 }
270 }
271 }
272 }
273
274 /// Get the BGRA pixel data.
275 pub fn pixels(&self) -> &[u8] {
276 &self.buffer
277 }
278
279 /// Width in pixels.
280 pub fn width(&self) -> u32 {
281 self.width
282 }
283
284 /// Height in pixels.
285 pub fn height(&self) -> u32 {
286 self.height
287 }
288
289 /// Fill a rectangle with a solid color.
290 pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) {
291 let x0 = (x as i32).max(0) as u32;
292 let y0 = (y as i32).max(0) as u32;
293 let x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32;
294 let y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32;
295
296 if color.a == 255 {
297 // Fully opaque — direct write.
298 for py in y0..y1 {
299 for px in x0..x1 {
300 self.set_pixel(px, py, color);
301 }
302 }
303 } else if color.a > 0 {
304 // Semi-transparent — alpha blend.
305 let alpha = color.a as u32;
306 let inv_alpha = 255 - alpha;
307 for py in y0..y1 {
308 for px in x0..x1 {
309 let offset = ((py * self.width + px) * 4) as usize;
310 let dst_b = self.buffer[offset] as u32;
311 let dst_g = self.buffer[offset + 1] as u32;
312 let dst_r = self.buffer[offset + 2] as u32;
313 self.buffer[offset] =
314 ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8;
315 self.buffer[offset + 1] =
316 ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8;
317 self.buffer[offset + 2] =
318 ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8;
319 self.buffer[offset + 3] = 255;
320 }
321 }
322 }
323 }
324
325 /// Draw a line of text using the font to render glyphs.
326 fn draw_text_line(&mut self, line: &TextLine, font_size: f32, color: Color, font: &Font) {
327 let positioned = font.render_text(&line.text, font_size);
328
329 for glyph in &positioned {
330 if let Some(ref bitmap) = glyph.bitmap {
331 // Glyph origin: line position + glyph horizontal offset.
332 let gx = line.x + glyph.x + bitmap.bearing_x as f32;
333 // Baseline is at line.y + font_size (approximate: baseline at ~80% of font_size).
334 // bearing_y is distance from baseline to top of glyph (positive = above baseline).
335 let baseline_y = line.y + font_size;
336 let gy = baseline_y - bitmap.bearing_y as f32;
337
338 self.composite_glyph(gx, gy, bitmap, color);
339 }
340 }
341 }
342
343 /// Composite a grayscale glyph bitmap onto the buffer with anti-aliasing.
344 fn composite_glyph(
345 &mut self,
346 x: f32,
347 y: f32,
348 bitmap: &we_text::font::rasterizer::GlyphBitmap,
349 color: Color,
350 ) {
351 let x0 = x as i32;
352 let y0 = y as i32;
353
354 for by in 0..bitmap.height {
355 for bx in 0..bitmap.width {
356 let px = x0 + bx as i32;
357 let py = y0 + by as i32;
358
359 if px < 0 || py < 0 || px >= self.width as i32 || py >= self.height as i32 {
360 continue;
361 }
362
363 let coverage = bitmap.data[(by * bitmap.width + bx) as usize];
364 if coverage == 0 {
365 continue;
366 }
367
368 let px = px as u32;
369 let py = py as u32;
370
371 // Alpha-blend the glyph coverage with the text color onto the background.
372 let alpha = (coverage as u32 * color.a as u32) / 255;
373 if alpha == 0 {
374 continue;
375 }
376
377 let offset = ((py * self.width + px) * 4) as usize;
378 let dst_b = self.buffer[offset] as u32;
379 let dst_g = self.buffer[offset + 1] as u32;
380 let dst_r = self.buffer[offset + 2] as u32;
381
382 // Source-over compositing.
383 let inv_alpha = 255 - alpha;
384 self.buffer[offset] = ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8;
385 self.buffer[offset + 1] =
386 ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8;
387 self.buffer[offset + 2] =
388 ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8;
389 self.buffer[offset + 3] = 255; // Fully opaque.
390 }
391 }
392 }
393
394 /// Draw an RGBA8 image scaled to the given display dimensions.
395 ///
396 /// Uses nearest-neighbor sampling for scaling. The source image is in RGBA8
397 /// format; the buffer is BGRA. Alpha compositing is performed for semi-transparent pixels.
398 fn draw_image(&mut self, x: f32, y: f32, width: f32, height: f32, image: &Image) {
399 if image.width == 0 || image.height == 0 || width <= 0.0 || height <= 0.0 {
400 return;
401 }
402
403 let dst_x0 = (x as i32).max(0) as u32;
404 let dst_y0 = (y as i32).max(0) as u32;
405 let dst_x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32;
406 let dst_y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32;
407
408 let scale_x = image.width as f32 / width;
409 let scale_y = image.height as f32 / height;
410
411 for dst_y in dst_y0..dst_y1 {
412 let src_y = ((dst_y as f32 - y) * scale_y) as u32;
413 let src_y = src_y.min(image.height - 1);
414
415 for dst_x in dst_x0..dst_x1 {
416 let src_x = ((dst_x as f32 - x) * scale_x) as u32;
417 let src_x = src_x.min(image.width - 1);
418
419 let src_offset = ((src_y * image.width + src_x) * 4) as usize;
420 let r = image.data[src_offset] as u32;
421 let g = image.data[src_offset + 1] as u32;
422 let b = image.data[src_offset + 2] as u32;
423 let a = image.data[src_offset + 3] as u32;
424
425 if a == 0 {
426 continue;
427 }
428
429 let dst_offset = ((dst_y * self.width + dst_x) * 4) as usize;
430
431 if a == 255 {
432 // Fully opaque — direct write (RGBA → BGRA).
433 self.buffer[dst_offset] = b as u8;
434 self.buffer[dst_offset + 1] = g as u8;
435 self.buffer[dst_offset + 2] = r as u8;
436 self.buffer[dst_offset + 3] = 255;
437 } else {
438 // Alpha blend.
439 let inv_a = 255 - a;
440 let dst_b = self.buffer[dst_offset] as u32;
441 let dst_g = self.buffer[dst_offset + 1] as u32;
442 let dst_r = self.buffer[dst_offset + 2] as u32;
443 self.buffer[dst_offset] = ((b * a + dst_b * inv_a) / 255) as u8;
444 self.buffer[dst_offset + 1] = ((g * a + dst_g * inv_a) / 255) as u8;
445 self.buffer[dst_offset + 2] = ((r * a + dst_r * inv_a) / 255) as u8;
446 self.buffer[dst_offset + 3] = 255;
447 }
448 }
449 }
450 }
451
452 /// Set a single pixel to the given color (no blending).
453 fn set_pixel(&mut self, x: u32, y: u32, color: Color) {
454 if x >= self.width || y >= self.height {
455 return;
456 }
457 let offset = ((y * self.width + x) * 4) as usize;
458 self.buffer[offset] = color.b; // B
459 self.buffer[offset + 1] = color.g; // G
460 self.buffer[offset + 2] = color.r; // R
461 self.buffer[offset + 3] = color.a; // A
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use we_dom::Document;
469 use we_style::computed::{extract_stylesheets, resolve_styles};
470 use we_text::font::Font;
471
472 fn test_font() -> Font {
473 let paths = [
474 "/System/Library/Fonts/Geneva.ttf",
475 "/System/Library/Fonts/Monaco.ttf",
476 ];
477 for path in &paths {
478 let p = std::path::Path::new(path);
479 if p.exists() {
480 return Font::from_file(p).expect("failed to parse font");
481 }
482 }
483 panic!("no test font found");
484 }
485
486 fn layout_doc(doc: &Document) -> we_layout::LayoutTree {
487 let font = test_font();
488 let sheets = extract_stylesheets(doc);
489 let styled = resolve_styles(doc, &sheets).unwrap();
490 we_layout::layout(
491 &styled,
492 doc,
493 800.0,
494 600.0,
495 &font,
496 &std::collections::HashMap::new(),
497 )
498 }
499
500 #[test]
501 fn renderer_new_white_background() {
502 let r = Renderer::new(10, 10);
503 assert_eq!(r.width(), 10);
504 assert_eq!(r.height(), 10);
505 assert_eq!(r.pixels().len(), 10 * 10 * 4);
506 // All pixels should be white (BGRA: 255, 255, 255, 255).
507 for pixel in r.pixels().chunks_exact(4) {
508 assert_eq!(pixel, [255, 255, 255, 255]);
509 }
510 }
511
512 #[test]
513 fn fill_rect_basic() {
514 let mut r = Renderer::new(20, 20);
515 let red = Color::new(255, 0, 0, 255);
516 r.fill_rect(5.0, 5.0, 10.0, 10.0, red);
517
518 // Pixel at (7, 7) should be red (BGRA: 0, 0, 255, 255).
519 let offset = ((7 * 20 + 7) * 4) as usize;
520 assert_eq!(r.pixels()[offset], 0); // B
521 assert_eq!(r.pixels()[offset + 1], 0); // G
522 assert_eq!(r.pixels()[offset + 2], 255); // R
523 assert_eq!(r.pixels()[offset + 3], 255); // A
524
525 // Pixel at (0, 0) should still be white.
526 assert_eq!(r.pixels()[0], 255); // B
527 assert_eq!(r.pixels()[1], 255); // G
528 assert_eq!(r.pixels()[2], 255); // R
529 assert_eq!(r.pixels()[3], 255); // A
530 }
531
532 #[test]
533 fn fill_rect_clipping() {
534 let mut r = Renderer::new(10, 10);
535 let blue = Color::new(0, 0, 255, 255);
536 // Rect extends beyond the buffer — should not panic.
537 r.fill_rect(-5.0, -5.0, 20.0, 20.0, blue);
538
539 // All pixels should be blue (BGRA: 255, 0, 0, 255).
540 for pixel in r.pixels().chunks_exact(4) {
541 assert_eq!(pixel, [255, 0, 0, 255]);
542 }
543 }
544
545 #[test]
546 fn display_list_from_empty_layout() {
547 let doc = Document::new();
548 let font = test_font();
549 let sheets = extract_stylesheets(&doc);
550 let styled = resolve_styles(&doc, &sheets);
551 if let Some(styled) = styled {
552 let tree = we_layout::layout(
553 &styled,
554 &doc,
555 800.0,
556 600.0,
557 &font,
558 &std::collections::HashMap::new(),
559 );
560 let list = build_display_list(&tree);
561 assert!(list.len() <= 1);
562 }
563 }
564
565 #[test]
566 fn display_list_has_background_and_text() {
567 let mut doc = Document::new();
568 let root = doc.root();
569 let html = doc.create_element("html");
570 let body = doc.create_element("body");
571 let p = doc.create_element("p");
572 let text = doc.create_text("Hello world");
573 doc.append_child(root, html);
574 doc.append_child(html, body);
575 doc.append_child(body, p);
576 doc.append_child(p, text);
577
578 let tree = layout_doc(&doc);
579 let list = build_display_list(&tree);
580
581 let has_text = list
582 .iter()
583 .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. }));
584
585 assert!(has_text, "should have at least one DrawGlyphs");
586 }
587
588 #[test]
589 fn paint_simple_page() {
590 let mut doc = Document::new();
591 let root = doc.root();
592 let html = doc.create_element("html");
593 let body = doc.create_element("body");
594 let p = doc.create_element("p");
595 let text = doc.create_text("Hello");
596 doc.append_child(root, html);
597 doc.append_child(html, body);
598 doc.append_child(body, p);
599 doc.append_child(p, text);
600
601 let font = test_font();
602 let tree = layout_doc(&doc);
603 let mut renderer = Renderer::new(800, 600);
604 renderer.paint(&tree, &font, &HashMap::new());
605
606 let pixels = renderer.pixels();
607
608 // The buffer should have some non-white pixels (from text rendering).
609 let has_non_white = pixels.chunks_exact(4).any(|p| p != [255, 255, 255, 255]);
610 assert!(
611 has_non_white,
612 "rendered page should have non-white pixels from text"
613 );
614 }
615
616 #[test]
617 fn bgra_format_correct() {
618 let mut r = Renderer::new(1, 1);
619 let color = Color::new(100, 150, 200, 255);
620 r.set_pixel(0, 0, color);
621 let pixels = r.pixels();
622 // BGRA format.
623 assert_eq!(pixels[0], 200); // B
624 assert_eq!(pixels[1], 150); // G
625 assert_eq!(pixels[2], 100); // R
626 assert_eq!(pixels[3], 255); // A
627 }
628
629 #[test]
630 fn paint_heading_produces_larger_glyphs() {
631 let mut doc = Document::new();
632 let root = doc.root();
633 let html = doc.create_element("html");
634 let body = doc.create_element("body");
635 let h1 = doc.create_element("h1");
636 let h1_text = doc.create_text("Big");
637 let p = doc.create_element("p");
638 let p_text = doc.create_text("Small");
639 doc.append_child(root, html);
640 doc.append_child(html, body);
641 doc.append_child(body, h1);
642 doc.append_child(h1, h1_text);
643 doc.append_child(body, p);
644 doc.append_child(p, p_text);
645
646 let tree = layout_doc(&doc);
647 let list = build_display_list(&tree);
648
649 // There should be DrawGlyphs commands with different font sizes.
650 let font_sizes: Vec<f32> = list
651 .iter()
652 .filter_map(|c| match c {
653 PaintCommand::DrawGlyphs { font_size, .. } => Some(*font_size),
654 _ => None,
655 })
656 .collect();
657
658 assert!(font_sizes.len() >= 2, "should have at least 2 text lines");
659 // h1 has font_size 32, p has font_size 16.
660 assert!(
661 font_sizes.iter().any(|&s| s > 20.0),
662 "should have a heading with large font size"
663 );
664 assert!(
665 font_sizes.iter().any(|&s| s < 20.0),
666 "should have a paragraph with normal font size"
667 );
668 }
669
670 #[test]
671 fn renderer_zero_size() {
672 let r = Renderer::new(0, 0);
673 assert_eq!(r.pixels().len(), 0);
674 }
675
676 #[test]
677 fn glyph_compositing_anti_aliased() {
678 // Render text and verify we get anti-aliased (partially transparent) pixels.
679 let mut doc = Document::new();
680 let root = doc.root();
681 let html = doc.create_element("html");
682 let body = doc.create_element("body");
683 let p = doc.create_element("p");
684 let text = doc.create_text("MMMMM");
685 doc.append_child(root, html);
686 doc.append_child(html, body);
687 doc.append_child(body, p);
688 doc.append_child(p, text);
689
690 let font = test_font();
691 let tree = layout_doc(&doc);
692 let mut renderer = Renderer::new(800, 600);
693 renderer.paint(&tree, &font, &HashMap::new());
694
695 let pixels = renderer.pixels();
696 // Find pixels that are not pure white and not pure black.
697 // These represent anti-aliased edges of glyphs.
698 let mut has_intermediate = false;
699 for pixel in pixels.chunks_exact(4) {
700 let b = pixel[0];
701 let g = pixel[1];
702 let r = pixel[2];
703 // Anti-aliased pixel: gray between white and black.
704 if r > 0 && r < 255 && r == g && g == b {
705 has_intermediate = true;
706 break;
707 }
708 }
709 assert!(
710 has_intermediate,
711 "should have anti-aliased (gray) pixels from glyph compositing"
712 );
713 }
714
715 #[test]
716 fn css_color_renders_correctly() {
717 let html_str = r#"<!DOCTYPE html>
718<html>
719<head><style>p { color: red; }</style></head>
720<body><p>Red text</p></body>
721</html>"#;
722 let doc = we_html::parse_html(html_str);
723 let font = test_font();
724 let sheets = extract_stylesheets(&doc);
725 let styled = resolve_styles(&doc, &sheets).unwrap();
726 let tree = we_layout::layout(
727 &styled,
728 &doc,
729 800.0,
730 600.0,
731 &font,
732 &std::collections::HashMap::new(),
733 );
734
735 let list = build_display_list(&tree);
736 let text_colors: Vec<&Color> = list
737 .iter()
738 .filter_map(|c| match c {
739 PaintCommand::DrawGlyphs { color, .. } => Some(color),
740 _ => None,
741 })
742 .collect();
743
744 assert!(!text_colors.is_empty());
745 // Text should be red.
746 assert_eq!(*text_colors[0], Color::rgb(255, 0, 0));
747 }
748
749 #[test]
750 fn css_background_color_renders() {
751 let html_str = r#"<!DOCTYPE html>
752<html>
753<head><style>div { background-color: yellow; }</style></head>
754<body><div>Content</div></body>
755</html>"#;
756 let doc = we_html::parse_html(html_str);
757 let font = test_font();
758 let sheets = extract_stylesheets(&doc);
759 let styled = resolve_styles(&doc, &sheets).unwrap();
760 let tree = we_layout::layout(
761 &styled,
762 &doc,
763 800.0,
764 600.0,
765 &font,
766 &std::collections::HashMap::new(),
767 );
768
769 let list = build_display_list(&tree);
770 let fill_colors: Vec<&Color> = list
771 .iter()
772 .filter_map(|c| match c {
773 PaintCommand::FillRect { color, .. } => Some(color),
774 _ => None,
775 })
776 .collect();
777
778 // Should have a yellow fill rect for the div background.
779 assert!(fill_colors.iter().any(|c| **c == Color::rgb(255, 255, 0)));
780 }
781
782 #[test]
783 fn border_rendering() {
784 let html_str = r#"<!DOCTYPE html>
785<html>
786<head><style>div { border: 2px solid red; }</style></head>
787<body><div>Bordered</div></body>
788</html>"#;
789 let doc = we_html::parse_html(html_str);
790 let font = test_font();
791 let sheets = extract_stylesheets(&doc);
792 let styled = resolve_styles(&doc, &sheets).unwrap();
793 let tree = we_layout::layout(
794 &styled,
795 &doc,
796 800.0,
797 600.0,
798 &font,
799 &std::collections::HashMap::new(),
800 );
801
802 let list = build_display_list(&tree);
803 let red_fills: Vec<_> = list
804 .iter()
805 .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0)))
806 .collect();
807
808 // Should have 4 border fills (top, right, bottom, left).
809 assert_eq!(red_fills.len(), 4, "should have 4 border edges");
810 }
811}