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