we (web engine): Experimental web browser project to understand the limits of Claude
at flexbox 1665 lines 56 kB view raw
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, Rect, TextLine, SCROLLBAR_WIDTH}; 12use we_style::computed::{BorderStyle, Overflow, TextDecoration, Visibility}; 13use we_text::font::Font; 14 15/// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. 16pub type ScrollState = HashMap<NodeId, (f32, f32)>; 17 18/// Scroll bar track color (light gray). 19const SCROLLBAR_TRACK_COLOR: Color = Color { 20 r: 230, 21 g: 230, 22 b: 230, 23 a: 255, 24}; 25 26/// Scroll bar thumb color (darker gray). 27const SCROLLBAR_THUMB_COLOR: Color = Color { 28 r: 160, 29 g: 160, 30 b: 160, 31 a: 255, 32}; 33 34/// A paint command in the display list. 35#[derive(Debug)] 36pub enum PaintCommand { 37 /// Fill a rectangle with a solid color. 38 FillRect { 39 x: f32, 40 y: f32, 41 width: f32, 42 height: f32, 43 color: Color, 44 }, 45 /// Draw a text fragment at a position with styling. 46 DrawGlyphs { 47 line: TextLine, 48 font_size: f32, 49 color: Color, 50 }, 51 /// Draw an image at a position with given display dimensions. 52 DrawImage { 53 x: f32, 54 y: f32, 55 width: f32, 56 height: f32, 57 node_id: NodeId, 58 }, 59 /// Push a clip rectangle onto the clip stack. All subsequent paint 60 /// commands are clipped to the intersection of all active clip rects. 61 PushClip { 62 x: f32, 63 y: f32, 64 width: f32, 65 height: f32, 66 }, 67 /// Pop the most recent clip rectangle off the clip stack. 68 PopClip, 69} 70 71/// A flat list of paint commands in painter's order. 72pub type DisplayList = Vec<PaintCommand>; 73 74/// Build a display list from a layout tree. 75/// 76/// Walks the tree in depth-first pre-order (painter's order): 77/// backgrounds first, then borders, then text on top. 78pub fn build_display_list(tree: &LayoutTree) -> DisplayList { 79 build_display_list_with_scroll(tree, &HashMap::new()) 80} 81 82/// Build a display list from a layout tree with scroll state. 83/// 84/// `page_scroll` is the viewport-level scroll offset (x, y) applied to all content. 85/// `scroll_state` maps element NodeIds to their per-element scroll offsets. 86pub fn build_display_list_with_scroll( 87 tree: &LayoutTree, 88 scroll_state: &ScrollState, 89) -> DisplayList { 90 let mut list = DisplayList::new(); 91 paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state); 92 list 93} 94 95/// Build a display list with page-level scrolling. 96/// 97/// `page_scroll_y` shifts all content vertically (viewport-level scroll). 98pub fn build_display_list_with_page_scroll( 99 tree: &LayoutTree, 100 page_scroll_y: f32, 101 scroll_state: &ScrollState, 102) -> DisplayList { 103 let mut list = DisplayList::new(); 104 paint_box(&tree.root, &mut list, (0.0, -page_scroll_y), scroll_state); 105 list 106} 107 108fn paint_box( 109 layout_box: &LayoutBox, 110 list: &mut DisplayList, 111 translate: (f32, f32), 112 scroll_state: &ScrollState, 113) { 114 let visible = layout_box.visibility == Visibility::Visible; 115 let tx = translate.0; 116 let ty = translate.1; 117 118 if visible { 119 paint_background(layout_box, list, tx, ty); 120 paint_borders(layout_box, list, tx, ty); 121 122 // Emit image paint command for replaced elements. 123 if let Some((rw, rh)) = layout_box.replaced_size { 124 if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 125 list.push(PaintCommand::DrawImage { 126 x: layout_box.rect.x + tx, 127 y: layout_box.rect.y + ty, 128 width: rw, 129 height: rh, 130 node_id, 131 }); 132 } 133 } 134 135 paint_text(layout_box, list, tx, ty); 136 } 137 138 // Determine if this box is scrollable. 139 let scrollable = 140 layout_box.overflow == Overflow::Scroll || layout_box.overflow == Overflow::Auto; 141 142 // If this box has overflow clipping, push a clip rect for the padding box. 143 let clips = layout_box.overflow != Overflow::Visible; 144 if clips { 145 let clip = padding_box(layout_box); 146 list.push(PaintCommand::PushClip { 147 x: clip.x + tx, 148 y: clip.y + ty, 149 width: clip.width, 150 height: clip.height, 151 }); 152 } 153 154 // Compute child translate: adds scroll offset for scrollable boxes. 155 let mut child_translate = translate; 156 if scrollable { 157 if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 158 if let Some(&(sx, sy)) = scroll_state.get(&node_id) { 159 child_translate.0 -= sx; 160 child_translate.1 -= sy; 161 } 162 } 163 } 164 165 // Always recurse into children — they may override visibility. 166 for child in &layout_box.children { 167 paint_box(child, list, child_translate, scroll_state); 168 } 169 170 if clips { 171 list.push(PaintCommand::PopClip); 172 } 173 174 // Paint scroll bars after PopClip so they're not clipped by the container. 175 if scrollable && visible { 176 paint_scrollbars(layout_box, list, tx, ty, scroll_state); 177 } 178} 179 180/// Compute the padding box rectangle for a layout box. 181/// The padding box is the content area expanded by padding. 182fn padding_box(layout_box: &LayoutBox) -> Rect { 183 Rect { 184 x: layout_box.rect.x - layout_box.padding.left, 185 y: layout_box.rect.y - layout_box.padding.top, 186 width: layout_box.rect.width + layout_box.padding.left + layout_box.padding.right, 187 height: layout_box.rect.height + layout_box.padding.top + layout_box.padding.bottom, 188 } 189} 190 191/// Extract the NodeId from a BoxType, if it has one. 192fn node_id_from_box_type(box_type: &BoxType) -> Option<NodeId> { 193 match box_type { 194 BoxType::Block(id) | BoxType::Inline(id) => Some(*id), 195 BoxType::TextRun { node, .. } => Some(*node), 196 BoxType::Anonymous => None, 197 } 198} 199 200fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 201 let bg = layout_box.background_color; 202 // Only paint if the background is not fully transparent and the box has area. 203 if bg.a == 0 { 204 return; 205 } 206 if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { 207 // Background covers the padding box (content + padding), not including border. 208 list.push(PaintCommand::FillRect { 209 x: layout_box.rect.x + tx, 210 y: layout_box.rect.y + ty, 211 width: layout_box.rect.width, 212 height: layout_box.rect.height, 213 color: bg, 214 }); 215 } 216} 217 218fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 219 let b = &layout_box.border; 220 let r = &layout_box.rect; 221 let styles = &layout_box.border_styles; 222 let colors = &layout_box.border_colors; 223 224 // Border box starts at content origin minus padding and border. 225 let bx = r.x - layout_box.padding.left - b.left + tx; 226 let by = r.y - layout_box.padding.top - b.top + ty; 227 let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right; 228 let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom; 229 230 // Top border 231 if b.top > 0.0 && styles[0] != BorderStyle::None && styles[0] != BorderStyle::Hidden { 232 list.push(PaintCommand::FillRect { 233 x: bx, 234 y: by, 235 width: bw, 236 height: b.top, 237 color: colors[0], 238 }); 239 } 240 // Right border 241 if b.right > 0.0 && styles[1] != BorderStyle::None && styles[1] != BorderStyle::Hidden { 242 list.push(PaintCommand::FillRect { 243 x: bx + bw - b.right, 244 y: by, 245 width: b.right, 246 height: bh, 247 color: colors[1], 248 }); 249 } 250 // Bottom border 251 if b.bottom > 0.0 && styles[2] != BorderStyle::None && styles[2] != BorderStyle::Hidden { 252 list.push(PaintCommand::FillRect { 253 x: bx, 254 y: by + bh - b.bottom, 255 width: bw, 256 height: b.bottom, 257 color: colors[2], 258 }); 259 } 260 // Left border 261 if b.left > 0.0 && styles[3] != BorderStyle::None && styles[3] != BorderStyle::Hidden { 262 list.push(PaintCommand::FillRect { 263 x: bx, 264 y: by, 265 width: b.left, 266 height: bh, 267 color: colors[3], 268 }); 269 } 270} 271 272fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 273 for line in &layout_box.lines { 274 let color = line.color; 275 let font_size = line.font_size; 276 277 // Record index before pushing glyphs so we can insert the background 278 // before the text in painter's order. 279 let glyph_idx = list.len(); 280 281 // Create a translated copy of the text line. 282 let mut translated_line = line.clone(); 283 translated_line.x += tx; 284 translated_line.y += ty; 285 286 list.push(PaintCommand::DrawGlyphs { 287 line: translated_line, 288 font_size, 289 color, 290 }); 291 292 // Draw underline as a 1px line below the baseline. 293 if line.text_decoration == TextDecoration::Underline && line.width > 0.0 { 294 let baseline_y = line.y + ty + font_size; 295 let underline_y = baseline_y + 2.0; 296 list.push(PaintCommand::FillRect { 297 x: line.x + tx, 298 y: underline_y, 299 width: line.width, 300 height: 1.0, 301 color, 302 }); 303 } 304 305 // Draw inline background if not transparent. 306 if line.background_color.a > 0 && line.width > 0.0 { 307 list.insert( 308 glyph_idx, 309 PaintCommand::FillRect { 310 x: line.x + tx, 311 y: line.y + ty, 312 width: line.width, 313 height: font_size * 1.2, 314 color: line.background_color, 315 }, 316 ); 317 } 318 } 319} 320 321/// Paint scroll bars for a scrollable box. 322fn paint_scrollbars( 323 layout_box: &LayoutBox, 324 list: &mut DisplayList, 325 tx: f32, 326 ty: f32, 327 scroll_state: &ScrollState, 328) { 329 let pad = padding_box(layout_box); 330 let viewport_height = pad.height; 331 let content_height = layout_box.content_height; 332 333 // Determine whether scroll bars should be shown. 334 let show_vertical = match layout_box.overflow { 335 Overflow::Scroll => true, 336 Overflow::Auto => content_height > viewport_height, 337 _ => false, 338 }; 339 340 if !show_vertical || viewport_height <= 0.0 { 341 return; 342 } 343 344 // Scroll bar track: right edge of the padding box. 345 let track_x = pad.x + pad.width - SCROLLBAR_WIDTH + tx; 346 let track_y = pad.y + ty; 347 let track_height = viewport_height; 348 349 // Paint the track background. 350 list.push(PaintCommand::FillRect { 351 x: track_x, 352 y: track_y, 353 width: SCROLLBAR_WIDTH, 354 height: track_height, 355 color: SCROLLBAR_TRACK_COLOR, 356 }); 357 358 // Compute thumb size and position. 359 let max_content = content_height.max(viewport_height); 360 let thumb_ratio = viewport_height / max_content; 361 let thumb_height = (thumb_ratio * track_height).max(20.0).min(track_height); 362 363 // Get current scroll offset. 364 let scroll_y = node_id_from_box_type(&layout_box.box_type) 365 .and_then(|id| scroll_state.get(&id)) 366 .map(|&(_, sy)| sy) 367 .unwrap_or(0.0); 368 369 let max_scroll = (content_height - viewport_height).max(0.0); 370 let scroll_ratio = if max_scroll > 0.0 { 371 scroll_y / max_scroll 372 } else { 373 0.0 374 }; 375 let thumb_y = track_y + scroll_ratio * (track_height - thumb_height); 376 377 // Paint the thumb. 378 list.push(PaintCommand::FillRect { 379 x: track_x, 380 y: thumb_y, 381 width: SCROLLBAR_WIDTH, 382 height: thumb_height, 383 color: SCROLLBAR_THUMB_COLOR, 384 }); 385} 386 387/// An axis-aligned clip rectangle. 388#[derive(Debug, Clone, Copy)] 389struct ClipRect { 390 x0: f32, 391 y0: f32, 392 x1: f32, 393 y1: f32, 394} 395 396impl ClipRect { 397 /// Intersect two clip rects, returning the overlapping region. 398 /// Returns None if they don't overlap. 399 fn intersect(self, other: ClipRect) -> Option<ClipRect> { 400 let x0 = self.x0.max(other.x0); 401 let y0 = self.y0.max(other.y0); 402 let x1 = self.x1.min(other.x1); 403 let y1 = self.y1.min(other.y1); 404 if x0 < x1 && y0 < y1 { 405 Some(ClipRect { x0, y0, x1, y1 }) 406 } else { 407 None 408 } 409 } 410} 411 412/// Software renderer that paints a display list into a BGRA pixel buffer. 413pub struct Renderer { 414 width: u32, 415 height: u32, 416 /// BGRA pixel data, row-major, top-to-bottom. 417 buffer: Vec<u8>, 418 /// Stack of clip rectangles. When non-empty, all drawing is clipped to 419 /// the intersection of all active clip rects. 420 clip_stack: Vec<ClipRect>, 421} 422 423impl Renderer { 424 /// Create a new renderer with the given dimensions. 425 /// The buffer is initialized to white. 426 pub fn new(width: u32, height: u32) -> Renderer { 427 let size = (width as usize) * (height as usize) * 4; 428 let mut buffer = vec![0u8; size]; 429 // Fill with white (BGRA: B=255, G=255, R=255, A=255). 430 for pixel in buffer.chunks_exact_mut(4) { 431 pixel[0] = 255; // B 432 pixel[1] = 255; // G 433 pixel[2] = 255; // R 434 pixel[3] = 255; // A 435 } 436 Renderer { 437 width, 438 height, 439 buffer, 440 clip_stack: Vec::new(), 441 } 442 } 443 444 /// Compute the effective clip rect from the clip stack. 445 /// Returns None if the clip stack is empty (no clipping). 446 fn active_clip(&self) -> Option<ClipRect> { 447 if self.clip_stack.is_empty() { 448 return None; 449 } 450 // Start with the full buffer as the initial rect, then intersect. 451 let mut result = ClipRect { 452 x0: 0.0, 453 y0: 0.0, 454 x1: self.width as f32, 455 y1: self.height as f32, 456 }; 457 for clip in &self.clip_stack { 458 match result.intersect(*clip) { 459 Some(r) => result = r, 460 None => { 461 return Some(ClipRect { 462 x0: 0.0, 463 y0: 0.0, 464 x1: 0.0, 465 y1: 0.0, 466 }) 467 } 468 } 469 } 470 Some(result) 471 } 472 473 /// Paint a layout tree into the pixel buffer. 474 pub fn paint( 475 &mut self, 476 layout_tree: &LayoutTree, 477 font: &Font, 478 images: &HashMap<NodeId, &Image>, 479 ) { 480 self.paint_with_scroll(layout_tree, font, images, 0.0, &HashMap::new()); 481 } 482 483 /// Paint a layout tree with scroll state into the pixel buffer. 484 pub fn paint_with_scroll( 485 &mut self, 486 layout_tree: &LayoutTree, 487 font: &Font, 488 images: &HashMap<NodeId, &Image>, 489 page_scroll_y: f32, 490 scroll_state: &ScrollState, 491 ) { 492 let display_list = 493 build_display_list_with_page_scroll(layout_tree, page_scroll_y, scroll_state); 494 for cmd in &display_list { 495 match cmd { 496 PaintCommand::FillRect { 497 x, 498 y, 499 width, 500 height, 501 color, 502 } => { 503 self.fill_rect(*x, *y, *width, *height, *color); 504 } 505 PaintCommand::DrawGlyphs { 506 line, 507 font_size, 508 color, 509 } => { 510 self.draw_text_line(line, *font_size, *color, font); 511 } 512 PaintCommand::DrawImage { 513 x, 514 y, 515 width, 516 height, 517 node_id, 518 } => { 519 if let Some(image) = images.get(node_id) { 520 self.draw_image(*x, *y, *width, *height, image); 521 } 522 } 523 PaintCommand::PushClip { 524 x, 525 y, 526 width, 527 height, 528 } => { 529 self.clip_stack.push(ClipRect { 530 x0: *x, 531 y0: *y, 532 x1: *x + *width, 533 y1: *y + *height, 534 }); 535 } 536 PaintCommand::PopClip => { 537 self.clip_stack.pop(); 538 } 539 } 540 } 541 } 542 543 /// Get the BGRA pixel data. 544 pub fn pixels(&self) -> &[u8] { 545 &self.buffer 546 } 547 548 /// Width in pixels. 549 pub fn width(&self) -> u32 { 550 self.width 551 } 552 553 /// Height in pixels. 554 pub fn height(&self) -> u32 { 555 self.height 556 } 557 558 /// Fill a rectangle with a solid color. 559 pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { 560 let mut fx0 = x; 561 let mut fy0 = y; 562 let mut fx1 = x + width; 563 let mut fy1 = y + height; 564 565 // Apply clip rect if active. 566 if let Some(clip) = self.active_clip() { 567 fx0 = fx0.max(clip.x0); 568 fy0 = fy0.max(clip.y0); 569 fx1 = fx1.min(clip.x1); 570 fy1 = fy1.min(clip.y1); 571 if fx0 >= fx1 || fy0 >= fy1 { 572 return; 573 } 574 } 575 576 let x0 = (fx0 as i32).max(0) as u32; 577 let y0 = (fy0 as i32).max(0) as u32; 578 let x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; 579 let y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; 580 581 if color.a == 255 { 582 // Fully opaque — direct write. 583 for py in y0..y1 { 584 for px in x0..x1 { 585 self.set_pixel(px, py, color); 586 } 587 } 588 } else if color.a > 0 { 589 // Semi-transparent — alpha blend. 590 let alpha = color.a as u32; 591 let inv_alpha = 255 - alpha; 592 for py in y0..y1 { 593 for px in x0..x1 { 594 let offset = ((py * self.width + px) * 4) as usize; 595 let dst_b = self.buffer[offset] as u32; 596 let dst_g = self.buffer[offset + 1] as u32; 597 let dst_r = self.buffer[offset + 2] as u32; 598 self.buffer[offset] = 599 ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; 600 self.buffer[offset + 1] = 601 ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; 602 self.buffer[offset + 2] = 603 ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; 604 self.buffer[offset + 3] = 255; 605 } 606 } 607 } 608 } 609 610 /// Draw a line of text using the font to render glyphs. 611 fn draw_text_line(&mut self, line: &TextLine, font_size: f32, color: Color, font: &Font) { 612 let positioned = font.render_text(&line.text, font_size); 613 614 for glyph in &positioned { 615 if let Some(ref bitmap) = glyph.bitmap { 616 // Glyph origin: line position + glyph horizontal offset. 617 let gx = line.x + glyph.x + bitmap.bearing_x as f32; 618 // Baseline is at line.y + font_size (approximate: baseline at ~80% of font_size). 619 // bearing_y is distance from baseline to top of glyph (positive = above baseline). 620 let baseline_y = line.y + font_size; 621 let gy = baseline_y - bitmap.bearing_y as f32; 622 623 self.composite_glyph(gx, gy, bitmap, color); 624 } 625 } 626 } 627 628 /// Composite a grayscale glyph bitmap onto the buffer with anti-aliasing. 629 fn composite_glyph( 630 &mut self, 631 x: f32, 632 y: f32, 633 bitmap: &we_text::font::rasterizer::GlyphBitmap, 634 color: Color, 635 ) { 636 let x0 = x as i32; 637 let y0 = y as i32; 638 let clip = self.active_clip(); 639 640 for by in 0..bitmap.height { 641 for bx in 0..bitmap.width { 642 let px = x0 + bx as i32; 643 let py = y0 + by as i32; 644 645 if px < 0 || py < 0 || px >= self.width as i32 || py >= self.height as i32 { 646 continue; 647 } 648 649 // Apply clip rect. 650 if let Some(ref c) = clip { 651 if (px as f32) < c.x0 652 || (px as f32) >= c.x1 653 || (py as f32) < c.y0 654 || (py as f32) >= c.y1 655 { 656 continue; 657 } 658 } 659 660 let coverage = bitmap.data[(by * bitmap.width + bx) as usize]; 661 if coverage == 0 { 662 continue; 663 } 664 665 let px = px as u32; 666 let py = py as u32; 667 668 // Alpha-blend the glyph coverage with the text color onto the background. 669 let alpha = (coverage as u32 * color.a as u32) / 255; 670 if alpha == 0 { 671 continue; 672 } 673 674 let offset = ((py * self.width + px) * 4) as usize; 675 let dst_b = self.buffer[offset] as u32; 676 let dst_g = self.buffer[offset + 1] as u32; 677 let dst_r = self.buffer[offset + 2] as u32; 678 679 // Source-over compositing. 680 let inv_alpha = 255 - alpha; 681 self.buffer[offset] = ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; 682 self.buffer[offset + 1] = 683 ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; 684 self.buffer[offset + 2] = 685 ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; 686 self.buffer[offset + 3] = 255; // Fully opaque. 687 } 688 } 689 } 690 691 /// Draw an RGBA8 image scaled to the given display dimensions. 692 /// 693 /// Uses nearest-neighbor sampling for scaling. The source image is in RGBA8 694 /// format; the buffer is BGRA. Alpha compositing is performed for semi-transparent pixels. 695 fn draw_image(&mut self, x: f32, y: f32, width: f32, height: f32, image: &Image) { 696 if image.width == 0 || image.height == 0 || width <= 0.0 || height <= 0.0 { 697 return; 698 } 699 700 let mut fx0 = x; 701 let mut fy0 = y; 702 let mut fx1 = x + width; 703 let mut fy1 = y + height; 704 705 if let Some(clip) = self.active_clip() { 706 fx0 = fx0.max(clip.x0); 707 fy0 = fy0.max(clip.y0); 708 fx1 = fx1.min(clip.x1); 709 fy1 = fy1.min(clip.y1); 710 if fx0 >= fx1 || fy0 >= fy1 { 711 return; 712 } 713 } 714 715 let dst_x0 = (fx0 as i32).max(0) as u32; 716 let dst_y0 = (fy0 as i32).max(0) as u32; 717 let dst_x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; 718 let dst_y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; 719 720 let scale_x = image.width as f32 / width; 721 let scale_y = image.height as f32 / height; 722 723 for dst_y in dst_y0..dst_y1 { 724 let src_y = ((dst_y as f32 - y) * scale_y) as u32; 725 let src_y = src_y.min(image.height - 1); 726 727 for dst_x in dst_x0..dst_x1 { 728 let src_x = ((dst_x as f32 - x) * scale_x) as u32; 729 let src_x = src_x.min(image.width - 1); 730 731 let src_offset = ((src_y * image.width + src_x) * 4) as usize; 732 let r = image.data[src_offset] as u32; 733 let g = image.data[src_offset + 1] as u32; 734 let b = image.data[src_offset + 2] as u32; 735 let a = image.data[src_offset + 3] as u32; 736 737 if a == 0 { 738 continue; 739 } 740 741 let dst_offset = ((dst_y * self.width + dst_x) * 4) as usize; 742 743 if a == 255 { 744 // Fully opaque — direct write (RGBA → BGRA). 745 self.buffer[dst_offset] = b as u8; 746 self.buffer[dst_offset + 1] = g as u8; 747 self.buffer[dst_offset + 2] = r as u8; 748 self.buffer[dst_offset + 3] = 255; 749 } else { 750 // Alpha blend. 751 let inv_a = 255 - a; 752 let dst_b = self.buffer[dst_offset] as u32; 753 let dst_g = self.buffer[dst_offset + 1] as u32; 754 let dst_r = self.buffer[dst_offset + 2] as u32; 755 self.buffer[dst_offset] = ((b * a + dst_b * inv_a) / 255) as u8; 756 self.buffer[dst_offset + 1] = ((g * a + dst_g * inv_a) / 255) as u8; 757 self.buffer[dst_offset + 2] = ((r * a + dst_r * inv_a) / 255) as u8; 758 self.buffer[dst_offset + 3] = 255; 759 } 760 } 761 } 762 } 763 764 /// Set a single pixel to the given color (no blending). 765 fn set_pixel(&mut self, x: u32, y: u32, color: Color) { 766 if x >= self.width || y >= self.height { 767 return; 768 } 769 let offset = ((y * self.width + x) * 4) as usize; 770 self.buffer[offset] = color.b; // B 771 self.buffer[offset + 1] = color.g; // G 772 self.buffer[offset + 2] = color.r; // R 773 self.buffer[offset + 3] = color.a; // A 774 } 775} 776 777#[cfg(test)] 778mod tests { 779 use super::*; 780 use we_dom::Document; 781 use we_style::computed::{extract_stylesheets, resolve_styles}; 782 use we_text::font::Font; 783 784 fn test_font() -> Font { 785 let paths = [ 786 "/System/Library/Fonts/Geneva.ttf", 787 "/System/Library/Fonts/Monaco.ttf", 788 ]; 789 for path in &paths { 790 let p = std::path::Path::new(path); 791 if p.exists() { 792 return Font::from_file(p).expect("failed to parse font"); 793 } 794 } 795 panic!("no test font found"); 796 } 797 798 fn layout_doc(doc: &Document) -> we_layout::LayoutTree { 799 let font = test_font(); 800 let sheets = extract_stylesheets(doc); 801 let styled = resolve_styles(doc, &sheets, (800.0, 600.0)).unwrap(); 802 we_layout::layout( 803 &styled, 804 doc, 805 800.0, 806 600.0, 807 &font, 808 &std::collections::HashMap::new(), 809 ) 810 } 811 812 #[test] 813 fn renderer_new_white_background() { 814 let r = Renderer::new(10, 10); 815 assert_eq!(r.width(), 10); 816 assert_eq!(r.height(), 10); 817 assert_eq!(r.pixels().len(), 10 * 10 * 4); 818 // All pixels should be white (BGRA: 255, 255, 255, 255). 819 for pixel in r.pixels().chunks_exact(4) { 820 assert_eq!(pixel, [255, 255, 255, 255]); 821 } 822 } 823 824 #[test] 825 fn fill_rect_basic() { 826 let mut r = Renderer::new(20, 20); 827 let red = Color::new(255, 0, 0, 255); 828 r.fill_rect(5.0, 5.0, 10.0, 10.0, red); 829 830 // Pixel at (7, 7) should be red (BGRA: 0, 0, 255, 255). 831 let offset = ((7 * 20 + 7) * 4) as usize; 832 assert_eq!(r.pixels()[offset], 0); // B 833 assert_eq!(r.pixels()[offset + 1], 0); // G 834 assert_eq!(r.pixels()[offset + 2], 255); // R 835 assert_eq!(r.pixels()[offset + 3], 255); // A 836 837 // Pixel at (0, 0) should still be white. 838 assert_eq!(r.pixels()[0], 255); // B 839 assert_eq!(r.pixels()[1], 255); // G 840 assert_eq!(r.pixels()[2], 255); // R 841 assert_eq!(r.pixels()[3], 255); // A 842 } 843 844 #[test] 845 fn fill_rect_clipping() { 846 let mut r = Renderer::new(10, 10); 847 let blue = Color::new(0, 0, 255, 255); 848 // Rect extends beyond the buffer — should not panic. 849 r.fill_rect(-5.0, -5.0, 20.0, 20.0, blue); 850 851 // All pixels should be blue (BGRA: 255, 0, 0, 255). 852 for pixel in r.pixels().chunks_exact(4) { 853 assert_eq!(pixel, [255, 0, 0, 255]); 854 } 855 } 856 857 #[test] 858 fn display_list_from_empty_layout() { 859 let doc = Document::new(); 860 let font = test_font(); 861 let sheets = extract_stylesheets(&doc); 862 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)); 863 if let Some(styled) = styled { 864 let tree = we_layout::layout( 865 &styled, 866 &doc, 867 800.0, 868 600.0, 869 &font, 870 &std::collections::HashMap::new(), 871 ); 872 let list = build_display_list(&tree); 873 assert!(list.len() <= 1); 874 } 875 } 876 877 #[test] 878 fn display_list_has_background_and_text() { 879 let mut doc = Document::new(); 880 let root = doc.root(); 881 let html = doc.create_element("html"); 882 let body = doc.create_element("body"); 883 let p = doc.create_element("p"); 884 let text = doc.create_text("Hello world"); 885 doc.append_child(root, html); 886 doc.append_child(html, body); 887 doc.append_child(body, p); 888 doc.append_child(p, text); 889 890 let tree = layout_doc(&doc); 891 let list = build_display_list(&tree); 892 893 let has_text = list 894 .iter() 895 .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); 896 897 assert!(has_text, "should have at least one DrawGlyphs"); 898 } 899 900 #[test] 901 fn paint_simple_page() { 902 let mut doc = Document::new(); 903 let root = doc.root(); 904 let html = doc.create_element("html"); 905 let body = doc.create_element("body"); 906 let p = doc.create_element("p"); 907 let text = doc.create_text("Hello"); 908 doc.append_child(root, html); 909 doc.append_child(html, body); 910 doc.append_child(body, p); 911 doc.append_child(p, text); 912 913 let font = test_font(); 914 let tree = layout_doc(&doc); 915 let mut renderer = Renderer::new(800, 600); 916 renderer.paint(&tree, &font, &HashMap::new()); 917 918 let pixels = renderer.pixels(); 919 920 // The buffer should have some non-white pixels (from text rendering). 921 let has_non_white = pixels.chunks_exact(4).any(|p| p != [255, 255, 255, 255]); 922 assert!( 923 has_non_white, 924 "rendered page should have non-white pixels from text" 925 ); 926 } 927 928 #[test] 929 fn bgra_format_correct() { 930 let mut r = Renderer::new(1, 1); 931 let color = Color::new(100, 150, 200, 255); 932 r.set_pixel(0, 0, color); 933 let pixels = r.pixels(); 934 // BGRA format. 935 assert_eq!(pixels[0], 200); // B 936 assert_eq!(pixels[1], 150); // G 937 assert_eq!(pixels[2], 100); // R 938 assert_eq!(pixels[3], 255); // A 939 } 940 941 #[test] 942 fn paint_heading_produces_larger_glyphs() { 943 let mut doc = Document::new(); 944 let root = doc.root(); 945 let html = doc.create_element("html"); 946 let body = doc.create_element("body"); 947 let h1 = doc.create_element("h1"); 948 let h1_text = doc.create_text("Big"); 949 let p = doc.create_element("p"); 950 let p_text = doc.create_text("Small"); 951 doc.append_child(root, html); 952 doc.append_child(html, body); 953 doc.append_child(body, h1); 954 doc.append_child(h1, h1_text); 955 doc.append_child(body, p); 956 doc.append_child(p, p_text); 957 958 let tree = layout_doc(&doc); 959 let list = build_display_list(&tree); 960 961 // There should be DrawGlyphs commands with different font sizes. 962 let font_sizes: Vec<f32> = list 963 .iter() 964 .filter_map(|c| match c { 965 PaintCommand::DrawGlyphs { font_size, .. } => Some(*font_size), 966 _ => None, 967 }) 968 .collect(); 969 970 assert!(font_sizes.len() >= 2, "should have at least 2 text lines"); 971 // h1 has font_size 32, p has font_size 16. 972 assert!( 973 font_sizes.iter().any(|&s| s > 20.0), 974 "should have a heading with large font size" 975 ); 976 assert!( 977 font_sizes.iter().any(|&s| s < 20.0), 978 "should have a paragraph with normal font size" 979 ); 980 } 981 982 #[test] 983 fn renderer_zero_size() { 984 let r = Renderer::new(0, 0); 985 assert_eq!(r.pixels().len(), 0); 986 } 987 988 #[test] 989 fn glyph_compositing_anti_aliased() { 990 // Render text and verify we get anti-aliased (partially transparent) pixels. 991 let mut doc = Document::new(); 992 let root = doc.root(); 993 let html = doc.create_element("html"); 994 let body = doc.create_element("body"); 995 let p = doc.create_element("p"); 996 let text = doc.create_text("MMMMM"); 997 doc.append_child(root, html); 998 doc.append_child(html, body); 999 doc.append_child(body, p); 1000 doc.append_child(p, text); 1001 1002 let font = test_font(); 1003 let tree = layout_doc(&doc); 1004 let mut renderer = Renderer::new(800, 600); 1005 renderer.paint(&tree, &font, &HashMap::new()); 1006 1007 let pixels = renderer.pixels(); 1008 // Find pixels that are not pure white and not pure black. 1009 // These represent anti-aliased edges of glyphs. 1010 let mut has_intermediate = false; 1011 for pixel in pixels.chunks_exact(4) { 1012 let b = pixel[0]; 1013 let g = pixel[1]; 1014 let r = pixel[2]; 1015 // Anti-aliased pixel: gray between white and black. 1016 if r > 0 && r < 255 && r == g && g == b { 1017 has_intermediate = true; 1018 break; 1019 } 1020 } 1021 assert!( 1022 has_intermediate, 1023 "should have anti-aliased (gray) pixels from glyph compositing" 1024 ); 1025 } 1026 1027 #[test] 1028 fn css_color_renders_correctly() { 1029 let html_str = r#"<!DOCTYPE html> 1030<html> 1031<head><style>p { color: red; }</style></head> 1032<body><p>Red text</p></body> 1033</html>"#; 1034 let doc = we_html::parse_html(html_str); 1035 let font = test_font(); 1036 let sheets = extract_stylesheets(&doc); 1037 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 1038 let tree = we_layout::layout( 1039 &styled, 1040 &doc, 1041 800.0, 1042 600.0, 1043 &font, 1044 &std::collections::HashMap::new(), 1045 ); 1046 1047 let list = build_display_list(&tree); 1048 let text_colors: Vec<&Color> = list 1049 .iter() 1050 .filter_map(|c| match c { 1051 PaintCommand::DrawGlyphs { color, .. } => Some(color), 1052 _ => None, 1053 }) 1054 .collect(); 1055 1056 assert!(!text_colors.is_empty()); 1057 // Text should be red. 1058 assert_eq!(*text_colors[0], Color::rgb(255, 0, 0)); 1059 } 1060 1061 #[test] 1062 fn css_background_color_renders() { 1063 let html_str = r#"<!DOCTYPE html> 1064<html> 1065<head><style>div { background-color: yellow; }</style></head> 1066<body><div>Content</div></body> 1067</html>"#; 1068 let doc = we_html::parse_html(html_str); 1069 let font = test_font(); 1070 let sheets = extract_stylesheets(&doc); 1071 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 1072 let tree = we_layout::layout( 1073 &styled, 1074 &doc, 1075 800.0, 1076 600.0, 1077 &font, 1078 &std::collections::HashMap::new(), 1079 ); 1080 1081 let list = build_display_list(&tree); 1082 let fill_colors: Vec<&Color> = list 1083 .iter() 1084 .filter_map(|c| match c { 1085 PaintCommand::FillRect { color, .. } => Some(color), 1086 _ => None, 1087 }) 1088 .collect(); 1089 1090 // Should have a yellow fill rect for the div background. 1091 assert!(fill_colors.iter().any(|c| **c == Color::rgb(255, 255, 0))); 1092 } 1093 1094 #[test] 1095 fn border_rendering() { 1096 let html_str = r#"<!DOCTYPE html> 1097<html> 1098<head><style>div { border: 2px solid red; }</style></head> 1099<body><div>Bordered</div></body> 1100</html>"#; 1101 let doc = we_html::parse_html(html_str); 1102 let font = test_font(); 1103 let sheets = extract_stylesheets(&doc); 1104 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 1105 let tree = we_layout::layout( 1106 &styled, 1107 &doc, 1108 800.0, 1109 600.0, 1110 &font, 1111 &std::collections::HashMap::new(), 1112 ); 1113 1114 let list = build_display_list(&tree); 1115 let red_fills: Vec<_> = list 1116 .iter() 1117 .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0))) 1118 .collect(); 1119 1120 // Should have 4 border fills (top, right, bottom, left). 1121 assert_eq!(red_fills.len(), 4, "should have 4 border edges"); 1122 } 1123 1124 // --- Visibility painting tests --- 1125 1126 #[test] 1127 fn visibility_hidden_not_painted() { 1128 let html_str = r#"<!DOCTYPE html> 1129<html><head><style> 1130body { margin: 0; } 1131div { visibility: hidden; background-color: red; width: 100px; height: 100px; } 1132</style></head> 1133<body><div>Hidden</div></body></html>"#; 1134 let doc = we_html::parse_html(html_str); 1135 let tree = layout_doc(&doc); 1136 let list = build_display_list(&tree); 1137 1138 // No red background should be in the display list. 1139 let red_fills = list.iter().filter(|c| { 1140 matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0)) 1141 }); 1142 assert_eq!(red_fills.count(), 0, "hidden element should not be painted"); 1143 1144 // No text should be rendered for the hidden element. 1145 let hidden_text = list.iter().filter( 1146 |c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Hidden"), 1147 ); 1148 assert_eq!( 1149 hidden_text.count(), 1150 0, 1151 "hidden element text should not be painted" 1152 ); 1153 } 1154 1155 #[test] 1156 fn visibility_visible_child_of_hidden_parent_is_painted() { 1157 let html_str = r#"<!DOCTYPE html> 1158<html><head><style> 1159body { margin: 0; } 1160.parent { visibility: hidden; } 1161.child { visibility: visible; } 1162</style></head> 1163<body> 1164<div class="parent"><p class="child">Visible</p></div> 1165</body></html>"#; 1166 let doc = we_html::parse_html(html_str); 1167 let tree = layout_doc(&doc); 1168 let list = build_display_list(&tree); 1169 1170 // The visible child's text should appear in the display list. 1171 let visible_text = list.iter().filter( 1172 |c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text.contains("Visible")), 1173 ); 1174 assert!( 1175 visible_text.count() > 0, 1176 "visible child of hidden parent should be painted" 1177 ); 1178 } 1179 1180 #[test] 1181 fn visibility_collapse_not_painted() { 1182 let html_str = r#"<!DOCTYPE html> 1183<html><head><style> 1184body { margin: 0; } 1185div { visibility: collapse; background-color: blue; width: 50px; height: 50px; } 1186</style></head> 1187<body><div>Collapsed</div></body></html>"#; 1188 let doc = we_html::parse_html(html_str); 1189 let tree = layout_doc(&doc); 1190 let list = build_display_list(&tree); 1191 1192 let blue_fills = list.iter().filter(|c| { 1193 matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(0, 0, 255)) 1194 }); 1195 assert_eq!( 1196 blue_fills.count(), 1197 0, 1198 "collapse element should not be painted" 1199 ); 1200 } 1201 1202 // --- Overflow clipping tests --- 1203 1204 #[test] 1205 fn overflow_hidden_generates_clip_commands() { 1206 let html_str = r#"<!DOCTYPE html> 1207<html><head><style> 1208body { margin: 0; } 1209.container { overflow: hidden; width: 100px; height: 50px; } 1210</style></head> 1211<body><div class="container"><p>Content</p></div></body></html>"#; 1212 let doc = we_html::parse_html(html_str); 1213 let tree = layout_doc(&doc); 1214 let list = build_display_list(&tree); 1215 1216 let push_count = list 1217 .iter() 1218 .filter(|c| matches!(c, PaintCommand::PushClip { .. })) 1219 .count(); 1220 let pop_count = list 1221 .iter() 1222 .filter(|c| matches!(c, PaintCommand::PopClip)) 1223 .count(); 1224 1225 assert!(push_count >= 1, "overflow:hidden should emit PushClip"); 1226 assert_eq!(push_count, pop_count, "PushClip/PopClip must be balanced"); 1227 } 1228 1229 #[test] 1230 fn overflow_visible_no_clip_commands() { 1231 let html_str = r#"<!DOCTYPE html> 1232<html><head><style> 1233body { margin: 0; } 1234.container { overflow: visible; width: 100px; height: 50px; } 1235</style></head> 1236<body><div class="container"><p>Content</p></div></body></html>"#; 1237 let doc = we_html::parse_html(html_str); 1238 let tree = layout_doc(&doc); 1239 let list = build_display_list(&tree); 1240 1241 let push_count = list 1242 .iter() 1243 .filter(|c| matches!(c, PaintCommand::PushClip { .. })) 1244 .count(); 1245 1246 assert_eq!( 1247 push_count, 0, 1248 "overflow:visible should not emit clip commands" 1249 ); 1250 } 1251 1252 #[test] 1253 fn overflow_hidden_clips_child_background() { 1254 // A tall child inside a short overflow:hidden container. 1255 // The child's red background should be clipped to the container's bounds. 1256 let html_str = r#"<!DOCTYPE html> 1257<html><head><style> 1258body { margin: 0; } 1259.container { overflow: hidden; width: 100px; height: 50px; } 1260.child { background-color: red; width: 100px; height: 200px; } 1261</style></head> 1262<body><div class="container"><div class="child"></div></div></body></html>"#; 1263 let doc = we_html::parse_html(html_str); 1264 let font = test_font(); 1265 let tree = layout_doc(&doc); 1266 let mut renderer = Renderer::new(200, 200); 1267 renderer.paint(&tree, &font, &HashMap::new()); 1268 1269 let pixels = renderer.pixels(); 1270 1271 // Pixel at (50, 25) — inside the container — should be red. 1272 let inside_offset = ((25 * 200 + 50) * 4) as usize; 1273 assert_eq!(pixels[inside_offset], 0, "B inside should be 0 (red)"); 1274 assert_eq!( 1275 pixels[inside_offset + 2], 1276 255, 1277 "R inside should be 255 (red)" 1278 ); 1279 1280 // Pixel at (50, 100) — outside the container (below 50px) — should be white. 1281 let outside_offset = ((100 * 200 + 50) * 4) as usize; 1282 assert_eq!( 1283 pixels[outside_offset], 255, 1284 "B outside should be 255 (white)" 1285 ); 1286 assert_eq!( 1287 pixels[outside_offset + 1], 1288 255, 1289 "G outside should be 255 (white)" 1290 ); 1291 assert_eq!( 1292 pixels[outside_offset + 2], 1293 255, 1294 "R outside should be 255 (white)" 1295 ); 1296 } 1297 1298 #[test] 1299 fn overflow_auto_clips_like_hidden() { 1300 let html_str = r#"<!DOCTYPE html> 1301<html><head><style> 1302body { margin: 0; } 1303.container { overflow: auto; width: 100px; height: 50px; } 1304.child { background-color: blue; width: 100px; height: 200px; } 1305</style></head> 1306<body><div class="container"><div class="child"></div></div></body></html>"#; 1307 let doc = we_html::parse_html(html_str); 1308 let font = test_font(); 1309 let tree = layout_doc(&doc); 1310 let mut renderer = Renderer::new(200, 200); 1311 renderer.paint(&tree, &font, &HashMap::new()); 1312 1313 let pixels = renderer.pixels(); 1314 1315 // Pixel at (50, 100) — below the container — should be white (clipped). 1316 let outside_offset = ((100 * 200 + 50) * 4) as usize; 1317 assert_eq!( 1318 pixels[outside_offset], 255, 1319 "overflow:auto should clip content below container" 1320 ); 1321 } 1322 1323 #[test] 1324 fn overflow_scroll_clips_like_hidden() { 1325 let html_str = r#"<!DOCTYPE html> 1326<html><head><style> 1327body { margin: 0; } 1328.container { overflow: scroll; width: 100px; height: 50px; } 1329.child { background-color: green; width: 100px; height: 200px; } 1330</style></head> 1331<body><div class="container"><div class="child"></div></div></body></html>"#; 1332 let doc = we_html::parse_html(html_str); 1333 let font = test_font(); 1334 let tree = layout_doc(&doc); 1335 let mut renderer = Renderer::new(200, 200); 1336 renderer.paint(&tree, &font, &HashMap::new()); 1337 1338 let pixels = renderer.pixels(); 1339 1340 // Pixel at (50, 100) — below the container — should be white (clipped). 1341 let outside_offset = ((100 * 200 + 50) * 4) as usize; 1342 assert_eq!( 1343 pixels[outside_offset], 255, 1344 "overflow:scroll should clip content below container" 1345 ); 1346 } 1347 1348 #[test] 1349 fn nested_overflow_clips_intersect() { 1350 // Outer: 200x200, inner: 100x100, both overflow:hidden. 1351 // A large red child should only appear in the inner 100x100 area. 1352 let html_str = r#"<!DOCTYPE html> 1353<html><head><style> 1354body { margin: 0; } 1355.outer { overflow: hidden; width: 200px; height: 200px; } 1356.inner { overflow: hidden; width: 100px; height: 100px; } 1357.child { background-color: red; width: 500px; height: 500px; } 1358</style></head> 1359<body> 1360<div class="outer"><div class="inner"><div class="child"></div></div></div> 1361</body></html>"#; 1362 let doc = we_html::parse_html(html_str); 1363 let font = test_font(); 1364 let tree = layout_doc(&doc); 1365 let mut renderer = Renderer::new(300, 300); 1366 renderer.paint(&tree, &font, &HashMap::new()); 1367 1368 let pixels = renderer.pixels(); 1369 1370 // Pixel at (50, 50) — inside inner container — should be red. 1371 let inside_offset = ((50 * 300 + 50) * 4) as usize; 1372 assert_eq!(pixels[inside_offset + 2], 255, "should be red inside inner"); 1373 1374 // Pixel at (150, 50) — inside outer but outside inner — should be white. 1375 let between_offset = ((50 * 300 + 150) * 4) as usize; 1376 assert_eq!( 1377 pixels[between_offset], 255, 1378 "should be white outside inner clip" 1379 ); 1380 1381 // Pixel at (250, 50) — outside both — should be white. 1382 let outside_offset = ((50 * 300 + 250) * 4) as usize; 1383 assert_eq!( 1384 pixels[outside_offset], 255, 1385 "should be white outside both clips" 1386 ); 1387 } 1388 1389 #[test] 1390 fn clip_rect_intersect_basic() { 1391 let a = ClipRect { 1392 x0: 0.0, 1393 y0: 0.0, 1394 x1: 100.0, 1395 y1: 100.0, 1396 }; 1397 let b = ClipRect { 1398 x0: 50.0, 1399 y0: 50.0, 1400 x1: 150.0, 1401 y1: 150.0, 1402 }; 1403 let c = a.intersect(b).unwrap(); 1404 assert_eq!(c.x0, 50.0); 1405 assert_eq!(c.y0, 50.0); 1406 assert_eq!(c.x1, 100.0); 1407 assert_eq!(c.y1, 100.0); 1408 } 1409 1410 #[test] 1411 fn clip_rect_no_overlap() { 1412 let a = ClipRect { 1413 x0: 0.0, 1414 y0: 0.0, 1415 x1: 50.0, 1416 y1: 50.0, 1417 }; 1418 let b = ClipRect { 1419 x0: 100.0, 1420 y0: 100.0, 1421 x1: 200.0, 1422 y1: 200.0, 1423 }; 1424 assert!(a.intersect(b).is_none()); 1425 } 1426 1427 #[test] 1428 fn display_none_not_in_display_list() { 1429 let html_str = r#"<!DOCTYPE html> 1430<html><head><style> 1431body { margin: 0; } 1432.gone { display: none; background-color: green; } 1433</style></head> 1434<body> 1435<p>Visible</p> 1436<div class="gone">Gone</div> 1437</body></html>"#; 1438 let doc = we_html::parse_html(html_str); 1439 let tree = layout_doc(&doc); 1440 let list = build_display_list(&tree); 1441 1442 let green_fills = list.iter().filter(|c| { 1443 matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(0, 128, 0)) 1444 }); 1445 assert_eq!( 1446 green_fills.count(), 1447 0, 1448 "display:none element should not be in display list" 1449 ); 1450 } 1451 1452 // --- Overflow scrolling tests --- 1453 1454 #[test] 1455 fn overflow_scroll_renders_scrollbar_always() { 1456 // overflow:scroll should always render scroll bars, even when content fits. 1457 let html_str = r#"<!DOCTYPE html> 1458<html><head><style> 1459body { margin: 0; } 1460.container { overflow: scroll; width: 200px; height: 200px; } 1461.child { height: 50px; } 1462</style></head> 1463<body><div class="container"><div class="child"></div></div></body></html>"#; 1464 let doc = we_html::parse_html(html_str); 1465 let tree = layout_doc(&doc); 1466 let list = build_display_list(&tree); 1467 1468 // Should have scroll bar track and thumb FillRect commands. 1469 let scrollbar_fills: Vec<_> = list 1470 .iter() 1471 .filter(|c| { 1472 matches!(c, PaintCommand::FillRect { color, .. } 1473 if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) 1474 }) 1475 .collect(); 1476 1477 assert!( 1478 scrollbar_fills.len() >= 2, 1479 "overflow:scroll should render track and thumb (got {} fills)", 1480 scrollbar_fills.len() 1481 ); 1482 } 1483 1484 #[test] 1485 fn overflow_auto_scrollbar_only_when_overflows() { 1486 // overflow:auto with content that fits should NOT show scroll bars. 1487 let html_str = r#"<!DOCTYPE html> 1488<html><head><style> 1489body { margin: 0; } 1490.container { overflow: auto; width: 200px; height: 200px; } 1491.child { height: 50px; } 1492</style></head> 1493<body><div class="container"><div class="child"></div></div></body></html>"#; 1494 let doc = we_html::parse_html(html_str); 1495 let tree = layout_doc(&doc); 1496 let list = build_display_list(&tree); 1497 1498 let scrollbar_fills = list.iter().filter(|c| { 1499 matches!(c, PaintCommand::FillRect { color, .. } 1500 if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) 1501 }); 1502 1503 assert_eq!( 1504 scrollbar_fills.count(), 1505 0, 1506 "overflow:auto should not render scroll bars when content fits" 1507 ); 1508 } 1509 1510 #[test] 1511 fn overflow_auto_scrollbar_when_content_overflows() { 1512 // overflow:auto with content taller than container should show scroll bars. 1513 let html_str = r#"<!DOCTYPE html> 1514<html><head><style> 1515body { margin: 0; } 1516.container { overflow: auto; width: 200px; height: 100px; } 1517.child { background-color: red; height: 500px; } 1518</style></head> 1519<body><div class="container"><div class="child"></div></div></body></html>"#; 1520 let doc = we_html::parse_html(html_str); 1521 let tree = layout_doc(&doc); 1522 let list = build_display_list(&tree); 1523 1524 let scrollbar_fills: Vec<_> = list 1525 .iter() 1526 .filter(|c| { 1527 matches!(c, PaintCommand::FillRect { color, .. } 1528 if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) 1529 }) 1530 .collect(); 1531 1532 assert!( 1533 scrollbar_fills.len() >= 2, 1534 "overflow:auto should render scroll bars when content overflows (got {} fills)", 1535 scrollbar_fills.len() 1536 ); 1537 } 1538 1539 #[test] 1540 fn scroll_offset_shifts_content() { 1541 // Scrolling should shift content within a scroll container. 1542 let html_str = r#"<!DOCTYPE html> 1543<html><head><style> 1544body { margin: 0; } 1545.container { overflow: scroll; width: 200px; height: 100px; } 1546.child { background-color: red; width: 200px; height: 500px; } 1547</style></head> 1548<body><div class="container"><div class="child"></div></div></body></html>"#; 1549 let doc = we_html::parse_html(html_str); 1550 let font = test_font(); 1551 let tree = layout_doc(&doc); 1552 1553 // Find the container's NodeId to set scroll offset. 1554 let container_id = tree 1555 .root 1556 .iter() 1557 .find_map(|b| { 1558 if b.overflow == Overflow::Scroll { 1559 node_id_from_box_type(&b.box_type) 1560 } else { 1561 None 1562 } 1563 }) 1564 .expect("should find scroll container"); 1565 1566 // Without scroll: pixel at (50, 50) should be red (inside child). 1567 let mut renderer = Renderer::new(300, 300); 1568 renderer.paint(&tree, &font, &HashMap::new()); 1569 let pixels = renderer.pixels(); 1570 let offset_50_50 = ((50 * 300 + 50) * 4) as usize; 1571 assert_eq!( 1572 pixels[offset_50_50 + 2], 1573 255, 1574 "before scroll: (50,50) should be red" 1575 ); 1576 1577 // With scroll offset 60px: content shifts up, so pixel at (50, 50) should 1578 // still be red (content extends to 500px), but pixel at (50, 0) should now 1579 // show content that was at y=60. 1580 let mut scroll_state = HashMap::new(); 1581 scroll_state.insert(container_id, (0.0, 60.0)); 1582 let mut renderer2 = Renderer::new(300, 300); 1583 renderer2.paint_with_scroll(&tree, &font, &HashMap::new(), 0.0, &scroll_state); 1584 let pixels2 = renderer2.pixels(); 1585 1586 // After scrolling 60px, content starts at y=-60 in the viewport. 1587 // The container clips to its bounds, so pixel at (50, 50) is still visible 1588 // red content (it was at y=110 in original content, now at y=50). 1589 assert_eq!( 1590 pixels2[offset_50_50 + 2], 1591 255, 1592 "after scroll: (50,50) should still be red (content is tall)" 1593 ); 1594 } 1595 1596 #[test] 1597 fn scroll_bar_thumb_proportional() { 1598 // Thumb size should be proportional to viewport/content ratio. 1599 let html_str = r#"<!DOCTYPE html> 1600<html><head><style> 1601body { margin: 0; } 1602.container { overflow: scroll; width: 200px; height: 100px; } 1603.child { height: 400px; } 1604</style></head> 1605<body><div class="container"><div class="child"></div></div></body></html>"#; 1606 let doc = we_html::parse_html(html_str); 1607 let tree = layout_doc(&doc); 1608 let list = build_display_list(&tree); 1609 1610 // Find the thumb FillRect (SCROLLBAR_THUMB_COLOR). 1611 let thumb = list.iter().find(|c| { 1612 matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_THUMB_COLOR) 1613 }); 1614 1615 assert!(thumb.is_some(), "should have a scroll bar thumb"); 1616 if let Some(PaintCommand::FillRect { height, .. }) = thumb { 1617 // Container height is 100, content is 400. 1618 // Thumb ratio = 100/400 = 0.25, track height = 100. 1619 // Thumb height = max(0.25 * 100, 20) = 25. 1620 assert!( 1621 *height >= 20.0 && *height <= 100.0, 1622 "thumb height {} should be proportional and within bounds", 1623 height 1624 ); 1625 } 1626 } 1627 1628 #[test] 1629 fn page_scroll_shifts_all_content() { 1630 let html_str = r#"<!DOCTYPE html> 1631<html><head><style> 1632body { margin: 0; } 1633.block { background-color: red; width: 100px; height: 100px; } 1634</style></head> 1635<body><div class="block"></div></body></html>"#; 1636 let doc = we_html::parse_html(html_str); 1637 let font = test_font(); 1638 let tree = layout_doc(&doc); 1639 1640 // Without page scroll: red at (50, 50). 1641 let mut r1 = Renderer::new(200, 200); 1642 r1.paint(&tree, &font, &HashMap::new()); 1643 let offset = ((50 * 200 + 50) * 4) as usize; 1644 assert_eq!(r1.pixels()[offset + 2], 255, "should be red without scroll"); 1645 1646 // With page scroll 80px: the red block shifts up by 80px. 1647 // Pixel at (50, 50) was at y=130 in content, which is beyond the 100px block. 1648 let mut r2 = Renderer::new(200, 200); 1649 r2.paint_with_scroll(&tree, &font, &HashMap::new(), 80.0, &HashMap::new()); 1650 let pixels2 = r2.pixels(); 1651 // At (50, 50) with 80px scroll: this shows content at y=130, which is white. 1652 assert_eq!( 1653 pixels2[offset], 255, 1654 "should be white at (50,50) with 80px page scroll" 1655 ); 1656 1657 // Pixel at (50, 10) with 80px scroll shows content at y=90, which is still red. 1658 let offset_10 = ((10 * 200 + 50) * 4) as usize; 1659 assert_eq!( 1660 pixels2[offset_10 + 2], 1661 255, 1662 "should be red at (50,10) with 80px page scroll" 1663 ); 1664 } 1665}