//! 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. pub mod atlas; pub mod gpu; use std::collections::HashMap; use we_css::values::Color; use we_dom::NodeId; use we_image::pixel::Image; use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH}; use we_platform::metal::{ClearColor, CommandQueue, Device, MetalLayer}; use we_style::computed::{ BorderStyle, LengthOrAuto, Overflow, Position, TextDecoration, Visibility, WillChange, }; use we_text::font::Font; /// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. pub type ScrollState = HashMap; /// Scroll bar track color (light gray). const SCROLLBAR_TRACK_COLOR: Color = Color { r: 230, g: 230, b: 230, a: 255, }; /// Scroll bar thumb color (darker gray). const SCROLLBAR_THUMB_COLOR: Color = Color { r: 160, g: 160, b: 160, 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 text fragment at a position with styling. DrawGlyphs { line: TextLine, font_size: f32, color: Color, }, /// Draw an image at a position with given display dimensions. DrawImage { x: f32, y: f32, width: f32, height: f32, node_id: NodeId, }, /// Push a clip rectangle onto the clip stack. All subsequent paint /// commands are clipped to the intersection of all active clip rects. PushClip { x: f32, y: f32, width: f32, height: f32, }, /// Pop the most recent clip rectangle off the clip stack. PopClip, /// Begin a compositing layer. Content within the layer is rendered to an /// intermediate surface and then composited with the given opacity. PushLayer { opacity: f32, x: f32, y: f32, width: f32, height: f32, }, /// End the current compositing layer and composite it onto the parent. PopLayer, } /// 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 borders, then text on top. pub fn build_display_list(tree: &LayoutTree) -> DisplayList { build_display_list_with_scroll(tree, &HashMap::new()) } /// Build a display list from a layout tree with scroll state. /// /// `page_scroll` is the viewport-level scroll offset (x, y) applied to all content. /// `scroll_state` maps element NodeIds to their per-element scroll offsets. pub fn build_display_list_with_scroll( tree: &LayoutTree, scroll_state: &ScrollState, ) -> DisplayList { let mut list = DisplayList::new(); paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state, 0.0); list } /// Build a display list with page-level scrolling. /// /// `page_scroll_y` shifts all content vertically (viewport-level scroll). pub fn build_display_list_with_page_scroll( tree: &LayoutTree, page_scroll_y: f32, scroll_state: &ScrollState, ) -> DisplayList { let mut list = DisplayList::new(); paint_box( &tree.root, &mut list, (0.0, -page_scroll_y), scroll_state, 0.0, ); list } /// Returns `true` if a box is positioned (absolute or fixed). fn is_positioned(b: &LayoutBox) -> bool { b.position == Position::Absolute || b.position == Position::Fixed } /// Returns `true` if a layout box requires its own compositing layer. fn needs_compositing_layer(b: &LayoutBox) -> bool { // opacity < 1.0 requires group compositing to render correctly b.opacity < 1.0 // will-change hints signal the compositor to promote to a layer || b.will_change != WillChange::default() // fixed-position elements benefit from their own layer || b.position == Position::Fixed // scrollable containers with overflow render content to a layer texture || ((b.overflow == Overflow::Scroll || b.overflow == Overflow::Auto) && b.content_height > b.rect.height) } fn paint_box( layout_box: &LayoutBox, list: &mut DisplayList, translate: (f32, f32), scroll_state: &ScrollState, sticky_ref_screen_y: f32, ) { let visible = layout_box.visibility == Visibility::Visible; let tx = translate.0; let ty = translate.1; // If this box needs a compositing layer, wrap all its paint commands. let layer = needs_compositing_layer(layout_box); if layer { let bb = border_box(layout_box); list.push(PaintCommand::PushLayer { opacity: layout_box.opacity, x: bb.x + tx, y: bb.y + ty, width: bb.width, height: bb.height, }); } if visible { paint_background(layout_box, list, tx, ty); paint_borders(layout_box, list, tx, ty); // Emit image paint command for replaced elements. if let Some((rw, rh)) = layout_box.replaced_size { if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { list.push(PaintCommand::DrawImage { x: layout_box.rect.x + tx, y: layout_box.rect.y + ty, width: rw, height: rh, node_id, }); } } paint_text(layout_box, list, tx, ty); } // Determine if this box is scrollable. let scrollable = layout_box.overflow == Overflow::Scroll || layout_box.overflow == Overflow::Auto; // If this box has overflow clipping, push a clip rect for the padding box. let clips = layout_box.overflow != Overflow::Visible; if clips { let clip = padding_box(layout_box); list.push(PaintCommand::PushClip { x: clip.x + tx, y: clip.y + ty, width: clip.width, height: clip.height, }); } // Compute child translate: adds scroll offset for scrollable boxes. let mut child_translate = translate; // When entering a scroll container, update the sticky reference point // to the container's padding box top in screen coordinates (pre-scroll). let mut child_sticky_ref = sticky_ref_screen_y; if scrollable { // The scroll container's padding box top on screen (before scroll). child_sticky_ref = (layout_box.rect.y - layout_box.padding.top) + translate.1; if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { if let Some(&(sx, sy)) = scroll_state.get(&node_id) { child_translate.0 -= sx; child_translate.1 -= sy; } } } // Check if any children are positioned (absolute/fixed). let has_positioned = layout_box.children.iter().any(is_positioned); if has_positioned { // CSS stacking context ordering: // 1. Positioned children with negative z-index // 2. In-flow (non-positioned) children in tree order // 3. Positioned children with z-index >= 0 (or auto) in z-index order // Collect positioned children indices, partitioned by z-index sign. let mut negative_z: Vec = Vec::new(); let mut non_negative_z: Vec = Vec::new(); for (i, child) in layout_box.children.iter().enumerate() { if is_positioned(child) { let z = child.z_index.unwrap_or(0); if z < 0 { negative_z.push(i); } else { non_negative_z.push(i); } } } // Sort by z-index (stable sort preserves tree order for equal z-index). negative_z.sort_by_key(|&i| layout_box.children[i].z_index.unwrap_or(0)); non_negative_z.sort_by_key(|&i| layout_box.children[i].z_index.unwrap_or(0)); // Paint negative z-index positioned children. for &i in &negative_z { paint_child( &layout_box.children[i], list, child_translate, scroll_state, child_sticky_ref, ); } // Paint in-flow children in tree order. for child in &layout_box.children { if !is_positioned(child) { paint_child(child, list, child_translate, scroll_state, child_sticky_ref); } } // Paint non-negative z-index positioned children. for &i in &non_negative_z { paint_child( &layout_box.children[i], list, child_translate, scroll_state, child_sticky_ref, ); } } else { // No positioned children — paint all in tree order. for child in &layout_box.children { paint_child(child, list, child_translate, scroll_state, child_sticky_ref); } } if clips { list.push(PaintCommand::PopClip); } // Paint scroll bars after PopClip so they're not clipped by the container. if scrollable && visible { paint_scrollbars(layout_box, list, tx, ty, scroll_state); } if layer { list.push(PaintCommand::PopLayer); } } /// Paint a child box, applying sticky positioning offset when needed. fn paint_child( child: &LayoutBox, list: &mut DisplayList, child_translate: (f32, f32), scroll_state: &ScrollState, sticky_ref_screen_y: f32, ) { if child.position == Position::Sticky { let adjusted = compute_sticky_translate(child, child_translate, sticky_ref_screen_y); paint_box(child, list, adjusted, scroll_state, sticky_ref_screen_y); } else { paint_box( child, list, child_translate, scroll_state, sticky_ref_screen_y, ); } } /// Resolve a `LengthOrAuto` to an optional pixel value for sticky offsets. fn resolve_sticky_px(value: LengthOrAuto, reference: f32) -> Option { match value { LengthOrAuto::Length(v) => Some(v), LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), LengthOrAuto::Calc(px, pct) => Some(px + pct / 100.0 * reference), LengthOrAuto::ClampCalc { min_px, px_offset, pct, max_px, } => Some( (px_offset + pct / 100.0 * reference) .max(min_px) .min(max_px), ), LengthOrAuto::Auto => None, } } /// Compute the adjusted translate for a `position: sticky` element. /// /// The element is clamped so that its margin box stays within its /// `sticky_constraint` rectangle while honouring the CSS offset thresholds /// (`top`, `bottom`, `left`, `right`). fn compute_sticky_translate( child: &LayoutBox, child_translate: (f32, f32), sticky_ref_screen_y: f32, ) -> (f32, f32) { let constraint = match child.sticky_constraint { Some(c) => c, None => return child_translate, }; let [css_top, _css_right, css_bottom, _css_left] = child.css_offsets; let mut delta_y = 0.0f32; let margin_top_doc = child.rect.y - child.padding.top - child.border.top - child.margin.top; let margin_bottom_doc = child.rect.y + child.rect.height + child.padding.bottom + child.border.bottom + child.margin.bottom; let margin_top_screen = margin_top_doc + child_translate.1; let margin_bottom_screen = margin_bottom_doc + child_translate.1; let constraint_top_screen = constraint.y + child_translate.1; let constraint_bottom_screen = (constraint.y + constraint.height) + child_translate.1; // Handle `top` stickiness: push the element down so its margin box top // is at least `sticky_ref_screen_y + top`. if let Some(top) = resolve_sticky_px(css_top, constraint.height) { let target = sticky_ref_screen_y + top; let raw_delta = (target - margin_top_screen).max(0.0); // Clamp so margin box bottom does not exceed constraint bottom. let max_delta = (constraint_bottom_screen - margin_bottom_screen).max(0.0); delta_y = raw_delta.min(max_delta); } // Handle `bottom` stickiness: pull the element up so its margin box // bottom does not go below `sticky_ref_screen_y + visible_height - bottom`. // Without a reliable visible-height at this point we approximate by clamping // against the constraint top. if let Some(bottom) = resolve_sticky_px(css_bottom, constraint.height) { // Bottom stickiness: the element should not scroll below the // visible area minus the bottom offset. The visible bottom is // approximated as constraint_bottom_screen. let target_bottom = constraint_bottom_screen - bottom; let raw = (margin_bottom_screen + delta_y - target_bottom).max(0.0); // Clamp so margin top does not go above constraint top. let max_up = (margin_top_screen + delta_y - constraint_top_screen).max(0.0); delta_y -= raw.min(max_up); } (child_translate.0, child_translate.1 + delta_y) } /// Compute the padding box rectangle for a layout box. /// The padding box is the content area expanded by padding. fn padding_box(layout_box: &LayoutBox) -> Rect { Rect { x: layout_box.rect.x - layout_box.padding.left, y: layout_box.rect.y - layout_box.padding.top, width: layout_box.rect.width + layout_box.padding.left + layout_box.padding.right, height: layout_box.rect.height + layout_box.padding.top + layout_box.padding.bottom, } } /// Compute the border box of a layout box (padding box + border widths). fn border_box(layout_box: &LayoutBox) -> Rect { let pb = padding_box(layout_box); Rect { x: pb.x - layout_box.border.left, y: pb.y - layout_box.border.top, width: pb.width + layout_box.border.left + layout_box.border.right, height: pb.height + layout_box.border.top + layout_box.border.bottom, } } /// Extract the NodeId from a BoxType, if it has one. fn node_id_from_box_type(box_type: &BoxType) -> Option { match box_type { BoxType::Block(id) | BoxType::Inline(id) => Some(*id), BoxType::TextRun { node, .. } => Some(*node), BoxType::Anonymous => None, } } fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bg = layout_box.background_color; // Only paint if the background is not fully transparent and the box has area. if bg.a == 0 { return; } if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { // Background covers the padding box (content + padding), not including border. list.push(PaintCommand::FillRect { x: layout_box.rect.x + tx, y: layout_box.rect.y + ty, width: layout_box.rect.width, height: layout_box.rect.height, color: bg, }); } } fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let b = &layout_box.border; let r = &layout_box.rect; let styles = &layout_box.border_styles; let colors = &layout_box.border_colors; // Border box starts at content origin minus padding and border. let bx = r.x - layout_box.padding.left - b.left + tx; let by = r.y - layout_box.padding.top - b.top + ty; let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right; let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom; // Top border if b.top > 0.0 && styles[0] != BorderStyle::None && styles[0] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx, y: by, width: bw, height: b.top, color: colors[0], }); } // Right border if b.right > 0.0 && styles[1] != BorderStyle::None && styles[1] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx + bw - b.right, y: by, width: b.right, height: bh, color: colors[1], }); } // Bottom border if b.bottom > 0.0 && styles[2] != BorderStyle::None && styles[2] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx, y: by + bh - b.bottom, width: bw, height: b.bottom, color: colors[2], }); } // Left border if b.left > 0.0 && styles[3] != BorderStyle::None && styles[3] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx, y: by, width: b.left, height: bh, color: colors[3], }); } } fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { for line in &layout_box.lines { let color = line.color; let font_size = line.font_size; // Record index before pushing glyphs so we can insert the background // before the text in painter's order. let glyph_idx = list.len(); // Create a translated copy of the text line. let mut translated_line = line.clone(); translated_line.x += tx; translated_line.y += ty; list.push(PaintCommand::DrawGlyphs { line: translated_line, font_size, color, }); // Draw underline as a 1px line below the baseline. if line.text_decoration == TextDecoration::Underline && line.width > 0.0 { let baseline_y = line.y + ty + font_size; let underline_y = baseline_y + 2.0; list.push(PaintCommand::FillRect { x: line.x + tx, y: underline_y, width: line.width, height: 1.0, color, }); } // Draw inline background if not transparent. if line.background_color.a > 0 && line.width > 0.0 { list.insert( glyph_idx, PaintCommand::FillRect { x: line.x + tx, y: line.y + ty, width: line.width, height: font_size * 1.2, color: line.background_color, }, ); } } } /// Paint scroll bars for a scrollable box. fn paint_scrollbars( layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32, scroll_state: &ScrollState, ) { let pad = padding_box(layout_box); let viewport_height = pad.height; let content_height = layout_box.content_height; // Determine whether scroll bars should be shown. let show_vertical = match layout_box.overflow { Overflow::Scroll => true, Overflow::Auto => content_height > viewport_height, _ => false, }; if !show_vertical || viewport_height <= 0.0 { return; } // Scroll bar track: right edge of the padding box. let track_x = pad.x + pad.width - SCROLLBAR_WIDTH + tx; let track_y = pad.y + ty; let track_height = viewport_height; // Paint the track background. list.push(PaintCommand::FillRect { x: track_x, y: track_y, width: SCROLLBAR_WIDTH, height: track_height, color: SCROLLBAR_TRACK_COLOR, }); // Compute thumb size and position. let max_content = content_height.max(viewport_height); let thumb_ratio = viewport_height / max_content; let thumb_height = (thumb_ratio * track_height).max(20.0).min(track_height); // Get current scroll offset. let scroll_y = node_id_from_box_type(&layout_box.box_type) .and_then(|id| scroll_state.get(&id)) .map(|&(_, sy)| sy) .unwrap_or(0.0); let max_scroll = (content_height - viewport_height).max(0.0); let scroll_ratio = if max_scroll > 0.0 { scroll_y / max_scroll } else { 0.0 }; let thumb_y = track_y + scroll_ratio * (track_height - thumb_height); // Paint the thumb. list.push(PaintCommand::FillRect { x: track_x, y: thumb_y, width: SCROLLBAR_WIDTH, height: thumb_height, color: SCROLLBAR_THUMB_COLOR, }); } /// An axis-aligned clip rectangle. #[derive(Debug, Clone, Copy)] struct ClipRect { x0: f32, y0: f32, x1: f32, y1: f32, } impl ClipRect { /// Intersect two clip rects, returning the overlapping region. /// Returns None if they don't overlap. fn intersect(self, other: ClipRect) -> Option { let x0 = self.x0.max(other.x0); let y0 = self.y0.max(other.y0); let x1 = self.x1.min(other.x1); let y1 = self.y1.min(other.y1); if x0 < x1 && y0 < y1 { Some(ClipRect { x0, y0, x1, y1 }) } else { None } } } /// Software renderer that paints a display list into a BGRA pixel buffer. /// Saved state for a software compositing layer. struct SoftwareLayerState { /// The parent's pixel buffer that was replaced. saved_buffer: Vec, /// Layer opacity to apply when compositing back. opacity: f32, } pub struct Renderer { width: u32, height: u32, /// BGRA pixel data, row-major, top-to-bottom. buffer: Vec, /// Stack of clip rectangles. When non-empty, all drawing is clipped to /// the intersection of all active clip rects. clip_stack: Vec, /// Stack of compositing layer states for group opacity. layer_stack: 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, clip_stack: Vec::new(), layer_stack: Vec::new(), } } /// Compute the effective clip rect from the clip stack. /// Returns None if the clip stack is empty (no clipping). fn active_clip(&self) -> Option { if self.clip_stack.is_empty() { return None; } // Start with the full buffer as the initial rect, then intersect. let mut result = ClipRect { x0: 0.0, y0: 0.0, x1: self.width as f32, y1: self.height as f32, }; for clip in &self.clip_stack { match result.intersect(*clip) { Some(r) => result = r, None => { return Some(ClipRect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0, }) } } } Some(result) } /// Paint a layout tree into the pixel buffer. pub fn paint( &mut self, layout_tree: &LayoutTree, font: &Font, images: &HashMap, ) { self.paint_with_scroll(layout_tree, font, images, 0.0, &HashMap::new()); } /// Paint a layout tree with scroll state into the pixel buffer. pub fn paint_with_scroll( &mut self, layout_tree: &LayoutTree, font: &Font, images: &HashMap, page_scroll_y: f32, scroll_state: &ScrollState, ) { let display_list = build_display_list_with_page_scroll(layout_tree, page_scroll_y, scroll_state); self.paint_display_list(&display_list, font, images); } /// Paint a pre-built display list into the pixel buffer. /// /// Resets the buffer to white before painting. This is the common entry /// point used by [`RenderBackend`] for software rendering. pub fn paint_display_list( &mut self, display_list: &DisplayList, font: &Font, images: &HashMap, ) { // Reset buffer to white. for pixel in self.buffer.chunks_exact_mut(4) { pixel[0] = 255; // B pixel[1] = 255; // G pixel[2] = 255; // R pixel[3] = 255; // A } self.clip_stack.clear(); self.layer_stack.clear(); 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); } PaintCommand::DrawImage { x, y, width, height, node_id, } => { if let Some(image) = images.get(node_id) { self.draw_image(*x, *y, *width, *height, image); } } PaintCommand::PushClip { x, y, width, height, } => { self.clip_stack.push(ClipRect { x0: *x, y0: *y, x1: *x + *width, y1: *y + *height, }); } PaintCommand::PopClip => { self.clip_stack.pop(); } PaintCommand::PushLayer { opacity, .. } => { if *opacity < 1.0 { // Save the current buffer and start rendering to a // fresh transparent buffer. let saved = std::mem::replace( &mut self.buffer, vec![0u8; (self.width as usize) * (self.height as usize) * 4], ); self.layer_stack.push(SoftwareLayerState { saved_buffer: saved, opacity: *opacity, }); } else { // Opacity 1.0 — no isolation needed, push a no-op. self.layer_stack.push(SoftwareLayerState { saved_buffer: Vec::new(), opacity: 1.0, }); } } PaintCommand::PopLayer => { if let Some(state) = self.layer_stack.pop() { if state.opacity < 1.0 { // Composite the layer buffer onto the saved parent // buffer with the layer opacity. let layer_buf = std::mem::replace(&mut self.buffer, state.saved_buffer); let alpha = (state.opacity * 255.0) as u32; let len = self.buffer.len(); let mut i = 0; while i + 3 < len { let src_a = layer_buf[i + 3] as u32; if src_a > 0 { // Combine layer pixel alpha with group opacity. let a = (src_a * alpha) / 255; let inv_a = 255 - a; let src_b = layer_buf[i] as u32; let src_g = layer_buf[i + 1] as u32; let src_r = layer_buf[i + 2] as u32; let dst_b = self.buffer[i] as u32; let dst_g = self.buffer[i + 1] as u32; let dst_r = self.buffer[i + 2] as u32; let dst_a = self.buffer[i + 3] as u32; self.buffer[i] = ((src_b * a + dst_b * inv_a) / 255) as u8; self.buffer[i + 1] = ((src_g * a + dst_g * inv_a) / 255) as u8; self.buffer[i + 2] = ((src_r * a + dst_r * inv_a) / 255) as u8; self.buffer[i + 3] = (a + (dst_a * inv_a) / 255).min(255) as u8; } i += 4; } } } } } } } /// 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. pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { let mut fx0 = x; let mut fy0 = y; let mut fx1 = x + width; let mut fy1 = y + height; // Apply clip rect if active. if let Some(clip) = self.active_clip() { fx0 = fx0.max(clip.x0); fy0 = fy0.max(clip.y0); fx1 = fx1.min(clip.x1); fy1 = fy1.min(clip.y1); if fx0 >= fx1 || fy0 >= fy1 { return; } } let x0 = (fx0 as i32).max(0) as u32; let y0 = (fy0 as i32).max(0) as u32; let x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; let y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; if color.a == 255 { // Fully opaque — direct write. for py in y0..y1 { for px in x0..x1 { self.set_pixel(px, py, color); } } } else if color.a > 0 { // Semi-transparent — alpha blend. let alpha = color.a as u32; let inv_alpha = 255 - alpha; for py in y0..y1 { for px in x0..x1 { 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; 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; } } } } /// 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; let clip = self.active_clip(); 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; } // Apply clip rect. if let Some(ref c) = clip { if (px as f32) < c.x0 || (px as f32) >= c.x1 || (py as f32) < c.y0 || (py as f32) >= c.y1 { 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. } } } /// Draw an RGBA8 image scaled to the given display dimensions. /// /// Uses nearest-neighbor sampling for scaling. The source image is in RGBA8 /// format; the buffer is BGRA. Alpha compositing is performed for semi-transparent pixels. fn draw_image(&mut self, x: f32, y: f32, width: f32, height: f32, image: &Image) { if image.width == 0 || image.height == 0 || width <= 0.0 || height <= 0.0 { return; } let mut fx0 = x; let mut fy0 = y; let mut fx1 = x + width; let mut fy1 = y + height; if let Some(clip) = self.active_clip() { fx0 = fx0.max(clip.x0); fy0 = fy0.max(clip.y0); fx1 = fx1.min(clip.x1); fy1 = fy1.min(clip.y1); if fx0 >= fx1 || fy0 >= fy1 { return; } } let dst_x0 = (fx0 as i32).max(0) as u32; let dst_y0 = (fy0 as i32).max(0) as u32; let dst_x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; let dst_y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; let scale_x = image.width as f32 / width; let scale_y = image.height as f32 / height; for dst_y in dst_y0..dst_y1 { let src_y = ((dst_y as f32 - y) * scale_y) as u32; let src_y = src_y.min(image.height - 1); for dst_x in dst_x0..dst_x1 { let src_x = ((dst_x as f32 - x) * scale_x) as u32; let src_x = src_x.min(image.width - 1); let src_offset = ((src_y * image.width + src_x) * 4) as usize; let r = image.data[src_offset] as u32; let g = image.data[src_offset + 1] as u32; let b = image.data[src_offset + 2] as u32; let a = image.data[src_offset + 3] as u32; if a == 0 { continue; } let dst_offset = ((dst_y * self.width + dst_x) * 4) as usize; if a == 255 { // Fully opaque — direct write (RGBA → BGRA). self.buffer[dst_offset] = b as u8; self.buffer[dst_offset + 1] = g as u8; self.buffer[dst_offset + 2] = r as u8; self.buffer[dst_offset + 3] = 255; } else { // Alpha blend. let inv_a = 255 - a; let dst_b = self.buffer[dst_offset] as u32; let dst_g = self.buffer[dst_offset + 1] as u32; let dst_r = self.buffer[dst_offset + 2] as u32; self.buffer[dst_offset] = ((b * a + dst_b * inv_a) / 255) as u8; self.buffer[dst_offset + 1] = ((g * a + dst_g * inv_a) / 255) as u8; self.buffer[dst_offset + 2] = ((r * a + dst_r * inv_a) / 255) as u8; self.buffer[dst_offset + 3] = 255; } } } } /// 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 } } // --------------------------------------------------------------------------- // Render backend abstraction // --------------------------------------------------------------------------- /// Rendering backend that abstracts over Metal GPU and software CPU renderers. /// /// Automatically selects the best available backend at creation time: /// Metal GPU if available, software CPU otherwise. Falls back to software /// if Metal shader compilation fails. pub enum RenderBackend { /// Metal GPU-accelerated renderer. Metal(gpu::GpuRenderer), /// Software CPU rasterizer. Software(Renderer), } impl RenderBackend { /// Create a software-only render backend. pub fn new_software(width: u32, height: u32) -> Self { RenderBackend::Software(Renderer::new(width, height)) } /// Create a render backend, preferring Metal GPU when available. /// /// Falls back to software rendering if: /// - Metal device is not available (no GPU / unsupported hardware) /// - Metal shader compilation fails /// /// Logs which backend is active to stderr. pub fn new(width: u32, height: u32) -> Self { match Device::system_default() { Some(device) => match gpu::GpuRenderer::new(device) { Some(gpu) => { eprintln!("[we] Render backend: Metal GPU"); RenderBackend::Metal(gpu) } None => { eprintln!( "[we] Metal device available but shader compilation failed, \ falling back to software rendering" ); RenderBackend::Software(Renderer::new(width, height)) } }, None => { eprintln!("[we] Render backend: Software CPU"); RenderBackend::Software(Renderer::new(width, height)) } } } /// Whether this backend uses Metal GPU rendering. pub fn is_metal(&self) -> bool { matches!(self, RenderBackend::Metal(_)) } /// Human-readable name of the active backend. pub fn name(&self) -> &str { match self { RenderBackend::Metal(_) => "Metal GPU", RenderBackend::Software(_) => "Software CPU", } } /// Render a display list via the Metal GPU backend. /// /// Silently skips the frame if the drawable is unavailable (e.g. app /// backgrounded) — this is normal Metal behaviour, not an error. /// /// No-op if the backend is Software. #[allow(clippy::too_many_arguments)] pub fn render_metal( &mut self, display_list: &DisplayList, font: &Font, images: &HashMap, layer: &MetalLayer, queue: &CommandQueue, clear_color: ClearColor, viewport_width: f32, viewport_height: f32, ) { if let RenderBackend::Metal(gpu) = self { gpu.render( display_list, font, images, layer, queue, clear_color, viewport_width, viewport_height, ); } } /// Render a display list via the software CPU backend. /// /// Resets the pixel buffer to white, then paints. After calling this, /// read the result with [`pixels()`](Self::pixels). /// /// No-op if the backend is Metal. pub fn render_software( &mut self, display_list: &DisplayList, font: &Font, images: &HashMap, ) { if let RenderBackend::Software(renderer) = self { renderer.paint_display_list(display_list, font, images); } } /// Access the BGRA pixel buffer (Software backend only). /// /// Returns `None` for the Metal backend (which renders directly to the /// Metal layer). pub fn pixels(&self) -> Option<&[u8]> { match self { RenderBackend::Software(renderer) => Some(renderer.pixels()), RenderBackend::Metal(_) => None, } } /// Resize the software renderer's pixel buffer. /// /// No-op for the Metal backend (drawable size is managed by the layer). pub fn resize_software(&mut self, width: u32, height: u32) { if let RenderBackend::Software(renderer) = self { *renderer = Renderer::new(width, height); } } /// Clear cached image textures (call on page navigation). pub fn clear_image_cache(&mut self) { if let RenderBackend::Metal(gpu) = self { gpu.clear_image_cache(); } } } #[cfg(test)] mod tests { use super::*; use we_dom::Document; use we_style::computed::{extract_stylesheets, resolve_styles}; 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"); } fn layout_doc(doc: &Document) -> we_layout::LayoutTree { let font = test_font(); let sheets = extract_stylesheets(doc); let styled = resolve_styles(doc, &sheets, (800.0, 600.0)).unwrap(); we_layout::layout( &styled, doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ) } #[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::new(255, 0, 0, 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::new(0, 0, 255, 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 display_list_from_empty_layout() { let doc = Document::new(); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)); if let Some(styled) = styled { let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); assert!(list.len() <= 1); } } #[test] fn display_list_has_background_and_text() { 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 = layout_doc(&doc); let list = build_display_list(&tree); let has_text = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); assert!(has_text, "should have at least one DrawGlyphs"); } #[test] fn paint_simple_page() { 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 font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(800, 600); renderer.paint(&tree, &font, &HashMap::new()); 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::new(100, 150, 200, 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 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 = layout_doc(&doc); 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 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 font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(800, 600); renderer.paint(&tree, &font, &HashMap::new()); 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" ); } #[test] fn css_color_renders_correctly() { let html_str = r#"

Red text

"#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let text_colors: Vec<&Color> = list .iter() .filter_map(|c| match c { PaintCommand::DrawGlyphs { color, .. } => Some(color), _ => None, }) .collect(); assert!(!text_colors.is_empty()); // Text should be red. assert_eq!(*text_colors[0], Color::rgb(255, 0, 0)); } #[test] fn css_background_color_renders() { let html_str = r#"
Content
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let fill_colors: Vec<&Color> = list .iter() .filter_map(|c| match c { PaintCommand::FillRect { color, .. } => Some(color), _ => None, }) .collect(); // Should have a yellow fill rect for the div background. assert!(fill_colors.iter().any(|c| **c == Color::rgb(255, 255, 0))); } #[test] fn border_rendering() { let html_str = r#"
Bordered
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let red_fills: Vec<_> = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0))) .collect(); // Should have 4 border fills (top, right, bottom, left). assert_eq!(red_fills.len(), 4, "should have 4 border edges"); } // --- Visibility painting tests --- #[test] fn visibility_hidden_not_painted() { let html_str = r#"
Hidden
"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // No red background should be in the display list. let red_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0)) }); assert_eq!(red_fills.count(), 0, "hidden element should not be painted"); // No text should be rendered for the hidden element. let hidden_text = list.iter().filter( |c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Hidden"), ); assert_eq!( hidden_text.count(), 0, "hidden element text should not be painted" ); } #[test] fn visibility_visible_child_of_hidden_parent_is_painted() { let html_str = r#"

Visible

"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // The visible child's text should appear in the display list. let visible_text = list.iter().filter( |c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text.contains("Visible")), ); assert!( visible_text.count() > 0, "visible child of hidden parent should be painted" ); } #[test] fn visibility_collapse_not_painted() { let html_str = r#"
Collapsed
"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let blue_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(0, 0, 255)) }); assert_eq!( blue_fills.count(), 0, "collapse element should not be painted" ); } // --- Overflow clipping tests --- #[test] fn overflow_hidden_generates_clip_commands() { let html_str = r#"

Content

"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let push_count = list .iter() .filter(|c| matches!(c, PaintCommand::PushClip { .. })) .count(); let pop_count = list .iter() .filter(|c| matches!(c, PaintCommand::PopClip)) .count(); assert!(push_count >= 1, "overflow:hidden should emit PushClip"); assert_eq!(push_count, pop_count, "PushClip/PopClip must be balanced"); } #[test] fn overflow_visible_no_clip_commands() { let html_str = r#"

Content

"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let push_count = list .iter() .filter(|c| matches!(c, PaintCommand::PushClip { .. })) .count(); assert_eq!( push_count, 0, "overflow:visible should not emit clip commands" ); } #[test] fn overflow_hidden_clips_child_background() { // A tall child inside a short overflow:hidden container. // The child's red background should be clipped to the container's bounds. let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(200, 200); renderer.paint(&tree, &font, &HashMap::new()); let pixels = renderer.pixels(); // Pixel at (50, 25) — inside the container — should be red. let inside_offset = ((25 * 200 + 50) * 4) as usize; assert_eq!(pixels[inside_offset], 0, "B inside should be 0 (red)"); assert_eq!( pixels[inside_offset + 2], 255, "R inside should be 255 (red)" ); // Pixel at (50, 100) — outside the container (below 50px) — should be white. let outside_offset = ((100 * 200 + 50) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "B outside should be 255 (white)" ); assert_eq!( pixels[outside_offset + 1], 255, "G outside should be 255 (white)" ); assert_eq!( pixels[outside_offset + 2], 255, "R outside should be 255 (white)" ); } #[test] fn overflow_auto_clips_like_hidden() { let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(200, 200); renderer.paint(&tree, &font, &HashMap::new()); let pixels = renderer.pixels(); // Pixel at (50, 100) — below the container — should be white (clipped). let outside_offset = ((100 * 200 + 50) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "overflow:auto should clip content below container" ); } #[test] fn overflow_scroll_clips_like_hidden() { let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(200, 200); renderer.paint(&tree, &font, &HashMap::new()); let pixels = renderer.pixels(); // Pixel at (50, 100) — below the container — should be white (clipped). let outside_offset = ((100 * 200 + 50) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "overflow:scroll should clip content below container" ); } #[test] fn nested_overflow_clips_intersect() { // Outer: 200x200, inner: 100x100, both overflow:hidden. // A large red child should only appear in the inner 100x100 area. let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(300, 300); renderer.paint(&tree, &font, &HashMap::new()); let pixels = renderer.pixels(); // Pixel at (50, 50) — inside inner container — should be red. let inside_offset = ((50 * 300 + 50) * 4) as usize; assert_eq!(pixels[inside_offset + 2], 255, "should be red inside inner"); // Pixel at (150, 50) — inside outer but outside inner — should be white. let between_offset = ((50 * 300 + 150) * 4) as usize; assert_eq!( pixels[between_offset], 255, "should be white outside inner clip" ); // Pixel at (250, 50) — outside both — should be white. let outside_offset = ((50 * 300 + 250) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "should be white outside both clips" ); } #[test] fn clip_rect_intersect_basic() { let a = ClipRect { x0: 0.0, y0: 0.0, x1: 100.0, y1: 100.0, }; let b = ClipRect { x0: 50.0, y0: 50.0, x1: 150.0, y1: 150.0, }; let c = a.intersect(b).unwrap(); assert_eq!(c.x0, 50.0); assert_eq!(c.y0, 50.0); assert_eq!(c.x1, 100.0); assert_eq!(c.y1, 100.0); } #[test] fn clip_rect_no_overlap() { let a = ClipRect { x0: 0.0, y0: 0.0, x1: 50.0, y1: 50.0, }; let b = ClipRect { x0: 100.0, y0: 100.0, x1: 200.0, y1: 200.0, }; assert!(a.intersect(b).is_none()); } #[test] fn display_none_not_in_display_list() { let html_str = r#"

Visible

Gone
"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let green_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(0, 128, 0)) }); assert_eq!( green_fills.count(), 0, "display:none element should not be in display list" ); } // --- Overflow scrolling tests --- #[test] fn overflow_scroll_renders_scrollbar_always() { // overflow:scroll should always render scroll bars, even when content fits. let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Should have scroll bar track and thumb FillRect commands. let scrollbar_fills: Vec<_> = list .iter() .filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) }) .collect(); assert!( scrollbar_fills.len() >= 2, "overflow:scroll should render track and thumb (got {} fills)", scrollbar_fills.len() ); } #[test] fn overflow_auto_scrollbar_only_when_overflows() { // overflow:auto with content that fits should NOT show scroll bars. let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let scrollbar_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) }); assert_eq!( scrollbar_fills.count(), 0, "overflow:auto should not render scroll bars when content fits" ); } #[test] fn overflow_auto_scrollbar_when_content_overflows() { // overflow:auto with content taller than container should show scroll bars. let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let scrollbar_fills: Vec<_> = list .iter() .filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) }) .collect(); assert!( scrollbar_fills.len() >= 2, "overflow:auto should render scroll bars when content overflows (got {} fills)", scrollbar_fills.len() ); } #[test] fn scroll_offset_shifts_content() { // Scrolling should shift content within a scroll container. let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); // Find the container's NodeId to set scroll offset. let container_id = tree .root .iter() .find_map(|b| { if b.overflow == Overflow::Scroll { node_id_from_box_type(&b.box_type) } else { None } }) .expect("should find scroll container"); // Without scroll: pixel at (50, 50) should be red (inside child). let mut renderer = Renderer::new(300, 300); renderer.paint(&tree, &font, &HashMap::new()); let pixels = renderer.pixels(); let offset_50_50 = ((50 * 300 + 50) * 4) as usize; assert_eq!( pixels[offset_50_50 + 2], 255, "before scroll: (50,50) should be red" ); // With scroll offset 60px: content shifts up, so pixel at (50, 50) should // still be red (content extends to 500px), but pixel at (50, 0) should now // show content that was at y=60. let mut scroll_state = HashMap::new(); scroll_state.insert(container_id, (0.0, 60.0)); let mut renderer2 = Renderer::new(300, 300); renderer2.paint_with_scroll(&tree, &font, &HashMap::new(), 0.0, &scroll_state); let pixels2 = renderer2.pixels(); // After scrolling 60px, content starts at y=-60 in the viewport. // The container clips to its bounds, so pixel at (50, 50) is still visible // red content (it was at y=110 in original content, now at y=50). assert_eq!( pixels2[offset_50_50 + 2], 255, "after scroll: (50,50) should still be red (content is tall)" ); } #[test] fn scroll_bar_thumb_proportional() { // Thumb size should be proportional to viewport/content ratio. let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Find the thumb FillRect (SCROLLBAR_THUMB_COLOR). let thumb = list.iter().find(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_THUMB_COLOR) }); assert!(thumb.is_some(), "should have a scroll bar thumb"); if let Some(PaintCommand::FillRect { height, .. }) = thumb { // Container height is 100, content is 400. // Thumb ratio = 100/400 = 0.25, track height = 100. // Thumb height = max(0.25 * 100, 20) = 25. assert!( *height >= 20.0 && *height <= 100.0, "thumb height {} should be proportional and within bounds", height ); } } #[test] fn page_scroll_shifts_all_content() { let html_str = r#"
"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); // Without page scroll: red at (50, 50). let mut r1 = Renderer::new(200, 200); r1.paint(&tree, &font, &HashMap::new()); let offset = ((50 * 200 + 50) * 4) as usize; assert_eq!(r1.pixels()[offset + 2], 255, "should be red without scroll"); // With page scroll 80px: the red block shifts up by 80px. // Pixel at (50, 50) was at y=130 in content, which is beyond the 100px block. let mut r2 = Renderer::new(200, 200); r2.paint_with_scroll(&tree, &font, &HashMap::new(), 80.0, &HashMap::new()); let pixels2 = r2.pixels(); // At (50, 50) with 80px scroll: this shows content at y=130, which is white. assert_eq!( pixels2[offset], 255, "should be white at (50,50) with 80px page scroll" ); // Pixel at (50, 10) with 80px scroll shows content at y=90, which is still red. let offset_10 = ((10 * 200 + 50) * 4) as usize; assert_eq!( pixels2[offset_10 + 2], 255, "should be red at (50,10) with 80px page scroll" ); } // ----------------------------------------------------------------------- // Sticky positioning paint-time tests // ----------------------------------------------------------------------- #[test] fn sticky_sticks_to_top_when_scrolled() { // A sticky element with top:0 inside a tall container should // stick to the top of the viewport when page-scrolled past it. let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); doc.append_child(root, html); doc.append_child(html, body); // Container tall enough to scroll. let container = doc.create_element("div"); doc.append_child(body, container); doc.set_attribute(container, "style", "width: 400px; height: 1000px;"); // Spacer pushes sticky down. let spacer = doc.create_element("div"); doc.append_child(container, spacer); doc.set_attribute(spacer, "style", "height: 100px;"); // Sticky element. let sticky = doc.create_element("div"); let text = doc.create_text("Sticky"); doc.append_child(container, sticky); doc.append_child(sticky, text); doc.set_attribute( sticky, "style", "position: sticky; top: 0; height: 30px; background-color: red;", ); let tree = layout_doc(&doc); let scroll_state: ScrollState = HashMap::new(); // With no scroll, the display list should show the sticky at its // normal flow position. let list_no_scroll = build_display_list_with_page_scroll(&tree, 0.0, &scroll_state); // Find the red FillRect — that's our sticky background. let sticky_bg_no_scroll = list_no_scroll .iter() .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) .expect("should find sticky red background"); let no_scroll_y = match sticky_bg_no_scroll { PaintCommand::FillRect { y, .. } => *y, _ => unreachable!(), }; // Normal position: body has default 8px margin, spacer is 100px, // so sticky should be around y≈108. assert!( no_scroll_y > 90.0, "without scroll, sticky should be at its normal position, got y={}", no_scroll_y, ); // Now scroll 200px — the sticky element's normal position would be // around 108 - 200 = -92 (off screen), but it should stick at y=0. let list_scrolled = build_display_list_with_page_scroll(&tree, 200.0, &scroll_state); let sticky_bg_scrolled = list_scrolled .iter() .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) .expect("should find sticky red background when scrolled"); let scrolled_y = match sticky_bg_scrolled { PaintCommand::FillRect { y, .. } => *y, _ => unreachable!(), }; // The sticky element should be pinned near y=0 (content box y // accounts for padding/border/margin). assert!( scrolled_y >= -1.0 && scrolled_y < 20.0, "with 200px scroll, sticky should be near top (y≈0), got y={}", scrolled_y, ); } #[test] fn sticky_constrained_by_parent() { // When the containing block scrolls past, the sticky element should // unstick and scroll away with its container. let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); doc.append_child(root, html); doc.append_child(html, body); // Small container that will scroll off. let container = doc.create_element("div"); doc.append_child(body, container); doc.set_attribute(container, "style", "width: 400px; height: 200px;"); // Sticky element inside. let sticky = doc.create_element("div"); let text = doc.create_text("Sticky"); doc.append_child(container, sticky); doc.append_child(sticky, text); doc.set_attribute( sticky, "style", "position: sticky; top: 0; height: 30px; background-color: green;", ); // After container — more content so page can scroll further. let after = doc.create_element("div"); doc.append_child(body, after); doc.set_attribute(after, "style", "height: 2000px;"); let tree = layout_doc(&doc); let scroll_state: ScrollState = HashMap::new(); // Scroll far enough that the container is completely off-screen. // Container is at roughly y=8 (body margin) with height 200, // so bottom is at y≈208. Scroll by 400 should move it well off screen. let list = build_display_list_with_page_scroll(&tree, 400.0, &scroll_state); let sticky_bg = list.iter().find(|cmd| { matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 0 && color.g == 128 && color.b == 0) }); if let Some(PaintCommand::FillRect { y, .. }) = sticky_bg { // The sticky element should have scrolled off with its container. // Its screen y should be negative (off screen). assert!( y < &0.0, "sticky should be off-screen when container scrolled away, got y={}", y, ); } // If not found, the element might not be painted (which is also // acceptable if it's off-screen). } // ----------------------------------------------------------------------- // Compositing layer tests // ----------------------------------------------------------------------- #[test] fn opacity_element_generates_push_pop_layer() { let html = "
Hello
"; let doc = we_html::parse_html(html); let tree = layout_doc(&doc); let list = build_display_list(&tree); // There should be at least one PushLayer with opacity 0.5. let push_count = list .iter() .filter(|cmd| matches!(cmd, PaintCommand::PushLayer { opacity, .. } if *opacity < 1.0)) .count(); assert!(push_count >= 1, "expected PushLayer for opacity < 1.0"); let pop_count = list .iter() .filter(|cmd| matches!(cmd, PaintCommand::PopLayer)) .count(); assert_eq!(push_count, pop_count, "PushLayer/PopLayer must be balanced"); } #[test] fn full_opacity_no_isolation_layer() { // A fully opaque div should not generate PushLayer with opacity < 1.0. let html = "
Hi
"; let doc = we_html::parse_html(html); let tree = layout_doc(&doc); let list = build_display_list(&tree); let isolated_layers = list .iter() .filter(|cmd| matches!(cmd, PaintCommand::PushLayer { opacity, .. } if *opacity < 1.0)) .count(); assert_eq!( isolated_layers, 0, "opacity=1.0 should not produce isolated layers" ); } #[test] fn software_renderer_opacity_compositing() { // Render a red rect at 50% opacity over a white background. let mut r = Renderer::new(10, 10); let red = Color::new(255, 0, 0, 255); // Simulate PushLayer + FillRect + PopLayer through the display list. let display_list = vec![ PaintCommand::PushLayer { opacity: 0.5, x: 0.0, y: 0.0, width: 10.0, height: 10.0, }, PaintCommand::FillRect { x: 0.0, y: 0.0, width: 10.0, height: 10.0, color: red, }, PaintCommand::PopLayer, ]; for cmd in &display_list { match cmd { PaintCommand::FillRect { x, y, width, height, color, } => r.fill_rect(*x, *y, *width, *height, *color), PaintCommand::PushLayer { opacity, .. } => { if *opacity < 1.0 { let saved = std::mem::replace( &mut r.buffer, vec![0u8; (r.width as usize) * (r.height as usize) * 4], ); r.layer_stack.push(SoftwareLayerState { saved_buffer: saved, opacity: *opacity, }); } } PaintCommand::PopLayer => { if let Some(state) = r.layer_stack.pop() { if state.opacity < 1.0 { let layer_buf = std::mem::replace(&mut r.buffer, state.saved_buffer); let alpha = (state.opacity * 255.0) as u32; let len = r.buffer.len(); let mut i = 0; while i + 3 < len { let src_a = layer_buf[i + 3] as u32; if src_a > 0 { let a = (src_a * alpha) / 255; let inv_a = 255 - a; let src_b = layer_buf[i] as u32; let src_g = layer_buf[i + 1] as u32; let src_r = layer_buf[i + 2] as u32; let dst_b = r.buffer[i] as u32; let dst_g = r.buffer[i + 1] as u32; let dst_r = r.buffer[i + 2] as u32; let dst_a = r.buffer[i + 3] as u32; r.buffer[i] = ((src_b * a + dst_b * inv_a) / 255) as u8; r.buffer[i + 1] = ((src_g * a + dst_g * inv_a) / 255) as u8; r.buffer[i + 2] = ((src_r * a + dst_r * inv_a) / 255) as u8; r.buffer[i + 3] = (a + (dst_a * inv_a) / 255).min(255) as u8; } i += 4; } } } } _ => {} } } // The result should be a blend of red and white at ~50%. // BGRA: Red over white at 50% → approximately (128, 128, 255, 255) // allowing for rounding: R channel should be ~128±2, B channel ~128±2. let p = &r.pixels()[0..4]; // B channel: red has B=0, white has B=255 → ~128 assert!( (p[0] as i32 - 128).unsigned_abs() <= 2, "B should be ~128, got {}", p[0] ); // G channel: red has G=0, white has G=255 → ~128 assert!( (p[1] as i32 - 128).unsigned_abs() <= 2, "G should be ~128, got {}", p[1] ); // R channel: red has R=255, white has R=255 → 255 assert!(p[2] >= 253, "R should be ~255, got {}", p[2]); } #[test] fn needs_compositing_layer_opacity() { use we_style::computed::ComputedStyle; let mut style = ComputedStyle::default(); style.display = we_style::computed::Display::Block; style.opacity = 0.5; let b = we_layout::LayoutBox::from_style( we_layout::BoxType::Block(NodeId::from_index(0)), &style, ); assert!( needs_compositing_layer(&b), "opacity < 1.0 should need a layer" ); } #[test] fn needs_compositing_layer_will_change() { use we_style::computed::{ComputedStyle, WillChange}; let mut style = ComputedStyle::default(); style.display = we_style::computed::Display::Block; style.will_change = WillChange { transform: true, opacity: false, }; let b = we_layout::LayoutBox::from_style( we_layout::BoxType::Block(NodeId::from_index(0)), &style, ); assert!( needs_compositing_layer(&b), "will-change: transform should need a layer" ); } #[test] fn no_layer_for_normal_element() { use we_style::computed::ComputedStyle; let mut style = ComputedStyle::default(); style.display = we_style::computed::Display::Block; let b = we_layout::LayoutBox::from_style( we_layout::BoxType::Block(NodeId::from_index(0)), &style, ); assert!( !needs_compositing_layer(&b), "normal element should not need a layer" ); } #[test] fn paint_display_list_resets_buffer() { let mut r = Renderer::new(10, 10); let red = Color::new(255, 0, 0, 255); r.fill_rect(0.0, 0.0, 10.0, 10.0, red); // Buffer should be red. assert_eq!(r.pixels()[2], 255); // R // paint_display_list with empty list should reset to white. r.paint_display_list(&vec![], &test_font(), &HashMap::new()); for pixel in r.pixels().chunks_exact(4) { assert_eq!( pixel, [255, 255, 255, 255], "buffer should be white after reset" ); } } #[test] fn paint_display_list_executes_commands() { let mut r = Renderer::new(20, 20); let blue = Color::new(0, 0, 255, 255); let display_list = vec![PaintCommand::FillRect { x: 0.0, y: 0.0, width: 20.0, height: 20.0, color: blue, }]; r.paint_display_list(&display_list, &test_font(), &HashMap::new()); // 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 render_backend_new_returns_valid_backend() { let backend = RenderBackend::new(800, 600); // On macOS with Metal, this should be Metal; otherwise Software. // Either way it should have a valid name. assert!( backend.name() == "Metal GPU" || backend.name() == "Software CPU", "unexpected backend name: {}", backend.name() ); } #[test] fn render_backend_is_metal_matches_name() { let backend = RenderBackend::new(800, 600); if backend.is_metal() { assert_eq!(backend.name(), "Metal GPU"); } else { assert_eq!(backend.name(), "Software CPU"); } } #[test] fn render_backend_new_software() { let backend = RenderBackend::new_software(100, 100); assert!(!backend.is_metal()); assert_eq!(backend.name(), "Software CPU"); assert!(backend.pixels().is_some()); } #[test] fn render_backend_software_render_and_pixels() { let mut backend = RenderBackend::new_software(10, 10); let red = Color::new(255, 0, 0, 255); let display_list = vec![PaintCommand::FillRect { x: 0.0, y: 0.0, width: 10.0, height: 10.0, color: red, }]; backend.render_software(&display_list, &test_font(), &HashMap::new()); let pixels = backend .pixels() .expect("software backend should have pixels"); // All pixels should be red (BGRA: 0, 0, 255, 255). for pixel in pixels.chunks_exact(4) { assert_eq!(pixel, [0, 0, 255, 255]); } } #[test] fn render_backend_resize_software() { let mut backend = RenderBackend::new_software(10, 10); assert_eq!(backend.pixels().unwrap().len(), 10 * 10 * 4); backend.resize_software(20, 20); assert_eq!(backend.pixels().unwrap().len(), 20 * 20 * 4); } }