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}