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