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