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