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