we (web engine): Experimental web browser project to understand the limits of Claude
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}