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