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, ¤t_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}