diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 53bb956..d7af917 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1 +1,539 @@ //! Display list, software rasterizer, Metal GPU compositor. +//! +//! Walks a layout tree, generates paint commands, and rasterizes them +//! into a BGRA pixel buffer suitable for display via CoreGraphics. + +use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine}; +use we_text::font::Font; + +/// An RGBA color with 8-bit components. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Color { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl Color { + pub const BLACK: Color = Color { + r: 0, + g: 0, + b: 0, + a: 255, + }; + pub const WHITE: Color = Color { + r: 255, + g: 255, + b: 255, + a: 255, + }; +} + +/// A paint command in the display list. +#[derive(Debug)] +pub enum PaintCommand { + /// Fill a rectangle with a solid color. + FillRect { + x: f32, + y: f32, + width: f32, + height: f32, + color: Color, + }, + /// Draw a glyph bitmap at a position with a text color. + DrawGlyphs { + line: TextLine, + font_size: f32, + color: Color, + }, +} + +/// A flat list of paint commands in painter's order. +pub type DisplayList = Vec; + +/// Build a display list from a layout tree. +/// +/// Walks the tree in depth-first pre-order (painter's order): +/// backgrounds first, then text on top. +pub fn build_display_list(tree: &LayoutTree) -> DisplayList { + let mut list = DisplayList::new(); + paint_box(&tree.root, &mut list); + list +} + +fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { + // Paint background for block-level boxes. + paint_background(layout_box, list); + + // Paint text lines (inline content). + paint_text(layout_box, list); + + // Recurse into children. + for child in &layout_box.children { + paint_box(child, list); + } +} + +fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { + match &layout_box.box_type { + BoxType::Block(_) | BoxType::Anonymous => { + // Paint a white background for block boxes. + // Only emit background if the box has non-zero area. + if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { + list.push(PaintCommand::FillRect { + x: layout_box.rect.x, + y: layout_box.rect.y, + width: layout_box.rect.width, + height: layout_box.rect.height, + color: Color::WHITE, + }); + } + } + BoxType::Inline(_) | BoxType::TextRun { .. } => {} + } +} + +fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { + for line in &layout_box.lines { + list.push(PaintCommand::DrawGlyphs { + line: line.clone(), + font_size: layout_box.font_size, + color: Color::BLACK, + }); + } +} + +/// Software renderer that paints a display list into a BGRA pixel buffer. +pub struct Renderer { + width: u32, + height: u32, + /// BGRA pixel data, row-major, top-to-bottom. + buffer: Vec, +} + +impl Renderer { + /// Create a new renderer with the given dimensions. + /// The buffer is initialized to white. + pub fn new(width: u32, height: u32) -> Renderer { + let size = (width as usize) * (height as usize) * 4; + let mut buffer = vec![0u8; size]; + // Fill with white (BGRA: B=255, G=255, R=255, A=255). + for pixel in buffer.chunks_exact_mut(4) { + pixel[0] = 255; // B + pixel[1] = 255; // G + pixel[2] = 255; // R + pixel[3] = 255; // A + } + Renderer { + width, + height, + buffer, + } + } + + /// Paint a layout tree into the pixel buffer. + pub fn paint(&mut self, layout_tree: &LayoutTree, font: &Font) { + let display_list = build_display_list(layout_tree); + for cmd in &display_list { + match cmd { + PaintCommand::FillRect { + x, + y, + width, + height, + color, + } => { + self.fill_rect(*x, *y, *width, *height, *color); + } + PaintCommand::DrawGlyphs { + line, + font_size, + color, + } => { + self.draw_text_line(line, *font_size, *color, font); + } + } + } + } + + /// Get the BGRA pixel data. + pub fn pixels(&self) -> &[u8] { + &self.buffer + } + + /// Width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// Height in pixels. + pub fn height(&self) -> u32 { + self.height + } + + /// Fill a rectangle with a solid color. + fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { + let x0 = (x as i32).max(0) as u32; + let y0 = (y as i32).max(0) as u32; + let x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; + let y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; + + for py in y0..y1 { + for px in x0..x1 { + self.set_pixel(px, py, color); + } + } + } + + /// Draw a line of text using the font to render glyphs. + fn draw_text_line(&mut self, line: &TextLine, font_size: f32, color: Color, font: &Font) { + let positioned = font.render_text(&line.text, font_size); + + for glyph in &positioned { + if let Some(ref bitmap) = glyph.bitmap { + // Glyph origin: line position + glyph horizontal offset. + let gx = line.x + glyph.x + bitmap.bearing_x as f32; + // Baseline is at line.y + font_size (approximate: baseline at ~80% of font_size). + // bearing_y is distance from baseline to top of glyph (positive = above baseline). + let baseline_y = line.y + font_size; + let gy = baseline_y - bitmap.bearing_y as f32; + + self.composite_glyph(gx, gy, bitmap, color); + } + } + } + + /// Composite a grayscale glyph bitmap onto the buffer with anti-aliasing. + fn composite_glyph( + &mut self, + x: f32, + y: f32, + bitmap: &we_text::font::rasterizer::GlyphBitmap, + color: Color, + ) { + let x0 = x as i32; + let y0 = y as i32; + + for by in 0..bitmap.height { + for bx in 0..bitmap.width { + let px = x0 + bx as i32; + let py = y0 + by as i32; + + if px < 0 || py < 0 || px >= self.width as i32 || py >= self.height as i32 { + continue; + } + + let coverage = bitmap.data[(by * bitmap.width + bx) as usize]; + if coverage == 0 { + continue; + } + + let px = px as u32; + let py = py as u32; + + // Alpha-blend the glyph coverage with the text color onto the background. + let alpha = (coverage as u32 * color.a as u32) / 255; + if alpha == 0 { + continue; + } + + let offset = ((py * self.width + px) * 4) as usize; + let dst_b = self.buffer[offset] as u32; + let dst_g = self.buffer[offset + 1] as u32; + let dst_r = self.buffer[offset + 2] as u32; + + // Source-over compositing. + let inv_alpha = 255 - alpha; + self.buffer[offset] = ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; + self.buffer[offset + 1] = + ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; + self.buffer[offset + 2] = + ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; + self.buffer[offset + 3] = 255; // Fully opaque. + } + } + } + + /// Set a single pixel to the given color (no blending). + fn set_pixel(&mut self, x: u32, y: u32, color: Color) { + if x >= self.width || y >= self.height { + return; + } + let offset = ((y * self.width + x) * 4) as usize; + self.buffer[offset] = color.b; // B + self.buffer[offset + 1] = color.g; // G + self.buffer[offset + 2] = color.r; // R + self.buffer[offset + 3] = color.a; // A + } +} + +#[cfg(test)] +mod tests { + use super::*; + use we_dom::Document; + use we_text::font::Font; + + fn test_font() -> Font { + let paths = [ + "/System/Library/Fonts/Geneva.ttf", + "/System/Library/Fonts/Monaco.ttf", + ]; + for path in &paths { + let p = std::path::Path::new(path); + if p.exists() { + return Font::from_file(p).expect("failed to parse font"); + } + } + panic!("no test font found"); + } + + #[test] + fn renderer_new_white_background() { + let r = Renderer::new(10, 10); + assert_eq!(r.width(), 10); + assert_eq!(r.height(), 10); + assert_eq!(r.pixels().len(), 10 * 10 * 4); + // All pixels should be white (BGRA: 255, 255, 255, 255). + for pixel in r.pixels().chunks_exact(4) { + assert_eq!(pixel, [255, 255, 255, 255]); + } + } + + #[test] + fn fill_rect_basic() { + let mut r = Renderer::new(20, 20); + let red = Color { + r: 255, + g: 0, + b: 0, + a: 255, + }; + r.fill_rect(5.0, 5.0, 10.0, 10.0, red); + + // Pixel at (7, 7) should be red (BGRA: 0, 0, 255, 255). + let offset = ((7 * 20 + 7) * 4) as usize; + assert_eq!(r.pixels()[offset], 0); // B + assert_eq!(r.pixels()[offset + 1], 0); // G + assert_eq!(r.pixels()[offset + 2], 255); // R + assert_eq!(r.pixels()[offset + 3], 255); // A + + // Pixel at (0, 0) should still be white. + assert_eq!(r.pixels()[0], 255); // B + assert_eq!(r.pixels()[1], 255); // G + assert_eq!(r.pixels()[2], 255); // R + assert_eq!(r.pixels()[3], 255); // A + } + + #[test] + fn fill_rect_clipping() { + let mut r = Renderer::new(10, 10); + let blue = Color { + r: 0, + g: 0, + b: 255, + a: 255, + }; + // Rect extends beyond the buffer — should not panic. + r.fill_rect(-5.0, -5.0, 20.0, 20.0, blue); + + // All pixels should be blue (BGRA: 255, 0, 0, 255). + for pixel in r.pixels().chunks_exact(4) { + assert_eq!(pixel, [255, 0, 0, 255]); + } + } + + #[test] + fn color_constants() { + assert_eq!( + Color::BLACK, + Color { + r: 0, + g: 0, + b: 0, + a: 255 + } + ); + assert_eq!( + Color::WHITE, + Color { + r: 255, + g: 255, + b: 255, + a: 255 + } + ); + } + + #[test] + fn display_list_from_empty_layout() { + let font = test_font(); + let doc = Document::new(); + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let list = build_display_list(&tree); + // Empty document should produce no paint commands (or just a background). + // Just check it doesn't panic. + assert!(list.len() <= 1); + } + + #[test] + fn display_list_has_background_and_text() { + let font = test_font(); + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let text = doc.create_text("Hello world"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, text); + + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let list = build_display_list(&tree); + + let has_fill = list + .iter() + .any(|c| matches!(c, PaintCommand::FillRect { .. })); + let has_text = list + .iter() + .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); + + assert!(has_fill, "should have at least one FillRect"); + assert!(has_text, "should have at least one DrawGlyphs"); + } + + #[test] + fn paint_simple_page() { + let font = test_font(); + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let text = doc.create_text("Hello"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, text); + + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let mut renderer = Renderer::new(800, 600); + renderer.paint(&tree, &font); + + let pixels = renderer.pixels(); + + // The buffer should have some non-white pixels (from text rendering). + let has_non_white = pixels.chunks_exact(4).any(|p| p != [255, 255, 255, 255]); + assert!( + has_non_white, + "rendered page should have non-white pixels from text" + ); + } + + #[test] + fn bgra_format_correct() { + let mut r = Renderer::new(1, 1); + let color = Color { + r: 100, + g: 150, + b: 200, + a: 255, + }; + r.set_pixel(0, 0, color); + let pixels = r.pixels(); + // BGRA format. + assert_eq!(pixels[0], 200); // B + assert_eq!(pixels[1], 150); // G + assert_eq!(pixels[2], 100); // R + assert_eq!(pixels[3], 255); // A + } + + #[test] + fn paint_heading_produces_larger_glyphs() { + let font = test_font(); + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let h1 = doc.create_element("h1"); + let h1_text = doc.create_text("Big"); + let p = doc.create_element("p"); + let p_text = doc.create_text("Small"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, h1); + doc.append_child(h1, h1_text); + doc.append_child(body, p); + doc.append_child(p, p_text); + + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let list = build_display_list(&tree); + + // There should be DrawGlyphs commands with different font sizes. + let font_sizes: Vec = list + .iter() + .filter_map(|c| match c { + PaintCommand::DrawGlyphs { font_size, .. } => Some(*font_size), + _ => None, + }) + .collect(); + + assert!(font_sizes.len() >= 2, "should have at least 2 text lines"); + // h1 has font_size 32, p has font_size 16. + assert!( + font_sizes.iter().any(|&s| s > 20.0), + "should have a heading with large font size" + ); + assert!( + font_sizes.iter().any(|&s| s < 20.0), + "should have a paragraph with normal font size" + ); + } + + #[test] + fn renderer_zero_size() { + let r = Renderer::new(0, 0); + assert_eq!(r.pixels().len(), 0); + } + + #[test] + fn glyph_compositing_anti_aliased() { + // Render text and verify we get anti-aliased (partially transparent) pixels. + let font = test_font(); + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let text = doc.create_text("MMMMM"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, text); + + let tree = we_layout::layout(&doc, 800.0, 600.0, &font); + let mut renderer = Renderer::new(800, 600); + renderer.paint(&tree, &font); + + let pixels = renderer.pixels(); + // Find pixels that are not pure white and not pure black. + // These represent anti-aliased edges of glyphs. + let mut has_intermediate = false; + for pixel in pixels.chunks_exact(4) { + let b = pixel[0]; + let g = pixel[1]; + let r = pixel[2]; + // Anti-aliased pixel: gray between white and black. + if r > 0 && r < 255 && r == g && g == b { + has_intermediate = true; + break; + } + } + assert!( + has_intermediate, + "should have anti-aliased (gray) pixels from glyph compositing" + ); + } +}