Skip to main content

azul_layout/solver3/
display_list.rs

1//! Generates a renderer-agnostic display list from a laid-out tree.
2//!
3//! This module is the bridge between the layout solver and the compositor/renderer.
4//! Key types:
5//! - [`DisplayList`] — flat, paint-order-sorted list of drawing commands
6//! - [`DisplayListItem`] — a single drawing primitive or state-management command
7//! - [`DisplayListBuilder`] — internal builder that accumulates items during generation
8//!
9//! Entry points:
10//! - [`generate_display_list`] — converts a laid-out [`LayoutTree`] into a [`DisplayList`]
11//! - [`paginate_display_list_with_slicer_and_breaks`] — slices a display list into pages
12//!
13//! Coordinates are in **absolute window-logical pixels** ([`WindowLogicalRect`]).
14//! `HiDPI` scaling and scroll-offset conversion happen in the compositor.
15
16use std::{collections::{BTreeMap, HashMap}, sync::Arc};
17
18use azul_core::{
19    dom::{DomId, FormattingContext, NodeId, NodeType, ScrollbarOrientation},
20    geom::{LogicalPosition, LogicalRect, LogicalSize},
21    gpu::GpuValueCache,
22    hit_test::{CursorType, ScrollPosition, TAG_TYPE_CURSOR, TAG_TYPE_DOM_NODE},
23    resources::{
24        IdNamespace, ImageRef, OpacityKey, RendererResources, TransformKey,
25    },
26    transform::ComputedTransform3D,
27    selection::{Selection, SelectionRange, TextSelection},
28    styled_dom::StyledDom,
29    ui_solver::GlyphInstance,
30};
31use azul_css::{
32    css::CssPropertyValue,
33    codegen::format::GetHash,
34    props::{
35        basic::{ColorU, FontRef, PixelValue},
36        layout::{LayoutDisplay, LayoutOverflow, LayoutPosition},
37        property::{CssProperty, CssPropertyType},
38        style::{
39            background::{ConicGradient, ExtendMode, LinearGradient, RadialGradient},
40            border_radius::StyleBorderRadius,
41            box_shadow::{BoxShadowClipMode, StyleBoxShadow},
42            filter::{StyleFilter, StyleFilterVec},
43            BorderStyle, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth,
44            LayoutBorderTopWidth, StyleBorderBottomColor, StyleBorderBottomStyle,
45            StyleBorderLeftColor, StyleBorderLeftStyle, StyleBorderRightColor,
46            StyleBorderRightStyle, StyleBorderTopColor, StyleBorderTopStyle,
47        },
48    },
49    LayoutDebugMessage,
50};
51
52#[cfg(feature = "text_layout")]
53use crate::text3;
54#[cfg(feature = "text_layout")]
55use crate::text3::cache::{InlineShape, PositionedItem};
56use crate::{
57    debug_info,
58    font_traits::{
59        FontHash, FontLoaderTrait, ImageSource, InlineContent, ParsedFontTrait, ShapedItem,
60        UnifiedLayout,
61    },
62    solver3::{
63        getters::{
64            get_background_color, get_background_contents, get_border_info, get_border_radius,
65            get_break_after, get_break_before, get_caret_style,
66            get_overflow_clip_margin_property, get_overflow_x, get_overflow_y,
67            get_scrollbar_gutter_property, get_scrollbar_info_from_layout, get_scrollbar_style, get_selection_style,
68            get_style_border_radius, get_visibility, get_z_index, is_forced_page_break, BorderInfo, CaretStyle,
69            ComputedScrollbarStyle, SelectionStyle,
70        },
71        layout_tree::{LayoutNode, LayoutNodeHot, LayoutNodeWarm, LayoutTree},
72        positioning::get_position_type,
73        scrollbar::{ScrollbarRequirements, compute_scrollbar_geometry_with_button_size},
74        LayoutContext, LayoutError, Result,
75    },
76};
77
78const APPROX_ASCENT_RATIO: f32 = 0.8;
79const APPROX_UNDERLINE_THICKNESS_RATIO: f32 = 0.08;
80const APPROX_UNDERLINE_OFFSET_RATIO: f32 = 0.12;
81const APPROX_STRIKETHROUGH_OFFSET_RATIO: f32 = 0.3;
82const APPROX_OVERLINE_OFFSET_RATIO: f32 = 0.85;
83const APPROX_ELLIPSIS_WIDTH_RATIO: f32 = 0.6;
84const DEFAULT_A4_WIDTH_PT: f32 = 595.0;
85const DEFAULT_SHADOW_FONT_SIZE_PX: f32 = 16.0;
86
87/// Border widths for all four sides.
88///
89/// Each field is optional to allow partial border specifications.
90/// Used in [`DisplayListItem::Border`] to specify per-side border widths.
91#[derive(Debug, Clone, Copy)]
92pub struct StyleBorderWidths {
93    /// Top border width (CSS `border-top-width`)
94    pub top: Option<CssPropertyValue<LayoutBorderTopWidth>>,
95    /// Right border width (CSS `border-right-width`)
96    pub right: Option<CssPropertyValue<LayoutBorderRightWidth>>,
97    /// Bottom border width (CSS `border-bottom-width`)
98    pub bottom: Option<CssPropertyValue<LayoutBorderBottomWidth>>,
99    /// Left border width (CSS `border-left-width`)
100    pub left: Option<CssPropertyValue<LayoutBorderLeftWidth>>,
101}
102
103/// Border colors for all four sides.
104///
105/// Each field is optional to allow partial border specifications.
106/// Used in [`DisplayListItem::Border`] to specify per-side border colors.
107#[derive(Debug, Clone, Copy)]
108pub struct StyleBorderColors {
109    /// Top border color (CSS `border-top-color`)
110    pub top: Option<CssPropertyValue<StyleBorderTopColor>>,
111    /// Right border color (CSS `border-right-color`)
112    pub right: Option<CssPropertyValue<StyleBorderRightColor>>,
113    /// Bottom border color (CSS `border-bottom-color`)
114    pub bottom: Option<CssPropertyValue<StyleBorderBottomColor>>,
115    /// Left border color (CSS `border-left-color`)
116    pub left: Option<CssPropertyValue<StyleBorderLeftColor>>,
117}
118
119/// Border styles for all four sides.
120///
121/// Each field is optional to allow partial border specifications.
122/// Used in [`DisplayListItem::Border`] to specify per-side border styles
123/// (solid, dashed, dotted, none, etc.).
124#[derive(Debug, Clone, Copy)]
125pub struct StyleBorderStyles {
126    /// Top border style (CSS `border-top-style`)
127    pub top: Option<CssPropertyValue<StyleBorderTopStyle>>,
128    /// Right border style (CSS `border-right-style`)
129    pub right: Option<CssPropertyValue<StyleBorderRightStyle>>,
130    /// Bottom border style (CSS `border-bottom-style`)
131    pub bottom: Option<CssPropertyValue<StyleBorderBottomStyle>>,
132    /// Left border style (CSS `border-left-style`)
133    pub left: Option<CssPropertyValue<StyleBorderLeftStyle>>,
134}
135
136/// A rectangle in border-box coordinates (includes padding and border).
137/// This is what layout calculates and stores in `used_size` and absolute positions.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct BorderBoxRect(pub LogicalRect);
140
141/// A `LogicalRect` known to be in **absolute window coordinates** (as output
142/// by the layout engine).
143///
144/// All spatial bounds stored in [`DisplayListItem`] use
145/// this type so that the compositor is *forced* to convert them to
146/// frame-relative coordinates before passing them to `WebRender`.
147///
148/// ## Coordinate-space contract
149///
150/// * **Layout engine** produces `WindowLogicalRect` values.
151/// * **Compositor** converts via `resolve_rect()` → `WebRender` `LayoutRect`.
152/// * Passing a `WindowLogicalRect` directly to a `WebRender` push function is a
153///   **type error** (it wraps `LogicalRect`, not `LayoutRect`).
154///
155/// See `doc/SCROLL_COORDINATE_ARCHITECTURE.md` for background.
156#[derive(Debug, Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash)]
157pub struct WindowLogicalRect(pub LogicalRect);
158
159impl WindowLogicalRect {
160    #[inline]
161    #[must_use] pub const fn new(origin: LogicalPosition, size: LogicalSize) -> Self {
162        Self(LogicalRect::new(origin, size))
163    }
164
165    #[inline]
166    #[must_use] pub const fn zero() -> Self {
167        Self(LogicalRect::zero())
168    }
169
170    /// Access the inner `LogicalRect` (still in window space – the caller is
171    /// responsible for applying any offset conversion).
172    #[inline]
173    #[must_use] pub const fn inner(&self) -> &LogicalRect {
174        &self.0
175    }
176
177    #[inline]
178    #[must_use] pub const fn into_inner(self) -> LogicalRect {
179        self.0
180    }
181
182    // Convenience accessors
183    #[inline] #[must_use] pub const fn origin(&self) -> LogicalPosition { self.0.origin }
184    #[inline] #[must_use] pub const fn size(&self)   -> LogicalSize     { self.0.size }
185}
186
187impl From<LogicalRect> for WindowLogicalRect {
188    #[inline]
189    fn from(r: LogicalRect) -> Self { Self(r) }
190}
191
192impl From<WindowLogicalRect> for LogicalRect {
193    #[inline]
194    fn from(w: WindowLogicalRect) -> Self { w.0 }
195}
196
197/// Simple struct for passing element dimensions to border-radius calculation
198#[derive(Debug, Clone, Copy)]
199pub struct PhysicalSizeImport {
200    pub width: f32,
201    pub height: f32,
202}
203
204/// Complete drawing information for a scrollbar with all visual components.
205///
206/// This contains the resolved geometry and colors for all scrollbar parts:
207/// - Track: The background area where the thumb slides
208/// - Thumb: The draggable indicator showing current scroll position
209/// - Buttons: Optional up/down or left/right arrow buttons
210/// - Corner: The area where horizontal and vertical scrollbars meet
211#[derive(Debug, Clone, PartialEq)]
212pub struct ScrollbarDrawInfo {
213    /// Overall bounds of the entire scrollbar (including track and buttons)
214    pub bounds: WindowLogicalRect,
215    /// Scrollbar orientation (horizontal or vertical)
216    pub orientation: ScrollbarOrientation,
217
218    // Track area (the background rail)
219    /// Bounds of the track area
220    pub track_bounds: WindowLogicalRect,
221    /// Color of the track background
222    pub track_color: ColorU,
223
224    // Thumb (the draggable part)
225    /// Bounds of the thumb
226    pub thumb_bounds: WindowLogicalRect,
227    /// Color of the thumb
228    pub thumb_color: ColorU,
229    /// Border radius for rounded thumb corners
230    pub thumb_border_radius: BorderRadius,
231
232    // Optional buttons (arrows at ends)
233    /// Optional decrement button bounds (up/left arrow)
234    pub button_decrement_bounds: Option<WindowLogicalRect>,
235    /// Optional increment button bounds (down/right arrow)
236    pub button_increment_bounds: Option<WindowLogicalRect>,
237    /// Color for buttons
238    pub button_color: ColorU,
239
240    /// Optional opacity key for GPU-side fading animation.
241    pub opacity_key: Option<OpacityKey>,
242    /// Optional transform key for GPU-side scrollbar thumb positioning.
243    /// When present, the compositor will wrap the thumb in a `PushReferenceFrame`
244    /// with `PropertyBinding::Binding` so `WebRender` can animate the thumb position
245    /// without rebuilding the display list.
246    pub thumb_transform_key: Option<TransformKey>,
247    /// Initial transform for the scrollbar thumb (current scroll position).
248    /// This is the transform applied when the display list is first built.
249    /// During GPU-only scroll, `synchronize_gpu_values` updates this dynamically.
250    pub thumb_initial_transform: ComputedTransform3D,
251    /// Optional hit-test ID for `WebRender` hit-testing.
252    pub hit_id: Option<azul_core::hit_test::ScrollbarHitId>,
253    /// Whether to clip scrollbar to container's border-radius
254    pub clip_to_container_border: bool,
255    /// Container's border-radius (for clipping)
256    pub container_border_radius: BorderRadius,
257    /// Scrollbar visibility mode — used by back-registration to choose initial opacity.
258    /// `Always` → initial opacity 1.0; `WhenScrolling` → initial opacity 0.0.
259    pub visibility: azul_css::props::style::scrollbar::ScrollbarVisibilityMode,
260}
261
262impl BorderBoxRect {
263    /// Convert border-box to content-box by subtracting padding and border.
264    /// Content-box is where inline layout and text actually render.
265    #[must_use] pub fn to_content_box(
266        self,
267        padding: &crate::solver3::geometry::EdgeSizes,
268        border: &crate::solver3::geometry::EdgeSizes,
269    ) -> ContentBoxRect {
270        ContentBoxRect(LogicalRect {
271            origin: LogicalPosition {
272                x: self.0.origin.x + padding.left + border.left,
273                y: self.0.origin.y + padding.top + border.top,
274            },
275            size: LogicalSize {
276                width: self.0.size.width
277                    - padding.left
278                    - padding.right
279                    - border.left
280                    - border.right,
281                height: self.0.size.height
282                    - padding.top
283                    - padding.bottom
284                    - border.top
285                    - border.bottom,
286            },
287        })
288    }
289
290    /// Get the inner `LogicalRect`
291    #[must_use] pub const fn rect(&self) -> LogicalRect {
292        self.0
293    }
294}
295
296/// A rectangle in content-box coordinates (excludes padding and border).
297/// This is where text and inline content is positioned by the inline formatter.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct ContentBoxRect(pub LogicalRect);
300
301impl ContentBoxRect {
302    /// Get the inner `LogicalRect`
303    #[must_use] pub const fn rect(&self) -> LogicalRect {
304        self.0
305    }
306}
307
308/// The final, renderer-agnostic output of the layout engine.
309///
310/// This is a flat list of drawing and state-management commands, already sorted
311/// according to the CSS paint order. A renderer can consume this list directly.
312#[derive(Debug, Default, Clone)]
313pub struct DisplayList {
314    pub items: Vec<DisplayListItem>,
315    /// Optional mapping from item index to the DOM `NodeId` that generated it.
316    /// Used for pagination to look up CSS break properties.
317    /// Not all items have a source node (e.g., synthesized decorations).
318    pub node_mapping: Vec<Option<NodeId>>,
319    /// Y-positions where forced page breaks should occur (from break-before/break-after: always).
320    /// These are absolute Y coordinates in the infinite canvas coordinate system.
321    /// The slicer will ensure page boundaries align with these positions.
322    pub forced_page_breaks: Vec<f32>,
323    /// Index ranges (start, end) of display list items that belong to fixed-position elements.
324    /// In paged media, these items are replicated on every page (CSS Positioned Layout §2.1).
325    pub fixed_position_item_ranges: Vec<(usize, usize)>,
326}
327
328impl DisplayList {
329    /// Patch text glyph data for a specific layout node without rebuilding
330    /// the entire display list. Returns the damage rect covering all
331    /// affected text items, or None if no matching items found.
332    ///
333    /// Used for `GlyphSwap` incremental relayout: glyphs changed but
334    /// positions are identical, so only the glyph IDs need updating.
335    pub(crate) fn patch_text_glyphs(
336        &mut self,
337        node_index: usize,
338        new_glyphs_by_run: &[Vec<GlyphInstance>],
339    ) -> Option<LogicalRect> {
340        let mut run_idx = 0;
341        let mut damage: Option<LogicalRect> = None;
342
343        for item in &mut self.items {
344            if let DisplayListItem::Text {
345                ref mut glyphs,
346                ref clip_rect,
347                source_node_index: Some(src_idx),
348                ..
349            } = item {
350                if *src_idx == node_index
351                    && run_idx < new_glyphs_by_run.len() {
352                        glyphs.clone_from(&new_glyphs_by_run[run_idx]);
353                        let bounds = *clip_rect.inner();
354                        damage = Some(damage.map_or(bounds, |d| {
355                                // rect union (was crate::cpurender::union_rect, which
356                                // is gated behind the `cpurender` feature; inlined here
357                                // so display-list damage tracking works without it / on WASM)
358                                let x = d.origin.x.min(bounds.origin.x);
359                                let y = d.origin.y.min(bounds.origin.y);
360                                let right = (d.origin.x + d.size.width)
361                                    .max(bounds.origin.x + bounds.size.width);
362                                let bottom = (d.origin.y + d.size.height)
363                                    .max(bounds.origin.y + bounds.size.height);
364                                LogicalRect {
365                                    origin: LogicalPosition { x, y },
366                                    size: LogicalSize { width: right - x, height: bottom - y },
367                                }
368                            }));
369                        run_idx += 1;
370                    }
371            }
372        }
373
374        damage
375    }
376
377    /// Compute a damage rect from the difference between old and new text
378    /// layout results, starting from a given line index.
379    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
380    pub(crate) fn compute_text_damage_rect(
381        old_items: &[PositionedItem],
382        new_items: &[PositionedItem],
383        container_origin: LogicalPosition,
384        affected_line: usize,
385    ) -> LogicalRect {
386        let expand = |items: &[PositionedItem]| -> (f32, f32, f32, f32) {
387            let mut lx = f32::MAX;
388            let mut ly = f32::MAX;
389            let mut rx = f32::MIN;
390            let mut ry = f32::MIN;
391            for item in items {
392                if item.line_index >= affected_line {
393                    let bounds = item.item.bounds();
394                    let x = container_origin.x + item.position.x;
395                    let y = container_origin.y + item.position.y;
396                    lx = lx.min(x);
397                    ly = ly.min(y);
398                    rx = rx.max(x + bounds.width);
399                    ry = ry.max(y + bounds.height);
400                }
401            }
402            (lx, ly, rx, ry)
403        };
404
405        let (olx, oly, orx, ory) = expand(old_items);
406        let (nlx, nly, nrx, nry) = expand(new_items);
407        let min_x = olx.min(nlx);
408        let min_y = oly.min(nly);
409        let max_x = orx.max(nrx);
410        let max_y = ory.max(nry);
411
412        if min_x > max_x || min_y > max_y {
413            return LogicalRect::default();
414        }
415
416        LogicalRect {
417            origin: LogicalPosition { x: min_x, y: min_y },
418            size: LogicalSize { width: max_x - min_x, height: max_y - min_y },
419        }
420    }
421
422    /// Generates a JSON representation of the display list for debugging.
423    /// This includes clip chain analysis showing how clips are stacked.
424    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
425    pub(crate) fn to_debug_json(&self) -> String {
426        use std::fmt::Write;
427        let mut json = String::new();
428        writeln!(json, "{{").unwrap();
429        writeln!(json, "  \"total_items\": {},", self.items.len()).unwrap();
430        writeln!(json, "  \"items\": [").unwrap();
431
432        let mut clip_depth = 0i32;
433        let mut scroll_depth = 0i32;
434        let mut stacking_depth = 0i32;
435
436        for (i, item) in self.items.iter().enumerate() {
437            let comma = if i < self.items.len() - 1 { "," } else { "" };
438            let node_id = self.node_mapping.get(i).and_then(|n| *n);
439
440            match item {
441                DisplayListItem::PushClip {
442                    bounds,
443                    border_radius,
444                } => {
445                    clip_depth += 1;
446                    writeln!(json, "    {{").unwrap();
447                    writeln!(json, "      \"index\": {i},").unwrap();
448                    writeln!(json, "      \"type\": \"PushClip\",").unwrap();
449                    writeln!(json, "      \"clip_depth\": {clip_depth},").unwrap();
450                    writeln!(json, "      \"scroll_depth\": {scroll_depth},").unwrap();
451                    writeln!(json, "      \"bounds\": {{ \"x\": {:.1}, \"y\": {:.1}, \"w\": {:.1}, \"h\": {:.1} }},", 
452                        bounds.0.origin.x, bounds.0.origin.y, bounds.0.size.width, bounds.0.size.height).unwrap();
453                    writeln!(json, "      \"border_radius\": {{ \"tl\": {:.1}, \"tr\": {:.1}, \"bl\": {:.1}, \"br\": {:.1} }},",
454                        border_radius.top_left, border_radius.top_right,
455                        border_radius.bottom_left, border_radius.bottom_right).unwrap();
456                    writeln!(json, "      \"node_id\": {node_id:?}").unwrap();
457                    writeln!(json, "    }}{comma}").unwrap();
458                }
459                DisplayListItem::PopClip => {
460                    writeln!(json, "    {{").unwrap();
461                    writeln!(json, "      \"index\": {i},").unwrap();
462                    writeln!(json, "      \"type\": \"PopClip\",").unwrap();
463                    writeln!(json, "      \"clip_depth_before\": {clip_depth},").unwrap();
464                    writeln!(json, "      \"clip_depth_after\": {}", clip_depth - 1).unwrap();
465                    writeln!(json, "    }}{comma}").unwrap();
466                    clip_depth -= 1;
467                }
468                DisplayListItem::PushScrollFrame {
469                    clip_bounds,
470                    content_size,
471                    scroll_id,
472                } => {
473                    scroll_depth += 1;
474                    writeln!(json, "    {{").unwrap();
475                    writeln!(json, "      \"index\": {i},").unwrap();
476                    writeln!(json, "      \"type\": \"PushScrollFrame\",").unwrap();
477                    writeln!(json, "      \"clip_depth\": {clip_depth},").unwrap();
478                    writeln!(json, "      \"scroll_depth\": {scroll_depth},").unwrap();
479                    writeln!(json, "      \"clip_bounds\": {{ \"x\": {:.1}, \"y\": {:.1}, \"w\": {:.1}, \"h\": {:.1} }},",
480                        clip_bounds.0.origin.x, clip_bounds.0.origin.y,
481                        clip_bounds.0.size.width, clip_bounds.0.size.height).unwrap();
482                    writeln!(
483                        json,
484                        "      \"content_size\": {{ \"w\": {:.1}, \"h\": {:.1} }},",
485                        content_size.width, content_size.height
486                    )
487                    .unwrap();
488                    writeln!(json, "      \"scroll_id\": {scroll_id},").unwrap();
489                    writeln!(json, "      \"node_id\": {node_id:?}").unwrap();
490                    writeln!(json, "    }}{comma}").unwrap();
491                }
492                DisplayListItem::PopScrollFrame => {
493                    writeln!(json, "    {{").unwrap();
494                    writeln!(json, "      \"index\": {i},").unwrap();
495                    writeln!(json, "      \"type\": \"PopScrollFrame\",").unwrap();
496                    writeln!(json, "      \"scroll_depth_before\": {scroll_depth},").unwrap();
497                    writeln!(json, "      \"scroll_depth_after\": {}", scroll_depth - 1).unwrap();
498                    writeln!(json, "    }}{comma}").unwrap();
499                    scroll_depth -= 1;
500                }
501                DisplayListItem::PushStackingContext { z_index, bounds } => {
502                    stacking_depth += 1;
503                    writeln!(json, "    {{").unwrap();
504                    writeln!(json, "      \"index\": {i},").unwrap();
505                    writeln!(json, "      \"type\": \"PushStackingContext\",").unwrap();
506                    writeln!(json, "      \"stacking_depth\": {stacking_depth},").unwrap();
507                    writeln!(json, "      \"z_index\": {z_index},").unwrap();
508                    writeln!(json, "      \"bounds\": {{ \"x\": {:.1}, \"y\": {:.1}, \"w\": {:.1}, \"h\": {:.1} }}",
509                        bounds.0.origin.x, bounds.0.origin.y, bounds.0.size.width, bounds.0.size.height).unwrap();
510                    writeln!(json, "    }}{comma}").unwrap();
511                }
512                DisplayListItem::PopStackingContext => {
513                    writeln!(json, "    {{").unwrap();
514                    writeln!(json, "      \"index\": {i},").unwrap();
515                    writeln!(json, "      \"type\": \"PopStackingContext\",").unwrap();
516                    writeln!(json, "      \"stacking_depth_before\": {stacking_depth},").unwrap();
517                    writeln!(
518                        json,
519                        "      \"stacking_depth_after\": {}",
520                        stacking_depth - 1
521                    )
522                    .unwrap();
523                    writeln!(json, "    }}{comma}").unwrap();
524                    stacking_depth -= 1;
525                }
526                DisplayListItem::Rect {
527                    bounds,
528                    color,
529                    border_radius,
530                } => {
531                    writeln!(json, "    {{").unwrap();
532                    writeln!(json, "      \"index\": {i},").unwrap();
533                    writeln!(json, "      \"type\": \"Rect\",").unwrap();
534                    writeln!(json, "      \"clip_depth\": {clip_depth},").unwrap();
535                    writeln!(json, "      \"scroll_depth\": {scroll_depth},").unwrap();
536                    writeln!(json, "      \"bounds\": {{ \"x\": {:.1}, \"y\": {:.1}, \"w\": {:.1}, \"h\": {:.1} }},",
537                        bounds.0.origin.x, bounds.0.origin.y, bounds.0.size.width, bounds.0.size.height).unwrap();
538                    writeln!(
539                        json,
540                        "      \"color\": \"rgba({},{},{},{})\",",
541                        color.r, color.g, color.b, color.a
542                    )
543                    .unwrap();
544                    writeln!(json, "      \"node_id\": {node_id:?}").unwrap();
545                    writeln!(json, "    }}{comma}").unwrap();
546                }
547                DisplayListItem::Border { bounds, .. } => {
548                    writeln!(json, "    {{").unwrap();
549                    writeln!(json, "      \"index\": {i},").unwrap();
550                    writeln!(json, "      \"type\": \"Border\",").unwrap();
551                    writeln!(json, "      \"clip_depth\": {clip_depth},").unwrap();
552                    writeln!(json, "      \"scroll_depth\": {scroll_depth},").unwrap();
553                    writeln!(json, "      \"bounds\": {{ \"x\": {:.1}, \"y\": {:.1}, \"w\": {:.1}, \"h\": {:.1} }},",
554                        bounds.0.origin.x, bounds.0.origin.y, bounds.0.size.width, bounds.0.size.height).unwrap();
555                    writeln!(json, "      \"node_id\": {node_id:?}").unwrap();
556                    writeln!(json, "    }}{comma}").unwrap();
557                }
558                DisplayListItem::ScrollBarStyled { info } => {
559                    writeln!(json, "    {{").unwrap();
560                    writeln!(json, "      \"index\": {i},").unwrap();
561                    writeln!(json, "      \"type\": \"ScrollBarStyled\",").unwrap();
562                    writeln!(json, "      \"clip_depth\": {clip_depth},").unwrap();
563                    writeln!(json, "      \"scroll_depth\": {scroll_depth},").unwrap();
564                    writeln!(json, "      \"orientation\": \"{:?}\",", info.orientation).unwrap();
565                    writeln!(json, "      \"bounds\": {{ \"x\": {:.1}, \"y\": {:.1}, \"w\": {:.1}, \"h\": {:.1} }}",
566                        info.bounds.0.origin.x, info.bounds.0.origin.y,
567                        info.bounds.0.size.width, info.bounds.0.size.height).unwrap();
568                    writeln!(json, "    }}{comma}").unwrap();
569                }
570                _ => {
571                    writeln!(json, "    {{").unwrap();
572                    writeln!(json, "      \"index\": {i},").unwrap();
573                    writeln!(
574                        json,
575                        "      \"type\": \"{:?}\",",
576                        std::mem::discriminant(item)
577                    )
578                    .unwrap();
579                    writeln!(json, "      \"clip_depth\": {clip_depth},").unwrap();
580                    writeln!(json, "      \"scroll_depth\": {scroll_depth},").unwrap();
581                    writeln!(json, "      \"node_id\": {node_id:?}").unwrap();
582                    writeln!(json, "    }}{comma}").unwrap();
583                }
584            }
585        }
586
587        writeln!(json, "  ],").unwrap();
588        writeln!(json, "  \"final_clip_depth\": {clip_depth},").unwrap();
589        writeln!(json, "  \"final_scroll_depth\": {scroll_depth},").unwrap();
590        writeln!(json, "  \"final_stacking_depth\": {stacking_depth},").unwrap();
591        writeln!(
592            json,
593            "  \"balanced\": {}",
594            clip_depth == 0 && scroll_depth == 0 && stacking_depth == 0
595        )
596        .unwrap();
597        writeln!(json, "}}").unwrap();
598
599        json
600    }
601}
602
603/// A command in the display list. Can be either a drawing primitive or a
604/// state-management instruction for the renderer's graphics context.
605#[derive(Debug, Clone)]
606pub enum DisplayListItem {
607    // Drawing Primitives
608    /// A filled rectangle with optional rounded corners.
609    /// Used for backgrounds, colored boxes, and other solid fills.
610    Rect {
611        /// The rectangle bounds in absolute window coordinates
612        bounds: WindowLogicalRect,
613        /// The fill color (RGBA)
614        color: ColorU,
615        /// Corner radii for rounded rectangles
616        border_radius: BorderRadius,
617    },
618    /// A selection highlight rectangle (e.g., for text selection).
619    /// Rendered behind text to show selected regions.
620    SelectionRect {
621        /// The rectangle bounds in absolute window coordinates
622        bounds: WindowLogicalRect,
623        /// Corner radii for rounded selection
624        border_radius: BorderRadius,
625        /// The selection highlight color (typically semi-transparent)
626        color: ColorU,
627    },
628    /// A text cursor (caret) rectangle.
629    /// Typically a thin vertical line indicating text insertion point.
630    CursorRect {
631        /// The cursor bounds (usually narrow width)
632        bounds: WindowLogicalRect,
633        /// The cursor color
634        color: ColorU,
635    },
636    /// A CSS border with per-side widths, colors, and styles.
637    /// Supports different styles per side (solid, dashed, dotted, etc.).
638    Border {
639        /// The border-box bounds
640        bounds: WindowLogicalRect,
641        /// Border widths for each side
642        widths: StyleBorderWidths,
643        /// Border colors for each side
644        colors: StyleBorderColors,
645        /// Border styles for each side (solid, dashed, etc.)
646        styles: StyleBorderStyles,
647        /// Corner radii for rounded borders
648        border_radius: StyleBorderRadius,
649    },
650    /// Text layout with full metadata (for PDF, accessibility, etc.)
651    /// This is pushed BEFORE the individual Text items and contains
652    /// the original text, glyph-to-unicode mapping, and positioning info
653    TextLayout {
654        layout: Arc<dyn std::any::Any + Send + Sync>, // Type-erased UnifiedLayout
655        bounds: WindowLogicalRect,
656        font_hash: FontHash,
657        font_size_px: f32,
658        color: ColorU,
659    },
660    /// Text rendered with individual glyph positioning (for simple renderers)
661    Text {
662        glyphs: Vec<GlyphInstance>,
663        font_hash: FontHash,
664        font_size_px: f32,
665        color: ColorU,
666        clip_rect: WindowLogicalRect,
667        /// Layout node index that produced this text run.
668        /// Enables patching glyphs without full display list regeneration.
669        source_node_index: Option<usize>,
670    },
671    /// Underline decoration for text (CSS text-decoration: underline)
672    Underline {
673        bounds: WindowLogicalRect,
674        color: ColorU,
675        thickness: f32,
676    },
677    /// Strikethrough decoration for text (CSS text-decoration: line-through)
678    Strikethrough {
679        bounds: WindowLogicalRect,
680        color: ColorU,
681        thickness: f32,
682    },
683    /// Overline decoration for text (CSS text-decoration: overline)
684    Overline {
685        bounds: WindowLogicalRect,
686        color: ColorU,
687        thickness: f32,
688    },
689    Image {
690        bounds: WindowLogicalRect,
691        image: ImageRef,
692        border_radius: BorderRadius,
693    },
694    /// A dedicated primitive for a scrollbar with optional GPU-animated opacity.
695    /// This is a simple single-color scrollbar used for basic rendering.
696    ScrollBar {
697        bounds: WindowLogicalRect,
698        color: ColorU,
699        orientation: ScrollbarOrientation,
700        /// Optional opacity key for GPU-side fading animation.
701        /// If present, the renderer will use this key to look up dynamic opacity.
702        /// If None, the alpha channel of `color` is used directly.
703        opacity_key: Option<OpacityKey>,
704        /// Optional hit-test ID for `WebRender` hit-testing.
705        /// If present, allows event handlers to identify which scrollbar component was clicked.
706        hit_id: Option<azul_core::hit_test::ScrollbarHitId>,
707    },
708    /// A fully styled scrollbar with separate track, thumb, and optional buttons.
709    /// Used when CSS scrollbar properties are specified.
710    ScrollBarStyled {
711        /// Complete drawing information for all scrollbar components
712        info: Box<ScrollbarDrawInfo>,
713    },
714
715    /// An embedded `VirtualView` that references a child DOM with its own display list.
716    /// The renderer will look up the child display list by `child_dom_id` and
717    /// render it within the bounds. The `VirtualView` viewport is rendered in parent
718    /// coordinate space (NOT inside a scroll frame) so it stays stationary.
719    /// Scroll offset is communicated to the `VirtualView` callback, not via `WebRender`.
720    VirtualView {
721        /// The `DomId` of the child DOM (similar to webrender's `pipeline_id`)
722        child_dom_id: DomId,
723        /// The bounds where the `VirtualView` should be rendered
724        bounds: WindowLogicalRect,
725        /// The clip rect for the `VirtualView` content
726        clip_rect: WindowLogicalRect,
727    },
728
729    /// Placeholder emitted during display list generation for `VirtualView` nodes.
730    /// `window.rs` replaces this with a real `VirtualView` item after invoking
731    /// the `VirtualView` callback. This avoids the need for post-hoc scroll frame
732    /// scanning — `window.rs` simply finds the placeholder by `node_id`.
733    ///
734    /// Unlike regular scrollable nodes, `VirtualView` nodes do NOT get a
735    /// PushScrollFrame/PopScrollFrame pair. Scroll state is managed by
736    /// `ScrollManager` and passed to the `VirtualView` callback as `scroll_offset`.
737    VirtualViewPlaceholder {
738        /// The DOM `NodeId` of the `VirtualView` element in the parent DOM
739        node_id: NodeId,
740        /// The layout bounds of the `VirtualView` container
741        bounds: WindowLogicalRect,
742        /// The clip rect (same as bounds initially, may be adjusted)
743        clip_rect: WindowLogicalRect,
744    },
745
746    // --- State-Management Commands ---
747    /// Pushes a new clipping rectangle onto the renderer's clip stack.
748    /// All subsequent primitives will be clipped by this rect until a `PopClip`.
749    PushClip {
750        bounds: WindowLogicalRect,
751        border_radius: BorderRadius,
752    },
753    /// Pops the current clip from the renderer's clip stack.
754    PopClip,
755
756    /// Pushes an image-based clip mask onto the renderer's clip stack.
757    /// The mask image should be R8 format: white (255) = visible, black (0) = clipped.
758    /// All subsequent primitives will be masked until `PopImageMaskClip`.
759    PushImageMaskClip {
760        /// The bounds of the element being clipped
761        bounds: WindowLogicalRect,
762        /// The mask image (R8 format)
763        mask_image: ImageRef,
764        /// The rect within which the mask is applied
765        mask_rect: WindowLogicalRect,
766    },
767    /// Pops the current image mask clip from the renderer's clip stack.
768    PopImageMaskClip,
769
770    /// Defines a scrollable area. This is a specialized clip that also
771    /// establishes a new coordinate system for its children, which can be offset.
772    PushScrollFrame {
773        /// The clip rect in the parent's coordinate space.
774        clip_bounds: WindowLogicalRect,
775        /// The total size of the scrollable content.
776        content_size: LogicalSize,
777        /// An ID for the renderer to track this scrollable area between frames.
778        scroll_id: LocalScrollId,
779    },
780    /// Pops the current scroll frame.
781    PopScrollFrame,
782
783    /// Pushes a new stacking context for proper z-index layering.
784    /// All subsequent primitives until `PopStackingContext` will be in this stacking context.
785    PushStackingContext {
786        /// The z-index for this stacking context (for debugging/validation)
787        z_index: i32,
788        /// The bounds of the stacking context root element
789        bounds: WindowLogicalRect,
790    },
791    /// Pops the current stacking context.
792    PopStackingContext,
793
794    /// Pushes a reference frame with a GPU-accelerated transform.
795    /// Used for CSS transforms and drag visual offsets.
796    /// Creates a new spatial coordinate system for all children.
797    PushReferenceFrame {
798        /// The transform key for GPU-animated property binding
799        transform_key: TransformKey,
800        /// The initial transform value (identity for drag, computed for CSS transform)
801        initial_transform: ComputedTransform3D,
802        /// The bounds of the reference frame (origin = transform origin)
803        bounds: WindowLogicalRect,
804    },
805    /// Pops the current reference frame.
806    PopReferenceFrame,
807
808    /// Defines a region for hit-testing.
809    HitTestArea {
810        bounds: WindowLogicalRect,
811        tag: DisplayListTagId, // This would be a renderer-agnostic ID type
812    },
813
814    // --- Gradient Primitives ---
815    /// A linear gradient fill.
816    LinearGradient {
817        bounds: WindowLogicalRect,
818        gradient: LinearGradient,
819        border_radius: BorderRadius,
820    },
821    /// A radial gradient fill.
822    RadialGradient {
823        bounds: WindowLogicalRect,
824        gradient: RadialGradient,
825        border_radius: BorderRadius,
826    },
827    /// A conic (angular) gradient fill.
828    ConicGradient {
829        bounds: WindowLogicalRect,
830        gradient: ConicGradient,
831        border_radius: BorderRadius,
832    },
833
834    // --- Shadow Effects ---
835    /// A box shadow (either outset or inset).
836    BoxShadow {
837        bounds: WindowLogicalRect,
838        shadow: StyleBoxShadow,
839        border_radius: BorderRadius,
840    },
841
842    // --- Filter Effects ---
843    /// Push a filter effect that applies to subsequent content.
844    PushFilter {
845        bounds: WindowLogicalRect,
846        filters: Vec<StyleFilter>,
847    },
848    /// Pop a previously pushed filter.
849    PopFilter,
850
851    /// Push a backdrop filter (applies to content behind the element).
852    PushBackdropFilter {
853        bounds: WindowLogicalRect,
854        filters: Vec<StyleFilter>,
855    },
856    /// Pop a previously pushed backdrop filter.
857    PopBackdropFilter,
858
859    /// Push an opacity layer.
860    PushOpacity {
861        bounds: WindowLogicalRect,
862        opacity: f32,
863    },
864    /// Pop an opacity layer.
865    PopOpacity,
866
867    /// Push a text shadow that applies to subsequent text content.
868    PushTextShadow {
869        shadow: StyleBoxShadow,
870    },
871    /// Pop all text shadows.
872    PopTextShadow,
873}
874
875impl DisplayListItem {
876    /// Compare two display list items for visual equality (same appearance when rendered).
877    /// Used by damage computation to detect content changes within the same bounds.
878    /// Conservative: returns `false` (assumes different) for complex types like Arc<dyn Any>.
879    // Exact float equality is intentional: this is frame-to-frame damage detection,
880    // so any bit-level change in a coordinate/color/thickness SHOULD force a redraw.
881    // An epsilon comparison would wrongly skip sub-epsilon visual updates.
882    #[allow(clippy::float_cmp)]
883    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
884    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
885    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
886    #[must_use] pub fn is_visually_equal(&self, other: &Self) -> bool {
887        if std::mem::discriminant(self) != std::mem::discriminant(other) {
888            return false;
889        }
890        match (self, other) {
891            (Self::Rect { bounds: b1, color: c1, border_radius: br1 },
892             Self::Rect { bounds: b2, color: c2, border_radius: br2 }) => {
893                b1 == b2 && c1 == c2 && br1.top_left == br2.top_left && br1.top_right == br2.top_right
894                    && br1.bottom_left == br2.bottom_left && br1.bottom_right == br2.bottom_right
895            }
896            (Self::SelectionRect { bounds: b1, border_radius: br1, color: c1 },
897             Self::SelectionRect { bounds: b2, border_radius: br2, color: c2 }) => {
898                b1 == b2 && c1 == c2 && br1.top_left == br2.top_left && br1.top_right == br2.top_right
899                    && br1.bottom_left == br2.bottom_left && br1.bottom_right == br2.bottom_right
900            }
901            (Self::CursorRect { bounds: b1, color: c1 },
902             Self::CursorRect { bounds: b2, color: c2 }) => b1 == b2 && c1 == c2,
903            (Self::Text { glyphs: g1, font_hash: fh1, font_size_px: fs1, color: c1, clip_rect: cr1, .. },
904             Self::Text { glyphs: g2, font_hash: fh2, font_size_px: fs2, color: c2, clip_rect: cr2, .. }) => {
905                cr1 == cr2 && c1 == c2 && fh1 == fh2 && fs1 == fs2 && g1.len() == g2.len()
906                    && g1.iter().zip(g2.iter()).all(|(a, b)| {
907                        a.index == b.index
908                            && a.point.x == b.point.x
909                            && a.point.y == b.point.y
910                    })
911            }
912            (Self::Underline { bounds: b1, color: c1, thickness: t1 },
913             Self::Underline { bounds: b2, color: c2, thickness: t2 }) => b1 == b2 && c1 == c2 && t1 == t2,
914            (Self::Strikethrough { bounds: b1, color: c1, thickness: t1 },
915             Self::Strikethrough { bounds: b2, color: c2, thickness: t2 }) => b1 == b2 && c1 == c2 && t1 == t2,
916            (Self::Overline { bounds: b1, color: c1, thickness: t1 },
917             Self::Overline { bounds: b2, color: c2, thickness: t2 }) => b1 == b2 && c1 == c2 && t1 == t2,
918            (Self::Border { bounds: b1, widths: w1, colors: c1, styles: s1, .. },
919             Self::Border { bounds: b2, widths: w2, colors: c2, styles: s2, .. }) => {
920                b1 == b2
921                    && w1.top == w2.top && w1.right == w2.right && w1.bottom == w2.bottom && w1.left == w2.left
922                    && c1.top == c2.top && c1.right == c2.right && c1.bottom == c2.bottom && c1.left == c2.left
923                    && s1.top == s2.top && s1.right == s2.right && s1.bottom == s2.bottom && s1.left == s2.left
924            }
925            (Self::Image { bounds: b1, image: i1, border_radius: br1 },
926             Self::Image { bounds: b2, image: i2, border_radius: br2 }) => {
927                b1 == b2
928                    && std::ptr::eq(i1.data, i2.data) // pointer identity
929                    && br1.top_left == br2.top_left && br1.top_right == br2.top_right
930                    && br1.bottom_left == br2.bottom_left && br1.bottom_right == br2.bottom_right
931            }
932            (Self::BoxShadow { bounds: b1, shadow: s1, border_radius: br1 },
933             Self::BoxShadow { bounds: b2, shadow: s2, border_radius: br2 }) => {
934                b1 == b2 && s1 == s2
935                    && br1.top_left == br2.top_left && br1.top_right == br2.top_right
936                    && br1.bottom_left == br2.bottom_left && br1.bottom_right == br2.bottom_right
937            }
938            (Self::LinearGradient { bounds: b1, gradient: g1, border_radius: br1 },
939             Self::LinearGradient { bounds: b2, gradient: g2, border_radius: br2 }) => {
940                b1 == b2 && g1 == g2
941                    && br1.top_left == br2.top_left && br1.top_right == br2.top_right
942                    && br1.bottom_left == br2.bottom_left && br1.bottom_right == br2.bottom_right
943            }
944            (Self::RadialGradient { bounds: b1, gradient: g1, border_radius: br1 },
945             Self::RadialGradient { bounds: b2, gradient: g2, border_radius: br2 }) => {
946                b1 == b2 && g1 == g2
947                    && br1.top_left == br2.top_left && br1.top_right == br2.top_right
948                    && br1.bottom_left == br2.bottom_left && br1.bottom_right == br2.bottom_right
949            }
950            (Self::ConicGradient { bounds: b1, gradient: g1, border_radius: br1 },
951             Self::ConicGradient { bounds: b2, gradient: g2, border_radius: br2 }) => {
952                b1 == b2 && g1 == g2
953                    && br1.top_left == br2.top_left && br1.top_right == br2.top_right
954                    && br1.bottom_left == br2.bottom_left && br1.bottom_right == br2.bottom_right
955            }
956            (Self::ScrollBar { bounds: b1, color: c1, .. },
957             Self::ScrollBar { bounds: b2, color: c2, .. }) => b1 == b2 && c1 == c2,
958            (Self::PushClip { bounds: b1, .. }, Self::PushClip { bounds: b2, .. }) => b1 == b2,
959            (Self::PushScrollFrame { clip_bounds: b1, scroll_id: s1, .. },
960             Self::PushScrollFrame { clip_bounds: b2, scroll_id: s2, .. }) => b1 == b2 && s1 == s2,
961            (Self::PushStackingContext { z_index: z1, bounds: b1 },
962             Self::PushStackingContext { z_index: z2, bounds: b2 }) => z1 == z2 && b1 == b2,
963            (Self::PushOpacity { bounds: b1, opacity: o1 },
964             Self::PushOpacity { bounds: b2, opacity: o2 }) => b1 == b2 && o1 == o2,
965            // Pop items with no fields are always equal (discriminant already matched)
966            (Self::PopClip, Self::PopClip)
967            | (Self::PopImageMaskClip, Self::PopImageMaskClip)
968            | (Self::PopScrollFrame, Self::PopScrollFrame)
969            | (Self::PopStackingContext, Self::PopStackingContext)
970            | (Self::PopReferenceFrame, Self::PopReferenceFrame)
971            | (Self::PopFilter, Self::PopFilter)
972            | (Self::PopBackdropFilter, Self::PopBackdropFilter)
973            | (Self::PopOpacity, Self::PopOpacity)
974            | (Self::PopTextShadow, Self::PopTextShadow) => true,
975            // HitTestArea paints NO pixels (hit-testing only), so two of them are
976            // always visually equal — a moved/changed hit region never needs a
977            // repaint on its own. Without this it hit `_ => false` and forced
978            // false-positive damage on every relayout (#12).
979            (Self::HitTestArea { .. }, Self::HitTestArea { .. }) => true,
980            // TextLayout: visually equal iff same box / font / colour AND the same
981            // underlying (type-erased) layout allocation. A no-op relayout reuses
982            // the cached layout Arc (pointer identity holds); a real text change
983            // reshapes into a new Arc. Without this it hit `_ => false` and
984            // reported damage every frame (#12).
985            (Self::TextLayout { layout: l1, bounds: b1, font_hash: fh1, font_size_px: fs1, color: c1 },
986             Self::TextLayout { layout: l2, bounds: b2, font_hash: fh2, font_size_px: fs2, color: c2 }) => {
987                b1 == b2
988                    && fh1 == fh2
989                    && fs1 == fs2
990                    && c1 == c2
991                    && Arc::ptr_eq(l1, l2)
992            }
993            // ScrollBarStyled: equal iff the STATIC drawing info matches. The
994            // LIVE thumb position/opacity are read from the GPU value cache at
995            // raster time (thumb_transform_key/opacity_key) — value changes are
996            // damaged by the render_frame GPU-value diff, NOT by this item
997            // comparison. Without this arm every scrollbar'd window re-damaged
998            // its bar every frame (`_ => false`), so `FrameDamage::None` was
999            // unreachable and idle windows re-rendered + re-presented forever.
1000            (Self::ScrollBarStyled { info: i1 }, Self::ScrollBarStyled { info: i2 }) => i1 == i2,
1001            // VirtualView: the item only carries WHERE the child renders; the
1002            // child DOM's content changes are detected by
1003            // compute_virtual_view_damage (child display-list diff).
1004            (Self::VirtualView { child_dom_id: d1, bounds: b1, clip_rect: c1 },
1005             Self::VirtualView { child_dom_id: d2, bounds: b2, clip_rect: c2 }) => {
1006                d1 == d2 && b1 == b2 && c1 == c2
1007            }
1008            (Self::VirtualViewPlaceholder { bounds: b1, .. },
1009             Self::VirtualViewPlaceholder { bounds: b2, .. }) => b1 == b2,
1010            // PushReferenceFrame: the LIVE transform (drag, animation) is a GPU
1011            // cache value keyed by transform_key — covered by the GPU-value
1012            // diff, same as the scrollbar thumb.
1013            (Self::PushReferenceFrame { transform_key: k1, initial_transform: t1, bounds: b1 },
1014             Self::PushReferenceFrame { transform_key: k2, initial_transform: t2, bounds: b2 }) => {
1015                k1 == k2 && t1 == t2 && b1 == b2
1016            }
1017            (Self::PushFilter { bounds: b1, filters: f1 },
1018             Self::PushFilter { bounds: b2, filters: f2 }) => b1 == b2 && f1 == f2,
1019            (Self::PushBackdropFilter { bounds: b1, filters: f1 },
1020             Self::PushBackdropFilter { bounds: b2, filters: f2 }) => b1 == b2 && f1 == f2,
1021            // PushImageMaskClip: ImageRef comparison is by underlying data hash
1022            // (cheap identity), so a swapped mask image reports unequal.
1023            (Self::PushImageMaskClip { bounds: b1, mask_image: m1, mask_rect: r1 },
1024             Self::PushImageMaskClip { bounds: b2, mask_image: m2, mask_rect: r2 }) => {
1025                b1 == b2 && r1 == r2 && m1.get_hash() == m2.get_hash()
1026            }
1027            (Self::PushTextShadow { shadow: s1 }, Self::PushTextShadow { shadow: s2 }) => s1 == s2,
1028            // For other complex types (Image, gradients, etc.),
1029            // conservatively assume different
1030            _ => false,
1031        }
1032    }
1033
1034    /// Returns true if this item is a state-management command (Push/Pop)
1035    /// that must always be processed to maintain correct stacks.
1036    #[must_use] pub const fn is_state_management(&self) -> bool {
1037        matches!(self,
1038            Self::PushClip { .. }
1039            | Self::PopClip
1040            | Self::PushImageMaskClip { .. }
1041            | Self::PopImageMaskClip
1042            | Self::PushScrollFrame { .. }
1043            | Self::PopScrollFrame
1044            | Self::PushStackingContext { .. }
1045            | Self::PopStackingContext
1046            | Self::PushReferenceFrame { .. }
1047            | Self::PopReferenceFrame
1048            | Self::PushFilter { .. }
1049            | Self::PopFilter
1050            | Self::PushBackdropFilter { .. }
1051            | Self::PopBackdropFilter
1052            | Self::PushOpacity { .. }
1053            | Self::PopOpacity
1054            | Self::PushTextShadow { .. }
1055            | Self::PopTextShadow
1056        )
1057    }
1058
1059    /// Return the visual bounding rect including effects that extend beyond
1060    /// content bounds (e.g. box-shadow spread/blur/offset). Used for damage
1061    /// rect computation where we need the full repaint area.
1062    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
1063    #[must_use] pub fn visual_bounds(&self) -> Option<LogicalRect> {
1064        match self {
1065            Self::BoxShadow { bounds, shadow, .. } => {
1066                let b = *bounds.inner();
1067                // Shadow can extend beyond element bounds by offset + spread + blur
1068                let ox = shadow
1069                    .offset_x
1070                    .to_pixels_internal(DEFAULT_SHADOW_FONT_SIZE_PX, DEFAULT_SHADOW_FONT_SIZE_PX)
1071                    .abs();
1072                let oy = shadow
1073                    .offset_y
1074                    .to_pixels_internal(DEFAULT_SHADOW_FONT_SIZE_PX, DEFAULT_SHADOW_FONT_SIZE_PX)
1075                    .abs();
1076                let blur = shadow
1077                    .blur_radius
1078                    .to_pixels_internal(DEFAULT_SHADOW_FONT_SIZE_PX, DEFAULT_SHADOW_FONT_SIZE_PX)
1079                    .abs();
1080                let spread = shadow
1081                    .spread_radius
1082                    .to_pixels_internal(DEFAULT_SHADOW_FONT_SIZE_PX, DEFAULT_SHADOW_FONT_SIZE_PX)
1083                    .abs();
1084                let expand = ox + oy + blur + spread;
1085                Some(LogicalRect {
1086                    origin: LogicalPosition {
1087                        x: b.origin.x - expand,
1088                        y: b.origin.y - expand,
1089                    },
1090                    size: LogicalSize {
1091                        width: b.size.width + expand * 2.0,
1092                        height: b.size.height + expand * 2.0,
1093                    },
1094                })
1095            }
1096            _ => self.bounds(),
1097        }
1098    }
1099
1100    /// Return the bounding rect of this item, or None for push/pop commands
1101    /// that don't have their own visual bounds.
1102    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1103    #[must_use] pub fn bounds(&self) -> Option<LogicalRect> {
1104        match self {
1105            Self::Rect { bounds, .. }
1106            | Self::SelectionRect { bounds, .. }
1107            | Self::CursorRect { bounds, .. }
1108            | Self::Border { bounds, .. }
1109            | Self::Text { clip_rect: bounds, .. }
1110            | Self::TextLayout { bounds, .. }
1111            | Self::Underline { bounds, .. }
1112            | Self::Strikethrough { bounds, .. }
1113            | Self::Overline { bounds, .. }
1114            | Self::Image { bounds, .. }
1115            | Self::ScrollBar { bounds, .. }
1116            | Self::LinearGradient { bounds, .. }
1117            | Self::RadialGradient { bounds, .. }
1118            | Self::ConicGradient { bounds, .. }
1119            | Self::BoxShadow { bounds, .. }
1120            | Self::VirtualView { bounds, .. }
1121            | Self::VirtualViewPlaceholder { bounds, .. }
1122            | Self::HitTestArea { bounds, .. }
1123            | Self::PushClip { bounds, .. }
1124            | Self::PushImageMaskClip { bounds, .. }
1125            | Self::PushScrollFrame { clip_bounds: bounds, .. }
1126            | Self::PushStackingContext { bounds, .. }
1127            | Self::PushReferenceFrame { bounds, .. }
1128            | Self::PushFilter { bounds, .. }
1129            | Self::PushBackdropFilter { bounds, .. }
1130            | Self::PushOpacity { bounds, .. } => Some(*bounds.inner()),
1131            Self::ScrollBarStyled { info, .. } => Some(*info.bounds.inner()),
1132            Self::PushTextShadow { .. } => None, // text shadow has no bounds, affects following text
1133            Self::PopClip
1134            | Self::PopImageMaskClip
1135            | Self::PopScrollFrame
1136            | Self::PopStackingContext
1137            | Self::PopReferenceFrame
1138            | Self::PopFilter
1139            | Self::PopBackdropFilter
1140            | Self::PopOpacity
1141            | Self::PopTextShadow => None,
1142        }
1143    }
1144}
1145
1146// Helper structs for the DisplayList
1147#[derive(Debug, Copy, Clone, Default, PartialEq)]
1148pub struct BorderRadius {
1149    pub top_left: f32,
1150    pub top_right: f32,
1151    pub bottom_left: f32,
1152    pub bottom_right: f32,
1153}
1154
1155impl BorderRadius {
1156    #[must_use] pub fn is_zero(&self) -> bool {
1157        self.top_left == 0.0
1158            && self.top_right == 0.0
1159            && self.bottom_left == 0.0
1160            && self.bottom_right == 0.0
1161    }
1162}
1163
1164// Dummy types for compilation
1165pub type LocalScrollId = u64;
1166/// Display list tag ID as (payload, `type_marker`) tuple.
1167/// The u16 field is used as a namespace marker:
1168/// - 0x0100 = DOM Node (regular interactive elements)
1169/// - 0x0200 = Scrollbar component
1170pub(crate) type DisplayListTagId = (u64, u16);
1171
1172/// Internal builder to accumulate display list items during generation.
1173#[derive(Debug, Default)]
1174struct DisplayListBuilder {
1175    items: Vec<DisplayListItem>,
1176    node_mapping: Vec<Option<NodeId>>,
1177    /// Current node being processed (set by generator)
1178    current_node: Option<NodeId>,
1179    /// Collected debug messages (transferred to ctx on finalize)
1180    debug_messages: Vec<LayoutDebugMessage>,
1181    /// Whether debug logging is enabled
1182    debug_enabled: bool,
1183    /// Y-positions where forced page breaks should occur
1184    forced_page_breaks: Vec<f32>,
1185    /// Index ranges of items from fixed-position elements (for paged media replication)
1186    fixed_position_item_ranges: Vec<(usize, usize)>,
1187    /// Start index of the current fixed-position element being built, if any
1188    fixed_position_start: Option<usize>,
1189}
1190
1191impl DisplayListBuilder {
1192    pub(crate) fn new() -> Self {
1193        Self::default()
1194    }
1195
1196    pub(crate) const fn with_debug(debug_enabled: bool) -> Self {
1197        Self {
1198            items: Vec::new(),
1199            node_mapping: Vec::new(),
1200            current_node: None,
1201            debug_messages: Vec::new(),
1202            debug_enabled,
1203            forced_page_breaks: Vec::new(),
1204            fixed_position_item_ranges: Vec::new(),
1205            fixed_position_start: None,
1206        }
1207    }
1208
1209    /// Log a debug message if debug is enabled
1210    fn debug_log(&mut self, message: String) {
1211        if self.debug_enabled {
1212            self.debug_messages.push(LayoutDebugMessage::info(message));
1213        }
1214    }
1215
1216    /// Build the display list and transfer debug messages to the provided option
1217    pub(crate) fn build_with_debug(
1218        mut self,
1219        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
1220    ) -> DisplayList {
1221        // Transfer collected debug messages to the context
1222        if let Some(msgs) = debug_messages.as_mut() {
1223            msgs.append(&mut self.debug_messages);
1224        }
1225        DisplayList {
1226            items: self.items,
1227            node_mapping: self.node_mapping,
1228            forced_page_breaks: self.forced_page_breaks,
1229            fixed_position_item_ranges: self.fixed_position_item_ranges,
1230        }
1231    }
1232
1233    /// Set the current node context for subsequent push operations
1234    pub(crate) const fn set_current_node(&mut self, node_id: Option<NodeId>) {
1235        self.current_node = node_id;
1236    }
1237
1238    /// Mark the start of a fixed-position element's display items.
1239    pub(crate) const fn begin_fixed_position_element(&mut self) {
1240        self.fixed_position_start = Some(self.items.len());
1241    }
1242
1243    /// Mark the end of a fixed-position element's display items.
1244    /// Records the (start, end) index range for paged media replication.
1245    pub(crate) fn end_fixed_position_element(&mut self) {
1246        if let Some(start) = self.fixed_position_start.take() {
1247            let end = self.items.len();
1248            if end > start {
1249                self.fixed_position_item_ranges.push((start, end));
1250            }
1251        }
1252    }
1253
1254    /// Register a forced page break at the given Y position.
1255    /// This is used for CSS break-before: always and break-after: always.
1256    pub(crate) fn add_forced_page_break(&mut self, y_position: f32) {
1257        // Avoid duplicates and keep sorted
1258        if !self.forced_page_breaks.contains(&y_position) {
1259            self.forced_page_breaks.push(y_position);
1260            self.forced_page_breaks
1261                .sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
1262        }
1263    }
1264
1265    /// Push an item and record its node mapping
1266    fn push_item(&mut self, item: DisplayListItem) {
1267        self.items.push(item);
1268        self.node_mapping.push(self.current_node);
1269    }
1270
1271    pub(crate) fn build(self) -> DisplayList {
1272        DisplayList {
1273            items: self.items,
1274            node_mapping: self.node_mapping,
1275            forced_page_breaks: self.forced_page_breaks,
1276            fixed_position_item_ranges: self.fixed_position_item_ranges,
1277        }
1278    }
1279
1280    pub(crate) fn push_hit_test_area(&mut self, bounds: LogicalRect, tag: DisplayListTagId) {
1281        self.push_item(DisplayListItem::HitTestArea { bounds: bounds.into(), tag });
1282    }
1283
1284    /// Push a simple single-color scrollbar (legacy method).
1285    pub(crate) fn push_scrollbar(
1286        &mut self,
1287        bounds: LogicalRect,
1288        color: ColorU,
1289        orientation: ScrollbarOrientation,
1290        opacity_key: Option<OpacityKey>,
1291        hit_id: Option<azul_core::hit_test::ScrollbarHitId>,
1292    ) {
1293        if color.a > 0 || opacity_key.is_some() {
1294            // Optimization: Don't draw fully transparent items without opacity keys.
1295            self.push_item(DisplayListItem::ScrollBar {
1296                bounds: bounds.into(),
1297                color,
1298                orientation,
1299                opacity_key,
1300                hit_id,
1301            });
1302        }
1303    }
1304
1305    /// Push a fully styled scrollbar with track, thumb, and optional buttons.
1306    pub(crate) fn push_scrollbar_styled(&mut self, info: ScrollbarDrawInfo) {
1307        // Only push if at least the thumb or track is visible
1308        if info.thumb_color.a > 0 || info.track_color.a > 0 || info.opacity_key.is_some() {
1309            self.push_item(DisplayListItem::ScrollBarStyled {
1310                info: Box::new(info),
1311            });
1312        }
1313    }
1314
1315    pub(crate) fn push_rect(&mut self, bounds: LogicalRect, color: ColorU, border_radius: BorderRadius) {
1316        if color.a > 0 {
1317            // Optimization: Don't draw fully transparent items.
1318            self.push_item(DisplayListItem::Rect {
1319                bounds: bounds.into(),
1320                color,
1321                border_radius,
1322            });
1323        }
1324    }
1325
1326    /// Unified method to paint all background layers and border for an element.
1327    ///
1328    /// This consolidates the background/border painting logic that was previously
1329    /// duplicated across:
1330    /// - `paint_node_background_and_border()` for block elements
1331    /// - `paint_inline_shape()` for inline-block elements
1332    ///
1333    /// The backgrounds are painted in order (back to front per CSS spec), followed
1334    /// by the border.
1335    pub(crate) fn push_backgrounds_and_border(
1336        &mut self,
1337        bounds: LogicalRect,
1338        background_contents: &[azul_css::props::style::StyleBackgroundContent],
1339        border_info: &BorderInfo,
1340        simple_border_radius: BorderRadius,
1341        style_border_radius: StyleBorderRadius,
1342        image_cache: &azul_core::resources::ImageCache,
1343    ) {
1344        use azul_css::props::style::StyleBackgroundContent;
1345
1346        // Paint all background layers in order (CSS paints backgrounds back to front)
1347        for bg in background_contents {
1348            match bg {
1349                StyleBackgroundContent::Color(color) => {
1350                    self.push_rect(bounds, *color, simple_border_radius);
1351                }
1352                StyleBackgroundContent::LinearGradient(gradient) => {
1353                    self.push_linear_gradient(bounds, gradient.clone(), simple_border_radius);
1354                }
1355                StyleBackgroundContent::RadialGradient(gradient) => {
1356                    self.push_radial_gradient(bounds, gradient.clone(), simple_border_radius);
1357                }
1358                StyleBackgroundContent::ConicGradient(gradient) => {
1359                    self.push_conic_gradient(bounds, gradient.clone(), simple_border_radius);
1360                }
1361                StyleBackgroundContent::Image(image_id) => {
1362                    if let Some(image_ref) = image_cache.get_css_image_id(image_id) {
1363                        self.push_image(bounds, image_ref.clone(), simple_border_radius);
1364                    }
1365                }
1366                StyleBackgroundContent::SystemColor(_s) => {
1367                    // TODO(superplan g8): resolve via SystemColorRef::resolve(&SystemColors,
1368                    // fallback) and push_rect. SystemColors is not threaded into the
1369                    // display-list builder yet, so `background: system:<name>` currently
1370                    // parses but paints nothing (graceful no-op rather than a wrong color).
1371                }
1372            }
1373        }
1374
1375        // Paint border
1376        self.push_border(
1377            bounds,
1378            border_info.widths,
1379            border_info.colors,
1380            border_info.styles,
1381            style_border_radius,
1382        );
1383    }
1384
1385    /// Paint backgrounds and border for inline text elements.
1386    ///
1387    /// Similar to `push_backgrounds_and_border` but uses `InlineBorderInfo` which stores
1388    /// pre-resolved pixel values instead of CSS property values. This is used for
1389    /// inline (display: inline) elements where the border info is computed during
1390    /// text layout and stored in the glyph runs.
1391    pub(crate) fn push_inline_backgrounds_and_border(
1392        &mut self,
1393        bounds: LogicalRect,
1394        background_color: Option<ColorU>,
1395        background_contents: &[azul_css::props::style::StyleBackgroundContent],
1396        border: Option<&crate::text3::cache::InlineBorderInfo>,
1397        image_cache: &azul_core::resources::ImageCache,
1398    ) {
1399        use azul_css::props::style::StyleBackgroundContent;
1400
1401        // Paint solid background color if present
1402        if let Some(bg_color) = background_color {
1403            self.push_rect(bounds, bg_color, BorderRadius::default());
1404        }
1405
1406        // Paint all background layers in order (CSS paints backgrounds back to front)
1407        for bg in background_contents {
1408            match bg {
1409                StyleBackgroundContent::Color(color) => {
1410                    self.push_rect(bounds, *color, BorderRadius::default());
1411                }
1412                StyleBackgroundContent::LinearGradient(gradient) => {
1413                    self.push_linear_gradient(bounds, gradient.clone(), BorderRadius::default());
1414                }
1415                StyleBackgroundContent::RadialGradient(gradient) => {
1416                    self.push_radial_gradient(bounds, gradient.clone(), BorderRadius::default());
1417                }
1418                StyleBackgroundContent::ConicGradient(gradient) => {
1419                    self.push_conic_gradient(bounds, gradient.clone(), BorderRadius::default());
1420                }
1421                StyleBackgroundContent::Image(image_id) => {
1422                    if let Some(image_ref) = image_cache.get_css_image_id(image_id) {
1423                        self.push_image(bounds, image_ref.clone(), BorderRadius::default());
1424                    }
1425                }
1426                StyleBackgroundContent::SystemColor(_s) => {
1427                    // TODO(superplan g8): resolve via SystemColorRef::resolve(&SystemColors,
1428                    // fallback) and push_rect. SystemColors is not threaded into the
1429                    // display-list builder yet, so `background: system:<name>` currently
1430                    // parses but paints nothing (graceful no-op rather than a wrong color).
1431                }
1432            }
1433        }
1434
1435        // Paint border if present
1436        // CSS 2.2 §8.6: suppress left/right borders at split points, respecting direction
1437        if let Some(border) = border {
1438            let effective_left = if border.left_inset() > 0.0 { border.left } else { 0.0 };
1439            let effective_right = if border.right_inset() > 0.0 { border.right } else { 0.0 };
1440            if border.top > 0.0 || effective_right > 0.0 || border.bottom > 0.0 || effective_left > 0.0 {
1441                let border_widths = StyleBorderWidths {
1442                    top: Some(CssPropertyValue::Exact(LayoutBorderTopWidth {
1443                        inner: PixelValue::px(border.top),
1444                    })),
1445                    right: Some(CssPropertyValue::Exact(LayoutBorderRightWidth {
1446                        inner: PixelValue::px(effective_right),
1447                    })),
1448                    bottom: Some(CssPropertyValue::Exact(LayoutBorderBottomWidth {
1449                        inner: PixelValue::px(border.bottom),
1450                    })),
1451                    left: Some(CssPropertyValue::Exact(LayoutBorderLeftWidth {
1452                        inner: PixelValue::px(effective_left),
1453                    })),
1454                };
1455                let border_colors = StyleBorderColors {
1456                    top: Some(CssPropertyValue::Exact(StyleBorderTopColor {
1457                        inner: border.top_color,
1458                    })),
1459                    right: Some(CssPropertyValue::Exact(StyleBorderRightColor {
1460                        inner: border.right_color,
1461                    })),
1462                    bottom: Some(CssPropertyValue::Exact(StyleBorderBottomColor {
1463                        inner: border.bottom_color,
1464                    })),
1465                    left: Some(CssPropertyValue::Exact(StyleBorderLeftColor {
1466                        inner: border.left_color,
1467                    })),
1468                };
1469                let border_styles = StyleBorderStyles {
1470                    top: Some(CssPropertyValue::Exact(StyleBorderTopStyle {
1471                        inner: BorderStyle::Solid,
1472                    })),
1473                    right: Some(CssPropertyValue::Exact(StyleBorderRightStyle {
1474                        inner: BorderStyle::Solid,
1475                    })),
1476                    bottom: Some(CssPropertyValue::Exact(StyleBorderBottomStyle {
1477                        inner: BorderStyle::Solid,
1478                    })),
1479                    left: Some(CssPropertyValue::Exact(StyleBorderLeftStyle {
1480                        inner: BorderStyle::Solid,
1481                    })),
1482                };
1483                let radius_px = PixelValue::px(border.radius.unwrap_or(0.0));
1484                let border_radius = StyleBorderRadius {
1485                    top_left: radius_px,
1486                    top_right: radius_px,
1487                    bottom_left: radius_px,
1488                    bottom_right: radius_px,
1489                };
1490
1491                self.push_border(
1492                    bounds,
1493                    border_widths,
1494                    border_colors,
1495                    border_styles,
1496                    border_radius,
1497                );
1498            }
1499        }
1500    }
1501
1502    /// Push a linear gradient background
1503    pub(crate) fn push_linear_gradient(
1504        &mut self,
1505        bounds: LogicalRect,
1506        gradient: LinearGradient,
1507        border_radius: BorderRadius,
1508    ) {
1509        self.push_item(DisplayListItem::LinearGradient {
1510            bounds: bounds.into(),
1511            gradient,
1512            border_radius,
1513        });
1514    }
1515
1516    /// Push a radial gradient background
1517    pub(crate) fn push_radial_gradient(
1518        &mut self,
1519        bounds: LogicalRect,
1520        gradient: RadialGradient,
1521        border_radius: BorderRadius,
1522    ) {
1523        self.push_item(DisplayListItem::RadialGradient {
1524            bounds: bounds.into(),
1525            gradient,
1526            border_radius,
1527        });
1528    }
1529
1530    /// Push a conic gradient background
1531    pub(crate) fn push_conic_gradient(
1532        &mut self,
1533        bounds: LogicalRect,
1534        gradient: ConicGradient,
1535        border_radius: BorderRadius,
1536    ) {
1537        self.push_item(DisplayListItem::ConicGradient {
1538            bounds: bounds.into(),
1539            gradient,
1540            border_radius,
1541        });
1542    }
1543
1544    pub(crate) fn push_selection_rect(
1545        &mut self,
1546        bounds: LogicalRect,
1547        color: ColorU,
1548        border_radius: BorderRadius,
1549    ) {
1550        if color.a > 0 {
1551            self.push_item(DisplayListItem::SelectionRect {
1552                bounds: bounds.into(),
1553                color,
1554                border_radius,
1555            });
1556        }
1557    }
1558
1559    pub(crate) fn push_cursor_rect(&mut self, bounds: LogicalRect, color: ColorU) {
1560        // Always emit the caret item — even with alpha == 0 in the blink-off phase — so
1561        // the display-list item COUNT stays stable across blink phases. That lets
1562        // compute_display_list_damage diff it down to a caret-sized rect instead of
1563        // falling back to a full-window repaint every ~530ms.
1564        self.push_item(DisplayListItem::CursorRect { bounds: bounds.into(), color });
1565    }
1566    pub(crate) fn push_clip(&mut self, bounds: LogicalRect, border_radius: BorderRadius) {
1567        self.push_item(DisplayListItem::PushClip {
1568            bounds: bounds.into(),
1569            border_radius,
1570        });
1571    }
1572    pub(crate) fn pop_clip(&mut self) {
1573        self.push_item(DisplayListItem::PopClip);
1574    }
1575    pub(crate) fn push_image_mask_clip(&mut self, bounds: LogicalRect, mask_image: ImageRef, mask_rect: LogicalRect) {
1576        self.push_item(DisplayListItem::PushImageMaskClip {
1577            bounds: bounds.into(),
1578            mask_image,
1579            mask_rect: mask_rect.into(),
1580        });
1581    }
1582    pub(crate) fn pop_image_mask_clip(&mut self) {
1583        self.push_item(DisplayListItem::PopImageMaskClip);
1584    }
1585    pub(crate) fn push_scroll_frame(
1586        &mut self,
1587        clip_bounds: LogicalRect,
1588        content_size: LogicalSize,
1589        scroll_id: LocalScrollId,
1590    ) {
1591        self.push_item(DisplayListItem::PushScrollFrame {
1592            clip_bounds: clip_bounds.into(),
1593            content_size,
1594            scroll_id,
1595        });
1596    }
1597    pub(crate) fn pop_scroll_frame(&mut self) {
1598        self.push_item(DisplayListItem::PopScrollFrame);
1599    }
1600    pub(crate) fn push_virtual_view_placeholder(
1601        &mut self,
1602        node_id: NodeId,
1603        bounds: LogicalRect,
1604        clip_rect: LogicalRect,
1605    ) {
1606        self.push_item(DisplayListItem::VirtualViewPlaceholder {
1607            node_id,
1608            bounds: bounds.into(),
1609            clip_rect: clip_rect.into(),
1610        });
1611    }
1612    pub(crate) fn push_border(
1613        &mut self,
1614        bounds: LogicalRect,
1615        widths: StyleBorderWidths,
1616        colors: StyleBorderColors,
1617        styles: StyleBorderStyles,
1618        border_radius: StyleBorderRadius,
1619    ) {
1620        // Check if any border side is visible
1621        let has_visible_border = {
1622            let has_width = widths.top.is_some()
1623                || widths.right.is_some()
1624                || widths.bottom.is_some()
1625                || widths.left.is_some();
1626            let has_style = styles.top.is_some()
1627                || styles.right.is_some()
1628                || styles.bottom.is_some()
1629                || styles.left.is_some();
1630            has_width && has_style
1631        };
1632
1633        if has_visible_border {
1634            self.push_item(DisplayListItem::Border {
1635                bounds: bounds.into(),
1636                widths,
1637                colors,
1638                styles,
1639                border_radius,
1640            });
1641        }
1642    }
1643
1644    pub(crate) fn push_stacking_context(&mut self, z_index: i32, bounds: LogicalRect) {
1645        self.push_item(DisplayListItem::PushStackingContext { z_index, bounds: bounds.into() });
1646    }
1647
1648    pub(crate) fn pop_stacking_context(&mut self) {
1649        self.push_item(DisplayListItem::PopStackingContext);
1650    }
1651
1652    pub(crate) fn push_reference_frame(
1653        &mut self,
1654        transform_key: TransformKey,
1655        initial_transform: ComputedTransform3D,
1656        bounds: LogicalRect,
1657    ) {
1658        self.push_item(DisplayListItem::PushReferenceFrame {
1659            transform_key,
1660            initial_transform,
1661            bounds: bounds.into(),
1662        });
1663    }
1664
1665    pub(crate) fn pop_reference_frame(&mut self) {
1666        self.push_item(DisplayListItem::PopReferenceFrame);
1667    }
1668
1669    pub(crate) fn push_text_run(
1670        &mut self,
1671        glyphs: Vec<GlyphInstance>,
1672        font_hash: FontHash, // Just the hash, not the full FontRef
1673        font_size_px: f32,
1674        color: ColorU,
1675        clip_rect: LogicalRect,
1676        source_node_index: Option<usize>,
1677    ) {
1678        self.debug_log(format!(
1679            "[push_text_run] {} glyphs, font_size={}px, color=({},{},{},{}), clip={:?}",
1680            glyphs.len(),
1681            font_size_px,
1682            color.r,
1683            color.g,
1684            color.b,
1685            color.a,
1686            clip_rect
1687        ));
1688
1689        if !glyphs.is_empty() && color.a > 0 {
1690            self.push_item(DisplayListItem::Text {
1691                glyphs,
1692                font_hash,
1693                font_size_px,
1694                color,
1695                clip_rect: clip_rect.into(),
1696                source_node_index,
1697            });
1698        } else {
1699            self.debug_log(format!(
1700                "[push_text_run] SKIPPED: glyphs.is_empty()={}, color.a={}",
1701                glyphs.is_empty(),
1702                color.a
1703            ));
1704        }
1705    }
1706
1707    pub(crate) fn push_text_layout(
1708        &mut self,
1709        layout: Arc<dyn std::any::Any + Send + Sync>,
1710        bounds: LogicalRect,
1711        font_hash: FontHash,
1712        font_size_px: f32,
1713        color: ColorU,
1714    ) {
1715        if color.a > 0 {
1716            self.push_item(DisplayListItem::TextLayout {
1717                layout,
1718                bounds: bounds.into(),
1719                font_hash,
1720                font_size_px,
1721                color,
1722            });
1723        }
1724    }
1725
1726    pub(crate) fn push_underline(&mut self, bounds: LogicalRect, color: ColorU, thickness: f32) {
1727        if color.a > 0 && thickness > 0.0 {
1728            self.push_item(DisplayListItem::Underline {
1729                bounds: bounds.into(),
1730                color,
1731                thickness,
1732            });
1733        }
1734    }
1735
1736    pub(crate) fn push_strikethrough(&mut self, bounds: LogicalRect, color: ColorU, thickness: f32) {
1737        if color.a > 0 && thickness > 0.0 {
1738            self.push_item(DisplayListItem::Strikethrough {
1739                bounds: bounds.into(),
1740                color,
1741                thickness,
1742            });
1743        }
1744    }
1745
1746    pub(crate) fn push_overline(&mut self, bounds: LogicalRect, color: ColorU, thickness: f32) {
1747        if color.a > 0 && thickness > 0.0 {
1748            self.push_item(DisplayListItem::Overline {
1749                bounds: bounds.into(),
1750                color,
1751                thickness,
1752            });
1753        }
1754    }
1755
1756    pub(crate) fn push_image(&mut self, bounds: LogicalRect, image: ImageRef, border_radius: BorderRadius) {
1757        self.push_item(DisplayListItem::Image { bounds: bounds.into(), image, border_radius });
1758    }
1759}
1760
1761/// Main entry point for generating the display list.
1762#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
1763/// # Errors
1764///
1765/// Returns a `LayoutError` if display-list generation fails.
1766pub fn generate_display_list<T: ParsedFontTrait + Sync + 'static>(
1767    ctx: &mut LayoutContext<'_, T>,
1768    tree: &LayoutTree,
1769    calculated_positions: &super::PositionVec,
1770    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
1771    scroll_ids: &HashMap<usize, u64>,
1772    gpu_value_cache: Option<&GpuValueCache>,
1773    renderer_resources: &RendererResources,
1774    id_namespace: IdNamespace,
1775    dom_id: DomId,
1776) -> Result<DisplayList> {
1777    debug_info!(
1778        ctx,
1779        "[DisplayList] generate_display_list: tree has {} nodes, {} positions calculated",
1780        tree.nodes.len(),
1781        calculated_positions.len()
1782    );
1783
1784    debug_info!(ctx, "Starting display list generation");
1785    debug_info!(
1786        ctx,
1787        "Collecting stacking contexts from root node {}",
1788        tree.root
1789    );
1790
1791    let positioned_tree = PositionedTree {
1792        tree,
1793        calculated_positions,
1794    };
1795    let mut generator = DisplayListGenerator::new(
1796        ctx,
1797        scroll_offsets,
1798        &positioned_tree,
1799        scroll_ids,
1800        gpu_value_cache,
1801        renderer_resources,
1802        id_namespace,
1803        dom_id,
1804    );
1805
1806    // Create builder with debug enabled if ctx has debug messages
1807    let debug_enabled = generator.ctx.debug_messages.is_some();
1808    let mut builder = DisplayListBuilder::with_debug(debug_enabled);
1809
1810    // 0. Canvas background propagation (CSS 2.1 § 14.2):
1811    //    "The background of the root element becomes the background of the canvas."
1812    //    If the root (html) has a transparent background, propagate from <body>.
1813    //    The canvas background fills the ENTIRE viewport, not just the root's content box.
1814    //    This is critical when <html> doesn't have height:100% — without this,
1815    //    the body's background only covers the body's content area, not the viewport.
1816    {
1817        let root_node = tree.get(tree.root);
1818        if let Some(root) = root_node {
1819            if let Some(root_dom_id) = root.dom_node_id {
1820                let root_state = generator.get_styled_node_state(root_dom_id);
1821                let canvas_bg = get_background_color(
1822                    generator.ctx.styled_dom,
1823                    root_dom_id,
1824                    &root_state,
1825                );
1826                if canvas_bg.a > 0 {
1827                    let viewport_rect = LogicalRect {
1828                        origin: LogicalPosition::zero(),
1829                        size: generator.ctx.viewport_size,
1830                    };
1831                    builder.push_rect(viewport_rect, canvas_bg, BorderRadius::default());
1832                    debug_info!(
1833                        generator.ctx,
1834                        "[DisplayList] Canvas background: color=({},{},{},{}), size={:?}",
1835                        canvas_bg.r, canvas_bg.g, canvas_bg.b, canvas_bg.a,
1836                        generator.ctx.viewport_size
1837                    );
1838                }
1839            }
1840        }
1841    }
1842
1843    // +spec:stacking-contexts:33d435 - CSS 2.2 painting order: build stacking context tree then traverse in z-order
1844    // +spec:stacking-contexts:887766 - CSS2 §9.9 stacking contexts, z-index layering, and painting order
1845    // 1. Build a tree of stacking contexts, which defines the global paint order.
1846    // +spec:display-property:9a419c - root element always forms a stacking context (it's the tree root)
1847    let stacking_context_tree = generator.collect_stacking_contexts(tree.root)?;
1848
1849    // 2. Traverse the stacking context tree to generate display items in the correct order.
1850    debug_info!(
1851        generator.ctx,
1852        "Generating display items from stacking context tree"
1853    );
1854    generator.generate_for_stacking_context(&mut builder, &stacking_context_tree)?;
1855
1856    // Build display list and transfer debug messages to context
1857    let display_list = builder.build_with_debug(generator.ctx.debug_messages);
1858    debug_info!(
1859        generator.ctx,
1860        "[DisplayList] Generated {} display items",
1861        display_list.items.len()
1862    );
1863    Ok(display_list)
1864}
1865
1866/// A helper struct that holds all necessary state and context for the generation process.
1867struct DisplayListGenerator<'a, 'b, T: ParsedFontTrait> {
1868    ctx: &'a mut LayoutContext<'b, T>,
1869    scroll_offsets: &'a BTreeMap<NodeId, ScrollPosition>,
1870    positioned_tree: &'a PositionedTree<'a>,
1871    scroll_ids: &'a HashMap<usize, u64>,
1872    gpu_value_cache: Option<&'a GpuValueCache>,
1873    renderer_resources: &'a RendererResources,
1874    id_namespace: IdNamespace,
1875    dom_id: DomId,
1876}
1877
1878// +spec:stacking-contexts:9e85a3 - Stacking context tree: hierarchical, nested, atomic painting order
1879/// Represents a node in the CSS stacking context tree, not the DOM tree.
1880#[derive(Debug)]
1881struct StackingContext {
1882    node_index: usize,
1883    z_index: i32,
1884    child_contexts: Vec<StackingContext>,
1885    /// Children that do not create their own stacking contexts and are painted in DOM order.
1886    in_flow_children: Vec<usize>,
1887}
1888
1889impl<'a, 'b, T> DisplayListGenerator<'a, 'b, T>
1890where
1891    T: ParsedFontTrait + Sync + 'static,
1892{
1893    pub(crate) const fn new(
1894        ctx: &'a mut LayoutContext<'b, T>,
1895        scroll_offsets: &'a BTreeMap<NodeId, ScrollPosition>,
1896        positioned_tree: &'a PositionedTree<'a>,
1897        scroll_ids: &'a HashMap<usize, u64>,
1898        gpu_value_cache: Option<&'a GpuValueCache>,
1899        renderer_resources: &'a RendererResources,
1900        id_namespace: IdNamespace,
1901        dom_id: DomId,
1902    ) -> Self {
1903        Self {
1904            ctx,
1905            scroll_offsets,
1906            positioned_tree,
1907            scroll_ids,
1908            gpu_value_cache,
1909            renderer_resources,
1910            id_namespace,
1911            dom_id,
1912        }
1913    }
1914
1915    /// Helper to get styled node state for a node
1916    fn get_styled_node_state(&self, dom_id: NodeId) -> azul_core::styled_dom::StyledNodeState {
1917        self.ctx
1918            .styled_dom
1919            .styled_nodes
1920            .as_container()
1921            .get(dom_id)
1922            .map(|n| n.styled_node_state)
1923            .unwrap_or_default()
1924    }
1925
1926    // +spec:overflow:visibility - CSS 2.2 §11.2: visibility:hidden makes the box invisible
1927    // but still affects layout. Checked per-node because visibility is inherited and a child
1928    // with visibility:visible inside a hidden parent must still be painted.
1929    fn is_node_hidden(&self, node_index: usize) -> bool {
1930        use azul_css::props::style::effects::StyleVisibility;
1931        let Some(node) = self.positioned_tree.tree.get(node_index) else {
1932            return false;
1933        };
1934        let Some(dom_id) = node.dom_node_id else {
1935            return false;
1936        };
1937        let node_state = self.get_styled_node_state(dom_id);
1938        matches!(
1939            get_visibility(self.ctx.styled_dom, dom_id, &node_state),
1940            crate::solver3::getters::MultiValue::Exact(
1941                StyleVisibility::Hidden | StyleVisibility::Collapse
1942            )
1943        )
1944    }
1945
1946    /// Gets the cursor type for a text node from its CSS properties.
1947    /// Defaults to Text (I-beam) cursor if no explicit cursor is set.
1948    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1949    fn get_cursor_type_for_text_node(&self, node_id: NodeId) -> CursorType {
1950        use azul_css::props::style::effects::StyleCursor;
1951        
1952        let styled_node_state = self.get_styled_node_state(node_id);
1953        let node_data_container = self.ctx.styled_dom.node_data.as_container();
1954        let node_data = node_data_container.get(node_id);
1955        
1956        // Query the cursor CSS property for this text node
1957        if let Some(node_data) = node_data {
1958            if let Some(CssPropertyValue::Exact(cursor)) = self.ctx.styled_dom.get_css_property_cache().get_cursor(
1959                node_data,
1960                &node_id,
1961                &styled_node_state,
1962            ) {
1963                    return match cursor {
1964                        StyleCursor::Default => CursorType::Default,
1965                        StyleCursor::Pointer => CursorType::Pointer,
1966                        StyleCursor::Text => CursorType::Text,
1967                        StyleCursor::Crosshair => CursorType::Crosshair,
1968                        StyleCursor::Move => CursorType::Move,
1969                        StyleCursor::Help => CursorType::Help,
1970                        StyleCursor::Wait => CursorType::Wait,
1971                        StyleCursor::Progress => CursorType::Progress,
1972                        StyleCursor::NsResize => CursorType::NsResize,
1973                        StyleCursor::EwResize => CursorType::EwResize,
1974                        StyleCursor::NeswResize => CursorType::NeswResize,
1975                        StyleCursor::NwseResize => CursorType::NwseResize,
1976                        StyleCursor::NResize => CursorType::NResize,
1977                        StyleCursor::SResize => CursorType::SResize,
1978                        StyleCursor::EResize => CursorType::EResize,
1979                        StyleCursor::WResize => CursorType::WResize,
1980                        StyleCursor::Grab => CursorType::Grab,
1981                        StyleCursor::Grabbing => CursorType::Grabbing,
1982                        StyleCursor::RowResize => CursorType::RowResize,
1983                        StyleCursor::ColResize => CursorType::ColResize,
1984                        // Map less common cursors to closest available
1985                        StyleCursor::SeResize | StyleCursor::NeswResize => CursorType::NeswResize,
1986                        StyleCursor::ZoomIn | StyleCursor::ZoomOut => CursorType::Default,
1987                        StyleCursor::Copy | StyleCursor::Alias => CursorType::Default,
1988                        StyleCursor::Cell => CursorType::Crosshair,
1989                        StyleCursor::AllScroll => CursorType::Move,
1990                        StyleCursor::ContextMenu => CursorType::Default,
1991                        StyleCursor::VerticalText => CursorType::Text,
1992                        StyleCursor::Unset => CursorType::Text, // Default to text for text nodes
1993                    };
1994            }
1995        }
1996        
1997        // Default: Text cursor (I-beam) for text nodes
1998        CursorType::Text
1999    }
2000
2001    /// Emits drawing commands for text selections only (not cursor).
2002    /// The cursor is drawn separately via `paint_cursor()`.
2003    fn paint_selections(
2004        &self,
2005        builder: &mut DisplayListBuilder,
2006        node_index: usize,
2007    ) -> Result<()> {
2008        let node = self
2009            .positioned_tree
2010            .tree
2011            .get(node_index)
2012            .ok_or(LayoutError::InvalidTree)?;
2013        let Some(dom_id) = node.dom_node_id else {
2014            return Ok(());
2015        };
2016        
2017        // Get inline layout using the unified helper that handles IFC membership
2018        // This is critical: text nodes don't have their own inline_layout_result,
2019        // but they have ifc_membership pointing to their IFC root
2020        let Some(layout) = self.positioned_tree.tree.get_inline_layout_for_node(node_index) else {
2021            return Ok(());
2022        };
2023
2024        // Get the absolute position of this node (border-box position)
2025        let node_pos = self
2026            .positioned_tree
2027            .calculated_positions
2028            .get(node_index)
2029            .copied()
2030            .unwrap_or_default();
2031
2032        // Selection rects from `get_selection_rects` are in the IFC root's content-box
2033        // coordinate space. For an inline text node, the node's OWN box position is never
2034        // assigned (stays the `f32::MIN` sentinel), so we must anchor to the IFC root's
2035        // position + padding/border — exactly the box that owns the inline layout. (The
2036        // caret avoids this because paint_cursor runs on the IFC-root node directly.)
2037        let ifc_root_index = self.positioned_tree.tree.get_ifc_root_layout_index(node_index);
2038        let anchor_pos = self
2039            .positioned_tree
2040            .calculated_positions
2041            .get(ifc_root_index)
2042            .copied()
2043            .unwrap_or(node_pos);
2044        let anchor_bp = self
2045            .positioned_tree
2046            .tree
2047            .get(ifc_root_index).map_or_else(|| node.box_props.unpack(), |n| n.box_props.unpack());
2048        let content_box_offset_x = anchor_pos.x + anchor_bp.padding.left + anchor_bp.border.left;
2049        let content_box_offset_y = anchor_pos.y + anchor_bp.padding.top + anchor_bp.border.top;
2050
2051        // Check if text is selectable (respects CSS user-select property)
2052        let node_state = &self.ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2053        let is_selectable = super::getters::is_text_selectable(self.ctx.styled_dom, dom_id, node_state);
2054        if !is_selectable {
2055            return Ok(());
2056        }
2057
2058        // === NEW: Check text_selections first (multi-node selection support) ===
2059        if let Some(text_selection) = self.ctx.text_selections.get(&self.ctx.styled_dom.dom_id) {
2060            if let Some(range) = text_selection.affected_nodes.get(&dom_id) {
2061                let is_collapsed = text_selection.is_collapsed();
2062                // Only draw selection highlight if NOT collapsed
2063                if !is_collapsed {
2064                    let rects = layout.get_selection_rects(range);
2065                    let style = get_selection_style(self.ctx.styled_dom, Some(dom_id), self.ctx.system_style.as_ref());
2066
2067                    let border_radius = BorderRadius {
2068                        top_left: style.radius,
2069                        top_right: style.radius,
2070                        bottom_left: style.radius,
2071                        bottom_right: style.radius,
2072                    };
2073
2074                    for mut rect in rects {
2075                        rect.origin.x += content_box_offset_x;
2076                        rect.origin.y += content_box_offset_y;
2077                        builder.push_selection_rect(rect, style.bg_color, border_radius);
2078                    }
2079                }
2080                
2081                return Ok(());
2082            }
2083        }
2084
2085        Ok(())
2086    }
2087
2088    /// Emits drawing commands for all text cursors (carets).
2089    /// Iterates over `ctx.cursor_locations` to support multi-cursor rendering.
2090    /// Preedit underline is only rendered for the primary (last) cursor.
2091    #[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
2092    fn paint_cursor(
2093        &self,
2094        builder: &mut DisplayListBuilder,
2095        node_index: usize,
2096    ) -> Result<()> {
2097        // NOTE: we deliberately do NOT early-return in the blink-off phase. Emitting the
2098        // caret item every frame — with alpha forced to 0 when invisible (see caret_color
2099        // below) — keeps the display-list item COUNT stable across blink phases, so
2100        // compute_display_list_damage yields a tiny caret-sized damage rect instead of
2101        // bailing to a full-window repaint on every ~530ms blink toggle.
2102
2103        // Early exit if no cursor locations
2104        if self.ctx.cursor_locations.is_empty() {
2105            return Ok(());
2106        }
2107
2108        let node = self
2109            .positioned_tree
2110            .tree
2111            .get(node_index)
2112            .ok_or(LayoutError::InvalidTree)?;
2113        let Some(dom_id) = node.dom_node_id else {
2114            return Ok(());
2115        };
2116
2117        // Check if this node is contenteditable
2118        let is_contenteditable = super::getters::is_node_contenteditable_inherited(self.ctx.styled_dom, dom_id);
2119        if !is_contenteditable {
2120            return Ok(());
2121        }
2122
2123        // Check if text is selectable
2124        let node_state = &self.ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2125        let is_selectable = super::getters::is_text_selectable(self.ctx.styled_dom, dom_id, node_state);
2126        if !is_selectable {
2127            return Ok(());
2128        }
2129
2130        // Get inline layout
2131        let Some(layout) = self.positioned_tree.tree.get_inline_layout_for_node(node_index) else {
2132            return Ok(());
2133        };
2134
2135        // Compute content-box offset once
2136        let node_pos = self
2137            .positioned_tree
2138            .calculated_positions
2139            .get(node_index)
2140            .copied()
2141            .unwrap_or_default();
2142        let bp = node.box_props.unpack();
2143        let padding = &bp.padding;
2144        let border = &bp.border;
2145        let content_box_offset_x = node_pos.x + padding.left + border.left;
2146        let content_box_offset_y = node_pos.y + padding.top + border.top;
2147
2148        let style = get_caret_style(self.ctx.styled_dom, Some(dom_id));
2149
2150        // Find the index of the last (primary) cursor that belongs to this DOM/node,
2151        // so preedit underline is only drawn on the actual primary cursor.
2152        let primary_idx_for_this_node = self.ctx.cursor_locations.iter().enumerate()
2153            .rev()
2154            .find(|(_, (cd, cn, _))| {
2155                *cd == self.ctx.styled_dom.dom_id && (*cn == dom_id || self.positioned_tree.tree.children(node_index).iter().any(|&child_idx| {
2156                    self.positioned_tree.tree.get(child_idx)
2157                        .and_then(|c| c.dom_node_id)
2158                        .is_some_and(|id| id == *cn)
2159                }))
2160            })
2161            .map(|(i, _)| i);
2162
2163        for (i, (cursor_dom_id, cursor_node_id, cursor)) in self.ctx.cursor_locations.iter().enumerate() {
2164            // Check DOM ID matches
2165            if self.ctx.styled_dom.dom_id != *cursor_dom_id {
2166                continue;
2167            }
2168
2169            // Check this node contains the cursor
2170            if dom_id != *cursor_node_id {
2171                let is_ifc_root_of_cursor = self.positioned_tree.tree.children(node_index)
2172                    .iter()
2173                    .any(|&child_idx| {
2174                        self.positioned_tree.tree.get(child_idx)
2175                            .and_then(|c| c.dom_node_id)
2176                            .is_some_and(|id| id == *cursor_node_id)
2177                    });
2178                if !is_ifc_root_of_cursor {
2179                    continue;
2180                }
2181            }
2182
2183            // Get cursor rect from text layout
2184            let Some(mut rect) = layout.get_cursor_rect(cursor) else {
2185                continue;
2186            };
2187
2188            rect.origin.x += content_box_offset_x;
2189            rect.origin.y += content_box_offset_y;
2190            rect.size.width = style.width;
2191
2192            // Blink: keep the caret item present every frame (stable item count for
2193            // incremental damage) but make it invisible in the off phase by zeroing alpha.
2194            let caret_color = if self.ctx.cursor_is_visible {
2195                style.color
2196            } else {
2197                ColorU { a: 0, ..style.color }
2198            };
2199            builder.push_cursor_rect(rect, caret_color);
2200
2201            // Preedit underline only on the primary cursor for this node
2202            let is_primary = primary_idx_for_this_node == Some(i);
2203            if is_primary {
2204                if let Some(ref preedit) = self.ctx.preedit_text {
2205                    if !preedit.is_empty() {
2206                        let char_count = preedit.chars().count() as f32;
2207                        let approx_char_width = style.width.max(8.0);
2208                        let preedit_width = char_count * approx_char_width;
2209                        let underline_bounds = LogicalRect {
2210                            origin: LogicalPosition {
2211                                x: rect.origin.x + rect.size.width,
2212                                y: rect.origin.y + rect.size.height - 2.0,
2213                            },
2214                            size: LogicalSize {
2215                                width: preedit_width,
2216                                height: 2.0,
2217                            },
2218                        };
2219                        builder.push_underline(underline_bounds, style.color, 2.0);
2220                    }
2221                }
2222            }
2223        }
2224
2225        Ok(())
2226    }
2227
2228    /// Emits drawing commands for selection and cursor.
2229    /// Delegates to `paint_selections()` and `paint_cursor()`.
2230    fn paint_selection_and_cursor(
2231        &self,
2232        builder: &mut DisplayListBuilder,
2233        node_index: usize,
2234    ) -> Result<()> {
2235        self.paint_selections(builder, node_index)?;
2236        self.paint_cursor(builder, node_index)?;
2237        Ok(())
2238    }
2239
2240    /// Recursively builds the tree of stacking contexts starting from a given layout node.
2241    // +spec:writing-modes:a86a28 - preorder depth-first traversal of the rendering tree in logical order
2242    fn collect_stacking_contexts(&mut self, node_index: usize) -> Result<StackingContext> {
2243        let node = self
2244            .positioned_tree
2245            .tree
2246            .get(node_index)
2247            .ok_or(LayoutError::InvalidTree)?;
2248        let z_index = get_z_index(self.ctx.styled_dom, node.dom_node_id);
2249
2250        if let Some(dom_id) = node.dom_node_id {
2251            let node_type = &self.ctx.styled_dom.node_data.as_container()[dom_id];
2252            debug_info!(
2253                self.ctx,
2254                "Collecting stacking context for node {} ({:?}), z-index={}",
2255                node_index,
2256                node_type.get_node_type(),
2257                z_index
2258            );
2259        }
2260
2261        let mut child_contexts = Vec::new();
2262        let mut in_flow_children = Vec::new();
2263
2264        for &child_index in self.positioned_tree.tree.children(node_index) {
2265            if self.establishes_stacking_context(child_index) {
2266                child_contexts.push(self.collect_stacking_contexts(child_index)?);
2267            } else {
2268                in_flow_children.push(child_index);
2269                // Recurse into non-stacking-context children to find nested
2270                // stacking contexts. Per CSS 2.2 Appendix E, these are promoted
2271                // to be child stacking contexts of the nearest ancestor SC.
2272                self.find_nested_stacking_contexts(child_index, &mut child_contexts)?;
2273            }
2274        }
2275
2276        Ok(StackingContext {
2277            node_index,
2278            z_index,
2279            child_contexts,
2280            in_flow_children,
2281        })
2282    }
2283
2284    /// Recursively searches non-stacking-context subtrees for nested stacking
2285    /// contexts, promoting them to the parent stacking context's child list.
2286    fn find_nested_stacking_contexts(
2287        &mut self,
2288        parent_index: usize,
2289        child_contexts: &mut Vec<StackingContext>,
2290    ) -> Result<()> {
2291        for &child_index in self.positioned_tree.tree.children(parent_index) {
2292            if self.establishes_stacking_context(child_index) {
2293                child_contexts.push(self.collect_stacking_contexts(child_index)?);
2294            } else {
2295                self.find_nested_stacking_contexts(child_index, child_contexts)?;
2296            }
2297        }
2298        Ok(())
2299    }
2300
2301    // +spec:box-model:de94ab - stacking context painting order (negative z, in-flow, z=0, positive z)
2302    // +spec:display-property:337069 - CSS 2.2 E.2 painting order: stacking contexts sorted by z-index, in-flow children in tree order
2303    // +spec:display-property:7b0a87 - CSS 2.2 E.2 painting order: negative z-index, in-flow, z-index 0/auto, positive z-index
2304    // +spec:stacking-contexts:5cbdfb - full CSS painting order (bg, neg-z, in-flow, z0, pos-z)
2305    // +spec:stacking-contexts:3ded3a - CSS 2.2 Appendix E painting order: definitions and tree order traversal
2306    // +spec:stacking-contexts:973368 - CSS 2.2 Appendix E.2 painting order: bg/border, negative z, in-flow, zero z, positive z
2307    // +spec:stacking-contexts:464bb7 - CSS 2.2 §9.9.1 painting order: negative z-index, in-flow, z-index 0, positive z-index (recursive)
2308    /// Recursively traverses the stacking context tree, emitting drawing commands to the builder
2309    /// according to the CSS Painting Algorithm specification.
2310    // +spec:display-property:39e879 - CSS 2.2 E.2 painting order for block-level and inline-level elements
2311    // +spec:display-property:de4c66 - CSS 2.2 E.2 stacking context paint order (canvas bg, negative z, in-flow, floats, inline, positive z)
2312    // +spec:overflow:6e48b4 - CSS 2.2 Appendix E painting order: bg/border, negative z-index, in-flow, floats, z-index 0/auto, positive z-index
2313    // +spec:stacking-contexts:55ca96 - CSS 2.2 E.2 painting order: backgrounds, negative z-index, in-flow, z-index 0/auto, positive z-index
2314    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2315    fn generate_for_stacking_context(
2316        &mut self,
2317        builder: &mut DisplayListBuilder,
2318        context: &StackingContext,
2319    ) -> Result<()> {
2320        // Before painting the node, check if it establishes a new clip or scroll frame.
2321        let node = self
2322            .positioned_tree
2323            .tree
2324            .get(context.node_index)
2325            .ok_or(LayoutError::InvalidTree)?;
2326
2327        if let Some(dom_id) = node.dom_node_id {
2328            let node_type = &self.ctx.styled_dom.node_data.as_container()[dom_id];
2329            debug_info!(
2330                self.ctx,
2331                "Painting stacking context for node {} ({:?}), z-index={}, {} child contexts, {} \
2332                 in-flow children",
2333                context.node_index,
2334                node_type.get_node_type(),
2335                context.z_index,
2336                context.child_contexts.len(),
2337                context.in_flow_children.len()
2338            );
2339        }
2340
2341        // Set current node BEFORE pushing stacking context so that
2342        // the PushStackingContext item gets the correct node_mapping entry.
2343        // This is critical for drag visual offset matching.
2344        builder.set_current_node(node.dom_node_id);
2345
2346        // Track fixed-position elements for paged media replication (CSS Positioned Layout §2.1)
2347        let is_fixed_position = node.dom_node_id
2348            .is_some_and(|dom_id| get_position_type(self.ctx.styled_dom, Some(dom_id)) == LayoutPosition::Fixed);
2349        if is_fixed_position {
2350            builder.begin_fixed_position_element();
2351        }
2352
2353        // Check if this node has a GPU-accelerated transform (CSS transform or drag).
2354        // If so, wrap in a reference frame so WebRender can animate it on the GPU.
2355        let has_reference_frame = node.dom_node_id.and_then(|dom_id| {
2356            self.gpu_value_cache.and_then(|cache| {
2357                let key = cache.css_transform_keys.get(&dom_id)?;
2358                let transform = cache.css_current_transform_values.get(&dom_id)?;
2359                Some((*key, *transform))
2360            })
2361        });
2362
2363        // Push a stacking context for WebRender
2364        // Get the node's bounds for the stacking context
2365        let node_pos = self
2366            .positioned_tree
2367            .calculated_positions
2368            .get(context.node_index)
2369            .copied()
2370            .unwrap_or_default();
2371        let node_size = node.used_size.unwrap_or(LogicalSize {
2372            width: 0.0,
2373            height: 0.0,
2374        });
2375        let node_bounds = LogicalRect {
2376            origin: node_pos,
2377            size: node_size,
2378        };
2379
2380        // Push reference frame BEFORE stacking context if node has a transform
2381        if let Some((transform_key, initial_transform)) = has_reference_frame {
2382            builder.push_reference_frame(transform_key, initial_transform, node_bounds);
2383        }
2384
2385        builder.push_stacking_context(context.z_index, node_bounds);
2386
2387        // Push opacity/filter effects if the node has them
2388        let mut pushed_opacity = false;
2389        let mut pushed_filter = false;
2390        let mut pushed_backdrop_filter = false;
2391
2392        if let Some(dom_id) = node.dom_node_id {
2393            let node_data = &self.ctx.styled_dom.node_data.as_container()[dom_id];
2394            let node_state = &self.ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2395
2396            // Opacity (GPU: fast path via compact cache)
2397            let opacity = crate::solver3::getters::get_opacity(
2398                self.ctx.styled_dom, dom_id, node_state,
2399            );
2400
2401            if opacity < 1.0 {
2402                builder.push_item(DisplayListItem::PushOpacity {
2403                    bounds: node_bounds.into(),
2404                    opacity,
2405                });
2406                pushed_opacity = true;
2407            }
2408
2409            // Filter
2410            if let Some(filter_vec_value) = self.ctx.styled_dom.css_property_cache.ptr
2411                .get_filter(node_data, &dom_id, node_state)
2412            {
2413                if let Some(filter_vec) = filter_vec_value.get_property() {
2414                    let filters: Vec<_> = filter_vec.as_ref().to_vec();
2415                    if !filters.is_empty() {
2416                        builder.push_item(DisplayListItem::PushFilter {
2417                            bounds: node_bounds.into(),
2418                            filters,
2419                        });
2420                        pushed_filter = true;
2421                    }
2422                }
2423            }
2424
2425            // Backdrop filter
2426            if let Some(backdrop_filter_value) = self.ctx.styled_dom.css_property_cache.ptr
2427                .get_backdrop_filter(node_data, &dom_id, node_state)
2428            {
2429                if let Some(filter_vec) = backdrop_filter_value.get_property() {
2430                    let filters: Vec<_> = filter_vec.as_ref().to_vec();
2431                    if !filters.is_empty() {
2432                        builder.push_item(DisplayListItem::PushBackdropFilter {
2433                            bounds: node_bounds.into(),
2434                            filters,
2435                        });
2436                        pushed_backdrop_filter = true;
2437                    }
2438                }
2439            }
2440        }
2441
2442        // 0b. Push image mask clip if this node has one.
2443        // This wraps background, border, and all children so the SVG mask clips everything.
2444        let did_push_image_mask = self.push_image_mask_clip(builder, context.node_index);
2445
2446        // +spec:box-model:84b238 - CSS 2.2 E.2 painting order: bg/border, negative z, in-flow, z=0, positive z
2447        // 1. Paint background and borders for the context's root element.
2448        // This must be BEFORE push_node_clips so the container background
2449        // is rendered in parent space (stationary), not scroll space.
2450        // +spec:overflow:40052b - backgrounds paint at border-box, scrollbars overlay on top (scrollbar-extended background positioning area)
2451        self.paint_node_background_and_border(builder, context.node_index)?;
2452
2453        // 1b. For scrollable containers, push the hit-test area BEFORE the scroll frame
2454        // so the hit-test covers the entire container box (including visible area),
2455        // not just the scrolled content. This ensures scroll wheel events hit the
2456        // container regardless of scroll position.
2457        // +spec:overflow:visibility - visibility:hidden scroll containers must not allow
2458        // interactive scrolling per CSS 2.2 §11.2
2459        if !self.is_node_hidden(context.node_index) {
2460            if let Some(dom_id) = node.dom_node_id {
2461                let styled_node_state = self.get_styled_node_state(dom_id);
2462                let overflow_x = get_overflow_x(self.ctx.styled_dom, dom_id, &styled_node_state);
2463                let overflow_y = get_overflow_y(self.ctx.styled_dom, dom_id, &styled_node_state);
2464                if overflow_x.is_scroll() || overflow_y.is_scroll() {
2465                    if let Some(tag_id) = get_tag_id(self.ctx.styled_dom, node.dom_node_id) {
2466                        builder.push_hit_test_area(node_bounds, tag_id);
2467                    }
2468                }
2469            }
2470        }
2471
2472        // 2. Push clips and scroll frames AFTER painting background
2473        // +spec:positioning:ddc554 - overflow clips apply to absolutely positioned descendants
2474        // when this node is their containing block (stacking contexts painted within clip scope)
2475        // TODO: CSS Overflow 3 says overflow clips should NOT apply to abs-pos descendants
2476        // whose containing block is above this clipper. Currently all descendants are clipped.
2477        // The containing_block_index field on LayoutNode is set for this purpose.
2478        let did_push_clip_or_scroll = self.push_node_clips(builder, context.node_index, node);
2479
2480        // +spec:display-contents:434de8 - E.2 painting order: negative z-index, in-flow, z-index 0/auto, positive z-index
2481        // 3. Paint child stacking contexts with negative z-indices.
2482        let mut negative_z_children: Vec<_> = context
2483            .child_contexts
2484            .iter()
2485            .filter(|c| c.z_index < 0)
2486            .collect();
2487        negative_z_children.sort_by_key(|c| c.z_index);
2488        for child in negative_z_children {
2489            self.generate_for_stacking_context(builder, child)?;
2490        }
2491
2492        // 4. Paint the in-flow descendants of the context root.
2493        self.paint_in_flow_descendants(builder, context.node_index, &context.in_flow_children)?;
2494
2495        // +spec:stacking-contexts:9a4eb3 - z-index:auto/0 positioned descendants painted in tree order
2496        // 5. Paint child stacking contexts with z-index: 0 / auto.
2497        for child in context.child_contexts.iter().filter(|c| c.z_index == 0) {
2498            self.generate_for_stacking_context(builder, child)?;
2499        }
2500
2501        // +spec:stacking-contexts:198fa4 - positive z-index stacking contexts painted in z-index order then tree order
2502        // 6. Paint child stacking contexts with positive z-indices.
2503        let mut positive_z_children: Vec<_> = context
2504            .child_contexts
2505            .iter()
2506            .filter(|c| c.z_index > 0)
2507            .collect();
2508
2509        positive_z_children.sort_by_key(|c| c.z_index);
2510
2511        for child in positive_z_children {
2512            self.generate_for_stacking_context(builder, child)?;
2513        }
2514
2515        // Pop image mask clip (before filter/opacity since it was pushed after them)
2516        if did_push_image_mask {
2517            builder.pop_image_mask_clip();
2518        }
2519
2520        // Pop filter/opacity effects (in reverse order of push)
2521        if pushed_backdrop_filter {
2522            builder.push_item(DisplayListItem::PopBackdropFilter);
2523        }
2524        if pushed_filter {
2525            builder.push_item(DisplayListItem::PopFilter);
2526        }
2527        if pushed_opacity {
2528            builder.push_item(DisplayListItem::PopOpacity);
2529        }
2530
2531        // Pop the stacking context for WebRender
2532        builder.pop_stacking_context();
2533
2534        // Pop reference frame if we pushed one
2535        if has_reference_frame.is_some() {
2536            builder.pop_reference_frame();
2537        }
2538
2539        // End fixed-position tracking (records the item range for paged media replication)
2540        if is_fixed_position {
2541            builder.end_fixed_position_element();
2542        }
2543
2544        // After painting the node and all its descendants, pop any contexts it pushed.
2545        // For VirtualView nodes, emit the placeholder INSIDE the clip (before PopClip)
2546        // so the virtualized view viewport is clipped to the container.
2547        if did_push_clip_or_scroll {
2548            // Emit VirtualViewPlaceholder before popping the clip so it's inside PushClip/PopClip
2549            if let Some(dom_id) = node.dom_node_id {
2550                if self.is_virtual_view_node(dom_id) {
2551                    builder.push_virtual_view_placeholder(dom_id, node_bounds, node_bounds);
2552                }
2553            }
2554            self.pop_node_clips(builder, node);
2555        } else {
2556            // Even without clips, emit VirtualViewPlaceholder for VirtualView nodes
2557            if let Some(dom_id) = node.dom_node_id {
2558                if self.is_virtual_view_node(dom_id) {
2559                    builder.push_virtual_view_placeholder(dom_id, node_bounds, node_bounds);
2560                }
2561            }
2562        }
2563
2564        // Paint scrollbars AFTER popping the clip, so they appear on top of content
2565        // and are not clipped by the scroll frame
2566        self.paint_scrollbars(builder, context.node_index)?;
2567
2568        Ok(())
2569    }
2570
2571    /// Paints the content and non-stacking-context children.
2572    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2573    fn paint_in_flow_descendants(
2574        &mut self,
2575        builder: &mut DisplayListBuilder,
2576        node_index: usize,
2577        children_indices: &[usize],
2578    ) -> Result<()> {
2579        // NOTE: We do NOT paint the node's background here - that was already done by
2580        // generate_for_stacking_context! Only paint selection, cursor, and content for the
2581        // current node
2582
2583        // 2. Paint selection highlights and the text cursor if applicable.
2584        self.paint_selection_and_cursor(builder, node_index)?;
2585
2586        // 3. Paint the node's own content (text, images, hit-test areas).
2587        self.paint_node_content(builder, node_index)?;
2588
2589        // +spec:display-property:86a3de - inline-level boxes painted in document order; z-index does not apply
2590        // +spec:floats:b8c494 - E.2 painting order: non-positioned floats painted after block-level descendants, in tree order
2591        // 4. Recursively paint the in-flow children in correct CSS painting order:
2592        //    - First: Non-float, non-dragging block-level children
2593        //    - Then: Float, non-dragging children (so they appear on top)
2594        //    - Finally: Dragging children (so they appear on top of everything per W3C spec)
2595
2596        // Separate children into floats, non-floats, and dragging.
2597        // Skip children that establish stacking contexts - those are painted
2598        // separately via generate_for_stacking_context with proper z-ordering.
2599        let mut non_float_children = Vec::new();
2600        let mut float_children = Vec::new();
2601        let mut dragging_children = Vec::new();
2602
2603        for &child_index in children_indices {
2604            // Skip stacking context children - they're painted by the stacking
2605            // context tree traversal, not by the in-flow descendant path.
2606            if self.establishes_stacking_context(child_index) {
2607                continue;
2608            }
2609            let child_node = self
2610                .positioned_tree
2611                .tree
2612                .get(child_index)
2613                .ok_or(LayoutError::InvalidTree)?;
2614
2615            // Check if this child is being dragged (paint last for z-order)
2616            let is_dragging = child_node.dom_node_id.is_some_and(|dom_id| {
2617                let styled_node_state = self.get_styled_node_state(dom_id);
2618                styled_node_state.dragging
2619            });
2620
2621            if is_dragging {
2622                dragging_children.push(child_index);
2623                continue;
2624            }
2625
2626            // Check if this child is a float
2627            let is_float = if let Some(dom_id) = child_node.dom_node_id {
2628                use crate::solver3::getters::get_float;
2629                let styled_node_state = self.get_styled_node_state(dom_id);
2630                let float_value = get_float(self.ctx.styled_dom, dom_id, &styled_node_state);
2631                !matches!(
2632                    float_value.unwrap_or_default(),
2633                    azul_css::props::layout::LayoutFloat::None
2634                )
2635            } else {
2636                false
2637            };
2638
2639            if is_float {
2640                float_children.push(child_index);
2641            } else {
2642                non_float_children.push(child_index);
2643            }
2644        }
2645
2646        // Paint non-float children first
2647        for child_index in non_float_children {
2648            let child_node = self
2649                .positioned_tree
2650                .tree
2651                .get(child_index)
2652                .ok_or(LayoutError::InvalidTree)?;
2653
2654            // Check if this child has a GPU transform (CSS transform or drag)
2655            let child_ref_frame = child_node.dom_node_id.and_then(|dom_id| {
2656                self.gpu_value_cache.and_then(|cache| {
2657                    let key = cache.css_transform_keys.get(&dom_id)?;
2658                    let transform = cache.css_current_transform_values.get(&dom_id)?;
2659                    Some((*key, *transform))
2660                })
2661            });
2662
2663            // Push reference frame if child has a transform
2664            if let Some((transform_key, initial_transform)) = child_ref_frame {
2665                let child_pos = self
2666                    .positioned_tree
2667                    .calculated_positions
2668            .get(child_index)
2669                    .copied()
2670                    .unwrap_or_default();
2671                let child_size = child_node.used_size.unwrap_or(LogicalSize {
2672                    width: 0.0,
2673                    height: 0.0,
2674                });
2675                let child_bounds = LogicalRect {
2676                    origin: child_pos,
2677                    size: child_size,
2678                };
2679                builder.set_current_node(child_node.dom_node_id);
2680                builder.push_reference_frame(transform_key, initial_transform, child_bounds);
2681            }
2682
2683            // Push image mask clip if this child has one (wraps background + children)
2684            let did_push_child_image_mask = self.push_image_mask_clip(builder, child_index);
2685
2686            // IMPORTANT: Paint background and border BEFORE pushing clips!
2687            // This ensures the container's background is in parent space (stationary),
2688            // not in scroll space. Same logic as generate_for_stacking_context.
2689            self.paint_node_background_and_border(builder, child_index)?;
2690
2691            // Push clips and scroll frames AFTER painting background
2692            let did_push_clip = self.push_node_clips(builder, child_index, child_node);
2693
2694            // Paint descendants inside the clip/scroll frame
2695            self.paint_in_flow_descendants(builder, child_index, self.positioned_tree.tree.children(child_index))?;
2696
2697            // For VirtualView children: emit placeholder INSIDE the clip
2698            if let Some(dom_id) = child_node.dom_node_id {
2699                if self.is_virtual_view_node(dom_id) {
2700                    let child_bounds = self.get_paint_rect(child_index).unwrap_or_default();
2701                    builder.push_virtual_view_placeholder(dom_id, child_bounds, child_bounds);
2702                }
2703            }
2704
2705            // Pop the child's clips.
2706            if did_push_clip {
2707                self.pop_node_clips(builder, child_node);
2708            }
2709
2710            // Pop image mask clip
2711            if did_push_child_image_mask {
2712                builder.pop_image_mask_clip();
2713            }
2714
2715            // Paint scrollbars AFTER popping clips so they appear on top of content
2716            self.paint_scrollbars(builder, child_index)?;
2717
2718            // Pop reference frame if we pushed one
2719            if child_ref_frame.is_some() {
2720                builder.pop_reference_frame();
2721            }
2722        }
2723
2724        // +spec:positioning:1bcbb5 - floats rendered in front of non-positioned in-flow blocks, but behind in-flow inlines
2725        // Paint float children AFTER non-floats (so they appear on top)
2726        for child_index in float_children {
2727            let child_node = self
2728                .positioned_tree
2729                .tree
2730                .get(child_index)
2731                .ok_or(LayoutError::InvalidTree)?;
2732
2733            // Check if this child has a GPU transform (CSS transform or drag)
2734            let child_ref_frame = child_node.dom_node_id.and_then(|dom_id| {
2735                self.gpu_value_cache.and_then(|cache| {
2736                    let key = cache.css_transform_keys.get(&dom_id)?;
2737                    let transform = cache.css_current_transform_values.get(&dom_id)?;
2738                    Some((*key, *transform))
2739                })
2740            });
2741
2742            // Push reference frame if child has a transform
2743            if let Some((transform_key, initial_transform)) = child_ref_frame {
2744                let child_pos = self
2745                    .positioned_tree
2746                    .calculated_positions
2747            .get(child_index)
2748                    .copied()
2749                    .unwrap_or_default();
2750                let child_size = child_node.used_size.unwrap_or(LogicalSize {
2751                    width: 0.0,
2752                    height: 0.0,
2753                });
2754                let child_bounds = LogicalRect {
2755                    origin: child_pos,
2756                    size: child_size,
2757                };
2758                builder.set_current_node(child_node.dom_node_id);
2759                builder.push_reference_frame(transform_key, initial_transform, child_bounds);
2760            }
2761
2762            // Same as above: push image mask, paint background, then clips
2763            let did_push_child_image_mask = self.push_image_mask_clip(builder, child_index);
2764            self.paint_node_background_and_border(builder, child_index)?;
2765            let did_push_clip = self.push_node_clips(builder, child_index, child_node);
2766            self.paint_in_flow_descendants(builder, child_index, self.positioned_tree.tree.children(child_index))?;
2767
2768            // For VirtualView children: emit placeholder INSIDE the clip
2769            if let Some(dom_id) = child_node.dom_node_id {
2770                if self.is_virtual_view_node(dom_id) {
2771                    let child_bounds = self.get_paint_rect(child_index).unwrap_or_default();
2772                    builder.push_virtual_view_placeholder(dom_id, child_bounds, child_bounds);
2773                }
2774            }
2775
2776            if did_push_clip {
2777                self.pop_node_clips(builder, child_node);
2778            }
2779            if did_push_child_image_mask {
2780                builder.pop_image_mask_clip();
2781            }
2782
2783            // Paint scrollbars AFTER popping clips so they appear on top of content
2784            self.paint_scrollbars(builder, child_index)?;
2785
2786            // Pop reference frame if we pushed one
2787            if child_ref_frame.is_some() {
2788                builder.pop_reference_frame();
2789            }
2790        }
2791
2792        // Paint dragging children LAST so they appear on top of everything (W3C spec)
2793        for child_index in dragging_children {
2794            let child_node = self
2795                .positioned_tree
2796                .tree
2797                .get(child_index)
2798                .ok_or(LayoutError::InvalidTree)?;
2799
2800            // Check if this child has a GPU transform (CSS transform or drag)
2801            let child_ref_frame = child_node.dom_node_id.and_then(|dom_id| {
2802                self.gpu_value_cache.and_then(|cache| {
2803                    let key = cache.css_transform_keys.get(&dom_id)?;
2804                    let transform = cache.css_current_transform_values.get(&dom_id)?;
2805                    Some((*key, *transform))
2806                })
2807            });
2808
2809            // Push reference frame if child has a transform
2810            if let Some((transform_key, initial_transform)) = child_ref_frame {
2811                let child_pos = self
2812                    .positioned_tree
2813                    .calculated_positions
2814            .get(child_index)
2815                    .copied()
2816                    .unwrap_or_default();
2817                let child_size = child_node.used_size.unwrap_or(LogicalSize {
2818                    width: 0.0,
2819                    height: 0.0,
2820                });
2821                let child_bounds = LogicalRect {
2822                    origin: child_pos,
2823                    size: child_size,
2824                };
2825                builder.set_current_node(child_node.dom_node_id);
2826                builder.push_reference_frame(transform_key, initial_transform, child_bounds);
2827            }
2828
2829            // Same as above: push image mask, paint background, then clips
2830            let did_push_child_image_mask = self.push_image_mask_clip(builder, child_index);
2831            self.paint_node_background_and_border(builder, child_index)?;
2832            let did_push_clip = self.push_node_clips(builder, child_index, child_node);
2833            self.paint_in_flow_descendants(builder, child_index, self.positioned_tree.tree.children(child_index))?;
2834
2835            // For VirtualView children: emit placeholder INSIDE the clip
2836            if let Some(dom_id) = child_node.dom_node_id {
2837                if self.is_virtual_view_node(dom_id) {
2838                    let child_bounds = self.get_paint_rect(child_index).unwrap_or_default();
2839                    builder.push_virtual_view_placeholder(dom_id, child_bounds, child_bounds);
2840                }
2841            }
2842
2843            if did_push_clip {
2844                self.pop_node_clips(builder, child_node);
2845            }
2846            if did_push_child_image_mask {
2847                builder.pop_image_mask_clip();
2848            }
2849
2850            // Paint scrollbars AFTER popping clips so they appear on top of content
2851            self.paint_scrollbars(builder, child_index)?;
2852
2853            // Pop reference frame if we pushed one
2854            if child_ref_frame.is_some() {
2855                builder.pop_reference_frame();
2856            }
2857        }
2858
2859        Ok(())
2860    }
2861
2862    /// Returns true if the given DOM node is a `VirtualView` node.
2863    fn is_virtual_view_node(&self, dom_id: NodeId) -> bool {
2864        let node_data_container = self.ctx.styled_dom.node_data.as_container();
2865        node_data_container
2866            .get(dom_id)
2867            .is_some_and(|nd| matches!(nd.get_node_type(), NodeType::VirtualView))
2868    }
2869
2870    /// Checks if a node has an image mask clip and pushes `PushImageMaskClip` if so.
2871    /// Returns true if a clip was pushed (caller must pop it).
2872    fn push_image_mask_clip(
2873        &self,
2874        builder: &mut DisplayListBuilder,
2875        node_index: usize,
2876    ) -> bool {
2877        let Some(node) = self.positioned_tree.tree.get(node_index) else {
2878            return false;
2879        };
2880        let Some(dom_id) = node.dom_node_id else {
2881            return false;
2882        };
2883        let node_data_container = self.ctx.styled_dom.node_data.as_container();
2884        let Some(node_data) = node_data_container.get(dom_id) else {
2885            return false;
2886        };
2887        match node_data.get_svg_data() {
2888            Some(azul_core::dom::SvgNodeData::ImageClipMask(clip_mask)) => {
2889                let paint_rect = self.get_paint_rect(node_index).unwrap_or_default();
2890                // Convert mask rect from element-local to window-logical coordinates
2891                let mask_rect = LogicalRect {
2892                    origin: LogicalPosition {
2893                        x: paint_rect.origin.x + clip_mask.rect.origin.x,
2894                        y: paint_rect.origin.y + clip_mask.rect.origin.y,
2895                    },
2896                    size: clip_mask.rect.size,
2897                };
2898                builder.push_image_mask_clip(
2899                    paint_rect,
2900                    clip_mask.image.clone(),
2901                    mask_rect,
2902                );
2903                true
2904            }
2905            #[cfg(feature = "cpurender")]
2906            Some(azul_core::dom::SvgNodeData::Path(svg_clip)) => {
2907                let paint_rect = self.get_paint_rect(node_index).unwrap_or_default();
2908                rasterize_svg_clip_to_r8(svg_clip, &paint_rect).is_some_and(|mask_image| {
2909                    builder.push_image_mask_clip(paint_rect, mask_image, paint_rect);
2910                    true
2911                })
2912            }
2913            #[cfg(not(feature = "cpurender"))]
2914            Some(azul_core::dom::SvgNodeData::Path(_)) => false,
2915            // Other SvgNodeData variants (shapes, gradients, etc.) don't produce clip masks
2916            Some(_) => false,
2917            None => false,
2918        }
2919    }
2920
2921    // +spec:overflow:531bd2 - ancestor clips accumulate via push_clip/pop_clip stack (cumulative intersection)
2922    // +spec:overflow:8098ec - overflow clipping/scrolling; abs-pos elements with containing block outside scroller are not scrolled
2923    /// Checks if a node requires clipping or scrolling and pushes the appropriate commands.
2924    /// Returns true if any command was pushed.
2925    ///
2926    /// // +spec:containing-block:62aa5c - overflow clipping applies to all content except
2927    /// // descendants whose containing block is the viewport or an ancestor of this element
2928    /// // (i.e. absolutely positioned elements that escape the overflow container).
2929    /// // TODO: exempt abs-pos descendants whose containing block is an ancestor of this node.
2930    ///
2931    /// For `VirtualView` nodes with `overflow: scroll/auto`, we intentionally skip
2932    /// `PushScrollFrame` / `PopScrollFrame`. `VirtualView` scroll state is managed by
2933    /// `ScrollManager`, not `WebRender`'s APZ. Instead we emit only a `PushClip`
2934    /// and later an `VirtualViewPlaceholder` (see `generate_for_stacking_context`).
2935    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
2936    fn push_node_clips(
2937        &self,
2938        builder: &mut DisplayListBuilder,
2939        node_index: usize,
2940        node: &LayoutNodeHot,
2941    ) -> bool {
2942        let Some(dom_id) = node.dom_node_id else {
2943            return false;
2944        };
2945
2946        let styled_node_state = self.get_styled_node_state(dom_id);
2947
2948        let raw_overflow_x = get_overflow_x(self.ctx.styled_dom, dom_id, &styled_node_state);
2949        let raw_overflow_y = get_overflow_y(self.ctx.styled_dom, dom_id, &styled_node_state);
2950        // +spec:overflow:833078 - resolve visible/clip to auto/hidden per CSS Overflow 3 §3.1
2951        let overflow_x = raw_overflow_x.resolve_computed(&raw_overflow_y);
2952        let overflow_y = raw_overflow_y.resolve_computed(&raw_overflow_x);
2953
2954        let paint_rect = self.get_paint_rect(node_index).unwrap_or_default();
2955        let element_size = PhysicalSizeImport {
2956            width: paint_rect.size.width,
2957            height: paint_rect.size.height,
2958        };
2959        let border_radius = get_border_radius(
2960            self.ctx.styled_dom,
2961            dom_id,
2962            &styled_node_state,
2963            element_size,
2964            self.ctx.viewport_size,
2965        );
2966
2967        // +spec:positioning:9c261b - clip-path (modern replacement for legacy 'clip' property)
2968        // The legacy CSS 2.2 'clip' property applied only to absolutely positioned elements;
2969        // clip-path supersedes it and applies to all elements per CSS Masking Level 1.
2970        // If present, push a clip region derived from the clip-path shape.
2971        // This is evaluated before overflow clips; both can be active simultaneously.
2972        let has_clip_path = super::getters::get_clip_path(
2973            self.ctx.styled_dom, dom_id, &styled_node_state,
2974        ).is_some_and(|clip_path| if let Some((clip_rect, radius)) = resolve_clip_path(&clip_path, paint_rect) {
2975                let br = if radius > 0.0 {
2976                    BorderRadius {
2977                        top_left: radius,
2978                        top_right: radius,
2979                        bottom_left: radius,
2980                        bottom_right: radius,
2981                    }
2982                } else {
2983                    BorderRadius::default()
2984                };
2985                builder.push_clip(clip_rect, br);
2986                true
2987            } else {
2988                false
2989            });
2990
2991        // +spec:overflow:6890f2 - text-overflow: clip inline content at end line box edge when overflow != visible
2992        // +spec:overflow:77d7ce - clipping region defines visible portion of border box; default is not clipped
2993        let needs_clip = overflow_x.is_clipped() || overflow_y.is_clipped();
2994
2995        if !needs_clip {
2996            return has_clip_path;
2997        }
2998
2999        // +spec:overflow:c52f2a - clipping region is rounded to element's border-radius
3000        // +spec:overflow:913b23 - when both axes are clip, region is rounded per overflow-clip-margin
3001        // +spec:overflow:449d69 - when one axis is clip and the other is visible, clipping region is not rounded
3002        // +spec:overflow:449d69 - when one axis is clip and the other is visible, clipping region is not rounded
3003        let ox_clip = overflow_x.is_clipped() && !overflow_x.is_scroll() && !overflow_x.is_auto_overflow();
3004        let oy_clip = overflow_y.is_clipped() && !overflow_y.is_scroll() && !overflow_y.is_auto_overflow();
3005        let ox_visible = !overflow_x.is_clipped();
3006        let oy_visible = !overflow_y.is_clipped();
3007        let border_radius = if (ox_clip && oy_visible) || (oy_clip && ox_visible)
3008        {
3009            BorderRadius::default()
3010        } else {
3011            border_radius
3012        };
3013
3014        let paint_rect = self.get_paint_rect(node_index).unwrap_or_default();
3015
3016        let bp = node.box_props.unpack();
3017        let border = &bp.border;
3018
3019        // Get scrollbar info to adjust clip rect for content area
3020        let scrollbar_info = self.positioned_tree.tree.warm(node_index)
3021            .and_then(|w| w.scrollbar_info)
3022            .unwrap_or_default();
3023
3024        // +spec:overflow:13cacb - clip rect clamped to 0 so zero-size clips hide all pixels
3025        // +spec:overflow:9207bc - clip rect computed from border-box edges (analogous to CSS 2.2 clip: rect() offsets)
3026        // +spec:overflow:3d5b53 - overflow clips to padding edge, scroll mechanism for scroll/auto
3027        // The clip rect for content should exclude the scrollbar area
3028        // Scrollbars are drawn inside the border-box, on the right/bottom edges
3029        // +spec:overflow:a825a6 - TODO: abs-pos elements with containing block outside this
3030        // element should not be clipped (currently all DOM children are clipped)
3031        let mut clip_rect = LogicalRect {
3032            origin: LogicalPosition {
3033                x: paint_rect.origin.x + border.left,
3034                y: paint_rect.origin.y + border.top,
3035            },
3036            size: LogicalSize {
3037                // Reduce width/height by scrollbar dimensions so content doesn't overlap scrollbar
3038                width: (paint_rect.size.width
3039                    - border.left
3040                    - border.right
3041                    - scrollbar_info.scrollbar_width)
3042                    .max(0.0),
3043                height: (paint_rect.size.height
3044                    - border.top
3045                    - border.bottom
3046                    - scrollbar_info.scrollbar_height)
3047                    .max(0.0),
3048            },
3049        };
3050
3051        // +spec:overflow:342f47 - overflow-clip-margin expands clip edge for overflow:clip only
3052        // Per CSS Overflow 3 §3.2: overflow-clip-margin has no effect on overflow:hidden
3053        // or overflow:scroll. It only expands the overflow clip edge when overflow:clip is used.
3054        apply_overflow_clip_margin(
3055            &mut clip_rect,
3056            &overflow_x,
3057            &overflow_y,
3058            self.ctx.styled_dom,
3059            dom_id,
3060            &styled_node_state,
3061        );
3062
3063        let is_virtual_view = self.is_virtual_view_node(dom_id);
3064
3065        // +spec:overflow:484889 - clip content in unreachable scrollable overflow region
3066        // +spec:overflow:917dae - scrollable overflow rect is a rectangle in box's own coordinate system
3067        // Every clipped node pushes a clip (scrollable, hidden, or clip alike).
3068        builder.push_clip(clip_rect, border_radius);
3069        // Regular scrollable nodes ALSO push a scroll frame: WebRender's APZ
3070        // manages the offset via define_scroll_frame, CPU renderers translate
3071        // children by scroll_offset. VirtualView scroll state is instead managed
3072        // by ScrollManager and passed to the callback as scroll_offset, with the
3073        // VirtualViewPlaceholder emitted after pop_node_clips in
3074        // generate_for_stacking_context — so VirtualView nodes get only the clip.
3075        if (overflow_x.is_scroll() || overflow_y.is_scroll()) && !is_virtual_view {
3076            let scroll_id = self.scroll_ids.get(&node_index).copied().unwrap_or(0);
3077            let content_size = get_scroll_content_size(node, self.positioned_tree.tree.warm(node_index));
3078            builder.push_scroll_frame(clip_rect, content_size, scroll_id);
3079        }
3080
3081        true
3082    }
3083
3084    /// Pops any clip/scroll commands associated with a node.
3085    fn pop_node_clips(&self, builder: &mut DisplayListBuilder, node: &LayoutNodeHot) {
3086        let Some(dom_id) = node.dom_node_id else {
3087            return;
3088        };
3089
3090        let styled_node_state = self.get_styled_node_state(dom_id);
3091        // Mirror push_node_clips EXACTLY: resolve visible/clip → auto/hidden per
3092        // CSS Overflow 3 §3.1 (an axis computes to auto/hidden when the *other*
3093        // axis is a scroll container). push_node_clips decides whether to emit a
3094        // scroll frame from the RESOLVED values; popping from the RAW values can
3095        // disagree. Concretely: the auto-injected titlebar title has
3096        // overflow-x:hidden, overflow-y:visible → push resolves y→auto (a scroll
3097        // container, since is_scroll() counts Auto) and emits PushClip +
3098        // PushScrollFrame, but pop saw raw y=visible (is_scroll=false) and emitted
3099        // only PopClip → an unbalanced PushScrollFrame. The layer allocator then
3100        // extends the titlebar's scroll layer to the end of the list, swallowing
3101        // the document body into the titlebar's clip rect (blank window) and
3102        // underflowing the clip stack. Resolving here keeps push/pop symmetric.
3103        let raw_overflow_x = get_overflow_x(self.ctx.styled_dom, dom_id, &styled_node_state);
3104        let raw_overflow_y = get_overflow_y(self.ctx.styled_dom, dom_id, &styled_node_state);
3105        let overflow_x = raw_overflow_x.resolve_computed(&raw_overflow_y);
3106        let overflow_y = raw_overflow_y.resolve_computed(&raw_overflow_x);
3107
3108        let paint_rect = self
3109            .get_paint_rect(
3110                self.positioned_tree
3111                    .tree
3112                    .nodes
3113                    .iter()
3114                    .position(|n| n.dom_node_id == Some(dom_id))
3115                    .unwrap_or(0),
3116            )
3117            .unwrap_or_default();
3118
3119        let element_size = PhysicalSizeImport {
3120            width: paint_rect.size.width,
3121            height: paint_rect.size.height,
3122        };
3123        let border_radius = get_border_radius(
3124            self.ctx.styled_dom,
3125            dom_id,
3126            &styled_node_state,
3127            element_size,
3128            self.ctx.viewport_size,
3129        );
3130
3131        let needs_clip =
3132            overflow_x.is_clipped() || overflow_y.is_clipped();
3133
3134        let is_virtual_view = self.is_virtual_view_node(dom_id);
3135
3136        if needs_clip {
3137            // Regular (non-VirtualView) scroll/auto also pushed a scroll frame;
3138            // pop it first (LIFO) before the shared clip. Hidden/clip and
3139            // VirtualView scroll only pushed a clip.
3140            if (overflow_x.is_scroll() || overflow_y.is_scroll()) && !is_virtual_view {
3141                builder.pop_scroll_frame();
3142            }
3143            builder.pop_clip();
3144        }
3145
3146        // Pop the clip-path clip if one was pushed.
3147        // This mirrors the push_node_clips logic: if clip-path is set,
3148        // a PushClip was emitted before any overflow clips.
3149        // We pop it last (stack order: clip-path pushed first, popped last).
3150        if let Some(clip_path) = super::getters::get_clip_path(
3151            self.ctx.styled_dom, dom_id, &styled_node_state,
3152        ) {
3153            if resolve_clip_path(&clip_path, paint_rect).is_some() {
3154                builder.pop_clip();
3155            }
3156        }
3157
3158    }
3159
3160    /// Calculates the final paint-time rectangle for a node.
3161    /// 
3162    /// ## Coordinate Space
3163    /// 
3164    /// Returns the node's position in **absolute window coordinates** (logical pixels).
3165    /// This is the coordinate space used throughout the display list:
3166    /// 
3167    /// - Origin: Top-left corner of the window
3168    /// - Units: Logical pixels (`HiDPI` scaling happens in compositor2.rs)
3169    /// - Scroll: NOT applied here - `WebRender` scroll frames handle scroll offset
3170    ///   transformation internally via `define_scroll_frame()`
3171    /// 
3172    /// ## Important
3173    /// 
3174    /// Do NOT manually subtract scroll offset here! `WebRender`'s scroll spatial
3175    /// transforms handle this. Subtracting here would cause double-offset and
3176    /// parallax effects (backgrounds and text moving at different speeds).
3177    fn get_paint_rect(&self, node_index: usize) -> Option<LogicalRect> {
3178        let node = self.positioned_tree.tree.get(node_index)?;
3179        let pos = self
3180            .positioned_tree
3181            .calculated_positions
3182            .get(node_index)
3183            .copied()
3184            .unwrap_or_default();
3185        let size = node.used_size.unwrap_or_default();
3186
3187        // NOTE: Scroll offset is NOT applied here!
3188        // WebRender scroll frames handle scroll transformation.
3189        // See compositor2.rs PushScrollFrame for details.
3190
3191        Some(LogicalRect::new(pos, size))
3192    }
3193
3194    /// Emits drawing commands for the background and border of a single node.
3195    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3196    fn paint_node_background_and_border(
3197        &mut self,
3198        builder: &mut DisplayListBuilder,
3199        node_index: usize,
3200    ) -> Result<()> {
3201        let Some(paint_rect) = self.get_paint_rect(node_index) else {
3202            return Ok(());
3203        };
3204        let node = self
3205            .positioned_tree
3206            .tree
3207            .get(node_index)
3208            .ok_or(LayoutError::InvalidTree)?;
3209
3210        // Set current node for node mapping (for pagination break properties)
3211        builder.set_current_node(node.dom_node_id);
3212
3213        // Check for CSS break-before/break-after properties and register forced page breaks
3214        // This is used by the pagination slicer to insert page breaks at correct positions
3215        if let Some(dom_id) = node.dom_node_id {
3216            let break_before = get_break_before(self.ctx.styled_dom, Some(dom_id));
3217            let break_after = get_break_after(self.ctx.styled_dom, Some(dom_id));
3218
3219            // For break-before: always, insert a page break at the top of this element
3220            if is_forced_page_break(break_before) {
3221                let y_position = paint_rect.origin.y;
3222                builder.add_forced_page_break(y_position);
3223                debug_info!(
3224                    self.ctx,
3225                    "Registered forced page break BEFORE node {} at y={}",
3226                    node_index,
3227                    y_position
3228                );
3229            }
3230
3231            // For break-after: always, insert a page break at the bottom of this element
3232            if is_forced_page_break(break_after) {
3233                let y_position = paint_rect.origin.y + paint_rect.size.height;
3234                builder.add_forced_page_break(y_position);
3235                debug_info!(
3236                    self.ctx,
3237                    "Registered forced page break AFTER node {} at y={}",
3238                    node_index,
3239                    y_position
3240                );
3241            }
3242        }
3243
3244        // CSS 2.2 §11.2: visibility:hidden — box is invisible but still affects layout.
3245        // Skip painting background/border for hidden nodes, but traversal continues
3246        // so visible descendants are still painted.
3247        if self.is_node_hidden(node_index) {
3248            return Ok(());
3249        }
3250
3251        // Skip inline and inline-block elements ONLY if they participate in an IFC (Inline Formatting Context).
3252        // In Flex or Grid containers, inline-block elements are treated as flex/grid items and must be painted here.
3253        // Inline elements participate in inline formatting context and their backgrounds
3254        // must be positioned by the text layout engine, not the block layout engine
3255        //
3256        // IMPORTANT: The parent check must look at the PARENT NODE's formatting_context,
3257        // not the current node's. If parent is Flex/Grid, we paint this element as a flex/grid item.
3258        // Also check parent_formatting_context field which stores parent's FC during tree construction.
3259        let warm = self.positioned_tree.tree.warm(node_index);
3260        let parent_is_flex_or_grid = warm
3261            .and_then(|w| w.parent_formatting_context.as_ref().map(|fc| matches!(fc, FormattingContext::Flex | FormattingContext::Grid)))
3262            .unwrap_or(false);
3263        
3264        if let Some(dom_id) = node.dom_node_id {
3265            let display = {
3266                use crate::solver3::getters::get_display_property;
3267                get_display_property(self.ctx.styled_dom, Some(dom_id))
3268                    .unwrap_or(LayoutDisplay::Inline)
3269            };
3270
3271            if display == LayoutDisplay::InlineBlock || display == LayoutDisplay::Inline {
3272                debug_info!(
3273                    self.ctx,
3274                    "[paint_node] node {} has display={:?}, parent_formatting_context={:?}, parent_is_flex_or_grid={}",
3275                    node_index,
3276                    display,
3277                    warm.and_then(|w| w.parent_formatting_context.as_ref()),
3278                    parent_is_flex_or_grid
3279                );
3280
3281                if !parent_is_flex_or_grid {
3282                    // Normally, text3 handles inline/inline-block backgrounds via
3283                    // InlineShape (inline-block) or glyph runs (inline). However,
3284                    // if this inline-block establishes a stacking context (e.g.
3285                    // position:relative + z-index, opacity < 1, transform), we MUST
3286                    // paint its background here. generate_for_stacking_context paints
3287                    // background (step 1) → children (steps 3-6). If we skip the
3288                    // background, paint_inline_shape in the parent's paint_node_content
3289                    // would paint it AFTER the children, obscuring them.
3290                    if display == LayoutDisplay::InlineBlock
3291                        && self.establishes_stacking_context(node_index)
3292                    {
3293                        // Fall through to paint background/border now
3294                    } else {
3295                        return Ok(());
3296                    }
3297                }
3298                // Fall through to paint this element - it's a flex/grid item
3299            }
3300        }
3301
3302        // CSS 2.2 Section 17.5.1: Tables in the visual formatting model
3303        // Table-internal elements (row groups, rows, columns, column groups) have their
3304        // backgrounds painted by paint_table_items() in the correct 6-layer order.
3305        // Skip background painting here to avoid double-painting at wrong positions
3306        // (calculated_positions for TR elements may not reflect row offsets correctly;
3307        // paint_table_items computes row rects from cell bounding boxes instead).
3308        // Table CELLS still need content painting via paint_in_flow_descendants, so
3309        // we only skip the background/border here — content painting continues normally.
3310        if matches!(node.formatting_context,
3311            FormattingContext::TableRowGroup | FormattingContext::TableRow |
3312            FormattingContext::TableColumnGroup
3313        ) {
3314            return Ok(());
3315        }
3316
3317        // Tables have a special 6-layer background painting order
3318        if matches!(node.formatting_context, FormattingContext::Table) {
3319            debug_info!(
3320                self.ctx,
3321                "Painting table backgrounds/borders for node {} at {:?}",
3322                node_index,
3323                paint_rect
3324            );
3325            // Delegate to specialized table painting function
3326            return self.paint_table_items(builder, node_index);
3327        }
3328
3329        if let Some(dom_id) = node.dom_node_id {
3330            let styled_node_state = self.get_styled_node_state(dom_id);
3331            let background_contents =
3332                get_background_contents(self.ctx.styled_dom, dom_id, &styled_node_state);
3333            let border_info = get_border_info(self.ctx.styled_dom, dom_id, &styled_node_state);
3334
3335            let node_type = &self.ctx.styled_dom.node_data.as_container()[dom_id];
3336            debug_info!(
3337                self.ctx,
3338                "Painting background/border for node {} ({:?}) at {:?}, backgrounds={:?}",
3339                node_index,
3340                node_type.get_node_type(),
3341                paint_rect,
3342                background_contents.len()
3343            );
3344
3345            // Get both versions: simple BorderRadius for rect clipping and StyleBorderRadius for
3346            // border rendering
3347            let element_size = PhysicalSizeImport {
3348                width: paint_rect.size.width,
3349                height: paint_rect.size.height,
3350            };
3351            let simple_border_radius = get_border_radius(
3352                self.ctx.styled_dom,
3353                dom_id,
3354                &styled_node_state,
3355                element_size,
3356                self.ctx.viewport_size,
3357            );
3358            let style_border_radius =
3359                get_style_border_radius(self.ctx.styled_dom, dom_id, &styled_node_state);
3360
3361            // Paint box shadows before backgrounds (CSS spec: shadows render behind the element)
3362            let node_state = &self.ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
3363
3364            // +spec:overflow:bb4308 - box shadows are ink overflow: painted outside border box, not affecting layout
3365            // Check all four sides for box-shadow (azul stores them per-side).
3366            // Routed through `super::getters::*` so the compact-cache has_box_shadow
3367            // fast path fires — most nodes have no shadow and skip 4 cascade walks.
3368            for shadow in [
3369                super::getters::get_box_shadow_left(self.ctx.styled_dom, dom_id, node_state),
3370                super::getters::get_box_shadow_right(self.ctx.styled_dom, dom_id, node_state),
3371                super::getters::get_box_shadow_top(self.ctx.styled_dom, dom_id, node_state),
3372                super::getters::get_box_shadow_bottom(self.ctx.styled_dom, dom_id, node_state),
3373            ].into_iter().flatten() {
3374                builder.push_item(DisplayListItem::BoxShadow {
3375                    bounds: paint_rect.into(),
3376                    shadow,
3377                    border_radius: simple_border_radius,
3378                });
3379            }
3380
3381            // Use unified background/border painting
3382            builder.push_backgrounds_and_border(
3383                paint_rect,
3384                &background_contents,
3385                &border_info,
3386                simple_border_radius,
3387                style_border_radius,
3388                self.ctx.image_cache,
3389            );
3390
3391        }
3392
3393        Ok(())
3394    }
3395
3396    //   backgrounds are invisible, allowing table background to show through
3397    // +spec:box-model:124815 - Table layer background painting order (6 layers: table, col-group, col, row-group, row, cell)
3398    // +spec:positioning:702985 - Table background painting in 6 layers (17.5.1)
3399    // +spec:table-layout:7370dc - Table layers and transparency: 6-layer background painting order
3400    // +spec:table-layout:7a5909 - table layers: 6-layer background paint order (table/colgroup/col/rowgroup/row/cell)
3401    /// CSS 2.2 Section 17.5.1: Table background painting in 6 layers
3402    ///
3403    /// Implements the CSS 2.2 specification for table background painting order.
3404    /// Unlike regular block elements, tables paint backgrounds in layers from back to front:
3405    ///
3406    /// 1. Table background (lowest layer)
3407    /// 2. Column group backgrounds
3408    /// 3. Column backgrounds
3409    /// 4. Row group backgrounds
3410    /// 5. Row backgrounds
3411    /// 6. Cell backgrounds (topmost layer)
3412    ///
3413    /// Then borders are painted (respecting border-collapse mode).
3414    /// Finally, cell content is painted on top of everything.
3415    ///
3416    /// This function generates simple display list items (Rect, Border) in the correct
3417    /// CSS paint order, making `WebRender` integration trivial.
3418    fn paint_table_items(
3419        &self,
3420        builder: &mut DisplayListBuilder,
3421        table_index: usize,
3422    ) -> Result<()> {
3423        let table_node = self
3424            .positioned_tree
3425            .tree
3426            .get(table_index)
3427            .ok_or(LayoutError::InvalidTree)?;
3428
3429        let Some(table_paint_rect) = self.get_paint_rect(table_index) else {
3430            return Ok(());
3431        };
3432
3433        // Layer 1: Table background
3434        if let Some(dom_id) = table_node.dom_node_id {
3435            let styled_node_state = self.get_styled_node_state(dom_id);
3436            let bg_color = get_background_color(self.ctx.styled_dom, dom_id, &styled_node_state);
3437            let element_size = PhysicalSizeImport {
3438                width: table_paint_rect.size.width,
3439                height: table_paint_rect.size.height,
3440            };
3441            let border_radius = get_border_radius(
3442                self.ctx.styled_dom,
3443                dom_id,
3444                &styled_node_state,
3445                element_size,
3446                self.ctx.viewport_size,
3447            );
3448
3449            builder.push_rect(table_paint_rect, bg_color, border_radius);
3450        }
3451
3452        // Traverse table children to paint layers 2-6
3453
3454        // Layer 2: Column group backgrounds
3455        // Layer 3: Column backgrounds (columns are children of column groups)
3456        for &child_idx in self.positioned_tree.tree.children(table_index) {
3457            let child_node = self.positioned_tree.tree.get(child_idx);
3458            if let Some(node) = child_node {
3459                if matches!(node.formatting_context, FormattingContext::TableColumnGroup) {
3460                    // Paint column group background
3461                    self.paint_element_background(builder, child_idx);
3462
3463                    // Paint backgrounds of individual columns within this group
3464                    for &col_idx in self.positioned_tree.tree.children(child_idx) {
3465                        self.paint_element_background(builder, col_idx);
3466                    }
3467                }
3468            }
3469        }
3470
3471        // Layer 4: Row group backgrounds (tbody, thead, tfoot)
3472        // Layer 5: Row backgrounds
3473        // Layer 6: Cell backgrounds
3474        for &child_idx in self.positioned_tree.tree.children(table_index) {
3475            let child_node = self.positioned_tree.tree.get(child_idx);
3476            if let Some(node) = child_node {
3477                match node.formatting_context {
3478                    FormattingContext::TableRowGroup => {
3479                        // Paint row group background
3480                        self.paint_element_background(builder, child_idx);
3481
3482                        // Paint rows within this group
3483                        for &row_idx in self.positioned_tree.tree.children(child_idx) {
3484                            self.paint_table_row_and_cells(builder, row_idx);
3485                        }
3486                    }
3487                    FormattingContext::TableRow => {
3488                        // Direct row child (no row group wrapper)
3489                        self.paint_table_row_and_cells(builder, child_idx);
3490                    }
3491                    _ => {}
3492                }
3493            }
3494        }
3495
3496        // Borders are painted separately after all backgrounds
3497        // This is handled by the normal rendering flow for each element
3498        // TODO: For border-collapse: collapse tables, resolve conflicts between
3499        // adjacent cell borders using BorderInfo::resolve_conflict() from fc.rs.
3500        // Currently all cells paint their own borders (separate model behavior).
3501
3502        Ok(())
3503    }
3504
3505    /// Helper function to paint a table row's background and then its cells' backgrounds
3506    /// Layer 5: Row background
3507    /// Layer 6: Cell backgrounds (painted after row, so they appear on top)
3508    fn paint_table_row_and_cells(
3509        &self,
3510        builder: &mut DisplayListBuilder,
3511        row_idx: usize,
3512    ) {
3513        // Layer 5: Paint row background.
3514        // Rows don't have entries in calculated_positions (adding them would
3515        // double-offset cells during position recursion). Compute the row rect
3516        // from the bounding box of its cell children.
3517        if let Some(row_node) = self.positioned_tree.tree.get(row_idx) {
3518            if let Some(dom_id) = row_node.dom_node_id {
3519                let styled_node_state = self.get_styled_node_state(dom_id);
3520                let bg_color = get_background_color(self.ctx.styled_dom, dom_id, &styled_node_state);
3521                if bg_color.a > 0 {
3522                    // Compute row rect from cell children
3523                    let mut min_x = f32::MAX;
3524                    let mut min_y = f32::MAX;
3525                    let mut max_x = f32::MIN;
3526                    let mut max_y = f32::MIN;
3527                    for &cell_idx in self.positioned_tree.tree.children(row_idx) {
3528                        if let Some(cell_rect) = self.get_paint_rect(cell_idx) {
3529                            min_x = min_x.min(cell_rect.origin.x);
3530                            min_y = min_y.min(cell_rect.origin.y);
3531                            max_x = max_x.max(cell_rect.origin.x + cell_rect.size.width);
3532                            max_y = max_y.max(cell_rect.origin.y + cell_rect.size.height);
3533                        }
3534                    }
3535                    if min_x < max_x && min_y < max_y {
3536                        let row_rect = LogicalRect::new(
3537                            LogicalPosition::new(min_x, min_y),
3538                            LogicalSize::new(max_x - min_x, max_y - min_y),
3539                        );
3540                        builder.push_rect(row_rect, bg_color, BorderRadius::default());
3541                    }
3542                }
3543            }
3544        }
3545
3546        // Layer 6: Paint cell backgrounds (topmost layer)
3547        if let Some(_node) = self.positioned_tree.tree.get(row_idx) {
3548            for &cell_idx in self.positioned_tree.tree.children(row_idx) {
3549                self.paint_element_background(builder, cell_idx);
3550            }
3551        }
3552
3553    }
3554
3555    /// Helper function to paint an element's background (used for all table elements)
3556    /// Reads background-color and border-radius from CSS properties and emits `push_rect()`
3557    fn paint_element_background(
3558        &self,
3559        builder: &mut DisplayListBuilder,
3560        node_index: usize,
3561    ) {
3562        let Some(paint_rect) = self.get_paint_rect(node_index) else {
3563            return;
3564        };
3565
3566        let Some(node) = self.positioned_tree.tree.get(node_index) else {
3567            return;
3568        };
3569        let Some(dom_id) = node.dom_node_id else {
3570            return;
3571        };
3572
3573        let styled_node_state = self.get_styled_node_state(dom_id);
3574        let bg_color = get_background_color(self.ctx.styled_dom, dom_id, &styled_node_state);
3575
3576        // Only paint if background color has alpha > 0 (optimization)
3577        if bg_color.a == 0 {
3578            return;
3579        }
3580
3581        let element_size = PhysicalSizeImport {
3582            width: paint_rect.size.width,
3583            height: paint_rect.size.height,
3584        };
3585        let border_radius = get_border_radius(
3586            self.ctx.styled_dom,
3587            dom_id,
3588            &styled_node_state,
3589            element_size,
3590            self.ctx.viewport_size,
3591        );
3592
3593        builder.push_rect(paint_rect, bg_color, border_radius);
3594
3595    }
3596
3597    /// Emits drawing commands for the foreground content, including hit-test areas and scrollbars.
3598    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3599    fn paint_node_content(
3600        &mut self,
3601        builder: &mut DisplayListBuilder,
3602        node_index: usize,
3603    ) -> Result<()> {
3604        // CSS 2.2 §11.2: visibility:hidden — skip painting content for hidden nodes.
3605        if self.is_node_hidden(node_index) {
3606            return Ok(());
3607        }
3608
3609        let node = self
3610            .positioned_tree
3611            .tree
3612            .get(node_index)
3613            .ok_or(LayoutError::InvalidTree)?;
3614        let node_warm = self.positioned_tree.tree.warm(node_index);
3615
3616        // Set current node for node mapping (for pagination break properties)
3617        builder.set_current_node(node.dom_node_id);
3618
3619        let Some(mut paint_rect) = self.get_paint_rect(node_index) else {
3620            return Ok(());
3621        };
3622
3623        // For text nodes (with inline layout), the used_size might be 0x0.
3624        // In this case, compute the bounds from the inline layout result.
3625        if paint_rect.size.width == 0.0 || paint_rect.size.height == 0.0 {
3626            if let Some(cached_layout) = node_warm.and_then(|w| w.inline_layout_result.as_ref()) {
3627                let content_bounds = cached_layout.layout.bounds();
3628                paint_rect.size.width = content_bounds.width;
3629                paint_rect.size.height = content_bounds.height;
3630            }
3631        }
3632
3633        // Add a hit-test area for this node if it's interactive.
3634        // NOTE: For scrollable containers (overflow: scroll/auto), the hit-test area
3635        // was already pushed in generate_for_stacking_context BEFORE the scroll frame,
3636        // so we skip it here to avoid duplicate hit-test areas that would scroll with content.
3637        if let Some(tag_id) = get_tag_id(self.ctx.styled_dom, node.dom_node_id) {
3638            let is_scrollable = if let Some(dom_id) = node.dom_node_id {
3639                let styled_node_state = self.get_styled_node_state(dom_id);
3640                let overflow_x = get_overflow_x(self.ctx.styled_dom, dom_id, &styled_node_state);
3641                let overflow_y = get_overflow_y(self.ctx.styled_dom, dom_id, &styled_node_state);
3642                overflow_x.is_scroll() || overflow_y.is_scroll()
3643            } else {
3644                false
3645            };
3646
3647            // Push hit-test area for this node ONLY if it's not a scrollable container.
3648            // Scrollable containers already have their hit-test area pushed BEFORE the scroll frame
3649            // in generate_for_stacking_context, ensuring the hit-test stays stationary in parent space
3650            // while content scrolls. Pushing it again here would create a duplicate that scrolls
3651            // with content, causing hit-test failures when scrolled to the bottom.
3652            if !is_scrollable {
3653                builder.push_hit_test_area(paint_rect, tag_id);
3654            }
3655        }
3656
3657        // Paint the node's visible content.
3658        if let Some(cached_layout) = node_warm.and_then(|w| w.inline_layout_result.as_ref()) {
3659            let inline_layout = &cached_layout.layout;
3660            debug_info!(
3661                self.ctx,
3662                "[paint_node] node {} has inline_layout with {} items",
3663                node_index,
3664                inline_layout.items.len()
3665            );
3666
3667            if let Some(dom_id) = node.dom_node_id {
3668                let node_type = &self.ctx.styled_dom.node_data.as_container()[dom_id];
3669                debug_info!(
3670                    self.ctx,
3671                    "Painting inline content for node {} ({:?}) at {:?}, {} layout items",
3672                    node_index,
3673                    node_type.get_node_type(),
3674                    paint_rect,
3675                    inline_layout.items.len()
3676                );
3677            }
3678
3679            // paint_rect is the border-box, but inline layout positions are relative to
3680            // content-box. Use type-safe conversion to make this clear and avoid manual
3681            // calculations.
3682            let border_box = BorderBoxRect(paint_rect);
3683            let nbp = node.box_props.unpack();
3684            let mut content_box_rect =
3685                border_box.to_content_box(&nbp.padding, &nbp.border).rect();
3686
3687            // Save the viewport-sized content box for clipping BEFORE expanding
3688            // to full scroll content size. Text must be clipped to the viewport
3689            // when overflow is hidden/scroll/auto, not to the full content size.
3690            let viewport_clip_rect = content_box_rect;
3691
3692            // For scrollable containers, extend the content rect to the full content size.
3693            // The scroll frame handles clipping - we need to paint ALL content, not just
3694            // what fits in the viewport. Otherwise glyphs beyond the viewport are not rendered.
3695            let content_size = get_scroll_content_size(node, node_warm);
3696            if content_size.height > content_box_rect.size.height {
3697                content_box_rect.size.height = content_size.height;
3698            }
3699            if content_size.width > content_box_rect.size.width {
3700                content_box_rect.size.width = content_size.width;
3701            }
3702
3703            // Check for text-shadow and wrap inline content with push/pop shadow
3704            let mut pushed_text_shadow = false;
3705            if let Some(dom_id) = node.dom_node_id {
3706                let node_data = &self.ctx.styled_dom.node_data.as_container()[dom_id];
3707                let node_state = &self.ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
3708                if let Some(shadow_val) = self.ctx.styled_dom.css_property_cache.ptr
3709                    .get_text_shadow(node_data, &dom_id, node_state)
3710                {
3711                    if let Some(shadow) = shadow_val.get_property() {
3712                        builder.push_item(DisplayListItem::PushTextShadow {
3713                            shadow: (**shadow),
3714                        });
3715                        pushed_text_shadow = true;
3716                    }
3717                }
3718            }
3719
3720            self.paint_inline_content(builder, content_box_rect, viewport_clip_rect, inline_layout, node_index);
3721
3722            if pushed_text_shadow {
3723                builder.push_item(DisplayListItem::PopTextShadow);
3724            }
3725        } else if let Some(dom_id) = node.dom_node_id {
3726            // +spec:replaced-elements:edd21b - block-level replaced element painted atomically per E.2
3727            // +spec:replaced-elements:516b2a - replaced content painted atomically in painting order
3728            // This node might be a simple replaced element, like an <img> tag.
3729            let node_data = &self.ctx.styled_dom.node_data.as_container()[dom_id];
3730            if let NodeType::Image(image_ref) = node_data.get_node_type() {
3731                debug_info!(
3732                    self.ctx,
3733                    "Painting image for node {} at {:?}",
3734                    node_index,
3735                    paint_rect
3736                );
3737                // Get border-radius so the compositor can clip the image to rounded corners
3738                let styled_node_state = self.get_styled_node_state(dom_id);
3739                let element_size = PhysicalSizeImport {
3740                    width: paint_rect.size.width,
3741                    height: paint_rect.size.height,
3742                };
3743                let border_radius = get_border_radius(
3744                    self.ctx.styled_dom,
3745                    dom_id,
3746                    &styled_node_state,
3747                    element_size,
3748                    self.ctx.viewport_size,
3749                );
3750                // Store the ImageRef directly in the display list
3751                builder.push_image(paint_rect, image_ref.as_ref().clone(), border_radius);
3752            }
3753        }
3754
3755        Ok(())
3756    }
3757
3758    /// Emits drawing commands for scrollbars. This is called AFTER popping the scroll frame
3759    /// clip so scrollbars appear on top of content and are not clipped.
3760    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3761    fn paint_scrollbars(&self, builder: &mut DisplayListBuilder, node_index: usize) -> Result<()> {
3762        // CSS 2.2 §11.2: visibility:hidden scroll containers must not paint scrollbars,
3763        // but their layout space is preserved (already handled by layout).
3764        if self.is_node_hidden(node_index) {
3765            return Ok(());
3766        }
3767
3768        let node = self
3769            .positioned_tree
3770            .tree
3771            .get(node_index)
3772            .ok_or(LayoutError::InvalidTree)?;
3773
3774        let Some(paint_rect) = self.get_paint_rect(node_index) else {
3775            return Ok(());
3776        };
3777
3778        // Check if we need to draw scrollbars for this node.
3779        let scrollbar_info = self.positioned_tree.tree.warm(node_index)
3780            .and_then(|w| w.scrollbar_info)
3781            .unwrap_or_default();
3782
3783        // Get node_id for GPU cache lookup and CSS style lookup
3784        let node_id = node.dom_node_id;
3785
3786        // Get CSS scrollbar style for this node (cached per LayoutContext).
3787        let scrollbar_style = node_id
3788            .map(|nid| {
3789                let node_state =
3790                    &self.ctx.styled_dom.styled_nodes.as_container()[nid].styled_node_state;
3791                crate::solver3::getters::get_scrollbar_style_cached(self.ctx, nid, node_state)
3792            })
3793            .unwrap_or_default();
3794
3795        // Skip if scrollbar-width: none
3796        if matches!(
3797            scrollbar_style.width_mode,
3798            azul_css::props::style::scrollbar::LayoutScrollbarWidth::None
3799        ) {
3800            return Ok(());
3801        }
3802
3803        // +spec:overflow:3dfb2c - when scrollbar gutter is present but scrollbar is not,
3804        // paint the gutter background as an extension of the padding
3805        let scrollbar_gutter = node_id
3806            .and_then(|nid| {
3807                let node_state =
3808                    &self.ctx.styled_dom.styled_nodes.as_container()[nid].styled_node_state;
3809                get_scrollbar_gutter_property(self.ctx.styled_dom, nid, node_state).exact()
3810            })
3811            .unwrap_or_default();
3812        let gutter_is_stable = matches!(
3813            scrollbar_gutter,
3814            azul_css::props::layout::overflow::StyleScrollbarGutter::Stable
3815            | azul_css::props::layout::overflow::StyleScrollbarGutter::StableBothEdges
3816        );
3817        let gutter_both_edges = matches!(
3818            scrollbar_gutter,
3819            azul_css::props::layout::overflow::StyleScrollbarGutter::StableBothEdges
3820        );
3821
3822        if gutter_is_stable {
3823            let gbp = node.box_props.unpack();
3824            let border = &gbp.border;
3825            let gutter_width = scrollbar_style.visual_width_px;
3826            // Paint gutter as padding extension when scrollbar is absent
3827            let bg_color = node_id
3828                .map_or(ColorU::TRANSPARENT, |nid| {
3829                    let node_state =
3830                        &self.ctx.styled_dom.styled_nodes.as_container()[nid].styled_node_state;
3831                    get_background_color(self.ctx.styled_dom, nid, node_state)
3832                });
3833
3834            if !scrollbar_info.needs_vertical && gutter_width > 0.0 {
3835                // Right-side gutter (inline-end)
3836                let gutter_rect = LogicalRect {
3837                    origin: LogicalPosition::new(
3838                        paint_rect.origin.x + paint_rect.size.width - border.right - gutter_width,
3839                        paint_rect.origin.y + border.top,
3840                    ),
3841                    size: LogicalSize::new(
3842                        gutter_width,
3843                        (paint_rect.size.height - border.top - border.bottom).max(0.0),
3844                    ),
3845                };
3846                builder.push_rect(gutter_rect, bg_color, BorderRadius::default());
3847
3848                // Both-edges: also paint left-side gutter (inline-start)
3849                if gutter_both_edges {
3850                    let left_gutter_rect = LogicalRect {
3851                        origin: LogicalPosition::new(
3852                            paint_rect.origin.x + border.left,
3853                            paint_rect.origin.y + border.top,
3854                        ),
3855                        size: LogicalSize::new(
3856                            gutter_width,
3857                            (paint_rect.size.height - border.top - border.bottom).max(0.0),
3858                        ),
3859                    };
3860                    builder.push_rect(left_gutter_rect, bg_color, BorderRadius::default());
3861                }
3862            }
3863        }
3864
3865        // Get border dimensions to position scrollbar inside the border-box
3866        let sbp = node.box_props.unpack();
3867        let border = &sbp.border;
3868
3869        // Get border-radius for potential clipping
3870        let container_border_radius = node_id
3871            .map(|nid| {
3872                let node_state =
3873                    &self.ctx.styled_dom.styled_nodes.as_container()[nid].styled_node_state;
3874                let element_size = PhysicalSizeImport {
3875                    width: paint_rect.size.width,
3876                    height: paint_rect.size.height,
3877                };
3878                let viewport_size =
3879                    LogicalSize::new(self.ctx.viewport_size.width, self.ctx.viewport_size.height);
3880                get_border_radius(
3881                    self.ctx.styled_dom,
3882                    nid,
3883                    node_state,
3884                    element_size,
3885                    viewport_size,
3886                )
3887            })
3888            .unwrap_or_default();
3889
3890        // Calculate the inner rect (content-box) where scrollbars should be placed
3891        // Scrollbars are positioned inside the border, at the right/bottom edges
3892        let inner_rect = LogicalRect {
3893            origin: LogicalPosition::new(
3894                paint_rect.origin.x + border.left,
3895                paint_rect.origin.y + border.top,
3896            ),
3897            size: LogicalSize::new(
3898                (paint_rect.size.width - border.left - border.right).max(0.0),
3899                (paint_rect.size.height - border.top - border.bottom).max(0.0),
3900            ),
3901        };
3902
3903        // Get scroll position for thumb calculation
3904        // ScrollPosition contains parent_rect and children_rect
3905        // The scroll offset is the difference between children_rect.origin and parent_rect.origin
3906        let (scroll_offset_x, scroll_offset_y) = node_id
3907            .and_then(|nid| {
3908                self.scroll_offsets.get(&nid).map(|pos| {
3909                    (
3910                        pos.children_rect.origin.x - pos.parent_rect.origin.x,
3911                        pos.children_rect.origin.y - pos.parent_rect.origin.y,
3912                    )
3913                })
3914            })
3915            .unwrap_or((0.0, 0.0));
3916
3917        // Get content size for thumb proportional sizing
3918        // Use the node's get_content_size() method which returns the actual content size
3919        // from overflow_content_size (set during layout) or computes it from text/children.
3920        // For VirtualView nodes, the virtual_scroll_size (propagated through ScrollPosition.children_rect)
3921        // is more accurate than the layout-computed content size.
3922        let content_size = node_id
3923            .and_then(|nid| self.scroll_offsets.get(&nid)).map_or_else(|| self.positioned_tree.tree.get_content_size(node_index), |pos| pos.children_rect.size);
3924
3925        // Calculate thumb border-radius (half the scrollbar width for pill-shaped thumb)
3926        let thumb_radius = scrollbar_style.visual_width_px / 2.0;
3927        let thumb_border_radius = BorderRadius {
3928            top_left: thumb_radius,
3929            top_right: thumb_radius,
3930            bottom_left: thumb_radius,
3931            bottom_right: thumb_radius,
3932        };
3933
3934        if scrollbar_info.needs_vertical {
3935            // Look up opacity key from GPU cache for GPU-animated opacity.
3936            // If a key already exists in the cache from a previous frame, reuse it.
3937            // Otherwise, create a new unique key. The key will be registered
3938            // in the GPU cache after layout_document returns (same pattern as
3939            // transform keys). This ensures the display list ALWAYS has an
3940            // opacity binding, so GPU-only scroll updates can animate it.
3941            let opacity_key = node_id.map(|nid| {
3942                self.gpu_value_cache
3943                    .and_then(|cache| {
3944                        cache
3945                            .scrollbar_v_opacity_keys
3946                            .get(&(self.dom_id, nid))
3947                            .copied()
3948                    })
3949                    .unwrap_or_else(OpacityKey::unique)
3950            });
3951
3952            // Vertical scrollbar: use shared geometry computation
3953            let button_size = if scrollbar_style.show_scroll_buttons {
3954                scrollbar_style.scroll_button_size_px
3955            } else {
3956                0.0
3957            };
3958            let v_geom = compute_scrollbar_geometry_with_button_size(
3959                ScrollbarOrientation::Vertical,
3960                inner_rect,
3961                content_size,
3962                scroll_offset_y,
3963                scrollbar_style.visual_width_px,
3964                scrollbar_info.needs_horizontal,
3965                button_size,
3966            );
3967
3968            // Position thumb after the top button; GPU transform moves it within usable track
3969            let thumb_bounds = LogicalRect {
3970                origin: LogicalPosition::new(
3971                    v_geom.track_rect.origin.x,
3972                    v_geom.track_rect.origin.y + v_geom.button_size,
3973                ),
3974                size: LogicalSize::new(v_geom.width_px, v_geom.thumb_length),
3975            };
3976
3977            // Look up transform key from GPU cache for GPU-animated thumb positioning.
3978            // If a key already exists in the cache from a previous frame, reuse it.
3979            // Otherwise, create a new unique key. The key will be registered
3980            // in the GPU cache after layout_document returns.
3981            let thumb_transform_key = node_id.map(|nid| {
3982                self.gpu_value_cache
3983                    .and_then(|cache| cache.transform_keys.get(&nid).copied())
3984                    .unwrap_or_else(TransformKey::unique)
3985            });
3986
3987            // Initial transform: translate thumb within usable region
3988            let thumb_initial_transform =
3989                ComputedTransform3D::new_translation(0.0, v_geom.thumb_offset, 0.0);
3990
3991            // Generate hit-test ID for vertical scrollbar thumb
3992            let hit_id = node_id
3993                .map(|nid| azul_core::hit_test::ScrollbarHitId::VerticalThumb(self.dom_id, nid));
3994
3995            // Buttons at top/bottom of track (only if enabled in style)
3996            let (button_decrement_bounds, button_increment_bounds) = if scrollbar_style.show_scroll_buttons && v_geom.button_size > 0.0 {
3997                (
3998                    Some(LogicalRect {
3999                        origin: v_geom.track_rect.origin,
4000                        size: LogicalSize::new(v_geom.button_size, v_geom.button_size),
4001                    }),
4002                    Some(LogicalRect {
4003                        origin: LogicalPosition::new(
4004                            v_geom.track_rect.origin.x,
4005                            v_geom.track_rect.origin.y + v_geom.track_rect.size.height - v_geom.button_size,
4006                        ),
4007                        size: LogicalSize::new(v_geom.button_size, v_geom.button_size),
4008                    }),
4009                )
4010            } else {
4011                (None, None)
4012            };
4013            builder.push_scrollbar_styled(ScrollbarDrawInfo {
4014                bounds: v_geom.track_rect.into(),
4015                orientation: ScrollbarOrientation::Vertical,
4016                track_bounds: v_geom.track_rect.into(),
4017                track_color: scrollbar_style.track_color,
4018                thumb_bounds: thumb_bounds.into(),
4019                thumb_color: scrollbar_style.thumb_color,
4020                thumb_border_radius,
4021                button_decrement_bounds: button_decrement_bounds.map(Into::into),
4022                button_increment_bounds: button_increment_bounds.map(Into::into),
4023                button_color: scrollbar_style.button_color,
4024                opacity_key,
4025                thumb_transform_key,
4026                thumb_initial_transform,
4027                hit_id,
4028                clip_to_container_border: scrollbar_style.clip_to_container_border,
4029                container_border_radius,
4030                visibility: scrollbar_style.visibility,
4031            });
4032        }
4033
4034        if scrollbar_info.needs_horizontal {
4035            // Look up horizontal opacity key from GPU cache (same pattern as vertical).
4036            let opacity_key = node_id.map(|nid| {
4037                self.gpu_value_cache
4038                    .and_then(|cache| {
4039                        cache
4040                            .scrollbar_h_opacity_keys
4041                            .get(&(self.dom_id, nid))
4042                            .copied()
4043                    })
4044                    .unwrap_or_else(OpacityKey::unique)
4045            });
4046
4047            // Horizontal scrollbar: use shared geometry computation
4048            let h_button_size = if scrollbar_style.show_scroll_buttons {
4049                scrollbar_style.scroll_button_size_px
4050            } else {
4051                0.0
4052            };
4053            let h_geom = compute_scrollbar_geometry_with_button_size(
4054                ScrollbarOrientation::Horizontal,
4055                inner_rect,
4056                content_size,
4057                scroll_offset_x,
4058                scrollbar_style.visual_width_px,
4059                scrollbar_info.needs_vertical,
4060                h_button_size,
4061            );
4062
4063            // Position thumb after the left button; GPU transform moves it within usable track
4064            let thumb_bounds = LogicalRect {
4065                origin: LogicalPosition::new(
4066                    h_geom.track_rect.origin.x + h_geom.button_size,
4067                    h_geom.track_rect.origin.y,
4068                ),
4069                size: LogicalSize::new(h_geom.thumb_length, h_geom.width_px),
4070            };
4071
4072            // Look up horizontal transform key from GPU cache for GPU-animated thumb positioning.
4073            let thumb_transform_key = node_id.map(|nid| {
4074                self.gpu_value_cache
4075                    .and_then(|cache| cache.h_transform_keys.get(&nid).copied())
4076                    .unwrap_or_else(TransformKey::unique)
4077            });
4078            let thumb_initial_transform =
4079                ComputedTransform3D::new_translation(h_geom.thumb_offset, 0.0, 0.0);
4080
4081            // Generate hit-test ID for horizontal scrollbar thumb
4082            let hit_id = node_id
4083                .map(|nid| azul_core::hit_test::ScrollbarHitId::HorizontalThumb(self.dom_id, nid));
4084
4085            // Buttons at left/right of track (only if enabled in style)
4086            let (button_decrement_bounds, button_increment_bounds) = if scrollbar_style.show_scroll_buttons && h_geom.button_size > 0.0 {
4087                (
4088                    Some(LogicalRect {
4089                        origin: h_geom.track_rect.origin,
4090                        size: LogicalSize::new(h_geom.button_size, h_geom.button_size),
4091                    }),
4092                    Some(LogicalRect {
4093                        origin: LogicalPosition::new(
4094                            h_geom.track_rect.origin.x + h_geom.track_rect.size.width - h_geom.button_size,
4095                            h_geom.track_rect.origin.y,
4096                        ),
4097                        size: LogicalSize::new(h_geom.button_size, h_geom.button_size),
4098                    }),
4099                )
4100            } else {
4101                (None, None)
4102            };
4103            builder.push_scrollbar_styled(ScrollbarDrawInfo {
4104                bounds: h_geom.track_rect.into(),
4105                orientation: ScrollbarOrientation::Horizontal,
4106                track_bounds: h_geom.track_rect.into(),
4107                track_color: scrollbar_style.track_color,
4108                thumb_bounds: thumb_bounds.into(),
4109                thumb_color: scrollbar_style.thumb_color,
4110                thumb_border_radius,
4111                button_decrement_bounds: button_decrement_bounds.map(Into::into),
4112                button_increment_bounds: button_increment_bounds.map(Into::into),
4113                button_color: scrollbar_style.button_color,
4114                opacity_key,
4115                thumb_transform_key,
4116                thumb_initial_transform,
4117                hit_id,
4118                clip_to_container_border: scrollbar_style.clip_to_container_border,
4119                container_border_radius,
4120                visibility: scrollbar_style.visibility,
4121            });
4122        }
4123
4124        Ok(())
4125    }
4126
4127    /// Converts the rich layout information from `text3` into drawing commands.
4128    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
4129    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4130    fn paint_inline_content(
4131        &self,
4132        builder: &mut DisplayListBuilder,
4133        container_rect: LogicalRect,
4134        viewport_clip_rect: LogicalRect,
4135        layout: &UnifiedLayout,
4136        source_node_index: usize,
4137    ) {
4138        // TODO: This will always paint images over the glyphs
4139        // TODO: Handle z-index within inline content (e.g. background images)
4140        // NOTE: Text decorations (underline, strikethrough, overline) are handled in push_text_layout_to_display_list
4141        // TODO: Text shadows not yet implemented
4142        // NOTE: Text-overflow ellipsis is handled via apply_text_overflow_ellipsis()
4143        // which can be called as a post-processing step on the display list when
4144        // the node has overflow:hidden and text-overflow:ellipsis CSS properties.
4145        // +spec:overflow:7807b1 - text-overflow ellipsis side depends on direction (RTL clips left, LTR clips right); not yet implemented
4146        // +spec:overflow:bbf9c1 - text-overflow ellipsis should only truncate content
4147        // that is actually clipped; as content scrolls into view, show it instead of ellipsis
4148        // TODO: Handle text overflowing (based on container_rect and overflow behavior)
4149
4150        // Calculate actual content bounds from the layout
4151        // Use these bounds instead of container_rect to avoid inflated bounds
4152        // that extend beyond actual text content
4153        let layout_bounds = layout.bounds();
4154        let actual_bounds = if layout_bounds.width > 0.0 && layout_bounds.height > 0.0 {
4155            LogicalRect {
4156                origin: container_rect.origin,
4157                size: LogicalSize {
4158                    width: layout_bounds.width,
4159                    height: layout_bounds.height,
4160                },
4161            }
4162        } else {
4163            // If layout has no content, don't push TextLayout item at all
4164            // This prevents 0x0 TextLayout items that pollute height calculation
4165            LogicalRect {
4166                origin: container_rect.origin,
4167                size: LogicalSize::default(),
4168            }
4169        };
4170
4171        // Only push TextLayout if layout has actual content
4172        // This prevents empty TextLayout items with 0x0 bounds at various Y positions
4173        // from affecting pagination height calculations
4174        if layout_bounds.width > 0.0 || layout_bounds.height > 0.0 {
4175            builder.push_text_layout(
4176                Arc::new(layout.clone()),
4177                actual_bounds,
4178                FontHash::from_hash(0), // Will be updated per glyph run
4179                12.0,                   // Default font size, will be updated per glyph run
4180                ColorU {
4181                    r: 0,
4182                    g: 0,
4183                    b: 0,
4184                    a: 255,
4185                }, // Default color
4186            );
4187        }
4188
4189        let glyph_runs = crate::text3::glyphs::get_glyph_runs_simple(layout);
4190
4191        // FIRST PASS: Render backgrounds (solid colors, gradients) and borders for each glyph run
4192        // This must happen BEFORE rendering text so that backgrounds appear behind text.
4193        for glyph_run in &glyph_runs {
4194            // Calculate the bounding box for this glyph run
4195            if let (Some(first_glyph), Some(last_glyph)) =
4196                (glyph_run.glyphs.first(), glyph_run.glyphs.last())
4197            {
4198                // Calculate run bounds from glyph positions
4199                let run_start_x = container_rect.origin.x + first_glyph.point.x;
4200                let run_end_x = container_rect.origin.x + last_glyph.point.x;
4201                let run_width = (run_end_x - run_start_x).max(0.0);
4202
4203                // Skip if run has no width
4204                if run_width <= 0.0 {
4205                    continue;
4206                }
4207
4208                // Approximate height based on font size (baseline is at glyph.point.y)
4209                let baseline_y = container_rect.origin.y + first_glyph.point.y;
4210                let font_size = glyph_run.font_size_px;
4211                let ascent = font_size * APPROX_ASCENT_RATIO;
4212
4213                let mut run_bounds = LogicalRect::new(
4214                    LogicalPosition::new(run_start_x, baseline_y - ascent),
4215                    LogicalSize::new(run_width, font_size),
4216                );
4217
4218                // Expand run_bounds by padding + border so the background/border
4219                // rect covers the full inline box, not just the glyph area.
4220                if let Some(border) = &glyph_run.border {
4221                    let left_inset = border.left_inset();
4222                    let right_inset = border.right_inset();
4223                    let top_inset = border.top_inset();
4224                    let bottom_inset = border.bottom_inset();
4225
4226                    run_bounds.origin.x -= left_inset;
4227                    run_bounds.origin.y -= top_inset;
4228                    run_bounds.size.width += left_inset + right_inset;
4229                    run_bounds.size.height += top_inset + bottom_inset;
4230                }
4231
4232                builder.push_inline_backgrounds_and_border(
4233                    run_bounds,
4234                    glyph_run.background_color,
4235                    &glyph_run.background_content,
4236                    glyph_run.border.as_ref(),
4237                    self.ctx.image_cache,
4238                );
4239            }
4240        }
4241
4242        // SECOND PASS: Render text runs
4243        for glyph_run in &glyph_runs {
4244            // Clip text to the viewport-sized content box, not the full scroll
4245            // content area. This prevents text from overflowing outside the
4246            // container when overflow is hidden/scroll/auto.
4247            let clip_rect = viewport_clip_rect;
4248
4249            // Fix: Offset glyph positions by the container origin.
4250            // Text layout is relative to (0,0) of the IFC, but we need absolute coordinates.
4251            let offset_glyphs: Vec<GlyphInstance> = glyph_run
4252                .glyphs
4253                .iter()
4254                .map(|g| {
4255                    let mut g = *g;
4256                    g.point.x += container_rect.origin.x;
4257                    g.point.y += container_rect.origin.y;
4258                    g
4259                })
4260                .collect();
4261
4262            // Store only the font hash in the display list to keep it lean
4263            builder.push_text_run(
4264                offset_glyphs,
4265                FontHash::from_hash(glyph_run.font_hash),
4266                glyph_run.font_size_px,
4267                glyph_run.color,
4268                clip_rect,
4269                Some(source_node_index),
4270            );
4271
4272            // Render text decorations if present OR if this is IME composition preview
4273            let needs_underline = glyph_run.text_decoration.underline || glyph_run.is_ime_preview;
4274            let needs_strikethrough = glyph_run.text_decoration.strikethrough;
4275            let needs_overline = glyph_run.text_decoration.overline;
4276
4277            if needs_underline || needs_strikethrough || needs_overline {
4278                // Calculate the bounding box for this glyph run
4279                if let (Some(first_glyph), Some(last_glyph)) =
4280                    (glyph_run.glyphs.first(), glyph_run.glyphs.last())
4281                {
4282                    let decoration_start_x = container_rect.origin.x + first_glyph.point.x;
4283                    let decoration_end_x = container_rect.origin.x + last_glyph.point.x;
4284                    let decoration_width = decoration_end_x - decoration_start_x;
4285
4286                    // Use font metrics to determine decoration positions
4287                    // Standard ratios based on CSS specification
4288                    let font_size = glyph_run.font_size_px;
4289                    let thickness = (font_size * APPROX_UNDERLINE_THICKNESS_RATIO).max(1.0);
4290
4291                    // Baseline is at glyph.point.y
4292                    let baseline_y = container_rect.origin.y + first_glyph.point.y;
4293
4294                    if needs_underline {
4295                        // Underline is typically 10-15% below baseline
4296                        // IME composition always gets underlined
4297                        let underline_y = baseline_y + (font_size * APPROX_UNDERLINE_OFFSET_RATIO);
4298                        let underline_bounds = LogicalRect::new(
4299                            LogicalPosition::new(decoration_start_x, underline_y),
4300                            LogicalSize::new(decoration_width, thickness),
4301                        );
4302                        builder.push_underline(underline_bounds, glyph_run.color, thickness);
4303                    }
4304
4305                    if needs_strikethrough {
4306                        // Strikethrough is typically 40% above baseline (middle of x-height)
4307                        let strikethrough_y = baseline_y - (font_size * APPROX_STRIKETHROUGH_OFFSET_RATIO);
4308                        let strikethrough_bounds = LogicalRect::new(
4309                            LogicalPosition::new(decoration_start_x, strikethrough_y),
4310                            LogicalSize::new(decoration_width, thickness),
4311                        );
4312                        builder.push_strikethrough(
4313                            strikethrough_bounds,
4314                            glyph_run.color,
4315                            thickness,
4316                        );
4317                    }
4318
4319                    if needs_overline {
4320                        // Overline is typically at cap-height (75% above baseline)
4321                        let overline_y = baseline_y - (font_size * APPROX_OVERLINE_OFFSET_RATIO);
4322                        let overline_bounds = LogicalRect::new(
4323                            LogicalPosition::new(decoration_start_x, overline_y),
4324                            LogicalSize::new(decoration_width, thickness),
4325                        );
4326                        builder.push_overline(overline_bounds, glyph_run.color, thickness);
4327                    }
4328                }
4329            }
4330        }
4331
4332        // THIRD PASS: Generate hit-test areas for text runs
4333        // This enables cursor resolution directly on text nodes instead of their containers
4334        for glyph_run in &glyph_runs {
4335            // Only generate hit-test areas for runs with a source node id
4336            let Some(source_node_id) = glyph_run.source_node_id else {
4337                continue;
4338            };
4339
4340            // Calculate the bounding box for this glyph run
4341            if let (Some(first_glyph), Some(last_glyph)) =
4342                (glyph_run.glyphs.first(), glyph_run.glyphs.last())
4343            {
4344                let run_start_x = container_rect.origin.x + first_glyph.point.x;
4345                let run_end_x = container_rect.origin.x + last_glyph.point.x;
4346                let run_width = (run_end_x - run_start_x).max(0.0);
4347
4348                // Skip if run has no width
4349                if run_width <= 0.0 {
4350                    continue;
4351                }
4352
4353                // Calculate run bounds using font metrics
4354                let baseline_y = container_rect.origin.y + first_glyph.point.y;
4355                let font_size = glyph_run.font_size_px;
4356                let ascent = font_size * APPROX_ASCENT_RATIO;
4357
4358                let run_bounds = LogicalRect::new(
4359                    LogicalPosition::new(run_start_x, baseline_y - ascent),
4360                    LogicalSize::new(run_width, font_size),
4361                );
4362
4363                // Query the cursor type for this text node from the CSS property cache
4364                // Default to Text cursor (I-beam) for text nodes
4365                let cursor_type = self.get_cursor_type_for_text_node(source_node_id);
4366
4367                // Construct the hit-test tag for cursor resolution
4368                // tag.0 = DomId (upper 32 bits) | NodeId (lower 32 bits)
4369                // tag.1 = TAG_TYPE_CURSOR | cursor_type
4370                let tag_value = ((self.dom_id.inner as u64) << 32) | (source_node_id.index() as u64);
4371                let tag_type = TAG_TYPE_CURSOR | (cursor_type as u16);
4372                let tag_id = (tag_value, tag_type);
4373
4374                builder.push_hit_test_area(run_bounds, tag_id);
4375            }
4376        }
4377
4378        // Render inline objects (images, shapes/inline-blocks, etc.)
4379        // These are positioned by the text3 engine and need to be rendered at their calculated
4380        // positions
4381        for positioned_item in &layout.items {
4382            self.paint_inline_object(builder, container_rect.origin, positioned_item);
4383        }
4384    }
4385
4386    /// Paints a single inline object (image, shape, or inline-block)
4387    fn paint_inline_object(
4388        &self,
4389        builder: &mut DisplayListBuilder,
4390        base_pos: LogicalPosition,
4391        positioned_item: &PositionedItem,
4392    ) {
4393        let ShapedItem::Object {
4394            content, bounds, ..
4395        } = &positioned_item.item
4396        else {
4397            // Other item types (e.g., breaks) don't produce painted output.
4398            return;
4399        };
4400
4401        // Calculate the absolute position of this object
4402        // positioned_item.position is relative to the container
4403        let object_bounds = LogicalRect::new(
4404            LogicalPosition::new(
4405                base_pos.x + positioned_item.position.x,
4406                base_pos.y + positioned_item.position.y,
4407            ),
4408            LogicalSize::new(bounds.width, bounds.height),
4409        );
4410
4411        match content {
4412            InlineContent::Image(image) => {
4413                if let Some(image_ref) = get_image_ref_for_image_source(
4414                    &image.source,
4415                    self.ctx.image_cache,
4416                    object_bounds.size,
4417                ) {
4418                    builder.push_image(object_bounds, image_ref, BorderRadius::default());
4419                }
4420            }
4421            InlineContent::Shape(shape) => {
4422                self.paint_inline_shape(builder, object_bounds, shape, bounds);
4423            }
4424            _ => {}
4425        }
4426    }
4427
4428    // +spec:inline-block:a60a89 - inline-block painted atomically as pseudo-stacking-context per E.2
4429    /// Paints an inline shape (inline-block background and border)
4430    fn paint_inline_shape(
4431        &self,
4432        builder: &mut DisplayListBuilder,
4433        object_bounds: LogicalRect,
4434        shape: &InlineShape,
4435        bounds: &crate::text3::cache::Rect,
4436    ) {
4437        // Render inline-block backgrounds and borders using their CSS styling
4438        // The text3 engine positions these correctly in the inline flow
4439        let Some(node_id) = shape.source_node_id else {
4440            return;
4441        };
4442
4443        // If this inline-block establishes a stacking context, its background was
4444        // already painted by paint_node_background_and_border (called from
4445        // generate_for_stacking_context). Painting again here would cause
4446        // double-rendering. Skip it.
4447        if let Some(indices) = self.positioned_tree.tree.dom_to_layout.get(&node_id) {
4448            if let Some(&idx) = indices.first() {
4449                if self.establishes_stacking_context(idx) {
4450                    return;
4451                }
4452            }
4453        }
4454
4455        let styled_node_state =
4456            &self.ctx.styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
4457
4458        // Get all background layers (colors, gradients, images)
4459        let background_contents =
4460            get_background_contents(self.ctx.styled_dom, node_id, styled_node_state);
4461
4462        // Get border information
4463        let border_info = get_border_info(self.ctx.styled_dom, node_id, styled_node_state);
4464
4465        // FIX: object_bounds is the margin-box position from text3.
4466        // We need to convert to border-box for painting backgrounds/borders.
4467        let margins = self.positioned_tree.tree.dom_to_layout.get(&node_id).map_or_else(
4468            crate::solver3::geometry::EdgeSizes::default,
4469            |indices| indices.first().map_or_else(
4470                crate::solver3::geometry::EdgeSizes::default,
4471                |&idx| self.positioned_tree.tree.nodes[idx].box_props.unpack().margin,
4472            ),
4473        );
4474
4475        // Convert margin-box bounds to border-box bounds
4476        let border_box_bounds = LogicalRect {
4477            origin: LogicalPosition {
4478                x: object_bounds.origin.x + margins.left,
4479                y: object_bounds.origin.y + margins.top,
4480            },
4481            size: LogicalSize {
4482                width: (object_bounds.size.width - margins.left - margins.right).max(0.0),
4483                height: (object_bounds.size.height - margins.top - margins.bottom).max(0.0),
4484            },
4485        };
4486
4487        let element_size = PhysicalSizeImport {
4488            width: border_box_bounds.size.width,
4489            height: border_box_bounds.size.height,
4490        };
4491
4492        // Get border radius for background clipping
4493        let simple_border_radius = get_border_radius(
4494            self.ctx.styled_dom,
4495            node_id,
4496            styled_node_state,
4497            element_size,
4498            self.ctx.viewport_size,
4499        );
4500
4501        // Get style border radius for border rendering
4502        let style_border_radius =
4503            get_style_border_radius(self.ctx.styled_dom, node_id, styled_node_state);
4504
4505        // Use unified background/border painting with border-box bounds
4506        builder.push_backgrounds_and_border(
4507            border_box_bounds,
4508            &background_contents,
4509            &border_info,
4510            simple_border_radius,
4511            style_border_radius,
4512            self.ctx.image_cache,
4513        );
4514
4515        // Push hit-test area for this inline-block element
4516        // This is critical for buttons and other inline-block elements to receive
4517        // mouse events and display the correct cursor (e.g., cursor: pointer)
4518        if let Some(tag_id) = get_tag_id(self.ctx.styled_dom, Some(node_id)) {
4519            builder.push_hit_test_area(border_box_bounds, tag_id);
4520        }
4521
4522    }
4523
4524    // +spec:overflow:d1d5f6 - CSS 2.2 §9.9.1 stacking context creation and 7-layer paint order
4525    /// Determines if a node establishes a new stacking context based on CSS rules.
4526    // +spec:overflow:47b791 - z-index applies to positioned boxes; z-index:auto does not establish stacking context
4527    // +spec:positioning:8c6efd - Stacking contexts: positioned elements with z-index != auto establish new stacking context
4528    // +spec:positioning:b84cfa - z-index stacking context creation: integer z-index on positioned elements creates SC; auto on fixed/root creates SC
4529    // +spec:positioning:d06368 - relative/absolute with z-index:auto do not form stacking context but are painted as if they did
4530    fn establishes_stacking_context(&self, node_index: usize) -> bool {
4531        let Some(node) = self.positioned_tree.tree.get(node_index) else {
4532            return false;
4533        };
4534        let Some(dom_id) = node.dom_node_id else {
4535            return false;
4536        };
4537
4538        let position = get_position_type(self.ctx.styled_dom, Some(dom_id));
4539        let z_auto = crate::solver3::getters::is_z_index_auto(self.ctx.styled_dom, Some(dom_id));
4540
4541        // +spec:position-sticky:66ba22 - fixed and sticky positioned boxes form a stacking context
4542        if position == LayoutPosition::Fixed || position == LayoutPosition::Sticky {
4543            return true;
4544        }
4545
4546        // +spec:positioning:d06368 - relative/absolute with z-index:auto do not form stacking context
4547        // z-index:auto on position:absolute does NOT establish stacking context
4548        if position == LayoutPosition::Absolute {
4549            return !z_auto;
4550        }
4551
4552        // position:relative with explicit z-index integer establishes stacking context
4553        if position == LayoutPosition::Relative && !z_auto {
4554            return true;
4555        }
4556
4557        if let Some(styled_node) = self.ctx.styled_dom.styled_nodes.as_container().get(dom_id) {
4558            let node_data = &self.ctx.styled_dom.node_data.as_container()[dom_id];
4559            let node_state =
4560                &self.ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4561
4562            // Opacity < 1 (GPU: fast path via compact cache)
4563            if crate::solver3::getters::get_opacity(
4564                self.ctx.styled_dom, dom_id, node_state,
4565            ) < 1.0 {
4566                return true;
4567            }
4568
4569            // Transform != none (GPU: has_transform bit check, then slow walk only if set)
4570            if let Some(t) = crate::solver3::getters::get_transform(
4571                self.ctx.styled_dom, dom_id, node_state,
4572            ) {
4573                if !t.is_empty() {
4574                    return true;
4575                }
4576            }
4577        }
4578
4579        false
4580    }
4581}
4582
4583/// Helper struct to pass layout results to the display list generator.
4584///
4585/// Combines the layout tree with pre-calculated absolute positions for each node.
4586/// The positions are stored separately because they are computed in a final
4587/// positioning pass after layout is complete.
4588#[derive(Debug)]
4589pub struct PositionedTree<'a> {
4590    /// The layout tree containing all nodes with their computed sizes
4591    pub tree: &'a LayoutTree,
4592    /// Map from node index to its absolute position in the document
4593    pub calculated_positions: &'a super::PositionVec,
4594}
4595
4596/// Expands `clip_rect` outward by the `overflow-clip-margin` value on axes that use `overflow: clip`.
4597///
4598/// Per CSS Overflow 3 §3.2, `overflow-clip-margin` only applies to `overflow: clip` —
4599/// it has no effect on `overflow: hidden`, `scroll`, or `auto`.
4600#[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
4601fn apply_overflow_clip_margin(
4602    clip_rect: &mut LogicalRect,
4603    overflow_x: &super::getters::MultiValue<LayoutOverflow>,
4604    overflow_y: &super::getters::MultiValue<LayoutOverflow>,
4605    styled_dom: &StyledDom,
4606    dom_id: NodeId,
4607    styled_node_state: &azul_core::styled_dom::StyledNodeState,
4608) {
4609    if !overflow_x.is_clip() && !overflow_y.is_clip() {
4610        return;
4611    }
4612    let clip_margin = get_overflow_clip_margin_property(styled_dom, dom_id, styled_node_state);
4613    let Some(margin_val) = clip_margin.exact() else {
4614        return;
4615    };
4616    let m = margin_val.inner.to_pixels_internal(0.0, 0.0, 0.0).max(0.0);
4617    if m <= 0.0 {
4618        return;
4619    }
4620    if overflow_x.is_clip() {
4621        clip_rect.origin.x -= m;
4622        clip_rect.size.width += m * 2.0;
4623    }
4624    if overflow_y.is_clip() {
4625        clip_rect.origin.y -= m;
4626        clip_rect.size.height += m * 2.0;
4627    }
4628}
4629
4630fn get_scroll_id(id: Option<NodeId>) -> LocalScrollId {
4631    id.map_or(0, |i| i.index() as u64)
4632}
4633
4634/// Calculates the actual content size of a node, including all children and text.
4635/// This is used to determine if scrollbars should appear for overflow: auto.
4636// +spec:overflow:c2ed94 - replaced element overflow is ink overflow (not scrollable);
4637// replaced elements (images) don't contribute scrollable overflow here
4638fn get_scroll_content_size(node: &LayoutNodeHot, warm: Option<&LayoutNodeWarm>) -> LogicalSize {
4639    // First check if we have a pre-calculated overflow_content_size (for block children)
4640    if let Some(overflow_size) = warm.and_then(|w| w.overflow_content_size) {
4641        return overflow_size;
4642    }
4643
4644    // Start with the node's own size
4645    let mut content_size = node.used_size.unwrap_or_default();
4646
4647    // If this node has text layout, calculate the bounds of all text items
4648    if let Some(cached_layout) = warm.and_then(|w| w.inline_layout_result.as_ref()) {
4649        let text_layout = &cached_layout.layout;
4650        // Find the maximum extent of all positioned items
4651        let mut max_x: f32 = 0.0;
4652        let mut max_y: f32 = 0.0;
4653
4654        for positioned_item in &text_layout.items {
4655            let item_bounds = positioned_item.item.bounds();
4656            let item_right = positioned_item.position.x + item_bounds.width;
4657            let item_bottom = positioned_item.position.y + item_bounds.height;
4658
4659            max_x = max_x.max(item_right);
4660            max_y = max_y.max(item_bottom);
4661        }
4662
4663        // Use the maximum extent as content size if it's larger
4664        content_size.width = content_size.width.max(max_x);
4665        content_size.height = content_size.height.max(max_y);
4666    }
4667
4668    content_size
4669}
4670
4671fn get_tag_id(dom: &StyledDom, id: Option<NodeId>) -> Option<DisplayListTagId> {
4672    let node_id = id?;
4673    let tag_mapping = dom.tag_ids_to_node_ids.as_ref().iter().find(|m| {
4674        m.node_id.into_crate_internal() == Some(node_id)
4675    })?;
4676    Some((tag_mapping.tag_id.inner, TAG_TYPE_DOM_NODE))
4677}
4678
4679/// Resolve an [`ImageSource`] (as carried by an inline `InlineContent::Image`)
4680/// to a concrete [`ImageRef`] ready for `push_image`.
4681///
4682/// `target_size` is the object's logical box size, used only to size the raster
4683/// when rasterizing an SVG source.
4684#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
4685fn get_image_ref_for_image_source(
4686    source: &ImageSource,
4687    image_cache: &azul_core::resources::ImageCache,
4688    target_size: LogicalSize,
4689) -> Option<ImageRef> {
4690    match source {
4691        ImageSource::Ref(image_ref) => Some(image_ref.clone()),
4692        ImageSource::Url(url) => {
4693            // CSS url() image — resolved exactly like `background-image`: look it
4694            // up in the ImageCache by its CSS id (see push_backgrounds_and_border).
4695            let css_id: azul_css::AzString = url.clone().into();
4696            image_cache.get_css_image_id(&css_id).cloned()
4697        }
4698        ImageSource::Data(bytes) => {
4699            // Encoded image bytes (PNG/JPEG/…): decode to a RawImage, then build
4700            // an ImageRef. The `decode` module is gated on `std` and the decoder
4701            // itself needs the `image` crate (`image_decoding`).
4702            #[cfg(all(feature = "std", feature = "image_decoding"))]
4703            {
4704                use crate::image::decode::{
4705                    decode_raw_image_from_any_bytes, ResultRawImageDecodeImageError,
4706                };
4707                if let ResultRawImageDecodeImageError::Ok(raw) =
4708                    decode_raw_image_from_any_bytes(bytes)
4709                {
4710                    return ImageRef::new_rawimage(raw);
4711                }
4712                None
4713            }
4714            #[cfg(not(all(feature = "std", feature = "image_decoding")))]
4715            {
4716                let _ = bytes;
4717                None
4718            }
4719        }
4720        ImageSource::Svg(svg) => {
4721            // Rasterize the SVG source to the object's box size using the CPU SVG
4722            // renderer. Needs the `cpurender` feature.
4723            #[cfg(feature = "cpurender")]
4724            {
4725                let w = (target_size.width.round() as u32).max(1);
4726                let h = (target_size.height.round() as u32).max(1);
4727                crate::cpurender::render_svg_to_imageref(svg.as_bytes(), w, h).ok()
4728            }
4729            #[cfg(not(feature = "cpurender"))]
4730            {
4731                let _ = (svg, target_size);
4732                None
4733            }
4734        }
4735        ImageSource::Placeholder(_) => {
4736            // Layout-only placeholder: reserves space, paints nothing.
4737            None
4738        }
4739    }
4740}
4741
4742/// Get the bounds of a display list item in window-logical coordinates.
4743fn get_display_item_bounds(item: &DisplayListItem) -> Option<WindowLogicalRect> {
4744    item.bounds().map(WindowLogicalRect::from)
4745}
4746
4747/// Clip a display list item to page bounds and offset to page-relative coordinates.
4748/// Returns None if the item is completely outside the page bounds.
4749#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
4750#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4751fn clip_and_offset_display_item(
4752    item: &DisplayListItem,
4753    page_top: f32,
4754    page_bottom: f32,
4755) -> Option<DisplayListItem> {
4756    match item {
4757        DisplayListItem::Rect {
4758            bounds,
4759            color,
4760            border_radius,
4761        } => clip_rect_item(bounds.into_inner(), *color, *border_radius, page_top, page_bottom),
4762
4763        DisplayListItem::Border {
4764            bounds,
4765            widths,
4766            colors,
4767            styles,
4768            border_radius,
4769        } => clip_border_item(
4770            bounds.into_inner(),
4771            *widths,
4772            *colors,
4773            *styles,
4774            *border_radius,
4775            page_top,
4776            page_bottom,
4777        ),
4778
4779        DisplayListItem::SelectionRect {
4780            bounds,
4781            border_radius,
4782            color,
4783        } => clip_selection_rect_item(bounds.into_inner(), *border_radius, *color, page_top, page_bottom),
4784
4785        DisplayListItem::CursorRect { bounds, color } => {
4786            clip_cursor_rect_item(bounds.into_inner(), *color, page_top, page_bottom)
4787        }
4788
4789        DisplayListItem::Image { bounds, image, border_radius } => {
4790            clip_image_item(bounds.into_inner(), image.clone(), *border_radius, page_top, page_bottom)
4791        }
4792
4793        DisplayListItem::TextLayout {
4794            layout,
4795            bounds,
4796            font_hash,
4797            font_size_px,
4798            color,
4799        } => clip_text_layout_item(
4800            layout,
4801            bounds.into_inner(),
4802            *font_hash,
4803            *font_size_px,
4804            *color,
4805            page_top,
4806            page_bottom,
4807        ),
4808
4809        DisplayListItem::Text {
4810            glyphs,
4811            font_hash,
4812            font_size_px,
4813            color,
4814            clip_rect,
4815            ..
4816        } => clip_text_item(
4817            glyphs,
4818            *font_hash,
4819            *font_size_px,
4820            *color,
4821            clip_rect.into_inner(),
4822            page_top,
4823            page_bottom,
4824        ),
4825
4826        DisplayListItem::Underline {
4827            bounds,
4828            color,
4829            thickness,
4830        } => clip_text_decoration_item(
4831            bounds.into_inner(),
4832            *color,
4833            *thickness,
4834            TextDecorationType::Underline,
4835            page_top,
4836            page_bottom,
4837        ),
4838
4839        DisplayListItem::Strikethrough {
4840            bounds,
4841            color,
4842            thickness,
4843        } => clip_text_decoration_item(
4844            bounds.into_inner(),
4845            *color,
4846            *thickness,
4847            TextDecorationType::Strikethrough,
4848            page_top,
4849            page_bottom,
4850        ),
4851
4852        DisplayListItem::Overline {
4853            bounds,
4854            color,
4855            thickness,
4856        } => clip_text_decoration_item(
4857            bounds.into_inner(),
4858            *color,
4859            *thickness,
4860            TextDecorationType::Overline,
4861            page_top,
4862            page_bottom,
4863        ),
4864
4865        DisplayListItem::ScrollBar {
4866            bounds,
4867            color,
4868            orientation,
4869            opacity_key,
4870            hit_id,
4871        } => clip_scrollbar_item(
4872            bounds.into_inner(),
4873            *color,
4874            *orientation,
4875            *opacity_key,
4876            *hit_id,
4877            page_top,
4878            page_bottom,
4879        ),
4880
4881        DisplayListItem::HitTestArea { bounds, tag } => {
4882            clip_hit_test_area_item(bounds.into_inner(), *tag, page_top, page_bottom)
4883        }
4884
4885        DisplayListItem::VirtualView {
4886            child_dom_id,
4887            bounds,
4888            clip_rect,
4889        } => clip_virtual_view_item(*child_dom_id, bounds.into_inner(), clip_rect.into_inner(), page_top, page_bottom),
4890
4891        // ScrollBarStyled - clip based on overall bounds
4892        DisplayListItem::ScrollBarStyled { info } => {
4893            let bounds = info.bounds;
4894            if bounds.0.origin.y + bounds.0.size.height < page_top || bounds.0.origin.y > page_bottom {
4895                None
4896            } else {
4897                // Clone and offset all the internal bounds
4898                let mut clipped_info = (**info).clone();
4899                let y_offset = -page_top;
4900                clipped_info.bounds = offset_rect_y(clipped_info.bounds.into_inner(), y_offset).into();
4901                clipped_info.track_bounds = offset_rect_y(clipped_info.track_bounds.into_inner(), y_offset).into();
4902                clipped_info.thumb_bounds = offset_rect_y(clipped_info.thumb_bounds.into_inner(), y_offset).into();
4903                if let Some(b) = clipped_info.button_decrement_bounds {
4904                    clipped_info.button_decrement_bounds = Some(offset_rect_y(b.into_inner(), y_offset).into());
4905                }
4906                if let Some(b) = clipped_info.button_increment_bounds {
4907                    clipped_info.button_increment_bounds = Some(offset_rect_y(b.into_inner(), y_offset).into());
4908                }
4909                Some(DisplayListItem::ScrollBarStyled {
4910                    info: Box::new(clipped_info),
4911                })
4912            }
4913        }
4914
4915        // State management items - skip for now (would need proper per-page tracking)
4916        DisplayListItem::PushClip { .. }
4917        | DisplayListItem::PopClip
4918        | DisplayListItem::PushScrollFrame { .. }
4919        | DisplayListItem::PopScrollFrame
4920        | DisplayListItem::PushStackingContext { .. }
4921        | DisplayListItem::PopStackingContext
4922        | DisplayListItem::VirtualViewPlaceholder { .. } => None,
4923
4924        // Gradient items - simple bounds check
4925        DisplayListItem::LinearGradient {
4926            bounds,
4927            gradient,
4928            border_radius,
4929        } => {
4930            if bounds.0.origin.y + bounds.0.size.height < page_top || bounds.0.origin.y > page_bottom {
4931                None
4932            } else {
4933                Some(DisplayListItem::LinearGradient {
4934                    bounds: offset_rect_y(bounds.into_inner(), -page_top).into(),
4935                    gradient: gradient.clone(),
4936                    border_radius: *border_radius,
4937                })
4938            }
4939        }
4940        DisplayListItem::RadialGradient {
4941            bounds,
4942            gradient,
4943            border_radius,
4944        } => {
4945            if bounds.0.origin.y + bounds.0.size.height < page_top || bounds.0.origin.y > page_bottom {
4946                None
4947            } else {
4948                Some(DisplayListItem::RadialGradient {
4949                    bounds: offset_rect_y(bounds.into_inner(), -page_top).into(),
4950                    gradient: gradient.clone(),
4951                    border_radius: *border_radius,
4952                })
4953            }
4954        }
4955        DisplayListItem::ConicGradient {
4956            bounds,
4957            gradient,
4958            border_radius,
4959        } => {
4960            if bounds.0.origin.y + bounds.0.size.height < page_top || bounds.0.origin.y > page_bottom {
4961                None
4962            } else {
4963                Some(DisplayListItem::ConicGradient {
4964                    bounds: offset_rect_y(bounds.into_inner(), -page_top).into(),
4965                    gradient: gradient.clone(),
4966                    border_radius: *border_radius,
4967                })
4968            }
4969        }
4970
4971        // BoxShadow - simple bounds check
4972        DisplayListItem::BoxShadow {
4973            bounds,
4974            shadow,
4975            border_radius,
4976        } => {
4977            if bounds.0.origin.y + bounds.0.size.height < page_top || bounds.0.origin.y > page_bottom {
4978                None
4979            } else {
4980                Some(DisplayListItem::BoxShadow {
4981                    bounds: offset_rect_y(bounds.into_inner(), -page_top).into(),
4982                    shadow: *shadow,
4983                    border_radius: *border_radius,
4984                })
4985            }
4986        }
4987
4988        // Filter effects - skip for now (would need proper per-page tracking)
4989        DisplayListItem::PushFilter { .. }
4990        | DisplayListItem::PopFilter
4991        | DisplayListItem::PushBackdropFilter { .. }
4992        | DisplayListItem::PopBackdropFilter
4993        | DisplayListItem::PushOpacity { .. }
4994        | DisplayListItem::PopOpacity
4995        | DisplayListItem::PushReferenceFrame { .. }
4996        | DisplayListItem::PopReferenceFrame
4997        | DisplayListItem::PushTextShadow { .. }
4998        | DisplayListItem::PopTextShadow
4999        | DisplayListItem::PushImageMaskClip { .. }
5000        | DisplayListItem::PopImageMaskClip => None,
5001    }
5002}
5003
5004// Helper functions for clip_and_offset_display_item
5005
5006/// Internal enum for text decoration type dispatch
5007#[derive(Debug, Clone, Copy)]
5008enum TextDecorationType {
5009    Underline,
5010    Strikethrough,
5011    Overline,
5012}
5013
5014/// Clips a filled rectangle to page bounds.
5015fn clip_rect_item(
5016    bounds: LogicalRect,
5017    color: ColorU,
5018    border_radius: BorderRadius,
5019    page_top: f32,
5020    page_bottom: f32,
5021) -> Option<DisplayListItem> {
5022    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| DisplayListItem::Rect {
5023        bounds: clipped.into(),
5024        color,
5025        border_radius,
5026    })
5027}
5028
5029/// Clips a border to page bounds, hiding top/bottom borders when clipped.
5030fn clip_border_item(
5031    bounds: LogicalRect,
5032    widths: StyleBorderWidths,
5033    colors: StyleBorderColors,
5034    styles: StyleBorderStyles,
5035    border_radius: StyleBorderRadius,
5036    page_top: f32,
5037    page_bottom: f32,
5038) -> Option<DisplayListItem> {
5039    let original_bounds = bounds;
5040    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| {
5041        let new_widths = adjust_border_widths_for_clipping(
5042            widths,
5043            original_bounds,
5044            clipped,
5045            page_top,
5046            page_bottom,
5047        );
5048        DisplayListItem::Border {
5049            bounds: clipped.into(),
5050            widths: new_widths,
5051            colors,
5052            styles,
5053            border_radius,
5054        }
5055    })
5056}
5057
5058/// Adjusts border widths when a border is clipped at page boundaries.
5059/// Hides top border if clipped at top, bottom border if clipped at bottom.
5060fn adjust_border_widths_for_clipping(
5061    mut widths: StyleBorderWidths,
5062    original_bounds: LogicalRect,
5063    clipped: LogicalRect,
5064    page_top: f32,
5065    page_bottom: f32,
5066) -> StyleBorderWidths {
5067    // Hide top border if we clipped the top
5068    if clipped.origin.y > 0.0 && original_bounds.origin.y < page_top {
5069        widths.top = None;
5070    }
5071
5072    // Hide bottom border if we clipped the bottom
5073    let original_bottom = original_bounds.origin.y + original_bounds.size.height;
5074    let clipped_bottom = clipped.origin.y + clipped.size.height;
5075    if original_bottom > page_bottom && clipped_bottom >= page_bottom - page_top - 1.0 {
5076        widths.bottom = None;
5077    }
5078
5079    widths
5080}
5081
5082/// Clips a selection rectangle to page bounds.
5083fn clip_selection_rect_item(
5084    bounds: LogicalRect,
5085    border_radius: BorderRadius,
5086    color: ColorU,
5087    page_top: f32,
5088    page_bottom: f32,
5089) -> Option<DisplayListItem> {
5090    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| DisplayListItem::SelectionRect {
5091        bounds: clipped.into(),
5092        border_radius,
5093        color,
5094    })
5095}
5096
5097/// Clips a cursor rectangle to page bounds.
5098fn clip_cursor_rect_item(
5099    bounds: LogicalRect,
5100    color: ColorU,
5101    page_top: f32,
5102    page_bottom: f32,
5103) -> Option<DisplayListItem> {
5104    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| DisplayListItem::CursorRect {
5105        bounds: clipped.into(),
5106        color,
5107    })
5108}
5109
5110/// Clips an image to page bounds if it overlaps the page.
5111fn clip_image_item(
5112    bounds: LogicalRect,
5113    image: ImageRef,
5114    border_radius: BorderRadius,
5115    page_top: f32,
5116    page_bottom: f32,
5117) -> Option<DisplayListItem> {
5118    if !rect_intersects(&bounds, page_top, page_bottom) {
5119        return None;
5120    }
5121    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| DisplayListItem::Image {
5122        bounds: clipped.into(),
5123        image,
5124        border_radius,
5125    })
5126}
5127
5128/// Clips a text layout block to page bounds, filtering individual text items.
5129fn clip_text_layout_item(
5130    layout: &Arc<dyn std::any::Any + Send + Sync>,
5131    bounds: LogicalRect,
5132    font_hash: FontHash,
5133    font_size_px: f32,
5134    color: ColorU,
5135    page_top: f32,
5136    page_bottom: f32,
5137) -> Option<DisplayListItem> {
5138    if !rect_intersects(&bounds, page_top, page_bottom) {
5139        return None;
5140    }
5141
5142    // Try to downcast and filter UnifiedLayout items
5143    #[cfg(feature = "text_layout")]
5144    if let Some(unified_layout) = layout.downcast_ref::<UnifiedLayout>() {
5145        return clip_unified_layout(
5146            unified_layout,
5147            bounds,
5148            font_hash,
5149            font_size_px,
5150            color,
5151            page_top,
5152            page_bottom,
5153        );
5154    }
5155
5156    // Fallback: simple bounds offset (legacy behavior)
5157    Some(DisplayListItem::TextLayout {
5158        layout: layout.clone(),
5159        bounds: offset_rect_y(bounds, -page_top).into(),
5160        font_hash,
5161        font_size_px,
5162        color,
5163    })
5164}
5165
5166/// Clips a `UnifiedLayout` by filtering items to those on the current page.
5167#[cfg(feature = "text_layout")]
5168fn clip_unified_layout(
5169    unified_layout: &UnifiedLayout,
5170    bounds: LogicalRect,
5171    font_hash: FontHash,
5172    font_size_px: f32,
5173    color: ColorU,
5174    page_top: f32,
5175    page_bottom: f32,
5176) -> Option<DisplayListItem> {
5177    let layout_origin_y = bounds.origin.y;
5178    let layout_origin_x = bounds.origin.x;
5179
5180    // Filter items whose center falls within this page
5181    let filtered_items: Vec<_> = unified_layout
5182        .items
5183        .iter()
5184        .filter(|item| item_center_on_page(item, layout_origin_y, page_top, page_bottom))
5185        .cloned()
5186        .collect();
5187
5188    if filtered_items.is_empty() {
5189        return None;
5190    }
5191
5192    // Calculate new origin for page-relative positioning
5193    let new_origin_y = (layout_origin_y - page_top).max(0.0);
5194
5195    // Transform items to page-relative coordinates and calculate bounds
5196    let (offset_items, min_y, max_y, max_width) =
5197        transform_items_to_page_coords(filtered_items, layout_origin_y, page_top, new_origin_y);
5198
5199    let new_layout = UnifiedLayout {
5200        items: offset_items,
5201        overflow: unified_layout.overflow.clone(),
5202    };
5203
5204    let new_bounds = LogicalRect {
5205        origin: LogicalPosition {
5206            x: layout_origin_x,
5207            y: new_origin_y,
5208        },
5209        size: LogicalSize {
5210            width: max_width.max(bounds.size.width),
5211            height: (max_y - min_y.min(0.0)).max(0.0),
5212        },
5213    };
5214
5215    Some(DisplayListItem::TextLayout {
5216        layout: Arc::new(new_layout),
5217        bounds: new_bounds.into(),
5218        font_hash,
5219        font_size_px,
5220        color,
5221    })
5222}
5223
5224/// Checks if an item's center point falls within the page bounds.
5225#[cfg(feature = "text_layout")]
5226fn item_center_on_page(
5227    item: &PositionedItem,
5228    layout_origin_y: f32,
5229    page_top: f32,
5230    page_bottom: f32,
5231) -> bool {
5232    let item_y_absolute = layout_origin_y + item.position.y;
5233    let item_height = item.item.bounds().height;
5234    let item_center_y = item_y_absolute + (item_height / 2.0);
5235    item_center_y >= page_top && item_center_y < page_bottom
5236}
5237
5238/// Transforms filtered items to page-relative coordinates.
5239/// Returns (items, `min_y`, `max_y`, `max_width`).
5240#[cfg(feature = "text_layout")]
5241fn transform_items_to_page_coords(
5242    items: Vec<PositionedItem>,
5243    layout_origin_y: f32,
5244    page_top: f32,
5245    new_origin_y: f32,
5246) -> (Vec<PositionedItem>, f32, f32, f32) {
5247    let mut min_y = f32::MAX;
5248    let mut max_y = f32::MIN;
5249    let mut max_width = 0.0f32;
5250
5251    let offset_items: Vec<_> = items
5252        .into_iter()
5253        .map(|mut item| {
5254            let abs_y = layout_origin_y + item.position.y;
5255            let page_y = abs_y - page_top;
5256            let new_item_y = page_y - new_origin_y;
5257
5258            let item_bounds = item.item.bounds();
5259            min_y = min_y.min(new_item_y);
5260            max_y = max_y.max(new_item_y + item_bounds.height);
5261            max_width = max_width.max(item.position.x + item_bounds.width);
5262
5263            item.position.y = new_item_y;
5264            item
5265        })
5266        .collect();
5267
5268    (offset_items, min_y, max_y, max_width)
5269}
5270
5271/// Clips a text glyph run to page bounds, filtering individual glyphs.
5272fn clip_text_item(
5273    glyphs: &[GlyphInstance],
5274    font_hash: FontHash,
5275    font_size_px: f32,
5276    color: ColorU,
5277    clip_rect: LogicalRect,
5278    page_top: f32,
5279    page_bottom: f32,
5280) -> Option<DisplayListItem> {
5281    if !rect_intersects(&clip_rect, page_top, page_bottom) {
5282        return None;
5283    }
5284
5285    // Filter glyphs using center-point decision (baseline position)
5286    let page_glyphs: Vec<_> = glyphs
5287        .iter()
5288        .filter(|g| g.point.y >= page_top && g.point.y < page_bottom)
5289        .map(|g| GlyphInstance {
5290            index: g.index,
5291            point: LogicalPosition {
5292                x: g.point.x,
5293                y: g.point.y - page_top,
5294            },
5295            size: g.size,
5296        })
5297        .collect();
5298
5299    if page_glyphs.is_empty() {
5300        return None;
5301    }
5302
5303    Some(DisplayListItem::Text {
5304        glyphs: page_glyphs,
5305        font_hash,
5306        font_size_px,
5307        color,
5308        clip_rect: offset_rect_y(clip_rect, -page_top).into(),
5309        source_node_index: None,
5310    })
5311}
5312
5313/// Clips a text decoration (underline, strikethrough, or overline) to page bounds.
5314fn clip_text_decoration_item(
5315    bounds: LogicalRect,
5316    color: ColorU,
5317    thickness: f32,
5318    decoration_type: TextDecorationType,
5319    page_top: f32,
5320    page_bottom: f32,
5321) -> Option<DisplayListItem> {
5322    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| match decoration_type {
5323        TextDecorationType::Underline => DisplayListItem::Underline {
5324            bounds: clipped.into(),
5325            color,
5326            thickness,
5327        },
5328        TextDecorationType::Strikethrough => DisplayListItem::Strikethrough {
5329            bounds: clipped.into(),
5330            color,
5331            thickness,
5332        },
5333        TextDecorationType::Overline => DisplayListItem::Overline {
5334            bounds: clipped.into(),
5335            color,
5336            thickness,
5337        },
5338    })
5339}
5340
5341/// Clips a scrollbar to page bounds.
5342fn clip_scrollbar_item(
5343    bounds: LogicalRect,
5344    color: ColorU,
5345    orientation: ScrollbarOrientation,
5346    opacity_key: Option<OpacityKey>,
5347    hit_id: Option<azul_core::hit_test::ScrollbarHitId>,
5348    page_top: f32,
5349    page_bottom: f32,
5350) -> Option<DisplayListItem> {
5351    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| DisplayListItem::ScrollBar {
5352        bounds: clipped.into(),
5353        color,
5354        orientation,
5355        opacity_key,
5356        hit_id,
5357    })
5358}
5359
5360/// Clips a hit test area to page bounds.
5361fn clip_hit_test_area_item(
5362    bounds: LogicalRect,
5363    tag: DisplayListTagId,
5364    page_top: f32,
5365    page_bottom: f32,
5366) -> Option<DisplayListItem> {
5367    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| DisplayListItem::HitTestArea {
5368        bounds: clipped.into(),
5369        tag,
5370    })
5371}
5372
5373/// Clips a virtualized view to page bounds.
5374fn clip_virtual_view_item(
5375    child_dom_id: DomId,
5376    bounds: LogicalRect,
5377    clip_rect: LogicalRect,
5378    page_top: f32,
5379    page_bottom: f32,
5380) -> Option<DisplayListItem> {
5381    clip_rect_bounds(bounds, page_top, page_bottom).map(|clipped| DisplayListItem::VirtualView {
5382        child_dom_id,
5383        bounds: clipped.into(),
5384        clip_rect: offset_rect_y(clip_rect, -page_top).into(),
5385    })
5386}
5387
5388/// Clip a rectangle to page bounds and offset to page-relative coordinates.
5389/// Returns None if the rectangle is completely outside the page.
5390fn clip_rect_bounds(bounds: LogicalRect, page_top: f32, page_bottom: f32) -> Option<LogicalRect> {
5391    let item_top = bounds.origin.y;
5392    let item_bottom = bounds.origin.y + bounds.size.height;
5393
5394    // Check if completely outside page
5395    if item_bottom <= page_top || item_top >= page_bottom {
5396        return None;
5397    }
5398
5399    // Calculate clipped bounds
5400    let clipped_top = item_top.max(page_top);
5401    let clipped_bottom = item_bottom.min(page_bottom);
5402    let clipped_height = clipped_bottom - clipped_top;
5403
5404    // Offset to page-relative coordinates
5405    let page_relative_y = clipped_top - page_top;
5406
5407    Some(LogicalRect {
5408        origin: LogicalPosition {
5409            x: bounds.origin.x,
5410            y: page_relative_y,
5411        },
5412        size: LogicalSize {
5413            width: bounds.size.width,
5414            height: clipped_height,
5415        },
5416    })
5417}
5418
5419/// Check if a rectangle intersects the page bounds.
5420fn rect_intersects(bounds: &LogicalRect, page_top: f32, page_bottom: f32) -> bool {
5421    let item_top = bounds.origin.y;
5422    let item_bottom = bounds.origin.y + bounds.size.height;
5423    item_bottom > page_top && item_top < page_bottom
5424}
5425
5426/// Offset a rectangle's Y coordinate.
5427fn offset_rect_y(bounds: LogicalRect, offset_y: f32) -> LogicalRect {
5428    LogicalRect {
5429        origin: LogicalPosition {
5430            x: bounds.origin.x,
5431            y: bounds.origin.y + offset_y,
5432        },
5433        size: bounds.size,
5434    }
5435}
5436
5437// Slicer based pagination: "Infinite Canvas with Clipping"
5438//
5439// This approach treats pages as "viewports" into a single infinite canvas:
5440//
5441// 1. Layout generates ONE display list on an infinite vertical strip
5442// 2. Each page is a clip rectangle that "views" a portion of that strip
5443// 3. Items that span page boundaries are clipped and appear on BOTH pages
5444
5445use azul_css::props::layout::fragmentation::{BreakInside, PageBreak};
5446
5447use crate::solver3::pagination::{
5448    HeaderFooterConfig, MarginBoxContent, PageInfo, TableHeaderInfo, TableHeaderTracker,
5449};
5450
5451/// Configuration for the slicer-based pagination.
5452#[derive(Debug, Clone, Default)]
5453pub struct SlicerConfig {
5454    /// Height of each page's content area (excludes margins, headers, footers)
5455    pub page_content_height: f32,
5456    /// Height of "dead zone" between pages (for margins, headers, footers)
5457    /// This represents space that content should NOT overlap with
5458    pub page_gap: f32,
5459    /// Whether to clip items that span page boundaries (true) or push them to next page (false)
5460    pub allow_clipping: bool,
5461    /// Header and footer configuration
5462    pub header_footer: HeaderFooterConfig,
5463    /// Width of the page content area (for centering headers/footers)
5464    pub page_width: f32,
5465    /// Table headers that need repetition across pages
5466    pub table_headers: TableHeaderTracker,
5467}
5468
5469impl SlicerConfig {
5470    /// Create a simple slicer config with no gaps between pages.
5471    #[must_use] pub fn simple(page_height: f32) -> Self {
5472        Self {
5473            page_content_height: page_height,
5474            page_gap: 0.0,
5475            allow_clipping: true,
5476            header_footer: HeaderFooterConfig::default(),
5477            page_width: DEFAULT_A4_WIDTH_PT, // Default A4 width in points
5478            table_headers: TableHeaderTracker::default(),
5479        }
5480    }
5481
5482    /// Create a slicer config with margins/gaps between pages.
5483    #[must_use] pub fn with_gap(page_height: f32, gap: f32) -> Self {
5484        Self {
5485            page_content_height: page_height,
5486            page_gap: gap,
5487            allow_clipping: true,
5488            header_footer: HeaderFooterConfig::default(),
5489            page_width: DEFAULT_A4_WIDTH_PT,
5490            table_headers: TableHeaderTracker::default(),
5491        }
5492    }
5493
5494    /// Add header/footer configuration.
5495    #[must_use] pub fn with_header_footer(mut self, config: HeaderFooterConfig) -> Self {
5496        self.header_footer = config;
5497        self
5498    }
5499
5500    /// Set the page width (for header/footer positioning).
5501    #[must_use] pub const fn with_page_width(mut self, width: f32) -> Self {
5502        self.page_width = width;
5503        self
5504    }
5505
5506    /// Add table headers for repetition.
5507    #[must_use] pub fn with_table_headers(mut self, tracker: TableHeaderTracker) -> Self {
5508        self.table_headers = tracker;
5509        self
5510    }
5511
5512    /// Register a single table header.
5513    pub fn register_table_header(&mut self, info: TableHeaderInfo) {
5514        self.table_headers.register_table_header(info);
5515    }
5516
5517    /// The total height of a page "slot" including the gap.
5518    #[must_use] pub fn page_slot_height(&self) -> f32 {
5519        self.page_content_height + self.page_gap
5520    }
5521
5522    /// Calculate which page a Y coordinate falls on.
5523    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5524    #[must_use] pub fn page_for_y(&self, y: f32) -> usize {
5525        if self.page_slot_height() <= 0.0 {
5526            return 0;
5527        }
5528        (y / self.page_slot_height()).floor() as usize
5529    }
5530
5531    /// Get the Y range for a specific page (in infinite canvas coordinates).
5532    #[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5533    #[must_use] pub fn page_bounds(&self, page_index: usize) -> (f32, f32) {
5534        let start = page_index as f32 * self.page_slot_height();
5535        let end = start + self.page_content_height;
5536        (start, end)
5537    }
5538}
5539
5540/// Paginate with CSS break property support.
5541///
5542/// This function calculates page boundaries based on CSS break-before, break-after,
5543/// and break-inside properties, then clips content to those boundaries.
5544///
5545/// **Key insight**: Items are NEVER shifted. Instead, page boundaries are adjusted
5546/// to honor break properties.
5547#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5548/// # Errors
5549///
5550/// Returns a `LayoutError` if paginating the display list fails.
5551pub fn paginate_display_list_with_slicer_and_breaks(
5552    full_display_list: DisplayList,
5553    config: &SlicerConfig,
5554    renderer_resources: &RendererResources,
5555) -> Result<Vec<DisplayList>> {
5556    if config.page_content_height <= 0.0 || config.page_content_height >= f32::MAX {
5557        return Ok(vec![full_display_list]);
5558    }
5559
5560    // Calculate base header/footer space (used for pages that show headers/footers)
5561    let base_header_space = if config.header_footer.show_header {
5562        config.header_footer.header_height
5563    } else {
5564        0.0
5565    };
5566    let base_footer_space = if config.header_footer.show_footer {
5567        config.header_footer.footer_height
5568    } else {
5569        0.0
5570    };
5571
5572    // Calculate effective heights for different page types
5573    let normal_page_content_height =
5574        config.page_content_height - base_header_space - base_footer_space;
5575    let first_page_content_height = if config.header_footer.skip_first_page {
5576        // First page has full height when skipping headers/footers
5577        config.page_content_height
5578    } else {
5579        normal_page_content_height
5580    };
5581
5582    // Step 1: Calculate page break positions based on CSS properties
5583    //
5584    // Instead of using regular intervals, we calculate where page breaks
5585    // should occur based on:
5586    //
5587    // - break-before: always → force break before this item
5588    // - break-after: always → force break after this item
5589    // - break-inside: avoid → don't break inside this item (push to next page if needed)
5590
5591    let page_breaks = calculate_page_break_positions(
5592        &full_display_list,
5593        first_page_content_height,
5594        normal_page_content_height,
5595    );
5596
5597    let num_pages = page_breaks.len();
5598
5599    // Create per-page display lists by slicing the master list
5600    let mut pages: Vec<DisplayList> = Vec::with_capacity(num_pages);
5601
5602    for (page_idx, &(content_start_y, content_end_y)) in page_breaks.iter().enumerate() {
5603        // Generate page info for header/footer content
5604        let page_info = PageInfo::new(page_idx + 1, num_pages);
5605
5606        // Calculate per-page header/footer space
5607        let skip_this_page = config.header_footer.skip_first_page && page_info.is_first;
5608        let header_space = if config.header_footer.show_header && !skip_this_page {
5609            config.header_footer.header_height
5610        } else {
5611            0.0
5612        };
5613        let footer_space = if config.header_footer.show_footer && !skip_this_page {
5614            config.header_footer.footer_height
5615        } else {
5616            0.0
5617        };
5618
5619        let _ = footer_space; // Currently unused but reserved for future
5620
5621        let mut page_items = Vec::new();
5622        let mut page_node_mapping = Vec::new();
5623
5624        // 1. Add header if enabled
5625        if config.header_footer.show_header && !skip_this_page {
5626            let header_text = config.header_footer.header_text(page_info);
5627            if !header_text.is_empty() {
5628                let header_items = generate_text_display_items(
5629                    &header_text,
5630                    LogicalRect {
5631                        origin: LogicalPosition { x: 0.0, y: 0.0 },
5632                        size: LogicalSize {
5633                            width: config.page_width,
5634                            height: config.header_footer.header_height,
5635                        },
5636                    },
5637                    config.header_footer.font_size,
5638                    config.header_footer.text_color,
5639                    TextAlignment::Center,
5640                    renderer_resources,
5641                );
5642                for item in header_items {
5643                    page_items.push(item);
5644                    page_node_mapping.push(None);
5645                }
5646            }
5647        }
5648
5649        // 2. Inject repeated table headers (if any)
5650        let repeated_headers = config.table_headers.get_repeated_headers_for_page(
5651            page_idx,
5652            content_start_y,
5653            content_end_y,
5654        );
5655
5656        let mut thead_total_height = 0.0f32;
5657        for (y_offset_from_page_top, thead_items, thead_height) in repeated_headers {
5658            let thead_y = header_space + y_offset_from_page_top;
5659            for item in thead_items {
5660                let translated_item = offset_display_item_y(item, thead_y);
5661                page_items.push(translated_item);
5662                page_node_mapping.push(None);
5663            }
5664            thead_total_height = thead_total_height.max(thead_height);
5665        }
5666
5667        // 3. Calculate content offset (after header and repeated table headers)
5668        let content_y_offset = header_space + thead_total_height;
5669
5670        // 4. Slice and offset content items (skip fixed-position items, they are added in step 4b)
5671        for (item_idx, item) in full_display_list.items.iter().enumerate() {
5672            // Skip items that belong to fixed-position elements (they are replicated separately)
5673            let is_fixed = full_display_list.fixed_position_item_ranges.iter()
5674                .any(|&(start, end)| item_idx >= start && item_idx < end);
5675            if is_fixed {
5676                continue;
5677            }
5678            if let Some(clipped_item) =
5679                clip_and_offset_display_item(item, content_start_y, content_end_y)
5680            {
5681                let final_item = if content_y_offset > 0.0 {
5682                    offset_display_item_y(&clipped_item, content_y_offset)
5683                } else {
5684                    clipped_item
5685                };
5686                page_items.push(final_item);
5687                let node_mapping = full_display_list
5688                    .node_mapping
5689                    .get(item_idx)
5690                    .copied()
5691                    .flatten();
5692                page_node_mapping.push(node_mapping);
5693            }
5694        }
5695
5696        // 4b. Replicate fixed-position items on every page (CSS Positioned Layout §2.1)
5697        // Fixed-position boxes are fixed relative to the page box, so they appear
5698        // at the same position on every page without Y-offset adjustment.
5699        for &(start, end) in &full_display_list.fixed_position_item_ranges {
5700            for item_idx in start..end {
5701                if let Some(item) = full_display_list.items.get(item_idx) {
5702                    let final_item = if content_y_offset > 0.0 {
5703                        offset_display_item_y(item, content_y_offset)
5704                    } else {
5705                        item.clone()
5706                    };
5707                    page_items.push(final_item);
5708                    let node_mapping = full_display_list
5709                        .node_mapping
5710                        .get(item_idx)
5711                        .copied()
5712                        .flatten();
5713                    page_node_mapping.push(node_mapping);
5714                }
5715            }
5716        }
5717
5718        // 5. Add footer if enabled
5719        if config.header_footer.show_footer && !skip_this_page {
5720            let footer_text = config.header_footer.footer_text(page_info);
5721            if !footer_text.is_empty() {
5722                let footer_y = config.page_content_height - config.header_footer.footer_height;
5723                let footer_items = generate_text_display_items(
5724                    &footer_text,
5725                    LogicalRect {
5726                        origin: LogicalPosition {
5727                            x: 0.0,
5728                            y: footer_y,
5729                        },
5730                        size: LogicalSize {
5731                            width: config.page_width,
5732                            height: config.header_footer.footer_height,
5733                        },
5734                    },
5735                    config.header_footer.font_size,
5736                    config.header_footer.text_color,
5737                    TextAlignment::Center,
5738                    renderer_resources,
5739                );
5740                for item in footer_items {
5741                    page_items.push(item);
5742                    page_node_mapping.push(None);
5743                }
5744            }
5745        }
5746
5747        pages.push(DisplayList {
5748            items: page_items,
5749            node_mapping: page_node_mapping,
5750            forced_page_breaks: Vec::new(),
5751            fixed_position_item_ranges: Vec::new(), // Already handled during pagination
5752        });
5753    }
5754
5755    // Ensure at least one page
5756    if pages.is_empty() {
5757        pages.push(DisplayList::default());
5758    }
5759
5760    Ok(pages)
5761}
5762
5763/// Calculate page break positions respecting CSS forced page breaks.
5764///
5765/// Returns a vector of (`start_y`, `end_y`) tuples representing each page's content bounds.
5766///
5767/// This function uses the `forced_page_breaks` from the `DisplayList` to insert
5768/// page breaks at positions specified by CSS `break-before: always` and `break-after: always`.
5769/// Regular page breaks still occur at normal intervals when no forced break is present.
5770fn calculate_page_break_positions(
5771    display_list: &DisplayList,
5772    first_page_height: f32,
5773    normal_page_height: f32,
5774) -> Vec<(f32, f32)> {
5775    let total_height = calculate_display_list_height(display_list);
5776
5777    if total_height <= 0.0 || first_page_height <= 0.0 {
5778        return vec![(0.0, total_height.max(first_page_height))];
5779    }
5780
5781    // Collect all potential break points: forced breaks + regular interval breaks
5782    let mut break_points: Vec<f32> = Vec::new();
5783
5784    // Add forced page breaks from the display list (from CSS break-before/break-after)
5785    for &forced_break_y in &display_list.forced_page_breaks {
5786        if forced_break_y > 0.0 && forced_break_y < total_height {
5787            break_points.push(forced_break_y);
5788        }
5789    }
5790
5791    // Generate regular interval break points
5792    let mut y = first_page_height;
5793    #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
5794    while y < total_height {
5795        break_points.push(y);
5796        y += normal_page_height;
5797    }
5798
5799    // Sort and deduplicate break points
5800    break_points.sort_by(|a, b| a.partial_cmp(b).unwrap());
5801    break_points.dedup_by(|a, b| (*a - *b).abs() < 1.0); // Merge breaks within 1px
5802
5803    // Convert break points to page ranges
5804    let mut page_breaks: Vec<(f32, f32)> = Vec::new();
5805    let mut page_start = 0.0f32;
5806
5807    for break_y in break_points {
5808        if break_y > page_start {
5809            page_breaks.push((page_start, break_y));
5810            page_start = break_y;
5811        }
5812    }
5813
5814    // Add final page if there's remaining content
5815    if page_start < total_height {
5816        page_breaks.push((page_start, total_height));
5817    }
5818
5819    // Ensure at least one page
5820    if page_breaks.is_empty() {
5821        page_breaks.push((0.0, total_height.max(first_page_height)));
5822    }
5823
5824    page_breaks
5825}
5826
5827/// Text alignment for generated header/footer text.
5828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5829enum TextAlignment {
5830    Left,
5831    Center,
5832    Right,
5833}
5834
5835/// Helper to offset all Y coordinates of a display item.
5836#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5837fn offset_display_item_y(item: &DisplayListItem, y_offset: f32) -> DisplayListItem {
5838    if y_offset == 0.0 {
5839        return item.clone();
5840    }
5841
5842    match item {
5843        DisplayListItem::Rect {
5844            bounds,
5845            color,
5846            border_radius,
5847        } => DisplayListItem::Rect {
5848            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5849            color: *color,
5850            border_radius: *border_radius,
5851        },
5852        DisplayListItem::Border {
5853            bounds,
5854            widths,
5855            colors,
5856            styles,
5857            border_radius,
5858        } => DisplayListItem::Border {
5859            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5860            widths: *widths,
5861            colors: *colors,
5862            styles: *styles,
5863            border_radius: *border_radius,
5864        },
5865        DisplayListItem::Text {
5866            glyphs,
5867            font_hash,
5868            font_size_px,
5869            color,
5870            clip_rect,
5871            ..
5872        } => {
5873            let offset_glyphs: Vec<GlyphInstance> = glyphs
5874                .iter()
5875                .map(|g| GlyphInstance {
5876                    index: g.index,
5877                    point: LogicalPosition {
5878                        x: g.point.x,
5879                        y: g.point.y + y_offset,
5880                    },
5881                    size: g.size,
5882                })
5883                .collect();
5884            DisplayListItem::Text {
5885                glyphs: offset_glyphs,
5886                font_hash: *font_hash,
5887                font_size_px: *font_size_px,
5888                color: *color,
5889                clip_rect: offset_rect_y(clip_rect.into_inner(), y_offset).into(),
5890                source_node_index: None,
5891            }
5892        }
5893        DisplayListItem::TextLayout {
5894            layout,
5895            bounds,
5896            font_hash,
5897            font_size_px,
5898            color,
5899        } => DisplayListItem::TextLayout {
5900            layout: layout.clone(),
5901            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5902            font_hash: *font_hash,
5903            font_size_px: *font_size_px,
5904            color: *color,
5905        },
5906        DisplayListItem::Image { bounds, image, border_radius } => DisplayListItem::Image {
5907            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5908            image: image.clone(),
5909            border_radius: *border_radius,
5910        },
5911        // Pass through other items with their bounds offset
5912        DisplayListItem::SelectionRect {
5913            bounds,
5914            border_radius,
5915            color,
5916        } => DisplayListItem::SelectionRect {
5917            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5918            border_radius: *border_radius,
5919            color: *color,
5920        },
5921        DisplayListItem::CursorRect { bounds, color } => DisplayListItem::CursorRect {
5922            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5923            color: *color,
5924        },
5925        DisplayListItem::Underline {
5926            bounds,
5927            color,
5928            thickness,
5929        } => DisplayListItem::Underline {
5930            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5931            color: *color,
5932            thickness: *thickness,
5933        },
5934        DisplayListItem::Strikethrough {
5935            bounds,
5936            color,
5937            thickness,
5938        } => DisplayListItem::Strikethrough {
5939            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5940            color: *color,
5941            thickness: *thickness,
5942        },
5943        DisplayListItem::Overline {
5944            bounds,
5945            color,
5946            thickness,
5947        } => DisplayListItem::Overline {
5948            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5949            color: *color,
5950            thickness: *thickness,
5951        },
5952        DisplayListItem::ScrollBar {
5953            bounds,
5954            color,
5955            orientation,
5956            opacity_key,
5957            hit_id,
5958        } => DisplayListItem::ScrollBar {
5959            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5960            color: *color,
5961            orientation: *orientation,
5962            opacity_key: *opacity_key,
5963            hit_id: *hit_id,
5964        },
5965        DisplayListItem::HitTestArea { bounds, tag } => DisplayListItem::HitTestArea {
5966            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5967            tag: *tag,
5968        },
5969        DisplayListItem::PushClip {
5970            bounds,
5971            border_radius,
5972        } => DisplayListItem::PushClip {
5973            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5974            border_radius: *border_radius,
5975        },
5976        DisplayListItem::PushScrollFrame {
5977            clip_bounds,
5978            content_size,
5979            scroll_id,
5980        } => DisplayListItem::PushScrollFrame {
5981            clip_bounds: offset_rect_y(clip_bounds.into_inner(), y_offset).into(),
5982            content_size: *content_size,
5983            scroll_id: *scroll_id,
5984        },
5985        DisplayListItem::PushStackingContext { bounds, z_index } => {
5986            DisplayListItem::PushStackingContext {
5987                bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5988                z_index: *z_index,
5989            }
5990        }
5991        DisplayListItem::VirtualView {
5992            child_dom_id,
5993            bounds,
5994            clip_rect,
5995        } => DisplayListItem::VirtualView {
5996            child_dom_id: *child_dom_id,
5997            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
5998            clip_rect: offset_rect_y(clip_rect.into_inner(), y_offset).into(),
5999        },
6000        DisplayListItem::VirtualViewPlaceholder {
6001            node_id,
6002            bounds,
6003            clip_rect,
6004        } => DisplayListItem::VirtualViewPlaceholder {
6005            node_id: *node_id,
6006            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6007            clip_rect: offset_rect_y(clip_rect.into_inner(), y_offset).into(),
6008        },
6009        // Pass through stateless items
6010        DisplayListItem::PopClip => DisplayListItem::PopClip,
6011        DisplayListItem::PopScrollFrame => DisplayListItem::PopScrollFrame,
6012        DisplayListItem::PopStackingContext => DisplayListItem::PopStackingContext,
6013
6014        // Gradient items
6015        DisplayListItem::LinearGradient {
6016            bounds,
6017            gradient,
6018            border_radius,
6019        } => DisplayListItem::LinearGradient {
6020            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6021            gradient: gradient.clone(),
6022            border_radius: *border_radius,
6023        },
6024        DisplayListItem::RadialGradient {
6025            bounds,
6026            gradient,
6027            border_radius,
6028        } => DisplayListItem::RadialGradient {
6029            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6030            gradient: gradient.clone(),
6031            border_radius: *border_radius,
6032        },
6033        DisplayListItem::ConicGradient {
6034            bounds,
6035            gradient,
6036            border_radius,
6037        } => DisplayListItem::ConicGradient {
6038            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6039            gradient: gradient.clone(),
6040            border_radius: *border_radius,
6041        },
6042
6043        // BoxShadow
6044        DisplayListItem::BoxShadow {
6045            bounds,
6046            shadow,
6047            border_radius,
6048        } => DisplayListItem::BoxShadow {
6049            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6050            shadow: *shadow,
6051            border_radius: *border_radius,
6052        },
6053
6054        // Filter effects
6055        DisplayListItem::PushFilter { bounds, filters } => DisplayListItem::PushFilter {
6056            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6057            filters: filters.clone(),
6058        },
6059        DisplayListItem::PopFilter => DisplayListItem::PopFilter,
6060        DisplayListItem::PushBackdropFilter { bounds, filters } => {
6061            DisplayListItem::PushBackdropFilter {
6062                bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6063                filters: filters.clone(),
6064            }
6065        }
6066        DisplayListItem::PopBackdropFilter => DisplayListItem::PopBackdropFilter,
6067        DisplayListItem::PushOpacity { bounds, opacity } => DisplayListItem::PushOpacity {
6068            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6069            opacity: *opacity,
6070        },
6071        DisplayListItem::PopOpacity => DisplayListItem::PopOpacity,
6072        DisplayListItem::ScrollBarStyled { info } => {
6073            let mut offset_info = (**info).clone();
6074            offset_info.bounds = offset_rect_y(offset_info.bounds.into_inner(), y_offset).into();
6075            offset_info.track_bounds = offset_rect_y(offset_info.track_bounds.into_inner(), y_offset).into();
6076            offset_info.thumb_bounds = offset_rect_y(offset_info.thumb_bounds.into_inner(), y_offset).into();
6077            if let Some(b) = offset_info.button_decrement_bounds {
6078                offset_info.button_decrement_bounds = Some(offset_rect_y(b.into_inner(), y_offset).into());
6079            }
6080            if let Some(b) = offset_info.button_increment_bounds {
6081                offset_info.button_increment_bounds = Some(offset_rect_y(b.into_inner(), y_offset).into());
6082            }
6083            DisplayListItem::ScrollBarStyled {
6084                info: Box::new(offset_info),
6085            }
6086        }
6087
6088        // Reference frames - offset the bounds
6089        DisplayListItem::PushReferenceFrame {
6090            transform_key,
6091            initial_transform,
6092            bounds,
6093        } => DisplayListItem::PushReferenceFrame {
6094            transform_key: *transform_key,
6095            initial_transform: *initial_transform,
6096            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6097        },
6098        DisplayListItem::PopReferenceFrame => DisplayListItem::PopReferenceFrame,
6099        DisplayListItem::PushTextShadow { shadow } => DisplayListItem::PushTextShadow {
6100            shadow: *shadow,
6101        },
6102        DisplayListItem::PopTextShadow => DisplayListItem::PopTextShadow,
6103        DisplayListItem::PushImageMaskClip {
6104            bounds,
6105            mask_image,
6106            mask_rect,
6107        } => DisplayListItem::PushImageMaskClip {
6108            bounds: offset_rect_y(bounds.into_inner(), y_offset).into(),
6109            mask_image: mask_image.clone(),
6110            mask_rect: offset_rect_y(mask_rect.into_inner(), y_offset).into(),
6111        },
6112        DisplayListItem::PopImageMaskClip => DisplayListItem::PopImageMaskClip,
6113    }
6114}
6115
6116/// Generate display list items for simple text (paginated headers/footers).
6117///
6118/// Shapes `text` against a registered font (chosen from `renderer_resources`)
6119/// and emits a single [`DisplayListItem::Text`] whose glyph indices are the
6120/// font's real GIDs and whose `font_hash` is the font's registered hash, so the
6121/// renderer (`cpurender::render_text`) can resolve and paint it.
6122///
6123/// This is a deliberately simple shaper for short, single-line running
6124/// headers/footers (e.g. "Page 1 of 3"): per-character cmap lookup + horizontal
6125/// advance, no complex shaping / kerning / bidi. The full text pipeline is not
6126/// used because the pagination call site does not carry a styled run — only the
6127/// header/footer string and a font size/color.
6128///
6129/// History: a previous stub fabricated glyphs whose `index` was the Unicode
6130/// *codepoint* and whose `font_hash` was `0`, which matched no registered font
6131/// (the renderer logged "Font hash 0 not found" and painted nothing). A later
6132/// revision returned an empty list. Both rendered no header/footer text; this
6133/// emits real glyphs.
6134///
6135/// Returns an empty list only when `text` is empty, no font is registered, or
6136/// the chosen font has degenerate metrics (`units_per_em == 0`).
6137fn generate_text_display_items(
6138    text: &str,
6139    bounds: LogicalRect,
6140    font_size: f32,
6141    color: ColorU,
6142    alignment: TextAlignment,
6143    renderer_resources: &RendererResources,
6144) -> Vec<DisplayListItem> {
6145    if text.is_empty() || font_size <= 0.0 {
6146        return Vec::new();
6147    }
6148
6149    // Pick the first registered font. Running headers/footers do not carry a
6150    // styled run, so there is no per-node font family to resolve; the document's
6151    // registered font is a reasonable choice for the page furniture.
6152    let Some((_font_key, (font_ref, _instances))) =
6153        renderer_resources.currently_registered_fonts.iter().next()
6154    else {
6155        return Vec::new();
6156    };
6157
6158    let parsed = crate::font_ref_to_parsed_font(font_ref);
6159    let units_per_em = f32::from(parsed.font_metrics.units_per_em);
6160    if units_per_em <= 0.0 {
6161        return Vec::new();
6162    }
6163    let scale = font_size / units_per_em;
6164    let font_hash = parsed.hash;
6165
6166    // First pass: shape (cmap lookup + advance) and accumulate total width.
6167    let mut shaped: Vec<(u16, f32)> = Vec::new(); // (glyph_id, advance_px)
6168    let mut total_width = 0.0f32;
6169    for c in text.chars() {
6170        let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
6171        let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
6172        shaped.push((gid, advance));
6173        total_width += advance;
6174    }
6175
6176    if shaped.is_empty() {
6177        return Vec::new();
6178    }
6179
6180    // Horizontal placement within the box.
6181    let start_x = match alignment {
6182        TextAlignment::Center => (bounds.size.width - total_width).mul_add(0.5, bounds.origin.x),
6183        TextAlignment::Right => bounds.origin.x + (bounds.size.width - total_width),
6184        TextAlignment::Left => bounds.origin.x,
6185    };
6186
6187    // Vertical placement: center the text's em-box in the header/footer band and
6188    // place the baseline accordingly (point.y is the glyph baseline).
6189    let ascent_px = parsed.font_metrics.ascent * scale;
6190    let descent_px = parsed.font_metrics.descent * scale; // hhea descender, usually negative
6191    let text_height = ascent_px - descent_px;
6192    let baseline_y =
6193        bounds.origin.y + (bounds.size.height - text_height).mul_add(0.5, ascent_px);
6194
6195    let mut pen_x = start_x;
6196    let mut glyphs: Vec<GlyphInstance> = Vec::with_capacity(shaped.len());
6197    for (gid, advance) in shaped {
6198        let size = parsed
6199            .get_glyph_size(gid, font_size)
6200            .unwrap_or(LogicalSize {
6201                width: advance,
6202                height: font_size,
6203            });
6204        glyphs.push(GlyphInstance {
6205            index: u32::from(gid),
6206            point: LogicalPosition {
6207                x: pen_x,
6208                y: baseline_y,
6209            },
6210            size,
6211        });
6212        pen_x += advance;
6213    }
6214
6215    vec![DisplayListItem::Text {
6216        glyphs,
6217        font_hash: FontHash::from_hash(font_hash),
6218        font_size_px: font_size,
6219        color,
6220        clip_rect: bounds.into(),
6221        source_node_index: None,
6222    }]
6223}
6224
6225/// Calculate the total height of a display list (max Y + height of all items).
6226fn calculate_display_list_height(display_list: &DisplayList) -> f32 {
6227    let mut max_bottom = 0.0f32;
6228
6229    for item in &display_list.items {
6230        if let Some(bounds) = get_display_item_bounds(item) {
6231            // Skip items with zero height - they don't contribute to visible content
6232            if bounds.0.size.height < 0.1 {
6233                continue;
6234            }
6235            
6236            let item_bottom = bounds.0.origin.y + bounds.0.size.height;
6237            if item_bottom > max_bottom {
6238                max_bottom = item_bottom;
6239            }
6240        }
6241    }
6242
6243    max_bottom
6244}
6245
6246/// Break property information for pagination decisions.
6247#[derive(Debug, Clone, Copy, Default)]
6248// fields mirror the CSS break-before / break-after / break-inside properties
6249#[allow(clippy::struct_field_names)]
6250struct BreakProperties {
6251    break_before: PageBreak,
6252    break_after: PageBreak,
6253    break_inside: BreakInside,
6254}
6255
6256// ============================================================================
6257// TEXT-OVERFLOW STUB
6258// ============================================================================
6259
6260/// Applies text-overflow ellipsis handling to a display list.
6261///
6262/// CSS UI Module Level 3, section 6.2 (text-overflow):
6263/// When inline content overflows a block container that has `overflow: hidden`
6264/// (or clip/scroll) and `text-overflow: ellipsis`, the overflowing text should
6265/// be replaced with an ellipsis character (U+2026) or a custom string.
6266///
6267/// This is a display-list post-processing step that modifies glyph runs
6268/// to show an ellipsis when text overflows its container. It operates on
6269/// the assumption that the container already has a `PushClip` that clips
6270/// the overflow -- this function additionally replaces the trailing glyphs
6271/// with an ellipsis so the user gets a visual indicator of truncation.
6272///
6273/// # Parameters
6274/// - `display_list`: The display list to modify (text items may be clipped/replaced)
6275/// - `container_bounds`: The bounds of the containing block (overflow boundary)
6276/// - `_ellipsis`: The ellipsis string (currently unused; U+2026 glyph index is used)
6277///
6278/// # Algorithm
6279/// 1. For each Text item in the display list, check if any glyphs extend
6280///    past the container's right edge (inline-end in LTR).
6281/// 2. If so, find the last glyph that fits entirely within the container,
6282///    accounting for the width of the ellipsis character.
6283/// 3. Remove all glyphs after that point.
6284/// 4. Append an ellipsis glyph (U+2026 = glyph index 0x2026 as a fallback;
6285///    proper glyph lookup requires font metrics not available here).
6286///
6287/// Note: This is a best-effort implementation. A pixel-perfect version would
6288/// need access to font metrics to measure the exact ellipsis glyph width and
6289/// to look up the correct glyph index for the ellipsis in each font.
6290// +spec:overflow:f175b9 - bidi ellipsis: characters visually at the end edge of the line are hidden for ellipsis
6291pub(crate) fn apply_text_overflow_ellipsis(
6292    display_list: &mut DisplayList,
6293    container_bounds: LogicalRect,
6294    _ellipsis: &str,
6295) {
6296    let container_right = container_bounds.origin.x + container_bounds.size.width;
6297
6298    // Approximate ellipsis width as ~0.6 * font_size (typical for "..." in most fonts).
6299    // This is a heuristic; proper implementation requires font metric access.
6300    for item in &mut display_list.items {
6301        if let DisplayListItem::Text {
6302            glyphs,
6303            font_size_px,
6304            clip_rect,
6305            ..
6306        } = item {
6307                if glyphs.is_empty() {
6308                    continue;
6309                }
6310
6311                // Check if any glyph extends past the container right edge
6312                let last_glyph = &glyphs[glyphs.len() - 1];
6313                let last_glyph_right = last_glyph.point.x + last_glyph.size.width;
6314
6315                if last_glyph_right <= container_right {
6316                    continue; // No overflow, nothing to do
6317                }
6318
6319                // Estimate ellipsis width
6320                let ellipsis_width = *font_size_px * APPROX_ELLIPSIS_WIDTH_RATIO;
6321                let truncation_edge = container_right - ellipsis_width;
6322
6323                // Find the last glyph that fits before the truncation edge
6324                let mut keep_count = 0;
6325                for (i, glyph) in glyphs.iter().enumerate() {
6326                    let glyph_right = glyph.point.x + glyph.size.width;
6327                    if glyph_right > truncation_edge {
6328                        break;
6329                    }
6330                    keep_count = i + 1;
6331                }
6332
6333                // Truncate the glyphs
6334                glyphs.truncate(keep_count);
6335
6336                // Append an ellipsis glyph. We use Unicode codepoint U+2026
6337                // (HORIZONTAL ELLIPSIS) as the glyph index. This is a common
6338                // convention; renderers that use proper glyph IDs will need to
6339                // map this to the font's actual glyph index.
6340                let ellipsis_x = glyphs.last().map_or(container_bounds.origin.x, |last| last.point.x + last.size.width);
6341
6342                let ellipsis_glyph = GlyphInstance {
6343                    index: 0x2026, // U+2026 HORIZONTAL ELLIPSIS
6344                    point: LogicalPosition::new(ellipsis_x, glyphs.first().map_or(
6345                        container_bounds.origin.y,
6346                        |g| g.point.y,
6347                    )),
6348                    size: LogicalSize::new(ellipsis_width, *font_size_px),
6349                };
6350
6351                glyphs.push(ellipsis_glyph);
6352
6353                // Update the clip rect to match the container bounds so
6354                // the ellipsis is visible but nothing past it is shown
6355                *clip_rect = container_bounds.into();
6356            }
6357    }
6358}
6359
6360// ============================================================================
6361// CLIP-PATH STUB
6362// ============================================================================
6363
6364/// Resolves a CSS clip-path shape to a clipping rectangle.
6365///
6366/// CSS Masking Module Level 1, section 3 (clip-path):
6367/// The clip-path property creates a clipping region that determines which parts
6368/// of an element are visible. Content outside the clipping region is hidden.
6369///
6370/// Currently supported clip-path values:
6371/// - `inset()` - rectangular clip with optional rounding
6372/// - `circle()` - approximated as bounding box rectangle
6373/// - `ellipse()` - approximated as bounding box rectangle
6374/// - `polygon()` - approximated as axis-aligned bounding box
6375/// - `none` - no clipping (returns None)
6376///
6377/// # Parameters
6378/// - `clip_path`: The resolved clip-path CSS property value
6379/// - `node_bounds`: The reference box for resolving clip-path values
6380///
6381/// # Returns
6382/// A `(LogicalRect, f32)` tuple: the clip rectangle and border radius,
6383/// or `None` if no clipping should be applied.
6384///
6385/// Note: Circle, ellipse, and polygon shapes are approximated as axis-aligned
6386/// bounding boxes. A full implementation would use path-based clipping in the
6387/// renderer, but rectangular clips work for the most common use cases.
6388#[allow(clippy::many_single_char_names)] // domain-standard coordinate/geometry/short-lived names
6389pub(crate) fn resolve_clip_path(
6390    clip_path: &azul_css::props::layout::shape::ClipPath,
6391    node_bounds: LogicalRect,
6392) -> Option<(LogicalRect, f32)> {
6393    use azul_css::props::layout::shape::ClipPath;
6394    use azul_css::shape::CssShape;
6395
6396    match clip_path {
6397        ClipPath::None => None,
6398        ClipPath::Shape(shape) => {
6399            match shape {
6400                CssShape::Inset(inset) => {
6401                    // CSS inset() creates a rectangular clip inset from each edge.
6402                    // inset(top right bottom left round border-radius)
6403                    let x = node_bounds.origin.x + inset.inset_left;
6404                    let y = node_bounds.origin.y + inset.inset_top;
6405                    let w = (node_bounds.size.width - inset.inset_left - inset.inset_right).max(0.0);
6406                    let h = (node_bounds.size.height - inset.inset_top - inset.inset_bottom).max(0.0);
6407                    let radius = match inset.border_radius {
6408                        azul_css::corety::OptionF32::Some(r) => r,
6409                        azul_css::corety::OptionF32::None => 0.0,
6410                    };
6411                    Some((LogicalRect {
6412                        origin: LogicalPosition::new(x, y),
6413                        size: LogicalSize::new(w, h),
6414                    }, radius))
6415                }
6416                CssShape::Circle(circle) => {
6417                    // Approximate circle as a square bounding box centered at the circle center.
6418                    // CSS circle(radius at cx cy). The center point coordinates are in
6419                    // absolute units (pre-resolved by the CSS parser).
6420                    let cx = node_bounds.origin.x + circle.center.x;
6421                    let cy = node_bounds.origin.y + circle.center.y;
6422                    let r = circle.radius;
6423                    Some((LogicalRect {
6424                        origin: LogicalPosition::new(cx - r, cy - r),
6425                        size: LogicalSize::new(r * 2.0, r * 2.0),
6426                    }, r))
6427                }
6428                CssShape::Ellipse(ellipse) => {
6429                    // Approximate ellipse as its bounding box.
6430                    let cx = node_bounds.origin.x + ellipse.center.x;
6431                    let cy = node_bounds.origin.y + ellipse.center.y;
6432                    let rx = ellipse.radius_x;
6433                    let ry = ellipse.radius_y;
6434                    let radius = rx.min(ry);
6435                    Some((LogicalRect {
6436                        origin: LogicalPosition::new(cx - rx, cy - ry),
6437                        size: LogicalSize::new(rx * 2.0, ry * 2.0),
6438                    }, radius))
6439                }
6440                CssShape::Polygon(polygon) => {
6441                    // Compute the axis-aligned bounding box of the polygon.
6442                    if polygon.points.is_empty() {
6443                        return None;
6444                    }
6445                    let mut min_x = f32::INFINITY;
6446                    let mut min_y = f32::INFINITY;
6447                    let mut max_x = f32::NEG_INFINITY;
6448                    let mut max_y = f32::NEG_INFINITY;
6449                    for point in &polygon.points {
6450                        // Polygon points are in absolute coordinates (pre-resolved)
6451                        let px = node_bounds.origin.x + point.x;
6452                        let py = node_bounds.origin.y + point.y;
6453                        min_x = min_x.min(px);
6454                        min_y = min_y.min(py);
6455                        max_x = max_x.max(px);
6456                        max_y = max_y.max(py);
6457                    }
6458                    Some((LogicalRect {
6459                        origin: LogicalPosition::new(min_x, min_y),
6460                        size: LogicalSize::new((max_x - min_x).max(0.0), (max_y - min_y).max(0.0)),
6461                    }, 0.0))
6462                }
6463                CssShape::Path(_) => {
6464                    // SVG paths are not supported for clip-path yet.
6465                    // Return the full node bounds (no clipping).
6466                    None
6467                }
6468            }
6469        }
6470    }
6471}
6472
6473/// Applies a CSS clip-path to the display list by inserting PushClip/PopClip.
6474///
6475/// This is a post-processing step that wraps all items between `start_index`
6476/// and the current end of the display list in a clip region derived from
6477/// the clip-path shape.
6478///
6479/// # Parameters
6480/// - `display_list`: The display list to modify
6481/// - `start_index`: The index of the first item belonging to this node
6482/// - `clip_rect`: The resolved clip rectangle
6483/// - `border_radius`: The border radius for the clip (from inset round, or circle)
6484pub(crate) fn apply_clip_path(
6485    display_list: &mut DisplayList,
6486    start_index: usize,
6487    clip_rect: LogicalRect,
6488    border_radius: f32,
6489) {
6490    let br = if border_radius > 0.0 {
6491        BorderRadius {
6492            top_left: border_radius,
6493            top_right: border_radius,
6494            bottom_left: border_radius,
6495            bottom_right: border_radius,
6496        }
6497    } else {
6498        BorderRadius::default()
6499    };
6500
6501    // Insert PushClip at start_index
6502    display_list.items.insert(start_index, DisplayListItem::PushClip {
6503        bounds: clip_rect.into(),
6504        border_radius: br,
6505    });
6506    // Insert a corresponding None in node_mapping
6507    if display_list.node_mapping.len() >= start_index {
6508        display_list.node_mapping.insert(start_index, None);
6509    }
6510
6511    // Append PopClip at the end
6512    display_list.items.push(DisplayListItem::PopClip);
6513    display_list.node_mapping.push(None);
6514}
6515
6516/// Rasterize an `SvgMultiPolygon` clip path into an R8 image mask at the given paint rect size.
6517///
6518/// Returns `None` if the rect has zero size.
6519#[cfg(feature = "cpurender")]
6520#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
6521fn rasterize_svg_clip_to_r8(
6522    svg_clip: &azul_core::svg::SvgMultiPolygon,
6523    paint_rect: &LogicalRect,
6524) -> Option<ImageRef> {
6525    use agg_rust::{
6526        basics::FillingRule,
6527        color::Rgba8,
6528        path_storage::PathStorage,
6529        pixfmt_rgba::PixfmtRgba32,
6530        rasterizer_scanline_aa::RasterizerScanlineAa,
6531        renderer_base::RendererBase,
6532        renderer_scanline::render_scanlines_aa_solid,
6533        rendering_buffer::RowAccessor,
6534        scanline_u::ScanlineU8,
6535    };
6536    use azul_core::resources::{ImageRef, RawImage, RawImageFormat, RawImageData};
6537
6538    let w = paint_rect.size.width.ceil() as u32;
6539    let h = paint_rect.size.height.ceil() as u32;
6540    if w == 0 || h == 0 {
6541        return None;
6542    }
6543
6544    // Build agg PathStorage from SvgMultiPolygon
6545    let mut path = PathStorage::new();
6546    for ring in svg_clip.rings.as_ref() {
6547        let mut first = true;
6548        for item in ring.items.as_ref() {
6549            match item {
6550                azul_core::svg::SvgPathElement::Line(l) => {
6551                    if first {
6552                        path.move_to(
6553                            f64::from(l.start.x - paint_rect.origin.x),
6554                            f64::from(l.start.y - paint_rect.origin.y),
6555                        );
6556                        first = false;
6557                    }
6558                    path.line_to(
6559                        f64::from(l.end.x - paint_rect.origin.x),
6560                        f64::from(l.end.y - paint_rect.origin.y),
6561                    );
6562                }
6563                azul_core::svg::SvgPathElement::QuadraticCurve(q) => {
6564                    if first {
6565                        path.move_to(
6566                            f64::from(q.start.x - paint_rect.origin.x),
6567                            f64::from(q.start.y - paint_rect.origin.y),
6568                        );
6569                        first = false;
6570                    }
6571                    path.curve3(
6572                        f64::from(q.ctrl.x - paint_rect.origin.x),
6573                        f64::from(q.ctrl.y - paint_rect.origin.y),
6574                        f64::from(q.end.x - paint_rect.origin.x),
6575                        f64::from(q.end.y - paint_rect.origin.y),
6576                    );
6577                }
6578                azul_core::svg::SvgPathElement::CubicCurve(c) => {
6579                    if first {
6580                        path.move_to(
6581                            f64::from(c.start.x - paint_rect.origin.x),
6582                            f64::from(c.start.y - paint_rect.origin.y),
6583                        );
6584                        first = false;
6585                    }
6586                    path.curve4(
6587                        f64::from(c.ctrl_1.x - paint_rect.origin.x),
6588                        f64::from(c.ctrl_1.y - paint_rect.origin.y),
6589                        f64::from(c.ctrl_2.x - paint_rect.origin.x),
6590                        f64::from(c.ctrl_2.y - paint_rect.origin.y),
6591                        f64::from(c.end.x - paint_rect.origin.x),
6592                        f64::from(c.end.y - paint_rect.origin.y),
6593                    );
6594                }
6595            }
6596        }
6597    }
6598
6599    // Rasterize to RGBA32 buffer
6600    let mut rgba_buf = vec![0u8; (w * h * 4) as usize];
6601    {
6602        let stride = (w * 4) as i32;
6603        let mut ra = unsafe {
6604            RowAccessor::new_with_buf(rgba_buf.as_mut_ptr(), w, h, stride)
6605        };
6606        let pf = PixfmtRgba32::new(&mut ra);
6607        let mut rb = RendererBase::new(pf);
6608
6609        let mut ras = RasterizerScanlineAa::new();
6610        ras.filling_rule(FillingRule::NonZero);
6611        ras.add_path(&mut path, 0);
6612
6613        let mut sl = ScanlineU8::new();
6614        let white = Rgba8 { r: 255, g: 255, b: 255, a: 255 };
6615        render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &white);
6616    }
6617
6618    // Extract alpha channel as R8 mask
6619    let r8_data: Vec<u8> = rgba_buf.chunks_exact(4).map(|px| px[3]).collect();
6620
6621    ImageRef::new_rawimage(RawImage {
6622        pixels: RawImageData::U8(r8_data.into()),
6623        width: w as usize,
6624        height: h as usize,
6625        premultiplied_alpha: false,
6626        data_format: RawImageFormat::R8,
6627        tag: Vec::new().into(),
6628    })
6629}
6630
6631#[cfg(test)]
6632mod pagination_text_tests {
6633    use super::*;
6634    use crate::font::parsed::ParsedFont;
6635    use azul_core::resources::{FontKey, IdNamespace};
6636
6637    /// Loads a system font for testing, retaining source bytes so advances work.
6638    fn load_test_font() -> Option<ParsedFont> {
6639        let candidates = [
6640            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
6641            "/System/Library/Fonts/Helvetica.ttc",
6642            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
6643            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
6644            "C:/Windows/Fonts/arial.ttf",
6645        ];
6646        for path in candidates {
6647            if let Ok(bytes) = std::fs::read(path) {
6648                let arc = Arc::new(rust_fontconfig::FontBytes::Owned(
6649                    Arc::from(bytes.as_slice()),
6650                ));
6651                if let Some(font) =
6652                    ParsedFont::from_bytes(&bytes, 0, &mut Vec::new()).map(|f| f.with_source_bytes(arc))
6653                {
6654                    return Some(font);
6655                }
6656            }
6657        }
6658        None
6659    }
6660
6661    fn renderer_resources_with(font: ParsedFont) -> RendererResources {
6662        let mut rr = RendererResources::default();
6663        let font_ref = crate::parsed_font_to_font_ref(font);
6664        let key = FontKey::unique(IdNamespace(0));
6665        let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
6666        rr.font_hash_map.insert(hash, key);
6667        rr.currently_registered_fonts
6668            .insert(key, (font_ref, BTreeMap::default()));
6669        rr
6670    }
6671
6672    /// The pagination header/footer text path must emit real glyph display items
6673    /// (the audit flagged it as a no-op rendering nothing).
6674    #[test]
6675    fn generate_text_display_items_emits_glyphs() {
6676        let Some(font) = load_test_font() else {
6677            eprintln!("[skip] no system font available");
6678            return;
6679        };
6680        let expected_hash = font.hash;
6681        let rr = renderer_resources_with(font);
6682
6683        let bounds = LogicalRect {
6684            origin: LogicalPosition { x: 0.0, y: 0.0 },
6685            size: LogicalSize { width: 400.0, height: 30.0 },
6686        };
6687        let items = generate_text_display_items(
6688            "Page 1 of 3",
6689            bounds,
6690            14.0,
6691            ColorU { r: 0, g: 0, b: 0, a: 255 },
6692            TextAlignment::Center,
6693            &rr,
6694        );
6695
6696        assert_eq!(items.len(), 1, "expected exactly one Text item");
6697        match &items[0] {
6698            DisplayListItem::Text { glyphs, font_hash, color, .. } => {
6699                assert_eq!(glyphs.len(), "Page 1 of 3".chars().count());
6700                assert_eq!(font_hash.font_hash, expected_hash, "must use registered font hash");
6701                assert_ne!(font_hash.font_hash, 0, "hash 0 resolves no font");
6702                assert_eq!(color.a, 255);
6703                // Glyph IDs must be real (cmap-resolved), not raw codepoints.
6704                let p_gid = glyphs[0].index;
6705                assert_ne!(p_gid, 'P' as u32, "glyph index must be a GID, not a codepoint");
6706                // Pen must advance: x coordinates strictly increase across the run.
6707                assert!(glyphs[1].point.x > glyphs[0].point.x, "pen did not advance");
6708            }
6709            other => panic!("expected DisplayListItem::Text, got {other:?}"),
6710        }
6711    }
6712
6713    /// With no registered fonts there is nothing to shape against -> empty.
6714    #[test]
6715    fn generate_text_display_items_empty_without_font() {
6716        let rr = RendererResources::default();
6717        let bounds = LogicalRect {
6718            origin: LogicalPosition { x: 0.0, y: 0.0 },
6719            size: LogicalSize { width: 400.0, height: 30.0 },
6720        };
6721        let items = generate_text_display_items(
6722            "Header",
6723            bounds,
6724            14.0,
6725            ColorU { r: 0, g: 0, b: 0, a: 255 },
6726            TextAlignment::Center,
6727            &rr,
6728        );
6729        assert!(items.is_empty());
6730    }
6731}
6732
6733#[cfg(test)]
6734#[allow(clippy::float_cmp, clippy::too_many_lines)]
6735mod autotest_generated {
6736    use super::*;
6737
6738    // ---------------------------------------------------------------------
6739    // Construction helpers
6740    // ---------------------------------------------------------------------
6741
6742    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
6743        LogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h))
6744    }
6745
6746    fn opaque() -> ColorU {
6747        ColorU { r: 10, g: 20, b: 30, a: 255 }
6748    }
6749
6750    fn glyph(index: u32, x: f32, y: f32) -> GlyphInstance {
6751        GlyphInstance {
6752            index,
6753            point: LogicalPosition::new(x, y),
6754            size: LogicalSize::new(8.0, 12.0),
6755        }
6756    }
6757
6758    fn no_widths() -> StyleBorderWidths {
6759        StyleBorderWidths { top: None, right: None, bottom: None, left: None }
6760    }
6761
6762    fn all_widths() -> StyleBorderWidths {
6763        StyleBorderWidths {
6764            top: Some(CssPropertyValue::Exact(LayoutBorderTopWidth::default())),
6765            right: Some(CssPropertyValue::Exact(LayoutBorderRightWidth::default())),
6766            bottom: Some(CssPropertyValue::Exact(LayoutBorderBottomWidth::default())),
6767            left: Some(CssPropertyValue::Exact(LayoutBorderLeftWidth::default())),
6768        }
6769    }
6770
6771    fn no_colors() -> StyleBorderColors {
6772        StyleBorderColors { top: None, right: None, bottom: None, left: None }
6773    }
6774
6775    fn no_styles() -> StyleBorderStyles {
6776        StyleBorderStyles { top: None, right: None, bottom: None, left: None }
6777    }
6778
6779    fn all_styles() -> StyleBorderStyles {
6780        StyleBorderStyles {
6781            top: Some(CssPropertyValue::Exact(StyleBorderTopStyle::default())),
6782            right: Some(CssPropertyValue::Exact(StyleBorderRightStyle::default())),
6783            bottom: Some(CssPropertyValue::Exact(StyleBorderBottomStyle::default())),
6784            left: Some(CssPropertyValue::Exact(StyleBorderLeftStyle::default())),
6785        }
6786    }
6787
6788    fn zero_style_radius() -> StyleBorderRadius {
6789        StyleBorderRadius {
6790            top_left: PixelValue::zero(),
6791            top_right: PixelValue::zero(),
6792            bottom_left: PixelValue::zero(),
6793            bottom_right: PixelValue::zero(),
6794        }
6795    }
6796
6797    fn test_image() -> ImageRef {
6798        ImageRef::null_image(4, 4, azul_core::resources::RawImageFormat::RGBA8, Vec::new())
6799    }
6800
6801    fn text_item(src: Option<usize>, clip: LogicalRect, glyphs: Vec<GlyphInstance>) -> DisplayListItem {
6802        DisplayListItem::Text {
6803            glyphs,
6804            font_hash: FontHash::from_hash(7),
6805            font_size_px: 16.0,
6806            color: opaque(),
6807            clip_rect: clip.into(),
6808            source_node_index: src,
6809        }
6810    }
6811
6812    fn list_of(items: Vec<DisplayListItem>) -> DisplayList {
6813        let node_mapping = vec![None; items.len()];
6814        DisplayList { items, node_mapping, ..DisplayList::default() }
6815    }
6816
6817    #[cfg(feature = "text_layout")]
6818    fn positioned(line_index: usize, x: f32, y: f32, w: f32, h: f32) -> PositionedItem {
6819        PositionedItem {
6820            item: crate::text3::cache::ShapedItem::Tab {
6821                source: azul_core::selection::ContentIndex { run_index: 0, item_index: 0 },
6822                bounds: crate::text3::cache::Rect { x: 0.0, y: 0.0, width: w, height: h },
6823            },
6824            position: crate::text3::cache::Point { x, y },
6825            line_index,
6826        }
6827    }
6828
6829    // ---------------------------------------------------------------------
6830    // WindowLogicalRect / BorderBoxRect / ContentBoxRect
6831    // ---------------------------------------------------------------------
6832
6833    #[test]
6834    fn window_logical_rect_accessors_roundtrip() {
6835        let origin = LogicalPosition::new(-3.5, 12.25);
6836        let size = LogicalSize::new(100.0, 40.0);
6837        let w = WindowLogicalRect::new(origin, size);
6838
6839        assert_eq!(w.origin(), origin);
6840        assert_eq!(w.size(), size);
6841        assert_eq!(*w.inner(), LogicalRect::new(origin, size));
6842        assert_eq!(w.into_inner(), LogicalRect::new(origin, size));
6843        // From/Into must be the identity on the wrapped rect.
6844        assert_eq!(WindowLogicalRect::from(w.into_inner()), w);
6845        assert_eq!(LogicalRect::from(w), w.into_inner());
6846    }
6847
6848    #[test]
6849    fn window_logical_rect_zero_is_neutral() {
6850        let z = WindowLogicalRect::zero();
6851        assert_eq!(z, WindowLogicalRect::default());
6852        assert_eq!(z.origin(), LogicalPosition::zero());
6853        assert_eq!(z.size(), LogicalSize::zero());
6854        assert_eq!(z.into_inner(), LogicalRect::zero());
6855    }
6856
6857    #[test]
6858    fn window_logical_rect_extreme_values_do_not_panic() {
6859        for (x, y, w, h) in [
6860            (f32::MAX, f32::MAX, f32::MAX, f32::MAX),
6861            (f32::MIN, f32::MIN, 0.0, 0.0),
6862            (f32::INFINITY, f32::NEG_INFINITY, f32::INFINITY, 0.0),
6863            (f32::NAN, f32::NAN, f32::NAN, f32::NAN),
6864        ] {
6865            let r = WindowLogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h));
6866            // Accessors must round-trip the raw bits regardless of how odd they are.
6867            assert_eq!(r.origin().x.to_bits(), x.to_bits());
6868            assert_eq!(r.size().height.to_bits(), h.to_bits());
6869            let _ = format!("{r:?}");
6870        }
6871    }
6872
6873    #[test]
6874    fn border_box_to_content_box_subtracts_padding_and_border() {
6875        let bb = BorderBoxRect(rect(10.0, 20.0, 100.0, 50.0));
6876        let padding = crate::solver3::geometry::EdgeSizes { top: 1.0, right: 2.0, bottom: 3.0, left: 4.0 };
6877        let border = crate::solver3::geometry::EdgeSizes { top: 5.0, right: 6.0, bottom: 7.0, left: 8.0 };
6878
6879        let cb = bb.to_content_box(&padding, &border);
6880        assert_eq!(cb.rect(), rect(22.0, 26.0, 80.0, 34.0));
6881        assert_eq!(bb.rect(), rect(10.0, 20.0, 100.0, 50.0), "receiver copy is unchanged");
6882    }
6883
6884    #[test]
6885    fn border_box_to_content_box_zero_edges_is_identity() {
6886        let bb = BorderBoxRect(rect(1.0, 2.0, 3.0, 4.0));
6887        let zero = crate::solver3::geometry::EdgeSizes::default();
6888        assert_eq!(bb.to_content_box(&zero, &zero).rect(), bb.rect());
6889    }
6890
6891    #[test]
6892    fn border_box_to_content_box_overinset_yields_negative_size_not_a_panic() {
6893        // padding + border exceed the box: the result is a NEGATIVE content box.
6894        // Nothing clamps it, so downstream code must tolerate it. Pin that here so
6895        // a future clamp is a deliberate, visible change.
6896        let bb = BorderBoxRect(rect(0.0, 0.0, 10.0, 10.0));
6897        let big = crate::solver3::geometry::EdgeSizes { top: 50.0, right: 50.0, bottom: 50.0, left: 50.0 };
6898        let cb = bb.to_content_box(&big, &big);
6899        assert!(cb.rect().size.width < 0.0);
6900        assert!(cb.rect().size.height < 0.0);
6901    }
6902
6903    #[test]
6904    fn border_box_to_content_box_nan_and_inf_do_not_panic() {
6905        let bb = BorderBoxRect(rect(0.0, 0.0, f32::MAX, f32::MAX));
6906        let nan = crate::solver3::geometry::EdgeSizes {
6907            top: f32::NAN, right: f32::NAN, bottom: f32::NAN, left: f32::NAN,
6908        };
6909        let inf = crate::solver3::geometry::EdgeSizes {
6910            top: f32::INFINITY, right: f32::INFINITY, bottom: f32::INFINITY, left: f32::INFINITY,
6911        };
6912        assert!(bb.to_content_box(&nan, &nan).rect().size.width.is_nan());
6913        // MAX - inf - inf ... = -inf (defined, not a trap)
6914        assert!(bb.to_content_box(&inf, &inf).rect().size.width.is_infinite());
6915    }
6916
6917    #[test]
6918    fn content_box_rect_getter_returns_wrapped_rect() {
6919        let r = rect(-1.0, -2.0, 0.0, 0.0);
6920        assert_eq!(ContentBoxRect(r).rect(), r);
6921        assert_eq!(ContentBoxRect(LogicalRect::zero()).rect(), LogicalRect::zero());
6922    }
6923
6924    // ---------------------------------------------------------------------
6925    // BorderRadius::is_zero
6926    // ---------------------------------------------------------------------
6927
6928    #[test]
6929    fn border_radius_is_zero_basic() {
6930        assert!(BorderRadius::default().is_zero());
6931        assert!(!BorderRadius { top_left: 1.0, ..BorderRadius::default() }.is_zero());
6932        assert!(!BorderRadius { bottom_right: 0.001, ..BorderRadius::default() }.is_zero());
6933    }
6934
6935    #[test]
6936    fn border_radius_is_zero_edge_floats() {
6937        // -0.0 == 0.0 in IEEE-754, so a negative zero radius still counts as zero.
6938        assert!(BorderRadius { top_left: -0.0, top_right: -0.0, bottom_left: -0.0, bottom_right: -0.0 }.is_zero());
6939        // NaN != 0.0, so a NaN radius is (conservatively) *not* zero — no panic.
6940        assert!(!BorderRadius { top_left: f32::NAN, ..BorderRadius::default() }.is_zero());
6941        assert!(!BorderRadius { top_right: f32::INFINITY, ..BorderRadius::default() }.is_zero());
6942        // A negative radius is not zero either.
6943        assert!(!BorderRadius { bottom_left: -5.0, ..BorderRadius::default() }.is_zero());
6944    }
6945
6946    // ---------------------------------------------------------------------
6947    // DisplayListItem predicates / getters
6948    // ---------------------------------------------------------------------
6949
6950    #[test]
6951    fn is_state_management_true_for_push_pop_only() {
6952        let state = [
6953            DisplayListItem::PushClip { bounds: rect(0.0, 0.0, 1.0, 1.0).into(), border_radius: BorderRadius::default() },
6954            DisplayListItem::PopClip,
6955            DisplayListItem::PopImageMaskClip,
6956            DisplayListItem::PopScrollFrame,
6957            DisplayListItem::PopStackingContext,
6958            DisplayListItem::PopReferenceFrame,
6959            DisplayListItem::PopFilter,
6960            DisplayListItem::PopBackdropFilter,
6961            DisplayListItem::PopOpacity,
6962            DisplayListItem::PopTextShadow,
6963            DisplayListItem::PushStackingContext { z_index: 0, bounds: WindowLogicalRect::zero() },
6964            DisplayListItem::PushOpacity { bounds: WindowLogicalRect::zero(), opacity: 0.5 },
6965            DisplayListItem::PushTextShadow { shadow: StyleBoxShadow::default() },
6966        ];
6967        for item in &state {
6968            assert!(item.is_state_management(), "{item:?} must be state management");
6969        }
6970
6971        let drawing = [
6972            DisplayListItem::Rect { bounds: WindowLogicalRect::zero(), color: opaque(), border_radius: BorderRadius::default() },
6973            DisplayListItem::CursorRect { bounds: WindowLogicalRect::zero(), color: opaque() },
6974            // HitTestArea paints nothing but is NOT a stack command — it must not be forced through.
6975            DisplayListItem::HitTestArea { bounds: WindowLogicalRect::zero(), tag: (0, TAG_TYPE_DOM_NODE) },
6976            text_item(None, LogicalRect::zero(), Vec::new()),
6977        ];
6978        for item in &drawing {
6979            assert!(!item.is_state_management(), "{item:?} must NOT be state management");
6980        }
6981    }
6982
6983    #[test]
6984    fn bounds_reports_none_only_for_pop_and_text_shadow() {
6985        let r = rect(1.0, 2.0, 3.0, 4.0);
6986        assert_eq!(
6987            DisplayListItem::Rect { bounds: r.into(), color: opaque(), border_radius: BorderRadius::default() }.bounds(),
6988            Some(r)
6989        );
6990        // Text reports its CLIP rect as its bounds, not a glyph hull.
6991        assert_eq!(text_item(Some(0), r, vec![glyph(1, 999.0, 999.0)]).bounds(), Some(r));
6992        assert_eq!(
6993            DisplayListItem::PushScrollFrame {
6994                clip_bounds: r.into(),
6995                content_size: LogicalSize::new(9.0, 9.0),
6996                scroll_id: 3,
6997            }.bounds(),
6998            Some(r)
6999        );
7000        assert_eq!(DisplayListItem::PopClip.bounds(), None);
7001        assert_eq!(DisplayListItem::PopOpacity.bounds(), None);
7002        assert_eq!(
7003            DisplayListItem::PushTextShadow { shadow: StyleBoxShadow::default() }.bounds(),
7004            None,
7005            "a text shadow has no bounds of its own"
7006        );
7007    }
7008
7009    #[test]
7010    fn bounds_on_degenerate_rects_does_not_panic() {
7011        for r in [
7012            LogicalRect::zero(),
7013            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
7014            rect(f32::MIN, f32::MIN, f32::MAX, f32::MAX),
7015            rect(0.0, 0.0, -10.0, -10.0),
7016        ] {
7017            let item = DisplayListItem::Rect { bounds: r.into(), color: opaque(), border_radius: BorderRadius::default() };
7018            assert!(item.bounds().is_some());
7019            assert!(item.visual_bounds().is_some());
7020        }
7021    }
7022
7023    #[test]
7024    fn visual_bounds_matches_bounds_for_non_shadow_items() {
7025        let r = rect(5.0, 6.0, 7.0, 8.0);
7026        let item = DisplayListItem::Rect { bounds: r.into(), color: opaque(), border_radius: BorderRadius::default() };
7027        assert_eq!(item.visual_bounds(), item.bounds());
7028        assert_eq!(DisplayListItem::PopClip.visual_bounds(), None);
7029    }
7030
7031    #[test]
7032    fn visual_bounds_expands_box_shadow_by_offset_blur_and_spread() {
7033        use azul_css::props::basic::pixel::PixelValueNoPercent;
7034        let shadow = StyleBoxShadow {
7035            offset_x: PixelValueNoPercent { inner: PixelValue::const_px(2) },
7036            offset_y: PixelValueNoPercent { inner: PixelValue::const_px(3) },
7037            blur_radius: PixelValueNoPercent { inner: PixelValue::const_px(4) },
7038            spread_radius: PixelValueNoPercent { inner: PixelValue::const_px(5) },
7039            clip_mode: BoxShadowClipMode::default(),
7040            color: ColorU::BLACK,
7041        };
7042        let item = DisplayListItem::BoxShadow {
7043            bounds: rect(100.0, 100.0, 50.0, 50.0).into(),
7044            shadow,
7045            border_radius: BorderRadius::default(),
7046        };
7047        // expand = |2| + |3| + |4| + |5| = 14, applied on every side.
7048        let vb = item.visual_bounds().expect("box shadow has visual bounds");
7049        assert_eq!(vb, rect(86.0, 86.0, 78.0, 78.0));
7050        // The visual bounds must strictly contain the paint bounds.
7051        let b = item.bounds().unwrap();
7052        assert!(vb.origin.x < b.origin.x && vb.size.width > b.size.width);
7053    }
7054
7055    #[test]
7056    fn visual_bounds_box_shadow_with_negative_offsets_uses_absolute_values() {
7057        use azul_css::props::basic::pixel::PixelValueNoPercent;
7058        let shadow = StyleBoxShadow {
7059            offset_x: PixelValueNoPercent { inner: PixelValue::const_px(-10) },
7060            offset_y: PixelValueNoPercent { inner: PixelValue::const_px(-10) },
7061            blur_radius: PixelValueNoPercent { inner: PixelValue::const_px(0) },
7062            spread_radius: PixelValueNoPercent { inner: PixelValue::const_px(0) },
7063            clip_mode: BoxShadowClipMode::default(),
7064            color: ColorU::BLACK,
7065        };
7066        let item = DisplayListItem::BoxShadow {
7067            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7068            shadow,
7069            border_radius: BorderRadius::default(),
7070        };
7071        // .abs() is applied, so the shadow expands symmetrically by 20 in each direction.
7072        assert_eq!(item.visual_bounds().unwrap(), rect(-20.0, -20.0, 50.0, 50.0));
7073    }
7074
7075    // ---------------------------------------------------------------------
7076    // DisplayListItem::is_visually_equal
7077    // ---------------------------------------------------------------------
7078
7079    #[test]
7080    fn is_visually_equal_reflexive_and_discriminant_guarded() {
7081        let a = DisplayListItem::Rect {
7082            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7083            color: opaque(),
7084            border_radius: BorderRadius::default(),
7085        };
7086        assert!(a.is_visually_equal(&a));
7087        assert!(!a.is_visually_equal(&DisplayListItem::PopClip), "different variants are never equal");
7088        assert!(!DisplayListItem::PopClip.is_visually_equal(&a));
7089    }
7090
7091    #[test]
7092    fn is_visually_equal_detects_field_changes() {
7093        let base = DisplayListItem::Rect {
7094            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7095            color: opaque(),
7096            border_radius: BorderRadius::default(),
7097        };
7098        let moved = DisplayListItem::Rect {
7099            bounds: rect(1.0, 0.0, 10.0, 10.0).into(),
7100            color: opaque(),
7101            border_radius: BorderRadius::default(),
7102        };
7103        let recolored = DisplayListItem::Rect {
7104            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7105            color: ColorU { r: 255, g: 0, b: 0, a: 255 },
7106            border_radius: BorderRadius::default(),
7107        };
7108        let rounded = DisplayListItem::Rect {
7109            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7110            color: opaque(),
7111            border_radius: BorderRadius { top_left: 4.0, ..BorderRadius::default() },
7112        };
7113        assert!(!base.is_visually_equal(&moved));
7114        assert!(!base.is_visually_equal(&recolored));
7115        assert!(!base.is_visually_equal(&rounded));
7116    }
7117
7118    #[test]
7119    fn is_visually_equal_pops_are_always_equal() {
7120        for (a, b) in [
7121            (DisplayListItem::PopClip, DisplayListItem::PopClip),
7122            (DisplayListItem::PopScrollFrame, DisplayListItem::PopScrollFrame),
7123            (DisplayListItem::PopOpacity, DisplayListItem::PopOpacity),
7124            (DisplayListItem::PopTextShadow, DisplayListItem::PopTextShadow),
7125        ] {
7126            assert!(a.is_visually_equal(&b));
7127        }
7128        assert!(!DisplayListItem::PopClip.is_visually_equal(&DisplayListItem::PopOpacity));
7129    }
7130
7131    #[test]
7132    fn is_visually_equal_hit_test_areas_never_damage() {
7133        // Documented: hit-test areas paint no pixels, so ANY two are visually equal
7134        // (regression guard for issue #12 — a moved hit region must not force a repaint).
7135        let a = DisplayListItem::HitTestArea { bounds: rect(0.0, 0.0, 1.0, 1.0).into(), tag: (1, TAG_TYPE_DOM_NODE) };
7136        let b = DisplayListItem::HitTestArea { bounds: rect(500.0, 900.0, 7.0, 7.0).into(), tag: (99, TAG_TYPE_CURSOR) };
7137        assert!(a.is_visually_equal(&b));
7138    }
7139
7140    #[test]
7141    fn is_visually_equal_text_layout_uses_arc_pointer_identity() {
7142        let shared: Arc<dyn std::any::Any + Send + Sync> = Arc::new(42u32);
7143        let other: Arc<dyn std::any::Any + Send + Sync> = Arc::new(42u32);
7144        let make = |layout: Arc<dyn std::any::Any + Send + Sync>| DisplayListItem::TextLayout {
7145            layout,
7146            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7147            font_hash: FontHash::from_hash(1),
7148            font_size_px: 16.0,
7149            color: opaque(),
7150        };
7151        assert!(make(shared.clone()).is_visually_equal(&make(shared)), "same Arc => reuse => no damage");
7152        assert!(
7153            !make(Arc::new(42u32)).is_visually_equal(&make(other)),
7154            "distinct allocations => conservatively different"
7155        );
7156    }
7157
7158    #[test]
7159    fn is_visually_equal_image_uses_pointer_identity_not_content() {
7160        let img = test_image();
7161        let a = DisplayListItem::Image {
7162            bounds: rect(0.0, 0.0, 4.0, 4.0).into(),
7163            image: img.clone(),
7164            border_radius: BorderRadius::default(),
7165        };
7166        let same_alloc = DisplayListItem::Image {
7167            bounds: rect(0.0, 0.0, 4.0, 4.0).into(),
7168            image: img,
7169            border_radius: BorderRadius::default(),
7170        };
7171        assert!(a.is_visually_equal(&same_alloc));
7172
7173        // A byte-identical but separately allocated image is conservatively "different".
7174        let b = DisplayListItem::Image {
7175            bounds: rect(0.0, 0.0, 4.0, 4.0).into(),
7176            image: test_image(),
7177            border_radius: BorderRadius::default(),
7178        };
7179        assert!(!a.is_visually_equal(&b));
7180    }
7181
7182    #[test]
7183    fn is_visually_equal_text_compares_glyph_ids_and_positions() {
7184        let clip = rect(0.0, 0.0, 100.0, 20.0);
7185        let a = text_item(Some(1), clip, vec![glyph(5, 0.0, 10.0), glyph(6, 8.0, 10.0)]);
7186        let same = text_item(Some(999), clip, vec![glyph(5, 0.0, 10.0), glyph(6, 8.0, 10.0)]);
7187        let diff_gid = text_item(Some(1), clip, vec![glyph(5, 0.0, 10.0), glyph(7, 8.0, 10.0)]);
7188        let diff_pos = text_item(Some(1), clip, vec![glyph(5, 0.0, 10.0), glyph(6, 9.0, 10.0)]);
7189        let shorter = text_item(Some(1), clip, vec![glyph(5, 0.0, 10.0)]);
7190        let empty = text_item(Some(1), clip, Vec::new());
7191
7192        assert!(a.is_visually_equal(&same), "source_node_index is not a visual property");
7193        assert!(!a.is_visually_equal(&diff_gid));
7194        assert!(!a.is_visually_equal(&diff_pos));
7195        assert!(!a.is_visually_equal(&shorter), "glyph count mismatch => different");
7196        assert!(!a.is_visually_equal(&empty));
7197        assert!(empty.is_visually_equal(&empty), "empty glyph runs are equal to each other");
7198    }
7199
7200    #[test]
7201    fn is_visually_equal_nan_thickness_is_conservatively_unequal() {
7202        // Raw f32 `==` on thickness: NaN != NaN, so even a self-comparison of a NaN
7203        // thickness reports "changed". That is the SAFE direction (forces a repaint),
7204        // and it must not panic.
7205        let a = DisplayListItem::Underline {
7206            bounds: rect(0.0, 0.0, 10.0, 1.0).into(),
7207            color: opaque(),
7208            thickness: f32::NAN,
7209        };
7210        assert!(!a.is_visually_equal(&a));
7211    }
7212
7213    #[test]
7214    fn is_visually_equal_bounds_are_quantized_to_a_thousandth_of_a_pixel() {
7215        // LogicalRect equality is fixed-point (1/1000 px). Sub-quantum jitter is
7216        // deliberately treated as "no visual change" so float noise cannot damage.
7217        let a = DisplayListItem::Rect {
7218            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7219            color: opaque(),
7220            border_radius: BorderRadius::default(),
7221        };
7222        let jittered = DisplayListItem::Rect {
7223            bounds: rect(0.000_01, 0.0, 10.0, 10.0).into(),
7224            color: opaque(),
7225            border_radius: BorderRadius::default(),
7226        };
7227        assert!(a.is_visually_equal(&jittered));
7228
7229        // A whole-pixel move is above the quantum and *is* reported.
7230        let moved = DisplayListItem::Rect {
7231            bounds: rect(1.0, 0.0, 10.0, 10.0).into(),
7232            color: opaque(),
7233            border_radius: BorderRadius::default(),
7234        };
7235        assert!(!a.is_visually_equal(&moved));
7236    }
7237
7238    // ---------------------------------------------------------------------
7239    // DisplayListBuilder
7240    // ---------------------------------------------------------------------
7241
7242    #[test]
7243    fn builder_new_is_empty_and_matches_with_debug_false() {
7244        let b = DisplayListBuilder::new();
7245        assert!(b.items.is_empty());
7246        assert!(b.node_mapping.is_empty());
7247        assert!(!b.debug_enabled);
7248        assert!(b.forced_page_breaks.is_empty());
7249        assert!(b.fixed_position_item_ranges.is_empty());
7250        assert!(b.fixed_position_start.is_none());
7251
7252        let dl = DisplayListBuilder::new().build();
7253        assert!(dl.items.is_empty());
7254        assert!(dl.node_mapping.is_empty());
7255    }
7256
7257    #[test]
7258    fn builder_with_debug_toggles_message_collection() {
7259        let mut off = DisplayListBuilder::with_debug(false);
7260        off.debug_log("dropped".to_string());
7261        assert!(off.debug_messages.is_empty());
7262
7263        let mut on = DisplayListBuilder::with_debug(true);
7264        on.debug_log(String::new());
7265        on.debug_log("x".repeat(100_000)); // huge message must not panic
7266        on.debug_log("🦀 unicode \u{0}\u{FFFD} nul".to_string());
7267        assert_eq!(on.debug_messages.len(), 3);
7268    }
7269
7270    #[test]
7271    fn builder_push_item_keeps_node_mapping_in_lockstep() {
7272        let mut b = DisplayListBuilder::new();
7273        b.set_current_node(Some(NodeId::new(4)));
7274        b.push_rect(rect(0.0, 0.0, 1.0, 1.0), opaque(), BorderRadius::default());
7275        b.set_current_node(None);
7276        b.pop_clip();
7277
7278        let dl = b.build();
7279        assert_eq!(dl.items.len(), 2);
7280        assert_eq!(dl.items.len(), dl.node_mapping.len(), "node_mapping must parallel items");
7281        assert_eq!(dl.node_mapping[0], Some(NodeId::new(4)));
7282        assert_eq!(dl.node_mapping[1], None);
7283    }
7284
7285    #[test]
7286    fn builder_skips_fully_transparent_fills() {
7287        let mut b = DisplayListBuilder::new();
7288        b.push_rect(rect(0.0, 0.0, 10.0, 10.0), ColorU::TRANSPARENT, BorderRadius::default());
7289        b.push_selection_rect(rect(0.0, 0.0, 10.0, 10.0), ColorU::TRANSPARENT, BorderRadius::default());
7290        b.push_scrollbar(rect(0.0, 0.0, 10.0, 10.0), ColorU::TRANSPARENT, ScrollbarOrientation::Vertical, None, None);
7291        assert!(b.items.is_empty(), "alpha == 0 with no opacity key paints nothing");
7292
7293        // An opacity key means the alpha is animated on the GPU — it must still be pushed.
7294        b.push_scrollbar(
7295            rect(0.0, 0.0, 10.0, 10.0),
7296            ColorU::TRANSPARENT,
7297            ScrollbarOrientation::Vertical,
7298            Some(OpacityKey::unique()),
7299            None,
7300        );
7301        assert_eq!(b.items.len(), 1);
7302    }
7303
7304    #[test]
7305    fn builder_cursor_rect_is_emitted_even_when_invisible() {
7306        // Blink-off carets MUST still emit an item so the item count stays stable
7307        // across blink phases (otherwise damage falls back to a full-window repaint).
7308        let mut b = DisplayListBuilder::new();
7309        b.push_cursor_rect(rect(0.0, 0.0, 1.0, 16.0), ColorU::TRANSPARENT);
7310        assert_eq!(b.items.len(), 1);
7311        assert!(matches!(b.items[0], DisplayListItem::CursorRect { .. }));
7312    }
7313
7314    #[test]
7315    fn builder_text_decorations_require_positive_thickness_and_alpha() {
7316        let bounds = rect(0.0, 0.0, 10.0, 2.0);
7317        for thickness in [0.0, -1.0, f32::NAN, f32::NEG_INFINITY] {
7318            let mut b = DisplayListBuilder::new();
7319            b.push_underline(bounds, opaque(), thickness);
7320            b.push_strikethrough(bounds, opaque(), thickness);
7321            b.push_overline(bounds, opaque(), thickness);
7322            assert!(b.items.is_empty(), "thickness {thickness} must not paint");
7323        }
7324
7325        // +inf is > 0.0, so it *is* pushed (defined, no panic).
7326        let mut b = DisplayListBuilder::new();
7327        b.push_underline(bounds, opaque(), f32::INFINITY);
7328        assert_eq!(b.items.len(), 1);
7329
7330        // Transparent decorations are skipped regardless of thickness.
7331        let mut b = DisplayListBuilder::new();
7332        b.push_overline(bounds, ColorU::TRANSPARENT, 3.0);
7333        assert!(b.items.is_empty());
7334    }
7335
7336    #[test]
7337    fn builder_text_run_skips_empty_glyphs_and_transparent_color() {
7338        let clip = rect(0.0, 0.0, 100.0, 20.0);
7339        let mut b = DisplayListBuilder::new();
7340        b.push_text_run(Vec::new(), FontHash::invalid(), 16.0, opaque(), clip, Some(0));
7341        b.push_text_run(vec![glyph(1, 0.0, 0.0)], FontHash::invalid(), 16.0, ColorU::TRANSPARENT, clip, Some(0));
7342        assert!(b.items.is_empty());
7343
7344        // NaN / huge font sizes are pass-through values, not a panic.
7345        b.push_text_run(vec![glyph(1, 0.0, 0.0)], FontHash::invalid(), f32::NAN, opaque(), clip, None);
7346        b.push_text_run(vec![glyph(2, 0.0, 0.0)], FontHash::invalid(), f32::MAX, opaque(), clip, None);
7347        assert_eq!(b.items.len(), 2);
7348    }
7349
7350    #[test]
7351    fn builder_border_requires_both_a_width_and_a_style() {
7352        let bounds = rect(0.0, 0.0, 10.0, 10.0);
7353
7354        let mut b = DisplayListBuilder::new();
7355        b.push_border(bounds, no_widths(), no_colors(), no_styles(), zero_style_radius());
7356        assert!(b.items.is_empty(), "no widths + no styles => nothing to draw");
7357
7358        let mut b = DisplayListBuilder::new();
7359        b.push_border(bounds, all_widths(), no_colors(), no_styles(), zero_style_radius());
7360        assert!(b.items.is_empty(), "width without style => nothing to draw");
7361
7362        let mut b = DisplayListBuilder::new();
7363        b.push_border(bounds, no_widths(), no_colors(), all_styles(), zero_style_radius());
7364        assert!(b.items.is_empty(), "style without width => nothing to draw");
7365
7366        let mut b = DisplayListBuilder::new();
7367        b.push_border(bounds, all_widths(), no_colors(), all_styles(), zero_style_radius());
7368        assert_eq!(b.items.len(), 1);
7369    }
7370
7371    #[test]
7372    fn builder_forced_page_breaks_are_deduped_and_sorted() {
7373        let mut b = DisplayListBuilder::new();
7374        b.add_forced_page_break(300.0);
7375        b.add_forced_page_break(100.0);
7376        b.add_forced_page_break(300.0); // exact duplicate
7377        b.add_forced_page_break(200.0);
7378        b.add_forced_page_break(0.0);
7379        b.add_forced_page_break(-50.0); // negative is accepted verbatim
7380        b.add_forced_page_break(f32::INFINITY);
7381        b.add_forced_page_break(f32::NEG_INFINITY);
7382        assert_eq!(
7383            b.forced_page_breaks,
7384            vec![f32::NEG_INFINITY, -50.0, 0.0, 100.0, 200.0, 300.0, f32::INFINITY]
7385        );
7386    }
7387
7388    #[test]
7389    fn builder_forced_page_break_nan_is_never_deduped() {
7390        // `contains(&NaN)` is always false (NaN != NaN), so NaN breaks are NOT deduped —
7391        // repeated calls accumulate. The sort uses partial_cmp().unwrap_or(Equal), so it
7392        // survives the non-total order rather than panicking on an `unwrap`.
7393        let mut b = DisplayListBuilder::new();
7394        b.add_forced_page_break(f32::NAN);
7395        b.add_forced_page_break(f32::NAN);
7396        b.add_forced_page_break(100.0);
7397        assert_eq!(b.forced_page_breaks.len(), 3, "a NaN break is never deduped");
7398        assert_eq!(b.forced_page_breaks.iter().filter(|v| v.is_nan()).count(), 2);
7399    }
7400
7401    #[test]
7402    fn builder_fixed_position_ranges_need_a_begin_and_at_least_one_item() {
7403        // end without begin: no-op, no panic.
7404        let mut b = DisplayListBuilder::new();
7405        b.end_fixed_position_element();
7406        assert!(b.fixed_position_item_ranges.is_empty());
7407
7408        // begin + end with zero items in between: nothing recorded (end > start is false).
7409        let mut b = DisplayListBuilder::new();
7410        b.begin_fixed_position_element();
7411        b.end_fixed_position_element();
7412        assert!(b.fixed_position_item_ranges.is_empty());
7413
7414        // begin + items + end: the half-open [start, end) range is recorded.
7415        let mut b = DisplayListBuilder::new();
7416        b.pop_clip(); // one pre-existing item at index 0
7417        b.begin_fixed_position_element();
7418        b.push_rect(rect(0.0, 0.0, 1.0, 1.0), opaque(), BorderRadius::default());
7419        b.push_rect(rect(0.0, 0.0, 2.0, 2.0), opaque(), BorderRadius::default());
7420        b.end_fixed_position_element();
7421        assert_eq!(b.fixed_position_item_ranges, vec![(1, 3)]);
7422
7423        // A second end() without a matching begin() must not re-record.
7424        b.end_fixed_position_element();
7425        assert_eq!(b.fixed_position_item_ranges.len(), 1);
7426    }
7427
7428    #[test]
7429    fn builder_build_with_debug_transfers_messages() {
7430        let mut b = DisplayListBuilder::with_debug(true);
7431        b.debug_log("hello".to_string());
7432        b.push_rect(rect(0.0, 0.0, 1.0, 1.0), opaque(), BorderRadius::default());
7433
7434        let mut sink = Some(vec![LayoutDebugMessage::info("pre-existing".to_string())]);
7435        let dl = b.build_with_debug(&mut sink);
7436        let msgs = sink.expect("sink stays Some");
7437        assert_eq!(msgs.len(), 2, "messages are appended, not replaced");
7438        assert_eq!(dl.items.len(), 1);
7439
7440        // A None sink must swallow the messages rather than panic.
7441        let mut b = DisplayListBuilder::with_debug(true);
7442        b.debug_log("dropped".to_string());
7443        let mut none_sink: Option<Vec<LayoutDebugMessage>> = None;
7444        let dl = b.build_with_debug(&mut none_sink);
7445        assert!(none_sink.is_none());
7446        assert!(dl.items.is_empty());
7447    }
7448
7449    #[test]
7450    fn builder_stack_pushes_accept_extreme_arguments() {
7451        let mut b = DisplayListBuilder::new();
7452        // i32 extremes for z-index, degenerate bounds, and an all-NaN clip.
7453        b.push_stacking_context(i32::MIN, rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN));
7454        b.push_stacking_context(i32::MAX, rect(0.0, 0.0, -1.0, -1.0));
7455        b.pop_stacking_context();
7456        b.push_clip(rect(f32::MIN, f32::MIN, f32::MAX, f32::MAX), BorderRadius { top_left: f32::INFINITY, ..BorderRadius::default() });
7457        b.pop_clip();
7458        b.push_scroll_frame(LogicalRect::zero(), LogicalSize::new(f32::MAX, f32::MAX), u64::MAX);
7459        b.pop_scroll_frame();
7460        b.push_image_mask_clip(LogicalRect::zero(), test_image(), rect(0.0, 0.0, -5.0, -5.0));
7461        b.pop_image_mask_clip();
7462        b.push_reference_frame(TransformKey::unique(), ComputedTransform3D::IDENTITY, LogicalRect::zero());
7463        b.pop_reference_frame();
7464        b.push_virtual_view_placeholder(NodeId::ZERO, LogicalRect::zero(), LogicalRect::zero());
7465        b.push_hit_test_area(rect(0.0, 0.0, 1.0, 1.0), (u64::MAX, TAG_TYPE_CURSOR));
7466        b.push_image(LogicalRect::zero(), test_image(), BorderRadius::default());
7467        b.push_linear_gradient(LogicalRect::zero(), LinearGradient::default(), BorderRadius::default());
7468        b.push_radial_gradient(LogicalRect::zero(), RadialGradient::default(), BorderRadius::default());
7469        b.push_conic_gradient(LogicalRect::zero(), ConicGradient::default(), BorderRadius::default());
7470
7471        let dl = b.build();
7472        assert_eq!(dl.items.len(), 17);
7473        assert_eq!(dl.items.len(), dl.node_mapping.len());
7474    }
7475
7476    // ---------------------------------------------------------------------
7477    // Free geometry helpers: rect_intersects / clip_rect_bounds / offset_rect_y
7478    // ---------------------------------------------------------------------
7479
7480    #[test]
7481    fn rect_intersects_uses_a_half_open_page_interval() {
7482        let page = (100.0f32, 200.0f32);
7483        // Fully inside.
7484        assert!(rect_intersects(&rect(0.0, 120.0, 10.0, 10.0), page.0, page.1));
7485        // Straddling both edges.
7486        assert!(rect_intersects(&rect(0.0, 50.0, 10.0, 300.0), page.0, page.1));
7487        // Entirely above / below.
7488        assert!(!rect_intersects(&rect(0.0, 0.0, 10.0, 10.0), page.0, page.1));
7489        assert!(!rect_intersects(&rect(0.0, 500.0, 10.0, 10.0), page.0, page.1));
7490        // Touching exactly: bottom edge == page_top is NOT an intersection...
7491        assert!(!rect_intersects(&rect(0.0, 90.0, 10.0, 10.0), page.0, page.1));
7492        // ...and top edge == page_bottom is NOT either.
7493        assert!(!rect_intersects(&rect(0.0, 200.0, 10.0, 10.0), page.0, page.1));
7494        // A zero-height rect strictly inside DOES intersect (its single edge is in range).
7495        assert!(rect_intersects(&rect(0.0, 150.0, 10.0, 0.0), page.0, page.1));
7496        // ...but a zero-height rect sitting exactly on either page edge does not.
7497        assert!(!rect_intersects(&rect(0.0, 100.0, 10.0, 0.0), page.0, page.1));
7498        assert!(!rect_intersects(&rect(0.0, 200.0, 10.0, 0.0), page.0, page.1));
7499    }
7500
7501    #[test]
7502    fn rect_intersects_with_nan_is_false_not_a_panic() {
7503        assert!(!rect_intersects(&rect(0.0, f32::NAN, 10.0, 10.0), 0.0, 100.0));
7504        assert!(!rect_intersects(&rect(0.0, 10.0, 10.0, f32::NAN), 0.0, 100.0));
7505        assert!(!rect_intersects(&rect(0.0, 10.0, 10.0, 10.0), f32::NAN, f32::NAN));
7506        // Infinite page bounds cover everything finite.
7507        assert!(rect_intersects(&rect(0.0, 10.0, 10.0, 10.0), f32::NEG_INFINITY, f32::INFINITY));
7508    }
7509
7510    #[test]
7511    fn clip_rect_bounds_clips_and_rebases_to_page_relative_coords() {
7512        // Item straddles the top of page [100, 200): keep the visible slice, rebase to y=0.
7513        let clipped = clip_rect_bounds(rect(5.0, 50.0, 20.0, 100.0), 100.0, 200.0).unwrap();
7514        assert_eq!(clipped, rect(5.0, 0.0, 20.0, 50.0));
7515
7516        // Item straddles the bottom: kept slice starts at its own offset into the page.
7517        let clipped = clip_rect_bounds(rect(5.0, 180.0, 20.0, 100.0), 100.0, 200.0).unwrap();
7518        assert_eq!(clipped, rect(5.0, 80.0, 20.0, 20.0));
7519
7520        // Item strictly inside: only rebased, never resized.
7521        let clipped = clip_rect_bounds(rect(5.0, 120.0, 20.0, 30.0), 100.0, 200.0).unwrap();
7522        assert_eq!(clipped, rect(5.0, 20.0, 20.0, 30.0));
7523
7524        // Item larger than the page on both sides: clamped to exactly the page height.
7525        let clipped = clip_rect_bounds(rect(0.0, 0.0, 20.0, 10_000.0), 100.0, 200.0).unwrap();
7526        assert_eq!(clipped, rect(0.0, 0.0, 20.0, 100.0));
7527    }
7528
7529    #[test]
7530    fn clip_rect_bounds_rejects_off_page_and_edge_touching_rects() {
7531        assert_eq!(clip_rect_bounds(rect(0.0, 0.0, 10.0, 10.0), 100.0, 200.0), None);
7532        assert_eq!(clip_rect_bounds(rect(0.0, 300.0, 10.0, 10.0), 100.0, 200.0), None);
7533        // bottom == page_top -> outside (half-open interval).
7534        assert_eq!(clip_rect_bounds(rect(0.0, 90.0, 10.0, 10.0), 100.0, 200.0), None);
7535        // top == page_bottom -> outside.
7536        assert_eq!(clip_rect_bounds(rect(0.0, 200.0, 10.0, 10.0), 100.0, 200.0), None);
7537    }
7538
7539    #[test]
7540    fn clip_rect_bounds_zero_height_rects() {
7541        // A zero-height rect sitting exactly on page_top is rejected (bottom <= top).
7542        assert_eq!(clip_rect_bounds(rect(0.0, 100.0, 10.0, 0.0), 100.0, 200.0), None);
7543        // A zero-height rect strictly inside survives as a zero-height slice.
7544        assert_eq!(
7545            clip_rect_bounds(rect(0.0, 150.0, 10.0, 0.0), 100.0, 200.0),
7546            Some(rect(0.0, 50.0, 10.0, 0.0))
7547        );
7548    }
7549
7550    #[test]
7551    fn clip_rect_bounds_with_an_inverted_page_produces_a_degenerate_rect_not_a_panic() {
7552        // page_top > page_bottom is never produced by calculate_page_break_positions,
7553        // but nothing rejects it here: the height goes NEGATIVE. Pinned so the missing
7554        // guard is visible rather than silently feeding a negative rect downstream.
7555        let out = clip_rect_bounds(rect(0.0, 0.0, 10.0, 500.0), 200.0, 100.0)
7556            .expect("an inverted page is not rejected");
7557        assert!(out.size.height < 0.0, "no clamp: an inverted page yields a negative height");
7558    }
7559
7560    #[test]
7561    fn clip_rect_bounds_with_extreme_pages_does_not_panic() {
7562        // An unbounded page keeps the item intact (no clipping).
7563        let full = clip_rect_bounds(rect(0.0, 10.0, 10.0, 10.0), f32::NEG_INFINITY, f32::INFINITY)
7564            .expect("an infinite page contains everything");
7565        assert_eq!(full.size, LogicalSize::new(10.0, 10.0));
7566
7567        // NaN page bounds: every comparison is false, so the rect is kept. f32::max/min
7568        // drop the NaN operand, so the SIZE stays clean and only the rebased origin goes
7569        // NaN. Defined, total, and crucially NOT a panic.
7570        let nan_page = clip_rect_bounds(rect(0.0, 10.0, 10.0, 10.0), f32::NAN, f32::NAN)
7571            .expect("NaN bounds fail both rejection tests");
7572        assert!(nan_page.origin.y.is_nan(), "the page-relative rebase propagates NaN");
7573        assert_eq!(nan_page.size.height, 10.0, "min/max ignore NaN, so the height survives");
7574
7575        // f32::MAX height must not overflow into a panic.
7576        assert_eq!(
7577            clip_rect_bounds(rect(0.0, 0.0, 10.0, f32::MAX), 0.0, 100.0),
7578            Some(rect(0.0, 0.0, 10.0, 100.0))
7579        );
7580    }
7581
7582    #[test]
7583    fn offset_rect_y_only_moves_y_and_preserves_size() {
7584        assert_eq!(offset_rect_y(rect(1.0, 2.0, 3.0, 4.0), 0.0), rect(1.0, 2.0, 3.0, 4.0));
7585        assert_eq!(offset_rect_y(rect(1.0, 2.0, 3.0, 4.0), 10.0), rect(1.0, 12.0, 3.0, 4.0));
7586        assert_eq!(offset_rect_y(rect(1.0, 2.0, 3.0, 4.0), -10.0), rect(1.0, -8.0, 3.0, 4.0));
7587
7588        // Non-finite offsets are propagated, never trapped.
7589        assert!(offset_rect_y(rect(0.0, 0.0, 1.0, 1.0), f32::INFINITY).origin.y.is_infinite());
7590        assert!(offset_rect_y(rect(0.0, 0.0, 1.0, 1.0), f32::NAN).origin.y.is_nan());
7591        // MAX + MAX saturates to +inf under IEEE-754, not a wrap.
7592        assert!(offset_rect_y(rect(0.0, f32::MAX, 1.0, 1.0), f32::MAX).origin.y.is_infinite());
7593        // The size is untouched in every case.
7594        assert_eq!(offset_rect_y(rect(0.0, 0.0, 3.0, 4.0), f32::NAN).size, LogicalSize::new(3.0, 4.0));
7595    }
7596
7597    // ---------------------------------------------------------------------
7598    // Per-item page clipping
7599    // ---------------------------------------------------------------------
7600
7601    #[test]
7602    fn clip_rect_item_drops_off_page_and_rebases_on_page() {
7603        assert!(clip_rect_item(rect(0.0, 0.0, 10.0, 10.0), opaque(), BorderRadius::default(), 100.0, 200.0).is_none());
7604
7605        let item = clip_rect_item(rect(0.0, 150.0, 10.0, 100.0), opaque(), BorderRadius::default(), 100.0, 200.0)
7606            .expect("overlaps the page");
7607        match item {
7608            DisplayListItem::Rect { bounds, color, .. } => {
7609                assert_eq!(bounds.into_inner(), rect(0.0, 50.0, 10.0, 50.0));
7610                assert_eq!(color, opaque());
7611            }
7612            other => panic!("expected Rect, got {other:?}"),
7613        }
7614    }
7615
7616    #[test]
7617    fn clip_cursor_selection_hittest_and_scrollbar_items_share_the_page_test() {
7618        let off = rect(0.0, 0.0, 10.0, 10.0);
7619        let on = rect(0.0, 120.0, 10.0, 10.0);
7620        let (top, bottom) = (100.0, 200.0);
7621
7622        assert!(clip_cursor_rect_item(off, opaque(), top, bottom).is_none());
7623        assert!(clip_cursor_rect_item(on, opaque(), top, bottom).is_some());
7624
7625        assert!(clip_selection_rect_item(off, BorderRadius::default(), opaque(), top, bottom).is_none());
7626        assert!(clip_selection_rect_item(on, BorderRadius::default(), opaque(), top, bottom).is_some());
7627
7628        assert!(clip_hit_test_area_item(off, (1, TAG_TYPE_DOM_NODE), top, bottom).is_none());
7629        assert!(clip_hit_test_area_item(on, (1, TAG_TYPE_DOM_NODE), top, bottom).is_some());
7630
7631        assert!(clip_scrollbar_item(off, opaque(), ScrollbarOrientation::Vertical, None, None, top, bottom).is_none());
7632        assert!(clip_scrollbar_item(on, opaque(), ScrollbarOrientation::Vertical, None, None, top, bottom).is_some());
7633
7634        assert!(clip_image_item(off, test_image(), BorderRadius::default(), top, bottom).is_none());
7635        assert!(clip_image_item(on, test_image(), BorderRadius::default(), top, bottom).is_some());
7636
7637        assert!(clip_virtual_view_item(DomId::ROOT_ID, off, off, top, bottom).is_none());
7638        assert!(clip_virtual_view_item(DomId::ROOT_ID, on, on, top, bottom).is_some());
7639    }
7640
7641    #[test]
7642    fn clip_text_decoration_item_preserves_the_decoration_kind() {
7643        let on = rect(0.0, 120.0, 10.0, 2.0);
7644        let (top, bottom) = (100.0, 200.0);
7645
7646        assert!(matches!(
7647            clip_text_decoration_item(on, opaque(), 1.0, TextDecorationType::Underline, top, bottom),
7648            Some(DisplayListItem::Underline { .. })
7649        ));
7650        assert!(matches!(
7651            clip_text_decoration_item(on, opaque(), 1.0, TextDecorationType::Strikethrough, top, bottom),
7652            Some(DisplayListItem::Strikethrough { .. })
7653        ));
7654        assert!(matches!(
7655            clip_text_decoration_item(on, opaque(), 1.0, TextDecorationType::Overline, top, bottom),
7656            Some(DisplayListItem::Overline { .. })
7657        ));
7658        // Off-page decorations are dropped even with a NaN thickness.
7659        assert!(clip_text_decoration_item(
7660            rect(0.0, 0.0, 10.0, 2.0), opaque(), f32::NAN, TextDecorationType::Underline, top, bottom
7661        ).is_none());
7662    }
7663
7664    #[test]
7665    fn clip_text_item_filters_glyphs_by_baseline_into_a_half_open_page() {
7666        let clip = rect(0.0, 90.0, 200.0, 120.0);
7667        let glyphs = vec![
7668            glyph(1, 0.0, 99.0),   // above page  -> dropped
7669            glyph(2, 8.0, 100.0),  // exactly page_top -> KEPT (>= top)
7670            glyph(3, 16.0, 150.0), // inside -> kept
7671            glyph(4, 24.0, 200.0), // exactly page_bottom -> dropped (< bottom)
7672            glyph(5, 32.0, 250.0), // below -> dropped
7673        ];
7674
7675        let item = clip_text_item(&glyphs, FontHash::from_hash(9), 16.0, opaque(), clip, 100.0, 200.0)
7676            .expect("some glyphs are on the page");
7677        match item {
7678            DisplayListItem::Text { glyphs: kept, clip_rect, source_node_index, .. } => {
7679                assert_eq!(kept.iter().map(|g| g.index).collect::<Vec<_>>(), vec![2, 3]);
7680                // Kept glyphs are rebased to page-relative Y.
7681                assert_eq!(kept[0].point.y, 0.0);
7682                assert_eq!(kept[1].point.y, 50.0);
7683                // X is never touched.
7684                assert_eq!(kept[0].point.x, 8.0);
7685                assert_eq!(clip_rect.into_inner().origin.y, -10.0);
7686                assert_eq!(source_node_index, None, "the paginated copy loses its source node");
7687            }
7688            other => panic!("expected Text, got {other:?}"),
7689        }
7690    }
7691
7692    #[test]
7693    fn clip_text_item_returns_none_when_nothing_survives() {
7694        let clip = rect(0.0, 90.0, 200.0, 120.0);
7695        // The clip rect overlaps the page but no glyph baseline does.
7696        let outside = vec![glyph(1, 0.0, 95.0), glyph(2, 8.0, 400.0)];
7697        assert!(clip_text_item(&outside, FontHash::from_hash(1), 16.0, opaque(), clip, 100.0, 200.0).is_none());
7698
7699        // An empty glyph run is dropped too.
7700        assert!(clip_text_item(&[], FontHash::from_hash(1), 16.0, opaque(), clip, 100.0, 200.0).is_none());
7701
7702        // A clip rect entirely off the page short-circuits before glyph filtering.
7703        assert!(clip_text_item(
7704            &[glyph(1, 0.0, 150.0)], FontHash::from_hash(1), 16.0, opaque(),
7705            rect(0.0, 0.0, 10.0, 10.0), 100.0, 200.0
7706        ).is_none());
7707
7708        // NaN glyph baselines never satisfy either bound -> filtered out -> None.
7709        let nan_glyphs = vec![glyph(1, 0.0, f32::NAN)];
7710        assert!(clip_text_item(&nan_glyphs, FontHash::from_hash(1), 16.0, opaque(), clip, 100.0, 200.0).is_none());
7711    }
7712
7713    #[test]
7714    fn clip_border_item_hides_the_bottom_border_when_clipped_at_the_bottom() {
7715        let (top, bottom) = (100.0f32, 200.0f32);
7716        // Spans past the bottom of the page.
7717        let original = rect(0.0, 50.0, 100.0, 200.0);
7718        let item = clip_border_item(original, all_widths(), no_colors(), all_styles(), zero_style_radius(), top, bottom)
7719            .expect("overlaps the page");
7720        match item {
7721            DisplayListItem::Border { widths, .. } => {
7722                assert!(widths.bottom.is_none(), "a border cut by the page edge must not draw its bottom rule");
7723                assert!(widths.left.is_some() && widths.right.is_some(), "side borders survive");
7724            }
7725            other => panic!("expected Border, got {other:?}"),
7726        }
7727    }
7728
7729    #[test]
7730    fn adjust_border_widths_keeps_every_side_for_a_fully_contained_border() {
7731        let (top, bottom) = (100.0f32, 200.0f32);
7732        let original = rect(0.0, 110.0, 100.0, 50.0); // strictly inside the page
7733        let clipped = clip_rect_bounds(original, top, bottom).unwrap();
7734        assert_eq!(clipped, rect(0.0, 10.0, 100.0, 50.0), "unclipped => only rebased");
7735
7736        let widths = adjust_border_widths_for_clipping(all_widths(), original, clipped, top, bottom);
7737        assert!(widths.top.is_some());
7738        assert!(widths.bottom.is_some());
7739        assert!(widths.left.is_some());
7740        assert!(widths.right.is_some());
7741    }
7742
7743    #[test]
7744    fn adjust_border_widths_with_nan_page_bounds_does_not_panic() {
7745        let original = rect(0.0, 0.0, 10.0, 10.0);
7746        let clipped = rect(0.0, 0.0, 10.0, 10.0);
7747        let w = adjust_border_widths_for_clipping(all_widths(), original, clipped, f32::NAN, f32::NAN);
7748        // Every NaN comparison is false, so nothing is hidden — defined and total.
7749        assert!(w.top.is_some() && w.bottom.is_some());
7750    }
7751
7752    #[test]
7753    fn clip_and_offset_display_item_drops_state_management_commands() {
7754        // Pagination cannot re-derive a clip/scroll/stacking stack per page, so those
7755        // items are deliberately dropped. Pin it so a change is visible.
7756        for item in [
7757            DisplayListItem::PushClip { bounds: rect(0.0, 120.0, 10.0, 10.0).into(), border_radius: BorderRadius::default() },
7758            DisplayListItem::PopClip,
7759            DisplayListItem::PushScrollFrame {
7760                clip_bounds: rect(0.0, 120.0, 10.0, 10.0).into(),
7761                content_size: LogicalSize::new(10.0, 10.0),
7762                scroll_id: 1,
7763            },
7764            DisplayListItem::PopScrollFrame,
7765            DisplayListItem::PushStackingContext { z_index: 0, bounds: rect(0.0, 120.0, 10.0, 10.0).into() },
7766            DisplayListItem::PopStackingContext,
7767            DisplayListItem::PopOpacity,
7768            DisplayListItem::PopTextShadow,
7769            DisplayListItem::VirtualViewPlaceholder {
7770                node_id: NodeId::ZERO,
7771                bounds: rect(0.0, 120.0, 10.0, 10.0).into(),
7772                clip_rect: rect(0.0, 120.0, 10.0, 10.0).into(),
7773            },
7774        ] {
7775            assert!(
7776                clip_and_offset_display_item(&item, 100.0, 200.0).is_none(),
7777                "{item:?} must be dropped by the paginator"
7778            );
7779        }
7780    }
7781
7782    #[test]
7783    fn clip_and_offset_display_item_dispatches_drawing_items() {
7784        let on_page = DisplayListItem::Rect {
7785            bounds: rect(0.0, 150.0, 10.0, 10.0).into(),
7786            color: opaque(),
7787            border_radius: BorderRadius::default(),
7788        };
7789        let clipped = clip_and_offset_display_item(&on_page, 100.0, 200.0).expect("on page");
7790        assert_eq!(clipped.bounds(), Some(rect(0.0, 50.0, 10.0, 10.0)));
7791
7792        let off_page = DisplayListItem::Rect {
7793            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
7794            color: opaque(),
7795            border_radius: BorderRadius::default(),
7796        };
7797        assert!(clip_and_offset_display_item(&off_page, 100.0, 200.0).is_none());
7798
7799        // Gradients use a plain bounds test (no sub-item filtering).
7800        let grad = DisplayListItem::LinearGradient {
7801            bounds: rect(0.0, 150.0, 10.0, 10.0).into(),
7802            gradient: LinearGradient::default(),
7803            border_radius: BorderRadius::default(),
7804        };
7805        assert!(clip_and_offset_display_item(&grad, 100.0, 200.0).is_some());
7806        assert!(clip_and_offset_display_item(&grad, 1000.0, 2000.0).is_none());
7807    }
7808
7809    // ---------------------------------------------------------------------
7810    // get_display_item_bounds / offset_display_item_y / heights
7811    // ---------------------------------------------------------------------
7812
7813    #[test]
7814    fn get_display_item_bounds_mirrors_item_bounds() {
7815        let r = rect(1.0, 2.0, 3.0, 4.0);
7816        let item = DisplayListItem::Rect { bounds: r.into(), color: opaque(), border_radius: BorderRadius::default() };
7817        assert_eq!(get_display_item_bounds(&item), Some(WindowLogicalRect::from(r)));
7818        assert_eq!(get_display_item_bounds(&DisplayListItem::PopClip), None);
7819    }
7820
7821    #[test]
7822    fn offset_display_item_y_zero_offset_is_a_pure_clone() {
7823        let item = text_item(Some(3), rect(0.0, 10.0, 100.0, 20.0), vec![glyph(1, 5.0, 15.0)]);
7824        let same = offset_display_item_y(&item, 0.0);
7825        assert!(item.is_visually_equal(&same));
7826        assert_eq!(same.bounds(), item.bounds());
7827    }
7828
7829    #[test]
7830    fn offset_display_item_y_moves_glyphs_and_clip_together() {
7831        let item = text_item(Some(3), rect(0.0, 10.0, 100.0, 20.0), vec![glyph(1, 5.0, 15.0), glyph(2, 13.0, 15.0)]);
7832        match offset_display_item_y(&item, -10.0) {
7833            DisplayListItem::Text { glyphs, clip_rect, .. } => {
7834                assert_eq!(clip_rect.into_inner(), rect(0.0, 0.0, 100.0, 20.0));
7835                assert_eq!(glyphs[0].point.y, 5.0);
7836                assert_eq!(glyphs[1].point.y, 5.0);
7837                assert_eq!(glyphs[0].point.x, 5.0, "X must not move");
7838            }
7839            other => panic!("expected Text, got {other:?}"),
7840        }
7841    }
7842
7843    #[test]
7844    fn offset_display_item_y_with_nonfinite_offsets_does_not_panic() {
7845        let item = DisplayListItem::Rect {
7846            bounds: rect(0.0, 10.0, 5.0, 5.0).into(),
7847            color: opaque(),
7848            border_radius: BorderRadius::default(),
7849        };
7850        assert!(offset_display_item_y(&item, f32::NAN).bounds().unwrap().origin.y.is_nan());
7851        assert!(offset_display_item_y(&item, f32::INFINITY).bounds().unwrap().origin.y.is_infinite());
7852        assert!(offset_display_item_y(&item, f32::MIN).bounds().unwrap().origin.y.is_finite());
7853    }
7854
7855    #[test]
7856    fn calculate_display_list_height_ignores_hairline_items() {
7857        assert_eq!(calculate_display_list_height(&DisplayList::default()), 0.0);
7858
7859        // Items thinner than 0.1px do not contribute (they are not "visible content").
7860        let dl = list_of(vec![
7861            DisplayListItem::Rect {
7862                bounds: rect(0.0, 5000.0, 10.0, 0.05).into(),
7863                color: opaque(),
7864                border_radius: BorderRadius::default(),
7865            },
7866            DisplayListItem::Rect {
7867                bounds: rect(0.0, 10.0, 10.0, 40.0).into(),
7868                color: opaque(),
7869                border_radius: BorderRadius::default(),
7870            },
7871        ]);
7872        assert_eq!(calculate_display_list_height(&dl), 50.0);
7873    }
7874
7875    #[test]
7876    fn calculate_display_list_height_never_returns_negative_or_nan() {
7877        // Negative and NaN geometry must floor at 0.0 rather than poison the height
7878        // (a NaN height would panic the paginator's `partial_cmp().unwrap()` sort).
7879        let dl = list_of(vec![
7880            DisplayListItem::Rect {
7881                bounds: rect(0.0, -500.0, 10.0, 100.0).into(),
7882                color: opaque(),
7883                border_radius: BorderRadius::default(),
7884            },
7885            DisplayListItem::Rect {
7886                bounds: rect(0.0, f32::NAN, 10.0, f32::NAN).into(),
7887                color: opaque(),
7888                border_radius: BorderRadius::default(),
7889            },
7890            DisplayListItem::PopClip, // no bounds at all
7891        ]);
7892        let h = calculate_display_list_height(&dl);
7893        assert!(!h.is_nan(), "a NaN total height would panic the page-break sort");
7894        assert_eq!(h, 0.0);
7895    }
7896
7897    // ---------------------------------------------------------------------
7898    // get_scroll_id
7899    // ---------------------------------------------------------------------
7900
7901    #[test]
7902    fn get_scroll_id_maps_node_index_and_collides_none_with_node_zero() {
7903        assert_eq!(get_scroll_id(None), 0);
7904        assert_eq!(get_scroll_id(Some(NodeId::new(5))), 5);
7905        assert_eq!(get_scroll_id(Some(NodeId::new(usize::MAX))), usize::MAX as u64);
7906        // NOTE: the "no node" sentinel and the root node both map to 0.
7907        assert_eq!(get_scroll_id(Some(NodeId::ZERO)), get_scroll_id(None));
7908    }
7909
7910    // ---------------------------------------------------------------------
7911    // SlicerConfig
7912    // ---------------------------------------------------------------------
7913
7914    #[test]
7915    fn slicer_config_builders_set_the_fields_they_name() {
7916        let simple = SlicerConfig::simple(800.0);
7917        assert_eq!(simple.page_content_height, 800.0);
7918        assert_eq!(simple.page_gap, 0.0);
7919        assert!(simple.allow_clipping);
7920        assert_eq!(simple.page_width, DEFAULT_A4_WIDTH_PT);
7921        assert_eq!(simple.page_slot_height(), 800.0);
7922
7923        let gapped = SlicerConfig::with_gap(800.0, 40.0);
7924        assert_eq!(gapped.page_gap, 40.0);
7925        assert_eq!(gapped.page_slot_height(), 840.0);
7926
7927        let wide = SlicerConfig::simple(800.0).with_page_width(1000.0);
7928        assert_eq!(wide.page_width, 1000.0);
7929        assert_eq!(wide.page_content_height, 800.0, "with_page_width must not disturb the height");
7930    }
7931
7932    #[test]
7933    fn slicer_config_builders_accept_extreme_values() {
7934        for h in [0.0, -100.0, f32::MAX, f32::INFINITY, f32::NAN] {
7935            let c = SlicerConfig::simple(h);
7936            assert_eq!(c.page_content_height.to_bits(), h.to_bits());
7937            let _ = c.page_slot_height();
7938            let _ = c.page_for_y(10.0);
7939            let _ = c.page_bounds(0);
7940        }
7941        // NaN gap propagates into the slot height without panicking.
7942        assert!(SlicerConfig::with_gap(100.0, f32::NAN).page_slot_height().is_nan());
7943    }
7944
7945    #[test]
7946    fn page_for_y_basic_and_boundary() {
7947        let c = SlicerConfig::simple(100.0);
7948        assert_eq!(c.page_for_y(0.0), 0);
7949        assert_eq!(c.page_for_y(99.999), 0);
7950        assert_eq!(c.page_for_y(100.0), 1, "the page boundary belongs to the next page");
7951        assert_eq!(c.page_for_y(250.0), 2);
7952
7953        // The gap counts towards the slot: page 1 starts at 120, not 100.
7954        let g = SlicerConfig::with_gap(100.0, 20.0);
7955        assert_eq!(g.page_for_y(119.0), 0);
7956        assert_eq!(g.page_for_y(120.0), 1);
7957    }
7958
7959    #[test]
7960    fn page_for_y_saturates_instead_of_wrapping_or_trapping() {
7961        let c = SlicerConfig::simple(100.0);
7962        // Negative Y floors to a negative page; the f32->usize cast SATURATES to 0
7963        // (Rust >= 1.45), it does not wrap to usize::MAX.
7964        assert_eq!(c.page_for_y(-1.0), 0);
7965        assert_eq!(c.page_for_y(-1e30), 0);
7966        assert_eq!(c.page_for_y(f32::NEG_INFINITY), 0);
7967        // NaN saturates to 0 as well.
7968        assert_eq!(c.page_for_y(f32::NAN), 0);
7969        // Enormous Y saturates to usize::MAX rather than overflowing.
7970        assert_eq!(c.page_for_y(f32::INFINITY), usize::MAX);
7971        assert_eq!(c.page_for_y(f32::MAX), usize::MAX);
7972    }
7973
7974    #[test]
7975    fn page_for_y_with_a_nonpositive_slot_is_always_page_zero() {
7976        // Guard against a division by zero / infinite page index.
7977        assert_eq!(SlicerConfig::default().page_for_y(f32::MAX), 0);
7978        assert_eq!(SlicerConfig::simple(0.0).page_for_y(500.0), 0);
7979        assert_eq!(SlicerConfig::with_gap(100.0, -100.0).page_for_y(500.0), 0, "slot == 0");
7980        assert_eq!(SlicerConfig::with_gap(100.0, -500.0).page_for_y(500.0), 0, "slot < 0");
7981        // A NaN slot is not > 0.0, but it also fails the `<= 0.0` guard; the cast still
7982        // saturates NaN to 0 rather than trapping.
7983        assert_eq!(SlicerConfig::simple(f32::NAN).page_for_y(500.0), 0);
7984    }
7985
7986    #[test]
7987    fn page_bounds_are_contiguous_without_a_gap_and_spaced_with_one() {
7988        let c = SlicerConfig::simple(100.0);
7989        assert_eq!(c.page_bounds(0), (0.0, 100.0));
7990        assert_eq!(c.page_bounds(1), (100.0, 200.0));
7991        assert_eq!(c.page_bounds(3), (300.0, 400.0));
7992
7993        let g = SlicerConfig::with_gap(100.0, 20.0);
7994        assert_eq!(g.page_bounds(0), (0.0, 100.0));
7995        assert_eq!(g.page_bounds(2), (240.0, 340.0), "the gap is dead space between pages");
7996
7997        // page_for_y and page_bounds must agree.
7998        for page in 0..5usize {
7999            let (start, _end) = g.page_bounds(page);
8000            assert_eq!(g.page_for_y(start), page);
8001        }
8002    }
8003
8004    #[test]
8005    fn page_bounds_at_extreme_indices_do_not_panic() {
8006        let c = SlicerConfig::simple(100.0);
8007        let (start, end) = c.page_bounds(usize::MAX);
8008        assert!(start.is_finite() && end.is_finite(), "usize::MAX as f32 * 100 stays in f32 range");
8009        assert!(start > 0.0);
8010
8011        // A zero-height config collapses every page onto (0, 0).
8012        assert_eq!(SlicerConfig::default().page_bounds(9999), (0.0, 0.0));
8013    }
8014
8015    // ---------------------------------------------------------------------
8016    // calculate_page_break_positions
8017    // ---------------------------------------------------------------------
8018
8019    #[test]
8020    fn page_breaks_for_an_empty_or_zero_height_list_is_a_single_page() {
8021        let pages = calculate_page_break_positions(&DisplayList::default(), 100.0, 100.0);
8022        assert_eq!(pages, vec![(0.0, 100.0)], "an empty document still has one page");
8023
8024        // first_page_height <= 0 short-circuits to a single page too.
8025        let dl = list_of(vec![DisplayListItem::Rect {
8026            bounds: rect(0.0, 0.0, 10.0, 250.0).into(),
8027            color: opaque(),
8028            border_radius: BorderRadius::default(),
8029        }]);
8030        assert_eq!(calculate_page_break_positions(&dl, 0.0, 100.0), vec![(0.0, 250.0)]);
8031        assert_eq!(calculate_page_break_positions(&dl, -50.0, 100.0), vec![(0.0, 250.0)]);
8032    }
8033
8034    #[test]
8035    fn page_breaks_split_at_regular_intervals() {
8036        let dl = list_of(vec![DisplayListItem::Rect {
8037            bounds: rect(0.0, 0.0, 10.0, 250.0).into(),
8038            color: opaque(),
8039            border_radius: BorderRadius::default(),
8040        }]);
8041        let pages = calculate_page_break_positions(&dl, 100.0, 100.0);
8042        assert_eq!(pages, vec![(0.0, 100.0), (100.0, 200.0), (200.0, 250.0)]);
8043
8044        // Pages must tile the document with no gaps and no overlaps.
8045        for w in pages.windows(2) {
8046            assert_eq!(w[0].1, w[1].0);
8047        }
8048        assert_eq!(pages.first().unwrap().0, 0.0);
8049        assert_eq!(pages.last().unwrap().1, 250.0);
8050    }
8051
8052    #[test]
8053    fn page_breaks_honour_forced_breaks_and_merge_near_duplicates() {
8054        let mut dl = list_of(vec![DisplayListItem::Rect {
8055            bounds: rect(0.0, 0.0, 10.0, 250.0).into(),
8056            color: opaque(),
8057            border_radius: BorderRadius::default(),
8058        }]);
8059        dl.forced_page_breaks = vec![50.0];
8060        assert_eq!(
8061            calculate_page_break_positions(&dl, 100.0, 100.0),
8062            vec![(0.0, 50.0), (50.0, 100.0), (100.0, 200.0), (200.0, 250.0)]
8063        );
8064
8065        // A forced break within 1px of a regular break is merged, not duplicated
8066        // (a duplicate would emit a zero-height page).
8067        dl.forced_page_breaks = vec![100.5];
8068        let pages = calculate_page_break_positions(&dl, 100.0, 100.0);
8069        assert_eq!(pages, vec![(0.0, 100.0), (100.0, 200.0), (200.0, 250.0)]);
8070        assert!(pages.iter().all(|(s, e)| e > s), "no zero-height pages");
8071    }
8072
8073    #[test]
8074    fn page_breaks_ignore_out_of_range_and_nan_forced_breaks() {
8075        // A NaN forced break MUST be filtered before the sort — the sort uses
8076        // `partial_cmp().unwrap()` and would panic on a NaN.
8077        let mut dl = list_of(vec![DisplayListItem::Rect {
8078            bounds: rect(0.0, 0.0, 10.0, 250.0).into(),
8079            color: opaque(),
8080            border_radius: BorderRadius::default(),
8081        }]);
8082        dl.forced_page_breaks = vec![f32::NAN, -10.0, 0.0, 250.0, 9999.0, f32::INFINITY];
8083        let pages = calculate_page_break_positions(&dl, 100.0, 100.0);
8084        // Only the regular interval breaks survive.
8085        assert_eq!(pages, vec![(0.0, 100.0), (100.0, 200.0), (200.0, 250.0)]);
8086    }
8087
8088    #[test]
8089    fn page_breaks_with_a_nan_first_page_height_still_yields_one_page() {
8090        let dl = list_of(vec![DisplayListItem::Rect {
8091            bounds: rect(0.0, 0.0, 10.0, 250.0).into(),
8092            color: opaque(),
8093            border_radius: BorderRadius::default(),
8094        }]);
8095        // `while NaN < total` is immediately false, so no regular breaks are produced
8096        // and the whole document lands on one page. Defined, and NOT a hang.
8097        let pages = calculate_page_break_positions(&dl, f32::NAN, 100.0);
8098        assert_eq!(pages, vec![(0.0, 250.0)]);
8099    }
8100
8101    // ---------------------------------------------------------------------
8102    // paginate_display_list_with_slicer_and_breaks (public entry point)
8103    // ---------------------------------------------------------------------
8104
8105    #[test]
8106    fn paginate_with_a_degenerate_page_height_returns_the_list_unsliced() {
8107        let rr = RendererResources::default();
8108        let dl = list_of(vec![DisplayListItem::Rect {
8109            bounds: rect(0.0, 0.0, 10.0, 250.0).into(),
8110            color: opaque(),
8111            border_radius: BorderRadius::default(),
8112        }]);
8113
8114        for h in [0.0, -1.0, f32::MAX, f32::INFINITY] {
8115            let pages = paginate_display_list_with_slicer_and_breaks(dl.clone(), &SlicerConfig::simple(h), &rr)
8116                .expect("degenerate page height must not error");
8117            assert_eq!(pages.len(), 1, "page height {h} => no slicing");
8118            assert_eq!(pages[0].items.len(), 1);
8119        }
8120    }
8121
8122    #[test]
8123    fn paginate_slices_content_into_pages() {
8124        let rr = RendererResources::default();
8125        let dl = list_of(vec![DisplayListItem::Rect {
8126            bounds: rect(0.0, 0.0, 10.0, 250.0).into(),
8127            color: opaque(),
8128            border_radius: BorderRadius::default(),
8129        }]);
8130        let pages = paginate_display_list_with_slicer_and_breaks(dl, &SlicerConfig::simple(100.0), &rr)
8131            .expect("pagination succeeds");
8132        assert_eq!(pages.len(), 3);
8133        // The tall rect is clipped onto every page it crosses, always rebased to y=0.
8134        for page in &pages {
8135            let r = page.items.iter().find_map(DisplayListItem::bounds).expect("each page keeps a slice");
8136            assert_eq!(r.origin.y, 0.0);
8137            assert!(r.size.height > 0.0 && r.size.height <= 100.0);
8138        }
8139    }
8140
8141    // ---------------------------------------------------------------------
8142    // DisplayList::patch_text_glyphs
8143    // ---------------------------------------------------------------------
8144
8145    #[test]
8146    fn patch_text_glyphs_replaces_matching_runs_and_returns_their_damage() {
8147        let clip = rect(10.0, 20.0, 100.0, 30.0);
8148        let mut dl = list_of(vec![text_item(Some(7), clip, vec![glyph(1, 0.0, 0.0)])]);
8149
8150        let damage = dl.patch_text_glyphs(7, &[vec![glyph(42, 5.0, 6.0), glyph(43, 13.0, 6.0)]]);
8151        assert_eq!(damage, Some(clip), "damage covers the run's clip rect");
8152        match &dl.items[0] {
8153            DisplayListItem::Text { glyphs, .. } => {
8154                assert_eq!(glyphs.iter().map(|g| g.index).collect::<Vec<_>>(), vec![42, 43]);
8155            }
8156            other => panic!("expected Text, got {other:?}"),
8157        }
8158    }
8159
8160    #[test]
8161    fn patch_text_glyphs_unions_damage_across_runs() {
8162        let a = rect(0.0, 0.0, 50.0, 10.0);
8163        let b = rect(100.0, 200.0, 50.0, 10.0);
8164        let mut dl = list_of(vec![
8165            text_item(Some(3), a, vec![glyph(1, 0.0, 0.0)]),
8166            text_item(Some(3), b, vec![glyph(2, 0.0, 0.0)]),
8167        ]);
8168
8169        let damage = dl.patch_text_glyphs(3, &[vec![glyph(9, 0.0, 0.0)], vec![glyph(10, 0.0, 0.0)]])
8170            .expect("both runs matched");
8171        // The union spans from a's top-left to b's bottom-right.
8172        assert_eq!(damage, rect(0.0, 0.0, 150.0, 210.0));
8173    }
8174
8175    #[test]
8176    fn patch_text_glyphs_returns_none_when_nothing_matches() {
8177        let clip = rect(0.0, 0.0, 10.0, 10.0);
8178        let mut dl = list_of(vec![
8179            text_item(Some(7), clip, vec![glyph(1, 0.0, 0.0)]),
8180            text_item(None, clip, vec![glyph(2, 0.0, 0.0)]), // no source node => never patched
8181            DisplayListItem::PopClip,
8182        ]);
8183
8184        assert_eq!(dl.patch_text_glyphs(8, &[vec![glyph(9, 0.0, 0.0)]]), None, "wrong node index");
8185        assert_eq!(dl.patch_text_glyphs(usize::MAX, &[vec![glyph(9, 0.0, 0.0)]]), None);
8186        assert_eq!(dl.patch_text_glyphs(7, &[]), None, "no replacement runs => nothing to do");
8187
8188        // Nothing was mutated by any of the failed patches.
8189        match &dl.items[0] {
8190            DisplayListItem::Text { glyphs, .. } => assert_eq!(glyphs[0].index, 1),
8191            other => panic!("expected Text, got {other:?}"),
8192        }
8193    }
8194
8195    #[test]
8196    fn patch_text_glyphs_stops_when_it_runs_out_of_replacement_runs() {
8197        let clip = rect(0.0, 0.0, 10.0, 10.0);
8198        let mut dl = list_of(vec![
8199            text_item(Some(1), clip, vec![glyph(100, 0.0, 0.0)]),
8200            text_item(Some(1), clip, vec![glyph(200, 0.0, 0.0)]),
8201            text_item(Some(1), clip, vec![glyph(300, 0.0, 0.0)]),
8202        ]);
8203
8204        // Only one replacement run for three matching items: patch the first, leave the rest.
8205        assert!(dl.patch_text_glyphs(1, &[vec![glyph(999, 0.0, 0.0)]]).is_some());
8206        let ids: Vec<u32> = dl.items.iter().map(|i| match i {
8207            DisplayListItem::Text { glyphs, .. } => glyphs[0].index,
8208            other => panic!("expected Text, got {other:?}"),
8209        }).collect();
8210        assert_eq!(ids, vec![999, 200, 300]);
8211    }
8212
8213    #[test]
8214    fn patch_text_glyphs_accepts_an_empty_replacement_run() {
8215        let clip = rect(0.0, 0.0, 10.0, 10.0);
8216        let mut dl = list_of(vec![text_item(Some(0), clip, vec![glyph(1, 0.0, 0.0)])]);
8217        // An empty run erases the glyphs but still reports damage over the old area.
8218        assert_eq!(dl.patch_text_glyphs(0, &[Vec::new()]), Some(clip));
8219        match &dl.items[0] {
8220            DisplayListItem::Text { glyphs, .. } => assert!(glyphs.is_empty()),
8221            other => panic!("expected Text, got {other:?}"),
8222        }
8223    }
8224
8225    // ---------------------------------------------------------------------
8226    // DisplayList::compute_text_damage_rect (text_layout)
8227    // ---------------------------------------------------------------------
8228
8229    #[cfg(feature = "text_layout")]
8230    #[test]
8231    fn compute_text_damage_rect_empty_inputs_yield_the_zero_rect() {
8232        let r = DisplayList::compute_text_damage_rect(&[], &[], LogicalPosition::new(100.0, 200.0), 0);
8233        assert_eq!(r, LogicalRect::zero(), "no items => no damage (not a MAX..MIN garbage rect)");
8234    }
8235
8236    #[cfg(feature = "text_layout")]
8237    #[test]
8238    fn compute_text_damage_rect_translates_by_the_container_origin() {
8239        let old = vec![positioned(0, 10.0, 20.0, 30.0, 40.0)];
8240        let r = DisplayList::compute_text_damage_rect(&old, &[], LogicalPosition::new(100.0, 200.0), 0);
8241        assert_eq!(r, rect(110.0, 220.0, 30.0, 40.0));
8242    }
8243
8244    #[cfg(feature = "text_layout")]
8245    #[test]
8246    fn compute_text_damage_rect_unions_old_and_new() {
8247        let old = vec![positioned(0, 0.0, 0.0, 10.0, 10.0)];
8248        let new = vec![positioned(0, 90.0, 190.0, 10.0, 10.0)];
8249        let r = DisplayList::compute_text_damage_rect(&old, &new, LogicalPosition::zero(), 0);
8250        assert_eq!(r, rect(0.0, 0.0, 100.0, 200.0), "damage must cover both the before and after ink");
8251    }
8252
8253    #[cfg(feature = "text_layout")]
8254    #[test]
8255    fn compute_text_damage_rect_skips_lines_before_the_affected_line() {
8256        let items = vec![
8257            positioned(0, 0.0, 0.0, 1000.0, 10.0),  // line 0 — untouched, must be excluded
8258            positioned(5, 10.0, 50.0, 20.0, 10.0),  // line 5 — the reflowed line
8259        ];
8260        let r = DisplayList::compute_text_damage_rect(&items, &items, LogicalPosition::zero(), 5);
8261        assert_eq!(r, rect(10.0, 50.0, 20.0, 10.0));
8262
8263        // An affected_line past every line damages nothing.
8264        let none = DisplayList::compute_text_damage_rect(&items, &items, LogicalPosition::zero(), usize::MAX);
8265        assert_eq!(none, LogicalRect::zero());
8266    }
8267
8268    #[cfg(feature = "text_layout")]
8269    #[test]
8270    fn compute_text_damage_rect_nan_positions_are_ignored_not_propagated() {
8271        // f32::min/max drop NaN operands, so a NaN-positioned item contributes nothing.
8272        let nan_only = vec![positioned(0, f32::NAN, f32::NAN, 10.0, 10.0)];
8273        let r = DisplayList::compute_text_damage_rect(&nan_only, &[], LogicalPosition::zero(), 0);
8274        assert_eq!(r, LogicalRect::zero(), "an all-NaN run must not produce a NaN damage rect");
8275
8276        // Mixed: min/max are applied PER AXIS, so the half-NaN item is not dropped
8277        // wholesale -- its NaN x contributes nothing, but its finite y=0.0 still
8278        // widens the union. The result is a safe non-NaN superset: y spans [0,15].
8279        let mixed = vec![positioned(0, f32::NAN, 0.0, 10.0, 10.0), positioned(0, 5.0, 5.0, 10.0, 10.0)];
8280        let r = DisplayList::compute_text_damage_rect(&mixed, &[], LogicalPosition::zero(), 0);
8281        assert!(!r.origin.x.is_nan() && !r.size.width.is_nan());
8282        assert_eq!(r, rect(5.0, 0.0, 10.0, 15.0));
8283    }
8284
8285    // ---------------------------------------------------------------------
8286    // item_center_on_page / transform_items_to_page_coords (text_layout)
8287    // ---------------------------------------------------------------------
8288
8289    #[cfg(feature = "text_layout")]
8290    #[test]
8291    fn item_center_on_page_uses_the_item_midpoint_half_open() {
8292        let item = positioned(0, 0.0, 0.0, 10.0, 20.0); // height 20 => center at +10
8293        // layout_origin_y 90 => absolute y 90, center 100 == page_top => on page.
8294        assert!(item_center_on_page(&item, 90.0, 100.0, 200.0));
8295        // center 99.99 => just above the page.
8296        assert!(!item_center_on_page(&item, 89.99, 100.0, 200.0));
8297        // center exactly page_bottom => OFF page (half-open interval).
8298        assert!(!item_center_on_page(&item, 190.0, 100.0, 200.0));
8299        assert!(item_center_on_page(&item, 189.0, 100.0, 200.0));
8300    }
8301
8302    #[cfg(feature = "text_layout")]
8303    #[test]
8304    fn item_center_on_page_with_nonfinite_values_is_false_not_a_panic() {
8305        let item = positioned(0, 0.0, f32::NAN, 10.0, 20.0);
8306        assert!(!item_center_on_page(&item, 0.0, 100.0, 200.0));
8307
8308        let inf = positioned(0, 0.0, f32::INFINITY, 10.0, 20.0);
8309        assert!(!item_center_on_page(&inf, 0.0, 100.0, 200.0));
8310
8311        let ok = positioned(0, 0.0, 150.0, 10.0, 20.0);
8312        assert!(!item_center_on_page(&ok, 0.0, f32::NAN, f32::NAN));
8313    }
8314
8315    #[cfg(feature = "text_layout")]
8316    #[test]
8317    fn transform_items_to_page_coords_rebases_and_reports_extents() {
8318        let items = vec![
8319            positioned(0, 0.0, 10.0, 30.0, 20.0),
8320            positioned(1, 5.0, 40.0, 50.0, 20.0),
8321        ];
8322        // layout starts at y=100; the page starts at y=100, so new_origin_y = 0.
8323        let (out, min_y, max_y, max_width) = transform_items_to_page_coords(items, 100.0, 100.0, 0.0);
8324        assert_eq!(out.len(), 2);
8325        assert_eq!(out[0].position.y, 10.0);
8326        assert_eq!(out[1].position.y, 40.0);
8327        assert_eq!(out[0].position.x, 0.0, "X is never rebased");
8328        assert_eq!(min_y, 10.0);
8329        assert_eq!(max_y, 60.0);
8330        assert_eq!(max_width, 55.0, "max_width is position.x + item width");
8331    }
8332
8333    #[cfg(feature = "text_layout")]
8334    #[test]
8335    fn transform_items_to_page_coords_on_an_empty_input_returns_sentinel_extents() {
8336        let (out, min_y, max_y, max_width) = transform_items_to_page_coords(Vec::new(), 0.0, 0.0, 0.0);
8337        assert!(out.is_empty());
8338        // The seeds are returned untouched — callers MUST NOT treat these as real bounds.
8339        assert_eq!(min_y, f32::MAX);
8340        assert_eq!(max_y, f32::MIN);
8341        assert_eq!(max_width, 0.0);
8342    }
8343
8344    // ---------------------------------------------------------------------
8345    // DisplayList::to_debug_json
8346    // ---------------------------------------------------------------------
8347
8348    #[test]
8349    fn to_debug_json_on_an_empty_list_reports_a_balanced_zero_item_list() {
8350        let json = DisplayList::default().to_debug_json();
8351        assert!(json.contains("\"total_items\": 0"));
8352        assert!(json.contains("\"balanced\": true"));
8353        assert!(json.contains("\"final_clip_depth\": 0"));
8354    }
8355
8356    #[test]
8357    fn to_debug_json_flags_an_unbalanced_clip_stack() {
8358        // A PushClip with no PopClip must be reported as unbalanced.
8359        let dl = list_of(vec![DisplayListItem::PushClip {
8360            bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
8361            border_radius: BorderRadius::default(),
8362        }]);
8363        let json = dl.to_debug_json();
8364        assert!(json.contains("\"final_clip_depth\": 1"));
8365        assert!(json.contains("\"balanced\": false"));
8366
8367        // A stray PopClip drives the depth negative — also unbalanced, not a panic.
8368        let json = list_of(vec![DisplayListItem::PopClip]).to_debug_json();
8369        assert!(json.contains("\"final_clip_depth\": -1"));
8370        assert!(json.contains("\"balanced\": false"));
8371    }
8372
8373    #[test]
8374    fn to_debug_json_survives_a_truncated_node_mapping_and_extreme_values() {
8375        // node_mapping shorter than items must not index out of bounds.
8376        let dl = DisplayList {
8377            items: vec![
8378                DisplayListItem::PushClip {
8379                    bounds: rect(f32::NAN, f32::INFINITY, f32::MAX, -1.0).into(),
8380                    border_radius: BorderRadius { top_left: f32::NAN, ..BorderRadius::default() },
8381                },
8382                DisplayListItem::PopClip,
8383                DisplayListItem::PushStackingContext { z_index: i32::MIN, bounds: WindowLogicalRect::zero() },
8384                DisplayListItem::PopStackingContext,
8385                DisplayListItem::PushScrollFrame {
8386                    clip_bounds: WindowLogicalRect::zero(),
8387                    content_size: LogicalSize::new(f32::MAX, f32::MAX),
8388                    scroll_id: u64::MAX,
8389                },
8390                DisplayListItem::PopScrollFrame,
8391                DisplayListItem::PopTextShadow, // exercises the `_ =>` fallback arm
8392            ],
8393            node_mapping: Vec::new(), // deliberately desynced
8394            ..DisplayList::default()
8395        };
8396        let json = dl.to_debug_json();
8397        assert!(json.contains("\"total_items\": 7"));
8398        assert!(json.contains("\"balanced\": true"), "every push is matched by a pop");
8399    }
8400
8401    // ---------------------------------------------------------------------
8402    // apply_text_overflow_ellipsis
8403    // ---------------------------------------------------------------------
8404
8405    #[test]
8406    fn ellipsis_leaves_non_overflowing_text_alone() {
8407        let container = rect(0.0, 0.0, 100.0, 20.0);
8408        let glyphs = vec![glyph(1, 0.0, 10.0), glyph(2, 10.0, 10.0)]; // right edge 18 < 100
8409        let mut dl = list_of(vec![text_item(Some(0), rect(0.0, 0.0, 100.0, 20.0), glyphs.clone())]);
8410
8411        apply_text_overflow_ellipsis(&mut dl, container, "…");
8412        match &dl.items[0] {
8413            DisplayListItem::Text { glyphs: g, .. } => {
8414                assert_eq!(g.len(), glyphs.len());
8415                assert_eq!(g.iter().map(|x| x.index).collect::<Vec<_>>(), vec![1, 2]);
8416            }
8417            other => panic!("expected Text, got {other:?}"),
8418        }
8419    }
8420
8421    #[test]
8422    fn ellipsis_truncates_overflowing_text_and_appends_u2026() {
8423        let container = rect(0.0, 0.0, 50.0, 20.0);
8424        // Glyphs at x = 0,10,20,30,40,50 each 8 wide => right edges 8,18,28,38,48,58.
8425        let glyphs: Vec<_> = (0..6).map(|i| glyph(i + 1, (i as f32) * 10.0, 10.0)).collect();
8426        let mut dl = list_of(vec![text_item(Some(0), rect(0.0, 0.0, 500.0, 20.0), glyphs)]);
8427
8428        apply_text_overflow_ellipsis(&mut dl, container, "…");
8429        match &dl.items[0] {
8430            DisplayListItem::Text { glyphs: g, clip_rect, .. } => {
8431                // font_size 16 => ellipsis width 9.6 => truncation edge 40.4;
8432                // glyph right edges 8/18/28/38 fit, 48 does not.
8433                assert_eq!(g.len(), 5, "4 kept glyphs + 1 ellipsis");
8434                assert_eq!(g.last().unwrap().index, 0x2026, "U+2026 HORIZONTAL ELLIPSIS");
8435                assert_eq!(g[3].index, 4, "the last kept glyph");
8436                // The clip rect is retargeted to the container so nothing spills past it.
8437                assert_eq!(clip_rect.into_inner(), container);
8438            }
8439            other => panic!("expected Text, got {other:?}"),
8440        }
8441    }
8442
8443    #[test]
8444    fn ellipsis_on_a_container_too_narrow_for_any_glyph_leaves_only_the_ellipsis() {
8445        // keep_count == 0 => glyphs.truncate(0) => `glyphs.last()` is None. The fallback
8446        // must anchor the ellipsis to the container origin instead of panicking.
8447        let container = rect(3.0, 4.0, 1.0, 20.0);
8448        let glyphs = vec![glyph(1, 0.0, 10.0), glyph(2, 10.0, 10.0)];
8449        let mut dl = list_of(vec![text_item(Some(0), rect(0.0, 0.0, 500.0, 20.0), glyphs)]);
8450
8451        apply_text_overflow_ellipsis(&mut dl, container, "…");
8452        match &dl.items[0] {
8453            DisplayListItem::Text { glyphs: g, .. } => {
8454                assert_eq!(g.len(), 1);
8455                assert_eq!(g[0].index, 0x2026);
8456                assert_eq!(g[0].point, LogicalPosition::new(3.0, 4.0), "anchored to the container origin");
8457            }
8458            other => panic!("expected Text, got {other:?}"),
8459        }
8460    }
8461
8462    #[test]
8463    fn ellipsis_skips_empty_runs_and_non_text_items() {
8464        let container = rect(0.0, 0.0, 1.0, 20.0);
8465        let mut dl = list_of(vec![
8466            text_item(Some(0), rect(0.0, 0.0, 500.0, 20.0), Vec::new()),
8467            DisplayListItem::PopClip,
8468            DisplayListItem::Rect {
8469                bounds: rect(0.0, 0.0, 900.0, 20.0).into(),
8470                color: opaque(),
8471                border_radius: BorderRadius::default(),
8472            },
8473        ]);
8474        apply_text_overflow_ellipsis(&mut dl, container, "…");
8475        match &dl.items[0] {
8476            DisplayListItem::Text { glyphs, .. } => assert!(glyphs.is_empty(), "an empty run is left alone"),
8477            other => panic!("expected Text, got {other:?}"),
8478        }
8479        assert_eq!(dl.items.len(), 3, "no items added or removed");
8480    }
8481
8482    #[test]
8483    fn ellipsis_with_a_nan_font_size_does_not_panic() {
8484        // A NaN ellipsis width makes every `glyph_right > truncation_edge` comparison
8485        // false, so nothing is truncated — but an ellipsis is still appended.
8486        let container = rect(0.0, 0.0, 50.0, 20.0);
8487        let glyphs = vec![glyph(1, 0.0, 10.0), glyph(2, 100.0, 10.0)];
8488        let mut dl = list_of(vec![DisplayListItem::Text {
8489            glyphs,
8490            font_hash: FontHash::invalid(),
8491            font_size_px: f32::NAN,
8492            color: opaque(),
8493            clip_rect: rect(0.0, 0.0, 500.0, 20.0).into(),
8494            source_node_index: None,
8495        }]);
8496
8497        apply_text_overflow_ellipsis(&mut dl, container, "…");
8498        match &dl.items[0] {
8499            DisplayListItem::Text { glyphs: g, .. } => {
8500                assert_eq!(g.len(), 3, "both glyphs kept + ellipsis");
8501                assert_eq!(g.last().unwrap().index, 0x2026);
8502                assert!(g.last().unwrap().size.width.is_nan());
8503            }
8504            other => panic!("expected Text, got {other:?}"),
8505        }
8506    }
8507
8508    // ---------------------------------------------------------------------
8509    // resolve_clip_path
8510    // ---------------------------------------------------------------------
8511
8512    #[test]
8513    fn resolve_clip_path_none_is_no_clip() {
8514        use azul_css::props::layout::shape::ClipPath;
8515        assert_eq!(resolve_clip_path(&ClipPath::None, rect(0.0, 0.0, 100.0, 100.0)), None);
8516        assert_eq!(resolve_clip_path(&ClipPath::default(), rect(0.0, 0.0, 100.0, 100.0)), None);
8517    }
8518
8519    #[test]
8520    fn resolve_clip_path_inset_shrinks_from_every_edge() {
8521        use azul_css::{corety::OptionF32, props::layout::shape::ClipPath, shape::{CssShape, ShapeInset}};
8522        let path = ClipPath::Shape(CssShape::Inset(ShapeInset {
8523            inset_top: 10.0,
8524            inset_right: 20.0,
8525            inset_bottom: 30.0,
8526            inset_left: 40.0,
8527            border_radius: OptionF32::Some(6.0),
8528        }));
8529        let (r, radius) = resolve_clip_path(&path, rect(100.0, 200.0, 300.0, 400.0)).expect("inset clips");
8530        assert_eq!(r, rect(140.0, 210.0, 240.0, 360.0));
8531        assert_eq!(radius, 6.0);
8532    }
8533
8534    #[test]
8535    fn resolve_clip_path_over_inset_clamps_the_size_to_zero() {
8536        use azul_css::{corety::OptionF32, props::layout::shape::ClipPath, shape::{CssShape, ShapeInset}};
8537        let path = ClipPath::Shape(CssShape::Inset(ShapeInset {
8538            inset_top: 500.0,
8539            inset_right: 500.0,
8540            inset_bottom: 500.0,
8541            inset_left: 500.0,
8542            border_radius: OptionF32::None,
8543        }));
8544        let (r, radius) = resolve_clip_path(&path, rect(0.0, 0.0, 100.0, 100.0)).expect("still returns a rect");
8545        assert_eq!(r.size, LogicalSize::zero(), "insets larger than the box collapse, they do not go negative");
8546        assert_eq!(radius, 0.0, "OptionF32::None => radius 0");
8547    }
8548
8549    #[test]
8550    fn resolve_clip_path_circle_and_ellipse_use_their_bounding_box() {
8551        use azul_css::{props::layout::shape::ClipPath, shape::{CssShape, ShapeCircle, ShapeEllipse, ShapePoint}};
8552
8553        let circle = ClipPath::Shape(CssShape::Circle(ShapeCircle {
8554            center: ShapePoint { x: 50.0, y: 50.0 },
8555            radius: 20.0,
8556        }));
8557        let (r, radius) = resolve_clip_path(&circle, rect(100.0, 100.0, 200.0, 200.0)).expect("circle clips");
8558        assert_eq!(r, rect(130.0, 130.0, 40.0, 40.0), "centre is relative to the node origin");
8559        assert_eq!(radius, 20.0);
8560
8561        let ellipse = ClipPath::Shape(CssShape::Ellipse(ShapeEllipse {
8562            center: ShapePoint { x: 50.0, y: 50.0 },
8563            radius_x: 10.0,
8564            radius_y: 30.0,
8565        }));
8566        let (r, radius) = resolve_clip_path(&ellipse, rect(0.0, 0.0, 100.0, 100.0)).expect("ellipse clips");
8567        assert_eq!(r, rect(40.0, 20.0, 20.0, 60.0));
8568        assert_eq!(radius, 10.0, "the rounding uses the SMALLER of the two radii");
8569    }
8570
8571    #[test]
8572    fn resolve_clip_path_circle_with_a_degenerate_radius_does_not_panic() {
8573        use azul_css::{props::layout::shape::ClipPath, shape::{CssShape, ShapeCircle, ShapePoint}};
8574        for radius in [0.0, -10.0, f32::NAN, f32::INFINITY] {
8575            let path = ClipPath::Shape(CssShape::Circle(ShapeCircle {
8576                center: ShapePoint { x: 0.0, y: 0.0 },
8577                radius,
8578            }));
8579            // The value is passed through unclamped; the contract here is only that it
8580            // returns a value deterministically instead of panicking.
8581            let out = resolve_clip_path(&path, rect(0.0, 0.0, 100.0, 100.0));
8582            assert!(out.is_some(), "radius {radius} must still resolve");
8583        }
8584    }
8585
8586    #[test]
8587    fn resolve_clip_path_polygon_bbox_and_empty_polygon() {
8588        use azul_css::{props::layout::shape::ClipPath, shape::{CssShape, ShapePoint, ShapePolygon}};
8589
8590        // An empty polygon has no bounding box => no clip.
8591        let empty = ClipPath::Shape(CssShape::Polygon(ShapePolygon { points: Vec::new().into() }));
8592        assert_eq!(resolve_clip_path(&empty, rect(0.0, 0.0, 100.0, 100.0)), None);
8593
8594        // A real polygon collapses to its axis-aligned bounding box.
8595        let tri = ClipPath::Shape(CssShape::Polygon(ShapePolygon {
8596            points: vec![
8597                ShapePoint { x: 10.0, y: 90.0 },
8598                ShapePoint { x: 50.0, y: 10.0 },
8599                ShapePoint { x: 90.0, y: 90.0 },
8600            ].into(),
8601        }));
8602        let (r, radius) = resolve_clip_path(&tri, rect(1000.0, 2000.0, 100.0, 100.0)).expect("polygon clips");
8603        assert_eq!(r, rect(1010.0, 2010.0, 80.0, 80.0));
8604        assert_eq!(radius, 0.0, "a polygon bbox is never rounded");
8605    }
8606
8607    #[test]
8608    fn resolve_clip_path_polygon_with_nan_points_collapses_instead_of_panicking() {
8609        use azul_css::{props::layout::shape::ClipPath, shape::{CssShape, ShapePoint, ShapePolygon}};
8610        let nan_poly = ClipPath::Shape(CssShape::Polygon(ShapePolygon {
8611            points: vec![ShapePoint { x: f32::NAN, y: f32::NAN }].into(),
8612        }));
8613        let (r, _) = resolve_clip_path(&nan_poly, rect(0.0, 0.0, 100.0, 100.0)).expect("still resolves");
8614        // f32::min/max drop NaN, leaving the ±INFINITY seeds; the `.max(0.0)` on the size
8615        // keeps the result degenerate-but-finite rather than negative.
8616        assert_eq!(r.size, LogicalSize::zero());
8617    }
8618
8619    #[test]
8620    fn resolve_clip_path_svg_path_is_unsupported_and_does_not_clip() {
8621        use azul_css::{props::layout::shape::ClipPath, shape::{CssShape, ShapePath}};
8622        let path = ClipPath::Shape(CssShape::Path(ShapePath {
8623            data: String::from("M 0 0 L 10 10 Z").into(),
8624        }));
8625        assert_eq!(
8626            resolve_clip_path(&path, rect(0.0, 0.0, 100.0, 100.0)),
8627            None,
8628            "path() clip-paths are not implemented => no clipping (rather than a wrong clip)"
8629        );
8630    }
8631
8632    // ---------------------------------------------------------------------
8633    // apply_clip_path
8634    // ---------------------------------------------------------------------
8635
8636    #[test]
8637    fn apply_clip_path_wraps_the_tail_of_the_list_and_keeps_the_mapping_in_sync() {
8638        let mut dl = list_of(vec![
8639            DisplayListItem::Rect {
8640                bounds: rect(0.0, 0.0, 10.0, 10.0).into(),
8641                color: opaque(),
8642                border_radius: BorderRadius::default(),
8643            },
8644            DisplayListItem::Rect {
8645                bounds: rect(0.0, 0.0, 20.0, 20.0).into(),
8646                color: opaque(),
8647                border_radius: BorderRadius::default(),
8648            },
8649        ]);
8650
8651        apply_clip_path(&mut dl, 1, rect(5.0, 5.0, 50.0, 50.0), 8.0);
8652
8653        assert_eq!(dl.items.len(), 4, "PushClip inserted + PopClip appended");
8654        assert_eq!(dl.items.len(), dl.node_mapping.len(), "node_mapping must stay parallel to items");
8655        assert!(matches!(dl.items[1], DisplayListItem::PushClip { .. }));
8656        assert!(matches!(dl.items[3], DisplayListItem::PopClip));
8657        assert_eq!(dl.node_mapping[1], None);
8658        assert_eq!(dl.node_mapping[3], None);
8659
8660        match &dl.items[1] {
8661            DisplayListItem::PushClip { bounds, border_radius } => {
8662                assert_eq!(bounds.into_inner(), rect(5.0, 5.0, 50.0, 50.0));
8663                // A positive radius is applied uniformly to all four corners.
8664                assert_eq!(border_radius.top_left, 8.0);
8665                assert_eq!(border_radius.bottom_right, 8.0);
8666            }
8667            other => panic!("expected PushClip, got {other:?}"),
8668        }
8669    }
8670
8671    #[test]
8672    fn apply_clip_path_with_a_nonpositive_radius_uses_square_corners() {
8673        for radius in [0.0, -5.0, f32::NAN] {
8674            let mut dl = list_of(vec![DisplayListItem::PopClip]);
8675            apply_clip_path(&mut dl, 0, rect(0.0, 0.0, 10.0, 10.0), radius);
8676            match &dl.items[0] {
8677                DisplayListItem::PushClip { border_radius, .. } => {
8678                    assert!(border_radius.is_zero(), "radius {radius} must not round the clip");
8679                }
8680                other => panic!("expected PushClip, got {other:?}"),
8681            }
8682        }
8683    }
8684
8685    #[test]
8686    fn apply_clip_path_at_the_end_index_appends_rather_than_panicking() {
8687        let mut dl = list_of(vec![DisplayListItem::PopClip]);
8688        // start_index == items.len() is the boundary case and must be accepted.
8689        apply_clip_path(&mut dl, 1, rect(0.0, 0.0, 10.0, 10.0), 0.0);
8690        assert_eq!(dl.items.len(), 3);
8691        assert_eq!(dl.items.len(), dl.node_mapping.len());
8692        assert!(matches!(dl.items[1], DisplayListItem::PushClip { .. }));
8693        assert!(matches!(dl.items[2], DisplayListItem::PopClip));
8694    }
8695
8696    #[test]
8697    #[should_panic(expected = "insertion index")]
8698    fn apply_clip_path_past_the_end_panics_on_the_vec_insert() {
8699        // BUG: `start_index` is not validated against `items.len()`, so an out-of-range
8700        // index panics inside `Vec::insert` instead of being rejected. Pinned so the
8701        // panic is a deliberate, visible contract rather than a latent crash.
8702        let mut dl = DisplayList::default();
8703        apply_clip_path(&mut dl, 3, rect(0.0, 0.0, 10.0, 10.0), 0.0);
8704    }
8705}