Skip to main content

azul_layout/solver3/
cache.rs

1//! Handling Viewport Resizing and Layout Thrashing
2//!
3//! The viewport size is a fundamental input to the entire layout process.
4//! A change in viewport size must trigger a relayout.
5//!
6//! 1. The `layout_document` function takes the `viewport` as an argument. The `LayoutCache` stores
7//!    the `viewport` from the previous frame.
8//! 2. The `reconcile_and_invalidate` function detects that the viewport has changed size
9//! 3. This single change—marking the root as a layout root—forces a full top-down pass
10//!    (`calculate_layout_for_subtree` starting from the root). This correctly recalculates all
11//!    percentage-based sizes and repositions all elements according to the new viewport dimensions.
12//! 4. The intrinsic size calculation (bottom-up) can often be skipped, as it's independent of the
13//!    container size, which is a significant optimization.
14
15use std::{
16    collections::{BTreeMap, BTreeSet, HashMap},
17    hash::{DefaultHasher, Hash, Hasher},
18};
19
20/// Floating-point comparison epsilon for cache size lookups.
21/// Controls the tolerance for cache hit matching in the per-node multi-slot cache.
22const CACHE_SIZE_EPSILON: f32 = 0.1;
23
24use azul_core::{
25    diff::NodeDataFingerprint,
26    dom::{FormattingContext, NodeId, NodeType},
27    geom::{LogicalPosition, LogicalRect, LogicalSize},
28    styled_dom::{StyledDom, StyledNode},
29};
30use azul_css::{
31    css::CssPropertyValue,
32    props::{
33        layout::{
34            LayoutDisplay, LayoutFlexWrap, LayoutHeight, LayoutJustifyContent, LayoutOverflow,
35            LayoutPosition, LayoutWritingMode,
36        },
37        property::{CssProperty, CssPropertyType},
38        style::StyleTextAlign,
39    },
40    LayoutDebugMessage, LayoutDebugMessageType,
41};
42
43use crate::{
44    font_traits::{FontLoaderTrait, ParsedFontTrait, TextLayoutCache},
45    solver3::{
46        fc::{self, layout_formatting_context, LayoutConstraints, OverflowBehavior},
47        geometry::PositionedRectangle,
48        getters::{
49            get_css_height, get_display_property, get_justify_content, get_overflow_x,
50            get_overflow_y, get_scrollbar_gutter_property, get_text_align, get_white_space_property, get_wrap, get_writing_mode,
51            MultiValue,
52        },
53        layout_tree::{
54            get_display_type, is_block_level, AnonymousBoxType, DirtyFlag, LayoutNode, LayoutNodeHot, LayoutTreeBuilder, SubtreeHash,
55        },
56        positioning::get_position_type,
57        scrollbar::ScrollbarRequirements,
58        sizing::calculate_used_size_for_node,
59        LayoutContext, LayoutError, LayoutTree, Result,
60    },
61    text3::cache::AvailableSpace as Text3AvailableSpace,
62};
63
64// ============================================================================
65// Per-Node Multi-Slot Cache (inspired by Taffy's 9+1 slot cache architecture)
66//
67// Instead of a global BTreeMap keyed by (node_index, available_size), each node
68// gets its own deterministic cache with 9 measurement slots + 1 full layout slot.
69// This eliminates O(log n) lookups, prevents slot collisions between MinContent/
70// MaxContent/Definite measurements, and cleanly separates sizing from positioning.
71//
72// Reference: https://github.com/DioxusLabs/taffy — Cache struct in src/tree/cache.rs
73// Azul improvement: cache is EXTERNAL (Vec<NodeCache> parallel to LayoutTree.nodes)
74// rather than stored on the node, keeping LayoutNode slim and avoiding &mut tree
75// for cache operations.
76// ============================================================================
77
78/// Determines whether `calculate_layout_for_subtree` should only compute
79/// the node's size (for parent's sizing pass) or perform full layout
80/// including child positioning.
81///
82/// Inspired by Taffy's `RunMode` enum. The two-mode approach enables the
83/// classic CSS two-pass layout: Pass 1 (ComputeSize) measures all children,
84/// Pass 2 (PerformLayout) positions them using the measured sizes.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ComputeMode {
87    /// Only compute the node's border-box size and baseline.
88    /// Does NOT store child positions. Used in BFC Pass 1 (sizing).
89    ComputeSize,
90    /// Compute size AND position all children.
91    /// Stores the full layout result including child positions.
92    /// Used in BFC Pass 2 (positioning) and as the final layout step.
93    PerformLayout,
94}
95
96/// Constraint classification for deterministic cache slot selection.
97///
98/// Inspired by Taffy's `AvailableSpace` enum. Each constraint type maps to a
99/// different cache slot, preventing collisions between e.g. MinContent and
100/// Definite measurements of the same node.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum AvailableWidthType {
103    /// A definite pixel value (or percentage resolved to pixels).
104    Definite,
105    /// Shrink-to-fit: the smallest size that doesn't cause overflow.
106    MinContent,
107    /// Use all available space: the largest size the content can use.
108    MaxContent,
109}
110
111/// Cache entry for sizing (ComputeSize mode) — stores NO positions.
112///
113/// This is the lightweight entry stored in the 9 measurement slots.
114/// It records what constraints were provided and what size resulted,
115/// enabling Taffy's "result matches request" optimization.
116#[derive(Debug, Clone)]
117pub struct SizingCacheEntry {
118    /// The available size that was provided as input.
119    pub available_size: LogicalSize,
120    /// The computed border-box size (output).
121    pub result_size: LogicalSize,
122    /// Baseline for inline alignment (if applicable).
123    pub baseline: Option<f32>,
124    /// First child's escaped top margin (CSS 2.2 § 8.3.1).
125    pub escaped_top_margin: Option<f32>,
126    /// Last child's escaped bottom margin (CSS 2.2 § 8.3.1).
127    pub escaped_bottom_margin: Option<f32>,
128}
129
130/// Cache entry for full layout (PerformLayout mode).
131///
132/// This is the single "final layout" slot. It includes child positions
133/// (relative to parent's content-box) and overflow/scrollbar info.
134#[derive(Debug, Clone)]
135pub struct LayoutCacheEntry {
136    /// The available size that was provided as input.
137    pub available_size: LogicalSize,
138    /// The computed border-box size (output).
139    pub result_size: LogicalSize,
140    /// Content overflow size (for scrolling).
141    pub content_size: LogicalSize,
142    /// Child positions relative to parent's content-box (NOT absolute).
143    pub child_positions: Vec<(usize, LogicalPosition)>,
144    /// First child's escaped top margin.
145    pub escaped_top_margin: Option<f32>,
146    /// Last child's escaped bottom margin.
147    pub escaped_bottom_margin: Option<f32>,
148    /// Scrollbar requirements for this node.
149    pub scrollbar_info: ScrollbarRequirements,
150}
151
152/// Per-node cache entry with 9 measurement slots + 1 full layout slot.
153///
154/// Inspired by Taffy's `Cache` struct (9+1 slots per node). The deterministic
155/// slot index is computed from the constraint combination, so entries never
156/// clobber each other (unlike the old global BTreeMap where fixed-point
157/// collisions were possible).
158///
159/// NOT stored on LayoutNode — lives in the external `LayoutCacheMap`.
160#[derive(Debug, Clone)]
161pub struct NodeCache {
162    /// 9 measurement slots (Taffy's deterministic scheme):
163    /// - Slot 0: both dimensions known
164    /// - Slots 1-2: only width known (MaxContent/Definite vs MinContent)
165    /// - Slots 3-4: only height known (MaxContent/Definite vs MinContent)
166    /// - Slots 5-8: neither known (2×2 combos of width/height constraint types)
167    pub measure_entries: [Option<SizingCacheEntry>; 9],
168
169    /// 1 full layout slot (with child positions, overflow, baseline).
170    /// Only populated after PerformLayout, not after ComputeSize.
171    pub layout_entry: Option<LayoutCacheEntry>,
172
173    /// Fast check for dirty propagation (Taffy optimization).
174    /// When true, all slots are empty — ancestors are also dirty.
175    pub is_empty: bool,
176}
177
178impl Default for NodeCache {
179    fn default() -> Self {
180        Self {
181            measure_entries: [None, None, None, None, None, None, None, None, None],
182            layout_entry: None,
183            is_empty: true, // fresh cache is empty/dirty
184        }
185    }
186}
187
188impl NodeCache {
189    /// Clear all cache entries, marking this node as dirty.
190    pub fn clear(&mut self) {
191        self.measure_entries = [None, None, None, None, None, None, None, None, None];
192        self.layout_entry = None;
193        self.is_empty = true;
194    }
195
196    /// Compute the deterministic slot index from constraint dimensions.
197    ///
198    /// This is Taffy's slot selection scheme: given whether width/height are
199    /// "known" (definite constraint provided by parent) and what type of
200    /// constraint applies to the unknown dimension(s), we get a unique slot 0–8.
201    pub fn slot_index(
202        width_known: bool,
203        height_known: bool,
204        width_type: AvailableWidthType,
205        height_type: AvailableWidthType,
206    ) -> usize {
207        match (width_known, height_known) {
208            (true, true) => 0,
209            (true, false) => {
210                if width_type == AvailableWidthType::MinContent { 2 } else { 1 }
211            }
212            (false, true) => {
213                if height_type == AvailableWidthType::MinContent { 4 } else { 3 }
214            }
215            (false, false) => {
216                let w = if width_type == AvailableWidthType::MinContent { 1 } else { 0 };
217                let h = if height_type == AvailableWidthType::MinContent { 1 } else { 0 };
218                5 + w * 2 + h
219            }
220        }
221    }
222
223    /// Look up a sizing cache entry, implementing Taffy's "result matches request"
224    /// optimization: if the caller provides the result size as a known dimension
225    /// (common in Pass1→Pass2 transitions), it's still a cache hit.
226    pub fn get_size(&self, slot: usize, known_dims: LogicalSize) -> Option<&SizingCacheEntry> {
227        let entry = self.measure_entries[slot].as_ref()?;
228        // Exact match on input constraints
229        if (known_dims.width - entry.available_size.width).abs() < CACHE_SIZE_EPSILON
230            && (known_dims.height - entry.available_size.height).abs() < CACHE_SIZE_EPSILON
231        {
232            return Some(entry);
233        }
234        // "Result matches request" — if the caller provides the result size
235        // as a known dimension, it's still a hit. This is the key optimization
236        // that makes two-pass layout O(n): Pass 1 measures a node, Pass 2
237        // provides the measured size as a constraint → automatic cache hit.
238        if (known_dims.width - entry.result_size.width).abs() < CACHE_SIZE_EPSILON
239            && (known_dims.height - entry.result_size.height).abs() < CACHE_SIZE_EPSILON
240        {
241            return Some(entry);
242        }
243        None
244    }
245
246    /// Store a sizing result in the given slot.
247    pub fn store_size(&mut self, slot: usize, entry: SizingCacheEntry) {
248        self.measure_entries[slot] = Some(entry);
249        self.is_empty = false;
250    }
251
252    /// Look up the full layout cache entry.
253    pub fn get_layout(&self, known_dims: LogicalSize) -> Option<&LayoutCacheEntry> {
254        let entry = self.layout_entry.as_ref()?;
255        if (known_dims.width - entry.available_size.width).abs() < CACHE_SIZE_EPSILON
256            && (known_dims.height - entry.available_size.height).abs() < CACHE_SIZE_EPSILON
257        {
258            return Some(entry);
259        }
260        // "Result matches request" for layout too
261        if (known_dims.width - entry.result_size.width).abs() < CACHE_SIZE_EPSILON
262            && (known_dims.height - entry.result_size.height).abs() < CACHE_SIZE_EPSILON
263        {
264            return Some(entry);
265        }
266        None
267    }
268
269    /// Store a full layout result.
270    pub fn store_layout(&mut self, entry: LayoutCacheEntry) {
271        self.layout_entry = Some(entry);
272        self.is_empty = false;
273    }
274}
275
276/// External layout cache, parallel to `LayoutTree.nodes`.
277///
278/// `cache_map.entries[i]` holds the cache for `LayoutTree.nodes[i]`.
279/// Stored on `LayoutCache` (persists across frames).
280///
281/// This is Azul's improvement over Taffy's on-node cache:
282/// - `LayoutNode` stays slim (0 bytes overhead)
283/// - No `&mut tree` needed to read/write cache entries
284/// - Cache can be resized independently after reconciliation
285/// - O(1) indexed lookup (Vec) instead of O(log n) (BTreeMap)
286#[derive(Debug, Clone, Default)]
287pub struct LayoutCacheMap {
288    pub entries: Vec<NodeCache>,
289}
290
291impl LayoutCacheMap {
292    /// Resize to match tree length after reconciliation.
293    /// New nodes get empty (dirty) caches. Removed nodes' caches are dropped.
294    pub fn resize_to_tree(&mut self, tree_len: usize) {
295        self.entries.resize_with(tree_len, NodeCache::default);
296    }
297
298    /// O(1) lookup by layout tree index.
299    #[inline]
300    pub fn get(&self, node_index: usize) -> &NodeCache {
301        &self.entries[node_index]
302    }
303
304    /// O(1) mutable lookup by layout tree index.
305    #[inline]
306    pub fn get_mut(&mut self, node_index: usize) -> &mut NodeCache {
307        &mut self.entries[node_index]
308    }
309
310    /// Invalidate a node and propagate dirty flags upward through ancestors.
311    ///
312    /// Implements Taffy's early-stop optimization: propagation halts at the
313    /// first ancestor whose cache is already empty (i.e., already dirty).
314    /// This prevents redundant O(depth) propagation when multiple children
315    /// of the same parent are dirtied.
316    pub fn mark_dirty(&mut self, node_index: usize, tree: &[LayoutNodeHot]) {
317        if node_index >= self.entries.len() {
318            return;
319        }
320        let cache = &mut self.entries[node_index];
321        if cache.is_empty {
322            return; // Already dirty → ancestors are too
323        }
324        cache.clear();
325
326        // Propagate upward (Taffy's early-stop optimization)
327        let mut current = tree.get(node_index).and_then(|n| n.parent);
328        while let Some(parent_idx) = current {
329            if parent_idx >= self.entries.len() {
330                break;
331            }
332            let parent_cache = &mut self.entries[parent_idx];
333            if parent_cache.is_empty {
334                break; // Stop early — ancestor already dirty
335            }
336            parent_cache.clear();
337            current = tree.get(parent_idx).and_then(|n| n.parent);
338        }
339    }
340}
341
342/// The persistent cache that holds the layout state between frames.
343#[derive(Debug, Clone, Default)]
344pub struct LayoutCache {
345    /// The fully laid-out tree from the previous frame. This is our primary cache.
346    pub tree: Option<LayoutTree>,
347    /// The final, absolute positions of all nodes from the previous frame.
348    pub calculated_positions: super::PositionVec,
349    /// The viewport size from the last layout pass, used to detect resizes.
350    pub viewport: Option<LogicalRect>,
351    /// Stable scroll IDs computed from node_data_hash (layout index -> scroll ID)
352    pub scroll_ids: HashMap<usize, u64>,
353    /// Mapping from scroll ID to DOM NodeId for hit testing
354    pub scroll_id_to_node_id: HashMap<u64, NodeId>,
355    /// CSS counter values for each node and counter name.
356    /// Key: (layout_index, counter_name), Value: counter value
357    /// This stores the computed counter values after processing counter-reset and
358    /// counter-increment.
359    pub counters: HashMap<(usize, String), i32>,
360    /// Cache of positioned floats for each BFC node (layout_index -> FloatingContext).
361    /// This persists float positions across multiple layout passes, ensuring IFC
362    /// children always have access to correct float exclusions even when layout is
363    /// recalculated.
364    pub float_cache: HashMap<usize, fc::FloatingContext>,
365    /// Per-node multi-slot cache (inspired by Taffy's 9+1 architecture).
366    /// External to LayoutTree — indexed by node index for O(1) lookup.
367    /// Persists across frames; resized after reconciliation.
368    pub cache_map: LayoutCacheMap,
369    /// Snapshot of calculated_positions from the previous frame, used by the
370    /// compositor to compute damage rects (old bounds vs new bounds).
371    pub previous_positions: super::PositionVec,
372    /// Cached display list keyed by `(root_subtree_hash, viewport)`.
373    /// When the reconciled tree has the same root subtree_hash AND
374    /// the same viewport as the cached one, the display list is
375    /// returned as-is — skipping layout, positioning, and
376    /// display-list generation entirely. Cleared whenever
377    /// `mark_dirty` fires on any node (since the root's upstream
378    /// invalidation chain clears its ancestors).
379    pub cached_display_list: Option<(SubtreeHash, LogicalRect, super::display_list::DisplayList)>,
380    /// Raw pointer of the StyledDom from the previous layout pass. When the
381    /// same `&StyledDom` reference is passed again AND the viewport is unchanged,
382    /// skip reconcile entirely and return the cached display list (saves ~0.8 ms).
383    pub prev_dom_ptr: usize,
384    pub prev_viewport: LogicalRect,
385}
386
387/// Approximate heap-byte breakdown of the solver3 LayoutCache.
388#[derive(Debug, Clone, Default)]
389pub struct Solver3CacheMemoryReport {
390    pub tree_bytes: usize,
391    pub tree_report: Option<super::layout_tree::LayoutTreeMemoryReport>,
392    pub calculated_positions_bytes: usize,
393    pub previous_positions_bytes: usize,
394    pub scroll_ids_bytes: usize,
395    pub scroll_id_to_node_id_bytes: usize,
396    pub counters_bytes: usize,
397    pub float_cache_bytes: usize,
398    pub cache_map_bytes: usize,
399    pub cached_display_list_bytes: usize,
400}
401
402impl Solver3CacheMemoryReport {
403    pub fn total_bytes(&self) -> usize {
404        self.tree_bytes
405            + self.calculated_positions_bytes
406            + self.previous_positions_bytes
407            + self.scroll_ids_bytes
408            + self.scroll_id_to_node_id_bytes
409            + self.counters_bytes
410            + self.float_cache_bytes
411            + self.cache_map_bytes
412            + self.cached_display_list_bytes
413    }
414}
415
416impl LayoutCache {
417    /// Approximate heap bytes retained by this LayoutCache.
418    pub fn memory_report(&self) -> Solver3CacheMemoryReport {
419        let tree_report = self.tree.as_ref().map(|t| t.memory_report());
420        let tree_bytes = tree_report.as_ref().map(|r| r.total_bytes()).unwrap_or(0);
421        // cache_map: Vec<NodeCache>; NodeCache has 9 Option<SizingCacheEntry>
422        // + 1 Option<LayoutCacheEntry>. Count filled layout entries' child_positions.
423        let mut cache_map_bytes = self.cache_map.entries.capacity()
424            * core::mem::size_of::<NodeCache>();
425        for e in &self.cache_map.entries {
426            if let Some(le) = &e.layout_entry {
427                cache_map_bytes += le.child_positions.capacity()
428                    * core::mem::size_of::<(usize, LogicalPosition)>();
429            }
430        }
431        Solver3CacheMemoryReport {
432            tree_bytes,
433            tree_report,
434            calculated_positions_bytes: self.calculated_positions.len()
435                * core::mem::size_of::<LogicalPosition>(),
436            previous_positions_bytes: self.previous_positions.len()
437                * core::mem::size_of::<LogicalPosition>(),
438            scroll_ids_bytes: self.scroll_ids.len()
439                * (core::mem::size_of::<usize>() + core::mem::size_of::<u64>()),
440            scroll_id_to_node_id_bytes: self.scroll_id_to_node_id.len()
441                * (core::mem::size_of::<u64>() + core::mem::size_of::<NodeId>()),
442            counters_bytes: self.counters.iter().map(|((_, name), _)| {
443                core::mem::size_of::<(usize, String)>()
444                    + core::mem::size_of::<i32>()
445                    + name.capacity()
446            }).sum(),
447            float_cache_bytes: self.float_cache.len() * 256, // conservative per-FC
448            cache_map_bytes,
449            cached_display_list_bytes: if self.cached_display_list.is_some() { 2048 } else { 0 },
450        }
451    }
452}
453
454/// The result of a reconciliation pass.
455#[derive(Debug, Default)]
456pub struct ReconciliationResult {
457    /// Set of nodes whose intrinsic size needs to be recalculated (bottom-up pass).
458    pub intrinsic_dirty: BTreeSet<usize>,
459    /// Set of layout roots whose subtrees need a new top-down layout pass.
460    pub layout_roots: BTreeSet<usize>,
461    /// Set of nodes that only need a paint/display-list update (no relayout).
462    pub paint_dirty: BTreeSet<usize>,
463}
464
465impl ReconciliationResult {
466    /// Checks if any layout or paint work is needed.
467    pub fn is_clean(&self) -> bool {
468        self.intrinsic_dirty.is_empty()
469            && self.layout_roots.is_empty()
470            && self.paint_dirty.is_empty()
471    }
472
473    /// Returns true if full layout work is needed for at least one node.
474    pub fn needs_layout(&self) -> bool {
475        !self.intrinsic_dirty.is_empty() || !self.layout_roots.is_empty()
476    }
477
478    /// Returns true if only paint work is needed (no layout).
479    pub fn needs_paint_only(&self) -> bool {
480        !self.needs_layout() && !self.paint_dirty.is_empty()
481    }
482}
483
484/// After dirty subtrees are laid out, this repositions their clean siblings
485/// without recalculating their internal layout. This is a critical optimization.
486///
487/// This function acts as a dispatcher, inspecting the parent's formatting context
488/// and calling the appropriate repositioning algorithm. For complex layout modes
489/// like Flexbox or Grid, this optimization is skipped, as a full relayout is
490/// often required to correctly recalculate spacing and sizing for all siblings.
491pub fn reposition_clean_subtrees(
492    styled_dom: &StyledDom,
493    tree: &LayoutTree,
494    layout_roots: &BTreeSet<usize>,
495    calculated_positions: &mut super::PositionVec,
496) {
497    // Find the unique parents of all dirty layout roots. These are the containers
498    // where sibling positions need to be adjusted.
499    let mut parents_to_reposition = BTreeSet::new();
500    for &root_idx in layout_roots {
501        if let Some(parent_idx) = tree.get(root_idx).and_then(|n| n.parent) {
502            parents_to_reposition.insert(parent_idx);
503        }
504    }
505
506    for parent_idx in parents_to_reposition {
507        let parent_node = match tree.get(parent_idx) {
508            Some(n) => n,
509            None => continue,
510        };
511
512        // Dispatch to the correct repositioning logic based on the parent's layout mode.
513        match parent_node.formatting_context {
514            // Cases that use simple block-flow stacking can be optimized.
515            FormattingContext::Block { .. } | FormattingContext::TableRowGroup => {
516                reposition_block_flow_siblings(
517                    styled_dom,
518                    parent_idx,
519                    tree,
520                    layout_roots,
521                    calculated_positions,
522                );
523            }
524
525            FormattingContext::Flex | FormattingContext::Grid => {
526                // Taffy handles this, so if a child is dirty, the parent would have
527                // already been marked as a layout_root and re-laid out by Taffy.
528                // We do nothing here for Flex or Grid.
529            }
530
531            FormattingContext::Table | FormattingContext::TableRow => {
532                // TODO: Table layout is interdependent. A change in one cell's size
533                // can affect the entire column's width or row's height, requiring a
534                // full relayout of the table. This optimization is skipped.
535            }
536
537            // Other contexts either don't contain children in a way that this
538            // optimization applies (e.g., Inline, TableCell) or are handled by other
539            // layout mechanisms (e.g., OutOfFlow).
540            _ => { /* Do nothing */ }
541        }
542    }
543}
544
545/// Convert LayoutOverflow to OverflowBehavior
546/// CSS Overflow Module Level 3: initial value of `overflow` is `visible`.
547// +spec:overflow:3a6297 - initial value 'visible', maps hidden/scroll/auto overflow behaviors
548pub fn to_overflow_behavior(overflow: MultiValue<LayoutOverflow>) -> fc::OverflowBehavior {
549    match overflow.unwrap_or(LayoutOverflow::Visible) {
550        LayoutOverflow::Visible => fc::OverflowBehavior::Visible,
551        LayoutOverflow::Hidden | LayoutOverflow::Clip => fc::OverflowBehavior::Hidden,
552        LayoutOverflow::Scroll => fc::OverflowBehavior::Scroll,
553        LayoutOverflow::Auto => fc::OverflowBehavior::Auto,
554    }
555}
556
557/// Convert StyleTextAlign to fc::TextAlign
558// +spec:text-alignment-spacing:43ea0a - text-align-all shorthand: aligns all lines except last (overridden by text-align-last)
559pub const fn style_text_align_to_fc(text_align: StyleTextAlign) -> fc::TextAlign {
560    match text_align {
561        StyleTextAlign::Start | StyleTextAlign::Left => fc::TextAlign::Start,
562        StyleTextAlign::End | StyleTextAlign::Right => fc::TextAlign::End,
563        StyleTextAlign::Center => fc::TextAlign::Center,
564        StyleTextAlign::Justify => fc::TextAlign::Justify,
565    }
566}
567
568/// Collects DOM child IDs from the node hierarchy into a Vec.
569///
570/// This is a helper function that flattens the sibling iteration into a simple loop.
571/// Children with `display: none` are filtered out since they generate no boxes.
572pub fn collect_children_dom_ids(styled_dom: &StyledDom, parent_dom_id: NodeId) -> Vec<NodeId> {
573    let hierarchy_container = styled_dom.node_hierarchy.as_container();
574    let mut children = Vec::new();
575
576    let Some(hierarchy_item) = hierarchy_container.get(parent_dom_id) else {
577        return children;
578    };
579
580    let Some(mut child_id) = hierarchy_item.first_child_id(parent_dom_id) else {
581        return children;
582    };
583
584    // +spec:display-property:9f02c6 - display:none elements generate no boxes
585    // +spec:display-property:3b507e - display:none excludes subtree from box tree
586    if get_display_type(styled_dom, child_id) != LayoutDisplay::None {
587        children.push(child_id);
588    }
589    while let Some(hierarchy_item) = hierarchy_container.get(child_id) {
590        let Some(next) = hierarchy_item.next_sibling_id() else {
591            break;
592        };
593        if get_display_type(styled_dom, next) != LayoutDisplay::None {
594            children.push(next);
595        }
596        child_id = next;
597    }
598
599    children
600}
601
602/// Checks if a flex container is simple enough to be treated like a block-stack for
603/// repositioning.
604pub fn is_simple_flex_stack(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> bool {
605    let Some(id) = dom_id else { return false };
606    let binding = styled_dom.styled_nodes.as_container();
607    let styled_node = match binding.get(id) {
608        Some(styled_node) => styled_node,
609        None => return false,
610    };
611
612    // Must be a single-line flex container
613    let wrap = get_wrap(styled_dom, id, &styled_node.styled_node_state);
614
615    if wrap.unwrap_or_default() != LayoutFlexWrap::NoWrap {
616        return false;
617    }
618
619    // Must be start-aligned, so there's no space distribution to recalculate.
620    let justify = get_justify_content(styled_dom, id, &styled_node.styled_node_state);
621
622    if !matches!(
623        justify.unwrap_or_default(),
624        LayoutJustifyContent::FlexStart | LayoutJustifyContent::Start
625    ) {
626        return false;
627    }
628
629    // Crucially, no clean siblings can have flexible sizes, otherwise a dirty
630    // sibling's size change could affect their resolved size.
631    // NOTE: This check is expensive and incomplete. A more robust solution might
632    // store flags on the LayoutNode indicating if flex factors are present.
633    // For now, we assume that if a container *could* have complex flex behavior,
634    // we play it safe and require a full relayout. This heuristic is a compromise.
635    // To be truly safe, we'd have to check all children for flex-grow/shrink > 0.
636
637    true
638}
639
640/// Repositions clean children within a simple block-flow layout (like a BFC or a
641/// table-row-group). It stacks children along the main axis, preserving their
642/// previously calculated cross-axis alignment.
643pub fn reposition_block_flow_siblings(
644    styled_dom: &StyledDom,
645    parent_idx: usize,
646    tree: &LayoutTree,
647    layout_roots: &BTreeSet<usize>,
648    calculated_positions: &mut super::PositionVec,
649) {
650    let parent_node = match tree.get(parent_idx) {
651        Some(n) => n,
652        None => return,
653    };
654    let dom_id = parent_node.dom_node_id.unwrap_or(NodeId::ZERO);
655    let styled_node_state = styled_dom
656        .styled_nodes
657        .as_container()
658        .get(dom_id)
659        .map(|n| n.styled_node_state.clone())
660        .unwrap_or_default();
661
662    let writing_mode = get_writing_mode(styled_dom, dom_id, &styled_node_state).unwrap_or_default();
663
664    let parent_pos = calculated_positions
665        .get(parent_idx)
666        .copied()
667        .unwrap_or_default();
668
669    let parent_bp = parent_node.box_props.unpack();
670    let content_box_origin = LogicalPosition::new(
671        parent_pos.x + parent_bp.padding.left,
672        parent_pos.y + parent_bp.padding.top,
673    );
674
675    let mut main_pen = 0.0;
676
677    for &child_idx in tree.children(parent_idx) {
678        let child_node = match tree.get(child_idx) {
679            Some(n) => n,
680            None => continue,
681        };
682
683        let child_size = child_node.used_size.unwrap_or_default();
684        let child_bp = child_node.box_props.unpack();
685        let child_main_sum = child_bp.margin.main_sum(writing_mode);
686        let margin_box_main_size = child_size.main(writing_mode) + child_main_sum;
687
688        if layout_roots.contains(&child_idx) {
689            // This child was DIRTY and has been correctly repositioned.
690            // Update the pen to the position immediately after this child.
691            let new_pos = match calculated_positions.get(child_idx) {
692                Some(p) => *p,
693                None => continue,
694            };
695
696            let main_axis_offset = if writing_mode.is_vertical() {
697                new_pos.x - content_box_origin.x
698            } else {
699                new_pos.y - content_box_origin.y
700            };
701
702            main_pen = main_axis_offset
703                + child_size.main(writing_mode)
704                + child_bp.margin.main_end(writing_mode);
705        } else {
706            // This child is *clean*. Calculate its new position and shift its
707            // entire subtree.
708            let old_pos = match calculated_positions.get(child_idx) {
709                Some(p) => *p,
710                None => continue,
711            };
712
713            let child_main_start = child_bp.margin.main_start(writing_mode);
714            let new_main_pos = main_pen + child_main_start;
715            let old_relative_pos = tree.warm(child_idx)
716                .and_then(|w| w.relative_position)
717                .unwrap_or_default();
718            let cross_pos = if writing_mode.is_vertical() {
719                old_relative_pos.y
720            } else {
721                old_relative_pos.x
722            };
723            let new_relative_pos =
724                LogicalPosition::from_main_cross(new_main_pos, cross_pos, writing_mode);
725
726            let new_absolute_pos = LogicalPosition::new(
727                content_box_origin.x + new_relative_pos.x,
728                content_box_origin.y + new_relative_pos.y,
729            );
730
731            if old_pos != new_absolute_pos {
732                let delta = LogicalPosition::new(
733                    new_absolute_pos.x - old_pos.x,
734                    new_absolute_pos.y - old_pos.y,
735                );
736                shift_subtree_position(child_idx, delta, tree, calculated_positions);
737            }
738
739            main_pen += margin_box_main_size;
740        }
741    }
742}
743
744/// Helper to recursively shift the absolute position of a node and all its descendants.
745pub fn shift_subtree_position(
746    node_idx: usize,
747    delta: LogicalPosition,
748    tree: &LayoutTree,
749    calculated_positions: &mut super::PositionVec,
750) {
751    if let Some(pos) = calculated_positions.get_mut(node_idx) {
752        pos.x += delta.x;
753        pos.y += delta.y;
754    }
755
756    if let Some(node) = tree.get(node_idx) {
757        let children = tree.children(node_idx).to_vec();
758        for &child_idx in &children {
759            shift_subtree_position(child_idx, delta, tree, calculated_positions);
760        }
761    }
762}
763
764/// Compares the new DOM against the cached tree, creating a new tree
765/// and identifying which parts need to be re-laid out.
766/// Count how many of the supplied DOM children would actually end up
767/// in the layout tree. Mirrors the filters applied by
768/// `LayoutTreeBuilder::build_recursive` so reconciliation can compare
769/// like-for-like:
770///
771/// - `display: none` nodes are skipped entirely.
772/// - In table structural contexts (table, row-group, row) whitespace
773///   text nodes are skipped (CSS 2.2 §17.2.1, matches
774///   `should_skip_for_table_structure`).
775/// - Whitespace-only inline runs that sit between block siblings
776///   collapse to zero boxes (CSS 2.2 §9.2.2.1).
777///
778/// The first two rules drop children unconditionally; the third only
779/// fires on siblings surrounding a block-level child, so we detect it
780/// by walking the run pairs. We do not build the runs — just count
781/// survivors.
782fn layout_relevant_child_count(
783    styled_dom: &azul_core::styled_dom::StyledDom,
784    children: &[NodeId],
785    parent_id: NodeId,
786) -> usize {
787    use super::getters::{get_display_property, MultiValue};
788    use super::layout_tree::{is_block_level, is_whitespace_only_text};
789
790    let parent_display = match get_display_property(styled_dom, Some(parent_id)) {
791        MultiValue::Exact(d) => d,
792        _ => azul_css::props::layout::display::LayoutDisplay::Block,
793    };
794    let is_table_structural = matches!(
795        parent_display,
796        azul_css::props::layout::display::LayoutDisplay::Table
797            | azul_css::props::layout::display::LayoutDisplay::InlineTable
798            | azul_css::props::layout::display::LayoutDisplay::TableRowGroup
799            | azul_css::props::layout::display::LayoutDisplay::TableHeaderGroup
800            | azul_css::props::layout::display::LayoutDisplay::TableFooterGroup
801            | azul_css::props::layout::display::LayoutDisplay::TableRow
802    );
803
804    let has_any_block_child = children
805        .iter()
806        .any(|&id| is_block_level(styled_dom, id));
807
808    let mut count = 0usize;
809    // When parent has any block child, whitespace-only inline runs
810    // surrounding blocks collapse. We approximate that by skipping
811    // whitespace text whenever any block sibling exists.
812    let collapse_inline_whitespace = has_any_block_child;
813    for &id in children {
814        // display:none drops
815        let display = match get_display_property(styled_dom, Some(id)) {
816            MultiValue::Exact(d) => d,
817            _ => azul_css::props::layout::display::LayoutDisplay::Block,
818        };
819        if matches!(display, azul_css::props::layout::display::LayoutDisplay::None) {
820            continue;
821        }
822        // Table-structural whitespace drops.
823        if is_table_structural && is_whitespace_only_text(styled_dom, id) {
824            continue;
825        }
826        // Whitespace-only inline run collapse when mixed with blocks.
827        if collapse_inline_whitespace
828            && !is_block_level(styled_dom, id)
829            && is_whitespace_only_text(styled_dom, id)
830        {
831            continue;
832        }
833        count += 1;
834    }
835    count
836}
837
838pub fn reconcile_and_invalidate<T: ParsedFontTrait>(
839    ctx: &mut LayoutContext<'_, T>,
840    cache: &LayoutCache,
841    viewport: LogicalRect,
842) -> Result<(LayoutTree, ReconciliationResult)> {
843    let _probe_outer = crate::probe::Probe::span("reconcile_and_invalidate");
844    let mut new_tree_builder = LayoutTreeBuilder::new(ctx.viewport_size);
845    let mut recon_result = ReconciliationResult::default();
846    let old_tree = cache.tree.as_ref();
847
848    // Check for viewport resize, which dirties the root for a top-down pass.
849    if cache.viewport.map_or(true, |v| v.size != viewport.size) {
850        recon_result.layout_roots.insert(0); // Root is always index 0
851    }
852
853    let root_dom_id = ctx
854        .styled_dom
855        .root
856        .into_crate_internal()
857        .unwrap_or(NodeId::ZERO);
858    let root_idx = reconcile_recursive(
859        ctx.styled_dom,
860        root_dom_id,
861        old_tree.map(|t| t.root),
862        None,
863        old_tree,
864        &mut new_tree_builder,
865        &mut recon_result,
866        &mut ctx.debug_messages,
867    )?;
868
869    // Clean up layout roots: if a parent is a layout root, its children don't need to be.
870    let final_layout_roots = recon_result
871        .layout_roots
872        .iter()
873        .filter(|&&idx| {
874            let mut current = new_tree_builder.get(idx).and_then(|n| n.parent);
875            while let Some(p_idx) = current {
876                if recon_result.layout_roots.contains(&p_idx) {
877                    return false;
878                }
879                current = new_tree_builder.get(p_idx).and_then(|n| n.parent);
880            }
881            true
882        })
883        .copied()
884        .collect();
885    recon_result.layout_roots = final_layout_roots;
886
887    let new_tree = new_tree_builder.build(root_idx);
888    // M12.7 diag: reconcile is about to return Ok. If 0x400AC is set but
889    // layout_document's step marker is stuck at 1 (post-`?` not reached), the
890    // lifted `?` mis-discriminated this Ok as Err (niche-Result mis-lift).
891    unsafe { core::ptr::write_volatile(0x400AC as *mut u32, 0xCC00_0001u32); }
892    Ok((new_tree, recon_result))
893}
894
895/// CSS 2.2 § 9.2.2.1: Checks whether an inline run consists entirely of
896/// whitespace-only text nodes, in which case it should NOT generate an
897/// anonymous IFC wrapper in a BFC mixed-content context.
898///
899/// This prevents whitespace between block elements from creating empty
900/// anonymous blocks that take up vertical space (regression c33e94b0).
901///
902/// Exception: if the parent (or any ancestor) has `white-space: pre`,
903/// `pre-wrap`, or `pre-line`, whitespace IS significant and the wrapper
904/// must still be created.
905fn is_whitespace_only_inline_run(
906    styled_dom: &StyledDom,
907    inline_run: &[(usize, NodeId)],
908    parent_dom_id: NodeId,
909) -> bool {
910    use azul_css::props::style::text::StyleWhiteSpace;
911
912    if inline_run.is_empty() {
913        return true;
914    }
915
916    // Check if the parent preserves whitespace
917    let parent_state = &styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
918    let white_space = match get_white_space_property(styled_dom, parent_dom_id, parent_state) {
919        MultiValue::Exact(ws) => Some(ws),
920        _ => None,
921    };
922
923    // If white-space preserves whitespace, don't strip
924    if matches!(
925        white_space,
926        Some(StyleWhiteSpace::Pre) | Some(StyleWhiteSpace::PreWrap) | Some(StyleWhiteSpace::PreLine)
927    ) {
928        return false;
929    }
930
931    // Check that every node in the run is a whitespace-only text node
932    let binding = styled_dom.node_data.as_container();
933    for &(_, dom_id) in inline_run {
934        if let Some(data) = binding.get(dom_id) {
935            match data.get_node_type() {
936                NodeType::Text(text) => {
937                    let s = text.as_str();
938                    if !s.chars().all(|c| matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0C')) {
939                        return false; // Non-whitespace text → must create wrapper
940                    }
941                }
942                _ => {
943                    return false; // Non-text inline element → must create wrapper
944                }
945            }
946        }
947    }
948
949    true // All nodes are whitespace-only text
950}
951
952/// Recursively traverses the new DOM and old tree, building a new tree and marking dirty nodes.
953pub fn reconcile_recursive(
954    styled_dom: &StyledDom,
955    new_dom_id: NodeId,
956    old_tree_idx: Option<usize>,
957    new_parent_idx: Option<usize>,
958    old_tree: Option<&LayoutTree>,
959    new_tree_builder: &mut LayoutTreeBuilder,
960    recon: &mut ReconciliationResult,
961    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
962) -> Result<usize> {
963    let node_data = &styled_dom.node_data.as_container()[new_dom_id];
964
965    let old_cold = old_tree.and_then(|t| old_tree_idx.and_then(|idx| t.cold(idx)));
966    match (old_tree.is_some(), old_tree_idx.is_some(), old_cold.is_some()) {
967        (false, _, _) => drop(crate::probe::Probe::span("recon_old_tree_none")),
968        (true, false, _) => drop(crate::probe::Probe::span("recon_old_idx_none")),
969        (true, true, false) => drop(crate::probe::Probe::span("recon_cold_none")),
970        (true, true, true) => drop(crate::probe::Probe::span("recon_cold_some")),
971    }
972
973    // Compute the new multi-field fingerprint instead of a single hash.
974    let new_fingerprint = {
975        let _p = crate::probe::Probe::span("fingerprint_compute");
976        NodeDataFingerprint::compute(
977            node_data,
978            styled_dom.styled_nodes.as_container().get(new_dom_id).map(|n| &n.styled_node_state),
979        )
980    };
981
982    // Compare fingerprints to determine what changed (Layout, Paint, or Nothing).
983    let dirty_flag = match old_cold {
984        None => {
985            drop(crate::probe::Probe::span("fp_new_node"));
986            DirtyFlag::Layout // new node → full layout
987        },
988        Some(old_c) => {
989            let change_set = old_c.node_data_fingerprint.diff(&new_fingerprint);
990            if change_set.needs_layout() {
991                drop(crate::probe::Probe::span("fp_needs_layout"));
992                // Cache the env check in a `OnceLock<bool>`: this branch
993                // fires once per dirty node (hundreds on cold layout),
994                // and a direct `env::var` is a mutex + hashmap lookup
995                // on macOS (~100 ns/call) even when the env var is unset.
996                static FP_DUMP_ENABLED: std::sync::OnceLock<bool> =
997                    std::sync::OnceLock::new();
998                let enabled = *FP_DUMP_ENABLED.get_or_init(|| {
999                    std::env::var_os("AZ_FP_DUMP").is_some()
1000                });
1001                if enabled {
1002                    use std::sync::atomic::{AtomicUsize, Ordering};
1003                    static DUMPED: AtomicUsize = AtomicUsize::new(0);
1004                    let n = DUMPED.fetch_add(1, Ordering::Relaxed);
1005                    if n < 10 {
1006                        eprintln!(
1007                            "[fp_diff {n}] dom={} old={:?} new={:?}",
1008                            new_dom_id.index(),
1009                            old_c.node_data_fingerprint,
1010                            new_fingerprint,
1011                        );
1012                    }
1013                }
1014                DirtyFlag::Layout
1015            } else if change_set.needs_paint() {
1016                drop(crate::probe::Probe::span("fp_needs_paint"));
1017                DirtyFlag::Paint
1018            } else {
1019                drop(crate::probe::Probe::span("fp_clean"));
1020                DirtyFlag::None
1021            }
1022        }
1023    };
1024    let is_dirty = dirty_flag >= DirtyFlag::Paint;
1025
1026    // M12.7: `|| old_tree.is_none()` — on COLD layout there is no old tree to
1027    // clone, so we MUST create a fresh node; taking the else-branch would hit
1028    // `ok_or(InvalidTree)` on a None old_tree. This is both semantically correct
1029    // AND robust against a mis-lifted `dirty_flag`/Option match (the suspected
1030    // niche-enum mis-discriminant) wrongly steering cold nodes into the else.
1031    let new_node_idx = if dirty_flag >= DirtyFlag::Layout || old_tree.is_none() {
1032        unsafe { core::ptr::write_volatile(0x400A8 as *mut u32, 0xBB00_0001u32); }
1033        new_tree_builder.create_node_from_dom(
1034            styled_dom,
1035            new_dom_id,
1036            new_parent_idx,
1037            debug_messages,
1038        )
1039    } else {
1040        unsafe { core::ptr::write_volatile(0x400A8 as *mut u32, 0xBB00_0002u32); }
1041        // Paint-only or clean: clone the old node (preserving layout cache)
1042        let old_full_node = old_tree
1043            .and_then(|t| old_tree_idx.and_then(|idx| t.get_full_node(idx)))
1044            .ok_or(LayoutError::InvalidTree)?;
1045        let mut idx = new_tree_builder.clone_node_from_old(&old_full_node, new_parent_idx);
1046        // If paint-only change, update the fingerprint and dirty flag
1047        if dirty_flag == DirtyFlag::Paint {
1048            if let Some(cloned) = new_tree_builder.get_mut(idx) {
1049                cloned.node_data_fingerprint = new_fingerprint;
1050                cloned.dirty_flag = DirtyFlag::Paint;
1051            }
1052        }
1053        idx
1054    };
1055
1056    // M12.7 diag: 0x400BC = new_node_idx (create_node_from_dom's return value) as
1057    // reconcile_recursive sees it. 0 = correct (the first node); 64 (matching the
1058    // build-marker root_idx) = the usize return mis-reads here.
1059    unsafe { core::ptr::write_volatile(0x400BC as *mut u32, 0xAB00_0000u32 | (new_node_idx as u32 & 0xffff)); }
1060
1061    // CRITICAL: For list-items, create a ::marker pseudo-element as the first child
1062    // This must be done after the node is created but before processing children
1063    // Per CSS Lists Module Level 3, ::marker is generated as the first child of list-items
1064    {
1065        use crate::solver3::getters::get_display_property;
1066        let display = get_display_property(styled_dom, Some(new_dom_id))
1067            .exact();
1068
1069        if matches!(display, Some(LayoutDisplay::ListItem)) {
1070            // Create ::marker pseudo-element for this list-item
1071            new_tree_builder.create_marker_pseudo_element(styled_dom, new_dom_id, new_node_idx);
1072        }
1073    }
1074
1075    // Reconcile children to check for structural changes and build the new tree structure.
1076    let mut new_children_dom_ids: Vec<_> = collect_children_dom_ids(styled_dom, new_dom_id);
1077
1078    // CSS 2.2 §17.2.1: Filter whitespace-only text nodes from table structural elements
1079    // (table, row-group, row). Without this, the reconciler sees them as "inline" children
1080    // mixed with block-level <td>/<th>, triggering incorrect anonymous IFC wrapping.
1081    // The layout tree builder already does this via should_skip_for_table_structure().
1082    {
1083        use super::getters::{get_display_property, MultiValue};
1084        let parent_display = match get_display_property(styled_dom, Some(new_dom_id)) {
1085            MultiValue::Exact(d) => d,
1086            _ => azul_css::props::layout::display::LayoutDisplay::Block,
1087        };
1088        if matches!(parent_display,
1089            azul_css::props::layout::display::LayoutDisplay::Table
1090            | azul_css::props::layout::display::LayoutDisplay::InlineTable
1091            | azul_css::props::layout::display::LayoutDisplay::TableRowGroup
1092            | azul_css::props::layout::display::LayoutDisplay::TableHeaderGroup
1093            | azul_css::props::layout::display::LayoutDisplay::TableFooterGroup
1094            | azul_css::props::layout::display::LayoutDisplay::TableRow
1095        ) {
1096            new_children_dom_ids.retain(|&id| {
1097                !super::layout_tree::is_whitespace_only_text(styled_dom, id)
1098            });
1099        }
1100    }
1101
1102    // Compute both positional and DOM-keyed lookups for the old
1103    // tree's children. The DOM-keyed map is authoritative for
1104    // reconciliation (positional drifts every time the layout-tree
1105    // builder drops a DOM child — whitespace text, display:none,
1106    // table-structural whitespace — or inserts an anonymous
1107    // wrapper that isn't in the DOM).
1108    let old_children_indices: Vec<usize> = old_tree
1109        .and_then(|t| old_tree_idx.map(|idx| t.children(idx).to_vec()))
1110        .unwrap_or_default();
1111    let old_children_by_dom: alloc::collections::BTreeMap<NodeId, usize> = old_tree
1112        .and_then(|t| old_tree_idx.map(|idx| {
1113            t.children(idx).iter()
1114                .filter_map(|&cidx| t.get(cidx).and_then(|n| n.dom_node_id).map(|did| (did, cidx)))
1115                .collect()
1116        }))
1117        .unwrap_or_default();
1118
1119    // Count of old layout children that correspond to a real DOM
1120    // node (exclude anonymous wrappers). This is what we compare
1121    // against the layout-relevant subset of new DOM children to
1122    // decide whether the structural shape actually changed.
1123    let old_layout_relevant_count = old_children_by_dom.len();
1124
1125    // Filter new DOM children to the subset the layout-tree builder
1126    // would actually emit. This mirrors `should_skip_for_table_structure`
1127    // and the `is_whitespace_only_inline_run` logic. Without this
1128    // filter, `children_are_different` fires on every reconcile
1129    // because the DOM has whitespace text nodes the layout tree
1130    // drops.
1131    let new_layout_relevant_count = layout_relevant_child_count(styled_dom, &new_children_dom_ids, new_dom_id);
1132
1133    let mut children_are_different = new_layout_relevant_count != old_layout_relevant_count;
1134    let mut new_child_hashes = Vec::new();
1135
1136    // +spec:display-property:42f9c0 - anonymous block boxes wrap inline runs when block container has mixed block/inline children
1137    // CSS 2.2 Section 9.2.1.1: Anonymous Block Boxes
1138    // When a block container has mixed block/inline children, we must:
1139    // 1. Wrap consecutive inline children in anonymous block boxes
1140    // 2. Leave block-level children as direct children
1141
1142    let has_block_child = new_children_dom_ids
1143        .iter()
1144        .any(|&id| is_block_level(styled_dom, id));
1145
1146    if !has_block_child {
1147        // All children are inline - no anonymous boxes needed
1148        // Simple case: process each child directly
1149        for (i, &new_child_dom_id) in new_children_dom_ids.iter().enumerate() {
1150            // DOM-ID match rather than positional — tree builder
1151            // may have dropped some DOM children (whitespace text
1152            // nodes) so positional drift mis-aligns the cache.
1153            // DOM-id match only: positional fallback would align
1154            // anonymous wrappers against real DOM nodes and trigger
1155            // spurious fingerprint mismatches (see fp_diff dump).
1156            let old_child_idx = old_children_by_dom.get(&new_child_dom_id).copied();
1157
1158            let reconciled_child_idx = reconcile_recursive(
1159                styled_dom,
1160                new_child_dom_id,
1161                old_child_idx,
1162                Some(new_node_idx),
1163                old_tree,
1164                new_tree_builder,
1165                recon,
1166                debug_messages,
1167            )?;
1168            if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1169                new_child_hashes.push(child_node.subtree_hash.0);
1170            }
1171
1172            if old_tree.and_then(|t| t.cold(old_child_idx?).map(|n| n.subtree_hash))
1173                != new_tree_builder
1174                    .get(reconciled_child_idx)
1175                    .map(|n| n.subtree_hash)
1176            {
1177                children_are_different = true;
1178            }
1179        }
1180    } else {
1181        // Mixed content: block and inline children
1182        // We must create anonymous block boxes around consecutive inline runs
1183
1184        if let Some(msgs) = debug_messages.as_mut() {
1185            msgs.push(LayoutDebugMessage::info(format!(
1186                "[reconcile_recursive] Mixed content in node {}: creating anonymous IFC wrappers",
1187                new_dom_id.index()
1188            )));
1189        }
1190
1191        let mut inline_run: Vec<(usize, NodeId)> = Vec::new(); // (dom_child_index, dom_id)
1192
1193        for (i, &new_child_dom_id) in new_children_dom_ids.iter().enumerate() {
1194            if is_block_level(styled_dom, new_child_dom_id) {
1195                // End current inline run if any
1196                if !inline_run.is_empty() {
1197                    // CSS 2.2 § 9.2.2.1: If the inline run consists entirely of
1198                    // whitespace-only text nodes (and white-space doesn't preserve it),
1199                    // skip creating the anonymous IFC wrapper. This prevents inter-block
1200                    // whitespace from creating empty blocks that take up vertical space.
1201                    // +spec:display-property:bef3fc - anonymous blocks of only collapsible whitespace removed from rendering tree
1202                    if is_whitespace_only_inline_run(styled_dom, &inline_run, new_dom_id) {
1203                        if let Some(msgs) = debug_messages.as_mut() {
1204                            msgs.push(LayoutDebugMessage::info(format!(
1205                                "[reconcile_recursive] Skipping whitespace-only inline run ({} nodes) between blocks in node {}",
1206                                inline_run.len(),
1207                                new_dom_id.index()
1208                            )));
1209                        }
1210                        inline_run.clear();
1211                    } else {
1212                    // Create anonymous IFC wrapper for the inline run
1213                    // This wrapper establishes an Inline Formatting Context
1214                    let anon_idx = new_tree_builder.create_anonymous_node(
1215                        new_node_idx,
1216                        AnonymousBoxType::InlineWrapper,
1217                        FormattingContext::Inline, // IFC for inline content
1218                    );
1219
1220                    if let Some(msgs) = debug_messages.as_mut() {
1221                        msgs.push(LayoutDebugMessage::info(format!(
1222                            "[reconcile_recursive] Created anonymous IFC wrapper (layout_idx={}) for {} inline children: {:?}",
1223                            anon_idx,
1224                            inline_run.len(),
1225                            inline_run.iter().map(|(_, id)| id.index()).collect::<Vec<_>>()
1226                        )));
1227                    }
1228
1229                    // Process each inline child under the anonymous wrapper
1230                    for (pos, inline_dom_id) in inline_run.drain(..) {
1231                        // Inline children live under the anon wrapper
1232                        // in the old tree, so the parent's direct
1233                        // `old_children_by_dom` map won't hit them.
1234                        // Fall through to the global `dom_to_layout`
1235                        // map; we don't care which anon wrapper they
1236                        // were under, only that their cold data
1237                        // (fingerprint) gets matched correctly.
1238                        let old_child_idx = old_children_by_dom.get(&inline_dom_id).copied()
1239                            .or_else(|| old_tree
1240                                .and_then(|t| t.dom_to_layout.get(&inline_dom_id))
1241                                .and_then(|v| v.first().copied()));
1242                        let reconciled_child_idx = reconcile_recursive(
1243                            styled_dom,
1244                            inline_dom_id,
1245                            old_child_idx,
1246                            Some(anon_idx), // Parent is the anonymous wrapper
1247                            old_tree,
1248                            new_tree_builder,
1249                            recon,
1250                            debug_messages,
1251                        )?;
1252                        if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1253                            new_child_hashes.push(child_node.subtree_hash.0);
1254                        }
1255                    }
1256
1257                    // NOTE: We intentionally do NOT unconditionally
1258                    // mark the anonymous wrapper as intrinsic_dirty
1259                    // here. If any of the inline children are
1260                    // themselves dirty, their own `mark_dirty` call
1261                    // propagates upward through this wrapper, so
1262                    // wrappers whose content is unchanged keep their
1263                    // cached layout. Setting `children_are_different`
1264                    // when the wrapper is newly created (no matching
1265                    // old anon) flips the parent to layout-dirty,
1266                    // which is what triggers a fresh wrapper layout.
1267                    children_are_different = true;
1268                    } // end else (non-whitespace run)
1269                }
1270
1271                // Process block-level child directly under parent
1272                let old_child_idx = old_children_by_dom.get(&new_child_dom_id).copied()
1273                    .or_else(|| old_children_indices.get(i).copied());
1274                let reconciled_child_idx = reconcile_recursive(
1275                    styled_dom,
1276                    new_child_dom_id,
1277                    old_child_idx,
1278                    Some(new_node_idx),
1279                    old_tree,
1280                    new_tree_builder,
1281                    recon,
1282                    debug_messages,
1283                )?;
1284                if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1285                    new_child_hashes.push(child_node.subtree_hash.0);
1286                }
1287
1288                if old_tree.and_then(|t| t.cold(old_child_idx?).map(|n| n.subtree_hash))
1289                    != new_tree_builder
1290                        .get(reconciled_child_idx)
1291                        .map(|n| n.subtree_hash)
1292                {
1293                    children_are_different = true;
1294                }
1295            } else {
1296                // Inline-level child - add to current run
1297                inline_run.push((i, new_child_dom_id));
1298            }
1299        }
1300
1301        // Process any remaining inline run at the end
1302        if !inline_run.is_empty() {
1303            // CSS 2.2 § 9.2.2.1: Skip whitespace-only trailing inline runs
1304            if is_whitespace_only_inline_run(styled_dom, &inline_run, new_dom_id) {
1305                if let Some(msgs) = debug_messages.as_mut() {
1306                    msgs.push(LayoutDebugMessage::info(format!(
1307                        "[reconcile_recursive] Skipping trailing whitespace-only inline run ({} nodes) in node {}",
1308                        inline_run.len(),
1309                        new_dom_id.index()
1310                    )));
1311                }
1312                // Don't create a wrapper — just drop the run
1313            } else {
1314            let anon_idx = new_tree_builder.create_anonymous_node(
1315                new_node_idx,
1316                AnonymousBoxType::InlineWrapper,
1317                FormattingContext::Inline, // IFC for inline content
1318            );
1319
1320            if let Some(msgs) = debug_messages.as_mut() {
1321                msgs.push(LayoutDebugMessage::info(format!(
1322                    "[reconcile_recursive] Created trailing anonymous IFC wrapper (layout_idx={}) for {} inline children: {:?}",
1323                    anon_idx,
1324                    inline_run.len(),
1325                    inline_run.iter().map(|(_, id)| id.index()).collect::<Vec<_>>()
1326                )));
1327            }
1328
1329            for (pos, inline_dom_id) in inline_run.drain(..) {
1330                let old_child_idx = old_children_by_dom.get(&inline_dom_id).copied();
1331                let reconciled_child_idx = reconcile_recursive(
1332                    styled_dom,
1333                    inline_dom_id,
1334                    old_child_idx,
1335                    Some(anon_idx),
1336                    old_tree,
1337                    new_tree_builder,
1338                    recon,
1339                    debug_messages,
1340                )?;
1341                if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1342                    new_child_hashes.push(child_node.subtree_hash.0);
1343                }
1344            }
1345
1346            // See note in main mixed-content branch: rely on
1347            // children's own mark_dirty to propagate upward rather
1348            // than invalidating the whole wrapper each reconcile.
1349            children_are_different = true;
1350            } // end else (non-whitespace trailing run)
1351        }
1352    }
1353
1354    // After reconciling children, calculate this node's full subtree hash.
1355    // Use a combined hash of the fingerprint fields for the subtree hash.
1356    let node_self_hash = {
1357        use std::hash::{DefaultHasher, Hash, Hasher};
1358        let mut h = DefaultHasher::new();
1359        new_fingerprint.hash(&mut h);
1360        h.finish()
1361    };
1362    let final_subtree_hash = calculate_subtree_hash(node_self_hash, &new_child_hashes);
1363    if let Some(current_node) = new_tree_builder.get_mut(new_node_idx) {
1364        current_node.subtree_hash = final_subtree_hash;
1365    }
1366
1367    // Classify this node into the appropriate dirty set based on what changed.
1368    if dirty_flag >= DirtyFlag::Layout || children_are_different {
1369        recon.intrinsic_dirty.insert(new_node_idx);
1370        recon.layout_roots.insert(new_node_idx);
1371    } else if dirty_flag == DirtyFlag::Paint {
1372        recon.paint_dirty.insert(new_node_idx);
1373    }
1374
1375    Ok(new_node_idx)
1376}
1377
1378/// Result of `prepare_layout_context`: contains the layout constraints and
1379/// intermediate values needed for `calculate_layout_for_subtree`.
1380struct PreparedLayoutContext<'a> {
1381    constraints: LayoutConstraints<'a>,
1382    /// DOM ID for the node. None for anonymous boxes.
1383    dom_id: Option<NodeId>,
1384    writing_mode: LayoutWritingMode,
1385    final_used_size: LogicalSize,
1386    box_props: crate::solver3::geometry::BoxProps,
1387}
1388
1389/// Prepares the layout context for a single node by calculating its used size
1390/// and building the layout constraints for its children.
1391///
1392/// For anonymous boxes (no dom_node_id), we use default values and inherit
1393/// from the containing block.
1394fn prepare_layout_context<'a, T: ParsedFontTrait>(
1395    ctx: &LayoutContext<'a, T>,
1396    tree: &LayoutTree,
1397    node_index: usize,
1398    containing_block_size: LogicalSize,
1399) -> Result<PreparedLayoutContext<'a>> {
1400    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
1401    let warm = tree.warm(node_index).ok_or(LayoutError::InvalidTree)?;
1402    let dom_id = node.dom_node_id; // Can be None for anonymous boxes
1403
1404    // Phase 1: Calculate this node's provisional used size
1405
1406    // This size is based on the node's CSS properties (width, height, etc.) and
1407    // its containing block. If height is 'auto', this is a temporary value.
1408    let intrinsic = warm.intrinsic_sizes.clone().unwrap_or_default();
1409    let final_used_size = calculate_used_size_for_node(
1410        ctx.styled_dom,
1411        dom_id, // Now Option<NodeId>
1412        containing_block_size,
1413        intrinsic,
1414        &node.box_props.unpack(),
1415        ctx.viewport_size,
1416    )?;
1417
1418    // Phase 2: Layout children using a formatting context
1419    // Use pre-computed styles from LayoutNodeWarm instead of repeated lookups
1420    let writing_mode = warm.computed_style.writing_mode;
1421    let text_align = warm.computed_style.text_align;
1422    let display = warm.computed_style.display;
1423    let overflow_y = warm.computed_style.overflow_y;
1424
1425    // Check if height is auto (no explicit height set)
1426    let height_is_auto = warm.computed_style.height.is_none();
1427
1428    let available_size_for_children = if height_is_auto {
1429        // Height is auto - use containing block size as available size
1430        let inner_size = node.box_props.inner_size(final_used_size, writing_mode);
1431
1432        // For inline elements (display: inline), the available width comes from
1433        // the containing block, not from the element's own intrinsic size.
1434        // CSS 2.2 § 10.3.1: Inline, non-replaced elements use containing block width.
1435        let available_width = match display {
1436            LayoutDisplay::Inline => containing_block_size.width,
1437            _ => inner_size.width,
1438        };
1439
1440        LogicalSize {
1441            width: available_width,
1442            // Use containing block height!
1443            height: containing_block_size.height,
1444        }
1445    } else {
1446        // Height is explicit - use inner size (after padding/border)
1447        node.box_props.inner_size(final_used_size, writing_mode)
1448    };
1449
1450    // NOTE: Scrollbar reservation is handled inside layout_bfc() where it subtracts
1451    // scrollbar width from children_containing_block_size. We do NOT subtract here
1452    // to avoid double-subtraction (layout_bfc already handles both the used_size
1453    // and available_size code paths).
1454
1455    let wm_ctx = crate::solver3::geometry::WritingModeContext::new(
1456        writing_mode,
1457        warm.computed_style.direction,
1458        warm.computed_style.text_orientation,
1459    );
1460    let constraints = LayoutConstraints {
1461        available_size: available_size_for_children,
1462        bfc_state: None,
1463        writing_mode,
1464        writing_mode_ctx: wm_ctx,
1465        text_align: style_text_align_to_fc(text_align),
1466        containing_block_size,
1467        available_width_type: Text3AvailableSpace::Definite(available_size_for_children.width),
1468    };
1469
1470    Ok(PreparedLayoutContext {
1471        constraints,
1472        dom_id,
1473        writing_mode,
1474        final_used_size,
1475        box_props: node.box_props.unpack(),
1476    })
1477}
1478
1479/// Core scrollbar info computation: given pre-computed content and container sizes plus
1480/// a DOM node for style look-up, determines whether scrollbars are needed.
1481///
1482/// This is the single source of truth for scrollbar detection. Both the BFC path
1483/// (`compute_scrollbar_info`) and the Taffy flex/grid path (`compute_child_layout`
1484/// in taffy_bridge.rs) call this function, ensuring consistent behaviour.
1485///
1486/// For paged media (PDF), scrollbars are never added since they don't exist in print.
1487pub fn compute_scrollbar_info_core<T: ParsedFontTrait>(
1488    ctx: &LayoutContext<'_, T>,
1489    dom_id: NodeId,
1490    styled_node_state: &azul_core::styled_dom::StyledNodeState,
1491    content_size: LogicalSize,
1492    container_size: LogicalSize,
1493) -> ScrollbarRequirements {
1494    // +spec:overflow:08b60d - non-interactive media: UA may show scroll indicators but we skip them for print
1495    if ctx.fragmentation_context.is_some() {
1496        return ScrollbarRequirements::default();
1497    }
1498
1499    let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, styled_node_state);
1500    let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, styled_node_state);
1501
1502    // Resolve the full scrollbar style **once** and reuse it
1503    // across the rest of this function + any further calls from
1504    // the same layout pass via `LayoutContext::scrollbar_style_cache`.
1505    // Previously we called `get_layout_scrollbar_width_px` (which
1506    // builds the full scrollbar_style internally, keeps only
1507    // `reserve_width_px`, then drops it) and then
1508    // `get_scrollbar_style` again — each build performs 9 cascade
1509    // walks (track/thumb/button/corner/width/color/visibility/
1510    // fade-delay/fade-duration). With the memo, subsequent calls
1511    // on the same (dom_id, state) are a HashMap hit.
1512    let scrollbar_style = crate::solver3::getters::get_scrollbar_style_cached(
1513        ctx, dom_id, styled_node_state,
1514    );
1515    let scrollbar_width_px = scrollbar_style.reserve_width_px;
1516
1517    let mut reqs = fc::check_scrollbar_necessity(
1518        content_size,
1519        container_size,
1520        to_overflow_behavior(overflow_x),
1521        to_overflow_behavior(overflow_y),
1522        scrollbar_width_px,
1523    );
1524    reqs.visual_width_px = scrollbar_style.visual_width_px;
1525
1526    // +spec:overflow:e90f12 - scrollbar-gutter reserves space independently of scrollbar presence
1527    // +spec:overflow:3c44cc - scrollbar-gutter: stable reserves gutter even when no scrollbar is shown
1528    // +spec:overflow:3a6966 - classic scrollbar gutter width == scrollbar width; overlay scrollbars have no gutter
1529    //
1530    // scrollbar-gutter only applies to scroll containers (overflow: auto or scroll).
1531    // "stable" reserves gutter on the inline-end edge even if no scrollbar is needed.
1532    // "stable both-edges" reserves gutter on both inline edges.
1533    let scrollbar_gutter = get_scrollbar_gutter_property(ctx.styled_dom, dom_id, styled_node_state)
1534        .unwrap_or(azul_css::props::layout::overflow::StyleScrollbarGutter::Auto);
1535    let ob_y = to_overflow_behavior(overflow_y);
1536    let is_scroll_container = matches!(ob_y, fc::OverflowBehavior::Scroll | fc::OverflowBehavior::Auto);
1537
1538    if is_scroll_container {
1539        use azul_css::props::layout::overflow::StyleScrollbarGutter;
1540        match scrollbar_gutter {
1541            StyleScrollbarGutter::Stable => {
1542                // Reserve gutter on inline-end even if no scrollbar is currently needed
1543                if !reqs.needs_vertical {
1544                    reqs.scrollbar_width = scrollbar_width_px;
1545                }
1546            }
1547            StyleScrollbarGutter::StableBothEdges => {
1548                // Reserve gutter on both inline edges
1549                reqs.scrollbar_width = scrollbar_width_px * 2.0;
1550            }
1551            StyleScrollbarGutter::Auto => {
1552                // Default: gutter only present when scrollbar is present (already handled)
1553            }
1554        }
1555    }
1556
1557    reqs
1558}
1559
1560/// Determines scrollbar requirements for a node based on content overflow.
1561///
1562/// Convenience wrapper around `compute_scrollbar_info_core` for the BFC layout path,
1563/// where the container size is derived from `box_props.inner_size(final_used_size, …)`.
1564fn compute_scrollbar_info<T: ParsedFontTrait>(
1565    ctx: &LayoutContext<'_, T>,
1566    dom_id: NodeId,
1567    styled_node_state: &azul_core::styled_dom::StyledNodeState,
1568    content_size: LogicalSize,
1569    box_props: &crate::solver3::geometry::BoxProps,
1570    final_used_size: LogicalSize,
1571    writing_mode: LayoutWritingMode,
1572) -> ScrollbarRequirements {
1573    let container_size = box_props.inner_size(final_used_size, writing_mode);
1574    compute_scrollbar_info_core(ctx, dom_id, styled_node_state, content_size, container_size)
1575}
1576
1577/// Checks if scrollbars changed compared to previous layout and if reflow is needed.
1578///
1579/// Detects both addition AND removal of scrollbars. Oscillation (add → remove → add)
1580/// is prevented by the outer layout loop's iteration limit (`loop_count > 10` in mod.rs),
1581/// not by suppressing removal detection here. This allows scrollbars to correctly
1582/// disappear when content shrinks or the window is resized larger.
1583fn check_scrollbar_change(
1584    tree: &LayoutTree,
1585    node_index: usize,
1586    scrollbar_info: &ScrollbarRequirements,
1587    skip_scrollbar_check: bool,
1588) -> bool {
1589    if skip_scrollbar_check {
1590        return false;
1591    }
1592
1593    let Some(warm_node) = tree.warm(node_index) else {
1594        return false;
1595    };
1596
1597    match &warm_node.scrollbar_info {
1598        None => scrollbar_info.needs_reflow(),
1599        Some(old_info) => {
1600            // Trigger reflow if scrollbar state changed in either direction
1601            let horizontal_changed = old_info.needs_horizontal != scrollbar_info.needs_horizontal;
1602            let vertical_changed = old_info.needs_vertical != scrollbar_info.needs_vertical;
1603            horizontal_changed || vertical_changed
1604        }
1605    }
1606}
1607
1608/// Returns the new scrollbar info directly, replacing any previous state.
1609///
1610/// Previous versions used `||` to make scrollbars "sticky" (never removed once added).
1611/// This prevented oscillation but caused scrollbars to persist forever—even after
1612/// content shrinks or the window grows. The outer layout loop's iteration cap
1613/// now handles oscillation safety, so we can faithfully reflect the current state.
1614fn merge_scrollbar_info(
1615    _tree: &LayoutTree,
1616    _node_index: usize,
1617    new_info: &ScrollbarRequirements,
1618) -> ScrollbarRequirements {
1619    new_info.clone()
1620}
1621
1622/// Calculates the content-box position from a margin-box position.
1623///
1624/// The content-box is offset from the margin-box by border + padding.
1625/// Margin is NOT added here because containing_block_pos already accounts for it.
1626fn calculate_content_box_pos(
1627    containing_block_pos: LogicalPosition,
1628    box_props: &crate::solver3::geometry::BoxProps,
1629) -> LogicalPosition {
1630    LogicalPosition::new(
1631        containing_block_pos.x + box_props.border.left + box_props.padding.left,
1632        containing_block_pos.y + box_props.border.top + box_props.padding.top,
1633    )
1634}
1635
1636/// Emits debug logging for content-box calculation if debug messages are enabled.
1637fn log_content_box_calculation<T: ParsedFontTrait>(
1638    ctx: &mut LayoutContext<'_, T>,
1639    node_index: usize,
1640    current_node: &LayoutNodeHot,
1641    containing_block_pos: LogicalPosition,
1642    self_content_box_pos: LogicalPosition,
1643) {
1644    let Some(debug_msgs) = ctx.debug_messages.as_mut() else {
1645        return;
1646    };
1647
1648    let dom_name = current_node
1649        .dom_node_id
1650        .and_then(|id| {
1651            ctx.styled_dom
1652                .node_data
1653                .as_container()
1654                .internal
1655                .get(id.index())
1656        })
1657        .map(|n| format!("{:?}", n.node_type))
1658        .unwrap_or_else(|| "Unknown".to_string());
1659
1660    let cbp = current_node.box_props.unpack();
1661    debug_msgs.push(LayoutDebugMessage::new(
1662        LayoutDebugMessageType::PositionCalculation,
1663        format!(
1664            "[CONTENT BOX {}] {} - margin-box pos=({:.2}, {:.2}) + border=({:.2},{:.2}) + \
1665             padding=({:.2},{:.2}) = content-box pos=({:.2}, {:.2})",
1666            node_index,
1667            dom_name,
1668            containing_block_pos.x,
1669            containing_block_pos.y,
1670            cbp.border.left,
1671            cbp.border.top,
1672            cbp.padding.left,
1673            cbp.padding.top,
1674            self_content_box_pos.x,
1675            self_content_box_pos.y
1676        ),
1677    ));
1678}
1679
1680/// Emits debug logging for child positioning if debug messages are enabled.
1681fn log_child_positioning<T: ParsedFontTrait>(
1682    ctx: &mut LayoutContext<'_, T>,
1683    child_index: usize,
1684    child_node: &LayoutNodeHot,
1685    self_content_box_pos: LogicalPosition,
1686    child_relative_pos: LogicalPosition,
1687    child_absolute_pos: LogicalPosition,
1688) {
1689    // Always print positioning info for debugging
1690    let child_dom_name = child_node
1691        .dom_node_id
1692        .and_then(|id| {
1693            ctx.styled_dom
1694                .node_data
1695                .as_container()
1696                .internal
1697                .get(id.index())
1698        })
1699        .map(|n| format!("{:?}", n.node_type))
1700        .unwrap_or_else(|| "Unknown".to_string());
1701
1702    let Some(debug_msgs) = ctx.debug_messages.as_mut() else {
1703        return;
1704    };
1705
1706    debug_msgs.push(LayoutDebugMessage::new(
1707        LayoutDebugMessageType::PositionCalculation,
1708        format!(
1709            "[CHILD POS {}] {} - parent content-box=({:.2}, {:.2}) + relative=({:.2}, {:.2}) + \
1710             margin=({:.2}, {:.2}) = absolute=({:.2}, {:.2})",
1711            child_index,
1712            child_dom_name,
1713            self_content_box_pos.x,
1714            self_content_box_pos.y,
1715            child_relative_pos.x,
1716            child_relative_pos.y,
1717            child_node.box_props.unpack().margin.left,
1718            child_node.box_props.unpack().margin.top,
1719            child_absolute_pos.x,
1720            child_absolute_pos.y
1721        ),
1722    ));
1723}
1724
1725/// Processes a single in-flow child: sets position and recurses.
1726///
1727/// For Flex/Grid containers, Taffy has already laid out the children completely.
1728/// We only recurse to position their grandchildren.
1729/// For Block/Inline/Table, layout_bfc/layout_ifc already laid out children in Pass 1.
1730/// We only need to set absolute positions and recurse for positioning grandchildren.
1731fn process_inflow_child<T: ParsedFontTrait>(
1732    ctx: &mut LayoutContext<'_, T>,
1733    tree: &mut LayoutTree,
1734    text_cache: &mut TextLayoutCache,
1735    child_index: usize,
1736    child_relative_pos: LogicalPosition,
1737    self_content_box_pos: LogicalPosition,
1738    inner_size_after_scrollbars: LogicalSize,
1739    writing_mode: LayoutWritingMode,
1740    is_flex_or_grid: bool,
1741    calculated_positions: &mut super::PositionVec,
1742    reflow_needed_for_scrollbars: &mut bool,
1743    float_cache: &mut HashMap<usize, fc::FloatingContext>,
1744) -> Result<()> {
1745    // Set relative position on child
1746    // child_relative_pos is [CoordinateSpace::Parent] - relative to parent's content-box
1747    let child_warm = tree.warm_mut(child_index).ok_or(LayoutError::InvalidTree)?;
1748    child_warm.relative_position = Some(child_relative_pos);
1749
1750    // Calculate absolute position
1751    // self_content_box_pos is [CoordinateSpace::Window] - absolute position of parent's content-box
1752    // child_absolute_pos becomes [CoordinateSpace::Window] - absolute window position of child
1753    let child_absolute_pos = LogicalPosition::new(
1754        self_content_box_pos.x + child_relative_pos.x,
1755        self_content_box_pos.y + child_relative_pos.y,
1756    );
1757
1758    // Debug logging
1759    {
1760        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1761        log_child_positioning(
1762            ctx,
1763            child_index,
1764            child_node,
1765            self_content_box_pos,
1766            child_relative_pos,
1767            child_absolute_pos,
1768        );
1769    }
1770
1771    // calculated_positions stores [CoordinateSpace::Window] - absolute positions
1772    super::pos_set(calculated_positions, child_index, child_absolute_pos);
1773
1774    // Get child's properties for recursion
1775    let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1776    let child_bp = child_node.box_props.unpack();
1777    let child_content_box_pos =
1778        calculate_content_box_pos(child_absolute_pos, &child_bp);
1779    let child_inner_size = child_bp
1780        .inner_size(child_node.used_size.unwrap_or_default(), writing_mode);
1781    let child_children: Vec<usize> = tree.children(child_index).to_vec();
1782    let child_fc = child_node.formatting_context.clone();
1783
1784    // Recurse to position grandchildren
1785    // OPTIMIZATION: For BFC/IFC children, layout_bfc/layout_ifc already computed their layout.
1786    // We just need to set absolute positions for descendants.
1787    // Only recurse if child has children to position.
1788    if !child_children.is_empty() {
1789        if is_flex_or_grid {
1790            // For Flex/Grid: Taffy already set used_size. Only recurse for grandchildren.
1791            position_flex_child_descendants(
1792                ctx,
1793                tree,
1794                text_cache,
1795                child_index,
1796                child_content_box_pos,
1797                child_inner_size,
1798                calculated_positions,
1799                reflow_needed_for_scrollbars,
1800                float_cache,
1801            )?;
1802        } else {
1803            // For Block/Inline/Table: The formatting context already laid out children.
1804            // Recursively position grandchildren using their cached layout data.
1805            position_bfc_child_descendants(
1806                tree,
1807                child_index,
1808                child_content_box_pos,
1809                calculated_positions,
1810            );
1811        }
1812    }
1813
1814    Ok(())
1815}
1816
1817/// Recursively positions descendants of a BFC/IFC child without re-computing layout.
1818/// The layout was already computed by layout_bfc/layout_ifc.
1819/// We only need to convert relative positions to absolute positions.
1820fn position_bfc_child_descendants(
1821    tree: &LayoutTree,
1822    node_index: usize,
1823    content_box_pos: LogicalPosition,
1824    calculated_positions: &mut super::PositionVec,
1825) {
1826    let Some(node) = tree.get(node_index) else { return };
1827
1828    for &child_index in tree.children(node_index) {
1829        let Some(child_node) = tree.get(child_index) else { continue };
1830
1831        // Use the relative_position that was set during formatting context layout
1832        let child_rel_pos = tree.warm(child_index)
1833            .and_then(|w| w.relative_position)
1834            .unwrap_or_default();
1835        let child_abs_pos = LogicalPosition::new(
1836            content_box_pos.x + child_rel_pos.x,
1837            content_box_pos.y + child_rel_pos.y,
1838        );
1839
1840        super::pos_set(calculated_positions, child_index, child_abs_pos);
1841
1842        // Calculate child's content-box position for recursion
1843        let cbp = child_node.box_props.unpack();
1844        let child_content_box_pos = LogicalPosition::new(
1845            child_abs_pos.x + cbp.border.left + cbp.padding.left,
1846            child_abs_pos.y + cbp.border.top + cbp.padding.top,
1847        );
1848        
1849        // Recurse to grandchildren
1850        position_bfc_child_descendants(tree, child_index, child_content_box_pos, calculated_positions);
1851    }
1852}
1853
1854/// Processes out-of-flow children (absolute/fixed positioned elements).
1855///
1856/// Out-of-flow elements don't appear in layout_output.positions but still need
1857/// a static position for when no explicit offsets are specified. This sets their
1858/// static position to the parent's content-box origin.
1859fn process_out_of_flow_children<T: ParsedFontTrait>(
1860    ctx: &mut LayoutContext<'_, T>,
1861    tree: &mut LayoutTree,
1862    text_cache: &mut TextLayoutCache,
1863    node_index: usize,
1864    self_content_box_pos: LogicalPosition,
1865    containing_block_size: LogicalSize,
1866    calculated_positions: &mut super::PositionVec,
1867    reflow_needed_for_scrollbars: &mut bool,
1868    float_cache: &mut HashMap<usize, fc::FloatingContext>,
1869) -> Result<()> {
1870    // Collect out-of-flow children (those not already positioned)
1871    let out_of_flow_children: Vec<(usize, Option<NodeId>)> = {
1872        let current_node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
1873        tree.children(node_index)
1874            .iter()
1875            .filter_map(|&child_index| {
1876                if super::pos_contains(calculated_positions, child_index) {
1877                    return None;
1878                }
1879                let child = tree.get(child_index)?;
1880                Some((child_index, child.dom_node_id))
1881            })
1882            .collect()
1883    };
1884
1885    for (child_index, child_dom_id_opt) in out_of_flow_children {
1886        let Some(child_dom_id) = child_dom_id_opt else {
1887            continue;
1888        };
1889
1890        let position_type = get_position_type(ctx.styled_dom, Some(child_dom_id));
1891        if position_type != LayoutPosition::Absolute && position_type != LayoutPosition::Fixed {
1892            continue;
1893        }
1894
1895        // Set static position to parent's content-box origin
1896        super::pos_set(calculated_positions, child_index, self_content_box_pos);
1897
1898        // Perform full layout for the absolutely positioned child so its
1899        // inline_layout_result is populated (text rendering needs this).
1900        // The containing block for abs-pos is the parent's padding box.
1901        calculate_layout_for_subtree(
1902            ctx,
1903            tree,
1904            text_cache,
1905            child_index,
1906            self_content_box_pos,
1907            containing_block_size,
1908            calculated_positions,
1909            reflow_needed_for_scrollbars,
1910            float_cache,
1911            ComputeMode::PerformLayout,
1912        )?;
1913    }
1914
1915    Ok(())
1916}
1917
1918/// Recursive, top-down pass to calculate used sizes and positions for a given subtree.
1919/// This is the single, authoritative function for in-flow layout.
1920///
1921/// Uses the per-node multi-slot cache (inspired by Taffy's 9+1 architecture) to
1922/// avoid O(n²) complexity. Each node has 9 measurement slots + 1 full layout slot.
1923///
1924/// ## Two-Mode Architecture (CSS Two-Pass Layout)
1925///
1926/// `compute_mode` determines behavior:
1927///
1928/// - **`ComputeSize`** (BFC Pass 1 — sizing):
1929///   Computes only the node's border-box size. On cache hit from measurement slots,
1930///   sets `used_size` and returns immediately — no child positioning. This is the
1931///   key to O(n) two-pass BFC: Pass 1 fills measurement caches cheaply.
1932///
1933/// - **`PerformLayout`** (BFC Pass 2 — positioning):
1934///   Computes size AND positions all children. On cache hit from layout slot,
1935///   applies cached child positions recursively. When Pass 2 provides the same
1936///   constraints as Pass 1, the "result matches request" optimization triggers
1937///   automatic cache hits.
1938///
1939/// ## Cache Hit Rates (Taffy's "result matches request" optimization)
1940///
1941/// When Pass 1 measures a node with available_size A and gets result_size R,
1942/// then Pass 2 provides R as a known_dimension, `get_size()` / `get_layout()`
1943/// recognize R == cached.result_size as a cache hit. This is the fundamental
1944/// mechanism ensuring O(n) total complexity across both passes.
1945pub fn calculate_layout_for_subtree<T: ParsedFontTrait>(
1946    ctx: &mut LayoutContext<'_, T>,
1947    tree: &mut LayoutTree,
1948    text_cache: &mut TextLayoutCache,
1949    node_index: usize,
1950    containing_block_pos: LogicalPosition,
1951    containing_block_size: LogicalSize,
1952    calculated_positions: &mut super::PositionVec,
1953    reflow_needed_for_scrollbars: &mut bool,
1954    float_cache: &mut HashMap<usize, fc::FloatingContext>,
1955    compute_mode: ComputeMode,
1956) -> Result<()> {
1957    let _probe = match compute_mode {
1958        ComputeMode::ComputeSize => crate::probe::Probe::span("size_node"),
1959        ComputeMode::PerformLayout => crate::probe::Probe::span("pos_node"),
1960    };
1961    // === PER-NODE CACHE CHECK (Taffy-inspired 9+1 slot cache) ===
1962    //
1963    // Two-mode cache lookup (CSS two-pass architecture):
1964    //
1965    // ComputeSize (Pass 1 — sizing):
1966    //   1. Check measurement slots (get_size) → if hit, set used_size and return.
1967    //      No child positioning needed — we only need the node's border-box size.
1968    //   2. Fall back to layout slot → if hit, extract size from full layout result.
1969    //
1970    // PerformLayout (Pass 2 — positioning):
1971    //   1. Check layout slot (get_layout) → if hit, apply cached child positions.
1972    //   2. No fallback to measurement slots (we need full positions, not just size).
1973    //
1974    // This split is critical for O(n) two-pass BFC:
1975    // - Pass 1 populates measurement slots (cheap: no absolute positioning)
1976    // - Pass 2 hits layout slot or re-computes with positions
1977    if node_index < ctx.cache_map.entries.len() {
1978        match compute_mode {
1979            ComputeMode::ComputeSize => {
1980                // ComputeSize: check measurement slot first (Taffy's 9-slot scheme)
1981                let sizing_hit = ctx.cache_map.entries[node_index]
1982                    .get_size(0, containing_block_size)
1983                    .cloned();
1984                if let Some(cached_sizing) = sizing_hit {
1985                    // SIZING CACHE HIT — set used_size and return immediately.
1986                    // No child positioning needed in ComputeSize mode.
1987                    drop(crate::probe::Probe::span("size_cache_hit_sizing"));
1988                    if let Some(node) = tree.get_mut(node_index) {
1989                        node.used_size = Some(cached_sizing.result_size);
1990                    }
1991                    if let Some(warm) = tree.warm_mut(node_index) {
1992                        warm.escaped_top_margin = cached_sizing.escaped_top_margin;
1993                        warm.escaped_bottom_margin = cached_sizing.escaped_bottom_margin;
1994                        warm.baseline = cached_sizing.baseline;
1995                    }
1996                    return Ok(());
1997                }
1998                // Fall through to layout slot check
1999                let layout_hit = ctx.cache_map.entries[node_index]
2000                    .get_layout(containing_block_size)
2001                    .cloned();
2002                if let Some(cached_layout) = layout_hit {
2003                    // Layout slot hit in ComputeSize mode — extract size only
2004                    drop(crate::probe::Probe::span("size_cache_hit_layout"));
2005                    if let Some(node) = tree.get_mut(node_index) {
2006                        node.used_size = Some(cached_layout.result_size);
2007                    }
2008                    if let Some(warm) = tree.warm_mut(node_index) {
2009                        warm.overflow_content_size = Some(cached_layout.content_size);
2010                        warm.scrollbar_info = Some(cached_layout.scrollbar_info.clone());
2011                    }
2012                    return Ok(());
2013                }
2014                drop(crate::probe::Probe::span("size_cache_miss"));
2015            }
2016            ComputeMode::PerformLayout => {
2017                // PerformLayout: check layout slot (the single "full layout" slot)
2018                let layout_hit = ctx.cache_map.entries[node_index]
2019                    .get_layout(containing_block_size)
2020                    .cloned();
2021                if let Some(cached_layout) = layout_hit {
2022                    drop(crate::probe::Probe::span("pos_cache_hit"));
2023                    // LAYOUT CACHE HIT — apply cached results with child positions
2024                    if let Some(node) = tree.get_mut(node_index) {
2025                        node.used_size = Some(cached_layout.result_size);
2026                    }
2027                    if let Some(warm) = tree.warm_mut(node_index) {
2028                        warm.overflow_content_size = Some(cached_layout.content_size);
2029                        warm.scrollbar_info = Some(cached_layout.scrollbar_info.clone());
2030                    }
2031
2032                    let box_props = tree.get(node_index)
2033                        .map(|n| n.box_props.unpack())
2034                        .unwrap_or_default();
2035                    let self_content_box_pos = calculate_content_box_pos(containing_block_pos, &box_props);
2036
2037                    // Apply cached child positions and recurse
2038                    let result_size = cached_layout.result_size;
2039                    for (child_index, child_relative_pos) in &cached_layout.child_positions {
2040                        let child_abs_pos = LogicalPosition::new(
2041                            self_content_box_pos.x + child_relative_pos.x,
2042                            self_content_box_pos.y + child_relative_pos.y,
2043                        );
2044                        super::pos_set(calculated_positions, *child_index, child_abs_pos);
2045
2046                        let inner = box_props.inner_size(
2047                            result_size,
2048                            LayoutWritingMode::HorizontalTb,
2049                        );
2050                        // Subtract scrollbar reservation from the available size
2051                        // passed to children. This mirrors what layout_bfc does in
2052                        // the MISS path — without it, a reflow-loop cache hit
2053                        // would hand children the full content-box width, ignoring
2054                        // any vertical/horizontal scrollbar that was detected.
2055                        let child_available_size =
2056                            cached_layout.scrollbar_info.shrink_size(inner);
2057                        calculate_layout_for_subtree(
2058                            ctx,
2059                            tree,
2060                            text_cache,
2061                            *child_index,
2062                            child_abs_pos,
2063                            child_available_size,
2064                            calculated_positions,
2065                            reflow_needed_for_scrollbars,
2066                            float_cache,
2067                            compute_mode,
2068                        )?;
2069                    }
2070
2071                    return Ok(());
2072                }
2073            }
2074        }
2075    }
2076    
2077    // === CACHE MISS — compute layout ===
2078    if compute_mode == ComputeMode::PerformLayout {
2079        drop(crate::probe::Probe::span("pos_cache_miss"));
2080    }
2081
2082    // Phase 1: Prepare layout context (calculate used size, constraints)
2083    let PreparedLayoutContext {
2084        constraints,
2085        dom_id,
2086        writing_mode,
2087        mut final_used_size,
2088        box_props,
2089    } = {
2090        let _p = crate::probe::Probe::span("prepare_layout_context");
2091        prepare_layout_context(ctx, tree, node_index, containing_block_size)?
2092    };
2093
2094    // Phase 1.5: Update used_size BEFORE calling layout_formatting_context.
2095    //
2096    // When a node is cloned from the old tree (clone_node_from_old), its used_size
2097    // retains the value from the previous layout pass. If the containing block changed
2098    // (e.g. viewport resize), the stale used_size would cause layout_bfc() to compute
2099    // an incorrect children_containing_block_size. By updating used_size here, we ensure
2100    // that layout_bfc reads the freshly resolved size from prepare_layout_context.
2101    {
2102        let is_table_cell = tree.get(node_index).map_or(false, |n| {
2103            matches!(n.formatting_context, FormattingContext::TableCell)
2104        });
2105        if !is_table_cell {
2106            if let Some(node) = tree.get_mut(node_index) {
2107                node.used_size = Some(final_used_size);
2108            }
2109        }
2110    }
2111
2112    // Phase 2: Layout children using the formatting context
2113    let layout_result = {
2114        let _p = crate::probe::Probe::span("layout_formatting_context");
2115        layout_formatting_context(ctx, tree, text_cache, node_index, &constraints, float_cache)?
2116    };
2117    let content_size = layout_result.output.overflow_size;
2118
2119    // If layout_formatting_context adjusted this node's used_size (e.g.
2120    // layout_flex_grid auto-applying box-sizing:border-box on the root),
2121    // propagate that back into final_used_size so Phase 3 (scrollbars),
2122    // Phase 4 (final write), and the self_content_box_pos calculation all
2123    // see the same border-box that the children were laid out inside.
2124    if let Some(adjusted) = tree.get(node_index).and_then(|n| n.used_size) {
2125        final_used_size = adjusted;
2126    }
2127
2128    // Phase 2.5: Resolve 'auto' main-axis size based on content
2129    // For anonymous boxes, use default styled node state
2130    let styled_node_state = dom_id
2131        .and_then(|id| ctx.styled_dom.styled_nodes.as_container().get(id).cloned())
2132        .map(|n| n.styled_node_state)
2133        .unwrap_or_default();
2134
2135    let css_height: MultiValue<LayoutHeight> = match dom_id {
2136        Some(id) => get_css_height(ctx.styled_dom, id, &styled_node_state),
2137        None => MultiValue::Auto, // Anonymous boxes have auto height
2138    };
2139
2140    // +spec:overflow:44ef3b - scroll container detection: overflow scroll/auto makes box a scroll container
2141    // Check if this node is a scroll container (overflow: scroll/auto).
2142    // Scroll containers must NOT expand to fit content — their height is
2143    // determined by the containing block, and overflow is scrollable.
2144    //
2145    // Exception: if the containing block height is infinite (unconstrained),
2146    // we must still grow, since you can't scroll inside an infinitely tall box.
2147    let is_scroll_container = dom_id.map_or(false, |id| {
2148        let ov_x = get_overflow_x(ctx.styled_dom, id, &styled_node_state);
2149        let ov_y = get_overflow_y(ctx.styled_dom, id, &styled_node_state);
2150        matches!(ov_x, MultiValue::Exact(LayoutOverflow::Scroll) | MultiValue::Exact(LayoutOverflow::Auto))
2151            || matches!(ov_y, MultiValue::Exact(LayoutOverflow::Scroll) | MultiValue::Exact(LayoutOverflow::Auto))
2152    });
2153
2154    if should_use_content_height(&css_height) {
2155        let skip_expansion = is_scroll_container
2156            && containing_block_size.height.is_finite()
2157            && containing_block_size.height > 0.0;
2158
2159        if !skip_expansion {
2160            final_used_size = apply_content_based_height(
2161                final_used_size,
2162                content_size,
2163                tree,
2164                node_index,
2165                writing_mode,
2166            );
2167        }
2168    }
2169
2170    // Phase 3: Scrollbar handling
2171    // Anonymous boxes don't have scrollbars
2172    let skip_scrollbar_check = ctx.fragmentation_context.is_some();
2173    let scrollbar_info = match dom_id {
2174        Some(id) => compute_scrollbar_info(
2175            ctx,
2176            id,
2177            &styled_node_state,
2178            content_size,
2179            &box_props,
2180            final_used_size,
2181            writing_mode,
2182        ),
2183        None => ScrollbarRequirements::default(),
2184    };
2185
2186    if check_scrollbar_change(tree, node_index, &scrollbar_info, skip_scrollbar_check) {
2187        *reflow_needed_for_scrollbars = true;
2188    }
2189
2190    let merged_scrollbar_info = merge_scrollbar_info(tree, node_index, &scrollbar_info);
2191    let content_box_size = box_props.inner_size(final_used_size, writing_mode);
2192    let inner_size_after_scrollbars = merged_scrollbar_info.shrink_size(content_box_size);
2193
2194    // Phase 4: Update this node's state
2195    let self_content_box_pos = {
2196        {
2197            let current_node = tree.get_mut(node_index).ok_or(LayoutError::InvalidTree)?;
2198
2199            // Table cells get their size from the table layout algorithm, don't overwrite
2200            let is_table_cell = matches!(
2201                current_node.formatting_context,
2202                FormattingContext::TableCell
2203            );
2204            if !is_table_cell || current_node.used_size.is_none() {
2205                current_node.used_size = Some(final_used_size);
2206            }
2207        }
2208
2209        // Update warm fields
2210        if let Some(warm) = tree.warm_mut(node_index) {
2211            warm.scrollbar_info = Some(merged_scrollbar_info.clone());
2212            // Store overflow content size for scroll frame calculation
2213            // +spec:overflow:f28d6a - hanging glyphs should be ink overflow, not scrollable overflow (not yet subtracted from content_size)
2214            warm.overflow_content_size = Some(content_size);
2215        }
2216
2217        // self_content_box_pos is [CoordinateSpace::Window] - the absolute position of this node's content-box
2218        let current_node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2219        let current_bp = current_node.box_props.unpack();
2220        let pos = calculate_content_box_pos(containing_block_pos, &current_bp);
2221        log_content_box_calculation(ctx, node_index, current_node, containing_block_pos, pos);
2222        pos
2223    };
2224
2225    // Phase 5: Determine formatting context type
2226    let is_flex_or_grid = {
2227        let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2228        matches!(
2229            node.formatting_context,
2230            FormattingContext::Flex | FormattingContext::Grid
2231        )
2232    };
2233
2234    // Phase 6: Process in-flow children
2235    // Positions in layout_result.output.positions are [CoordinateSpace::Parent] - relative to this node's content-box
2236    let positions: Vec<_> = layout_result
2237        .output
2238        .positions
2239        .iter()
2240        .map(|(&idx, &pos)| (idx, pos))
2241        .collect();
2242
2243    // Store child positions for cache
2244    let child_positions_for_cache: Vec<(usize, LogicalPosition)> = positions.clone();
2245
2246    for (child_index, child_relative_pos) in positions {
2247        process_inflow_child(
2248            ctx,
2249            tree,
2250            text_cache,
2251            child_index,
2252            child_relative_pos,
2253            self_content_box_pos,
2254            inner_size_after_scrollbars,
2255            writing_mode,
2256            is_flex_or_grid,
2257            calculated_positions,
2258            reflow_needed_for_scrollbars,
2259            float_cache,
2260        )?;
2261    }
2262
2263    // Phase 7: Process out-of-flow children (absolute/fixed)
2264    process_out_of_flow_children(
2265        ctx,
2266        tree,
2267        text_cache,
2268        node_index,
2269        self_content_box_pos,
2270        inner_size_after_scrollbars,
2271        calculated_positions,
2272        reflow_needed_for_scrollbars,
2273        float_cache,
2274    )?;
2275
2276    // === STORE RESULT IN PER-NODE CACHE (Taffy-inspired 9+1 slot cache) ===
2277    // Store both the full layout entry and a sizing measurement entry.
2278    // This enables O(n) two-pass BFC: Pass 1 populates cache, Pass 2 reads it.
2279    if node_index < ctx.cache_map.entries.len() {
2280        let warm_ref = tree.warm(node_index);
2281        let baseline = warm_ref.and_then(|n| n.baseline);
2282        let escaped_top = warm_ref.and_then(|n| n.escaped_top_margin);
2283        let escaped_bottom = warm_ref.and_then(|n| n.escaped_bottom_margin);
2284
2285        // Store in the layout slot (PerformLayout result)
2286        ctx.cache_map.get_mut(node_index).store_layout(LayoutCacheEntry {
2287            available_size: containing_block_size,
2288            result_size: final_used_size,
2289            content_size,
2290            child_positions: child_positions_for_cache.clone(),
2291            escaped_top_margin: escaped_top,
2292            escaped_bottom_margin: escaped_bottom,
2293            scrollbar_info: merged_scrollbar_info.clone(),
2294        });
2295
2296        // Also store in a measurement slot (slot 0: both dimensions known)
2297        // This enables the "result matches request" optimization (Taffy pattern):
2298        // when Pass 2 provides the same size as Pass 1 measured, it's a cache hit.
2299        ctx.cache_map.get_mut(node_index).store_size(0, SizingCacheEntry {
2300            available_size: containing_block_size,
2301            result_size: final_used_size,
2302            baseline,
2303            escaped_top_margin: escaped_top,
2304            escaped_bottom_margin: escaped_bottom,
2305        });
2306    }
2307
2308    Ok(())
2309}
2310
2311/// Recursively set static positions for out-of-flow descendants without doing layout
2312/// Recursively positions descendants of Flex/Grid children.
2313///
2314/// When a Flex container lays out its children via Taffy, the children have their
2315/// used_size and relative_position set, but their GRANDCHILDREN don't have positions
2316/// in calculated_positions yet. This function traverses down the tree and positions
2317/// all descendants properly.
2318fn position_flex_child_descendants<T: ParsedFontTrait>(
2319    ctx: &mut LayoutContext<'_, T>,
2320    tree: &mut LayoutTree,
2321    text_cache: &mut TextLayoutCache,
2322    node_index: usize,
2323    content_box_pos: LogicalPosition,
2324    available_size: LogicalSize,
2325    calculated_positions: &mut super::PositionVec,
2326    reflow_needed_for_scrollbars: &mut bool,
2327    float_cache: &mut HashMap<usize, fc::FloatingContext>,
2328) -> Result<()> {
2329    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2330    let children: Vec<usize> = tree.children(node_index).to_vec();
2331    let fc = node.formatting_context.clone();
2332
2333    // If this node is itself a Flex/Grid container, its children were laid out by Taffy
2334    // and already have relative_position set. We just need to convert to absolute and recurse.
2335    if matches!(fc, FormattingContext::Flex | FormattingContext::Grid) {
2336        for &child_index in &children {
2337            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
2338            let child_rel_pos = tree.warm(child_index)
2339                .and_then(|w| w.relative_position)
2340                .unwrap_or_default();
2341            let child_abs_pos = LogicalPosition::new(
2342                content_box_pos.x + child_rel_pos.x,
2343                content_box_pos.y + child_rel_pos.y,
2344            );
2345
2346            // Insert position
2347            super::pos_set(calculated_positions, child_index, child_abs_pos);
2348
2349            // Get child's content box for recursion
2350            let cbp = child_node.box_props.unpack();
2351            let child_content_box = LogicalPosition::new(
2352                child_abs_pos.x
2353                    + cbp.border.left
2354                    + cbp.padding.left,
2355                child_abs_pos.y
2356                    + cbp.border.top
2357                    + cbp.padding.top,
2358            );
2359            let child_inner_size = cbp.inner_size(
2360                child_node.used_size.unwrap_or_default(),
2361                LayoutWritingMode::HorizontalTb,
2362            );
2363
2364            // Recurse
2365            position_flex_child_descendants(
2366                ctx,
2367                tree,
2368                text_cache,
2369                child_index,
2370                child_content_box,
2371                child_inner_size,
2372                calculated_positions,
2373                reflow_needed_for_scrollbars,
2374                float_cache,
2375            )?;
2376        }
2377    } else {
2378        // For Block/Inline/Table children, their descendants need proper layout calculation
2379        // Use the output.positions from their own layout
2380        let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2381        let children: Vec<usize> = tree.children(node_index).to_vec();
2382
2383        for &child_index in &children {
2384            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
2385            let child_rel_pos = tree.warm(child_index)
2386                .and_then(|w| w.relative_position)
2387                .unwrap_or_default();
2388            let child_abs_pos = LogicalPosition::new(
2389                content_box_pos.x + child_rel_pos.x,
2390                content_box_pos.y + child_rel_pos.y,
2391            );
2392
2393            // Insert position
2394            super::pos_set(calculated_positions, child_index, child_abs_pos);
2395
2396            // Get child's content box for recursion
2397            let cbp = child_node.box_props.unpack();
2398            let child_content_box = LogicalPosition::new(
2399                child_abs_pos.x
2400                    + cbp.border.left
2401                    + cbp.padding.left,
2402                child_abs_pos.y
2403                    + cbp.border.top
2404                    + cbp.padding.top,
2405            );
2406            let child_inner_size = cbp.inner_size(
2407                child_node.used_size.unwrap_or_default(),
2408                LayoutWritingMode::HorizontalTb,
2409            );
2410
2411            // Recurse
2412            position_flex_child_descendants(
2413                ctx,
2414                tree,
2415                text_cache,
2416                child_index,
2417                child_content_box,
2418                child_inner_size,
2419                calculated_positions,
2420                reflow_needed_for_scrollbars,
2421                float_cache,
2422            )?;
2423        }
2424    }
2425
2426    Ok(())
2427}
2428
2429/// Checks if the given CSS height value should use content-based sizing
2430fn should_use_content_height(css_height: &MultiValue<LayoutHeight>) -> bool {
2431    match css_height {
2432        MultiValue::Auto | MultiValue::Initial | MultiValue::Inherit => {
2433            // Auto/Initial/Inherit height should use content-based sizing
2434            true
2435        }
2436        MultiValue::Exact(height) => match height {
2437            LayoutHeight::Auto => {
2438                // Auto height should use content-based sizing
2439                true
2440            }
2441            LayoutHeight::Px(px) => {
2442                // Check if it's zero or if it has explicit value
2443                // If it's a percentage or em, it's not auto
2444                use azul_css::props::basic::{pixel::PixelValue, SizeMetric};
2445                px == &PixelValue::zero()
2446                    || (px.metric != SizeMetric::Px
2447                        && px.metric != SizeMetric::Percent
2448                        && px.metric != SizeMetric::Em
2449                        && px.metric != SizeMetric::Rem)
2450            }
2451            LayoutHeight::MinContent | LayoutHeight::MaxContent | LayoutHeight::FitContent(_) => {
2452                // These are content-based, so they should use the content size
2453                true
2454            }
2455            LayoutHeight::Calc(_) => {
2456                // Calc expressions are not auto, they compute to a specific value
2457                false
2458            }
2459        },
2460    }
2461}
2462
2463/// Applies content-based height sizing to a node
2464///
2465/// **Note**: This function respects min-height/max-height constraints from Phase 1.
2466///
2467/// According to CSS 2.2 § 10.7, when height is 'auto', the final height must be
2468/// max(min_height, min(content_height, max_height)).
2469///
2470/// The `used_size` parameter already contains the size constrained by
2471/// min-height/max-height from the initial sizing pass. We must take the
2472/// maximum of this constrained size and the new content-based size to ensure
2473/// min-height is not lost.
2474fn apply_content_based_height(
2475    mut used_size: LogicalSize,
2476    content_size: LogicalSize,
2477    tree: &LayoutTree,
2478    node_index: usize,
2479    writing_mode: LayoutWritingMode,
2480) -> LogicalSize {
2481    let node_props = tree.get(node_index).unwrap().box_props.unpack();
2482    let main_axis_padding_border =
2483        node_props.padding.main_sum(writing_mode) + node_props.border.main_sum(writing_mode);
2484
2485    // CRITICAL: 'old_main_size' holds the size constrained by min-height/max-height from Phase 1
2486    let old_main_size = used_size.main(writing_mode);
2487    let new_main_size = content_size.main(writing_mode) + main_axis_padding_border;
2488
2489    // Final size = max(min_height_constrained_size, content_size)
2490    // This ensures that min-height is respected even when content is smaller
2491    let final_main_size = old_main_size.max(new_main_size);
2492
2493    used_size = used_size.with_main(writing_mode, final_main_size);
2494
2495    used_size
2496}
2497
2498// hash_styled_node_data() removed — replaced by NodeDataFingerprint::compute()
2499
2500fn calculate_subtree_hash(node_self_hash: u64, child_hashes: &[u64]) -> SubtreeHash {
2501    let mut hasher = DefaultHasher::new();
2502    node_self_hash.hash(&mut hasher);
2503    child_hashes.hash(&mut hasher);
2504    SubtreeHash(hasher.finish())
2505}
2506
2507/// Computes CSS counter values for all nodes in the layout tree.
2508///
2509/// This function traverses the tree in document order and processes counter-reset
2510/// and counter-increment properties. The computed values are stored in cache.counters.
2511///
2512/// CSS counters work with a stack-based scoping model:
2513/// - `counter-reset` creates a new scope and sets the counter to a value
2514/// - `counter-increment` increments the counter in the current scope
2515/// - When leaving a subtree, counter scopes are popped
2516pub fn compute_counters(
2517    styled_dom: &StyledDom,
2518    tree: &LayoutTree,
2519    counters: &mut HashMap<(usize, String), i32>,
2520) {
2521    // Track counter stacks: counter_name -> Vec<value>
2522    // Each entry in the Vec represents a nested scope
2523    let mut counter_stacks: HashMap<String, Vec<i32>> = HashMap::new();
2524
2525    // Stack to track which counters were reset at each tree level
2526    // When we pop back up the tree, we need to pop these counter scopes
2527    let mut scope_stack: Vec<Vec<String>> = Vec::new();
2528
2529    compute_counters_recursive(
2530        styled_dom,
2531        tree,
2532        tree.root,
2533        counters,
2534        &mut counter_stacks,
2535        &mut scope_stack,
2536    );
2537}
2538
2539fn compute_counters_recursive(
2540    styled_dom: &StyledDom,
2541    tree: &LayoutTree,
2542    node_idx: usize,
2543    counters: &mut HashMap<(usize, String), i32>,
2544    counter_stacks: &mut std::collections::HashMap<String, Vec<i32>>,
2545    scope_stack: &mut Vec<Vec<String>>,
2546) {
2547    let node = match tree.get(node_idx) {
2548        Some(n) => n,
2549        None => return,
2550    };
2551
2552    // Skip pseudo-elements (::marker, ::before, ::after) for counter processing
2553    // Pseudo-elements inherit counter values from their parent element
2554    // but don't participate in counter-reset or counter-increment themselves
2555    if tree.warm(node_idx).and_then(|w| w.pseudo_element.as_ref()).is_some() {
2556        // Store the parent's counter values for this pseudo-element
2557        // so it can be looked up during marker text generation
2558        if let Some(parent_idx) = node.parent {
2559            // Copy all counter values from parent to this pseudo-element
2560            let parent_counters: Vec<_> = counters
2561                .iter()
2562                .filter(|((idx, _), _)| *idx == parent_idx)
2563                .map(|((_, name), &value)| (name.clone(), value))
2564                .collect();
2565
2566            for (counter_name, value) in parent_counters {
2567                counters.insert((node_idx, counter_name), value);
2568            }
2569        }
2570
2571        // Don't recurse to children of pseudo-elements
2572        // (pseudo-elements shouldn't have children in normal circumstances)
2573        return;
2574    }
2575
2576    // Only process real DOM nodes, not anonymous boxes
2577    let dom_id = match node.dom_node_id {
2578        Some(id) => id,
2579        None => {
2580            // For anonymous boxes, just recurse to children
2581            for &child_idx in tree.children(node_idx) {
2582                compute_counters_recursive(
2583                    styled_dom,
2584                    tree,
2585                    child_idx,
2586                    counters,
2587                    counter_stacks,
2588                    scope_stack,
2589                );
2590            }
2591            return;
2592        }
2593    };
2594
2595    let node_data = &styled_dom.node_data.as_container()[dom_id];
2596    let node_state = &styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2597    let cache = &styled_dom.css_property_cache.ptr;
2598
2599    // Track which counters we reset at this level (for cleanup later)
2600    let mut reset_counters_at_this_level = Vec::new();
2601
2602    // CSS Lists §3: display: list-item automatically increments the "list-item" counter
2603    // Check if this is a list-item
2604    let display = {
2605        use crate::solver3::getters::get_display_property;
2606        get_display_property(styled_dom, Some(dom_id)).exact()
2607    };
2608    let is_list_item = matches!(display, Some(LayoutDisplay::ListItem));
2609
2610    // FAST PATH: almost no nodes declare counter-reset/counter-increment.
2611    // Single-bit check in compact cache lets us skip two cascade walks per node.
2612    let has_counter_css = node_state.is_normal()
2613        && cache.compact_cache.as_ref().map_or(true, |cc| cc.has_counter(dom_id.index()));
2614
2615    // Process counter-reset (now properly typed)
2616    let counter_reset = if has_counter_css {
2617        cache
2618            .get_counter_reset(node_data, &dom_id, node_state)
2619            .and_then(|v| v.get_property())
2620    } else {
2621        None
2622    };
2623
2624    if let Some(counter_reset) = counter_reset {
2625        let counter_name_str = counter_reset.counter_name.as_str();
2626        if counter_name_str != "none" {
2627            let counter_name = counter_name_str.to_string();
2628            let reset_value = counter_reset.value;
2629
2630            // Reset the counter by pushing a new scope
2631            counter_stacks
2632                .entry(counter_name.clone())
2633                .or_default()
2634                .push(reset_value);
2635            reset_counters_at_this_level.push(counter_name);
2636        }
2637    }
2638
2639    // Process counter-increment (now properly typed)
2640    let counter_inc = if has_counter_css {
2641        cache
2642            .get_counter_increment(node_data, &dom_id, node_state)
2643            .and_then(|v| v.get_property())
2644    } else {
2645        None
2646    };
2647
2648    if let Some(counter_inc) = counter_inc {
2649        let counter_name_str = counter_inc.counter_name.as_str();
2650        if counter_name_str != "none" {
2651            let counter_name = counter_name_str.to_string();
2652            let inc_value = counter_inc.value;
2653
2654            // Increment the counter in the current scope
2655            let stack = counter_stacks.entry(counter_name.clone()).or_default();
2656            if stack.is_empty() {
2657                // Auto-initialize if counter doesn't exist
2658                stack.push(inc_value);
2659            } else if let Some(current) = stack.last_mut() {
2660                *current += inc_value;
2661            }
2662        }
2663    }
2664
2665    // CSS Lists §3: display: list-item automatically increments "list-item" counter
2666    if is_list_item {
2667        let counter_name = "list-item".to_string();
2668        let stack = counter_stacks.entry(counter_name.clone()).or_default();
2669        if stack.is_empty() {
2670            // Auto-initialize if counter doesn't exist
2671            stack.push(1);
2672        } else {
2673            if let Some(current) = stack.last_mut() {
2674                *current += 1;
2675            }
2676        }
2677    }
2678
2679    // Store the current counter values for this node
2680    for (counter_name, stack) in counter_stacks.iter() {
2681        if let Some(&value) = stack.last() {
2682            counters.insert((node_idx, counter_name.clone()), value);
2683        }
2684    }
2685
2686    // Push scope tracking for cleanup
2687    scope_stack.push(reset_counters_at_this_level.clone());
2688
2689    // Recurse to children
2690    for &child_idx in tree.children(node_idx) {
2691        compute_counters_recursive(
2692            styled_dom,
2693            tree,
2694            child_idx,
2695            counters,
2696            counter_stacks,
2697            scope_stack,
2698        );
2699    }
2700
2701    // Pop counter scopes that were created at this level
2702    if let Some(reset_counters) = scope_stack.pop() {
2703        for counter_name in reset_counters {
2704            if let Some(stack) = counter_stacks.get_mut(&counter_name) {
2705                stack.pop();
2706            }
2707        }
2708    }
2709}