Skip to main content

azul_layout/solver3/
mod.rs

1//! solver3/mod.rs
2//!
3//! Next-generation CSS layout engine with proper formatting context separation
4
5pub mod cache;
6pub mod calc;
7pub mod counters;
8pub mod display_list;
9pub mod fc;
10pub mod geometry;
11pub mod getters;
12pub mod layout_tree;
13pub mod paged_layout;
14pub mod pagination;
15pub mod positioning;
16pub mod scrollbar;
17pub mod sizing;
18pub mod taffy_bridge;
19
20/// Lazy `debug_info` macro - only evaluates format args when `debug_messages` is Some
21#[macro_export]
22macro_rules! debug_info {
23    ($ctx:expr, $($arg:tt)*) => {
24        if $ctx.debug_messages.is_some() {
25            $ctx.debug_info_inner(format!($($arg)*));
26        }
27    };
28}
29
30/// Lazy `debug_warning` macro - only evaluates format args when `debug_messages` is Some
31#[macro_export]
32macro_rules! debug_warning {
33    ($ctx:expr, $($arg:tt)*) => {
34        if $ctx.debug_messages.is_some() {
35            $ctx.debug_warning_inner(format!($($arg)*));
36        }
37    };
38}
39
40/// Lazy `debug_error` macro - only evaluates format args when `debug_messages` is Some
41#[macro_export]
42macro_rules! debug_error {
43    ($ctx:expr, $($arg:tt)*) => {
44        if $ctx.debug_messages.is_some() {
45            $ctx.debug_error_inner(format!($($arg)*));
46        }
47    };
48}
49
50/// Lazy `debug_log` macro - only evaluates format args when `debug_messages` is Some
51#[macro_export]
52macro_rules! debug_log {
53    ($ctx:expr, $($arg:tt)*) => {
54        if $ctx.debug_messages.is_some() {
55            $ctx.debug_log_inner(format!($($arg)*));
56        }
57    };
58}
59
60/// Lazy `debug_box_props` macro - only evaluates format args when `debug_messages` is Some
61#[macro_export]
62macro_rules! debug_box_props {
63    ($ctx:expr, $($arg:tt)*) => {
64        if $ctx.debug_messages.is_some() {
65            $ctx.debug_box_props_inner(format!($($arg)*));
66        }
67    };
68}
69
70/// Lazy `debug_css_getter` macro - only evaluates format args when `debug_messages` is Some
71#[macro_export]
72macro_rules! debug_css_getter {
73    ($ctx:expr, $($arg:tt)*) => {
74        if $ctx.debug_messages.is_some() {
75            $ctx.debug_css_getter_inner(format!($($arg)*));
76        }
77    };
78}
79
80/// Lazy `debug_bfc_layout` macro - only evaluates format args when `debug_messages` is Some
81#[macro_export]
82macro_rules! debug_bfc_layout {
83    ($ctx:expr, $($arg:tt)*) => {
84        if $ctx.debug_messages.is_some() {
85            $ctx.debug_bfc_layout_inner(format!($($arg)*));
86        }
87    };
88}
89
90/// Lazy `debug_ifc_layout` macro - only evaluates format args when `debug_messages` is Some
91#[macro_export]
92macro_rules! debug_ifc_layout {
93    ($ctx:expr, $($arg:tt)*) => {
94        if $ctx.debug_messages.is_some() {
95            $ctx.debug_ifc_layout_inner(format!($($arg)*));
96        }
97    };
98}
99
100/// Lazy `debug_table_layout` macro - only evaluates format args when `debug_messages` is Some
101#[macro_export]
102macro_rules! debug_table_layout {
103    ($ctx:expr, $($arg:tt)*) => {
104        if $ctx.debug_messages.is_some() {
105            $ctx.debug_table_layout_inner(format!($($arg)*));
106        }
107    };
108}
109
110/// Lazy `debug_display_type` macro - only evaluates format args when `debug_messages` is Some
111#[macro_export]
112macro_rules! debug_display_type {
113    ($ctx:expr, $($arg:tt)*) => {
114        if $ctx.debug_messages.is_some() {
115            $ctx.debug_display_type_inner(format!($($arg)*));
116        }
117    };
118}
119
120use std::{collections::{BTreeMap, HashMap}, sync::Arc};
121
122use azul_core::{
123    dom::{DomId, NodeId},
124    geom::{LogicalPosition, LogicalRect, LogicalSize},
125    hit_test::{DocumentId, ScrollPosition},
126    resources::RendererResources,
127    selection::{TextCursor, TextSelection},
128    styled_dom::StyledDom,
129};
130
131/// Sentinel value for "position not yet computed". No real position is ever `f32::MIN`.
132pub(crate) const POSITION_UNSET: LogicalPosition = LogicalPosition { x: f32::MIN, y: f32::MIN };
133
134/// Maximum number of scrollbar-induced reflow iterations before layout gives up.
135/// Scrollbar appearance can change container size, which may trigger further scrollbar
136/// changes. This limit prevents infinite loops in pathological layouts.
137const MAX_SCROLLBAR_REFLOW_ITERATIONS: usize = 10;
138
139/// Vec-based position storage indexed by layout-tree node index.
140/// Replaces `BTreeMap<usize, LogicalPosition>` for O(1) access and cache-friendly iteration.
141pub type PositionVec = Vec<LogicalPosition>;
142
143/// Get position for node index, returning None if unset.
144///
145/// Note: only the `x` component is checked against the sentinel. This is sufficient
146/// because `POSITION_UNSET` always sets both `x` and `y` to `f32::MIN`, and `pos_set`
147/// always writes both components together.
148#[inline]
149#[must_use] pub fn pos_get(positions: &PositionVec, idx: usize) -> Option<LogicalPosition> {
150    positions.get(idx).copied().filter(|p| p.x != f32::MIN)
151}
152
153/// Set position for node index. Grows the vec if needed.
154#[inline]
155pub fn pos_set(positions: &mut PositionVec, idx: usize, pos: LogicalPosition) {
156    if idx >= positions.len() {
157        positions.resize(idx + 1, POSITION_UNSET);
158    }
159    positions[idx] = pos;
160}
161
162/// Check if position has been set for node index.
163#[inline]
164#[must_use] pub fn pos_contains(positions: &PositionVec, idx: usize) -> bool {
165    positions.get(idx).is_some_and(|p| p.x != f32::MIN)
166}
167use azul_css::{
168    props::property::{CssProperty, CssPropertyCategory},
169    LayoutDebugMessage, LayoutDebugMessageType,
170};
171
172use self::{
173    display_list::generate_display_list,
174    geometry::IntrinsicSizes,
175    getters::get_writing_mode,
176    layout_tree::{generate_layout_tree, LayoutTree},
177    sizing::calculate_intrinsic_sizes,
178};
179#[cfg(feature = "text_layout")]
180pub use crate::font_traits::TextLayoutCache;
181use crate::{
182    font_traits::ParsedFontTrait,
183    solver3::{
184        cache::LayoutCache,
185        display_list::DisplayList,
186        fc::LayoutConstraints,
187        layout_tree::DirtyFlag,
188    },
189};
190
191/// Central context for a single layout pass.
192#[derive(Debug)]
193pub struct LayoutContext<'a, T: ParsedFontTrait> {
194    pub styled_dom: &'a StyledDom,
195    #[cfg(feature = "text_layout")]
196    pub font_manager: &'a crate::font_traits::FontManager<T>,
197    #[cfg(not(feature = "text_layout"))]
198    pub font_manager: core::marker::PhantomData<&'a T>,
199    /// Text selections for rendering highlights. Populated from `MultiCursorState`.
200    pub text_selections: &'a BTreeMap<DomId, TextSelection>,
201    pub debug_messages: &'a mut Option<Vec<LayoutDebugMessage>>,
202    pub counters: &'a mut HashMap<(usize, String), i32>,
203    pub viewport_size: LogicalSize,
204    /// Fragmentation context for CSS Paged Media (PDF generation)
205    /// When Some, layout respects page boundaries and generates one `DisplayList` per page
206    pub fragmentation_context: Option<&'a mut crate::paged::FragmentationContext>,
207    /// Whether the text cursor should be drawn (managed by `CursorManager` blink timer)
208    /// When false, the cursor is in the "off" phase of blinking and should not be rendered.
209    /// When true (default), the cursor is visible.
210    pub cursor_is_visible: bool,
211    /// All active cursor locations from `MultiCursorState` / `CursorManager`.
212    /// Each entry is (`dom_id`, `node_id`, cursor). Multiple entries = multi-cursor mode.
213    /// Empty = no active cursor. The last entry is the primary cursor.
214    pub cursor_locations: Vec<(DomId, NodeId, TextCursor)>,
215    /// IME preedit (composition) text to render inline at the cursor position.
216    /// When Some, the text should be rendered with an underline decoration.
217    pub preedit_text: Option<String>,
218    /// Text content overrides from in-progress edits (`dirty_text_nodes`).
219    /// When a text node has been edited but not yet committed to the DOM,
220    /// the layout pipeline should read from here instead of `StyledDom`.
221    /// Key: (`DomId`, `NodeId` of the text node), Value: the edited text string.
222    pub dirty_text_overrides: BTreeMap<(DomId, NodeId), String>,
223    /// Per-node multi-slot cache (Taffy-inspired 9+1 architecture).
224    /// Moved out of `LayoutCache` via `std::mem::take` for the duration of layout,
225    /// then moved back after the layout pass completes.
226    pub cache_map: cache::LayoutCacheMap,
227    /// Image cache for resolving `background-image: url(...)` references.
228    pub image_cache: &'a azul_core::resources::ImageCache,
229    /// System style containing colors, fonts, metrics, and theme information.
230    /// Used for selection colors, caret styling, and other system-themed elements.
231    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
232    /// Callback to get the current system time. Used for profiling inside layout.
233    /// On WASM targets (where `std::time::Instant::now()` panics), callers should
234    /// supply a no-op or platform-specific implementation.
235    pub get_system_time_fn: azul_core::task::GetSystemTimeCallback,
236    /// Memoised `get_scrollbar_style` results, keyed by DOM node id.
237    /// `compute_scrollbar_info_core` is called many times per node
238    /// per layout pass (BFC path + Taffy flex/grid path + display
239    /// list), and each call previously did 9 cascade walks. Once
240    /// populated, subsequent callers in the same `LayoutContext`
241    /// (a single render) return a clone.
242    ///
243    /// Uses `RefCell` so shared `&self` borrows (e.g. in the Taffy
244    /// bridge's `get_core_container_style`) can mutate the cache
245    /// without lifting the ctx to `&mut`. Keyed by `NodeId` so
246    /// entries span DOMs in iframe-style nested documents if that
247    /// ever becomes a thing.
248    pub scrollbar_style_cache:
249        core::cell::RefCell<HashMap<NodeId, getters::ComputedScrollbarStyle>>,
250}
251
252impl<T: ParsedFontTrait> LayoutContext<'_, T> {
253    /// Internal method - called by `debug_log`! macro after checking `debug_messages.is_some()`
254    #[inline]
255    pub fn debug_log_inner(&mut self, message: String) {
256        if let Some(messages) = self.debug_messages.as_mut() {
257            messages.push(LayoutDebugMessage {
258                message: message.into(),
259                location: "solver3".into(),
260                message_type: LayoutDebugMessageType::default(),
261            });
262        }
263    }
264
265    /// Internal method - called by `debug_info`! macro after checking `debug_messages.is_some()`
266    #[inline]
267    pub fn debug_info_inner(&mut self, message: String) {
268        if let Some(messages) = self.debug_messages.as_mut() {
269            messages.push(LayoutDebugMessage::info(message));
270        }
271    }
272
273    /// Internal method - called by `debug_warning`! macro after checking `debug_messages.is_some()`
274    #[inline]
275    pub fn debug_warning_inner(&mut self, message: String) {
276        if let Some(messages) = self.debug_messages.as_mut() {
277            messages.push(LayoutDebugMessage::warning(message));
278        }
279    }
280
281    /// Internal method - called by `debug_error`! macro after checking `debug_messages.is_some()`
282    #[inline]
283    pub fn debug_error_inner(&mut self, message: String) {
284        if let Some(messages) = self.debug_messages.as_mut() {
285            messages.push(LayoutDebugMessage::error(message));
286        }
287    }
288
289    /// Internal method - called by `debug_box_props`! macro after checking `debug_messages.is_some()`
290    #[inline]
291    pub fn debug_box_props_inner(&mut self, message: String) {
292        if let Some(messages) = self.debug_messages.as_mut() {
293            messages.push(LayoutDebugMessage::box_props(message));
294        }
295    }
296
297    /// Internal method - called by `debug_css_getter`! macro after checking `debug_messages.is_some()`
298    #[inline]
299    pub fn debug_css_getter_inner(&mut self, message: String) {
300        if let Some(messages) = self.debug_messages.as_mut() {
301            messages.push(LayoutDebugMessage::css_getter(message));
302        }
303    }
304
305    /// Internal method - called by `debug_bfc_layout`! macro after checking `debug_messages.is_some()`
306    #[inline]
307    pub fn debug_bfc_layout_inner(&mut self, message: String) {
308        if let Some(messages) = self.debug_messages.as_mut() {
309            messages.push(LayoutDebugMessage::bfc_layout(message));
310        }
311    }
312
313    /// Internal method - called by `debug_ifc_layout`! macro after checking `debug_messages.is_some()`
314    #[inline]
315    pub fn debug_ifc_layout_inner(&mut self, message: String) {
316        if let Some(messages) = self.debug_messages.as_mut() {
317            messages.push(LayoutDebugMessage::ifc_layout(message));
318        }
319    }
320
321    /// Internal method - called by `debug_table_layout`! macro after checking `debug_messages.is_some()`
322    #[inline]
323    pub fn debug_table_layout_inner(&mut self, message: String) {
324        if let Some(messages) = self.debug_messages.as_mut() {
325            messages.push(LayoutDebugMessage::table_layout(message));
326        }
327    }
328
329    /// Internal method - called by `debug_display_type`! macro after checking `debug_messages.is_some()`
330    #[inline]
331    pub fn debug_display_type_inner(&mut self, message: String) {
332        if let Some(messages) = self.debug_messages.as_mut() {
333            messages.push(LayoutDebugMessage::display_type(message));
334        }
335    }
336}
337
338/// Main entry point for the incremental, cached layout engine.
339///
340/// `new_dom` is borrowed, not owned — every use inside is `&new_dom`,
341/// so taking ownership was a pure formality that forced every caller
342/// to `styled_dom.clone()` the DOM before calling. The clone was
343/// ~2 MiB per render on excel.html; kept at the borrow now.
344#[cfg(feature = "text_layout")]
345/// Web-backend opt-out for display-list generation.
346///
347/// When set, [`layout_document`] runs the full positioning pipeline
348/// (intrinsic sizing, taffy block/flex/grid, relative/sticky/absolute
349/// adjustment → `calculated_positions`) but **skips
350/// `generate_display_list`**, returning an empty [`DisplayList`]. The
351/// web backend emits TLV DOM patches, not a display list, so it needs
352/// the geometry in `calculated_positions` but nothing the painter
353/// produces. This also lets the AArch64→wasm lift drop the entire
354/// `display_list` painter surface (those symbols are classified `Leaf`
355/// in `dll/src/web/symbol_table.rs::classify_for_name`, so the
356/// transitive lifter never descends into them). Defaults `false` →
357/// desktop/native behaviour is unchanged.
358pub static SKIP_DISPLAY_LIST: core::sync::atomic::AtomicBool =
359    core::sync::atomic::AtomicBool::new(false);
360
361/// Set [`SKIP_DISPLAY_LIST`].
362///
363/// Provided as a function (rather than the
364/// caller touching the static directly) so the web backend's
365/// `dll`-crate caller reaches it through a normal `bl` into this
366/// `azul_layout` function — keeping the static's address computation
367/// intra-crate (direct `adrp+add`) instead of a cross-crate GOT load,
368/// which the AArch64→wasm lift mirrors more reliably.
369pub fn set_skip_display_list(skip: bool) {
370    SKIP_DISPLAY_LIST.store(skip, core::sync::atomic::Ordering::Relaxed);
371}
372
373// M12.7: keep this out-of-line so the web lift sees it as its own wasm fn
374// (not inlined into layout_dom_recursive). An opt-folded infinite loop in the
375// solver (a mis-lifted loop exit) is otherwise hidden inside the giant inlined
376// layout_dom_recursive; de-inlining lets AZ_FUEL/AZ_WASM_DEBUG name the actual
377// source fn — and may itself prevent the inlining-induced fold. No perf cost on
378// desktop (called once per layout).
379#[inline(never)]
380// node counts / indices / tree-len values fed to az_mark debug markers as u32; bounded.
381#[allow(clippy::cast_possible_truncation)]
382#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
383/// # Errors
384///
385/// Returns a `LayoutError` if document layout fails.
386pub fn layout_document<T: ParsedFontTrait + Sync + 'static>(
387    cache: &mut LayoutCache,
388    text_cache: &mut TextLayoutCache,
389    new_dom: &StyledDom,
390    viewport: LogicalRect,
391    font_manager: &crate::font_traits::FontManager<T>,
392    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
393    text_selections: &BTreeMap<DomId, TextSelection>,
394    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
395    gpu_value_cache: Option<&azul_core::gpu::GpuValueCache>,
396    renderer_resources: &azul_core::resources::RendererResources,
397    id_namespace: azul_core::resources::IdNamespace,
398    dom_id: DomId,
399    cursor_is_visible: bool,
400    cursor_locations: Vec<(DomId, NodeId, TextCursor)>,
401    preedit_text: Option<String>,
402    image_cache: &azul_core::resources::ImageCache,
403    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
404    get_system_time_fn: azul_core::task::GetSystemTimeCallback,
405) -> Result<DisplayList> {
406    use crate::window::LayoutWindow;
407
408    // Secondary mapping: anonymous wrappers (dom_node_id == None)
409    // by (parent_new_idx, ordinal-among-anon-siblings). An
410    // unchanged DOM produces the same anon wrappers in the same
411    // order under the same parent — matching by position here
412    // preserves their cache slots too. Without this, anon
413    // wrappers re-allocate empty every reconcile and invalidate
414    // their ancestors via `mark_dirty`.
415    fn collect_anon_children_by_parent(
416        tree: &LayoutTree,
417    ) -> HashMap<usize, Vec<usize>> {
418        let mut map: HashMap<usize, Vec<usize>> =
419            HashMap::new();
420        for (idx, node) in tree.nodes.iter().enumerate() {
421            if node.dom_node_id.is_some() {
422                continue;
423            }
424            if let Some(parent) = node.parent {
425                map.entry(parent).or_default().push(idx);
426            }
427        }
428        map
429    }
430
431    // Reset IFC ID counter at the start of each layout pass
432    // This ensures IFCs get consistent IDs across frames when the DOM structure is stable
433    layout_tree::IfcId::reset_counter();
434    // in layout_document returns the rc=5 Err (the error enum can't be captured
435    // reliably in the lift). The last value seen = the step that errored next.
436    { let _ = (0xDD00_0001u32); }
437    // If 0 here → the LogicalRect HFA arg was lost across the lifted call.
438
439    if let Some(msgs) = debug_messages.as_mut() {
440        msgs.push(LayoutDebugMessage::info(format!(
441            "[Layout] layout_document called - viewport: ({:.1}, {:.1}) size ({:.1}x{:.1})",
442            viewport.origin.x, viewport.origin.y, viewport.size.width, viewport.size.height
443        )));
444        msgs.push(LayoutDebugMessage::info(format!(
445            "[Layout] DOM has {} nodes",
446            new_dom.node_data.len()
447        )));
448    }
449
450    // Create temporary context without counters for tree generation
451    let mut counter_values = HashMap::new();
452    let mut ctx_temp = LayoutContext {
453        scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
454        styled_dom: new_dom,
455        font_manager,
456        text_selections,
457        debug_messages,
458        counters: &mut counter_values,
459        viewport_size: viewport.size,
460        fragmentation_context: None,
461        cursor_is_visible,
462        cursor_locations: cursor_locations.clone(),
463        preedit_text: preedit_text.clone(),
464        dirty_text_overrides: BTreeMap::new(),
465        cache_map: cache::LayoutCacheMap::default(), // temp context doesn't need real cache
466        image_cache,
467        system_style: system_style.clone(),
468        get_system_time_fn,
469    };
470
471    crate::probe::sample_peak_rss("rss:enter_layout_document");
472
473    // --- Step 0: record DOM pointer / viewport for diagnostics only ---
474    //
475    // NOTE: there is intentionally NO pointer-identity fast path here.
476    // Comparing `new_dom as *const StyledDom as usize` against a stored
477    // pointer is UNSOUND across layout passes: each `regenerate_layout`
478    // builds a fresh `StyledDom`, and after the previous one is dropped
479    // (e.g. `layout_and_generate_display_list` calls `layout_results.clear()`
480    // before re-laying out), the allocator/stack frequently hands the new,
481    // *different* StyledDom the SAME address. A pointer match therefore does
482    // NOT prove the content is unchanged — it would return the previous
483    // frame's display list for a structurally different DOM (e.g. an image
484    // removed from the tree would still appear in `scan_used_images`,
485    // breaking resource GC). The Step 1.1 structural-identity cache below
486    // (root `subtree_hash` + viewport) is the correct, content-based skip;
487    // it costs one ~600 µs reconcile pass but cannot be fooled by address
488    // reuse.
489    let dom_ptr = std::ptr::from_ref::<StyledDom>(new_dom) as usize;
490    cache.prev_dom_ptr = dom_ptr;
491    cache.prev_viewport = viewport;
492
493    // --- Step 1: Reconciliation & Invalidation ---
494    crate::probe::reset_peak();
495    let (new_tree_val, mut recon_result) =
496        cache::reconcile_and_invalidate(&mut ctx_temp, cache, viewport)?;
497    // [g56 FIX] Box the LayoutTree onto the HEAP. The lifted `&mut new_tree` passed to
498    // calculate_intrinsic_sizes was mis-lifted (callee saw nodes.len()=0 while the caller saw 2)
499    // because a stack/SROA'd `new_tree`'s address doesn't survive the cross-function lifted call
500    // (taking `&new_tree` lifted to 0x0). A heap allocation has a stable absolute wasm address
501    // that lifts reliably (cf. M8.4 "heap allocations work fine"). Deref coercion handles the
502    // `&new_tree`/`&mut new_tree`/`new_tree.field` sites unchanged.
503    let mut new_tree = Box::new(new_tree_val);
504    { let _ = (0xDD00_0002u32); }
505    // [az-diag g51 REVERT] 0x71 = reconcile_and_invalidate returned OK (no InvalidTree in reconcile).
506    unsafe { crate::az_mark(0x60704_u32, (0x71u32)); }
507    // [az-diag g54 REVERT] 0x40740 = new_tree.nodes.len() RIGHT AFTER reconcile. If 0 → reconcile
508    // built an empty LayoutTree (the bug is in reconcile_recursive/create_node_from_dom). If 2 →
509    // reconcile is fine and the tree gets emptied/mis-lifted downstream (check 0x40744 at the loop).
510    unsafe { crate::az_mark(0x60740_u32, (new_tree.nodes.len() as u32)); }
511    crate::probe::sample_peak_rss("rss:after_reconcile");
512    crate::probe::sample_phase_peak("rss:peak_during_reconcile");
513
514    // --- Step 1.1: Structural-identity display-list cache ---
515    //
516    // If the reconciled root subtree_hash matches the cached one AND
517    // the viewport is unchanged, nothing structural has moved — skip
518    // layout, positioning, AND display-list generation and return
519    // the cached display list verbatim.
520    //
521    // This fires on re-renders of an unchanged DOM: the reconcile
522    // pass still walks and hashes the tree, but that's ~600 µs vs
523    // the ~4 ms it would otherwise cost to re-emit the display list.
524    if let Some((cached_hash, cached_viewport, cached_dl)) = &cache.cached_display_list {
525        let new_root_hash = new_tree
526            .cold(new_tree.root)
527            .map(|c| c.subtree_hash);
528        if new_root_hash == Some(*cached_hash) && *cached_viewport == viewport {
529            let _p = crate::probe::Probe::span("display_list_cache_hit");
530            return Ok(cached_dl.clone());
531        }
532    }
533
534    // Step 1.2: Clear Taffy Caches for Dirty Nodes
535    for &node_idx in &recon_result.intrinsic_dirty {
536        if let Some(warm) = new_tree.warm_mut(node_idx) {
537            warm.taffy_cache.clear();
538        }
539    }
540
541    // Step 1.3: Compute CSS Counters
542    // This must be done after tree generation but before layout,
543    // as list markers need counter values during formatting context layout
544    {
545        let _p = crate::probe::Probe::span("compute_counters");
546        cache::compute_counters(new_dom, &new_tree, &mut counter_values);
547    }
548    // [az-diag g51 REVERT] 0x72 = compute_counters done (InvalidTree, if any, is after here).
549    unsafe { crate::az_mark(0x60704_u32, (0x72u32)); }
550
551    // Step 1.4: Resize and invalidate per-node cache (Taffy-inspired 9+1 slot cache)
552    // Move cache_map out of LayoutCache for the duration of layout (avoids borrow conflicts).
553    // It will be moved back after the layout pass completes.
554    //
555    // Critically: the old `cache_map.entries` is indexed by OLD
556    // layout-tree positions. The NEW tree may have re-ordered
557    // indices (anonymous wrapper slots shifted, whitespace nodes
558    // dropped, etc.). A plain `resize_with(default)` would silently
559    // serve the wrong node's cached result for any shifted index.
560    //
561    // Re-map by stable identity: build `old_layout_idx → new_layout_idx`
562    // via the `(dom_node_id → layout_idx)` tables on both trees,
563    // then move each surviving cache entry into its new slot. Nodes
564    // without a matching DOM id (pure anonymous wrappers) fall
565    // through to the default (empty, i.e. dirty) entry.
566    let mut cache_map = std::mem::take(&mut cache.cache_map);
567    let probe_cache_remap = Some(crate::probe::Probe::span("cache_map_remap"));
568    if let Some(old_tree) = cache.tree.as_ref() {
569        let mut remapped = cache::LayoutCacheMap::default();
570        remapped.entries.resize_with(new_tree.nodes.len(), Default::default);
571
572        // Primary mapping: DOM id → layout idx on both sides. This
573        // covers every node that has a corresponding DOM node.
574        for (dom_id, new_indices) in &new_tree.dom_to_layout {
575            let Some(old_indices) = old_tree.dom_to_layout.get(dom_id) else {
576                continue;
577            };
578            for (pair_idx, &new_layout_idx) in new_indices.iter().enumerate() {
579                let Some(&old_layout_idx) = old_indices.get(pair_idx) else {
580                    continue;
581                };
582                if old_layout_idx >= cache_map.entries.len()
583                    || new_layout_idx >= remapped.entries.len()
584                {
585                    continue;
586                }
587                remapped.entries[new_layout_idx] =
588                    core::mem::take(&mut cache_map.entries[old_layout_idx]);
589            }
590        }
591
592        // Build old-parent → [old_anon_indices] and
593        // new-parent → [new_anon_indices]; match by pair position.
594        let old_anon_by_parent = collect_anon_children_by_parent(old_tree);
595        let new_anon_by_parent = collect_anon_children_by_parent(&new_tree);
596
597        // For each new parent we know: look up its old twin by the
598        // dom-id mapping we just populated, then match anon children
599        // positionally within that parent.
600        // Build a new→old layout-idx lookup from the primary pass.
601        let mut new_to_old_layout_idx: HashMap<usize, usize> =
602            HashMap::new();
603        for (dom_id, new_indices) in &new_tree.dom_to_layout {
604            let Some(old_indices) = old_tree.dom_to_layout.get(dom_id) else {
605                continue;
606            };
607            for (pair_idx, &new_layout_idx) in new_indices.iter().enumerate() {
608                if let Some(&old_layout_idx) = old_indices.get(pair_idx) {
609                    new_to_old_layout_idx.insert(new_layout_idx, old_layout_idx);
610                }
611            }
612        }
613
614        for (new_parent_idx, new_anon_children) in new_anon_by_parent {
615            let Some(&old_parent_idx) = new_to_old_layout_idx.get(&new_parent_idx) else {
616                continue;
617            };
618            let Some(old_anon_children) = old_anon_by_parent.get(&old_parent_idx) else {
619                continue;
620            };
621            for (ord, &new_anon_idx) in new_anon_children.iter().enumerate() {
622                let Some(&old_anon_idx) = old_anon_children.get(ord) else {
623                    continue;
624                };
625                if old_anon_idx >= cache_map.entries.len()
626                    || new_anon_idx >= remapped.entries.len()
627                {
628                    continue;
629                }
630                remapped.entries[new_anon_idx] =
631                    core::mem::take(&mut cache_map.entries[old_anon_idx]);
632            }
633        }
634
635        cache_map = remapped;
636    } else {
637        cache_map.resize_to_tree(new_tree.nodes.len());
638    }
639    drop(probe_cache_remap);
640    crate::probe::sample_peak_rss("rss:after_cache_remap");
641    for &node_idx in &recon_result.intrinsic_dirty {
642        cache_map.mark_dirty(node_idx, &new_tree.nodes);
643    }
644    for &node_idx in &recon_result.layout_roots {
645        cache_map.mark_dirty(node_idx, &new_tree.nodes);
646    }
647
648    // Now create the real context with computed counters
649    let mut ctx = LayoutContext {
650        scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
651        styled_dom: new_dom,
652        font_manager,
653        text_selections,
654        debug_messages,
655        counters: &mut counter_values,
656        viewport_size: viewport.size,
657        fragmentation_context: None,
658        cursor_is_visible,
659        cursor_locations,
660        preedit_text,
661        dirty_text_overrides: BTreeMap::new(),
662        cache_map, // Moved from LayoutCache; will be moved back after layout
663        image_cache,
664        system_style,
665        get_system_time_fn,
666    };
667
668    // --- Step 1.5: Early Exit Optimization ---
669    // M12.7: `&& cache.tree.is_some()` — this "nothing changed, reuse cached
670    // layout" fast path REQUIRES a cached tree; on COLD layout cache.tree is
671    // None, so entering here would hit `ok_or(InvalidTree)`. recon_result must
672    // be dirty on cold (the viewport-resize dirties the root), but if
673    // is_clean() mis-evaluates we'd wrongly early-exit → InvalidTree. Guarding
674    // on a cached tree is both correct (can't reuse what isn't there) and
675    // robust. (rc=5 post-reconcile, step=2: this was the failing `?`.)
676    if recon_result.is_clean() && cache.tree.is_some() {
677        debug_log!(ctx, "No changes, returning existing display list");
678        let tree = cache.tree.as_ref().ok_or(LayoutError::InvalidTree)?;
679
680        // Use cached scroll IDs if available, otherwise compute them
681        let scroll_ids = if cache.scroll_ids.is_empty() {
682            use crate::window::LayoutWindow;
683            let (scroll_ids, scroll_id_to_node_id) =
684                LayoutWindow::compute_scroll_ids(tree, new_dom);
685            cache.scroll_ids.clone_from(&scroll_ids);
686            cache.scroll_id_to_node_id = scroll_id_to_node_id;
687            scroll_ids
688        } else {
689            cache.scroll_ids.clone()
690        };
691
692        if SKIP_DISPLAY_LIST.load(core::sync::atomic::Ordering::Relaxed) {
693            return Ok(DisplayList::default());
694        }
695        return generate_display_list(
696            &mut ctx,
697            tree,
698            &cache.calculated_positions,
699            scroll_offsets,
700            &scroll_ids,
701            gpu_value_cache,
702            renderer_resources,
703            id_namespace,
704            dom_id,
705        );
706    }
707
708    { let _ = (0xDD00_0003u32); }
709    // [az-diag g51 REVERT] 0x80 = reached the incremental layout loop (past early-exit + remap + dirty loops).
710    unsafe { crate::az_mark(0x60704_u32, (0x80u32)); }
711    // [az-diag g65 PATH-B VALIDATION] new_tree is still valid here (=2). Clone it into the HEAP-backed
712    // cache.tree (set AFTER the remap+early-exit which read the OLD cache.tree). cache is the stable
713    // &mut arg (read correctly throughout), so cache.tree is NOT a deep-SP-relative stack local. At the
714    // sizing call we read BOTH: stack new_tree (expect 0=corrupted) vs heap cache.tree (expect 2 if
715    // path B sidesteps the SP-drift/wild-store). If heap=2, the full cache.tree refactor will fix it.
716    cache.tree = Some((*new_tree).clone());
717    // [az-diag g66] disambiguate the g65 heap=1: read BOTH right after the clone. 0x407C0 = stack
718    // new_tree.nodes.len() (source), 0x407C4 = clone cache.tree.nodes.len(). If src=2 & clone=1 →
719    // Vec::clone MIS-LIFTS (drops a node) → the full MOVE-based cache.tree refactor avoids it (do it).
720    // If src=1=clone → corruption already reached line 758 (heisenbug) → move won't help.
721    unsafe {
722        crate::az_mark(0x607C0_u32, (new_tree.nodes.len() as u32));
723        crate::az_mark(0x607C4_u32, (cache.tree.as_ref().map_or(999, |t| t.nodes.len()) as u32));
724    }
725
726    // --- Step 2: Incremental Layout Loop (handles scrollbar-induced reflows) ---
727    let mut calculated_positions = cache.calculated_positions.clone();
728    let mut loop_count = 0;
729    loop {
730        loop_count += 1;
731        if loop_count > MAX_SCROLLBAR_REFLOW_ITERATIONS {
732            debug_warning!(ctx, "Scrollbar reflow loop hit limit of {} iterations, breaking to avoid infinite loop", MAX_SCROLLBAR_REFLOW_ITERATIONS);
733            break;
734        }
735
736        calculated_positions = {
737            let _p = crate::probe::Probe::span("clone_calculated_positions");
738            cache.calculated_positions.clone()
739        };
740        // [az-diag g70 RELIABLE free-band] 0x60780 = nodes.len AFTER the in-loop calculated_positions.clone().
741        unsafe { crate::az_mark(0x60780_u32, (new_tree.nodes.len() as u32)); }
742        let mut reflow_needed_for_scrollbars = false;
743
744        {
745            crate::probe::reset_peak();
746            // [az-diag g70 RELIABLE free-band] 0x60784 = nodes.len AFTER reset_peak (before the calc Span).
747            unsafe { crate::az_mark(0x60784_u32, (new_tree.nodes.len() as u32)); }
748            let _p = crate::probe::Probe::span("calc_intrinsic_sizes");
749            // [az-diag g70 RELIABLE free-band] 0x60788 = nodes.len AFTER the calc_intrinsic_sizes Span.
750            unsafe { crate::az_mark(0x60788_u32, (new_tree.nodes.len() as u32)); }
751            // [az-diag g72 FIX] REMOVED the g48 `#[cfg(feature="web_lift")] panic!(...)` that lived
752            // here. web-transpiler => azul-layout?/web_lift IS enabled (dll/Cargo.toml:651), so this
753            // panic WAS compiled in, and with `-Z build-std-features=panic_immediate_abort` it lowered
754            // to a bare `brk #0x1` right after the 0x90 marker — aborting BEFORE calculate_intrinsic_sizes.
755            // The whole-session "new_tree 2→0 corruption" was a MIRAGE: the beforeCall marker store was
756            // dead-code-eliminated (after the abort), so the harness read uninitialized 0, not a corrupted
757            // tree. Native disasm of layout_document proved it: 0x90 marker store → `brk #0x1` → no `bl
758            // calculate_intrinsic_sizes` anywhere. (The prior "string absent ⇒ web_lift off" check was
759            // wrong — panic_immediate_abort strips the message string.)
760            // [az-diag g65 PATH-B VALIDATION] 0x40748 = stack new_tree.nodes.len() (expect 0),
761            // 0x4074C = HEAP cache.tree.nodes.len() (expect 2 if path B sidesteps the corruption).
762            unsafe {
763                crate::az_mark(0x60748_u32, (new_tree.nodes.len() as u32));
764                crate::az_mark(0x6074C_u32, (cache.tree.as_ref().map_or(999, |t| t.nodes.len()) as u32));
765            }
766            calculate_intrinsic_sizes(
767                &mut ctx,
768                &mut new_tree,
769                text_cache,
770                &recon_result.intrinsic_dirty,
771            )?;
772        }
773        crate::probe::sample_peak_rss("rss:after_calc_intrinsic");
774        crate::probe::sample_phase_peak("rss:peak_during_intrinsic");
775        // divergence is inside calculate_intrinsic_sizes (the SIMD/text intrinsic pass).
776        { let _ = (0xDD00_0005u32); }
777
778        for &root_idx in &recon_result.layout_roots {
779            let (cb_pos, cb_size) = get_containing_block_for_node(
780                &new_tree,
781                new_dom,
782                root_idx,
783                &calculated_positions,
784                viewport,
785            );
786            // 0x05, the divergence is INSIDE get_containing_block_for_node (or the for-loop
787            // entry); if 0x53 but not 0x55, it's the margin logic / box_props.unpack below.
788            { let _ = (0xDD00_0053u32); }
789            // get_containing_block_for_node(viewport)). 800 here but viewport=800 ⟹ OK;
790            // 0 here with viewport=800 ⟹ get_containing_block_for_node lost it (HFA return).
791
792            // For ROOT nodes (no parent), we need to account for their margin.
793            // The containing block position from viewport is (0, 0), but the root's
794            // content starts at (margin + border + padding, margin + border + padding).
795            // We pass margin-adjusted position so calculate_content_box_pos works correctly.
796            let root_node = &new_tree.nodes[root_idx];
797            let root_bp = root_node.box_props.unpack();
798            { let _ = (0xDD00_0054u32); }
799
800            let is_root_with_margin = root_node.parent.is_none()
801                && (root_bp.margin.left != 0.0 || root_bp.margin.top != 0.0);
802
803            let adjusted_cb_pos = if is_root_with_margin {
804                LogicalPosition::new(
805                    cb_pos.x + root_bp.margin.left,
806                    cb_pos.y + root_bp.margin.top,
807                )
808            } else {
809                cb_pos
810            };
811            { let _ = (0xDD00_0056u32); }
812
813            // DEBUG: Log containing block info for this root
814            if let Some(debug_msgs) = ctx.debug_messages.as_mut() {
815                let dom_name = root_node
816                    .dom_node_id
817                    .and_then(|id| new_dom.node_data.as_container().internal.get(id.index())).map_or_else(|| "Unknown".to_string(), |n| format!("{:?}", n.node_type));
818
819                debug_msgs.push(LayoutDebugMessage::new(
820                    LayoutDebugMessageType::PositionCalculation,
821                    format!(
822                        "[LAYOUT ROOT {}] {} - CB pos=({:.2}, {:.2}), adjusted=({:.2}, {:.2}), \
823                         CB size=({:.2}x{:.2}), viewport=({:.2}x{:.2}), margin=({:.2}, {:.2})",
824                        root_idx,
825                        dom_name,
826                        cb_pos.x,
827                        cb_pos.y,
828                        adjusted_cb_pos.x,
829                        adjusted_cb_pos.y,
830                        cb_size.width,
831                        cb_size.height,
832                        viewport.size.width,
833                        viewport.size.height,
834                        root_bp.margin.left,
835                        root_bp.margin.top
836                    ),
837                ));
838            }
839
840            // Purge after intrinsic sizing — frees child_intrinsics Vecs,
841            // IntrinsicSizeCalculator temporaries, text measurement caches.
842            crate::probe::hint_purge_allocator();
843            crate::probe::sample_peak_rss("rss:before_root_layout");
844            crate::probe::reset_peak();
845            // 0x57 = it RETURNED. If step stays 0x55, calculate_layout_for_subtree diverges.
846            { let _ = (0xDD00_0055u32); }
847            // This is exactly what calc_used_size reads as `viewport`. 0 here pinpoints the
848            // loss to the ctx build (viewport.size → ctx.viewport_size copy).
849            // 0x5E = Err. Do NOT propagate (continue to the cache store) so layout-real can
850            // see whether the geometry was computed regardless of a (possibly spurious,
851            // niche-Result-mis-discriminated) Err.
852            let clr = {
853                let _p = crate::probe::Probe::span("root_layout_pass");
854                cache::calculate_layout_for_subtree(
855                    &mut ctx,
856                    &mut new_tree,
857                    text_cache,
858                    root_idx,
859                    adjusted_cb_pos,
860                    cb_size,
861                    &mut calculated_positions,
862                    &mut reflow_needed_for_scrollbars,
863                    &mut cache.float_cache,
864                    cache::ComputeMode::PerformLayout,
865                )
866            };
867            { let _ = (if clr.is_ok() { 0xDD00_0057u32 } else { 0xDD00_005Eu32 }); }
868            crate::probe::sample_peak_rss("rss:after_root_layout");
869            crate::probe::sample_phase_peak("rss:peak_during_root_layout");
870
871            // CRITICAL: Insert the root node's own position into calculated_positions
872            // This is necessary because calculate_layout_for_subtree only inserts
873            // positions for children, not for the root itself.
874            //
875            // For root nodes, the position should be at (margin.left, margin.top) relative
876            // to the viewport origin, because the margin creates space between the viewport
877            // edge and the element's border-box.
878            if !pos_contains(&calculated_positions, root_idx) {
879                let root_node = &new_tree.nodes[root_idx];
880                let root_bp2 = root_node.box_props.unpack();
881
882                // Calculate the root's border-box position by adding margins to viewport origin
883                // This is different from non-root nodes which inherit their position from
884                // their containing block.
885                let root_position = LogicalPosition::new(
886                    cb_pos.x + root_bp2.margin.left,
887                    cb_pos.y + root_bp2.margin.top,
888                );
889
890                // DEBUG: Log root positioning
891                if let Some(debug_msgs) = ctx.debug_messages.as_mut() {
892                    let dom_name = root_node
893                        .dom_node_id
894                        .and_then(|id| new_dom.node_data.as_container().internal.get(id.index())).map_or_else(|| "Unknown".to_string(), |n| format!("{:?}", n.node_type));
895
896                    debug_msgs.push(LayoutDebugMessage::new(
897                        LayoutDebugMessageType::PositionCalculation,
898                        format!(
899                            "[ROOT POSITION {}] {} - Inserting position=({:.2}, {:.2}) (viewport origin + margin), \
900                             margin=({:.2}, {:.2}, {:.2}, {:.2})",
901                            root_idx,
902                            dom_name,
903                            root_position.x,
904                            root_position.y,
905                            root_bp2.margin.top,
906                            root_bp2.margin.right,
907                            root_bp2.margin.bottom,
908                            root_bp2.margin.left
909                        ),
910                    ));
911                }
912
913                pos_set(&mut calculated_positions, root_idx, root_position);
914            }
915        }
916        // (step 6). If step stays 5, the divergence is in calculate_layout_for_subtree.
917        { let _ = (0xDD00_0006u32); }
918
919        {
920            let _p = crate::probe::Probe::span("reposition_clean_subtrees");
921            cache::reposition_clean_subtrees(
922                new_dom,
923                &new_tree,
924                &recon_result.layout_roots,
925                &mut calculated_positions,
926            );
927        }
928
929        if reflow_needed_for_scrollbars {
930            debug_log!(ctx,
931                "Scrollbars changed container size, starting full reflow (loop {})",
932                loop_count
933            );
934            recon_result.layout_roots.clear();
935            recon_result.layout_roots.insert(new_tree.root);
936            recon_result.intrinsic_dirty = (0..new_tree.nodes.len()).collect();
937            continue;
938        }
939
940        break;
941    }
942
943    // +spec:positioning:8d1286 - normal flow, relative, float, absolute positioning dispatch
944    // +spec:positioning:bdfc81 - Layout divided into sizing (Step 2) then positioning (Step 3)
945    // --- Step 3: Adjust Relatively Positioned Elements ---
946    // +spec:positioning:a831e8 - inline content width uses pre-relative-offset positions (satisfied by post-layout relative adjustment)
947    // +spec:positioning:e2647b - Relative positioning applied after line height calculation, so line height is not adjusted for relative offsets
948    // +spec:positioning:77a2d2 - Relatively positioned boxes considered without their offset during auto height
949    // +spec:positioning:b47ac2 - Relatively positioned boxes considered without their offset for block auto height
950    // Relative offsets applied AFTER layout, so auto-height calculation sees normal-flow positions.
951    // This must be done BEFORE positioning out-of-flow elements, because
952    // relatively positioned elements establish containing blocks for their
953    // absolutely positioned descendants. If we adjust relative positions after
954    // positioning absolute elements, the absolute elements will be positioned
955    // relative to the wrong (pre-adjustment) position of their containing block.
956    // Pass the viewport to correctly resolve percentage offsets for the root element.
957    {
958        let _p = crate::probe::Probe::span("adjust_relative_positions");
959        positioning::adjust_relative_positions(
960            &mut ctx,
961            &new_tree,
962            &mut calculated_positions,
963            viewport,
964        );
965    }
966
967    // --- Step 3.25: Adjust Sticky Positioned Elements ---
968    // Sticky elements are laid out in normal flow, then their visual position
969    // is clamped based on scroll offset and inset properties relative to the
970    // nearest scrollport. Must happen after relative positioning but before
971    // absolute positioning (sticky elements establish containing blocks).
972    {
973        let _p = crate::probe::Probe::span("adjust_sticky_positions");
974        positioning::adjust_sticky_positions(
975            &mut ctx,
976            &new_tree,
977            &mut calculated_positions,
978            scroll_offsets,
979            viewport,
980        );
981    }
982
983    // --- Step 3.5: Position Out-of-Flow Elements ---
984    // This must be done AFTER adjusting relative positions, so that absolutely
985    // positioned elements are positioned relative to the final (post-adjustment)
986    // position of their relatively positioned containing blocks.
987    {
988        let _p = crate::probe::Probe::span("position_out_of_flow");
989        positioning::position_out_of_flow_elements(
990            &mut ctx,
991            &mut new_tree,
992            text_cache,
993            &mut calculated_positions,
994            viewport,
995        );
996    }
997
998    // --- Step 3.75: Compute Stable Scroll IDs ---
999    // This must be done AFTER layout but BEFORE display list generation
1000    let (scroll_ids, scroll_id_to_node_id) = {
1001        let _p = crate::probe::Probe::span("compute_scroll_ids");
1002        LayoutWindow::compute_scroll_ids(&new_tree, new_dom)
1003    };
1004
1005    crate::probe::sample_peak_rss("rss:before_display_list");
1006    crate::probe::reset_peak();
1007    // --- Step 4: Generate Display List & Update Cache ---
1008    let display_list = if SKIP_DISPLAY_LIST.load(core::sync::atomic::Ordering::Relaxed) {
1009        // Web backend: positions are done; the painter is dead weight.
1010        DisplayList::default()
1011    } else {
1012        let _p = crate::probe::Probe::span("generate_display_list");
1013        generate_display_list(
1014            &mut ctx,
1015            &new_tree,
1016            &calculated_positions,
1017            scroll_offsets,
1018            &scroll_ids,
1019            gpu_value_cache,
1020            renderer_resources,
1021            id_namespace,
1022            dom_id,
1023        )?
1024    };
1025    crate::probe::sample_phase_peak("rss:peak_during_display_list");
1026
1027    // Move cache_map back into LayoutCache before dropping ctx
1028    let _p_writeback = crate::probe::Probe::span("cache_writeback");
1029    let cache_map_back = std::mem::take(&mut ctx.cache_map);
1030
1031    // Cache the freshly-generated display list keyed on the root's
1032    // subtree_hash + viewport. If the next `layout_document` call
1033    // sees matching values after reconcile, it returns this clone
1034    // directly and skips all downstream work.
1035    let root_subtree_hash = new_tree
1036        .cold(new_tree.root)
1037        .map_or(layout_tree::SubtreeHash(0), |c| c.subtree_hash);
1038    cache.cached_display_list = Some((root_subtree_hash, viewport, display_list.clone()));
1039
1040    cache.tree = Some(*new_tree); // [g56] unbox the heap LayoutTree back into the cache
1041    cache.previous_positions = std::mem::replace(&mut cache.calculated_positions, calculated_positions);
1042    cache.viewport = Some(viewport);
1043    cache.scroll_ids = scroll_ids;
1044    cache.scroll_id_to_node_id = scroll_id_to_node_id;
1045    // + calculated_positions.len in the low bits. If step stays 3, it diverged earlier.
1046    { let _ = (0xDD00_0004u32 | ((cache.calculated_positions.len() as u32 & 0xfff) << 4)); }
1047    cache.counters = counter_values;
1048    cache.cache_map = cache_map_back;
1049    crate::probe::sample_peak_rss("rss:after_layout_document");
1050
1051    Ok(display_list)
1052}
1053
1054// +spec:containing-block:159830 - Containing block chain: parent content-box for in-flow, viewport for initial containing block
1055// +spec:containing-block:22fbaa - computes the element's original containing block (before positioning effects)
1056// +spec:containing-block:238fc5 - containing block dimensions calculated here (CSS 2.2 §9.1.2 forward ref to §10)
1057// +spec:containing-block:263629 - block element's content-box establishes the containing block for its line boxes
1058// +spec:containing-block:2a5280 - boxes act as containing blocks for descendants; CB = parent's content box
1059// +spec:containing-block:6776cb - boxes positioned w.r.t. containing block but not confined; overflow allowed
1060// +spec:containing-block:718894 - CB derived from parent content-box edges; root uses initial CB (viewport)
1061// +spec:containing-block:a2aa37 - box edges act as containing block for descendants; initial containing block = viewport
1062// +spec:containing-block:e23b3f - CSS 2.2 §10.1: initial containing block = viewport; static/relative = parent content-box; fixed = viewport
1063// +spec:containing-block:e8fdb2 - Containing block resolution (CSS2 §9.1.2, §10.1)
1064// +spec:overflow:9a2b11 - containing block is content-box of parent; boxes may overflow it
1065// +spec:positioning:acc663 - containing block definition: element boxes positioned relative to containing block
1066pub(super) fn get_containing_block_for_node(
1067    tree: &LayoutTree,
1068    styled_dom: &StyledDom,
1069    node_idx: usize,
1070    calculated_positions: &PositionVec,
1071    viewport: LogicalRect,
1072) -> (LogicalPosition, LogicalSize) {
1073    if let Some(parent_idx) = tree.get(node_idx).and_then(|n| n.parent) {
1074        if let Some(parent_node) = tree.get(parent_idx) {
1075            let pos = pos_get(calculated_positions, parent_idx)
1076                .unwrap_or(viewport.origin);
1077            let size = parent_node.used_size.unwrap_or_default();
1078            // Position in calculated_positions is the margin-box position
1079            // To get content-box, add: border + padding (NOT margin, that's already in pos)
1080            let pbp = parent_node.box_props.unpack();
1081            let content_pos = LogicalPosition::new(
1082                pos.x + pbp.border.left + pbp.padding.left,
1083                pos.y + pbp.border.top + pbp.padding.top,
1084            );
1085
1086            if let Some(dom_id) = parent_node.dom_node_id {
1087                let styled_node_state = &styled_dom
1088                    .styled_nodes
1089                    .as_container()
1090                    .get(dom_id)
1091                    .map(|n| &n.styled_node_state)
1092                    .copied()
1093                    .unwrap_or_default();
1094                // +spec:containing-block:c205e5 - writing mode of containing block used for inner_size (orthogonal flow awareness)
1095                let writing_mode =
1096                    get_writing_mode(styled_dom, dom_id, styled_node_state).unwrap_or_default();
1097                let content_size = pbp.inner_size(size, writing_mode);
1098                return (content_pos, content_size);
1099            }
1100
1101            return (content_pos, size);
1102        }
1103    }
1104    
1105    // +spec:containing-block:41bdfc - ICB equals viewport; overflow:hidden on root clips to ICB
1106    // +spec:containing-block:1eed60 - Initial containing block establishes a BFC; viewport is the ICB
1107    // +spec:containing-block:99866f - Containing block is a rectangle for sizing/positioning; ICB from viewport
1108    // +spec:containing-block:22f09b - viewport serves as initial containing block for root element
1109    // Root element's containing block is the initial containing block (CSS 2.2 §10.1, CSS Display 3 §2.8).
1110    // +spec:containing-block:2fd7b1 - ICB equals viewport; principal writing mode propagated to ICB
1111    // Root element's containing block is the initial containing block (CSS 2.2 §10.1, CSS Display 3 §2.8).
1112    // The principal writing mode is propagated to the ICB and viewport (css-writing-modes-4 §8.1).
1113    // +spec:containing-block:5efb84 - Root element's containing block is the initial containing block
1114    // +spec:containing-block:6278fb - initial containing block is the viewport; also serves as initial fixed containing block
1115    // Root element's containing block is the initial containing block (CSS 2.2 §10.1, CSS Display 3 §2.8).
1116    // For ROOT nodes: the containing block is the viewport (initial containing block).
1117    // Do NOT subtract margin here - margins are handled in calculate_used_size().
1118    // The margin creates space between viewport edge and element's border-box,
1119    // but the available space for calculating width/height percentages
1120    // is still the full viewport size.
1121    (viewport.origin, viewport.size)
1122}
1123
1124// [g119 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the `Text(font_traits::LayoutError)`
1125// variant's String/FontSelector pointer gives `Result<T, LayoutError>` a POINTER-niche disc, which
1126// the web lift MIS-READS → every solver3 `?`/Result return flips Ok→Err (heisenbug; g118 = collect's
1127// Result<(),LayoutError> arrived as Err → rc=5 InvalidTree though the out-param content was correct).
1128// An explicit u8 tag (0..=4) moves the Result niche to unused tag values (5..) = a simple u8 compare
1129// the lift handles. Same disc-mis-lift class as InlineContent/LogicalItem/ShapedItem (g117/g118).
1130#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
1131#[derive(Debug)]
1132#[repr(C, u8)]
1133pub enum LayoutError {
1134    InvalidTree,
1135    SizingFailed,
1136    PositioningFailed,
1137    DisplayListFailed,
1138    Text(crate::font_traits::LayoutError),
1139}
1140
1141impl std::fmt::Display for LayoutError {
1142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143        match self {
1144            Self::InvalidTree => write!(f, "Invalid layout tree"),
1145            Self::SizingFailed => write!(f, "Sizing calculation failed"),
1146            Self::PositioningFailed => write!(f, "Position calculation failed"),
1147            Self::DisplayListFailed => write!(f, "Display list generation failed"),
1148            Self::Text(e) => write!(f, "Text layout error: {e:?}"),
1149        }
1150    }
1151}
1152
1153impl From<crate::font_traits::LayoutError> for LayoutError {
1154    fn from(err: crate::font_traits::LayoutError) -> Self {
1155        Self::Text(err)
1156    }
1157}
1158
1159impl std::error::Error for LayoutError {}
1160
1161pub type Result<T> = std::result::Result<T, LayoutError>;