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>;
1162
1163#[cfg(test)]
1164#[allow(clippy::float_cmp, clippy::too_many_lines)]
1165mod autotest_generated {
1166    use azul_core::dom::{Dom, FormattingContext};
1167
1168    use super::*;
1169    use crate::solver3::{
1170        geometry::{EdgeSizes, PackedBoxProps, ResolvedBoxProps},
1171        layout_tree::{LayoutNodeCold, LayoutNodeHot, LayoutNodeWarm},
1172    };
1173
1174    // ==================================================================
1175    // Fixtures
1176    // ==================================================================
1177
1178    fn pos(x: f32, y: f32) -> LogicalPosition {
1179        LogicalPosition::new(x, y)
1180    }
1181
1182    fn size(w: f32, h: f32) -> LogicalSize {
1183        LogicalSize::new(w, h)
1184    }
1185
1186    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
1187        LogicalRect {
1188            origin: pos(x, y),
1189            size: size(w, h),
1190        }
1191    }
1192
1193    fn edges(top: f32, right: f32, bottom: f32, left: f32) -> EdgeSizes {
1194        EdgeSizes {
1195            top,
1196            right,
1197            bottom,
1198            left,
1199        }
1200    }
1201
1202    /// `ResolvedBoxProps` with the given padding + border and no margin.
1203    fn bp(padding: EdgeSizes, border: EdgeSizes) -> ResolvedBoxProps {
1204        ResolvedBoxProps {
1205            margin: EdgeSizes::default(),
1206            padding,
1207            border,
1208            ..ResolvedBoxProps::default()
1209        }
1210    }
1211
1212    fn hot(
1213        parent: Option<usize>,
1214        dom_node_id: Option<NodeId>,
1215        used_size: Option<LogicalSize>,
1216        props: &ResolvedBoxProps,
1217    ) -> LayoutNodeHot {
1218        LayoutNodeHot {
1219            box_props: PackedBoxProps::pack(props),
1220            dom_node_id,
1221            used_size,
1222            formatting_context: FormattingContext::default(),
1223            parent,
1224        }
1225    }
1226
1227    /// A `LayoutTree` carrying only what `get_containing_block_for_node` reads:
1228    /// hot nodes (parent link, box props, `used_size`, `dom_node_id`).
1229    fn tree_of(nodes: Vec<LayoutNodeHot>) -> LayoutTree {
1230        let n = nodes.len();
1231        LayoutTree {
1232            nodes,
1233            warm: vec![LayoutNodeWarm::default(); n],
1234            cold: vec![LayoutNodeCold::default(); n],
1235            root: 0,
1236            dom_to_layout: BTreeMap::new(),
1237            children_arena: Vec::new(),
1238            children_offsets: vec![(0, 0); n],
1239            subtree_needs_intrinsic: vec![false; n],
1240        }
1241    }
1242
1243    /// Box props survive a lossy i16×10 pack/unpack, so geometry derived from
1244    /// them is compared with a tolerance well under a tenth of a pixel.
1245    fn close(a: f32, b: f32) -> bool {
1246        (a - b).abs() < 1e-3
1247    }
1248
1249    /// `body` — one real DOM node, so `NodeId::ZERO` is always in range.
1250    fn body_dom() -> StyledDom {
1251        let mut dom = Dom::create_body();
1252        let (css, _warnings) = azul_css::parser2::new_from_str("");
1253        StyledDom::create(&mut dom, css)
1254    }
1255
1256    // ==================================================================
1257    // POSITION_UNSET — the sentinel the three pos_* helpers are built on
1258    // ==================================================================
1259
1260    #[test]
1261    fn position_unset_sets_both_components_to_f32_min() {
1262        // `pos_get`/`pos_contains` only test `x`; that shortcut is only sound
1263        // while the sentinel writes BOTH components. Pin the invariant here.
1264        assert_eq!(POSITION_UNSET.x, f32::MIN);
1265        assert_eq!(POSITION_UNSET.y, f32::MIN);
1266    }
1267
1268    // ==================================================================
1269    // pos_get / pos_contains — bounds + sentinel (numeric)
1270    // ==================================================================
1271
1272    #[test]
1273    fn pos_get_on_an_empty_vec_is_none_for_every_index() {
1274        let positions: PositionVec = Vec::new();
1275        for idx in [0usize, 1, 7, 1_000, usize::MAX / 2, usize::MAX] {
1276            assert!(pos_get(&positions, idx).is_none(), "idx {idx}");
1277            assert!(!pos_contains(&positions, idx), "idx {idx}");
1278        }
1279    }
1280
1281    #[test]
1282    fn pos_get_past_the_end_is_none_and_never_panics() {
1283        let mut positions: PositionVec = Vec::new();
1284        pos_set(&mut positions, 3, pos(1.0, 2.0));
1285        assert_eq!(positions.len(), 4);
1286
1287        for idx in [4usize, 5, 100, usize::MAX] {
1288            assert!(pos_get(&positions, idx).is_none(), "idx {idx}");
1289            assert!(!pos_contains(&positions, idx), "idx {idx}");
1290        }
1291    }
1292
1293    #[test]
1294    fn pos_get_at_zero_round_trips() {
1295        let mut positions: PositionVec = Vec::new();
1296        pos_set(&mut positions, 0, pos(0.0, 0.0));
1297
1298        let got = pos_get(&positions, 0).expect("index 0 was set");
1299        assert_eq!(got.x, 0.0);
1300        assert_eq!(got.y, 0.0);
1301        assert!(pos_contains(&positions, 0));
1302    }
1303
1304    #[test]
1305    fn an_explicitly_written_sentinel_reads_back_as_unset() {
1306        // Writing POSITION_UNSET is indistinguishable from never writing at all.
1307        // That is by design (it's how `pos_set`'s gap-fill works), but it means a
1308        // caller can never store the sentinel as a real position.
1309        let mut positions: PositionVec = Vec::new();
1310        pos_set(&mut positions, 0, POSITION_UNSET);
1311
1312        assert_eq!(positions.len(), 1);
1313        assert!(pos_get(&positions, 0).is_none());
1314        assert!(!pos_contains(&positions, 0));
1315    }
1316
1317    #[test]
1318    fn a_position_whose_x_is_f32_min_reads_back_as_unset_even_with_a_real_y() {
1319        // Only `x` is compared against the sentinel, so `y` is silently discarded
1320        // whenever `x` happens to land exactly on f32::MIN.
1321        let mut positions: PositionVec = Vec::new();
1322        pos_set(&mut positions, 0, pos(f32::MIN, 42.0));
1323
1324        assert!(pos_get(&positions, 0).is_none());
1325        assert!(!pos_contains(&positions, 0));
1326        // ...even though the value really is in the vec.
1327        assert_eq!(positions[0].y, 42.0);
1328    }
1329
1330    #[test]
1331    fn negative_f32_max_is_the_same_bit_pattern_as_the_sentinel() {
1332        // f32::MIN == -f32::MAX, so a genuinely computed x of -f32::MAX (e.g. a
1333        // wildly out-of-flow element) is swallowed by the sentinel check.
1334        assert_eq!(f32::MIN, -f32::MAX);
1335
1336        let mut positions: PositionVec = Vec::new();
1337        pos_set(&mut positions, 0, pos(-f32::MAX, 0.0));
1338        assert!(pos_get(&positions, 0).is_none());
1339    }
1340
1341    #[test]
1342    fn a_position_whose_y_is_f32_min_is_still_reported_as_set() {
1343        let mut positions: PositionVec = Vec::new();
1344        pos_set(&mut positions, 0, pos(0.0, f32::MIN));
1345
1346        let got = pos_get(&positions, 0).expect("x is not the sentinel, so it is set");
1347        assert_eq!(got.x, 0.0);
1348        assert_eq!(got.y, f32::MIN);
1349        assert!(pos_contains(&positions, 0));
1350    }
1351
1352    #[test]
1353    fn nan_positions_are_considered_set_and_survive_the_round_trip() {
1354        // NaN != f32::MIN is true, so a NaN position is "set" — it propagates into
1355        // layout rather than being filtered out as unset.
1356        let mut positions: PositionVec = Vec::new();
1357        pos_set(&mut positions, 0, pos(f32::NAN, f32::NAN));
1358
1359        let got = pos_get(&positions, 0).expect("NaN passes the sentinel filter");
1360        assert!(got.x.is_nan());
1361        assert!(got.y.is_nan());
1362        assert!(pos_contains(&positions, 0));
1363    }
1364
1365    #[test]
1366    fn infinite_and_extreme_positions_round_trip_unchanged() {
1367        let cases = [
1368            pos(f32::INFINITY, f32::NEG_INFINITY),
1369            pos(f32::MAX, -f32::MIN_POSITIVE),
1370            pos(-0.0, 0.0),
1371            pos(-1e30, 1e30),
1372            pos(f32::MIN_POSITIVE, f32::EPSILON),
1373        ];
1374        for (idx, case) in cases.iter().enumerate() {
1375            let mut positions: PositionVec = Vec::new();
1376            pos_set(&mut positions, idx, *case);
1377
1378            let got = pos_get(&positions, idx).expect("non-sentinel x is set");
1379            assert_eq!(got.x.to_bits(), case.x.to_bits(), "case {idx} x");
1380            assert_eq!(got.y.to_bits(), case.y.to_bits(), "case {idx} y");
1381            assert!(pos_contains(&positions, idx), "case {idx}");
1382        }
1383    }
1384
1385    #[test]
1386    fn pos_contains_always_agrees_with_pos_get() {
1387        // The predicate and the getter must never disagree — a divergence would
1388        // make `pos_get(..).unwrap()` guarded by `pos_contains` panic.
1389        let values = [
1390            pos(0.0, 0.0),
1391            pos(-0.0, -0.0),
1392            POSITION_UNSET,
1393            pos(f32::MIN, 1.0),
1394            pos(1.0, f32::MIN),
1395            pos(f32::NAN, 0.0),
1396            pos(f32::INFINITY, f32::INFINITY),
1397            pos(f32::NEG_INFINITY, 0.0),
1398            pos(f32::MAX, f32::MIN_POSITIVE),
1399            pos(-f32::MAX, 0.0),
1400        ];
1401        let mut positions: PositionVec = Vec::new();
1402        for (idx, v) in values.iter().enumerate() {
1403            pos_set(&mut positions, idx, *v);
1404        }
1405        for idx in 0..values.len() + 4 {
1406            assert_eq!(
1407                pos_contains(&positions, idx),
1408                pos_get(&positions, idx).is_some(),
1409                "idx {idx} disagrees"
1410            );
1411        }
1412    }
1413
1414    // ==================================================================
1415    // pos_set — growth semantics (numeric)
1416    // ==================================================================
1417
1418    #[test]
1419    fn pos_set_beyond_the_end_grows_and_fills_the_gap_with_the_sentinel() {
1420        let mut positions: PositionVec = Vec::new();
1421        pos_set(&mut positions, 3, pos(10.0, 20.0));
1422
1423        assert_eq!(positions.len(), 4, "grows to exactly idx + 1");
1424        for idx in 0..3 {
1425            assert!(pos_get(&positions, idx).is_none(), "gap idx {idx} must be unset");
1426            assert!(!pos_contains(&positions, idx), "gap idx {idx}");
1427            assert_eq!(positions[idx].x, POSITION_UNSET.x);
1428            assert_eq!(positions[idx].y, POSITION_UNSET.y);
1429        }
1430        let got = pos_get(&positions, 3).expect("idx 3 was set");
1431        assert_eq!(got.x, 10.0);
1432        assert_eq!(got.y, 20.0);
1433    }
1434
1435    #[test]
1436    fn pos_set_inside_the_vec_neither_grows_nor_shrinks_it() {
1437        let mut positions: PositionVec = vec![POSITION_UNSET; 5];
1438        pos_set(&mut positions, 0, pos(1.0, 1.0));
1439        assert_eq!(positions.len(), 5);
1440
1441        pos_set(&mut positions, 4, pos(2.0, 2.0));
1442        assert_eq!(positions.len(), 5);
1443        assert!(pos_contains(&positions, 0));
1444        assert!(pos_contains(&positions, 4));
1445        assert!(!pos_contains(&positions, 2), "untouched slots stay unset");
1446    }
1447
1448    #[test]
1449    fn pos_set_overwrites_an_existing_entry_in_place() {
1450        let mut positions: PositionVec = Vec::new();
1451        pos_set(&mut positions, 1, pos(1.0, 1.0));
1452        pos_set(&mut positions, 1, pos(-5.5, -6.5));
1453
1454        assert_eq!(positions.len(), 2);
1455        let got = pos_get(&positions, 1).expect("still set");
1456        assert_eq!(got.x, -5.5);
1457        assert_eq!(got.y, -6.5);
1458    }
1459
1460    #[test]
1461    fn pos_set_can_reset_an_entry_back_to_unset() {
1462        let mut positions: PositionVec = Vec::new();
1463        pos_set(&mut positions, 0, pos(3.0, 4.0));
1464        assert!(pos_contains(&positions, 0));
1465
1466        pos_set(&mut positions, 0, POSITION_UNSET);
1467        assert!(!pos_contains(&positions, 0));
1468        assert!(pos_get(&positions, 0).is_none());
1469    }
1470
1471    #[test]
1472    fn repeated_growth_preserves_every_earlier_entry() {
1473        let mut positions: PositionVec = Vec::new();
1474        for idx in (0..64).rev() {
1475            // Descending order: the first call allocates the whole vec, the rest
1476            // write inside it — the reverse of the ascending growth path.
1477            pos_set(&mut positions, idx, pos(idx as f32, -(idx as f32)));
1478        }
1479        assert_eq!(positions.len(), 64);
1480        for idx in 0..64 {
1481            let got = pos_get(&positions, idx).expect("all 64 were written");
1482            assert_eq!(got.x, idx as f32);
1483            assert_eq!(got.y, -(idx as f32));
1484        }
1485
1486        // A single jump far past the end must keep everything already written.
1487        pos_set(&mut positions, 4_095, pos(1.0, 1.0));
1488        assert_eq!(positions.len(), 4_096);
1489        for idx in 0..64 {
1490            assert!(pos_contains(&positions, idx), "idx {idx} lost after regrow");
1491        }
1492        for idx in 64..4_095 {
1493            assert!(!pos_contains(&positions, idx), "new slot {idx} must be unset");
1494        }
1495        assert!(pos_contains(&positions, 4_095));
1496    }
1497
1498    // ==================================================================
1499    // MAX_SCROLLBAR_REFLOW_ITERATIONS — the anti-infinite-loop bound
1500    // ==================================================================
1501
1502    #[test]
1503    fn the_scrollbar_reflow_bound_is_a_usable_positive_limit() {
1504        // `loop_count > MAX` is the only thing standing between a pathological
1505        // scrollbar oscillation and a hung frame. 0 would mean "never lay out".
1506        const _: () = assert!(
1507            MAX_SCROLLBAR_REFLOW_ITERATIONS >= 1 && MAX_SCROLLBAR_REFLOW_ITERATIONS <= 64,
1508            "the scrollbar reflow bound must be a usable positive limit; an absurd bound = a hung frame"
1509        );
1510    }
1511
1512    // ==================================================================
1513    // get_containing_block_for_node (numeric, private)
1514    // ==================================================================
1515
1516    #[test]
1517    fn a_root_node_gets_the_viewport_as_its_containing_block() {
1518        let dom = body_dom();
1519        let tree = tree_of(vec![hot(None, Some(NodeId::ZERO), Some(size(10.0, 10.0)), &ResolvedBoxProps::default())]);
1520        let viewport = rect(7.0, 9.0, 800.0, 600.0);
1521
1522        let (cb_pos, cb_size) =
1523            get_containing_block_for_node(&tree, &dom, 0, &Vec::new(), viewport);
1524
1525        assert_eq!(cb_pos.x, 7.0);
1526        assert_eq!(cb_pos.y, 9.0);
1527        assert_eq!(cb_size.width, 800.0);
1528        assert_eq!(cb_size.height, 600.0);
1529    }
1530
1531    #[test]
1532    fn an_out_of_range_node_index_falls_back_to_the_viewport() {
1533        let dom = body_dom();
1534        let tree = tree_of(vec![hot(None, None, Some(size(10.0, 10.0)), &ResolvedBoxProps::default())]);
1535        let viewport = rect(0.0, 0.0, 800.0, 600.0);
1536
1537        for idx in [1usize, 99, usize::MAX] {
1538            let (cb_pos, cb_size) =
1539                get_containing_block_for_node(&tree, &dom, idx, &Vec::new(), viewport);
1540            assert_eq!(cb_pos.x, 0.0, "idx {idx}");
1541            assert_eq!(cb_size.width, 800.0, "idx {idx}");
1542            assert_eq!(cb_size.height, 600.0, "idx {idx}");
1543        }
1544    }
1545
1546    #[test]
1547    fn a_dangling_parent_index_falls_back_to_the_viewport() {
1548        // Node 1 claims parent 999, which does not exist. The function must take
1549        // the viewport branch rather than index out of bounds.
1550        let dom = body_dom();
1551        let tree = tree_of(vec![
1552            hot(None, None, Some(size(10.0, 10.0)), &ResolvedBoxProps::default()),
1553            hot(Some(999), None, Some(size(10.0, 10.0)), &ResolvedBoxProps::default()),
1554        ]);
1555        let viewport = rect(1.0, 2.0, 300.0, 400.0);
1556
1557        let (cb_pos, cb_size) =
1558            get_containing_block_for_node(&tree, &dom, 1, &Vec::new(), viewport);
1559        assert_eq!(cb_pos.x, 1.0);
1560        assert_eq!(cb_pos.y, 2.0);
1561        assert_eq!(cb_size.width, 300.0);
1562        assert_eq!(cb_size.height, 400.0);
1563    }
1564
1565    #[test]
1566    fn a_dom_backed_parent_shrinks_the_containing_block_by_border_and_padding() {
1567        let dom = body_dom();
1568        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), edges(5.0, 5.0, 5.0, 5.0));
1569        let tree = tree_of(vec![
1570            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
1571            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1572        ]);
1573        let mut positions: PositionVec = Vec::new();
1574        pos_set(&mut positions, 0, pos(30.0, 40.0));
1575
1576        let (cb_pos, cb_size) = get_containing_block_for_node(
1577            &tree,
1578            &dom,
1579            1,
1580            &positions,
1581            rect(0.0, 0.0, 800.0, 600.0),
1582        );
1583
1584        // content origin = margin-box pos + border + padding
1585        assert!(close(cb_pos.x, 45.0), "x was {}", cb_pos.x);
1586        assert!(close(cb_pos.y, 55.0), "y was {}", cb_pos.y);
1587        // content size = border-box - (border + padding) on both sides
1588        assert!(close(cb_size.width, 170.0), "width was {}", cb_size.width);
1589        assert!(close(cb_size.height, 70.0), "height was {}", cb_size.height);
1590    }
1591
1592    #[test]
1593    fn an_anonymous_parent_offsets_the_origin_but_keeps_the_border_box_size() {
1594        // The `dom_node_id == None` arm returns `used_size` verbatim — it shifts the
1595        // origin inward by border+padding but does NOT shrink the size, unlike the
1596        // DOM-backed arm above. Asserted as-is so any future fix trips this test.
1597        let dom = body_dom();
1598        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), edges(5.0, 5.0, 5.0, 5.0));
1599        let tree = tree_of(vec![
1600            hot(None, None, Some(size(200.0, 100.0)), &parent_props),
1601            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1602        ]);
1603        let mut positions: PositionVec = Vec::new();
1604        pos_set(&mut positions, 0, pos(0.0, 0.0));
1605
1606        let (cb_pos, cb_size) = get_containing_block_for_node(
1607            &tree,
1608            &dom,
1609            1,
1610            &positions,
1611            rect(0.0, 0.0, 800.0, 600.0),
1612        );
1613
1614        assert!(close(cb_pos.x, 15.0), "x was {}", cb_pos.x);
1615        assert!(close(cb_pos.y, 15.0), "y was {}", cb_pos.y);
1616        assert_eq!(cb_size.width, 200.0, "anonymous arm does not subtract padding/border");
1617        assert_eq!(cb_size.height, 100.0);
1618    }
1619
1620    #[test]
1621    fn an_unpositioned_parent_falls_back_to_the_viewport_origin() {
1622        let dom = body_dom();
1623        let parent_props = bp(edges(1.0, 0.0, 0.0, 2.0), edges(3.0, 0.0, 0.0, 4.0));
1624        let tree = tree_of(vec![
1625            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
1626            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1627        ]);
1628        let viewport = rect(100.0, 200.0, 800.0, 600.0);
1629
1630        // `calculated_positions` is empty: the parent has no computed position yet.
1631        let (cb_pos, _) = get_containing_block_for_node(&tree, &dom, 1, &Vec::new(), viewport);
1632
1633        // viewport origin + border.left + padding.left, and the same on the y axis.
1634        assert!(close(cb_pos.x, 106.0), "x was {}", cb_pos.x);
1635        assert!(close(cb_pos.y, 204.0), "y was {}", cb_pos.y);
1636    }
1637
1638    #[test]
1639    fn a_parent_with_a_sentinel_position_is_treated_as_unpositioned() {
1640        // pos_get filters the sentinel, so a parent whose stored x is f32::MIN
1641        // resolves against the viewport origin instead.
1642        let dom = body_dom();
1643        let tree = tree_of(vec![
1644            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &ResolvedBoxProps::default()),
1645            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1646        ]);
1647        let mut positions: PositionVec = Vec::new();
1648        pos_set(&mut positions, 0, POSITION_UNSET);
1649
1650        let (cb_pos, _) = get_containing_block_for_node(
1651            &tree,
1652            &dom,
1653            1,
1654            &positions,
1655            rect(11.0, 13.0, 800.0, 600.0),
1656        );
1657        assert_eq!(cb_pos.x, 11.0);
1658        assert_eq!(cb_pos.y, 13.0);
1659    }
1660
1661    #[test]
1662    fn a_parent_without_a_used_size_yields_a_zero_sized_containing_block() {
1663        let dom = body_dom();
1664        let tree = tree_of(vec![
1665            hot(None, Some(NodeId::ZERO), None, &ResolvedBoxProps::default()),
1666            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1667        ]);
1668        let mut positions: PositionVec = Vec::new();
1669        pos_set(&mut positions, 0, pos(0.0, 0.0));
1670
1671        let (_, cb_size) = get_containing_block_for_node(
1672            &tree,
1673            &dom,
1674            1,
1675            &positions,
1676            rect(0.0, 0.0, 800.0, 600.0),
1677        );
1678        assert_eq!(cb_size.width, 0.0);
1679        assert_eq!(cb_size.height, 0.0);
1680    }
1681
1682    #[test]
1683    fn huge_parent_padding_saturates_instead_of_wrapping_the_origin() {
1684        // PackedBoxProps stores edges as i16 tenths-of-a-pixel: 1e30px clamps to
1685        // +3276.7px. Wrapping would push the containing block's origin NEGATIVE.
1686        let dom = body_dom();
1687        let parent_props = bp(edges(1e30, 1e30, 1e30, 1e30), edges(1e30, 1e30, 1e30, 1e30));
1688        let tree = tree_of(vec![
1689            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
1690            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1691        ]);
1692        let mut positions: PositionVec = Vec::new();
1693        pos_set(&mut positions, 0, pos(0.0, 0.0));
1694
1695        let (cb_pos, cb_size) = get_containing_block_for_node(
1696            &tree,
1697            &dom,
1698            1,
1699            &positions,
1700            rect(0.0, 0.0, 800.0, 600.0),
1701        );
1702
1703        assert!(cb_pos.x.is_finite() && cb_pos.y.is_finite());
1704        assert!(cb_pos.x > 0.0, "saturated padding must stay positive, got {}", cb_pos.x);
1705        assert!(cb_pos.x <= 2.0 * 3276.7 + 1.0, "clamped to the i16 ×10 range");
1706        // border + padding dwarf the border-box, so the content box floors at zero.
1707        assert_eq!(cb_size.width, 0.0);
1708        assert_eq!(cb_size.height, 0.0);
1709    }
1710
1711    #[test]
1712    fn a_nan_parent_position_propagates_but_does_not_panic_or_corrupt_the_size() {
1713        let dom = body_dom();
1714        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), EdgeSizes::default());
1715        let tree = tree_of(vec![
1716            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
1717            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1718        ]);
1719        let mut positions: PositionVec = Vec::new();
1720        pos_set(&mut positions, 0, pos(f32::NAN, f32::NAN));
1721
1722        let (cb_pos, cb_size) = get_containing_block_for_node(
1723            &tree,
1724            &dom,
1725            1,
1726            &positions,
1727            rect(0.0, 0.0, 800.0, 600.0),
1728        );
1729
1730        assert!(cb_pos.x.is_nan() && cb_pos.y.is_nan(), "NaN flows through unchanged");
1731        // The size path never touches the position, so it must stay clean.
1732        assert!(close(cb_size.width, 180.0), "width was {}", cb_size.width);
1733        assert!(close(cb_size.height, 80.0), "height was {}", cb_size.height);
1734    }
1735
1736    #[test]
1737    fn a_nan_parent_used_size_floors_the_containing_block_at_zero() {
1738        // inner_size() ends in `.max(0.0)`, which discards NaN — the containing
1739        // block collapses to 0 rather than exporting NaN into sizing.
1740        let dom = body_dom();
1741        let tree = tree_of(vec![
1742            hot(None, Some(NodeId::ZERO), Some(size(f32::NAN, f32::NAN)), &ResolvedBoxProps::default()),
1743            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1744        ]);
1745        let mut positions: PositionVec = Vec::new();
1746        pos_set(&mut positions, 0, pos(0.0, 0.0));
1747
1748        let (_, cb_size) = get_containing_block_for_node(
1749            &tree,
1750            &dom,
1751            1,
1752            &positions,
1753            rect(0.0, 0.0, 800.0, 600.0),
1754        );
1755        assert!(!cb_size.width.is_nan() && !cb_size.height.is_nan());
1756        assert_eq!(cb_size.width, 0.0);
1757        assert_eq!(cb_size.height, 0.0);
1758    }
1759
1760    #[test]
1761    fn an_infinite_parent_used_size_stays_infinite_rather_than_becoming_nan() {
1762        let dom = body_dom();
1763        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), edges(5.0, 5.0, 5.0, 5.0));
1764        let tree = tree_of(vec![
1765            hot(
1766                None,
1767                Some(NodeId::ZERO),
1768                Some(size(f32::INFINITY, f32::INFINITY)),
1769                &parent_props,
1770            ),
1771            hot(Some(0), None, None, &ResolvedBoxProps::default()),
1772        ]);
1773        let mut positions: PositionVec = Vec::new();
1774        pos_set(&mut positions, 0, pos(0.0, 0.0));
1775
1776        let (_, cb_size) = get_containing_block_for_node(
1777            &tree,
1778            &dom,
1779            1,
1780            &positions,
1781            rect(0.0, 0.0, 800.0, 600.0),
1782        );
1783        assert!(cb_size.width.is_infinite() && cb_size.width.is_sign_positive());
1784        assert!(cb_size.height.is_infinite() && cb_size.height.is_sign_positive());
1785    }
1786
1787    #[test]
1788    fn degenerate_viewports_pass_through_the_root_arm_untouched() {
1789        // The root arm is a pure identity on the viewport — it neither clamps
1790        // negatives nor sanitises NaN. Pin that so callers know to pre-validate.
1791        let dom = body_dom();
1792        let tree = tree_of(vec![hot(None, None, None, &ResolvedBoxProps::default())]);
1793
1794        let (p, s) = get_containing_block_for_node(
1795            &tree,
1796            &dom,
1797            0,
1798            &Vec::new(),
1799            rect(0.0, 0.0, 0.0, 0.0),
1800        );
1801        assert_eq!(s.width, 0.0);
1802        assert_eq!(s.height, 0.0);
1803        assert_eq!(p.x, 0.0);
1804
1805        let (_, s) = get_containing_block_for_node(
1806            &tree,
1807            &dom,
1808            0,
1809            &Vec::new(),
1810            rect(0.0, 0.0, -800.0, -600.0),
1811        );
1812        assert_eq!(s.width, -800.0, "negative viewport is not clamped");
1813
1814        let (p, s) = get_containing_block_for_node(
1815            &tree,
1816            &dom,
1817            0,
1818            &Vec::new(),
1819            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
1820        );
1821        assert!(p.x.is_nan() && s.width.is_nan(), "NaN viewport is not sanitised");
1822
1823        let (_, s) = get_containing_block_for_node(
1824            &tree,
1825            &dom,
1826            0,
1827            &Vec::new(),
1828            rect(0.0, 0.0, f32::MAX, f32::MAX),
1829        );
1830        assert_eq!(s.width, f32::MAX);
1831    }
1832
1833    // ==================================================================
1834    // LayoutError — Display (serializer)
1835    // ==================================================================
1836
1837    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
1838    #[test]
1839    fn every_layout_error_variant_renders_a_distinct_non_empty_message() {
1840        let variants = [
1841            LayoutError::InvalidTree,
1842            LayoutError::SizingFailed,
1843            LayoutError::PositioningFailed,
1844            LayoutError::DisplayListFailed,
1845            LayoutError::Text(crate::font_traits::LayoutError::BidiError("boom".to_string())),
1846        ];
1847        let rendered: Vec<String> = variants.iter().map(ToString::to_string).collect();
1848
1849        for msg in &rendered {
1850            assert!(!msg.is_empty(), "empty Display output");
1851            assert!(!msg.trim().is_empty(), "whitespace-only Display output");
1852        }
1853        for i in 0..rendered.len() {
1854            for j in (i + 1)..rendered.len() {
1855                assert_ne!(rendered[i], rendered[j], "variants {i} and {j} render alike");
1856            }
1857        }
1858    }
1859
1860    #[test]
1861    fn layout_error_display_matches_the_documented_wording() {
1862        assert_eq!(LayoutError::InvalidTree.to_string(), "Invalid layout tree");
1863        assert_eq!(LayoutError::SizingFailed.to_string(), "Sizing calculation failed");
1864        assert_eq!(
1865            LayoutError::PositioningFailed.to_string(),
1866            "Position calculation failed"
1867        );
1868        assert_eq!(
1869            LayoutError::DisplayListFailed.to_string(),
1870            "Display list generation failed"
1871        );
1872    }
1873
1874    #[test]
1875    fn layout_error_display_ignores_width_and_precision_specifiers() {
1876        // `write!(f, "...")` bypasses the formatter's padding/truncation, so a
1877        // caller aligning errors in a table gets no alignment at all.
1878        let e = LayoutError::InvalidTree;
1879        assert_eq!(format!("{e:>60}"), "Invalid layout tree");
1880        assert_eq!(format!("{e:.3}"), "Invalid layout tree");
1881        assert_eq!(format!("{e:^5}"), "Invalid layout tree");
1882    }
1883
1884    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
1885    #[test]
1886    fn the_text_variant_embeds_the_inner_error_and_survives_hostile_payloads() {
1887        let payloads = [
1888            String::new(),
1889            "\u{202e}rtl-override \u{0}nul".to_string(),
1890            "日本語のエラー 🎉".to_string(),
1891            "x".repeat(100_000),
1892            "\"quotes\" and \\backslashes\\".to_string(),
1893        ];
1894        for payload in payloads {
1895            let err = LayoutError::Text(crate::font_traits::LayoutError::ShapingError(
1896                payload.clone(),
1897            ));
1898            let msg = err.to_string();
1899            assert!(
1900                msg.starts_with("Text layout error: "),
1901                "unexpected prefix for {} byte payload",
1902                payload.len()
1903            );
1904            // The inner error is rendered with `{:?}`, so it is escaped, not raw —
1905            // but it must never be truncated away entirely.
1906            assert!(msg.len() >= "Text layout error: ".len() + payload.len());
1907        }
1908    }
1909
1910    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
1911    #[test]
1912    fn the_text_variant_renders_a_default_font_selector_without_panicking() {
1913        let err = LayoutError::Text(crate::font_traits::LayoutError::FontNotFound(
1914            crate::font_traits::FontSelector::default(),
1915        ));
1916        let msg = err.to_string();
1917        assert!(msg.starts_with("Text layout error: "));
1918        assert!(msg.contains("serif"), "the default family should show up: {msg}");
1919    }
1920
1921    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
1922    #[test]
1923    fn from_text_layout_error_wraps_into_the_text_variant() {
1924        let inner = crate::font_traits::LayoutError::InvalidText("bad".to_string());
1925        let wrapped: LayoutError = inner.into();
1926
1927        assert!(matches!(wrapped, LayoutError::Text(_)));
1928        assert!(wrapped.to_string().contains("bad"));
1929
1930        // The `?` sugar used all over solver3 goes through the same From impl.
1931        fn propagates() -> Result<()> {
1932            let failed: std::result::Result<(), crate::font_traits::LayoutError> = Err(
1933                crate::font_traits::LayoutError::HyphenationError("nope".to_string()),
1934            );
1935            failed?;
1936            Ok(())
1937        }
1938        assert!(matches!(propagates(), Err(LayoutError::Text(_))));
1939    }
1940
1941    #[test]
1942    fn layout_error_is_a_std_error_without_a_source() {
1943        use std::error::Error;
1944        let e = LayoutError::SizingFailed;
1945        assert!(e.source().is_none());
1946        // Debug must also be usable (it is what `Result::unwrap` prints).
1947        assert!(!format!("{e:?}").is_empty());
1948    }
1949
1950    // ==================================================================
1951    // LayoutContext debug sinks + the lazy debug_* macros
1952    // ==================================================================
1953
1954    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
1955    mod debug_sinks {
1956        use std::collections::{BTreeMap, HashMap};
1957
1958        use azul_core::{dom::DomId, selection::TextSelection, styled_dom::StyledDom};
1959        use azul_css::{props::basic::FontRef, LayoutDebugMessage, LayoutDebugMessageType};
1960
1961        use super::{body_dom, size};
1962        use crate::{
1963            font_traits::FontManager,
1964            solver3::{cache, LayoutContext},
1965        };
1966
1967        /// Owns everything a `LayoutContext` borrows, so a test can build one,
1968        /// poke it, drop it, and then inspect the captured messages.
1969        struct Env {
1970            styled_dom: StyledDom,
1971            font_manager: FontManager<FontRef>,
1972            text_selections: BTreeMap<DomId, TextSelection>,
1973            counters: HashMap<(usize, String), i32>,
1974            image_cache: azul_core::resources::ImageCache,
1975            debug_messages: Option<Vec<LayoutDebugMessage>>,
1976        }
1977
1978        impl Env {
1979            fn new(debug_messages: Option<Vec<LayoutDebugMessage>>) -> Self {
1980                Self {
1981                    styled_dom: body_dom(),
1982                    font_manager: FontManager::new(rust_fontconfig::FcFontCache::default())
1983                        .expect("FontManager over an empty font cache"),
1984                    text_selections: BTreeMap::new(),
1985                    counters: HashMap::new(),
1986                    image_cache: azul_core::resources::ImageCache::default(),
1987                    debug_messages,
1988                }
1989            }
1990
1991            fn ctx(&mut self) -> LayoutContext<'_, FontRef> {
1992                LayoutContext {
1993                    scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
1994                    styled_dom: &self.styled_dom,
1995                    font_manager: &self.font_manager,
1996                    text_selections: &self.text_selections,
1997                    debug_messages: &mut self.debug_messages,
1998                    counters: &mut self.counters,
1999                    viewport_size: size(800.0, 600.0),
2000                    fragmentation_context: None,
2001                    cursor_is_visible: true,
2002                    cursor_locations: Vec::new(),
2003                    preedit_text: None,
2004                    dirty_text_overrides: BTreeMap::new(),
2005                    cache_map: cache::LayoutCacheMap::default(),
2006                    image_cache: &self.image_cache,
2007                    system_style: None,
2008                    get_system_time_fn: azul_core::task::GetSystemTimeCallback {
2009                        cb: azul_core::task::get_system_time_libstd,
2010                    },
2011                }
2012            }
2013        }
2014
2015        #[test]
2016        fn each_debug_sink_appends_exactly_one_message_of_its_own_type() {
2017            let mut env = Env::new(Some(Vec::new()));
2018            {
2019                let mut ctx = env.ctx();
2020                ctx.debug_log_inner("log".to_string());
2021                ctx.debug_info_inner("info".to_string());
2022                ctx.debug_warning_inner("warning".to_string());
2023                ctx.debug_error_inner("error".to_string());
2024                ctx.debug_box_props_inner("box_props".to_string());
2025                ctx.debug_css_getter_inner("css_getter".to_string());
2026                ctx.debug_bfc_layout_inner("bfc".to_string());
2027                ctx.debug_ifc_layout_inner("ifc".to_string());
2028                ctx.debug_table_layout_inner("table".to_string());
2029                ctx.debug_display_type_inner("display".to_string());
2030            }
2031
2032            let msgs = env.debug_messages.expect("Some(vec) was passed in");
2033            let expected = [
2034                ("log", LayoutDebugMessageType::Info),
2035                ("info", LayoutDebugMessageType::Info),
2036                ("warning", LayoutDebugMessageType::Warning),
2037                ("error", LayoutDebugMessageType::Error),
2038                ("box_props", LayoutDebugMessageType::BoxProps),
2039                ("css_getter", LayoutDebugMessageType::CssGetter),
2040                ("bfc", LayoutDebugMessageType::BfcLayout),
2041                ("ifc", LayoutDebugMessageType::IfcLayout),
2042                ("table", LayoutDebugMessageType::TableLayout),
2043                ("display", LayoutDebugMessageType::DisplayType),
2044            ];
2045            assert_eq!(msgs.len(), expected.len(), "one message per call, in order");
2046            for (msg, (text, ty)) in msgs.iter().zip(expected) {
2047                assert_eq!(msg.message.as_str(), text);
2048                assert_eq!(msg.message_type, ty);
2049                assert!(!msg.location.as_str().is_empty(), "location must be recorded");
2050            }
2051        }
2052
2053        #[test]
2054        fn debug_log_inner_tags_the_message_with_the_solver3_location() {
2055            // `debug_log_inner` builds the message by hand (location = "solver3");
2056            // every other sink goes through LayoutDebugMessage::* (#[track_caller]
2057            // → a file:line inside this module).
2058            let mut env = Env::new(Some(Vec::new()));
2059            {
2060                let mut ctx = env.ctx();
2061                ctx.debug_log_inner("hello".to_string());
2062                ctx.debug_info_inner("hello".to_string());
2063            }
2064
2065            let msgs = env.debug_messages.expect("Some(vec)");
2066            assert_eq!(msgs[0].location.as_str(), "solver3");
2067            assert!(
2068                msgs[1].location.as_str().contains(".rs:"),
2069                "track_caller location, got {:?}",
2070                msgs[1].location.as_str()
2071            );
2072        }
2073
2074        #[test]
2075        fn the_debug_sinks_are_no_ops_when_debug_messages_is_none() {
2076            // The macros guard on `is_some()`, but the inner fns must be safe when
2077            // called directly (they are `pub`).
2078            let mut env = Env::new(None);
2079            {
2080                let mut ctx = env.ctx();
2081                ctx.debug_log_inner("log".to_string());
2082                ctx.debug_error_inner("error".to_string());
2083                ctx.debug_table_layout_inner("table".to_string());
2084            }
2085            assert!(env.debug_messages.is_none(), "must not materialise a Vec");
2086        }
2087
2088        #[test]
2089        fn debug_messages_preserve_hostile_payloads_byte_for_byte() {
2090            let payloads = [
2091                String::new(),
2092                "\u{0}\u{7}\u{1b}[31m".to_string(),
2093                "日本語 🎉 \u{202e}reversed".to_string(),
2094                "line\nbreak\ttab\r\n".to_string(),
2095                "{}{{}} {:?} %s %n".to_string(), // format-string lookalikes
2096                "x".repeat(200_000),
2097            ];
2098            let mut env = Env::new(Some(Vec::new()));
2099            {
2100                let mut ctx = env.ctx();
2101                for p in &payloads {
2102                    ctx.debug_info_inner(p.clone());
2103                }
2104            }
2105
2106            let msgs = env.debug_messages.expect("Some(vec)");
2107            assert_eq!(msgs.len(), payloads.len());
2108            for (msg, payload) in msgs.iter().zip(&payloads) {
2109                assert_eq!(msg.message.as_str(), payload.as_str());
2110            }
2111        }
2112
2113        #[test]
2114        fn the_debug_macros_push_one_message_each_when_capturing() {
2115            let mut env = Env::new(Some(Vec::new()));
2116            {
2117                let mut ctx = env.ctx();
2118                debug_log!(ctx, "log {}", 1);
2119                debug_info!(ctx, "info {}", 2);
2120                debug_warning!(ctx, "warning {}", 3);
2121                debug_error!(ctx, "error {}", 4);
2122                debug_box_props!(ctx, "box_props {}", 5);
2123                debug_css_getter!(ctx, "css_getter {}", 6);
2124                debug_bfc_layout!(ctx, "bfc {}", 7);
2125                debug_ifc_layout!(ctx, "ifc {}", 8);
2126                debug_table_layout!(ctx, "table {}", 9);
2127                debug_display_type!(ctx, "display {}", 10);
2128            }
2129
2130            let msgs = env.debug_messages.expect("Some(vec)");
2131            assert_eq!(msgs.len(), 10);
2132            assert_eq!(msgs[0].message.as_str(), "log 1");
2133            assert_eq!(msgs[9].message.as_str(), "display 10");
2134        }
2135
2136        #[test]
2137        fn the_debug_macros_do_not_evaluate_their_arguments_when_not_capturing() {
2138            // This laziness is the whole point of the macros: a `format!` per node
2139            // per pass would dominate a release layout. A regression here is silent.
2140            let evaluations = core::cell::Cell::new(0u32);
2141            let bump = |c: &core::cell::Cell<u32>| {
2142                c.set(c.get() + 1);
2143                c.get()
2144            };
2145
2146            let mut env = Env::new(None);
2147            {
2148                let mut ctx = env.ctx();
2149                debug_log!(ctx, "{}", bump(&evaluations));
2150                debug_info!(ctx, "{}", bump(&evaluations));
2151                debug_warning!(ctx, "{}", bump(&evaluations));
2152                debug_error!(ctx, "{}", bump(&evaluations));
2153                debug_box_props!(ctx, "{}", bump(&evaluations));
2154                debug_css_getter!(ctx, "{}", bump(&evaluations));
2155                debug_bfc_layout!(ctx, "{}", bump(&evaluations));
2156                debug_ifc_layout!(ctx, "{}", bump(&evaluations));
2157                debug_table_layout!(ctx, "{}", bump(&evaluations));
2158                debug_display_type!(ctx, "{}", bump(&evaluations));
2159            }
2160            assert_eq!(evaluations.get(), 0, "format args must stay unevaluated");
2161
2162            // ...and they ARE evaluated (exactly once) when capturing.
2163            let mut env = Env::new(Some(Vec::new()));
2164            {
2165                let mut ctx = env.ctx();
2166                debug_log!(ctx, "{}", bump(&evaluations));
2167            }
2168            assert_eq!(evaluations.get(), 1);
2169            assert_eq!(env.debug_messages.as_ref().map(Vec::len), Some(1));
2170        }
2171    }
2172
2173    // ==================================================================
2174    // set_skip_display_list — the web-backend opt-out flag
2175    // ==================================================================
2176
2177    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
2178    #[test]
2179    fn set_skip_display_list_round_trips_and_is_idempotent() {
2180        use core::sync::atomic::Ordering;
2181
2182        let previous = SKIP_DISPLAY_LIST.load(Ordering::Relaxed);
2183
2184        set_skip_display_list(true);
2185        assert!(SKIP_DISPLAY_LIST.load(Ordering::Relaxed));
2186        set_skip_display_list(true);
2187        assert!(SKIP_DISPLAY_LIST.load(Ordering::Relaxed), "double-set is idempotent");
2188
2189        set_skip_display_list(false);
2190        assert!(!SKIP_DISPLAY_LIST.load(Ordering::Relaxed));
2191
2192        // Restore whatever the process was using — other tests share this static.
2193        set_skip_display_list(previous);
2194        assert_eq!(SKIP_DISPLAY_LIST.load(Ordering::Relaxed), previous);
2195    }
2196
2197    // ==================================================================
2198    // layout_document — the entry point, driven with degenerate viewports
2199    // ==================================================================
2200
2201    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
2202    mod document {
2203        use std::collections::BTreeMap;
2204
2205        use azul_core::{
2206            dom::{Dom, DomId},
2207            geom::{LogicalPosition, LogicalRect, LogicalSize},
2208            resources::RendererResources,
2209            styled_dom::StyledDom,
2210        };
2211        use azul_css::props::basic::FontRef;
2212
2213        use crate::{
2214            font_traits::{FontManager, TextLayoutCache},
2215            solver3::{cache::LayoutCache, display_list::DisplayList, layout_document, Result},
2216        };
2217
2218        fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
2219            LogicalRect {
2220                origin: LogicalPosition::new(x, y),
2221                size: LogicalSize::new(w, h),
2222            }
2223        }
2224
2225        /// `body > div` — no text nodes, so an empty (font-less) `FontManager` is
2226        /// enough to exercise the whole reconcile → size → position → paint chain.
2227        fn simple_dom() -> StyledDom {
2228            let mut dom = Dom::create_body().with_child(Dom::create_div());
2229            let (css, _warnings) =
2230                azul_css::parser2::new_from_str("div { width: 50px; height: 20px; }");
2231            StyledDom::create(&mut dom, css)
2232        }
2233
2234        fn run(cache: &mut LayoutCache, dom: &StyledDom, viewport: LogicalRect) -> Result<DisplayList> {
2235            let mut text_cache = TextLayoutCache::new();
2236            let font_manager: FontManager<FontRef> =
2237                FontManager::new(rust_fontconfig::FcFontCache::default())
2238                    .expect("FontManager over an empty font cache");
2239            let renderer_resources = RendererResources::default();
2240            let image_cache = azul_core::resources::ImageCache::default();
2241            let mut debug_messages = None;
2242
2243            layout_document(
2244                cache,
2245                &mut text_cache,
2246                dom,
2247                viewport,
2248                &font_manager,
2249                &BTreeMap::new(),
2250                &BTreeMap::new(),
2251                &mut debug_messages,
2252                None,
2253                &renderer_resources,
2254                azul_core::resources::IdNamespace(0),
2255                DomId::ROOT_ID,
2256                false,
2257                Vec::new(),
2258                None,
2259                &image_cache,
2260                None,
2261                azul_core::task::GetSystemTimeCallback {
2262                    cb: azul_core::task::get_system_time_libstd,
2263                },
2264            )
2265        }
2266
2267        #[test]
2268        fn a_zero_sized_viewport_lays_out_without_panicking() {
2269            let dom = simple_dom();
2270            let mut cache = LayoutCache::default();
2271
2272            // Err is acceptable (a font-less environment may legitimately fail);
2273            // a panic, an infinite scrollbar reflow, or a NaN position is not.
2274            if run(&mut cache, &dom, rect(0.0, 0.0, 0.0, 0.0)).is_ok() {
2275                for p in &cache.calculated_positions {
2276                    assert!(!p.x.is_nan() && !p.y.is_nan(), "NaN position from a 0×0 viewport");
2277                }
2278            }
2279        }
2280
2281        #[test]
2282        fn degenerate_viewports_never_panic_or_hang() {
2283            // MAX_SCROLLBAR_REFLOW_ITERATIONS is the only guard against a reflow
2284            // oscillation, so each of these must terminate through it or earlier.
2285            let viewports = [
2286                rect(0.0, 0.0, f32::NAN, f32::NAN),
2287                rect(f32::NAN, f32::NAN, 800.0, 600.0),
2288                rect(0.0, 0.0, -800.0, -600.0),
2289                rect(0.0, 0.0, f32::MAX, f32::MAX),
2290                rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
2291                rect(-1e30, -1e30, 1.0, 1.0),
2292                rect(0.0, 0.0, f32::MIN_POSITIVE, f32::MIN_POSITIVE),
2293            ];
2294            for viewport in viewports {
2295                let dom = simple_dom();
2296                let mut cache = LayoutCache::default();
2297                let _ = run(&mut cache, &dom, viewport);
2298            }
2299        }
2300
2301        #[test]
2302        fn laying_out_the_same_dom_twice_is_stable_and_populates_the_cache() {
2303            let dom = simple_dom();
2304            let mut cache = LayoutCache::default();
2305            let viewport = rect(0.0, 0.0, 800.0, 600.0);
2306
2307            let first = run(&mut cache, &dom, viewport);
2308            if first.is_err() {
2309                // No fonts available in this environment — nothing to compare.
2310                return;
2311            }
2312            assert!(cache.cached_display_list.is_some(), "cold pass must seed the DL cache");
2313            assert_eq!(cache.viewport, Some(viewport));
2314            let positions_after_first = cache.calculated_positions.clone();
2315
2316            // Second pass: the structural-identity cache should short-circuit, and
2317            // must not corrupt the stored geometry on the way out.
2318            let second = run(&mut cache, &dom, viewport);
2319            assert!(second.is_ok(), "a warm relayout of an unchanged DOM must succeed");
2320            assert_eq!(
2321                cache.calculated_positions.len(),
2322                positions_after_first.len(),
2323                "warm pass changed the node count"
2324            );
2325            for (warm, cold) in cache.calculated_positions.iter().zip(&positions_after_first) {
2326                assert_eq!(warm.x.to_bits(), cold.x.to_bits(), "warm pass moved a node");
2327                assert_eq!(warm.y.to_bits(), cold.y.to_bits(), "warm pass moved a node");
2328            }
2329        }
2330    }
2331}