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::{BoxModel, EdgeOption, LayoutChild, LayoutNode, Position, Rect};
5
6use crate::engine::layouter::text_layouter::TextFlowLayouter;
7use crate::engine::layouter::types::{
8    Background, BackgroundDimension, BackgroundOffset, BackgroundPositionAxis, BackgroundRepeat,
9    BackgroundSize, BorderRadius, ClipPath, Color, ContainerStyle, CornerRadius, InfoNode,
10    NodeKind, TextDecoration, TextFlowStyle, TextStyle, Visibility,
11};
12use crate::engine::renderer_model::draw_command::{Brush, DrawCommand, FillRule, Paint};
13use crate::engine::renderer_model::geom::AffineTransform;
14use crate::engine::renderer_model::path::{
15    Path, append_quarter_ellipse, clamp_radii, ellipse_path, offset_path, polygon_path, rect_path,
16    rounded_rect_path,
17};
18use crate::engine::ui::ContentSize;
19
20/// Per-box-model push state for balanced pop generation.
21#[derive(Default, Clone, Copy)]
22struct BoxPushState {
23    border: bool,
24    clip_path: bool,
25    overflow_clip: bool,
26    content: bool,
27    scroll: bool,
28}
29
30// --------------------------------
31// Helpers
32// --------------------------------
33
34fn push_transform(cmd_buf: &mut Vec<DrawCommand>, dx: f32, dy: f32) -> bool {
35    if dx != 0.0 || dy != 0.0 {
36        cmd_buf.push(DrawCommand::PushTransform {
37            transform: AffineTransform::translate(dx, dy),
38        });
39        true
40    } else {
41        false
42    }
43}
44
45/// State of the nearest scrollport, used to resolve `position: sticky` offsets.
46///
47/// All coordinates are expressed in the current node's parent-content space
48/// (the same space its `border_box` coordinates live in).
49#[derive(Default, Clone, Copy)]
50struct StickyViewport {
51    /// Top-left corner of the scrollport's visible region, scroll offset already
52    /// applied. The renderer's scroll transform is `translate(scroll_x,
53    /// -scroll_y)`, so in the scrollport's content space this is
54    /// `(padding.left - scroll_x, padding.top + scroll_y)`; the value is rebased
55    /// into each descendant's content space.
56    top_left: (f32, f32),
57    /// Visible (padding-box) size of the nearest scrollport.
58    size: (f32, f32),
59}
60
61/// Whether the node scrolls its own content and thus establishes a scrollport
62/// for its subtree. The document root always does, because the UI layer scrolls
63/// it directly without necessarily setting its scroll flags.
64fn is_scrollport(kind: &NodeKind) -> bool {
65    match kind {
66        NodeKind::Container {
67            scroll_x, scroll_y, ..
68        }
69        | NodeKind::Custom {
70            scroll_x, scroll_y, ..
71        } => *scroll_x || *scroll_y,
72        _ => false,
73    }
74}
75
76/// Compute the sticky translate for a box relative to its nearest scrollport.
77///
78/// `box_rect` is the sticky box's border box (its natural position, in
79/// parent-content space) and `containing` is the parent content-box size — the
80/// containing block the box may not leave. Per CSS-POSITION-3 §3.4 the box is
81/// shifted inward just enough to keep each specified edge inside the
82/// scrollport's "sticky view rectangle" (the visible region, whose top-left is
83/// `viewport.top_left`), while staying within the containing block.
84fn sticky_offset(
85    edges: &EdgeOption,
86    box_rect: &Rect,
87    viewport: StickyViewport,
88    containing: (f32, f32),
89) -> (f32, f32) {
90    let x = box_rect.x;
91    let y = box_rect.y;
92    let w = box_rect.width;
93    let h = box_rect.height;
94
95    let mut dy: f32 = 0.0;
96    let port_top = viewport.top_left.1;
97    let port_bottom = viewport.top_left.1 + viewport.size.1;
98    if let Some(top) = edges.top {
99        dy = dy.max((port_top + top) - y);
100    }
101    if let Some(bottom) = edges.bottom {
102        // CSS-POSITION-3 §3.4: when the sticky view rectangle is shorter than
103        // the box, the end-edge inset is ignored and the box sticks to its
104        // start edge instead.
105        let has_room = match edges.top {
106            Some(top) => (port_bottom - bottom) - (port_top + top) >= h,
107            None => true,
108        };
109        if has_room {
110            dy = dy.min((port_bottom - bottom) - (y + h));
111        }
112    }
113    // Containing-block constraint (parent content box). The bounds are
114    // zero-clamped so an already-overflowing box is never forced to move
115    // (matching Chromium/Firefox "clamp sticky offset bounds by zero").
116    let dy_lo = (-y).min(0.0);
117    let dy_hi = (containing.1 - y - h).max(0.0);
118    dy = dy.max(dy_lo).min(dy_hi);
119
120    let mut dx: f32 = 0.0;
121    let port_left = viewport.top_left.0;
122    let port_right = viewport.top_left.0 + viewport.size.0;
123    if let Some(left) = edges.left {
124        dx = dx.max((port_left + left) - x);
125    }
126    if let Some(right) = edges.right {
127        let has_room = match edges.left {
128            Some(left) => (port_right - right) - (port_left + left) >= w,
129            None => true,
130        };
131        if has_room {
132            dx = dx.min((port_right - right) - (x + w));
133        }
134    }
135    let dx_lo = (-x).min(0.0);
136    let dx_hi = (containing.0 - x - w).max(0.0);
137    dx = dx.max(dx_lo).min(dx_hi);
138
139    (dx, dy)
140}
141
142/// Resolve the four outer corner radii to pixels against the border box.
143///
144/// Horizontal components resolve against the box width, vertical components
145/// against the box height (so `%` works per-axis per CSS).
146fn resolve_outer_radii(radius: &BorderRadius, box_w: f32, box_h: f32) -> [(f32, f32); 4] {
147    let resolve = |c: &CornerRadius| -> (f32, f32) {
148        (
149            c.x.resolve_with(Some(box_w), 0.0, 0.0)
150                .unwrap_or(0.0)
151                .max(0.0),
152            c.y.resolve_with(Some(box_h), 0.0, 0.0)
153                .unwrap_or(0.0)
154                .max(0.0),
155        )
156    };
157    [
158        resolve(&radius.top_left),
159        resolve(&radius.top_right),
160        resolve(&radius.bottom_right),
161        resolve(&radius.bottom_left),
162    ]
163}
164
165/// Compute the inner (padding-box) corner radii: the outer radii reduced by
166/// the two adjacent border widths, per CSS.
167fn inner_radii(outer: [(f32, f32); 4], bl: f32, bt: f32, br: f32, bb: f32) -> [(f32, f32); 4] {
168    [
169        (outer[0].0 - bl, outer[0].1 - bt),
170        (outer[1].0 - br, outer[1].1 - bt),
171        (outer[2].0 - br, outer[2].1 - bb),
172        (outer[3].0 - bl, outer[3].1 - bb),
173    ]
174    .map(|(x, y)| (x.max(0.0), y.max(0.0)))
175}
176
177/// Build the closed path of the top border edge, including the top-left and
178/// top-right corner caps. Coordinates are relative to the border-box origin.
179///
180/// `outer`/`inner` are the four corner radii in CSS order; the inner arcs are
181/// concentric with the outer arcs.
182fn top_border_strip(
183    w: f32,
184    bl: f32,
185    bt: f32,
186    br: f32,
187    outer: [(f32, f32); 4],
188    inner: [(f32, f32); 4],
189) -> Path {
190    let (rtl_x, rtl_y) = outer[0];
191    let (rtr_x, rtr_y) = outer[1];
192    let (itl_x, itl_y) = inner[0];
193    let (itr_x, itr_y) = inner[1];
194    let mut path = Path::new();
195    path.move_to(0.0, rtl_y);
196    append_quarter_ellipse(
197        &mut path,
198        rtl_x,
199        rtl_y,
200        rtl_x,
201        rtl_y,
202        (0.0, rtl_y),
203        (rtl_x, 0.0),
204    );
205    path.line_to(w - rtr_x, 0.0);
206    append_quarter_ellipse(
207        &mut path,
208        w - rtr_x,
209        rtr_y,
210        rtr_x,
211        rtr_y,
212        (w - rtr_x, 0.0),
213        (w, rtr_y),
214    );
215    // Draw the top edge line to the start of the inner top‑right corner.
216    // Use the right border width (`br`) for the outer edge, then transition to the inner radius.
217    path.line_to(w - br, rtr_y);
218    // Inner top‑right corner: connect outer edge to inner edge.
219    // The start point is at the outer edge (`w - br`, `rtr_y`),
220    // and the end point aligns with the inner radius.
221    append_quarter_ellipse(
222        &mut path,
223        w - rtr_x - br,
224        rtr_y - bt,
225        itr_x,
226        itr_y,
227        (w - br, rtr_y),
228        (w - rtr_x - br, bt),
229    );
230    path.line_to(itl_x, bt);
231    append_quarter_ellipse(
232        &mut path,
233        rtl_x - bl,
234        rtl_y - bt,
235        itl_x,
236        itl_y,
237        (itl_x, bt),
238        (bl, rtl_y),
239    );
240    path.close();
241    path
242}
243
244/// Build the closed path of the bottom border edge, including the bottom-left
245/// and bottom-right corner caps.
246fn bottom_border_strip(
247    w: f32,
248    h: f32,
249    bl: f32,
250    bb: f32,
251    br: f32,
252    outer: [(f32, f32); 4],
253    inner: [(f32, f32); 4],
254) -> Path {
255    let (rbl_x, rbl_y) = outer[3];
256    let (rbr_x, rbr_y) = outer[2];
257    let (ibl_x, ibl_y) = inner[3];
258    let (ibr_x, ibr_y) = inner[2];
259    let mut path = Path::new();
260    path.move_to(w, h - rbr_y);
261    append_quarter_ellipse(
262        &mut path,
263        w - rbr_x,
264        h - rbr_y,
265        rbr_x,
266        rbr_y,
267        (w, h - rbr_y),
268        (w - rbr_x, h),
269    );
270    path.line_to(rbl_x, h);
271    append_quarter_ellipse(
272        &mut path,
273        rbl_x,
274        h - rbl_y,
275        rbl_x,
276        rbl_y,
277        (rbl_x, h),
278        (0.0, h - rbl_y),
279    );
280    path.line_to(bl, h - rbl_y);
281    append_quarter_ellipse(
282        &mut path,
283        rbl_x,
284        h - rbl_y,
285        ibl_x,
286        ibl_y,
287        (bl, h - rbl_y),
288        (rbl_x, h - bb),
289    );
290    path.line_to(w - rbr_x, h - bb);
291    append_quarter_ellipse(
292        &mut path,
293        w - rbr_x,
294        h - rbr_y,
295        ibr_x,
296        ibr_y,
297        (w - rbr_x, h - bb),
298        (w - br, h - rbr_y),
299    );
300    path.close();
301    path
302}
303
304/// Draw the four border edges inside the current coordinate system.
305/// Coordinates are relative to the border-box origin when `ox`/`oy` are zero
306/// (the transform case); otherwise they are added to place the border in
307/// absolute space (the inline case, where no transform is pushed).
308fn draw_border(
309    cmd_buf: &mut Vec<DrawCommand>,
310    border_box: &ui_layout::Rect,
311    padding_box: &ui_layout::Rect,
312    style: &ContainerStyle,
313    ox: f32,
314    oy: f32,
315) {
316    let bw_top = (padding_box.y - border_box.y).max(0.0);
317    let bw_bottom =
318        (border_box.y + border_box.height - (padding_box.y + padding_box.height)).max(0.0);
319    let bw_left = (padding_box.x - border_box.x).max(0.0);
320    let bw_right = (border_box.x + border_box.width - (padding_box.x + padding_box.width)).max(0.0);
321
322    let w = border_box.width;
323    let h = border_box.height;
324    let mut outer = resolve_outer_radii(&style.border_radius, w, h);
325    outer = clamp_radii(outer, w, h);
326    let mut inner = inner_radii(outer, bw_left, bw_top, bw_right, bw_bottom);
327    inner = clamp_radii(
328        inner,
329        padding_box.width.max(0.0),
330        padding_box.height.max(0.0),
331    );
332
333    let bc = &style.border_color;
334    let push_fill = |cmd_buf: &mut Vec<DrawCommand>, path: Path, color: Color| {
335        cmd_buf.push(DrawCommand::Fill {
336            path,
337            rule: FillRule::NonZero,
338            paint: Paint {
339                brush: Brush::Solid(color),
340                opacity: 1.0,
341            },
342        });
343    };
344
345    let has_radius = outer.iter().any(|(rx, ry)| *rx > 0.0 || *ry > 0.0);
346    if !has_radius {
347        if bw_top > 0.0 {
348            push_fill(cmd_buf, rect_path(ox, oy, w, bw_top), bc.top);
349        }
350        if bw_bottom > 0.0 {
351            push_fill(
352                cmd_buf,
353                rect_path(ox, oy + h - bw_bottom, w, bw_bottom),
354                bc.bottom,
355            );
356        }
357        if bw_left > 0.0 {
358            push_fill(
359                cmd_buf,
360                rect_path(ox, oy + bw_top, bw_left, h - bw_top - bw_bottom),
361                bc.left,
362            );
363        }
364        if bw_right > 0.0 {
365            push_fill(
366                cmd_buf,
367                rect_path(
368                    ox + w - bw_right,
369                    oy + bw_top,
370                    bw_right,
371                    h - bw_top - bw_bottom,
372                ),
373                bc.right,
374            );
375        }
376        return;
377    }
378
379    if bw_top > 0.0 {
380        push_fill(
381            cmd_buf,
382            offset_path(
383                &top_border_strip(w, bw_left, bw_top, bw_right, outer, inner),
384                ox,
385                oy,
386            ),
387            bc.top,
388        );
389    }
390    if bw_bottom > 0.0 {
391        push_fill(
392            cmd_buf,
393            offset_path(
394                &bottom_border_strip(w, h, bw_left, bw_bottom, bw_right, outer, inner),
395                ox,
396                oy,
397            ),
398            bc.bottom,
399        );
400    }
401    if bw_left > 0.0 {
402        push_fill(
403            cmd_buf,
404            rect_path(ox, oy + outer[0].1, bw_left, h - outer[0].1 - outer[3].1),
405            bc.left,
406        );
407    }
408    if bw_right > 0.0 {
409        push_fill(
410            cmd_buf,
411            rect_path(
412                ox + w - bw_right,
413                oy + outer[1].1,
414                bw_right,
415                h - outer[1].1 - outer[2].1,
416            ),
417            bc.right,
418        );
419    }
420}
421
422/// Draw the background inside the padding box (rounded when a border radius
423/// is present).
424/// Coordinates are relative to the border-box origin when `ox`/`oy` are zero;
425/// otherwise they are added to place the background in absolute space.
426fn draw_background(
427    cmd_buf: &mut Vec<DrawCommand>,
428    border_box: &ui_layout::Rect,
429    padding_box: &ui_layout::Rect,
430    style: &ContainerStyle,
431    ox: f32,
432    oy: f32,
433) {
434    let x = padding_box.x - border_box.x + ox;
435    let y = padding_box.y - border_box.y + oy;
436    let bw_top = (padding_box.y - border_box.y).max(0.0);
437    let bw_bottom =
438        (border_box.y + border_box.height - (padding_box.y + padding_box.height)).max(0.0);
439    let bw_left = (padding_box.x - border_box.x).max(0.0);
440    let bw_right = (border_box.x + border_box.width - (padding_box.x + padding_box.width)).max(0.0);
441
442    let mut outer = resolve_outer_radii(&style.border_radius, border_box.width, border_box.height);
443    outer = clamp_radii(outer, border_box.width, border_box.height);
444    let mut inner = inner_radii(outer, bw_left, bw_top, bw_right, bw_bottom);
445    inner = clamp_radii(
446        inner,
447        padding_box.width.max(0.0),
448        padding_box.height.max(0.0),
449    );
450    // Build the rounded background path
451    let path = rounded_rect_path(
452        x,
453        y,
454        padding_box.width,
455        padding_box.height,
456        inner[0],
457        inner[1],
458        inner[2],
459        inner[3],
460    );
461    match &style.background {
462        Background::Color(c) if c.3 > 0 => {
463            cmd_buf.push(DrawCommand::Fill {
464                path,
465                rule: FillRule::NonZero,
466                paint: Paint {
467                    brush: Brush::Solid(*c),
468                    opacity: 1.0,
469                },
470            });
471        }
472        Background::Gradient(g) => {
473            cmd_buf.push(DrawCommand::Fill {
474                path,
475                rule: FillRule::NonZero,
476                paint: Paint {
477                    brush: Brush::Gradient(g.clone()),
478                    opacity: 1.0,
479                },
480            });
481        }
482        Background::Image { image, color, .. } => {
483            if color.3 > 0 {
484                cmd_buf.push(DrawCommand::Fill {
485                    path: path.clone(),
486                    rule: FillRule::NonZero,
487                    paint: Paint {
488                        brush: Brush::Solid(*color),
489                        opacity: 1.0,
490                    },
491                });
492            }
493            if let Some(image) = image {
494                let (image_width, image_height) = resolve_background_image_size(
495                    image.width() as f32,
496                    image.height() as f32,
497                    padding_box.width,
498                    padding_box.height,
499                    style.background_size,
500                );
501                if image_width > 0.0 && image_height > 0.0 {
502                    let image_x = resolve_background_axis(
503                        padding_box.width,
504                        image_width,
505                        style.background_position.x,
506                    );
507                    let image_y = resolve_background_axis(
508                        padding_box.height,
509                        image_height,
510                        style.background_position.y,
511                    );
512                    let repeat_x = matches!(
513                        style.background_repeat,
514                        BackgroundRepeat::Repeat | BackgroundRepeat::RepeatX
515                    );
516                    let repeat_y = matches!(
517                        style.background_repeat,
518                        BackgroundRepeat::Repeat | BackgroundRepeat::RepeatY
519                    );
520                    let xs = background_tile_positions(
521                        image_x,
522                        image_width,
523                        padding_box.width,
524                        repeat_x,
525                    );
526                    let ys = background_tile_positions(
527                        image_y,
528                        image_height,
529                        padding_box.height,
530                        repeat_y,
531                    );
532                    cmd_buf.push(DrawCommand::PushClip {
533                        path,
534                        rule: FillRule::NonZero,
535                    });
536                    for tile_y in ys {
537                        for tile_x in &xs {
538                            cmd_buf.push(DrawCommand::Fill {
539                                path: rect_path(x + *tile_x, y + tile_y, image_width, image_height),
540                                rule: FillRule::NonZero,
541                                paint: Paint {
542                                    brush: Brush::Image(image.clone()),
543                                    opacity: 1.0,
544                                },
545                            });
546                        }
547                    }
548                    cmd_buf.push(DrawCommand::PopClip);
549                }
550            }
551        }
552        _ => {}
553    }
554}
555
556fn resolve_background_dimension(dimension: BackgroundDimension, area: f32) -> Option<f32> {
557    match dimension {
558        BackgroundDimension::Auto => None,
559        BackgroundDimension::Length(value) => Some(value.max(0.0)),
560        BackgroundDimension::Percent(value) => Some((area * value).max(0.0)),
561    }
562}
563
564fn resolve_background_image_size(
565    intrinsic_width: f32,
566    intrinsic_height: f32,
567    area_width: f32,
568    area_height: f32,
569    size: BackgroundSize,
570) -> (f32, f32) {
571    if intrinsic_width <= 0.0 || intrinsic_height <= 0.0 {
572        return (0.0, 0.0);
573    }
574    let ratio = intrinsic_width / intrinsic_height;
575    match size {
576        BackgroundSize::Auto => (intrinsic_width, intrinsic_height),
577        BackgroundSize::Contain | BackgroundSize::Cover => {
578            let width_scale = area_width / intrinsic_width;
579            let height_scale = area_height / intrinsic_height;
580            let scale = if matches!(size, BackgroundSize::Contain) {
581                width_scale.min(height_scale)
582            } else {
583                width_scale.max(height_scale)
584            };
585            (intrinsic_width * scale, intrinsic_height * scale)
586        }
587        BackgroundSize::Explicit { width, height } => {
588            let width = resolve_background_dimension(width, area_width);
589            let height = resolve_background_dimension(height, area_height);
590            match (width, height) {
591                (Some(width), Some(height)) => (width, height),
592                (Some(width), None) => (width, width / ratio),
593                (None, Some(height)) => (height * ratio, height),
594                (None, None) => (intrinsic_width, intrinsic_height),
595            }
596        }
597    }
598}
599
600fn resolve_background_axis(area: f32, image: f32, position: BackgroundPositionAxis) -> f32 {
601    let available = area - image;
602    let length_offset = |offset| match offset {
603        BackgroundOffset::Zero => 0.0,
604        BackgroundOffset::Length(value) => value,
605        BackgroundOffset::Percent(value) => area * value,
606    };
607    match position {
608        BackgroundPositionAxis::Start(BackgroundOffset::Percent(value)) => available * value,
609        BackgroundPositionAxis::Start(offset) => length_offset(offset),
610        BackgroundPositionAxis::Center(offset) => available * 0.5 + length_offset(offset),
611        BackgroundPositionAxis::End(offset) => available - length_offset(offset),
612    }
613}
614
615fn background_tile_positions(base: f32, tile: f32, area: f32, repeat: bool) -> Vec<f32> {
616    if !repeat || tile <= 0.0 {
617        return vec![base];
618    }
619    let mut start = base;
620    while start > 0.0 {
621        start -= tile;
622    }
623    while start + tile <= 0.0 {
624        start += tile;
625    }
626    let mut positions = Vec::new();
627    let mut position = start;
628    while position < area && positions.len() < 512 {
629        positions.push(position);
630        position += tile;
631    }
632    positions
633}
634
635/// Convert a [`ClipPath`] definition into a concrete [`Path`] resolved
636/// against the element's border-box dimensions.
637fn clip_path_to_path(clip: &ClipPath, w: f32, h: f32) -> Path {
638    match clip {
639        ClipPath::None => rect_path(0.0, 0.0, w, h),
640        ClipPath::Circle {
641            radius,
642            center_x,
643            center_y,
644        } => {
645            let cx = center_x * w;
646            let cy = center_y * h;
647            // `radius` is a fraction of farthest-side (max(w, h)) per CSS.
648            let farthest = w.max(h);
649            let r = (radius * farthest).min(farthest);
650            ellipse_path(cx, cy, r, r)
651        }
652        ClipPath::Ellipse {
653            rx,
654            ry,
655            center_x,
656            center_y,
657        } => {
658            let cx = center_x * w;
659            let cy = center_y * h;
660            ellipse_path(cx, cy, rx * w, ry * h)
661        }
662        ClipPath::Inset {
663            top,
664            right,
665            bottom,
666            left,
667        } => {
668            let x = left * w;
669            let y = top * h;
670            let iw = (w - left * w - right * w).max(0.0);
671            let ih = (h - top * h - bottom * h).max(0.0);
672            rect_path(x, y, iw, ih)
673        }
674        ClipPath::Polygon { points } => {
675            let verts: Vec<(f32, f32)> = points.iter().map(|(px, py)| (px * w, py * h)).collect();
676            polygon_path(&verts)
677        }
678    }
679}
680
681/// Push all draw commands for a single box model, returning the pop state.
682#[allow(clippy::too_many_arguments)]
683fn push_box_model(
684    cmd_buf: &mut Vec<DrawCommand>,
685    box_model: &ui_layout::BoxModel,
686    style: &crate::engine::layouter::types::ContainerStyle,
687    scroll_offset_x: f32,
688    scroll_offset_y: f32,
689    is_inline: bool,
690    clips_overflow: bool,
691    draw_bg: bool,
692) -> BoxPushState {
693    let border_box = box_model.border_box;
694    let padding_box = box_model.padding_box;
695    let content_box = box_model.content_box;
696
697    let dx = content_box.x - border_box.x;
698    let dy = content_box.y - border_box.y;
699
700    // Inline containers lay their text out in the parent's coordinate space,
701    // and every line span yields its own box model, so no transform may be
702    // pushed here: otherwise the accumulated border/content offsets of all
703    // line boxes would displace the inline content.
704    let border = !is_inline && push_transform(cmd_buf, border_box.x, border_box.y);
705
706    // When no transform is pushed (inline), draw commands must use absolute
707    // coordinates; otherwise they are already relative to the border-box
708    // origin thanks to the transform above.
709    let (ox, oy) = if is_inline {
710        (border_box.x, border_box.y)
711    } else {
712        (0.0, 0.0)
713    };
714
715    // Push clip-path clip before drawing borders/backgrounds so the shape
716    // clips the entire element (border-box per CSS spec).
717    let clip_path_pushed = !is_inline
718        && !matches!(style.clip_path, ClipPath::None)
719        && border_box.width > 0.0
720        && border_box.height > 0.0;
721    if clip_path_pushed {
722        let path = clip_path_to_path(&style.clip_path, border_box.width, border_box.height);
723        cmd_buf.push(DrawCommand::PushClip {
724            path,
725            rule: FillRule::NonZero,
726        });
727    }
728
729    draw_border(cmd_buf, &border_box, &padding_box, style, ox, oy);
730
731    if draw_bg {
732        draw_background(cmd_buf, &border_box, &padding_box, style, ox, oy);
733    }
734
735    let overflow_clip =
736        !is_inline && clips_overflow && padding_box.width > 0.0 && padding_box.height > 0.0;
737    if overflow_clip {
738        cmd_buf.push(DrawCommand::PushClip {
739            path: rect_path(
740                padding_box.x - border_box.x,
741                padding_box.y - border_box.y,
742                padding_box.width,
743                padding_box.height,
744            ),
745            rule: FillRule::NonZero,
746        });
747    }
748
749    let content = !is_inline && push_transform(cmd_buf, dx, dy);
750    let scroll = !is_inline && push_transform(cmd_buf, -scroll_offset_x, -scroll_offset_y);
751
752    BoxPushState {
753        border,
754        clip_path: clip_path_pushed,
755        overflow_clip,
756        content,
757        scroll,
758    }
759}
760
761/// Pop commands for a single box model (reverse order of pushes).
762fn pop_box_model(cmd_buf: &mut Vec<DrawCommand>, state: BoxPushState) {
763    if state.scroll {
764        cmd_buf.push(DrawCommand::PopTransform);
765    }
766    if state.content {
767        cmd_buf.push(DrawCommand::PopTransform);
768    }
769    if state.overflow_clip {
770        cmd_buf.push(DrawCommand::PopClip);
771    }
772    if state.clip_path {
773        cmd_buf.push(DrawCommand::PopClip);
774    }
775    if state.border {
776        cmd_buf.push(DrawCommand::PopTransform);
777    }
778}
779
780/// Draw text spans for a single text node.
781fn draw_text(
782    cmd_buf: &mut Vec<DrawCommand>,
783    style: &TextStyle,
784    flow_style: TextFlowStyle,
785    text_id: usize,
786) {
787    if let Some(result) = TextFlowLayouter::get_result(text_id) {
788        for (i, line_text) in result.line_texts.iter().enumerate() {
789            let span = &result.spans[i];
790            let x = span.line_pos.0;
791            let y = span.line_pos.1;
792
793            cmd_buf.push(DrawCommand::DrawText {
794                x,
795                y,
796                text: line_text.as_str().into(),
797                style: style.clone(),
798                flow_style,
799            });
800
801            let font_size = flow_style.font_size;
802            let line_thickness = (font_size * 0.08).max(1.0);
803            let line_y_adj = if line_text.is_empty() {
804                y
805            } else {
806                y + font_size
807            };
808            let (line_y, draw) = match style.text_decoration {
809                TextDecoration::None => (0.0, false),
810                TextDecoration::Underline => (line_y_adj, true),
811                TextDecoration::LineThrough => (y + font_size * 0.5, true),
812                TextDecoration::Overline => (y, true),
813            };
814
815            if draw {
816                cmd_buf.push(DrawCommand::Fill {
817                    path: rect_path(
818                        x,
819                        line_y,
820                        span.x_range.end - span.x_range.start,
821                        line_thickness,
822                    ),
823                    rule: FillRule::NonZero,
824                    paint: Paint {
825                        brush: Brush::Solid(style.text_decoration_color.unwrap_or(style.color)),
826                        opacity: 1.0,
827                    },
828                });
829            }
830        }
831    }
832}
833
834// --------------------------------
835// Public entry point
836// --------------------------------
837
838/// LayoutNode + InfoNode → DrawCommand
839///
840/// `viewport` is the visible (window) size of the page area; it establishes the
841/// root scrollport that page-level `position: sticky` boxes stick to.
842pub fn generate_draw_commands(
843    cmd_buf: &mut Vec<DrawCommand>,
844    layout: &LayoutNode,
845    info: &InfoNode,
846    viewport: (f32, f32),
847) {
848    let (scroll_x, scroll_y) = info.kind.scroll_offsets();
849    let root_viewport = StickyViewport {
850        top_left: (-scroll_x, scroll_y),
851        size: viewport,
852    };
853    let containing = layout
854        .layout_box
855        .iter()
856        .next()
857        .map_or((0.0, 0.0), |b| (b.content_box.width, b.content_box.height));
858    let origin = layout
859        .layout_box
860        .iter()
861        .next()
862        .map_or((0.0, 0.0), |b| (b.content_box.x, b.content_box.y));
863    let mut popups: Vec<(Vec<DrawCommand>, (f32, f32))> = Vec::new();
864    generate_draw_commands_inner(
865        cmd_buf,
866        layout,
867        info,
868        (0.0, 0.0),
869        root_viewport,
870        containing,
871        origin,
872        &mut popups,
873        true,
874    );
875    // Top-layer popups render after every other box, outside all ancestor
876    // clips and transforms.
877    for (commands, (tx, ty)) in popups {
878        if tx != 0.0 || ty != 0.0 {
879            cmd_buf.push(DrawCommand::PushTransform {
880                transform: AffineTransform::translate(tx, ty),
881            });
882        }
883        cmd_buf.extend(commands);
884        if tx != 0.0 || ty != 0.0 {
885            cmd_buf.push(DrawCommand::PopTransform);
886        }
887    }
888}
889
890/// Content-box origin of a child layout node in page space, derived from the
891/// parent's page-space origin. Children are laid out relative to the parent's
892/// content box, so the child's origin is the parent's origin plus the child's
893/// content-box offset.
894fn child_origin(child: &LayoutNode, parent_origin: (f32, f32)) -> (f32, f32) {
895    child.layout_box.iter().next().map_or(parent_origin, |b| {
896        (
897            parent_origin.0 + b.content_box.x,
898            parent_origin.1 + b.content_box.y,
899        )
900    })
901}
902
903/// Recursive draw-command generation.
904///
905/// `accumulated_scroll` is the sum of the scroll offsets of every scrollable
906/// ancestor, expressed in content space (`(x, y)`), i.e. the displacement the
907/// current subtree inherits from ancestor scrolling. A `position: fixed` box
908/// is positioned relative to the viewport, so the inherited displacement is
909/// cancelled by pushing the inverse transform before its own box models and
910/// resetting the accumulated scroll for its descendants.
911///
912/// `viewport` is the nearest scrollport as seen from this node's parent-content
913/// space (used to resolve `position: sticky`), `containing` is this node's
914/// parent content-box size (the containing block sticky boxes must stay in),
915/// `origin` is this node's content-box origin in page space (unscrolled), and
916/// `is_root` marks the document root, which always establishes the page
917/// scrollport.
918///
919/// Open popups owned by custom nodes are collected (with the page-space
920/// translation of their content-box origin) instead of being drawn inline, so
921/// the caller can emit them above all page content.
922#[allow(clippy::too_many_arguments)]
923fn generate_draw_commands_inner(
924    cmd_buf: &mut Vec<DrawCommand>,
925    layout: &LayoutNode,
926    info: &InfoNode,
927    accumulated_scroll: (f32, f32),
928    viewport: StickyViewport,
929    containing: (f32, f32),
930    origin: (f32, f32),
931    popups: &mut Vec<(Vec<DrawCommand>, (f32, f32))>,
932    is_root: bool,
933) {
934    // Check visibility before pushing position-dependent transforms.
935    // Hidden elements return early, so any transform pushed above this point
936    // would otherwise leak into subsequent siblings.
937    match &info.kind {
938        NodeKind::Container { style, .. } | NodeKind::Custom { style, .. } => {
939            if matches!(style.visibility, Visibility::Hidden | Visibility::Collapse) {
940                return;
941            }
942        }
943        _ => {}
944    }
945
946    let mut box_states: Vec<BoxPushState> = Vec::new();
947
948    let is_fixed = layout.style.position.kind == Position::Fixed;
949    let is_sticky = layout.style.position.kind == Position::Sticky;
950    let is_inline = matches!(layout.layout_box, ui_layout::LayoutBox::InlineBox(_));
951
952    // Cancel the inherited scroll displacement for fixed-position boxes.
953    let cancel_scroll = is_fixed && (accumulated_scroll.0 != 0.0 || accumulated_scroll.1 != 0.0);
954    if cancel_scroll {
955        cmd_buf.push(DrawCommand::PushTransform {
956            transform: AffineTransform::translate(-accumulated_scroll.0, accumulated_scroll.1),
957        });
958    }
959
960    // Shift sticky boxes so each specified inset stays within the visible area
961    // of the nearest scrollport. Fixed boxes are positioned relative to the
962    // viewport, so sticky offsets never apply to them.
963    let sticky_pushed = if is_sticky && !is_fixed {
964        match layout.layout_box.iter().next() {
965            Some(bm) => {
966                let (dx, dy) = bm
967                    .sticky_edges
968                    .map(|edges| sticky_offset(&edges, &bm.border_box, viewport, containing))
969                    .unwrap_or((0.0, 0.0));
970                push_transform(cmd_buf, dx, dy)
971            }
972            None => false,
973        }
974    } else {
975        false
976    };
977
978    match &info.kind {
979        NodeKind::Text { .. } | NodeKind::LineBreak => unreachable!(),
980
981        NodeKind::Container {
982            scroll_x,
983            scroll_y,
984            scroll_offset_x,
985            scroll_offset_y,
986            style,
987            ..
988        } => {
989            for box_model in &layout.layout_box {
990                box_states.push(push_box_model(
991                    cmd_buf,
992                    &box_model,
993                    style,
994                    *scroll_offset_x,
995                    *scroll_offset_y,
996                    is_inline,
997                    *scroll_x || *scroll_y,
998                    true,
999                ));
1000            }
1001        }
1002
1003        NodeKind::Custom {
1004            scroll_x,
1005            scroll_y,
1006            scroll_offset_x,
1007            scroll_offset_y,
1008            style,
1009            layout_style,
1010            node,
1011            text_style,
1012            text_flow_style,
1013            ..
1014        } => {
1015            for box_model in &layout.layout_box {
1016                box_states.push(push_box_model(
1017                    cmd_buf,
1018                    &box_model,
1019                    style,
1020                    *scroll_offset_x,
1021                    *scroll_offset_y,
1022                    false,
1023                    *scroll_x || *scroll_y,
1024                    false,
1025                ));
1026            }
1027
1028            let size = layout.layout_box.iter().next().map_or_else(
1029                || node.intrinsic_size(),
1030                |box_model| ContentSize {
1031                    width: box_model.content_box.width,
1032                    height: box_model.content_box.height,
1033                },
1034            );
1035            node.draw_sized(cmd_buf, text_style, text_flow_style, layout_style, size);
1036            // Collect open popups so they render above every other box,
1037            // outside all ancestor clips and transforms.
1038            if let Some(popup) = node.popup(text_style, text_flow_style) {
1039                let own_scroll = info.kind.scroll_offsets();
1040                // Fixed boxes are positioned relative to the viewport, so the
1041                // inherited scroll displacement does not move their popup.
1042                let inherited_scroll = if is_fixed {
1043                    (0.0, 0.0)
1044                } else {
1045                    accumulated_scroll
1046                };
1047                let (mut tx, mut ty) = (
1048                    origin.0 - inherited_scroll.0 - own_scroll.0,
1049                    origin.1 - inherited_scroll.1 - own_scroll.1,
1050                );
1051                // Sticky boxes shift their whole subtree (and thus their
1052                // popup) by the sticky offset.
1053                if is_sticky
1054                    && !is_fixed
1055                    && let Some(bm) = layout.layout_box.iter().next()
1056                    && let Some(edges) = bm.sticky_edges
1057                {
1058                    let (dx, dy) = sticky_offset(&edges, &bm.border_box, viewport, containing);
1059                    tx += dx;
1060                    ty += dy;
1061                }
1062                popups.push((popup.commands, (tx, ty)));
1063            }
1064        }
1065    }
1066
1067    // Scroll offsets of this node itself; they scroll the node's own content.
1068    let own_scroll = info.kind.scroll_offsets();
1069    // A fixed box resets the inherited displacement (already cancelled above),
1070    // so its descendants only inherit its own scroll offset.
1071    let child_scroll = if is_fixed {
1072        own_scroll
1073    } else {
1074        (
1075            accumulated_scroll.0 + own_scroll.0,
1076            accumulated_scroll.1 + own_scroll.1,
1077        )
1078    };
1079
1080    // Sticky viewport state for this node's subtree, rebased into each child's
1081    // parent-content space. A node that scrolls its own content (or the root,
1082    // which the UI layer scrolls directly) becomes the scrollport for its
1083    // descendants; otherwise the inherited visible region is shifted by the
1084    // node's content-box offset.
1085    let child_viewport = if is_root || is_scrollport(&info.kind) {
1086        let bm = layout.layout_box.iter().next();
1087        let (scroll_x, scroll_y) = own_scroll;
1088        StickyViewport {
1089            top_left: bm.as_ref().map_or((0.0, 0.0), |b| {
1090                (
1091                    b.padding_box.x - b.content_box.x - scroll_x,
1092                    b.padding_box.y - b.content_box.y + scroll_y,
1093                )
1094            }),
1095            size: if is_root {
1096                viewport.size
1097            } else {
1098                bm.map_or(viewport.size, |b| {
1099                    (b.padding_box.width, b.padding_box.height)
1100                })
1101            },
1102        }
1103    } else {
1104        let bm = layout.layout_box.iter().next();
1105        StickyViewport {
1106            top_left: bm.map_or(viewport.top_left, |b| {
1107                (
1108                    viewport.top_left.0 - b.content_box.x,
1109                    viewport.top_left.1 - b.content_box.y,
1110                )
1111            }),
1112            size: viewport.size,
1113        }
1114    };
1115    let child_containing = layout
1116        .layout_box
1117        .iter()
1118        .next()
1119        .map_or(containing, |b| (b.content_box.width, b.content_box.height));
1120
1121    let mut layout_iter = layout.children.iter();
1122    let mut positive_stacking_children: Vec<(i32, usize, Vec<DrawCommand>)> = Vec::new();
1123
1124    for (child_order, child_info) in info.children.iter().enumerate() {
1125        match &child_info.kind {
1126            NodeKind::Text {
1127                text_id,
1128                style,
1129                flow_style,
1130                ..
1131            } => {
1132                draw_text(cmd_buf, style, *flow_style, *text_id);
1133                layout_iter.next();
1134            }
1135            NodeKind::LineBreak => {
1136                layout_iter.next();
1137            }
1138            NodeKind::Container { .. } => {
1139                if let Some(LayoutChild::Node(node)) = layout_iter.next() {
1140                    let child_origin = child_origin(node, origin);
1141                    let z_index = child_info.kind.z_index();
1142                    if z_index > 0 {
1143                        let mut child_commands = Vec::new();
1144                        generate_draw_commands_inner(
1145                            &mut child_commands,
1146                            node,
1147                            child_info,
1148                            child_scroll,
1149                            child_viewport,
1150                            child_containing,
1151                            child_origin,
1152                            popups,
1153                            false,
1154                        );
1155                        positive_stacking_children.push((z_index, child_order, child_commands));
1156                    } else {
1157                        generate_draw_commands_inner(
1158                            cmd_buf,
1159                            node,
1160                            child_info,
1161                            child_scroll,
1162                            child_viewport,
1163                            child_containing,
1164                            child_origin,
1165                            popups,
1166                            false,
1167                        );
1168                    }
1169                }
1170            }
1171            NodeKind::Custom {
1172                node,
1173                text_style,
1174                text_flow_style,
1175                style,
1176                layout_style,
1177                ..
1178            } => {
1179                match layout_iter.next() {
1180                    // Block custom element: recurse into the child layout node.
1181                    Some(LayoutChild::Node(node_layout)) => {
1182                        let child_origin = child_origin(node_layout, origin);
1183                        generate_draw_commands_inner(
1184                            cmd_buf,
1185                            node_layout,
1186                            child_info,
1187                            child_scroll,
1188                            child_viewport,
1189                            child_containing,
1190                            child_origin,
1191                            popups,
1192                            false,
1193                        );
1194                    }
1195                    // Inline custom element: consume the Object and draw it
1196                    // from the layout result stored on the tree child.
1197                    Some(LayoutChild::Custom(custom_child)) => {
1198                        if let Some(result) = custom_child.result() {
1199                            let bm = &result.box_model;
1200                            let rect = BoxModel {
1201                                sticky_edges: bm.sticky_edges,
1202                                border_box: bm.border_box,
1203                                padding_box: bm.padding_box,
1204                                content_box: bm.content_box,
1205                                children_box: bm.children_box,
1206                            };
1207                            let state = push_box_model(
1208                                cmd_buf, &rect, style, 0.0, 0.0, false, false, false,
1209                            );
1210                            node.draw_sized(
1211                                cmd_buf,
1212                                text_style,
1213                                text_flow_style,
1214                                layout_style,
1215                                ContentSize {
1216                                    width: rect.content_box.width,
1217                                    height: rect.content_box.height,
1218                                },
1219                            );
1220                            pop_box_model(cmd_buf, state);
1221                        }
1222                    }
1223                    _ => {}
1224                }
1225            }
1226        }
1227    }
1228
1229    positive_stacking_children.sort_by_key(|(z_index, child_order, _)| (*z_index, *child_order));
1230    for (_, _, commands) in positive_stacking_children {
1231        cmd_buf.extend(commands);
1232    }
1233
1234    if matches!(
1235        info.kind,
1236        NodeKind::Container { .. } | NodeKind::Custom { .. }
1237    ) {
1238        for state in box_states.iter().rev() {
1239            pop_box_model(cmd_buf, *state);
1240        }
1241    }
1242
1243    if sticky_pushed {
1244        cmd_buf.push(DrawCommand::PopTransform);
1245    }
1246
1247    if cancel_scroll {
1248        cmd_buf.push(DrawCommand::PopTransform);
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::engine::layouter::types::ContainerRole;
1256    use crate::engine::renderer_model::geom::AffineTransform;
1257    use crate::engine::ui::custom_node::{CustomNode, Popup};
1258    use std::sync::Arc;
1259    use ui_layout::Style;
1260
1261    fn count_balanced(commands: &[DrawCommand]) -> bool {
1262        let mut transform_depth = 0usize;
1263        let mut clip_depth = 0usize;
1264        for cmd in commands {
1265            match cmd {
1266                DrawCommand::PushTransform { .. } => transform_depth += 1,
1267                DrawCommand::PopTransform => transform_depth -= 1,
1268                DrawCommand::PushClip { .. } => clip_depth += 1,
1269                DrawCommand::PopClip => clip_depth -= 1,
1270                _ => {}
1271            }
1272        }
1273        transform_depth == 0 && clip_depth == 0
1274    }
1275
1276    #[test]
1277    fn test_push_pop_transform_balanced() {
1278        let mut buf = Vec::new();
1279        assert!(push_transform(&mut buf, 5.0, 5.0));
1280        assert!(matches!(buf.pop(), Some(DrawCommand::PushTransform { .. })));
1281        assert!(!push_transform(&mut buf, 0.0, 0.0));
1282    }
1283
1284    #[test]
1285    fn test_radii_resolution_and_clamp() {
1286        let outer = resolve_outer_radii(&BorderRadius::default(), 100.0, 50.0);
1287        assert_eq!(outer, [(0.0, 0.0); 4]);
1288    }
1289
1290    #[test]
1291    fn scratch_background_image_geometry_is_resolved() {
1292        let (width, height) = resolve_background_image_size(
1293            1200.0,
1294            600.0,
1295            800.0,
1296            400.0,
1297            BackgroundSize::Explicit {
1298                width: BackgroundDimension::Length(624.0),
1299                height: BackgroundDimension::Length(325.0),
1300            },
1301        );
1302        assert_eq!((width, height), (624.0, 325.0));
1303        assert_eq!(
1304            resolve_background_axis(
1305                800.0,
1306                width,
1307                BackgroundPositionAxis::End(BackgroundOffset::Zero),
1308            ),
1309            176.0
1310        );
1311        assert_eq!(
1312            resolve_background_axis(
1313                400.0,
1314                height,
1315                BackgroundPositionAxis::Center(BackgroundOffset::Zero),
1316            ),
1317            37.5
1318        );
1319    }
1320
1321    #[test]
1322    fn background_contain_and_cover_preserve_aspect_ratio() {
1323        assert_eq!(
1324            resolve_background_image_size(200.0, 100.0, 300.0, 300.0, BackgroundSize::Contain,),
1325            (300.0, 150.0)
1326        );
1327        assert_eq!(
1328            resolve_background_image_size(200.0, 100.0, 300.0, 300.0, BackgroundSize::Cover),
1329            (600.0, 300.0)
1330        );
1331    }
1332
1333    fn ui_rect(x: f32, y: f32, w: f32, h: f32) -> ui_layout::Rect {
1334        ui_layout::Rect {
1335            x,
1336            y,
1337            width: w,
1338            height: h,
1339        }
1340    }
1341
1342    #[test]
1343    fn test_single_box_model_is_balanced() {
1344        let box_model = ui_layout::BoxModel {
1345            sticky_edges: None,
1346            border_box: ui_rect(10.0, 20.0, 120.0, 60.0),
1347            padding_box: ui_rect(12.0, 22.0, 116.0, 56.0),
1348            content_box: ui_rect(12.0, 22.0, 116.0, 56.0),
1349            children_box: ui_rect(12.0, 22.0, 116.0, 56.0),
1350        };
1351        let style = ContainerStyle::default();
1352        let mut buf = Vec::new();
1353        let state = push_box_model(&mut buf, &box_model, &style, 0.0, 0.0, false, true, true);
1354        // Scroll/content transforms are no-ops here (zero offsets); border
1355        // transform + clip + content are pushed while the box is open.
1356        assert!(buf.len() >= 2);
1357        // Opening pushes are not yet balanced: a clip and transforms are pending.
1358        assert!(!count_balanced(&buf));
1359        pop_box_model(&mut buf, state);
1360        assert!(count_balanced(&buf));
1361    }
1362
1363    #[test]
1364    fn visible_overflow_does_not_clip_block_contents() {
1365        let box_model = ui_layout::BoxModel {
1366            sticky_edges: None,
1367            border_box: ui_rect(0.0, 0.0, 100.0, 50.0),
1368            padding_box: ui_rect(0.0, 0.0, 100.0, 50.0),
1369            content_box: ui_rect(0.0, 0.0, 100.0, 50.0),
1370            children_box: ui_rect(0.0, 0.0, 120.0, 60.0),
1371        };
1372        let mut commands = Vec::new();
1373        let state = push_box_model(
1374            &mut commands,
1375            &box_model,
1376            &ContainerStyle::default(),
1377            0.0,
1378            0.0,
1379            false,
1380            false,
1381            true,
1382        );
1383        assert!(
1384            !commands
1385                .iter()
1386                .any(|command| matches!(command, DrawCommand::PushClip { .. }))
1387        );
1388        pop_box_model(&mut commands, state);
1389        assert!(count_balanced(&commands));
1390    }
1391
1392    #[test]
1393    fn test_nested_box_models_balanced() {
1394        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
1395            sticky_edges: None,
1396            border_box: ui_rect(x, y, w, h),
1397            padding_box: ui_rect(x + 2.0, y + 2.0, w - 4.0, h - 4.0),
1398            content_box: ui_rect(x + 2.0, y + 2.0, w - 4.0, h - 4.0),
1399            children_box: ui_rect(x + 2.0, y + 2.0, w - 4.0, h - 4.0),
1400        };
1401        let style = ContainerStyle::default();
1402        let mut buf = Vec::new();
1403        let outer = push_box_model(
1404            &mut buf,
1405            &mk_box(0.0, 0.0, 100.0, 100.0),
1406            &style,
1407            3.0,
1408            4.0,
1409            false,
1410            true,
1411            true,
1412        );
1413        let inner = push_box_model(
1414            &mut buf,
1415            &mk_box(10.0, 10.0, 50.0, 50.0),
1416            &style,
1417            0.0,
1418            0.0,
1419            false,
1420            true,
1421            true,
1422        );
1423        // Sanity: inner push generated commands (border + background + clip).
1424        assert!(!buf.is_empty());
1425        pop_box_model(&mut buf, inner);
1426        pop_box_model(&mut buf, outer);
1427        assert!(count_balanced(&buf));
1428    }
1429
1430    #[test]
1431    fn test_affine_transform_reexport() {
1432        let t = AffineTransform::translate(1.0, 2.0);
1433        assert_eq!(t.apply(0.0, 0.0), (1.0, 2.0));
1434    }
1435
1436    /// Build an InfoNode subtree matching `generate_draw_commands`' expectation:
1437    /// `kind` (with `style`), `children` (each with `kind`).
1438    fn mk_info_node(kind: NodeKind, children: Vec<InfoNode>) -> InfoNode {
1439        InfoNode {
1440            kind,
1441            children,
1442            dom_id: None,
1443        }
1444    }
1445
1446    /// Collect the sequence of scroll-related transforms in `commands` as
1447    /// `(translate_x, translate_y)` tuples, in order.
1448    fn scroll_translates(commands: &[DrawCommand]) -> Vec<(f32, f32)> {
1449        let mut tx: Vec<f32> = Vec::new();
1450        let mut ty: Vec<f32> = Vec::new();
1451        let mut out = Vec::new();
1452        for cmd in commands {
1453            match cmd {
1454                DrawCommand::PushTransform { transform } => {
1455                    tx.push(transform.apply(0.0, 0.0).0);
1456                    ty.push(transform.apply(0.0, 0.0).1);
1457                    out.push((transform.apply(0.0, 0.0).0, transform.apply(0.0, 0.0).1));
1458                }
1459                DrawCommand::PopTransform => {
1460                    if let (Some(x), Some(y)) = (tx.pop(), ty.pop()) {
1461                        out.push((-x, -y));
1462                    }
1463                }
1464                _ => {}
1465            }
1466        }
1467        out
1468    }
1469
1470    #[test]
1471    fn fixed_node_cancels_inherited_scroll_offset() {
1472        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
1473            x,
1474            y,
1475            width: w,
1476            height: h,
1477        };
1478        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
1479            sticky_edges: None,
1480            border_box: ui_rect(x, y, w, h),
1481            padding_box: ui_rect(x, y, w, h),
1482            content_box: ui_rect(x, y, w, h),
1483            children_box: ui_rect(x, y, w, h),
1484        };
1485
1486        // A scrollable ancestor (scrolled by 50px) containing a fixed child.
1487        let scroller_style = Style::default();
1488        let mut scroller = LayoutNode::new(scroller_style);
1489        scroller.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 100.0, 100.0));
1490
1491        let mut fixed_style = Style::default();
1492        fixed_style.position.kind = Position::Fixed;
1493        let fixed = LayoutNode::new(fixed_style);
1494        scroller.children = vec![LayoutChild::Node(Box::new(fixed))];
1495
1496        let scroller_info = mk_info_node(
1497            NodeKind::Container {
1498                scroll_x: true,
1499                scroll_y: true,
1500                scroll_offset_x: 0.0,
1501                scroll_offset_y: 50.0,
1502                style: ContainerStyle::default(),
1503                role: ContainerRole::Normal,
1504            },
1505            vec![mk_info_node(
1506                NodeKind::Container {
1507                    scroll_x: false,
1508                    scroll_y: false,
1509                    scroll_offset_x: 0.0,
1510                    scroll_offset_y: 0.0,
1511                    style: ContainerStyle::default(),
1512                    role: ContainerRole::Normal,
1513                },
1514                Vec::new(),
1515            )],
1516        );
1517
1518        let mut commands = Vec::new();
1519        generate_draw_commands(&mut commands, &scroller, &scroller_info, (100.0, 100.0));
1520        assert!(count_balanced(&commands));
1521
1522        // The fixed child must push a transform cancelling the ancestor's
1523        // 50px scroll: `(-0, +50)` cancels the inherited `(0, -50)`.
1524        let translates = scroll_translates(&commands);
1525        assert!(
1526            translates.contains(&(0.0, 50.0)),
1527            "expected a scroll-cancelling transform, got {translates:?}"
1528        );
1529    }
1530
1531    #[test]
1532    fn positive_z_index_child_paints_after_later_normal_sibling() {
1533        let box_model = |x: f32| ui_layout::BoxModel {
1534            sticky_edges: None,
1535            border_box: ui_rect(x, 0.0, 50.0, 50.0),
1536            padding_box: ui_rect(x, 0.0, 50.0, 50.0),
1537            content_box: ui_rect(x, 0.0, 50.0, 50.0),
1538            children_box: ui_rect(x, 0.0, 50.0, 50.0),
1539        };
1540        let mut root = LayoutNode::new(Style::default());
1541        root.layout_box = ui_layout::LayoutBox::BlockBox(box_model(0.0));
1542        let mut front = LayoutNode::new(Style::default());
1543        front.layout_box = ui_layout::LayoutBox::BlockBox(box_model(0.0));
1544        let mut normal = LayoutNode::new(Style::default());
1545        normal.layout_box = ui_layout::LayoutBox::BlockBox(box_model(0.0));
1546        root.children = vec![
1547            LayoutChild::Node(Box::new(front)),
1548            LayoutChild::Node(Box::new(normal)),
1549        ];
1550
1551        let child_info = |color, z_index| {
1552            mk_info_node(
1553                NodeKind::Container {
1554                    scroll_x: false,
1555                    scroll_y: false,
1556                    scroll_offset_x: 0.0,
1557                    scroll_offset_y: 0.0,
1558                    style: ContainerStyle {
1559                        background: Background::Color(color),
1560                        z_index,
1561                        ..ContainerStyle::default()
1562                    },
1563                    role: ContainerRole::Normal,
1564                },
1565                Vec::new(),
1566            )
1567        };
1568        let root_info = mk_info_node(
1569            NodeKind::Container {
1570                scroll_x: false,
1571                scroll_y: false,
1572                scroll_offset_x: 0.0,
1573                scroll_offset_y: 0.0,
1574                style: ContainerStyle::default(),
1575                role: ContainerRole::Normal,
1576            },
1577            vec![
1578                child_info(Color(255, 0, 0, 255), Some(10)),
1579                child_info(Color(0, 0, 255, 255), None),
1580            ],
1581        );
1582
1583        let mut commands = Vec::new();
1584        generate_draw_commands(&mut commands, &root, &root_info, (100.0, 100.0));
1585        let colors: Vec<_> = commands
1586            .iter()
1587            .filter_map(|command| match command {
1588                DrawCommand::Fill {
1589                    paint:
1590                        Paint {
1591                            brush: Brush::Solid(color),
1592                            ..
1593                        },
1594                    ..
1595                } => Some(*color),
1596                _ => None,
1597            })
1598            .collect();
1599        assert_eq!(colors, vec![Color(0, 0, 255, 255), Color(255, 0, 0, 255)]);
1600        assert!(count_balanced(&commands));
1601    }
1602
1603    fn sticky_viewport(scroll: (f32, f32)) -> StickyViewport {
1604        StickyViewport {
1605            top_left: (-scroll.0, scroll.1),
1606            size: (800.0, 500.0),
1607        }
1608    }
1609
1610    fn sticky_edges(top: Option<f32>, bottom: Option<f32>) -> EdgeOption {
1611        EdgeOption {
1612            left: None,
1613            top,
1614            right: None,
1615            bottom,
1616        }
1617    }
1618
1619    #[test]
1620    fn test_sticky_offset_top_sticks_to_viewport_top() {
1621        // Natural y=600, viewport scrolled 600px: the box's top edge is pinned
1622        // at the viewport top + 10.
1623        let (dx, dy) = sticky_offset(
1624            &sticky_edges(Some(10.0), None),
1625            &ui_rect(0.0, 600.0, 100.0, 50.0),
1626            sticky_viewport((0.0, 600.0)),
1627            (800.0, 2000.0),
1628        );
1629        assert_eq!((dx, dy), (0.0, 10.0));
1630    }
1631
1632    #[test]
1633    fn test_sticky_offset_bottom_pushes_up() {
1634        // Natural bottom (1000) is below the viewport bottom minus the inset
1635        // (490): the box is pulled up by 510.
1636        let (dx, dy) = sticky_offset(
1637            &sticky_edges(None, Some(10.0)),
1638            &ui_rect(0.0, 900.0, 100.0, 100.0),
1639            sticky_viewport((0.0, 0.0)),
1640            (800.0, 2000.0),
1641        );
1642        assert_eq!((dx, dy), (0.0, -510.0));
1643    }
1644
1645    #[test]
1646    fn test_sticky_offset_no_movement_when_in_view() {
1647        let (dx, dy) = sticky_offset(
1648            &sticky_edges(Some(10.0), None),
1649            &ui_rect(0.0, 50.0, 100.0, 50.0),
1650            sticky_viewport((0.0, 0.0)),
1651            (800.0, 2000.0),
1652        );
1653        assert_eq!((dx, dy), (0.0, 0.0));
1654    }
1655
1656    #[test]
1657    fn test_sticky_offset_containing_block_hi_clamp() {
1658        // A box taller than the visible area near the container end must not
1659        // be pushed past the containing block bottom (natural bottom is
1660        // already at 1300, the container end).
1661        let (dx, dy) = sticky_offset(
1662            &sticky_edges(Some(10.0), None),
1663            &ui_rect(0.0, 900.0, 100.0, 400.0),
1664            sticky_viewport((0.0, 900.0)),
1665            (800.0, 1300.0),
1666        );
1667        assert_eq!((dx, dy), (0.0, 0.0));
1668    }
1669
1670    #[test]
1671    fn test_sticky_offset_inset_fit_ignores_end_edge_without_room() {
1672        // Both insets set but the sticky view rectangle is shorter than the
1673        // box: the bottom inset is ignored, the box sticks to its top edge.
1674        let (dx, dy) = sticky_offset(
1675            &sticky_edges(Some(10.0), Some(10.0)),
1676            &ui_rect(0.0, 100.0, 100.0, 500.0),
1677            sticky_viewport((0.0, 0.0)),
1678            (800.0, 2000.0),
1679        );
1680        assert_eq!((dx, dy), (0.0, 0.0));
1681    }
1682
1683    #[test]
1684    fn test_sticky_offset_horizontal_right_pushes_left() {
1685        // Box spans [850, 950], off-screen right of the 800-wide viewport; a
1686        // right inset of 10 pulls it left so its right edge sits at 790.
1687        let edges = EdgeOption {
1688            left: None,
1689            top: None,
1690            right: Some(10.0),
1691            bottom: None,
1692        };
1693        let (dx, dy) = sticky_offset(
1694            &edges,
1695            &ui_rect(850.0, 0.0, 100.0, 50.0),
1696            sticky_viewport((0.0, 0.0)),
1697            (2000.0, 2000.0),
1698        );
1699        assert_eq!((dx, dy), (-160.0, 0.0));
1700    }
1701
1702    #[test]
1703    fn sticky_node_pushes_sticky_offset_transform() {
1704        // Root acts as the page scrollport (scrolled 600px). A sticky child at
1705        // natural y=600 with top:10 must push a translate(0, 10).
1706        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
1707            x,
1708            y,
1709            width: w,
1710            height: h,
1711        };
1712        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
1713            sticky_edges: None,
1714            border_box: ui_rect(x, y, w, h),
1715            padding_box: ui_rect(x, y, w, h),
1716            content_box: ui_rect(x, y, w, h),
1717            children_box: ui_rect(x, y, w, h),
1718        };
1719
1720        let root_style = Style::default();
1721        let mut root = LayoutNode::new(root_style);
1722        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 800.0, 2000.0));
1723
1724        let mut sticky_style = Style::default();
1725        sticky_style.position.kind = Position::Sticky;
1726        let mut sticky = LayoutNode::new(sticky_style);
1727        let mut sticky_bm = mk_box(0.0, 600.0, 800.0, 100.0);
1728        sticky_bm.sticky_edges = Some(sticky_edges(Some(10.0), None));
1729        sticky.layout_box = ui_layout::LayoutBox::BlockBox(sticky_bm);
1730        root.children = vec![LayoutChild::Node(Box::new(sticky))];
1731
1732        let root_info = mk_info_node(
1733            NodeKind::Container {
1734                scroll_x: false,
1735                scroll_y: false,
1736                scroll_offset_x: 0.0,
1737                scroll_offset_y: 600.0,
1738                style: ContainerStyle::default(),
1739                role: ContainerRole::Normal,
1740            },
1741            vec![mk_info_node(
1742                NodeKind::Container {
1743                    scroll_x: false,
1744                    scroll_y: false,
1745                    scroll_offset_x: 0.0,
1746                    scroll_offset_y: 0.0,
1747                    style: ContainerStyle::default(),
1748                    role: ContainerRole::Normal,
1749                },
1750                Vec::new(),
1751            )],
1752        );
1753
1754        let mut commands = Vec::new();
1755        generate_draw_commands(&mut commands, &root, &root_info, (800.0, 500.0));
1756        assert!(count_balanced(&commands));
1757
1758        let translates = scroll_translates(&commands);
1759        assert!(
1760            translates.contains(&(0.0, 10.0)),
1761            "expected a sticky top offset, got {translates:?}"
1762        );
1763    }
1764
1765    #[test]
1766    fn sticky_node_bottom_pushes_up() {
1767        // Same setup but bottom:10, unscrolled, natural y=900: pulled up by 510.
1768        let ui_rect = |x: f32, y: f32, w: f32, h: f32| ui_layout::Rect {
1769            x,
1770            y,
1771            width: w,
1772            height: h,
1773        };
1774        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
1775            sticky_edges: None,
1776            border_box: ui_rect(x, y, w, h),
1777            padding_box: ui_rect(x, y, w, h),
1778            content_box: ui_rect(x, y, w, h),
1779            children_box: ui_rect(x, y, w, h),
1780        };
1781
1782        let root_style = Style::default();
1783        let mut root = LayoutNode::new(root_style);
1784        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 800.0, 2000.0));
1785
1786        let mut sticky_style = Style::default();
1787        sticky_style.position.kind = Position::Sticky;
1788        let mut sticky = LayoutNode::new(sticky_style);
1789        let mut sticky_bm = mk_box(0.0, 900.0, 800.0, 100.0);
1790        sticky_bm.sticky_edges = Some(sticky_edges(None, Some(10.0)));
1791        sticky.layout_box = ui_layout::LayoutBox::BlockBox(sticky_bm);
1792        root.children = vec![LayoutChild::Node(Box::new(sticky))];
1793
1794        let root_info = mk_info_node(
1795            NodeKind::Container {
1796                scroll_x: false,
1797                scroll_y: false,
1798                scroll_offset_x: 0.0,
1799                scroll_offset_y: 0.0,
1800                style: ContainerStyle::default(),
1801                role: ContainerRole::Normal,
1802            },
1803            vec![mk_info_node(
1804                NodeKind::Container {
1805                    scroll_x: false,
1806                    scroll_y: false,
1807                    scroll_offset_x: 0.0,
1808                    scroll_offset_y: 0.0,
1809                    style: ContainerStyle::default(),
1810                    role: ContainerRole::Normal,
1811                },
1812                Vec::new(),
1813            )],
1814        );
1815
1816        let mut commands = Vec::new();
1817        generate_draw_commands(&mut commands, &root, &root_info, (800.0, 500.0));
1818        assert!(count_balanced(&commands));
1819
1820        let translates = scroll_translates(&commands);
1821        assert!(
1822            translates.contains(&(0.0, -510.0)),
1823            "expected a sticky bottom offset, got {translates:?}"
1824        );
1825    }
1826
1827    /// A custom node that reports an open popup with a single fill command.
1828    #[derive(Debug)]
1829    struct PopupNode {
1830        open: bool,
1831        box_height: f32,
1832        popup_height: f32,
1833    }
1834
1835    impl CustomNode for PopupNode {
1836        fn draw_sized(
1837            &self,
1838            _cmd_buf: &mut Vec<DrawCommand>,
1839            _text_style: &TextStyle,
1840            _text_flow_style: &TextFlowStyle,
1841            _style: &Style,
1842            _size: ContentSize,
1843        ) {
1844        }
1845
1846        fn intrinsic_size(&self) -> ContentSize {
1847            ContentSize {
1848                width: 120.0,
1849                height: self.box_height,
1850            }
1851        }
1852
1853        fn popup(
1854            &self,
1855            _text_style: &TextStyle,
1856            _text_flow_style: &TextFlowStyle,
1857        ) -> Option<Popup> {
1858            self.open.then(|| Popup {
1859                rect: crate::engine::renderer_model::Rect {
1860                    x: 0.0,
1861                    y: self.box_height,
1862                    width: 120.0,
1863                    height: self.popup_height,
1864                },
1865                commands: vec![DrawCommand::Fill {
1866                    path: rect_path(0.0, self.box_height, 120.0, self.popup_height),
1867                    rule: FillRule::NonZero,
1868                    paint: Paint {
1869                        brush: Brush::Solid(Color(255, 0, 0, 255)),
1870                        opacity: 1.0,
1871                    },
1872                }],
1873            })
1874        }
1875    }
1876
1877    #[test]
1878    fn popup_is_emitted_as_top_layer_at_node_position() {
1879        // A select-like node with an open popup nested inside a scrollable
1880        // container: node content origin (10+5, 20+0) = (15, 20), inherited
1881        // scroll offset 50 → popup translate (15, 20-50) = (15, -30).
1882        let node: Arc<dyn CustomNode> = Arc::new(PopupNode {
1883            open: true,
1884            box_height: 28.0,
1885            popup_height: 84.0,
1886        });
1887
1888        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
1889            sticky_edges: None,
1890            border_box: ui_rect(x, y, w, h),
1891            padding_box: ui_rect(x, y, w, h),
1892            content_box: ui_rect(x, y, w, h),
1893            children_box: ui_rect(x, y, w, h),
1894        };
1895
1896        let mut root = LayoutNode::new(Style::default());
1897        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 200.0, 200.0));
1898
1899        let mut scroller = LayoutNode::new(Style::default());
1900        scroller.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(10.0, 20.0, 160.0, 100.0));
1901
1902        let mut custom = LayoutNode::new(Style::default());
1903        custom.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(5.0, 0.0, 120.0, 28.0));
1904        scroller.children = vec![LayoutChild::Node(Box::new(custom))];
1905        root.children = vec![LayoutChild::Node(Box::new(scroller))];
1906
1907        let root_info = mk_info_node(
1908            NodeKind::Container {
1909                scroll_x: false,
1910                scroll_y: false,
1911                scroll_offset_x: 0.0,
1912                scroll_offset_y: 0.0,
1913                style: ContainerStyle::default(),
1914                role: ContainerRole::Normal,
1915            },
1916            vec![mk_info_node(
1917                NodeKind::Container {
1918                    scroll_x: false,
1919                    scroll_y: true,
1920                    scroll_offset_x: 0.0,
1921                    scroll_offset_y: 50.0,
1922                    style: ContainerStyle::default(),
1923                    role: ContainerRole::Normal,
1924                },
1925                vec![mk_info_node(
1926                    NodeKind::Custom {
1927                        node,
1928                        scroll_x: false,
1929                        scroll_y: false,
1930                        scroll_offset_x: 0.0,
1931                        scroll_offset_y: 0.0,
1932                        style: ContainerStyle::default(),
1933                        layout_style: Style::default(),
1934                        text_style: TextStyle::default(),
1935                        text_flow_style: TextFlowStyle::default(),
1936                    },
1937                    Vec::new(),
1938                )],
1939            )],
1940        );
1941
1942        let mut commands = Vec::new();
1943        generate_draw_commands(&mut commands, &root, &root_info, (200.0, 200.0));
1944        assert!(count_balanced(&commands));
1945
1946        // The default styles paint nothing, so the only fill in the buffer is
1947        // the popup, emitted after every clip/transform.
1948        let fills = commands
1949            .iter()
1950            .filter(|cmd| matches!(cmd, DrawCommand::Fill { .. }))
1951            .count();
1952        assert_eq!(fills, 1, "popup must be the only painted box");
1953
1954        match &commands[commands.len() - 3..] {
1955            [
1956                DrawCommand::PushTransform { transform },
1957                DrawCommand::Fill { .. },
1958                DrawCommand::PopTransform,
1959            ] => {
1960                assert_eq!(transform.apply(0.0, 0.0), (15.0, -30.0));
1961            }
1962            _ => panic!("expected trailing popup PushTransform/Fill/PopTransform"),
1963        }
1964    }
1965
1966    #[test]
1967    fn closed_popup_is_not_emitted() {
1968        let node: Arc<dyn CustomNode> = Arc::new(PopupNode {
1969            open: false,
1970            box_height: 28.0,
1971            popup_height: 84.0,
1972        });
1973
1974        let mk_box = |x: f32, y: f32, w: f32, h: f32| ui_layout::BoxModel {
1975            sticky_edges: None,
1976            border_box: ui_rect(x, y, w, h),
1977            padding_box: ui_rect(x, y, w, h),
1978            content_box: ui_rect(x, y, w, h),
1979            children_box: ui_rect(x, y, w, h),
1980        };
1981
1982        let mut root = LayoutNode::new(Style::default());
1983        root.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 200.0, 200.0));
1984        let mut custom = LayoutNode::new(Style::default());
1985        custom.layout_box = ui_layout::LayoutBox::BlockBox(mk_box(0.0, 0.0, 120.0, 28.0));
1986        root.children = vec![LayoutChild::Node(Box::new(custom))];
1987
1988        let root_info = mk_info_node(
1989            NodeKind::Container {
1990                scroll_x: false,
1991                scroll_y: false,
1992                scroll_offset_x: 0.0,
1993                scroll_offset_y: 0.0,
1994                style: ContainerStyle::default(),
1995                role: ContainerRole::Normal,
1996            },
1997            vec![mk_info_node(
1998                NodeKind::Custom {
1999                    node,
2000                    scroll_x: false,
2001                    scroll_y: false,
2002                    scroll_offset_x: 0.0,
2003                    scroll_offset_y: 0.0,
2004                    style: ContainerStyle::default(),
2005                    layout_style: Style::default(),
2006                    text_style: TextStyle::default(),
2007                    text_flow_style: TextFlowStyle::default(),
2008                },
2009                Vec::new(),
2010            )],
2011        );
2012
2013        let mut commands = Vec::new();
2014        generate_draw_commands(&mut commands, &root, &root_info, (200.0, 200.0));
2015        assert!(count_balanced(&commands));
2016        assert_eq!(
2017            commands
2018                .iter()
2019                .filter(|cmd| matches!(cmd, DrawCommand::Fill { .. }))
2020                .count(),
2021            0
2022        );
2023    }
2024}