Skip to main content

azul_layout/solver3/
getters.rs

1// +spec:box-model:b3a79e - box assigned same styles as generating element; getters read from styled DOM per node
2//! Centralized CSS property getters for the layout solver pipeline
3
4use azul_core::{
5    dom::{NodeId, NodeType},
6    geom::LogicalSize,
7    id::NodeId as CoreNodeId,
8    styled_dom::{StyledDom, StyledNodeState},
9};
10use azul_css::{
11    css::CssPropertyValue,
12    props::{
13        basic::{
14            font::{StyleFontFamily, StyleFontFamilyVec, StyleFontStyle, StyleFontWeight},
15            pixel::{DEFAULT_FONT_SIZE, PT_TO_PX},
16            ColorU, PhysicalSize, PixelValue, PropertyContext, ResolutionContext,
17        },
18        layout::{
19            grid::GridTemplateAreas, BoxDecorationBreak, BreakInside, LayoutAlignContent,
20            LayoutAlignItems, LayoutBoxSizing, LayoutClear, LayoutDisplay, LayoutFlexDirection,
21            LayoutFlexWrap, LayoutFloat, LayoutHeight, LayoutJustifyContent, LayoutOverflow,
22            LayoutPosition, LayoutWidth, LayoutWritingMode, Orphans, PageBreak,
23            StyleOverflowClipMargin, StyleScrollbarGutter, Widows,
24        },
25        property::{
26            CssProperty, CssPropertyType, LayoutAlignContentValue, LayoutAlignItemsValue,
27            LayoutAlignSelfValue, LayoutFlexBasisValue, LayoutFlexDirectionValue,
28            LayoutFlexGrowValue, LayoutFlexShrinkValue, LayoutFlexWrapValue, LayoutGapValue,
29            LayoutGridAutoColumnsValue, LayoutGridAutoFlowValue, LayoutGridAutoRowsValue,
30            LayoutGridColumnValue, LayoutGridRowValue, LayoutGridTemplateColumnsValue,
31            LayoutGridTemplateRowsValue, LayoutJustifyContentValue, LayoutJustifyItemsValue,
32            LayoutJustifySelfValue,
33        },
34        style::{
35            border_radius::StyleBorderRadius,
36            lists::{StyleListStylePosition, StyleListStyleType},
37            StyleAlignmentBaseline, StyleDirection, StyleDominantBaseline, StyleInitialLetterAlign,
38            StyleInitialLetterWrap, StyleTextAlign, StyleTextBoxEdge, StyleTextBoxTrim,
39            StyleUnicodeBidi, StyleUserSelect, StyleVerticalAlign, StyleVisibility,
40            StyleWhiteSpace,
41        },
42    },
43};
44
45use crate::{
46    font_traits::{ParsedFontTrait, StyleProperties},
47    solver3::{
48        display_list::{BorderRadius, PhysicalSizeImport},
49        layout_tree::LayoutNode,
50        scrollbar::ScrollbarRequirements,
51    },
52};
53
54const DEFAULT_EM_SIZE: f32 = 16.0;
55const DEFAULT_CARET_WIDTH_PX: f32 = 2.0;
56const DEFAULT_CARET_BLINK_MS: u32 = 500;
57const DEFAULT_TAB_SIZE: f32 = 8.0;
58const SCROLLBAR_WIDTH_THIN: f32 = 8.0;
59const SCROLLBAR_WIDTH_AUTO: f32 = 12.0;
60const SCROLLBAR_HOVER_EXPAND_PX: f32 = 4.0;
61const THUMB_HOVER_LIGHTEN: u8 = 30;
62const THUMB_HOVER_ALPHA_ADD: u8 = 40;
63const THUMB_ACTIVE_DARKEN: u8 = 15;
64
65// Font-size resolution helper functions
66
67/// Helper function to get element's computed font-size.
68///
69/// **Memoised** for the common `Normal` pseudo-state: the first
70/// call on a given `StyledDom` populates
71/// `css_property_cache.ptr.resolved_font_sizes_px` via a single
72/// bottom-up DOM walk (N cascade walks total, stored as
73/// `Vec<f32>`); every subsequent call is a single Vec index.
74/// Non-normal state falls through to [`resolve_font_size_slow`].
75///
76/// Motivation: `AZ_PROP_COUNT=1` measured 329 629 `font-size`
77/// cascade walks per cold layout on excel.html (~730 per node).
78/// With this cache that collapses to ~500 total (one per node,
79/// once), and subsequent layouts hit the Vec directly.
80///
81/// The semantics of the slow path are preserved exactly: the
82/// `compute_all_font_sizes_px` walker mirrors the original's
83/// `computed_values` → cascade → `DEFAULT_FONT_SIZE` ordering,
84/// so rendered pixels are byte-identical.
85#[must_use] pub fn get_element_font_size(
86    styled_dom: &StyledDom,
87    dom_id: NodeId,
88    node_state: &StyledNodeState,
89) -> f32 {
90    // M12.7 FIX: the OnceLock-cached fast path
91    // (`is_normal → resolved_font_sizes_px.get_or_init(|| compute_all_font_sizes_px) →
92    // sizes.get`) MIS-LIFTS to wasm — it diverges (create_node_from_dom never returns →
93    // empty LayoutTree → 0 rects). PROVEN by isolation: skipping it lets
94    // get_element_font_size reach + return via resolve_font_size_slow, and
95    // create_resolution_context completes (sub-step 1→4). resolve_font_size_slow is the
96    // same resolution unmemoized (correct), so we always use it. (Native desktop is
97    // unaffected in correctness; it loses the per-DOM memoization — a minor perf cost
98    // only on the lifted web path's small DOMs. The cache-block lift bug — likely the
99    // compute_all_font_sizes_px closure's control/FP — is documented for a later remill
100    // fix that can restore the fast path.)
101    let _ = compute_all_font_sizes_px; // referenced so other callers / native keep it
102    resolve_font_size_slow(styled_dom, dom_id, node_state)
103}
104
105/// Bottom-up single-pass resolve of every node's font-size.
106/// Parents are computed before children (DFS pre-order invariant
107/// on `NodeId::index()`), so `em` inherits via the parent's
108/// already-stored pixel value. `rem` reads from `sizes[0]` once
109/// the root is populated (the root's own size resolves via the
110/// `computed_values` short-circuit if set, otherwise DEFAULT).
111///
112/// Preserves the original resolution order exactly:
113///
114/// 1. `computed_values` binary search → if `FontSize` is pre-
115///    resolved to a px value, use that.
116/// 2. Full cascade via `cache.get_font_size(...)`; if an explicit
117///    value is present, resolve with context.
118/// 3. `DEFAULT_FONT_SIZE` fallback — NOT `parent_font_size`,
119///    because the `computed_values` short-circuit at step 1 is
120///    the cascade's inheritance channel (pre-populated for every
121///    inheriting node).
122fn compute_all_font_sizes_px(styled_dom: &StyledDom) -> Vec<f32> {
123    use azul_css::props::{
124        basic::length::SizeMetric,
125        property::{CssProperty, CssPropertyType},
126    };
127
128    let n = styled_dom.node_data.len();
129    let mut sizes = alloc::vec![DEFAULT_FONT_SIZE; n];
130    if n == 0 {
131        return sizes;
132    }
133
134    let data_container = styled_dom.node_data.as_container();
135    let state_container = styled_dom.styled_nodes.as_container();
136    let hierarchy = styled_dom.node_hierarchy.as_container();
137    let cache = &styled_dom.css_property_cache.ptr;
138
139    for idx in 0..n {
140        let dom_id = NodeId::new(idx);
141
142        // Step 1: computed_values short-circuit (matches original).
143        if let Some(vec) = cache.computed_values.get(idx) {
144            if let Ok(cv_idx) = vec.binary_search_by_key(&CssPropertyType::FontSize, |(k, _)| *k) {
145                if let CssProperty::FontSize(css_val) = &vec[cv_idx].1.property {
146                    if let Some(fs) = css_val.get_property() {
147                        if fs.inner.metric == SizeMetric::Px {
148                            sizes[idx] = fs.inner.number.get();
149                            continue;
150                        }
151                    }
152                }
153            }
154        }
155
156        // Step 2: full cascade walk.
157        let parent_font_size = hierarchy
158            .get(dom_id)
159            .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
160            .map_or(DEFAULT_FONT_SIZE, |p| sizes[p.index()]);
161        let root_font_size = sizes[0];
162
163        let Some(node_data) = data_container.internal.get(idx) else {
164            sizes[idx] = DEFAULT_FONT_SIZE;
165            continue;
166        };
167        let Some(styled) = state_container.internal.get(idx) else {
168            sizes[idx] = DEFAULT_FONT_SIZE;
169            continue;
170        };
171        let node_state = &styled.styled_node_state;
172
173        // Step 2.5: compact cache fast path — avoids a full cascade walk
174        // per node. The build-time pass has already resolved em/% to px,
175        // so the raw u32 here is the final pixel value when set.
176        let mut fast_fs: Option<f32> = None;
177        let mut compact_said_inherit = false;
178        if node_state.is_normal() {
179            if let Some(ref cc) = cache.compact_cache {
180                let raw = cc.get_font_size_raw(idx);
181                if raw == azul_css::compact_cache::U32_SENTINEL
182                    || raw == azul_css::compact_cache::U32_INHERIT
183                    || raw == azul_css::compact_cache::U32_INITIAL
184                {
185                    compact_said_inherit = true;
186                } else if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
187                    // Already-resolved pixel value (em/% eliminated during build).
188                    if pv.metric == SizeMetric::Px {
189                        fast_fs = Some(pv.number.get());
190                    } else {
191                        // Shouldn't normally happen post-resolve, but fall through safely.
192                        let context = ResolutionContext {
193                            element_font_size: DEFAULT_FONT_SIZE,
194                            parent_font_size,
195                            root_font_size,
196                            containing_block_size: PhysicalSize::new(0.0, 0.0),
197                            element_size: None,
198                            viewport_size: PhysicalSize::new(0.0, 0.0),
199                        };
200                        fast_fs =
201                            Some(pv.resolve_with_context(&context, PropertyContext::FontSize));
202                    }
203                }
204            }
205        }
206        if let Some(fs) = fast_fs {
207            sizes[idx] = fs;
208            continue;
209        }
210        if compact_said_inherit {
211            sizes[idx] = parent_font_size;
212            continue;
213        }
214
215        let resolved = cache
216            .get_font_size(node_data, &dom_id, node_state)
217            .and_then(|v| v.get_property().copied())
218            .map(|v| {
219                let context = ResolutionContext {
220                    element_font_size: DEFAULT_FONT_SIZE,
221                    parent_font_size,
222                    root_font_size,
223                    containing_block_size: PhysicalSize::new(0.0, 0.0),
224                    element_size: None,
225                    viewport_size: PhysicalSize::new(0.0, 0.0),
226                };
227                v.inner
228                    .resolve_with_context(&context, PropertyContext::FontSize)
229            });
230
231        // Step 3: fallback to DEFAULT (matches original .unwrap_or).
232        sizes[idx] = resolved.unwrap_or(DEFAULT_FONT_SIZE);
233    }
234    sizes
235}
236
237/// Un-memoised recursive resolution, used as the fallback for
238/// non-normal pseudo-states in [`get_element_font_size`] and
239/// directly by tests that bypass the StyledDom-scoped cache.
240/// Keeps the original semantics verbatim.
241fn resolve_font_size_slow(
242    styled_dom: &StyledDom,
243    dom_id: NodeId,
244    node_state: &StyledNodeState,
245) -> f32 {
246    // ITERATIVE resolution (was unbounded self-recursion up the parent chain, which
247    // stack-overflowed on deeply nested DOMs and was O(N*depth)). We walk `parent_id`
248    // in a loop to collect the ancestor chain, then resolve top-down so each node's
249    // `em` inherits from its already-resolved parent. Result is identical to the old
250    // recursive version for a well-formed tree, but bounded by the tree depth in
251    // stack usage (a single Vec of ancestors instead of nested frames).
252    //
253    // Each ancestor is resolved against its OWN `styled_node_state` (previously the
254    // recursion incorrectly threaded the *child's* state into parent/root resolution),
255    // matching the sibling `get_parent_font_size` / `get_root_font_size` helpers.
256    let hierarchy = styled_dom.node_hierarchy.as_container();
257    let states = styled_dom.styled_nodes.as_container();
258    let root_id = NodeId::new(0);
259
260    // Root font-size, resolved from NodeId(0) with no parent and root == DEFAULT
261    // (mirrors the original: for node 0 the root branch returned DEFAULT directly).
262    let root_font_size = if dom_id == root_id {
263        DEFAULT_FONT_SIZE
264    } else {
265        let root_state = &states[root_id].styled_node_state;
266        resolve_font_size_one(
267            styled_dom,
268            root_id,
269            root_state,
270            DEFAULT_FONT_SIZE,
271            DEFAULT_FONT_SIZE,
272        )
273    };
274
275    // Collect the ancestor chain: chain[0] == dom_id, chain.last() == topmost ancestor.
276    let mut chain = Vec::new();
277    let mut cur = Some(dom_id);
278    while let Some(id) = cur {
279        chain.push(id);
280        cur = hierarchy
281            .get(id)
282            .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
283    }
284
285    // Resolve top-down. The topmost ancestor has parent_font_size == DEFAULT; each
286    // subsequent node inherits the previously-resolved value as its parent size.
287    let mut parent_font_size = DEFAULT_FONT_SIZE;
288    let mut resolved = DEFAULT_FONT_SIZE;
289    for &id in chain.iter().rev() {
290        // The target node keeps the caller-provided state (its own state, per the
291        // public contract); ancestors use their own stored state.
292        let this_state = if id == dom_id {
293            node_state
294        } else {
295            &states[id].styled_node_state
296        };
297        let this_root_fs = if id == root_id {
298            DEFAULT_FONT_SIZE
299        } else {
300            root_font_size
301        };
302        resolved =
303            resolve_font_size_one(styled_dom, id, this_state, parent_font_size, this_root_fs);
304        parent_font_size = resolved;
305    }
306    resolved
307}
308
309/// Resolves a single node's font-size given its already-resolved `parent_font_size`
310/// and `root_font_size`. Contains the per-node logic that the old recursive
311/// `resolve_font_size_slow` applied at each frame (computed-values px short-circuit,
312/// then a full cascade walk), with no recursion of its own.
313fn resolve_font_size_one(
314    styled_dom: &StyledDom,
315    dom_id: NodeId,
316    node_state: &StyledNodeState,
317    parent_font_size: f32,
318    root_font_size: f32,
319) -> f32 {
320    let node_data = &styled_dom.node_data.as_container()[dom_id];
321    let cache = &styled_dom.css_property_cache.ptr;
322
323    if let Some(vec) = cache.computed_values.get(dom_id.index()) {
324        if let Ok(idx) = vec.binary_search_by_key(&CssPropertyType::FontSize, |(k, _)| *k) {
325            if let CssProperty::FontSize(css_val) = &vec[idx].1.property {
326                if let Some(fs) = css_val.get_property() {
327                    if fs.inner.metric == azul_css::props::basic::length::SizeMetric::Px {
328                        return fs.inner.number.get();
329                    }
330                }
331            }
332        }
333    }
334
335    cache
336        .get_font_size(node_data, &dom_id, node_state)
337        .and_then(|v| v.get_property().copied())
338        .map_or(DEFAULT_FONT_SIZE, |v| {
339            let context = ResolutionContext {
340                element_font_size: DEFAULT_FONT_SIZE,
341                parent_font_size,
342                root_font_size,
343                containing_block_size: PhysicalSize::new(0.0, 0.0),
344                element_size: None,
345                viewport_size: PhysicalSize::new(0.0, 0.0),
346            };
347            v.inner
348                .resolve_with_context(&context, PropertyContext::FontSize)
349        })
350}
351
352/// Helper function to get parent's computed font-size.
353///
354/// Retrieves the parent's own `StyledNodeState` so that pseudo-class-specific
355/// font-size rules (e.g. `div:hover { font-size: 32px }`) are resolved
356/// against the parent's actual state, not the child's.
357#[must_use] pub fn get_parent_font_size(
358    styled_dom: &StyledDom,
359    dom_id: NodeId,
360    _node_state: &StyledNodeState, // child's state — intentionally unused
361) -> f32 {
362    styled_dom
363        .node_hierarchy
364        .as_container()
365        .get(dom_id)
366        .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
367        .map_or(DEFAULT_FONT_SIZE, |parent_id| {
368            let parent_state = &styled_dom.styled_nodes.as_container()[parent_id].styled_node_state;
369            get_element_font_size(styled_dom, parent_id, parent_state)
370        })
371}
372
373/// Helper function to get root element's font-size.
374///
375/// Uses the root element's own `StyledNodeState` so that pseudo-class-specific
376/// rules are resolved correctly regardless of which node triggered the call.
377#[must_use] pub fn get_root_font_size(styled_dom: &StyledDom, _node_state: &StyledNodeState) -> f32 {
378    let root_id = NodeId::new(0);
379    let root_state = &styled_dom.styled_nodes.as_container()[root_id].styled_node_state;
380    get_element_font_size(styled_dom, root_id, root_state)
381}
382
383/// A value that can be Auto, Initial, Inherit, or an explicit value.
384/// This preserves CSS cascade semantics better than Option<T>.
385#[derive(Debug, Copy, Clone, PartialEq, Eq)]
386#[derive(Default)]
387pub enum MultiValue<T> {
388    /// CSS 'auto' keyword
389    #[default]
390    Auto,
391    /// CSS 'initial' keyword - use initial value
392    Initial,
393    /// CSS 'inherit' keyword - inherit from parent
394    Inherit,
395    /// Explicit value (e.g., "10px", "50%")
396    Exact(T),
397}
398
399impl<T> MultiValue<T> {
400    /// Returns true if this is an Auto value
401    pub const fn is_auto(&self) -> bool {
402        matches!(self, Self::Auto)
403    }
404
405    /// Returns true if this is an explicit value
406    pub const fn is_exact(&self) -> bool {
407        matches!(self, Self::Exact(_))
408    }
409
410    /// Gets the exact value if present
411    pub fn exact(self) -> Option<T> {
412        match self {
413            Self::Exact(v) => Some(v),
414            _ => None,
415        }
416    }
417
418    /// Gets the exact value or returns the provided default
419    pub fn unwrap_or(self, default: T) -> T {
420        match self {
421            Self::Exact(v) => v,
422            _ => default,
423        }
424    }
425
426    /// Gets the exact value or returns `T::default()`
427    pub fn unwrap_or_default(self) -> T
428    where
429        T: Default,
430    {
431        match self {
432            Self::Exact(v) => v,
433            _ => T::default(),
434        }
435    }
436
437    /// Maps the inner value if Exact, otherwise returns self unchanged
438    pub fn map<U, F>(self, f: F) -> MultiValue<U>
439    where
440        F: FnOnce(T) -> U,
441    {
442        match self {
443            Self::Exact(v) => MultiValue::Exact(f(v)),
444            Self::Auto => MultiValue::Auto,
445            Self::Initial => MultiValue::Initial,
446            Self::Inherit => MultiValue::Inherit,
447        }
448    }
449}
450
451// Implement helper methods for LayoutOverflow specifically
452impl MultiValue<LayoutOverflow> {
453    /// Returns true if this overflow value causes content to be clipped.
454    /// This includes Hidden, Clip, Auto, and Scroll (all values except Visible).
455    #[must_use] pub const fn is_clipped(&self) -> bool {
456        matches!(
457            self,
458            Self::Exact(
459                LayoutOverflow::Hidden
460                    | LayoutOverflow::Clip
461                    | LayoutOverflow::Auto
462                    | LayoutOverflow::Scroll
463            )
464        )
465    }
466
467    #[must_use] pub const fn is_scroll(&self) -> bool {
468        matches!(
469            self,
470            Self::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
471        )
472    }
473
474    #[must_use] pub const fn is_auto_overflow(&self) -> bool {
475        matches!(self, Self::Exact(LayoutOverflow::Auto))
476    }
477
478    #[must_use] pub const fn is_hidden(&self) -> bool {
479        matches!(self, Self::Exact(LayoutOverflow::Hidden))
480    }
481
482    #[must_use] pub const fn is_hidden_or_clip(&self) -> bool {
483        matches!(
484            self,
485            Self::Exact(LayoutOverflow::Hidden | LayoutOverflow::Clip)
486        )
487    }
488
489    #[must_use] pub const fn is_scroll_explicit(&self) -> bool {
490        matches!(self, Self::Exact(LayoutOverflow::Scroll))
491    }
492
493    #[must_use] pub const fn is_clip(&self) -> bool {
494        matches!(self, Self::Exact(LayoutOverflow::Clip))
495    }
496
497    #[must_use] pub const fn is_visible_or_clip(&self) -> bool {
498        matches!(
499            self,
500            Self::Exact(LayoutOverflow::Visible | LayoutOverflow::Clip)
501        )
502    }
503
504    // +spec:overflow:833078 - visible/clip compute to auto/hidden if other axis is scrollable
505    /// Resolves the computed value per CSS Overflow 3 § 3.1:
506    /// visible/clip values compute to auto/hidden (respectively)
507    /// if the other axis is neither visible nor clip.
508    #[must_use] pub const fn resolve_computed(
509        &self,
510        other_axis: &Self,
511    ) -> Self {
512        match (self, other_axis) {
513            (Self::Exact(val), Self::Exact(other)) => {
514                Self::Exact(val.resolve_computed(*other))
515            }
516            _ => *self,
517        }
518    }
519}
520
521// Implement helper methods for LayoutPosition
522impl MultiValue<LayoutPosition> {
523    #[must_use] pub const fn is_absolute_or_fixed(&self) -> bool {
524        matches!(
525            self,
526            Self::Exact(LayoutPosition::Absolute | LayoutPosition::Fixed)
527        )
528    }
529}
530
531// Implement helper methods for LayoutFloat
532impl MultiValue<LayoutFloat> {
533    #[must_use] pub const fn is_none(&self) -> bool {
534        matches!(
535            self,
536            Self::Auto
537                | Self::Initial
538                | Self::Inherit
539                | Self::Exact(LayoutFloat::None)
540        )
541    }
542}
543
544
545/// Helper macro to reduce boilerplate for simple CSS property getters
546/// Returns the inner `PixelValue` wrapped in `MultiValue`
547macro_rules! get_css_property_pixel {
548    // Variant WITH compact cache fast path for i16-encoded resolved px properties
549    ($fn_name:ident, $cache_method:ident, $ua_property:expr, compact_i16 = $compact_method:ident) => {
550        #[must_use] pub fn $fn_name(
551            styled_dom: &StyledDom,
552            node_id: NodeId,
553            node_state: &StyledNodeState,
554        ) -> MultiValue<PixelValue> {
555            // FAST PATH: compact cache for normal state (O(1) array lookup)
556            if node_state.is_normal() {
557                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
558                    let raw = cc.$compact_method(node_id.index());
559                    if raw == azul_css::compact_cache::I16_AUTO {
560                        return MultiValue::Auto;
561                    }
562                    if raw == azul_css::compact_cache::I16_INITIAL {
563                        return MultiValue::Initial;
564                    }
565                    if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
566                        // Valid value: decode i16 ×10 → px
567                        return MultiValue::Exact(PixelValue::px(f32::from(raw) / 10.0));
568                    }
569                    // I16_SENTINEL or I16_INHERIT → fall through to slow path
570                }
571            }
572
573            let node_data = &styled_dom.node_data.as_container()[node_id];
574
575            let author_css = styled_dom
576                .css_property_cache
577                .ptr
578                .$cache_method(node_data, &node_id, node_state);
579
580            if let Some(ref val) = author_css {
581                if val.is_auto() {
582                    return MultiValue::Auto;
583                }
584                if let Some(exact) = val.get_property().copied() {
585                    return MultiValue::Exact(exact.inner);
586                }
587            }
588
589            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
590
591            if let Some(ua_prop) = ua_css {
592                if let Some(inner) = ua_prop.get_pixel_inner() {
593                    return MultiValue::Exact(inner);
594                }
595            }
596
597            MultiValue::Initial
598        }
599    };
600}
601
602/// Helper trait to extract `PixelValue` from any `CssProperty` variant
603trait CssPropertyPixelInner {
604    fn get_pixel_inner(&self) -> Option<PixelValue>;
605}
606
607impl CssPropertyPixelInner for CssProperty {
608    fn get_pixel_inner(&self) -> Option<PixelValue> {
609        match self {
610            Self::Left(CssPropertyValue::Exact(v)) => Some(v.inner),
611            Self::Right(CssPropertyValue::Exact(v)) => Some(v.inner),
612            Self::Top(CssPropertyValue::Exact(v)) => Some(v.inner),
613            Self::Bottom(CssPropertyValue::Exact(v)) => Some(v.inner),
614            Self::MarginLeft(CssPropertyValue::Exact(v)) => Some(v.inner),
615            Self::MarginRight(CssPropertyValue::Exact(v)) => Some(v.inner),
616            Self::MarginTop(CssPropertyValue::Exact(v)) => Some(v.inner),
617            Self::MarginBottom(CssPropertyValue::Exact(v)) => Some(v.inner),
618            Self::PaddingLeft(CssPropertyValue::Exact(v)) => Some(v.inner),
619            Self::PaddingRight(CssPropertyValue::Exact(v)) => Some(v.inner),
620            Self::PaddingTop(CssPropertyValue::Exact(v)) => Some(v.inner),
621            Self::PaddingBottom(CssPropertyValue::Exact(v)) => Some(v.inner),
622            _ => None,
623        }
624    }
625}
626
627/// Generic macro for CSS properties with UA CSS fallback - returns `MultiValue`<T>
628macro_rules! get_css_property {
629    // Variant WITH compact cache fast path (for enum properties in Tier 1)
630    ($fn_name:ident, $cache_method:ident, $return_type:ty, $ua_property:expr, compact = $compact_method:ident) => {
631        #[must_use] pub fn $fn_name(
632            styled_dom: &StyledDom,
633            node_id: NodeId,
634            node_state: &StyledNodeState,
635        ) -> MultiValue<$return_type> {
636            // FAST PATH: compact cache for normal state (O(1) array + bitshift)
637            // NOTE (M12.7): skipping this fast path does NOT fix get_display_type's
638            // divergence — the slow path / the `match get_display_type(...)` on the
639            // LayoutDisplay enum (a niche-discriminant) mis-lifts too. So this isn't the
640            // cache (unlike the font-size fix); it's the deeper niche/enum decode. Kept.
641            if node_state.is_normal() {
642                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
643                    return MultiValue::Exact(cc.$compact_method(node_id.index()));
644                }
645            }
646
647            // SLOW PATH: full cascade resolution
648            let node_data = &styled_dom.node_data.as_container()[node_id];
649
650            // 1. Check author CSS first
651            let author_css = styled_dom
652                .css_property_cache
653                .ptr
654                .$cache_method(node_data, &node_id, node_state);
655
656            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
657                return MultiValue::Exact(val);
658            }
659
660            // 2. Check User Agent CSS
661            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
662
663            if let Some(ua_prop) = ua_css {
664                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
665                    return MultiValue::Exact(val);
666                }
667            }
668
669            // 3. Fallback to Auto (not set)
670            MultiValue::Auto
671        }
672    };
673    // Variant WITH compact cache for u32-encoded dimension enums (LayoutWidth/LayoutHeight)
674    // These types have Auto, Px(PixelValue), MinContent, MaxContent, Calc variants
675    ($fn_name:ident, $cache_method:ident, $return_type:ty, $ua_property:expr, compact_u32_dim = $compact_raw_method:ident, $px_variant:path, $auto_variant:path, $min_content_variant:path, $max_content_variant:path) => {
676        #[must_use] pub fn $fn_name(
677            styled_dom: &StyledDom,
678            node_id: NodeId,
679            node_state: &StyledNodeState,
680        ) -> MultiValue<$return_type> {
681            // FAST PATH: compact cache for normal state
682            if node_state.is_normal() {
683                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
684                    let raw = cc.$compact_raw_method(node_id.index());
685                    match raw {
686                        azul_css::compact_cache::U32_AUTO => return MultiValue::Auto,
687                        azul_css::compact_cache::U32_INITIAL => return MultiValue::Initial,
688                        azul_css::compact_cache::U32_NONE => return MultiValue::Auto,
689                        azul_css::compact_cache::U32_MIN_CONTENT => return MultiValue::Exact($min_content_variant),
690                        azul_css::compact_cache::U32_MAX_CONTENT => return MultiValue::Exact($max_content_variant),
691                        azul_css::compact_cache::U32_SENTINEL | azul_css::compact_cache::U32_INHERIT => {
692                            // fall through to slow path
693                        }
694                        _ => {
695                            // Valid encoded pixel value
696                            if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
697                                return MultiValue::Exact($px_variant(pv));
698                            }
699                            // decode failed → slow path
700                        }
701                    }
702                }
703            }
704
705            // SLOW PATH: full cascade resolution
706            let node_data = &styled_dom.node_data.as_container()[node_id];
707
708            let author_css = styled_dom
709                .css_property_cache
710                .ptr
711                .$cache_method(node_data, &node_id, node_state);
712
713            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
714                return MultiValue::Exact(val);
715            }
716
717            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
718
719            if let Some(ua_prop) = ua_css {
720                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
721                    return MultiValue::Exact(val);
722                }
723            }
724
725            MultiValue::Auto
726        }
727    };
728    // Variant WITH compact cache for u32-encoded dimension structs (LayoutMinWidth etc.)
729    // These types are struct { inner: PixelValue }
730    ($fn_name:ident, $cache_method:ident, $return_type:ty, $ua_property:expr, compact_u32_struct = $compact_raw_method:ident) => {
731        #[must_use] pub fn $fn_name(
732            styled_dom: &StyledDom,
733            node_id: NodeId,
734            node_state: &StyledNodeState,
735        ) -> MultiValue<$return_type> {
736            // FAST PATH: compact cache for normal state
737            if node_state.is_normal() {
738                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
739                    let raw = cc.$compact_raw_method(node_id.index());
740                    match raw {
741                        azul_css::compact_cache::U32_AUTO | azul_css::compact_cache::U32_NONE => return MultiValue::Auto,
742                        azul_css::compact_cache::U32_INITIAL => return MultiValue::Initial,
743                        azul_css::compact_cache::U32_SENTINEL | azul_css::compact_cache::U32_INHERIT => {
744                            // fall through to slow path
745                        }
746                        _ => {
747                            if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
748                                return MultiValue::Exact(
749                                    <$return_type as azul_css::props::PixelValueTaker>::from_pixel_value(pv)
750                                );
751                            }
752                        }
753                    }
754                }
755            }
756
757            // SLOW PATH
758            let node_data = &styled_dom.node_data.as_container()[node_id];
759
760            let author_css = styled_dom
761                .css_property_cache
762                .ptr
763                .$cache_method(node_data, &node_id, node_state);
764
765            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
766                return MultiValue::Exact(val);
767            }
768
769            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
770
771            if let Some(ua_prop) = ua_css {
772                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
773                    return MultiValue::Exact(val);
774                }
775            }
776
777            MultiValue::Auto
778        }
779    };
780    // Variant WITHOUT compact cache (original behavior)
781    ($fn_name:ident, $cache_method:ident, $return_type:ty, $ua_property:expr) => {
782        #[must_use] pub fn $fn_name(
783            styled_dom: &StyledDom,
784            node_id: NodeId,
785            node_state: &StyledNodeState,
786        ) -> MultiValue<$return_type> {
787            let node_data = &styled_dom.node_data.as_container()[node_id];
788
789            // 1. Check author CSS first
790            let author_css = styled_dom
791                .css_property_cache
792                .ptr
793                .$cache_method(node_data, &node_id, node_state);
794
795            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
796                return MultiValue::Exact(val);
797            }
798
799            // 2. Check User Agent CSS
800            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
801
802            if let Some(ua_prop) = ua_css {
803                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
804                    return MultiValue::Exact(val);
805                }
806            }
807
808            // 3. Fallback to Auto (not set)
809            MultiValue::Auto
810        }
811    };
812}
813
814/// Helper trait to extract typed values from UA CSS properties
815trait ExtractPropertyValue<T> {
816    fn extract(&self) -> Option<T>;
817}
818
819fn extract_property_value<T>(prop: &CssProperty) -> Option<T>
820where
821    CssProperty: ExtractPropertyValue<T>,
822{
823    prop.extract()
824}
825
826// Implement extraction for all layout types
827
828impl ExtractPropertyValue<LayoutWidth> for CssProperty {
829    fn extract(&self) -> Option<LayoutWidth> {
830        match self {
831            Self::Width(CssPropertyValue::Exact(v)) => Some(v.clone()),
832            _ => None,
833        }
834    }
835}
836
837impl ExtractPropertyValue<LayoutHeight> for CssProperty {
838    fn extract(&self) -> Option<LayoutHeight> {
839        match self {
840            Self::Height(CssPropertyValue::Exact(v)) => Some(v.clone()),
841            _ => None,
842        }
843    }
844}
845
846impl ExtractPropertyValue<LayoutMinWidth> for CssProperty {
847    fn extract(&self) -> Option<LayoutMinWidth> {
848        match self {
849            Self::MinWidth(CssPropertyValue::Exact(v)) => Some(*v),
850            _ => None,
851        }
852    }
853}
854
855impl ExtractPropertyValue<LayoutMinHeight> for CssProperty {
856    fn extract(&self) -> Option<LayoutMinHeight> {
857        match self {
858            Self::MinHeight(CssPropertyValue::Exact(v)) => Some(*v),
859            _ => None,
860        }
861    }
862}
863
864impl ExtractPropertyValue<LayoutMaxWidth> for CssProperty {
865    fn extract(&self) -> Option<LayoutMaxWidth> {
866        match self {
867            Self::MaxWidth(CssPropertyValue::Exact(v)) => Some(*v),
868            _ => None,
869        }
870    }
871}
872
873impl ExtractPropertyValue<LayoutMaxHeight> for CssProperty {
874    fn extract(&self) -> Option<LayoutMaxHeight> {
875        match self {
876            Self::MaxHeight(CssPropertyValue::Exact(v)) => Some(*v),
877            _ => None,
878        }
879    }
880}
881
882impl ExtractPropertyValue<LayoutDisplay> for CssProperty {
883    fn extract(&self) -> Option<LayoutDisplay> {
884        match self {
885            Self::Display(CssPropertyValue::Exact(v)) => Some(*v),
886            _ => None,
887        }
888    }
889}
890
891impl ExtractPropertyValue<LayoutWritingMode> for CssProperty {
892    fn extract(&self) -> Option<LayoutWritingMode> {
893        match self {
894            Self::WritingMode(CssPropertyValue::Exact(v)) => Some(*v),
895            _ => None,
896        }
897    }
898}
899
900impl ExtractPropertyValue<LayoutFlexWrap> for CssProperty {
901    fn extract(&self) -> Option<LayoutFlexWrap> {
902        match self {
903            Self::FlexWrap(CssPropertyValue::Exact(v)) => Some(*v),
904            _ => None,
905        }
906    }
907}
908
909impl ExtractPropertyValue<LayoutJustifyContent> for CssProperty {
910    fn extract(&self) -> Option<LayoutJustifyContent> {
911        match self {
912            Self::JustifyContent(CssPropertyValue::Exact(v)) => Some(*v),
913            _ => None,
914        }
915    }
916}
917
918impl ExtractPropertyValue<StyleTextAlign> for CssProperty {
919    fn extract(&self) -> Option<StyleTextAlign> {
920        match self {
921            Self::TextAlign(CssPropertyValue::Exact(v)) => Some(*v),
922            _ => None,
923        }
924    }
925}
926
927impl ExtractPropertyValue<LayoutFloat> for CssProperty {
928    fn extract(&self) -> Option<LayoutFloat> {
929        match self {
930            Self::Float(CssPropertyValue::Exact(v)) => Some(*v),
931            _ => None,
932        }
933    }
934}
935
936impl ExtractPropertyValue<LayoutClear> for CssProperty {
937    fn extract(&self) -> Option<LayoutClear> {
938        match self {
939            Self::Clear(CssPropertyValue::Exact(v)) => Some(*v),
940            _ => None,
941        }
942    }
943}
944
945impl ExtractPropertyValue<LayoutOverflow> for CssProperty {
946    fn extract(&self) -> Option<LayoutOverflow> {
947        match self {
948            Self::OverflowX(CssPropertyValue::Exact(v))
949            | Self::OverflowY(CssPropertyValue::Exact(v))
950            | Self::OverflowBlock(CssPropertyValue::Exact(v))
951            | Self::OverflowInline(CssPropertyValue::Exact(v)) => Some(*v),
952            _ => None,
953        }
954    }
955}
956
957impl ExtractPropertyValue<LayoutPosition> for CssProperty {
958    fn extract(&self) -> Option<LayoutPosition> {
959        match self {
960            Self::Position(CssPropertyValue::Exact(v)) => Some(*v),
961            _ => None,
962        }
963    }
964}
965
966impl ExtractPropertyValue<LayoutBoxSizing> for CssProperty {
967    fn extract(&self) -> Option<LayoutBoxSizing> {
968        match self {
969            Self::BoxSizing(CssPropertyValue::Exact(v)) => Some(*v),
970            _ => None,
971        }
972    }
973}
974
975impl ExtractPropertyValue<PixelValue> for CssProperty {
976    fn extract(&self) -> Option<PixelValue> {
977        self.get_pixel_inner()
978    }
979}
980
981impl ExtractPropertyValue<LayoutFlexDirection> for CssProperty {
982    fn extract(&self) -> Option<LayoutFlexDirection> {
983        match self {
984            Self::FlexDirection(CssPropertyValue::Exact(v)) => Some(*v),
985            _ => None,
986        }
987    }
988}
989
990impl ExtractPropertyValue<LayoutAlignItems> for CssProperty {
991    fn extract(&self) -> Option<LayoutAlignItems> {
992        match self {
993            Self::AlignItems(CssPropertyValue::Exact(v)) => Some(*v),
994            _ => None,
995        }
996    }
997}
998
999impl ExtractPropertyValue<LayoutAlignContent> for CssProperty {
1000    fn extract(&self) -> Option<LayoutAlignContent> {
1001        match self {
1002            Self::AlignContent(CssPropertyValue::Exact(v)) => Some(*v),
1003            _ => None,
1004        }
1005    }
1006}
1007
1008impl ExtractPropertyValue<StyleFontWeight> for CssProperty {
1009    fn extract(&self) -> Option<StyleFontWeight> {
1010        match self {
1011            Self::FontWeight(CssPropertyValue::Exact(v)) => Some(*v),
1012            _ => None,
1013        }
1014    }
1015}
1016
1017impl ExtractPropertyValue<StyleFontStyle> for CssProperty {
1018    fn extract(&self) -> Option<StyleFontStyle> {
1019        match self {
1020            Self::FontStyle(CssPropertyValue::Exact(v)) => Some(*v),
1021            _ => None,
1022        }
1023    }
1024}
1025
1026impl ExtractPropertyValue<StyleVisibility> for CssProperty {
1027    fn extract(&self) -> Option<StyleVisibility> {
1028        match self {
1029            Self::Visibility(CssPropertyValue::Exact(v)) => Some(*v),
1030            _ => None,
1031        }
1032    }
1033}
1034
1035impl ExtractPropertyValue<StyleWhiteSpace> for CssProperty {
1036    fn extract(&self) -> Option<StyleWhiteSpace> {
1037        match self {
1038            Self::WhiteSpace(CssPropertyValue::Exact(v)) => Some(*v),
1039            _ => None,
1040        }
1041    }
1042}
1043
1044impl ExtractPropertyValue<StyleDirection> for CssProperty {
1045    fn extract(&self) -> Option<StyleDirection> {
1046        match self {
1047            Self::Direction(CssPropertyValue::Exact(v)) => Some(*v),
1048            _ => None,
1049        }
1050    }
1051}
1052
1053impl ExtractPropertyValue<StyleUnicodeBidi> for CssProperty {
1054    fn extract(&self) -> Option<StyleUnicodeBidi> {
1055        match self {
1056            Self::UnicodeBidi(CssPropertyValue::Exact(v)) => Some(*v),
1057            _ => None,
1058        }
1059    }
1060}
1061
1062impl ExtractPropertyValue<StyleTextBoxTrim> for CssProperty {
1063    fn extract(&self) -> Option<StyleTextBoxTrim> {
1064        match self {
1065            Self::TextBoxTrim(CssPropertyValue::Exact(v)) => Some(*v),
1066            _ => None,
1067        }
1068    }
1069}
1070
1071impl ExtractPropertyValue<StyleTextBoxEdge> for CssProperty {
1072    fn extract(&self) -> Option<StyleTextBoxEdge> {
1073        match self {
1074            Self::TextBoxEdge(CssPropertyValue::Exact(v)) => Some(*v),
1075            _ => None,
1076        }
1077    }
1078}
1079
1080impl ExtractPropertyValue<StyleDominantBaseline> for CssProperty {
1081    fn extract(&self) -> Option<StyleDominantBaseline> {
1082        match self {
1083            Self::DominantBaseline(CssPropertyValue::Exact(v)) => Some(*v),
1084            _ => None,
1085        }
1086    }
1087}
1088
1089impl ExtractPropertyValue<StyleAlignmentBaseline> for CssProperty {
1090    fn extract(&self) -> Option<StyleAlignmentBaseline> {
1091        match self {
1092            Self::AlignmentBaseline(CssPropertyValue::Exact(v)) => Some(*v),
1093            _ => None,
1094        }
1095    }
1096}
1097
1098impl ExtractPropertyValue<StyleInitialLetterAlign> for CssProperty {
1099    fn extract(&self) -> Option<StyleInitialLetterAlign> {
1100        match self {
1101            Self::InitialLetterAlign(CssPropertyValue::Exact(v)) => Some(*v),
1102            _ => None,
1103        }
1104    }
1105}
1106
1107impl ExtractPropertyValue<StyleInitialLetterWrap> for CssProperty {
1108    fn extract(&self) -> Option<StyleInitialLetterWrap> {
1109        match self {
1110            Self::InitialLetterWrap(CssPropertyValue::Exact(v)) => Some(*v),
1111            _ => None,
1112        }
1113    }
1114}
1115
1116impl ExtractPropertyValue<StyleScrollbarGutter> for CssProperty {
1117    fn extract(&self) -> Option<StyleScrollbarGutter> {
1118        match self {
1119            Self::ScrollbarGutter(CssPropertyValue::Exact(v)) => Some(*v),
1120            _ => None,
1121        }
1122    }
1123}
1124
1125impl ExtractPropertyValue<StyleOverflowClipMargin> for CssProperty {
1126    fn extract(&self) -> Option<StyleOverflowClipMargin> {
1127        match self {
1128            Self::OverflowClipMargin(CssPropertyValue::Exact(v)) => Some(*v),
1129            _ => None,
1130        }
1131    }
1132}
1133
1134impl ExtractPropertyValue<StyleVerticalAlign> for CssProperty {
1135    fn extract(&self) -> Option<StyleVerticalAlign> {
1136        match self {
1137            Self::VerticalAlign(CssPropertyValue::Exact(v)) => Some(*v),
1138            _ => None,
1139        }
1140    }
1141}
1142
1143get_css_property!(
1144    get_writing_mode,
1145    get_writing_mode,
1146    LayoutWritingMode,
1147    CssPropertyType::WritingMode,
1148    compact = get_writing_mode
1149);
1150
1151get_css_property!(
1152    get_css_width,
1153    get_width,
1154    LayoutWidth,
1155    CssPropertyType::Width,
1156    compact_u32_dim = get_width_raw,
1157    LayoutWidth::Px,
1158    LayoutWidth::Auto,
1159    LayoutWidth::MinContent,
1160    LayoutWidth::MaxContent
1161);
1162
1163get_css_property!(
1164    get_css_height,
1165    get_height,
1166    LayoutHeight,
1167    CssPropertyType::Height,
1168    compact_u32_dim = get_height_raw,
1169    LayoutHeight::Px,
1170    LayoutHeight::Auto,
1171    LayoutHeight::MinContent,
1172    LayoutHeight::MaxContent
1173);
1174
1175get_css_property!(
1176    get_wrap,
1177    get_flex_wrap,
1178    LayoutFlexWrap,
1179    CssPropertyType::FlexWrap,
1180    compact = get_flex_wrap
1181);
1182
1183get_css_property!(
1184    get_justify_content,
1185    get_justify_content,
1186    LayoutJustifyContent,
1187    CssPropertyType::JustifyContent,
1188    compact = get_justify_content
1189);
1190
1191get_css_property!(
1192    get_text_align,
1193    get_text_align,
1194    StyleTextAlign,
1195    CssPropertyType::TextAlign,
1196    compact = get_text_align
1197);
1198
1199get_css_property!(
1200    get_float,
1201    get_float,
1202    LayoutFloat,
1203    CssPropertyType::Float,
1204    compact = get_float
1205);
1206
1207get_css_property!(
1208    get_clear,
1209    get_clear,
1210    LayoutClear,
1211    CssPropertyType::Clear,
1212    compact = get_clear
1213);
1214
1215get_css_property!(
1216    get_overflow_x,
1217    get_overflow_x,
1218    LayoutOverflow,
1219    CssPropertyType::OverflowX,
1220    compact = get_overflow_x
1221);
1222
1223get_css_property!(
1224    get_overflow_y,
1225    get_overflow_y,
1226    LayoutOverflow,
1227    CssPropertyType::OverflowY,
1228    compact = get_overflow_y
1229);
1230
1231// +spec:overflow:17654b - overflow-block and overflow-inline logical properties resolve to physical overflow based on writing mode
1232get_css_property!(
1233    get_overflow_block,
1234    get_overflow_block,
1235    LayoutOverflow,
1236    CssPropertyType::OverflowBlock
1237);
1238
1239get_css_property!(
1240    get_overflow_inline,
1241    get_overflow_inline,
1242    LayoutOverflow,
1243    CssPropertyType::OverflowInline
1244);
1245
1246get_css_property!(
1247    get_position,
1248    get_position,
1249    LayoutPosition,
1250    CssPropertyType::Position,
1251    compact = get_position
1252);
1253
1254get_css_property!(
1255    get_css_box_sizing,
1256    get_box_sizing,
1257    LayoutBoxSizing,
1258    CssPropertyType::BoxSizing,
1259    compact = get_box_sizing
1260);
1261
1262get_css_property!(
1263    get_flex_direction,
1264    get_flex_direction,
1265    LayoutFlexDirection,
1266    CssPropertyType::FlexDirection,
1267    compact = get_flex_direction
1268);
1269
1270get_css_property!(
1271    get_align_items,
1272    get_align_items,
1273    LayoutAlignItems,
1274    CssPropertyType::AlignItems,
1275    compact = get_align_items
1276);
1277
1278get_css_property!(
1279    get_align_content,
1280    get_align_content,
1281    LayoutAlignContent,
1282    CssPropertyType::AlignContent,
1283    compact = get_align_content
1284);
1285
1286get_css_property!(
1287    get_font_weight_property,
1288    get_font_weight,
1289    StyleFontWeight,
1290    CssPropertyType::FontWeight,
1291    compact = get_font_weight
1292);
1293
1294get_css_property!(
1295    get_font_style_property,
1296    get_font_style,
1297    StyleFontStyle,
1298    CssPropertyType::FontStyle,
1299    compact = get_font_style
1300);
1301
1302get_css_property!(
1303    get_visibility,
1304    get_visibility,
1305    StyleVisibility,
1306    CssPropertyType::Visibility,
1307    compact = get_visibility
1308);
1309
1310get_css_property!(
1311    get_white_space_property,
1312    get_white_space,
1313    StyleWhiteSpace,
1314    CssPropertyType::WhiteSpace,
1315    compact = get_white_space
1316);
1317
1318// +spec:writing-modes:3af12f - unicode-bidi does not affect direction for layout; we use direction property directly
1319get_css_property!(
1320    get_direction_property,
1321    get_direction,
1322    StyleDirection,
1323    CssPropertyType::Direction,
1324    compact = get_direction
1325);
1326
1327// +spec:display-property:346799 - inline-level elements with unicode-bidi:normal have no effect on text ordering
1328// +spec:writing-modes:3e2632 - unicode-bidi property resolves embedding level for bidi algorithm (LRE/RLE/PDF)
1329// +spec:writing-modes:d2c94f - direction+unicode-bidi properties map to UAX#9 bidirectional algorithm
1330get_css_property!(
1331    get_unicode_bidi_property,
1332    get_unicode_bidi,
1333    StyleUnicodeBidi,
1334    CssPropertyType::UnicodeBidi
1335);
1336
1337// +spec:display-property:db5125 - text-box-trim on inline boxes trims content box to text-box-edge metric
1338// +spec:display-property:dceb24 - text-box-trim on inline boxes: content edges coincide with text baselines
1339get_css_property!(
1340    get_text_box_trim_property,
1341    get_text_box_trim,
1342    StyleTextBoxTrim,
1343    CssPropertyType::TextBoxTrim
1344);
1345
1346get_css_property!(
1347    get_text_box_edge_property,
1348    get_text_box_edge,
1349    StyleTextBoxEdge,
1350    CssPropertyType::TextBoxEdge
1351);
1352
1353get_css_property!(
1354    get_dominant_baseline_property,
1355    get_dominant_baseline,
1356    StyleDominantBaseline,
1357    CssPropertyType::DominantBaseline
1358);
1359
1360get_css_property!(
1361    get_alignment_baseline_property,
1362    get_alignment_baseline,
1363    StyleAlignmentBaseline,
1364    CssPropertyType::AlignmentBaseline
1365);
1366
1367get_css_property!(
1368    get_initial_letter_align_property,
1369    get_initial_letter_align,
1370    StyleInitialLetterAlign,
1371    CssPropertyType::InitialLetterAlign
1372);
1373
1374get_css_property!(
1375    get_initial_letter_wrap_property,
1376    get_initial_letter_wrap,
1377    StyleInitialLetterWrap,
1378    CssPropertyType::InitialLetterWrap
1379);
1380
1381// +spec:overflow:5d15e2 - block-start/block-end scrollbar gutter follows same rules as inline gutters when auto
1382//
1383// Hand-rolled fast path: 99% of nodes don't set scrollbar-gutter, and the
1384// default is `auto`. The compact cache stores the enum in 2 bits of
1385// tier2_cold.hot_flags, so we can return the answer without a cascade walk.
1386#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1387#[must_use] pub fn get_scrollbar_gutter_property(
1388    styled_dom: &StyledDom,
1389    node_id: NodeId,
1390    node_state: &StyledNodeState,
1391) -> MultiValue<StyleScrollbarGutter> {
1392    // FAST PATH: 2-bit enum in hot_flags
1393    if node_state.is_normal() {
1394        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1395            let bits = cc.get_scrollbar_gutter_bits(node_id.index());
1396            let val = match bits {
1397                azul_css::compact_cache::SCROLLBAR_GUTTER_AUTO => StyleScrollbarGutter::Auto,
1398                azul_css::compact_cache::SCROLLBAR_GUTTER_STABLE => StyleScrollbarGutter::Stable,
1399                azul_css::compact_cache::SCROLLBAR_GUTTER_BOTH_EDGES => {
1400                    StyleScrollbarGutter::StableBothEdges
1401                }
1402                _ => StyleScrollbarGutter::Auto,
1403            };
1404            return MultiValue::Exact(val);
1405        }
1406    }
1407
1408    // SLOW PATH: cascade resolution for pseudo-states or missing cache
1409    let node_data = &styled_dom.node_data.as_container()[node_id];
1410    let author_css = styled_dom
1411        .css_property_cache
1412        .ptr
1413        .get_scrollbar_gutter(node_data, &node_id, node_state);
1414    if let Some(val) = author_css.and_then(|v| v.get_property().copied()) {
1415        return MultiValue::Exact(val);
1416    }
1417    MultiValue::Auto
1418}
1419
1420get_css_property!(
1421    get_overflow_clip_margin_property,
1422    get_overflow_clip_margin,
1423    StyleOverflowClipMargin,
1424    CssPropertyType::OverflowClipMargin
1425);
1426
1427get_css_property!(
1428    get_object_fit_property,
1429    get_object_fit,
1430    StyleObjectFit,
1431    CssPropertyType::ObjectFit
1432);
1433
1434// +spec:writing-modes:257296 - text-orientation getter for vertical typesetting (upright/sideways)
1435//
1436// Hand-rolled (not macro-generated) to attach a negative fast-path: most
1437// nodes have no text-orientation declared (default = Mixed), so we avoid a
1438// cascade walk per fc.rs call (which is called ~2× per node).
1439#[must_use] pub fn get_text_orientation_property(
1440    styled_dom: &StyledDom,
1441    node_id: NodeId,
1442    node_state: &StyledNodeState,
1443) -> MultiValue<StyleTextOrientation> {
1444    if node_state.is_normal() {
1445        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1446            if !cc.has_text_orientation(node_id.index()) {
1447                return MultiValue::Auto;
1448            }
1449        }
1450    }
1451    let node_data = &styled_dom.node_data.as_container()[node_id];
1452    if let Some(val) = styled_dom
1453        .css_property_cache
1454        .ptr
1455        .get_text_orientation(node_data, &node_id, node_state)
1456        .and_then(|v| v.get_property().copied())
1457    {
1458        return MultiValue::Exact(val);
1459    }
1460    let ua = azul_core::ua_css::get_ua_property(
1461        &node_data.node_type,
1462        CssPropertyType::TextOrientation,
1463    );
1464    if let Some(ua_prop) = ua {
1465        if let Some(val) = extract_property_value::<StyleTextOrientation>(ua_prop) {
1466            return MultiValue::Exact(val);
1467        }
1468    }
1469    MultiValue::Auto
1470}
1471
1472get_css_property!(
1473    get_object_position_property,
1474    get_object_position,
1475    StyleObjectPosition,
1476    CssPropertyType::ObjectPosition
1477);
1478
1479get_css_property!(
1480    get_aspect_ratio_property,
1481    get_aspect_ratio,
1482    StyleAspectRatio,
1483    CssPropertyType::AspectRatio
1484);
1485
1486// NOTE: vertical-align does NOT use the compact cache because the compact cache
1487// only stores keyword variants (3 bits = 8 values) and silently drops
1488// Percentage/Length values by mapping them to Baseline. Always use the slow path.
1489#[must_use] pub fn get_vertical_align_property(
1490    styled_dom: &StyledDom,
1491    node_id: NodeId,
1492    node_state: &StyledNodeState,
1493) -> MultiValue<StyleVerticalAlign> {
1494    let node_data = &styled_dom.node_data.as_container()[node_id];
1495
1496    let author_css = styled_dom
1497        .css_property_cache
1498        .ptr
1499        .get_vertical_align(node_data, &node_id, node_state);
1500
1501    if let Some(val) = author_css.and_then(|v| v.get_property().copied()) {
1502        return MultiValue::Exact(val);
1503    }
1504
1505    let ua_css = azul_core::ua_css::get_ua_property(
1506        &node_data.node_type,
1507        CssPropertyType::VerticalAlign,
1508    );
1509
1510    if let Some(ua_prop) = ua_css {
1511        if let Some(val) = extract_property_value::<StyleVerticalAlign>(ua_prop) {
1512            return MultiValue::Exact(val);
1513        }
1514    }
1515
1516    MultiValue::Auto
1517}
1518// Complex Property Getters
1519
1520/// Get border radius for all four corners (raw CSS property values)
1521#[must_use] pub fn get_style_border_radius(
1522    styled_dom: &StyledDom,
1523    node_id: NodeId,
1524    node_state: &StyledNodeState,
1525) -> StyleBorderRadius {
1526    use azul_css::props::basic::pixel::PixelValue;
1527    // FAST PATH: all four corners live in tier2_cold as i16 px × 10. The
1528    // common case (no rounded corners anywhere) reads four bytes and bails.
1529    if node_state.is_normal() {
1530        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1531            let idx = node_id.index();
1532            let decode = |raw: i16| -> PixelValue {
1533                if raw >= azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
1534                    PixelValue::px(0.0)
1535                } else {
1536                    PixelValue::px(f32::from(raw) / 10.0)
1537                }
1538            };
1539            return StyleBorderRadius {
1540                top_left: decode(cc.get_border_top_left_radius_raw(idx)),
1541                top_right: decode(cc.get_border_top_right_radius_raw(idx)),
1542                bottom_right: decode(cc.get_border_bottom_right_radius_raw(idx)),
1543                bottom_left: decode(cc.get_border_bottom_left_radius_raw(idx)),
1544            };
1545        }
1546    }
1547    let node_data = &styled_dom.node_data.as_container()[node_id];
1548
1549    let top_left = styled_dom
1550        .css_property_cache
1551        .ptr
1552        .get_border_top_left_radius(node_data, &node_id, node_state)
1553        .and_then(|br| br.get_property_or_default())
1554        .map(|v| v.inner)
1555        .unwrap_or_default();
1556
1557    let top_right = styled_dom
1558        .css_property_cache
1559        .ptr
1560        .get_border_top_right_radius(node_data, &node_id, node_state)
1561        .and_then(|br| br.get_property_or_default())
1562        .map(|v| v.inner)
1563        .unwrap_or_default();
1564
1565    let bottom_right = styled_dom
1566        .css_property_cache
1567        .ptr
1568        .get_border_bottom_right_radius(node_data, &node_id, node_state)
1569        .and_then(|br| br.get_property_or_default())
1570        .map(|v| v.inner)
1571        .unwrap_or_default();
1572
1573    let bottom_left = styled_dom
1574        .css_property_cache
1575        .ptr
1576        .get_border_bottom_left_radius(node_data, &node_id, node_state)
1577        .and_then(|br| br.get_property_or_default())
1578        .map(|v| v.inner)
1579        .unwrap_or_default();
1580
1581    StyleBorderRadius {
1582        top_left,
1583        top_right,
1584        bottom_right,
1585        bottom_left,
1586    }
1587}
1588
1589/// Get border radius for all four corners (resolved to pixels)
1590///
1591/// # Arguments
1592/// * `element_size` - The element's own size (width × height) for % resolution. According to CSS
1593///   spec, border-radius % uses element's own dimensions.
1594#[must_use] pub fn get_border_radius(
1595    styled_dom: &StyledDom,
1596    node_id: NodeId,
1597    node_state: &StyledNodeState,
1598    element_size: PhysicalSizeImport,
1599    viewport_size: LogicalSize,
1600) -> BorderRadius {
1601    use azul_css::props::basic::{PhysicalSize, PropertyContext, ResolutionContext};
1602
1603    // FAST PATH: all four corners as i16 px × 10 in tier2_cold. The
1604    // overwhelmingly common case (no rounded corners) reads four bytes and
1605    // returns zeros without a cascade walk.
1606    if node_state.is_normal() {
1607        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1608            let idx = node_id.index();
1609            let tl = cc.get_border_top_left_radius_raw(idx);
1610            let tr = cc.get_border_top_right_radius_raw(idx);
1611            let br = cc.get_border_bottom_right_radius_raw(idx);
1612            let bl = cc.get_border_bottom_left_radius_raw(idx);
1613            // sentinel = "unset" = 0 px (no corner radius)
1614            let thresh = azul_css::compact_cache::I16_SENTINEL_THRESHOLD;
1615            let decode = |raw: i16| -> f32 {
1616                if raw >= thresh {
1617                    0.0
1618                } else {
1619                    f32::from(raw) / 10.0
1620                }
1621            };
1622            return BorderRadius {
1623                top_left: decode(tl),
1624                top_right: decode(tr),
1625                bottom_right: decode(br),
1626                bottom_left: decode(bl),
1627            };
1628        }
1629    }
1630
1631    let node_data = &styled_dom.node_data.as_container()[node_id];
1632
1633    // Get font sizes for em/rem resolution
1634    let element_font_size = get_element_font_size(styled_dom, node_id, node_state);
1635    let parent_font_size = styled_dom
1636        .node_hierarchy
1637        .as_container()
1638        .get(node_id)
1639        .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
1640        .map_or(DEFAULT_FONT_SIZE, |p| get_element_font_size(styled_dom, p, node_state));
1641    let root_font_size = get_root_font_size(styled_dom, node_state);
1642
1643    // Create resolution context
1644    let context = ResolutionContext {
1645        element_font_size,
1646        parent_font_size,
1647        root_font_size,
1648        containing_block_size: PhysicalSize::new(0.0, 0.0), // Not used for border-radius
1649        element_size: Some(PhysicalSize::new(element_size.width, element_size.height)),
1650        viewport_size: PhysicalSize::new(viewport_size.width, viewport_size.height),
1651    };
1652
1653    let top_left = styled_dom
1654        .css_property_cache
1655        .ptr
1656        .get_border_top_left_radius(node_data, &node_id, node_state)
1657        .and_then(|br| br.get_property().copied())
1658        .unwrap_or_default();
1659
1660    let top_right = styled_dom
1661        .css_property_cache
1662        .ptr
1663        .get_border_top_right_radius(node_data, &node_id, node_state)
1664        .and_then(|br| br.get_property().copied())
1665        .unwrap_or_default();
1666
1667    let bottom_right = styled_dom
1668        .css_property_cache
1669        .ptr
1670        .get_border_bottom_right_radius(node_data, &node_id, node_state)
1671        .and_then(|br| br.get_property().copied())
1672        .unwrap_or_default();
1673
1674    let bottom_left = styled_dom
1675        .css_property_cache
1676        .ptr
1677        .get_border_bottom_left_radius(node_data, &node_id, node_state)
1678        .and_then(|br| br.get_property().copied())
1679        .unwrap_or_default();
1680
1681    BorderRadius {
1682        top_left: top_left
1683            .inner
1684            .resolve_with_context(&context, PropertyContext::BorderRadius),
1685        top_right: top_right
1686            .inner
1687            .resolve_with_context(&context, PropertyContext::BorderRadius),
1688        bottom_right: bottom_right
1689            .inner
1690            .resolve_with_context(&context, PropertyContext::BorderRadius),
1691        bottom_left: bottom_left
1692            .inner
1693            .resolve_with_context(&context, PropertyContext::BorderRadius),
1694    }
1695}
1696
1697// +spec:stacking-contexts:a93e62 - stack level from z-index for stacking context ordering
1698// +spec:stacking-contexts:ae50ae - z-index specifies stack level; auto resolves to 0 (inherited from parent stacking context)
1699/// Get z-index for stacking context ordering.
1700///
1701/// Returns the resolved integer z-index value:
1702/// - `z-index: auto` → 0 (participates in parent's stacking context)
1703/// - `z-index: <integer>` → that integer value
1704#[must_use] pub fn get_z_index(styled_dom: &StyledDom, node_id: Option<NodeId>) -> i32 {
1705    use azul_css::props::layout::position::LayoutZIndex;
1706
1707    let Some(node_id) = node_id else {
1708        return 0;
1709    };
1710
1711    let node_state = &styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
1712
1713    // FAST PATH: compact cache for normal state
1714    if node_state.is_normal() {
1715        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1716            let raw = cc.get_z_index(node_id.index());
1717            if raw == azul_css::compact_cache::I16_AUTO {
1718                return 0;
1719            }
1720            if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
1721                return i32::from(raw);
1722            }
1723            // I16_SENTINEL → fall through to slow path
1724        }
1725    }
1726
1727    // SLOW PATH
1728    let node_data = &styled_dom.node_data.as_container()[node_id];
1729
1730    styled_dom
1731        .css_property_cache
1732        .ptr
1733        .get_z_index(node_data, &node_id, node_state)
1734        .and_then(|v| v.get_property())
1735        .map_or(0, |z| match z {
1736            LayoutZIndex::Auto => 0,
1737            LayoutZIndex::Integer(i) => *i,
1738        })
1739}
1740
1741// +spec:positioning:c041c4 - positioned elements with z-index != auto establish stacking contexts
1742// z-index:<integer> ALWAYS establishes new stacking context on positioned elements
1743/// Returns true if z-index is `auto` (the initial value), false if it's an explicit `<integer>`.
1744/// This distinction matters for stacking context creation per §9.9.1.
1745#[must_use] pub fn is_z_index_auto(styled_dom: &StyledDom, node_id: Option<NodeId>) -> bool {
1746    use azul_css::props::layout::position::LayoutZIndex;
1747
1748    let Some(node_id) = node_id else {
1749        return true;
1750    };
1751
1752    let node_state = &styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
1753
1754    // FAST PATH: compact cache for normal state
1755    if node_state.is_normal() {
1756        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1757            let raw = cc.get_z_index(node_id.index());
1758            if raw == azul_css::compact_cache::I16_AUTO {
1759                return true;
1760            }
1761            if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
1762                return false; // explicit integer
1763            }
1764            // I16_SENTINEL → fall through to slow path
1765        }
1766    }
1767
1768    // SLOW PATH
1769    let node_data = &styled_dom.node_data.as_container()[node_id];
1770
1771    styled_dom
1772        .css_property_cache
1773        .ptr
1774        .get_z_index(node_data, &node_id, node_state)
1775        .and_then(|v| v.get_property())
1776        .is_none_or(|z| matches!(z, LayoutZIndex::Auto)) // no value = auto
1777}
1778
1779// Rendering Property Getters
1780
1781/// Information about background color for a node
1782///
1783/// # CSS Background Propagation (Special Case for HTML Root)
1784///
1785/// According to CSS Backgrounds and Borders Module Level 3, Section "The Canvas Background
1786/// and the HTML `<body>` Element":
1787///
1788/// For HTML documents where the root element is `<html>`, if the computed value of
1789/// `background-image` on the root element is `none` AND its `background-color` is `transparent`,
1790/// user agents **must propagate** the computed values of the background properties from the
1791/// first `<body>` child element to the root element.
1792///
1793/// This behavior exists for backwards compatibility with older HTML where backgrounds were
1794/// typically set on `<body>` using `bgcolor` attributes, and ensures that the `<body>`
1795/// background covers the entire viewport/canvas even when `<body>` itself has constrained
1796/// dimensions.
1797///
1798/// Implementation: When requesting the background of an `<html>` node, we first check if it
1799/// has a transparent background with no image. If so, we look for a `<body>` child and use
1800/// its background instead.
1801#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1802#[must_use] pub fn get_background_color(
1803    styled_dom: &StyledDom,
1804    node_id: NodeId,
1805    node_state: &StyledNodeState,
1806) -> ColorU {
1807    let node_data = &styled_dom.node_data.as_container()[node_id];
1808    let cache = &styled_dom.css_property_cache.ptr;
1809
1810    // Fast path: Get this node's background.
1811    // Negative fast path: if compact cache says `has_background == 0` on a
1812    // normal-state node, skip the cascade walk entirely. Only declared backgrounds
1813    // set the bit, so `false` is a safe "unconditionally transparent" signal.
1814    let get_node_bg = |nid: NodeId, ndata: &azul_core::dom::NodeData, state: &StyledNodeState| {
1815        if state.is_normal() {
1816            if let Some(ref cc) = cache.compact_cache {
1817                if !cc.has_background(nid.index()) {
1818                    return None;
1819                }
1820            }
1821        }
1822        cache
1823            .get_background_content(ndata, &nid, state)
1824            .and_then(|bg| bg.get_property())
1825            .and_then(|bg_vec| bg_vec.get(0).cloned())
1826            .and_then(|first_bg| match &first_bg {
1827                azul_css::props::style::StyleBackgroundContent::Color(color) => Some(*color),
1828                azul_css::props::style::StyleBackgroundContent::Image(_) => None, // Has image, not transparent
1829                _ => None,
1830            })
1831    };
1832
1833    let own_bg = get_node_bg(node_id, node_data, node_state);
1834
1835    // CSS Background Propagation: Special handling for <html> root element
1836    // Only check propagation if this is an Html node AND has transparent background (no
1837    // color/image)
1838    if !matches!(node_data.node_type, NodeType::Html) || own_bg.is_some() {
1839        // Not Html or has its own background - return own background or transparent
1840        return own_bg.unwrap_or(ColorU {
1841            r: 0,
1842            g: 0,
1843            b: 0,
1844            a: 0,
1845        });
1846    }
1847
1848    // Html node with transparent background - check if we should propagate from <body>
1849    let first_child = styled_dom
1850        .node_hierarchy
1851        .as_container()
1852        .get(node_id)
1853        .and_then(|node| node.first_child_id(node_id));
1854
1855    let Some(first_child) = first_child else {
1856        return ColorU {
1857            r: 0,
1858            g: 0,
1859            b: 0,
1860            a: 0,
1861        };
1862    };
1863
1864    let first_child_data = &styled_dom.node_data.as_container()[first_child];
1865
1866    // Check if first child is <body>
1867    if !matches!(first_child_data.node_type, NodeType::Body) {
1868        return ColorU {
1869            r: 0,
1870            g: 0,
1871            b: 0,
1872            a: 0,
1873        };
1874    }
1875
1876    // Propagate <body>'s background to <html> (canvas)
1877    let first_child_state = &styled_dom.styled_nodes.as_container()[first_child].styled_node_state;
1878    get_node_bg(first_child, first_child_data, first_child_state).unwrap_or(ColorU {
1879        r: 0,
1880        g: 0,
1881        b: 0,
1882        a: 0,
1883    })
1884}
1885
1886/// Returns all background content layers for a node (colors, gradients, images).
1887/// This is used for rendering backgrounds that may include linear/radial/conic gradients.
1888///
1889/// CSS Background Propagation (CSS Backgrounds 3, Section 2.11.2):
1890/// For HTML documents, if the root `<html>` element has no background (transparent with no image),
1891/// propagate the background from the first `<body>` child element.
1892#[must_use] pub fn get_background_contents(
1893    styled_dom: &StyledDom,
1894    node_id: NodeId,
1895    node_state: &StyledNodeState,
1896) -> Vec<azul_css::props::style::StyleBackgroundContent> {
1897    use azul_core::dom::NodeType;
1898    use azul_css::props::style::StyleBackgroundContent;
1899
1900    let node_data = &styled_dom.node_data.as_container()[node_id];
1901    let cache = &styled_dom.css_property_cache.ptr;
1902
1903    // Helper to get backgrounds for a node.
1904    // Negative fast path: if compact cache says `has_background == 0` on a normal
1905    // pseudo-state node, return empty without walking the cascade.
1906    let get_node_backgrounds = |nid: NodeId,
1907                                ndata: &azul_core::dom::NodeData,
1908                                state: &StyledNodeState|
1909     -> Vec<StyleBackgroundContent> {
1910        if state.is_normal() {
1911            if let Some(ref cc) = cache.compact_cache {
1912                if !cc.has_background(nid.index()) {
1913                    return Vec::new();
1914                }
1915            }
1916        }
1917        cache
1918            .get_background_content(ndata, &nid, state)
1919            .and_then(|bg| bg.get_property())
1920            .map(|bg_vec| bg_vec.iter().cloned().collect())
1921            .unwrap_or_default()
1922    };
1923
1924    let own_backgrounds = get_node_backgrounds(node_id, node_data, node_state);
1925
1926    // CSS Background Propagation: Special handling for <html> root element
1927    // Only check propagation if this is an Html node AND has no backgrounds
1928    if !matches!(node_data.node_type, NodeType::Html) || !own_backgrounds.is_empty() {
1929        return own_backgrounds;
1930    }
1931
1932    // Html node with no backgrounds - check if we should propagate from <body>
1933    let first_child = styled_dom
1934        .node_hierarchy
1935        .as_container()
1936        .get(node_id)
1937        .and_then(|node| node.first_child_id(node_id));
1938
1939    let Some(first_child) = first_child else {
1940        return own_backgrounds;
1941    };
1942
1943    let first_child_data = &styled_dom.node_data.as_container()[first_child];
1944
1945    // Check if first child is <body>
1946    if !matches!(first_child_data.node_type, NodeType::Body) {
1947        return own_backgrounds;
1948    }
1949
1950    // Propagate <body>'s backgrounds to <html> (canvas)
1951    let first_child_state = &styled_dom.styled_nodes.as_container()[first_child].styled_node_state;
1952    get_node_backgrounds(first_child, first_child_data, first_child_state)
1953}
1954
1955/// Information about border rendering
1956#[derive(Copy, Clone, Debug)]
1957pub struct BorderInfo {
1958    pub widths: crate::solver3::display_list::StyleBorderWidths,
1959    pub colors: crate::solver3::display_list::StyleBorderColors,
1960    pub styles: crate::solver3::display_list::StyleBorderStyles,
1961}
1962
1963#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1964#[must_use] pub fn get_border_info(
1965    styled_dom: &StyledDom,
1966    node_id: NodeId,
1967    node_state: &StyledNodeState,
1968) -> BorderInfo {
1969    use crate::solver3::display_list::{StyleBorderColors, StyleBorderStyles, StyleBorderWidths};
1970    use azul_css::css::CssPropertyValue;
1971    use azul_css::props::basic::color::ColorU;
1972    use azul_css::props::basic::pixel::PixelValue;
1973    use azul_css::props::style::border::{
1974        BorderStyle, StyleBorderBottomColor, StyleBorderBottomStyle, StyleBorderLeftColor,
1975        StyleBorderLeftStyle, StyleBorderRightColor, StyleBorderRightStyle, StyleBorderTopColor,
1976        StyleBorderTopStyle,
1977    };
1978    use azul_css::props::style::{
1979        LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth,
1980        LayoutBorderTopWidth,
1981    };
1982
1983    // FAST PATH: compact cache for normal state
1984    if node_state.is_normal() {
1985        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1986            let idx = node_id.index();
1987
1988            // Border widths: decode from compact i16 (resolved px × 10).
1989            // Previously this block called the slow convenience getters
1990            // despite being in the "fast path" branch — 2014 slow walks
1991            // per width × 4 widths per cold excel.html layout. Fixed
1992            // 2026-04-17.
1993            let make_width_px = |raw: i16| -> Option<PixelValue> {
1994                if raw == azul_css::compact_cache::I16_AUTO
1995                    || raw == azul_css::compact_cache::I16_INITIAL
1996                    || raw >= azul_css::compact_cache::I16_SENTINEL_THRESHOLD
1997                {
1998                    None
1999                } else {
2000                    Some(PixelValue::px(f32::from(raw) / 10.0))
2001                }
2002            };
2003            let widths = StyleBorderWidths {
2004                top: make_width_px(cc.get_border_top_width_raw(idx))
2005                    .map(|px| CssPropertyValue::Exact(LayoutBorderTopWidth { inner: px })),
2006                right: make_width_px(cc.get_border_right_width_raw(idx))
2007                    .map(|px| CssPropertyValue::Exact(LayoutBorderRightWidth { inner: px })),
2008                bottom: make_width_px(cc.get_border_bottom_width_raw(idx))
2009                    .map(|px| CssPropertyValue::Exact(LayoutBorderBottomWidth { inner: px })),
2010                left: make_width_px(cc.get_border_left_width_raw(idx))
2011                    .map(|px| CssPropertyValue::Exact(LayoutBorderLeftWidth { inner: px })),
2012            };
2013
2014            // Border colors from compact cache
2015            let make_color = |raw: u32| -> Option<ColorU> {
2016                if raw == 0 {
2017                    None
2018                } else {
2019                    Some(ColorU {
2020                        r: ((raw >> 24) & 0xFF) as u8,
2021                        g: ((raw >> 16) & 0xFF) as u8,
2022                        b: ((raw >> 8) & 0xFF) as u8,
2023                        a: (raw & 0xFF) as u8,
2024                    })
2025                }
2026            };
2027
2028            let colors = StyleBorderColors {
2029                top: make_color(cc.get_border_top_color_raw(idx))
2030                    .map(|c| CssPropertyValue::Exact(StyleBorderTopColor { inner: c })),
2031                right: make_color(cc.get_border_right_color_raw(idx))
2032                    .map(|c| CssPropertyValue::Exact(StyleBorderRightColor { inner: c })),
2033                bottom: make_color(cc.get_border_bottom_color_raw(idx))
2034                    .map(|c| CssPropertyValue::Exact(StyleBorderBottomColor { inner: c })),
2035                left: make_color(cc.get_border_left_color_raw(idx))
2036                    .map(|c| CssPropertyValue::Exact(StyleBorderLeftColor { inner: c })),
2037            };
2038
2039            // Border styles from compact cache
2040            let styles = StyleBorderStyles {
2041                top: Some(CssPropertyValue::Exact(StyleBorderTopStyle {
2042                    inner: cc.get_border_top_style(idx),
2043                })),
2044                right: Some(CssPropertyValue::Exact(StyleBorderRightStyle {
2045                    inner: cc.get_border_right_style(idx),
2046                })),
2047                bottom: Some(CssPropertyValue::Exact(StyleBorderBottomStyle {
2048                    inner: cc.get_border_bottom_style(idx),
2049                })),
2050                left: Some(CssPropertyValue::Exact(StyleBorderLeftStyle {
2051                    inner: cc.get_border_left_style(idx),
2052                })),
2053            };
2054
2055            return BorderInfo {
2056                widths,
2057                colors,
2058                styles,
2059            };
2060        }
2061    }
2062
2063    // SLOW PATH: full cascade
2064    let node_data = &styled_dom.node_data.as_container()[node_id];
2065
2066    // Get all border widths
2067    let widths = StyleBorderWidths {
2068        top: styled_dom
2069            .css_property_cache
2070            .ptr
2071            .get_border_top_width(node_data, &node_id, node_state)
2072            .copied(),
2073        right: styled_dom
2074            .css_property_cache
2075            .ptr
2076            .get_border_right_width(node_data, &node_id, node_state)
2077            .copied(),
2078        bottom: styled_dom
2079            .css_property_cache
2080            .ptr
2081            .get_border_bottom_width(node_data, &node_id, node_state)
2082            .copied(),
2083        left: styled_dom
2084            .css_property_cache
2085            .ptr
2086            .get_border_left_width(node_data, &node_id, node_state)
2087            .copied(),
2088    };
2089
2090    // Get all border colors
2091    let colors = StyleBorderColors {
2092        top: styled_dom
2093            .css_property_cache
2094            .ptr
2095            .get_border_top_color(node_data, &node_id, node_state)
2096            .copied(),
2097        right: styled_dom
2098            .css_property_cache
2099            .ptr
2100            .get_border_right_color(node_data, &node_id, node_state)
2101            .copied(),
2102        bottom: styled_dom
2103            .css_property_cache
2104            .ptr
2105            .get_border_bottom_color(node_data, &node_id, node_state)
2106            .copied(),
2107        left: styled_dom
2108            .css_property_cache
2109            .ptr
2110            .get_border_left_color(node_data, &node_id, node_state)
2111            .copied(),
2112    };
2113
2114    // Get all border styles
2115    let styles = StyleBorderStyles {
2116        top: styled_dom
2117            .css_property_cache
2118            .ptr
2119            .get_border_top_style(node_data, &node_id, node_state)
2120            .copied(),
2121        right: styled_dom
2122            .css_property_cache
2123            .ptr
2124            .get_border_right_style(node_data, &node_id, node_state)
2125            .copied(),
2126        bottom: styled_dom
2127            .css_property_cache
2128            .ptr
2129            .get_border_bottom_style(node_data, &node_id, node_state)
2130            .copied(),
2131        left: styled_dom
2132            .css_property_cache
2133            .ptr
2134            .get_border_left_style(node_data, &node_id, node_state)
2135            .copied(),
2136    };
2137
2138    BorderInfo {
2139        widths,
2140        colors,
2141        styles,
2142    }
2143}
2144
2145/// Convert `BorderInfo` to `InlineBorderInfo` for inline elements
2146///
2147/// This resolves the CSS property values to concrete pixel values and colors
2148/// that can be used during text rendering.
2149#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2150fn get_inline_border_info(
2151    styled_dom: &StyledDom,
2152    node_id: NodeId,
2153    node_state: &StyledNodeState,
2154    border_info: &BorderInfo,
2155    viewport: PhysicalSize,
2156) -> Option<crate::text3::cache::InlineBorderInfo> {
2157    use crate::text3::cache::InlineBorderInfo;
2158
2159    // Fetch padding values for inline elements. Viewport units (vw/vh/...) resolve
2160    // against the real viewport instead of being treated as raw pixels.
2161    fn resolve_padding(
2162        mv: MultiValue<PixelValue>,
2163        viewport: PhysicalSize,
2164    ) -> f32 {
2165        match mv {
2166            MultiValue::Exact(pv) => super::calc::resolve_pixel_value_with_viewport(
2167                &pv,
2168                0.0,
2169                DEFAULT_FONT_SIZE,
2170                DEFAULT_FONT_SIZE,
2171                viewport.width,
2172                viewport.height,
2173            ),
2174            _ => 0.0,
2175        }
2176    }
2177
2178    macro_rules! border_width_px {
2179        ($field:expr) => {
2180            $field
2181                .as_ref()
2182                .and_then(|v| v.get_property())
2183                .map(|w| w.inner.number.get())
2184                .unwrap_or(0.0)
2185        };
2186    }
2187
2188    macro_rules! border_color {
2189        ($field:expr) => {
2190            $field
2191                .as_ref()
2192                .and_then(|v| v.get_property())
2193                .map(|c| c.inner)
2194                .unwrap_or(ColorU::BLACK)
2195        };
2196    }
2197
2198    // Extract border-radius (simplified - uses the average of all corners if uniform)
2199    fn get_border_radius_px(
2200        styled_dom: &StyledDom,
2201        node_id: NodeId,
2202        node_state: &StyledNodeState,
2203    ) -> Option<f32> {
2204        let node_data = &styled_dom.node_data.as_container()[node_id];
2205
2206        let top_left = styled_dom
2207            .css_property_cache
2208            .ptr
2209            .get_border_top_left_radius(node_data, &node_id, node_state)
2210            .and_then(|br| br.get_property().copied())
2211            .map(|v| v.inner.number.get());
2212
2213        let top_right = styled_dom
2214            .css_property_cache
2215            .ptr
2216            .get_border_top_right_radius(node_data, &node_id, node_state)
2217            .and_then(|br| br.get_property().copied())
2218            .map(|v| v.inner.number.get());
2219
2220        let bottom_left = styled_dom
2221            .css_property_cache
2222            .ptr
2223            .get_border_bottom_left_radius(node_data, &node_id, node_state)
2224            .and_then(|br| br.get_property().copied())
2225            .map(|v| v.inner.number.get());
2226
2227        let bottom_right = styled_dom
2228            .css_property_cache
2229            .ptr
2230            .get_border_bottom_right_radius(node_data, &node_id, node_state)
2231            .and_then(|br| br.get_property().copied())
2232            .map(|v| v.inner.number.get());
2233
2234        // If any radius is defined, use the maximum (for inline, uniform radius is most common)
2235        let radii: Vec<f32> = [top_left, top_right, bottom_left, bottom_right]
2236            .into_iter()
2237            .flatten()
2238            .collect();
2239
2240        if radii.is_empty() {
2241            None
2242        } else {
2243            Some(radii.into_iter().fold(0.0f32, f32::max))
2244        }
2245    }
2246
2247    let top = border_width_px!(&border_info.widths.top);
2248    let right = border_width_px!(&border_info.widths.right);
2249    let bottom = border_width_px!(&border_info.widths.bottom);
2250    let left = border_width_px!(&border_info.widths.left);
2251
2252    let p_top = resolve_padding(get_css_padding_top(styled_dom, node_id, node_state), viewport);
2253    let p_right = resolve_padding(get_css_padding_right(styled_dom, node_id, node_state), viewport);
2254    let p_bottom = resolve_padding(get_css_padding_bottom(styled_dom, node_id, node_state), viewport);
2255    let p_left = resolve_padding(get_css_padding_left(styled_dom, node_id, node_state), viewport);
2256
2257    // Only return Some if there's actually a border or padding
2258    let has_border = top > 0.0 || right > 0.0 || bottom > 0.0 || left > 0.0;
2259    let has_padding = p_top > 0.0 || p_right > 0.0 || p_bottom > 0.0 || p_left > 0.0;
2260    if !has_border && !has_padding {
2261        return None;
2262    }
2263
2264    // CSS 2.2 §8.6: detect direction for visual-order border/padding rendering in bidi
2265    let is_rtl = matches!(
2266        get_direction_property(styled_dom, node_id, node_state),
2267        MultiValue::Exact(StyleDirection::Rtl)
2268    );
2269
2270    Some(InlineBorderInfo {
2271        top,
2272        right,
2273        bottom,
2274        left,
2275        top_color: border_color!(&border_info.colors.top),
2276        right_color: border_color!(&border_info.colors.right),
2277        bottom_color: border_color!(&border_info.colors.bottom),
2278        left_color: border_color!(&border_info.colors.left),
2279        radius: get_border_radius_px(styled_dom, node_id, node_state),
2280        padding_top: p_top,
2281        padding_right: p_right,
2282        padding_bottom: p_bottom,
2283        padding_left: p_left,
2284        is_first_fragment: true,
2285        is_last_fragment: true,
2286        is_rtl,
2287    })
2288}
2289
2290// Selection and Caret Styling
2291
2292/// Style information for text selection rendering
2293#[derive(Debug, Clone, Copy, Default)]
2294pub struct SelectionStyle {
2295    /// Background color of the selection highlight
2296    pub bg_color: ColorU,
2297    /// Text color when selected (overrides normal text color)
2298    pub text_color: Option<ColorU>,
2299    /// Border radius for selection rectangles
2300    pub radius: f32,
2301}
2302
2303/// Get selection style for a node
2304#[must_use] pub fn get_selection_style(
2305    styled_dom: &StyledDom,
2306    node_id: Option<NodeId>,
2307    system_style: Option<&std::sync::Arc<azul_css::system::SystemStyle>>,
2308) -> SelectionStyle {
2309    let Some(node_id) = node_id else {
2310        return SelectionStyle::default();
2311    };
2312
2313    let node_data = &styled_dom.node_data.as_container()[node_id];
2314    let node_state = &StyledNodeState::default();
2315
2316    // Try to get selection background from CSS, otherwise use system color, otherwise hard-coded default
2317    let default_bg = system_style
2318        .and_then(|ss| ss.colors.selection_background.as_option().copied())
2319        .unwrap_or(ColorU {
2320            r: 51,
2321            g: 153,
2322            b: 255, // Standard blue selection color
2323            a: 128, // Semi-transparent
2324        });
2325
2326    let bg_color = styled_dom
2327        .css_property_cache
2328        .ptr
2329        .get_selection_background_color(node_data, &node_id, node_state)
2330        .and_then(|c| c.get_property().copied())
2331        .map_or(default_bg, |c| c.inner);
2332
2333    // Try to get selection text color from CSS, otherwise use system color
2334    let default_text = system_style.and_then(|ss| ss.colors.selection_text.as_option().copied());
2335
2336    let text_color = styled_dom
2337        .css_property_cache
2338        .ptr
2339        .get_selection_color(node_data, &node_id, node_state)
2340        .and_then(|c| c.get_property().copied())
2341        .map(|c| c.inner)
2342        .or(default_text);
2343
2344    let radius = styled_dom
2345        .css_property_cache
2346        .ptr
2347        .get_selection_radius(node_data, &node_id, node_state)
2348        .and_then(|r| r.get_property().copied())
2349        .map_or(0.0, |r| r.inner.to_pixels_internal(0.0, DEFAULT_EM_SIZE, DEFAULT_EM_SIZE));
2350
2351    SelectionStyle {
2352        bg_color,
2353        text_color,
2354        radius,
2355    }
2356}
2357
2358/// Style information for caret rendering.
2359#[derive(Debug, Clone, Copy)]
2360pub struct CaretStyle {
2361    /// Color of the caret bar
2362    pub color: ColorU,
2363    /// Width of the caret bar in pixels
2364    pub width: f32,
2365    /// Blink animation duration in milliseconds (0 = no blink)
2366    pub animation_duration: u32,
2367}
2368
2369impl Default for CaretStyle {
2370    fn default() -> Self {
2371        Self {
2372            color: ColorU::BLACK,
2373            width: DEFAULT_CARET_WIDTH_PX,
2374            animation_duration: DEFAULT_CARET_BLINK_MS,
2375        }
2376    }
2377}
2378
2379/// Get caret style for a node
2380#[must_use] pub fn get_caret_style(styled_dom: &StyledDom, node_id: Option<NodeId>) -> CaretStyle {
2381    let Some(node_id) = node_id else {
2382        return CaretStyle::default();
2383    };
2384
2385    let node_data = &styled_dom.node_data.as_container()[node_id];
2386    let node_state = &StyledNodeState::default();
2387
2388    let color = styled_dom
2389        .css_property_cache
2390        .ptr
2391        .get_caret_color(node_data, &node_id, node_state)
2392        .and_then(|c| c.get_property().copied())
2393        // CSS `caret-color: auto` (the initial value) resolves to currentColor — the
2394        // element's text color — which by construction contrasts with the background.
2395        // Falling back to BLACK made the caret invisible on dark backgrounds / dark
2396        // system themes (and `color` IS inherited while `caret-color` may not be, so a
2397        // child text node still gets the right colour here).
2398        .map_or_else(|| {
2399            styled_dom
2400                .css_property_cache
2401                .ptr
2402                .get_text_color_or_default(node_data, &node_id, node_state)
2403                .inner
2404        }, |c| c.inner);
2405
2406    let width = styled_dom
2407        .css_property_cache
2408        .ptr
2409        .get_caret_width(node_data, &node_id, node_state)
2410        .and_then(|w| w.get_property().copied())
2411        .map_or(DEFAULT_CARET_WIDTH_PX, |w| w.inner.to_pixels_internal(0.0, DEFAULT_EM_SIZE, DEFAULT_EM_SIZE));
2412
2413    let animation_duration = styled_dom
2414        .css_property_cache
2415        .ptr
2416        .get_caret_animation_duration(node_data, &node_id, node_state)
2417        .and_then(|d| d.get_property().copied())
2418        .map_or(DEFAULT_CARET_BLINK_MS, |d| d.inner.inner);
2419
2420    CaretStyle {
2421        color,
2422        width,
2423        animation_duration,
2424    }
2425}
2426
2427// Scrollbar Information
2428
2429/// Get scrollbar information from a layout node.
2430///
2431/// Scrollbar requirements are computed during the layout phase in two paths:
2432/// - BFC layout: `compute_scrollbar_info()` in cache.rs
2433/// - Taffy layout: set in the measure callback in `taffy_bridge.rs`
2434///
2435/// If neither path set `scrollbar_info`, the node genuinely does not need
2436/// scrollbars. The previous heuristic (>3 children = force overflow) caused
2437/// false-positive scrollbars on normal containers.
2438#[must_use] pub fn get_scrollbar_info_from_layout(node: &LayoutNode) -> ScrollbarRequirements {
2439    node.scrollbar_info.unwrap_or_default()
2440}
2441
2442/// Resolve the **layout-effective** scrollbar width for a node, in pixels.
2443///
2444/// This combines three inputs:
2445/// 1. CSS `scrollbar-width` property on the node (`auto` → 16, `thin` → 8, `none` → 0)
2446/// 2. OS-level `ScrollbarPreferences.visibility` (overlay scrollbars → 0 layout reservation)
2447/// 3. Custom `-azul-scrollbar-style` width override
2448///
2449/// For **overlay** scrollbars (macOS `WhenScrolling`, or equivalent), this returns `0.0`
2450/// because overlay scrollbars are painted on top of content and do not consume layout space.
2451/// The scrollbar is still *rendered*, but no space is reserved during layout.
2452// +spec:overflow:b83014 - overlay scrollbars do not create scrollbar gutters
2453///
2454/// During display-list generation, use `get_scrollbar_style()` instead — that returns
2455/// the full visual style including the *paint* width (which may be non-zero for overlay).
2456pub fn get_layout_scrollbar_width_px<T: ParsedFontTrait>(
2457    ctx: &crate::solver3::LayoutContext<'_, T>,
2458    dom_id: NodeId,
2459    styled_node_state: &StyledNodeState,
2460) -> f32 {
2461    // Resolve the full scrollbar style (includes per-node CSS overrides + system style).
2462    // `reserve_width_px` already accounts for overlay vs legacy:
2463    //   overlay (WhenScrolling) → 0.0
2464    //   legacy (Always)         → visual_width_px
2465    let style = get_scrollbar_style(
2466        ctx.styled_dom,
2467        dom_id,
2468        styled_node_state,
2469        ctx.system_style.as_deref(),
2470    );
2471    style.reserve_width_px
2472}
2473
2474get_css_property!(
2475    get_display_property_internal,
2476    get_display,
2477    LayoutDisplay,
2478    CssPropertyType::Display,
2479    compact = get_display
2480);
2481
2482#[must_use] pub fn get_display_property(
2483    styled_dom: &StyledDom,
2484    dom_id: Option<NodeId>,
2485) -> MultiValue<LayoutDisplay> {
2486    let Some(id) = dom_id else {
2487        return MultiValue::Exact(LayoutDisplay::Inline);
2488    };
2489    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
2490    get_display_property_internal(styled_dom, id, node_state)
2491}
2492
2493/// CSS Display Module Level 3: Blockification of display values.
2494///
2495/// When an element is floated, absolutely positioned, or is the root element,
2496/// its computed display value may be "blockified" per the table in CSS Display 3 §2.7.
2497/// This function returns the blockified display value without mutating any state.
2498#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2499#[must_use] pub const fn blockify_display(raw_display: LayoutDisplay) -> LayoutDisplay {
2500    match raw_display {
2501        // Inline-level display types become their block-level equivalents
2502        LayoutDisplay::Inline => LayoutDisplay::Block,
2503        // Per CSS Display 3 §2.7: inline-block blockifies to block
2504        // (for legacy reasons, loses its flow-root nature)
2505        LayoutDisplay::InlineBlock => LayoutDisplay::Block,
2506        LayoutDisplay::InlineFlex => LayoutDisplay::Flex,
2507        LayoutDisplay::InlineTable => LayoutDisplay::Table,
2508        LayoutDisplay::InlineGrid => LayoutDisplay::Grid,
2509        // CSS 2.2 §9.7: table-internal display values blockify to block
2510        // for absolutely positioned, floated, or root elements
2511        LayoutDisplay::TableRowGroup
2512        | LayoutDisplay::TableColumn
2513        | LayoutDisplay::TableColumnGroup
2514        | LayoutDisplay::TableHeaderGroup
2515        | LayoutDisplay::TableFooterGroup
2516        | LayoutDisplay::TableRow
2517        | LayoutDisplay::TableCell
2518        | LayoutDisplay::TableCaption => LayoutDisplay::Block,
2519        // Already block-level types are unchanged
2520        other => other,
2521    }
2522}
2523
2524// +spec:positioning:c31c24 - blockification is a computed-value change for absolute/float/root elements
2525/// Resolves the computed display value for an element, applying blockification
2526/// rules per CSS Display Module Level 3 §2.7.
2527// +spec:display-property:641ac5 - computed display value applies blockification/inlinification (not "as specified")
2528///
2529/// This centralizes the blockification decision so that all layout phases
2530/// (`layout_tree`, sizing, positioning) use consistent display values.
2531// +spec:floats:52aea6 - computed display blockified for floated/positioned/root elements
2532// +spec:positioning:ce02a1 - out-of-flow boxes (floated or absolutely positioned) get blockified display
2533// four independent layout-state flags drive the blockification decision; bundling them
2534// into a struct would add ceremony without clarifying this pure decision function.
2535#[allow(clippy::fn_params_excessive_bools)]
2536#[must_use] pub fn get_computed_display(
2537    raw_display: LayoutDisplay,
2538    is_absolute_or_fixed: bool,
2539    is_floated: bool,
2540    is_root: bool,
2541    is_flex_grid_child: bool,
2542) -> LayoutDisplay {
2543    if raw_display == LayoutDisplay::None {
2544        return LayoutDisplay::None;
2545    }
2546    // +spec:positioning:69468c - absolute/fixed blockifies the box
2547    if is_absolute_or_fixed || is_floated || is_root || is_flex_grid_child {
2548        blockify_display(raw_display)
2549    } else {
2550        raw_display
2551    }
2552}
2553
2554// +spec:font-metrics:f7affa - vertical-align shorthand: maps CSS vertical-align values to inline layout alignment
2555/// Reads the CSS `vertical-align` property for a DOM node and converts it to
2556/// the text3 `VerticalAlign` enum used during inline layout.
2557// +spec:display-property:24c160 - vertical-align aligns inline-level box within the line
2558#[must_use] pub fn get_vertical_align_for_node(
2559    styled_dom: &StyledDom,
2560    dom_id: NodeId,
2561) -> crate::text3::cache::VerticalAlign {
2562    let node_state = &styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2563    let va = match get_vertical_align_property(styled_dom, dom_id, node_state) {
2564        MultiValue::Exact(v) => v,
2565        _ => StyleVerticalAlign::default(),
2566    };
2567    match va {
2568        StyleVerticalAlign::Baseline => crate::text3::cache::VerticalAlign::Baseline,
2569        StyleVerticalAlign::Top => crate::text3::cache::VerticalAlign::Top,
2570        StyleVerticalAlign::Middle => crate::text3::cache::VerticalAlign::Middle,
2571        StyleVerticalAlign::Bottom => crate::text3::cache::VerticalAlign::Bottom,
2572        StyleVerticalAlign::Sub => crate::text3::cache::VerticalAlign::Sub,
2573        StyleVerticalAlign::Superscript => crate::text3::cache::VerticalAlign::Super,
2574        StyleVerticalAlign::TextTop => crate::text3::cache::VerticalAlign::TextTop,
2575        StyleVerticalAlign::TextBottom => crate::text3::cache::VerticalAlign::TextBottom,
2576        // +spec:line-height:b41ee3 - percentage vertical-align: raise/lower by % of line-height, 0% = baseline
2577        StyleVerticalAlign::Percentage(p) => {
2578            let font_size = get_element_font_size(styled_dom, dom_id, node_state);
2579            let line_height = get_line_height_value(styled_dom, dom_id, node_state)
2580                .map_or(font_size * 1.2, |lh| lh.inner.normalized() * font_size);
2581            crate::text3::cache::VerticalAlign::Offset(p.normalized() * line_height)
2582        }
2583        // §10.8.1: <length> is absolute offset from baseline
2584        StyleVerticalAlign::Length(l) => {
2585            let font_size = get_element_font_size(styled_dom, dom_id, node_state);
2586            // TODO(superplan): viewport units (vw/vh/...) in a vertical-align <length>
2587            // fall back to raw pixels here because this getter has no viewport ctx.
2588            // Threading `viewport_size` requires changing this fn's signature, but one
2589            // of its callers (`sizing.rs::process_layout_children`) lives outside
2590            // Group 2's file ownership — deferred. (The sibling path in
2591            // fc.rs::translate_to_text3_constraints already resolves it via
2592            // `resolve_pixel_value_with_viewport`.)
2593            let px = super::calc::resolve_pixel_value(&l, 0.0, font_size, font_size);
2594            crate::text3::cache::VerticalAlign::Offset(px)
2595        }
2596    }
2597}
2598
2599#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
2600#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2601/// # Panics
2602///
2603/// Panics only on an internal indexing invariant (an in-range `get().unwrap()` over the font-family list).
2604pub fn get_style_properties(
2605    styled_dom: &StyledDom,
2606    dom_id: NodeId,
2607    system_style: Option<&std::sync::Arc<azul_css::system::SystemStyle>>,
2608    viewport_size: PhysicalSize,
2609) -> StyleProperties {
2610    use azul_css::props::basic::{PhysicalSize, PropertyContext, ResolutionContext};
2611
2612    let node_data = &styled_dom.node_data.as_container()[dom_id];
2613    let node_state = &styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2614    let cache = &styled_dom.css_property_cache.ptr;
2615
2616    // Fast path: use compact cache reverse map (works for inherited values on text nodes).
2617    // Slow path: only for non-normal pseudo states (:hover, :focus, etc.)
2618    let font_families = if node_state.is_normal() {
2619        cache
2620            .compact_cache
2621            .as_ref()
2622            .and_then(|cc| {
2623                let fh = cc.tier2b_text[dom_id.index()].font_family_hash;
2624                if fh == 0 {
2625                    return None;
2626                }
2627                cc.font_hash_to_families.get(&fh).cloned()
2628            })
2629            .unwrap_or_else(|| {
2630                StyleFontFamilyVec::from_vec(vec![StyleFontFamily::System("serif".into())])
2631            })
2632    } else {
2633        cache
2634            .get_font_family(node_data, &dom_id, node_state)
2635            .and_then(|v| v.get_property().cloned())
2636            .unwrap_or_else(|| {
2637                StyleFontFamilyVec::from_vec(vec![StyleFontFamily::System("serif".into())])
2638            })
2639    };
2640
2641    // Get parent's font-size for proper em resolution in font-size property.
2642    // FAST PATH: `get_parent_font_size` goes through `get_element_font_size`
2643    // which hits the memoised `resolved_font_sizes_px` Vec (O(1) array index).
2644    // The old code here walked the full CSS cascade for every call — 1485
2645    // slow walks per cold excel.html layout. Replaced 2026-04-17.
2646    let parent_font_size = get_parent_font_size(styled_dom, dom_id, node_state);
2647
2648    let root_font_size = get_root_font_size(styled_dom, node_state);
2649
2650    // Create resolution context for font-size (em refers to parent)
2651    let font_size_context = ResolutionContext {
2652        element_font_size: DEFAULT_FONT_SIZE, /* Not used for font-size property */
2653        parent_font_size,
2654        root_font_size,
2655        containing_block_size: PhysicalSize::new(0.0, 0.0),
2656        element_size: None,
2657        viewport_size,
2658    };
2659
2660    // Get font-size: either from this node's CSS, or inherit from parent
2661    // font-size is an inheritable property, so if the node doesn't have
2662    // an explicit font-size, it should inherit from the parent (not default to 16px)
2663    let font_size = {
2664        // FAST PATH: compact cache for normal state.
2665        // Sentinel/inherit/initial → inherit from parent directly (which is
2666        // what the slow cascade walk would fall back to via `.unwrap_or(parent_font_size)`
2667        // anyway — avoid the walk entirely).
2668        let mut fast_font_size: Option<f32> = None;
2669        let mut compact_said_inherit = false;
2670        if node_state.is_normal() {
2671            if let Some(ref cc) = cache.compact_cache {
2672                let raw = cc.get_font_size_raw(dom_id.index());
2673                if raw == azul_css::compact_cache::U32_SENTINEL
2674                    || raw == azul_css::compact_cache::U32_INHERIT
2675                    || raw == azul_css::compact_cache::U32_INITIAL
2676                {
2677                    compact_said_inherit = true;
2678                } else if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
2679                    fast_font_size = Some(
2680                        pv.resolve_with_context(&font_size_context, PropertyContext::FontSize),
2681                    );
2682                }
2683            }
2684        }
2685        fast_font_size.unwrap_or_else(|| {
2686            if compact_said_inherit {
2687                parent_font_size
2688            } else {
2689                cache
2690                    .get_font_size(node_data, &dom_id, node_state)
2691                    .and_then(|v| v.get_property().copied())
2692                    .map_or(parent_font_size, |v| {
2693                        v.inner
2694                            .resolve_with_context(&font_size_context, PropertyContext::FontSize)
2695                    })
2696            }
2697        })
2698    };
2699
2700    let color_from_cache = {
2701        // FAST PATH: compact cache for text color
2702        let mut fast_color = None;
2703        if node_state.is_normal() {
2704            if let Some(ref cc) = cache.compact_cache {
2705                let raw = cc.get_text_color_raw(dom_id.index());
2706                if raw != 0 {
2707                    // Decode 0xRRGGBBAA → ColorU
2708                    fast_color = Some(ColorU {
2709                        r: (raw >> 24) as u8,
2710                        g: (raw >> 16) as u8,
2711                        b: (raw >> 8) as u8,
2712                        a: raw as u8,
2713                    });
2714                }
2715            }
2716        }
2717        fast_color.or_else(|| {
2718            cache
2719                .get_text_color(node_data, &dom_id, node_state)
2720                .and_then(|v| v.get_property().copied())
2721                .map(|v| v.inner)
2722        })
2723    };
2724
2725    // CSS initial value for 'color' is UA-dependent but conventionally black.
2726    // Do NOT use system_style.colors.text here — that reflects the OS theme
2727    // (e.g. white on macOS dark mode) and would produce white text on
2728    // explicitly light-colored backgrounds.  System colors (CanvasText etc.)
2729    // should only be used when referenced through CSS system-color keywords.
2730    let color = color_from_cache.unwrap_or(ColorU::BLACK);
2731
2732    // +spec:font-metrics:e480da - line-height: normal/number/length/percentage resolution
2733    let line_height = {
2734        // FAST PATH: compact cache for line-height (stored as normalized × 1000 i16).
2735        // When the cache returns Some → we have a resolved value.
2736        // When it returns None AND node_state is normal → the compact cache stored
2737        // the sentinel, which means "line-height: normal" (the spec default).
2738        // Previously we fell through to a cascade walk here — but the default
2739        // has already been authoritatively decided by the builder, so the walk
2740        // would only ever re-confirm "no value, normal". 1600 pure-waste walks
2741        // per cold excel.html layout. Short-circuit to Normal directly.
2742        let mut fast_lh = None;
2743        let mut sentinel_normal = false;
2744        if node_state.is_normal() {
2745            if let Some(ref cc) = cache.compact_cache {
2746                if let Some(normalized) = cc.get_line_height(dom_id.index()) {
2747                    // The compact cache stores `normalized() * 1000` as i16, and
2748                    // get_line_height decodes it as `stored / 10`, i.e. this
2749                    // `normalized` value equals `PercentageValue::normalized() * 100`.
2750                    // Per the parser convention a NEGATIVE normalized() means an
2751                    // absolute pixel line-height (CSS line-height cannot be negative),
2752                    // so decode with the same rule fc.rs / the slow path use.
2753                    let n = normalized / 100.0;
2754                    fast_lh = Some(crate::text3::cache::LineHeight::Px(
2755                        if n < 0.0 { -n } else { n * font_size },
2756                    ));
2757                } else {
2758                    // Sentinel in compact cache = "normal" (CSS default).
2759                    sentinel_normal = true;
2760                }
2761            }
2762        }
2763        if sentinel_normal {
2764            crate::text3::cache::LineHeight::Normal
2765        } else {
2766            fast_lh.unwrap_or_else(|| {
2767                cache
2768                    .get_line_height(node_data, &dom_id, node_state)
2769                    .and_then(|v| v.get_property().copied())
2770                    .map_or(crate::text3::cache::LineHeight::Normal, |v| {
2771                        // Negative normalized() = absolute px value (parser convention
2772                        // for "50px" etc.); positive = multiple of font-size.
2773                        let n = v.inner.normalized();
2774                        crate::text3::cache::LineHeight::Px(if n < 0.0 { -n } else { n * font_size })
2775                    })
2776            })
2777        }
2778    };
2779
2780    // Get background color for INLINE elements only
2781    // CSS background-color is NOT inherited. For block-level elements (th, td, div, etc.),
2782    // the background is painted separately by paint_element_background() in display_list.rs.
2783    // Only inline elements (span, em, strong, a, etc.) should have their background color
2784    // propagated through StyleProperties for the text rendering pipeline.
2785    //
2786    // FAST PATH: use the compact-cache-backed display getter. The old code
2787    // here called `cache.get_display(..)` (the 3-arg convenience method on
2788    // CssPropertyCache) which routes through `get_property_slow` — 1485 slow
2789    // walks per cold excel.html layout. Replaced 2026-04-17.
2790    let display = match get_display_property(styled_dom, Some(dom_id)) {
2791        MultiValue::Exact(v) => v,
2792        _ => LayoutDisplay::Inline,
2793    };
2794
2795    // For inline and inline-block elements, get background content and border info
2796    // Block elements have their backgrounds/borders painted by display_list.rs
2797    let (background_color, background_content, border) =
2798        if matches!(display, LayoutDisplay::Inline | LayoutDisplay::InlineBlock) {
2799            let bg = get_background_color(styled_dom, dom_id, node_state);
2800            let bg_color = if bg.a > 0 { Some(bg) } else { None };
2801
2802            // Get full background contents (including gradients)
2803            let bg_contents = get_background_contents(styled_dom, dom_id, node_state);
2804
2805            // Get border info for inline elements
2806            let border_info = get_border_info(styled_dom, dom_id, node_state);
2807            let inline_border =
2808                get_inline_border_info(styled_dom, dom_id, node_state, &border_info, viewport_size);
2809
2810            (bg_color, bg_contents, inline_border)
2811        } else {
2812            // Block-level elements: background/border is painted by display_list.rs
2813            // via push_backgrounds_and_border() in DisplayListBuilder
2814            (None, Vec::new(), None)
2815        };
2816
2817    // Query font-weight from CSS cache
2818    let font_weight = match get_font_weight_property(styled_dom, dom_id, node_state) {
2819        MultiValue::Exact(v) => v,
2820        _ => StyleFontWeight::Normal,
2821    };
2822
2823    // Query font-style from CSS cache
2824    let font_style = match get_font_style_property(styled_dom, dom_id, node_state) {
2825        MultiValue::Exact(v) => v,
2826        _ => StyleFontStyle::Normal,
2827    };
2828
2829    // Convert StyleFontWeight/StyleFontStyle to fontconfig types
2830    let fc_weight = super::fc::convert_font_weight(font_weight);
2831    let fc_style = super::fc::convert_font_style(font_style);
2832
2833    // Check if any font family is a FontRef - if so, use FontStack::Ref
2834    // This allows embedded fonts (like Material Icons) to bypass fontconfig
2835    let font_stack = {
2836        let font_ref = (0..font_families.len()).find_map(|i| match font_families.get(i).unwrap() {
2837            StyleFontFamily::Ref(r) => Some(r.clone()),
2838            _ => None,
2839        });
2840
2841        font_ref.map_or_else(
2842            || {
2843                // Get platform for resolving system font types. None on the paged /
2844                // PDF layout path (system_style is hard-coded None there);
2845                // build_font_selector_stack then resolves via Platform::current() so
2846                // the names stay in lock-step with the font-loading pass.
2847                let platform = system_style.map(|ss| &ss.platform);
2848                FontStack::Stack(build_font_selector_stack(
2849                    &font_families,
2850                    platform,
2851                    fc_weight,
2852                    fc_style,
2853                ))
2854            },
2855            FontStack::Ref,
2856        )
2857    };
2858
2859    // Get letter-spacing from CSS
2860    let letter_spacing = {
2861        // FAST PATH: compact cache for letter-spacing (i16 resolved px × 10)
2862        let mut fast_ls = None;
2863        if node_state.is_normal() {
2864            if let Some(ref cc) = cache.compact_cache {
2865                if let Some(px_val) = cc.get_letter_spacing(dom_id.index()) {
2866                    fast_ls = Some(crate::text3::cache::Spacing::PxF(px_val));
2867                }
2868            }
2869        }
2870        fast_ls.unwrap_or_else(|| {
2871            cache
2872                .get_letter_spacing(node_data, &dom_id, node_state)
2873                .and_then(|v| v.get_property().copied())
2874                .map(|v| {
2875                    let px_value = v
2876                        .inner
2877                        .resolve_with_context(&font_size_context, PropertyContext::FontSize);
2878                    crate::text3::cache::Spacing::PxF(px_value)
2879                })
2880                .unwrap_or_default()
2881        })
2882    };
2883
2884    // Get word-spacing from CSS
2885    let word_spacing = {
2886        // FAST PATH: compact cache for word-spacing (i16 resolved px × 10)
2887        let mut fast_ws = None;
2888        if node_state.is_normal() {
2889            if let Some(ref cc) = cache.compact_cache {
2890                if let Some(px_val) = cc.get_word_spacing(dom_id.index()) {
2891                    fast_ws = Some(crate::text3::cache::Spacing::PxF(px_val));
2892                }
2893            }
2894        }
2895        fast_ws.unwrap_or_else(|| {
2896            cache
2897                .get_word_spacing(node_data, &dom_id, node_state)
2898                .and_then(|v| v.get_property().copied())
2899                .map(|v| {
2900                    let px_value = v
2901                        .inner
2902                        .resolve_with_context(&font_size_context, PropertyContext::FontSize);
2903                    crate::text3::cache::Spacing::PxF(px_value)
2904                })
2905                .unwrap_or_default()
2906        })
2907    };
2908
2909    // Get text-decoration from CSS.
2910    //
2911    // Fast path: the compact cache keeps a `has_text_decoration` flag. If
2912    // unset (the overwhelmingly common case — plain body text has no
2913    // decoration set), skip the 4-pseudo-state × 6-layer cascade walk
2914    // entirely. Only nodes that actually set text-decoration pay the walk.
2915    let text_decoration = {
2916        let mut skip_walk = false;
2917        if node_state.is_normal() {
2918            if let Some(ref cc) = cache.compact_cache {
2919                if !cc.has_text_decoration(dom_id.index()) {
2920                    skip_walk = true;
2921                }
2922            }
2923        }
2924        if skip_walk {
2925            crate::text3::cache::TextDecoration::default()
2926        } else {
2927            cache
2928                .get_text_decoration(node_data, &dom_id, node_state)
2929                .and_then(|v| v.get_property().copied())
2930                .map(crate::text3::cache::TextDecoration::from_css)
2931                .unwrap_or_default()
2932        }
2933    };
2934
2935    // Get tab-size (tab-size) from CSS.
2936    //
2937    // tab-size defaults to `I16_SENTINEL` in the compact cache builder
2938    // (spec default is "8", meaning 8 space widths). The old fallback
2939    // called `cache.get_tab_size(..)` (slow cascade) for every node whose
2940    // raw was SENTINEL — virtually every node, because almost nothing sets
2941    // tab-size. That was 1485 pure-waste slow walks per cold layout.
2942    //
2943    // New behaviour: sentinel → 8.0 directly. Only walk the cascade when
2944    // the compact cache is genuinely unavailable (no `compact_cache`) or
2945    // the node is in a pseudo-state that bypassed the cache.
2946    let tab_size = {
2947        let mut fast_tab = None;
2948        if node_state.is_normal() {
2949            if let Some(ref cc) = cache.compact_cache {
2950                let raw = cc.get_tab_size_raw(dom_id.index());
2951                if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
2952                    fast_tab = Some(f32::from(raw) / 10.0);
2953                } else {
2954                    // Sentinel / Inherit / Initial → spec default is 8.
2955                    fast_tab = Some(8.0);
2956                }
2957            }
2958        }
2959        fast_tab.unwrap_or_else(|| {
2960            cache
2961                .get_tab_size(node_data, &dom_id, node_state)
2962                .and_then(|v| v.get_property().copied())
2963                .map_or(DEFAULT_TAB_SIZE, |v| v.inner.number.get())
2964        })
2965    };
2966
2967    // Get text-transform from CSS (uppercase / lowercase / capitalize / full-width).
2968    // Applied to the run text before shaping (fc.rs::apply_text_transform) so that
2969    // intrinsic widths reflect the transformed glyphs.
2970    let text_transform = cache
2971        .get_text_transform(node_data, &dom_id, node_state)
2972        .and_then(|v| v.get_property().copied())
2973        .map(|t| {
2974            use azul_css::props::style::text::StyleTextTransform as Css;
2975            use crate::text3::cache::TextTransform as T3;
2976            match t {
2977                Css::None => T3::None,
2978                Css::Uppercase => T3::Uppercase,
2979                Css::Lowercase => T3::Lowercase,
2980                Css::Capitalize => T3::Capitalize,
2981                Css::FullWidth => T3::FullWidth,
2982            }
2983        })
2984        .unwrap_or_default();
2985
2986    StyleProperties {
2987        font_stack,
2988        font_size_px: font_size,
2989        color,
2990        background_color,
2991        background_content,
2992        border,
2993        line_height,
2994        letter_spacing,
2995        word_spacing,
2996        text_decoration,
2997        tab_size,
2998        text_transform,
2999        // These still use defaults - could be extended in future:
3000        // font_features, font_variations, writing_mode,
3001        // text_orientation, text_combine_upright, font_variant_*
3002        ..Default::default()
3003    }
3004}
3005
3006#[must_use] pub fn get_list_style_type(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> StyleListStyleType {
3007    let Some(id) = dom_id else {
3008        return StyleListStyleType::default();
3009    };
3010    let node_data = &styled_dom.node_data.as_container()[id];
3011    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3012    styled_dom
3013        .css_property_cache
3014        .ptr
3015        .get_list_style_type(node_data, &id, node_state)
3016        .and_then(|v| v.get_property().copied())
3017        .unwrap_or_default()
3018}
3019
3020#[must_use] pub fn get_list_style_position(
3021    styled_dom: &StyledDom,
3022    dom_id: Option<NodeId>,
3023) -> StyleListStylePosition {
3024    let Some(id) = dom_id else {
3025        return StyleListStylePosition::default();
3026    };
3027    let node_data = &styled_dom.node_data.as_container()[id];
3028    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3029    styled_dom
3030        .css_property_cache
3031        .ptr
3032        .get_list_style_position(node_data, &id, node_state)
3033        .and_then(|v| v.get_property().copied())
3034        .unwrap_or_default()
3035}
3036
3037// New: Taffy Bridge Getters - Box Model Properties with Ua Css Fallback
3038
3039use azul_css::props::layout::{
3040    LayoutInsetBottom, LayoutLeft, LayoutMarginBottom, LayoutMarginLeft, LayoutMarginRight,
3041    LayoutMarginTop, LayoutMaxHeight, LayoutMaxWidth, LayoutMinHeight, LayoutMinWidth,
3042    LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop, LayoutRight,
3043    LayoutTop,
3044};
3045
3046/// Get inset (position) properties - returns MultiValue<PixelValue>
3047get_css_property_pixel!(
3048    get_css_left,
3049    get_left,
3050    CssPropertyType::Left,
3051    compact_i16 = get_left
3052);
3053get_css_property_pixel!(
3054    get_css_right,
3055    get_right,
3056    CssPropertyType::Right,
3057    compact_i16 = get_right
3058);
3059get_css_property_pixel!(
3060    get_css_top,
3061    get_top,
3062    CssPropertyType::Top,
3063    compact_i16 = get_top
3064);
3065get_css_property_pixel!(
3066    get_css_bottom,
3067    get_bottom,
3068    CssPropertyType::Bottom,
3069    compact_i16 = get_bottom
3070);
3071
3072/// Get margin properties - returns MultiValue<PixelValue>
3073get_css_property_pixel!(
3074    get_css_margin_left,
3075    get_margin_left,
3076    CssPropertyType::MarginLeft,
3077    compact_i16 = get_margin_left_raw
3078);
3079get_css_property_pixel!(
3080    get_css_margin_right,
3081    get_margin_right,
3082    CssPropertyType::MarginRight,
3083    compact_i16 = get_margin_right_raw
3084);
3085get_css_property_pixel!(
3086    get_css_margin_top,
3087    get_margin_top,
3088    CssPropertyType::MarginTop,
3089    compact_i16 = get_margin_top_raw
3090);
3091get_css_property_pixel!(
3092    get_css_margin_bottom,
3093    get_margin_bottom,
3094    CssPropertyType::MarginBottom,
3095    compact_i16 = get_margin_bottom_raw
3096);
3097
3098/// Get padding properties - returns MultiValue<PixelValue>
3099get_css_property_pixel!(
3100    get_css_padding_left,
3101    get_padding_left,
3102    CssPropertyType::PaddingLeft,
3103    compact_i16 = get_padding_left_raw
3104);
3105get_css_property_pixel!(
3106    get_css_padding_right,
3107    get_padding_right,
3108    CssPropertyType::PaddingRight,
3109    compact_i16 = get_padding_right_raw
3110);
3111get_css_property_pixel!(
3112    get_css_padding_top,
3113    get_padding_top,
3114    CssPropertyType::PaddingTop,
3115    compact_i16 = get_padding_top_raw
3116);
3117get_css_property_pixel!(
3118    get_css_padding_bottom,
3119    get_padding_bottom,
3120    CssPropertyType::PaddingBottom,
3121    compact_i16 = get_padding_bottom_raw
3122);
3123
3124/// Get min/max size properties
3125get_css_property!(
3126    get_css_min_width,
3127    get_min_width,
3128    LayoutMinWidth,
3129    CssPropertyType::MinWidth,
3130    compact_u32_struct = get_min_width_raw
3131);
3132
3133get_css_property!(
3134    get_css_min_height,
3135    get_min_height,
3136    LayoutMinHeight,
3137    CssPropertyType::MinHeight,
3138    compact_u32_struct = get_min_height_raw
3139);
3140
3141get_css_property!(
3142    get_css_max_width,
3143    get_max_width,
3144    LayoutMaxWidth,
3145    CssPropertyType::MaxWidth,
3146    compact_u32_struct = get_max_width_raw
3147);
3148
3149get_css_property!(
3150    get_css_max_height,
3151    get_max_height,
3152    LayoutMaxHeight,
3153    CssPropertyType::MaxHeight,
3154    compact_u32_struct = get_max_height_raw
3155);
3156
3157/// Get border width properties (no UA CSS fallback needed, defaults to 0)
3158get_css_property_pixel!(
3159    get_css_border_left_width,
3160    get_border_left_width,
3161    CssPropertyType::BorderLeftWidth,
3162    compact_i16 = get_border_left_width_raw
3163);
3164get_css_property_pixel!(
3165    get_css_border_right_width,
3166    get_border_right_width,
3167    CssPropertyType::BorderRightWidth,
3168    compact_i16 = get_border_right_width_raw
3169);
3170get_css_property_pixel!(
3171    get_css_border_top_width,
3172    get_border_top_width,
3173    CssPropertyType::BorderTopWidth,
3174    compact_i16 = get_border_top_width_raw
3175);
3176get_css_property_pixel!(
3177    get_css_border_bottom_width,
3178    get_border_bottom_width,
3179    CssPropertyType::BorderBottomWidth,
3180    compact_i16 = get_border_bottom_width_raw
3181);
3182
3183// Fragmentation (page breaking) properties
3184
3185/// Get break-before property for paged media
3186#[must_use] pub fn get_break_before(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> PageBreak {
3187    let Some(id) = dom_id else {
3188        return PageBreak::Auto;
3189    };
3190    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3191    // Negative fast path: break-* is almost never declared.
3192    if node_state.is_normal() {
3193        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
3194            if !cc.has_break(id.index()) {
3195                return PageBreak::Auto;
3196            }
3197        }
3198    }
3199    let node_data = &styled_dom.node_data.as_container()[id];
3200    styled_dom
3201        .css_property_cache
3202        .ptr
3203        .get_break_before(node_data, &id, node_state)
3204        .and_then(|v| v.get_property().copied())
3205        .unwrap_or(PageBreak::Auto)
3206}
3207
3208/// Get break-after property for paged media
3209#[must_use] pub fn get_break_after(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> PageBreak {
3210    let Some(id) = dom_id else {
3211        return PageBreak::Auto;
3212    };
3213    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3214    if node_state.is_normal() {
3215        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
3216            if !cc.has_break(id.index()) {
3217                return PageBreak::Auto;
3218            }
3219        }
3220    }
3221    let node_data = &styled_dom.node_data.as_container()[id];
3222    styled_dom
3223        .css_property_cache
3224        .ptr
3225        .get_break_after(node_data, &id, node_state)
3226        .and_then(|v| v.get_property().copied())
3227        .unwrap_or(PageBreak::Auto)
3228}
3229
3230/// Check if a `PageBreak` value forces a page break (always, page, left, right, etc.)
3231#[must_use] pub const fn is_forced_page_break(page_break: PageBreak) -> bool {
3232    matches!(
3233        page_break,
3234        PageBreak::Always
3235            | PageBreak::Page
3236            | PageBreak::Left
3237            | PageBreak::Right
3238            | PageBreak::Recto
3239            | PageBreak::Verso
3240            | PageBreak::All
3241    )
3242}
3243
3244/// Get break-inside property for paged media
3245#[must_use] pub fn get_break_inside(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> BreakInside {
3246    let Some(id) = dom_id else {
3247        return BreakInside::Auto;
3248    };
3249    let node_data = &styled_dom.node_data.as_container()[id];
3250    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3251    styled_dom
3252        .css_property_cache
3253        .ptr
3254        .get_break_inside(node_data, &id, node_state)
3255        .and_then(|v| v.get_property().copied())
3256        .unwrap_or(BreakInside::Auto)
3257}
3258
3259/// Get orphans property (minimum lines at bottom of page)
3260#[must_use] pub fn get_orphans(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> u32 {
3261    let Some(id) = dom_id else {
3262        return 2; // Default value
3263    };
3264    let node_data = &styled_dom.node_data.as_container()[id];
3265    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3266    styled_dom
3267        .css_property_cache
3268        .ptr
3269        .get_orphans(node_data, &id, node_state)
3270        .and_then(|v| v.get_property().copied())
3271        .map_or(2, |o| o.inner)
3272}
3273
3274/// Get widows property (minimum lines at top of page)
3275#[must_use] pub fn get_widows(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> u32 {
3276    let Some(id) = dom_id else {
3277        return 2; // Default value
3278    };
3279    let node_data = &styled_dom.node_data.as_container()[id];
3280    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3281    styled_dom
3282        .css_property_cache
3283        .ptr
3284        .get_widows(node_data, &id, node_state)
3285        .and_then(|v| v.get_property().copied())
3286        .map_or(2, |w| w.inner)
3287}
3288
3289/// Get box-decoration-break property
3290#[must_use] pub fn get_box_decoration_break(
3291    styled_dom: &StyledDom,
3292    dom_id: Option<NodeId>,
3293) -> BoxDecorationBreak {
3294    let Some(id) = dom_id else {
3295        return BoxDecorationBreak::Slice;
3296    };
3297    let node_data = &styled_dom.node_data.as_container()[id];
3298    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3299    styled_dom
3300        .css_property_cache
3301        .ptr
3302        .get_box_decoration_break(node_data, &id, node_state)
3303        .and_then(|v| v.get_property().copied())
3304        .unwrap_or(BoxDecorationBreak::Slice)
3305}
3306
3307// Helper functions for break properties
3308
3309/// Check if a `PageBreak` value is avoid
3310#[must_use] pub const fn is_avoid_page_break(page_break: &PageBreak) -> bool {
3311    matches!(page_break, PageBreak::Avoid | PageBreak::AvoidPage)
3312}
3313
3314/// Check if a `BreakInside` value prevents breaks
3315#[must_use] pub const fn is_avoid_break_inside(break_inside: &BreakInside) -> bool {
3316    matches!(
3317        break_inside,
3318        BreakInside::Avoid | BreakInside::AvoidPage | BreakInside::AvoidColumn
3319    )
3320}
3321
3322// Font Chain Resolution - Pre-Layout Font Loading
3323
3324use std::collections::HashMap;
3325
3326use rust_fontconfig::{
3327    FcFontCache, FcWeight, FontFallbackChain, PatternMatch, UnicodeRange,
3328    DEFAULT_UNICODE_FALLBACK_SCRIPTS,
3329};
3330
3331use crate::text3::cache::{FontChainKey, FontChainKeyOrRef, FontSelector, FontStack, FontStyle};
3332
3333/// Build a fontconfig `FontSelector` stack from a list of CSS font families.
3334///
3335/// Shared by `get_style_properties` and `collect_font_stacks_from_styled_dom`.
3336/// `Ref` families are skipped (callers handle embedded fonts via `FontStack::Ref`),
3337/// `SystemType` families expand to the platform's fallback chain, and the generic
3338/// `sans-serif`/`serif`/`monospace` fallbacks are appended if not already present.
3339///
3340/// When `platform` is `None` (e.g. the paged / PDF layout path that hard-codes
3341/// `system_style = None`), system fonts resolve via `Platform::current()` so the
3342/// names stay in lock-step with the font-loading pass (which always uses
3343/// `Platform::current()`); diverging to a bare "sans-serif" would not match the
3344/// names the loader registered → zero glyphs → text collapses to 0 width.
3345// The `platform` binding uses a pre-declared `let current;` so the else branch can
3346// extend the lifetime of a freshly-computed Platform and hand back a reference to it;
3347// map_or_else cannot express this (the closure would return a dangling local ref).
3348#[allow(clippy::option_if_let_else)]
3349fn build_font_selector_stack(
3350    font_families: &StyleFontFamilyVec,
3351    platform: Option<&azul_css::system::Platform>,
3352    fc_weight: FcWeight,
3353    fc_style: FontStyle,
3354) -> Vec<FontSelector> {
3355    let mut stack = Vec::with_capacity(font_families.len() + 3);
3356
3357    for i in 0..font_families.len() {
3358        let family = font_families.get(i).unwrap();
3359        if matches!(family, StyleFontFamily::Ref(_)) {
3360            continue;
3361        }
3362        if let StyleFontFamily::SystemType(system_type) = family {
3363            let current;
3364            let platform = if let Some(p) = platform { p } else {
3365                current = azul_css::system::Platform::current();
3366                &current
3367            };
3368            let font_names = system_type.get_fallback_chain(platform);
3369            let system_weight = if system_type.is_bold() {
3370                FcWeight::Bold
3371            } else {
3372                fc_weight
3373            };
3374            let system_style = if system_type.is_italic() {
3375                FontStyle::Italic
3376            } else {
3377                fc_style
3378            };
3379            for font_name in font_names {
3380                stack.push(FontSelector {
3381                    family: font_name.to_string(),
3382                    weight: system_weight,
3383                    style: system_style,
3384                    unicode_ranges: Vec::new(),
3385                });
3386            }
3387        } else {
3388            stack.push(FontSelector {
3389                family: family.as_string(),
3390                weight: fc_weight,
3391                style: fc_style,
3392                unicode_ranges: Vec::new(),
3393            });
3394        }
3395    }
3396
3397    for fallback in &["sans-serif", "serif", "monospace"] {
3398        if !stack
3399            .iter()
3400            .any(|f| f.family.eq_ignore_ascii_case(fallback))
3401        {
3402            stack.push(FontSelector {
3403                family: (*fallback).to_string(),
3404                weight: FcWeight::Normal,
3405                style: FontStyle::Normal,
3406                unicode_ranges: Vec::new(),
3407            });
3408        }
3409    }
3410
3411    stack
3412}
3413
3414/// Result of collecting font stacks from a `StyledDom`
3415/// Contains all unique font stacks and the mapping from `StyleFontFamiliesHash` to `FontChainKey`
3416#[derive(Debug, Clone)]
3417pub struct CollectedFontStacks {
3418    /// All unique font stacks found in the document (system/file fonts via fontconfig)
3419    pub font_stacks: Vec<Vec<FontSelector>>,
3420    /// Map from the font stack hash to the index in `font_stacks`
3421    pub hash_to_index: HashMap<u64, usize>,
3422    /// Direct `FontRefs` that bypass fontconfig (e.g., embedded icon fonts)
3423    /// These are keyed by their pointer address for uniqueness
3424    pub font_refs: HashMap<usize, azul_css::props::basic::font::FontRef>,
3425}
3426
3427/// Resolved font chains ready for use in layout
3428/// This is the result of resolving font stacks against `FcFontCache`
3429#[derive(Debug, Clone, Default)]
3430pub struct ResolvedFontChains {
3431    /// Map from `FontChainKeyOrRef` to the resolved `FontFallbackChain`
3432    /// For `FontChainKeyOrRef::Ref` variants, the `FontFallbackChain` contains
3433    /// a single-font chain that covers the entire Unicode range.
3434    pub chains: HashMap<FontChainKeyOrRef, FontFallbackChain>,
3435    /// CSS families that were REQUESTED but could not be matched to any
3436    /// font (not on disk, not registered in memory).
3437    ///
3438    /// This used to be swallowed: the resolver moved on to the next family
3439    /// and, if the whole stack failed, `ensure_chains_nonempty` quietly
3440    /// attached an arbitrary system font. Every unmatched family therefore
3441    /// collapsed onto the SAME `FontId`, text rendered in a font nobody
3442    /// asked for, and no test could tell. A failed family match is now a
3443    /// first-class output: it is recorded here and logged once
3444    /// (see `report_unresolved_families`).
3445    pub unresolved_families: std::collections::BTreeSet<String>,
3446    /// Chains that matched NOTHING at all and only render because
3447    /// `ensure_chains_nonempty` attached a last-resort font. These are
3448    /// rendering in a font the stylesheet never asked for.
3449    pub last_resort_chains: usize,
3450}
3451
3452impl ResolvedFontChains {
3453    /// Get a font chain by its key
3454    #[must_use] pub fn get(&self, key: &FontChainKeyOrRef) -> Option<&FontFallbackChain> {
3455        self.chains.get(key)
3456    }
3457
3458    /// Get a font chain by `FontChainKey` (for system fonts)
3459    #[must_use] pub fn get_by_chain_key(&self, key: &FontChainKey) -> Option<&FontFallbackChain> {
3460        self.chains.get(&FontChainKeyOrRef::Chain(key.clone()))
3461    }
3462
3463    /// Get a font chain for a font stack (via fontconfig)
3464    #[must_use] pub fn get_for_font_stack(&self, font_stack: &[FontSelector]) -> Option<&FontFallbackChain> {
3465        let key = FontChainKeyOrRef::Chain(FontChainKey::from_selectors(font_stack));
3466        self.chains.get(&key)
3467    }
3468
3469    /// Get a font chain for a `FontRef` pointer
3470    #[must_use] pub fn get_for_font_ref(&self, ptr: usize) -> Option<&FontFallbackChain> {
3471        self.chains.get(&FontChainKeyOrRef::Ref(ptr))
3472    }
3473
3474    /// Consume self and return the inner `HashMap` with `FontChainKeyOrRef` keys
3475    ///
3476    /// This is useful when you need access to both Chain and Ref variants.
3477    #[must_use] pub fn into_inner(self) -> HashMap<FontChainKeyOrRef, FontFallbackChain> {
3478        self.chains
3479    }
3480
3481    /// Consume self and return only the fontconfig-resolved chains
3482    ///
3483    /// This filters out `FontRef` entries and returns only the chains
3484    /// resolved via fontconfig. This is what `FontManager` expects.
3485    #[must_use] pub fn into_fontconfig_chains(self) -> HashMap<FontChainKey, FontFallbackChain> {
3486        // (2026-06-10: reverted to HashMap end-to-end — the empty-hashbrown RawIter hang behind
3487        // the 2026-06-05 BTreeMap migration was the un-mirrored EMPTY_GROUP static, fixed
3488        // transpiler-side in symbol_table.rs::compute_hashbrown_empty_group_ranges.)
3489        let mut out: HashMap<FontChainKey, FontFallbackChain> = HashMap::new();
3490        if self.chains.is_empty() {
3491            return out;
3492        }
3493        for (key, chain) in self.chains {
3494            if let FontChainKeyOrRef::Chain(chain_key) = key {
3495                out.insert(chain_key, chain);
3496            }
3497        }
3498        out
3499    }
3500
3501    /// Get the number of resolved chains
3502    #[must_use] pub fn len(&self) -> usize {
3503        self.chains.len()
3504    }
3505
3506    /// Check if there are no resolved chains
3507    #[must_use] pub fn is_empty(&self) -> bool {
3508        self.chains.is_empty()
3509    }
3510
3511    /// Get the number of direct `FontRefs`
3512    #[must_use] pub fn font_refs_len(&self) -> usize {
3513        self.chains.keys().filter(|k| k.is_ref()).count()
3514    }
3515}
3516
3517/// Collect all unique font stacks from a `StyledDom`
3518///
3519/// This is a pure function that iterates over all nodes in the DOM and
3520/// extracts the font-family property from each node that has text content.
3521///
3522/// # Arguments
3523/// * `styled_dom` - The styled DOM to extract font stacks from
3524/// * `platform` - The current platform for resolving system font types
3525///
3526/// # Returns
3527/// A `CollectedFontStacks` containing all unique font stacks and a hash-to-index mapping
3528#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
3529#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3530#[must_use] pub fn collect_font_stacks_from_styled_dom(
3531    styled_dom: &StyledDom,
3532    platform: &azul_css::system::Platform,
3533) -> CollectedFontStacks {
3534    use azul_css::compact_cache::{
3535        FONT_STYLE_MASK, FONT_STYLE_SHIFT, FONT_WEIGHT_MASK, FONT_WEIGHT_SHIFT,
3536    };
3537
3538    let mut font_stacks = Vec::new();
3539    let mut hash_to_index: HashMap<u64, usize> = HashMap::new();
3540    let mut font_refs: HashMap<usize, azul_css::props::basic::font::FontRef> = HashMap::new();
3541
3542    let node_data = styled_dom.node_data.as_container();
3543    let cache = &styled_dom.css_property_cache.ptr;
3544    let Some(compact) = cache.compact_cache.as_ref() else {
3545        return CollectedFontStacks {
3546            font_stacks,
3547            hash_to_index,
3548            font_refs,
3549        };
3550    };
3551
3552    // Phase 1: Scan compact cache arrays (just u64 reads) to find unique
3553    // (font_family_hash, weight, style) tuples. Record one representative
3554    // node index per unique tuple for the expensive CSS lookup in Phase 2.
3555    // Key: (font_family_hash, weight_encoded, style_encoded) → representative node index
3556    // (2026-06-10: reverted to HashMap — the historic g81/g47 empty-hashbrown mis-lift was the
3557    // un-mirrored EMPTY_GROUP static, fixed transpiler-side in symbol_table.rs::
3558    // compute_hashbrown_empty_group_ranges. std HashMap lifts correctly now; RandomState seeds
3559    // via the transpiler's HashmapRandomKeys fixed-seed body.)
3560    let mut unique_font_keys: HashMap<(u64, u8, u8), usize> = HashMap::new();
3561    let node_count = node_data.internal.len();
3562
3563    // WEB-LIFT: probe node_type bytes (NodeType #[repr(C,u8)], Text=177 per AzDom_createText).
3564    // 0x406D0..DC = n1.node_type bytes[0,1,2,4]; 0x406E0 = n0.node_type byte[0] (body disc).
3565    if node_count > 1 {
3566        let p1 = (&raw const node_data.internal[1].node_type).cast::<u8>();
3567        let p0 = (&raw const node_data.internal[0].node_type).cast::<u8>();
3568        unsafe {
3569            crate::az_mark(0x606D0_u32, u32::from(core::ptr::read(p1)));
3570            crate::az_mark(0x606D4_u32, u32::from(core::ptr::read(p1.add(1))));
3571            crate::az_mark(0x606D8_u32, u32::from(core::ptr::read(p1.add(2))));
3572            crate::az_mark(0x606DC_u32, u32::from(core::ptr::read(p1.add(4))));
3573            crate::az_mark(0x606E0_u32, u32::from(core::ptr::read(p0)));
3574        }
3575    }
3576    for i in 0..node_count {
3577        // Only text nodes need fonts. WEB-LIFT: the lifted `matches!(node_type,
3578        // NodeType::Text(_))` MIS-LIFTS (compares against a mis-lifted discriminant
3579        // constant) — text nodes never match → no font stack → no chain → text h=0.
3580        // NodeType is #[repr(C,u8)] so the discriminant is the u8 at offset 0; Text=177
3581        // (per AzDom_createText: `mov w8,#0xb1; strb w8,[x19]`). Compare the raw
3582        // discriminant to the literal 177 (a source literal lifts correctly).
3583        let nt_disc = unsafe {
3584            core::ptr::read((&raw const node_data.internal[i].node_type).cast::<u8>())
3585        };
3586        let is_text = nt_disc == 177
3587            || matches!(node_data.internal[i].node_type, NodeType::Text(_));
3588        if !is_text {
3589            continue;
3590        }
3591        let fh = compact.tier2b_text[i].font_family_hash;
3592        let t1 = compact.tier1_enums[i];
3593        let weight_bits = ((t1 >> FONT_WEIGHT_SHIFT) & FONT_WEIGHT_MASK) as u8;
3594        let style_bits = ((t1 >> FONT_STYLE_SHIFT) & FONT_STYLE_MASK) as u8;
3595        let key = (fh, weight_bits, style_bits);
3596        unique_font_keys.entry(key).or_insert(i);
3597    }
3598
3599    // WASM-ONLY PROBE (REVERT): why 0 chains? 0x406C0=tag(5E5E0003), C4=node_count,
3600    // C8=unique_font_keys.len() (#text nodes matched in Phase 1). If C8=0 → the lifted
3601    // `matches!(node_type, NodeType::Text(_))` FAILS for the text node (node_type mis-lift)
3602    // → no font stack → no chain → text h=0. C is the count of NodeType::Text via a raw
3603    // discriminant byte read (node_type tag), to compare against the matches! result.
3604    {
3605        let mut raw_text = 0u32;
3606        for i in 0..node_count {
3607            // NodeType is repr(C,u8)-ish; read the leading discriminant byte directly.
3608            let nt_ptr = (&raw const node_data.internal[i].node_type).cast::<u8>();
3609            let disc = unsafe { core::ptr::read_volatile(nt_ptr) };
3610            // Text is one specific discriminant; count whatever the body node ISN'T.
3611            if disc != unsafe { core::ptr::read_volatile((&raw const node_data.internal[0].node_type).cast::<u8>()) } {
3612                raw_text += 1;
3613            }
3614        }
3615        unsafe {
3616            crate::az_mark(0x606C0_u32, (0x5E5E_0003_u32));
3617            crate::az_mark(0x606C4_u32, (node_count as u32));
3618            crate::az_mark(0x606C8_u32, (unique_font_keys.len() as u32));
3619            crate::az_mark(0x606CC_u32, (raw_text));
3620        }
3621    }
3622
3623    // Phase 2: For each unique tuple, do ONE expensive CSS lookup on the
3624    // representative node to get the actual font-family names.
3625    let styled_nodes = styled_dom.styled_nodes.as_container();
3626
3627    for (&(fh, _wb, _sb), &repr_idx) in &unique_font_keys {
3628        let Some(dom_id) = NodeId::from_usize(repr_idx) else {
3629            continue;
3630        };
3631        let node_state = &styled_nodes[dom_id].styled_node_state;
3632
3633        // Use reverse map from compact cache: hash → actual font families.
3634        // This works for ALL nodes including text nodes that inherit font-family
3635        // via compact cache (where get_property_slow would return None).
3636        let font_families = compact
3637            .font_hash_to_families
3638            .get(&fh)
3639            .cloned()
3640            .unwrap_or_else(|| {
3641                StyleFontFamilyVec::from_vec(vec![StyleFontFamily::System("serif".into())])
3642            });
3643
3644        // Check for embedded FontRef
3645        if let Some(StyleFontFamily::Ref(font_ref)) = font_families.get(0) {
3646            let ptr = font_ref.parsed as usize;
3647            font_refs.entry(ptr).or_insert_with(|| font_ref.clone());
3648            continue;
3649        }
3650
3651        let font_weight = match get_font_weight_property(styled_dom, dom_id, node_state) {
3652            MultiValue::Exact(v) => v,
3653            _ => StyleFontWeight::Normal,
3654        };
3655        let font_style = match get_font_style_property(styled_dom, dom_id, node_state) {
3656            MultiValue::Exact(v) => v,
3657            _ => StyleFontStyle::Normal,
3658        };
3659
3660        let fc_weight = super::fc::convert_font_weight(font_weight);
3661        let fc_style = super::fc::convert_font_style(font_style);
3662
3663        let font_stack =
3664            build_font_selector_stack(&font_families, Some(platform), fc_weight, fc_style);
3665
3666        if font_stack.is_empty() {
3667            continue;
3668        }
3669
3670        let key = FontChainKey::from_selectors(&font_stack);
3671        let hash = {
3672            use std::hash::{Hash, Hasher};
3673            let mut hasher = std::collections::hash_map::DefaultHasher::new();
3674            key.hash(&mut hasher);
3675            hasher.finish()
3676        };
3677
3678        hash_to_index.entry(hash).or_insert_with(|| {
3679            let idx = font_stacks.len();
3680            font_stacks.push(font_stack);
3681            idx
3682        });
3683    }
3684
3685    CollectedFontStacks {
3686        font_stacks,
3687        hash_to_index,
3688        font_refs,
3689    }
3690}
3691
3692/// Resolve all font chains for the collected font stacks
3693///
3694/// This is a pure function that takes the collected font stacks and resolves
3695/// them against the `FcFontCache` to produce `FontFallbackChains`.
3696///
3697/// # Arguments
3698/// * `collected` - The collected font stacks from `collect_font_stacks_from_styled_dom`
3699/// * `fc_cache` - The fontconfig cache to resolve fonts against
3700///
3701/// # Returns
3702/// A `ResolvedFontChains` containing all resolved font chains
3703/// Walk every text node in `styled_dom` and collect the set of
3704/// non-ASCII codepoints actually present in the document.
3705///
3706/// Used by [`prune_chain_to_used_chars`] to drop CSS-fallback fonts
3707/// from a resolved chain when the *first* match in a `css_fallbacks`
3708/// group already covers everything the page asks for. ASCII (`< 0x80`)
3709/// is universally covered by every Latin font we'd resolve, so we
3710/// skip it here to keep the set small. Unicode characters in the
3711/// returned set are deduped + sorted via `BTreeSet`.
3712///
3713/// Cost: O(total text length). Cheap relative to layout itself.
3714#[must_use] pub fn collect_used_codepoints(styled_dom: &StyledDom) -> std::collections::BTreeSet<u32> {
3715    let mut out = std::collections::BTreeSet::new();
3716    let node_data = styled_dom.node_data.as_container();
3717    for node in node_data.internal {
3718        let NodeType::Text(s) = &node.node_type else {
3719            continue;
3720        };
3721        for c in s.as_str().chars() {
3722            let cp = c as u32;
3723            if cp >= 0x80 {
3724                out.insert(cp);
3725            }
3726        }
3727    }
3728    out
3729}
3730
3731/// Like [`collect_used_codepoints`] but keeps ASCII.
3732///
3733/// The fast-probe
3734/// path (`FcFontRegistry::request_fonts_fast`) *does* need ASCII:
3735/// "the font has to cover every codepoint I will render" is only
3736/// true if we tell it every codepoint, and "Segoe UI" not being
3737/// installed on macOS means even ASCII has to fall through to a
3738/// system default.
3739///
3740/// `collect_used_codepoints` strips ASCII because its caller
3741/// (`prune_chain_to_used_chars`) runs *after* resolution to trim an
3742/// already-resolved chain and every Latin-covering font passes ASCII
3743/// trivially. That assumption doesn't hold during probing.
3744#[must_use] pub fn collect_used_codepoints_all(styled_dom: &StyledDom) -> std::collections::BTreeSet<char> {
3745    let mut out = std::collections::BTreeSet::new();
3746    let node_data = styled_dom.node_data.as_container();
3747    for node in node_data.internal {
3748        let NodeType::Text(s) = &node.node_type else {
3749            continue;
3750        };
3751        for c in s.as_str().chars() {
3752            out.insert(c);
3753        }
3754    }
3755    out
3756}
3757
3758/// Trim a [`FontFallbackChain`] down to the minimum set of `FontMatch`
3759/// entries needed to cover `used_chars` (typically from
3760/// [`collect_used_codepoints`]).
3761///
3762/// For each `css_fallbacks` group, walk matches in the resolver's
3763/// preferred order and keep them until every codepoint in
3764/// `used_chars` is covered (per the OS/2 unicode-range bits cached
3765/// in `FontMatch.unicode_ranges`). Always keeps at least the first
3766/// match per group so a font listed in CSS doesn't disappear.
3767///
3768/// `unicode_fallbacks` is filtered to only include fonts whose
3769/// ranges intersect `used_chars` — Phase-6's
3770/// [`scripts_present_in_styled_dom`] already scopes the *script
3771/// blocks* but a single block (e.g. CJK Unified, U+4E00..U+9FFF)
3772/// can have hundreds of matching system fonts; this prunes them
3773/// down to the few that actually cover the codepoints used.
3774///
3775/// On excel.html (~ASCII-only) this drops the per-chain
3776/// `css_fallbacks` from 5 → 1 in each group, eliminating ~20 of
3777/// the 26 fonts that would otherwise be parsed by
3778/// `load_fonts_from_disk`.
3779pub fn prune_chain_to_used_chars(
3780    chain: &mut FontFallbackChain,
3781    used_chars: &std::collections::BTreeSet<u32>,
3782) {
3783    fn fm_covers(fm: &rust_fontconfig::FontMatch, cp: u32) -> bool {
3784        fm.unicode_ranges
3785            .iter()
3786            .any(|r| cp >= r.start && cp <= r.end)
3787    }
3788
3789    for group in &mut chain.css_fallbacks {
3790        if group.fonts.is_empty() {
3791            continue;
3792        }
3793        // Track which non-ASCII chars still need coverage as we walk
3794        // matches in order. We always keep at least the first match.
3795        let mut needed: Vec<u32> = used_chars.iter().copied().collect();
3796        needed.retain(|&cp| !fm_covers(&group.fonts[0], cp));
3797        let mut keep = 1;
3798        for fm in group.fonts.iter().skip(1) {
3799            if needed.is_empty() {
3800                break;
3801            }
3802            keep += 1;
3803            needed.retain(|&cp| !fm_covers(fm, cp));
3804        }
3805        group.fonts.truncate(keep);
3806    }
3807
3808    chain
3809        .unicode_fallbacks
3810        .retain(|fm| used_chars.iter().any(|&cp| fm_covers(fm, cp)));
3811}
3812
3813/// Scan text-node content in `styled_dom` and return the subset of
3814/// [`rust_fontconfig::DEFAULT_UNICODE_FALLBACK_SCRIPTS`] whose code-point
3815/// ranges actually appear in any text.
3816///
3817/// Short-circuits once all seven
3818/// ranges have been seen.
3819///
3820/// Callers pass the result as `scripts_hint` to
3821/// [`resolve_font_chains`] / [`collect_and_resolve_font_chains_with_registration`];
3822/// `rust_fontconfig::FcFontCache::resolve_font_chain_with_scripts` then
3823/// only pulls in Unicode-fallback fonts for scripts the document
3824/// actually uses. An ASCII-only page returns an empty vector, which
3825/// avoids dragging Arial Unicode MS, CJK fonts, etc. into the
3826/// resolved chain and therefore into the eager-load step.
3827#[must_use] pub fn scripts_present_in_styled_dom(styled_dom: &StyledDom) -> Vec<UnicodeRange> {
3828    let scripts = DEFAULT_UNICODE_FALLBACK_SCRIPTS;
3829    let mut seen = vec![false; scripts.len()];
3830    let mut hits = 0usize;
3831    let node_data = styled_dom.node_data.as_container();
3832    'outer: for node in node_data.internal {
3833        let text: &str = match &node.node_type {
3834            NodeType::Text(s) => s.as_str(),
3835            _ => continue,
3836        };
3837        for c in text.chars() {
3838            let cp = c as u32;
3839            // Cheap reject: everything below the first fallback-script
3840            // range (Cyrillic starts at U+0400) is covered by the CSS
3841            // fallbacks' own glyphs — no reason to probe.
3842            if cp < 0x0400 {
3843                continue;
3844            }
3845            for (idx, r) in scripts.iter().enumerate() {
3846                if !seen[idx] && cp >= r.start && cp <= r.end {
3847                    seen[idx] = true;
3848                    hits += 1;
3849                    if hits == scripts.len() {
3850                        break 'outer;
3851                    }
3852                    break;
3853                }
3854            }
3855        }
3856    }
3857    scripts
3858        .iter()
3859        .enumerate()
3860        .filter_map(|(i, r)| if seen[i] { Some(*r) } else { None })
3861        .collect()
3862}
3863
3864/// Resolve font chains for a collected set of stacks.
3865///
3866/// `scripts_hint`:
3867/// - `None` keeps the original "all 7 default scripts" behaviour
3868///   (Cyrillic / Arabic / Devanagari / Hiragana / Katakana / CJK /
3869///   Hangul) — equivalent to passing
3870///   `Some(rust_fontconfig::DEFAULT_UNICODE_FALLBACK_SCRIPTS)`.
3871/// - `Some(&[])` attaches *no* Unicode fallbacks, suitable for
3872///   ASCII-only documents. Combined with `prune_chain_to_used_chars`
3873///   this is what eliminates Arial Unicode MS / CJK / Arabic font
3874///   loads on Latin-only pages.
3875/// - `Some(ranges)` attaches fallbacks only for the listed scripts.
3876///   Production callers compute this via
3877///   [`scripts_present_in_styled_dom`].
3878#[must_use] pub fn resolve_font_chains(
3879    collected: &CollectedFontStacks,
3880    fc_cache: &FcFontCache,
3881    scripts_hint: Option<&[UnicodeRange]>,
3882) -> ResolvedFontChains {
3883    resolve_font_chains_with_registry(collected, fc_cache, None, scripts_hint, &HashMap::new())
3884}
3885
3886/// Split a CSS font stack into (a) the groups that resolve to an in-memory
3887/// font registered BY FAMILY NAME and (b) the families that still have to
3888/// be looked up on disk.
3889///
3890/// In-memory fonts are the bundled/embedder/test fonts registered with
3891/// [`crate::text3::cache::FontManager::register_named_font`]. They must be
3892/// matched here, in azul, because the fast disk resolver
3893/// (`FcFontRegistry::request_fonts_fast`) only walks file paths and cannot
3894/// see them at all. Matching is on the NORMALIZED family name, which also
3895/// makes `font-family: "Foo Bar"` (the CSS parser keeps the quotes) match
3896/// the registered `Foo Bar`.
3897fn split_memory_matches(
3898    font_families: &[String],
3899    memory_families: &HashMap<String, rust_fontconfig::FontMatch>,
3900) -> (Vec<rust_fontconfig::CssFallbackGroup>, Vec<String>) {
3901    let mut groups = Vec::new();
3902    let mut disk = Vec::new();
3903    for family in font_families {
3904        let norm = rust_fontconfig::utils::normalize_family_name(family);
3905        if let Some(m) = memory_families.get(&norm) {
3906            groups.push(rust_fontconfig::CssFallbackGroup {
3907                css_name: family.clone(),
3908                fonts: vec![m.clone()],
3909            });
3910        } else {
3911            disk.push(family.clone());
3912        }
3913    }
3914    (groups, disk)
3915}
3916
3917/// Registry-aware variant of [`resolve_font_chains`].
3918///
3919/// When `registry`
3920/// is `Some`, each chain resolution goes through
3921/// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
3922/// which priority-bumps the builder for families not yet in the
3923/// snapshot and waits for them — the "scout-on-demand" path that
3924/// avoids the eager common-stack pre-parse.
3925///
3926/// When `registry` is `None`, falls back to
3927/// [`rust_fontconfig::FcFontCache::resolve_font_chain_with_scripts`]
3928/// against the passed-in snapshot, which is what
3929/// [`resolve_font_chains`] does and what every code path did before
3930/// Phase 3.
3931#[must_use] pub fn resolve_font_chains_with_registry(
3932    collected: &CollectedFontStacks,
3933    fc_cache: &FcFontCache,
3934    registry: Option<&rust_fontconfig::registry::FcFontRegistry>,
3935    scripts_hint: Option<&[UnicodeRange]>,
3936    memory_families: &HashMap<String, rust_fontconfig::FontMatch>,
3937) -> ResolvedFontChains {
3938    let mut chains = HashMap::new();
3939    let mut unresolved: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3940
3941    // Resolve system/file font stacks via fontconfig
3942    for font_stack in &collected.font_stacks {
3943        if font_stack.is_empty() {
3944            continue;
3945        }
3946
3947        // Build font families list
3948        // (2026-06-10) Build the key through the ONE canonical constructor
3949        // (FontChainKey::from_selectors — first-wins dedup + the same empty-stack
3950        // fallback) so the stored key always matches the shaping-time lookup key.
3951        let canonical_key = FontChainKey::from_selectors(font_stack);
3952        let font_families = canonical_key.font_families.clone();
3953
3954        let weight = font_stack[0].weight;
3955        let is_italic = font_stack[0].style == FontStyle::Italic;
3956        let is_oblique = font_stack[0].style == FontStyle::Oblique;
3957
3958        let cache_key = FontChainKeyOrRef::Chain(FontChainKey {
3959            font_families: font_families.clone(),
3960            weight,
3961            italic: is_italic,
3962            oblique: is_oblique,
3963        });
3964
3965        // Skip if already resolved
3966        if chains.contains_key(&cache_key) {
3967            continue;
3968        }
3969
3970        // Resolve the font chain
3971        // IMPORTANT: Use False (not DontCare) when style is Normal.
3972        // DontCare means "accept italic too" which can match italic fonts.
3973        // False means "must NOT be italic" which correctly prefers Normal.
3974        let italic = if is_italic {
3975            PatternMatch::True
3976        } else {
3977            PatternMatch::False
3978        };
3979        let oblique = if is_oblique {
3980            PatternMatch::True
3981        } else {
3982            PatternMatch::False
3983        };
3984
3985        // MEMORY FONTS FIRST (see `split_memory_matches`): a family
3986        // registered by name into the cache's in-memory table wins over
3987        // anything on disk, exactly as CSS says.
3988        let (mem_groups, disk_families) = split_memory_matches(&font_families, memory_families);
3989
3990        // Registry-aware resolve: scout-on-demand path when available.
3991        // See `resolve_font_chains_with_registry` doc for rationale.
3992        let mut chain = if disk_families.is_empty() {
3993            FontFallbackChain {
3994                css_fallbacks: Vec::new(),
3995                unicode_fallbacks: Vec::new(),
3996                original_stack: font_families.clone(),
3997            }
3998        } else {
3999            registry.map_or_else(
4000                || {
4001                    let mut trace = Vec::new();
4002                    fc_cache.resolve_font_chain_with_scripts(
4003                        &disk_families,
4004                        weight,
4005                        italic,
4006                        oblique,
4007                        scripts_hint,
4008                        &mut trace,
4009                    )
4010                },
4011                |reg| {
4012                    reg.request_and_resolve_with_scripts(
4013                        &disk_families,
4014                        weight,
4015                        italic,
4016                        oblique,
4017                        scripts_hint,
4018                    )
4019                },
4020            )
4021        };
4022        if !mem_groups.is_empty() {
4023            let mut merged = mem_groups;
4024            merged.extend(chain.css_fallbacks.drain(..));
4025            chain.css_fallbacks = merged;
4026        }
4027
4028        // A family that produced no group matched NOTHING — record it (see
4029        // `ResolvedFontChains::unresolved_families`).
4030        for family in &font_families {
4031            let matched = chain
4032                .css_fallbacks
4033                .iter()
4034                .any(|g| g.css_name.eq_ignore_ascii_case(family) && !g.fonts.is_empty());
4035            if !matched && !is_generic_family(family) {
4036                unresolved.insert(family.clone());
4037            }
4038        }
4039
4040        // WEB-LIFT last resort (in azul-layout, NOT rust-fontconfig — so the fragile
4041        // `with_memory_fonts` isn't re-codegen'd into a trapping shape): the lifted
4042        // resolve_font_chain query path can return an EMPTY chain even when a fallback
4043        // font IS registered (generic→OS-name expansion + token/unicode query is
4044        // lift-fragile). If the chain has no fonts, append the first registered font so
4045        // load_missing_for_chains / resolve_char find it and text shapes (not measure 0).
4046        let total_fonts = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
4047            + chain.unicode_fallbacks.len();
4048        if total_fonts == 0 {
4049            if let Some((_pattern, id)) = fc_cache.list().first() {
4050                // Vec::new() ranges (not pattern.unicode_ranges.clone()) — the Vec-clone
4051                // mis-lifts on the web backend and empty == "no range restriction" here.
4052                chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
4053                    id: *id,
4054                    unicode_ranges: Vec::new(),
4055                    fallbacks: Vec::new(),
4056                });
4057            }
4058        }
4059
4060        chains.insert(cache_key, chain);
4061    }
4062
4063    // NOTE: FontRefs bypass fontconfig entirely — the shaping code checks
4064    // style.font_stack for FontStack::Ref and uses the font data directly.
4065    // No entries are inserted into `chains` for them.
4066
4067    let out = ResolvedFontChains {
4068        chains,
4069        unresolved_families: unresolved,
4070        last_resort_chains: 0,
4071    };
4072    report_unresolved_families(&out);
4073    out
4074}
4075
4076/// WEB-LIFT last resort, applied LIFT-SAFELY. The lifted backend drops in-place
4077/// mutations made through `BTreeMap::values_mut()` (the pushed `FontMatch` is silently
4078/// lost — same class as the cascade `From` mapped-collect drop) and mis-lifts the
4079/// `pattern.unicode_ranges.clone()` Vec-clone. So this rebuilds the map with an explicit
4080/// `for` loop (no `values_mut`) and appends a coverage-agnostic fallback using
4081/// `Vec::new()` ranges (the convention already used across this file for "no specific
4082/// range restriction"). Applied on BOTH resolver return paths — the fast path otherwise
4083/// returns chains with no last resort at all, so when the lifted
4084/// `query_matches`/`find_unicode_fallbacks` yields an empty chain even though a fallback
4085/// font IS registered, the text node measures 0 → `LayoutError::InvalidTree`.
4086fn ensure_chains_nonempty(resolved: &mut ResolvedFontChains, fc_cache: &FcFontCache) {
4087    let fallback_id = match fc_cache.list().first() {
4088        Some((_pattern, id)) => *id,
4089        None => return,
4090    };
4091    let keys: Vec<FontChainKeyOrRef> = resolved.chains.keys().cloned().collect();
4092    let mut rebuilt: HashMap<FontChainKeyOrRef, FontFallbackChain> =
4093        HashMap::new();
4094    let mut last_resort = 0usize;
4095    for key in keys {
4096        if let Some(mut chain) = resolved.chains.remove(&key) {
4097            let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
4098                + chain.unicode_fallbacks.len();
4099            if total == 0 {
4100                // NOT SILENT: this chain matched nothing at all. Every such
4101                // chain gets the SAME arbitrary `fallback_id` — which is
4102                // precisely how N distinct font-families collapsed onto one
4103                // FontId. It still renders (a missing font must never be a
4104                // blank screen), but it is now counted and reported.
4105                last_resort += 1;
4106                if let FontChainKeyOrRef::Chain(k) = &key {
4107                    eprintln!(
4108                        "[azul][font] LAST-RESORT fallback for font stack {:?}: nothing in \
4109                         the stack matched, rendering in an arbitrary system font.",
4110                        k.font_families
4111                    );
4112                }
4113                chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
4114                    id: fallback_id,
4115                    unicode_ranges: Vec::new(),
4116                    fallbacks: Vec::new(),
4117                });
4118            }
4119            rebuilt.insert(key, chain);
4120        }
4121    }
4122    resolved.chains = rebuilt;
4123    resolved.last_resort_chains = last_resort;
4124}
4125
4126/// Convenience function that collects and resolves font chains in one call
4127///
4128/// # Arguments
4129/// * `styled_dom` - The styled DOM to extract font stacks from
4130/// * `fc_cache` - The fontconfig cache to resolve fonts against
4131/// * `platform` - The current platform for resolving system font types
4132///
4133/// # Returns
4134/// A `ResolvedFontChains` containing all resolved font chains
4135/// Collect font stacks, register embedded fonts, and resolve font chains
4136/// in a single pass over the DOM nodes. Replaces the old two-pass approach
4137/// where `register_embedded_fonts_from_styled_dom` + `collect_and_resolve_font_chains`
4138/// each independently scanned all nodes.
4139pub fn collect_and_resolve_font_chains_with_registration<T: ParsedFontTrait>(
4140    styled_dom: &StyledDom,
4141    fc_cache: &FcFontCache,
4142    font_manager: &crate::text3::cache::FontManager<T>,
4143    platform: &azul_css::system::Platform,
4144) -> ResolvedFontChains {
4145    let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
4146
4147    // Register embedded FontRefs (from the same scan, no second pass)
4148    for font_ref in collected.font_refs.values() {
4149        font_manager.register_embedded_font(font_ref);
4150    }
4151
4152    // Fast path (rust-fontconfig 4.2): when a registry is attached
4153    // we can resolve each stack by cmap-probing candidate files
4154    // against the codepoints the DOM actually uses, instead of
4155    // letting `request_fonts` eagerly parse every CSS fallback
4156    // via allsorts. On excel.html this drops `font_chain_resolve`
4157    // from ~128 ms / 49 faces parsed to ~5 ms / 3 faces.
4158    //
4159    // Falls back to the legacy pattern-map resolver when:
4160    //   - no registry is present (offline `FcFontCache` callers)
4161    //   - the DOM has no text codepoints (no shaping to be done,
4162    //     so cmap-probing has nothing to check and partial-cover
4163    //     entries would be surprising)
4164    if let Some(registry) = font_manager.registry.as_deref() {
4165        let used_chars = collect_used_codepoints_all(styled_dom);
4166        if !used_chars.is_empty() {
4167            let mut fast = resolve_font_chains_fast(
4168                &collected,
4169                registry,
4170                &used_chars,
4171                &font_manager.memory_families,
4172            );
4173            ensure_chains_nonempty(&mut fast, fc_cache);
4174            return fast;
4175        }
4176    }
4177
4178    // Legacy path: pattern-map resolver. Only reached when the
4179    // caller passes an `FcFontCache` without a live registry
4180    // (ad-hoc tests, the PDF writer, etc.).
4181    let scripts = scripts_present_in_styled_dom(styled_dom);
4182    let mut resolved = resolve_font_chains_with_registry(
4183        &collected,
4184        fc_cache,
4185        font_manager.registry.as_deref(),
4186        Some(&scripts),
4187        &font_manager.memory_families,
4188    );
4189
4190    let used_chars = collect_used_codepoints(styled_dom);
4191    for chain in resolved.chains.values_mut() {
4192        prune_chain_to_used_chars(chain, &used_chars);
4193    }
4194    // WEB-LIFT last resort (AFTER the prune, so it survives — the prune drops fonts
4195    // whose parsed cmap doesn't cover used_chars, which removes the registered fallback
4196    // before it's parsed): if a chain ended up empty, append the first registered font
4197    // so load_missing_for_chains finds it and text shapes instead of measuring 0.
4198    // LIFT-SAFE rebuild (see ensure_chains_nonempty) — the old `values_mut()` +
4199    // `unicode_ranges.clone()` version dropped the push in the lifted backend, leaving
4200    // the chain empty (web-text-min n1 measured 0xfffffffe/auto → InvalidTree).
4201    ensure_chains_nonempty(&mut resolved, fc_cache);
4202    resolved
4203}
4204
4205/// Fast-path resolver backed by [`FcFontRegistry::request_fonts_fast`].
4206///
4207/// Iterates `collected.font_stacks`, shapes each `(stack, weight,
4208/// italic, oblique)` combo into a cmap-probe request carrying the
4209/// DOM's codepoint set, calls the registry, and returns a
4210/// `ResolvedFontChains` keyed by `FontChainKeyOrRef::Chain` — the
4211/// same keys the legacy resolver emits, so downstream code
4212/// (`load_missing_for_chains`, `shape_with_font_fallback`) is
4213/// unchanged.
4214pub fn resolve_font_chains_fast(
4215    collected: &CollectedFontStacks,
4216    registry: &rust_fontconfig::registry::FcFontRegistry,
4217    codepoints: &std::collections::BTreeSet<char>,
4218    memory_families: &HashMap<String, rust_fontconfig::FontMatch>,
4219) -> ResolvedFontChains {
4220    use rust_fontconfig::PatternMatch;
4221
4222    static DBG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4223    let dbg = *DBG.get_or_init(|| std::env::var_os("AZ_FAST_RESOLVE_DEBUG").is_some());
4224
4225    let mut chains: HashMap<FontChainKeyOrRef, FontFallbackChain> = HashMap::new();
4226    let mut unresolved: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4227
4228    for font_stack in &collected.font_stacks {
4229        if font_stack.is_empty() {
4230            continue;
4231        }
4232
4233        // (2026-06-10) Build the key through the ONE canonical constructor
4234        // (FontChainKey::from_selectors — first-wins dedup + the same empty-stack
4235        // fallback) so the stored key always matches the shaping-time lookup key.
4236        let canonical_key = FontChainKey::from_selectors(font_stack);
4237        let font_families = canonical_key.font_families.clone();
4238
4239        let weight = font_stack[0].weight;
4240        let is_italic = font_stack[0].style == FontStyle::Italic;
4241        let is_oblique = font_stack[0].style == FontStyle::Oblique;
4242
4243        let cache_key = FontChainKeyOrRef::Chain(FontChainKey {
4244            font_families: font_families.clone(),
4245            weight,
4246            italic: is_italic,
4247            oblique: is_oblique,
4248        });
4249
4250        if chains.contains_key(&cache_key) {
4251            continue;
4252        }
4253
4254        let italic_match = if is_italic {
4255            PatternMatch::True
4256        } else {
4257            PatternMatch::False
4258        };
4259
4260        // ── MEMORY FONTS FIRST ──────────────────────────────────────────
4261        // `request_fonts_fast` only knows about fonts that exist as FILES
4262        // (it walks the registry's `known_paths`). A family registered via
4263        // `FontManager::register_named_font` (bundled embedder font, the
4264        // built-in mock test fonts) lives only in the `FcFontCache`'s
4265        // memory-font table and is INVISIBLE to it — such a family silently
4266        // fell through to a system fallback on every production build
4267        // (production always has a live registry, so it always took this
4268        // path). Match memory families by name here, in CSS order, and only
4269        // hand the remaining families to the disk probe.
4270        let (mut css_fallbacks, disk_families) =
4271            split_memory_matches(&font_families, memory_families);
4272
4273        let request = vec![(disk_families.clone(), codepoints.clone())];
4274        let mut chains_out = if disk_families.is_empty() {
4275            Vec::new()
4276        } else {
4277            registry.request_fonts_fast(&request, weight, italic_match)
4278        };
4279        if dbg {
4280            let total_fonts: usize = chains_out
4281                .iter()
4282                .map(|c| c.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>())
4283                .sum();
4284            eprintln!(
4285                "[FAST] stack {:?} w={:?} i={:?} → {} groups, {} faces",
4286                font_families,
4287                weight,
4288                italic_match,
4289                chains_out
4290                    .first()
4291                    .map_or(0, |c| c.css_fallbacks.len()),
4292                total_fonts,
4293            );
4294        }
4295        // Merge: memory-matched groups (in CSS order) + whatever the disk
4296        // probe found for the remaining families.
4297        let mut chain = chains_out.pop().unwrap_or(FontFallbackChain {
4298            css_fallbacks: Vec::new(),
4299            unicode_fallbacks: Vec::new(),
4300            original_stack: font_families.clone(),
4301        });
4302        if !css_fallbacks.is_empty() {
4303            css_fallbacks.extend(chain.css_fallbacks.drain(..));
4304            chain.css_fallbacks = css_fallbacks;
4305        }
4306
4307        // A family that produced no group matched NOTHING. Record it — a
4308        // silently-unmatched family is the root cause of "every font-family
4309        // renders in the same fallback font".
4310        for family in &font_families {
4311            let matched = chain
4312                .css_fallbacks
4313                .iter()
4314                .any(|g| g.css_name.eq_ignore_ascii_case(family) && !g.fonts.is_empty());
4315            if !matched && !is_generic_family(family) {
4316                unresolved.insert(family.clone());
4317            }
4318        }
4319
4320        chains.insert(cache_key, chain);
4321    }
4322
4323    let out = ResolvedFontChains {
4324        chains,
4325        unresolved_families: unresolved,
4326        last_resort_chains: 0,
4327    };
4328    report_unresolved_families(&out);
4329    out
4330}
4331
4332/// CSS generic families are not expected to match by name (they are
4333/// expanded to concrete OS families before lookup), so a missing group for
4334/// them is not a resolution failure worth reporting.
4335fn is_generic_family(family: &str) -> bool {
4336    matches!(
4337        family.to_ascii_lowercase().as_str(),
4338        "serif"
4339            | "sans-serif"
4340            | "monospace"
4341            | "cursive"
4342            | "fantasy"
4343            | "system-ui"
4344            | "ui-serif"
4345            | "ui-sans-serif"
4346            | "ui-monospace"
4347            | "ui-rounded"
4348            | "emoji"
4349            | "math"
4350            | "fangsong"
4351    )
4352}
4353
4354/// Log every family the resolver could not match, ONCE per process per
4355/// family name.
4356///
4357/// This is the diagnostic that was missing. Before this, a stylesheet
4358/// asking for `font-family: Arial` on a box with no Arial installed got a
4359/// system fallback and said nothing — so eight different families rendering
4360/// identically looked like correct behaviour to every test we had.
4361fn report_unresolved_families(resolved: &ResolvedFontChains) {
4362    use std::sync::{Mutex, OnceLock};
4363    if resolved.unresolved_families.is_empty() {
4364        return;
4365    }
4366    static SEEN: OnceLock<Mutex<std::collections::BTreeSet<String>>> = OnceLock::new();
4367    let seen = SEEN.get_or_init(|| Mutex::new(std::collections::BTreeSet::new()));
4368    let Ok(mut seen) = seen.lock() else { return };
4369    for family in &resolved.unresolved_families {
4370        if seen.insert(family.clone()) {
4371            eprintln!(
4372                "[azul][font] UNRESOLVED font-family {family:?}: no font file and no \
4373                 registered in-memory font matches this family. Text that asks for it \
4374                 renders in a FALLBACK font. Register it with \
4375                 FontManager::register_named_font(), or install it."
4376            );
4377        }
4378    }
4379}
4380
4381/// Legacy wrapper: collect + resolve without registration. Kept for
4382/// backward compatibility; defaults to the full 7-script unicode
4383/// fallback set.
4384#[must_use] pub fn collect_and_resolve_font_chains(
4385    styled_dom: &StyledDom,
4386    fc_cache: &FcFontCache,
4387    platform: &azul_css::system::Platform,
4388) -> ResolvedFontChains {
4389    let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
4390    resolve_font_chains(&collected, fc_cache, None)
4391}
4392
4393/// Legacy wrapper: register only. Prefer `collect_and_resolve_font_chains_with_registration`.
4394pub fn register_embedded_fonts_from_styled_dom<T: ParsedFontTrait>(
4395    styled_dom: &StyledDom,
4396    font_manager: &crate::text3::cache::FontManager<T>,
4397    platform: &azul_css::system::Platform,
4398) {
4399    let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
4400    for font_ref in collected.font_refs.values() {
4401        font_manager.register_embedded_font(font_ref);
4402    }
4403}
4404
4405// Font Loading Functions
4406
4407use std::collections::HashSet;
4408
4409use rust_fontconfig::FontId;
4410
4411/// Extract all unique `FontIds` from resolved font chains
4412///
4413/// This function collects all `FontIds` that are referenced in the font chains,
4414/// which represents the complete set of fonts that may be needed for rendering.
4415#[must_use] pub fn collect_font_ids_from_chains(chains: &ResolvedFontChains) -> HashSet<FontId> {
4416    let mut font_ids = HashSet::new();
4417
4418    // M12.7: hashbrown's RawIterRange (the .values() iterator below) mis-lifts
4419    // to wasm and loops forever on an empty map; is_empty() is len-based, so
4420    // bail out before iterating when there are no chains (web bare-body case).
4421    if chains.chains.is_empty() {
4422        return font_ids;
4423    }
4424
4425    for chain in chains.chains.values() {
4426        // Collect from CSS fallbacks
4427        for group in &chain.css_fallbacks {
4428            for font in &group.fonts {
4429                font_ids.insert(font.id);
4430            }
4431        }
4432
4433        // Collect from Unicode fallbacks
4434        for font in &chain.unicode_fallbacks {
4435            font_ids.insert(font.id);
4436        }
4437    }
4438
4439    font_ids
4440}
4441
4442/// Compute which fonts need to be loaded (diff with already loaded fonts)
4443///
4444/// # Arguments
4445/// * `required_fonts` - Set of `FontIds` that are needed
4446/// * `already_loaded` - Set of `FontIds` that are already loaded
4447///
4448/// # Returns
4449/// Set of `FontIds` that need to be loaded
4450#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
4451#[must_use] pub fn compute_fonts_to_load(
4452    required_fonts: &HashSet<FontId>,
4453    already_loaded: &HashSet<FontId>,
4454) -> HashSet<FontId> {
4455    // M12.7: `.difference()` drives hashbrown's RawIterRange, which mis-lifts
4456    // to wasm and loops on an empty map. Nothing required → nothing to load.
4457    if required_fonts.is_empty() {
4458        return HashSet::new();
4459    }
4460    required_fonts.difference(already_loaded).copied().collect()
4461}
4462
4463/// Result of loading fonts
4464#[derive(Debug)]
4465pub struct FontLoadResult<T> {
4466    /// Successfully loaded fonts
4467    pub loaded: HashMap<FontId, T>,
4468    /// `FontIds` that failed to load, with error messages
4469    pub failed: Vec<(FontId, String)>,
4470}
4471
4472/// Load fonts from disk using the provided loader function
4473///
4474/// This is a generic function that works with any font loading implementation.
4475/// The `load_fn` parameter should be a function that takes font bytes and an index,
4476/// and returns a parsed font or an error.
4477///
4478/// # Arguments
4479/// * `font_ids` - Set of `FontIds` to load
4480/// * `fc_cache` - The fontconfig cache to get font paths from
4481/// * `load_fn` - Function to load and parse font bytes
4482///
4483/// # Returns
4484/// A `FontLoadResult` containing successfully loaded fonts and any failures
4485#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
4486pub fn load_fonts_from_disk<T, F>(
4487    font_ids: &HashSet<FontId>,
4488    fc_cache: &FcFontCache,
4489    load_fn: F,
4490) -> FontLoadResult<T>
4491where
4492    // Bytes come in as `Arc<FontBytes>` so the loader can retain
4493    // them cheaply (one `Arc::clone` per retained copy). On disk the
4494    // backing is an mmap, so untouched glyf/CFF pages don't count
4495    // toward RSS — the layout shaper only faults in pages it reads.
4496    F: Fn(
4497        std::sync::Arc<rust_fontconfig::FontBytes>,
4498        usize,
4499    ) -> Result<T, crate::text3::cache::LayoutError>,
4500{
4501    let mut loaded = HashMap::new();
4502    let mut failed = Vec::new();
4503
4504    for font_id in font_ids {
4505        // Get font bytes from fc_cache as a shared mmap. Faces backed
4506        // by the same .ttc all observe the same `Arc<FontBytes>` via
4507        // rust_fontconfig's `shared_bytes` dedup.
4508        let Some(font_bytes) = fc_cache.get_font_bytes(font_id) else {
4509            failed.push((
4510                *font_id,
4511                format!("Could not get font bytes for {font_id:?}"),
4512            ));
4513            continue;
4514        };
4515
4516        // Get font index (for font collections like .ttc files)
4517        let font_index = fc_cache
4518            .get_font_by_id(font_id)
4519            .map_or(0, |source| match source {
4520                rust_fontconfig::OwnedFontSource::Disk(path) => path.font_index,
4521                rust_fontconfig::OwnedFontSource::Memory(font) => font.font_index,
4522            });
4523
4524        // Load the font using the provided function
4525        match load_fn(font_bytes, font_index) {
4526            Ok(font) => {
4527                loaded.insert(*font_id, font);
4528            }
4529            Err(e) => {
4530                failed.push((
4531                    *font_id,
4532                    format!("Failed to parse font {font_id:?}: {e:?}"),
4533                ));
4534            }
4535        }
4536    }
4537
4538    FontLoadResult { loaded, failed }
4539}
4540
4541/// Convenience function to load all required fonts for a styled DOM
4542///
4543/// This function:
4544/// 1. Collects all font stacks from the DOM
4545/// 2. Resolves them to font chains
4546/// 3. Extracts all required `FontIds`
4547/// 4. Computes which fonts need to be loaded (diff with already loaded)
4548/// 5. Loads the missing fonts
4549///
4550/// # Arguments
4551/// * `styled_dom` - The styled DOM to extract font requirements from
4552/// * `fc_cache` - The fontconfig cache
4553/// * `already_loaded` - Set of `FontIds` that are already loaded
4554/// * `load_fn` - Function to load and parse font bytes
4555/// * `platform` - The current platform for resolving system font types
4556///
4557/// # Returns
4558/// A tuple of (`ResolvedFontChains`, `FontLoadResult`)
4559#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
4560pub fn resolve_and_load_fonts<T, F>(
4561    styled_dom: &StyledDom,
4562    fc_cache: &FcFontCache,
4563    already_loaded: &HashSet<FontId>,
4564    load_fn: F,
4565    platform: &azul_css::system::Platform,
4566) -> (ResolvedFontChains, FontLoadResult<T>)
4567where
4568    F: Fn(
4569        std::sync::Arc<rust_fontconfig::FontBytes>,
4570        usize,
4571    ) -> Result<T, crate::text3::cache::LayoutError>,
4572{
4573    // Step 1-2: Collect and resolve font chains
4574    let chains = collect_and_resolve_font_chains(styled_dom, fc_cache, platform);
4575
4576    // Step 3: Extract all required FontIds
4577    let required_fonts = collect_font_ids_from_chains(&chains);
4578
4579    // Step 4: Compute diff
4580    let fonts_to_load = compute_fonts_to_load(&required_fonts, already_loaded);
4581
4582    // Step 5: Load missing fonts
4583    let load_result = load_fonts_from_disk(&fonts_to_load, fc_cache, load_fn);
4584
4585    (chains, load_result)
4586}
4587
4588// ============================================================================
4589// Scrollbar Style Getters
4590// ============================================================================
4591
4592use azul_css::props::style::scrollbar::{
4593    LayoutScrollbarWidth, ScrollbarVisibilityMode, StyleScrollbarColor,
4594};
4595
4596/// Computed scrollbar style for a node.
4597///
4598/// All visual defaults (colors, width) come from the UA CSS conditional rules
4599/// in `core/src/ua_css.rs` — individual `CssPropertyWithConditions` entries for
4600/// `scrollbar-color` and `scrollbar-width`, keyed on `@os` / `@theme`.
4601///
4602/// Overlay behaviour (fade timing, visibility, clip) is derived from the
4603/// resolved `scrollbar-width` mode:
4604///   - `thin`  → overlay:  fade 500/200 ms, `WhenScrolling`, clip = true
4605///   - `auto`  → classic:  no fade, `Always`, clip = false
4606///   - `none`  → hidden:   no fade, `Always`, clip = false
4607///
4608/// Per-node CSS overrides (in priority order):
4609///   1. `-azul-scrollbar-style`  (full `ScrollbarInfo` override)
4610///   2. `scrollbar-width`        (overrides width + overlay mode)
4611///   3. `scrollbar-color`        (overrides thumb / track colours)
4612#[derive(Copy, Debug, Clone)]
4613pub struct ComputedScrollbarStyle {
4614    /// The scrollbar width mode (auto/thin/none)
4615    pub width_mode: LayoutScrollbarWidth,
4616    /// Visual width in pixels — used for rendering track + thumb.
4617    /// Non-zero even for overlay scrollbars.
4618    pub visual_width_px: f32,
4619    /// Reserve width in pixels — layout space subtracted from content area.
4620    /// 0 for overlay scrollbars, equal to `visual_width_px` for legacy.
4621    pub reserve_width_px: f32,
4622    /// Thumb color
4623    pub thumb_color: ColorU,
4624    /// Track color
4625    pub track_color: ColorU,
4626    /// Button color (for scroll arrows)
4627    pub button_color: ColorU,
4628    /// Corner color (where scrollbars meet)
4629    pub corner_color: ColorU,
4630    /// Whether to clip the scrollbar to the container's border-radius
4631    pub clip_to_container_border: bool,
4632    /// Delay in ms before scrollbar starts fading out (0 = never fade)
4633    pub fade_delay_ms: u32,
4634    /// Duration of fade-out animation in ms (0 = instant)
4635    pub fade_duration_ms: u32,
4636    /// Scrollbar visibility mode (always / when-scrolling / auto)
4637    pub visibility: ScrollbarVisibilityMode,
4638    /// Whether to show top/bottom (or left/right) arrow buttons.
4639    /// When false, the track spans the entire scrollbar length.
4640    pub show_scroll_buttons: bool,
4641    /// Size of each arrow button in px (square: width = height).
4642    /// Only used when `show_scroll_buttons == true`.
4643    pub scroll_button_size_px: f32,
4644    /// Whether to show the corner rect where V and H scrollbars meet.
4645    pub show_corner_rect: bool,
4646    /// Thumb color when hovered (None = use `thumb_color`)
4647    pub thumb_color_hover: Option<ColorU>,
4648    /// Thumb color when pressed/active (None = use `thumb_color`)
4649    pub thumb_color_active: Option<ColorU>,
4650    /// Track color when hovered (None = use `track_color`)
4651    pub track_color_hover: Option<ColorU>,
4652    /// Visual width when hovered (None = use `visual_width_px`)
4653    pub visual_width_px_hover: Option<f32>,
4654    /// Visual width when pressed (None = use `visual_width_px`)
4655    pub visual_width_px_active: Option<f32>,
4656}
4657
4658impl Default for ComputedScrollbarStyle {
4659    fn default() -> Self {
4660        // Evaluate UA CSS rules with a default context (no OS info).
4661        // Picks the unconditional fallback: classic light, auto width.
4662        let ctx = azul_css::dynamic_selector::DynamicSelectorContext::default();
4663        let ua = azul_core::ua_css::evaluate_ua_scrollbar_css(&ctx);
4664        Self::from_ua_resolved(&ua)
4665    }
4666}
4667
4668impl ComputedScrollbarStyle {
4669    /// Build from resolved UA scrollbar CSS properties.
4670    ///
4671    /// Each property is read individually from the resolved UA CSS.
4672    fn from_ua_resolved(ua: &azul_core::ua_css::ResolvedUaScrollbar) -> Self {
4673        let width_mode = ua.width;
4674        let visibility = ua.visibility;
4675        let fade_delay_ms = ua.fade_delay.ms;
4676        let fade_duration_ms = ua.fade_duration.ms;
4677
4678        let visual_width_px = match width_mode {
4679            LayoutScrollbarWidth::Thin => SCROLLBAR_WIDTH_THIN,
4680            LayoutScrollbarWidth::Auto => SCROLLBAR_WIDTH_AUTO,
4681            LayoutScrollbarWidth::None => 0.0,
4682        };
4683
4684        // Overlay scrollbars don't reserve layout space and hide buttons / corner.
4685        let is_overlay = visibility == ScrollbarVisibilityMode::WhenScrolling;
4686        let reserve_width_px = if is_overlay { 0.0 } else { visual_width_px };
4687        let show_scroll_buttons = !is_overlay;
4688        let scroll_button_size_px = if is_overlay { 0.0 } else { visual_width_px };
4689        let show_corner_rect = !is_overlay;
4690
4691        let (thumb_color, track_color) = match ua.color {
4692            StyleScrollbarColor::Custom(c) => (c.thumb, c.track),
4693            StyleScrollbarColor::Auto => (ColorU::TRANSPARENT, ColorU::TRANSPARENT),
4694        };
4695
4696        // Compute hover / active variants:
4697        // Hover: lighten thumb, widen by +SCROLLBAR_HOVER_EXPAND_PX
4698        // Active: darken thumb, widen by +SCROLLBAR_HOVER_EXPAND_PX
4699        let thumb_hover = ColorU {
4700            r: thumb_color.r.saturating_add(THUMB_HOVER_LIGHTEN),
4701            g: thumb_color.g.saturating_add(THUMB_HOVER_LIGHTEN),
4702            b: thumb_color.b.saturating_add(THUMB_HOVER_LIGHTEN),
4703            a: thumb_color.a.saturating_add(THUMB_HOVER_ALPHA_ADD),
4704        };
4705        let thumb_active = ColorU {
4706            r: thumb_color.r.saturating_sub(THUMB_ACTIVE_DARKEN),
4707            g: thumb_color.g.saturating_sub(THUMB_ACTIVE_DARKEN),
4708            b: thumb_color.b.saturating_sub(THUMB_ACTIVE_DARKEN),
4709            a: 255,
4710        };
4711        let track_hover = ColorU {
4712            r: track_color.r,
4713            g: track_color.g,
4714            b: track_color.b,
4715            a: track_color.a.saturating_add(THUMB_HOVER_ALPHA_ADD),
4716        };
4717        let hover_width = visual_width_px + SCROLLBAR_HOVER_EXPAND_PX;
4718        let active_width = visual_width_px + SCROLLBAR_HOVER_EXPAND_PX;
4719
4720        Self {
4721            width_mode,
4722            visual_width_px,
4723            reserve_width_px,
4724            thumb_color,
4725            track_color,
4726            button_color: ColorU::TRANSPARENT,
4727            corner_color: ColorU::TRANSPARENT,
4728            clip_to_container_border: is_overlay,
4729            fade_delay_ms,
4730            fade_duration_ms,
4731            visibility,
4732            show_scroll_buttons,
4733            scroll_button_size_px,
4734            show_corner_rect,
4735            thumb_color_hover: Some(thumb_hover),
4736            thumb_color_active: Some(thumb_active),
4737            track_color_hover: Some(track_hover),
4738            visual_width_px_hover: Some(hover_width),
4739            visual_width_px_active: Some(active_width),
4740        }
4741    }
4742}
4743
4744/// Get the computed scrollbar style for a node.
4745///
4746/// Resolution order (later wins):
4747///   1. UA scrollbar CSS (`CssPropertyWithConditions` in `ua_css.rs`,
4748///      evaluated via `@os` / `@theme` conditions)
4749///   2. CSS `-azul-scrollbar-style` (full `ScrollbarInfo` customisation)
4750///   3. CSS `scrollbar-width`  (overrides width only)
4751///   4. CSS `scrollbar-color`  (overrides thumb / track colours)
4752///   5. CSS `-azul-scrollbar-visibility` (overrides visibility + clip)
4753///   6. CSS `-azul-scrollbar-fade-delay` (overrides fade delay)
4754///   7. CSS `-azul-scrollbar-fade-duration` (overrides fade duration)
4755///
4756/// When `system_style` is `None`, falls back to the unconditional UA rule
4757/// (classic light scrollbar).
4758#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4759#[must_use] pub fn get_scrollbar_style(
4760    styled_dom: &StyledDom,
4761    node_id: NodeId,
4762    node_state: &StyledNodeState,
4763    system_style: Option<&azul_css::system::SystemStyle>,
4764) -> ComputedScrollbarStyle {
4765    let node_data = &styled_dom.node_data.as_container()[node_id];
4766
4767    // Step 1: Evaluate UA scrollbar CSS using the DynamicSelector system.
4768    let ctx = system_style.map_or_else(
4769        azul_css::dynamic_selector::DynamicSelectorContext::default,
4770        azul_css::dynamic_selector::DynamicSelectorContext::from_system_style,
4771    );
4772    let ua = azul_core::ua_css::evaluate_ua_scrollbar_css(&ctx);
4773    let result = ComputedScrollbarStyle::from_ua_resolved(&ua);
4774
4775    // FAST PATH: 99% of nodes have no scrollbar CSS. Bail before walking 8 × cascade.
4776    if node_state.is_normal() {
4777        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
4778            if !cc.has_scrollbar_css(node_id.index()) {
4779                return result;
4780            }
4781        }
4782    }
4783    let mut result = result;
4784
4785    // Step 2: Check individual scrollbar part backgrounds
4786    if let Some(track) = styled_dom
4787        .css_property_cache
4788        .ptr
4789        .get_scrollbar_track(node_data, &node_id, node_state)
4790        .and_then(|v| v.get_property())
4791    {
4792        result.track_color = extract_color_from_background(track);
4793    }
4794    if let Some(thumb) = styled_dom
4795        .css_property_cache
4796        .ptr
4797        .get_scrollbar_thumb(node_data, &node_id, node_state)
4798        .and_then(|v| v.get_property())
4799    {
4800        result.thumb_color = extract_color_from_background(thumb);
4801    }
4802    if let Some(button) = styled_dom
4803        .css_property_cache
4804        .ptr
4805        .get_scrollbar_button(node_data, &node_id, node_state)
4806        .and_then(|v| v.get_property())
4807    {
4808        result.button_color = extract_color_from_background(button);
4809    }
4810    if let Some(corner) = styled_dom
4811        .css_property_cache
4812        .ptr
4813        .get_scrollbar_corner(node_data, &node_id, node_state)
4814        .and_then(|v| v.get_property())
4815    {
4816        result.corner_color = extract_color_from_background(corner);
4817    }
4818
4819    // Step 3: Check for scrollbar-width (overrides width only, not overlay)
4820    if let Some(scrollbar_width) = styled_dom
4821        .css_property_cache
4822        .ptr
4823        .get_scrollbar_width(node_data, &node_id, node_state)
4824        .and_then(|v| v.get_property())
4825    {
4826        result.width_mode = *scrollbar_width;
4827        let w = match scrollbar_width {
4828            LayoutScrollbarWidth::Auto => SCROLLBAR_WIDTH_AUTO,
4829            LayoutScrollbarWidth::Thin => SCROLLBAR_WIDTH_THIN,
4830            LayoutScrollbarWidth::None => 0.0,
4831        };
4832        result.visual_width_px = w;
4833        if result.visibility != ScrollbarVisibilityMode::WhenScrolling {
4834            result.reserve_width_px = w;
4835        }
4836    }
4837
4838    // Step 4: Check for scrollbar-color (overrides thumb/track colors)
4839    if let Some(scrollbar_color) = styled_dom
4840        .css_property_cache
4841        .ptr
4842        .get_scrollbar_color(node_data, &node_id, node_state)
4843        .and_then(|v| v.get_property())
4844    {
4845        match scrollbar_color {
4846            StyleScrollbarColor::Auto => { /* keep */ }
4847            StyleScrollbarColor::Custom(custom) => {
4848                result.thumb_color = custom.thumb;
4849                result.track_color = custom.track;
4850            }
4851        }
4852    }
4853
4854    // Step 5: Check for -azul-scrollbar-visibility
4855    if let Some(vis) = styled_dom
4856        .css_property_cache
4857        .ptr
4858        .get_scrollbar_visibility(node_data, &node_id, node_state)
4859        .and_then(|v| v.get_property())
4860    {
4861        result.visibility = *vis;
4862        result.clip_to_container_border = *vis == ScrollbarVisibilityMode::WhenScrolling;
4863        // Overlay mode: no reserved layout space, hide buttons and corner
4864        let is_overlay = *vis == ScrollbarVisibilityMode::WhenScrolling;
4865        if is_overlay {
4866            result.reserve_width_px = 0.0;
4867            result.show_scroll_buttons = false;
4868            result.scroll_button_size_px = 0.0;
4869            result.show_corner_rect = false;
4870        } else {
4871            result.reserve_width_px = result.visual_width_px;
4872        }
4873    }
4874
4875    // Step 6: Check for -azul-scrollbar-fade-delay
4876    if let Some(delay) = styled_dom
4877        .css_property_cache
4878        .ptr
4879        .get_scrollbar_fade_delay(node_data, &node_id, node_state)
4880        .and_then(|v| v.get_property())
4881    {
4882        result.fade_delay_ms = delay.ms;
4883    }
4884
4885    // Step 7: Check for -azul-scrollbar-fade-duration
4886    if let Some(dur) = styled_dom
4887        .css_property_cache
4888        .ptr
4889        .get_scrollbar_fade_duration(node_data, &node_id, node_state)
4890        .and_then(|v| v.get_property())
4891    {
4892        result.fade_duration_ms = dur.ms;
4893    }
4894
4895    result
4896}
4897
4898/// Cached wrapper for [`get_scrollbar_style`] that reuses the
4899/// memo stored on `LayoutContext`.
4900///
4901/// The underlying call performs
4902/// 9 cascade walks per node (track/thumb/button/corner/width/
4903/// color/visibility/fade-delay/fade-duration). The BFC, Taffy,
4904/// and display-list callers all hit the same node many times
4905/// inside a single layout pass, so caching turns ~21 rebuilds per
4906/// node into one.
4907///
4908/// Falls back to the uncached `get_scrollbar_style` when no ctx
4909/// is available (shouldn't happen in the current code paths).
4910pub fn get_scrollbar_style_cached<T: ParsedFontTrait>(
4911    ctx: &crate::solver3::LayoutContext<'_, T>,
4912    node_id: NodeId,
4913    node_state: &StyledNodeState,
4914) -> ComputedScrollbarStyle {
4915    if let Some(s) = ctx.scrollbar_style_cache.borrow().get(&node_id) {
4916        return *s;
4917    }
4918    let style = get_scrollbar_style(
4919        ctx.styled_dom,
4920        node_id,
4921        node_state,
4922        ctx.system_style.as_deref(),
4923    );
4924    ctx.scrollbar_style_cache
4925        .borrow_mut()
4926        .insert(node_id, style);
4927    style
4928}
4929
4930/// Helper to extract a solid color from a `StyleBackgroundContent`
4931const fn extract_color_from_background(
4932    bg: &azul_css::props::style::background::StyleBackgroundContent,
4933) -> ColorU {
4934    use azul_css::props::style::background::StyleBackgroundContent;
4935    match bg {
4936        StyleBackgroundContent::Color(c) => *c,
4937        _ => ColorU::TRANSPARENT,
4938    }
4939}
4940
4941/// Check if a node should clip its scrollbar to the container's border-radius
4942#[must_use] pub fn should_clip_scrollbar_to_border(
4943    styled_dom: &StyledDom,
4944    node_id: NodeId,
4945    node_state: &StyledNodeState,
4946) -> bool {
4947    let style = get_scrollbar_style(styled_dom, node_id, node_state, None);
4948    style.clip_to_container_border
4949}
4950
4951/// Get the scrollbar visual width in pixels for a node (used for rendering)
4952#[must_use] pub fn get_scrollbar_width_px(
4953    styled_dom: &StyledDom,
4954    node_id: NodeId,
4955    node_state: &StyledNodeState,
4956) -> f32 {
4957    let style = get_scrollbar_style(styled_dom, node_id, node_state, None);
4958    style.visual_width_px
4959}
4960
4961/// Checks if text in a node is selectable based on CSS `user-select` property.
4962///
4963/// Returns `true` if the text can be selected (default behavior),
4964/// `false` if `user-select: none` is set.
4965#[must_use] pub fn is_text_selectable(
4966    styled_dom: &StyledDom,
4967    node_id: NodeId,
4968    node_state: &StyledNodeState,
4969) -> bool {
4970    let node_data = &styled_dom.node_data.as_container()[node_id];
4971
4972    styled_dom
4973        .css_property_cache
4974        .ptr
4975        .get_user_select(node_data, &node_id, node_state)
4976        .and_then(|v| v.get_property())
4977        .is_none_or(|us| *us != StyleUserSelect::None) // Default: text is selectable
4978}
4979
4980/// Checks if a node has the `contenteditable` attribute set directly.
4981///
4982/// Returns `true` if:
4983/// - The node has `contenteditable: true` set via `.set_contenteditable(true)`
4984/// - OR the node has `contenteditable` attribute set to `true`
4985///
4986/// This does NOT check inheritance - use `is_node_contenteditable_inherited` for that.
4987#[must_use] pub fn is_node_contenteditable(styled_dom: &StyledDom, node_id: NodeId) -> bool {
4988    use azul_core::dom::AttributeType;
4989
4990    let node_data = &styled_dom.node_data.as_container()[node_id];
4991
4992    // First check the direct contenteditable field (primary method)
4993    if node_data.is_contenteditable() {
4994        return true;
4995    }
4996
4997    // Also check the attribute for backwards compatibility
4998    // Only return true if the attribute value is explicitly true
4999    node_data
5000        .attributes()
5001        .as_ref()
5002        .iter()
5003        .any(|attr| matches!(attr, AttributeType::ContentEditable(true)))
5004}
5005// =============================================================================
5006// Additional ExtractPropertyValue impls (not in compact cache tier 1/2)
5007// =============================================================================
5008
5009use azul_css::props::layout::table::{
5010    LayoutTableLayout, StyleBorderCollapse, StyleCaptionSide, StyleEmptyCells,
5011};
5012use azul_css::props::layout::text::LayoutTextJustify;
5013use azul_css::props::style::effects::StyleAspectRatio;
5014use azul_css::props::style::effects::StyleCursor;
5015use azul_css::props::style::effects::StyleObjectFit;
5016use azul_css::props::style::effects::StyleObjectPosition;
5017use azul_css::props::style::effects::StyleTextOrientation;
5018use azul_css::props::style::text::StyleHyphens;
5019use azul_css::props::style::text::StyleLineBreak;
5020use azul_css::props::style::text::StyleOverflowWrap;
5021use azul_css::props::style::text::StyleTextAlignLast;
5022use azul_css::props::style::text::StyleWordBreak;
5023
5024impl ExtractPropertyValue<LayoutTextJustify> for CssProperty {
5025    fn extract(&self) -> Option<LayoutTextJustify> {
5026        match self {
5027            Self::TextJustify(CssPropertyValue::Exact(v)) => Some(*v),
5028            _ => None,
5029        }
5030    }
5031}
5032
5033impl ExtractPropertyValue<StyleHyphens> for CssProperty {
5034    fn extract(&self) -> Option<StyleHyphens> {
5035        match self {
5036            Self::Hyphens(CssPropertyValue::Exact(v)) => Some(*v),
5037            _ => None,
5038        }
5039    }
5040}
5041
5042impl ExtractPropertyValue<StyleWordBreak> for CssProperty {
5043    fn extract(&self) -> Option<StyleWordBreak> {
5044        match self {
5045            Self::WordBreak(CssPropertyValue::Exact(v)) => Some(*v),
5046            _ => None,
5047        }
5048    }
5049}
5050
5051impl ExtractPropertyValue<StyleOverflowWrap> for CssProperty {
5052    fn extract(&self) -> Option<StyleOverflowWrap> {
5053        match self {
5054            Self::OverflowWrap(CssPropertyValue::Exact(v)) => Some(*v),
5055            _ => None,
5056        }
5057    }
5058}
5059
5060impl ExtractPropertyValue<StyleLineBreak> for CssProperty {
5061    fn extract(&self) -> Option<StyleLineBreak> {
5062        match self {
5063            Self::LineBreak(CssPropertyValue::Exact(v)) => Some(*v),
5064            _ => None,
5065        }
5066    }
5067}
5068
5069impl ExtractPropertyValue<StyleTextAlignLast> for CssProperty {
5070    fn extract(&self) -> Option<StyleTextAlignLast> {
5071        match self {
5072            Self::TextAlignLast(CssPropertyValue::Exact(v)) => Some(*v),
5073            _ => None,
5074        }
5075    }
5076}
5077
5078impl ExtractPropertyValue<StyleObjectFit> for CssProperty {
5079    fn extract(&self) -> Option<StyleObjectFit> {
5080        match self {
5081            Self::ObjectFit(CssPropertyValue::Exact(v)) => Some(*v),
5082            _ => None,
5083        }
5084    }
5085}
5086
5087impl ExtractPropertyValue<StyleTextOrientation> for CssProperty {
5088    fn extract(&self) -> Option<StyleTextOrientation> {
5089        match self {
5090            Self::TextOrientation(CssPropertyValue::Exact(v)) => Some(*v),
5091            _ => None,
5092        }
5093    }
5094}
5095
5096impl ExtractPropertyValue<StyleObjectPosition> for CssProperty {
5097    fn extract(&self) -> Option<StyleObjectPosition> {
5098        match self {
5099            Self::ObjectPosition(CssPropertyValue::Exact(v)) => Some(*v),
5100            _ => None,
5101        }
5102    }
5103}
5104
5105impl ExtractPropertyValue<StyleAspectRatio> for CssProperty {
5106    fn extract(&self) -> Option<StyleAspectRatio> {
5107        match self {
5108            Self::AspectRatio(CssPropertyValue::Exact(v)) => Some(*v),
5109            _ => None,
5110        }
5111    }
5112}
5113
5114impl ExtractPropertyValue<LayoutTableLayout> for CssProperty {
5115    fn extract(&self) -> Option<LayoutTableLayout> {
5116        match self {
5117            Self::TableLayout(CssPropertyValue::Exact(v)) => Some(*v),
5118            _ => None,
5119        }
5120    }
5121}
5122
5123impl ExtractPropertyValue<StyleBorderCollapse> for CssProperty {
5124    fn extract(&self) -> Option<StyleBorderCollapse> {
5125        match self {
5126            Self::BorderCollapse(CssPropertyValue::Exact(v)) => Some(*v),
5127            _ => None,
5128        }
5129    }
5130}
5131
5132impl ExtractPropertyValue<StyleCaptionSide> for CssProperty {
5133    fn extract(&self) -> Option<StyleCaptionSide> {
5134        match self {
5135            Self::CaptionSide(CssPropertyValue::Exact(v)) => Some(*v),
5136            _ => None,
5137        }
5138    }
5139}
5140
5141impl ExtractPropertyValue<StyleEmptyCells> for CssProperty {
5142    fn extract(&self) -> Option<StyleEmptyCells> {
5143        match self {
5144            Self::EmptyCells(CssPropertyValue::Exact(v)) => Some(*v),
5145            _ => None,
5146        }
5147    }
5148}
5149
5150impl ExtractPropertyValue<StyleCursor> for CssProperty {
5151    fn extract(&self) -> Option<StyleCursor> {
5152        match self {
5153            Self::Cursor(CssPropertyValue::Exact(v)) => Some(*v),
5154            _ => None,
5155        }
5156    }
5157}
5158
5159// =============================================================================
5160// Additional macro-based getters (not covered by compact cache fast-path getters)
5161// =============================================================================
5162
5163get_css_property!(
5164    get_text_justify,
5165    get_text_justify,
5166    LayoutTextJustify,
5167    CssPropertyType::TextJustify
5168);
5169
5170get_css_property!(
5171    get_hyphens,
5172    get_hyphens,
5173    StyleHyphens,
5174    CssPropertyType::Hyphens
5175);
5176
5177get_css_property!(
5178    get_word_break,
5179    get_word_break,
5180    StyleWordBreak,
5181    CssPropertyType::WordBreak
5182);
5183
5184get_css_property!(
5185    get_overflow_wrap,
5186    get_overflow_wrap,
5187    StyleOverflowWrap,
5188    CssPropertyType::OverflowWrap
5189);
5190
5191get_css_property!(
5192    get_line_break,
5193    get_line_break,
5194    StyleLineBreak,
5195    CssPropertyType::LineBreak
5196);
5197
5198get_css_property!(
5199    get_text_align_last,
5200    get_text_align_last,
5201    StyleTextAlignLast,
5202    CssPropertyType::TextAlignLast
5203);
5204
5205get_css_property!(
5206    get_table_layout,
5207    get_table_layout,
5208    LayoutTableLayout,
5209    CssPropertyType::TableLayout
5210);
5211
5212get_css_property!(
5213    get_border_collapse,
5214    get_border_collapse,
5215    StyleBorderCollapse,
5216    CssPropertyType::BorderCollapse,
5217    compact = get_border_collapse
5218);
5219
5220get_css_property!(
5221    get_caption_side,
5222    get_caption_side,
5223    StyleCaptionSide,
5224    CssPropertyType::CaptionSide
5225);
5226
5227get_css_property!(
5228    get_empty_cells,
5229    get_empty_cells,
5230    StyleEmptyCells,
5231    CssPropertyType::EmptyCells
5232);
5233
5234get_css_property!(
5235    get_cursor_property,
5236    get_cursor,
5237    StyleCursor,
5238    CssPropertyType::Cursor
5239);
5240
5241// =============================================================================
5242// Handwritten getters (Option<T>, special logic, or non-standard returns)
5243// =============================================================================
5244
5245/// Get height property value for IFC text layout height reference.
5246#[must_use] pub fn get_height_value(
5247    styled_dom: &StyledDom,
5248    node_id: NodeId,
5249    node_state: &StyledNodeState,
5250) -> Option<LayoutHeight> {
5251    let node_data = &styled_dom.node_data.as_container()[node_id];
5252    styled_dom
5253        .css_property_cache
5254        .ptr
5255        .get_height(node_data, &node_id, node_state)
5256        .and_then(|v| v.get_property())
5257        .cloned()
5258}
5259
5260/// Get shape-inside property. Returns Option<ShapeInside> (cloned).
5261#[must_use] pub fn get_shape_inside(
5262    styled_dom: &StyledDom,
5263    node_id: NodeId,
5264    node_state: &StyledNodeState,
5265) -> Option<azul_css::props::layout::shape::ShapeInside> {
5266    let node_data = &styled_dom.node_data.as_container()[node_id];
5267    styled_dom
5268        .css_property_cache
5269        .ptr
5270        .get_shape_inside(node_data, &node_id, node_state)
5271        .and_then(|v| v.get_property())
5272        .cloned()
5273}
5274
5275/// Get shape-outside property. Returns Option<ShapeOutside> (cloned).
5276#[must_use] pub fn get_shape_outside(
5277    styled_dom: &StyledDom,
5278    node_id: NodeId,
5279    node_state: &StyledNodeState,
5280) -> Option<azul_css::props::layout::shape::ShapeOutside> {
5281    let node_data = &styled_dom.node_data.as_container()[node_id];
5282    styled_dom
5283        .css_property_cache
5284        .ptr
5285        .get_shape_outside(node_data, &node_id, node_state)
5286        .and_then(|v| v.get_property())
5287        .cloned()
5288}
5289
5290/// Get line-height as the full `StyleLineHeight` value for caller resolution.
5291#[must_use] pub fn get_line_height_value(
5292    styled_dom: &StyledDom,
5293    node_id: NodeId,
5294    node_state: &StyledNodeState,
5295) -> Option<azul_css::props::style::text::StyleLineHeight> {
5296    let node_data = &styled_dom.node_data.as_container()[node_id];
5297    styled_dom
5298        .css_property_cache
5299        .ptr
5300        .get_line_height(node_data, &node_id, node_state)
5301        .and_then(|v| v.get_property())
5302        .copied()
5303}
5304
5305/// Get text-indent as the full `StyleTextIndent` value for caller resolution.
5306#[must_use] pub fn get_text_indent_value(
5307    styled_dom: &StyledDom,
5308    node_id: NodeId,
5309    node_state: &StyledNodeState,
5310) -> Option<azul_css::props::style::text::StyleTextIndent> {
5311    let node_data = &styled_dom.node_data.as_container()[node_id];
5312    styled_dom
5313        .css_property_cache
5314        .ptr
5315        .get_text_indent(node_data, &node_id, node_state)
5316        .and_then(|v| v.get_property())
5317        .copied()
5318}
5319
5320/// Get column-count property. Returns Option<ColumnCount>.
5321#[must_use] pub fn get_column_count(
5322    styled_dom: &StyledDom,
5323    node_id: NodeId,
5324    node_state: &StyledNodeState,
5325) -> Option<azul_css::props::layout::column::ColumnCount> {
5326    let node_data = &styled_dom.node_data.as_container()[node_id];
5327    styled_dom
5328        .css_property_cache
5329        .ptr
5330        .get_column_count(node_data, &node_id, node_state)
5331        .and_then(|v| v.get_property())
5332        .copied()
5333}
5334
5335/// Get initial-letter property. Returns Option<StyleInitialLetter>.
5336#[must_use] pub fn get_initial_letter(
5337    styled_dom: &StyledDom,
5338    node_id: NodeId,
5339    node_state: &StyledNodeState,
5340) -> Option<azul_css::props::style::text::StyleInitialLetter> {
5341    let node_data = &styled_dom.node_data.as_container()[node_id];
5342    styled_dom
5343        .css_property_cache
5344        .ptr
5345        .get_initial_letter(node_data, &node_id, node_state)
5346        .and_then(|v| v.get_property())
5347        .copied()
5348}
5349
5350/// Get line-clamp property. Returns Option<StyleLineClamp>.
5351#[must_use] pub fn get_line_clamp(
5352    styled_dom: &StyledDom,
5353    node_id: NodeId,
5354    node_state: &StyledNodeState,
5355) -> Option<azul_css::props::style::text::StyleLineClamp> {
5356    let node_data = &styled_dom.node_data.as_container()[node_id];
5357    styled_dom
5358        .css_property_cache
5359        .ptr
5360        .get_line_clamp(node_data, &node_id, node_state)
5361        .and_then(|v| v.get_property())
5362        .copied()
5363}
5364
5365/// Get hanging-punctuation property. Returns Option<StyleHangingPunctuation>.
5366#[must_use] pub fn get_hanging_punctuation(
5367    styled_dom: &StyledDom,
5368    node_id: NodeId,
5369    node_state: &StyledNodeState,
5370) -> Option<azul_css::props::style::text::StyleHangingPunctuation> {
5371    let node_data = &styled_dom.node_data.as_container()[node_id];
5372    styled_dom
5373        .css_property_cache
5374        .ptr
5375        .get_hanging_punctuation(node_data, &node_id, node_state)
5376        .and_then(|v| v.get_property())
5377        .copied()
5378}
5379
5380/// Get text-combine-upright property. Returns Option<StyleTextCombineUpright>.
5381#[must_use] pub fn get_text_combine_upright(
5382    styled_dom: &StyledDom,
5383    node_id: NodeId,
5384    node_state: &StyledNodeState,
5385) -> Option<azul_css::props::style::text::StyleTextCombineUpright> {
5386    let node_data = &styled_dom.node_data.as_container()[node_id];
5387    styled_dom
5388        .css_property_cache
5389        .ptr
5390        .get_text_combine_upright(node_data, &node_id, node_state)
5391        .and_then(|v| v.get_property())
5392        .copied()
5393}
5394
5395/// Get exclusion-margin value. Returns f32 (default 0.0).
5396#[must_use] pub fn get_exclusion_margin(
5397    styled_dom: &StyledDom,
5398    node_id: NodeId,
5399    node_state: &StyledNodeState,
5400) -> f32 {
5401    let node_data = &styled_dom.node_data.as_container()[node_id];
5402    styled_dom
5403        .css_property_cache
5404        .ptr
5405        .get_exclusion_margin(node_data, &node_id, node_state)
5406        .and_then(|v| v.get_property())
5407        .map_or(0.0, |v| v.inner.get())
5408}
5409
5410/// Get hyphenation-language property. Returns Option<StyleHyphenationLanguage>.
5411#[must_use] pub fn get_hyphenation_language(
5412    styled_dom: &StyledDom,
5413    node_id: NodeId,
5414    node_state: &StyledNodeState,
5415) -> Option<azul_css::props::style::exclusion::StyleHyphenationLanguage> {
5416    let node_data = &styled_dom.node_data.as_container()[node_id];
5417    styled_dom
5418        .css_property_cache
5419        .ptr
5420        .get_hyphenation_language(node_data, &node_id, node_state)
5421        .and_then(|v| v.get_property())
5422        .cloned()
5423}
5424
5425/// Get border-spacing property.
5426#[must_use] pub fn get_border_spacing(
5427    styled_dom: &StyledDom,
5428    node_id: NodeId,
5429    node_state: &StyledNodeState,
5430) -> azul_css::props::layout::table::LayoutBorderSpacing {
5431    use azul_css::props::basic::pixel::PixelValue;
5432
5433    // FAST PATH: compact cache for normal state
5434    if node_state.is_normal() {
5435        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5436            let h_raw = cc.get_border_spacing_h_raw(node_id.index());
5437            let v_raw = cc.get_border_spacing_v_raw(node_id.index());
5438            // Both 0 means no border-spacing set (default)
5439            // Sentinel means non-px unit → slow path
5440            if h_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
5441                && v_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
5442            {
5443                return azul_css::props::layout::table::LayoutBorderSpacing {
5444                    horizontal: PixelValue::px(f32::from(h_raw) / 10.0),
5445                    vertical: PixelValue::px(f32::from(v_raw) / 10.0),
5446                };
5447            }
5448        }
5449    }
5450
5451    // SLOW PATH
5452    let node_data = &styled_dom.node_data.as_container()[node_id];
5453    styled_dom
5454        .css_property_cache
5455        .ptr
5456        .get_border_spacing(node_data, &node_id, node_state)
5457        .and_then(|v| v.get_property())
5458        .copied()
5459        .unwrap_or_default()
5460}
5461
5462/// Get opacity value. Returns f32 (default 1.0).
5463///
5464/// GPU fast path: the compact cache encodes opacity as a u8 (0-254, 255 = unset).
5465/// Avoids the 4-pseudo-state × 6-layer cascade walk for animations reading opacity
5466/// across every node each frame.
5467#[must_use] pub fn get_opacity(styled_dom: &StyledDom, node_id: NodeId, node_state: &StyledNodeState) -> f32 {
5468    // FAST PATH: compact cache for normal state
5469    if node_state.is_normal() {
5470        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5471            let raw = cc.get_opacity_raw(node_id.index());
5472            if raw == azul_css::compact_cache::OPACITY_SENTINEL {
5473                return 1.0;
5474            }
5475            return f32::from(raw) / 254.0;
5476        }
5477    }
5478    // SLOW PATH: fall back to cascade walk (state != normal, or no compact cache)
5479    let node_data = &styled_dom.node_data.as_container()[node_id];
5480    styled_dom
5481        .css_property_cache
5482        .ptr
5483        .get_opacity(node_data, &node_id, node_state)
5484        .and_then(|v| v.get_property())
5485        .map_or(1.0, |v| v.inner.normalized())
5486}
5487
5488/// Get filter property. Returns Option with cloned filter list.
5489#[must_use] pub fn get_filter(
5490    styled_dom: &StyledDom,
5491    node_id: NodeId,
5492    node_state: &StyledNodeState,
5493) -> Option<azul_css::props::style::filter::StyleFilterVec> {
5494    if node_state.is_normal() {
5495        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5496            if !cc.has_filter(node_id.index()) {
5497                return None;
5498            }
5499        }
5500    }
5501    let node_data = &styled_dom.node_data.as_container()[node_id];
5502    styled_dom
5503        .css_property_cache
5504        .ptr
5505        .get_filter(node_data, &node_id, node_state)
5506        .and_then(|v| v.get_property())
5507        .cloned()
5508}
5509
5510/// Get backdrop-filter property. Returns Option with cloned filter list.
5511#[must_use] pub fn get_backdrop_filter(
5512    styled_dom: &StyledDom,
5513    node_id: NodeId,
5514    node_state: &StyledNodeState,
5515) -> Option<azul_css::props::style::filter::StyleFilterVec> {
5516    if node_state.is_normal() {
5517        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5518            if !cc.has_backdrop_filter(node_id.index()) {
5519                return None;
5520            }
5521        }
5522    }
5523    let node_data = &styled_dom.node_data.as_container()[node_id];
5524    styled_dom
5525        .css_property_cache
5526        .ptr
5527        .get_backdrop_filter(node_data, &node_id, node_state)
5528        .and_then(|v| v.get_property())
5529        .cloned()
5530}
5531
5532/// Compact-cache negative fast path for all 4 box-shadow sides.
5533/// Most nodes have no shadow; cheap to check one bit vs. 4 cascade walks.
5534#[inline]
5535fn box_shadow_fast_bail(
5536    styled_dom: &StyledDom,
5537    node_id: NodeId,
5538    node_state: &StyledNodeState,
5539) -> bool {
5540    if !node_state.is_normal() {
5541        return false;
5542    }
5543    if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5544        return !cc.has_box_shadow(node_id.index());
5545    }
5546    false
5547}
5548
5549/// Get box-shadow for left side. Returns Option<StyleBoxShadow> (cloned).
5550#[must_use] pub fn get_box_shadow_left(
5551    styled_dom: &StyledDom,
5552    node_id: NodeId,
5553    node_state: &StyledNodeState,
5554) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5555    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5556        return None;
5557    }
5558    let node_data = &styled_dom.node_data.as_container()[node_id];
5559    styled_dom
5560        .css_property_cache
5561        .ptr
5562        .get_box_shadow_left(node_data, &node_id, node_state)
5563        .and_then(|v| v.get_property())
5564        .map(|v| (**v))
5565}
5566
5567/// Get box-shadow for right side. Returns Option<StyleBoxShadow> (cloned).
5568#[must_use] pub fn get_box_shadow_right(
5569    styled_dom: &StyledDom,
5570    node_id: NodeId,
5571    node_state: &StyledNodeState,
5572) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5573    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5574        return None;
5575    }
5576    let node_data = &styled_dom.node_data.as_container()[node_id];
5577    styled_dom
5578        .css_property_cache
5579        .ptr
5580        .get_box_shadow_right(node_data, &node_id, node_state)
5581        .and_then(|v| v.get_property())
5582        .map(|v| (**v))
5583}
5584
5585/// Get box-shadow for top side. Returns Option<StyleBoxShadow> (cloned).
5586#[must_use] pub fn get_box_shadow_top(
5587    styled_dom: &StyledDom,
5588    node_id: NodeId,
5589    node_state: &StyledNodeState,
5590) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5591    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5592        return None;
5593    }
5594    let node_data = &styled_dom.node_data.as_container()[node_id];
5595    styled_dom
5596        .css_property_cache
5597        .ptr
5598        .get_box_shadow_top(node_data, &node_id, node_state)
5599        .and_then(|v| v.get_property())
5600        .map(|v| (**v))
5601}
5602
5603/// Get box-shadow for bottom side. Returns Option<StyleBoxShadow> (cloned).
5604#[must_use] pub fn get_box_shadow_bottom(
5605    styled_dom: &StyledDom,
5606    node_id: NodeId,
5607    node_state: &StyledNodeState,
5608) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5609    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5610        return None;
5611    }
5612    let node_data = &styled_dom.node_data.as_container()[node_id];
5613    styled_dom
5614        .css_property_cache
5615        .ptr
5616        .get_box_shadow_bottom(node_data, &node_id, node_state)
5617        .and_then(|v| v.get_property())
5618        .map(|v| (**v))
5619}
5620
5621/// Get text-shadow property. Returns Option<StyleBoxShadow> (cloned).
5622#[must_use] pub fn get_text_shadow(
5623    styled_dom: &StyledDom,
5624    node_id: NodeId,
5625    node_state: &StyledNodeState,
5626) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5627    if node_state.is_normal() {
5628        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5629            if !cc.has_text_shadow(node_id.index()) {
5630                return None;
5631            }
5632        }
5633    }
5634    let node_data = &styled_dom.node_data.as_container()[node_id];
5635    styled_dom
5636        .css_property_cache
5637        .ptr
5638        .get_text_shadow(node_data, &node_id, node_state)
5639        .and_then(|v| v.get_property())
5640        .map(|v| (**v))
5641}
5642
5643/// Get transform property. Returns Option (non-empty transform list, cloned).
5644///
5645/// GPU fast path: the compact cache keeps a `has_transform` flag. If unset,
5646/// skips the cascade walk entirely — which is the overwhelming case since most
5647/// nodes have no transform. Only nodes that actually have a transform pay the
5648/// slow-walk cost to retrieve the parsed value.
5649#[must_use] pub fn get_transform(
5650    styled_dom: &StyledDom,
5651    node_id: NodeId,
5652    node_state: &StyledNodeState,
5653) -> Option<azul_css::props::style::transform::StyleTransformVec> {
5654    // FAST PATH: bit check in compact cache
5655    if node_state.is_normal() {
5656        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5657            if !cc.has_transform(node_id.index()) {
5658                return None;
5659            }
5660            // has_transform set → fall through to cascade walk for the value
5661        }
5662    }
5663    let node_data = &styled_dom.node_data.as_container()[node_id];
5664    styled_dom
5665        .css_property_cache
5666        .ptr
5667        .get_transform(node_data, &node_id, node_state)
5668        .and_then(|v| v.get_property())
5669        .cloned()
5670}
5671
5672/// Get counter-reset property. Returns Option<CounterReset> (cloned).
5673#[must_use] pub fn get_counter_reset(
5674    styled_dom: &StyledDom,
5675    node_id: NodeId,
5676    node_state: &StyledNodeState,
5677) -> Option<azul_css::props::style::content::CounterReset> {
5678    let node_data = &styled_dom.node_data.as_container()[node_id];
5679    styled_dom
5680        .css_property_cache
5681        .ptr
5682        .get_counter_reset(node_data, &node_id, node_state)
5683        .and_then(|v| v.get_property())
5684        .cloned()
5685}
5686
5687/// Get counter-increment property. Returns Option<CounterIncrement> (cloned).
5688#[must_use] pub fn get_counter_increment(
5689    styled_dom: &StyledDom,
5690    node_id: NodeId,
5691    node_state: &StyledNodeState,
5692) -> Option<azul_css::props::style::content::CounterIncrement> {
5693    let node_data = &styled_dom.node_data.as_container()[node_id];
5694    styled_dom
5695        .css_property_cache
5696        .ptr
5697        .get_counter_increment(node_data, &node_id, node_state)
5698        .and_then(|v| v.get_property())
5699        .cloned()
5700}
5701
5702/// W3C-conformant contenteditable inheritance check.
5703///
5704/// In the W3C model, the `contenteditable` attribute is **inherited**:
5705/// - A node is editable if it has `contenteditable="true"` set directly
5706/// - OR if its parent has `isContentEditable` as true
5707/// - UNLESS the node explicitly sets `contenteditable="false"`
5708///
5709/// This function traverses up the DOM tree to determine editability.
5710///
5711/// # Returns
5712///
5713/// - `true` if the node is editable (either directly or via inheritance)
5714/// - `false` if the node is not editable or has `contenteditable="false"`
5715///
5716/// # Example
5717///
5718/// ```html
5719/// <div contenteditable="true">
5720///   A                              <!-- editable (inherited) -->
5721///   <div contenteditable="false">
5722///     B                            <!-- NOT editable (explicitly false) -->
5723///   </div>
5724///   C                              <!-- editable (inherited) -->
5725/// </div>
5726/// ```
5727#[must_use] pub fn is_node_contenteditable_inherited(styled_dom: &StyledDom, node_id: NodeId) -> bool {
5728    use azul_core::dom::AttributeType;
5729
5730    let node_data_container = styled_dom.node_data.as_container();
5731    let hierarchy = styled_dom.node_hierarchy.as_container();
5732
5733    let mut current_node_id = Some(node_id);
5734
5735    while let Some(nid) = current_node_id {
5736        let node_data = &node_data_container[nid];
5737
5738        // First check the direct contenteditable field (set via set_contenteditable())
5739        // This takes precedence as it's the API-level setting
5740        if node_data.is_contenteditable() {
5741            return true;
5742        }
5743
5744        // Then check for explicit contenteditable attribute on this node
5745        // This handles HTML-style contenteditable="true" or contenteditable="false"
5746        for attr in node_data.attributes().as_ref() {
5747            if let AttributeType::ContentEditable(is_editable) = attr {
5748                // If explicitly set to true, node is editable
5749                // If explicitly set to false, node is NOT editable (blocks inheritance)
5750                return *is_editable;
5751            }
5752        }
5753
5754        // No explicit setting on this node, check parent for inheritance
5755        current_node_id = hierarchy.get(nid).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
5756    }
5757
5758    // Reached root without finding contenteditable - not editable
5759    false
5760}
5761
5762/// Find the contenteditable ancestor of a node.
5763///
5764/// When focus lands on a text node inside a contenteditable container,
5765/// we need to find the actual container that has the `contenteditable` attribute.
5766///
5767/// # Returns
5768///
5769/// - `Some(node_id)` of the contenteditable ancestor (may be the node itself)
5770/// - `None` if no contenteditable ancestor exists
5771#[must_use] pub fn find_contenteditable_ancestor(styled_dom: &StyledDom, node_id: NodeId) -> Option<NodeId> {
5772    use azul_core::dom::AttributeType;
5773
5774    let node_data_container = styled_dom.node_data.as_container();
5775    let hierarchy = styled_dom.node_hierarchy.as_container();
5776
5777    let mut current_node_id = Some(node_id);
5778
5779    while let Some(nid) = current_node_id {
5780        let node_data = &node_data_container[nid];
5781
5782        // First check the direct contenteditable field (set via set_contenteditable())
5783        if node_data.is_contenteditable() {
5784            return Some(nid);
5785        }
5786
5787        // Then check for contenteditable attribute on this node
5788        for attr in node_data.attributes().as_ref() {
5789            if let AttributeType::ContentEditable(is_editable) = attr {
5790                if *is_editable {
5791                    return Some(nid);
5792                }
5793                // Explicitly not editable - stop search
5794                return None;
5795            }
5796        }
5797
5798        // Check parent
5799        current_node_id = hierarchy.get(nid).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
5800    }
5801
5802    None
5803}
5804
5805// --- Taffy bridge property getters ---
5806//
5807// These getters return `Option<CssPropertyValue<T>>` (cloned from cache) for use
5808// by taffy_bridge.rs. The conversion from CssPropertyValue to taffy types is done
5809// in taffy_bridge.rs itself. Routing access through these functions centralizes
5810// all CSS property lookups for future cache optimizations (e.g., FxHash migration).
5811
5812macro_rules! get_css_property_value {
5813    ($fn_name:ident, $cache_method:ident, $ret_type:ty) => {
5814        #[must_use] pub fn $fn_name(
5815            styled_dom: &StyledDom,
5816            node_id: NodeId,
5817            node_state: &StyledNodeState,
5818        ) -> Option<$ret_type> {
5819            let node_data = &styled_dom.node_data.as_container()[node_id];
5820            styled_dom
5821                .css_property_cache
5822                .ptr
5823                .$cache_method(node_data, &node_id, node_state)
5824                .cloned()
5825        }
5826    };
5827}
5828
5829// Flexbox properties
5830get_css_property_value!(
5831    get_flex_direction_prop,
5832    get_flex_direction,
5833    LayoutFlexDirectionValue
5834);
5835get_css_property_value!(get_flex_wrap_prop, get_flex_wrap, LayoutFlexWrapValue);
5836get_css_property_value!(get_flex_grow_prop, get_flex_grow, LayoutFlexGrowValue);
5837get_css_property_value!(get_flex_shrink_prop, get_flex_shrink, LayoutFlexShrinkValue);
5838get_css_property_value!(get_flex_basis_prop, get_flex_basis, LayoutFlexBasisValue);
5839
5840// Alignment properties
5841get_css_property_value!(get_align_items_prop, get_align_items, LayoutAlignItemsValue);
5842get_css_property_value!(get_align_self_prop, get_align_self, LayoutAlignSelfValue);
5843get_css_property_value!(
5844    get_align_content_prop,
5845    get_align_content,
5846    LayoutAlignContentValue
5847);
5848get_css_property_value!(
5849    get_justify_content_prop,
5850    get_justify_content,
5851    LayoutJustifyContentValue
5852);
5853get_css_property_value!(
5854    get_justify_items_prop,
5855    get_justify_items,
5856    LayoutJustifyItemsValue
5857);
5858get_css_property_value!(
5859    get_justify_self_prop,
5860    get_justify_self,
5861    LayoutJustifySelfValue
5862);
5863
5864// Gap
5865get_css_property_value!(get_gap_prop, get_gap, LayoutGapValue);
5866
5867// Grid properties
5868get_css_property_value!(
5869    get_grid_template_rows_prop,
5870    get_grid_template_rows,
5871    LayoutGridTemplateRowsValue
5872);
5873get_css_property_value!(
5874    get_grid_template_columns_prop,
5875    get_grid_template_columns,
5876    LayoutGridTemplateColumnsValue
5877);
5878get_css_property_value!(
5879    get_grid_auto_rows_prop,
5880    get_grid_auto_rows,
5881    LayoutGridAutoRowsValue
5882);
5883get_css_property_value!(
5884    get_grid_auto_columns_prop,
5885    get_grid_auto_columns,
5886    LayoutGridAutoColumnsValue
5887);
5888get_css_property_value!(
5889    get_grid_auto_flow_prop,
5890    get_grid_auto_flow,
5891    LayoutGridAutoFlowValue
5892);
5893get_css_property_value!(get_grid_column_prop, get_grid_column, LayoutGridColumnValue);
5894get_css_property_value!(get_grid_row_prop, get_grid_row, LayoutGridRowValue);
5895
5896/// Get grid-template-areas property.
5897///
5898/// Uses the generic `get_property()` since `CssPropertyCache` lacks a specific getter.
5899/// Returns the inner `GridTemplateAreas` value (already unwrapped from `CssPropertyValue`).
5900#[must_use] pub fn get_grid_template_areas_prop(
5901    styled_dom: &StyledDom,
5902    node_id: NodeId,
5903    node_state: &StyledNodeState,
5904) -> Option<GridTemplateAreas> {
5905    let node_data = &styled_dom.node_data.as_container()[node_id];
5906    styled_dom
5907        .css_property_cache
5908        .ptr
5909        .get_property(
5910            node_data,
5911            &node_id,
5912            node_state,
5913            &CssPropertyType::GridTemplateAreas,
5914        )
5915        .and_then(|p| {
5916            if let CssProperty::GridTemplateAreas(v) = p {
5917                v.get_property().cloned()
5918            } else {
5919                None
5920            }
5921        })
5922}
5923
5924/// Get clip-path property. Returns the `ClipPath` value for the node.
5925///
5926/// CSS Masking Module Level 1, section 3:
5927/// The clip-path property creates a clipping region that determines which parts
5928/// of an element are visible. Returns None for `clip-path: none` (default).
5929#[must_use] pub fn get_clip_path(
5930    styled_dom: &StyledDom,
5931    node_id: NodeId,
5932    node_state: &StyledNodeState,
5933) -> Option<azul_css::props::layout::shape::ClipPath> {
5934    // Negative fast path: most nodes have `clip-path: none`.
5935    if node_state.is_normal() {
5936        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5937            if !cc.has_clip_path(node_id.index()) {
5938                return None;
5939            }
5940        }
5941    }
5942    let node_data = &styled_dom.node_data.as_container()[node_id];
5943    styled_dom
5944        .css_property_cache
5945        .ptr
5946        .get_clip_path(node_data, &node_id, node_state)
5947        .and_then(|v| v.get_property())
5948        .cloned()
5949}