Skip to main content

orinium_browser/engine/renderer_model/
box_model.rs

1//! Generation of [`DrawCommand`]s from the layout tree (box models, borders,
2//! backgrounds and text).
3
4use ui_layout::{
5    BoxModel, EdgeOption, InnerDisplay, LayoutChild, LayoutNode, OuterDisplay, Position, Rect,
6};
7
8use crate::engine::layouter::text_layouter::TextFlowLayouter;
9use crate::engine::layouter::types::{
10    Background, BackgroundDimension, BackgroundImage, BackgroundOffset, BackgroundPositionAxis,
11    BackgroundRepeat, BackgroundSize, BorderRadius, ClipPath, Color, ContainerStyle, CornerRadius,
12    InfoNode, NodeKind, TextDecoration, TextFlowStyle, TextStyle, Visibility,
13};
14use crate::engine::renderer_model::draw_command::{Brush, DrawCommand, FillRule, Paint};
15use crate::engine::renderer_model::geom::AffineTransform;
16use crate::engine::renderer_model::path::{
17    Path, append_quarter_ellipse, clamp_radii, ellipse_path, offset_path, polygon_path, rect_path,
18    rounded_rect_path,
19};
20use crate::engine::svg::emit_commands;
21use crate::engine::ui::ContentSize;
22
23/// Per-box-model push state for balanced pop generation.
24#[derive(Default, Clone, Copy)]
25struct BoxPushState {
26    border: bool,
27    /// Whether the element's CSS `transform` was pushed.
28    transform: bool,
29    clip_path: bool,
30    overflow_clip: bool,
31    content: bool,
32    scroll: bool,
33    /// The pushed clip-path resolves to an empty (zero-area) region, so nothing
34    /// in the subtree is visible and its painting can be skipped entirely.
35    cull: bool,
36}
37
38// --------------------------------
39// Helpers
40// --------------------------------
41
42fn push_transform(cmd_buf: &mut Vec<DrawCommand>, dx: f32, dy: f32) -> bool {
43    if dx != 0.0 || dy != 0.0 {
44        cmd_buf.push(DrawCommand::PushTransform {
45            transform: AffineTransform::translate(dx, dy),
46        });
47        true
48    } else {
49        false
50    }
51}
52
53/// State of the nearest scrollport, used to resolve `position: sticky` offsets.
54///
55/// All coordinates are expressed in the current node's parent-content space
56/// (the same space its `border_box` coordinates live in).
57#[derive(Default, Clone, Copy)]
58pub struct StickyViewport {
59    /// Top-left corner of the scrollport's visible region, scroll offset already
60    /// applied. The renderer's scroll transform is `translate(scroll_x,
61    /// -scroll_y)`, so in the scrollport's content space this is
62    /// `(padding.left - scroll_x, padding.top + scroll_y)`; the value is rebased
63    /// into each descendant's content space.
64    pub top_left: (f32, f32),
65    /// Visible (padding-box) size of the nearest scrollport.
66    pub size: (f32, f32),
67}
68
69/// Whether the node scrolls its own content and thus establishes a scrollport
70/// for its subtree. The document root always does, because the UI layer scrolls
71/// it directly without necessarily setting its scroll flags.
72pub fn is_scrollport(kind: &NodeKind) -> bool {
73    match kind {
74        NodeKind::Container {
75            scroll_x, scroll_y, ..
76        }
77        | NodeKind::Custom {
78            scroll_x, scroll_y, ..
79        }
80        | NodeKind::Svg {
81            scroll_x, scroll_y, ..
82        } => *scroll_x || *scroll_y,
83        _ => false,
84    }
85}
86
87/// Compute the sticky translate for a box relative to its nearest scrollport.
88///
89/// `box_rect` is the sticky box's border box (its natural position, in
90/// parent-content space) and `containing` is the parent content-box size — the
91/// containing block the box may not leave. Per CSS-POSITION-3 §3.4 the box is
92/// shifted inward just enough to keep each specified edge inside the
93/// scrollport's "sticky view rectangle" (the visible region, whose top-left is
94/// `viewport.top_left`), while staying within the containing block.
95pub fn sticky_offset(
96    edges: &EdgeOption,
97    box_rect: &Rect,
98    viewport: StickyViewport,
99    containing: (f32, f32),
100) -> (f32, f32) {
101    let x = box_rect.x;
102    let y = box_rect.y;
103    let w = box_rect.width;
104    let h = box_rect.height;
105
106    let mut dy: f32 = 0.0;
107    let port_top = viewport.top_left.1;
108    let port_bottom = viewport.top_left.1 + viewport.size.1;
109    if let Some(top) = edges.top {
110        dy = dy.max((port_top + top) - y);
111    }
112    if let Some(bottom) = edges.bottom {
113        // CSS-POSITION-3 §3.4: when the sticky view rectangle is shorter than
114        // the box, the end-edge inset is ignored and the box sticks to its
115        // start edge instead.
116        let has_room = match edges.top {
117            Some(top) => (port_bottom - bottom) - (port_top + top) >= h,
118            None => true,
119        };
120        if has_room {
121            dy = dy.min((port_bottom - bottom) - (y + h));
122        }
123    }
124    // Containing-block constraint (parent content box). The bounds are
125    // zero-clamped so an already-overflowing box is never forced to move
126    // (matching Chromium/Firefox "clamp sticky offset bounds by zero").
127    let dy_lo = (-y).min(0.0);
128    let dy_hi = (containing.1 - y - h).max(0.0);
129    dy = dy.max(dy_lo).min(dy_hi);
130
131    let mut dx: f32 = 0.0;
132    let port_left = viewport.top_left.0;
133    let port_right = viewport.top_left.0 + viewport.size.0;
134    if let Some(left) = edges.left {
135        dx = dx.max((port_left + left) - x);
136    }
137    if let Some(right) = edges.right {
138        let has_room = match edges.left {
139            Some(left) => (port_right - right) - (port_left + left) >= w,
140            None => true,
141        };
142        if has_room {
143            dx = dx.min((port_right - right) - (x + w));
144        }
145    }
146    let dx_lo = (-x).min(0.0);
147    let dx_hi = (containing.0 - x - w).max(0.0);
148    dx = dx.max(dx_lo).min(dx_hi);
149
150    (dx, dy)
151}
152
153/// Resolve the four outer corner radii to pixels against the border box.
154///
155/// Horizontal components resolve against the box width, vertical components
156/// against the box height (so `%` works per-axis per CSS).
157fn resolve_outer_radii(radius: &BorderRadius, box_w: f32, box_h: f32) -> [(f32, f32); 4] {
158    let resolve = |c: &CornerRadius| -> (f32, f32) {
159        (
160            c.x.resolve_with(Some(box_w), 0.0, 0.0)
161                .unwrap_or(0.0)
162                .max(0.0),
163            c.y.resolve_with(Some(box_h), 0.0, 0.0)
164                .unwrap_or(0.0)
165                .max(0.0),
166        )
167    };
168    [
169        resolve(&radius.top_left),
170        resolve(&radius.top_right),
171        resolve(&radius.bottom_right),
172        resolve(&radius.bottom_left),
173    ]
174}
175
176/// Compute the inner (padding-box) corner radii: the outer radii reduced by
177/// the two adjacent border widths, per CSS.
178fn inner_radii(outer: [(f32, f32); 4], bl: f32, bt: f32, br: f32, bb: f32) -> [(f32, f32); 4] {
179    [
180        (outer[0].0 - bl, outer[0].1 - bt),
181        (outer[1].0 - br, outer[1].1 - bt),
182        (outer[2].0 - br, outer[2].1 - bb),
183        (outer[3].0 - bl, outer[3].1 - bb),
184    ]
185    .map(|(x, y)| (x.max(0.0), y.max(0.0)))
186}
187
188/// Build the closed path of the top border edge, including the top-left and
189/// top-right corner caps. Coordinates are relative to the border-box origin.
190///
191/// `outer`/`inner` are the four corner radii in CSS order; the inner arcs are
192/// concentric with the outer arcs.
193fn top_border_strip(
194    w: f32,
195    bl: f32,
196    bt: f32,
197    br: f32,
198    outer: [(f32, f32); 4],
199    inner: [(f32, f32); 4],
200) -> Path {
201    let (rtl_x, rtl_y) = outer[0];
202    let (rtr_x, rtr_y) = outer[1];
203    let (itl_x, itl_y) = inner[0];
204    let (itr_x, itr_y) = inner[1];
205    let mut path = Path::new();
206    path.move_to(0.0, rtl_y);
207    append_quarter_ellipse(
208        &mut path,
209        rtl_x,
210        rtl_y,
211        rtl_x,
212        rtl_y,
213        (0.0, rtl_y),
214        (rtl_x, 0.0),
215    );
216    path.line_to(w - rtr_x, 0.0);
217    append_quarter_ellipse(
218        &mut path,
219        w - rtr_x,
220        rtr_y,
221        rtr_x,
222        rtr_y,
223        (w - rtr_x, 0.0),
224        (w, rtr_y),
225    );
226    // Draw the top edge line to the start of the inner top‑right corner.
227    // Use the right border width (`br`) for the outer edge, then transition to the inner radius.
228    path.line_to(w - br, rtr_y);
229    // Inner top‑right corner: connect outer edge to inner edge.
230    // The start point is at the outer edge (`w - br`, `rtr_y`),
231    // and the end point aligns with the inner radius.
232    append_quarter_ellipse(
233        &mut path,
234        w - rtr_x - br,
235        rtr_y - bt,
236        itr_x,
237        itr_y,
238        (w - br, rtr_y),
239        (w - rtr_x - br, bt),
240    );
241    path.line_to(itl_x, bt);
242    append_quarter_ellipse(
243        &mut path,
244        rtl_x - bl,
245        rtl_y - bt,
246        itl_x,
247        itl_y,
248        (itl_x, bt),
249        (bl, rtl_y),
250    );
251    path.close();
252    path
253}
254
255/// Build the closed path of the bottom border edge, including the bottom-left
256/// and bottom-right corner caps.
257fn bottom_border_strip(
258    w: f32,
259    h: f32,
260    bl: f32,
261    bb: f32,
262    br: f32,
263    outer: [(f32, f32); 4],
264    inner: [(f32, f32); 4],
265) -> Path {
266    let (rbl_x, rbl_y) = outer[3];
267    let (rbr_x, rbr_y) = outer[2];
268    let (ibl_x, ibl_y) = inner[3];
269    let (ibr_x, ibr_y) = inner[2];
270    let mut path = Path::new();
271    path.move_to(w, h - rbr_y);
272    append_quarter_ellipse(
273        &mut path,
274        w - rbr_x,
275        h - rbr_y,
276        rbr_x,
277        rbr_y,
278        (w, h - rbr_y),
279        (w - rbr_x, h),
280    );
281    path.line_to(rbl_x, h);
282    append_quarter_ellipse(
283        &mut path,
284        rbl_x,
285        h - rbl_y,
286        rbl_x,
287        rbl_y,
288        (rbl_x, h),
289        (0.0, h - rbl_y),
290    );
291    path.line_to(bl, h - rbl_y);
292    append_quarter_ellipse(
293        &mut path,
294        rbl_x,
295        h - rbl_y,
296        ibl_x,
297        ibl_y,
298        (bl, h - rbl_y),
299        (rbl_x, h - bb),
300    );
301    path.line_to(w - rbr_x, h - bb);
302    append_quarter_ellipse(
303        &mut path,
304        w - rbr_x,
305        h - rbr_y,
306        ibr_x,
307        ibr_y,
308        (w - rbr_x, h - bb),
309        (w - br, h - rbr_y),
310    );
311    path.close();
312    path
313}
314
315/// Draw the four border edges inside the current coordinate system.
316/// Coordinates are relative to the border-box origin when `ox`/`oy` are zero
317/// (the transform case); otherwise they are added to place the border in
318/// absolute space (the inline case, where no transform is pushed).
319fn draw_border(
320    cmd_buf: &mut Vec<DrawCommand>,
321    border_box: &ui_layout::Rect,
322    padding_box: &ui_layout::Rect,
323    style: &ContainerStyle,
324    ox: f32,
325    oy: f32,
326    opacity: f32,
327) {
328    let bw_top = (padding_box.y - border_box.y).max(0.0);
329    let bw_bottom =
330        (border_box.y + border_box.height - (padding_box.y + padding_box.height)).max(0.0);
331    let bw_left = (padding_box.x - border_box.x).max(0.0);
332    let bw_right = (border_box.x + border_box.width - (padding_box.x + padding_box.width)).max(0.0);
333
334    let w = border_box.width;
335    let h = border_box.height;
336    let mut outer = resolve_outer_radii(&style.border_radius, w, h);
337    outer = clamp_radii(outer, w, h);
338    let mut inner = inner_radii(outer, bw_left, bw_top, bw_right, bw_bottom);
339    inner = clamp_radii(
340        inner,
341        padding_box.width.max(0.0),
342        padding_box.height.max(0.0),
343    );
344
345    let bc = &style.border_color;
346    let bs = &style.border_style;
347    let push_fill = |cmd_buf: &mut Vec<DrawCommand>, path: Path, color: Color| {
348        cmd_buf.push(DrawCommand::Fill {
349            path,
350            rule: FillRule::NonZero,
351            paint: Paint {
352                brush: Brush::Solid(color),
353                opacity,
354            },
355        });
356    };
357
358    let has_radius = outer.iter().any(|(rx, ry)| *rx > 0.0 || *ry > 0.0);
359    if !has_radius {
360        if bw_top > 0.0 && bs.top != crate::engine::layouter::types::BorderStyle::None {
361            push_fill(cmd_buf, rect_path(ox, oy, w, bw_top), bc.top);
362        }
363        if bw_bottom > 0.0 && bs.bottom != crate::engine::layouter::types::BorderStyle::None {
364            push_fill(
365                cmd_buf,
366                rect_path(ox, oy + h - bw_bottom, w, bw_bottom),
367                bc.bottom,
368            );
369        }
370        if bw_left > 0.0 && bs.left != crate::engine::layouter::types::BorderStyle::None {
371            push_fill(
372                cmd_buf,
373                rect_path(ox, oy + bw_top, bw_left, h - bw_top - bw_bottom),
374                bc.left,
375            );
376        }
377        if bw_right > 0.0 && bs.right != crate::engine::layouter::types::BorderStyle::None {
378            push_fill(
379                cmd_buf,
380                rect_path(
381                    ox + w - bw_right,
382                    oy + bw_top,
383                    bw_right,
384                    h - bw_top - bw_bottom,
385                ),
386                bc.right,
387            );
388        }
389        return;
390    }
391
392    if bw_top > 0.0 && bs.top != crate::engine::layouter::types::BorderStyle::None {
393        push_fill(
394            cmd_buf,
395            offset_path(
396                &top_border_strip(w, bw_left, bw_top, bw_right, outer, inner),
397                ox,
398                oy,
399            ),
400            bc.top,
401        );
402    }
403    if bw_bottom > 0.0 && bs.bottom != crate::engine::layouter::types::BorderStyle::None {
404        push_fill(
405            cmd_buf,
406            offset_path(
407                &bottom_border_strip(w, h, bw_left, bw_bottom, bw_right, outer, inner),
408                ox,
409                oy,
410            ),
411            bc.bottom,
412        );
413    }
414    if bw_left > 0.0 && bs.left != crate::engine::layouter::types::BorderStyle::None {
415        push_fill(
416            cmd_buf,
417            rect_path(ox, oy + outer[0].1, bw_left, h - outer[0].1 - outer[3].1),
418            bc.left,
419        );
420    }
421    if bw_right > 0.0 && bs.right != crate::engine::layouter::types::BorderStyle::None {
422        push_fill(
423            cmd_buf,
424            rect_path(
425                ox + w - bw_right,
426                oy + outer[1].1,
427                bw_right,
428                h - outer[1].1 - outer[2].1,
429            ),
430            bc.right,
431        );
432    }
433}
434
435/// Draw the background inside the padding box (rounded when a border radius
436/// is present).
437/// Coordinates are relative to the border-box origin when `ox`/`oy` are zero;
438/// otherwise they are added to place the background in absolute space.
439fn draw_background(
440    cmd_buf: &mut Vec<DrawCommand>,
441    border_box: &ui_layout::Rect,
442    padding_box: &ui_layout::Rect,
443    style: &ContainerStyle,
444    ox: f32,
445    oy: f32,
446    opacity: f32,
447) {
448    let x = padding_box.x - border_box.x + ox;
449    let y = padding_box.y - border_box.y + oy;
450    let bw_top = (padding_box.y - border_box.y).max(0.0);
451    let bw_bottom =
452        (border_box.y + border_box.height - (padding_box.y + padding_box.height)).max(0.0);
453    let bw_left = (padding_box.x - border_box.x).max(0.0);
454    let bw_right = (border_box.x + border_box.width - (padding_box.x + padding_box.width)).max(0.0);
455
456    let mut outer = resolve_outer_radii(&style.border_radius, border_box.width, border_box.height);
457    outer = clamp_radii(outer, border_box.width, border_box.height);
458    let mut inner = inner_radii(outer, bw_left, bw_top, bw_right, bw_bottom);
459    inner = clamp_radii(
460        inner,
461        padding_box.width.max(0.0),
462        padding_box.height.max(0.0),
463    );
464    // Build the rounded background path
465    let path = rounded_rect_path(
466        x,
467        y,
468        padding_box.width,
469        padding_box.height,
470        inner[0],
471        inner[1],
472        inner[2],
473        inner[3],
474    );
475    // CSS paints the background color beneath every image/gradient layer.
476    if let Some(color) = style.background.color
477        && color.3 > 0
478    {
479        cmd_buf.push(DrawCommand::Fill {
480            path: path.clone(),
481            rule: FillRule::NonZero,
482            paint: Paint {
483                brush: Brush::Solid(color),
484                opacity,
485            },
486        });
487    }
488
489    // Image/gradient layers are stored bottom-to-top; paint top-to-bottom.
490    // Each layer carries its own position/size/repeat geometry.
491    for layer in style.background.layers.iter().rev() {
492        let layer_repeat = layer.repeat;
493        let layer_size = layer.size;
494        let layer_position = layer.position;
495
496        match &layer.image {
497            Some(BackgroundImage::Gradient(g)) => {
498                cmd_buf.push(DrawCommand::Fill {
499                    path: path.clone(),
500                    rule: FillRule::NonZero,
501                    paint: Paint {
502                        brush: Brush::Gradient(g.clone()),
503                        opacity,
504                    },
505                });
506            }
507            Some(BackgroundImage::Image {
508                source: _,
509                image: Some(image),
510            }) => {
511                let (image_width, image_height) = resolve_background_image_size(
512                    image.width() as f32,
513                    image.height() as f32,
514                    padding_box.width,
515                    padding_box.height,
516                    layer_size,
517                );
518                if image_width > 0.0 && image_height > 0.0 {
519                    let image_x =
520                        resolve_background_axis(padding_box.width, image_width, layer_position.x);
521                    let image_y =
522                        resolve_background_axis(padding_box.height, image_height, layer_position.y);
523                    let repeat_x = matches!(
524                        layer_repeat,
525                        BackgroundRepeat::Repeat | BackgroundRepeat::RepeatX
526                    );
527                    let repeat_y = matches!(
528                        layer_repeat,
529                        BackgroundRepeat::Repeat | BackgroundRepeat::RepeatY
530                    );
531                    let xs = background_tile_positions(
532                        image_x,
533                        image_width,
534                        padding_box.width,
535                        repeat_x,
536                    );
537                    let ys = background_tile_positions(
538                        image_y,
539                        image_height,
540                        padding_box.height,
541                        repeat_y,
542                    );
543                    cmd_buf.push(DrawCommand::PushClip {
544                        path: path.clone(),
545                        rule: FillRule::NonZero,
546                    });
547                    for tile_y in ys {
548                        for tile_x in &xs {
549                            cmd_buf.push(DrawCommand::Fill {
550                                path: rect_path(x + *tile_x, y + tile_y, image_width, image_height),
551                                rule: FillRule::NonZero,
552                                paint: Paint {
553                                    brush: Brush::Image(image.clone()),
554                                    opacity,
555                                },
556                            });
557                        }
558                    }
559                    cmd_buf.push(DrawCommand::PopClip);
560                }
561            }
562            Some(BackgroundImage::Image { image: None, .. }) => {}
563            None => {}
564        }
565    }
566}
567
568fn resolve_background_dimension(dimension: BackgroundDimension, area: f32) -> Option<f32> {
569    match dimension {
570        BackgroundDimension::Auto => None,
571        BackgroundDimension::Length(value) => Some(value.max(0.0)),
572        BackgroundDimension::Percent(value) => Some((area * value).max(0.0)),
573    }
574}
575
576fn resolve_background_image_size(
577    intrinsic_width: f32,
578    intrinsic_height: f32,
579    area_width: f32,
580    area_height: f32,
581    size: BackgroundSize,
582) -> (f32, f32) {
583    if intrinsic_width <= 0.0 || intrinsic_height <= 0.0 {
584        return (0.0, 0.0);
585    }
586    let ratio = intrinsic_width / intrinsic_height;
587    match size {
588        BackgroundSize::Auto => (intrinsic_width, intrinsic_height),
589        BackgroundSize::Contain | BackgroundSize::Cover => {
590            let width_scale = area_width / intrinsic_width;
591            let height_scale = area_height / intrinsic_height;
592            let scale = if matches!(size, BackgroundSize::Contain) {
593                width_scale.min(height_scale)
594            } else {
595                width_scale.max(height_scale)
596            };
597            (intrinsic_width * scale, intrinsic_height * scale)
598        }
599        BackgroundSize::Explicit { width, height } => {
600            let width = resolve_background_dimension(width, area_width);
601            let height = resolve_background_dimension(height, area_height);
602            match (width, height) {
603                (Some(width), Some(height)) => (width, height),
604                (Some(width), None) => (width, width / ratio),
605                (None, Some(height)) => (height * ratio, height),
606                (None, None) => (intrinsic_width, intrinsic_height),
607            }
608        }
609    }
610}
611
612fn resolve_background_axis(area: f32, image: f32, position: BackgroundPositionAxis) -> f32 {
613    let available = area - image;
614    let length_offset = |offset| match offset {
615        BackgroundOffset::Zero => 0.0,
616        BackgroundOffset::Length(value) => value,
617        BackgroundOffset::Percent(value) => area * value,
618    };
619    match position {
620        BackgroundPositionAxis::Start(BackgroundOffset::Percent(value)) => available * value,
621        BackgroundPositionAxis::Start(offset) => length_offset(offset),
622        BackgroundPositionAxis::Center(offset) => available * 0.5 + length_offset(offset),
623        BackgroundPositionAxis::End(offset) => available - length_offset(offset),
624    }
625}
626
627fn background_tile_positions(base: f32, tile: f32, area: f32, repeat: bool) -> Vec<f32> {
628    if !repeat || tile <= 0.0 {
629        return vec![base];
630    }
631    let mut start = base;
632    while start > 0.0 {
633        start -= tile;
634    }
635    while start + tile <= 0.0 {
636        start += tile;
637    }
638    let mut positions = Vec::new();
639    let mut position = start;
640    while position < area && positions.len() < 512 {
641        positions.push(position);
642        position += tile;
643    }
644    positions
645}
646
647/// Convert a [`ClipPath`] definition into a concrete [`Path`] resolved
648/// against the element's border-box dimensions.
649fn clip_path_to_path(clip: &ClipPath, w: f32, h: f32) -> Path {
650    match clip {
651        ClipPath::None => rect_path(0.0, 0.0, w, h),
652        ClipPath::Circle {
653            radius,
654            center_x,
655            center_y,
656        } => {
657            let cx = center_x * w;
658            let cy = center_y * h;
659            // `radius` is a fraction of farthest-side (max(w, h)) per CSS.
660            let farthest = w.max(h);
661            let r = (radius * farthest).min(farthest);
662            ellipse_path(cx, cy, r, r)
663        }
664        ClipPath::Ellipse {
665            rx,
666            ry,
667            center_x,
668            center_y,
669        } => {
670            let cx = center_x * w;
671            let cy = center_y * h;
672            ellipse_path(cx, cy, rx * w, ry * h)
673        }
674        ClipPath::Inset {
675            top,
676            right,
677            bottom,
678            left,
679        } => {
680            let x = left * w;
681            let y = top * h;
682            let iw = (w - left * w - right * w).max(0.0);
683            let ih = (h - top * h - bottom * h).max(0.0);
684            rect_path(x, y, iw, ih)
685        }
686        ClipPath::Polygon { points } => {
687            let verts: Vec<(f32, f32)> = points.iter().map(|(px, py)| (px * w, py * h)).collect();
688            polygon_path(&verts)
689        }
690    }
691}
692
693/// Whether a [`ClipPath`] resolves to an empty shape against a box of the
694/// given border-box size (so the element and every descendant are invisible).
695fn clip_path_is_empty(clip: &ClipPath, w: f32, h: f32) -> bool {
696    match clip {
697        ClipPath::None => false,
698        ClipPath::Circle { radius, .. } => *radius <= 0.0 || w <= 0.0 || h <= 0.0,
699        ClipPath::Ellipse { rx, ry, .. } => *rx <= 0.0 || *ry <= 0.0 || w <= 0.0 || h <= 0.0,
700        ClipPath::Inset {
701            top,
702            right,
703            bottom,
704            left,
705        } => {
706            (w - left * w - right * w).max(0.0) <= 0.0 || (h - top * h - bottom * h).max(0.0) <= 0.0
707        }
708        ClipPath::Polygon { points } => points.len() < 3,
709    }
710}
711
712/// Push all draw commands for a single box model, returning the pop state.
713#[allow(clippy::too_many_arguments)]
714fn push_box_model(
715    cmd_buf: &mut Vec<DrawCommand>,
716    box_model: &ui_layout::BoxModel,
717    style: &crate::engine::layouter::types::ContainerStyle,
718    scroll_offset_x: f32,
719    scroll_offset_y: f32,
720    is_inline: bool,
721    clips_overflow: bool,
722    draw_bg: bool,
723    paint: bool,
724    opacity: f32,
725) -> BoxPushState {
726    let border_box = box_model.border_box;
727    let padding_box = box_model.padding_box;
728    let content_box = box_model.content_box;
729
730    let dx = content_box.x - border_box.x;
731    let dy = content_box.y - border_box.y;
732
733    // Inline containers lay their text out in the parent's coordinate space,
734    // and every line span yields its own box model, so no transform may be
735    // pushed here: otherwise the accumulated border/content offsets of all
736    // line boxes would displace the inline content.
737    let border = !is_inline && push_transform(cmd_buf, border_box.x, border_box.y);
738
739    // Apply the element's CSS `transform` about the element's center
740    // (CSS `transform-origin` defaults to `50% 50%`). The border translation
741    // above moved the origin to the border-box top-left, so we sandwich the
742    // transform with a translate to/from the center.
743    let transform = !is_inline
744        && style
745            .transform
746            .is_some_and(|t| t != AffineTransform::identity())
747        && {
748            let center_x = border_box.width / 2.0;
749            let center_y = border_box.height / 2.0;
750            let t = style.transform.expect("checked above");
751            let centered = AffineTransform::translate(center_x, center_y)
752                .then(&t)
753                .then(&AffineTransform::translate(-center_x, -center_y));
754            cmd_buf.push(DrawCommand::PushTransform {
755                transform: centered,
756            });
757            true
758        };
759
760    // When no transform is pushed (inline), draw commands must use absolute
761    // coordinates; otherwise they are already relative to the border-box
762    // origin thanks to the transform above.
763    let (ox, oy) = if is_inline {
764        (border_box.x, border_box.y)
765    } else {
766        (0.0, 0.0)
767    };
768
769    // Push clip-path clip before drawing borders/backgrounds so the shape
770    // clips the entire element (border-box per CSS spec).
771    let clip_path_pushed = !is_inline
772        && !matches!(style.clip_path, ClipPath::None)
773        && border_box.width > 0.0
774        && border_box.height > 0.0;
775
776    // A zero-area clip-path (e.g. GitHub's `clip-path: rect(0 0 0 0)` on
777    // `.sr-only` elements) hides the element and every descendant.
778    let clip_culls = clip_path_pushed
779        && clip_path_is_empty(&style.clip_path, border_box.width, border_box.height);
780
781    // Culled subtrees draw nothing; drop the clip-path clip as well (the
782    // zero-area rect is pure overhead).
783    let clip_path_pushed = clip_path_pushed && !clip_culls;
784    if clip_path_pushed {
785        let path = clip_path_to_path(&style.clip_path, border_box.width, border_box.height);
786        cmd_buf.push(DrawCommand::PushClip {
787            path,
788            rule: FillRule::NonZero,
789        });
790    }
791
792    // A `visibility: hidden` element paints neither borders nor backgrounds,
793    // but still establishes its transforms/clips so explicitly visible
794    // descendants are positioned and clipped correctly.
795    if paint && !clip_culls {
796        draw_border(cmd_buf, &border_box, &padding_box, style, ox, oy, opacity);
797
798        if draw_bg {
799            draw_background(cmd_buf, &border_box, &padding_box, style, ox, oy, opacity);
800        }
801    }
802
803    // Everything below is invisible when culling; drop the clip-path and
804    // overflow clips (and their pops) instead of emitting empty regions.
805    let overflow_clip = !clip_culls
806        && !is_inline
807        && clips_overflow
808        && padding_box.width > 0.0
809        && padding_box.height > 0.0;
810    if overflow_clip {
811        cmd_buf.push(DrawCommand::PushClip {
812            path: rect_path(
813                padding_box.x - border_box.x,
814                padding_box.y - border_box.y,
815                padding_box.width,
816                padding_box.height,
817            ),
818            rule: FillRule::NonZero,
819        });
820    }
821
822    let content = !is_inline && push_transform(cmd_buf, dx, dy);
823    let scroll = !is_inline && push_transform(cmd_buf, -scroll_offset_x, -scroll_offset_y);
824
825    BoxPushState {
826        border,
827        transform,
828        clip_path: clip_path_pushed,
829        overflow_clip,
830        content,
831        scroll,
832        cull: clip_culls,
833    }
834}
835
836/// Pop commands for a single box model (reverse order of pushes).
837fn pop_box_model(cmd_buf: &mut Vec<DrawCommand>, state: BoxPushState) {
838    if state.scroll {
839        cmd_buf.push(DrawCommand::PopTransform);
840    }
841    if state.content {
842        cmd_buf.push(DrawCommand::PopTransform);
843    }
844    if state.overflow_clip {
845        cmd_buf.push(DrawCommand::PopClip);
846    }
847    if state.clip_path {
848        cmd_buf.push(DrawCommand::PopClip);
849    }
850    if state.transform {
851        cmd_buf.push(DrawCommand::PopTransform);
852    }
853    if state.border {
854        cmd_buf.push(DrawCommand::PopTransform);
855    }
856}
857
858/// Draw text spans for a single text node.
859fn draw_text(
860    cmd_buf: &mut Vec<DrawCommand>,
861    style: &TextStyle,
862    flow_style: TextFlowStyle,
863    text_id: usize,
864    offset: (f32, f32),
865    opacity: f32,
866) {
867    if let Some(result) = TextFlowLayouter::get_result(text_id) {
868        // Text is composited directly onto the page surface, so multiplying
869        // the color alpha by `opacity` is equivalent to a real opacity layer.
870        let mut style = style.clone();
871        style.color.3 = (style.color.3 as f32 * opacity).round() as u8;
872        for (i, line_text) in result.line_texts.iter().enumerate() {
873            let span = &result.spans[i];
874            let x = span.line_pos.0 + offset.0;
875            let y = span.line_pos.1 + offset.1;
876
877            cmd_buf.push(DrawCommand::DrawText {
878                x,
879                y,
880                text: line_text.as_str().into(),
881                style: style.clone(),
882                flow_style,
883            });
884
885            let font_size = flow_style.font_size;
886            let line_thickness = (font_size * 0.08).max(1.0);
887            let line_y_adj = if line_text.is_empty() {
888                y
889            } else {
890                y + font_size
891            };
892            let (line_y, draw) = match style.text_decoration {
893                TextDecoration::None => (0.0, false),
894                TextDecoration::Underline => (line_y_adj, true),
895                TextDecoration::LineThrough => (y + font_size * 0.5, true),
896                TextDecoration::Overline => (y, true),
897            };
898
899            if draw {
900                cmd_buf.push(DrawCommand::Fill {
901                    path: rect_path(
902                        x,
903                        line_y,
904                        span.x_range.end - span.x_range.start,
905                        line_thickness,
906                    ),
907                    rule: FillRule::NonZero,
908                    paint: Paint {
909                        brush: Brush::Solid(style.text_decoration_color.unwrap_or(style.color)),
910                        opacity,
911                    },
912                });
913            }
914        }
915    }
916}
917
918// --------------------------------
919// Public entry point
920// --------------------------------
921
922/// Paints the canvas background (CSS Backgrounds 3 §2.11.1): the root
923/// element's background is propagated to the canvas and covers the entire
924/// viewport, even when the root box is shorter than the viewport. The info
925/// root is the document node, so when it is transparent the background of its
926/// first element child (html) is propagated instead. Only solid colors are
927/// propagated; the element still paints gradient/image backgrounds on its own
928/// box.
929fn emit_canvas_background(cmd_buf: &mut Vec<DrawCommand>, info: &InfoNode, viewport: (f32, f32)) {
930    /// Check if a background has any visible content (non-transparent color
931    /// or any image/gradient layers).
932    fn has_visible_background(bg: &Background) -> bool {
933        bg.color.is_some_and(|c| c.3 > 0) || !bg.layers.is_empty()
934    }
935
936    /// Resolve the canvas background per CSS 2.1 §14.2 / CSS Backgrounds 3:
937    /// if the root element (html) has no visible background, the canvas
938    /// background is the first body child's background.
939    fn resolve_canvas_background(info: &InfoNode) -> Option<&Background> {
940        // Find the html element: first Container child with tag "html".
941        let html = info.children.iter().find(|child| {
942            if let NodeKind::Container { style, .. } = &child.kind {
943                style.tag.as_deref() == Some("html")
944            } else {
945                false
946            }
947        });
948
949        match html {
950            Some(html_info) => {
951                let html_bg = html_info.kind.container_bg();
952                if html_bg.is_some_and(has_visible_background) {
953                    html_bg
954                } else {
955                    // Fall back to the first body child's background.
956                    html_info.children.iter().find_map(|child| {
957                        if let NodeKind::Container { style, .. } = &child.kind {
958                            (style.tag.as_deref() == Some("body"))
959                                .then_some(child.kind.container_bg())
960                                .flatten()
961                        } else {
962                            None
963                        }
964                    })
965                }
966            }
967            None => {
968                // No html element found; fall back to the first visible
969                // background color in the tree (root, then children).
970                let solid_color = |bg: Option<&Background>| match bg {
971                    Some(bg) if bg.color.is_some_and(|c| c.3 > 0) => Some(bg.color.unwrap()),
972                    _ => None,
973                };
974                let child_bg = || {
975                    info.children
976                        .iter()
977                        .find_map(|child| solid_color(child.kind.container_bg()))
978                };
979                solid_color(info.kind.container_bg())
980                    .or_else(child_bg)
981                    .and_then(|_| {
982                        // Return a reference to the first background with visible color.
983                        info.kind
984                            .container_bg()
985                            .filter(|bg| has_visible_background(bg))
986                            .or_else(|| {
987                                info.children.iter().find_map(|child| {
988                                    child
989                                        .kind
990                                        .container_bg()
991                                        .filter(|bg| has_visible_background(bg))
992                                })
993                            })
994                    })
995            }
996        }
997    }
998
999    let background = resolve_canvas_background(info);
1000    let Some(bg) = background else {
1001        // Fall back to the previous behavior: first solid color found in the
1002        // tree (root, then children).
1003        let solid_color = |bg: Option<&Background>| match bg {
1004            Some(bg) if bg.color.is_some_and(|c| c.3 > 0) => Some(bg.color.unwrap()),
1005            _ => None,
1006        };
1007        let child_bg = || {
1008            info.children
1009                .iter()
1010                .find_map(|child| solid_color(child.kind.container_bg()))
1011        };
1012        let background = solid_color(info.kind.container_bg()).or_else(child_bg);
1013        let Some(color) = background else {
1014            return;
1015        };
1016        cmd_buf.push(DrawCommand::Fill {
1017            path: rect_path(0.0, 0.0, viewport.0, viewport.1),
1018            rule: FillRule::NonZero,
1019            paint: Paint {
1020                brush: Brush::Solid(color),
1021                opacity: 1.0,
1022            },
1023        });
1024        return;
1025    };
1026
1027    // Paint solid color first (bottom-most layer).
1028    if let Some(color) = bg.color.filter(|c| c.3 > 0) {
1029        cmd_buf.push(DrawCommand::Fill {
1030            path: rect_path(0.0, 0.0, viewport.0, viewport.1),
1031            rule: FillRule::NonZero,
1032            paint: Paint {
1033                brush: Brush::Solid(color),
1034                opacity: 1.0,
1035            },
1036        });
1037    }
1038
1039    // Paint image/gradient layers bottom-to-top, each covering the viewport.
1040    for layer in bg.layers.iter().rev() {
1041        match &layer.image {
1042            Some(BackgroundImage::Gradient(g)) => {
1043                cmd_buf.push(DrawCommand::Fill {
1044                    path: rect_path(0.0, 0.0, viewport.0, viewport.1),
1045                    rule: FillRule::NonZero,
1046                    paint: Paint {
1047                        brush: Brush::Gradient(g.clone()),
1048                        opacity: 1.0,
1049                    },
1050                });
1051            }
1052            Some(BackgroundImage::Image {
1053                image: Some(image), ..
1054            }) => {
1055                cmd_buf.push(DrawCommand::Fill {
1056                    path: rect_path(0.0, 0.0, viewport.0, viewport.1),
1057                    rule: FillRule::NonZero,
1058                    paint: Paint {
1059                        brush: Brush::Image(image.clone()),
1060                        opacity: 1.0,
1061                    },
1062                });
1063            }
1064            Some(BackgroundImage::Image { image: None, .. }) => {}
1065            None => {}
1066        }
1067    }
1068}
1069
1070/// LayoutNode + InfoNode → DrawCommand
1071///
1072/// `viewport` is the visible (window) size of the page area; it establishes the
1073/// root scrollport that page-level `position: sticky` boxes stick to.
1074pub fn generate_draw_commands(
1075    cmd_buf: &mut Vec<DrawCommand>,
1076    layout: &LayoutNode,
1077    info: &InfoNode,
1078    viewport: (f32, f32),
1079) {
1080    emit_canvas_background(cmd_buf, info, viewport);
1081    let (scroll_x, scroll_y) = info.kind.scroll_offsets();
1082    let root_viewport = StickyViewport {
1083        top_left: (-scroll_x, scroll_y),
1084        size: viewport,
1085    };
1086    let containing = layout
1087        .layout_box
1088        .iter()
1089        .next()
1090        .map_or((0.0, 0.0), |b| (b.content_box.width, b.content_box.height));
1091    let origin = layout
1092        .layout_box
1093        .iter()
1094        .next()
1095        .map_or((0.0, 0.0), |b| (b.content_box.x, b.content_box.y));
1096    let mut popups: Vec<(Vec<DrawCommand>, (f32, f32))> = Vec::new();
1097    generate_draw_commands_inner(
1098        cmd_buf,
1099        layout,
1100        info,
1101        (0.0, 0.0),
1102        root_viewport,
1103        containing,
1104        origin,
1105        origin,
1106        &mut popups,
1107        1.0,
1108        true,
1109    );
1110    // Top-layer popups render after every other box, outside all ancestor
1111    // clips and transforms.
1112    for (commands, (tx, ty)) in popups {
1113        if tx != 0.0 || ty != 0.0 {
1114            cmd_buf.push(DrawCommand::PushTransform {
1115                transform: AffineTransform::translate(tx, ty),
1116            });
1117        }
1118        cmd_buf.extend(commands);
1119        if tx != 0.0 || ty != 0.0 {
1120            cmd_buf.push(DrawCommand::PopTransform);
1121        }
1122    }
1123}
1124
1125/// Content-box origin of a child layout node in page space, derived from the
1126/// parent's page-space origin. Children are laid out relative to the parent's
1127/// content box, so the child's origin is the parent's origin plus the child's
1128/// content-box offset.
1129fn child_origin(child: &LayoutNode, parent_origin: (f32, f32)) -> (f32, f32) {
1130    child.layout_box.iter().next().map_or(parent_origin, |b| {
1131        (
1132            parent_origin.0 + b.content_box.x,
1133            parent_origin.1 + b.content_box.y,
1134        )
1135    })
1136}
1137
1138/// Page-space origin of the transform a child subtree will run under. A
1139/// block-level container child pushes its own content transform, so its active
1140/// origin is its own; an inline container child that shares the parent's line
1141/// space pushes no transform and inherits the enclosing block's active origin.
1142/// A flow-root inline child (inline-block / blockified float) shares the
1143/// parent's line space for Y (ui_layout bakes the line position into its box
1144/// models) but owns its own box space for X, so its children run under its own
1145/// origin on both axes.
1146/// Custom elements push a box transform of their own even when laid out inline,
1147/// so the caller passes `child_origin` directly for those.
1148fn child_transform_origin(
1149    child: &LayoutNode,
1150    parent_origin: (f32, f32),
1151    inherited: (f32, f32),
1152) -> (f32, f32) {
1153    if matches!(child.layout_box, ui_layout::LayoutBox::InlineBox(_)) {
1154        inherited
1155    } else {
1156        child_origin(child, parent_origin)
1157    }
1158}
1159
1160/// Recursive draw-command generation.
1161///
1162/// `accumulated_scroll` is the sum of the scroll offsets of every scrollable
1163/// ancestor, expressed in content space (`(x, y)`), i.e. the displacement the
1164/// current subtree inherits from ancestor scrolling. A `position: fixed` box
1165/// is positioned relative to the viewport, so the inherited displacement is
1166/// cancelled by pushing the inverse transform before its own box models and
1167/// resetting the accumulated scroll for its descendants.
1168///
1169/// `viewport` is the nearest scrollport as seen from this node's parent-content
1170/// space (used to resolve `position: sticky`), `containing` is this node's
1171/// parent content-box size (the containing block sticky boxes must stay in),
1172/// `origin` is this node's content-box origin in page space (unscrolled), and
1173/// `is_root` marks the document root, which always establishes the page
1174/// scrollport.
1175///
1176/// Open popups owned by custom nodes are collected (with the page-space
1177/// translation of their content-box origin) instead of being drawn inline, so
1178/// the caller can emit them above all page content.
1179#[allow(clippy::too_many_arguments)]
1180fn generate_draw_commands_inner(
1181    cmd_buf: &mut Vec<DrawCommand>,
1182    layout: &LayoutNode,
1183    info: &InfoNode,
1184    accumulated_scroll: (f32, f32),
1185    viewport: StickyViewport,
1186    containing: (f32, f32),
1187    origin: (f32, f32),
1188    transform_origin: (f32, f32),
1189    popups: &mut Vec<(Vec<DrawCommand>, (f32, f32))>,
1190    opacity: f32,
1191    is_root: bool,
1192) {
1193    // CSS `visibility` semantics: a hidden element paints nothing of its own
1194    // (box, borders, backgrounds, text) but still participates in layout, and
1195    // descendants with an explicit `visibility: visible` computed style DO
1196    // paint (computed visibility is already resolved per node by the style
1197    // builder, so a hidden ancestor does not force hidden descendants here).
1198    // Therefore the subtree must still be traversed; only this node's own
1199    // painting is suppressed.
1200    let self_hidden = matches!(
1201        &info.kind,
1202        NodeKind::Container { style, .. }
1203            | NodeKind::Custom { style, .. }
1204            | NodeKind::Svg { style, .. }
1205            if matches!(style.visibility, Visibility::Hidden | Visibility::Collapse)
1206    );
1207
1208    // CSS `opacity` multiplies down the tree: an element with `opacity: 0.5`
1209    // renders itself and every descendant at half strength. `opacity` is the
1210    // product of all ancestors' opacities; `effective_opacity` folds this
1211    // node's own opacity in for its box, borders, backgrounds, text and SVG
1212    // content, and is passed to children as their inherited multiplier.
1213    let effective_opacity = {
1214        let own = match &info.kind {
1215            NodeKind::Container { style, .. }
1216            | NodeKind::Custom { style, .. }
1217            | NodeKind::Svg { style, .. } => style.opacity,
1218            _ => 1.0,
1219        };
1220        opacity * own
1221    };
1222
1223    let mut box_states: Vec<BoxPushState> = Vec::new();
1224
1225    let is_fixed = layout.style.position.kind == Position::Fixed;
1226    let is_sticky = layout.style.position.kind == Position::Sticky;
1227    let is_inline = matches!(layout.layout_box, ui_layout::LayoutBox::InlineBox(_));
1228
1229    // Inline containers that establish their own inline formatting context
1230    // (display: inline-block, or a blockified float) nest their descendants in
1231    // their own content coordinate space. ui_layout lays those descendants out
1232    // relative to this box's content box on the X axis, but on the Y axis it
1233    // bakes this box's own line position into them (an inline box shares the
1234    // parent's line space). The content can therefore be translated by the
1235    // box's X offset only; Y is already carried by the styled coordinates and
1236    // must not be offset again. Plain inline boxes never translate.
1237    let establishes_inline_box_space = is_inline
1238        && layout.style.display.outer() == Some(OuterDisplay::Inline)
1239        && layout.style.display.inner() == Some(InnerDisplay::FlowRoot);
1240
1241    let flow_root_inline_dx = if establishes_inline_box_space {
1242        origin.0 - transform_origin.0
1243    } else {
1244        0.0
1245    };
1246
1247    // Cancel the inherited scroll displacement for fixed-position boxes.
1248    let cancel_scroll = is_fixed && (accumulated_scroll.0 != 0.0 || accumulated_scroll.1 != 0.0);
1249    if cancel_scroll {
1250        cmd_buf.push(DrawCommand::PushTransform {
1251            transform: AffineTransform::translate(-accumulated_scroll.0, accumulated_scroll.1),
1252        });
1253    }
1254
1255    // Shift sticky boxes so each specified inset stays within the visible area
1256    // of the nearest scrollport. Fixed boxes are positioned relative to the
1257    // viewport, so sticky offsets never apply to them.
1258    let sticky_pushed = if is_sticky && !is_fixed {
1259        match layout.layout_box.iter().next() {
1260            Some(bm) => {
1261                let (dx, dy) = bm
1262                    .sticky_edges
1263                    .map(|edges| sticky_offset(&edges, &bm.border_box, viewport, containing))
1264                    .unwrap_or((0.0, 0.0));
1265                push_transform(cmd_buf, dx, dy)
1266            }
1267            None => false,
1268        }
1269    } else {
1270        false
1271    };
1272
1273    match &info.kind {
1274        NodeKind::Text { .. } | NodeKind::LineBreak => unreachable!(),
1275
1276        NodeKind::Container {
1277            scroll_x,
1278            scroll_y,
1279            scroll_offset_x,
1280            scroll_offset_y,
1281            style,
1282            ..
1283        } => {
1284            for box_model in &layout.layout_box {
1285                box_states.push(push_box_model(
1286                    cmd_buf,
1287                    &box_model,
1288                    style,
1289                    *scroll_offset_x,
1290                    *scroll_offset_y,
1291                    is_inline,
1292                    *scroll_x || *scroll_y,
1293                    true,
1294                    !self_hidden,
1295                    effective_opacity,
1296                ));
1297            }
1298        }
1299
1300        // An inline SVG paints its viewBox-space subpaths into the content
1301        // box right after the box is established, à la `emit_canvas_background`.
1302        NodeKind::Svg {
1303            view_box,
1304            shapes,
1305            scroll_x,
1306            scroll_y,
1307            scroll_offset_x,
1308            scroll_offset_y,
1309            style,
1310            ..
1311        } => {
1312            for box_model in &layout.layout_box {
1313                box_states.push(push_box_model(
1314                    cmd_buf,
1315                    &box_model,
1316                    style,
1317                    *scroll_offset_x,
1318                    *scroll_offset_y,
1319                    is_inline,
1320                    *scroll_x || *scroll_y,
1321                    true,
1322                    !self_hidden,
1323                    effective_opacity,
1324                ));
1325            }
1326
1327            // The vector content belongs to the element itself (like its borders
1328            // and background), so a hidden node paints nothing.
1329            if !self_hidden && let Some(bm) = layout.layout_box.iter().next() {
1330                emit_commands(
1331                    cmd_buf,
1332                    (
1333                        bm.content_box.x,
1334                        bm.content_box.y,
1335                        bm.content_box.width,
1336                        bm.content_box.height,
1337                    ),
1338                    is_inline,
1339                    *view_box,
1340                    shapes,
1341                    effective_opacity,
1342                );
1343            }
1344        }
1345
1346        NodeKind::Custom {
1347            scroll_x,
1348            scroll_y,
1349            scroll_offset_x,
1350            scroll_offset_y,
1351            style,
1352            layout_style,
1353            node,
1354            text_style,
1355            text_flow_style,
1356            ..
1357        } => 'custom: {
1358            for box_model in &layout.layout_box {
1359                box_states.push(push_box_model(
1360                    cmd_buf,
1361                    &box_model,
1362                    style,
1363                    *scroll_offset_x,
1364                    *scroll_offset_y,
1365                    false,
1366                    *scroll_x || *scroll_y,
1367                    true,
1368                    !self_hidden,
1369                    effective_opacity,
1370                ));
1371            }
1372
1373            // `display: none` / `display: contents` custom elements have no
1374            // laid-out box (`LayoutBox::None`); render nothing rather than
1375            // falling back to the component's intrinsic size.
1376            let base_box = match &layout.layout_box {
1377                ui_layout::LayoutBox::BlockBox(bm) => Some(bm.clone()),
1378                ui_layout::LayoutBox::InlineBox(ib) => Some(ib.box_model.clone()),
1379                _ => None,
1380            };
1381            let Some(size) = base_box.as_ref().map(|bm| ContentSize {
1382                width: bm.content_box.width,
1383                height: bm.content_box.height,
1384            }) else {
1385                break 'custom;
1386            };
1387            let culled = box_states.iter().any(|s| s.cull);
1388            if !self_hidden && !culled {
1389                node.draw_sized(cmd_buf, text_style, text_flow_style, layout_style, size);
1390            }
1391            // Collect open popups so they render above every other box,
1392            // outside all ancestor clips and transforms.
1393            if let Some(popup) = node.popup(text_style, text_flow_style) {
1394                let own_scroll = info.kind.scroll_offsets();
1395                // Fixed boxes are positioned relative to the viewport, so the
1396                // inherited scroll displacement does not move their popup.
1397                let inherited_scroll = if is_fixed {
1398                    (0.0, 0.0)
1399                } else {
1400                    accumulated_scroll
1401                };
1402                let (mut tx, mut ty) = (
1403                    origin.0 - inherited_scroll.0 - own_scroll.0,
1404                    origin.1 - inherited_scroll.1 - own_scroll.1,
1405                );
1406                // Sticky boxes shift their whole subtree (and thus their
1407                // popup) by the sticky offset.
1408                if is_sticky
1409                    && !is_fixed
1410                    && let Some(bm) = layout.layout_box.iter().next()
1411                    && let Some(edges) = bm.sticky_edges
1412                {
1413                    let (dx, dy) = sticky_offset(&edges, &bm.border_box, viewport, containing);
1414                    tx += dx;
1415                    ty += dy;
1416                }
1417                popups.push((popup.commands, (tx, ty)));
1418            }
1419        }
1420    }
1421
1422    // A zero-area clip-path anywhere in this node's box stack hides the whole
1423    // subtree: skip painting it (transforms/clips stay balanced).
1424    let subtree_culled = box_states.iter().any(|s| s.cull);
1425
1426    // Scroll offsets of this node itself; they scroll the node's own content.
1427    let own_scroll = info.kind.scroll_offsets();
1428    // A fixed box resets the inherited displacement (already cancelled above),
1429    // so its descendants only inherit its own scroll offset.
1430    let child_scroll = if is_fixed {
1431        own_scroll
1432    } else {
1433        (
1434            accumulated_scroll.0 + own_scroll.0,
1435            accumulated_scroll.1 + own_scroll.1,
1436        )
1437    };
1438
1439    // Sticky viewport state for this node's subtree, rebased into each child's
1440    // parent-content space. A node that scrolls its own content (or the root,
1441    // which the UI layer scrolls directly) becomes the scrollport for its
1442    // descendants; otherwise the inherited visible region is shifted by the
1443    // node's content-box offset.
1444    let child_viewport = if is_root || is_scrollport(&info.kind) {
1445        let bm = layout.layout_box.iter().next();
1446        let (scroll_x, scroll_y) = own_scroll;
1447        StickyViewport {
1448            top_left: bm.as_ref().map_or((0.0, 0.0), |b| {
1449                (
1450                    b.padding_box.x - b.content_box.x - scroll_x,
1451                    b.padding_box.y - b.content_box.y + scroll_y,
1452                )
1453            }),
1454            size: if is_root {
1455                viewport.size
1456            } else {
1457                bm.map_or(viewport.size, |b| {
1458                    (b.padding_box.width, b.padding_box.height)
1459                })
1460            },
1461        }
1462    } else {
1463        let bm = layout.layout_box.iter().next();
1464        StickyViewport {
1465            top_left: bm.map_or(viewport.top_left, |b| {
1466                (
1467                    viewport.top_left.0 - b.content_box.x,
1468                    viewport.top_left.1 - b.content_box.y,
1469                )
1470            }),
1471            size: viewport.size,
1472        }
1473    };
1474    let child_containing = layout
1475        .layout_box
1476        .iter()
1477        .next()
1478        .map_or(containing, |b| (b.content_box.width, b.content_box.height));
1479
1480    // A flow-root inline box (inline-block / blockified float) translates its
1481    // content by its own X offset within the active transform space; the Y
1482    // bake from ui_layout's line-space layout is left untouched.
1483    let flow_root_inline_pushed =
1484        flow_root_inline_dx != 0.0 && push_transform(cmd_buf, flow_root_inline_dx, 0.0);
1485
1486    let mut layout_iter = layout.children.iter();
1487    let mut positive_stacking_children: Vec<(i32, usize, Vec<DrawCommand>)> = Vec::new();
1488
1489    for (child_order, child_info) in info.children.iter().enumerate() {
1490        match &child_info.kind {
1491            NodeKind::Text {
1492                text_id,
1493                style,
1494                flow_style,
1495                ..
1496            } => {
1497                // Text belongs to its parent box, so a hidden parent hides it.
1498                if !self_hidden && !subtree_culled {
1499                    // Content is always drawn relative to the established box
1500                    // space: block and flow-root inline frames push their own
1501                    // transform, and plain inline text lives in the nearest
1502                    // enclosing block's line space.
1503                    draw_text(
1504                        cmd_buf,
1505                        style,
1506                        *flow_style,
1507                        *text_id,
1508                        (0.0, 0.0),
1509                        effective_opacity,
1510                    );
1511                }
1512                layout_iter.next();
1513            }
1514            NodeKind::LineBreak => {
1515                layout_iter.next();
1516            }
1517            NodeKind::Container { .. } | NodeKind::Svg { .. } => {
1518                if subtree_culled {
1519                    layout_iter.next();
1520                    continue;
1521                }
1522                if let Some(LayoutChild::Node(node)) = layout_iter.next() {
1523                    let child_origin = child_origin(node, origin);
1524                    // A plain inline frame pushes no transform of its own, so
1525                    // its box children inherit the enclosing block's active
1526                    // origin; a flow-root inline (or block) frame establishes
1527                    // its own box space, so inlines it contains are relative
1528                    // to its own content origin.
1529                    let inherited = if establishes_inline_box_space {
1530                        origin
1531                    } else {
1532                        transform_origin
1533                    };
1534                    let child_transform_origin = child_transform_origin(node, origin, inherited);
1535                    let z_index = child_info.kind.z_index();
1536                    if z_index > 0 {
1537                        let mut child_commands = Vec::new();
1538                        generate_draw_commands_inner(
1539                            &mut child_commands,
1540                            node,
1541                            child_info,
1542                            child_scroll,
1543                            child_viewport,
1544                            child_containing,
1545                            child_origin,
1546                            child_transform_origin,
1547                            popups,
1548                            effective_opacity,
1549                            false,
1550                        );
1551                        positive_stacking_children.push((z_index, child_order, child_commands));
1552                    } else {
1553                        generate_draw_commands_inner(
1554                            cmd_buf,
1555                            node,
1556                            child_info,
1557                            child_scroll,
1558                            child_viewport,
1559                            child_containing,
1560                            child_origin,
1561                            child_transform_origin,
1562                            popups,
1563                            effective_opacity,
1564                            false,
1565                        );
1566                    }
1567                }
1568            }
1569            NodeKind::Custom {
1570                node,
1571                text_style,
1572                text_flow_style,
1573                style,
1574                layout_style,
1575                ..
1576            } => {
1577                if subtree_culled {
1578                    layout_iter.next();
1579                    continue;
1580                }
1581                match layout_iter.next() {
1582                    // Block custom element: recurse into the child layout node.
1583                    Some(LayoutChild::Node(node_layout)) => {
1584                        let child_origin = child_origin(node_layout, origin);
1585                        // Custom elements always push a box transform of their
1586                        // own, even when laid out as inline boxes.
1587                        generate_draw_commands_inner(
1588                            cmd_buf,
1589                            node_layout,
1590                            child_info,
1591                            child_scroll,
1592                            child_viewport,
1593                            child_containing,
1594                            child_origin,
1595                            child_origin,
1596                            popups,
1597                            effective_opacity,
1598                            false,
1599                        );
1600                    }
1601                    // Inline custom element: consume the Object and draw it
1602                    // from the layout result stored on the tree child.
1603                    Some(LayoutChild::Custom(custom_child)) => {
1604                        if let Some(result) = custom_child.result() {
1605                            let bm = &result.box_model;
1606                            let rect = BoxModel {
1607                                sticky_edges: bm.sticky_edges,
1608                                border_box: bm.border_box,
1609                                padding_box: bm.padding_box,
1610                                content_box: bm.content_box,
1611                                children_box: bm.children_box,
1612                            };
1613                            let size = layout
1614                                .layout_box
1615                                .iter()
1616                                .next()
1617                                .map(|box_model| ContentSize {
1618                                    width: box_model.content_box.width,
1619                                    height: box_model.content_box.height,
1620                                })
1621                                .unwrap_or(ContentSize {
1622                                    width: rect.content_box.width,
1623                                    height: rect.content_box.height,
1624                                });
1625                            // The parent custom element owns CSS box painting;
1626                            // this bridge box only positions its intrinsic content.
1627                            let state = push_box_model(
1628                                cmd_buf,
1629                                &rect,
1630                                style,
1631                                0.0,
1632                                0.0,
1633                                false,
1634                                false,
1635                                false,
1636                                false,
1637                                effective_opacity,
1638                            );
1639                            node.draw_sized(
1640                                cmd_buf,
1641                                text_style,
1642                                text_flow_style,
1643                                layout_style,
1644                                size,
1645                            );
1646                            pop_box_model(cmd_buf, state);
1647                        }
1648                    }
1649                    _ => {}
1650                }
1651            }
1652        }
1653    }
1654
1655    positive_stacking_children.sort_by_key(|(z_index, child_order, _)| (*z_index, *child_order));
1656    for (_, _, commands) in positive_stacking_children {
1657        cmd_buf.extend(commands);
1658    }
1659
1660    if matches!(
1661        info.kind,
1662        NodeKind::Container { .. } | NodeKind::Custom { .. } | NodeKind::Svg { .. }
1663    ) {
1664        for state in box_states.iter().rev() {
1665            pop_box_model(cmd_buf, *state);
1666        }
1667    }
1668
1669    if sticky_pushed {
1670        cmd_buf.push(DrawCommand::PopTransform);
1671    }
1672
1673    if cancel_scroll {
1674        cmd_buf.push(DrawCommand::PopTransform);
1675    }
1676
1677    if flow_root_inline_pushed {
1678        cmd_buf.push(DrawCommand::PopTransform);
1679    }
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684    use super::*;
1685    use crate::engine::layouter::types::{BackgroundLayer, ContainerRole};
1686    use crate::engine::renderer_model::geom::AffineTransform;
1687    use crate::engine::ui::custom_node::{CustomNode, Popup};
1688    use std::sync::Arc;
1689    use ui_layout::Style;
1690
1691    fn count_balanced(commands: &[DrawCommand]) -> bool {
1692        let mut transform_depth = 0usize;
1693        let mut clip_depth = 0usize;
1694        for cmd in commands {
1695            match cmd {
1696                DrawCommand::PushTransform { .. } => transform_depth += 1,
1697                DrawCommand::PopTransform => transform_depth -= 1,
1698                DrawCommand::PushClip { .. } => clip_depth += 1,
1699                DrawCommand::PopClip => clip_depth -= 1,
1700                _ => {}
1701            }
1702        }
1703        transform_depth == 0 && clip_depth == 0
1704    }
1705
1706    #[test]
1707    fn test_push_pop_transform_balanced() {
1708        let mut buf = Vec::new();
1709        assert!(push_transform(&mut buf, 5.0, 5.0));
1710        assert!(matches!(buf.pop(), Some(DrawCommand::PushTransform { .. })));
1711        assert!(!push_transform(&mut buf, 0.0, 0.0));
1712    }
1713
1714    #[test]
1715    fn test_radii_resolution_and_clamp() {
1716        let outer = resolve_outer_radii(&BorderRadius::default(), 100.0, 50.0);
1717        assert_eq!(outer, [(0.0, 0.0); 4]);
1718    }
1719
1720    #[test]
1721    fn scratch_background_image_geometry_is_resolved() {
1722        let (width, height) = resolve_background_image_size(
1723            1200.0,
1724            600.0,
1725            800.0,
1726            400.0,
1727            BackgroundSize::Explicit {
1728                width: BackgroundDimension::Length(624.0),
1729                height: BackgroundDimension::Length(325.0),
1730            },
1731        );
1732        assert_eq!((width, height), (624.0, 325.0));
1733        assert_eq!(
1734            resolve_background_axis(
1735                800.0,
1736                width,
1737                BackgroundPositionAxis::End(BackgroundOffset::Zero),
1738            ),
1739            176.0
1740        );
1741        assert_eq!(
1742            resolve_background_axis(
1743                400.0,
1744                height,
1745                BackgroundPositionAxis::Center(BackgroundOffset::Zero),
1746            ),
1747            37.5
1748        );
1749    }
1750
1751    #[test]
1752    fn background_contain_and_cover_preserve_aspect_ratio() {
1753        assert_eq!(
1754            resolve_background_image_size(200.0, 100.0, 300.0, 300.0, BackgroundSize::Contain,),
1755            (300.0, 150.0)
1756        );
1757        assert_eq!(
1758            resolve_background_image_size(200.0, 100.0, 300.0, 300.0, BackgroundSize::Cover),
1759            (600.0, 300.0)
1760        );
1761    }
1762
1763    #[test]
1764    fn background_color_is_painted_below_gradient_layers() {
1765        use crate::engine::layouter::types::{Gradient, GradientKind, GradientSpread};
1766
1767        let gradient = Gradient {
1768            kind: GradientKind::Linear { angle: 0.0 },
1769            stops: Vec::new(),
1770            spread: GradientSpread::Pad,
1771        };
1772        let style = ContainerStyle {
1773            background: Background {
1774                color: Some(Color(255, 0, 0, 255)),
1775                layers: vec![BackgroundLayer {
1776                    image: Some(crate::engine::layouter::types::BackgroundImage::Gradient(
1777                        gradient,
1778                    )),
1779                    position: Default::default(),
1780                    size: Default::default(),
1781                    repeat: Default::default(),
1782                }],
1783            },
1784            ..ContainerStyle::default()
1785        };
1786        let rect = ui_rect(0.0, 0.0, 100.0, 50.0);
1787        let mut cmd_buf = Vec::new();
1788        draw_background(&mut cmd_buf, &rect, &rect, &style, 0.0, 0.0, 1.0);
1789
1790        let mut brushes = cmd_buf.iter().filter_map(|cmd| match cmd {
1791            DrawCommand::Fill { paint, .. } => Some(paint.brush.clone()),
1792            _ => None,
1793        });
1794        assert!(matches!(
1795            brushes.next(),
1796            Some(Brush::Solid(Color(255, 0, 0, 255)))
1797        ));
1798        assert!(matches!(brushes.next(), Some(Brush::Gradient(_))));
1799    }
1800
1801    #[test]
1802    fn background_geometry_uses_the_original_layer_index() {
1803        use crate::engine::renderer_model::draw_command::Image;
1804        use crate::engine::renderer_model::path::PathCommand;
1805
1806        let path_size = |path: &Path| {
1807            let mut max = (0.0f32, 0.0f32);
1808            for command in &path.commands {
1809                match command {
1810                    PathCommand::MoveTo { x, y } | PathCommand::LineTo { x, y } => {
1811                        max.0 = max.0.max(*x);
1812                        max.1 = max.1.max(*y);
1813                    }
1814                    _ => {}
1815                }
1816            }
1817            max
1818        };
1819
1820        let top = Image::from_rgba(1, 1, vec![0, 0, 0, 0]).expect("top image");
1821        let bottom = Image::from_rgba(2, 1, vec![0; 8]).expect("bottom image");
1822        let top_id = top.id();
1823        let bottom_id = bottom.id();
1824        let style = ContainerStyle {
1825            background: Background {
1826                color: None,
1827                layers: vec![
1828                    BackgroundLayer {
1829                        image: Some(crate::engine::layouter::types::BackgroundImage::Image {
1830                            source: "top".into(),
1831                            image: Some(top),
1832                        }),
1833                        position: Default::default(),
1834                        size: BackgroundSize::Explicit {
1835                            width: BackgroundDimension::Length(10.0),
1836                            height: BackgroundDimension::Length(10.0),
1837                        },
1838                        repeat: BackgroundRepeat::NoRepeat,
1839                    },
1840                    BackgroundLayer {
1841                        image: Some(crate::engine::layouter::types::BackgroundImage::Image {
1842                            source: "bottom".into(),
1843                            image: Some(bottom),
1844                        }),
1845                        position: Default::default(),
1846                        size: BackgroundSize::Explicit {
1847                            width: BackgroundDimension::Length(20.0),
1848                            height: BackgroundDimension::Length(20.0),
1849                        },
1850                        repeat: BackgroundRepeat::NoRepeat,
1851                    },
1852                ],
1853            },
1854            ..ContainerStyle::default()
1855        };
1856        let rect = ui_rect(0.0, 0.0, 100.0, 100.0);
1857        let mut cmd_buf = Vec::new();
1858        draw_background(&mut cmd_buf, &rect, &rect, &style, 0.0, 0.0, 1.0);
1859
1860        let fills: Vec<(u64, (f32, f32))> = cmd_buf
1861            .iter()
1862            .filter_map(|cmd| match cmd {
1863                DrawCommand::Fill {
1864                    path,
1865                    paint:
1866                        Paint {
1867                            brush: Brush::Image(image),
1868                            ..
1869                        },
1870                    ..
1871                } => Some((image.id(), path_size(path))),
1872                _ => None,
1873            })
1874            .collect();
1875        assert_eq!(
1876            fills,
1877            vec![(bottom_id, (20.0, 20.0)), (top_id, (10.0, 10.0))]
1878        );
1879    }
1880
1881    fn ui_rect(x: f32, y: f32, w: f32, h: f32) -> ui_layout::Rect {
1882        ui_layout::Rect {
1883            x,
1884            y,
1885            width: w,
1886            height: h,
1887        }
1888    }
1889
1890    #[derive(Debug)]
1891    struct DrawingNode;
1892
1893    impl CustomNode for DrawingNode {
1894        fn draw_sized(
1895            &self,
1896            cmd_buf: &mut Vec<DrawCommand>,
1897            _text_style: &TextStyle,
1898            _text_flow_style: &TextFlowStyle,
1899            _style: &Style,
1900            size: ContentSize,
1901        ) {
1902            cmd_buf.push(DrawCommand::Fill {
1903                path: rect_path(0.0, 0.0, size.width, size.height),
1904                rule: FillRule::NonZero,
1905                paint: Paint {
1906                    brush: Brush::Solid(Color(255, 0, 0, 255)),
1907                    opacity: 1.0,
1908                },
1909            });
1910        }
1911
1912        fn intrinsic_size(&self) -> ContentSize {
1913            ContentSize {
1914                width: 10.0,
1915                height: 10.0,
1916            }
1917        }
1918    }
1919
1920    #[test]
1921    fn inline_custom_content_uses_its_flow_root_position_once() {
1922        let mut root = LayoutNode::new(Style::default());
1923        root.layout_box = ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
1924            sticky_edges: None,
1925            border_box: ui_rect(0.0, 0.0, 200.0, 200.0),
1926            padding_box: ui_rect(0.0, 0.0, 200.0, 200.0),
1927            content_box: ui_rect(0.0, 0.0, 200.0, 200.0),
1928            children_box: ui_rect(0.0, 0.0, 200.0, 200.0),
1929        });
1930
1931        let inline_style = Style {
1932            display: ui_layout::Display::parse("inline-block").unwrap(),
1933            ..Style::default()
1934        };
1935        let mut custom = LayoutNode::new(inline_style.clone());
1936        custom.layout_box = ui_layout::LayoutBox::InlineBox(ui_layout::InlineBox {
1937            box_model: ui_layout::BoxModel {
1938                sticky_edges: None,
1939                border_box: ui_rect(3.0, 10.0, 20.0, 20.0),
1940                padding_box: ui_rect(4.0, 11.0, 18.0, 18.0),
1941                content_box: ui_rect(8.0, 15.0, 10.0, 10.0),
1942                children_box: ui_rect(8.0, 15.0, 10.0, 10.0),
1943            },
1944            line_spans: vec![ui_layout::LineSpan {
1945                x_range: 0.0..10.0,
1946                line_pos: (3.0, 10.0),
1947                line_index: 0,
1948            }],
1949        });
1950        root.children = vec![LayoutChild::Node(Box::new(custom))];
1951
1952        let root_info = mk_info_node(
1953            NodeKind::Container {
1954                scroll_x: false,
1955                scroll_y: false,
1956                scroll_offset_x: 0.0,
1957                scroll_offset_y: 0.0,
1958                style: ContainerStyle::default(),
1959                role: ContainerRole::Normal,
1960            },
1961            vec![mk_info_node(
1962                NodeKind::Custom {
1963                    node: Arc::new(DrawingNode),
1964                    scroll_x: false,
1965                    scroll_y: false,
1966                    scroll_offset_x: 0.0,
1967                    scroll_offset_y: 0.0,
1968                    style: ContainerStyle::default(),
1969                    layout_style: inline_style,
1970                    text_style: TextStyle::default(),
1971                    text_flow_style: TextFlowStyle::default(),
1972                },
1973                Vec::new(),
1974            )],
1975        );
1976
1977        let mut commands = Vec::new();
1978        generate_draw_commands(&mut commands, &root, &root_info, (200.0, 200.0));
1979        assert!(count_balanced(&commands));
1980
1981        let mut translations = Vec::new();
1982        for command in commands {
1983            match command {
1984                DrawCommand::PushTransform { transform } => {
1985                    translations.push(transform.apply(0.0, 0.0));
1986                }
1987                DrawCommand::Fill { paint, .. } => {
1988                    if !matches!(paint.brush, Brush::Solid(Color(255, 0, 0, 255))) {
1989                        translations.clear();
1990                        continue;
1991                    }
1992                    assert_eq!(translations, [(3.0, 10.0), (5.0, 5.0)]);
1993                    assert_eq!(
1994                        translations
1995                            .iter()
1996                            .fold((0.0, 0.0), |(x, y), (dx, dy)| { (x + dx, y + dy) }),
1997                        (8.0, 15.0)
1998                    );
1999                    return;
2000                }
2001                _ => {}
2002            }
2003        }
2004        panic!("custom content fill was not emitted");
2005    }
2006
2007    #[test]
2008    fn test_single_box_model_is_balanced() {
2009        let box_model = ui_layout::BoxModel {
2010            sticky_edges: None,
2011            border_box: ui_rect(10.0, 20.0, 120.0, 60.0),
2012            padding_box: ui_rect(12.0, 22.0, 116.0, 56.0),
2013            content_box: ui_rect(12.0, 22.0, 116.0, 56.0),
2014            children_box: ui_rect(12.0, 22.0, 116.0, 56.0),
2015        };
2016        let style = ContainerStyle::default();
2017        let mut buf = Vec::new();
2018        let state = push_box_model(
2019            &mut buf, &box_model, &style, 0.0, 0.0, false, true, true, true, 1.0,
2020        );
2021        // Scroll/content transforms are no-ops here (zero offsets); border
2022        // transform + clip + content are pushed while the box is open.
2023        assert!(buf.len() >= 2);
2024        // Opening pushes are not yet balanced: a clip and transforms are pending.
2025        assert!(!count_balanced(&buf));
2026        pop_box_model(&mut buf, state);
2027        assert!(count_balanced(&buf));
2028    }
2029
2030    #[test]
2031    fn transform_origin_is_element_center() {
2032        let box_model = ui_layout::BoxModel {
2033            sticky_edges: None,
2034            border_box: ui_rect(10.0, 20.0, 100.0, 50.0),
2035            padding_box: ui_rect(12.0, 22.0, 96.0, 46.0),
2036            content_box: ui_rect(12.0, 22.0, 96.0, 46.0),
2037            children_box: ui_rect(12.0, 22.0, 96.0, 46.0),
2038        };
2039        let style = ContainerStyle {
2040            transform: Some(AffineTransform::rotate(std::f32::consts::FRAC_PI_2)),
2041            ..Default::default()
2042        };
2043        let mut buf = Vec::new();
2044        let state = push_box_model(
2045            &mut buf, &box_model, &style, 0.0, 0.0, false, true, true, true, 1.0,
2046        );
2047        pop_box_model(&mut buf, state);
2048
2049        // Commands: [border translate(10,20), centered transform, content translate(..)].
2050        let transforms: Vec<&AffineTransform> = buf
2051            .iter()
2052            .filter_map(|cmd| match cmd {
2053                DrawCommand::PushTransform { transform } => Some(transform),
2054                _ => None,
2055            })
2056            .collect();
2057        assert!(transforms.len() >= 2);
2058        let centered = transforms[1];
2059
2060        // Expected: translate(50, 25) * rotate(90deg) * translate(-50, -25).
2061        let expected = AffineTransform::translate(50.0, 25.0)
2062            .then(&AffineTransform::rotate(std::f32::consts::FRAC_PI_2))
2063            .then(&AffineTransform::translate(-50.0, -25.0));
2064        assert!((centered.m11 - expected.m11).abs() < 1e-5);
2065        assert!((centered.m12 - expected.m12).abs() < 1e-5);
2066        assert!((centered.m21 - expected.m21).abs() < 1e-5);
2067        assert!((centered.m22 - expected.m22).abs() < 1e-5);
2068        assert!((centered.dx - expected.dx).abs() < 1e-5);
2069        assert!((centered.dy - expected.dy).abs() < 1e-5);
2070
2071        // The element's center (local (50, 25)) must stay fixed under rotation.
2072        let (cx, cy) = centered.apply(50.0, 25.0);
2073        assert!((cx - 50.0).abs() < 1e-5, "center x = {cx}");
2074        assert!((cy - 25.0).abs() < 1e-5, "center y = {cy}");
2075    }
2076
2077    #[test]
2078    fn visible_overflow_does_not_clip_block_contents() {
2079        let box_model = ui_layout::BoxModel {
2080            sticky_edges: None,
2081            border_box: ui_rect(0.0, 0.0, 100.0, 50.0),
2082            padding_box: ui_rect(0.0, 0.0, 100.0, 50.0),
2083            content_box: ui_rect(0.0, 0.0, 100.0, 50.0),
2084            children_box: ui_rect(0.0, 0.0, 120.0, 60.0),
2085        };
2086        let mut commands = Vec::new();
2087        let state = push_box_model(
2088            &mut commands,
2089            &box_model,
2090            &ContainerStyle::default(),
2091            0.0,
2092            0.0,
2093            false,
2094            false,
2095            true,
2096            true,
2097            1.0,
2098        );
2099        assert!(
2100            !commands
2101                .iter()
2102                .any(|command| matches!(command, DrawCommand::PushClip { .. }))
2103        );
2104        pop_box_model(&mut commands, state);
2105        assert!(count_balanced(&commands));
2106    }
2107
2108    #[test]
2109    fn test_nested_box_models_balanced() {
2110        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2111            sticky_edges: None,
2112            border_box: ui_rect(x, y, w, h),
2113            padding_box: ui_rect(x + 2.0, y + 2.0, w - 4.0, h - 4.0),
2114            content_box: ui_rect(x + 2.0, y + 2.0, w - 4.0, h - 4.0),
2115            children_box: ui_rect(x + 2.0, y + 2.0, w - 4.0, h - 4.0),
2116        };
2117        let style = ContainerStyle::default();
2118        let mut buf = Vec::new();
2119        let outer = push_box_model(
2120            &mut buf,
2121            &mk_box(0.0, 0.0, 100.0, 100.0),
2122            &style,
2123            3.0,
2124            4.0,
2125            false,
2126            true,
2127            true,
2128            true,
2129            1.0,
2130        );
2131        let inner = push_box_model(
2132            &mut buf,
2133            &mk_box(10.0, 10.0, 50.0, 50.0),
2134            &style,
2135            0.0,
2136            0.0,
2137            false,
2138            true,
2139            true,
2140            true,
2141            1.0,
2142        );
2143        // Sanity: inner push generated commands (border + background + clip).
2144        assert!(!buf.is_empty());
2145        pop_box_model(&mut buf, inner);
2146        pop_box_model(&mut buf, outer);
2147        assert!(count_balanced(&buf));
2148    }
2149
2150    #[test]
2151    fn test_affine_transform_reexport() {
2152        let t = AffineTransform::translate(1.0, 2.0);
2153        assert_eq!(t.apply(0.0, 0.0), (1.0, 2.0));
2154    }
2155
2156    /// Build an InfoNode subtree matching `generate_draw_commands`' expectation:
2157    /// `kind` (with `style`), `children` (each with `kind`).
2158    fn mk_info_node(kind: NodeKind, children: Vec<InfoNode>) -> InfoNode {
2159        InfoNode {
2160            kind,
2161            children,
2162            dom_id: None,
2163        }
2164    }
2165
2166    fn hidden_container_info(children: Vec<InfoNode>) -> InfoNode {
2167        let style = ContainerStyle {
2168            visibility: Visibility::Hidden,
2169            ..ContainerStyle::default()
2170        };
2171        mk_info_node(
2172            NodeKind::Container {
2173                scroll_x: false,
2174                scroll_y: false,
2175                scroll_offset_x: 0.0,
2176                scroll_offset_y: 0.0,
2177                style,
2178                role: ContainerRole::Normal,
2179            },
2180            children,
2181        )
2182    }
2183
2184    fn visible_container_info(children: Vec<InfoNode>) -> InfoNode {
2185        let style = ContainerStyle {
2186            background: Background {
2187                color: Some(Color(255, 0, 0, 255)),
2188                layers: Vec::new(),
2189            },
2190            ..ContainerStyle::default()
2191        };
2192        mk_info_node(
2193            NodeKind::Container {
2194                scroll_x: false,
2195                scroll_y: false,
2196                scroll_offset_x: 0.0,
2197                scroll_offset_y: 0.0,
2198                style,
2199                role: ContainerRole::Normal,
2200            },
2201            children,
2202        )
2203    }
2204
2205    /// A `visibility: hidden` ancestor must NOT prune explicitly visible
2206    /// descendants: the hidden box paints nothing itself, but its visible
2207    /// children still emit draw commands (CSS `visibility` is per-element,
2208    /// unlike `display: none`). This is what Reddit's FOUC-guard CSS relies
2209    /// on (`:not(:defined) { visibility: hidden }` plus explicit
2210    /// `visibility: visible` on descendants).
2211    #[test]
2212    fn hidden_ancestor_does_not_prune_visible_descendants() {
2213        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
2214            x,
2215            y,
2216            width: w,
2217            height: h,
2218        };
2219        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2220            sticky_edges: None,
2221            border_box: ui_rect(x, y, w, h),
2222            padding_box: ui_rect(x, y, w, h),
2223            content_box: ui_rect(x, y, w, h),
2224            children_box: ui_rect(x, y, w, h),
2225        };
2226
2227        // hidden ancestor (with a background) → visible child (with a background)
2228        let mut hidden_root = LayoutNode::new(Style::default());
2229        hidden_root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 100.0, 100.0));
2230        let mut visible_child = LayoutNode::new(Style::default());
2231        visible_child.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 50.0, 50.0));
2232        hidden_root.children = vec![LayoutChild::Node(Box::new(visible_child))];
2233
2234        let hidden_info = hidden_container_info(vec![visible_container_info(Vec::new())]);
2235
2236        let mut commands = Vec::new();
2237        generate_draw_commands(&mut commands, &hidden_root, &hidden_info, (100.0, 100.0));
2238
2239        // The visible descendant still generates commands.
2240        assert!(
2241            !commands.is_empty(),
2242            "visible descendants of a hidden ancestor must still draw"
2243        );
2244        assert!(count_balanced(&commands));
2245    }
2246
2247    /// A fully hidden subtree (every node computed hidden) paints nothing:
2248    /// no backgrounds, borders, or text.
2249    #[test]
2250    fn fully_hidden_subtree_paints_nothing() {
2251        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
2252            x,
2253            y,
2254            width: w,
2255            height: h,
2256        };
2257        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2258            sticky_edges: None,
2259            border_box: ui_rect(x, y, w, h),
2260            padding_box: ui_rect(x, y, w, h),
2261            content_box: ui_rect(x, y, w, h),
2262            children_box: ui_rect(x, y, w, h),
2263        };
2264
2265        let mut hidden_root = LayoutNode::new(Style::default());
2266        hidden_root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 100.0, 100.0));
2267        let mut hidden_child = LayoutNode::new(Style::default());
2268        hidden_child.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 50.0, 50.0));
2269        hidden_root.children = vec![LayoutChild::Node(Box::new(hidden_child))];
2270
2271        let hidden_info = hidden_container_info(vec![hidden_container_info(Vec::new())]);
2272
2273        let mut commands = Vec::new();
2274        generate_draw_commands(&mut commands, &hidden_root, &hidden_info, (100.0, 100.0));
2275
2276        assert!(
2277            commands.is_empty(),
2278            "a fully hidden subtree must not paint, got {commands:?}"
2279        );
2280    }
2281
2282    /// Collect the sequence of scroll-related transforms in `commands` as
2283    /// `(translate_x, translate_y)` tuples, in order.
2284    fn scroll_translates(commands: &[DrawCommand]) -> Vec<(f32, f32)> {
2285        let mut tx: Vec<f32> = Vec::new();
2286        let mut ty: Vec<f32> = Vec::new();
2287        let mut out = Vec::new();
2288        for cmd in commands {
2289            match cmd {
2290                DrawCommand::PushTransform { transform } => {
2291                    tx.push(transform.apply(0.0, 0.0).0);
2292                    ty.push(transform.apply(0.0, 0.0).1);
2293                    out.push((transform.apply(0.0, 0.0).0, transform.apply(0.0, 0.0).1));
2294                }
2295                DrawCommand::PopTransform => {
2296                    if let (Some(x), Some(y)) = (tx.pop(), ty.pop()) {
2297                        out.push((-x, -y));
2298                    }
2299                }
2300                _ => {}
2301            }
2302        }
2303        out
2304    }
2305
2306    #[test]
2307    fn fixed_node_cancels_inherited_scroll_offset() {
2308        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
2309            x,
2310            y,
2311            width: w,
2312            height: h,
2313        };
2314        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2315            sticky_edges: None,
2316            border_box: ui_rect(x, y, w, h),
2317            padding_box: ui_rect(x, y, w, h),
2318            content_box: ui_rect(x, y, w, h),
2319            children_box: ui_rect(x, y, w, h),
2320        };
2321
2322        // A scrollable ancestor (scrolled by 50px) containing a fixed child.
2323        let scroller_style = Style::default();
2324        let mut scroller = LayoutNode::new(scroller_style);
2325        scroller.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 100.0, 100.0));
2326
2327        let mut fixed_style = Style::default();
2328        fixed_style.position.kind = Position::Fixed;
2329        let fixed = LayoutNode::new(fixed_style);
2330        scroller.children = vec![LayoutChild::Node(Box::new(fixed))];
2331
2332        let scroller_info = mk_info_node(
2333            NodeKind::Container {
2334                scroll_x: true,
2335                scroll_y: true,
2336                scroll_offset_x: 0.0,
2337                scroll_offset_y: 50.0,
2338                style: ContainerStyle::default(),
2339                role: ContainerRole::Normal,
2340            },
2341            vec![mk_info_node(
2342                NodeKind::Container {
2343                    scroll_x: false,
2344                    scroll_y: false,
2345                    scroll_offset_x: 0.0,
2346                    scroll_offset_y: 0.0,
2347                    style: ContainerStyle::default(),
2348                    role: ContainerRole::Normal,
2349                },
2350                Vec::new(),
2351            )],
2352        );
2353
2354        let mut commands = Vec::new();
2355        generate_draw_commands(&mut commands, &scroller, &scroller_info, (100.0, 100.0));
2356        assert!(count_balanced(&commands));
2357
2358        // The fixed child must push a transform cancelling the ancestor's
2359        // 50px scroll: `(-0, +50)` cancels the inherited `(0, -50)`.
2360        let translates = scroll_translates(&commands);
2361        assert!(
2362            translates.contains(&(0.0, 50.0)),
2363            "expected a scroll-cancelling transform, got {translates:?}"
2364        );
2365    }
2366
2367    #[test]
2368    fn positive_z_index_child_paints_after_later_normal_sibling() {
2369        let box_model = |x: f32| ui_layout::BoxModel {
2370            sticky_edges: None,
2371            border_box: ui_rect(x, 0.0, 50.0, 50.0),
2372            padding_box: ui_rect(x, 0.0, 50.0, 50.0),
2373            content_box: ui_rect(x, 0.0, 50.0, 50.0),
2374            children_box: ui_rect(x, 0.0, 50.0, 50.0),
2375        };
2376        let mut root = LayoutNode::new(Style::default());
2377        root.layout_box = ui_layout::LayoutBox::BlockBox(box_model(0.0));
2378        let mut front = LayoutNode::new(Style::default());
2379        front.layout_box = ui_layout::LayoutBox::BlockBox(box_model(0.0));
2380        let mut normal = LayoutNode::new(Style::default());
2381        normal.layout_box = ui_layout::LayoutBox::BlockBox(box_model(0.0));
2382        root.children = vec![
2383            LayoutChild::Node(Box::new(front)),
2384            LayoutChild::Node(Box::new(normal)),
2385        ];
2386
2387        let child_info = |color, z_index| {
2388            mk_info_node(
2389                NodeKind::Container {
2390                    scroll_x: false,
2391                    scroll_y: false,
2392                    scroll_offset_x: 0.0,
2393                    scroll_offset_y: 0.0,
2394                    style: ContainerStyle {
2395                        background: Background {
2396                            color: Some(color),
2397                            layers: Vec::new(),
2398                        },
2399                        z_index,
2400                        ..ContainerStyle::default()
2401                    },
2402                    role: ContainerRole::Normal,
2403                },
2404                Vec::new(),
2405            )
2406        };
2407        let root_info = mk_info_node(
2408            NodeKind::Container {
2409                scroll_x: false,
2410                scroll_y: false,
2411                scroll_offset_x: 0.0,
2412                scroll_offset_y: 0.0,
2413                style: ContainerStyle::default(),
2414                role: ContainerRole::Normal,
2415            },
2416            vec![
2417                child_info(Color(255, 0, 0, 255), Some(10)),
2418                child_info(Color(0, 0, 255, 255), None),
2419            ],
2420        );
2421
2422        let mut commands = Vec::new();
2423        generate_draw_commands(&mut commands, &root, &root_info, (100.0, 100.0));
2424        let colors: Vec<_> = commands
2425            .iter()
2426            .filter_map(|command| match command {
2427                DrawCommand::Fill {
2428                    paint:
2429                        Paint {
2430                            brush: Brush::Solid(color),
2431                            ..
2432                        },
2433                    ..
2434                } => Some(*color),
2435                _ => None,
2436            })
2437            .collect();
2438        assert_eq!(
2439            colors,
2440            vec![
2441                Color(255, 0, 0, 255),
2442                Color(0, 0, 255, 255),
2443                Color(255, 0, 0, 255)
2444            ]
2445        );
2446        assert!(count_balanced(&commands));
2447    }
2448
2449    fn sticky_viewport(scroll: (f32, f32)) -> StickyViewport {
2450        StickyViewport {
2451            top_left: (-scroll.0, scroll.1),
2452            size: (800.0, 500.0),
2453        }
2454    }
2455
2456    fn sticky_edges(top: Option<f32>, bottom: Option<f32>) -> EdgeOption {
2457        EdgeOption {
2458            left: None,
2459            top,
2460            right: None,
2461            bottom,
2462        }
2463    }
2464
2465    #[test]
2466    fn test_sticky_offset_top_sticks_to_viewport_top() {
2467        // Natural y=600, viewport scrolled 600px: the box's top edge is pinned
2468        // at the viewport top + 10.
2469        let (dx, dy) = sticky_offset(
2470            &sticky_edges(Some(10.0), None),
2471            &ui_rect(0.0, 600.0, 100.0, 50.0),
2472            sticky_viewport((0.0, 600.0)),
2473            (800.0, 2000.0),
2474        );
2475        assert_eq!((dx, dy), (0.0, 10.0));
2476    }
2477
2478    #[test]
2479    fn test_sticky_offset_bottom_pushes_up() {
2480        // Natural bottom (1000) is below the viewport bottom minus the inset
2481        // (490): the box is pulled up by 510.
2482        let (dx, dy) = sticky_offset(
2483            &sticky_edges(None, Some(10.0)),
2484            &ui_rect(0.0, 900.0, 100.0, 100.0),
2485            sticky_viewport((0.0, 0.0)),
2486            (800.0, 2000.0),
2487        );
2488        assert_eq!((dx, dy), (0.0, -510.0));
2489    }
2490
2491    #[test]
2492    fn test_sticky_offset_no_movement_when_in_view() {
2493        let (dx, dy) = sticky_offset(
2494            &sticky_edges(Some(10.0), None),
2495            &ui_rect(0.0, 50.0, 100.0, 50.0),
2496            sticky_viewport((0.0, 0.0)),
2497            (800.0, 2000.0),
2498        );
2499        assert_eq!((dx, dy), (0.0, 0.0));
2500    }
2501
2502    #[test]
2503    fn test_sticky_offset_containing_block_hi_clamp() {
2504        // A box taller than the visible area near the container end must not
2505        // be pushed past the containing block bottom (natural bottom is
2506        // already at 1300, the container end).
2507        let (dx, dy) = sticky_offset(
2508            &sticky_edges(Some(10.0), None),
2509            &ui_rect(0.0, 900.0, 100.0, 400.0),
2510            sticky_viewport((0.0, 900.0)),
2511            (800.0, 1300.0),
2512        );
2513        assert_eq!((dx, dy), (0.0, 0.0));
2514    }
2515
2516    #[test]
2517    fn test_sticky_offset_inset_fit_ignores_end_edge_without_room() {
2518        // Both insets set but the sticky view rectangle is shorter than the
2519        // box: the bottom inset is ignored, the box sticks to its top edge.
2520        let (dx, dy) = sticky_offset(
2521            &sticky_edges(Some(10.0), Some(10.0)),
2522            &ui_rect(0.0, 100.0, 100.0, 500.0),
2523            sticky_viewport((0.0, 0.0)),
2524            (800.0, 2000.0),
2525        );
2526        assert_eq!((dx, dy), (0.0, 0.0));
2527    }
2528
2529    #[test]
2530    fn test_sticky_offset_horizontal_right_pushes_left() {
2531        // Box spans [850, 950], off-screen right of the 800-wide viewport; a
2532        // right inset of 10 pulls it left so its right edge sits at 790.
2533        let edges = EdgeOption {
2534            left: None,
2535            top: None,
2536            right: Some(10.0),
2537            bottom: None,
2538        };
2539        let (dx, dy) = sticky_offset(
2540            &edges,
2541            &ui_rect(850.0, 0.0, 100.0, 50.0),
2542            sticky_viewport((0.0, 0.0)),
2543            (2000.0, 2000.0),
2544        );
2545        assert_eq!((dx, dy), (-160.0, 0.0));
2546    }
2547
2548    #[test]
2549    fn sticky_node_pushes_sticky_offset_transform() {
2550        // Root acts as the page scrollport (scrolled 600px). A sticky child at
2551        // natural y=600 with top:10 must push a translate(0, 10).
2552        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
2553            x,
2554            y,
2555            width: w,
2556            height: h,
2557        };
2558        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2559            sticky_edges: None,
2560            border_box: ui_rect(x, y, w, h),
2561            padding_box: ui_rect(x, y, w, h),
2562            content_box: ui_rect(x, y, w, h),
2563            children_box: ui_rect(x, y, w, h),
2564        };
2565
2566        let root_style = Style::default();
2567        let mut root = LayoutNode::new(root_style);
2568        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 800.0, 2000.0));
2569
2570        let mut sticky_style = Style::default();
2571        sticky_style.position.kind = Position::Sticky;
2572        let mut sticky = LayoutNode::new(sticky_style);
2573        let mut sticky_bm = mk_box(0.0, 600.0, 800.0, 100.0);
2574        sticky_bm.sticky_edges = Some(sticky_edges(Some(10.0), None));
2575        sticky.layout_box = ui_layout::LayoutBox::BlockBox(sticky_bm);
2576        root.children = vec![LayoutChild::Node(Box::new(sticky))];
2577
2578        let root_info = mk_info_node(
2579            NodeKind::Container {
2580                scroll_x: false,
2581                scroll_y: false,
2582                scroll_offset_x: 0.0,
2583                scroll_offset_y: 600.0,
2584                style: ContainerStyle::default(),
2585                role: ContainerRole::Normal,
2586            },
2587            vec![mk_info_node(
2588                NodeKind::Container {
2589                    scroll_x: false,
2590                    scroll_y: false,
2591                    scroll_offset_x: 0.0,
2592                    scroll_offset_y: 0.0,
2593                    style: ContainerStyle::default(),
2594                    role: ContainerRole::Normal,
2595                },
2596                Vec::new(),
2597            )],
2598        );
2599
2600        let mut commands = Vec::new();
2601        generate_draw_commands(&mut commands, &root, &root_info, (800.0, 500.0));
2602        assert!(count_balanced(&commands));
2603
2604        let translates = scroll_translates(&commands);
2605        assert!(
2606            translates.contains(&(0.0, 10.0)),
2607            "expected a sticky top offset, got {translates:?}"
2608        );
2609    }
2610
2611    #[test]
2612    fn sticky_node_bottom_pushes_up() {
2613        // Same setup but bottom:10, unscrolled, natural y=900: pulled up by 510.
2614        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
2615            x,
2616            y,
2617            width: w,
2618            height: h,
2619        };
2620        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2621            sticky_edges: None,
2622            border_box: ui_rect(x, y, w, h),
2623            padding_box: ui_rect(x, y, w, h),
2624            content_box: ui_rect(x, y, w, h),
2625            children_box: ui_rect(x, y, w, h),
2626        };
2627
2628        let root_style = Style::default();
2629        let mut root = LayoutNode::new(root_style);
2630        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 800.0, 2000.0));
2631
2632        let mut sticky_style = Style::default();
2633        sticky_style.position.kind = Position::Sticky;
2634        let mut sticky = LayoutNode::new(sticky_style);
2635        let mut sticky_bm = mk_box(0.0, 900.0, 800.0, 100.0);
2636        sticky_bm.sticky_edges = Some(sticky_edges(None, Some(10.0)));
2637        sticky.layout_box = ui_layout::LayoutBox::BlockBox(sticky_bm);
2638        root.children = vec![LayoutChild::Node(Box::new(sticky))];
2639
2640        let root_info = mk_info_node(
2641            NodeKind::Container {
2642                scroll_x: false,
2643                scroll_y: false,
2644                scroll_offset_x: 0.0,
2645                scroll_offset_y: 0.0,
2646                style: ContainerStyle::default(),
2647                role: ContainerRole::Normal,
2648            },
2649            vec![mk_info_node(
2650                NodeKind::Container {
2651                    scroll_x: false,
2652                    scroll_y: false,
2653                    scroll_offset_x: 0.0,
2654                    scroll_offset_y: 0.0,
2655                    style: ContainerStyle::default(),
2656                    role: ContainerRole::Normal,
2657                },
2658                Vec::new(),
2659            )],
2660        );
2661
2662        let mut commands = Vec::new();
2663        generate_draw_commands(&mut commands, &root, &root_info, (800.0, 500.0));
2664        assert!(count_balanced(&commands));
2665
2666        let translates = scroll_translates(&commands);
2667        assert!(
2668            translates.contains(&(0.0, -510.0)),
2669            "expected a sticky bottom offset, got {translates:?}"
2670        );
2671    }
2672
2673    /// A custom node that reports an open popup with a single fill command.
2674    #[derive(Debug)]
2675    struct PopupNode {
2676        open: bool,
2677        box_height: f32,
2678        popup_height: f32,
2679    }
2680
2681    impl CustomNode for PopupNode {
2682        fn draw_sized(
2683            &self,
2684            _cmd_buf: &mut Vec<DrawCommand>,
2685            _text_style: &TextStyle,
2686            _text_flow_style: &TextFlowStyle,
2687            _style: &Style,
2688            _size: ContentSize,
2689        ) {
2690        }
2691
2692        fn intrinsic_size(&self) -> ContentSize {
2693            ContentSize {
2694                width: 120.0,
2695                height: self.box_height,
2696            }
2697        }
2698
2699        fn popup(
2700            &self,
2701            _text_style: &TextStyle,
2702            _text_flow_style: &TextFlowStyle,
2703        ) -> Option<Popup> {
2704            self.open.then(|| Popup {
2705                rect: crate::engine::renderer_model::Rect {
2706                    x: 0.0,
2707                    y: self.box_height,
2708                    width: 120.0,
2709                    height: self.popup_height,
2710                },
2711                commands: vec![DrawCommand::Fill {
2712                    path: rect_path(0.0, self.box_height, 120.0, self.popup_height),
2713                    rule: FillRule::NonZero,
2714                    paint: Paint {
2715                        brush: Brush::Solid(Color(255, 0, 0, 255)),
2716                        opacity: 1.0,
2717                    },
2718                }],
2719            })
2720        }
2721    }
2722
2723    #[test]
2724    fn popup_is_emitted_as_top_layer_at_node_position() {
2725        // A select-like node with an open popup nested inside a scrollable
2726        // container: node content origin (10+5, 20+0) = (15, 20), inherited
2727        // scroll offset 50 → popup translate (15, 20-50) = (15, -30).
2728        let node: Arc<dyn CustomNode> = Arc::new(PopupNode {
2729            open: true,
2730            box_height: 28.0,
2731            popup_height: 84.0,
2732        });
2733
2734        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2735            sticky_edges: None,
2736            border_box: ui_rect(x, y, w, h),
2737            padding_box: ui_rect(x, y, w, h),
2738            content_box: ui_rect(x, y, w, h),
2739            children_box: ui_rect(x, y, w, h),
2740        };
2741
2742        let mut root = LayoutNode::new(Style::default());
2743        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 200.0, 200.0));
2744
2745        let mut scroller = LayoutNode::new(Style::default());
2746        scroller.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(10.0, 20.0, 160.0, 100.0));
2747
2748        let mut custom = LayoutNode::new(Style::default());
2749        custom.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(5.0, 0.0, 120.0, 28.0));
2750        scroller.children = vec![LayoutChild::Node(Box::new(custom))];
2751        root.children = vec![LayoutChild::Node(Box::new(scroller))];
2752
2753        let root_info = mk_info_node(
2754            NodeKind::Container {
2755                scroll_x: false,
2756                scroll_y: false,
2757                scroll_offset_x: 0.0,
2758                scroll_offset_y: 0.0,
2759                style: ContainerStyle::default(),
2760                role: ContainerRole::Normal,
2761            },
2762            vec![mk_info_node(
2763                NodeKind::Container {
2764                    scroll_x: false,
2765                    scroll_y: true,
2766                    scroll_offset_x: 0.0,
2767                    scroll_offset_y: 50.0,
2768                    style: ContainerStyle::default(),
2769                    role: ContainerRole::Normal,
2770                },
2771                vec![mk_info_node(
2772                    NodeKind::Custom {
2773                        node,
2774                        scroll_x: false,
2775                        scroll_y: false,
2776                        scroll_offset_x: 0.0,
2777                        scroll_offset_y: 0.0,
2778                        style: ContainerStyle::default(),
2779                        layout_style: Style::default(),
2780                        text_style: TextStyle::default(),
2781                        text_flow_style: TextFlowStyle::default(),
2782                    },
2783                    Vec::new(),
2784                )],
2785            )],
2786        );
2787
2788        let mut commands = Vec::new();
2789        generate_draw_commands(&mut commands, &root, &root_info, (200.0, 200.0));
2790        assert!(count_balanced(&commands));
2791
2792        // The default styles paint nothing, so the only fill in the buffer is
2793        // the popup, emitted after every clip/transform.
2794        let fills = commands
2795            .iter()
2796            .filter(|cmd| matches!(cmd, DrawCommand::Fill { .. }))
2797            .count();
2798        assert_eq!(fills, 1, "popup must be the only painted box");
2799
2800        match &commands[commands.len() - 3..] {
2801            [
2802                DrawCommand::PushTransform { transform },
2803                DrawCommand::Fill { .. },
2804                DrawCommand::PopTransform,
2805            ] => {
2806                assert_eq!(transform.apply(0.0, 0.0), (15.0, -30.0));
2807            }
2808            _ => panic!("expected trailing popup PushTransform/Fill/PopTransform"),
2809        }
2810    }
2811
2812    #[test]
2813    fn closed_popup_is_not_emitted() {
2814        let node: Arc<dyn CustomNode> = Arc::new(PopupNode {
2815            open: false,
2816            box_height: 28.0,
2817            popup_height: 84.0,
2818        });
2819
2820        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
2821            sticky_edges: None,
2822            border_box: ui_rect(x, y, w, h),
2823            padding_box: ui_rect(x, y, w, h),
2824            content_box: ui_rect(x, y, w, h),
2825            children_box: ui_rect(x, y, w, h),
2826        };
2827
2828        let mut root = LayoutNode::new(Style::default());
2829        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 200.0, 200.0));
2830        let mut custom = LayoutNode::new(Style::default());
2831        custom.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 120.0, 28.0));
2832        root.children = vec![LayoutChild::Node(Box::new(custom))];
2833
2834        let root_info = mk_info_node(
2835            NodeKind::Container {
2836                scroll_x: false,
2837                scroll_y: false,
2838                scroll_offset_x: 0.0,
2839                scroll_offset_y: 0.0,
2840                style: ContainerStyle::default(),
2841                role: ContainerRole::Normal,
2842            },
2843            vec![mk_info_node(
2844                NodeKind::Custom {
2845                    node,
2846                    scroll_x: false,
2847                    scroll_y: false,
2848                    scroll_offset_x: 0.0,
2849                    scroll_offset_y: 0.0,
2850                    style: ContainerStyle::default(),
2851                    layout_style: Style::default(),
2852                    text_style: TextStyle::default(),
2853                    text_flow_style: TextFlowStyle::default(),
2854                },
2855                Vec::new(),
2856            )],
2857        );
2858
2859        let mut commands = Vec::new();
2860        generate_draw_commands(&mut commands, &root, &root_info, (200.0, 200.0));
2861        assert!(count_balanced(&commands));
2862        assert_eq!(
2863            commands
2864                .iter()
2865                .filter(|cmd| matches!(cmd, DrawCommand::Fill { .. }))
2866                .count(),
2867            0
2868        );
2869    }
2870
2871    #[test]
2872    fn canvas_background_inherits_from_body_when_html_has_none() {
2873        // html has no background, body has red → canvas paints red.
2874        let body_info = mk_info_node(
2875            NodeKind::Container {
2876                scroll_x: false,
2877                scroll_y: false,
2878                scroll_offset_x: 0.0,
2879                scroll_offset_y: 0.0,
2880                style: ContainerStyle {
2881                    background: Background {
2882                        color: Some(Color(255, 0, 0, 255)),
2883                        layers: Vec::new(),
2884                    },
2885                    tag: Some("body".to_string()),
2886                    ..ContainerStyle::default()
2887                },
2888                role: ContainerRole::Normal,
2889            },
2890            Vec::new(),
2891        );
2892        let html_info = mk_info_node(
2893            NodeKind::Container {
2894                scroll_x: false,
2895                scroll_y: false,
2896                scroll_offset_x: 0.0,
2897                scroll_offset_y: 0.0,
2898                style: ContainerStyle {
2899                    background: Background::default(),
2900                    tag: Some("html".to_string()),
2901                    ..ContainerStyle::default()
2902                },
2903                role: ContainerRole::Normal,
2904            },
2905            vec![body_info],
2906        );
2907        let root_info = mk_info_node(
2908            NodeKind::Container {
2909                scroll_x: false,
2910                scroll_y: false,
2911                scroll_offset_x: 0.0,
2912                scroll_offset_y: 0.0,
2913                style: ContainerStyle::default(),
2914                role: ContainerRole::Normal,
2915            },
2916            vec![html_info],
2917        );
2918
2919        let mut commands = Vec::new();
2920        generate_draw_commands(
2921            &mut commands,
2922            &LayoutNode::new(Style::default()),
2923            &root_info,
2924            (100.0, 100.0),
2925        );
2926
2927        let fills: Vec<_> = commands
2928            .iter()
2929            .filter_map(|cmd| match cmd {
2930                DrawCommand::Fill {
2931                    paint:
2932                        Paint {
2933                            brush: Brush::Solid(color),
2934                            ..
2935                        },
2936                    ..
2937                } => Some(*color),
2938                _ => None,
2939            })
2940            .collect();
2941
2942        // Canvas paints body's red, then html paints (none), then body paints red again.
2943        assert!(
2944            fills.contains(&Color(255, 0, 0, 255)),
2945            "canvas should paint body's red background, got {fills:?}"
2946        );
2947    }
2948
2949    #[test]
2950    fn canvas_background_uses_html_when_both_specified() {
2951        // html has green, body has red → canvas paints html's green (html wins).
2952        let body_info = mk_info_node(
2953            NodeKind::Container {
2954                scroll_x: false,
2955                scroll_y: false,
2956                scroll_offset_x: 0.0,
2957                scroll_offset_y: 0.0,
2958                style: ContainerStyle {
2959                    background: Background {
2960                        color: Some(Color(255, 0, 0, 255)),
2961                        layers: Vec::new(),
2962                    },
2963                    tag: Some("body".to_string()),
2964                    ..ContainerStyle::default()
2965                },
2966                role: ContainerRole::Normal,
2967            },
2968            Vec::new(),
2969        );
2970        let html_info = mk_info_node(
2971            NodeKind::Container {
2972                scroll_x: false,
2973                scroll_y: false,
2974                scroll_offset_x: 0.0,
2975                scroll_offset_y: 0.0,
2976                style: ContainerStyle {
2977                    background: Background {
2978                        color: Some(Color(0, 255, 0, 255)),
2979                        layers: Vec::new(),
2980                    },
2981                    tag: Some("html".to_string()),
2982                    ..ContainerStyle::default()
2983                },
2984                role: ContainerRole::Normal,
2985            },
2986            vec![body_info],
2987        );
2988        let root_info = mk_info_node(
2989            NodeKind::Container {
2990                scroll_x: false,
2991                scroll_y: false,
2992                scroll_offset_x: 0.0,
2993                scroll_offset_y: 0.0,
2994                style: ContainerStyle::default(),
2995                role: ContainerRole::Normal,
2996            },
2997            vec![html_info],
2998        );
2999
3000        let mut commands = Vec::new();
3001        generate_draw_commands(
3002            &mut commands,
3003            &LayoutNode::new(Style::default()),
3004            &root_info,
3005            (100.0, 100.0),
3006        );
3007
3008        let fills: Vec<_> = commands
3009            .iter()
3010            .filter_map(|cmd| match cmd {
3011                DrawCommand::Fill {
3012                    paint:
3013                        Paint {
3014                            brush: Brush::Solid(color),
3015                            ..
3016                        },
3017                    ..
3018                } => Some(*color),
3019                _ => None,
3020            })
3021            .collect();
3022
3023        assert!(
3024            fills.contains(&Color(0, 255, 0, 255)),
3025            "canvas should paint html's green background, got {fills:?}"
3026        );
3027        assert!(
3028            fills[0] == Color(0, 255, 0, 255),
3029            "first canvas fill should be html's green background"
3030        );
3031    }
3032
3033    #[test]
3034    fn canvas_background_transparent_when_neither_html_nor_body_has_background() {
3035        let body_info = mk_info_node(
3036            NodeKind::Container {
3037                scroll_x: false,
3038                scroll_y: false,
3039                scroll_offset_x: 0.0,
3040                scroll_offset_y: 0.0,
3041                style: ContainerStyle {
3042                    tag: Some("body".to_string()),
3043                    ..ContainerStyle::default()
3044                },
3045                role: ContainerRole::Normal,
3046            },
3047            Vec::new(),
3048        );
3049        let html_info = mk_info_node(
3050            NodeKind::Container {
3051                scroll_x: false,
3052                scroll_y: false,
3053                scroll_offset_x: 0.0,
3054                scroll_offset_y: 0.0,
3055                style: ContainerStyle {
3056                    tag: Some("html".to_string()),
3057                    ..ContainerStyle::default()
3058                },
3059                role: ContainerRole::Normal,
3060            },
3061            vec![body_info],
3062        );
3063        let root_info = mk_info_node(
3064            NodeKind::Container {
3065                scroll_x: false,
3066                scroll_y: false,
3067                scroll_offset_x: 0.0,
3068                scroll_offset_y: 0.0,
3069                style: ContainerStyle::default(),
3070                role: ContainerRole::Normal,
3071            },
3072            vec![html_info],
3073        );
3074
3075        let mut commands = Vec::new();
3076        generate_draw_commands(
3077            &mut commands,
3078            &LayoutNode::new(Style::default()),
3079            &root_info,
3080            (100.0, 100.0),
3081        );
3082
3083        let fill_count = commands
3084            .iter()
3085            .filter(|cmd| matches!(cmd, DrawCommand::Fill { .. }))
3086            .count();
3087        assert_eq!(
3088            fill_count, 0,
3089            "no canvas background fill when neither html nor body has background"
3090        );
3091    }
3092
3093    #[test]
3094    fn canvas_background_inherits_body_gradient_when_html_has_none() {
3095        use crate::engine::layouter::types::{Gradient, GradientKind, GradientSpread};
3096
3097        let gradient = Gradient {
3098            kind: GradientKind::Linear { angle: 0.0 },
3099            stops: Vec::new(),
3100            spread: GradientSpread::Pad,
3101        };
3102        let body_info = mk_info_node(
3103            NodeKind::Container {
3104                scroll_x: false,
3105                scroll_y: false,
3106                scroll_offset_x: 0.0,
3107                scroll_offset_y: 0.0,
3108                style: ContainerStyle {
3109                    background: Background {
3110                        color: None,
3111                        layers: vec![crate::engine::layouter::types::BackgroundLayer {
3112                            image: Some(crate::engine::layouter::types::BackgroundImage::Gradient(
3113                                gradient,
3114                            )),
3115                            position: Default::default(),
3116                            size: Default::default(),
3117                            repeat: Default::default(),
3118                        }],
3119                    },
3120                    tag: Some("body".to_string()),
3121                    ..ContainerStyle::default()
3122                },
3123                role: ContainerRole::Normal,
3124            },
3125            Vec::new(),
3126        );
3127        let html_info = mk_info_node(
3128            NodeKind::Container {
3129                scroll_x: false,
3130                scroll_y: false,
3131                scroll_offset_x: 0.0,
3132                scroll_offset_y: 0.0,
3133                style: ContainerStyle {
3134                    background: Background::default(),
3135                    tag: Some("html".to_string()),
3136                    ..ContainerStyle::default()
3137                },
3138                role: ContainerRole::Normal,
3139            },
3140            vec![body_info],
3141        );
3142        let root_info = mk_info_node(
3143            NodeKind::Container {
3144                scroll_x: false,
3145                scroll_y: false,
3146                scroll_offset_x: 0.0,
3147                scroll_offset_y: 0.0,
3148                style: ContainerStyle::default(),
3149                role: ContainerRole::Normal,
3150            },
3151            vec![html_info],
3152        );
3153
3154        let mut commands = Vec::new();
3155        generate_draw_commands(
3156            &mut commands,
3157            &LayoutNode::new(Style::default()),
3158            &root_info,
3159            (100.0, 100.0),
3160        );
3161
3162        let gradient_count = commands
3163            .iter()
3164            .filter(|cmd| {
3165                matches!(
3166                    cmd,
3167                    DrawCommand::Fill {
3168                        paint: Paint {
3169                            brush: Brush::Gradient(_),
3170                            ..
3171                        },
3172                        ..
3173                    }
3174                )
3175            })
3176            .count();
3177
3178        assert!(
3179            gradient_count > 0,
3180            "canvas should paint body's gradient layer"
3181        );
3182    }
3183}