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, StyleBaselineSource, StyleDirection, StyleDominantBaseline,
38            StyleInitialLetterAlign, StyleLineFitEdge,
39            StyleInitialLetterWrap, StyleTextAlign, StyleTextBoxEdge, StyleTextBoxTrim,
40            StyleUnicodeBidi, StyleUserSelect, StyleVerticalAlign, StyleVisibility,
41            StyleWhiteSpace,
42        },
43    },
44};
45
46use crate::{
47    font_traits::{ParsedFontTrait, StyleProperties},
48    solver3::{
49        display_list::{BorderRadius, PhysicalSizeImport},
50        layout_tree::LayoutNode,
51        scrollbar::ScrollbarRequirements,
52    },
53};
54
55const DEFAULT_EM_SIZE: f32 = 16.0;
56const DEFAULT_CARET_WIDTH_PX: f32 = 2.0;
57const DEFAULT_CARET_BLINK_MS: u32 = 500;
58const DEFAULT_TAB_SIZE: f32 = 8.0;
59const SCROLLBAR_WIDTH_THIN: f32 = 8.0;
60const SCROLLBAR_WIDTH_AUTO: f32 = 12.0;
61const SCROLLBAR_HOVER_EXPAND_PX: f32 = 4.0;
62const THUMB_HOVER_LIGHTEN: u8 = 30;
63const THUMB_HOVER_ALPHA_ADD: u8 = 40;
64const THUMB_ACTIVE_DARKEN: u8 = 15;
65
66// Font-size resolution helper functions
67
68/// Helper function to get element's computed font-size.
69///
70/// **Memoised** for the common `Normal` pseudo-state: the first
71/// call on a given `StyledDom` populates
72/// `css_property_cache.ptr.resolved_font_sizes_px` via a single
73/// bottom-up DOM walk (N cascade walks total, stored as
74/// `Vec<f32>`); every subsequent call is a single Vec index.
75/// Non-normal state falls through to [`resolve_font_size_slow`].
76///
77/// Motivation: `AZ_PROP_COUNT=1` measured 329 629 `font-size`
78/// cascade walks per cold layout on excel.html (~730 per node).
79/// With this cache that collapses to ~500 total (one per node,
80/// once), and subsequent layouts hit the Vec directly.
81///
82/// The semantics of the slow path are preserved exactly: the
83/// `compute_all_font_sizes_px` walker mirrors the original's
84/// `computed_values` → cascade → `DEFAULT_FONT_SIZE` ordering,
85/// so rendered pixels are byte-identical.
86#[must_use] pub fn get_element_font_size(
87    styled_dom: &StyledDom,
88    dom_id: NodeId,
89    node_state: &StyledNodeState,
90) -> f32 {
91    // M12.7 FIX: the OnceLock-cached fast path
92    // (`is_normal → resolved_font_sizes_px.get_or_init(|| compute_all_font_sizes_px) →
93    // sizes.get`) MIS-LIFTS to wasm — it diverges (create_node_from_dom never returns →
94    // empty LayoutTree → 0 rects). PROVEN by isolation: skipping it lets
95    // get_element_font_size reach + return via resolve_font_size_slow, and
96    // create_resolution_context completes (sub-step 1→4). resolve_font_size_slow is the
97    // same resolution unmemoized (correct), so we always use it. (Native desktop is
98    // unaffected in correctness; it loses the per-DOM memoization — a minor perf cost
99    // only on the lifted web path's small DOMs. The cache-block lift bug — likely the
100    // compute_all_font_sizes_px closure's control/FP — is documented for a later remill
101    // fix that can restore the fast path.)
102    let _ = compute_all_font_sizes_px; // referenced so other callers / native keep it
103    resolve_font_size_slow(styled_dom, dom_id, node_state)
104}
105
106/// Bottom-up single-pass resolve of every node's font-size.
107/// Parents are computed before children (DFS pre-order invariant
108/// on `NodeId::index()`), so `em` inherits via the parent's
109/// already-stored pixel value. `rem` reads from `sizes[0]` once
110/// the root is populated (the root's own size resolves via the
111/// `computed_values` short-circuit if set, otherwise DEFAULT).
112///
113/// Preserves the original resolution order exactly:
114///
115/// 1. `computed_values` binary search → if `FontSize` is pre-
116///    resolved to a px value, use that.
117/// 2. Full cascade via `cache.get_font_size(...)`; if an explicit
118///    value is present, resolve with context.
119/// 3. `DEFAULT_FONT_SIZE` fallback — NOT `parent_font_size`,
120///    because the `computed_values` short-circuit at step 1 is
121///    the cascade's inheritance channel (pre-populated for every
122///    inheriting node).
123fn compute_all_font_sizes_px(styled_dom: &StyledDom) -> Vec<f32> {
124    use azul_css::props::{
125        basic::length::SizeMetric,
126        property::{CssProperty, CssPropertyType},
127    };
128
129    let n = styled_dom.node_data.len();
130    let mut sizes = alloc::vec![DEFAULT_FONT_SIZE; n];
131    if n == 0 {
132        return sizes;
133    }
134
135    let data_container = styled_dom.node_data.as_container();
136    let state_container = styled_dom.styled_nodes.as_container();
137    let hierarchy = styled_dom.node_hierarchy.as_container();
138    let cache = &styled_dom.css_property_cache.ptr;
139
140    for idx in 0..n {
141        let dom_id = NodeId::new(idx);
142
143        // Step 1: computed_values short-circuit (matches original).
144        if let Some(vec) = cache.computed_values.get(idx) {
145            if let Ok(cv_idx) = vec.binary_search_by_key(&CssPropertyType::FontSize, |(k, _)| *k) {
146                if let CssProperty::FontSize(css_val) = &vec[cv_idx].1.property {
147                    if let Some(fs) = css_val.get_property() {
148                        if fs.inner.metric == SizeMetric::Px {
149                            sizes[idx] = fs.inner.number.get();
150                            continue;
151                        }
152                    }
153                }
154            }
155        }
156
157        // Step 2: full cascade walk.
158        let parent_font_size = hierarchy
159            .get(dom_id)
160            .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
161            .map_or(DEFAULT_FONT_SIZE, |p| sizes[p.index()]);
162        let root_font_size = sizes[0];
163
164        let Some(node_data) = data_container.internal.get(idx) else {
165            sizes[idx] = DEFAULT_FONT_SIZE;
166            continue;
167        };
168        let Some(styled) = state_container.internal.get(idx) else {
169            sizes[idx] = DEFAULT_FONT_SIZE;
170            continue;
171        };
172        let node_state = &styled.styled_node_state;
173
174        // Step 2.5: compact cache fast path — avoids a full cascade walk
175        // per node. The build-time pass has already resolved em/% to px,
176        // so the raw u32 here is the final pixel value when set.
177        let mut fast_fs: Option<f32> = None;
178        let mut compact_said_inherit = false;
179        if node_state.is_normal() {
180            if let Some(ref cc) = cache.compact_cache {
181                let raw = cc.get_font_size_raw(idx);
182                if raw == azul_css::compact_cache::U32_SENTINEL
183                    || raw == azul_css::compact_cache::U32_INHERIT
184                    || raw == azul_css::compact_cache::U32_INITIAL
185                {
186                    compact_said_inherit = true;
187                } else if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
188                    // Already-resolved pixel value (em/% eliminated during build).
189                    if pv.metric == SizeMetric::Px {
190                        fast_fs = Some(pv.number.get());
191                    } else {
192                        // Shouldn't normally happen post-resolve, but fall through safely.
193                        let context = ResolutionContext {
194                            element_font_size: DEFAULT_FONT_SIZE,
195                            parent_font_size,
196                            root_font_size,
197                            containing_block_size: PhysicalSize::new(0.0, 0.0),
198                            element_size: None,
199                            viewport_size: PhysicalSize::new(0.0, 0.0),
200                        };
201                        fast_fs =
202                            Some(pv.resolve_with_context(&context, PropertyContext::FontSize));
203                    }
204                }
205            }
206        }
207        if let Some(fs) = fast_fs {
208            sizes[idx] = fs;
209            continue;
210        }
211        if compact_said_inherit {
212            sizes[idx] = parent_font_size;
213            continue;
214        }
215
216        let resolved = cache
217            .get_font_size(node_data, &dom_id, node_state)
218            .and_then(|v| v.get_property().copied())
219            .map(|v| {
220                let context = ResolutionContext {
221                    element_font_size: DEFAULT_FONT_SIZE,
222                    parent_font_size,
223                    root_font_size,
224                    containing_block_size: PhysicalSize::new(0.0, 0.0),
225                    element_size: None,
226                    viewport_size: PhysicalSize::new(0.0, 0.0),
227                };
228                v.inner
229                    .resolve_with_context(&context, PropertyContext::FontSize)
230            });
231
232        // Step 3: fallback to DEFAULT (matches original .unwrap_or).
233        sizes[idx] = resolved.unwrap_or(DEFAULT_FONT_SIZE);
234    }
235    sizes
236}
237
238/// Un-memoised recursive resolution, used as the fallback for
239/// non-normal pseudo-states in [`get_element_font_size`] and
240/// directly by tests that bypass the StyledDom-scoped cache.
241/// Keeps the original semantics verbatim.
242fn resolve_font_size_slow(
243    styled_dom: &StyledDom,
244    dom_id: NodeId,
245    node_state: &StyledNodeState,
246) -> f32 {
247    // ITERATIVE resolution (was unbounded self-recursion up the parent chain, which
248    // stack-overflowed on deeply nested DOMs and was O(N*depth)). We walk `parent_id`
249    // in a loop to collect the ancestor chain, then resolve top-down so each node's
250    // `em` inherits from its already-resolved parent. Result is identical to the old
251    // recursive version for a well-formed tree, but bounded by the tree depth in
252    // stack usage (a single Vec of ancestors instead of nested frames).
253    //
254    // Each ancestor is resolved against its OWN `styled_node_state` (previously the
255    // recursion incorrectly threaded the *child's* state into parent/root resolution),
256    // matching the sibling `get_parent_font_size` / `get_root_font_size` helpers.
257    let hierarchy = styled_dom.node_hierarchy.as_container();
258    let states = styled_dom.styled_nodes.as_container();
259    let root_id = NodeId::new(0);
260
261    // Root font-size, resolved from NodeId(0) with no parent and root == DEFAULT
262    // (mirrors the original: for node 0 the root branch returned DEFAULT directly).
263    let root_font_size = if dom_id == root_id {
264        DEFAULT_FONT_SIZE
265    } else {
266        let root_state = &states[root_id].styled_node_state;
267        resolve_font_size_one(
268            styled_dom,
269            root_id,
270            root_state,
271            DEFAULT_FONT_SIZE,
272            DEFAULT_FONT_SIZE,
273        )
274    };
275
276    // Collect the ancestor chain: chain[0] == dom_id, chain.last() == topmost ancestor.
277    let mut chain = Vec::new();
278    let mut cur = Some(dom_id);
279    while let Some(id) = cur {
280        chain.push(id);
281        cur = hierarchy
282            .get(id)
283            .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
284    }
285
286    // Resolve top-down. The topmost ancestor has parent_font_size == DEFAULT; each
287    // subsequent node inherits the previously-resolved value as its parent size.
288    let mut parent_font_size = DEFAULT_FONT_SIZE;
289    let mut resolved = DEFAULT_FONT_SIZE;
290    for &id in chain.iter().rev() {
291        // The target node keeps the caller-provided state (its own state, per the
292        // public contract); ancestors use their own stored state.
293        let this_state = if id == dom_id {
294            node_state
295        } else {
296            &states[id].styled_node_state
297        };
298        let this_root_fs = if id == root_id {
299            DEFAULT_FONT_SIZE
300        } else {
301            root_font_size
302        };
303        resolved =
304            resolve_font_size_one(styled_dom, id, this_state, parent_font_size, this_root_fs);
305        parent_font_size = resolved;
306    }
307    resolved
308}
309
310/// Resolves a single node's font-size given its already-resolved `parent_font_size`
311/// and `root_font_size`. Contains the per-node logic that the old recursive
312/// `resolve_font_size_slow` applied at each frame (computed-values px short-circuit,
313/// then a full cascade walk), with no recursion of its own.
314fn resolve_font_size_one(
315    styled_dom: &StyledDom,
316    dom_id: NodeId,
317    node_state: &StyledNodeState,
318    parent_font_size: f32,
319    root_font_size: f32,
320) -> f32 {
321    let node_data = &styled_dom.node_data.as_container()[dom_id];
322    let cache = &styled_dom.css_property_cache.ptr;
323
324    if let Some(vec) = cache.computed_values.get(dom_id.index()) {
325        if let Ok(idx) = vec.binary_search_by_key(&CssPropertyType::FontSize, |(k, _)| *k) {
326            if let CssProperty::FontSize(css_val) = &vec[idx].1.property {
327                if let Some(fs) = css_val.get_property() {
328                    if fs.inner.metric == azul_css::props::basic::length::SizeMetric::Px {
329                        return fs.inner.number.get();
330                    }
331                }
332            }
333        }
334    }
335
336    cache
337        .get_font_size(node_data, &dom_id, node_state)
338        .and_then(|v| v.get_property().copied())
339        .map_or(DEFAULT_FONT_SIZE, |v| {
340            let context = ResolutionContext {
341                element_font_size: DEFAULT_FONT_SIZE,
342                parent_font_size,
343                root_font_size,
344                containing_block_size: PhysicalSize::new(0.0, 0.0),
345                element_size: None,
346                viewport_size: PhysicalSize::new(0.0, 0.0),
347            };
348            v.inner
349                .resolve_with_context(&context, PropertyContext::FontSize)
350        })
351}
352
353/// Helper function to get parent's computed font-size.
354///
355/// Retrieves the parent's own `StyledNodeState` so that pseudo-class-specific
356/// font-size rules (e.g. `div:hover { font-size: 32px }`) are resolved
357/// against the parent's actual state, not the child's.
358#[must_use] pub fn get_parent_font_size(
359    styled_dom: &StyledDom,
360    dom_id: NodeId,
361    _node_state: &StyledNodeState, // child's state — intentionally unused
362) -> f32 {
363    styled_dom
364        .node_hierarchy
365        .as_container()
366        .get(dom_id)
367        .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
368        .map_or(DEFAULT_FONT_SIZE, |parent_id| {
369            let parent_state = &styled_dom.styled_nodes.as_container()[parent_id].styled_node_state;
370            get_element_font_size(styled_dom, parent_id, parent_state)
371        })
372}
373
374/// Helper function to get root element's font-size.
375///
376/// Uses the root element's own `StyledNodeState` so that pseudo-class-specific
377/// rules are resolved correctly regardless of which node triggered the call.
378#[must_use] pub fn get_root_font_size(styled_dom: &StyledDom, _node_state: &StyledNodeState) -> f32 {
379    let root_id = NodeId::new(0);
380    let root_state = &styled_dom.styled_nodes.as_container()[root_id].styled_node_state;
381    get_element_font_size(styled_dom, root_id, root_state)
382}
383
384/// A value that can be Auto, Initial, Inherit, or an explicit value.
385/// This preserves CSS cascade semantics better than Option<T>.
386#[derive(Debug, Copy, Clone, PartialEq, Eq)]
387#[derive(Default)]
388pub enum MultiValue<T> {
389    /// CSS 'auto' keyword
390    #[default]
391    Auto,
392    /// CSS 'initial' keyword - use initial value
393    Initial,
394    /// CSS 'inherit' keyword - inherit from parent
395    Inherit,
396    /// Explicit value (e.g., "10px", "50%")
397    Exact(T),
398}
399
400impl<T> MultiValue<T> {
401    /// Returns true if this is an Auto value
402    pub const fn is_auto(&self) -> bool {
403        matches!(self, Self::Auto)
404    }
405
406    /// Returns true if this is an explicit value
407    pub const fn is_exact(&self) -> bool {
408        matches!(self, Self::Exact(_))
409    }
410
411    /// Gets the exact value if present
412    pub fn exact(self) -> Option<T> {
413        match self {
414            Self::Exact(v) => Some(v),
415            _ => None,
416        }
417    }
418
419    /// Gets the exact value or returns the provided default
420    pub fn unwrap_or(self, default: T) -> T {
421        match self {
422            Self::Exact(v) => v,
423            _ => default,
424        }
425    }
426
427    /// Gets the exact value or returns `T::default()`
428    pub fn unwrap_or_default(self) -> T
429    where
430        T: Default,
431    {
432        match self {
433            Self::Exact(v) => v,
434            _ => T::default(),
435        }
436    }
437
438    /// Maps the inner value if Exact, otherwise returns self unchanged
439    pub fn map<U, F>(self, f: F) -> MultiValue<U>
440    where
441        F: FnOnce(T) -> U,
442    {
443        match self {
444            Self::Exact(v) => MultiValue::Exact(f(v)),
445            Self::Auto => MultiValue::Auto,
446            Self::Initial => MultiValue::Initial,
447            Self::Inherit => MultiValue::Inherit,
448        }
449    }
450}
451
452// Implement helper methods for LayoutOverflow specifically
453impl MultiValue<LayoutOverflow> {
454    /// Returns true if this overflow value causes content to be clipped.
455    /// This includes Hidden, Clip, Auto, and Scroll (all values except Visible).
456    #[must_use] pub const fn is_clipped(&self) -> bool {
457        matches!(
458            self,
459            Self::Exact(
460                LayoutOverflow::Hidden
461                    | LayoutOverflow::Clip
462                    | LayoutOverflow::Auto
463                    | LayoutOverflow::Scroll
464            )
465        )
466    }
467
468    #[must_use] pub const fn is_scroll(&self) -> bool {
469        matches!(
470            self,
471            Self::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
472        )
473    }
474
475    #[must_use] pub const fn is_auto_overflow(&self) -> bool {
476        matches!(self, Self::Exact(LayoutOverflow::Auto))
477    }
478
479    #[must_use] pub const fn is_hidden(&self) -> bool {
480        matches!(self, Self::Exact(LayoutOverflow::Hidden))
481    }
482
483    #[must_use] pub const fn is_hidden_or_clip(&self) -> bool {
484        matches!(
485            self,
486            Self::Exact(LayoutOverflow::Hidden | LayoutOverflow::Clip)
487        )
488    }
489
490    #[must_use] pub const fn is_scroll_explicit(&self) -> bool {
491        matches!(self, Self::Exact(LayoutOverflow::Scroll))
492    }
493
494    #[must_use] pub const fn is_clip(&self) -> bool {
495        matches!(self, Self::Exact(LayoutOverflow::Clip))
496    }
497
498    #[must_use] pub const fn is_visible_or_clip(&self) -> bool {
499        matches!(
500            self,
501            Self::Exact(LayoutOverflow::Visible | LayoutOverflow::Clip)
502        )
503    }
504
505    /// True iff `overflow` is EXPLICITLY set to a value that establishes a block
506    /// formatting context (CSS 2.2 §9.4.1: `hidden`/`scroll`/`auto`). `visible`,
507    /// `clip`, and the unset/initial/inherit sentinel do NOT — the initial value
508    /// is `visible`, so an unset overflow must not establish a BFC. Using
509    /// `!is_visible_or_clip()` for this was wrong: the "not set" `MultiValue::Auto`
510    /// sentinel is not visible/clip, so every plain block spuriously got a BFC on
511    /// the slow cascade path (the fast path returns `Exact(Visible)` and did not).
512    #[must_use] pub const fn establishes_bfc(&self) -> bool {
513        matches!(
514            self,
515            Self::Exact(LayoutOverflow::Hidden | LayoutOverflow::Scroll | LayoutOverflow::Auto)
516        )
517    }
518
519    // +spec:overflow:833078 - visible/clip compute to auto/hidden if other axis is scrollable
520    /// Resolves the computed value per CSS Overflow 3 § 3.1:
521    /// visible/clip values compute to auto/hidden (respectively)
522    /// if the other axis is neither visible nor clip.
523    #[must_use] pub const fn resolve_computed(
524        &self,
525        other_axis: &Self,
526    ) -> Self {
527        match (self, other_axis) {
528            (Self::Exact(val), Self::Exact(other)) => {
529                Self::Exact(val.resolve_computed(*other))
530            }
531            _ => *self,
532        }
533    }
534}
535
536// Implement helper methods for LayoutPosition
537impl MultiValue<LayoutPosition> {
538    #[must_use] pub const fn is_absolute_or_fixed(&self) -> bool {
539        matches!(
540            self,
541            Self::Exact(LayoutPosition::Absolute | LayoutPosition::Fixed)
542        )
543    }
544}
545
546// Implement helper methods for LayoutFloat
547impl MultiValue<LayoutFloat> {
548    #[must_use] pub const fn is_none(&self) -> bool {
549        matches!(
550            self,
551            Self::Auto
552                | Self::Initial
553                | Self::Inherit
554                | Self::Exact(LayoutFloat::None)
555        )
556    }
557}
558
559
560/// Helper macro to reduce boilerplate for simple CSS property getters
561/// Returns the inner `PixelValue` wrapped in `MultiValue`
562macro_rules! get_css_property_pixel {
563    // Variant WITH compact cache fast path for i16-encoded resolved px properties
564    ($fn_name:ident, $cache_method:ident, $ua_property:expr, compact_i16 = $compact_method:ident) => {
565        #[must_use] pub fn $fn_name(
566            styled_dom: &StyledDom,
567            node_id: NodeId,
568            node_state: &StyledNodeState,
569        ) -> MultiValue<PixelValue> {
570            // FAST PATH: compact cache for normal state (O(1) array lookup)
571            if node_state.is_normal() {
572                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
573                    let raw = cc.$compact_method(node_id.index());
574                    if raw == azul_css::compact_cache::I16_AUTO {
575                        return MultiValue::Auto;
576                    }
577                    if raw == azul_css::compact_cache::I16_INITIAL {
578                        return MultiValue::Initial;
579                    }
580                    if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
581                        // Valid value: decode i16 ×10 → px
582                        return MultiValue::Exact(PixelValue::px(f32::from(raw) / 10.0));
583                    }
584                    // I16_SENTINEL or I16_INHERIT → fall through to slow path
585                }
586            }
587
588            let node_data = &styled_dom.node_data.as_container()[node_id];
589
590            let author_css = styled_dom
591                .css_property_cache
592                .ptr
593                .$cache_method(node_data, &node_id, node_state);
594
595            if let Some(ref val) = author_css {
596                if val.is_auto() {
597                    return MultiValue::Auto;
598                }
599                if let Some(exact) = val.get_property().copied() {
600                    return MultiValue::Exact(exact.inner);
601                }
602            }
603
604            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
605
606            if let Some(ua_prop) = ua_css {
607                if let Some(inner) = ua_prop.get_pixel_inner() {
608                    return MultiValue::Exact(inner);
609                }
610            }
611
612            MultiValue::Initial
613        }
614    };
615}
616
617/// Helper trait to extract `PixelValue` from any `CssProperty` variant
618trait CssPropertyPixelInner {
619    fn get_pixel_inner(&self) -> Option<PixelValue>;
620}
621
622impl CssPropertyPixelInner for CssProperty {
623    fn get_pixel_inner(&self) -> Option<PixelValue> {
624        match self {
625            Self::Left(CssPropertyValue::Exact(v)) => Some(v.inner),
626            Self::Right(CssPropertyValue::Exact(v)) => Some(v.inner),
627            Self::Top(CssPropertyValue::Exact(v)) => Some(v.inner),
628            Self::Bottom(CssPropertyValue::Exact(v)) => Some(v.inner),
629            Self::MarginLeft(CssPropertyValue::Exact(v)) => Some(v.inner),
630            Self::MarginRight(CssPropertyValue::Exact(v)) => Some(v.inner),
631            Self::MarginTop(CssPropertyValue::Exact(v)) => Some(v.inner),
632            Self::MarginBottom(CssPropertyValue::Exact(v)) => Some(v.inner),
633            Self::PaddingLeft(CssPropertyValue::Exact(v)) => Some(v.inner),
634            Self::PaddingRight(CssPropertyValue::Exact(v)) => Some(v.inner),
635            Self::PaddingTop(CssPropertyValue::Exact(v)) => Some(v.inner),
636            Self::PaddingBottom(CssPropertyValue::Exact(v)) => Some(v.inner),
637            _ => None,
638        }
639    }
640}
641
642/// Generic macro for CSS properties with UA CSS fallback - returns `MultiValue`<T>
643macro_rules! get_css_property {
644    // Variant WITH compact cache fast path (for enum properties in Tier 1)
645    ($fn_name:ident, $cache_method:ident, $return_type:ty, $ua_property:expr, compact = $compact_method:ident) => {
646        #[must_use] pub fn $fn_name(
647            styled_dom: &StyledDom,
648            node_id: NodeId,
649            node_state: &StyledNodeState,
650        ) -> MultiValue<$return_type> {
651            // FAST PATH: compact cache for normal state (O(1) array + bitshift)
652            // NOTE (M12.7): skipping this fast path does NOT fix get_display_type's
653            // divergence — the slow path / the `match get_display_type(...)` on the
654            // LayoutDisplay enum (a niche-discriminant) mis-lifts too. So this isn't the
655            // cache (unlike the font-size fix); it's the deeper niche/enum decode. Kept.
656            if node_state.is_normal() {
657                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
658                    return MultiValue::Exact(cc.$compact_method(node_id.index()));
659                }
660            }
661
662            // SLOW PATH: full cascade resolution
663            let node_data = &styled_dom.node_data.as_container()[node_id];
664
665            // 1. Check author CSS first
666            let author_css = styled_dom
667                .css_property_cache
668                .ptr
669                .$cache_method(node_data, &node_id, node_state);
670
671            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
672                return MultiValue::Exact(val);
673            }
674
675            // 2. Check User Agent CSS
676            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
677
678            if let Some(ua_prop) = ua_css {
679                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
680                    return MultiValue::Exact(val);
681                }
682            }
683
684            // 3. Fallback to Auto (not set)
685            MultiValue::Auto
686        }
687    };
688    // Variant WITH compact cache for u32-encoded dimension enums (LayoutWidth/LayoutHeight)
689    // These types have Auto, Px(PixelValue), MinContent, MaxContent, Calc variants
690    ($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) => {
691        #[must_use] pub fn $fn_name(
692            styled_dom: &StyledDom,
693            node_id: NodeId,
694            node_state: &StyledNodeState,
695        ) -> MultiValue<$return_type> {
696            // FAST PATH: compact cache for normal state
697            if node_state.is_normal() {
698                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
699                    let raw = cc.$compact_raw_method(node_id.index());
700                    match raw {
701                        azul_css::compact_cache::U32_AUTO => return MultiValue::Auto,
702                        azul_css::compact_cache::U32_INITIAL => return MultiValue::Initial,
703                        azul_css::compact_cache::U32_NONE => return MultiValue::Auto,
704                        azul_css::compact_cache::U32_MIN_CONTENT => return MultiValue::Exact($min_content_variant),
705                        azul_css::compact_cache::U32_MAX_CONTENT => return MultiValue::Exact($max_content_variant),
706                        azul_css::compact_cache::U32_SENTINEL | azul_css::compact_cache::U32_INHERIT => {
707                            // fall through to slow path
708                        }
709                        _ => {
710                            // Valid encoded pixel value
711                            if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
712                                return MultiValue::Exact($px_variant(pv));
713                            }
714                            // decode failed → slow path
715                        }
716                    }
717                }
718            }
719
720            // SLOW PATH: full cascade resolution
721            let node_data = &styled_dom.node_data.as_container()[node_id];
722
723            let author_css = styled_dom
724                .css_property_cache
725                .ptr
726                .$cache_method(node_data, &node_id, node_state);
727
728            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
729                return MultiValue::Exact(val);
730            }
731
732            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
733
734            if let Some(ua_prop) = ua_css {
735                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
736                    return MultiValue::Exact(val);
737                }
738            }
739
740            MultiValue::Auto
741        }
742    };
743    // Variant WITH compact cache for u32-encoded dimension structs (LayoutMinWidth etc.)
744    // These types are struct { inner: PixelValue }
745    ($fn_name:ident, $cache_method:ident, $return_type:ty, $ua_property:expr, compact_u32_struct = $compact_raw_method:ident) => {
746        #[must_use] pub fn $fn_name(
747            styled_dom: &StyledDom,
748            node_id: NodeId,
749            node_state: &StyledNodeState,
750        ) -> MultiValue<$return_type> {
751            // FAST PATH: compact cache for normal state
752            if node_state.is_normal() {
753                if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
754                    let raw = cc.$compact_raw_method(node_id.index());
755                    match raw {
756                        azul_css::compact_cache::U32_AUTO | azul_css::compact_cache::U32_NONE => return MultiValue::Auto,
757                        azul_css::compact_cache::U32_INITIAL => return MultiValue::Initial,
758                        azul_css::compact_cache::U32_SENTINEL | azul_css::compact_cache::U32_INHERIT => {
759                            // fall through to slow path
760                        }
761                        _ => {
762                            if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
763                                return MultiValue::Exact(
764                                    <$return_type as azul_css::props::PixelValueTaker>::from_pixel_value(pv)
765                                );
766                            }
767                        }
768                    }
769                }
770            }
771
772            // SLOW PATH
773            let node_data = &styled_dom.node_data.as_container()[node_id];
774
775            let author_css = styled_dom
776                .css_property_cache
777                .ptr
778                .$cache_method(node_data, &node_id, node_state);
779
780            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
781                return MultiValue::Exact(val);
782            }
783
784            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
785
786            if let Some(ua_prop) = ua_css {
787                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
788                    return MultiValue::Exact(val);
789                }
790            }
791
792            MultiValue::Auto
793        }
794    };
795    // Variant WITHOUT compact cache (original behavior)
796    ($fn_name:ident, $cache_method:ident, $return_type:ty, $ua_property:expr) => {
797        #[must_use] pub fn $fn_name(
798            styled_dom: &StyledDom,
799            node_id: NodeId,
800            node_state: &StyledNodeState,
801        ) -> MultiValue<$return_type> {
802            let node_data = &styled_dom.node_data.as_container()[node_id];
803
804            // 1. Check author CSS first
805            let author_css = styled_dom
806                .css_property_cache
807                .ptr
808                .$cache_method(node_data, &node_id, node_state);
809
810            if let Some(val) = author_css.and_then(|v| v.get_property().cloned()) {
811                return MultiValue::Exact(val);
812            }
813
814            // 2. Check User Agent CSS
815            let ua_css = azul_core::ua_css::get_ua_property(&node_data.node_type, $ua_property);
816
817            if let Some(ua_prop) = ua_css {
818                if let Some(val) = extract_property_value::<$return_type>(ua_prop) {
819                    return MultiValue::Exact(val);
820                }
821            }
822
823            // 3. Fallback to Auto (not set)
824            MultiValue::Auto
825        }
826    };
827}
828
829/// Helper trait to extract typed values from UA CSS properties
830trait ExtractPropertyValue<T> {
831    fn extract(&self) -> Option<T>;
832}
833
834fn extract_property_value<T>(prop: &CssProperty) -> Option<T>
835where
836    CssProperty: ExtractPropertyValue<T>,
837{
838    prop.extract()
839}
840
841// Implement extraction for all layout types
842
843impl ExtractPropertyValue<LayoutWidth> for CssProperty {
844    fn extract(&self) -> Option<LayoutWidth> {
845        match self {
846            Self::Width(CssPropertyValue::Exact(v)) => Some(v.clone()),
847            _ => None,
848        }
849    }
850}
851
852impl ExtractPropertyValue<LayoutHeight> for CssProperty {
853    fn extract(&self) -> Option<LayoutHeight> {
854        match self {
855            Self::Height(CssPropertyValue::Exact(v)) => Some(v.clone()),
856            _ => None,
857        }
858    }
859}
860
861impl ExtractPropertyValue<LayoutMinWidth> for CssProperty {
862    fn extract(&self) -> Option<LayoutMinWidth> {
863        match self {
864            Self::MinWidth(CssPropertyValue::Exact(v)) => Some(*v),
865            _ => None,
866        }
867    }
868}
869
870impl ExtractPropertyValue<LayoutMinHeight> for CssProperty {
871    fn extract(&self) -> Option<LayoutMinHeight> {
872        match self {
873            Self::MinHeight(CssPropertyValue::Exact(v)) => Some(*v),
874            _ => None,
875        }
876    }
877}
878
879impl ExtractPropertyValue<LayoutMaxWidth> for CssProperty {
880    fn extract(&self) -> Option<LayoutMaxWidth> {
881        match self {
882            Self::MaxWidth(CssPropertyValue::Exact(v)) => Some(*v),
883            _ => None,
884        }
885    }
886}
887
888impl ExtractPropertyValue<LayoutMaxHeight> for CssProperty {
889    fn extract(&self) -> Option<LayoutMaxHeight> {
890        match self {
891            Self::MaxHeight(CssPropertyValue::Exact(v)) => Some(*v),
892            _ => None,
893        }
894    }
895}
896
897impl ExtractPropertyValue<LayoutDisplay> for CssProperty {
898    fn extract(&self) -> Option<LayoutDisplay> {
899        match self {
900            Self::Display(CssPropertyValue::Exact(v)) => Some(*v),
901            _ => None,
902        }
903    }
904}
905
906impl ExtractPropertyValue<LayoutWritingMode> for CssProperty {
907    fn extract(&self) -> Option<LayoutWritingMode> {
908        match self {
909            Self::WritingMode(CssPropertyValue::Exact(v)) => Some(*v),
910            _ => None,
911        }
912    }
913}
914
915impl ExtractPropertyValue<LayoutFlexWrap> for CssProperty {
916    fn extract(&self) -> Option<LayoutFlexWrap> {
917        match self {
918            Self::FlexWrap(CssPropertyValue::Exact(v)) => Some(*v),
919            _ => None,
920        }
921    }
922}
923
924impl ExtractPropertyValue<LayoutJustifyContent> for CssProperty {
925    fn extract(&self) -> Option<LayoutJustifyContent> {
926        match self {
927            Self::JustifyContent(CssPropertyValue::Exact(v)) => Some(*v),
928            _ => None,
929        }
930    }
931}
932
933impl ExtractPropertyValue<StyleTextAlign> for CssProperty {
934    fn extract(&self) -> Option<StyleTextAlign> {
935        match self {
936            Self::TextAlign(CssPropertyValue::Exact(v)) => Some(*v),
937            _ => None,
938        }
939    }
940}
941
942impl ExtractPropertyValue<LayoutFloat> for CssProperty {
943    fn extract(&self) -> Option<LayoutFloat> {
944        match self {
945            Self::Float(CssPropertyValue::Exact(v)) => Some(*v),
946            _ => None,
947        }
948    }
949}
950
951impl ExtractPropertyValue<LayoutClear> for CssProperty {
952    fn extract(&self) -> Option<LayoutClear> {
953        match self {
954            Self::Clear(CssPropertyValue::Exact(v)) => Some(*v),
955            _ => None,
956        }
957    }
958}
959
960impl ExtractPropertyValue<LayoutOverflow> for CssProperty {
961    fn extract(&self) -> Option<LayoutOverflow> {
962        match self {
963            Self::OverflowX(CssPropertyValue::Exact(v))
964            | Self::OverflowY(CssPropertyValue::Exact(v))
965            | Self::OverflowBlock(CssPropertyValue::Exact(v))
966            | Self::OverflowInline(CssPropertyValue::Exact(v)) => Some(*v),
967            _ => None,
968        }
969    }
970}
971
972impl ExtractPropertyValue<LayoutPosition> for CssProperty {
973    fn extract(&self) -> Option<LayoutPosition> {
974        match self {
975            Self::Position(CssPropertyValue::Exact(v)) => Some(*v),
976            _ => None,
977        }
978    }
979}
980
981impl ExtractPropertyValue<LayoutBoxSizing> for CssProperty {
982    fn extract(&self) -> Option<LayoutBoxSizing> {
983        match self {
984            Self::BoxSizing(CssPropertyValue::Exact(v)) => Some(*v),
985            _ => None,
986        }
987    }
988}
989
990impl ExtractPropertyValue<PixelValue> for CssProperty {
991    fn extract(&self) -> Option<PixelValue> {
992        self.get_pixel_inner()
993    }
994}
995
996impl ExtractPropertyValue<LayoutFlexDirection> for CssProperty {
997    fn extract(&self) -> Option<LayoutFlexDirection> {
998        match self {
999            Self::FlexDirection(CssPropertyValue::Exact(v)) => Some(*v),
1000            _ => None,
1001        }
1002    }
1003}
1004
1005impl ExtractPropertyValue<LayoutAlignItems> for CssProperty {
1006    fn extract(&self) -> Option<LayoutAlignItems> {
1007        match self {
1008            Self::AlignItems(CssPropertyValue::Exact(v)) => Some(*v),
1009            _ => None,
1010        }
1011    }
1012}
1013
1014impl ExtractPropertyValue<LayoutAlignContent> for CssProperty {
1015    fn extract(&self) -> Option<LayoutAlignContent> {
1016        match self {
1017            Self::AlignContent(CssPropertyValue::Exact(v)) => Some(*v),
1018            _ => None,
1019        }
1020    }
1021}
1022
1023impl ExtractPropertyValue<StyleFontWeight> for CssProperty {
1024    fn extract(&self) -> Option<StyleFontWeight> {
1025        match self {
1026            Self::FontWeight(CssPropertyValue::Exact(v)) => Some(*v),
1027            _ => None,
1028        }
1029    }
1030}
1031
1032impl ExtractPropertyValue<StyleFontStyle> for CssProperty {
1033    fn extract(&self) -> Option<StyleFontStyle> {
1034        match self {
1035            Self::FontStyle(CssPropertyValue::Exact(v)) => Some(*v),
1036            _ => None,
1037        }
1038    }
1039}
1040
1041impl ExtractPropertyValue<StyleVisibility> for CssProperty {
1042    fn extract(&self) -> Option<StyleVisibility> {
1043        match self {
1044            Self::Visibility(CssPropertyValue::Exact(v)) => Some(*v),
1045            _ => None,
1046        }
1047    }
1048}
1049
1050impl ExtractPropertyValue<StyleWhiteSpace> for CssProperty {
1051    fn extract(&self) -> Option<StyleWhiteSpace> {
1052        match self {
1053            Self::WhiteSpace(CssPropertyValue::Exact(v)) => Some(*v),
1054            _ => None,
1055        }
1056    }
1057}
1058
1059impl ExtractPropertyValue<StyleDirection> for CssProperty {
1060    fn extract(&self) -> Option<StyleDirection> {
1061        match self {
1062            Self::Direction(CssPropertyValue::Exact(v)) => Some(*v),
1063            _ => None,
1064        }
1065    }
1066}
1067
1068impl ExtractPropertyValue<StyleUnicodeBidi> for CssProperty {
1069    fn extract(&self) -> Option<StyleUnicodeBidi> {
1070        match self {
1071            Self::UnicodeBidi(CssPropertyValue::Exact(v)) => Some(*v),
1072            _ => None,
1073        }
1074    }
1075}
1076
1077impl ExtractPropertyValue<StyleTextBoxTrim> for CssProperty {
1078    fn extract(&self) -> Option<StyleTextBoxTrim> {
1079        match self {
1080            Self::TextBoxTrim(CssPropertyValue::Exact(v)) => Some(*v),
1081            _ => None,
1082        }
1083    }
1084}
1085
1086impl ExtractPropertyValue<StyleTextBoxEdge> for CssProperty {
1087    fn extract(&self) -> Option<StyleTextBoxEdge> {
1088        match self {
1089            Self::TextBoxEdge(CssPropertyValue::Exact(v)) => Some(*v),
1090            _ => None,
1091        }
1092    }
1093}
1094
1095impl ExtractPropertyValue<StyleDominantBaseline> for CssProperty {
1096    fn extract(&self) -> Option<StyleDominantBaseline> {
1097        match self {
1098            Self::DominantBaseline(CssPropertyValue::Exact(v)) => Some(*v),
1099            _ => None,
1100        }
1101    }
1102}
1103
1104impl ExtractPropertyValue<StyleAlignmentBaseline> for CssProperty {
1105    fn extract(&self) -> Option<StyleAlignmentBaseline> {
1106        match self {
1107            Self::AlignmentBaseline(CssPropertyValue::Exact(v)) => Some(*v),
1108            _ => None,
1109        }
1110    }
1111}
1112
1113impl ExtractPropertyValue<StyleBaselineSource> for CssProperty {
1114    fn extract(&self) -> Option<StyleBaselineSource> {
1115        match self {
1116            Self::BaselineSource(CssPropertyValue::Exact(v)) => Some(*v),
1117            _ => None,
1118        }
1119    }
1120}
1121
1122impl ExtractPropertyValue<StyleLineFitEdge> for CssProperty {
1123    fn extract(&self) -> Option<StyleLineFitEdge> {
1124        match self {
1125            Self::LineFitEdge(CssPropertyValue::Exact(v)) => Some(*v),
1126            _ => None,
1127        }
1128    }
1129}
1130
1131impl ExtractPropertyValue<StyleInitialLetterAlign> for CssProperty {
1132    fn extract(&self) -> Option<StyleInitialLetterAlign> {
1133        match self {
1134            Self::InitialLetterAlign(CssPropertyValue::Exact(v)) => Some(*v),
1135            _ => None,
1136        }
1137    }
1138}
1139
1140impl ExtractPropertyValue<StyleInitialLetterWrap> for CssProperty {
1141    fn extract(&self) -> Option<StyleInitialLetterWrap> {
1142        match self {
1143            Self::InitialLetterWrap(CssPropertyValue::Exact(v)) => Some(*v),
1144            _ => None,
1145        }
1146    }
1147}
1148
1149impl ExtractPropertyValue<StyleScrollbarGutter> for CssProperty {
1150    fn extract(&self) -> Option<StyleScrollbarGutter> {
1151        match self {
1152            Self::ScrollbarGutter(CssPropertyValue::Exact(v)) => Some(*v),
1153            _ => None,
1154        }
1155    }
1156}
1157
1158impl ExtractPropertyValue<StyleOverflowClipMargin> for CssProperty {
1159    fn extract(&self) -> Option<StyleOverflowClipMargin> {
1160        match self {
1161            Self::OverflowClipMargin(CssPropertyValue::Exact(v)) => Some(*v),
1162            _ => None,
1163        }
1164    }
1165}
1166
1167impl ExtractPropertyValue<StyleVerticalAlign> for CssProperty {
1168    fn extract(&self) -> Option<StyleVerticalAlign> {
1169        match self {
1170            Self::VerticalAlign(CssPropertyValue::Exact(v)) => Some(*v),
1171            _ => None,
1172        }
1173    }
1174}
1175
1176get_css_property!(
1177    get_writing_mode,
1178    get_writing_mode,
1179    LayoutWritingMode,
1180    CssPropertyType::WritingMode,
1181    compact = get_writing_mode
1182);
1183
1184get_css_property!(
1185    get_css_width,
1186    get_width,
1187    LayoutWidth,
1188    CssPropertyType::Width,
1189    compact_u32_dim = get_width_raw,
1190    LayoutWidth::Px,
1191    LayoutWidth::Auto,
1192    LayoutWidth::MinContent,
1193    LayoutWidth::MaxContent
1194);
1195
1196get_css_property!(
1197    get_css_height,
1198    get_height,
1199    LayoutHeight,
1200    CssPropertyType::Height,
1201    compact_u32_dim = get_height_raw,
1202    LayoutHeight::Px,
1203    LayoutHeight::Auto,
1204    LayoutHeight::MinContent,
1205    LayoutHeight::MaxContent
1206);
1207
1208get_css_property!(
1209    get_wrap,
1210    get_flex_wrap,
1211    LayoutFlexWrap,
1212    CssPropertyType::FlexWrap,
1213    compact = get_flex_wrap
1214);
1215
1216get_css_property!(
1217    get_justify_content,
1218    get_justify_content,
1219    LayoutJustifyContent,
1220    CssPropertyType::JustifyContent,
1221    compact = get_justify_content
1222);
1223
1224get_css_property!(
1225    get_text_align,
1226    get_text_align,
1227    StyleTextAlign,
1228    CssPropertyType::TextAlign,
1229    compact = get_text_align
1230);
1231
1232get_css_property!(
1233    get_float,
1234    get_float,
1235    LayoutFloat,
1236    CssPropertyType::Float,
1237    compact = get_float
1238);
1239
1240get_css_property!(
1241    get_clear,
1242    get_clear,
1243    LayoutClear,
1244    CssPropertyType::Clear,
1245    compact = get_clear
1246);
1247
1248get_css_property!(
1249    get_overflow_x,
1250    get_overflow_x,
1251    LayoutOverflow,
1252    CssPropertyType::OverflowX,
1253    compact = get_overflow_x
1254);
1255
1256get_css_property!(
1257    get_overflow_y,
1258    get_overflow_y,
1259    LayoutOverflow,
1260    CssPropertyType::OverflowY,
1261    compact = get_overflow_y
1262);
1263
1264// +spec:overflow:17654b - overflow-block and overflow-inline logical properties resolve to physical overflow based on writing mode
1265get_css_property!(
1266    get_overflow_block,
1267    get_overflow_block,
1268    LayoutOverflow,
1269    CssPropertyType::OverflowBlock
1270);
1271
1272get_css_property!(
1273    get_overflow_inline,
1274    get_overflow_inline,
1275    LayoutOverflow,
1276    CssPropertyType::OverflowInline
1277);
1278
1279get_css_property!(
1280    get_position,
1281    get_position,
1282    LayoutPosition,
1283    CssPropertyType::Position,
1284    compact = get_position
1285);
1286
1287get_css_property!(
1288    get_css_box_sizing,
1289    get_box_sizing,
1290    LayoutBoxSizing,
1291    CssPropertyType::BoxSizing,
1292    compact = get_box_sizing
1293);
1294
1295get_css_property!(
1296    get_flex_direction,
1297    get_flex_direction,
1298    LayoutFlexDirection,
1299    CssPropertyType::FlexDirection,
1300    compact = get_flex_direction
1301);
1302
1303get_css_property!(
1304    get_align_items,
1305    get_align_items,
1306    LayoutAlignItems,
1307    CssPropertyType::AlignItems,
1308    compact = get_align_items
1309);
1310
1311get_css_property!(
1312    get_align_content,
1313    get_align_content,
1314    LayoutAlignContent,
1315    CssPropertyType::AlignContent,
1316    compact = get_align_content
1317);
1318
1319get_css_property!(
1320    get_font_weight_property,
1321    get_font_weight,
1322    StyleFontWeight,
1323    CssPropertyType::FontWeight,
1324    compact = get_font_weight
1325);
1326
1327get_css_property!(
1328    get_font_style_property,
1329    get_font_style,
1330    StyleFontStyle,
1331    CssPropertyType::FontStyle,
1332    compact = get_font_style
1333);
1334
1335get_css_property!(
1336    get_visibility,
1337    get_visibility,
1338    StyleVisibility,
1339    CssPropertyType::Visibility,
1340    compact = get_visibility
1341);
1342
1343get_css_property!(
1344    get_white_space_property,
1345    get_white_space,
1346    StyleWhiteSpace,
1347    CssPropertyType::WhiteSpace,
1348    compact = get_white_space
1349);
1350
1351// +spec:writing-modes:3af12f - unicode-bidi does not affect direction for layout; we use direction property directly
1352get_css_property!(
1353    get_direction_property,
1354    get_direction,
1355    StyleDirection,
1356    CssPropertyType::Direction,
1357    compact = get_direction
1358);
1359
1360// +spec:display-property:346799 - inline-level elements with unicode-bidi:normal have no effect on text ordering
1361// +spec:writing-modes:3e2632 - unicode-bidi property resolves embedding level for bidi algorithm (LRE/RLE/PDF)
1362// +spec:writing-modes:d2c94f - direction+unicode-bidi properties map to UAX#9 bidirectional algorithm
1363get_css_property!(
1364    get_unicode_bidi_property,
1365    get_unicode_bidi,
1366    StyleUnicodeBidi,
1367    CssPropertyType::UnicodeBidi
1368);
1369
1370// +spec:display-property:db5125 - text-box-trim on inline boxes trims content box to text-box-edge metric
1371// +spec:display-property:dceb24 - text-box-trim on inline boxes: content edges coincide with text baselines
1372get_css_property!(
1373    get_text_box_trim_property,
1374    get_text_box_trim,
1375    StyleTextBoxTrim,
1376    CssPropertyType::TextBoxTrim
1377);
1378
1379get_css_property!(
1380    get_text_box_edge_property,
1381    get_text_box_edge,
1382    StyleTextBoxEdge,
1383    CssPropertyType::TextBoxEdge
1384);
1385
1386get_css_property!(
1387    get_dominant_baseline_property,
1388    get_dominant_baseline,
1389    StyleDominantBaseline,
1390    CssPropertyType::DominantBaseline
1391);
1392
1393get_css_property!(
1394    get_alignment_baseline_property,
1395    get_alignment_baseline,
1396    StyleAlignmentBaseline,
1397    CssPropertyType::AlignmentBaseline
1398);
1399
1400get_css_property!(
1401    get_baseline_source_property,
1402    get_baseline_source,
1403    StyleBaselineSource,
1404    CssPropertyType::BaselineSource
1405);
1406
1407get_css_property!(
1408    get_line_fit_edge_property,
1409    get_line_fit_edge,
1410    StyleLineFitEdge,
1411    CssPropertyType::LineFitEdge
1412);
1413
1414get_css_property!(
1415    get_initial_letter_align_property,
1416    get_initial_letter_align,
1417    StyleInitialLetterAlign,
1418    CssPropertyType::InitialLetterAlign
1419);
1420
1421get_css_property!(
1422    get_initial_letter_wrap_property,
1423    get_initial_letter_wrap,
1424    StyleInitialLetterWrap,
1425    CssPropertyType::InitialLetterWrap
1426);
1427
1428// +spec:overflow:5d15e2 - block-start/block-end scrollbar gutter follows same rules as inline gutters when auto
1429//
1430// Hand-rolled fast path: 99% of nodes don't set scrollbar-gutter, and the
1431// default is `auto`. The compact cache stores the enum in 2 bits of
1432// tier2_cold.hot_flags, so we can return the answer without a cascade walk.
1433#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1434#[must_use] pub fn get_scrollbar_gutter_property(
1435    styled_dom: &StyledDom,
1436    node_id: NodeId,
1437    node_state: &StyledNodeState,
1438) -> MultiValue<StyleScrollbarGutter> {
1439    // FAST PATH: 2-bit enum in hot_flags
1440    if node_state.is_normal() {
1441        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1442            let bits = cc.get_scrollbar_gutter_bits(node_id.index());
1443            let val = match bits {
1444                azul_css::compact_cache::SCROLLBAR_GUTTER_AUTO => StyleScrollbarGutter::Auto,
1445                azul_css::compact_cache::SCROLLBAR_GUTTER_STABLE => StyleScrollbarGutter::Stable,
1446                azul_css::compact_cache::SCROLLBAR_GUTTER_BOTH_EDGES => {
1447                    StyleScrollbarGutter::StableBothEdges
1448                }
1449                _ => StyleScrollbarGutter::Auto,
1450            };
1451            return MultiValue::Exact(val);
1452        }
1453    }
1454
1455    // SLOW PATH: cascade resolution for pseudo-states or missing cache
1456    let node_data = &styled_dom.node_data.as_container()[node_id];
1457    let author_css = styled_dom
1458        .css_property_cache
1459        .ptr
1460        .get_scrollbar_gutter(node_data, &node_id, node_state);
1461    if let Some(val) = author_css.and_then(|v| v.get_property().copied()) {
1462        return MultiValue::Exact(val);
1463    }
1464    MultiValue::Auto
1465}
1466
1467get_css_property!(
1468    get_overflow_clip_margin_property,
1469    get_overflow_clip_margin,
1470    StyleOverflowClipMargin,
1471    CssPropertyType::OverflowClipMargin
1472);
1473
1474get_css_property!(
1475    get_object_fit_property,
1476    get_object_fit,
1477    StyleObjectFit,
1478    CssPropertyType::ObjectFit
1479);
1480
1481get_css_property!(
1482    get_text_overflow_property,
1483    get_text_overflow,
1484    StyleTextOverflow,
1485    CssPropertyType::TextOverflow
1486);
1487
1488// +spec:writing-modes:257296 - text-orientation getter for vertical typesetting (upright/sideways)
1489//
1490// Hand-rolled (not macro-generated) to attach a negative fast-path: most
1491// nodes have no text-orientation declared (default = Mixed), so we avoid a
1492// cascade walk per fc.rs call (which is called ~2× per node).
1493#[must_use] pub fn get_text_orientation_property(
1494    styled_dom: &StyledDom,
1495    node_id: NodeId,
1496    node_state: &StyledNodeState,
1497) -> MultiValue<StyleTextOrientation> {
1498    if node_state.is_normal() {
1499        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1500            if !cc.has_text_orientation(node_id.index()) {
1501                return MultiValue::Auto;
1502            }
1503        }
1504    }
1505    let node_data = &styled_dom.node_data.as_container()[node_id];
1506    if let Some(val) = styled_dom
1507        .css_property_cache
1508        .ptr
1509        .get_text_orientation(node_data, &node_id, node_state)
1510        .and_then(|v| v.get_property().copied())
1511    {
1512        return MultiValue::Exact(val);
1513    }
1514    let ua = azul_core::ua_css::get_ua_property(
1515        &node_data.node_type,
1516        CssPropertyType::TextOrientation,
1517    );
1518    if let Some(ua_prop) = ua {
1519        if let Some(val) = extract_property_value::<StyleTextOrientation>(ua_prop) {
1520            return MultiValue::Exact(val);
1521        }
1522    }
1523    MultiValue::Auto
1524}
1525
1526get_css_property!(
1527    get_object_position_property,
1528    get_object_position,
1529    StyleObjectPosition,
1530    CssPropertyType::ObjectPosition
1531);
1532
1533get_css_property!(
1534    get_aspect_ratio_property,
1535    get_aspect_ratio,
1536    StyleAspectRatio,
1537    CssPropertyType::AspectRatio
1538);
1539
1540// NOTE: vertical-align does NOT use the compact cache because the compact cache
1541// only stores keyword variants (3 bits = 8 values) and silently drops
1542// Percentage/Length values by mapping them to Baseline. Always use the slow path.
1543#[must_use] pub fn get_vertical_align_property(
1544    styled_dom: &StyledDom,
1545    node_id: NodeId,
1546    node_state: &StyledNodeState,
1547) -> MultiValue<StyleVerticalAlign> {
1548    let node_data = &styled_dom.node_data.as_container()[node_id];
1549
1550    let author_css = styled_dom
1551        .css_property_cache
1552        .ptr
1553        .get_vertical_align(node_data, &node_id, node_state);
1554
1555    if let Some(val) = author_css.and_then(|v| v.get_property().copied()) {
1556        return MultiValue::Exact(val);
1557    }
1558
1559    let ua_css = azul_core::ua_css::get_ua_property(
1560        &node_data.node_type,
1561        CssPropertyType::VerticalAlign,
1562    );
1563
1564    if let Some(ua_prop) = ua_css {
1565        if let Some(val) = extract_property_value::<StyleVerticalAlign>(ua_prop) {
1566            return MultiValue::Exact(val);
1567        }
1568    }
1569
1570    MultiValue::Auto
1571}
1572// Complex Property Getters
1573
1574/// Get border radius for all four corners (raw CSS property values)
1575#[must_use] pub fn get_style_border_radius(
1576    styled_dom: &StyledDom,
1577    node_id: NodeId,
1578    node_state: &StyledNodeState,
1579) -> StyleBorderRadius {
1580    use azul_css::props::basic::pixel::PixelValue;
1581    // FAST PATH: all four corners live in tier2_cold as i16 px × 10. The
1582    // common case (no rounded corners anywhere) reads four bytes and bails.
1583    if node_state.is_normal() {
1584        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1585            let idx = node_id.index();
1586            let decode = |raw: i16| -> PixelValue {
1587                if raw >= azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
1588                    PixelValue::px(0.0)
1589                } else {
1590                    PixelValue::px(f32::from(raw) / 10.0)
1591                }
1592            };
1593            return StyleBorderRadius {
1594                top_left: decode(cc.get_border_top_left_radius_raw(idx)),
1595                top_right: decode(cc.get_border_top_right_radius_raw(idx)),
1596                bottom_right: decode(cc.get_border_bottom_right_radius_raw(idx)),
1597                bottom_left: decode(cc.get_border_bottom_left_radius_raw(idx)),
1598            };
1599        }
1600    }
1601    let node_data = &styled_dom.node_data.as_container()[node_id];
1602
1603    let top_left = styled_dom
1604        .css_property_cache
1605        .ptr
1606        .get_border_top_left_radius(node_data, &node_id, node_state)
1607        .and_then(|br| br.get_property_or_default())
1608        .map(|v| v.inner)
1609        .unwrap_or_default();
1610
1611    let top_right = styled_dom
1612        .css_property_cache
1613        .ptr
1614        .get_border_top_right_radius(node_data, &node_id, node_state)
1615        .and_then(|br| br.get_property_or_default())
1616        .map(|v| v.inner)
1617        .unwrap_or_default();
1618
1619    let bottom_right = styled_dom
1620        .css_property_cache
1621        .ptr
1622        .get_border_bottom_right_radius(node_data, &node_id, node_state)
1623        .and_then(|br| br.get_property_or_default())
1624        .map(|v| v.inner)
1625        .unwrap_or_default();
1626
1627    let bottom_left = styled_dom
1628        .css_property_cache
1629        .ptr
1630        .get_border_bottom_left_radius(node_data, &node_id, node_state)
1631        .and_then(|br| br.get_property_or_default())
1632        .map(|v| v.inner)
1633        .unwrap_or_default();
1634
1635    StyleBorderRadius {
1636        top_left,
1637        top_right,
1638        bottom_right,
1639        bottom_left,
1640    }
1641}
1642
1643/// Get border radius for all four corners (resolved to pixels)
1644///
1645/// # Arguments
1646/// * `element_size` - The element's own size (width × height) for % resolution. According to CSS
1647///   spec, border-radius % uses element's own dimensions.
1648#[must_use] pub fn get_border_radius(
1649    styled_dom: &StyledDom,
1650    node_id: NodeId,
1651    node_state: &StyledNodeState,
1652    element_size: PhysicalSizeImport,
1653    viewport_size: LogicalSize,
1654) -> BorderRadius {
1655    use azul_css::props::basic::{PhysicalSize, PropertyContext, ResolutionContext};
1656
1657    // FAST PATH: all four corners as i16 px × 10 in tier2_cold. The
1658    // overwhelmingly common case (no rounded corners) reads four bytes and
1659    // returns zeros without a cascade walk.
1660    if node_state.is_normal() {
1661        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1662            let idx = node_id.index();
1663            let tl = cc.get_border_top_left_radius_raw(idx);
1664            let tr = cc.get_border_top_right_radius_raw(idx);
1665            let br = cc.get_border_bottom_right_radius_raw(idx);
1666            let bl = cc.get_border_bottom_left_radius_raw(idx);
1667            // sentinel = "unset" = 0 px (no corner radius)
1668            let thresh = azul_css::compact_cache::I16_SENTINEL_THRESHOLD;
1669            let decode = |raw: i16| -> f32 {
1670                if raw >= thresh {
1671                    0.0
1672                } else {
1673                    f32::from(raw) / 10.0
1674                }
1675            };
1676            return BorderRadius {
1677                top_left: decode(tl),
1678                top_right: decode(tr),
1679                bottom_right: decode(br),
1680                bottom_left: decode(bl),
1681            };
1682        }
1683    }
1684
1685    let node_data = &styled_dom.node_data.as_container()[node_id];
1686
1687    // Get font sizes for em/rem resolution
1688    let element_font_size = get_element_font_size(styled_dom, node_id, node_state);
1689    let parent_font_size = styled_dom
1690        .node_hierarchy
1691        .as_container()
1692        .get(node_id)
1693        .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
1694        .map_or(DEFAULT_FONT_SIZE, |p| get_element_font_size(styled_dom, p, node_state));
1695    let root_font_size = get_root_font_size(styled_dom, node_state);
1696
1697    // Create resolution context
1698    let context = ResolutionContext {
1699        element_font_size,
1700        parent_font_size,
1701        root_font_size,
1702        containing_block_size: PhysicalSize::new(0.0, 0.0), // Not used for border-radius
1703        element_size: Some(PhysicalSize::new(element_size.width, element_size.height)),
1704        viewport_size: PhysicalSize::new(viewport_size.width, viewport_size.height),
1705    };
1706
1707    let top_left = styled_dom
1708        .css_property_cache
1709        .ptr
1710        .get_border_top_left_radius(node_data, &node_id, node_state)
1711        .and_then(|br| br.get_property().copied())
1712        .unwrap_or_default();
1713
1714    let top_right = styled_dom
1715        .css_property_cache
1716        .ptr
1717        .get_border_top_right_radius(node_data, &node_id, node_state)
1718        .and_then(|br| br.get_property().copied())
1719        .unwrap_or_default();
1720
1721    let bottom_right = styled_dom
1722        .css_property_cache
1723        .ptr
1724        .get_border_bottom_right_radius(node_data, &node_id, node_state)
1725        .and_then(|br| br.get_property().copied())
1726        .unwrap_or_default();
1727
1728    let bottom_left = styled_dom
1729        .css_property_cache
1730        .ptr
1731        .get_border_bottom_left_radius(node_data, &node_id, node_state)
1732        .and_then(|br| br.get_property().copied())
1733        .unwrap_or_default();
1734
1735    BorderRadius {
1736        top_left: top_left
1737            .inner
1738            .resolve_with_context(&context, PropertyContext::BorderRadius),
1739        top_right: top_right
1740            .inner
1741            .resolve_with_context(&context, PropertyContext::BorderRadius),
1742        bottom_right: bottom_right
1743            .inner
1744            .resolve_with_context(&context, PropertyContext::BorderRadius),
1745        bottom_left: bottom_left
1746            .inner
1747            .resolve_with_context(&context, PropertyContext::BorderRadius),
1748    }
1749}
1750
1751// +spec:stacking-contexts:a93e62 - stack level from z-index for stacking context ordering
1752// +spec:stacking-contexts:ae50ae - z-index specifies stack level; auto resolves to 0 (inherited from parent stacking context)
1753/// Get z-index for stacking context ordering.
1754///
1755/// Returns the resolved integer z-index value:
1756/// - `z-index: auto` → 0 (participates in parent's stacking context)
1757/// - `z-index: <integer>` → that integer value
1758#[must_use] pub fn get_z_index(styled_dom: &StyledDom, node_id: Option<NodeId>) -> i32 {
1759    use azul_css::props::layout::position::LayoutZIndex;
1760
1761    let Some(node_id) = node_id else {
1762        return 0;
1763    };
1764
1765    let node_state = &styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
1766
1767    // FAST PATH: compact cache for normal state
1768    if node_state.is_normal() {
1769        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1770            let raw = cc.get_z_index(node_id.index());
1771            if raw == azul_css::compact_cache::I16_AUTO {
1772                return 0;
1773            }
1774            if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
1775                return i32::from(raw);
1776            }
1777            // I16_SENTINEL → fall through to slow path
1778        }
1779    }
1780
1781    // SLOW PATH
1782    let node_data = &styled_dom.node_data.as_container()[node_id];
1783
1784    styled_dom
1785        .css_property_cache
1786        .ptr
1787        .get_z_index(node_data, &node_id, node_state)
1788        .and_then(|v| v.get_property())
1789        .map_or(0, |z| match z {
1790            LayoutZIndex::Auto => 0,
1791            LayoutZIndex::Integer(i) => *i,
1792        })
1793}
1794
1795// +spec:positioning:c041c4 - positioned elements with z-index != auto establish stacking contexts
1796// z-index:<integer> ALWAYS establishes new stacking context on positioned elements
1797/// Returns true if z-index is `auto` (the initial value), false if it's an explicit `<integer>`.
1798/// This distinction matters for stacking context creation per §9.9.1.
1799#[must_use] pub fn is_z_index_auto(styled_dom: &StyledDom, node_id: Option<NodeId>) -> bool {
1800    use azul_css::props::layout::position::LayoutZIndex;
1801
1802    let Some(node_id) = node_id else {
1803        return true;
1804    };
1805
1806    let node_state = &styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
1807
1808    // FAST PATH: compact cache for normal state
1809    if node_state.is_normal() {
1810        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
1811            let raw = cc.get_z_index(node_id.index());
1812            if raw == azul_css::compact_cache::I16_AUTO {
1813                return true;
1814            }
1815            if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
1816                return false; // explicit integer
1817            }
1818            // I16_SENTINEL → fall through to slow path
1819        }
1820    }
1821
1822    // SLOW PATH
1823    let node_data = &styled_dom.node_data.as_container()[node_id];
1824
1825    styled_dom
1826        .css_property_cache
1827        .ptr
1828        .get_z_index(node_data, &node_id, node_state)
1829        .and_then(|v| v.get_property())
1830        .is_none_or(|z| matches!(z, LayoutZIndex::Auto)) // no value = auto
1831}
1832
1833// Rendering Property Getters
1834
1835/// Information about background color for a node
1836///
1837/// # CSS Background Propagation (Special Case for HTML Root)
1838///
1839/// According to CSS Backgrounds and Borders Module Level 3, Section "The Canvas Background
1840/// and the HTML `<body>` Element":
1841///
1842/// For HTML documents where the root element is `<html>`, if the computed value of
1843/// `background-image` on the root element is `none` AND its `background-color` is `transparent`,
1844/// user agents **must propagate** the computed values of the background properties from the
1845/// first `<body>` child element to the root element.
1846///
1847/// This behavior exists for backwards compatibility with older HTML where backgrounds were
1848/// typically set on `<body>` using `bgcolor` attributes, and ensures that the `<body>`
1849/// background covers the entire viewport/canvas even when `<body>` itself has constrained
1850/// dimensions.
1851///
1852/// Implementation: When requesting the background of an `<html>` node, we first check if it
1853/// has a transparent background with no image. If so, we look for a `<body>` child and use
1854/// its background instead.
1855#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1856#[must_use] pub fn get_background_color(
1857    styled_dom: &StyledDom,
1858    node_id: NodeId,
1859    node_state: &StyledNodeState,
1860) -> ColorU {
1861    let node_data = &styled_dom.node_data.as_container()[node_id];
1862    let cache = &styled_dom.css_property_cache.ptr;
1863
1864    // Fast path: Get this node's background.
1865    // Negative fast path: if compact cache says `has_background == 0` on a
1866    // normal-state node, skip the cascade walk entirely. Only declared backgrounds
1867    // set the bit, so `false` is a safe "unconditionally transparent" signal.
1868    let get_node_bg = |nid: NodeId, ndata: &azul_core::dom::NodeData, state: &StyledNodeState| {
1869        if state.is_normal() {
1870            if let Some(ref cc) = cache.compact_cache {
1871                if !cc.has_background(nid.index()) {
1872                    return None;
1873                }
1874            }
1875        }
1876        cache
1877            .get_background_content(ndata, &nid, state)
1878            .and_then(|bg| bg.get_property())
1879            .and_then(|bg_vec| bg_vec.get(0).cloned())
1880            .and_then(|first_bg| match &first_bg {
1881                azul_css::props::style::StyleBackgroundContent::Color(color) => Some(*color),
1882                azul_css::props::style::StyleBackgroundContent::Image(_) => None, // Has image, not transparent
1883                _ => None,
1884            })
1885    };
1886
1887    let own_bg = get_node_bg(node_id, node_data, node_state);
1888
1889    // CSS Background Propagation: Special handling for <html> root element
1890    // Only check propagation if this is an Html node AND has transparent background (no
1891    // color/image)
1892    if !matches!(node_data.node_type, NodeType::Html) || own_bg.is_some() {
1893        // Not Html or has its own background - return own background or transparent
1894        return own_bg.unwrap_or(ColorU {
1895            r: 0,
1896            g: 0,
1897            b: 0,
1898            a: 0,
1899        });
1900    }
1901
1902    // Html node with transparent background - check if we should propagate from <body>
1903    let first_child = styled_dom
1904        .node_hierarchy
1905        .as_container()
1906        .get(node_id)
1907        .and_then(|node| node.first_child_id(node_id));
1908
1909    let Some(first_child) = first_child else {
1910        return ColorU {
1911            r: 0,
1912            g: 0,
1913            b: 0,
1914            a: 0,
1915        };
1916    };
1917
1918    let first_child_data = &styled_dom.node_data.as_container()[first_child];
1919
1920    // Check if first child is <body>
1921    if !matches!(first_child_data.node_type, NodeType::Body) {
1922        return ColorU {
1923            r: 0,
1924            g: 0,
1925            b: 0,
1926            a: 0,
1927        };
1928    }
1929
1930    // Propagate <body>'s background to <html> (canvas)
1931    let first_child_state = &styled_dom.styled_nodes.as_container()[first_child].styled_node_state;
1932    get_node_bg(first_child, first_child_data, first_child_state).unwrap_or(ColorU {
1933        r: 0,
1934        g: 0,
1935        b: 0,
1936        a: 0,
1937    })
1938}
1939
1940/// Returns all background content layers for a node (colors, gradients, images).
1941/// This is used for rendering backgrounds that may include linear/radial/conic gradients.
1942///
1943/// CSS Background Propagation (CSS Backgrounds 3, Section 2.11.2):
1944/// For HTML documents, if the root `<html>` element has no background (transparent with no image),
1945/// propagate the background from the first `<body>` child element.
1946#[must_use] pub fn get_background_contents(
1947    styled_dom: &StyledDom,
1948    node_id: NodeId,
1949    node_state: &StyledNodeState,
1950) -> Vec<azul_css::props::style::StyleBackgroundContent> {
1951    use azul_core::dom::NodeType;
1952    use azul_css::props::style::StyleBackgroundContent;
1953
1954    let node_data = &styled_dom.node_data.as_container()[node_id];
1955    let cache = &styled_dom.css_property_cache.ptr;
1956
1957    // Helper to get backgrounds for a node.
1958    // Negative fast path: if compact cache says `has_background == 0` on a normal
1959    // pseudo-state node, return empty without walking the cascade.
1960    let get_node_backgrounds = |nid: NodeId,
1961                                ndata: &azul_core::dom::NodeData,
1962                                state: &StyledNodeState|
1963     -> Vec<StyleBackgroundContent> {
1964        if state.is_normal() {
1965            if let Some(ref cc) = cache.compact_cache {
1966                if !cc.has_background(nid.index()) {
1967                    return Vec::new();
1968                }
1969            }
1970        }
1971        cache
1972            .get_background_content(ndata, &nid, state)
1973            .and_then(|bg| bg.get_property())
1974            .map(|bg_vec| bg_vec.iter().cloned().collect())
1975            .unwrap_or_default()
1976    };
1977
1978    let own_backgrounds = get_node_backgrounds(node_id, node_data, node_state);
1979
1980    // CSS Background Propagation: Special handling for <html> root element
1981    // Only check propagation if this is an Html node AND has no backgrounds
1982    if !matches!(node_data.node_type, NodeType::Html) || !own_backgrounds.is_empty() {
1983        return own_backgrounds;
1984    }
1985
1986    // Html node with no backgrounds - check if we should propagate from <body>
1987    let first_child = styled_dom
1988        .node_hierarchy
1989        .as_container()
1990        .get(node_id)
1991        .and_then(|node| node.first_child_id(node_id));
1992
1993    let Some(first_child) = first_child else {
1994        return own_backgrounds;
1995    };
1996
1997    let first_child_data = &styled_dom.node_data.as_container()[first_child];
1998
1999    // Check if first child is <body>
2000    if !matches!(first_child_data.node_type, NodeType::Body) {
2001        return own_backgrounds;
2002    }
2003
2004    // Propagate <body>'s backgrounds to <html> (canvas)
2005    let first_child_state = &styled_dom.styled_nodes.as_container()[first_child].styled_node_state;
2006    get_node_backgrounds(first_child, first_child_data, first_child_state)
2007}
2008
2009/// Information about border rendering
2010#[derive(Copy, Clone, Debug)]
2011pub struct BorderInfo {
2012    pub widths: crate::solver3::display_list::StyleBorderWidths,
2013    pub colors: crate::solver3::display_list::StyleBorderColors,
2014    pub styles: crate::solver3::display_list::StyleBorderStyles,
2015}
2016
2017#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2018#[must_use] pub fn get_border_info(
2019    styled_dom: &StyledDom,
2020    node_id: NodeId,
2021    node_state: &StyledNodeState,
2022) -> BorderInfo {
2023    use crate::solver3::display_list::{StyleBorderColors, StyleBorderStyles, StyleBorderWidths};
2024    use azul_css::css::CssPropertyValue;
2025    use azul_css::props::basic::color::ColorU;
2026    use azul_css::props::basic::pixel::PixelValue;
2027    use azul_css::props::style::border::{
2028        BorderStyle, StyleBorderBottomColor, StyleBorderBottomStyle, StyleBorderLeftColor,
2029        StyleBorderLeftStyle, StyleBorderRightColor, StyleBorderRightStyle, StyleBorderTopColor,
2030        StyleBorderTopStyle,
2031    };
2032    use azul_css::props::style::{
2033        LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth,
2034        LayoutBorderTopWidth,
2035    };
2036
2037    // FAST PATH: compact cache for normal state
2038    if node_state.is_normal() {
2039        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
2040            let idx = node_id.index();
2041
2042            // Border widths: decode from compact i16 (resolved px × 10).
2043            // Previously this block called the slow convenience getters
2044            // despite being in the "fast path" branch — 2014 slow walks
2045            // per width × 4 widths per cold excel.html layout. Fixed
2046            // 2026-04-17.
2047            let make_width_px = |raw: i16| -> Option<PixelValue> {
2048                if raw == azul_css::compact_cache::I16_AUTO
2049                    || raw == azul_css::compact_cache::I16_INITIAL
2050                    || raw >= azul_css::compact_cache::I16_SENTINEL_THRESHOLD
2051                {
2052                    None
2053                } else {
2054                    Some(PixelValue::px(f32::from(raw) / 10.0))
2055                }
2056            };
2057            let widths = StyleBorderWidths {
2058                top: make_width_px(cc.get_border_top_width_raw(idx))
2059                    .map(|px| CssPropertyValue::Exact(LayoutBorderTopWidth { inner: px })),
2060                right: make_width_px(cc.get_border_right_width_raw(idx))
2061                    .map(|px| CssPropertyValue::Exact(LayoutBorderRightWidth { inner: px })),
2062                bottom: make_width_px(cc.get_border_bottom_width_raw(idx))
2063                    .map(|px| CssPropertyValue::Exact(LayoutBorderBottomWidth { inner: px })),
2064                left: make_width_px(cc.get_border_left_width_raw(idx))
2065                    .map(|px| CssPropertyValue::Exact(LayoutBorderLeftWidth { inner: px })),
2066            };
2067
2068            // Border colors from compact cache
2069            let make_color = |raw: u32| -> Option<ColorU> {
2070                if raw == 0 {
2071                    None
2072                } else {
2073                    Some(ColorU {
2074                        r: ((raw >> 24) & 0xFF) as u8,
2075                        g: ((raw >> 16) & 0xFF) as u8,
2076                        b: ((raw >> 8) & 0xFF) as u8,
2077                        a: (raw & 0xFF) as u8,
2078                    })
2079                }
2080            };
2081
2082            let colors = StyleBorderColors {
2083                top: make_color(cc.get_border_top_color_raw(idx))
2084                    .map(|c| CssPropertyValue::Exact(StyleBorderTopColor { inner: c })),
2085                right: make_color(cc.get_border_right_color_raw(idx))
2086                    .map(|c| CssPropertyValue::Exact(StyleBorderRightColor { inner: c })),
2087                bottom: make_color(cc.get_border_bottom_color_raw(idx))
2088                    .map(|c| CssPropertyValue::Exact(StyleBorderBottomColor { inner: c })),
2089                left: make_color(cc.get_border_left_color_raw(idx))
2090                    .map(|c| CssPropertyValue::Exact(StyleBorderLeftColor { inner: c })),
2091            };
2092
2093            // Border styles from compact cache
2094            let styles = StyleBorderStyles {
2095                top: Some(CssPropertyValue::Exact(StyleBorderTopStyle {
2096                    inner: cc.get_border_top_style(idx),
2097                })),
2098                right: Some(CssPropertyValue::Exact(StyleBorderRightStyle {
2099                    inner: cc.get_border_right_style(idx),
2100                })),
2101                bottom: Some(CssPropertyValue::Exact(StyleBorderBottomStyle {
2102                    inner: cc.get_border_bottom_style(idx),
2103                })),
2104                left: Some(CssPropertyValue::Exact(StyleBorderLeftStyle {
2105                    inner: cc.get_border_left_style(idx),
2106                })),
2107            };
2108
2109            return BorderInfo {
2110                widths,
2111                colors,
2112                styles,
2113            };
2114        }
2115    }
2116
2117    // SLOW PATH: full cascade
2118    let node_data = &styled_dom.node_data.as_container()[node_id];
2119
2120    // Get all border widths
2121    let widths = StyleBorderWidths {
2122        top: styled_dom
2123            .css_property_cache
2124            .ptr
2125            .get_border_top_width(node_data, &node_id, node_state)
2126            .copied(),
2127        right: styled_dom
2128            .css_property_cache
2129            .ptr
2130            .get_border_right_width(node_data, &node_id, node_state)
2131            .copied(),
2132        bottom: styled_dom
2133            .css_property_cache
2134            .ptr
2135            .get_border_bottom_width(node_data, &node_id, node_state)
2136            .copied(),
2137        left: styled_dom
2138            .css_property_cache
2139            .ptr
2140            .get_border_left_width(node_data, &node_id, node_state)
2141            .copied(),
2142    };
2143
2144    // Get all border colors
2145    let colors = StyleBorderColors {
2146        top: styled_dom
2147            .css_property_cache
2148            .ptr
2149            .get_border_top_color(node_data, &node_id, node_state)
2150            .copied(),
2151        right: styled_dom
2152            .css_property_cache
2153            .ptr
2154            .get_border_right_color(node_data, &node_id, node_state)
2155            .copied(),
2156        bottom: styled_dom
2157            .css_property_cache
2158            .ptr
2159            .get_border_bottom_color(node_data, &node_id, node_state)
2160            .copied(),
2161        left: styled_dom
2162            .css_property_cache
2163            .ptr
2164            .get_border_left_color(node_data, &node_id, node_state)
2165            .copied(),
2166    };
2167
2168    // Get all border styles
2169    let styles = StyleBorderStyles {
2170        top: styled_dom
2171            .css_property_cache
2172            .ptr
2173            .get_border_top_style(node_data, &node_id, node_state)
2174            .copied(),
2175        right: styled_dom
2176            .css_property_cache
2177            .ptr
2178            .get_border_right_style(node_data, &node_id, node_state)
2179            .copied(),
2180        bottom: styled_dom
2181            .css_property_cache
2182            .ptr
2183            .get_border_bottom_style(node_data, &node_id, node_state)
2184            .copied(),
2185        left: styled_dom
2186            .css_property_cache
2187            .ptr
2188            .get_border_left_style(node_data, &node_id, node_state)
2189            .copied(),
2190    };
2191
2192    BorderInfo {
2193        widths,
2194        colors,
2195        styles,
2196    }
2197}
2198
2199/// Convert `BorderInfo` to `InlineBorderInfo` for inline elements
2200///
2201/// This resolves the CSS property values to concrete pixel values and colors
2202/// that can be used during text rendering.
2203#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2204fn get_inline_border_info(
2205    styled_dom: &StyledDom,
2206    node_id: NodeId,
2207    node_state: &StyledNodeState,
2208    border_info: &BorderInfo,
2209    viewport: PhysicalSize,
2210) -> Option<crate::text3::cache::InlineBorderInfo> {
2211    use crate::text3::cache::InlineBorderInfo;
2212
2213    // Fetch padding values for inline elements. Viewport units (vw/vh/...) resolve
2214    // against the real viewport instead of being treated as raw pixels.
2215    fn resolve_padding(
2216        mv: MultiValue<PixelValue>,
2217        viewport: PhysicalSize,
2218    ) -> f32 {
2219        match mv {
2220            MultiValue::Exact(pv) => super::calc::resolve_pixel_value_with_viewport(
2221                &pv,
2222                0.0,
2223                DEFAULT_FONT_SIZE,
2224                DEFAULT_FONT_SIZE,
2225                viewport.width,
2226                viewport.height,
2227            ),
2228            _ => 0.0,
2229        }
2230    }
2231
2232    macro_rules! border_width_px {
2233        ($field:expr) => {
2234            $field
2235                .as_ref()
2236                .and_then(|v| v.get_property())
2237                .map(|w| w.inner.number.get())
2238                .unwrap_or(0.0)
2239        };
2240    }
2241
2242    macro_rules! border_color {
2243        ($field:expr) => {
2244            $field
2245                .as_ref()
2246                .and_then(|v| v.get_property())
2247                .map(|c| c.inner)
2248                .unwrap_or(ColorU::BLACK)
2249        };
2250    }
2251
2252    // Extract border-radius (simplified - uses the average of all corners if uniform)
2253    fn get_border_radius_px(
2254        styled_dom: &StyledDom,
2255        node_id: NodeId,
2256        node_state: &StyledNodeState,
2257    ) -> Option<f32> {
2258        let node_data = &styled_dom.node_data.as_container()[node_id];
2259
2260        let top_left = styled_dom
2261            .css_property_cache
2262            .ptr
2263            .get_border_top_left_radius(node_data, &node_id, node_state)
2264            .and_then(|br| br.get_property().copied())
2265            .map(|v| v.inner.number.get());
2266
2267        let top_right = styled_dom
2268            .css_property_cache
2269            .ptr
2270            .get_border_top_right_radius(node_data, &node_id, node_state)
2271            .and_then(|br| br.get_property().copied())
2272            .map(|v| v.inner.number.get());
2273
2274        let bottom_left = styled_dom
2275            .css_property_cache
2276            .ptr
2277            .get_border_bottom_left_radius(node_data, &node_id, node_state)
2278            .and_then(|br| br.get_property().copied())
2279            .map(|v| v.inner.number.get());
2280
2281        let bottom_right = styled_dom
2282            .css_property_cache
2283            .ptr
2284            .get_border_bottom_right_radius(node_data, &node_id, node_state)
2285            .and_then(|br| br.get_property().copied())
2286            .map(|v| v.inner.number.get());
2287
2288        // If any radius is defined, use the maximum (for inline, uniform radius is most common)
2289        let radii: Vec<f32> = [top_left, top_right, bottom_left, bottom_right]
2290            .into_iter()
2291            .flatten()
2292            .collect();
2293
2294        if radii.is_empty() {
2295            None
2296        } else {
2297            Some(radii.into_iter().fold(0.0f32, f32::max))
2298        }
2299    }
2300
2301    let top = border_width_px!(&border_info.widths.top);
2302    let right = border_width_px!(&border_info.widths.right);
2303    let bottom = border_width_px!(&border_info.widths.bottom);
2304    let left = border_width_px!(&border_info.widths.left);
2305
2306    let p_top = resolve_padding(get_css_padding_top(styled_dom, node_id, node_state), viewport);
2307    let p_right = resolve_padding(get_css_padding_right(styled_dom, node_id, node_state), viewport);
2308    let p_bottom = resolve_padding(get_css_padding_bottom(styled_dom, node_id, node_state), viewport);
2309    let p_left = resolve_padding(get_css_padding_left(styled_dom, node_id, node_state), viewport);
2310
2311    // Only return Some if there's actually a border or padding
2312    let has_border = top > 0.0 || right > 0.0 || bottom > 0.0 || left > 0.0;
2313    let has_padding = p_top > 0.0 || p_right > 0.0 || p_bottom > 0.0 || p_left > 0.0;
2314    if !has_border && !has_padding {
2315        return None;
2316    }
2317
2318    // CSS 2.2 §8.6: detect direction for visual-order border/padding rendering in bidi
2319    let is_rtl = matches!(
2320        get_direction_property(styled_dom, node_id, node_state),
2321        MultiValue::Exact(StyleDirection::Rtl)
2322    );
2323
2324    Some(InlineBorderInfo {
2325        top,
2326        right,
2327        bottom,
2328        left,
2329        top_color: border_color!(&border_info.colors.top),
2330        right_color: border_color!(&border_info.colors.right),
2331        bottom_color: border_color!(&border_info.colors.bottom),
2332        left_color: border_color!(&border_info.colors.left),
2333        radius: get_border_radius_px(styled_dom, node_id, node_state),
2334        padding_top: p_top,
2335        padding_right: p_right,
2336        padding_bottom: p_bottom,
2337        padding_left: p_left,
2338        is_first_fragment: true,
2339        is_last_fragment: true,
2340        is_rtl,
2341    })
2342}
2343
2344// Selection and Caret Styling
2345
2346/// Style information for text selection rendering
2347#[derive(Debug, Clone, Copy, Default)]
2348pub struct SelectionStyle {
2349    /// Background color of the selection highlight
2350    pub bg_color: ColorU,
2351    /// Text color when selected (overrides normal text color)
2352    pub text_color: Option<ColorU>,
2353    /// Border radius for selection rectangles
2354    pub radius: f32,
2355}
2356
2357/// Get selection style for a node
2358#[must_use] pub fn get_selection_style(
2359    styled_dom: &StyledDom,
2360    node_id: Option<NodeId>,
2361    system_style: Option<&std::sync::Arc<azul_css::system::SystemStyle>>,
2362) -> SelectionStyle {
2363    let Some(node_id) = node_id else {
2364        return SelectionStyle::default();
2365    };
2366
2367    let node_data = &styled_dom.node_data.as_container()[node_id];
2368    let node_state = &StyledNodeState::default();
2369
2370    // Try to get selection background from CSS, otherwise use system color, otherwise hard-coded default
2371    let default_bg = system_style
2372        .and_then(|ss| ss.colors.selection_background.as_option().copied())
2373        .unwrap_or(ColorU {
2374            r: 51,
2375            g: 153,
2376            b: 255, // Standard blue selection color
2377            a: 128, // Semi-transparent
2378        });
2379
2380    let bg_color = styled_dom
2381        .css_property_cache
2382        .ptr
2383        .get_selection_background_color(node_data, &node_id, node_state)
2384        .and_then(|c| c.get_property().copied())
2385        .map_or(default_bg, |c| c.inner);
2386
2387    // Try to get selection text color from CSS, otherwise use system color
2388    let default_text = system_style.and_then(|ss| ss.colors.selection_text.as_option().copied());
2389
2390    let text_color = styled_dom
2391        .css_property_cache
2392        .ptr
2393        .get_selection_color(node_data, &node_id, node_state)
2394        .and_then(|c| c.get_property().copied())
2395        .map(|c| c.inner)
2396        .or(default_text);
2397
2398    let radius = styled_dom
2399        .css_property_cache
2400        .ptr
2401        .get_selection_radius(node_data, &node_id, node_state)
2402        .and_then(|r| r.get_property().copied())
2403        .map_or(0.0, |r| r.inner.to_pixels_internal(0.0, DEFAULT_EM_SIZE, DEFAULT_EM_SIZE));
2404
2405    SelectionStyle {
2406        bg_color,
2407        text_color,
2408        radius,
2409    }
2410}
2411
2412/// Style information for caret rendering.
2413#[derive(Debug, Clone, Copy)]
2414pub struct CaretStyle {
2415    /// Color of the caret bar
2416    pub color: ColorU,
2417    /// Width of the caret bar in pixels
2418    pub width: f32,
2419    /// Blink animation duration in milliseconds (0 = no blink)
2420    pub animation_duration: u32,
2421}
2422
2423impl Default for CaretStyle {
2424    fn default() -> Self {
2425        Self {
2426            color: ColorU::BLACK,
2427            width: DEFAULT_CARET_WIDTH_PX,
2428            animation_duration: DEFAULT_CARET_BLINK_MS,
2429        }
2430    }
2431}
2432
2433/// Get caret style for a node
2434#[must_use] pub fn get_caret_style(styled_dom: &StyledDom, node_id: Option<NodeId>) -> CaretStyle {
2435    let Some(node_id) = node_id else {
2436        return CaretStyle::default();
2437    };
2438
2439    let node_data = &styled_dom.node_data.as_container()[node_id];
2440    let node_state = &StyledNodeState::default();
2441
2442    let color = styled_dom
2443        .css_property_cache
2444        .ptr
2445        .get_caret_color(node_data, &node_id, node_state)
2446        .and_then(|c| c.get_property().copied())
2447        // CSS `caret-color: auto` (the initial value) resolves to currentColor — the
2448        // element's text color — which by construction contrasts with the background.
2449        // Falling back to BLACK made the caret invisible on dark backgrounds / dark
2450        // system themes (and `color` IS inherited while `caret-color` may not be, so a
2451        // child text node still gets the right colour here).
2452        .map_or_else(|| {
2453            styled_dom
2454                .css_property_cache
2455                .ptr
2456                .get_text_color_or_default(node_data, &node_id, node_state)
2457                .inner
2458        }, |c| c.inner);
2459
2460    let width = styled_dom
2461        .css_property_cache
2462        .ptr
2463        .get_caret_width(node_data, &node_id, node_state)
2464        .and_then(|w| w.get_property().copied())
2465        .map_or(DEFAULT_CARET_WIDTH_PX, |w| w.inner.to_pixels_internal(0.0, DEFAULT_EM_SIZE, DEFAULT_EM_SIZE));
2466
2467    let animation_duration = styled_dom
2468        .css_property_cache
2469        .ptr
2470        .get_caret_animation_duration(node_data, &node_id, node_state)
2471        .and_then(|d| d.get_property().copied())
2472        .map_or(DEFAULT_CARET_BLINK_MS, |d| d.inner.inner);
2473
2474    CaretStyle {
2475        color,
2476        width,
2477        animation_duration,
2478    }
2479}
2480
2481// Scrollbar Information
2482
2483/// Get scrollbar information from a layout node.
2484///
2485/// Scrollbar requirements are computed during the layout phase in two paths:
2486/// - BFC layout: `compute_scrollbar_info()` in cache.rs
2487/// - Taffy layout: set in the measure callback in `taffy_bridge.rs`
2488///
2489/// If neither path set `scrollbar_info`, the node genuinely does not need
2490/// scrollbars. The previous heuristic (>3 children = force overflow) caused
2491/// false-positive scrollbars on normal containers.
2492#[must_use] pub fn get_scrollbar_info_from_layout(node: &LayoutNode) -> ScrollbarRequirements {
2493    node.scrollbar_info.unwrap_or_default()
2494}
2495
2496/// Resolve the **layout-effective** scrollbar width for a node, in pixels.
2497///
2498/// This combines three inputs:
2499/// 1. CSS `scrollbar-width` property on the node (`auto` → 16, `thin` → 8, `none` → 0)
2500/// 2. OS-level `ScrollbarPreferences.visibility` (overlay scrollbars → 0 layout reservation)
2501/// 3. Custom `-azul-scrollbar-style` width override
2502///
2503/// For **overlay** scrollbars (macOS `WhenScrolling`, or equivalent), this returns `0.0`
2504/// because overlay scrollbars are painted on top of content and do not consume layout space.
2505/// The scrollbar is still *rendered*, but no space is reserved during layout.
2506// +spec:overflow:b83014 - overlay scrollbars do not create scrollbar gutters
2507///
2508/// During display-list generation, use `get_scrollbar_style()` instead — that returns
2509/// the full visual style including the *paint* width (which may be non-zero for overlay).
2510pub fn get_layout_scrollbar_width_px<T: ParsedFontTrait>(
2511    ctx: &crate::solver3::LayoutContext<'_, T>,
2512    dom_id: NodeId,
2513    styled_node_state: &StyledNodeState,
2514) -> f32 {
2515    // Resolve the full scrollbar style (includes per-node CSS overrides + system style).
2516    // `reserve_width_px` already accounts for overlay vs legacy:
2517    //   overlay (WhenScrolling) → 0.0
2518    //   legacy (Always)         → visual_width_px
2519    let style = get_scrollbar_style(
2520        ctx.styled_dom,
2521        dom_id,
2522        styled_node_state,
2523        ctx.system_style.as_deref(),
2524    );
2525    style.reserve_width_px
2526}
2527
2528get_css_property!(
2529    get_display_property_internal,
2530    get_display,
2531    LayoutDisplay,
2532    CssPropertyType::Display,
2533    compact = get_display
2534);
2535
2536#[must_use] pub fn get_display_property(
2537    styled_dom: &StyledDom,
2538    dom_id: Option<NodeId>,
2539) -> MultiValue<LayoutDisplay> {
2540    let Some(id) = dom_id else {
2541        return MultiValue::Exact(LayoutDisplay::Inline);
2542    };
2543    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
2544    get_display_property_internal(styled_dom, id, node_state)
2545}
2546
2547/// CSS Display Module Level 3: Blockification of display values.
2548///
2549/// When an element is floated, absolutely positioned, or is the root element,
2550/// its computed display value may be "blockified" per the table in CSS Display 3 §2.7.
2551/// This function returns the blockified display value without mutating any state.
2552#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2553#[must_use] pub const fn blockify_display(raw_display: LayoutDisplay) -> LayoutDisplay {
2554    match raw_display {
2555        // Inline-level display types become their block-level equivalents
2556        LayoutDisplay::Inline => LayoutDisplay::Block,
2557        // Per CSS Display 3 §2.7: inline-block blockifies to block
2558        // (for legacy reasons, loses its flow-root nature)
2559        LayoutDisplay::InlineBlock => LayoutDisplay::Block,
2560        LayoutDisplay::InlineFlex => LayoutDisplay::Flex,
2561        LayoutDisplay::InlineTable => LayoutDisplay::Table,
2562        LayoutDisplay::InlineGrid => LayoutDisplay::Grid,
2563        // CSS 2.2 §9.7: table-internal display values blockify to block
2564        // for absolutely positioned, floated, or root elements
2565        LayoutDisplay::TableRowGroup
2566        | LayoutDisplay::TableColumn
2567        | LayoutDisplay::TableColumnGroup
2568        | LayoutDisplay::TableHeaderGroup
2569        | LayoutDisplay::TableFooterGroup
2570        | LayoutDisplay::TableRow
2571        | LayoutDisplay::TableCell
2572        | LayoutDisplay::TableCaption => LayoutDisplay::Block,
2573        // Already block-level types are unchanged
2574        other => other,
2575    }
2576}
2577
2578// +spec:positioning:c31c24 - blockification is a computed-value change for absolute/float/root elements
2579/// Resolves the computed display value for an element, applying blockification
2580/// rules per CSS Display Module Level 3 §2.7.
2581// +spec:display-property:641ac5 - computed display value applies blockification/inlinification (not "as specified")
2582///
2583/// This centralizes the blockification decision so that all layout phases
2584/// (`layout_tree`, sizing, positioning) use consistent display values.
2585// +spec:floats:52aea6 - computed display blockified for floated/positioned/root elements
2586// +spec:positioning:ce02a1 - out-of-flow boxes (floated or absolutely positioned) get blockified display
2587// four independent layout-state flags drive the blockification decision; bundling them
2588// into a struct would add ceremony without clarifying this pure decision function.
2589#[allow(clippy::fn_params_excessive_bools)]
2590#[must_use] pub fn get_computed_display(
2591    raw_display: LayoutDisplay,
2592    is_absolute_or_fixed: bool,
2593    is_floated: bool,
2594    is_root: bool,
2595    is_flex_grid_child: bool,
2596) -> LayoutDisplay {
2597    if raw_display == LayoutDisplay::None {
2598        return LayoutDisplay::None;
2599    }
2600    // +spec:positioning:69468c - absolute/fixed blockifies the box
2601    if is_absolute_or_fixed || is_floated || is_root || is_flex_grid_child {
2602        blockify_display(raw_display)
2603    } else {
2604        raw_display
2605    }
2606}
2607
2608// +spec:font-metrics:f7affa - vertical-align shorthand: maps CSS vertical-align values to inline layout alignment
2609/// Reads the CSS `vertical-align` property for a DOM node and converts it to
2610/// the text3 `VerticalAlign` enum used during inline layout.
2611// +spec:display-property:24c160 - vertical-align aligns inline-level box within the line
2612#[must_use] pub fn get_vertical_align_for_node(
2613    styled_dom: &StyledDom,
2614    dom_id: NodeId,
2615) -> crate::text3::cache::VerticalAlign {
2616    let node_state = &styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2617    let va = match get_vertical_align_property(styled_dom, dom_id, node_state) {
2618        MultiValue::Exact(v) => v,
2619        _ => StyleVerticalAlign::default(),
2620    };
2621    match va {
2622        StyleVerticalAlign::Baseline => crate::text3::cache::VerticalAlign::Baseline,
2623        StyleVerticalAlign::Top => crate::text3::cache::VerticalAlign::Top,
2624        StyleVerticalAlign::Middle => crate::text3::cache::VerticalAlign::Middle,
2625        StyleVerticalAlign::Bottom => crate::text3::cache::VerticalAlign::Bottom,
2626        StyleVerticalAlign::Sub => crate::text3::cache::VerticalAlign::Sub,
2627        StyleVerticalAlign::Superscript => crate::text3::cache::VerticalAlign::Super,
2628        StyleVerticalAlign::TextTop => crate::text3::cache::VerticalAlign::TextTop,
2629        StyleVerticalAlign::TextBottom => crate::text3::cache::VerticalAlign::TextBottom,
2630        // +spec:line-height:b41ee3 - percentage vertical-align: raise/lower by % of line-height, 0% = baseline
2631        StyleVerticalAlign::Percentage(p) => {
2632            let font_size = get_element_font_size(styled_dom, dom_id, node_state);
2633            // Line-height uses the parser convention (see `get_line_height_value` /
2634            // the LineHeight::Px path): a NEGATIVE normalized value is an absolute
2635            // px length, a positive one is a unitless multiple of font-size. The
2636            // old `normalized() * font_size` scaled (and sign-flipped) absolute
2637            // line-heights — e.g. `line-height: 30px` + `vertical-align: 50%` gave
2638            // -240px instead of +15px.
2639            let line_height = get_line_height_value(styled_dom, dom_id, node_state)
2640                .map_or(font_size * 1.2, |lh| {
2641                    let n = lh.inner.normalized();
2642                    if n < 0.0 { -n } else { n * font_size }
2643                });
2644            crate::text3::cache::VerticalAlign::Offset(p.normalized() * line_height)
2645        }
2646        // §10.8.1: <length> is absolute offset from baseline
2647        StyleVerticalAlign::Length(l) => {
2648            let font_size = get_element_font_size(styled_dom, dom_id, node_state);
2649            // TODO(superplan): viewport units (vw/vh/...) in a vertical-align <length>
2650            // fall back to raw pixels here because this getter has no viewport ctx.
2651            // Threading `viewport_size` requires changing this fn's signature, but one
2652            // of its callers (`sizing.rs::process_layout_children`) lives outside
2653            // Group 2's file ownership — deferred. (The sibling path in
2654            // fc.rs::translate_to_text3_constraints already resolves it via
2655            // `resolve_pixel_value_with_viewport`.)
2656            let px = super::calc::resolve_pixel_value(&l, 0.0, font_size, font_size);
2657            crate::text3::cache::VerticalAlign::Offset(px)
2658        }
2659    }
2660}
2661
2662#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
2663#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2664/// # Panics
2665///
2666/// Panics only on an internal indexing invariant (an in-range `get().unwrap()` over the font-family list).
2667pub fn get_style_properties(
2668    styled_dom: &StyledDom,
2669    dom_id: NodeId,
2670    system_style: Option<&std::sync::Arc<azul_css::system::SystemStyle>>,
2671    viewport_size: PhysicalSize,
2672) -> StyleProperties {
2673    use azul_css::props::basic::{PhysicalSize, PropertyContext, ResolutionContext};
2674
2675    let node_data = &styled_dom.node_data.as_container()[dom_id];
2676    let node_state = &styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2677    let cache = &styled_dom.css_property_cache.ptr;
2678
2679    // Fast path: use compact cache reverse map (works for inherited values on text nodes).
2680    // Slow path: only for non-normal pseudo states (:hover, :focus, etc.)
2681    let font_families = if node_state.is_normal() {
2682        cache
2683            .compact_cache
2684            .as_ref()
2685            .and_then(|cc| {
2686                let fh = cc.tier2b_text[dom_id.index()].font_family_hash;
2687                if fh == 0 {
2688                    return None;
2689                }
2690                cc.font_hash_to_families.get(&fh).cloned()
2691            })
2692            .unwrap_or_else(|| {
2693                StyleFontFamilyVec::from_vec(vec![StyleFontFamily::System("serif".into())])
2694            })
2695    } else {
2696        cache
2697            .get_font_family(node_data, &dom_id, node_state)
2698            .and_then(|v| v.get_property().cloned())
2699            .unwrap_or_else(|| {
2700                StyleFontFamilyVec::from_vec(vec![StyleFontFamily::System("serif".into())])
2701            })
2702    };
2703
2704    // Get parent's font-size for proper em resolution in font-size property.
2705    // FAST PATH: `get_parent_font_size` goes through `get_element_font_size`
2706    // which hits the memoised `resolved_font_sizes_px` Vec (O(1) array index).
2707    // The old code here walked the full CSS cascade for every call — 1485
2708    // slow walks per cold excel.html layout. Replaced 2026-04-17.
2709    let parent_font_size = get_parent_font_size(styled_dom, dom_id, node_state);
2710
2711    let root_font_size = get_root_font_size(styled_dom, node_state);
2712
2713    // Create resolution context for font-size (em refers to parent)
2714    let font_size_context = ResolutionContext {
2715        element_font_size: DEFAULT_FONT_SIZE, /* Not used for font-size property */
2716        parent_font_size,
2717        root_font_size,
2718        containing_block_size: PhysicalSize::new(0.0, 0.0),
2719        element_size: None,
2720        viewport_size,
2721    };
2722
2723    // Get font-size: either from this node's CSS, or inherit from parent
2724    // font-size is an inheritable property, so if the node doesn't have
2725    // an explicit font-size, it should inherit from the parent (not default to 16px)
2726    let font_size = {
2727        // FAST PATH: compact cache for normal state.
2728        // Sentinel/inherit/initial → inherit from parent directly (which is
2729        // what the slow cascade walk would fall back to via `.unwrap_or(parent_font_size)`
2730        // anyway — avoid the walk entirely).
2731        let mut fast_font_size: Option<f32> = None;
2732        let mut compact_said_inherit = false;
2733        if node_state.is_normal() {
2734            if let Some(ref cc) = cache.compact_cache {
2735                let raw = cc.get_font_size_raw(dom_id.index());
2736                if raw == azul_css::compact_cache::U32_SENTINEL
2737                    || raw == azul_css::compact_cache::U32_INHERIT
2738                    || raw == azul_css::compact_cache::U32_INITIAL
2739                {
2740                    compact_said_inherit = true;
2741                } else if let Some(pv) = azul_css::compact_cache::decode_pixel_value_u32(raw) {
2742                    fast_font_size = Some(
2743                        pv.resolve_with_context(&font_size_context, PropertyContext::FontSize),
2744                    );
2745                }
2746            }
2747        }
2748        fast_font_size.unwrap_or_else(|| {
2749            if compact_said_inherit {
2750                parent_font_size
2751            } else {
2752                cache
2753                    .get_font_size(node_data, &dom_id, node_state)
2754                    .and_then(|v| v.get_property().copied())
2755                    .map_or(parent_font_size, |v| {
2756                        v.inner
2757                            .resolve_with_context(&font_size_context, PropertyContext::FontSize)
2758                    })
2759            }
2760        })
2761    };
2762
2763    let color_from_cache = {
2764        // FAST PATH: compact cache for text color
2765        let mut fast_color = None;
2766        if node_state.is_normal() {
2767            if let Some(ref cc) = cache.compact_cache {
2768                let raw = cc.get_text_color_raw(dom_id.index());
2769                if raw != 0 {
2770                    // Decode 0xRRGGBBAA → ColorU
2771                    fast_color = Some(ColorU {
2772                        r: (raw >> 24) as u8,
2773                        g: (raw >> 16) as u8,
2774                        b: (raw >> 8) as u8,
2775                        a: raw as u8,
2776                    });
2777                }
2778            }
2779        }
2780        fast_color.or_else(|| {
2781            cache
2782                .get_text_color(node_data, &dom_id, node_state)
2783                .and_then(|v| v.get_property().copied())
2784                .map(|v| v.inner)
2785        })
2786    };
2787
2788    // CSS initial value for 'color' is UA-dependent but conventionally black.
2789    // Do NOT use system_style.colors.text here — that reflects the OS theme
2790    // (e.g. white on macOS dark mode) and would produce white text on
2791    // explicitly light-colored backgrounds.  System colors (CanvasText etc.)
2792    // should only be used when referenced through CSS system-color keywords.
2793    let color = color_from_cache.unwrap_or(ColorU::BLACK);
2794
2795    // +spec:font-metrics:e480da - line-height: normal/number/length/percentage resolution
2796    let line_height = {
2797        // FAST PATH: compact cache for line-height (stored as normalized × 1000 i16).
2798        // When the cache returns Some → we have a resolved value.
2799        // When it returns None AND node_state is normal → the compact cache stored
2800        // the sentinel, which means "line-height: normal" (the spec default).
2801        // Previously we fell through to a cascade walk here — but the default
2802        // has already been authoritatively decided by the builder, so the walk
2803        // would only ever re-confirm "no value, normal". 1600 pure-waste walks
2804        // per cold excel.html layout. Short-circuit to Normal directly.
2805        let mut fast_lh = None;
2806        let mut sentinel_normal = false;
2807        if node_state.is_normal() {
2808            if let Some(ref cc) = cache.compact_cache {
2809                if let Some(normalized) = cc.get_line_height(dom_id.index()) {
2810                    // The compact cache stores `normalized() * 1000` as i16, and
2811                    // get_line_height decodes it as `stored / 10`, i.e. this
2812                    // `normalized` value equals `PercentageValue::normalized() * 100`.
2813                    // Per the parser convention a NEGATIVE normalized() means an
2814                    // absolute pixel line-height (CSS line-height cannot be negative),
2815                    // so decode with the same rule fc.rs / the slow path use.
2816                    let n = normalized / 100.0;
2817                    fast_lh = Some(crate::text3::cache::LineHeight::Px(
2818                        if n < 0.0 { -n } else { n * font_size },
2819                    ));
2820                } else {
2821                    // Sentinel in compact cache = "normal" (CSS default).
2822                    sentinel_normal = true;
2823                }
2824            }
2825        }
2826        if sentinel_normal {
2827            crate::text3::cache::LineHeight::Normal
2828        } else {
2829            fast_lh.unwrap_or_else(|| {
2830                cache
2831                    .get_line_height(node_data, &dom_id, node_state)
2832                    .and_then(|v| v.get_property().copied())
2833                    .map_or(crate::text3::cache::LineHeight::Normal, |v| {
2834                        // Negative normalized() = absolute px value (parser convention
2835                        // for "50px" etc.); positive = multiple of font-size.
2836                        let n = v.inner.normalized();
2837                        crate::text3::cache::LineHeight::Px(if n < 0.0 { -n } else { n * font_size })
2838                    })
2839            })
2840        }
2841    };
2842
2843    // Get background color for INLINE elements only
2844    // CSS background-color is NOT inherited. For block-level elements (th, td, div, etc.),
2845    // the background is painted separately by paint_element_background() in display_list.rs.
2846    // Only inline elements (span, em, strong, a, etc.) should have their background color
2847    // propagated through StyleProperties for the text rendering pipeline.
2848    //
2849    // FAST PATH: use the compact-cache-backed display getter. The old code
2850    // here called `cache.get_display(..)` (the 3-arg convenience method on
2851    // CssPropertyCache) which routes through `get_property_slow` — 1485 slow
2852    // walks per cold excel.html layout. Replaced 2026-04-17.
2853    let display = match get_display_property(styled_dom, Some(dom_id)) {
2854        MultiValue::Exact(v) => v,
2855        _ => LayoutDisplay::Inline,
2856    };
2857
2858    // For inline and inline-block elements, get background content and border info
2859    // Block elements have their backgrounds/borders painted by display_list.rs
2860    let (background_color, background_content, border) =
2861        if matches!(display, LayoutDisplay::Inline | LayoutDisplay::InlineBlock) {
2862            let bg = get_background_color(styled_dom, dom_id, node_state);
2863            let bg_color = if bg.a > 0 { Some(bg) } else { None };
2864
2865            // Get full background contents (including gradients)
2866            let bg_contents = get_background_contents(styled_dom, dom_id, node_state);
2867
2868            // Get border info for inline elements
2869            let border_info = get_border_info(styled_dom, dom_id, node_state);
2870            let inline_border =
2871                get_inline_border_info(styled_dom, dom_id, node_state, &border_info, viewport_size);
2872
2873            (bg_color, bg_contents, inline_border)
2874        } else {
2875            // Block-level elements: background/border is painted by display_list.rs
2876            // via push_backgrounds_and_border() in DisplayListBuilder
2877            (None, Vec::new(), None)
2878        };
2879
2880    // Query font-weight from CSS cache
2881    let font_weight = match get_font_weight_property(styled_dom, dom_id, node_state) {
2882        MultiValue::Exact(v) => v,
2883        _ => StyleFontWeight::Normal,
2884    };
2885
2886    // Query font-style from CSS cache
2887    let font_style = match get_font_style_property(styled_dom, dom_id, node_state) {
2888        MultiValue::Exact(v) => v,
2889        _ => StyleFontStyle::Normal,
2890    };
2891
2892    // Convert StyleFontWeight/StyleFontStyle to fontconfig types
2893    let fc_weight = super::fc::convert_font_weight(font_weight);
2894    let fc_style = super::fc::convert_font_style(font_style);
2895
2896    // Check if any font family is a FontRef - if so, use FontStack::Ref
2897    // This allows embedded fonts (like Material Icons) to bypass fontconfig
2898    let font_stack = {
2899        let font_ref = (0..font_families.len()).find_map(|i| match font_families.get(i).unwrap() {
2900            StyleFontFamily::Ref(r) => Some(r.clone()),
2901            _ => None,
2902        });
2903
2904        font_ref.map_or_else(
2905            || {
2906                // Get platform for resolving system font types. None on the paged /
2907                // PDF layout path (system_style is hard-coded None there);
2908                // build_font_selector_stack then resolves via Platform::current() so
2909                // the names stay in lock-step with the font-loading pass.
2910                let platform = system_style.map(|ss| &ss.platform);
2911                FontStack::Stack(build_font_selector_stack(
2912                    &font_families,
2913                    platform,
2914                    fc_weight,
2915                    fc_style,
2916                ))
2917            },
2918            FontStack::Ref,
2919        )
2920    };
2921
2922    // Get letter-spacing from CSS
2923    let letter_spacing = {
2924        // FAST PATH: compact cache for letter-spacing (i16 resolved px × 10)
2925        let mut fast_ls = None;
2926        if node_state.is_normal() {
2927            if let Some(ref cc) = cache.compact_cache {
2928                if let Some(px_val) = cc.get_letter_spacing(dom_id.index()) {
2929                    fast_ls = Some(crate::text3::cache::Spacing::PxF(px_val));
2930                }
2931            }
2932        }
2933        fast_ls.unwrap_or_else(|| {
2934            cache
2935                .get_letter_spacing(node_data, &dom_id, node_state)
2936                .and_then(|v| v.get_property().copied())
2937                .map(|v| {
2938                    let px_value = v
2939                        .inner
2940                        .resolve_with_context(&font_size_context, PropertyContext::FontSize);
2941                    crate::text3::cache::Spacing::PxF(px_value)
2942                })
2943                .unwrap_or_default()
2944        })
2945    };
2946
2947    // Get word-spacing from CSS
2948    let word_spacing = {
2949        // FAST PATH: compact cache for word-spacing (i16 resolved px × 10)
2950        let mut fast_ws = None;
2951        if node_state.is_normal() {
2952            if let Some(ref cc) = cache.compact_cache {
2953                if let Some(px_val) = cc.get_word_spacing(dom_id.index()) {
2954                    fast_ws = Some(crate::text3::cache::Spacing::PxF(px_val));
2955                }
2956            }
2957        }
2958        fast_ws.unwrap_or_else(|| {
2959            cache
2960                .get_word_spacing(node_data, &dom_id, node_state)
2961                .and_then(|v| v.get_property().copied())
2962                .map(|v| {
2963                    let px_value = v
2964                        .inner
2965                        .resolve_with_context(&font_size_context, PropertyContext::FontSize);
2966                    crate::text3::cache::Spacing::PxF(px_value)
2967                })
2968                .unwrap_or_default()
2969        })
2970    };
2971
2972    // Get text-decoration from CSS.
2973    //
2974    // Fast path: the compact cache keeps a `has_text_decoration` flag. If
2975    // unset (the overwhelmingly common case — plain body text has no
2976    // decoration set), skip the 4-pseudo-state × 6-layer cascade walk
2977    // entirely. Only nodes that actually set text-decoration pay the walk.
2978    let text_decoration = {
2979        let mut skip_walk = false;
2980        if node_state.is_normal() {
2981            if let Some(ref cc) = cache.compact_cache {
2982                if !cc.has_text_decoration(dom_id.index()) {
2983                    skip_walk = true;
2984                }
2985            }
2986        }
2987        if skip_walk {
2988            crate::text3::cache::TextDecoration::default()
2989        } else {
2990            cache
2991                .get_text_decoration(node_data, &dom_id, node_state)
2992                .and_then(|v| v.get_property().copied())
2993                .map(crate::text3::cache::TextDecoration::from_css)
2994                .unwrap_or_default()
2995        }
2996    };
2997
2998    // Get tab-size (tab-size) from CSS.
2999    //
3000    // tab-size defaults to `I16_SENTINEL` in the compact cache builder
3001    // (spec default is "8", meaning 8 space widths). The old fallback
3002    // called `cache.get_tab_size(..)` (slow cascade) for every node whose
3003    // raw was SENTINEL — virtually every node, because almost nothing sets
3004    // tab-size. That was 1485 pure-waste slow walks per cold layout.
3005    //
3006    // New behaviour: sentinel → 8.0 directly. Only walk the cascade when
3007    // the compact cache is genuinely unavailable (no `compact_cache`) or
3008    // the node is in a pseudo-state that bypassed the cache.
3009    let tab_size = {
3010        let mut fast_tab = None;
3011        if node_state.is_normal() {
3012            if let Some(ref cc) = cache.compact_cache {
3013                let raw = cc.get_tab_size_raw(dom_id.index());
3014                if raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
3015                    fast_tab = Some(f32::from(raw) / 10.0);
3016                } else {
3017                    // Sentinel / Inherit / Initial → spec default is 8.
3018                    fast_tab = Some(8.0);
3019                }
3020            }
3021        }
3022        fast_tab.unwrap_or_else(|| {
3023            cache
3024                .get_tab_size(node_data, &dom_id, node_state)
3025                .and_then(|v| v.get_property().copied())
3026                .map_or(DEFAULT_TAB_SIZE, |v| v.inner.number.get())
3027        })
3028    };
3029
3030    // Get text-transform from CSS (uppercase / lowercase / capitalize / full-width).
3031    // Applied to the run text before shaping (fc.rs::apply_text_transform) so that
3032    // intrinsic widths reflect the transformed glyphs.
3033    let text_transform = cache
3034        .get_text_transform(node_data, &dom_id, node_state)
3035        .and_then(|v| v.get_property().copied())
3036        .map(|t| {
3037            use azul_css::props::style::text::StyleTextTransform as Css;
3038            use crate::text3::cache::TextTransform as T3;
3039            match t {
3040                Css::None => T3::None,
3041                Css::Uppercase => T3::Uppercase,
3042                Css::Lowercase => T3::Lowercase,
3043                Css::Capitalize => T3::Capitalize,
3044                Css::FullWidth => T3::FullWidth,
3045            }
3046        })
3047        .unwrap_or_default();
3048
3049    StyleProperties {
3050        font_stack,
3051        font_size_px: font_size,
3052        color,
3053        background_color,
3054        background_content,
3055        border,
3056        line_height,
3057        letter_spacing,
3058        word_spacing,
3059        text_decoration,
3060        tab_size,
3061        text_transform,
3062        // Per-run vertical-align so a `<span style="vertical-align:super/sub">` shifts
3063        // its text clusters (get_item_vertical_align reads this). Without it every text
3064        // cluster fell back to the IFC root's alignment (baseline), so sub/super/length
3065        // vertical-align on inline spans had no effect.
3066        vertical_align: get_vertical_align_for_node(styled_dom, dom_id),
3067        // These still use defaults - could be extended in future:
3068        // font_features, font_variations, writing_mode,
3069        // text_orientation, text_combine_upright, font_variant_*
3070        ..Default::default()
3071    }
3072}
3073
3074#[must_use] pub fn get_list_style_type(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> StyleListStyleType {
3075    let Some(id) = dom_id else {
3076        return StyleListStyleType::default();
3077    };
3078    let node_data = &styled_dom.node_data.as_container()[id];
3079    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3080    styled_dom
3081        .css_property_cache
3082        .ptr
3083        .get_list_style_type(node_data, &id, node_state)
3084        .and_then(|v| v.get_property().copied())
3085        .unwrap_or_default()
3086}
3087
3088#[must_use] pub fn get_list_style_position(
3089    styled_dom: &StyledDom,
3090    dom_id: Option<NodeId>,
3091) -> StyleListStylePosition {
3092    let Some(id) = dom_id else {
3093        return StyleListStylePosition::default();
3094    };
3095    let node_data = &styled_dom.node_data.as_container()[id];
3096    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3097    styled_dom
3098        .css_property_cache
3099        .ptr
3100        .get_list_style_position(node_data, &id, node_state)
3101        .and_then(|v| v.get_property().copied())
3102        .unwrap_or_default()
3103}
3104
3105// New: Taffy Bridge Getters - Box Model Properties with Ua Css Fallback
3106
3107use azul_css::props::layout::{
3108    LayoutInsetBottom, LayoutLeft, LayoutMarginBottom, LayoutMarginLeft, LayoutMarginRight,
3109    LayoutMarginTop, LayoutMaxHeight, LayoutMaxWidth, LayoutMinHeight, LayoutMinWidth,
3110    LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop, LayoutRight,
3111    LayoutTop,
3112};
3113
3114/// Get inset (position) properties - returns MultiValue<PixelValue>
3115get_css_property_pixel!(
3116    get_css_left,
3117    get_left,
3118    CssPropertyType::Left,
3119    compact_i16 = get_left
3120);
3121get_css_property_pixel!(
3122    get_css_right,
3123    get_right,
3124    CssPropertyType::Right,
3125    compact_i16 = get_right
3126);
3127get_css_property_pixel!(
3128    get_css_top,
3129    get_top,
3130    CssPropertyType::Top,
3131    compact_i16 = get_top
3132);
3133get_css_property_pixel!(
3134    get_css_bottom,
3135    get_bottom,
3136    CssPropertyType::Bottom,
3137    compact_i16 = get_bottom
3138);
3139
3140/// Get margin properties - returns MultiValue<PixelValue>
3141get_css_property_pixel!(
3142    get_css_margin_left,
3143    get_margin_left,
3144    CssPropertyType::MarginLeft,
3145    compact_i16 = get_margin_left_raw
3146);
3147get_css_property_pixel!(
3148    get_css_margin_right,
3149    get_margin_right,
3150    CssPropertyType::MarginRight,
3151    compact_i16 = get_margin_right_raw
3152);
3153get_css_property_pixel!(
3154    get_css_margin_top,
3155    get_margin_top,
3156    CssPropertyType::MarginTop,
3157    compact_i16 = get_margin_top_raw
3158);
3159get_css_property_pixel!(
3160    get_css_margin_bottom,
3161    get_margin_bottom,
3162    CssPropertyType::MarginBottom,
3163    compact_i16 = get_margin_bottom_raw
3164);
3165
3166/// Get padding properties - returns MultiValue<PixelValue>
3167get_css_property_pixel!(
3168    get_css_padding_left,
3169    get_padding_left,
3170    CssPropertyType::PaddingLeft,
3171    compact_i16 = get_padding_left_raw
3172);
3173get_css_property_pixel!(
3174    get_css_padding_right,
3175    get_padding_right,
3176    CssPropertyType::PaddingRight,
3177    compact_i16 = get_padding_right_raw
3178);
3179get_css_property_pixel!(
3180    get_css_padding_top,
3181    get_padding_top,
3182    CssPropertyType::PaddingTop,
3183    compact_i16 = get_padding_top_raw
3184);
3185get_css_property_pixel!(
3186    get_css_padding_bottom,
3187    get_padding_bottom,
3188    CssPropertyType::PaddingBottom,
3189    compact_i16 = get_padding_bottom_raw
3190);
3191
3192/// Get min/max size properties
3193get_css_property!(
3194    get_css_min_width,
3195    get_min_width,
3196    LayoutMinWidth,
3197    CssPropertyType::MinWidth,
3198    compact_u32_struct = get_min_width_raw
3199);
3200
3201get_css_property!(
3202    get_css_min_height,
3203    get_min_height,
3204    LayoutMinHeight,
3205    CssPropertyType::MinHeight,
3206    compact_u32_struct = get_min_height_raw
3207);
3208
3209get_css_property!(
3210    get_css_max_width,
3211    get_max_width,
3212    LayoutMaxWidth,
3213    CssPropertyType::MaxWidth,
3214    compact_u32_struct = get_max_width_raw
3215);
3216
3217get_css_property!(
3218    get_css_max_height,
3219    get_max_height,
3220    LayoutMaxHeight,
3221    CssPropertyType::MaxHeight,
3222    compact_u32_struct = get_max_height_raw
3223);
3224
3225/// Get border width properties (no UA CSS fallback needed, defaults to 0)
3226get_css_property_pixel!(
3227    get_css_border_left_width,
3228    get_border_left_width,
3229    CssPropertyType::BorderLeftWidth,
3230    compact_i16 = get_border_left_width_raw
3231);
3232get_css_property_pixel!(
3233    get_css_border_right_width,
3234    get_border_right_width,
3235    CssPropertyType::BorderRightWidth,
3236    compact_i16 = get_border_right_width_raw
3237);
3238get_css_property_pixel!(
3239    get_css_border_top_width,
3240    get_border_top_width,
3241    CssPropertyType::BorderTopWidth,
3242    compact_i16 = get_border_top_width_raw
3243);
3244get_css_property_pixel!(
3245    get_css_border_bottom_width,
3246    get_border_bottom_width,
3247    CssPropertyType::BorderBottomWidth,
3248    compact_i16 = get_border_bottom_width_raw
3249);
3250
3251// Fragmentation (page breaking) properties
3252
3253/// Get break-before property for paged media
3254#[must_use] pub fn get_break_before(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> PageBreak {
3255    let Some(id) = dom_id else {
3256        return PageBreak::Auto;
3257    };
3258    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3259    // Negative fast path: break-* is almost never declared.
3260    if node_state.is_normal() {
3261        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
3262            if !cc.has_break(id.index()) {
3263                return PageBreak::Auto;
3264            }
3265        }
3266    }
3267    let node_data = &styled_dom.node_data.as_container()[id];
3268    styled_dom
3269        .css_property_cache
3270        .ptr
3271        .get_break_before(node_data, &id, node_state)
3272        .and_then(|v| v.get_property().copied())
3273        .unwrap_or(PageBreak::Auto)
3274}
3275
3276/// Get break-after property for paged media
3277#[must_use] pub fn get_break_after(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> PageBreak {
3278    let Some(id) = dom_id else {
3279        return PageBreak::Auto;
3280    };
3281    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3282    if node_state.is_normal() {
3283        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
3284            if !cc.has_break(id.index()) {
3285                return PageBreak::Auto;
3286            }
3287        }
3288    }
3289    let node_data = &styled_dom.node_data.as_container()[id];
3290    styled_dom
3291        .css_property_cache
3292        .ptr
3293        .get_break_after(node_data, &id, node_state)
3294        .and_then(|v| v.get_property().copied())
3295        .unwrap_or(PageBreak::Auto)
3296}
3297
3298/// Check if a `PageBreak` value forces a page break (always, page, left, right, etc.)
3299#[must_use] pub const fn is_forced_page_break(page_break: PageBreak) -> bool {
3300    matches!(
3301        page_break,
3302        PageBreak::Always
3303            | PageBreak::Page
3304            | PageBreak::Left
3305            | PageBreak::Right
3306            | PageBreak::Recto
3307            | PageBreak::Verso
3308            | PageBreak::All
3309    )
3310}
3311
3312/// Get break-inside property for paged media
3313#[must_use] pub fn get_break_inside(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> BreakInside {
3314    let Some(id) = dom_id else {
3315        return BreakInside::Auto;
3316    };
3317    let node_data = &styled_dom.node_data.as_container()[id];
3318    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3319    styled_dom
3320        .css_property_cache
3321        .ptr
3322        .get_break_inside(node_data, &id, node_state)
3323        .and_then(|v| v.get_property().copied())
3324        .unwrap_or(BreakInside::Auto)
3325}
3326
3327/// Get orphans property (minimum lines at bottom of page)
3328#[must_use] pub fn get_orphans(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> u32 {
3329    let Some(id) = dom_id else {
3330        return 2; // Default value
3331    };
3332    let node_data = &styled_dom.node_data.as_container()[id];
3333    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3334    styled_dom
3335        .css_property_cache
3336        .ptr
3337        .get_orphans(node_data, &id, node_state)
3338        .and_then(|v| v.get_property().copied())
3339        .map_or(2, |o| o.inner)
3340}
3341
3342/// Get widows property (minimum lines at top of page)
3343#[must_use] pub fn get_widows(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> u32 {
3344    let Some(id) = dom_id else {
3345        return 2; // Default value
3346    };
3347    let node_data = &styled_dom.node_data.as_container()[id];
3348    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3349    styled_dom
3350        .css_property_cache
3351        .ptr
3352        .get_widows(node_data, &id, node_state)
3353        .and_then(|v| v.get_property().copied())
3354        .map_or(2, |w| w.inner)
3355}
3356
3357/// Get box-decoration-break property
3358#[must_use] pub fn get_box_decoration_break(
3359    styled_dom: &StyledDom,
3360    dom_id: Option<NodeId>,
3361) -> BoxDecorationBreak {
3362    let Some(id) = dom_id else {
3363        return BoxDecorationBreak::Slice;
3364    };
3365    let node_data = &styled_dom.node_data.as_container()[id];
3366    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3367    styled_dom
3368        .css_property_cache
3369        .ptr
3370        .get_box_decoration_break(node_data, &id, node_state)
3371        .and_then(|v| v.get_property().copied())
3372        .unwrap_or(BoxDecorationBreak::Slice)
3373}
3374
3375// Helper functions for break properties
3376
3377/// Check if a `PageBreak` value is avoid
3378#[must_use] pub const fn is_avoid_page_break(page_break: &PageBreak) -> bool {
3379    matches!(page_break, PageBreak::Avoid | PageBreak::AvoidPage)
3380}
3381
3382/// Check if a `BreakInside` value prevents breaks
3383#[must_use] pub const fn is_avoid_break_inside(break_inside: &BreakInside) -> bool {
3384    matches!(
3385        break_inside,
3386        BreakInside::Avoid | BreakInside::AvoidPage | BreakInside::AvoidColumn
3387    )
3388}
3389
3390// Font Chain Resolution - Pre-Layout Font Loading
3391
3392use std::collections::HashMap;
3393
3394use rust_fontconfig::{
3395    FcFontCache, FcWeight, FontFallbackChain, PatternMatch, UnicodeRange,
3396    DEFAULT_UNICODE_FALLBACK_SCRIPTS,
3397};
3398
3399use crate::text3::cache::{FontChainKey, FontChainKeyOrRef, FontSelector, FontStack, FontStyle};
3400
3401/// Build a fontconfig `FontSelector` stack from a list of CSS font families.
3402///
3403/// Shared by `get_style_properties` and `collect_font_stacks_from_styled_dom`.
3404/// `Ref` families are skipped (callers handle embedded fonts via `FontStack::Ref`),
3405/// `SystemType` families expand to the platform's fallback chain, and the generic
3406/// `sans-serif`/`serif`/`monospace` fallbacks are appended if not already present.
3407///
3408/// When `platform` is `None` (e.g. the paged / PDF layout path that hard-codes
3409/// `system_style = None`), system fonts resolve via `Platform::current()` so the
3410/// names stay in lock-step with the font-loading pass (which always uses
3411/// `Platform::current()`); diverging to a bare "sans-serif" would not match the
3412/// names the loader registered → zero glyphs → text collapses to 0 width.
3413// The `platform` binding uses a pre-declared `let current;` so the else branch can
3414// extend the lifetime of a freshly-computed Platform and hand back a reference to it;
3415// map_or_else cannot express this (the closure would return a dangling local ref).
3416#[allow(clippy::option_if_let_else)]
3417fn build_font_selector_stack(
3418    font_families: &StyleFontFamilyVec,
3419    platform: Option<&azul_css::system::Platform>,
3420    fc_weight: FcWeight,
3421    fc_style: FontStyle,
3422) -> Vec<FontSelector> {
3423    let mut stack = Vec::with_capacity(font_families.len() + 3);
3424
3425    for i in 0..font_families.len() {
3426        let family = font_families.get(i).unwrap();
3427        if matches!(family, StyleFontFamily::Ref(_)) {
3428            continue;
3429        }
3430        if let StyleFontFamily::SystemType(system_type) = family {
3431            let current;
3432            let platform = if let Some(p) = platform { p } else {
3433                current = azul_css::system::Platform::current();
3434                &current
3435            };
3436            let font_names = system_type.get_fallback_chain(platform);
3437            let system_weight = if system_type.is_bold() {
3438                FcWeight::Bold
3439            } else {
3440                fc_weight
3441            };
3442            let system_style = if system_type.is_italic() {
3443                FontStyle::Italic
3444            } else {
3445                fc_style
3446            };
3447            for font_name in font_names {
3448                stack.push(FontSelector {
3449                    family: font_name.to_string(),
3450                    weight: system_weight,
3451                    style: system_style,
3452                    unicode_ranges: Vec::new(),
3453                });
3454            }
3455        } else {
3456            stack.push(FontSelector {
3457                // as_query_string, NOT as_string: FontManager queries fontconfig with the
3458                // RAW name. as_string() CSS-quotes whitespace names ("Times New Roman" ->
3459                // "\"Times New Roman\""), which corrupts the query for every multi-word font.
3460                family: family.as_query_string(),
3461                weight: fc_weight,
3462                style: fc_style,
3463                unicode_ranges: Vec::new(),
3464            });
3465        }
3466    }
3467
3468    for fallback in &["sans-serif", "serif", "monospace"] {
3469        if !stack
3470            .iter()
3471            .any(|f| f.family.eq_ignore_ascii_case(fallback))
3472        {
3473            stack.push(FontSelector {
3474                family: (*fallback).to_string(),
3475                weight: FcWeight::Normal,
3476                style: FontStyle::Normal,
3477                unicode_ranges: Vec::new(),
3478            });
3479        }
3480    }
3481
3482    stack
3483}
3484
3485/// Result of collecting font stacks from a `StyledDom`
3486/// Contains all unique font stacks and the mapping from `StyleFontFamiliesHash` to `FontChainKey`
3487#[derive(Debug, Clone)]
3488pub struct CollectedFontStacks {
3489    /// All unique font stacks found in the document (system/file fonts via fontconfig)
3490    pub font_stacks: Vec<Vec<FontSelector>>,
3491    /// Map from the font stack hash to the index in `font_stacks`
3492    pub hash_to_index: HashMap<u64, usize>,
3493    /// Direct `FontRefs` that bypass fontconfig (e.g., embedded icon fonts)
3494    /// These are keyed by their pointer address for uniqueness
3495    pub font_refs: HashMap<usize, azul_css::props::basic::font::FontRef>,
3496}
3497
3498/// Resolved font chains ready for use in layout
3499/// This is the result of resolving font stacks against `FcFontCache`
3500#[derive(Debug, Clone, Default)]
3501pub struct ResolvedFontChains {
3502    /// Map from `FontChainKeyOrRef` to the resolved `FontFallbackChain`
3503    /// For `FontChainKeyOrRef::Ref` variants, the `FontFallbackChain` contains
3504    /// a single-font chain that covers the entire Unicode range.
3505    pub chains: HashMap<FontChainKeyOrRef, FontFallbackChain>,
3506    /// CSS families that were REQUESTED but could not be matched to any
3507    /// font (not on disk, not registered in memory).
3508    ///
3509    /// This used to be swallowed: the resolver moved on to the next family
3510    /// and, if the whole stack failed, `ensure_chains_nonempty` quietly
3511    /// attached an arbitrary system font. Every unmatched family therefore
3512    /// collapsed onto the SAME `FontId`, text rendered in a font nobody
3513    /// asked for, and no test could tell. A failed family match is now a
3514    /// first-class output: it is recorded here and logged once
3515    /// (see `report_unresolved_families`).
3516    pub unresolved_families: std::collections::BTreeSet<String>,
3517    /// Chains that matched NOTHING at all and only render because
3518    /// `ensure_chains_nonempty` attached a last-resort font. These are
3519    /// rendering in a font the stylesheet never asked for.
3520    pub last_resort_chains: usize,
3521}
3522
3523impl ResolvedFontChains {
3524    /// Get a font chain by its key
3525    #[must_use] pub fn get(&self, key: &FontChainKeyOrRef) -> Option<&FontFallbackChain> {
3526        self.chains.get(key)
3527    }
3528
3529    /// Get a font chain by `FontChainKey` (for system fonts)
3530    #[must_use] pub fn get_by_chain_key(&self, key: &FontChainKey) -> Option<&FontFallbackChain> {
3531        self.chains.get(&FontChainKeyOrRef::Chain(key.clone()))
3532    }
3533
3534    /// Get a font chain for a font stack (via fontconfig)
3535    #[must_use] pub fn get_for_font_stack(&self, font_stack: &[FontSelector]) -> Option<&FontFallbackChain> {
3536        let key = FontChainKeyOrRef::Chain(FontChainKey::from_selectors(font_stack));
3537        self.chains.get(&key)
3538    }
3539
3540    /// Get a font chain for a `FontRef` pointer
3541    #[must_use] pub fn get_for_font_ref(&self, ptr: usize) -> Option<&FontFallbackChain> {
3542        self.chains.get(&FontChainKeyOrRef::Ref(ptr))
3543    }
3544
3545    /// Consume self and return the inner `HashMap` with `FontChainKeyOrRef` keys
3546    ///
3547    /// This is useful when you need access to both Chain and Ref variants.
3548    #[must_use] pub fn into_inner(self) -> HashMap<FontChainKeyOrRef, FontFallbackChain> {
3549        self.chains
3550    }
3551
3552    /// Consume self and return only the fontconfig-resolved chains
3553    ///
3554    /// This filters out `FontRef` entries and returns only the chains
3555    /// resolved via fontconfig. This is what `FontManager` expects.
3556    #[must_use] pub fn into_fontconfig_chains(self) -> HashMap<FontChainKey, FontFallbackChain> {
3557        // (2026-06-10: reverted to HashMap end-to-end — the empty-hashbrown RawIter hang behind
3558        // the 2026-06-05 BTreeMap migration was the un-mirrored EMPTY_GROUP static, fixed
3559        // transpiler-side in symbol_table.rs::compute_hashbrown_empty_group_ranges.)
3560        let mut out: HashMap<FontChainKey, FontFallbackChain> = HashMap::new();
3561        if self.chains.is_empty() {
3562            return out;
3563        }
3564        for (key, chain) in self.chains {
3565            if let FontChainKeyOrRef::Chain(chain_key) = key {
3566                out.insert(chain_key, chain);
3567            }
3568        }
3569        out
3570    }
3571
3572    /// Get the number of resolved chains
3573    #[must_use] pub fn len(&self) -> usize {
3574        self.chains.len()
3575    }
3576
3577    /// Check if there are no resolved chains
3578    #[must_use] pub fn is_empty(&self) -> bool {
3579        self.chains.is_empty()
3580    }
3581
3582    /// Get the number of direct `FontRefs`
3583    #[must_use] pub fn font_refs_len(&self) -> usize {
3584        self.chains.keys().filter(|k| k.is_ref()).count()
3585    }
3586}
3587
3588/// Collect all unique font stacks from a `StyledDom`
3589///
3590/// This is a pure function that iterates over all nodes in the DOM and
3591/// extracts the font-family property from each node that has text content.
3592///
3593/// # Arguments
3594/// * `styled_dom` - The styled DOM to extract font stacks from
3595/// * `platform` - The current platform for resolving system font types
3596///
3597/// # Returns
3598/// A `CollectedFontStacks` containing all unique font stacks and a hash-to-index mapping
3599#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
3600#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3601#[must_use] pub fn collect_font_stacks_from_styled_dom(
3602    styled_dom: &StyledDom,
3603    platform: &azul_css::system::Platform,
3604) -> CollectedFontStacks {
3605    use azul_css::compact_cache::{
3606        FONT_STYLE_MASK, FONT_STYLE_SHIFT, FONT_WEIGHT_MASK, FONT_WEIGHT_SHIFT,
3607    };
3608
3609    let mut font_stacks = Vec::new();
3610    let mut hash_to_index: HashMap<u64, usize> = HashMap::new();
3611    let mut font_refs: HashMap<usize, azul_css::props::basic::font::FontRef> = HashMap::new();
3612
3613    let node_data = styled_dom.node_data.as_container();
3614    let cache = &styled_dom.css_property_cache.ptr;
3615    let Some(compact) = cache.compact_cache.as_ref() else {
3616        return CollectedFontStacks {
3617            font_stacks,
3618            hash_to_index,
3619            font_refs,
3620        };
3621    };
3622
3623    // Phase 1: Scan compact cache arrays (just u64 reads) to find unique
3624    // (font_family_hash, weight, style) tuples. Record one representative
3625    // node index per unique tuple for the expensive CSS lookup in Phase 2.
3626    // Key: (font_family_hash, weight_encoded, style_encoded) → representative node index
3627    // (2026-06-10: reverted to HashMap — the historic g81/g47 empty-hashbrown mis-lift was the
3628    // un-mirrored EMPTY_GROUP static, fixed transpiler-side in symbol_table.rs::
3629    // compute_hashbrown_empty_group_ranges. std HashMap lifts correctly now; RandomState seeds
3630    // via the transpiler's HashmapRandomKeys fixed-seed body.)
3631    let mut unique_font_keys: HashMap<(u64, u8, u8), usize> = HashMap::new();
3632    let node_count = node_data.internal.len();
3633
3634    // WEB-LIFT: probe node_type bytes (NodeType #[repr(C,u8)], Text=177 per AzDom_createText).
3635    // 0x406D0..DC = n1.node_type bytes[0,1,2,4]; 0x406E0 = n0.node_type byte[0] (body disc).
3636    if node_count > 1 {
3637        let p1 = (&raw const node_data.internal[1].node_type).cast::<u8>();
3638        let p0 = (&raw const node_data.internal[0].node_type).cast::<u8>();
3639        unsafe {
3640            crate::az_mark(0x606D0_u32, u32::from(core::ptr::read(p1)));
3641            crate::az_mark(0x606D4_u32, u32::from(core::ptr::read(p1.add(1))));
3642            crate::az_mark(0x606D8_u32, u32::from(core::ptr::read(p1.add(2))));
3643            crate::az_mark(0x606DC_u32, u32::from(core::ptr::read(p1.add(4))));
3644            crate::az_mark(0x606E0_u32, u32::from(core::ptr::read(p0)));
3645        }
3646    }
3647    for i in 0..node_count {
3648        // Only text nodes need fonts. WEB-LIFT: the lifted `matches!(node_type,
3649        // NodeType::Text(_))` MIS-LIFTS (compares against a mis-lifted discriminant
3650        // constant) — text nodes never match → no font stack → no chain → text h=0.
3651        // NodeType is #[repr(C,u8)] so the discriminant is the u8 at offset 0; Text=177
3652        // (per AzDom_createText: `mov w8,#0xb1; strb w8,[x19]`). Compare the raw
3653        // discriminant to the literal 177 (a source literal lifts correctly).
3654        let nt_disc = unsafe {
3655            core::ptr::read((&raw const node_data.internal[i].node_type).cast::<u8>())
3656        };
3657        let is_text = nt_disc == 177
3658            || matches!(node_data.internal[i].node_type, NodeType::Text(_));
3659        if !is_text {
3660            continue;
3661        }
3662        let fh = compact.tier2b_text[i].font_family_hash;
3663        let t1 = compact.tier1_enums[i];
3664        let weight_bits = ((t1 >> FONT_WEIGHT_SHIFT) & FONT_WEIGHT_MASK) as u8;
3665        let style_bits = ((t1 >> FONT_STYLE_SHIFT) & FONT_STYLE_MASK) as u8;
3666        let key = (fh, weight_bits, style_bits);
3667        unique_font_keys.entry(key).or_insert(i);
3668    }
3669
3670    // WASM-ONLY PROBE (REVERT): why 0 chains? 0x406C0=tag(5E5E0003), C4=node_count,
3671    // C8=unique_font_keys.len() (#text nodes matched in Phase 1). If C8=0 → the lifted
3672    // `matches!(node_type, NodeType::Text(_))` FAILS for the text node (node_type mis-lift)
3673    // → no font stack → no chain → text h=0. C is the count of NodeType::Text via a raw
3674    // discriminant byte read (node_type tag), to compare against the matches! result.
3675    {
3676        let mut raw_text = 0u32;
3677        for i in 0..node_count {
3678            // NodeType is repr(C,u8)-ish; read the leading discriminant byte directly.
3679            let nt_ptr = (&raw const node_data.internal[i].node_type).cast::<u8>();
3680            let disc = unsafe { core::ptr::read_volatile(nt_ptr) };
3681            // Text is one specific discriminant; count whatever the body node ISN'T.
3682            if disc != unsafe { core::ptr::read_volatile((&raw const node_data.internal[0].node_type).cast::<u8>()) } {
3683                raw_text += 1;
3684            }
3685        }
3686        unsafe {
3687            crate::az_mark(0x606C0_u32, (0x5E5E_0003_u32));
3688            crate::az_mark(0x606C4_u32, (node_count as u32));
3689            crate::az_mark(0x606C8_u32, (unique_font_keys.len() as u32));
3690            crate::az_mark(0x606CC_u32, (raw_text));
3691        }
3692    }
3693
3694    // Phase 2: For each unique tuple, do ONE expensive CSS lookup on the
3695    // representative node to get the actual font-family names.
3696    let styled_nodes = styled_dom.styled_nodes.as_container();
3697
3698    for (&(fh, _wb, _sb), &repr_idx) in &unique_font_keys {
3699        let Some(dom_id) = NodeId::from_usize(repr_idx) else {
3700            continue;
3701        };
3702        let node_state = &styled_nodes[dom_id].styled_node_state;
3703
3704        // Use reverse map from compact cache: hash → actual font families.
3705        // This works for ALL nodes including text nodes that inherit font-family
3706        // via compact cache (where get_property_slow would return None).
3707        let font_families = compact
3708            .font_hash_to_families
3709            .get(&fh)
3710            .cloned()
3711            .unwrap_or_else(|| {
3712                StyleFontFamilyVec::from_vec(vec![StyleFontFamily::System("serif".into())])
3713            });
3714
3715        // Check for embedded FontRef
3716        if let Some(StyleFontFamily::Ref(font_ref)) = font_families.get(0) {
3717            let ptr = font_ref.parsed as usize;
3718            font_refs.entry(ptr).or_insert_with(|| font_ref.clone());
3719            continue;
3720        }
3721
3722        let font_weight = match get_font_weight_property(styled_dom, dom_id, node_state) {
3723            MultiValue::Exact(v) => v,
3724            _ => StyleFontWeight::Normal,
3725        };
3726        let font_style = match get_font_style_property(styled_dom, dom_id, node_state) {
3727            MultiValue::Exact(v) => v,
3728            _ => StyleFontStyle::Normal,
3729        };
3730
3731        let fc_weight = super::fc::convert_font_weight(font_weight);
3732        let fc_style = super::fc::convert_font_style(font_style);
3733
3734        let font_stack =
3735            build_font_selector_stack(&font_families, Some(platform), fc_weight, fc_style);
3736
3737        if font_stack.is_empty() {
3738            continue;
3739        }
3740
3741        let key = FontChainKey::from_selectors(&font_stack);
3742        let hash = {
3743            use std::hash::{Hash, Hasher};
3744            let mut hasher = std::collections::hash_map::DefaultHasher::new();
3745            key.hash(&mut hasher);
3746            hasher.finish()
3747        };
3748
3749        hash_to_index.entry(hash).or_insert_with(|| {
3750            let idx = font_stacks.len();
3751            font_stacks.push(font_stack);
3752            idx
3753        });
3754    }
3755
3756    CollectedFontStacks {
3757        font_stacks,
3758        hash_to_index,
3759        font_refs,
3760    }
3761}
3762
3763/// Resolve all font chains for the collected font stacks
3764///
3765/// This is a pure function that takes the collected font stacks and resolves
3766/// them against the `FcFontCache` to produce `FontFallbackChains`.
3767///
3768/// # Arguments
3769/// * `collected` - The collected font stacks from `collect_font_stacks_from_styled_dom`
3770/// * `fc_cache` - The fontconfig cache to resolve fonts against
3771///
3772/// # Returns
3773/// A `ResolvedFontChains` containing all resolved font chains
3774/// Walk every text node in `styled_dom` and collect the set of
3775/// non-ASCII codepoints actually present in the document.
3776///
3777/// Used by [`prune_chain_to_used_chars`] to drop CSS-fallback fonts
3778/// from a resolved chain when the *first* match in a `css_fallbacks`
3779/// group already covers everything the page asks for. ASCII (`< 0x80`)
3780/// is universally covered by every Latin font we'd resolve, so we
3781/// skip it here to keep the set small. Unicode characters in the
3782/// returned set are deduped + sorted via `BTreeSet`.
3783///
3784/// Cost: O(total text length). Cheap relative to layout itself.
3785#[must_use] pub fn collect_used_codepoints(styled_dom: &StyledDom) -> std::collections::BTreeSet<u32> {
3786    let mut out = std::collections::BTreeSet::new();
3787    let node_data = styled_dom.node_data.as_container();
3788    for node in node_data.internal {
3789        let NodeType::Text(s) = &node.node_type else {
3790            continue;
3791        };
3792        for c in s.as_str().chars() {
3793            let cp = c as u32;
3794            if cp >= 0x80 {
3795                out.insert(cp);
3796            }
3797        }
3798    }
3799    out
3800}
3801
3802/// Like [`collect_used_codepoints`] but keeps ASCII.
3803///
3804/// The fast-probe
3805/// path (`FcFontRegistry::request_fonts_fast`) *does* need ASCII:
3806/// "the font has to cover every codepoint I will render" is only
3807/// true if we tell it every codepoint, and "Segoe UI" not being
3808/// installed on macOS means even ASCII has to fall through to a
3809/// system default.
3810///
3811/// `collect_used_codepoints` strips ASCII because its caller
3812/// (`prune_chain_to_used_chars`) runs *after* resolution to trim an
3813/// already-resolved chain and every Latin-covering font passes ASCII
3814/// trivially. That assumption doesn't hold during probing.
3815#[must_use] pub fn collect_used_codepoints_all(styled_dom: &StyledDom) -> std::collections::BTreeSet<char> {
3816    let mut out = std::collections::BTreeSet::new();
3817    let node_data = styled_dom.node_data.as_container();
3818    for node in node_data.internal {
3819        let NodeType::Text(s) = &node.node_type else {
3820            continue;
3821        };
3822        for c in s.as_str().chars() {
3823            out.insert(c);
3824        }
3825    }
3826    out
3827}
3828
3829/// Trim a [`FontFallbackChain`] down to the minimum set of `FontMatch`
3830/// entries needed to cover `used_chars` (typically from
3831/// [`collect_used_codepoints`]).
3832///
3833/// For each `css_fallbacks` group, walk matches in the resolver's
3834/// preferred order and keep them until every codepoint in
3835/// `used_chars` is covered (per the OS/2 unicode-range bits cached
3836/// in `FontMatch.unicode_ranges`). Always keeps at least the first
3837/// match per group so a font listed in CSS doesn't disappear.
3838///
3839/// `unicode_fallbacks` is filtered to only include fonts whose
3840/// ranges intersect `used_chars` — Phase-6's
3841/// [`scripts_present_in_styled_dom`] already scopes the *script
3842/// blocks* but a single block (e.g. CJK Unified, U+4E00..U+9FFF)
3843/// can have hundreds of matching system fonts; this prunes them
3844/// down to the few that actually cover the codepoints used.
3845///
3846/// On excel.html (~ASCII-only) this drops the per-chain
3847/// `css_fallbacks` from 5 → 1 in each group, eliminating ~20 of
3848/// the 26 fonts that would otherwise be parsed by
3849/// `load_fonts_from_disk`.
3850pub fn prune_chain_to_used_chars(
3851    chain: &mut FontFallbackChain,
3852    used_chars: &std::collections::BTreeSet<u32>,
3853) {
3854    fn fm_covers(fm: &rust_fontconfig::FontMatch, cp: u32) -> bool {
3855        fm.unicode_ranges
3856            .iter()
3857            .any(|r| cp >= r.start && cp <= r.end)
3858    }
3859
3860    for group in &mut chain.css_fallbacks {
3861        if group.fonts.is_empty() {
3862            continue;
3863        }
3864        // Track which non-ASCII chars still need coverage as we walk
3865        // matches in order. We always keep at least the first match.
3866        let mut needed: Vec<u32> = used_chars.iter().copied().collect();
3867        needed.retain(|&cp| !fm_covers(&group.fonts[0], cp));
3868        let mut keep = 1;
3869        for fm in group.fonts.iter().skip(1) {
3870            if needed.is_empty() {
3871                break;
3872            }
3873            keep += 1;
3874            needed.retain(|&cp| !fm_covers(fm, cp));
3875        }
3876        group.fonts.truncate(keep);
3877    }
3878
3879    chain
3880        .unicode_fallbacks
3881        .retain(|fm| used_chars.iter().any(|&cp| fm_covers(fm, cp)));
3882}
3883
3884/// Scan text-node content in `styled_dom` and return the subset of
3885/// [`rust_fontconfig::DEFAULT_UNICODE_FALLBACK_SCRIPTS`] whose code-point
3886/// ranges actually appear in any text.
3887///
3888/// Short-circuits once all seven
3889/// ranges have been seen.
3890///
3891/// Callers pass the result as `scripts_hint` to
3892/// [`resolve_font_chains`] / [`collect_and_resolve_font_chains_with_registration`];
3893/// `rust_fontconfig::FcFontCache::resolve_font_chain_with_scripts` then
3894/// only pulls in Unicode-fallback fonts for scripts the document
3895/// actually uses. An ASCII-only page returns an empty vector, which
3896/// avoids dragging Arial Unicode MS, CJK fonts, etc. into the
3897/// resolved chain and therefore into the eager-load step.
3898#[must_use] pub fn scripts_present_in_styled_dom(styled_dom: &StyledDom) -> Vec<UnicodeRange> {
3899    let scripts = DEFAULT_UNICODE_FALLBACK_SCRIPTS;
3900    let mut seen = vec![false; scripts.len()];
3901    let mut hits = 0usize;
3902    let node_data = styled_dom.node_data.as_container();
3903    'outer: for node in node_data.internal {
3904        let text: &str = match &node.node_type {
3905            NodeType::Text(s) => s.as_str(),
3906            _ => continue,
3907        };
3908        for c in text.chars() {
3909            let cp = c as u32;
3910            // Cheap reject: everything below the first fallback-script
3911            // range (Cyrillic starts at U+0400) is covered by the CSS
3912            // fallbacks' own glyphs — no reason to probe.
3913            if cp < 0x0400 {
3914                continue;
3915            }
3916            for (idx, r) in scripts.iter().enumerate() {
3917                if !seen[idx] && cp >= r.start && cp <= r.end {
3918                    seen[idx] = true;
3919                    hits += 1;
3920                    if hits == scripts.len() {
3921                        break 'outer;
3922                    }
3923                    break;
3924                }
3925            }
3926        }
3927    }
3928    scripts
3929        .iter()
3930        .enumerate()
3931        .filter_map(|(i, r)| if seen[i] { Some(*r) } else { None })
3932        .collect()
3933}
3934
3935/// Resolve font chains for a collected set of stacks.
3936///
3937/// `scripts_hint`:
3938/// - `None` keeps the original "all 7 default scripts" behaviour
3939///   (Cyrillic / Arabic / Devanagari / Hiragana / Katakana / CJK /
3940///   Hangul) — equivalent to passing
3941///   `Some(rust_fontconfig::DEFAULT_UNICODE_FALLBACK_SCRIPTS)`.
3942/// - `Some(&[])` attaches *no* Unicode fallbacks, suitable for
3943///   ASCII-only documents. Combined with `prune_chain_to_used_chars`
3944///   this is what eliminates Arial Unicode MS / CJK / Arabic font
3945///   loads on Latin-only pages.
3946/// - `Some(ranges)` attaches fallbacks only for the listed scripts.
3947///   Production callers compute this via
3948///   [`scripts_present_in_styled_dom`].
3949#[must_use] pub fn resolve_font_chains(
3950    collected: &CollectedFontStacks,
3951    fc_cache: &FcFontCache,
3952    scripts_hint: Option<&[UnicodeRange]>,
3953) -> ResolvedFontChains {
3954    resolve_font_chains_with_registry(collected, fc_cache, None, scripts_hint, &HashMap::new())
3955}
3956
3957/// Split a CSS font stack into (a) the groups that resolve to an in-memory
3958/// font registered BY FAMILY NAME and (b) the families that still have to
3959/// be looked up on disk.
3960///
3961/// In-memory fonts are the bundled/embedder/test fonts registered with
3962/// [`crate::text3::cache::FontManager::register_named_font`]. They must be
3963/// matched here, in azul, because the fast disk resolver
3964/// (`FcFontRegistry::request_fonts_fast`) only walks file paths and cannot
3965/// see them at all. Matching is on the NORMALIZED family name, which also
3966/// makes `font-family: "Foo Bar"` (the CSS parser keeps the quotes) match
3967/// the registered `Foo Bar`.
3968fn split_memory_matches(
3969    font_families: &[String],
3970    memory_families: &HashMap<String, Vec<crate::text3::cache::MemoryFace>>,
3971    weight: FcWeight,
3972    italic: bool,
3973    oblique: bool,
3974) -> (Vec<rust_fontconfig::CssFallbackGroup>, Vec<String>) {
3975    let mut groups = Vec::new();
3976    let mut disk = Vec::new();
3977    for family in font_families {
3978        let norm = rust_fontconfig::utils::normalize_family_name(family);
3979        if let Some(face) = memory_families
3980            .get(&norm)
3981            .and_then(|faces| pick_memory_face(faces, weight, italic, oblique))
3982        {
3983            groups.push(rust_fontconfig::CssFallbackGroup {
3984                css_name: family.clone(),
3985                fonts: vec![face.font_match.clone()],
3986            });
3987        } else {
3988            disk.push(family.clone());
3989        }
3990    }
3991    (groups, disk)
3992}
3993
3994/// Choose the registered in-memory face that best matches a CSS
3995/// `(weight, italic/oblique)` query. Prefers faces whose slant matches the
3996/// request, then the nearest weight via [`rust_fontconfig::FcWeight::find_best_match`]
3997/// (the CSS weight-fallback order). A variable face whose `wght` axis spans the
3998/// requested weight is treated as an exact match.
3999///
4000/// This is what makes `font-weight: bold` actually select a registered bold
4001/// face: several faces (regular, bold, oblique…) share one family name, and this
4002/// picks among them instead of taking whichever registered last.
4003fn pick_memory_face(
4004    faces: &[crate::text3::cache::MemoryFace],
4005    weight: FcWeight,
4006    italic: bool,
4007    oblique: bool,
4008) -> Option<&crate::text3::cache::MemoryFace> {
4009    if faces.is_empty() {
4010        return None;
4011    }
4012    let want_slanted = italic || oblique;
4013    // Prefer faces matching the requested slant; fall back to all faces so a
4014    // family with only an upright face still resolves for `font-style: italic`.
4015    let slant_pool: Vec<&crate::text3::cache::MemoryFace> = faces
4016        .iter()
4017        .filter(|f| (f.italic || f.oblique) == want_slanted)
4018        .collect();
4019    let pool: Vec<&crate::text3::cache::MemoryFace> = if slant_pool.is_empty() {
4020        faces.iter().collect()
4021    } else {
4022        slant_pool
4023    };
4024    // A variable face whose wght axis covers the request satisfies it exactly.
4025    let req = f32::from(weight as u16);
4026    if let Some(vf) = pool
4027        .iter()
4028        .copied()
4029        .find(|f| f.weight_axis.is_some_and(|(min, max)| req >= min && req <= max))
4030    {
4031        return Some(vf);
4032    }
4033    // Otherwise pick the nearest static weight (CSS fallback order).
4034    let avail: Vec<FcWeight> = pool.iter().map(|f| f.weight).collect();
4035    let best = weight.find_best_match(&avail).unwrap_or(weight);
4036    pool.iter()
4037        .copied()
4038        .find(|f| f.weight == best)
4039        .or_else(|| pool.first().copied())
4040}
4041
4042/// Registry-aware variant of [`resolve_font_chains`].
4043///
4044/// When `registry`
4045/// is `Some`, each chain resolution goes through
4046/// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
4047/// which priority-bumps the builder for families not yet in the
4048/// snapshot and waits for them — the "scout-on-demand" path that
4049/// avoids the eager common-stack pre-parse.
4050///
4051/// When `registry` is `None`, falls back to
4052/// [`rust_fontconfig::FcFontCache::resolve_font_chain_with_scripts`]
4053/// against the passed-in snapshot, which is what
4054/// [`resolve_font_chains`] does and what every code path did before
4055/// Phase 3.
4056#[allow(clippy::implicit_hasher)] // internal; memory_families always uses the default hasher
4057#[must_use] pub fn resolve_font_chains_with_registry(
4058    collected: &CollectedFontStacks,
4059    fc_cache: &FcFontCache,
4060    registry: Option<&rust_fontconfig::registry::FcFontRegistry>,
4061    scripts_hint: Option<&[UnicodeRange]>,
4062    memory_families: &HashMap<String, Vec<crate::text3::cache::MemoryFace>>,
4063) -> ResolvedFontChains {
4064    let mut chains = HashMap::new();
4065    let mut unresolved: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4066
4067    // Resolve system/file font stacks via fontconfig
4068    for font_stack in &collected.font_stacks {
4069        if font_stack.is_empty() {
4070            continue;
4071        }
4072
4073        // Build font families list
4074        // (2026-06-10) Build the key through the ONE canonical constructor
4075        // (FontChainKey::from_selectors — first-wins dedup + the same empty-stack
4076        // fallback) so the stored key always matches the shaping-time lookup key.
4077        let canonical_key = FontChainKey::from_selectors(font_stack);
4078        let font_families = canonical_key.font_families.clone();
4079
4080        let weight = font_stack[0].weight;
4081        let is_italic = font_stack[0].style == FontStyle::Italic;
4082        let is_oblique = font_stack[0].style == FontStyle::Oblique;
4083
4084        let cache_key = FontChainKeyOrRef::Chain(FontChainKey {
4085            font_families: font_families.clone(),
4086            weight,
4087            italic: is_italic,
4088            oblique: is_oblique,
4089        });
4090
4091        // Skip if already resolved
4092        if chains.contains_key(&cache_key) {
4093            continue;
4094        }
4095
4096        // Resolve the font chain
4097        // IMPORTANT: Use False (not DontCare) when style is Normal.
4098        // DontCare means "accept italic too" which can match italic fonts.
4099        // False means "must NOT be italic" which correctly prefers Normal.
4100        let italic = if is_italic {
4101            PatternMatch::True
4102        } else {
4103            PatternMatch::False
4104        };
4105        let oblique = if is_oblique {
4106            PatternMatch::True
4107        } else {
4108            PatternMatch::False
4109        };
4110
4111        // MEMORY FONTS FIRST (see `split_memory_matches`): a family
4112        // registered by name into the cache's in-memory table wins over
4113        // anything on disk, exactly as CSS says.
4114        let (mem_groups, disk_families) =
4115            split_memory_matches(&font_families, memory_families, weight, is_italic, is_oblique);
4116
4117        // Registry-aware resolve: scout-on-demand path when available.
4118        // See `resolve_font_chains_with_registry` doc for rationale.
4119        let mut chain = if disk_families.is_empty() {
4120            FontFallbackChain {
4121                css_fallbacks: Vec::new(),
4122                unicode_fallbacks: Vec::new(),
4123                original_stack: font_families.clone(),
4124            }
4125        } else {
4126            registry.map_or_else(
4127                || {
4128                    let mut trace = Vec::new();
4129                    fc_cache.resolve_font_chain_with_scripts(
4130                        &disk_families,
4131                        weight,
4132                        italic,
4133                        oblique,
4134                        scripts_hint,
4135                        &mut trace,
4136                    )
4137                },
4138                |reg| {
4139                    reg.request_and_resolve_with_scripts(
4140                        &disk_families,
4141                        weight,
4142                        italic,
4143                        oblique,
4144                        scripts_hint,
4145                    )
4146                },
4147            )
4148        };
4149        if !mem_groups.is_empty() {
4150            let mut merged = mem_groups;
4151            merged.append(&mut chain.css_fallbacks);
4152            chain.css_fallbacks = merged;
4153        }
4154
4155        // A family that produced no group matched NOTHING — record it (see
4156        // `ResolvedFontChains::unresolved_families`).
4157        for family in &font_families {
4158            let matched = chain
4159                .css_fallbacks
4160                .iter()
4161                .any(|g| g.css_name.eq_ignore_ascii_case(family) && !g.fonts.is_empty());
4162            if !matched && !is_generic_family(family) {
4163                unresolved.insert(family.clone());
4164            }
4165        }
4166
4167        // WEB-LIFT last resort (in azul-layout, NOT rust-fontconfig — so the fragile
4168        // `with_memory_fonts` isn't re-codegen'd into a trapping shape): the lifted
4169        // resolve_font_chain query path can return an EMPTY chain even when a fallback
4170        // font IS registered (generic→OS-name expansion + token/unicode query is
4171        // lift-fragile). If the chain has no fonts, append the first registered font so
4172        // load_missing_for_chains / resolve_char find it and text shapes (not measure 0).
4173        let total_fonts = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
4174            + chain.unicode_fallbacks.len();
4175        if total_fonts == 0 {
4176            if let Some((_pattern, id)) = fc_cache.list().first() {
4177                // Vec::new() ranges (not pattern.unicode_ranges.clone()) — the Vec-clone
4178                // mis-lifts on the web backend and empty == "no range restriction" here.
4179                chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
4180                    id: *id,
4181                    unicode_ranges: Vec::new(),
4182                    fallbacks: Vec::new(),
4183                });
4184            }
4185        }
4186
4187        chains.insert(cache_key, chain);
4188    }
4189
4190    // NOTE: FontRefs bypass fontconfig entirely — the shaping code checks
4191    // style.font_stack for FontStack::Ref and uses the font data directly.
4192    // No entries are inserted into `chains` for them.
4193
4194    let out = ResolvedFontChains {
4195        chains,
4196        unresolved_families: unresolved,
4197        last_resort_chains: 0,
4198    };
4199    report_unresolved_families(&out);
4200    out
4201}
4202
4203/// WEB-LIFT last resort, applied LIFT-SAFELY. The lifted backend drops in-place
4204/// mutations made through `BTreeMap::values_mut()` (the pushed `FontMatch` is silently
4205/// lost — same class as the cascade `From` mapped-collect drop) and mis-lifts the
4206/// `pattern.unicode_ranges.clone()` Vec-clone. So this rebuilds the map with an explicit
4207/// `for` loop (no `values_mut`) and appends a coverage-agnostic fallback using
4208/// `Vec::new()` ranges (the convention already used across this file for "no specific
4209/// range restriction"). Applied on BOTH resolver return paths — the fast path otherwise
4210/// returns chains with no last resort at all, so when the lifted
4211/// `query_matches`/`find_unicode_fallbacks` yields an empty chain even though a fallback
4212/// font IS registered, the text node measures 0 → `LayoutError::InvalidTree`.
4213fn ensure_chains_nonempty(resolved: &mut ResolvedFontChains, fc_cache: &FcFontCache) {
4214    let fallback_id = match fc_cache.list().first() {
4215        Some((_pattern, id)) => *id,
4216        None => return,
4217    };
4218    let keys: Vec<FontChainKeyOrRef> = resolved.chains.keys().cloned().collect();
4219    let mut rebuilt: HashMap<FontChainKeyOrRef, FontFallbackChain> =
4220        HashMap::new();
4221    let mut last_resort = 0usize;
4222    for key in keys {
4223        if let Some(mut chain) = resolved.chains.remove(&key) {
4224            let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
4225                + chain.unicode_fallbacks.len();
4226            if total == 0 {
4227                // NOT SILENT: this chain matched nothing at all. Every such
4228                // chain gets the SAME arbitrary `fallback_id` — which is
4229                // precisely how N distinct font-families collapsed onto one
4230                // FontId. It still renders (a missing font must never be a
4231                // blank screen), but it is now counted and reported.
4232                last_resort += 1;
4233                if let FontChainKeyOrRef::Chain(k) = &key {
4234                    eprintln!(
4235                        "[azul][font] LAST-RESORT fallback for font stack {:?}: nothing in \
4236                         the stack matched, rendering in an arbitrary system font.",
4237                        k.font_families
4238                    );
4239                }
4240                chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
4241                    id: fallback_id,
4242                    unicode_ranges: Vec::new(),
4243                    fallbacks: Vec::new(),
4244                });
4245            }
4246            rebuilt.insert(key, chain);
4247        }
4248    }
4249    resolved.chains = rebuilt;
4250    resolved.last_resort_chains = last_resort;
4251}
4252
4253/// Convenience function that collects and resolves font chains in one call
4254///
4255/// # Arguments
4256/// * `styled_dom` - The styled DOM to extract font stacks from
4257/// * `fc_cache` - The fontconfig cache to resolve fonts against
4258/// * `platform` - The current platform for resolving system font types
4259///
4260/// # Returns
4261/// A `ResolvedFontChains` containing all resolved font chains
4262/// Collect font stacks, register embedded fonts, and resolve font chains
4263/// in a single pass over the DOM nodes. Replaces the old two-pass approach
4264/// where `register_embedded_fonts_from_styled_dom` + `collect_and_resolve_font_chains`
4265/// each independently scanned all nodes.
4266pub fn collect_and_resolve_font_chains_with_registration<T: ParsedFontTrait>(
4267    styled_dom: &StyledDom,
4268    fc_cache: &FcFontCache,
4269    font_manager: &crate::text3::cache::FontManager<T>,
4270    platform: &azul_css::system::Platform,
4271) -> ResolvedFontChains {
4272    let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
4273
4274    // Register embedded FontRefs (from the same scan, no second pass)
4275    for font_ref in collected.font_refs.values() {
4276        font_manager.register_embedded_font(font_ref);
4277    }
4278
4279    // Fast path (rust-fontconfig 4.2): when a registry is attached
4280    // we can resolve each stack by cmap-probing candidate files
4281    // against the codepoints the DOM actually uses, instead of
4282    // letting `request_fonts` eagerly parse every CSS fallback
4283    // via allsorts. On excel.html this drops `font_chain_resolve`
4284    // from ~128 ms / 49 faces parsed to ~5 ms / 3 faces.
4285    //
4286    // Falls back to the legacy pattern-map resolver when:
4287    //   - no registry is present (offline `FcFontCache` callers)
4288    //   - the DOM has no text codepoints (no shaping to be done,
4289    //     so cmap-probing has nothing to check and partial-cover
4290    //     entries would be surprising)
4291    if let Some(registry) = font_manager.registry.as_deref() {
4292        let used_chars = collect_used_codepoints_all(styled_dom);
4293        if !used_chars.is_empty() {
4294            let mut fast = resolve_font_chains_fast(
4295                &collected,
4296                registry,
4297                &used_chars,
4298                &font_manager.memory_families,
4299            );
4300            ensure_chains_nonempty(&mut fast, fc_cache);
4301            return fast;
4302        }
4303    }
4304
4305    // Legacy path: pattern-map resolver. Only reached when the
4306    // caller passes an `FcFontCache` without a live registry
4307    // (ad-hoc tests, the PDF writer, etc.).
4308    let scripts = scripts_present_in_styled_dom(styled_dom);
4309    let mut resolved = resolve_font_chains_with_registry(
4310        &collected,
4311        fc_cache,
4312        font_manager.registry.as_deref(),
4313        Some(&scripts),
4314        &font_manager.memory_families,
4315    );
4316
4317    let used_chars = collect_used_codepoints(styled_dom);
4318    for chain in resolved.chains.values_mut() {
4319        prune_chain_to_used_chars(chain, &used_chars);
4320    }
4321    // WEB-LIFT last resort (AFTER the prune, so it survives — the prune drops fonts
4322    // whose parsed cmap doesn't cover used_chars, which removes the registered fallback
4323    // before it's parsed): if a chain ended up empty, append the first registered font
4324    // so load_missing_for_chains finds it and text shapes instead of measuring 0.
4325    // LIFT-SAFE rebuild (see ensure_chains_nonempty) — the old `values_mut()` +
4326    // `unicode_ranges.clone()` version dropped the push in the lifted backend, leaving
4327    // the chain empty (web-text-min n1 measured 0xfffffffe/auto → InvalidTree).
4328    ensure_chains_nonempty(&mut resolved, fc_cache);
4329    resolved
4330}
4331
4332/// Fast-path resolver backed by [`FcFontRegistry::request_fonts_fast`].
4333///
4334/// Iterates `collected.font_stacks`, shapes each `(stack, weight,
4335/// italic, oblique)` combo into a cmap-probe request carrying the
4336/// DOM's codepoint set, calls the registry, and returns a
4337/// `ResolvedFontChains` keyed by `FontChainKeyOrRef::Chain` — the
4338/// same keys the legacy resolver emits, so downstream code
4339/// (`load_missing_for_chains`, `shape_with_font_fallback`) is
4340/// unchanged.
4341#[allow(clippy::implicit_hasher)] // internal; memory_families always uses the default hasher
4342pub fn resolve_font_chains_fast(
4343    collected: &CollectedFontStacks,
4344    registry: &rust_fontconfig::registry::FcFontRegistry,
4345    codepoints: &std::collections::BTreeSet<char>,
4346    memory_families: &HashMap<String, Vec<crate::text3::cache::MemoryFace>>,
4347) -> ResolvedFontChains {
4348    use rust_fontconfig::PatternMatch;
4349
4350    static DBG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4351    let dbg = *DBG.get_or_init(|| std::env::var_os("AZ_FAST_RESOLVE_DEBUG").is_some());
4352
4353    let mut chains: HashMap<FontChainKeyOrRef, FontFallbackChain> = HashMap::new();
4354    let mut unresolved: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4355
4356    for font_stack in &collected.font_stacks {
4357        if font_stack.is_empty() {
4358            continue;
4359        }
4360
4361        // (2026-06-10) Build the key through the ONE canonical constructor
4362        // (FontChainKey::from_selectors — first-wins dedup + the same empty-stack
4363        // fallback) so the stored key always matches the shaping-time lookup key.
4364        let canonical_key = FontChainKey::from_selectors(font_stack);
4365        let font_families = canonical_key.font_families.clone();
4366
4367        let weight = font_stack[0].weight;
4368        let is_italic = font_stack[0].style == FontStyle::Italic;
4369        let is_oblique = font_stack[0].style == FontStyle::Oblique;
4370
4371        let cache_key = FontChainKeyOrRef::Chain(FontChainKey {
4372            font_families: font_families.clone(),
4373            weight,
4374            italic: is_italic,
4375            oblique: is_oblique,
4376        });
4377
4378        if chains.contains_key(&cache_key) {
4379            continue;
4380        }
4381
4382        let italic_match = if is_italic {
4383            PatternMatch::True
4384        } else {
4385            PatternMatch::False
4386        };
4387
4388        // ── MEMORY FONTS FIRST ──────────────────────────────────────────
4389        // `request_fonts_fast` only knows about fonts that exist as FILES
4390        // (it walks the registry's `known_paths`). A family registered via
4391        // `FontManager::register_named_font` (bundled embedder font, the
4392        // built-in mock test fonts) lives only in the `FcFontCache`'s
4393        // memory-font table and is INVISIBLE to it — such a family silently
4394        // fell through to a system fallback on every production build
4395        // (production always has a live registry, so it always took this
4396        // path). Match memory families by name here, in CSS order, and only
4397        // hand the remaining families to the disk probe.
4398        let (mut css_fallbacks, disk_families) =
4399            split_memory_matches(&font_families, memory_families, weight, is_italic, is_oblique);
4400
4401        let request = vec![(disk_families.clone(), codepoints.clone())];
4402        let mut chains_out = if disk_families.is_empty() {
4403            Vec::new()
4404        } else {
4405            registry.request_fonts_fast(&request, weight, italic_match)
4406        };
4407        if dbg {
4408            let total_fonts: usize = chains_out
4409                .iter()
4410                .map(|c| c.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>())
4411                .sum();
4412            eprintln!(
4413                "[FAST] stack {:?} w={:?} i={:?} → {} groups, {} faces",
4414                font_families,
4415                weight,
4416                italic_match,
4417                chains_out
4418                    .first()
4419                    .map_or(0, |c| c.css_fallbacks.len()),
4420                total_fonts,
4421            );
4422        }
4423        // Merge: memory-matched groups (in CSS order) + whatever the disk
4424        // probe found for the remaining families.
4425        let mut chain = chains_out.pop().unwrap_or_else(|| FontFallbackChain {
4426            css_fallbacks: Vec::new(),
4427            unicode_fallbacks: Vec::new(),
4428            original_stack: font_families.clone(),
4429        });
4430        if !css_fallbacks.is_empty() {
4431            css_fallbacks.append(&mut chain.css_fallbacks);
4432            chain.css_fallbacks = css_fallbacks;
4433        }
4434
4435        // A family that produced no group matched NOTHING. Record it — a
4436        // silently-unmatched family is the root cause of "every font-family
4437        // renders in the same fallback font".
4438        for family in &font_families {
4439            let matched = chain
4440                .css_fallbacks
4441                .iter()
4442                .any(|g| g.css_name.eq_ignore_ascii_case(family) && !g.fonts.is_empty());
4443            if !matched && !is_generic_family(family) {
4444                unresolved.insert(family.clone());
4445            }
4446        }
4447
4448        chains.insert(cache_key, chain);
4449    }
4450
4451    let out = ResolvedFontChains {
4452        chains,
4453        unresolved_families: unresolved,
4454        last_resort_chains: 0,
4455    };
4456    report_unresolved_families(&out);
4457    out
4458}
4459
4460/// CSS generic families are not expected to match by name (they are
4461/// expanded to concrete OS families before lookup), so a missing group for
4462/// them is not a resolution failure worth reporting.
4463fn is_generic_family(family: &str) -> bool {
4464    matches!(
4465        family.to_ascii_lowercase().as_str(),
4466        "serif"
4467            | "sans-serif"
4468            | "monospace"
4469            | "cursive"
4470            | "fantasy"
4471            | "system-ui"
4472            | "ui-serif"
4473            | "ui-sans-serif"
4474            | "ui-monospace"
4475            | "ui-rounded"
4476            | "emoji"
4477            | "math"
4478            | "fangsong"
4479    )
4480}
4481
4482/// Log every family the resolver could not match, ONCE per process per
4483/// family name.
4484///
4485/// This is the diagnostic that was missing. Before this, a stylesheet
4486/// asking for `font-family: Arial` on a box with no Arial installed got a
4487/// system fallback and said nothing — so eight different families rendering
4488/// identically looked like correct behaviour to every test we had.
4489fn report_unresolved_families(resolved: &ResolvedFontChains) {
4490    use std::sync::{Mutex, OnceLock};
4491    static SEEN: OnceLock<Mutex<std::collections::BTreeSet<String>>> = OnceLock::new();
4492    if resolved.unresolved_families.is_empty() {
4493        return;
4494    }
4495    let seen = SEEN.get_or_init(|| Mutex::new(std::collections::BTreeSet::new()));
4496    let Ok(mut seen) = seen.lock() else { return };
4497    for family in &resolved.unresolved_families {
4498        if seen.insert(family.clone()) {
4499            eprintln!(
4500                "[azul][font] UNRESOLVED font-family {family:?}: no font file and no \
4501                 registered in-memory font matches this family. Text that asks for it \
4502                 renders in a FALLBACK font. Register it with \
4503                 FontManager::register_named_font(), or install it."
4504            );
4505        }
4506    }
4507}
4508
4509/// Legacy wrapper: collect + resolve without registration. Kept for
4510/// backward compatibility; defaults to the full 7-script unicode
4511/// fallback set.
4512#[must_use] pub fn collect_and_resolve_font_chains(
4513    styled_dom: &StyledDom,
4514    fc_cache: &FcFontCache,
4515    platform: &azul_css::system::Platform,
4516) -> ResolvedFontChains {
4517    let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
4518    resolve_font_chains(&collected, fc_cache, None)
4519}
4520
4521/// Legacy wrapper: register only. Prefer `collect_and_resolve_font_chains_with_registration`.
4522pub fn register_embedded_fonts_from_styled_dom<T: ParsedFontTrait>(
4523    styled_dom: &StyledDom,
4524    font_manager: &crate::text3::cache::FontManager<T>,
4525    platform: &azul_css::system::Platform,
4526) {
4527    let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
4528    for font_ref in collected.font_refs.values() {
4529        font_manager.register_embedded_font(font_ref);
4530    }
4531}
4532
4533// Font Loading Functions
4534
4535use std::collections::HashSet;
4536
4537use rust_fontconfig::FontId;
4538
4539/// Extract all unique `FontIds` from resolved font chains
4540///
4541/// This function collects all `FontIds` that are referenced in the font chains,
4542/// which represents the complete set of fonts that may be needed for rendering.
4543#[must_use] pub fn collect_font_ids_from_chains(chains: &ResolvedFontChains) -> HashSet<FontId> {
4544    let mut font_ids = HashSet::new();
4545
4546    // M12.7: hashbrown's RawIterRange (the .values() iterator below) mis-lifts
4547    // to wasm and loops forever on an empty map; is_empty() is len-based, so
4548    // bail out before iterating when there are no chains (web bare-body case).
4549    if chains.chains.is_empty() {
4550        return font_ids;
4551    }
4552
4553    for chain in chains.chains.values() {
4554        // Collect from CSS fallbacks
4555        for group in &chain.css_fallbacks {
4556            for font in &group.fonts {
4557                font_ids.insert(font.id);
4558            }
4559        }
4560
4561        // Collect from Unicode fallbacks
4562        for font in &chain.unicode_fallbacks {
4563            font_ids.insert(font.id);
4564        }
4565    }
4566
4567    font_ids
4568}
4569
4570/// Compute which fonts need to be loaded (diff with already loaded fonts)
4571///
4572/// # Arguments
4573/// * `required_fonts` - Set of `FontIds` that are needed
4574/// * `already_loaded` - Set of `FontIds` that are already loaded
4575///
4576/// # Returns
4577/// Set of `FontIds` that need to be loaded
4578#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
4579#[must_use] pub fn compute_fonts_to_load(
4580    required_fonts: &HashSet<FontId>,
4581    already_loaded: &HashSet<FontId>,
4582) -> HashSet<FontId> {
4583    // M12.7: `.difference()` drives hashbrown's RawIterRange, which mis-lifts
4584    // to wasm and loops on an empty map. Nothing required → nothing to load.
4585    if required_fonts.is_empty() {
4586        return HashSet::new();
4587    }
4588    required_fonts.difference(already_loaded).copied().collect()
4589}
4590
4591/// Result of loading fonts
4592#[derive(Debug)]
4593pub struct FontLoadResult<T> {
4594    /// Successfully loaded fonts
4595    pub loaded: HashMap<FontId, T>,
4596    /// `FontIds` that failed to load, with error messages
4597    pub failed: Vec<(FontId, String)>,
4598}
4599
4600/// Load fonts from disk using the provided loader function
4601///
4602/// This is a generic function that works with any font loading implementation.
4603/// The `load_fn` parameter should be a function that takes font bytes and an index,
4604/// and returns a parsed font or an error.
4605///
4606/// # Arguments
4607/// * `font_ids` - Set of `FontIds` to load
4608/// * `fc_cache` - The fontconfig cache to get font paths from
4609/// * `load_fn` - Function to load and parse font bytes
4610///
4611/// # Returns
4612/// A `FontLoadResult` containing successfully loaded fonts and any failures
4613#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
4614pub fn load_fonts_from_disk<T, F>(
4615    font_ids: &HashSet<FontId>,
4616    fc_cache: &FcFontCache,
4617    load_fn: F,
4618) -> FontLoadResult<T>
4619where
4620    // Bytes come in as `Arc<FontBytes>` so the loader can retain
4621    // them cheaply (one `Arc::clone` per retained copy). On disk the
4622    // backing is an mmap, so untouched glyf/CFF pages don't count
4623    // toward RSS — the layout shaper only faults in pages it reads.
4624    F: Fn(
4625        std::sync::Arc<rust_fontconfig::FontBytes>,
4626        usize,
4627    ) -> Result<T, crate::text3::cache::LayoutError>,
4628{
4629    let mut loaded = HashMap::new();
4630    let mut failed = Vec::new();
4631
4632    for font_id in font_ids {
4633        // Get font bytes from fc_cache as a shared mmap. Faces backed
4634        // by the same .ttc all observe the same `Arc<FontBytes>` via
4635        // rust_fontconfig's `shared_bytes` dedup.
4636        let Some(font_bytes) = fc_cache.get_font_bytes(font_id) else {
4637            failed.push((
4638                *font_id,
4639                format!("Could not get font bytes for {font_id:?}"),
4640            ));
4641            continue;
4642        };
4643
4644        // Get font index (for font collections like .ttc files)
4645        let font_index = fc_cache
4646            .get_font_by_id(font_id)
4647            .map_or(0, |source| match source {
4648                rust_fontconfig::OwnedFontSource::Disk(path) => path.font_index,
4649                rust_fontconfig::OwnedFontSource::Memory(font) => font.font_index,
4650            });
4651
4652        // Load the font using the provided function
4653        match load_fn(font_bytes, font_index) {
4654            Ok(font) => {
4655                loaded.insert(*font_id, font);
4656            }
4657            Err(e) => {
4658                failed.push((
4659                    *font_id,
4660                    format!("Failed to parse font {font_id:?}: {e:?}"),
4661                ));
4662            }
4663        }
4664    }
4665
4666    FontLoadResult { loaded, failed }
4667}
4668
4669/// Convenience function to load all required fonts for a styled DOM
4670///
4671/// This function:
4672/// 1. Collects all font stacks from the DOM
4673/// 2. Resolves them to font chains
4674/// 3. Extracts all required `FontIds`
4675/// 4. Computes which fonts need to be loaded (diff with already loaded)
4676/// 5. Loads the missing fonts
4677///
4678/// # Arguments
4679/// * `styled_dom` - The styled DOM to extract font requirements from
4680/// * `fc_cache` - The fontconfig cache
4681/// * `already_loaded` - Set of `FontIds` that are already loaded
4682/// * `load_fn` - Function to load and parse font bytes
4683/// * `platform` - The current platform for resolving system font types
4684///
4685/// # Returns
4686/// A tuple of (`ResolvedFontChains`, `FontLoadResult`)
4687#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
4688pub fn resolve_and_load_fonts<T, F>(
4689    styled_dom: &StyledDom,
4690    fc_cache: &FcFontCache,
4691    already_loaded: &HashSet<FontId>,
4692    load_fn: F,
4693    platform: &azul_css::system::Platform,
4694) -> (ResolvedFontChains, FontLoadResult<T>)
4695where
4696    F: Fn(
4697        std::sync::Arc<rust_fontconfig::FontBytes>,
4698        usize,
4699    ) -> Result<T, crate::text3::cache::LayoutError>,
4700{
4701    // Step 1-2: Collect and resolve font chains
4702    let chains = collect_and_resolve_font_chains(styled_dom, fc_cache, platform);
4703
4704    // Step 3: Extract all required FontIds
4705    let required_fonts = collect_font_ids_from_chains(&chains);
4706
4707    // Step 4: Compute diff
4708    let fonts_to_load = compute_fonts_to_load(&required_fonts, already_loaded);
4709
4710    // Step 5: Load missing fonts
4711    let load_result = load_fonts_from_disk(&fonts_to_load, fc_cache, load_fn);
4712
4713    (chains, load_result)
4714}
4715
4716// ============================================================================
4717// Scrollbar Style Getters
4718// ============================================================================
4719
4720use azul_css::props::style::scrollbar::{
4721    LayoutScrollbarWidth, ScrollbarVisibilityMode, StyleScrollbarColor,
4722};
4723
4724/// Computed scrollbar style for a node.
4725///
4726/// All visual defaults (colors, width) come from the UA CSS conditional rules
4727/// in `core/src/ua_css.rs` — individual `CssPropertyWithConditions` entries for
4728/// `scrollbar-color` and `scrollbar-width`, keyed on `@os` / `@theme`.
4729///
4730/// Overlay behaviour (fade timing, visibility, clip) is derived from the
4731/// resolved `scrollbar-width` mode:
4732///   - `thin`  → overlay:  fade 500/200 ms, `WhenScrolling`, clip = true
4733///   - `auto`  → classic:  no fade, `Always`, clip = false
4734///   - `none`  → hidden:   no fade, `Always`, clip = false
4735///
4736/// Per-node CSS overrides (in priority order):
4737///   1. `-azul-scrollbar-style`  (full `ScrollbarInfo` override)
4738///   2. `scrollbar-width`        (overrides width + overlay mode)
4739///   3. `scrollbar-color`        (overrides thumb / track colours)
4740#[derive(Copy, Debug, Clone)]
4741pub struct ComputedScrollbarStyle {
4742    /// The scrollbar width mode (auto/thin/none)
4743    pub width_mode: LayoutScrollbarWidth,
4744    /// Visual width in pixels — used for rendering track + thumb.
4745    /// Non-zero even for overlay scrollbars.
4746    pub visual_width_px: f32,
4747    /// Reserve width in pixels — layout space subtracted from content area.
4748    /// 0 for overlay scrollbars, equal to `visual_width_px` for legacy.
4749    pub reserve_width_px: f32,
4750    /// Thumb color
4751    pub thumb_color: ColorU,
4752    /// Track color
4753    pub track_color: ColorU,
4754    /// Button color (for scroll arrows)
4755    pub button_color: ColorU,
4756    /// Corner color (where scrollbars meet)
4757    pub corner_color: ColorU,
4758    /// Whether to clip the scrollbar to the container's border-radius
4759    pub clip_to_container_border: bool,
4760    /// Delay in ms before scrollbar starts fading out (0 = never fade)
4761    pub fade_delay_ms: u32,
4762    /// Duration of fade-out animation in ms (0 = instant)
4763    pub fade_duration_ms: u32,
4764    /// Scrollbar visibility mode (always / when-scrolling / auto)
4765    pub visibility: ScrollbarVisibilityMode,
4766    /// Whether to show top/bottom (or left/right) arrow buttons.
4767    /// When false, the track spans the entire scrollbar length.
4768    pub show_scroll_buttons: bool,
4769    /// Size of each arrow button in px (square: width = height).
4770    /// Only used when `show_scroll_buttons == true`.
4771    pub scroll_button_size_px: f32,
4772    /// Whether to show the corner rect where V and H scrollbars meet.
4773    pub show_corner_rect: bool,
4774    /// Thumb color when hovered (None = use `thumb_color`)
4775    pub thumb_color_hover: Option<ColorU>,
4776    /// Thumb color when pressed/active (None = use `thumb_color`)
4777    pub thumb_color_active: Option<ColorU>,
4778    /// Track color when hovered (None = use `track_color`)
4779    pub track_color_hover: Option<ColorU>,
4780    /// Visual width when hovered (None = use `visual_width_px`)
4781    pub visual_width_px_hover: Option<f32>,
4782    /// Visual width when pressed (None = use `visual_width_px`)
4783    pub visual_width_px_active: Option<f32>,
4784}
4785
4786impl Default for ComputedScrollbarStyle {
4787    fn default() -> Self {
4788        // Evaluate UA CSS rules with a default context (no OS info).
4789        // Picks the unconditional fallback: classic light, auto width.
4790        let ctx = azul_css::dynamic_selector::DynamicSelectorContext::default();
4791        let ua = azul_core::ua_css::evaluate_ua_scrollbar_css(&ctx);
4792        Self::from_ua_resolved(&ua)
4793    }
4794}
4795
4796impl ComputedScrollbarStyle {
4797    /// Build from resolved UA scrollbar CSS properties.
4798    ///
4799    /// Each property is read individually from the resolved UA CSS.
4800    fn from_ua_resolved(ua: &azul_core::ua_css::ResolvedUaScrollbar) -> Self {
4801        let width_mode = ua.width;
4802        let visibility = ua.visibility;
4803        let fade_delay_ms = ua.fade_delay.ms;
4804        let fade_duration_ms = ua.fade_duration.ms;
4805
4806        let visual_width_px = match width_mode {
4807            LayoutScrollbarWidth::Thin => SCROLLBAR_WIDTH_THIN,
4808            LayoutScrollbarWidth::Auto => SCROLLBAR_WIDTH_AUTO,
4809            LayoutScrollbarWidth::None => 0.0,
4810        };
4811
4812        // Overlay scrollbars don't reserve layout space and hide buttons / corner.
4813        let is_overlay = visibility == ScrollbarVisibilityMode::WhenScrolling;
4814        let reserve_width_px = if is_overlay { 0.0 } else { visual_width_px };
4815        let show_scroll_buttons = !is_overlay;
4816        let scroll_button_size_px = if is_overlay { 0.0 } else { visual_width_px };
4817        let show_corner_rect = !is_overlay;
4818
4819        let (thumb_color, track_color) = match ua.color {
4820            StyleScrollbarColor::Custom(c) => (c.thumb, c.track),
4821            StyleScrollbarColor::Auto => (ColorU::TRANSPARENT, ColorU::TRANSPARENT),
4822        };
4823
4824        // Compute hover / active variants:
4825        // Hover: lighten thumb, widen by +SCROLLBAR_HOVER_EXPAND_PX
4826        // Active: darken thumb, widen by +SCROLLBAR_HOVER_EXPAND_PX
4827        let thumb_hover = ColorU {
4828            r: thumb_color.r.saturating_add(THUMB_HOVER_LIGHTEN),
4829            g: thumb_color.g.saturating_add(THUMB_HOVER_LIGHTEN),
4830            b: thumb_color.b.saturating_add(THUMB_HOVER_LIGHTEN),
4831            a: thumb_color.a.saturating_add(THUMB_HOVER_ALPHA_ADD),
4832        };
4833        let thumb_active = ColorU {
4834            r: thumb_color.r.saturating_sub(THUMB_ACTIVE_DARKEN),
4835            g: thumb_color.g.saturating_sub(THUMB_ACTIVE_DARKEN),
4836            b: thumb_color.b.saturating_sub(THUMB_ACTIVE_DARKEN),
4837            a: 255,
4838        };
4839        let track_hover = ColorU {
4840            r: track_color.r,
4841            g: track_color.g,
4842            b: track_color.b,
4843            a: track_color.a.saturating_add(THUMB_HOVER_ALPHA_ADD),
4844        };
4845        let hover_width = visual_width_px + SCROLLBAR_HOVER_EXPAND_PX;
4846        let active_width = visual_width_px + SCROLLBAR_HOVER_EXPAND_PX;
4847
4848        Self {
4849            width_mode,
4850            visual_width_px,
4851            reserve_width_px,
4852            thumb_color,
4853            track_color,
4854            button_color: ColorU::TRANSPARENT,
4855            corner_color: ColorU::TRANSPARENT,
4856            clip_to_container_border: is_overlay,
4857            fade_delay_ms,
4858            fade_duration_ms,
4859            visibility,
4860            show_scroll_buttons,
4861            scroll_button_size_px,
4862            show_corner_rect,
4863            thumb_color_hover: Some(thumb_hover),
4864            thumb_color_active: Some(thumb_active),
4865            track_color_hover: Some(track_hover),
4866            visual_width_px_hover: Some(hover_width),
4867            visual_width_px_active: Some(active_width),
4868        }
4869    }
4870}
4871
4872/// Get the computed scrollbar style for a node.
4873///
4874/// Resolution order (later wins):
4875///   1. UA scrollbar CSS (`CssPropertyWithConditions` in `ua_css.rs`,
4876///      evaluated via `@os` / `@theme` conditions)
4877///   2. CSS `-azul-scrollbar-style` (full `ScrollbarInfo` customisation)
4878///   3. CSS `scrollbar-width`  (overrides width only)
4879///   4. CSS `scrollbar-color`  (overrides thumb / track colours)
4880///   5. CSS `-azul-scrollbar-visibility` (overrides visibility + clip)
4881///   6. CSS `-azul-scrollbar-fade-delay` (overrides fade delay)
4882///   7. CSS `-azul-scrollbar-fade-duration` (overrides fade duration)
4883///
4884/// When `system_style` is `None`, falls back to the unconditional UA rule
4885/// (classic light scrollbar).
4886#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4887#[must_use] pub fn get_scrollbar_style(
4888    styled_dom: &StyledDom,
4889    node_id: NodeId,
4890    node_state: &StyledNodeState,
4891    system_style: Option<&azul_css::system::SystemStyle>,
4892) -> ComputedScrollbarStyle {
4893    let node_data = &styled_dom.node_data.as_container()[node_id];
4894
4895    // Step 1: Evaluate UA scrollbar CSS using the DynamicSelector system.
4896    let ctx = system_style.map_or_else(
4897        azul_css::dynamic_selector::DynamicSelectorContext::default,
4898        azul_css::dynamic_selector::DynamicSelectorContext::from_system_style,
4899    );
4900    let ua = azul_core::ua_css::evaluate_ua_scrollbar_css(&ctx);
4901    let result = ComputedScrollbarStyle::from_ua_resolved(&ua);
4902
4903    // FAST PATH: 99% of nodes have no scrollbar CSS. Bail before walking 8 × cascade.
4904    if node_state.is_normal() {
4905        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
4906            if !cc.has_scrollbar_css(node_id.index()) {
4907                return result;
4908            }
4909        }
4910    }
4911    let mut result = result;
4912
4913    // Step 2: Check individual scrollbar part backgrounds
4914    if let Some(track) = styled_dom
4915        .css_property_cache
4916        .ptr
4917        .get_scrollbar_track(node_data, &node_id, node_state)
4918        .and_then(|v| v.get_property())
4919    {
4920        result.track_color = extract_color_from_background(track);
4921    }
4922    if let Some(thumb) = styled_dom
4923        .css_property_cache
4924        .ptr
4925        .get_scrollbar_thumb(node_data, &node_id, node_state)
4926        .and_then(|v| v.get_property())
4927    {
4928        result.thumb_color = extract_color_from_background(thumb);
4929    }
4930    if let Some(button) = styled_dom
4931        .css_property_cache
4932        .ptr
4933        .get_scrollbar_button(node_data, &node_id, node_state)
4934        .and_then(|v| v.get_property())
4935    {
4936        result.button_color = extract_color_from_background(button);
4937    }
4938    if let Some(corner) = styled_dom
4939        .css_property_cache
4940        .ptr
4941        .get_scrollbar_corner(node_data, &node_id, node_state)
4942        .and_then(|v| v.get_property())
4943    {
4944        result.corner_color = extract_color_from_background(corner);
4945    }
4946
4947    // Step 3: Check for scrollbar-width (overrides width only, not overlay)
4948    if let Some(scrollbar_width) = styled_dom
4949        .css_property_cache
4950        .ptr
4951        .get_scrollbar_width(node_data, &node_id, node_state)
4952        .and_then(|v| v.get_property())
4953    {
4954        result.width_mode = *scrollbar_width;
4955        let w = match scrollbar_width {
4956            LayoutScrollbarWidth::Auto => SCROLLBAR_WIDTH_AUTO,
4957            LayoutScrollbarWidth::Thin => SCROLLBAR_WIDTH_THIN,
4958            LayoutScrollbarWidth::None => 0.0,
4959        };
4960        result.visual_width_px = w;
4961        if result.visibility != ScrollbarVisibilityMode::WhenScrolling {
4962            result.reserve_width_px = w;
4963        }
4964    }
4965
4966    // Step 4: Check for scrollbar-color (overrides thumb/track colors)
4967    if let Some(scrollbar_color) = styled_dom
4968        .css_property_cache
4969        .ptr
4970        .get_scrollbar_color(node_data, &node_id, node_state)
4971        .and_then(|v| v.get_property())
4972    {
4973        match scrollbar_color {
4974            StyleScrollbarColor::Auto => { /* keep */ }
4975            StyleScrollbarColor::Custom(custom) => {
4976                result.thumb_color = custom.thumb;
4977                result.track_color = custom.track;
4978            }
4979        }
4980    }
4981
4982    // Step 5: Check for -azul-scrollbar-visibility
4983    if let Some(vis) = styled_dom
4984        .css_property_cache
4985        .ptr
4986        .get_scrollbar_visibility(node_data, &node_id, node_state)
4987        .and_then(|v| v.get_property())
4988    {
4989        result.visibility = *vis;
4990        result.clip_to_container_border = *vis == ScrollbarVisibilityMode::WhenScrolling;
4991        // Overlay mode: no reserved layout space, hide buttons and corner
4992        let is_overlay = *vis == ScrollbarVisibilityMode::WhenScrolling;
4993        if is_overlay {
4994            result.reserve_width_px = 0.0;
4995            result.show_scroll_buttons = false;
4996            result.scroll_button_size_px = 0.0;
4997            result.show_corner_rect = false;
4998        } else {
4999            result.reserve_width_px = result.visual_width_px;
5000        }
5001    }
5002
5003    // Step 6: Check for -azul-scrollbar-fade-delay
5004    if let Some(delay) = styled_dom
5005        .css_property_cache
5006        .ptr
5007        .get_scrollbar_fade_delay(node_data, &node_id, node_state)
5008        .and_then(|v| v.get_property())
5009    {
5010        result.fade_delay_ms = delay.ms;
5011    }
5012
5013    // Step 7: Check for -azul-scrollbar-fade-duration
5014    if let Some(dur) = styled_dom
5015        .css_property_cache
5016        .ptr
5017        .get_scrollbar_fade_duration(node_data, &node_id, node_state)
5018        .and_then(|v| v.get_property())
5019    {
5020        result.fade_duration_ms = dur.ms;
5021    }
5022
5023    result
5024}
5025
5026/// Cached wrapper for [`get_scrollbar_style`] that reuses the
5027/// memo stored on `LayoutContext`.
5028///
5029/// The underlying call performs
5030/// 9 cascade walks per node (track/thumb/button/corner/width/
5031/// color/visibility/fade-delay/fade-duration). The BFC, Taffy,
5032/// and display-list callers all hit the same node many times
5033/// inside a single layout pass, so caching turns ~21 rebuilds per
5034/// node into one.
5035///
5036/// Falls back to the uncached `get_scrollbar_style` when no ctx
5037/// is available (shouldn't happen in the current code paths).
5038pub fn get_scrollbar_style_cached<T: ParsedFontTrait>(
5039    ctx: &crate::solver3::LayoutContext<'_, T>,
5040    node_id: NodeId,
5041    node_state: &StyledNodeState,
5042) -> ComputedScrollbarStyle {
5043    if let Some(s) = ctx.scrollbar_style_cache.borrow().get(&node_id) {
5044        return *s;
5045    }
5046    let style = get_scrollbar_style(
5047        ctx.styled_dom,
5048        node_id,
5049        node_state,
5050        ctx.system_style.as_deref(),
5051    );
5052    ctx.scrollbar_style_cache
5053        .borrow_mut()
5054        .insert(node_id, style);
5055    style
5056}
5057
5058/// Helper to extract a solid color from a `StyleBackgroundContent`
5059const fn extract_color_from_background(
5060    bg: &azul_css::props::style::background::StyleBackgroundContent,
5061) -> ColorU {
5062    use azul_css::props::style::background::StyleBackgroundContent;
5063    match bg {
5064        StyleBackgroundContent::Color(c) => *c,
5065        _ => ColorU::TRANSPARENT,
5066    }
5067}
5068
5069/// Check if a node should clip its scrollbar to the container's border-radius
5070#[must_use] pub fn should_clip_scrollbar_to_border(
5071    styled_dom: &StyledDom,
5072    node_id: NodeId,
5073    node_state: &StyledNodeState,
5074) -> bool {
5075    let style = get_scrollbar_style(styled_dom, node_id, node_state, None);
5076    style.clip_to_container_border
5077}
5078
5079/// Get the scrollbar visual width in pixels for a node (used for rendering)
5080#[must_use] pub fn get_scrollbar_width_px(
5081    styled_dom: &StyledDom,
5082    node_id: NodeId,
5083    node_state: &StyledNodeState,
5084) -> f32 {
5085    let style = get_scrollbar_style(styled_dom, node_id, node_state, None);
5086    style.visual_width_px
5087}
5088
5089/// Checks if text in a node is selectable based on CSS `user-select` property.
5090///
5091/// Returns `true` if the text can be selected (default behavior),
5092/// `false` if `user-select: none` is set.
5093#[must_use] pub fn is_text_selectable(
5094    styled_dom: &StyledDom,
5095    node_id: NodeId,
5096    node_state: &StyledNodeState,
5097) -> bool {
5098    let node_data = &styled_dom.node_data.as_container()[node_id];
5099
5100    styled_dom
5101        .css_property_cache
5102        .ptr
5103        .get_user_select(node_data, &node_id, node_state)
5104        .and_then(|v| v.get_property())
5105        .is_none_or(|us| *us != StyleUserSelect::None) // Default: text is selectable
5106}
5107
5108/// Checks if a node has the `contenteditable` attribute set directly.
5109///
5110/// Returns `true` if:
5111/// - The node has `contenteditable: true` set via `.set_contenteditable(true)`
5112/// - OR the node has `contenteditable` attribute set to `true`
5113///
5114/// This does NOT check inheritance - use `is_node_contenteditable_inherited` for that.
5115#[must_use] pub fn is_node_contenteditable(styled_dom: &StyledDom, node_id: NodeId) -> bool {
5116    use azul_core::dom::AttributeType;
5117
5118    let node_data = &styled_dom.node_data.as_container()[node_id];
5119
5120    // First check the direct contenteditable field (primary method)
5121    if node_data.is_contenteditable() {
5122        return true;
5123    }
5124
5125    // Also check the attribute for backwards compatibility
5126    // Only return true if the attribute value is explicitly true
5127    node_data
5128        .attributes()
5129        .as_ref()
5130        .iter()
5131        .any(|attr| matches!(attr, AttributeType::ContentEditable(true)))
5132}
5133// =============================================================================
5134// Additional ExtractPropertyValue impls (not in compact cache tier 1/2)
5135// =============================================================================
5136
5137use azul_css::props::layout::table::{
5138    LayoutTableLayout, StyleBorderCollapse, StyleCaptionSide, StyleEmptyCells,
5139};
5140use azul_css::props::layout::text::LayoutTextJustify;
5141use azul_css::props::style::effects::StyleAspectRatio;
5142use azul_css::props::style::effects::StyleCursor;
5143use azul_css::props::style::effects::StyleObjectFit;
5144use azul_css::props::style::effects::StyleObjectPosition;
5145use azul_css::props::layout::overflow::StyleTextOverflow;
5146use azul_css::props::style::effects::StyleTextOrientation;
5147use azul_css::props::style::text::StyleHyphens;
5148use azul_css::props::style::text::StyleLineBreak;
5149use azul_css::props::style::text::StyleOverflowWrap;
5150use azul_css::props::style::text::StyleTextAlignLast;
5151use azul_css::props::style::text::StyleWordBreak;
5152
5153impl ExtractPropertyValue<LayoutTextJustify> for CssProperty {
5154    fn extract(&self) -> Option<LayoutTextJustify> {
5155        match self {
5156            Self::TextJustify(CssPropertyValue::Exact(v)) => Some(*v),
5157            _ => None,
5158        }
5159    }
5160}
5161
5162impl ExtractPropertyValue<StyleHyphens> for CssProperty {
5163    fn extract(&self) -> Option<StyleHyphens> {
5164        match self {
5165            Self::Hyphens(CssPropertyValue::Exact(v)) => Some(*v),
5166            _ => None,
5167        }
5168    }
5169}
5170
5171impl ExtractPropertyValue<StyleWordBreak> for CssProperty {
5172    fn extract(&self) -> Option<StyleWordBreak> {
5173        match self {
5174            Self::WordBreak(CssPropertyValue::Exact(v)) => Some(*v),
5175            _ => None,
5176        }
5177    }
5178}
5179
5180impl ExtractPropertyValue<StyleOverflowWrap> for CssProperty {
5181    fn extract(&self) -> Option<StyleOverflowWrap> {
5182        match self {
5183            Self::OverflowWrap(CssPropertyValue::Exact(v)) => Some(*v),
5184            _ => None,
5185        }
5186    }
5187}
5188
5189impl ExtractPropertyValue<StyleLineBreak> for CssProperty {
5190    fn extract(&self) -> Option<StyleLineBreak> {
5191        match self {
5192            Self::LineBreak(CssPropertyValue::Exact(v)) => Some(*v),
5193            _ => None,
5194        }
5195    }
5196}
5197
5198impl ExtractPropertyValue<StyleTextAlignLast> for CssProperty {
5199    fn extract(&self) -> Option<StyleTextAlignLast> {
5200        match self {
5201            Self::TextAlignLast(CssPropertyValue::Exact(v)) => Some(*v),
5202            _ => None,
5203        }
5204    }
5205}
5206
5207impl ExtractPropertyValue<StyleObjectFit> for CssProperty {
5208    fn extract(&self) -> Option<StyleObjectFit> {
5209        match self {
5210            Self::ObjectFit(CssPropertyValue::Exact(v)) => Some(*v),
5211            _ => None,
5212        }
5213    }
5214}
5215
5216impl ExtractPropertyValue<StyleTextOverflow> for CssProperty {
5217    fn extract(&self) -> Option<StyleTextOverflow> {
5218        match self {
5219            Self::TextOverflow(CssPropertyValue::Exact(v)) => Some(*v),
5220            _ => None,
5221        }
5222    }
5223}
5224
5225impl ExtractPropertyValue<StyleTextOrientation> for CssProperty {
5226    fn extract(&self) -> Option<StyleTextOrientation> {
5227        match self {
5228            Self::TextOrientation(CssPropertyValue::Exact(v)) => Some(*v),
5229            _ => None,
5230        }
5231    }
5232}
5233
5234impl ExtractPropertyValue<StyleObjectPosition> for CssProperty {
5235    fn extract(&self) -> Option<StyleObjectPosition> {
5236        match self {
5237            Self::ObjectPosition(CssPropertyValue::Exact(v)) => Some(*v),
5238            _ => None,
5239        }
5240    }
5241}
5242
5243impl ExtractPropertyValue<StyleAspectRatio> for CssProperty {
5244    fn extract(&self) -> Option<StyleAspectRatio> {
5245        match self {
5246            Self::AspectRatio(CssPropertyValue::Exact(v)) => Some(*v),
5247            _ => None,
5248        }
5249    }
5250}
5251
5252impl ExtractPropertyValue<LayoutTableLayout> for CssProperty {
5253    fn extract(&self) -> Option<LayoutTableLayout> {
5254        match self {
5255            Self::TableLayout(CssPropertyValue::Exact(v)) => Some(*v),
5256            _ => None,
5257        }
5258    }
5259}
5260
5261impl ExtractPropertyValue<StyleBorderCollapse> for CssProperty {
5262    fn extract(&self) -> Option<StyleBorderCollapse> {
5263        match self {
5264            Self::BorderCollapse(CssPropertyValue::Exact(v)) => Some(*v),
5265            _ => None,
5266        }
5267    }
5268}
5269
5270impl ExtractPropertyValue<StyleCaptionSide> for CssProperty {
5271    fn extract(&self) -> Option<StyleCaptionSide> {
5272        match self {
5273            Self::CaptionSide(CssPropertyValue::Exact(v)) => Some(*v),
5274            _ => None,
5275        }
5276    }
5277}
5278
5279impl ExtractPropertyValue<StyleEmptyCells> for CssProperty {
5280    fn extract(&self) -> Option<StyleEmptyCells> {
5281        match self {
5282            Self::EmptyCells(CssPropertyValue::Exact(v)) => Some(*v),
5283            _ => None,
5284        }
5285    }
5286}
5287
5288impl ExtractPropertyValue<StyleCursor> for CssProperty {
5289    fn extract(&self) -> Option<StyleCursor> {
5290        match self {
5291            Self::Cursor(CssPropertyValue::Exact(v)) => Some(*v),
5292            _ => None,
5293        }
5294    }
5295}
5296
5297// =============================================================================
5298// Additional macro-based getters (not covered by compact cache fast-path getters)
5299// =============================================================================
5300
5301get_css_property!(
5302    get_text_justify,
5303    get_text_justify,
5304    LayoutTextJustify,
5305    CssPropertyType::TextJustify
5306);
5307
5308get_css_property!(
5309    get_hyphens,
5310    get_hyphens,
5311    StyleHyphens,
5312    CssPropertyType::Hyphens
5313);
5314
5315get_css_property!(
5316    get_word_break,
5317    get_word_break,
5318    StyleWordBreak,
5319    CssPropertyType::WordBreak
5320);
5321
5322get_css_property!(
5323    get_overflow_wrap,
5324    get_overflow_wrap,
5325    StyleOverflowWrap,
5326    CssPropertyType::OverflowWrap
5327);
5328
5329get_css_property!(
5330    get_line_break,
5331    get_line_break,
5332    StyleLineBreak,
5333    CssPropertyType::LineBreak
5334);
5335
5336get_css_property!(
5337    get_text_align_last,
5338    get_text_align_last,
5339    StyleTextAlignLast,
5340    CssPropertyType::TextAlignLast
5341);
5342
5343get_css_property!(
5344    get_table_layout,
5345    get_table_layout,
5346    LayoutTableLayout,
5347    CssPropertyType::TableLayout
5348);
5349
5350get_css_property!(
5351    get_border_collapse,
5352    get_border_collapse,
5353    StyleBorderCollapse,
5354    CssPropertyType::BorderCollapse,
5355    compact = get_border_collapse
5356);
5357
5358get_css_property!(
5359    get_caption_side,
5360    get_caption_side,
5361    StyleCaptionSide,
5362    CssPropertyType::CaptionSide
5363);
5364
5365get_css_property!(
5366    get_empty_cells,
5367    get_empty_cells,
5368    StyleEmptyCells,
5369    CssPropertyType::EmptyCells
5370);
5371
5372get_css_property!(
5373    get_cursor_property,
5374    get_cursor,
5375    StyleCursor,
5376    CssPropertyType::Cursor
5377);
5378
5379// =============================================================================
5380// Handwritten getters (Option<T>, special logic, or non-standard returns)
5381// =============================================================================
5382
5383/// Get height property value for IFC text layout height reference.
5384#[must_use] pub fn get_height_value(
5385    styled_dom: &StyledDom,
5386    node_id: NodeId,
5387    node_state: &StyledNodeState,
5388) -> Option<LayoutHeight> {
5389    let node_data = &styled_dom.node_data.as_container()[node_id];
5390    styled_dom
5391        .css_property_cache
5392        .ptr
5393        .get_height(node_data, &node_id, node_state)
5394        .and_then(|v| v.get_property())
5395        .cloned()
5396}
5397
5398/// Get shape-inside property. Returns Option<ShapeInside> (cloned).
5399#[must_use] pub fn get_shape_inside(
5400    styled_dom: &StyledDom,
5401    node_id: NodeId,
5402    node_state: &StyledNodeState,
5403) -> Option<azul_css::props::layout::shape::ShapeInside> {
5404    let node_data = &styled_dom.node_data.as_container()[node_id];
5405    styled_dom
5406        .css_property_cache
5407        .ptr
5408        .get_shape_inside(node_data, &node_id, node_state)
5409        .and_then(|v| v.get_property())
5410        .cloned()
5411}
5412
5413/// Get shape-outside property. Returns Option<ShapeOutside> (cloned).
5414#[must_use] pub fn get_shape_outside(
5415    styled_dom: &StyledDom,
5416    node_id: NodeId,
5417    node_state: &StyledNodeState,
5418) -> Option<azul_css::props::layout::shape::ShapeOutside> {
5419    let node_data = &styled_dom.node_data.as_container()[node_id];
5420    styled_dom
5421        .css_property_cache
5422        .ptr
5423        .get_shape_outside(node_data, &node_id, node_state)
5424        .and_then(|v| v.get_property())
5425        .cloned()
5426}
5427
5428/// Get line-height as the full `StyleLineHeight` value for caller resolution.
5429#[must_use] pub fn get_line_height_value(
5430    styled_dom: &StyledDom,
5431    node_id: NodeId,
5432    node_state: &StyledNodeState,
5433) -> Option<azul_css::props::style::text::StyleLineHeight> {
5434    let node_data = &styled_dom.node_data.as_container()[node_id];
5435    styled_dom
5436        .css_property_cache
5437        .ptr
5438        .get_line_height(node_data, &node_id, node_state)
5439        .and_then(|v| v.get_property())
5440        .copied()
5441}
5442
5443/// Get text-indent as the full `StyleTextIndent` value for caller resolution.
5444#[must_use] pub fn get_text_indent_value(
5445    styled_dom: &StyledDom,
5446    node_id: NodeId,
5447    node_state: &StyledNodeState,
5448) -> Option<azul_css::props::style::text::StyleTextIndent> {
5449    let node_data = &styled_dom.node_data.as_container()[node_id];
5450    styled_dom
5451        .css_property_cache
5452        .ptr
5453        .get_text_indent(node_data, &node_id, node_state)
5454        .and_then(|v| v.get_property())
5455        .copied()
5456}
5457
5458/// Get column-count property. Returns Option<ColumnCount>.
5459#[must_use] pub fn get_column_count(
5460    styled_dom: &StyledDom,
5461    node_id: NodeId,
5462    node_state: &StyledNodeState,
5463) -> Option<azul_css::props::layout::column::ColumnCount> {
5464    let node_data = &styled_dom.node_data.as_container()[node_id];
5465    styled_dom
5466        .css_property_cache
5467        .ptr
5468        .get_column_count(node_data, &node_id, node_state)
5469        .and_then(|v| v.get_property())
5470        .copied()
5471}
5472
5473/// Get initial-letter property. Returns Option<StyleInitialLetter>.
5474#[must_use] pub fn get_initial_letter(
5475    styled_dom: &StyledDom,
5476    node_id: NodeId,
5477    node_state: &StyledNodeState,
5478) -> Option<azul_css::props::style::text::StyleInitialLetter> {
5479    let node_data = &styled_dom.node_data.as_container()[node_id];
5480    styled_dom
5481        .css_property_cache
5482        .ptr
5483        .get_initial_letter(node_data, &node_id, node_state)
5484        .and_then(|v| v.get_property())
5485        .copied()
5486}
5487
5488/// Get line-clamp property. Returns Option<StyleLineClamp>.
5489#[must_use] pub fn get_line_clamp(
5490    styled_dom: &StyledDom,
5491    node_id: NodeId,
5492    node_state: &StyledNodeState,
5493) -> Option<azul_css::props::style::text::StyleLineClamp> {
5494    let node_data = &styled_dom.node_data.as_container()[node_id];
5495    styled_dom
5496        .css_property_cache
5497        .ptr
5498        .get_line_clamp(node_data, &node_id, node_state)
5499        .and_then(|v| v.get_property())
5500        .copied()
5501}
5502
5503/// Get hanging-punctuation property. Returns Option<StyleHangingPunctuation>.
5504#[must_use] pub fn get_hanging_punctuation(
5505    styled_dom: &StyledDom,
5506    node_id: NodeId,
5507    node_state: &StyledNodeState,
5508) -> Option<azul_css::props::style::text::StyleHangingPunctuation> {
5509    let node_data = &styled_dom.node_data.as_container()[node_id];
5510    styled_dom
5511        .css_property_cache
5512        .ptr
5513        .get_hanging_punctuation(node_data, &node_id, node_state)
5514        .and_then(|v| v.get_property())
5515        .copied()
5516}
5517
5518/// Get text-combine-upright property. Returns Option<StyleTextCombineUpright>.
5519#[must_use] pub fn get_text_combine_upright(
5520    styled_dom: &StyledDom,
5521    node_id: NodeId,
5522    node_state: &StyledNodeState,
5523) -> Option<azul_css::props::style::text::StyleTextCombineUpright> {
5524    let node_data = &styled_dom.node_data.as_container()[node_id];
5525    styled_dom
5526        .css_property_cache
5527        .ptr
5528        .get_text_combine_upright(node_data, &node_id, node_state)
5529        .and_then(|v| v.get_property())
5530        .copied()
5531}
5532
5533/// Get exclusion-margin value. Returns f32 (default 0.0).
5534#[must_use] pub fn get_exclusion_margin(
5535    styled_dom: &StyledDom,
5536    node_id: NodeId,
5537    node_state: &StyledNodeState,
5538) -> f32 {
5539    let node_data = &styled_dom.node_data.as_container()[node_id];
5540    styled_dom
5541        .css_property_cache
5542        .ptr
5543        .get_exclusion_margin(node_data, &node_id, node_state)
5544        .and_then(|v| v.get_property())
5545        .map_or(0.0, |v| v.inner.get())
5546}
5547
5548/// Get hyphenation-language property. Returns Option<StyleHyphenationLanguage>.
5549#[must_use] pub fn get_hyphenation_language(
5550    styled_dom: &StyledDom,
5551    node_id: NodeId,
5552    node_state: &StyledNodeState,
5553) -> Option<azul_css::props::style::exclusion::StyleHyphenationLanguage> {
5554    let node_data = &styled_dom.node_data.as_container()[node_id];
5555    styled_dom
5556        .css_property_cache
5557        .ptr
5558        .get_hyphenation_language(node_data, &node_id, node_state)
5559        .and_then(|v| v.get_property())
5560        .cloned()
5561}
5562
5563/// Get border-spacing property.
5564#[must_use] pub fn get_border_spacing(
5565    styled_dom: &StyledDom,
5566    node_id: NodeId,
5567    node_state: &StyledNodeState,
5568) -> azul_css::props::layout::table::LayoutBorderSpacing {
5569    use azul_css::props::basic::pixel::PixelValue;
5570
5571    // FAST PATH: compact cache for normal state
5572    if node_state.is_normal() {
5573        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5574            let h_raw = cc.get_border_spacing_h_raw(node_id.index());
5575            let v_raw = cc.get_border_spacing_v_raw(node_id.index());
5576            // Both 0 means no border-spacing set (default)
5577            // Sentinel means non-px unit → slow path
5578            if h_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
5579                && v_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
5580            {
5581                return azul_css::props::layout::table::LayoutBorderSpacing {
5582                    horizontal: PixelValue::px(f32::from(h_raw) / 10.0),
5583                    vertical: PixelValue::px(f32::from(v_raw) / 10.0),
5584                };
5585            }
5586        }
5587    }
5588
5589    // SLOW PATH
5590    let node_data = &styled_dom.node_data.as_container()[node_id];
5591    styled_dom
5592        .css_property_cache
5593        .ptr
5594        .get_border_spacing(node_data, &node_id, node_state)
5595        .and_then(|v| v.get_property())
5596        .copied()
5597        .unwrap_or_default()
5598}
5599
5600/// Get opacity value. Returns f32 (default 1.0).
5601///
5602/// GPU fast path: the compact cache encodes opacity as a u8 (0-254, 255 = unset).
5603/// Avoids the 4-pseudo-state × 6-layer cascade walk for animations reading opacity
5604/// across every node each frame.
5605#[must_use] pub fn get_opacity(styled_dom: &StyledDom, node_id: NodeId, node_state: &StyledNodeState) -> f32 {
5606    // FAST PATH: compact cache for normal state
5607    if node_state.is_normal() {
5608        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5609            let raw = cc.get_opacity_raw(node_id.index());
5610            if raw == azul_css::compact_cache::OPACITY_SENTINEL {
5611                return 1.0;
5612            }
5613            return f32::from(raw) / 254.0;
5614        }
5615    }
5616    // SLOW PATH: fall back to cascade walk (state != normal, or no compact cache)
5617    let node_data = &styled_dom.node_data.as_container()[node_id];
5618    styled_dom
5619        .css_property_cache
5620        .ptr
5621        .get_opacity(node_data, &node_id, node_state)
5622        .and_then(|v| v.get_property())
5623        .map_or(1.0, |v| v.inner.normalized())
5624}
5625
5626/// Get filter property. Returns Option with cloned filter list.
5627#[must_use] pub fn get_filter(
5628    styled_dom: &StyledDom,
5629    node_id: NodeId,
5630    node_state: &StyledNodeState,
5631) -> Option<azul_css::props::style::filter::StyleFilterVec> {
5632    if node_state.is_normal() {
5633        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5634            if !cc.has_filter(node_id.index()) {
5635                return None;
5636            }
5637        }
5638    }
5639    let node_data = &styled_dom.node_data.as_container()[node_id];
5640    styled_dom
5641        .css_property_cache
5642        .ptr
5643        .get_filter(node_data, &node_id, node_state)
5644        .and_then(|v| v.get_property())
5645        .cloned()
5646}
5647
5648/// Get backdrop-filter property. Returns Option with cloned filter list.
5649#[must_use] pub fn get_backdrop_filter(
5650    styled_dom: &StyledDom,
5651    node_id: NodeId,
5652    node_state: &StyledNodeState,
5653) -> Option<azul_css::props::style::filter::StyleFilterVec> {
5654    if node_state.is_normal() {
5655        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5656            if !cc.has_backdrop_filter(node_id.index()) {
5657                return None;
5658            }
5659        }
5660    }
5661    let node_data = &styled_dom.node_data.as_container()[node_id];
5662    styled_dom
5663        .css_property_cache
5664        .ptr
5665        .get_backdrop_filter(node_data, &node_id, node_state)
5666        .and_then(|v| v.get_property())
5667        .cloned()
5668}
5669
5670/// Compact-cache negative fast path for all 4 box-shadow sides.
5671/// Most nodes have no shadow; cheap to check one bit vs. 4 cascade walks.
5672#[inline]
5673fn box_shadow_fast_bail(
5674    styled_dom: &StyledDom,
5675    node_id: NodeId,
5676    node_state: &StyledNodeState,
5677) -> bool {
5678    if !node_state.is_normal() {
5679        return false;
5680    }
5681    if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5682        return !cc.has_box_shadow(node_id.index());
5683    }
5684    false
5685}
5686
5687/// Get box-shadow for left side. Returns Option<StyleBoxShadow> (cloned).
5688#[must_use] pub fn get_box_shadow_left(
5689    styled_dom: &StyledDom,
5690    node_id: NodeId,
5691    node_state: &StyledNodeState,
5692) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5693    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5694        return None;
5695    }
5696    let node_data = &styled_dom.node_data.as_container()[node_id];
5697    styled_dom
5698        .css_property_cache
5699        .ptr
5700        .get_box_shadow_left(node_data, &node_id, node_state)
5701        .and_then(|v| v.get_property())
5702        .map(|v| (**v))
5703}
5704
5705/// Get box-shadow for right side. Returns Option<StyleBoxShadow> (cloned).
5706#[must_use] pub fn get_box_shadow_right(
5707    styled_dom: &StyledDom,
5708    node_id: NodeId,
5709    node_state: &StyledNodeState,
5710) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5711    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5712        return None;
5713    }
5714    let node_data = &styled_dom.node_data.as_container()[node_id];
5715    styled_dom
5716        .css_property_cache
5717        .ptr
5718        .get_box_shadow_right(node_data, &node_id, node_state)
5719        .and_then(|v| v.get_property())
5720        .map(|v| (**v))
5721}
5722
5723/// Get box-shadow for top side. Returns Option<StyleBoxShadow> (cloned).
5724#[must_use] pub fn get_box_shadow_top(
5725    styled_dom: &StyledDom,
5726    node_id: NodeId,
5727    node_state: &StyledNodeState,
5728) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5729    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5730        return None;
5731    }
5732    let node_data = &styled_dom.node_data.as_container()[node_id];
5733    styled_dom
5734        .css_property_cache
5735        .ptr
5736        .get_box_shadow_top(node_data, &node_id, node_state)
5737        .and_then(|v| v.get_property())
5738        .map(|v| (**v))
5739}
5740
5741/// Get box-shadow for bottom side. Returns Option<StyleBoxShadow> (cloned).
5742#[must_use] pub fn get_box_shadow_bottom(
5743    styled_dom: &StyledDom,
5744    node_id: NodeId,
5745    node_state: &StyledNodeState,
5746) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5747    if box_shadow_fast_bail(styled_dom, node_id, node_state) {
5748        return None;
5749    }
5750    let node_data = &styled_dom.node_data.as_container()[node_id];
5751    styled_dom
5752        .css_property_cache
5753        .ptr
5754        .get_box_shadow_bottom(node_data, &node_id, node_state)
5755        .and_then(|v| v.get_property())
5756        .map(|v| (**v))
5757}
5758
5759/// Get text-shadow property. Returns Option<StyleBoxShadow> (cloned).
5760#[must_use] pub fn get_text_shadow(
5761    styled_dom: &StyledDom,
5762    node_id: NodeId,
5763    node_state: &StyledNodeState,
5764) -> Option<azul_css::props::style::box_shadow::StyleBoxShadow> {
5765    if node_state.is_normal() {
5766        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5767            if !cc.has_text_shadow(node_id.index()) {
5768                return None;
5769            }
5770        }
5771    }
5772    let node_data = &styled_dom.node_data.as_container()[node_id];
5773    styled_dom
5774        .css_property_cache
5775        .ptr
5776        .get_text_shadow(node_data, &node_id, node_state)
5777        .and_then(|v| v.get_property())
5778        .map(|v| (**v))
5779}
5780
5781/// Get transform property. Returns Option (non-empty transform list, cloned).
5782///
5783/// GPU fast path: the compact cache keeps a `has_transform` flag. If unset,
5784/// skips the cascade walk entirely — which is the overwhelming case since most
5785/// nodes have no transform. Only nodes that actually have a transform pay the
5786/// slow-walk cost to retrieve the parsed value.
5787#[must_use] pub fn get_transform(
5788    styled_dom: &StyledDom,
5789    node_id: NodeId,
5790    node_state: &StyledNodeState,
5791) -> Option<azul_css::props::style::transform::StyleTransformVec> {
5792    // FAST PATH: bit check in compact cache
5793    if node_state.is_normal() {
5794        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
5795            if !cc.has_transform(node_id.index()) {
5796                return None;
5797            }
5798            // has_transform set → fall through to cascade walk for the value
5799        }
5800    }
5801    let node_data = &styled_dom.node_data.as_container()[node_id];
5802    styled_dom
5803        .css_property_cache
5804        .ptr
5805        .get_transform(node_data, &node_id, node_state)
5806        .and_then(|v| v.get_property())
5807        .cloned()
5808}
5809
5810/// Get counter-reset property. Returns Option<CounterReset> (cloned).
5811#[must_use] pub fn get_counter_reset(
5812    styled_dom: &StyledDom,
5813    node_id: NodeId,
5814    node_state: &StyledNodeState,
5815) -> Option<azul_css::props::style::content::CounterReset> {
5816    let node_data = &styled_dom.node_data.as_container()[node_id];
5817    styled_dom
5818        .css_property_cache
5819        .ptr
5820        .get_counter_reset(node_data, &node_id, node_state)
5821        .and_then(|v| v.get_property())
5822        .cloned()
5823}
5824
5825/// Get counter-increment property. Returns Option<CounterIncrement> (cloned).
5826#[must_use] pub fn get_counter_increment(
5827    styled_dom: &StyledDom,
5828    node_id: NodeId,
5829    node_state: &StyledNodeState,
5830) -> Option<azul_css::props::style::content::CounterIncrement> {
5831    let node_data = &styled_dom.node_data.as_container()[node_id];
5832    styled_dom
5833        .css_property_cache
5834        .ptr
5835        .get_counter_increment(node_data, &node_id, node_state)
5836        .and_then(|v| v.get_property())
5837        .cloned()
5838}
5839
5840/// W3C-conformant contenteditable inheritance check.
5841///
5842/// In the W3C model, the `contenteditable` attribute is **inherited**:
5843/// - A node is editable if it has `contenteditable="true"` set directly
5844/// - OR if its parent has `isContentEditable` as true
5845/// - UNLESS the node explicitly sets `contenteditable="false"`
5846///
5847/// This function traverses up the DOM tree to determine editability.
5848///
5849/// # Returns
5850///
5851/// - `true` if the node is editable (either directly or via inheritance)
5852/// - `false` if the node is not editable or has `contenteditable="false"`
5853///
5854/// # Example
5855///
5856/// ```html
5857/// <div contenteditable="true">
5858///   A                              <!-- editable (inherited) -->
5859///   <div contenteditable="false">
5860///     B                            <!-- NOT editable (explicitly false) -->
5861///   </div>
5862///   C                              <!-- editable (inherited) -->
5863/// </div>
5864/// ```
5865#[must_use] pub fn is_node_contenteditable_inherited(styled_dom: &StyledDom, node_id: NodeId) -> bool {
5866    use azul_core::dom::AttributeType;
5867
5868    let node_data_container = styled_dom.node_data.as_container();
5869    let hierarchy = styled_dom.node_hierarchy.as_container();
5870
5871    let mut current_node_id = Some(node_id);
5872
5873    while let Some(nid) = current_node_id {
5874        let node_data = &node_data_container[nid];
5875
5876        // First check the direct contenteditable field (set via set_contenteditable())
5877        // This takes precedence as it's the API-level setting
5878        if node_data.is_contenteditable() {
5879            return true;
5880        }
5881
5882        // Then check for explicit contenteditable attribute on this node
5883        // This handles HTML-style contenteditable="true" or contenteditable="false"
5884        for attr in node_data.attributes().as_ref() {
5885            if let AttributeType::ContentEditable(is_editable) = attr {
5886                // If explicitly set to true, node is editable
5887                // If explicitly set to false, node is NOT editable (blocks inheritance)
5888                return *is_editable;
5889            }
5890        }
5891
5892        // No explicit setting on this node, check parent for inheritance
5893        current_node_id = hierarchy.get(nid).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
5894    }
5895
5896    // Reached root without finding contenteditable - not editable
5897    false
5898}
5899
5900/// Find the contenteditable ancestor of a node.
5901///
5902/// When focus lands on a text node inside a contenteditable container,
5903/// we need to find the actual container that has the `contenteditable` attribute.
5904///
5905/// # Returns
5906///
5907/// - `Some(node_id)` of the contenteditable ancestor (may be the node itself)
5908/// - `None` if no contenteditable ancestor exists
5909#[must_use] pub fn find_contenteditable_ancestor(styled_dom: &StyledDom, node_id: NodeId) -> Option<NodeId> {
5910    use azul_core::dom::AttributeType;
5911
5912    let node_data_container = styled_dom.node_data.as_container();
5913    let hierarchy = styled_dom.node_hierarchy.as_container();
5914
5915    let mut current_node_id = Some(node_id);
5916
5917    while let Some(nid) = current_node_id {
5918        let node_data = &node_data_container[nid];
5919
5920        // First check the direct contenteditable field (set via set_contenteditable())
5921        if node_data.is_contenteditable() {
5922            return Some(nid);
5923        }
5924
5925        // Then check for contenteditable attribute on this node
5926        for attr in node_data.attributes().as_ref() {
5927            if let AttributeType::ContentEditable(is_editable) = attr {
5928                if *is_editable {
5929                    return Some(nid);
5930                }
5931                // Explicitly not editable - stop search
5932                return None;
5933            }
5934        }
5935
5936        // Check parent
5937        current_node_id = hierarchy.get(nid).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
5938    }
5939
5940    None
5941}
5942
5943// --- Taffy bridge property getters ---
5944//
5945// These getters return `Option<CssPropertyValue<T>>` (cloned from cache) for use
5946// by taffy_bridge.rs. The conversion from CssPropertyValue to taffy types is done
5947// in taffy_bridge.rs itself. Routing access through these functions centralizes
5948// all CSS property lookups for future cache optimizations (e.g., FxHash migration).
5949
5950macro_rules! get_css_property_value {
5951    ($fn_name:ident, $cache_method:ident, $ret_type:ty) => {
5952        #[must_use] pub fn $fn_name(
5953            styled_dom: &StyledDom,
5954            node_id: NodeId,
5955            node_state: &StyledNodeState,
5956        ) -> Option<$ret_type> {
5957            let node_data = &styled_dom.node_data.as_container()[node_id];
5958            styled_dom
5959                .css_property_cache
5960                .ptr
5961                .$cache_method(node_data, &node_id, node_state)
5962                .cloned()
5963        }
5964    };
5965}
5966
5967// Flexbox properties
5968get_css_property_value!(
5969    get_flex_direction_prop,
5970    get_flex_direction,
5971    LayoutFlexDirectionValue
5972);
5973get_css_property_value!(get_flex_wrap_prop, get_flex_wrap, LayoutFlexWrapValue);
5974get_css_property_value!(get_flex_grow_prop, get_flex_grow, LayoutFlexGrowValue);
5975get_css_property_value!(get_flex_shrink_prop, get_flex_shrink, LayoutFlexShrinkValue);
5976get_css_property_value!(get_flex_basis_prop, get_flex_basis, LayoutFlexBasisValue);
5977
5978// Alignment properties
5979get_css_property_value!(get_align_items_prop, get_align_items, LayoutAlignItemsValue);
5980get_css_property_value!(get_align_self_prop, get_align_self, LayoutAlignSelfValue);
5981get_css_property_value!(
5982    get_align_content_prop,
5983    get_align_content,
5984    LayoutAlignContentValue
5985);
5986get_css_property_value!(
5987    get_justify_content_prop,
5988    get_justify_content,
5989    LayoutJustifyContentValue
5990);
5991get_css_property_value!(
5992    get_justify_items_prop,
5993    get_justify_items,
5994    LayoutJustifyItemsValue
5995);
5996get_css_property_value!(
5997    get_justify_self_prop,
5998    get_justify_self,
5999    LayoutJustifySelfValue
6000);
6001
6002// Gap
6003get_css_property_value!(get_gap_prop, get_gap, LayoutGapValue);
6004
6005// Grid properties
6006get_css_property_value!(
6007    get_grid_template_rows_prop,
6008    get_grid_template_rows,
6009    LayoutGridTemplateRowsValue
6010);
6011get_css_property_value!(
6012    get_grid_template_columns_prop,
6013    get_grid_template_columns,
6014    LayoutGridTemplateColumnsValue
6015);
6016get_css_property_value!(
6017    get_grid_auto_rows_prop,
6018    get_grid_auto_rows,
6019    LayoutGridAutoRowsValue
6020);
6021get_css_property_value!(
6022    get_grid_auto_columns_prop,
6023    get_grid_auto_columns,
6024    LayoutGridAutoColumnsValue
6025);
6026get_css_property_value!(
6027    get_grid_auto_flow_prop,
6028    get_grid_auto_flow,
6029    LayoutGridAutoFlowValue
6030);
6031get_css_property_value!(get_grid_column_prop, get_grid_column, LayoutGridColumnValue);
6032get_css_property_value!(get_grid_row_prop, get_grid_row, LayoutGridRowValue);
6033
6034/// Get grid-template-areas property.
6035///
6036/// Uses the generic `get_property()` since `CssPropertyCache` lacks a specific getter.
6037/// Returns the inner `GridTemplateAreas` value (already unwrapped from `CssPropertyValue`).
6038#[must_use] pub fn get_grid_template_areas_prop(
6039    styled_dom: &StyledDom,
6040    node_id: NodeId,
6041    node_state: &StyledNodeState,
6042) -> Option<GridTemplateAreas> {
6043    let node_data = &styled_dom.node_data.as_container()[node_id];
6044    styled_dom
6045        .css_property_cache
6046        .ptr
6047        .get_property(
6048            node_data,
6049            &node_id,
6050            node_state,
6051            &CssPropertyType::GridTemplateAreas,
6052        )
6053        .and_then(|p| {
6054            if let CssProperty::GridTemplateAreas(v) = p {
6055                v.get_property().cloned()
6056            } else {
6057                None
6058            }
6059        })
6060}
6061
6062/// Get clip-path property. Returns the `ClipPath` value for the node.
6063///
6064/// CSS Masking Module Level 1, section 3:
6065/// The clip-path property creates a clipping region that determines which parts
6066/// of an element are visible. Returns None for `clip-path: none` (default).
6067#[must_use] pub fn get_clip_path(
6068    styled_dom: &StyledDom,
6069    node_id: NodeId,
6070    node_state: &StyledNodeState,
6071) -> Option<azul_css::props::layout::shape::ClipPath> {
6072    // Negative fast path: most nodes have `clip-path: none`.
6073    if node_state.is_normal() {
6074        if let Some(ref cc) = styled_dom.css_property_cache.ptr.compact_cache {
6075            if !cc.has_clip_path(node_id.index()) {
6076                return None;
6077            }
6078        }
6079    }
6080    let node_data = &styled_dom.node_data.as_container()[node_id];
6081    styled_dom
6082        .css_property_cache
6083        .ptr
6084        .get_clip_path(node_data, &node_id, node_state)
6085        .and_then(|v| v.get_property())
6086        .cloned()
6087}
6088
6089#[cfg(test)]
6090#[allow(clippy::float_cmp, clippy::too_many_lines)]
6091mod autotest_generated {
6092    use azul_core::{dom::Dom, ua_css::ResolvedUaScrollbar};
6093    use azul_css::{
6094        css::Css,
6095        props::style::{
6096            background::StyleBackgroundContent,
6097            scrollbar::{ScrollbarColorCustom, ScrollbarFadeDelay, ScrollbarFadeDuration},
6098        },
6099    };
6100    use rust_fontconfig::{CssFallbackGroup, FontMatch};
6101
6102    use super::*;
6103
6104    // ---------------------------------------------------------------------
6105    // helpers
6106    // ---------------------------------------------------------------------
6107
6108    /// Every `LayoutOverflow` variant.
6109    const ALL_OVERFLOW: [LayoutOverflow; 5] = [
6110        LayoutOverflow::Scroll,
6111        LayoutOverflow::Auto,
6112        LayoutOverflow::Hidden,
6113        LayoutOverflow::Visible,
6114        LayoutOverflow::Clip,
6115    ];
6116
6117    /// Every `LayoutDisplay` variant.
6118    const ALL_DISPLAY: [LayoutDisplay; 23] = [
6119        LayoutDisplay::None,
6120        LayoutDisplay::Block,
6121        LayoutDisplay::Inline,
6122        LayoutDisplay::InlineBlock,
6123        LayoutDisplay::Flex,
6124        LayoutDisplay::InlineFlex,
6125        LayoutDisplay::Table,
6126        LayoutDisplay::InlineTable,
6127        LayoutDisplay::TableRowGroup,
6128        LayoutDisplay::TableHeaderGroup,
6129        LayoutDisplay::TableFooterGroup,
6130        LayoutDisplay::TableRow,
6131        LayoutDisplay::TableColumnGroup,
6132        LayoutDisplay::TableColumn,
6133        LayoutDisplay::TableCell,
6134        LayoutDisplay::TableCaption,
6135        LayoutDisplay::FlowRoot,
6136        LayoutDisplay::ListItem,
6137        LayoutDisplay::RunIn,
6138        LayoutDisplay::Marker,
6139        LayoutDisplay::Grid,
6140        LayoutDisplay::InlineGrid,
6141        LayoutDisplay::Contents,
6142    ];
6143
6144    /// Every `PageBreak` variant.
6145    const ALL_PAGE_BREAK: [PageBreak; 12] = [
6146        PageBreak::Auto,
6147        PageBreak::Avoid,
6148        PageBreak::Always,
6149        PageBreak::All,
6150        PageBreak::Page,
6151        PageBreak::AvoidPage,
6152        PageBreak::Left,
6153        PageBreak::Right,
6154        PageBreak::Recto,
6155        PageBreak::Verso,
6156        PageBreak::Column,
6157        PageBreak::AvoidColumn,
6158    ];
6159
6160    /// Every `BreakInside` variant.
6161    const ALL_BREAK_INSIDE: [BreakInside; 4] = [
6162        BreakInside::Auto,
6163        BreakInside::Avoid,
6164        BreakInside::AvoidPage,
6165        BreakInside::AvoidColumn,
6166    ];
6167
6168    /// Every `LayoutScrollbarWidth` variant.
6169    const ALL_SCROLLBAR_WIDTH: [LayoutScrollbarWidth; 3] = [
6170        LayoutScrollbarWidth::Auto,
6171        LayoutScrollbarWidth::Thin,
6172        LayoutScrollbarWidth::None,
6173    ];
6174
6175    /// Every `ScrollbarVisibilityMode` variant.
6176    const ALL_VISIBILITY: [ScrollbarVisibilityMode; 3] = [
6177        ScrollbarVisibilityMode::Always,
6178        ScrollbarVisibilityMode::WhenScrolling,
6179        ScrollbarVisibilityMode::Auto,
6180    ];
6181
6182    fn parse(css: &str) -> Css {
6183        azul_css::parser2::new_from_str(css).0
6184    }
6185
6186    /// `<body>` with `n` `<div>` children, cascaded against `css`.
6187    /// Node ids are pre-order: `0` = body, `1..=n` = the children.
6188    fn body_with_divs(n: usize, css: &str) -> StyledDom {
6189        let children: Vec<Dom> = (0..n).map(|_| Dom::create_div()).collect();
6190        let mut dom = Dom::create_body().with_children(children.into());
6191        StyledDom::create(&mut dom, parse(css))
6192    }
6193
6194    /// `<body>` with a single text child.
6195    fn body_with_text(text: &str) -> StyledDom {
6196        let mut dom = Dom::create_body().with_children(vec![Dom::create_text(text)].into());
6197        StyledDom::create(&mut dom, Css::empty())
6198    }
6199
6200    fn normal() -> StyledNodeState {
6201        StyledNodeState::default()
6202    }
6203
6204    /// A non-`Normal` pseudo-state; forces every getter off its compact-cache
6205    /// fast path and onto the full cascade walk.
6206    fn hovered() -> StyledNodeState {
6207        StyledNodeState {
6208            hover: true,
6209            ..StyledNodeState::default()
6210        }
6211    }
6212
6213    fn state_of(sd: &StyledDom, id: NodeId) -> StyledNodeState {
6214        sd.get_styled_node_state(&id)
6215    }
6216
6217    fn empty_chains() -> ResolvedFontChains {
6218        ResolvedFontChains {
6219            chains: HashMap::new(),
6220            ..Default::default()
6221        }
6222    }
6223
6224    fn chain_key(family: &str) -> FontChainKey {
6225        FontChainKey {
6226            font_families: vec![family.to_string()],
6227            weight: FcWeight::Normal,
6228            italic: false,
6229            oblique: false,
6230        }
6231    }
6232
6233    /// A `FontMatch` covering exactly `ranges`.
6234    fn font_match(id: u128, ranges: &[(u32, u32)]) -> FontMatch {
6235        FontMatch {
6236            id: FontId(id),
6237            unicode_ranges: ranges
6238                .iter()
6239                .map(|&(start, end)| UnicodeRange { start, end })
6240                .collect(),
6241            fallbacks: Vec::new(),
6242        }
6243    }
6244
6245    fn chain_with(groups: Vec<CssFallbackGroup>, unicode: Vec<FontMatch>) -> FontFallbackChain {
6246        FontFallbackChain {
6247            css_fallbacks: groups,
6248            unicode_fallbacks: unicode,
6249            original_stack: Vec::new(),
6250        }
6251    }
6252
6253    /// A `LayoutNode` carrying nothing but the `scrollbar_info` under test.
6254    fn bare_layout_node(scrollbar_info: Option<ScrollbarRequirements>) -> LayoutNode {
6255        use azul_core::{diff::NodeDataFingerprint, dom::FormattingContext};
6256
6257        use crate::solver3::{
6258            geometry::{BoxProps, UnresolvedBoxProps},
6259            layout_tree::{ComputedLayoutStyle, DirtyFlag, SubtreeHash},
6260        };
6261
6262        LayoutNode {
6263            box_props: BoxProps::default(),
6264            dom_node_id: None,
6265            children: Vec::new(),
6266            used_size: None,
6267            formatting_context: FormattingContext::Inline,
6268            parent: None,
6269            intrinsic_sizes: None,
6270            baseline: None,
6271            inline_layout_result: None,
6272            scrollbar_info,
6273            relative_position: None,
6274            overflow_content_size: None,
6275            taffy_cache: taffy::Cache::new(),
6276            computed_style: ComputedLayoutStyle::default(),
6277            pseudo_element: None,
6278            escaped_top_margin: None,
6279            escaped_bottom_margin: None,
6280            parent_formatting_context: None,
6281            ifc_membership: None,
6282            containing_block_index: None,
6283            anonymous_type: None,
6284            node_data_fingerprint: NodeDataFingerprint::default(),
6285            subtree_hash: SubtreeHash(0),
6286            dirty_flag: DirtyFlag::Layout,
6287            unresolved_box_props: UnresolvedBoxProps::default(),
6288            ifc_id: None,
6289        }
6290    }
6291
6292    // =====================================================================
6293    // MultiValue<T> — generic predicates and combinators
6294    // =====================================================================
6295
6296    #[test]
6297    fn multivalue_default_is_auto() {
6298        let v: MultiValue<i32> = MultiValue::default();
6299        assert!(v.is_auto());
6300        assert!(!v.is_exact());
6301    }
6302
6303    #[test]
6304    fn multivalue_is_auto_and_is_exact_are_mutually_exclusive() {
6305        let cases: [MultiValue<i32>; 4] = [
6306            MultiValue::Auto,
6307            MultiValue::Initial,
6308            MultiValue::Inherit,
6309            MultiValue::Exact(7),
6310        ];
6311        for v in cases {
6312            assert!(
6313                !(v.is_auto() && v.is_exact()),
6314                "a value cannot be both Auto and Exact: {v:?}"
6315            );
6316        }
6317        assert!(MultiValue::<i32>::Auto.is_auto());
6318        assert!(!MultiValue::<i32>::Initial.is_auto());
6319        assert!(!MultiValue::<i32>::Inherit.is_auto());
6320        assert!(!MultiValue::Exact(7).is_auto());
6321
6322        assert!(MultiValue::Exact(7).is_exact());
6323        assert!(!MultiValue::<i32>::Auto.is_exact());
6324        assert!(!MultiValue::<i32>::Initial.is_exact());
6325        assert!(!MultiValue::<i32>::Inherit.is_exact());
6326    }
6327
6328    #[test]
6329    fn multivalue_exact_returns_some_only_for_the_exact_variant() {
6330        assert_eq!(MultiValue::Exact(42_i32).exact(), Some(42));
6331        assert_eq!(MultiValue::<i32>::Auto.exact(), None);
6332        assert_eq!(MultiValue::<i32>::Initial.exact(), None);
6333        assert_eq!(MultiValue::<i32>::Inherit.exact(), None);
6334    }
6335
6336    #[test]
6337    fn multivalue_exact_round_trips_extreme_payloads() {
6338        // Boundary integers survive Exact() → exact() unchanged.
6339        for probe in [i32::MIN, -1, 0, 1, i32::MAX] {
6340            assert_eq!(MultiValue::Exact(probe).exact(), Some(probe));
6341        }
6342        // NaN is not equal to itself: assert the *shape*, not equality.
6343        let nan = MultiValue::Exact(f32::NAN).exact().unwrap();
6344        assert!(nan.is_nan());
6345        assert_eq!(MultiValue::Exact(f32::INFINITY).exact(), Some(f32::INFINITY));
6346        assert_eq!(
6347            MultiValue::Exact(f32::NEG_INFINITY).exact(),
6348            Some(f32::NEG_INFINITY)
6349        );
6350    }
6351
6352    #[test]
6353    fn multivalue_unwrap_or_uses_the_default_for_every_non_exact_variant() {
6354        assert_eq!(MultiValue::Exact(5_i32).unwrap_or(99), 5);
6355        assert_eq!(MultiValue::<i32>::Auto.unwrap_or(99), 99);
6356        assert_eq!(MultiValue::<i32>::Initial.unwrap_or(99), 99);
6357        assert_eq!(MultiValue::<i32>::Inherit.unwrap_or(99), 99);
6358        // The default is returned verbatim, even when it is a degenerate float.
6359        assert!(MultiValue::<f32>::Auto.unwrap_or(f32::NAN).is_nan());
6360    }
6361
6362    #[test]
6363    fn multivalue_unwrap_or_default_falls_back_to_t_default() {
6364        assert_eq!(MultiValue::Exact(5_i32).unwrap_or_default(), 5);
6365        assert_eq!(MultiValue::<i32>::Auto.unwrap_or_default(), 0);
6366        assert_eq!(MultiValue::<i32>::Initial.unwrap_or_default(), 0);
6367        assert_eq!(MultiValue::<i32>::Inherit.unwrap_or_default(), 0);
6368        // T = LayoutOverflow → Default is Visible (the CSS initial value).
6369        assert_eq!(
6370            MultiValue::<LayoutOverflow>::Inherit.unwrap_or_default(),
6371            LayoutOverflow::Visible
6372        );
6373    }
6374
6375    #[test]
6376    fn multivalue_map_transforms_exact_and_preserves_the_keyword_variants() {
6377        assert_eq!(MultiValue::Exact(2_i32).map(|v| v * 2), MultiValue::Exact(4));
6378        assert_eq!(MultiValue::<i32>::Auto.map(|v| v * 2), MultiValue::Auto);
6379        assert_eq!(
6380            MultiValue::<i32>::Initial.map(|v| v * 2),
6381            MultiValue::Initial
6382        );
6383        assert_eq!(
6384            MultiValue::<i32>::Inherit.map(|v| v * 2),
6385            MultiValue::Inherit
6386        );
6387    }
6388
6389    #[test]
6390    fn multivalue_map_never_invokes_the_closure_for_keyword_variants() {
6391        // A keyword variant carries no T, so the mapper must not be called at all.
6392        let auto: MultiValue<i32> = MultiValue::Auto;
6393        let _ = auto.map(|_| -> i32 { panic!("map() called f() on MultiValue::Auto") });
6394        let initial: MultiValue<i32> = MultiValue::Initial;
6395        let _ = initial.map(|_| -> i32 { panic!("map() called f() on MultiValue::Initial") });
6396        let inherit: MultiValue<i32> = MultiValue::Inherit;
6397        let _ = inherit.map(|_| -> i32 { panic!("map() called f() on MultiValue::Inherit") });
6398    }
6399
6400    #[test]
6401    fn multivalue_map_can_change_the_payload_type() {
6402        let mapped: MultiValue<usize> = MultiValue::Exact("hello").map(str::len);
6403        assert_eq!(mapped, MultiValue::Exact(5));
6404        // Overflow-adjacent payload: i32::MIN mapped to its (wrapping) absolute value
6405        // must not debug-panic inside map itself.
6406        let abs: MultiValue<i32> = MultiValue::Exact(i32::MIN).map(i32::wrapping_abs);
6407        assert_eq!(abs, MultiValue::Exact(i32::MIN));
6408    }
6409
6410    // =====================================================================
6411    // MultiValue<LayoutOverflow> — overflow predicates
6412    // =====================================================================
6413
6414    #[test]
6415    fn overflow_predicates_match_the_spec_for_every_exact_variant() {
6416        for o in ALL_OVERFLOW {
6417            let v = MultiValue::Exact(o);
6418            assert_eq!(
6419                v.is_clipped(),
6420                o != LayoutOverflow::Visible,
6421                "is_clipped is every value except Visible ({o:?})"
6422            );
6423            assert_eq!(
6424                v.is_scroll(),
6425                matches!(o, LayoutOverflow::Scroll | LayoutOverflow::Auto),
6426                "is_scroll ({o:?})"
6427            );
6428            assert_eq!(
6429                v.is_auto_overflow(),
6430                o == LayoutOverflow::Auto,
6431                "is_auto_overflow ({o:?})"
6432            );
6433            assert_eq!(
6434                v.is_hidden(),
6435                o == LayoutOverflow::Hidden,
6436                "is_hidden ({o:?})"
6437            );
6438            assert_eq!(
6439                v.is_hidden_or_clip(),
6440                matches!(o, LayoutOverflow::Hidden | LayoutOverflow::Clip),
6441                "is_hidden_or_clip ({o:?})"
6442            );
6443            assert_eq!(
6444                v.is_scroll_explicit(),
6445                o == LayoutOverflow::Scroll,
6446                "is_scroll_explicit ({o:?})"
6447            );
6448            assert_eq!(v.is_clip(), o == LayoutOverflow::Clip, "is_clip ({o:?})");
6449            assert_eq!(
6450                v.is_visible_or_clip(),
6451                matches!(o, LayoutOverflow::Visible | LayoutOverflow::Clip),
6452                "is_visible_or_clip ({o:?})"
6453            );
6454            assert_eq!(
6455                v.establishes_bfc(),
6456                matches!(
6457                    o,
6458                    LayoutOverflow::Hidden | LayoutOverflow::Scroll | LayoutOverflow::Auto
6459                ),
6460                "establishes_bfc ({o:?})"
6461            );
6462        }
6463    }
6464
6465    #[test]
6466    fn overflow_predicates_are_false_for_every_keyword_variant() {
6467        // Gotcha guard: MultiValue::Auto is the CSS *keyword* `auto`, which is NOT
6468        // the same thing as Exact(LayoutOverflow::Auto). None of the LayoutOverflow
6469        // predicates may fire for a keyword variant.
6470        let keywords: [MultiValue<LayoutOverflow>; 3] = [
6471            MultiValue::Auto,
6472            MultiValue::Initial,
6473            MultiValue::Inherit,
6474        ];
6475        for v in keywords {
6476            assert!(!v.is_clipped(), "{v:?}");
6477            assert!(!v.is_scroll(), "{v:?}");
6478            assert!(!v.is_auto_overflow(), "{v:?}");
6479            assert!(!v.is_hidden(), "{v:?}");
6480            assert!(!v.is_hidden_or_clip(), "{v:?}");
6481            assert!(!v.is_scroll_explicit(), "{v:?}");
6482            assert!(!v.is_clip(), "{v:?}");
6483            assert!(!v.is_visible_or_clip(), "{v:?}");
6484            // The unset/initial/inherit sentinel is `visible` (initial) => no BFC.
6485            assert!(!v.establishes_bfc(), "{v:?}");
6486        }
6487    }
6488
6489    #[test]
6490    fn overflow_scroll_implies_clipped_and_clip_implies_hidden_or_clip() {
6491        for o in ALL_OVERFLOW {
6492            let v = MultiValue::Exact(o);
6493            assert!(
6494                !v.is_scroll() || v.is_clipped(),
6495                "anything that scrolls also clips ({o:?})"
6496            );
6497            assert!(
6498                !v.is_clip() || v.is_hidden_or_clip(),
6499                "clip is a subset of hidden_or_clip ({o:?})"
6500            );
6501            assert!(
6502                !v.is_scroll_explicit() || v.is_scroll(),
6503                "explicit scroll is a subset of scroll ({o:?})"
6504            );
6505        }
6506    }
6507
6508    /// Exercises the rule tagged `+spec:overflow:833078` on `resolve_computed`.
6509    #[test]
6510    fn overflow_resolve_computed_matches_css_overflow_3_section_3_1() {
6511        for this in ALL_OVERFLOW {
6512            for other in ALL_OVERFLOW {
6513                let got = MultiValue::Exact(this).resolve_computed(&MultiValue::Exact(other));
6514                let other_is_scrollable =
6515                    !matches!(other, LayoutOverflow::Visible | LayoutOverflow::Clip);
6516                let want = if other_is_scrollable {
6517                    match this {
6518                        LayoutOverflow::Visible => LayoutOverflow::Auto,
6519                        LayoutOverflow::Clip => LayoutOverflow::Hidden,
6520                        keep => keep,
6521                    }
6522                } else {
6523                    this
6524                };
6525                assert_eq!(
6526                    got,
6527                    MultiValue::Exact(want),
6528                    "resolve_computed({this:?}, {other:?})"
6529                );
6530            }
6531        }
6532    }
6533
6534    #[test]
6535    fn overflow_resolve_computed_is_a_no_op_unless_both_axes_are_exact() {
6536        let keywords: [MultiValue<LayoutOverflow>; 3] = [
6537            MultiValue::Auto,
6538            MultiValue::Initial,
6539            MultiValue::Inherit,
6540        ];
6541        // Non-Exact self → returned unchanged, whatever the other axis is.
6542        for v in keywords {
6543            for other in ALL_OVERFLOW {
6544                assert_eq!(v.resolve_computed(&MultiValue::Exact(other)), v);
6545            }
6546            assert_eq!(v.resolve_computed(&MultiValue::Auto), v);
6547        }
6548        // Non-Exact *other* axis → self is returned unchanged, no blockification.
6549        for this in ALL_OVERFLOW {
6550            let v = MultiValue::Exact(this);
6551            for other in keywords {
6552                assert_eq!(v.resolve_computed(&other), v, "{this:?} vs {other:?}");
6553            }
6554        }
6555    }
6556
6557    #[test]
6558    fn overflow_resolve_computed_is_idempotent() {
6559        for this in ALL_OVERFLOW {
6560            for other in ALL_OVERFLOW {
6561                let other_mv = MultiValue::Exact(other);
6562                let once = MultiValue::Exact(this).resolve_computed(&other_mv);
6563                let twice = once.resolve_computed(&other_mv);
6564                assert_eq!(once, twice, "resolve_computed({this:?}, {other:?}) twice");
6565            }
6566        }
6567    }
6568
6569    // =====================================================================
6570    // MultiValue<LayoutPosition> / MultiValue<LayoutFloat>
6571    // =====================================================================
6572
6573    #[test]
6574    fn position_is_absolute_or_fixed_only_for_absolute_and_fixed() {
6575        let all = [
6576            LayoutPosition::Static,
6577            LayoutPosition::Relative,
6578            LayoutPosition::Absolute,
6579            LayoutPosition::Fixed,
6580            LayoutPosition::Sticky,
6581        ];
6582        for p in all {
6583            assert_eq!(
6584                MultiValue::Exact(p).is_absolute_or_fixed(),
6585                matches!(p, LayoutPosition::Absolute | LayoutPosition::Fixed),
6586                "{p:?}"
6587            );
6588        }
6589        // Keyword variants carry no position → never out-of-flow.
6590        assert!(!MultiValue::<LayoutPosition>::Auto.is_absolute_or_fixed());
6591        assert!(!MultiValue::<LayoutPosition>::Initial.is_absolute_or_fixed());
6592        assert!(!MultiValue::<LayoutPosition>::Inherit.is_absolute_or_fixed());
6593    }
6594
6595    #[test]
6596    fn float_is_none_treats_every_keyword_variant_as_not_floated() {
6597        assert!(MultiValue::Exact(LayoutFloat::None).is_none());
6598        assert!(!MultiValue::Exact(LayoutFloat::Left).is_none());
6599        assert!(!MultiValue::Exact(LayoutFloat::Right).is_none());
6600        // Unlike the overflow predicates, `is_none` deliberately folds the keyword
6601        // variants in: an unset float is not a float.
6602        assert!(MultiValue::<LayoutFloat>::Auto.is_none());
6603        assert!(MultiValue::<LayoutFloat>::Initial.is_none());
6604        assert!(MultiValue::<LayoutFloat>::Inherit.is_none());
6605        assert!(MultiValue::<LayoutFloat>::default().is_none());
6606    }
6607
6608    // =====================================================================
6609    // blockify_display / get_computed_display
6610    // =====================================================================
6611
6612    #[test]
6613    fn blockify_display_follows_the_css_display_3_table() {
6614        for d in ALL_DISPLAY {
6615            let want = match d {
6616                LayoutDisplay::Inline | LayoutDisplay::InlineBlock => LayoutDisplay::Block,
6617                LayoutDisplay::InlineFlex => LayoutDisplay::Flex,
6618                LayoutDisplay::InlineTable => LayoutDisplay::Table,
6619                LayoutDisplay::InlineGrid => LayoutDisplay::Grid,
6620                LayoutDisplay::TableRowGroup
6621                | LayoutDisplay::TableColumn
6622                | LayoutDisplay::TableColumnGroup
6623                | LayoutDisplay::TableHeaderGroup
6624                | LayoutDisplay::TableFooterGroup
6625                | LayoutDisplay::TableRow
6626                | LayoutDisplay::TableCell
6627                | LayoutDisplay::TableCaption => LayoutDisplay::Block,
6628                other => other,
6629            };
6630            assert_eq!(blockify_display(d), want, "blockify_display({d:?})");
6631        }
6632    }
6633
6634    #[test]
6635    fn blockify_display_is_idempotent_and_never_produces_an_inline_level_value() {
6636        for d in ALL_DISPLAY {
6637            let once = blockify_display(d);
6638            assert_eq!(
6639                blockify_display(once),
6640                once,
6641                "blockify_display is not idempotent for {d:?}"
6642            );
6643            assert!(
6644                !matches!(
6645                    once,
6646                    LayoutDisplay::Inline
6647                        | LayoutDisplay::InlineBlock
6648                        | LayoutDisplay::InlineFlex
6649                        | LayoutDisplay::InlineTable
6650                        | LayoutDisplay::InlineGrid
6651                ),
6652                "blockified {d:?} is still inline-level: {once:?}"
6653            );
6654        }
6655    }
6656
6657    #[test]
6658    fn get_computed_display_keeps_none_regardless_of_the_flags() {
6659        // display:none boxes are never generated, so no flag may resurrect them.
6660        for flags in 0_u8..16 {
6661            let got = get_computed_display(
6662                LayoutDisplay::None,
6663                flags & 1 != 0,
6664                flags & 2 != 0,
6665                flags & 4 != 0,
6666                flags & 8 != 0,
6667            );
6668            assert_eq!(got, LayoutDisplay::None, "flags={flags:#06b}");
6669        }
6670    }
6671
6672    #[test]
6673    fn get_computed_display_is_the_identity_when_no_flag_is_set() {
6674        for d in ALL_DISPLAY {
6675            assert_eq!(
6676                get_computed_display(d, false, false, false, false),
6677                d,
6678                "an in-flow, non-root, non-flex-child box keeps its specified display ({d:?})"
6679            );
6680        }
6681    }
6682
6683    #[test]
6684    fn get_computed_display_blockifies_whenever_any_flag_is_set() {
6685        for d in ALL_DISPLAY {
6686            if d == LayoutDisplay::None {
6687                continue; // covered by the dedicated None test
6688            }
6689            // Each of the four flags on its own must blockify, and so must every
6690            // combination of them.
6691            for flags in 1_u8..16 {
6692                let got = get_computed_display(
6693                    d,
6694                    flags & 1 != 0,
6695                    flags & 2 != 0,
6696                    flags & 4 != 0,
6697                    flags & 8 != 0,
6698                );
6699                assert_eq!(
6700                    got,
6701                    blockify_display(d),
6702                    "get_computed_display({d:?}, flags={flags:#06b})"
6703                );
6704            }
6705        }
6706    }
6707
6708    // =====================================================================
6709    // Fragmentation predicates
6710    // =====================================================================
6711
6712    #[test]
6713    fn is_forced_page_break_covers_exactly_the_forcing_keywords() {
6714        for pb in ALL_PAGE_BREAK {
6715            let want = matches!(
6716                pb,
6717                PageBreak::Always
6718                    | PageBreak::Page
6719                    | PageBreak::Left
6720                    | PageBreak::Right
6721                    | PageBreak::Recto
6722                    | PageBreak::Verso
6723                    | PageBreak::All
6724            );
6725            assert_eq!(is_forced_page_break(pb), want, "{pb:?}");
6726        }
6727        // `column` forces a *column* break, not a page break.
6728        assert!(!is_forced_page_break(PageBreak::Column));
6729        assert!(!is_forced_page_break(PageBreak::Auto));
6730        assert!(!is_forced_page_break(PageBreak::default()));
6731    }
6732
6733    #[test]
6734    fn is_avoid_page_break_covers_exactly_avoid_and_avoid_page() {
6735        for pb in ALL_PAGE_BREAK {
6736            let want = matches!(pb, PageBreak::Avoid | PageBreak::AvoidPage);
6737            assert_eq!(is_avoid_page_break(&pb), want, "{pb:?}");
6738        }
6739        // `avoid-column` avoids a column break, not a page break.
6740        assert!(!is_avoid_page_break(&PageBreak::AvoidColumn));
6741    }
6742
6743    #[test]
6744    fn forced_and_avoid_page_break_are_never_both_true() {
6745        for pb in ALL_PAGE_BREAK {
6746            assert!(
6747                !(is_forced_page_break(pb) && is_avoid_page_break(&pb)),
6748                "{pb:?} is simultaneously forced and avoided"
6749            );
6750        }
6751    }
6752
6753    #[test]
6754    fn is_avoid_break_inside_is_true_for_every_variant_except_auto() {
6755        for bi in ALL_BREAK_INSIDE {
6756            assert_eq!(is_avoid_break_inside(&bi), bi != BreakInside::Auto, "{bi:?}");
6757        }
6758        assert!(!is_avoid_break_inside(&BreakInside::default()));
6759    }
6760
6761    // =====================================================================
6762    // ComputedScrollbarStyle::from_ua_resolved
6763    // =====================================================================
6764
6765    fn ua(
6766        width: LayoutScrollbarWidth,
6767        visibility: ScrollbarVisibilityMode,
6768        color: StyleScrollbarColor,
6769        delay_ms: u32,
6770        duration_ms: u32,
6771    ) -> ResolvedUaScrollbar {
6772        ResolvedUaScrollbar {
6773            color,
6774            width,
6775            visibility,
6776            fade_delay: ScrollbarFadeDelay { ms: delay_ms },
6777            fade_duration: ScrollbarFadeDuration { ms: duration_ms },
6778        }
6779    }
6780
6781    #[test]
6782    fn from_ua_resolved_holds_its_invariants_across_the_whole_width_visibility_matrix() {
6783        for width in ALL_SCROLLBAR_WIDTH {
6784            for visibility in ALL_VISIBILITY {
6785                let s = ComputedScrollbarStyle::from_ua_resolved(&ua(
6786                    width,
6787                    visibility,
6788                    StyleScrollbarColor::Auto,
6789                    0,
6790                    0,
6791                ));
6792
6793                assert_eq!(s.width_mode, width);
6794                assert_eq!(s.visibility, visibility);
6795
6796                let expected_visual = match width {
6797                    LayoutScrollbarWidth::Thin => SCROLLBAR_WIDTH_THIN,
6798                    LayoutScrollbarWidth::Auto => SCROLLBAR_WIDTH_AUTO,
6799                    LayoutScrollbarWidth::None => 0.0,
6800                };
6801                assert_eq!(s.visual_width_px, expected_visual, "{width:?}");
6802
6803                // Only `WhenScrolling` is an overlay scrollbar. `Auto` is NOT.
6804                let is_overlay = visibility == ScrollbarVisibilityMode::WhenScrolling;
6805                assert_eq!(s.clip_to_container_border, is_overlay);
6806                assert_eq!(s.show_scroll_buttons, !is_overlay);
6807                assert_eq!(s.show_corner_rect, !is_overlay);
6808                if is_overlay {
6809                    assert_eq!(s.reserve_width_px, 0.0, "overlay reserves no layout space");
6810                    assert_eq!(s.scroll_button_size_px, 0.0);
6811                } else {
6812                    assert_eq!(s.reserve_width_px, s.visual_width_px);
6813                    assert_eq!(s.scroll_button_size_px, s.visual_width_px);
6814                }
6815
6816                // Hover/active widths are always the visual width plus the expand delta.
6817                assert_eq!(
6818                    s.visual_width_px_hover,
6819                    Some(s.visual_width_px + SCROLLBAR_HOVER_EXPAND_PX)
6820                );
6821                assert_eq!(
6822                    s.visual_width_px_active,
6823                    Some(s.visual_width_px + SCROLLBAR_HOVER_EXPAND_PX)
6824                );
6825                assert!(s.reserve_width_px <= s.visual_width_px);
6826                assert!(s.visual_width_px.is_finite());
6827            }
6828        }
6829    }
6830
6831    #[test]
6832    fn from_ua_resolved_saturates_the_hover_and_active_colour_maths_at_the_u8_boundaries() {
6833        // Max channels: +30 lighten / +40 alpha must saturate, not wrap or panic.
6834        let white = ColorU {
6835            r: 255,
6836            g: 255,
6837            b: 255,
6838            a: 255,
6839        };
6840        let s = ComputedScrollbarStyle::from_ua_resolved(&ua(
6841            LayoutScrollbarWidth::Auto,
6842            ScrollbarVisibilityMode::Always,
6843            StyleScrollbarColor::Custom(ScrollbarColorCustom {
6844                thumb: white,
6845                track: white,
6846            }),
6847            0,
6848            0,
6849        ));
6850        let hover = s.thumb_color_hover.expect("hover thumb colour");
6851        assert_eq!((hover.r, hover.g, hover.b, hover.a), (255, 255, 255, 255));
6852        let track_hover = s.track_color_hover.expect("hover track colour");
6853        assert_eq!(track_hover.a, 255);
6854
6855        // Min channels: -15 darken must saturate at 0, and the active alpha is pinned
6856        // to 255 regardless of the source alpha.
6857        let black0 = ColorU {
6858            r: 0,
6859            g: 0,
6860            b: 0,
6861            a: 0,
6862        };
6863        let s = ComputedScrollbarStyle::from_ua_resolved(&ua(
6864            LayoutScrollbarWidth::Auto,
6865            ScrollbarVisibilityMode::Always,
6866            StyleScrollbarColor::Custom(ScrollbarColorCustom {
6867                thumb: black0,
6868                track: black0,
6869            }),
6870            0,
6871            0,
6872        ));
6873        let active = s.thumb_color_active.expect("active thumb colour");
6874        assert_eq!((active.r, active.g, active.b), (0, 0, 0));
6875        assert_eq!(active.a, 255, "the active thumb is always fully opaque");
6876        let hover = s.thumb_color_hover.expect("hover thumb colour");
6877        assert_eq!(
6878            (hover.r, hover.g, hover.b, hover.a),
6879            (
6880                THUMB_HOVER_LIGHTEN,
6881                THUMB_HOVER_LIGHTEN,
6882                THUMB_HOVER_LIGHTEN,
6883                THUMB_HOVER_ALPHA_ADD
6884            )
6885        );
6886    }
6887
6888    #[test]
6889    fn from_ua_resolved_passes_extreme_fade_timings_through_without_overflow() {
6890        let s = ComputedScrollbarStyle::from_ua_resolved(&ua(
6891            LayoutScrollbarWidth::Thin,
6892            ScrollbarVisibilityMode::WhenScrolling,
6893            StyleScrollbarColor::Auto,
6894            u32::MAX,
6895            u32::MAX,
6896        ));
6897        assert_eq!(s.fade_delay_ms, u32::MAX);
6898        assert_eq!(s.fade_duration_ms, u32::MAX);
6899
6900        let s = ComputedScrollbarStyle::from_ua_resolved(&ua(
6901            LayoutScrollbarWidth::Thin,
6902            ScrollbarVisibilityMode::WhenScrolling,
6903            StyleScrollbarColor::Auto,
6904            0,
6905            0,
6906        ));
6907        assert_eq!(s.fade_delay_ms, 0);
6908        assert_eq!(s.fade_duration_ms, 0);
6909    }
6910
6911    #[test]
6912    fn from_ua_resolved_maps_scrollbar_color_auto_to_transparent() {
6913        let s = ComputedScrollbarStyle::from_ua_resolved(&ua(
6914            LayoutScrollbarWidth::Auto,
6915            ScrollbarVisibilityMode::Always,
6916            StyleScrollbarColor::Auto,
6917            0,
6918            0,
6919        ));
6920        assert_eq!(s.thumb_color, ColorU::TRANSPARENT);
6921        assert_eq!(s.track_color, ColorU::TRANSPARENT);
6922        assert_eq!(s.button_color, ColorU::TRANSPARENT);
6923        assert_eq!(s.corner_color, ColorU::TRANSPARENT);
6924    }
6925
6926    #[test]
6927    fn computed_scrollbar_style_default_is_internally_consistent() {
6928        let d = ComputedScrollbarStyle::default();
6929        assert!(d.visual_width_px.is_finite() && d.visual_width_px >= 0.0);
6930        assert!(d.reserve_width_px.is_finite() && d.reserve_width_px >= 0.0);
6931        assert!(d.reserve_width_px <= d.visual_width_px);
6932        let overlay = d.visibility == ScrollbarVisibilityMode::WhenScrolling;
6933        assert_eq!(d.show_scroll_buttons, !overlay);
6934        assert_eq!(d.clip_to_container_border, overlay);
6935    }
6936
6937    // =====================================================================
6938    // extract_color_from_background
6939    // =====================================================================
6940
6941    #[test]
6942    fn extract_color_from_background_returns_the_solid_colour_verbatim() {
6943        for probe in [
6944            ColorU::TRANSPARENT,
6945            ColorU::BLACK,
6946            ColorU::WHITE,
6947            ColorU {
6948                r: 1,
6949                g: 2,
6950                b: 3,
6951                a: 4,
6952            },
6953            ColorU {
6954                r: 255,
6955                g: 0,
6956                b: 255,
6957                a: 0,
6958            },
6959        ] {
6960            assert_eq!(
6961                extract_color_from_background(&StyleBackgroundContent::Color(probe)),
6962                probe
6963            );
6964        }
6965    }
6966
6967    #[test]
6968    fn extract_color_from_background_falls_back_to_transparent_for_non_colour_layers() {
6969        // An image layer has no solid colour to extract → transparent, not a panic.
6970        let img = StyleBackgroundContent::Image("does-not-exist.png".into());
6971        assert_eq!(extract_color_from_background(&img), ColorU::TRANSPARENT);
6972        // Empty / unicode image names are still just "not a colour".
6973        let empty = StyleBackgroundContent::Image(String::new().into());
6974        assert_eq!(extract_color_from_background(&empty), ColorU::TRANSPARENT);
6975        let unicode = StyleBackgroundContent::Image("картинка-🎉.png".into());
6976        assert_eq!(extract_color_from_background(&unicode), ColorU::TRANSPARENT);
6977    }
6978
6979    // =====================================================================
6980    // get_scrollbar_info_from_layout
6981    // =====================================================================
6982
6983    #[test]
6984    fn get_scrollbar_info_from_layout_defaults_to_no_scrollbars_when_layout_never_set_it() {
6985        let node = bare_layout_node(None);
6986        let got = get_scrollbar_info_from_layout(&node);
6987        assert!(!got.needs_horizontal);
6988        assert!(!got.needs_vertical);
6989        assert_eq!(got.scrollbar_width, 0.0);
6990        assert_eq!(got.scrollbar_height, 0.0);
6991        assert_eq!(got.visual_width_px, 0.0);
6992    }
6993
6994    #[test]
6995    fn get_scrollbar_info_from_layout_returns_whatever_layout_stored_including_degenerate_floats() {
6996        let stored = ScrollbarRequirements {
6997            needs_horizontal: true,
6998            needs_vertical: true,
6999            scrollbar_width: f32::NAN,
7000            scrollbar_height: f32::INFINITY,
7001            visual_width_px: -1.0,
7002        };
7003        let got = get_scrollbar_info_from_layout(&bare_layout_node(Some(stored)));
7004        assert!(got.needs_horizontal && got.needs_vertical);
7005        assert!(got.scrollbar_width.is_nan(), "the getter must not sanitise");
7006        assert_eq!(got.scrollbar_height, f32::INFINITY);
7007        assert_eq!(got.visual_width_px, -1.0);
7008    }
7009
7010    // =====================================================================
7011    // ResolvedFontChains
7012    // =====================================================================
7013
7014    #[test]
7015    fn resolved_font_chains_empty_instance_answers_every_query_with_none() {
7016        let r = empty_chains();
7017        assert_eq!(r.len(), 0);
7018        assert!(r.is_empty());
7019        assert_eq!(r.font_refs_len(), 0);
7020
7021        assert!(r.get(&FontChainKeyOrRef::Ref(0)).is_none());
7022        assert!(r.get_by_chain_key(&chain_key("Arial")).is_none());
7023        assert!(r.get_for_font_stack(&[]).is_none());
7024        assert!(r.get_for_font_ref(0).is_none());
7025        assert!(r.get_for_font_ref(usize::MAX).is_none());
7026        assert!(r.get_for_font_ref(usize::MAX / 2).is_none());
7027
7028        assert!(r.clone().into_inner().is_empty());
7029        assert!(r.into_fontconfig_chains().is_empty());
7030    }
7031
7032    #[test]
7033    fn resolved_font_chains_get_by_chain_key_round_trips_the_inserted_key() {
7034        let key = chain_key("Iosevka");
7035        let mut chains = HashMap::new();
7036        chains.insert(
7037            FontChainKeyOrRef::Chain(key.clone()),
7038            chain_with(Vec::new(), Vec::new()),
7039        );
7040        let r = ResolvedFontChains { chains, ..Default::default() };
7041
7042        assert!(r.get_by_chain_key(&key).is_some());
7043        assert!(r.get(&FontChainKeyOrRef::Chain(key.clone())).is_some());
7044        // A key that differs only in weight is a different key.
7045        let heavier = FontChainKey {
7046            weight: FcWeight::Bold,
7047            ..key.clone()
7048        };
7049        assert!(r.get_by_chain_key(&heavier).is_none());
7050        // …and so is one that differs only in the italic flag.
7051        let italic = FontChainKey {
7052            italic: true,
7053            ..key
7054        };
7055        assert!(r.get_by_chain_key(&italic).is_none());
7056    }
7057
7058    #[test]
7059    fn resolved_font_chains_counts_and_filters_ref_entries() {
7060        let mut chains = HashMap::new();
7061        chains.insert(
7062            FontChainKeyOrRef::Chain(chain_key("Arial")),
7063            chain_with(Vec::new(), Vec::new()),
7064        );
7065        chains.insert(
7066            FontChainKeyOrRef::Ref(0xDEAD_BEEF),
7067            chain_with(Vec::new(), Vec::new()),
7068        );
7069        chains.insert(
7070            FontChainKeyOrRef::Ref(usize::MAX),
7071            chain_with(Vec::new(), Vec::new()),
7072        );
7073        let r = ResolvedFontChains { chains, ..Default::default() };
7074
7075        assert_eq!(r.len(), 3);
7076        assert!(!r.is_empty());
7077        assert_eq!(r.font_refs_len(), 2, "two Ref keys, one Chain key");
7078        assert!(r.get_for_font_ref(0xDEAD_BEEF).is_some());
7079        assert!(r.get_for_font_ref(usize::MAX).is_some());
7080        assert!(r.get_for_font_ref(0).is_none());
7081
7082        // into_fontconfig_chains drops every Ref entry.
7083        let fc_only = r.into_fontconfig_chains();
7084        assert_eq!(fc_only.len(), 1);
7085        assert!(fc_only.contains_key(&chain_key("Arial")));
7086    }
7087
7088    #[test]
7089    fn resolved_font_chains_get_for_font_stack_uses_the_canonical_selector_key() {
7090        let selectors = vec![FontSelector {
7091            family: "Arial".to_string(),
7092            weight: FcWeight::Normal,
7093            style: FontStyle::Normal,
7094            unicode_ranges: Vec::new(),
7095        }];
7096        let key = FontChainKey::from_selectors(&selectors);
7097        let mut chains = HashMap::new();
7098        chains.insert(
7099            FontChainKeyOrRef::Chain(key),
7100            chain_with(Vec::new(), Vec::new()),
7101        );
7102        let r = ResolvedFontChains { chains, ..Default::default() };
7103
7104        assert!(r.get_for_font_stack(&selectors).is_some());
7105        // An empty stack must not accidentally alias the "Arial" key.
7106        assert!(r.get_for_font_stack(&[]).is_none());
7107    }
7108
7109    // =====================================================================
7110    // collect_font_ids_from_chains / compute_fonts_to_load
7111    // =====================================================================
7112
7113    #[test]
7114    fn collect_font_ids_from_chains_dedupes_across_groups_and_unicode_fallbacks() {
7115        let mut chains = HashMap::new();
7116        chains.insert(
7117            FontChainKeyOrRef::Chain(chain_key("Arial")),
7118            chain_with(
7119                vec![
7120                    CssFallbackGroup {
7121                        css_name: "Arial".to_string(),
7122                        fonts: vec![font_match(1, &[]), font_match(2, &[])],
7123                    },
7124                    CssFallbackGroup {
7125                        css_name: "sans-serif".to_string(),
7126                        // FontId(1) also appears in the first group.
7127                        fonts: vec![font_match(1, &[]), font_match(3, &[])],
7128                    },
7129                ],
7130                vec![font_match(3, &[]), font_match(u128::MAX, &[])],
7131            ),
7132        );
7133        let ids = collect_font_ids_from_chains(&ResolvedFontChains { chains, ..Default::default() });
7134        assert_eq!(ids.len(), 4, "ids 1, 2, 3 and u128::MAX, each exactly once");
7135        for probe in [1_u128, 2, 3, u128::MAX] {
7136            assert!(ids.contains(&FontId(probe)), "missing FontId({probe})");
7137        }
7138        assert!(!ids.contains(&FontId(0)));
7139    }
7140
7141    #[test]
7142    fn collect_font_ids_from_chains_returns_empty_for_an_empty_or_fontless_chain_set() {
7143        assert!(collect_font_ids_from_chains(&empty_chains()).is_empty());
7144
7145        // A chain that exists but carries no fonts at all (the empty-fc_cache result).
7146        let mut chains = HashMap::new();
7147        chains.insert(
7148            FontChainKeyOrRef::Chain(chain_key("Nonexistent")),
7149            chain_with(
7150                vec![CssFallbackGroup {
7151                    css_name: "Nonexistent".to_string(),
7152                    fonts: Vec::new(),
7153                }],
7154                Vec::new(),
7155            ),
7156        );
7157        assert!(collect_font_ids_from_chains(&ResolvedFontChains { chains, ..Default::default() }).is_empty());
7158    }
7159
7160    #[test]
7161    fn compute_fonts_to_load_is_the_set_difference_and_bails_early_on_an_empty_requirement() {
7162        let a = FontId(0);
7163        let b = FontId(1);
7164        let c = FontId(u128::MAX);
7165
7166        let empty: HashSet<FontId> = HashSet::new();
7167        let all: HashSet<FontId> = [a, b, c].into_iter().collect();
7168        let loaded_b: HashSet<FontId> = [b].into_iter().collect();
7169
7170        // Nothing required → nothing to load, regardless of what is loaded.
7171        assert!(compute_fonts_to_load(&empty, &empty).is_empty());
7172        assert!(compute_fonts_to_load(&empty, &all).is_empty());
7173
7174        // Nothing loaded → load everything.
7175        assert_eq!(compute_fonts_to_load(&all, &empty), all);
7176
7177        // Partial overlap → only the missing ones.
7178        let todo = compute_fonts_to_load(&all, &loaded_b);
7179        assert_eq!(todo.len(), 2);
7180        assert!(todo.contains(&a) && todo.contains(&c));
7181        assert!(!todo.contains(&b));
7182
7183        // Already-loaded is a superset → nothing to do (and no underflow).
7184        assert!(compute_fonts_to_load(&loaded_b, &all).is_empty());
7185        assert!(compute_fonts_to_load(&all, &all).is_empty());
7186    }
7187
7188    // =====================================================================
7189    // prune_chain_to_used_chars
7190    // =====================================================================
7191
7192    #[test]
7193    fn prune_chain_to_used_chars_keeps_the_first_match_of_every_group_when_nothing_is_needed() {
7194        let mut chain = chain_with(
7195            vec![
7196                CssFallbackGroup {
7197                    css_name: "A".to_string(),
7198                    fonts: vec![font_match(1, &[(0, 0x10_FFFF)]), font_match(2, &[]), font_match(3, &[])],
7199                },
7200                CssFallbackGroup {
7201                    css_name: "B".to_string(),
7202                    fonts: vec![font_match(4, &[]), font_match(5, &[])],
7203                },
7204            ],
7205            vec![font_match(6, &[(0x4E00, 0x9FFF)])],
7206        );
7207
7208        prune_chain_to_used_chars(&mut chain, &std::collections::BTreeSet::new());
7209
7210        // Nothing to cover → every group collapses to its single best match…
7211        assert_eq!(chain.css_fallbacks[0].fonts.len(), 1);
7212        assert_eq!(chain.css_fallbacks[0].fonts[0].id, FontId(1));
7213        assert_eq!(chain.css_fallbacks[1].fonts.len(), 1);
7214        assert_eq!(chain.css_fallbacks[1].fonts[0].id, FontId(4));
7215        // …and no unicode fallback can intersect an empty codepoint set.
7216        assert!(chain.unicode_fallbacks.is_empty());
7217    }
7218
7219    #[test]
7220    fn prune_chain_to_used_chars_keeps_walking_until_every_codepoint_is_covered() {
7221        // 'é' (U+00E9) is only covered by the *second* font in the group.
7222        let mut chain = chain_with(
7223            vec![CssFallbackGroup {
7224                css_name: "A".to_string(),
7225                fonts: vec![
7226                    font_match(1, &[(0x20, 0x7F)]),   // ASCII only
7227                    font_match(2, &[(0x80, 0x24F)]),  // Latin-1 supplement + extended
7228                    font_match(3, &[(0x0, 0x10_FFFF)]), // everything (must be dropped)
7229                ],
7230            }],
7231            Vec::new(),
7232        );
7233        let used: std::collections::BTreeSet<u32> = [0xE9_u32].into_iter().collect();
7234
7235        prune_chain_to_used_chars(&mut chain, &used);
7236
7237        assert_eq!(
7238            chain.css_fallbacks[0].fonts.len(),
7239            2,
7240            "walk stops as soon as the needed codepoints are covered"
7241        );
7242        assert_eq!(chain.css_fallbacks[0].fonts[1].id, FontId(2));
7243    }
7244
7245    #[test]
7246    fn prune_chain_to_used_chars_keeps_the_whole_group_when_nothing_ever_covers_the_codepoint() {
7247        let mut chain = chain_with(
7248            vec![CssFallbackGroup {
7249                css_name: "A".to_string(),
7250                fonts: vec![font_match(1, &[(0x20, 0x7F)]), font_match(2, &[(0x20, 0x7F)])],
7251            }],
7252            vec![font_match(3, &[(0x20, 0x7F)])],
7253        );
7254        // A codepoint no font claims — and the numeric boundary of the u32 space.
7255        let used: std::collections::BTreeSet<u32> = [u32::MAX].into_iter().collect();
7256
7257        prune_chain_to_used_chars(&mut chain, &used);
7258
7259        assert_eq!(
7260            chain.css_fallbacks[0].fonts.len(),
7261            2,
7262            "an uncoverable codepoint must not silently drop CSS fonts"
7263        );
7264        assert!(
7265            chain.unicode_fallbacks.is_empty(),
7266            "no unicode fallback intersects U+FFFFFFFF"
7267        );
7268    }
7269
7270    #[test]
7271    fn prune_chain_to_used_chars_treats_unicode_ranges_as_inclusive_on_both_ends() {
7272        for probe in [0x4E00_u32, 0x9FFF] {
7273            let mut chain = chain_with(
7274                Vec::new(),
7275                vec![font_match(1, &[(0x4E00, 0x9FFF)]), font_match(2, &[(0x20, 0x7F)])],
7276            );
7277            let used: std::collections::BTreeSet<u32> = [probe].into_iter().collect();
7278            prune_chain_to_used_chars(&mut chain, &used);
7279            assert_eq!(
7280                chain.unicode_fallbacks.len(),
7281                1,
7282                "U+{probe:04X} is inside the inclusive CJK range"
7283            );
7284            assert_eq!(chain.unicode_fallbacks[0].id, FontId(1));
7285        }
7286        // One past each end of the range → no intersection.
7287        for probe in [0x4DFF_u32, 0xA000] {
7288            let mut chain = chain_with(Vec::new(), vec![font_match(1, &[(0x4E00, 0x9FFF)])]);
7289            let used: std::collections::BTreeSet<u32> = [probe].into_iter().collect();
7290            prune_chain_to_used_chars(&mut chain, &used);
7291            assert!(chain.unicode_fallbacks.is_empty(), "U+{probe:04X}");
7292        }
7293    }
7294
7295    #[test]
7296    fn prune_chain_to_used_chars_survives_empty_chains_and_empty_groups() {
7297        let mut empty = chain_with(Vec::new(), Vec::new());
7298        prune_chain_to_used_chars(&mut empty, &std::collections::BTreeSet::new());
7299        assert!(empty.css_fallbacks.is_empty());
7300        assert!(empty.unicode_fallbacks.is_empty());
7301
7302        // A group with zero fonts is skipped rather than truncated to a phantom entry.
7303        let mut fontless = chain_with(
7304            vec![CssFallbackGroup {
7305                css_name: "A".to_string(),
7306                fonts: Vec::new(),
7307            }],
7308            Vec::new(),
7309        );
7310        let used: std::collections::BTreeSet<u32> = [0x1F389_u32].into_iter().collect();
7311        prune_chain_to_used_chars(&mut fontless, &used);
7312        assert_eq!(fontless.css_fallbacks.len(), 1);
7313        assert!(fontless.css_fallbacks[0].fonts.is_empty());
7314    }
7315
7316    // =====================================================================
7317    // build_font_selector_stack
7318    // =====================================================================
7319
7320    #[test]
7321    fn build_font_selector_stack_always_appends_the_three_generic_fallbacks() {
7322        let families = StyleFontFamilyVec::from_vec(Vec::new());
7323        let stack = build_font_selector_stack(&families, None, FcWeight::Normal, FontStyle::Normal);
7324
7325        let names: Vec<&str> = stack.iter().map(|s| s.family.as_str()).collect();
7326        assert_eq!(names, ["sans-serif", "serif", "monospace"]);
7327        for s in &stack {
7328            assert_eq!(s.weight, FcWeight::Normal);
7329            assert_eq!(s.style, FontStyle::Normal);
7330        }
7331    }
7332
7333    #[test]
7334    fn build_font_selector_stack_puts_the_authored_families_first() {
7335        let families = StyleFontFamilyVec::from_vec(vec![
7336            StyleFontFamily::System("Iosevka".to_string().into()),
7337            StyleFontFamily::System("Menlo".to_string().into()),
7338        ]);
7339        let stack = build_font_selector_stack(&families, None, FcWeight::Bold, FontStyle::Italic);
7340
7341        assert_eq!(stack.len(), 5, "2 authored + 3 generic fallbacks");
7342        assert_eq!(stack[0].family, "Iosevka");
7343        assert_eq!(stack[1].family, "Menlo");
7344        // Authored families carry the requested weight/style…
7345        assert_eq!(stack[0].weight, FcWeight::Bold);
7346        assert_eq!(stack[0].style, FontStyle::Italic);
7347        // …while the appended generics are always the neutral Normal/Normal.
7348        assert_eq!(stack[4].family, "monospace");
7349        assert_eq!(stack[4].weight, FcWeight::Normal);
7350        assert_eq!(stack[4].style, FontStyle::Normal);
7351    }
7352
7353    #[test]
7354    fn build_font_selector_stack_does_not_duplicate_a_generic_the_author_already_listed() {
7355        // Case-insensitive: "MONOSPACE" must suppress the "monospace" fallback.
7356        let families =
7357            StyleFontFamilyVec::from_vec(vec![StyleFontFamily::System("MONOSPACE".to_string().into())]);
7358        let stack = build_font_selector_stack(&families, None, FcWeight::Normal, FontStyle::Normal);
7359
7360        assert_eq!(stack.len(), 3, "MONOSPACE + sans-serif + serif");
7361        assert_eq!(stack[0].family, "MONOSPACE");
7362        let lower: Vec<String> = stack.iter().map(|s| s.family.to_lowercase()).collect();
7363        assert_eq!(
7364            lower.iter().filter(|f| f.as_str() == "monospace").count(),
7365            1,
7366            "the generic must appear exactly once"
7367        );
7368
7369        // All three generics authored → nothing is appended.
7370        let families = StyleFontFamilyVec::from_vec(vec![
7371            StyleFontFamily::System("serif".to_string().into()),
7372            StyleFontFamily::System("Sans-Serif".to_string().into()),
7373            StyleFontFamily::System("monospace".to_string().into()),
7374        ]);
7375        let stack = build_font_selector_stack(&families, None, FcWeight::Normal, FontStyle::Normal);
7376        assert_eq!(stack.len(), 3);
7377    }
7378
7379    #[test]
7380    fn build_font_selector_stack_passes_hostile_family_names_through_untouched() {
7381        let huge = "A".repeat(10_000);
7382        let families = StyleFontFamilyVec::from_vec(vec![
7383            StyleFontFamily::System(String::new().into()),
7384            StyleFontFamily::System("  \t\n  ".to_string().into()),
7385            StyleFontFamily::System("M🎉 ǝɔɐɟdʎʇ — «Шрифт»".to_string().into()),
7386            StyleFontFamily::System(huge.clone().into()),
7387        ]);
7388        let stack = build_font_selector_stack(&families, None, FcWeight::Normal, FontStyle::Normal);
7389
7390        assert_eq!(stack.len(), 7, "4 authored + 3 generic fallbacks");
7391        assert_eq!(stack[0].family, "");
7392        assert_eq!(stack[2].family, "M🎉 ǝɔɐɟdʎʇ — «Шрифт»");
7393        assert_eq!(stack[3].family.len(), huge.len());
7394        assert_eq!(stack[6].family, "monospace");
7395    }
7396
7397    // =====================================================================
7398    // Font chain resolution against an empty FcFontCache
7399    // =====================================================================
7400
7401    #[test]
7402    fn resolve_font_chains_yields_nothing_for_an_empty_or_degenerate_collection() {
7403        let fc = FcFontCache::default();
7404
7405        let collected = CollectedFontStacks {
7406            font_stacks: Vec::new(),
7407            hash_to_index: HashMap::new(),
7408            font_refs: HashMap::new(),
7409        };
7410        assert!(resolve_font_chains(&collected, &fc, Some(&[])).is_empty());
7411
7412        // An empty *inner* stack is skipped, not turned into a phantom chain.
7413        let collected = CollectedFontStacks {
7414            font_stacks: vec![Vec::new()],
7415            hash_to_index: HashMap::new(),
7416            font_refs: HashMap::new(),
7417        };
7418        assert!(resolve_font_chains(&collected, &fc, Some(&[])).is_empty());
7419    }
7420
7421    // =====================================================================
7422    // Font-size resolution against a real StyledDom
7423    // =====================================================================
7424
7425    #[test]
7426    fn font_size_getters_return_the_default_for_an_unstyled_dom() {
7427        let sd = StyledDom::default();
7428        let root = NodeId::new(0);
7429        let st = normal();
7430
7431        assert_eq!(get_element_font_size(&sd, root, &st), DEFAULT_FONT_SIZE);
7432        assert_eq!(get_root_font_size(&sd, &st), DEFAULT_FONT_SIZE);
7433        // The root has no parent → the parent size falls back to the default.
7434        assert_eq!(get_parent_font_size(&sd, root, &st), DEFAULT_FONT_SIZE);
7435        assert_eq!(
7436            resolve_font_size_slow(&sd, root, &st),
7437            DEFAULT_FONT_SIZE,
7438            "the slow path must agree with the memoised one"
7439        );
7440    }
7441
7442    #[test]
7443    fn font_size_resolution_is_identical_on_the_normal_and_the_pseudo_state_paths() {
7444        // A non-normal state skips every compact-cache fast path; with no :hover rule
7445        // in the stylesheet it must still land on exactly the same pixel value.
7446        let sd = body_with_divs(1, "body { font-size: 32px; }");
7447        let child = NodeId::new(1);
7448        assert_eq!(
7449            get_element_font_size(&sd, child, &normal()),
7450            get_element_font_size(&sd, child, &hovered()),
7451        );
7452    }
7453
7454    #[test]
7455    fn font_size_em_resolves_against_the_parent_and_not_the_default() {
7456        let sd = body_with_divs(1, "body { font-size: 32px; } div { font-size: 2em; }");
7457        let root = NodeId::new(0);
7458        let child = NodeId::new(1);
7459
7460        assert_eq!(get_element_font_size(&sd, root, &state_of(&sd, root)), 32.0);
7461        assert_eq!(
7462            get_element_font_size(&sd, child, &state_of(&sd, child)),
7463            64.0,
7464            "2em under a 32px parent is 64px — resolving against DEFAULT_FONT_SIZE would give 32"
7465        );
7466        assert_eq!(
7467            get_parent_font_size(&sd, child, &state_of(&sd, child)),
7468            32.0
7469        );
7470        assert_eq!(get_root_font_size(&sd, &state_of(&sd, child)), 32.0);
7471    }
7472
7473    #[test]
7474    fn font_size_getters_stay_finite_for_hostile_stylesheet_values() {
7475        // Zero, huge and negative authored font sizes must not produce NaN/inf, and
7476        // must not panic on the em-inheritance walk.
7477        for css in [
7478            "body { font-size: 0px; }",
7479            "body { font-size: 0; }",
7480            "body { font-size: 999999px; }",
7481            "body { font-size: -10px; }",
7482            "body { font-size: 1e30px; }",
7483            "div { font-size: 1000em; }",
7484            "div { font-size: 0em; }",
7485        ] {
7486            let sd = body_with_divs(1, css);
7487            for id in [NodeId::new(0), NodeId::new(1)] {
7488                let st = state_of(&sd, id);
7489                let px = get_element_font_size(&sd, id, &st);
7490                assert!(
7491                    px.is_finite(),
7492                    "{css:?} produced a non-finite font-size ({px}) on node {id:?}"
7493                );
7494                assert_eq!(
7495                    px,
7496                    resolve_font_size_slow(&sd, id, &st),
7497                    "memoised and slow paths disagree for {css:?}"
7498                );
7499            }
7500        }
7501    }
7502
7503    #[test]
7504    fn font_size_resolution_walks_a_deep_ancestor_chain_without_recursing() {
7505        // resolve_font_size_slow used to self-recurse up the parent chain and blow the
7506        // stack. Build a 64-deep chain and check the iterative walk survives it.
7507        const DEPTH: usize = 64;
7508        let mut dom = Dom::create_div();
7509        for _ in 0..DEPTH {
7510            dom = Dom::create_div().with_children(vec![dom].into());
7511        }
7512        let mut root = Dom::create_body().with_children(vec![dom].into());
7513        let sd = StyledDom::create(&mut root, parse("body { font-size: 20px; }"));
7514
7515        let deepest = NodeId::new(sd.node_data.len() - 1);
7516        let st = state_of(&sd, deepest);
7517        let px = get_element_font_size(&sd, deepest, &st);
7518        assert!(px.is_finite() && px > 0.0);
7519        assert_eq!(px, resolve_font_size_slow(&sd, deepest, &st));
7520    }
7521
7522    #[test]
7523    fn resolve_font_size_one_is_stable_under_nan_and_infinite_context_sizes() {
7524        // parent/root font sizes are f32 inputs the caller supplies; degenerate values
7525        // must not panic, and (with no authored font-size) must not leak into the result.
7526        let sd = StyledDom::default();
7527        let root = NodeId::new(0);
7528        let st = normal();
7529        for (parent, rootsz) in [
7530            (0.0_f32, 0.0_f32),
7531            (f32::NAN, f32::NAN),
7532            (f32::INFINITY, f32::NEG_INFINITY),
7533            (f32::MAX, f32::MIN),
7534            (-1.0, -1.0),
7535        ] {
7536            let px = resolve_font_size_one(&sd, root, &st, parent, rootsz);
7537            assert_eq!(
7538                px, DEFAULT_FONT_SIZE,
7539                "an unstyled node ignores the context and falls back to the default \
7540                 (parent={parent}, root={rootsz})"
7541            );
7542        }
7543    }
7544
7545    // =====================================================================
7546    // Option<NodeId> getters — the None branch
7547    // =====================================================================
7548
7549    #[test]
7550    fn optional_node_getters_return_their_documented_defaults_for_none() {
7551        let sd = StyledDom::default();
7552
7553        assert_eq!(get_z_index(&sd, None), 0);
7554        assert!(is_z_index_auto(&sd, None));
7555        assert_eq!(get_break_before(&sd, None), PageBreak::Auto);
7556        assert_eq!(get_break_after(&sd, None), PageBreak::Auto);
7557        assert_eq!(get_break_inside(&sd, None), BreakInside::Auto);
7558        assert_eq!(get_orphans(&sd, None), 2);
7559        assert_eq!(get_widows(&sd, None), 2);
7560        assert_eq!(
7561            get_box_decoration_break(&sd, None),
7562            BoxDecorationBreak::Slice
7563        );
7564        assert_eq!(
7565            get_display_property(&sd, None),
7566            MultiValue::Exact(LayoutDisplay::Inline),
7567            "a missing node is treated as anonymous inline content"
7568        );
7569        assert_eq!(get_list_style_type(&sd, None), StyleListStyleType::default());
7570        assert_eq!(
7571            get_list_style_position(&sd, None),
7572            StyleListStylePosition::default()
7573        );
7574        assert_eq!(get_caret_style(&sd, None).width, DEFAULT_CARET_WIDTH_PX);
7575        assert_eq!(
7576            get_caret_style(&sd, None).animation_duration,
7577            DEFAULT_CARET_BLINK_MS
7578        );
7579        let sel = get_selection_style(&sd, None, None);
7580        assert_eq!(sel.radius, 0.0);
7581        assert_eq!(sel.text_color, None);
7582    }
7583
7584    #[test]
7585    fn z_index_defaults_to_auto_and_reads_back_explicit_integers() {
7586        let sd = body_with_divs(1, "");
7587        let root = NodeId::new(0);
7588        assert_eq!(get_z_index(&sd, Some(root)), 0);
7589        assert!(is_z_index_auto(&sd, Some(root)));
7590
7591        for (css, want) in [
7592            ("div { z-index: 0; }", 0_i32),
7593            ("div { z-index: 7; }", 7),
7594            ("div { z-index: -7; }", -7),
7595        ] {
7596            let sd = body_with_divs(1, css);
7597            let child = Some(NodeId::new(1));
7598            assert_eq!(get_z_index(&sd, child), want, "{css:?}");
7599            assert!(
7600                !is_z_index_auto(&sd, child),
7601                "an explicit integer is not auto ({css:?})"
7602            );
7603        }
7604
7605        // `z-index: auto` reads back as 0 but is still reported as auto.
7606        let sd = body_with_divs(1, "div { z-index: auto; }");
7607        let child = Some(NodeId::new(1));
7608        assert_eq!(get_z_index(&sd, child), 0);
7609        assert!(is_z_index_auto(&sd, child));
7610    }
7611
7612    #[test]
7613    fn z_index_reads_back_the_i16_encoding_boundaries_and_falls_through_above_them() {
7614        // The compact cache packs z-index into an i16 whose top four values are
7615        // sentinels (I16_SENTINEL_THRESHOLD = 32764). Values at or above the threshold
7616        // must be stored as the sentinel and re-read via the cascade, NOT truncated.
7617        for (css, want) in [
7618            ("div { z-index: 32763; }", 32_763_i32), // largest directly encodable
7619            ("div { z-index: -32768; }", -32_768),   // i16 lower bound
7620            ("div { z-index: 32764; }", 32_764),     // == threshold → sentinel → cascade
7621            ("div { z-index: 99999; }", 99_999),     // far above → sentinel → cascade
7622            ("div { z-index: 2147483647; }", i32::MAX),
7623        ] {
7624            let sd = body_with_divs(1, css);
7625            let child = Some(NodeId::new(1));
7626            assert_eq!(
7627                get_z_index(&sd, child),
7628                want,
7629                "{css:?} must survive the i16 compact encoding"
7630            );
7631            assert!(
7632                !is_z_index_auto(&sd, child),
7633                "an explicit (if huge) integer is not auto ({css:?})"
7634            );
7635        }
7636    }
7637
7638    // =====================================================================
7639    // Border radius
7640    // =====================================================================
7641
7642    #[test]
7643    fn border_radius_is_zero_by_default_for_every_degenerate_element_and_viewport_size() {
7644        let sd = StyledDom::default();
7645        let root = NodeId::new(0);
7646        let st = normal();
7647
7648        let sizes = [
7649            (0.0_f32, 0.0_f32),
7650            (-100.0, -100.0),
7651            (f32::NAN, f32::NAN),
7652            (f32::INFINITY, f32::INFINITY),
7653            (f32::MAX, f32::MAX),
7654            (f32::MIN_POSITIVE, f32::MIN_POSITIVE),
7655        ];
7656        for (w, h) in sizes {
7657            let element = PhysicalSizeImport {
7658                width: w,
7659                height: h,
7660            };
7661            let viewport = LogicalSize::new(w, h);
7662            let r = get_border_radius(&sd, root, &st, element, viewport);
7663            assert_eq!(r.top_left, 0.0, "element=({w}, {h})");
7664            assert_eq!(r.top_right, 0.0, "element=({w}, {h})");
7665            assert_eq!(r.bottom_left, 0.0, "element=({w}, {h})");
7666            assert_eq!(r.bottom_right, 0.0, "element=({w}, {h})");
7667        }
7668    }
7669
7670    #[test]
7671    fn border_radius_resolves_authored_pixels_on_both_the_normal_and_the_pseudo_path() {
7672        let sd = body_with_divs(1, "div { border-radius: 12px; }");
7673        let child = NodeId::new(1);
7674        let element = PhysicalSizeImport {
7675            width: 100.0,
7676            height: 50.0,
7677        };
7678        let viewport = LogicalSize::new(800.0, 600.0);
7679
7680        for st in [normal(), hovered()] {
7681            let r = get_border_radius(&sd, child, &st, element, viewport);
7682            for corner in [r.top_left, r.top_right, r.bottom_left, r.bottom_right] {
7683                assert!(corner.is_finite(), "corner must stay finite");
7684                assert_eq!(corner, 12.0);
7685            }
7686        }
7687
7688        let raw = get_style_border_radius(&sd, child, &normal());
7689        assert!(raw.top_left.number.get().is_finite());
7690    }
7691
7692    #[test]
7693    fn border_radius_percentages_stay_finite_for_zero_and_infinite_element_sizes() {
7694        let sd = body_with_divs(1, "div { border-radius: 50%; }");
7695        let child = NodeId::new(1);
7696        let viewport = LogicalSize::new(0.0, 0.0);
7697
7698        for (w, h) in [
7699            (0.0_f32, 0.0_f32),
7700            (f32::MAX, f32::MAX),
7701            (-10.0, -10.0),
7702            (f32::INFINITY, 1.0),
7703        ] {
7704            let element = PhysicalSizeImport {
7705                width: w,
7706                height: h,
7707            };
7708            // Only the pseudo-state path actually resolves the % (the compact cache
7709            // stores pre-resolved px), so exercise it explicitly.
7710            let r = get_border_radius(&sd, child, &hovered(), element, viewport);
7711            for corner in [r.top_left, r.top_right, r.bottom_left, r.bottom_right] {
7712                assert!(
7713                    !corner.is_nan(),
7714                    "a {w}x{h} element produced a NaN corner radius"
7715                );
7716            }
7717        }
7718    }
7719
7720    // =====================================================================
7721    // Smoke coverage for the remaining StyledDom getters
7722    // =====================================================================
7723
7724    #[test]
7725    fn optional_style_getters_are_all_none_on_an_unstyled_node() {
7726        let sd = body_with_divs(1, "");
7727        let id = NodeId::new(1);
7728
7729        for st in [normal(), hovered()] {
7730            assert!(get_shape_inside(&sd, id, &st).is_none());
7731            assert!(get_shape_outside(&sd, id, &st).is_none());
7732            assert!(get_line_clamp(&sd, id, &st).is_none());
7733            assert!(get_initial_letter(&sd, id, &st).is_none());
7734            assert!(get_hanging_punctuation(&sd, id, &st).is_none());
7735            assert!(get_text_combine_upright(&sd, id, &st).is_none());
7736            assert!(get_hyphenation_language(&sd, id, &st).is_none());
7737            assert!(get_column_count(&sd, id, &st).is_none());
7738            assert!(get_filter(&sd, id, &st).is_none());
7739            assert!(get_backdrop_filter(&sd, id, &st).is_none());
7740            assert!(get_box_shadow_left(&sd, id, &st).is_none());
7741            assert!(get_box_shadow_right(&sd, id, &st).is_none());
7742            assert!(get_box_shadow_top(&sd, id, &st).is_none());
7743            assert!(get_box_shadow_bottom(&sd, id, &st).is_none());
7744            assert!(get_text_shadow(&sd, id, &st).is_none());
7745            assert!(get_transform(&sd, id, &st).is_none());
7746            assert!(get_counter_reset(&sd, id, &st).is_none());
7747            assert!(get_counter_increment(&sd, id, &st).is_none());
7748            assert!(get_clip_path(&sd, id, &st).is_none());
7749            assert!(get_grid_template_areas_prop(&sd, id, &st).is_none());
7750        }
7751    }
7752
7753    #[test]
7754    fn numeric_style_getters_use_their_documented_defaults() {
7755        let sd = body_with_divs(1, "");
7756        let id = NodeId::new(1);
7757
7758        for st in [normal(), hovered()] {
7759            assert_eq!(get_opacity(&sd, id, &st), 1.0, "opacity defaults to 1.0");
7760            assert_eq!(
7761                get_exclusion_margin(&sd, id, &st),
7762                0.0,
7763                "exclusion-margin defaults to 0.0"
7764            );
7765            assert!(get_scrollbar_width_px(&sd, id, &st).is_finite());
7766            assert!(get_scrollbar_width_px(&sd, id, &st) >= 0.0);
7767        }
7768    }
7769
7770    #[test]
7771    fn opacity_in_range_agrees_on_the_compact_and_the_cascade_path() {
7772        for css in [
7773            "div { opacity: 0; }",
7774            "div { opacity: 1; }",
7775            "div { opacity: 0.5; }",
7776        ] {
7777            let sd = body_with_divs(1, css);
7778            let id = NodeId::new(1);
7779            let fast = get_opacity(&sd, id, &normal()); // compact-cache u8 path
7780            let slow = get_opacity(&sd, id, &hovered()); // full cascade path
7781            assert!(fast.is_finite() && slow.is_finite(), "{css:?}");
7782            assert!(
7783                (0.0..=1.0).contains(&fast),
7784                "{css:?} read back out of range on the compact path: {fast}"
7785            );
7786            assert!(
7787                (fast - slow).abs() < 0.01,
7788                "{css:?}: compact path says {fast}, cascade path says {slow}"
7789            );
7790        }
7791
7792        // A mid-range value must actually take effect (i.e. differ from the 1.0 default).
7793        let half = get_opacity(&body_with_divs(1, "div { opacity: 0.5; }"), NodeId::new(1), &normal());
7794        assert!(half < 1.0 && half > 0.0, "opacity: 0.5 read back as {half}");
7795    }
7796
7797    #[test]
7798    fn opacity_never_returns_nan_or_infinity_for_out_of_range_authored_values() {
7799        // NOTE: CSS Color 3 clamps opacity to [0,1]. The compact-cache encoder does
7800        // clamp (`normalized().clamp(0.0, 1.0)`), but `get_opacity`'s cascade path
7801        // returns `inner.normalized()` unclamped — so a non-Normal pseudo-state can
7802        // report an out-of-range opacity. That divergence is reported separately; the
7803        // invariant asserted here (always a finite number) must hold on BOTH paths.
7804        for css in [
7805            "div { opacity: 5; }",
7806            "div { opacity: -3; }",
7807            "div { opacity: 1e30; }",
7808        ] {
7809            let sd = body_with_divs(1, css);
7810            let id = NodeId::new(1);
7811            for st in [normal(), hovered()] {
7812                let o = get_opacity(&sd, id, &st);
7813                assert!(o.is_finite(), "{css:?} produced a non-finite opacity: {o}");
7814            }
7815            // The compact path is the one the encoder clamps, so it is always in range.
7816            let fast = get_opacity(&sd, id, &normal());
7817            assert!(
7818                (0.0..=1.0).contains(&fast),
7819                "{css:?} escaped the compact-cache clamp: {fast}"
7820            );
7821        }
7822    }
7823
7824    #[test]
7825    fn enum_property_getters_stay_deterministic_across_pseudo_states() {
7826        let sd = body_with_divs(1, "");
7827        let id = NodeId::new(1);
7828
7829        for st in [normal(), hovered()] {
7830            // These may be Auto or Exact depending on the UA sheet; the contract under
7831            // test is only that they answer without panicking and answer consistently.
7832            let gutter = get_scrollbar_gutter_property(&sd, id, &st);
7833            assert_eq!(gutter, get_scrollbar_gutter_property(&sd, id, &st));
7834            let orientation = get_text_orientation_property(&sd, id, &st);
7835            assert_eq!(orientation, get_text_orientation_property(&sd, id, &st));
7836            let valign = get_vertical_align_property(&sd, id, &st);
7837            assert_eq!(valign, get_vertical_align_property(&sd, id, &st));
7838
7839            let _ = get_background_color(&sd, id, &st);
7840            let _ = get_background_contents(&sd, id, &st);
7841            let _ = get_border_info(&sd, id, &st);
7842            let _ = get_border_spacing(&sd, id, &st);
7843            let _ = get_height_value(&sd, id, &st);
7844            let _ = get_line_height_value(&sd, id, &st);
7845            let _ = get_text_indent_value(&sd, id, &st);
7846        }
7847
7848        // vertical-align defaults to the baseline for an unstyled div.
7849        assert!(matches!(
7850            get_vertical_align_for_node(&sd, id),
7851            crate::text3::cache::VerticalAlign::Baseline
7852        ));
7853    }
7854
7855    #[test]
7856    fn get_inline_border_info_is_none_without_borders_and_survives_a_degenerate_viewport() {
7857        let sd = body_with_divs(1, "");
7858        let id = NodeId::new(1);
7859        let st = normal();
7860        let info = get_border_info(&sd, id, &st);
7861
7862        for viewport in [
7863            PhysicalSize::new(0.0, 0.0),
7864            PhysicalSize::new(f32::NAN, f32::NAN),
7865            PhysicalSize::new(f32::INFINITY, f32::INFINITY),
7866            PhysicalSize::new(-1.0, -1.0),
7867            PhysicalSize::new(f32::MAX, f32::MAX),
7868        ] {
7869            assert!(
7870                get_inline_border_info(&sd, id, &st, &info, viewport).is_none(),
7871                "a node with neither border nor padding has no inline border box"
7872            );
7873        }
7874    }
7875
7876    #[test]
7877    fn get_inline_border_info_reports_finite_widths_for_a_bordered_node() {
7878        let sd = body_with_divs(1, "div { border: 3px solid red; padding: 5px; }");
7879        let id = NodeId::new(1);
7880        let st = normal();
7881        let info = get_border_info(&sd, id, &st);
7882
7883        let inline = get_inline_border_info(&sd, id, &st, &info, PhysicalSize::new(800.0, 600.0))
7884            .expect("a bordered + padded node must produce an InlineBorderInfo");
7885        for w in [inline.top, inline.right, inline.bottom, inline.left] {
7886            assert!(w.is_finite() && w >= 0.0, "border width {w}");
7887        }
7888        for p in [
7889            inline.padding_top,
7890            inline.padding_right,
7891            inline.padding_bottom,
7892            inline.padding_left,
7893        ] {
7894            assert!(p.is_finite() && p >= 0.0, "padding {p}");
7895        }
7896        assert!(inline.is_first_fragment && inline.is_last_fragment);
7897        assert!(!inline.is_rtl, "the default direction is ltr");
7898
7899        // The same node under a NaN viewport: px lengths do not consult the viewport,
7900        // so the result must stay finite rather than turn into NaN.
7901        let nan_vp = get_inline_border_info(&sd, id, &st, &info, PhysicalSize::new(f32::NAN, f32::NAN))
7902            .expect("px borders do not depend on the viewport");
7903        assert!(nan_vp.top.is_finite() && nan_vp.padding_top.is_finite());
7904    }
7905
7906    #[test]
7907    fn get_style_properties_stays_finite_for_every_degenerate_viewport() {
7908        let sd = body_with_text("hello");
7909        for viewport in [
7910            PhysicalSize::new(0.0, 0.0),
7911            PhysicalSize::new(-1.0, -1.0),
7912            PhysicalSize::new(f32::NAN, f32::NAN),
7913            PhysicalSize::new(f32::INFINITY, f32::INFINITY),
7914            PhysicalSize::new(f32::MAX, f32::MAX),
7915        ] {
7916            for id in [NodeId::new(0), NodeId::new(1)] {
7917                let props = get_style_properties(&sd, id, None, viewport);
7918                assert!(
7919                    props.font_size_px.is_finite(),
7920                    "viewport {viewport:?} produced a non-finite font size"
7921                );
7922                assert!(props.font_size_px > 0.0);
7923            }
7924        }
7925    }
7926
7927    // =====================================================================
7928    // user-select / contenteditable predicates
7929    // =====================================================================
7930
7931    #[test]
7932    fn is_text_selectable_is_true_by_default_and_false_for_user_select_none() {
7933        let sd = body_with_divs(1, "");
7934        assert!(
7935            is_text_selectable(&sd, NodeId::new(1), &normal()),
7936            "text is selectable unless user-select says otherwise"
7937        );
7938
7939        let sd = body_with_divs(1, "div { user-select: none; }");
7940        assert!(!is_text_selectable(&sd, NodeId::new(1), &normal()));
7941
7942        let sd = body_with_divs(1, "div { user-select: text; }");
7943        assert!(is_text_selectable(&sd, NodeId::new(1), &normal()));
7944    }
7945
7946    #[test]
7947    fn contenteditable_is_false_everywhere_on_a_plain_dom() {
7948        let sd = body_with_divs(2, "");
7949        for idx in 0..sd.node_data.len() {
7950            let id = NodeId::new(idx);
7951            assert!(!is_node_contenteditable(&sd, id), "node {idx}");
7952            assert!(!is_node_contenteditable_inherited(&sd, id), "node {idx}");
7953            assert_eq!(find_contenteditable_ancestor(&sd, id), None, "node {idx}");
7954        }
7955    }
7956
7957    #[test]
7958    fn contenteditable_is_inherited_by_descendants_but_not_reported_as_direct() {
7959        // body(0) > editable div(1) > plain div(2)
7960        let mut editable = Dom::create_div().with_children(vec![Dom::create_div()].into());
7961        editable.root.set_contenteditable(true);
7962        let mut dom = Dom::create_body().with_children(vec![editable].into());
7963        let sd = StyledDom::create(&mut dom, Css::empty());
7964        assert_eq!(sd.node_data.len(), 3);
7965
7966        let (body, editable, child) = (NodeId::new(0), NodeId::new(1), NodeId::new(2));
7967
7968        assert!(!is_node_contenteditable(&sd, body));
7969        assert!(is_node_contenteditable(&sd, editable));
7970        assert!(
7971            !is_node_contenteditable(&sd, child),
7972            "the direct check must not walk up the tree"
7973        );
7974
7975        assert!(!is_node_contenteditable_inherited(&sd, body));
7976        assert!(is_node_contenteditable_inherited(&sd, editable));
7977        assert!(
7978            is_node_contenteditable_inherited(&sd, child),
7979            "editability is inherited from the ancestor"
7980        );
7981
7982        assert_eq!(find_contenteditable_ancestor(&sd, body), None);
7983        assert_eq!(find_contenteditable_ancestor(&sd, editable), Some(editable));
7984        assert_eq!(
7985            find_contenteditable_ancestor(&sd, child),
7986            Some(editable),
7987            "a nested node resolves to its editable container, not to itself"
7988        );
7989    }
7990
7991    // =====================================================================
7992    // Codepoint / script collection
7993    // =====================================================================
7994
7995    #[test]
7996    fn collect_used_codepoints_strips_ascii_while_the_all_variant_keeps_it() {
7997        // ASCII + Latin-1 + CJK + an astral-plane emoji (a surrogate pair in UTF-16).
7998        let sd = body_with_text("aé漢🎉");
7999
8000        let non_ascii = collect_used_codepoints(&sd);
8001        assert_eq!(non_ascii.len(), 3, "the ASCII 'a' is dropped");
8002        assert!(non_ascii.contains(&0x00E9));
8003        assert!(non_ascii.contains(&0x6F22));
8004        assert!(non_ascii.contains(&0x0001_F389), "astral plane codepoint");
8005        assert!(!non_ascii.contains(&u32::from(b'a')));
8006
8007        let all = collect_used_codepoints_all(&sd);
8008        assert_eq!(all.len(), 4);
8009        assert!(all.contains(&'a'));
8010        assert!(all.contains(&'🎉'));
8011    }
8012
8013    #[test]
8014    fn collect_used_codepoints_dedupes_and_handles_empty_and_ascii_only_text() {
8015        // Repeats collapse (BTreeSet), and a DOM with no text at all yields nothing.
8016        let sd = body_with_text("ααα");
8017        assert_eq!(collect_used_codepoints(&sd).len(), 1);
8018
8019        let sd = body_with_text("");
8020        assert!(collect_used_codepoints(&sd).is_empty());
8021        assert!(collect_used_codepoints_all(&sd).is_empty());
8022
8023        let sd = body_with_divs(3, "");
8024        assert!(
8025            collect_used_codepoints(&sd).is_empty(),
8026            "element nodes carry no codepoints"
8027        );
8028
8029        let sd = body_with_text("plain ascii");
8030        assert!(collect_used_codepoints(&sd).is_empty());
8031        assert!(!collect_used_codepoints_all(&sd).is_empty());
8032    }
8033
8034    #[test]
8035    fn scripts_present_in_styled_dom_is_empty_for_ascii_and_bounded_by_the_default_set() {
8036        let ascii = body_with_text("hello world");
8037        assert!(
8038            scripts_present_in_styled_dom(&ascii).is_empty(),
8039            "an ASCII-only page must not drag in any unicode fallback script"
8040        );
8041
8042        let empty = StyledDom::default();
8043        assert!(scripts_present_in_styled_dom(&empty).is_empty());
8044
8045        let cjk = body_with_text("漢字");
8046        let scripts = scripts_present_in_styled_dom(&cjk);
8047        assert!(!scripts.is_empty(), "CJK text must report at least one script");
8048        assert!(
8049            scripts.len() <= DEFAULT_UNICODE_FALLBACK_SCRIPTS.len(),
8050            "the result is always a subset of the default script set"
8051        );
8052        for r in &scripts {
8053            assert!(r.start <= r.end, "a script range must not be inverted");
8054        }
8055    }
8056
8057    #[test]
8058    fn collect_font_stacks_from_styled_dom_keeps_its_index_map_consistent() {
8059        let platform = azul_css::system::Platform::current();
8060
8061        for sd in [
8062            StyledDom::default(),
8063            body_with_text("hello"),
8064            body_with_divs(3, "div { font-family: Iosevka, monospace; }"),
8065        ] {
8066            let collected = collect_font_stacks_from_styled_dom(&sd, &platform);
8067            assert_eq!(
8068                collected.hash_to_index.len(),
8069                collected.font_stacks.len(),
8070                "every recorded hash must map to exactly one stack"
8071            );
8072            for &idx in collected.hash_to_index.values() {
8073                assert!(
8074                    idx < collected.font_stacks.len(),
8075                    "hash_to_index points past the end of font_stacks"
8076                );
8077            }
8078            for stack in &collected.font_stacks {
8079                assert!(!stack.is_empty(), "an empty font stack is never recorded");
8080            }
8081        }
8082    }
8083}