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 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/// Create a new PositionVec pre-sized for the given number of nodes.
144#[inline]
145pub fn new_position_vec(num_nodes: usize) -> PositionVec {
146    vec![POSITION_UNSET; num_nodes]
147}
148
149/// Get position for node index, returning None if unset.
150///
151/// Note: only the `x` component is checked against the sentinel. This is sufficient
152/// because `POSITION_UNSET` always sets both `x` and `y` to `f32::MIN`, and `pos_set`
153/// always writes both components together.
154#[inline(always)]
155pub fn pos_get(positions: &PositionVec, idx: usize) -> Option<LogicalPosition> {
156    positions.get(idx).copied().filter(|p| p.x != f32::MIN)
157}
158
159/// Set position for node index. Grows the vec if needed.
160#[inline(always)]
161pub fn pos_set(positions: &mut PositionVec, idx: usize, pos: LogicalPosition) {
162    if idx >= positions.len() {
163        positions.resize(idx + 1, POSITION_UNSET);
164    }
165    positions[idx] = pos;
166}
167
168/// Check if position has been set for node index.
169#[inline(always)]
170pub fn pos_contains(positions: &PositionVec, idx: usize) -> bool {
171    positions.get(idx).map_or(false, |p| p.x != f32::MIN)
172}
173use azul_css::{
174    props::property::{CssProperty, CssPropertyCategory},
175    LayoutDebugMessage, LayoutDebugMessageType,
176};
177
178use self::{
179    display_list::generate_display_list,
180    geometry::IntrinsicSizes,
181    getters::get_writing_mode,
182    layout_tree::{generate_layout_tree, LayoutTree},
183    sizing::calculate_intrinsic_sizes,
184};
185#[cfg(feature = "text_layout")]
186pub use crate::font_traits::TextLayoutCache;
187use crate::{
188    font_traits::ParsedFontTrait,
189    solver3::{
190        cache::LayoutCache,
191        display_list::DisplayList,
192        fc::{LayoutConstraints, LayoutResult},
193        layout_tree::DirtyFlag,
194    },
195};
196
197/// A map of hashes for each node to detect changes in content like text.
198pub type NodeHashMap = BTreeMap<usize, u64>;
199
200/// Central context for a single layout pass.
201pub struct LayoutContext<'a, T: ParsedFontTrait> {
202    pub styled_dom: &'a StyledDom,
203    #[cfg(feature = "text_layout")]
204    pub font_manager: &'a crate::font_traits::FontManager<T>,
205    #[cfg(not(feature = "text_layout"))]
206    pub font_manager: core::marker::PhantomData<&'a T>,
207    /// Text selections for rendering highlights. Populated from MultiCursorState.
208    pub text_selections: &'a BTreeMap<DomId, TextSelection>,
209    pub debug_messages: &'a mut Option<Vec<LayoutDebugMessage>>,
210    pub counters: &'a mut HashMap<(usize, String), i32>,
211    pub viewport_size: LogicalSize,
212    /// Fragmentation context for CSS Paged Media (PDF generation)
213    /// When Some, layout respects page boundaries and generates one DisplayList per page
214    pub fragmentation_context: Option<&'a mut crate::paged::FragmentationContext>,
215    /// Whether the text cursor should be drawn (managed by CursorManager blink timer)
216    /// When false, the cursor is in the "off" phase of blinking and should not be rendered.
217    /// When true (default), the cursor is visible.
218    pub cursor_is_visible: bool,
219    /// All active cursor locations from MultiCursorState / CursorManager.
220    /// Each entry is (dom_id, node_id, cursor). Multiple entries = multi-cursor mode.
221    /// Empty = no active cursor. The last entry is the primary cursor.
222    pub cursor_locations: Vec<(DomId, NodeId, TextCursor)>,
223    /// IME preedit (composition) text to render inline at the cursor position.
224    /// When Some, the text should be rendered with an underline decoration.
225    pub preedit_text: Option<String>,
226    /// Text content overrides from in-progress edits (dirty_text_nodes).
227    /// When a text node has been edited but not yet committed to the DOM,
228    /// the layout pipeline should read from here instead of StyledDom.
229    /// Key: (DomId, NodeId of the text node), Value: the edited text string.
230    pub dirty_text_overrides: BTreeMap<(DomId, NodeId), String>,
231    /// Per-node multi-slot cache (Taffy-inspired 9+1 architecture).
232    /// Moved out of LayoutCache via std::mem::take for the duration of layout,
233    /// then moved back after the layout pass completes.
234    pub cache_map: cache::LayoutCacheMap,
235    /// Image cache for resolving `background-image: url(...)` references.
236    pub image_cache: &'a azul_core::resources::ImageCache,
237    /// System style containing colors, fonts, metrics, and theme information.
238    /// Used for selection colors, caret styling, and other system-themed elements.
239    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
240    /// Callback to get the current system time. Used for profiling inside layout.
241    /// On WASM targets (where `std::time::Instant::now()` panics), callers should
242    /// supply a no-op or platform-specific implementation.
243    pub get_system_time_fn: azul_core::task::GetSystemTimeCallback,
244    /// Memoised `get_scrollbar_style` results, keyed by DOM node id.
245    /// `compute_scrollbar_info_core` is called many times per node
246    /// per layout pass (BFC path + Taffy flex/grid path + display
247    /// list), and each call previously did 9 cascade walks. Once
248    /// populated, subsequent callers in the same LayoutContext
249    /// (a single render) return a clone.
250    ///
251    /// Uses `RefCell` so shared `&self` borrows (e.g. in the Taffy
252    /// bridge's `get_core_container_style`) can mutate the cache
253    /// without lifting the ctx to `&mut`. Keyed by `NodeId` so
254    /// entries span DOMs in iframe-style nested documents if that
255    /// ever becomes a thing.
256    pub scrollbar_style_cache:
257        core::cell::RefCell<std::collections::HashMap<NodeId, crate::solver3::getters::ComputedScrollbarStyle>>,
258}
259
260impl<'a, T: ParsedFontTrait> LayoutContext<'a, T> {
261    /// Check if debug messages are enabled (for use with lazy macros)
262    #[inline]
263    pub fn has_debug(&self) -> bool {
264        self.debug_messages.is_some()
265    }
266
267    /// Internal method - called by debug_log! macro after checking has_debug()
268    #[inline]
269    pub fn debug_log_inner(&mut self, message: String) {
270        if let Some(messages) = self.debug_messages.as_mut() {
271            messages.push(LayoutDebugMessage {
272                message: message.into(),
273                location: "solver3".into(),
274                message_type: Default::default(),
275            });
276        }
277    }
278
279    /// Internal method - called by debug_info! macro after checking has_debug()
280    #[inline]
281    pub fn debug_info_inner(&mut self, message: String) {
282        if let Some(messages) = self.debug_messages.as_mut() {
283            messages.push(LayoutDebugMessage::info(message));
284        }
285    }
286
287    /// Internal method - called by debug_warning! macro after checking has_debug()
288    #[inline]
289    pub fn debug_warning_inner(&mut self, message: String) {
290        if let Some(messages) = self.debug_messages.as_mut() {
291            messages.push(LayoutDebugMessage::warning(message));
292        }
293    }
294
295    /// Internal method - called by debug_error! macro after checking has_debug()
296    #[inline]
297    pub fn debug_error_inner(&mut self, message: String) {
298        if let Some(messages) = self.debug_messages.as_mut() {
299            messages.push(LayoutDebugMessage::error(message));
300        }
301    }
302
303    /// Internal method - called by debug_box_props! macro after checking has_debug()
304    #[inline]
305    pub fn debug_box_props_inner(&mut self, message: String) {
306        if let Some(messages) = self.debug_messages.as_mut() {
307            messages.push(LayoutDebugMessage::box_props(message));
308        }
309    }
310
311    /// Internal method - called by debug_css_getter! macro after checking has_debug()
312    #[inline]
313    pub fn debug_css_getter_inner(&mut self, message: String) {
314        if let Some(messages) = self.debug_messages.as_mut() {
315            messages.push(LayoutDebugMessage::css_getter(message));
316        }
317    }
318
319    /// Internal method - called by debug_bfc_layout! macro after checking has_debug()
320    #[inline]
321    pub fn debug_bfc_layout_inner(&mut self, message: String) {
322        if let Some(messages) = self.debug_messages.as_mut() {
323            messages.push(LayoutDebugMessage::bfc_layout(message));
324        }
325    }
326
327    /// Internal method - called by debug_ifc_layout! macro after checking has_debug()
328    #[inline]
329    pub fn debug_ifc_layout_inner(&mut self, message: String) {
330        if let Some(messages) = self.debug_messages.as_mut() {
331            messages.push(LayoutDebugMessage::ifc_layout(message));
332        }
333    }
334
335    /// Internal method - called by debug_table_layout! macro after checking has_debug()
336    #[inline]
337    pub fn debug_table_layout_inner(&mut self, message: String) {
338        if let Some(messages) = self.debug_messages.as_mut() {
339            messages.push(LayoutDebugMessage::table_layout(message));
340        }
341    }
342
343    /// Internal method - called by debug_display_type! macro after checking has_debug()
344    #[inline]
345    pub fn debug_display_type_inner(&mut self, message: String) {
346        if let Some(messages) = self.debug_messages.as_mut() {
347            messages.push(LayoutDebugMessage::display_type(message));
348        }
349    }
350
351    // Convenience wrappers (prefer debug_*! macros for lazy evaluation)
352
353    #[inline]
354    pub fn debug_info(&mut self, message: impl Into<String>) {
355        self.debug_info_inner(message.into());
356    }
357    #[inline]
358    pub fn debug_warning(&mut self, message: impl Into<String>) {
359        self.debug_warning_inner(message.into());
360    }
361    #[inline]
362    pub fn debug_error(&mut self, message: impl Into<String>) {
363        self.debug_error_inner(message.into());
364    }
365    #[inline]
366    pub fn debug_log(&mut self, message: &str) {
367        self.debug_log_inner(message.to_string());
368    }
369    #[inline]
370    pub fn debug_box_props(&mut self, message: impl Into<String>) {
371        self.debug_box_props_inner(message.into());
372    }
373    #[inline]
374    pub fn debug_css_getter(&mut self, message: impl Into<String>) {
375        self.debug_css_getter_inner(message.into());
376    }
377    #[inline]
378    pub fn debug_bfc_layout(&mut self, message: impl Into<String>) {
379        self.debug_bfc_layout_inner(message.into());
380    }
381    #[inline]
382    pub fn debug_ifc_layout(&mut self, message: impl Into<String>) {
383        self.debug_ifc_layout_inner(message.into());
384    }
385    #[inline]
386    pub fn debug_table_layout(&mut self, message: impl Into<String>) {
387        self.debug_table_layout_inner(message.into());
388    }
389    #[inline]
390    pub fn debug_display_type(&mut self, message: impl Into<String>) {
391        self.debug_display_type_inner(message.into());
392    }
393}
394
395/// Main entry point for the incremental, cached layout engine.
396///
397/// `new_dom` is borrowed, not owned — every use inside is `&new_dom`,
398/// so taking ownership was a pure formality that forced every caller
399/// to `styled_dom.clone()` the DOM before calling. The clone was
400/// ~2 MiB per render on excel.html; kept at the borrow now.
401#[cfg(feature = "text_layout")]
402/// Web-backend opt-out for display-list generation.
403///
404/// When set, [`layout_document`] runs the full positioning pipeline
405/// (intrinsic sizing, taffy block/flex/grid, relative/sticky/absolute
406/// adjustment → `calculated_positions`) but **skips
407/// `generate_display_list`**, returning an empty [`DisplayList`]. The
408/// web backend emits TLV DOM patches, not a display list, so it needs
409/// the geometry in `calculated_positions` but nothing the painter
410/// produces. This also lets the AArch64→wasm lift drop the entire
411/// `display_list` painter surface (those symbols are classified `Leaf`
412/// in `dll/src/web/symbol_table.rs::classify_for_name`, so the
413/// transitive lifter never descends into them). Defaults `false` →
414/// desktop/native behaviour is unchanged.
415pub static SKIP_DISPLAY_LIST: core::sync::atomic::AtomicBool =
416    core::sync::atomic::AtomicBool::new(false);
417
418/// Set [`SKIP_DISPLAY_LIST`]. Provided as a function (rather than the
419/// caller touching the static directly) so the web backend's
420/// `dll`-crate caller reaches it through a normal `bl` into this
421/// `azul_layout` function — keeping the static's address computation
422/// intra-crate (direct `adrp+add`) instead of a cross-crate GOT load,
423/// which the AArch64→wasm lift mirrors more reliably.
424pub fn set_skip_display_list(skip: bool) {
425    SKIP_DISPLAY_LIST.store(skip, core::sync::atomic::Ordering::Relaxed);
426}
427
428// M12.7: keep this out-of-line so the web lift sees it as its own wasm fn
429// (not inlined into layout_dom_recursive). An opt-folded infinite loop in the
430// solver (a mis-lifted loop exit) is otherwise hidden inside the giant inlined
431// layout_dom_recursive; de-inlining lets AZ_FUEL/AZ_WASM_DEBUG name the actual
432// source fn — and may itself prevent the inlining-induced fold. No perf cost on
433// desktop (called once per layout).
434#[inline(never)]
435pub fn layout_document<T: ParsedFontTrait + Sync + 'static>(
436    cache: &mut LayoutCache,
437    text_cache: &mut TextLayoutCache,
438    new_dom: &StyledDom,
439    viewport: LogicalRect,
440    font_manager: &crate::font_traits::FontManager<T>,
441    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
442    text_selections: &BTreeMap<DomId, TextSelection>,
443    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
444    gpu_value_cache: Option<&azul_core::gpu::GpuValueCache>,
445    renderer_resources: &azul_core::resources::RendererResources,
446    id_namespace: azul_core::resources::IdNamespace,
447    dom_id: azul_core::dom::DomId,
448    cursor_is_visible: bool,
449    cursor_locations: Vec<(DomId, NodeId, TextCursor)>,
450    preedit_text: Option<String>,
451    image_cache: &azul_core::resources::ImageCache,
452    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
453    get_system_time_fn: azul_core::task::GetSystemTimeCallback,
454) -> Result<DisplayList> {
455    // Reset IFC ID counter at the start of each layout pass
456    // This ensures IFCs get consistent IDs across frames when the DOM structure is stable
457    crate::solver3::layout_tree::IfcId::reset_counter();
458    // M12.7 diag: progress marker at 0x400A4 (0xDD00_000N) — pinpoints WHICH `?`
459    // in layout_document returns the rc=5 Err (the error enum can't be captured
460    // reliably in the lift). The last value seen = the step that errored next.
461    unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0001u32); }
462
463    if let Some(msgs) = debug_messages.as_mut() {
464        msgs.push(LayoutDebugMessage::info(format!(
465            "[Layout] layout_document called - viewport: ({:.1}, {:.1}) size ({:.1}x{:.1})",
466            viewport.origin.x, viewport.origin.y, viewport.size.width, viewport.size.height
467        )));
468        msgs.push(LayoutDebugMessage::info(format!(
469            "[Layout] DOM has {} nodes",
470            new_dom.node_data.len()
471        )));
472    }
473
474    // Create temporary context without counters for tree generation
475    let mut counter_values = HashMap::new();
476    let mut ctx_temp = LayoutContext {
477        scrollbar_style_cache: core::cell::RefCell::new(std::collections::HashMap::new()),
478        styled_dom: new_dom,
479        font_manager,
480        text_selections,
481        debug_messages,
482        counters: &mut counter_values,
483        viewport_size: viewport.size,
484        fragmentation_context: None,
485        cursor_is_visible,
486        cursor_locations: cursor_locations.clone(),
487        preedit_text: preedit_text.clone(),
488        dirty_text_overrides: BTreeMap::new(),
489        cache_map: cache::LayoutCacheMap::default(), // temp context doesn't need real cache
490        image_cache,
491        system_style: system_style.clone(),
492        get_system_time_fn,
493    };
494
495    crate::probe::sample_peak_rss("rss:enter_layout_document");
496
497    // --- Step 0: Pointer-identity fast path ---
498    // If the exact same StyledDom reference is passed with the same viewport,
499    // skip reconcile entirely and return the cached display list.
500    let dom_ptr = new_dom as *const StyledDom as usize;
501    if dom_ptr == cache.prev_dom_ptr
502        && viewport == cache.prev_viewport
503        && cache.cached_display_list.is_some()
504    {
505        let _p = crate::probe::Probe::span("dom_ptr_cache_hit");
506        let (_, _, cached_dl) = cache.cached_display_list.as_ref().unwrap();
507        return Ok(cached_dl.clone());
508    }
509    cache.prev_dom_ptr = dom_ptr;
510    cache.prev_viewport = viewport;
511
512    // --- Step 1: Reconciliation & Invalidation ---
513    crate::probe::reset_peak();
514    let (mut new_tree, mut recon_result) =
515        cache::reconcile_and_invalidate(&mut ctx_temp, cache, viewport)?;
516    unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0002u32); }
517    crate::probe::sample_peak_rss("rss:after_reconcile");
518    crate::probe::sample_phase_peak("rss:peak_during_reconcile");
519
520    // --- Step 1.1: Structural-identity display-list cache ---
521    //
522    // If the reconciled root subtree_hash matches the cached one AND
523    // the viewport is unchanged, nothing structural has moved — skip
524    // layout, positioning, AND display-list generation and return
525    // the cached display list verbatim.
526    //
527    // This fires on re-renders of an unchanged DOM: the reconcile
528    // pass still walks and hashes the tree, but that's ~600 µs vs
529    // the ~4 ms it would otherwise cost to re-emit the display list.
530    if let Some((cached_hash, cached_viewport, cached_dl)) = &cache.cached_display_list {
531        let new_root_hash = new_tree
532            .cold(new_tree.root)
533            .map(|c| c.subtree_hash);
534        if new_root_hash == Some(*cached_hash) && *cached_viewport == viewport {
535            let _p = crate::probe::Probe::span("display_list_cache_hit");
536            return Ok(cached_dl.clone());
537        }
538    }
539
540    // Step 1.2: Clear Taffy Caches for Dirty Nodes
541    for &node_idx in &recon_result.intrinsic_dirty {
542        if let Some(warm) = new_tree.warm_mut(node_idx) {
543            warm.taffy_cache.clear();
544        }
545    }
546
547    // Step 1.3: Compute CSS Counters
548    // This must be done after tree generation but before layout,
549    // as list markers need counter values during formatting context layout
550    {
551        let _p = crate::probe::Probe::span("compute_counters");
552        cache::compute_counters(&new_dom, &new_tree, &mut counter_values);
553    }
554
555    // Step 1.4: Resize and invalidate per-node cache (Taffy-inspired 9+1 slot cache)
556    // Move cache_map out of LayoutCache for the duration of layout (avoids borrow conflicts).
557    // It will be moved back after the layout pass completes.
558    //
559    // Critically: the old `cache_map.entries` is indexed by OLD
560    // layout-tree positions. The NEW tree may have re-ordered
561    // indices (anonymous wrapper slots shifted, whitespace nodes
562    // dropped, etc.). A plain `resize_with(default)` would silently
563    // serve the wrong node's cached result for any shifted index.
564    //
565    // Re-map by stable identity: build `old_layout_idx → new_layout_idx`
566    // via the `(dom_node_id → layout_idx)` tables on both trees,
567    // then move each surviving cache entry into its new slot. Nodes
568    // without a matching DOM id (pure anonymous wrappers) fall
569    // through to the default (empty, i.e. dirty) entry.
570    let mut cache_map = std::mem::take(&mut cache.cache_map);
571    let _probe_cache_remap = Some(crate::probe::Probe::span("cache_map_remap"));
572    if let Some(old_tree) = cache.tree.as_ref() {
573        let mut remapped = cache::LayoutCacheMap::default();
574        remapped.entries.resize_with(new_tree.nodes.len(), Default::default);
575
576        // Primary mapping: DOM id → layout idx on both sides. This
577        // covers every node that has a corresponding DOM node.
578        for (dom_id, new_indices) in new_tree.dom_to_layout.iter() {
579            let Some(old_indices) = old_tree.dom_to_layout.get(dom_id) else {
580                continue;
581            };
582            for (pair_idx, &new_layout_idx) in new_indices.iter().enumerate() {
583                let Some(&old_layout_idx) = old_indices.get(pair_idx) else {
584                    continue;
585                };
586                if old_layout_idx >= cache_map.entries.len()
587                    || new_layout_idx >= remapped.entries.len()
588                {
589                    continue;
590                }
591                remapped.entries[new_layout_idx] =
592                    core::mem::take(&mut cache_map.entries[old_layout_idx]);
593            }
594        }
595
596        // Secondary mapping: anonymous wrappers (dom_node_id == None)
597        // by (parent_new_idx, ordinal-among-anon-siblings). An
598        // unchanged DOM produces the same anon wrappers in the same
599        // order under the same parent — matching by position here
600        // preserves their cache slots too. Without this, anon
601        // wrappers re-allocate empty every reconcile and invalidate
602        // their ancestors via `mark_dirty`.
603        fn collect_anon_children_by_parent(
604            tree: &LayoutTree,
605        ) -> std::collections::HashMap<usize, Vec<usize>> {
606            let mut map: std::collections::HashMap<usize, Vec<usize>> =
607                std::collections::HashMap::new();
608            for (idx, node) in tree.nodes.iter().enumerate() {
609                if node.dom_node_id.is_some() {
610                    continue;
611                }
612                if let Some(parent) = node.parent {
613                    map.entry(parent).or_default().push(idx);
614                }
615            }
616            map
617        }
618
619        // Build old-parent → [old_anon_indices] and
620        // new-parent → [new_anon_indices]; match by pair position.
621        let old_anon_by_parent = collect_anon_children_by_parent(old_tree);
622        let new_anon_by_parent = collect_anon_children_by_parent(&new_tree);
623
624        // For each new parent we know: look up its old twin by the
625        // dom-id mapping we just populated, then match anon children
626        // positionally within that parent.
627        // Build a new→old layout-idx lookup from the primary pass.
628        let mut new_to_old_layout_idx: std::collections::HashMap<usize, usize> =
629            std::collections::HashMap::new();
630        for (dom_id, new_indices) in new_tree.dom_to_layout.iter() {
631            let Some(old_indices) = old_tree.dom_to_layout.get(dom_id) else {
632                continue;
633            };
634            for (pair_idx, &new_layout_idx) in new_indices.iter().enumerate() {
635                if let Some(&old_layout_idx) = old_indices.get(pair_idx) {
636                    new_to_old_layout_idx.insert(new_layout_idx, old_layout_idx);
637                }
638            }
639        }
640
641        for (new_parent_idx, new_anon_children) in new_anon_by_parent {
642            let Some(&old_parent_idx) = new_to_old_layout_idx.get(&new_parent_idx) else {
643                continue;
644            };
645            let Some(old_anon_children) = old_anon_by_parent.get(&old_parent_idx) else {
646                continue;
647            };
648            for (ord, &new_anon_idx) in new_anon_children.iter().enumerate() {
649                let Some(&old_anon_idx) = old_anon_children.get(ord) else {
650                    continue;
651                };
652                if old_anon_idx >= cache_map.entries.len()
653                    || new_anon_idx >= remapped.entries.len()
654                {
655                    continue;
656                }
657                remapped.entries[new_anon_idx] =
658                    core::mem::take(&mut cache_map.entries[old_anon_idx]);
659            }
660        }
661
662        cache_map = remapped;
663    } else {
664        cache_map.resize_to_tree(new_tree.nodes.len());
665    }
666    drop(_probe_cache_remap);
667    crate::probe::sample_peak_rss("rss:after_cache_remap");
668    for &node_idx in &recon_result.intrinsic_dirty {
669        cache_map.mark_dirty(node_idx, &new_tree.nodes);
670    }
671    for &node_idx in &recon_result.layout_roots {
672        cache_map.mark_dirty(node_idx, &new_tree.nodes);
673    }
674
675    // Now create the real context with computed counters
676    let mut ctx = LayoutContext {
677        scrollbar_style_cache: core::cell::RefCell::new(std::collections::HashMap::new()),
678        styled_dom: &new_dom,
679        font_manager,
680        text_selections,
681        debug_messages,
682        counters: &mut counter_values,
683        viewport_size: viewport.size,
684        fragmentation_context: None,
685        cursor_is_visible,
686        cursor_locations,
687        preedit_text,
688        dirty_text_overrides: BTreeMap::new(),
689        cache_map, // Moved from LayoutCache; will be moved back after layout
690        image_cache,
691        system_style,
692        get_system_time_fn,
693    };
694
695    // --- Step 1.5: Early Exit Optimization ---
696    // M12.7: `&& cache.tree.is_some()` — this "nothing changed, reuse cached
697    // layout" fast path REQUIRES a cached tree; on COLD layout cache.tree is
698    // None, so entering here would hit `ok_or(InvalidTree)`. recon_result must
699    // be dirty on cold (the viewport-resize dirties the root), but if
700    // is_clean() mis-evaluates we'd wrongly early-exit → InvalidTree. Guarding
701    // on a cached tree is both correct (can't reuse what isn't there) and
702    // robust. (rc=5 post-reconcile, step=2: this was the failing `?`.)
703    if recon_result.is_clean() && cache.tree.is_some() {
704        debug_log!(ctx, "No changes, returning existing display list");
705        let tree = cache.tree.as_ref().ok_or(LayoutError::InvalidTree)?;
706
707        // Use cached scroll IDs if available, otherwise compute them
708        let scroll_ids = if cache.scroll_ids.is_empty() {
709            use crate::window::LayoutWindow;
710            let (scroll_ids, scroll_id_to_node_id) =
711                LayoutWindow::compute_scroll_ids(tree, &new_dom);
712            cache.scroll_ids = scroll_ids.clone();
713            cache.scroll_id_to_node_id = scroll_id_to_node_id;
714            scroll_ids
715        } else {
716            cache.scroll_ids.clone()
717        };
718
719        if SKIP_DISPLAY_LIST.load(core::sync::atomic::Ordering::Relaxed) {
720            return Ok(DisplayList::default());
721        }
722        return generate_display_list(
723            &mut ctx,
724            tree,
725            &cache.calculated_positions,
726            scroll_offsets,
727            &scroll_ids,
728            gpu_value_cache,
729            renderer_resources,
730            id_namespace,
731            dom_id,
732        );
733    }
734
735    // M12.7 diag: step 3 = passed Step 1.5 (early-exit), entering Step 2.
736    unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0003u32); }
737
738    // --- Step 2: Incremental Layout Loop (handles scrollbar-induced reflows) ---
739    let mut calculated_positions = cache.calculated_positions.clone();
740    let mut loop_count = 0;
741    loop {
742        loop_count += 1;
743        if loop_count > MAX_SCROLLBAR_REFLOW_ITERATIONS {
744            debug_warning!(ctx, "Scrollbar reflow loop hit limit of {} iterations, breaking to avoid infinite loop", MAX_SCROLLBAR_REFLOW_ITERATIONS);
745            break;
746        }
747
748        calculated_positions = {
749            let _p = crate::probe::Probe::span("clone_calculated_positions");
750            cache.calculated_positions.clone()
751        };
752        let mut reflow_needed_for_scrollbars = false;
753
754        {
755            crate::probe::reset_peak();
756            let _p = crate::probe::Probe::span("calc_intrinsic_sizes");
757            calculate_intrinsic_sizes(
758                &mut ctx,
759                &mut new_tree,
760                text_cache,
761                &recon_result.intrinsic_dirty,
762            )?;
763        }
764        crate::probe::sample_peak_rss("rss:after_calc_intrinsic");
765        crate::probe::sample_phase_peak("rss:peak_during_intrinsic");
766        // M12.7 diag: calculate_intrinsic_sizes returned (step 5). If step stays 3, the
767        // divergence is inside calculate_intrinsic_sizes (the SIMD/text intrinsic pass).
768        unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0005u32); }
769
770        for &root_idx in &recon_result.layout_roots {
771            let (cb_pos, cb_size) = get_containing_block_for_node(
772                &new_tree,
773                &new_dom,
774                root_idx,
775                &calculated_positions,
776                viewport,
777            );
778            // M12.7 diag: 0x53 = get_containing_block_for_node RETURNED. If step stays
779            // 0x05, the divergence is INSIDE get_containing_block_for_node (or the for-loop
780            // entry); if 0x53 but not 0x55, it's the margin logic / box_props.unpack below.
781            unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0053u32); }
782
783            // For ROOT nodes (no parent), we need to account for their margin.
784            // The containing block position from viewport is (0, 0), but the root's
785            // content starts at (margin + border + padding, margin + border + padding).
786            // We pass margin-adjusted position so calculate_content_box_pos works correctly.
787            let root_node = &new_tree.nodes[root_idx];
788            let root_bp = root_node.box_props.unpack();
789            unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0054u32); }
790
791            let is_root_with_margin = root_node.parent.is_none()
792                && (root_bp.margin.left != 0.0 || root_bp.margin.top != 0.0);
793
794            let adjusted_cb_pos = if is_root_with_margin {
795                LogicalPosition::new(
796                    cb_pos.x + root_bp.margin.left,
797                    cb_pos.y + root_bp.margin.top,
798                )
799            } else {
800                cb_pos
801            };
802            unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0056u32); }
803
804            // DEBUG: Log containing block info for this root
805            if let Some(debug_msgs) = ctx.debug_messages.as_mut() {
806                let dom_name = root_node
807                    .dom_node_id
808                    .and_then(|id| new_dom.node_data.as_container().internal.get(id.index()))
809                    .map(|n| format!("{:?}", n.node_type))
810                    .unwrap_or_else(|| "Unknown".to_string());
811
812                debug_msgs.push(LayoutDebugMessage::new(
813                    LayoutDebugMessageType::PositionCalculation,
814                    format!(
815                        "[LAYOUT ROOT {}] {} - CB pos=({:.2}, {:.2}), adjusted=({:.2}, {:.2}), \
816                         CB size=({:.2}x{:.2}), viewport=({:.2}x{:.2}), margin=({:.2}, {:.2})",
817                        root_idx,
818                        dom_name,
819                        cb_pos.x,
820                        cb_pos.y,
821                        adjusted_cb_pos.x,
822                        adjusted_cb_pos.y,
823                        cb_size.width,
824                        cb_size.height,
825                        viewport.size.width,
826                        viewport.size.height,
827                        root_bp.margin.left,
828                        root_bp.margin.top
829                    ),
830                ));
831            }
832
833            // Purge after intrinsic sizing — frees child_intrinsics Vecs,
834            // IntrinsicSizeCalculator temporaries, text measurement caches.
835            crate::probe::hint_purge_allocator();
836            crate::probe::sample_peak_rss("rss:before_root_layout");
837            crate::probe::reset_peak();
838            // M12.7 diag: 0x55 = about to call calculate_layout_for_subtree (got CB);
839            // 0x57 = it RETURNED. If step stays 0x55, calculate_layout_for_subtree diverges.
840            unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0055u32); }
841            // M12.7 diag: capture the Result instead of `?`-propagating it. 0x57 = Ok,
842            // 0x5E = Err. Do NOT propagate (continue to the cache store) so layout-real can
843            // see whether the geometry was computed regardless of a (possibly spurious,
844            // niche-Result-mis-discriminated) Err.
845            let _clr = {
846                let _p = crate::probe::Probe::span("root_layout_pass");
847                cache::calculate_layout_for_subtree(
848                    &mut ctx,
849                    &mut new_tree,
850                    text_cache,
851                    root_idx,
852                    adjusted_cb_pos,
853                    cb_size,
854                    &mut calculated_positions,
855                    &mut reflow_needed_for_scrollbars,
856                    &mut cache.float_cache,
857                    cache::ComputeMode::PerformLayout,
858                )
859            };
860            unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, if _clr.is_ok() { 0xDD00_0057u32 } else { 0xDD00_005Eu32 }); }
861            crate::probe::sample_peak_rss("rss:after_root_layout");
862            crate::probe::sample_phase_peak("rss:peak_during_root_layout");
863
864            // CRITICAL: Insert the root node's own position into calculated_positions
865            // This is necessary because calculate_layout_for_subtree only inserts
866            // positions for children, not for the root itself.
867            //
868            // For root nodes, the position should be at (margin.left, margin.top) relative
869            // to the viewport origin, because the margin creates space between the viewport
870            // edge and the element's border-box.
871            if !pos_contains(&calculated_positions, root_idx) {
872                let root_node = &new_tree.nodes[root_idx];
873                let root_bp2 = root_node.box_props.unpack();
874
875                // Calculate the root's border-box position by adding margins to viewport origin
876                // This is different from non-root nodes which inherit their position from
877                // their containing block.
878                let root_position = LogicalPosition::new(
879                    cb_pos.x + root_bp2.margin.left,
880                    cb_pos.y + root_bp2.margin.top,
881                );
882
883                // DEBUG: Log root positioning
884                if let Some(debug_msgs) = ctx.debug_messages.as_mut() {
885                    let dom_name = root_node
886                        .dom_node_id
887                        .and_then(|id| new_dom.node_data.as_container().internal.get(id.index()))
888                        .map(|n| format!("{:?}", n.node_type))
889                        .unwrap_or_else(|| "Unknown".to_string());
890
891                    debug_msgs.push(LayoutDebugMessage::new(
892                        LayoutDebugMessageType::PositionCalculation,
893                        format!(
894                            "[ROOT POSITION {}] {} - Inserting position=({:.2}, {:.2}) (viewport origin + margin), \
895                             margin=({:.2}, {:.2}, {:.2}, {:.2})",
896                            root_idx,
897                            dom_name,
898                            root_position.x,
899                            root_position.y,
900                            root_bp2.margin.top,
901                            root_bp2.margin.right,
902                            root_bp2.margin.bottom,
903                            root_bp2.margin.left
904                        ),
905                    ));
906                }
907
908                pos_set(&mut calculated_positions, root_idx, root_position);
909            }
910        }
911        // M12.7 diag: the per-root layout pass (calculate_layout_for_subtree) finished
912        // (step 6). If step stays 5, the divergence is in calculate_layout_for_subtree.
913        unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0006u32); }
914
915        {
916            let _p = crate::probe::Probe::span("reposition_clean_subtrees");
917            cache::reposition_clean_subtrees(
918                &new_dom,
919                &new_tree,
920                &recon_result.layout_roots,
921                &mut calculated_positions,
922            );
923        }
924
925        if reflow_needed_for_scrollbars {
926            debug_log!(ctx,
927                "Scrollbars changed container size, starting full reflow (loop {})",
928                loop_count
929            );
930            recon_result.layout_roots.clear();
931            recon_result.layout_roots.insert(new_tree.root);
932            recon_result.intrinsic_dirty = (0..new_tree.nodes.len()).collect();
933            continue;
934        }
935
936        break;
937    }
938
939    // +spec:positioning:8d1286 - normal flow, relative, float, absolute positioning dispatch
940    // +spec:positioning:bdfc81 - Layout divided into sizing (Step 2) then positioning (Step 3)
941    // --- Step 3: Adjust Relatively Positioned Elements ---
942    // +spec:positioning:a831e8 - inline content width uses pre-relative-offset positions (satisfied by post-layout relative adjustment)
943    // +spec:positioning:e2647b - Relative positioning applied after line height calculation, so line height is not adjusted for relative offsets
944    // +spec:positioning:77a2d2 - Relatively positioned boxes considered without their offset during auto height
945    // +spec:positioning:b47ac2 - Relatively positioned boxes considered without their offset for block auto height
946    // Relative offsets applied AFTER layout, so auto-height calculation sees normal-flow positions.
947    // This must be done BEFORE positioning out-of-flow elements, because
948    // relatively positioned elements establish containing blocks for their
949    // absolutely positioned descendants. If we adjust relative positions after
950    // positioning absolute elements, the absolute elements will be positioned
951    // relative to the wrong (pre-adjustment) position of their containing block.
952    // Pass the viewport to correctly resolve percentage offsets for the root element.
953    {
954        let _p = crate::probe::Probe::span("adjust_relative_positions");
955        positioning::adjust_relative_positions(
956            &mut ctx,
957            &new_tree,
958            &mut calculated_positions,
959            viewport,
960        )?;
961    }
962
963    // --- Step 3.25: Adjust Sticky Positioned Elements ---
964    // Sticky elements are laid out in normal flow, then their visual position
965    // is clamped based on scroll offset and inset properties relative to the
966    // nearest scrollport. Must happen after relative positioning but before
967    // absolute positioning (sticky elements establish containing blocks).
968    {
969        let _p = crate::probe::Probe::span("adjust_sticky_positions");
970        positioning::adjust_sticky_positions(
971            &mut ctx,
972            &new_tree,
973            &mut calculated_positions,
974            scroll_offsets,
975            viewport,
976        )?;
977    }
978
979    // --- Step 3.5: Position Out-of-Flow Elements ---
980    // This must be done AFTER adjusting relative positions, so that absolutely
981    // positioned elements are positioned relative to the final (post-adjustment)
982    // position of their relatively positioned containing blocks.
983    {
984        let _p = crate::probe::Probe::span("position_out_of_flow");
985        positioning::position_out_of_flow_elements(
986            &mut ctx,
987            &mut new_tree,
988            &mut calculated_positions,
989            viewport,
990        )?;
991    }
992
993    // --- Step 3.75: Compute Stable Scroll IDs ---
994    // This must be done AFTER layout but BEFORE display list generation
995    use crate::window::LayoutWindow;
996    let (scroll_ids, scroll_id_to_node_id) = {
997        let _p = crate::probe::Probe::span("compute_scroll_ids");
998        LayoutWindow::compute_scroll_ids(&new_tree, &new_dom)
999    };
1000
1001    crate::probe::sample_peak_rss("rss:before_display_list");
1002    crate::probe::reset_peak();
1003    // --- Step 4: Generate Display List & Update Cache ---
1004    let display_list = if SKIP_DISPLAY_LIST.load(core::sync::atomic::Ordering::Relaxed) {
1005        // Web backend: positions are done; the painter is dead weight.
1006        DisplayList::default()
1007    } else {
1008        let _p = crate::probe::Probe::span("generate_display_list");
1009        generate_display_list(
1010            &mut ctx,
1011            &new_tree,
1012            &calculated_positions,
1013            scroll_offsets,
1014            &scroll_ids,
1015            gpu_value_cache,
1016            renderer_resources,
1017            id_namespace,
1018            dom_id,
1019        )?
1020    };
1021    crate::probe::sample_phase_peak("rss:peak_during_display_list");
1022
1023    // Move cache_map back into LayoutCache before dropping ctx
1024    let _p_writeback = crate::probe::Probe::span("cache_writeback");
1025    let cache_map_back = std::mem::take(&mut ctx.cache_map);
1026
1027    // Cache the freshly-generated display list keyed on the root's
1028    // subtree_hash + viewport. If the next `layout_document` call
1029    // sees matching values after reconcile, it returns this clone
1030    // directly and skips all downstream work.
1031    let root_subtree_hash = new_tree
1032        .cold(new_tree.root)
1033        .map(|c| c.subtree_hash)
1034        .unwrap_or(crate::solver3::layout_tree::SubtreeHash(0));
1035    cache.cached_display_list = Some((root_subtree_hash, viewport, display_list.clone()));
1036
1037    cache.tree = Some(new_tree);
1038    cache.previous_positions = std::mem::replace(&mut cache.calculated_positions, calculated_positions);
1039    cache.viewport = Some(viewport);
1040    cache.scroll_ids = scroll_ids;
1041    cache.scroll_id_to_node_id = scroll_id_to_node_id;
1042    // M12.7 diag: layout_document reached the cache store (tree + positions). 0xDD00_0004
1043    // + calculated_positions.len in the low bits. If step stays 3, it diverged earlier.
1044    unsafe { core::ptr::write_volatile(0x400A4 as *mut u32, 0xDD00_0004u32 | ((cache.calculated_positions.len() as u32 & 0xfff) << 4)); }
1045    cache.counters = counter_values;
1046    cache.cache_map = cache_map_back;
1047    crate::probe::sample_peak_rss("rss:after_layout_document");
1048
1049    Ok(display_list)
1050}
1051
1052// +spec:containing-block:159830 - Containing block chain: parent content-box for in-flow, viewport for initial containing block
1053// +spec:containing-block:22fbaa - computes the element's original containing block (before positioning effects)
1054// +spec:containing-block:238fc5 - containing block dimensions calculated here (CSS 2.2 §9.1.2 forward ref to §10)
1055// +spec:containing-block:263629 - block element's content-box establishes the containing block for its line boxes
1056// +spec:containing-block:2a5280 - boxes act as containing blocks for descendants; CB = parent's content box
1057// +spec:containing-block:6776cb - boxes positioned w.r.t. containing block but not confined; overflow allowed
1058// +spec:containing-block:718894 - CB derived from parent content-box edges; root uses initial CB (viewport)
1059// +spec:containing-block:a2aa37 - box edges act as containing block for descendants; initial containing block = viewport
1060// +spec:containing-block:e23b3f - CSS 2.2 §10.1: initial containing block = viewport; static/relative = parent content-box; fixed = viewport
1061// +spec:containing-block:e8fdb2 - Containing block resolution (CSS2 §9.1.2, §10.1)
1062// +spec:overflow:9a2b11 - containing block is content-box of parent; boxes may overflow it
1063// +spec:positioning:acc663 - containing block definition: element boxes positioned relative to containing block
1064fn get_containing_block_for_node(
1065    tree: &LayoutTree,
1066    styled_dom: &StyledDom,
1067    node_idx: usize,
1068    calculated_positions: &PositionVec,
1069    viewport: LogicalRect,
1070) -> (LogicalPosition, LogicalSize) {
1071    if let Some(parent_idx) = tree.get(node_idx).and_then(|n| n.parent) {
1072        if let Some(parent_node) = tree.get(parent_idx) {
1073            let pos = calculated_positions
1074                .get(parent_idx)
1075                .copied()
1076                .unwrap_or_default();
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                    .cloned()
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#[derive(Debug)]
1125pub enum LayoutError {
1126    InvalidTree,
1127    SizingFailed,
1128    PositioningFailed,
1129    DisplayListFailed,
1130    Text(crate::font_traits::LayoutError),
1131}
1132
1133impl std::fmt::Display for LayoutError {
1134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1135        match self {
1136            LayoutError::InvalidTree => write!(f, "Invalid layout tree"),
1137            LayoutError::SizingFailed => write!(f, "Sizing calculation failed"),
1138            LayoutError::PositioningFailed => write!(f, "Position calculation failed"),
1139            LayoutError::DisplayListFailed => write!(f, "Display list generation failed"),
1140            LayoutError::Text(e) => write!(f, "Text layout error: {:?}", e),
1141        }
1142    }
1143}
1144
1145impl From<crate::font_traits::LayoutError> for LayoutError {
1146    fn from(err: crate::font_traits::LayoutError) -> Self {
1147        LayoutError::Text(err)
1148    }
1149}
1150
1151impl std::error::Error for LayoutError {}
1152
1153pub type Result<T> = std::result::Result<T, LayoutError>;