1
//! solver3/mod.rs
2
//!
3
//! Next-generation CSS layout engine with proper formatting context separation
4

            
5
pub mod cache;
6
pub mod calc;
7
pub mod counters;
8
pub mod display_list;
9
pub mod fc;
10
pub mod geometry;
11
pub mod getters;
12
pub mod layout_tree;
13
pub use layout_tree::LayoutNodeId;
14
pub mod page_breaks;
15
pub mod break_token;
16
pub mod paged_layout;
17
pub mod pagination;
18
pub mod positioning;
19
pub mod scrollbar;
20
pub mod sizing;
21
pub mod taffy_bridge;
22

            
23
/// Lazy `debug_info` macro - only evaluates format args when `debug_messages` is Some
24
#[macro_export]
25
macro_rules! debug_info {
26
    ($ctx:expr, $($arg:tt)*) => {
27
        if $ctx.debug_messages.is_some() {
28
            $ctx.debug_info_inner(format!($($arg)*));
29
        }
30
    };
31
}
32

            
33
/// Lazy `debug_warning` macro - only evaluates format args when `debug_messages` is Some
34
#[macro_export]
35
macro_rules! debug_warning {
36
    ($ctx:expr, $($arg:tt)*) => {
37
        if $ctx.debug_messages.is_some() {
38
            $ctx.debug_warning_inner(format!($($arg)*));
39
        }
40
    };
41
}
42

            
43
/// Lazy `debug_error` macro - only evaluates format args when `debug_messages` is Some
44
#[macro_export]
45
macro_rules! debug_error {
46
    ($ctx:expr, $($arg:tt)*) => {
47
        if $ctx.debug_messages.is_some() {
48
            $ctx.debug_error_inner(format!($($arg)*));
49
        }
50
    };
51
}
52

            
53
/// Lazy `debug_log` macro - only evaluates format args when `debug_messages` is Some
54
#[macro_export]
55
macro_rules! debug_log {
56
    ($ctx:expr, $($arg:tt)*) => {
57
        if $ctx.debug_messages.is_some() {
58
            $ctx.debug_log_inner(format!($($arg)*));
59
        }
60
    };
61
}
62

            
63
/// Lazy `debug_box_props` macro - only evaluates format args when `debug_messages` is Some
64
#[macro_export]
65
macro_rules! debug_box_props {
66
    ($ctx:expr, $($arg:tt)*) => {
67
        if $ctx.debug_messages.is_some() {
68
            $ctx.debug_box_props_inner(format!($($arg)*));
69
        }
70
    };
71
}
72

            
73
/// Lazy `debug_css_getter` macro - only evaluates format args when `debug_messages` is Some
74
#[macro_export]
75
macro_rules! debug_css_getter {
76
    ($ctx:expr, $($arg:tt)*) => {
77
        if $ctx.debug_messages.is_some() {
78
            $ctx.debug_css_getter_inner(format!($($arg)*));
79
        }
80
    };
81
}
82

            
83
/// Lazy `debug_bfc_layout` macro - only evaluates format args when `debug_messages` is Some
84
#[macro_export]
85
macro_rules! debug_bfc_layout {
86
    ($ctx:expr, $($arg:tt)*) => {
87
        if $ctx.debug_messages.is_some() {
88
            $ctx.debug_bfc_layout_inner(format!($($arg)*));
89
        }
90
    };
91
}
92

            
93
/// Lazy `debug_ifc_layout` macro - only evaluates format args when `debug_messages` is Some
94
#[macro_export]
95
macro_rules! debug_ifc_layout {
96
    ($ctx:expr, $($arg:tt)*) => {
97
        if $ctx.debug_messages.is_some() {
98
            $ctx.debug_ifc_layout_inner(format!($($arg)*));
99
        }
100
    };
101
}
102

            
103
/// Lazy `debug_table_layout` macro - only evaluates format args when `debug_messages` is Some
104
#[macro_export]
105
macro_rules! debug_table_layout {
106
    ($ctx:expr, $($arg:tt)*) => {
107
        if $ctx.debug_messages.is_some() {
108
            $ctx.debug_table_layout_inner(format!($($arg)*));
109
        }
110
    };
111
}
112

            
113
/// Lazy `debug_display_type` macro - only evaluates format args when `debug_messages` is Some
114
#[macro_export]
115
macro_rules! debug_display_type {
116
    ($ctx:expr, $($arg:tt)*) => {
117
        if $ctx.debug_messages.is_some() {
118
            $ctx.debug_display_type_inner(format!($($arg)*));
119
        }
120
    };
121
}
122

            
123
use std::{collections::{BTreeMap, HashMap}, sync::Arc};
124

            
125
use azul_core::{
126
    dom::{DomId, NodeId},
127
    geom::{LogicalPosition, LogicalRect, LogicalSize},
128
    hit_test::{DocumentId, ScrollPosition},
129
    resources::RendererResources,
130
    selection::{TextCursor, TextSelection},
131
    styled_dom::StyledDom,
132
};
133

            
134
/// Sentinel value for "position not yet computed". No real position is ever `f32::MIN`.
135
pub(crate) const POSITION_UNSET: LogicalPosition = LogicalPosition { x: f32::MIN, y: f32::MIN };
136

            
137
/// Maximum number of scrollbar-induced reflow iterations before layout gives up.
138
/// Scrollbar appearance can change container size, which may trigger further scrollbar
139
/// changes. This limit prevents infinite loops in pathological layouts.
140
const MAX_SCROLLBAR_REFLOW_ITERATIONS: usize = 10;
141

            
142
/// Vec-based position storage indexed by layout-tree node index.
143
/// Replaces `BTreeMap<usize, LogicalPosition>` for O(1) access and cache-friendly iteration.
144
pub type PositionVec = Vec<LogicalPosition>;
145

            
146
/// Get position for node index, returning None if unset.
147
///
148
/// Note: only the `x` component is checked against the sentinel. This is sufficient
149
/// because `POSITION_UNSET` always sets both `x` and `y` to `f32::MIN`, and `pos_set`
150
/// always writes both components together.
151
#[inline]
152
8739
#[must_use] pub fn pos_get(positions: &PositionVec, idx: usize) -> Option<LogicalPosition> {
153
8739
    positions.get(idx).copied().filter(|p| p.x != f32::MIN)
154
8739
}
155

            
156
/// Set position for node index. Grows the vec if needed.
157
#[inline]
158
428181
pub fn pos_set(positions: &mut PositionVec, idx: usize, pos: LogicalPosition) {
159
428181
    if idx >= positions.len() {
160
378293
        positions.resize(idx + 1, POSITION_UNSET);
161
378293
    }
162
428181
    positions[idx] = pos;
163
428181
}
164

            
165
/// Check if position has been set for node index.
166
#[inline]
167
72858
#[must_use] pub fn pos_contains(positions: &PositionVec, idx: usize) -> bool {
168
72858
    positions.get(idx).is_some_and(|p| p.x != f32::MIN)
169
72858
}
170
use azul_css::{
171
    props::property::{CssProperty, CssPropertyCategory},
172
    LayoutDebugMessage, LayoutDebugMessageType,
173
};
174

            
175
use self::{
176
    display_list::generate_display_list,
177
    geometry::IntrinsicSizes,
178
    getters::get_writing_mode,
179
    layout_tree::{generate_layout_tree, LayoutTree},
180
    sizing::calculate_intrinsic_sizes,
181
};
182
#[cfg(feature = "text_layout")]
183
pub use crate::font_traits::TextLayoutCache;
184
use crate::{
185
    font_traits::ParsedFontTrait,
186
    solver3::{
187
        cache::LayoutCache,
188
        display_list::DisplayList,
189
        fc::LayoutConstraints,
190
        layout_tree::DirtyFlag,
191
    },
192
};
193

            
194
/// Central context for a single layout pass.
195
#[derive(Debug)]
196
pub struct LayoutContext<'a, T: ParsedFontTrait> {
197
    pub styled_dom: &'a StyledDom,
198
    /// Per-document memo for [`crate::solver3::getters::get_style_properties`].
199
    ///
200
    /// Resolving a node's style allocates ~6.5 KB (a font stack of heap
201
    /// `String`s, a background-content Vec, a font-features Vec, a cloned
202
    /// family list), and the sizing pass and the inline-layout pass each
203
    /// resolve the same node independently. heaptrack measured 6.33 MB of
204
    /// peak heap over 966 calls on a 3-page document.
205
    ///
206
    /// It lives HERE, on the context, so its lifetime is exactly the
207
    /// document's. An earlier attempt used a thread-local keyed on
208
    /// `ptr::from_ref(styled_dom)`, which passed every library test and
209
    /// reddened sixteen reftests: a `StyledDom` is dropped and the next one
210
    /// reuses the address, so the second document was served the first
211
    /// one's styles. Ownership makes that unrepresentable rather than
212
    /// merely unlikely.
213
    pub style_cache: getters::StyleCache,
214
    #[cfg(feature = "text_layout")]
215
    pub font_manager: &'a crate::font_traits::FontManager<T>,
216
    #[cfg(not(feature = "text_layout"))]
217
    pub font_manager: core::marker::PhantomData<&'a T>,
218
    /// Text selections for rendering highlights. Populated from `MultiCursorState`.
219
    pub text_selections: &'a BTreeMap<DomId, TextSelection>,
220
    pub debug_messages: &'a mut Option<Vec<LayoutDebugMessage>>,
221
    pub counters: &'a mut HashMap<(usize, String), i32>,
222
    pub viewport_size: LogicalSize,
223
    /// Fragmentation context for CSS Paged Media (PDF generation)
224
    /// When Some, layout respects page boundaries and generates one `DisplayList` per page
225
    pub fragmentation_context: Option<&'a mut crate::paged::FragmentationContext>,
226
    /// Per-pass record: layout-tree indices of IFC roots whose LINE LAYOUT
227
    /// was recomputed this pass (`layout_flow` ran — `GlyphSwap` reuse does
228
    /// NOT count). The DL-patching invalidation signal: a re-flowed IFC's
229
    /// text items changed shape and must re-emit; everything else can be
230
    /// copied+translated. Drained into the patch machinery by
231
    /// `layout_document`; empty on GlyphSwap-only passes.
232
    pub reflowed_ifcs: std::collections::BTreeSet<usize>,
233
    /// Whether the text cursor should be drawn (managed by `CursorManager` blink timer)
234
    /// When false, the cursor is in the "off" phase of blinking and should not be rendered.
235
    /// When true (default), the cursor is visible.
236
    pub cursor_is_visible: bool,
237
    /// All active cursor locations from `MultiCursorState` / `CursorManager`.
238
    /// Each entry is (`dom_id`, `node_id`, cursor). Multiple entries = multi-cursor mode.
239
    /// Empty = no active cursor. The last entry is the primary cursor.
240
    pub cursor_locations: Vec<(DomId, NodeId, TextCursor)>,
241
    /// IME preedit (composition) text to render inline at the cursor position.
242
    /// When Some, the text should be rendered with an underline decoration.
243
    pub preedit_text: Option<String>,
244
    /// Per-node multi-slot cache (Taffy-inspired 9+1 architecture).
245
    /// Moved out of `LayoutCache` via `std::mem::take` for the duration of layout,
246
    /// then moved back after the layout pass completes.
247
    pub cache_map: cache::LayoutCacheMap,
248
    /// Image cache for resolving `background-image: url(...)` references.
249
    pub image_cache: &'a azul_core::resources::ImageCache,
250
    /// Content overlay for resolving quickly-mutable node content (swapped
251
    /// images, produced callback frames): readers resolve overlay→DOM via
252
    /// [`crate::overlay::ResolvedContent`]. `None` outside a live window
253
    /// (standalone paged/PDF layout, unit tests) — the DOM is then authoritative.
254
    pub content_overlay: Option<&'a crate::overlay::ContentOverlay>,
255
    /// System style containing colors, fonts, metrics, and theme information.
256
    /// Used for selection colors, caret styling, and other system-themed elements.
257
    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
258
    /// Callback to get the current system time. Used for profiling inside layout.
259
    /// On WASM targets (where `std::time::Instant::now()` panics), callers should
260
    /// supply a no-op or platform-specific implementation.
261
    pub get_system_time_fn: azul_core::task::GetSystemTimeCallback,
262
    /// Memoised `get_scrollbar_style` results, keyed by DOM node id.
263
    /// `compute_scrollbar_info_core` is called many times per node
264
    /// per layout pass (BFC path + Taffy flex/grid path + display
265
    /// list), and each call previously did 9 cascade walks. Once
266
    /// populated, subsequent callers in the same `LayoutContext`
267
    /// (a single render) return a clone.
268
    ///
269
    /// Uses `RefCell` so shared `&self` borrows (e.g. in the Taffy
270
    /// bridge's `get_core_container_style`) can mutate the cache
271
    /// without lifting the ctx to `&mut`. Keyed by `NodeId` so
272
    /// entries span DOMs in iframe-style nested documents if that
273
    /// ever becomes a thing.
274
    pub scrollbar_style_cache:
275
        core::cell::RefCell<HashMap<NodeId, getters::ComputedScrollbarStyle>>,
276
}
277

            
278
impl<T: ParsedFontTrait> LayoutContext<'_, T> {
279
    /// The one overlay→DOM content read order for this context's DOM.
280
    /// Layout (intrinsic sizing) and display-list build resolve node content
281
    /// exclusively through this — never via `NodeType::Image` directly.
282
    #[must_use]
283
94
    pub const fn resolved_content(&self) -> crate::overlay::ResolvedContent<'_> {
284
94
        crate::overlay::ResolvedContent {
285
94
            overlay: self.content_overlay,
286
94
            styled_dom: self.styled_dom,
287
94
            dom_id: self.styled_dom.dom_id,
288
94
        }
289
94
    }
290

            
291
    /// Internal method - called by `debug_log`! macro after checking `debug_messages.is_some()`
292
    #[inline]
293
311519
    pub fn debug_log_inner(&mut self, message: String) {
294
311519
        if let Some(messages) = self.debug_messages.as_mut() {
295
311518
            messages.push(LayoutDebugMessage {
296
311518
                message: message.into(),
297
311518
                location: "solver3".into(),
298
311518
                message_type: LayoutDebugMessageType::default(),
299
311518
            });
300
311518
        }
301
311519
    }
302

            
303
    /// Internal method - called by `debug_info`! macro after checking `debug_messages.is_some()`
304
    #[inline]
305
5614347
    pub fn debug_info_inner(&mut self, message: String) {
306
5614347
        if let Some(messages) = self.debug_messages.as_mut() {
307
5614347
            messages.push(LayoutDebugMessage::info(message));
308
5614347
        }
309
5614347
    }
310

            
311
    /// Internal method - called by `debug_warning`! macro after checking `debug_messages.is_some()`
312
    #[inline]
313
38
    pub fn debug_warning_inner(&mut self, message: String) {
314
38
        if let Some(messages) = self.debug_messages.as_mut() {
315
38
            messages.push(LayoutDebugMessage::warning(message));
316
38
        }
317
38
    }
318

            
319
    /// Internal method - called by `debug_error`! macro after checking `debug_messages.is_some()`
320
    #[inline]
321
3
    pub fn debug_error_inner(&mut self, message: String) {
322
3
        if let Some(messages) = self.debug_messages.as_mut() {
323
2
            messages.push(LayoutDebugMessage::error(message));
324
2
        }
325
3
    }
326

            
327
    /// Internal method - called by `debug_box_props`! macro after checking `debug_messages.is_some()`
328
    #[inline]
329
2
    pub fn debug_box_props_inner(&mut self, message: String) {
330
2
        if let Some(messages) = self.debug_messages.as_mut() {
331
2
            messages.push(LayoutDebugMessage::box_props(message));
332
2
        }
333
2
    }
334

            
335
    /// Internal method - called by `debug_css_getter`! macro after checking `debug_messages.is_some()`
336
    #[inline]
337
2
    pub fn debug_css_getter_inner(&mut self, message: String) {
338
2
        if let Some(messages) = self.debug_messages.as_mut() {
339
2
            messages.push(LayoutDebugMessage::css_getter(message));
340
2
        }
341
2
    }
342

            
343
    /// Internal method - called by `debug_bfc_layout`! macro after checking `debug_messages.is_some()`
344
    #[inline]
345
2
    pub fn debug_bfc_layout_inner(&mut self, message: String) {
346
2
        if let Some(messages) = self.debug_messages.as_mut() {
347
2
            messages.push(LayoutDebugMessage::bfc_layout(message));
348
2
        }
349
2
    }
350

            
351
    /// Internal method - called by `debug_ifc_layout`! macro after checking `debug_messages.is_some()`
352
    #[inline]
353
1132134
    pub fn debug_ifc_layout_inner(&mut self, message: String) {
354
1132134
        if let Some(messages) = self.debug_messages.as_mut() {
355
1132134
            messages.push(LayoutDebugMessage::ifc_layout(message));
356
1132134
        }
357
1132134
    }
358

            
359
    /// Internal method - called by `debug_table_layout`! macro after checking `debug_messages.is_some()`
360
    #[inline]
361
13161
    pub fn debug_table_layout_inner(&mut self, message: String) {
362
13161
        if let Some(messages) = self.debug_messages.as_mut() {
363
13160
            messages.push(LayoutDebugMessage::table_layout(message));
364
13160
        }
365
13161
    }
366

            
367
    /// Internal method - called by `debug_display_type`! macro after checking `debug_messages.is_some()`
368
    #[inline]
369
2
    pub fn debug_display_type_inner(&mut self, message: String) {
370
2
        if let Some(messages) = self.debug_messages.as_mut() {
371
2
            messages.push(LayoutDebugMessage::display_type(message));
372
2
        }
373
2
    }
374
}
375

            
376
/// Main entry point for the incremental, cached layout engine.
377
///
378
/// `new_dom` is borrowed, not owned — every use inside is `&new_dom`,
379
/// so taking ownership was a pure formality that forced every caller
380
/// to `styled_dom.clone()` the DOM before calling. The clone was
381
/// ~2 MiB per render on excel.html; kept at the borrow now.
382
#[cfg(feature = "text_layout")]
383
/// Web-backend opt-out for display-list generation.
384
///
385
/// When set, [`layout_document`] runs the full positioning pipeline
386
/// (intrinsic sizing, taffy block/flex/grid, relative/sticky/absolute
387
/// adjustment → `calculated_positions`) but **skips
388
/// `generate_display_list`**, returning an empty [`DisplayList`]. The
389
/// web backend emits TLV DOM patches, not a display list, so it needs
390
/// the geometry in `calculated_positions` but nothing the painter
391
/// produces. This also lets the AArch64→wasm lift drop the entire
392
/// `display_list` painter surface (those symbols are classified `Leaf`
393
/// in `dll/src/web/symbol_table.rs::classify_for_name`, so the
394
/// transitive lifter never descends into them). Defaults `false` →
395
/// desktop/native behaviour is unchanged.
396
pub static SKIP_DISPLAY_LIST: core::sync::atomic::AtomicBool =
397
    core::sync::atomic::AtomicBool::new(false);
398

            
399
/// Set [`SKIP_DISPLAY_LIST`].
400
///
401
/// Provided as a function (rather than the
402
/// caller touching the static directly) so the web backend's
403
/// `dll`-crate caller reaches it through a normal `bl` into this
404
/// `azul_layout` function — keeping the static's address computation
405
/// intra-crate (direct `adrp+add`) instead of a cross-crate GOT load,
406
/// which the AArch64→wasm lift mirrors more reliably.
407
4
pub fn set_skip_display_list(skip: bool) {
408
4
    SKIP_DISPLAY_LIST.store(skip, core::sync::atomic::Ordering::Relaxed);
409
4
}
410

            
411
// M12.7: keep this out-of-line so the web lift sees it as its own wasm fn
412
// (not inlined into layout_dom_recursive). An opt-folded infinite loop in the
413
// solver (a mis-lifted loop exit) is otherwise hidden inside the giant inlined
414
// layout_dom_recursive; de-inlining lets AZ_FUEL/AZ_WASM_DEBUG name the actual
415
// source fn — and may itself prevent the inlining-induced fold. No perf cost on
416
// desktop (called once per layout).
417
#[inline(never)]
418
// node counts / indices / tree-len values fed to az_mark debug markers as u32; bounded.
419
#[allow(clippy::cast_possible_truncation)]
420
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
421
/// # Errors
422
///
423
/// Returns a `LayoutError` if document layout fails.
424
4894
pub fn layout_document<T: ParsedFontTrait + Sync + 'static>(
425
4894
    cache: &mut LayoutCache,
426
4894
    text_cache: &mut TextLayoutCache,
427
4894
    new_dom: &StyledDom,
428
4894
    viewport: LogicalRect,
429
4894
    font_manager: &crate::font_traits::FontManager<T>,
430
4894
    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
431
4894
    text_selections: &BTreeMap<DomId, TextSelection>,
432
4894
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
433
4894
    gpu_value_cache: Option<&azul_core::gpu::GpuValueCache>,
434
4894
    renderer_resources: &azul_core::resources::RendererResources,
435
4894
    id_namespace: azul_core::resources::IdNamespace,
436
4894
    dom_id: DomId,
437
4894
    cursor_is_visible: bool,
438
4894
    cursor_locations: Vec<(DomId, NodeId, TextCursor)>,
439
4894
    preedit_text: Option<String>,
440
4894
    image_cache: &azul_core::resources::ImageCache,
441
4894
    content_overlay: Option<&crate::overlay::ContentOverlay>,
442
4894
    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
443
4894
    get_system_time_fn: azul_core::task::GetSystemTimeCallback,
444
4894
    // The CSS DIFF, folded into the solver's own dirtiness. The solver's
445
4894
    // reconcile compares DOM structure/content and is BLIND to a changed
446
4894
    // stylesheet over an identical tree — a CSS-only remount reused stale
447
4894
    // geometry (200px measured where the new sheet said 40px) and the
448
4894
    // structural-identity DL cache served stale paint. Each entry is a node
449
4894
    // whose computed style changed, with the WORST `RelayoutScope` across
450
4894
    // its changed properties; `RelayoutScope::None` entries are paint-only
451
4894
    // changes (colour/background/...) that must repaint without a single
452
4894
    // layout pass — the aggressive-caching half of this contract.
453
4894
    css_dirty: &[(NodeId, azul_css::props::property::RelayoutScope)],
454
4894
) -> Result<std::sync::Arc<DisplayList>> {
455
    use crate::window::LayoutWindow;
456

            
457
    // Secondary mapping: anonymous wrappers (dom_node_id == None)
458
    // by (parent_new_idx, ordinal-among-anon-siblings). An
459
    // unchanged DOM produces the same anon wrappers in the same
460
    // order under the same parent — matching by position here
461
    // preserves their cache slots too. Without this, anon
462
    // wrappers re-allocate empty every reconcile and invalidate
463
    // their ancestors via `mark_dirty`.
464
942
    fn collect_anon_children_by_parent(
465
942
        tree: &LayoutTree,
466
942
    ) -> HashMap<usize, Vec<usize>> {
467
942
        let mut map: HashMap<usize, Vec<usize>> =
468
942
            HashMap::new();
469
57170
        for (idx, node) in tree.nodes.iter().enumerate() {
470
57170
            if node.dom_node_id.is_some() {
471
57151
                continue;
472
19
            }
473
19
            if let Some(parent) = node.parent {
474
19
                map.entry(parent).or_default().push(idx);
475
19
            }
476
        }
477
942
        map
478
942
    }
479

            
480
    // Reset IFC ID counter at the start of each layout pass
481
    // This ensures IFCs get consistent IDs across frames when the DOM structure is stable
482
4894
    layout_tree::IfcId::reset_counter();
483
    // in layout_document returns the rc=5 Err (the error enum can't be captured
484
    // reliably in the lift). The last value seen = the step that errored next.
485
4894
    { let _ = (0xDD00_0001u32); }
486
    // If 0 here → the LogicalRect HFA arg was lost across the lifted call.
487

            
488
4894
    if let Some(msgs) = debug_messages.as_mut() {
489
4103
        msgs.push(LayoutDebugMessage::info(format!(
490
4103
            "[Layout] layout_document called - viewport: ({:.1}, {:.1}) size ({:.1}x{:.1})",
491
4103
            viewport.origin.x, viewport.origin.y, viewport.size.width, viewport.size.height
492
4103
        )));
493
4103
        msgs.push(LayoutDebugMessage::info(format!(
494
4103
            "[Layout] DOM has {} nodes",
495
4103
            new_dom.node_data.len()
496
4103
        )));
497
4131
    }
498

            
499
    // Create temporary context without counters for tree generation
500
4894
    let mut counter_values = HashMap::new();
501
4894
    let mut ctx_temp = LayoutContext {
502
4894
        style_cache: Default::default(),
503
4894
        scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
504
4894
        styled_dom: new_dom,
505
4894
        font_manager,
506
4894
        text_selections,
507
4894
        debug_messages,
508
4894
        counters: &mut counter_values,
509
4894
        viewport_size: viewport.size,
510
4894
        fragmentation_context: None,
511
4894
            reflowed_ifcs: std::collections::BTreeSet::new(),
512
4894
        cursor_is_visible,
513
4894
        cursor_locations: cursor_locations.clone(),
514
4894
        preedit_text: preedit_text.clone(),
515
4894
        cache_map: cache::LayoutCacheMap::default(), // temp context doesn't need real cache
516
4894
        image_cache,
517
4894
        content_overlay,
518
4894
        system_style: system_style.clone(),
519
4894
        get_system_time_fn,
520
4894
    };
521

            
522
4894
    crate::probe::sample_peak_rss("rss:enter_layout_document");
523

            
524
    // --- Step 0: record DOM pointer / viewport for diagnostics only ---
525
    //
526
    // NOTE: there is intentionally NO pointer-identity fast path here.
527
    // Comparing `new_dom as *const StyledDom as usize` against a stored
528
    // pointer is UNSOUND across layout passes: each `regenerate_layout`
529
    // builds a fresh `StyledDom`, and after the previous one is dropped
530
    // (e.g. `layout_and_generate_display_list` calls `layout_results.clear()`
531
    // before re-laying out), the allocator/stack frequently hands the new,
532
    // *different* StyledDom the SAME address. A pointer match therefore does
533
    // NOT prove the content is unchanged — it would return the previous
534
    // frame's display list for a structurally different DOM (e.g. an image
535
    // removed from the tree would still appear in `scan_used_images`,
536
    // breaking resource GC). The Step 1.1 structural-identity cache below
537
    // (root `subtree_hash` + viewport) is the correct, content-based skip;
538
    // it costs one ~600 µs reconcile pass but cannot be fooled by address
539
    // reuse.
540
4894
    let doc_setup_span = crate::probe::Probe::span("doc_setup_to_reconcile");
541
4894
    let dom_ptr = std::ptr::from_ref::<StyledDom>(new_dom) as usize;
542
4894
    cache.prev_dom_ptr = dom_ptr;
543
4894
    cache.prev_viewport = viewport;
544

            
545
    // --- Step 1: Reconciliation & Invalidation ---
546
4894
    crate::probe::reset_peak();
547
    // RESIZE-ONLY SKIP: the incremental-relayout resize path hands solver3
548
    // the SAME StyledDom object with zero DOM/style dirt — the viewport is
549
    // the only delta. Reconcile would walk 1209 nodes to rediscover
550
    // "everything reused, nothing dirty" (~9.6 ms/pass on big.md) and the
551
    // cache_map remap would rebuild an identity mapping. Take the retained
552
    // tree as-is instead: indices are unchanged (so the per-node size/layout
553
    // caches keep working WITHOUT a remap), layout_roots={root} re-runs the
554
    // top-down pass at the new size, and the censuses read full-reuse. The
555
    // hint is a one-shot latch set ONLY by the resize-latch call sites
556
    // (common/layout.rs incremental_relayout_for_resize); restyle-driven
557
    // incremental relayouts keep full reconcile — they NEED its fingerprint
558
    // diff for paint-dirty classification. The dom-id sanity check guards
559
    // against a stale hint meeting a swapped DOM.
560
4894
    drop(doc_setup_span);
561
4894
    let resize_only = core::mem::take(&mut cache.resize_only_hint);
562
4894
    let dom_len_for_hint = new_dom.node_data.as_ref().len();
563
4894
    let (new_tree_val, mut recon_result) = if resize_only
564
27
        && cache.tree.as_ref().is_some_and(|t| {
565
27
            t.dom_to_layout
566
27
                .last_key_value()
567
27
                .is_none_or(|(max_id, _)| max_id.index() < dom_len_for_hint)
568
27
        }) {
569
27
        let _p = crate::probe::Probe::span("reconcile_skipped_resize_only");
570
27
        cache.last_reconcile_was_skipped = true;
571
27
        let tree = cache.tree.take().expect("checked is_some above");
572
        // DL-patching input: the sizes the PREVIOUS pass computed — the
573
        // layout pass below overwrites used_size in place (same tree
574
        // object), so this is the only moment they can be captured.
575
27
        cache.previous_sizes = tree.nodes.iter().map(|n| n.used_size).collect();
576
27
        let mut r = cache::ReconciliationResult::default();
577
27
        r.layout_roots.insert(tree.root);
578
27
        r.reused_nodes = tree.nodes.len();
579
27
        r.fresh_nodes = 0;
580
27
        (tree, r)
581
    } else {
582
4867
        cache.last_reconcile_was_skipped = false;
583
        // DL-patching input for the STRUCTURE-PRESERVED case (a text edit):
584
        // the sizes the previous pass computed, captured before reconcile
585
        // consumes the old tree.
586
4867
        if let Some(t) = cache.tree.as_ref() {
587
632
            cache.previous_sizes = t.nodes.iter().map(|n| n.used_size).collect();
588
4235
        }
589
4867
        let dom_diff_clean = cache.dom_diff_clean.take();
590
4867
        cache::reconcile_and_invalidate(&mut ctx_temp, cache, viewport, dom_diff_clean)?
591
    };
592
    // The reuse census, persisted where a test can read it — see the field
593
    // docs on LayoutCache for why this pair is the ONLY external observable
594
    // that distinguishes "reused the warm tree" from "rebuilt it from
595
    // scratch" (both produce identical pixels).
596
4894
    cache.last_reconcile_reused = recon_result.reused_nodes;
597
4894
    cache.last_reconcile_fresh = recon_result.fresh_nodes;
598
4894
    cache.last_fingerprint_skips = recon_result.fingerprint_skips;
599
    // Structure preserved = the same-shaped tree: full reuse+fresh
600
    // accounting at an UNCHANGED node count (content changes make their
601
    // node FRESH — a text edit — but assign the same indices in the same
602
    // traversal; adds/removes change the count and disqualify). Fresh nodes
603
    // are force-re-emitted by the DL patch below, so a same-count REPLACE is
604
    // sound too: its new content never splices stale items.
605
4894
    cache.last_reconcile_structure_preserved = !cache.last_reconcile_was_skipped
606
4867
        && recon_result.reused_nodes + recon_result.fresh_nodes == new_tree_val.nodes.len()
607
4332
        && cache.previous_sizes.len() == new_tree_val.nodes.len();
608
    // [g56 FIX] Box the LayoutTree onto the HEAP. The lifted `&mut new_tree` passed to
609
    // calculate_intrinsic_sizes was mis-lifted (callee saw nodes.len()=0 while the caller saw 2)
610
    // because a stack/SROA'd `new_tree`'s address doesn't survive the cross-function lifted call
611
    // (taking `&new_tree` lifted to 0x0). A heap allocation has a stable absolute wasm address
612
    // that lifts reliably (cf. M8.4 "heap allocations work fine"). Deref coercion handles the
613
    // `&new_tree`/`&mut new_tree`/`new_tree.field` sites unchanged.
614
4894
    let mut new_tree = Box::new(new_tree_val);
615
4894
    { let _ = (0xDD00_0002u32); }
616
    // [az-diag g51 REVERT] 0x71 = reconcile_and_invalidate returned OK (no InvalidTree in reconcile).
617
4894
    unsafe { crate::az_mark(0x60704_u32, (0x71u32)); }
618
    // [az-diag g54 REVERT] 0x40740 = new_tree.nodes.len() RIGHT AFTER reconcile. If 0 → reconcile
619
    // built an empty LayoutTree (the bug is in reconcile_recursive/create_node_from_dom). If 2 →
620
    // reconcile is fine and the tree gets emptied/mis-lifted downstream (check 0x40744 at the loop).
621
4894
    unsafe { crate::az_mark(0x60740_u32, (new_tree.nodes.len() as u32)); }
622
4894
    crate::probe::sample_peak_rss("rss:after_reconcile");
623
4894
    crate::probe::sample_phase_peak("rss:peak_during_reconcile");
624

            
625
    // --- Step 1.1: Structural-identity display-list cache ---
626
    //
627
    // If the reconciled root subtree_hash matches the cached one AND
628
    // the viewport is unchanged, nothing structural has moved — skip
629
    // layout, positioning, AND display-list generation and return
630
    // the cached display list verbatim.
631
    //
632
    // This fires on re-renders of an unchanged DOM: the reconcile
633
    // pass still walks and hashes the tree, but that's ~600 µs vs
634
    // the ~4 ms it would otherwise cost to re-emit the display list.
635
659
    if let Some((cached_hash, cached_viewport, cached_gpu_fp, cached_dl)) =
636
4894
        &cache.cached_display_list
637
    {
638
659
        let new_root_hash = new_tree
639
659
            .cold(LayoutNodeId::new(new_tree.root))
640
659
            .map(|c| c.subtree_hash);
641
        // The GPU-KEY-POPULATION fingerprint is the third key component. The
642
        // emitted list is a function of which nodes carry transform/opacity
643
        // keys (`PushReferenceFrame` exists exactly for keyed nodes), and
644
        // diff-driven animation mints its keys AFTER the first layout — so on
645
        // (hash, viewport) alone this hit served the PRE-KEY list back:
646
        // no reference frames, no GPU damage, a frozen animation. Population
647
        // only, not values: values change every tick, and serving this cached
648
        // list across ticks while values flow through the GPU channel is the
649
        // point of the design.
650
659
        let gpu_fp = gpu_value_cache.map_or(0, azul_core::gpu::GpuValueCache::dl_emission_fingerprint);
651
659
        if css_dirty.is_empty()
652
648
            && new_root_hash == Some(*cached_hash)
653
394
            && *cached_viewport == viewport
654
181
            && *cached_gpu_fp == gpu_fp
655
        {
656
161
            let _p = crate::probe::Probe::span("display_list_cache_hit");
657
            #[cfg(feature = "std")]
658
161
            if std::env::var_os("AZ_ANIM_DEBUG").is_some() {
659
                eprintln!("[dlcache] HIT fp={gpu_fp:x} items={}", cached_dl.items.len());
660
161
            }
661
161
            let dl = cached_dl.clone();
662
            // The served list is IDENTICAL to the previous frame — this is
663
            // NOT a splice. Left stale from the last real build, the
664
            // renderers' patched-build damage override would replay the old
665
            // patch rects on an idle frame (op_add_remove_timer step 30:
666
            // expected no damage, got rects).
667
161
            cache.last_build_was_patched = false;
668
161
            cache.last_patch_damage = None;
669
161
            return Ok(dl);
670
498
        }
671
4235
    }
672

            
673
    // --- Step 1.15: fold the CSS DIFF into the solver's dirtiness ---
674
    //
675
    // Mirrors the reconcile classifier exactly (layout-affecting ->
676
    // intrinsic_dirty + layout_roots, the same pair a DirtyFlag::Layout node
677
    // gets). Paint-only entries (`RelayoutScope::None`) deliberately insert
678
    // NOTHING: with the Step 1.1 cache bypassed above, a clean reconcile
679
    // falls into the early-exit below, which reuses the whole tree and
680
    // re-emits the display list from the NEW styled_dom — repaint with zero
681
    // layout work. IfcOnly is treated as SizingOnly: over-invalidation is
682
    // correct (the IFC re-shapes inside the sizing pass); under-invalidation
683
    // is the 200px bug.
684
4733
    if !css_dirty.is_empty() {
685
12
        let needs_layout_work = css_dirty
686
12
            .iter()
687
13
            .any(|(_, scope)| *scope != azul_css::props::property::RelayoutScope::None);
688
12
        if needs_layout_work {
689
10
            let mut dom_to_tree: HashMap<NodeId, usize> =
690
10
                HashMap::with_capacity(new_tree.nodes.len());
691
66
            for (idx, node) in new_tree.nodes.iter().enumerate() {
692
66
                if let Some(d) = node.dom_node_id {
693
66
                    dom_to_tree.insert(d, idx);
694
66
                }
695
            }
696
204
            for (dom_id_dirty, scope) in css_dirty {
697
194
                if *scope == azul_css::props::property::RelayoutScope::None {
698
66
                    continue;
699
128
                }
700
128
                if let Some(&idx) = dom_to_tree.get(dom_id_dirty) {
701
128
                    recon_result.intrinsic_dirty.insert(idx);
702
128
                    recon_result.layout_roots.insert(idx);
703
128
                }
704
            }
705
2
        }
706
4721
    }
707

            
708
    // Step 1.2: Clear Taffy Caches for Dirty Nodes — and their ANCESTORS.
709
    //
710
    // The per-node `taffy_cache` persists across passes (it rides the warm
711
    // carry), keyed by taffy on (known_dimensions, available_space,
712
    // run_mode). Input changes miss by key on their own; what the key can
713
    // NOT see is a content change INSIDE the measured subtree — a deep text
714
    // edit changes what a flex item measures to at the SAME inputs. The
715
    // dirty node alone isn't enough: every ancestor's measure derives from
716
    // it, so the closure must be cleared (same propagation rule as
717
    // `calculate_intrinsic_sizes`' dirty_closure).
718
    //
719
    // This closure-clear is what made it safe to DELETE the unconditional
720
    // "clear every child before every flex layout" hammer in
721
    // `layout_taffy_subtree` (taffy_bridge.rs, from Nov 2025 — it predates
722
    // reconcile-driven dirtiness and was forcing 312 min/max-content
723
    // subtree re-layouts per pass on big.md, ~40% of a steady resize).
724
    {
725
4733
        let closure = sizing::compute_dirty_ancestor_closure(
726
4733
            &new_tree,
727
4733
            &recon_result.intrinsic_dirty,
728
        );
729
169256
        for &node_idx in &closure {
730
164523
            if let Some(warm) = new_tree.warm_mut(LayoutNodeId::new(node_idx)) {
731
164523
                warm.taffy_cache.clear();
732
164523
                warm.measured_content_sizes = (None, None);
733
164523
            }
734
        }
735
    }
736

            
737
    // Step 1.3: Compute CSS Counters
738
    // This must be done after tree generation but before layout,
739
    // as list markers need counter values during formatting context layout
740
4733
    if resize_only && !cache.counters.is_empty() {
741
        // CSS counters are a pure function of the DOM — on a resize-skip
742
        // pass the DOM is byte-identical, so last pass's values are THE
743
        // values (0.4 ms of ::marker recount per drag frame otherwise).
744
        counter_values.clone_from(&cache.counters);
745
4733
    } else {
746
4733
        let _p = crate::probe::Probe::span("compute_counters");
747
4733
        cache::compute_counters(new_dom, &new_tree, &mut counter_values);
748
4733
    }
749
    // [az-diag g51 REVERT] 0x72 = compute_counters done (InvalidTree, if any, is after here).
750
4733
    unsafe { crate::az_mark(0x60704_u32, (0x72u32)); }
751

            
752
    // Step 1.4: Resize and invalidate per-node cache (Taffy-inspired 9+1 slot cache)
753
    // Move cache_map out of LayoutCache for the duration of layout (avoids borrow conflicts).
754
    // It will be moved back after the layout pass completes.
755
    //
756
    // Critically: the old `cache_map.entries` is indexed by OLD
757
    // layout-tree positions. The NEW tree may have re-ordered
758
    // indices (anonymous wrapper slots shifted, whitespace nodes
759
    // dropped, etc.). A plain `resize_with(default)` would silently
760
    // serve the wrong node's cached result for any shifted index.
761
    //
762
    // Re-map by stable identity: build `old_layout_idx → new_layout_idx`
763
    // via the `(dom_node_id → layout_idx)` tables on both trees,
764
    // then move each surviving cache entry into its new slot. Nodes
765
    // without a matching DOM id (pure anonymous wrappers) fall
766
    // through to the default (empty, i.e. dirty) entry.
767
4733
    let mut cache_map = std::mem::take(&mut cache.cache_map);
768
4733
    let probe_cache_remap = Some(crate::probe::Probe::span("cache_map_remap"));
769
4733
    if let Some(old_tree) = cache.tree.as_ref() {
770
471
        let mut remapped = cache::LayoutCacheMap::default();
771
471
        remapped.entries.resize_with(new_tree.nodes.len(), Default::default);
772

            
773
        // Primary mapping: DOM id → layout idx on both sides. This
774
        // covers every node that has a corresponding DOM node.
775
28279
        for (dom_id, new_indices) in &new_tree.dom_to_layout {
776
27808
            let Some(old_indices) = old_tree.dom_to_layout.get(dom_id) else {
777
218
                continue;
778
            };
779
28427
            for (pair_idx, &new_layout_idx) in new_indices.iter().enumerate() {
780
28427
                let Some(&old_layout_idx) = old_indices.get(pair_idx) else {
781
                    continue;
782
                };
783
28427
                if old_layout_idx >= LayoutNodeId::new(cache_map.entries.len())
784
28420
                    || new_layout_idx >= LayoutNodeId::new(remapped.entries.len())
785
                {
786
7
                    continue;
787
28420
                }
788
28420
                remapped.entries[new_layout_idx.index()] =
789
28420
                    core::mem::take(&mut cache_map.entries[old_layout_idx.index()]);
790
            }
791
        }
792

            
793
        // Build old-parent → [old_anon_indices] and
794
        // new-parent → [new_anon_indices]; match by pair position.
795
471
        let old_anon_by_parent = collect_anon_children_by_parent(old_tree);
796
471
        let new_anon_by_parent = collect_anon_children_by_parent(&new_tree);
797

            
798
        // For each new parent we know: look up its old twin by the
799
        // dom-id mapping we just populated, then match anon children
800
        // positionally within that parent.
801
        // Build a new→old layout-idx lookup from the primary pass.
802
471
        let mut new_to_old_layout_idx: HashMap<LayoutNodeId, LayoutNodeId> =
803
471
            HashMap::new();
804
28279
        for (dom_id, new_indices) in &new_tree.dom_to_layout {
805
27808
            let Some(old_indices) = old_tree.dom_to_layout.get(dom_id) else {
806
218
                continue;
807
            };
808
28427
            for (pair_idx, &new_layout_idx) in new_indices.iter().enumerate() {
809
28427
                if let Some(&old_layout_idx) = old_indices.get(pair_idx) {
810
28427
                    new_to_old_layout_idx.insert(new_layout_idx, old_layout_idx);
811
28427
                }
812
            }
813
        }
814

            
815
481
        for (new_parent_idx, new_anon_children) in new_anon_by_parent {
816
10
            let Some(&old_parent_idx) = new_to_old_layout_idx.get(&LayoutNodeId::new(new_parent_idx)) else {
817
                continue;
818
            };
819
10
            let Some(old_anon_children) = old_anon_by_parent.get(&old_parent_idx.index()) else {
820
1
                continue;
821
            };
822
9
            for (ord, &new_anon_idx) in new_anon_children.iter().enumerate() {
823
9
                let Some(&old_anon_idx) = old_anon_children.get(ord) else {
824
                    continue;
825
                };
826
9
                if old_anon_idx >= cache_map.entries.len()
827
9
                    || new_anon_idx >= remapped.entries.len()
828
                {
829
                    continue;
830
9
                }
831
9
                remapped.entries[new_anon_idx] =
832
9
                    core::mem::take(&mut cache_map.entries[old_anon_idx]);
833
            }
834
        }
835

            
836
471
        cache_map = remapped;
837
4262
    } else {
838
4262
        cache_map.resize_to_tree(new_tree.nodes.len());
839
4262
    }
840
4733
    drop(probe_cache_remap);
841
4733
    crate::probe::sample_peak_rss("rss:after_cache_remap");
842
169091
    for &node_idx in &recon_result.intrinsic_dirty {
843
164358
        cache_map.mark_dirty(node_idx, &new_tree.nodes);
844
164358
    }
845
9445
    for &node_idx in &recon_result.layout_roots {
846
4712
        cache_map.mark_dirty(node_idx, &new_tree.nodes);
847
4712
    }
848

            
849
    // Now create the real context with computed counters
850
4733
    let mut ctx = LayoutContext {
851
4733
        style_cache: Default::default(),
852
4733
        scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
853
4733
        styled_dom: new_dom,
854
4733
        font_manager,
855
4733
        text_selections,
856
4733
        debug_messages,
857
4733
        counters: &mut counter_values,
858
4733
        viewport_size: viewport.size,
859
4733
        fragmentation_context: None,
860
4733
            reflowed_ifcs: std::collections::BTreeSet::new(),
861
4733
        cursor_is_visible,
862
4733
        cursor_locations,
863
4733
        preedit_text,
864
4733
        cache_map, // Moved from LayoutCache; will be moved back after layout
865
4733
        image_cache,
866
4733
        content_overlay,
867
4733
        system_style,
868
4733
        get_system_time_fn,
869
4733
    };
870

            
871
    // --- Step 1.5: Early Exit Optimization ---
872
    // M12.7: `&& cache.tree.is_some()` — this "nothing changed, reuse cached
873
    // layout" fast path REQUIRES a cached tree; on COLD layout cache.tree is
874
    // None, so entering here would hit `ok_or(InvalidTree)`. recon_result must
875
    // be dirty on cold (the viewport-resize dirties the root), but if
876
    // is_clean() mis-evaluates we'd wrongly early-exit → InvalidTree. Guarding
877
    // on a cached tree is both correct (can't reuse what isn't there) and
878
    // robust. (rc=5 post-reconcile, step=2: this was the failing `?`.)
879
4733
    if recon_result.is_clean() && cache.tree.is_some() {
880
22
        debug_log!(ctx, "No changes, returning existing display list");
881
        // The reuse census must describe THIS pass: zero sizing work, full
882
        // tree reuse. Leaving the previous pass's numbers in place made a
883
        // paint-only repaint look like it charged the cold pass's layout
884
        // work (the census is load-bearing — tests assert on it).
885
22
        cache.last_intrinsic_dirty = 0;
886
22
        cache.last_reconcile_reused = recon_result.reused_nodes;
887
22
        let tree = cache.tree.as_ref().ok_or(LayoutError::InvalidTree)?;
888

            
889
        // Use cached scroll IDs if available, otherwise compute them
890
22
        let scroll_ids = if cache.scroll_ids.is_empty() {
891
            use crate::window::LayoutWindow;
892
4
            let (scroll_ids, scroll_id_to_node_id) =
893
4
                LayoutWindow::compute_scroll_ids(tree, new_dom);
894
4
            cache.scroll_ids.clone_from(&scroll_ids);
895
4
            cache.scroll_id_to_node_id = scroll_id_to_node_id;
896
4
            scroll_ids
897
        } else {
898
18
            cache.scroll_ids.clone()
899
        };
900

            
901
22
        if SKIP_DISPLAY_LIST.load(core::sync::atomic::Ordering::Relaxed) {
902
            cache.last_build_was_patched = false;
903
            cache.last_patch_damage = None;
904
            return Ok(std::sync::Arc::new(DisplayList::default()));
905
22
        }
906
22
        let dl = generate_display_list(
907
22
            &mut ctx,
908
22
            tree,
909
22
            &cache.calculated_positions,
910
22
            scroll_offsets,
911
22
            &scroll_ids,
912
22
            gpu_value_cache,
913
22
            renderer_resources,
914
22
            id_namespace,
915
22
            dom_id,
916
        )
917
22
        .map(std::sync::Arc::new)?;
918
        // Refresh the structural-identity cache with what was JUST emitted.
919
        // This early exit re-emits precisely because something the cache key
920
        // could not see changed (a paint-only CSS diff, a GPU-key mint):
921
        // leaving the old entry in place would serve STALE PAINT on the next
922
        // call, and not storing at all would re-emit every frame for the
923
        // whole life of an animation.
924
22
        let root_hash = tree.cold(LayoutNodeId::new(tree.root)).map(|c| c.subtree_hash);
925
22
        let gpu_fp = gpu_value_cache
926
22
            .map_or(0, azul_core::gpu::GpuValueCache::dl_emission_fingerprint);
927
22
        if let Some(h) = root_hash {
928
22
            cache.cached_display_list = Some((h, viewport, gpu_fp, dl.clone()));
929
22
        }
930
22
        cache.last_dynamic_context = ctx
931
22
            .styled_dom
932
22
            .get_css_property_cache()
933
22
            .dynamic_context
934
22
            .as_deref()
935
22
            .cloned();
936
        // Full re-emit, not a splice — clear the patched-build flags so the
937
        // renderers' damage override cannot replay stale patch rects.
938
22
        cache.last_build_was_patched = false;
939
22
        cache.last_patch_damage = None;
940
22
        return Ok(dl);
941
4711
    }
942

            
943
4711
    { let _ = (0xDD00_0003u32); }
944
    // [az-diag g51 REVERT] 0x80 = reached the incremental layout loop (past early-exit + remap + dirty loops).
945
4711
    unsafe { crate::az_mark(0x60704_u32, (0x80u32)); }
946
    // [az-diag g65 PATH-B VALIDATION] new_tree is still valid here (=2). Clone it into the HEAP-backed
947
    // cache.tree (set AFTER the remap+early-exit which read the OLD cache.tree). cache is the stable
948
    // &mut arg (read correctly throughout), so cache.tree is NOT a deep-SP-relative stack local. At the
949
    // sizing call we read BOTH: stack new_tree (expect 0=corrupted) vs heap cache.tree (expect 2 if
950
    // path B sidesteps the SP-drift/wild-store). If heap=2, the full cache.tree refactor will fix it.
951
4711
    cache.tree = Some((*new_tree).clone());
952
    // [az-diag g66] disambiguate the g65 heap=1: read BOTH right after the clone. 0x407C0 = stack
953
    // new_tree.nodes.len() (source), 0x407C4 = clone cache.tree.nodes.len(). If src=2 & clone=1 →
954
    // Vec::clone MIS-LIFTS (drops a node) → the full MOVE-based cache.tree refactor avoids it (do it).
955
    // If src=1=clone → corruption already reached line 758 (heisenbug) → move won't help.
956
    unsafe {
957
4711
        crate::az_mark(0x607C0_u32, (new_tree.nodes.len() as u32));
958
4711
        crate::az_mark(0x607C4_u32, (cache.tree.as_ref().map_or(999, |t| t.nodes.len()) as u32));
959
    }
960

            
961
    // --- Step 2: Incremental Layout Loop (handles scrollbar-induced reflows) ---
962
4711
    let mut calculated_positions = cache.calculated_positions.clone();
963
4711
    let mut loop_count = 0;
964
    loop {
965
4819
        loop_count += 1;
966
4819
        if loop_count > MAX_SCROLLBAR_REFLOW_ITERATIONS {
967
            debug_warning!(ctx, "Scrollbar reflow loop hit limit of {} iterations, breaking to avoid infinite loop", MAX_SCROLLBAR_REFLOW_ITERATIONS);
968
            break;
969
4819
        }
970

            
971
4819
        calculated_positions = {
972
4819
            let _p = crate::probe::Probe::span("clone_calculated_positions");
973
4819
            cache.calculated_positions.clone()
974
        };
975
        // [az-diag g70 RELIABLE free-band] 0x60780 = nodes.len AFTER the in-loop calculated_positions.clone().
976
4819
        unsafe { crate::az_mark(0x60780_u32, (new_tree.nodes.len() as u32)); }
977
4819
        let mut reflow_needed_for_scrollbars = false;
978

            
979
        {
980
4819
            crate::probe::reset_peak();
981
            // [az-diag g70 RELIABLE free-band] 0x60784 = nodes.len AFTER reset_peak (before the calc Span).
982
4819
            unsafe { crate::az_mark(0x60784_u32, (new_tree.nodes.len() as u32)); }
983
4819
            let _p = crate::probe::Probe::span("calc_intrinsic_sizes");
984
            // [az-diag g70 RELIABLE free-band] 0x60788 = nodes.len AFTER the calc_intrinsic_sizes Span.
985
4819
            unsafe { crate::az_mark(0x60788_u32, (new_tree.nodes.len() as u32)); }
986
            // [az-diag g72 FIX] REMOVED the g48 `#[cfg(feature="web_lift")] panic!(...)` that lived
987
            // here. web-transpiler => azul-layout?/web_lift IS enabled (dll/Cargo.toml:651), so this
988
            // panic WAS compiled in, and with `-Z build-std-features=panic_immediate_abort` it lowered
989
            // to a bare `brk #0x1` right after the 0x90 marker — aborting BEFORE calculate_intrinsic_sizes.
990
            // The whole-session "new_tree 2→0 corruption" was a MIRAGE: the beforeCall marker store was
991
            // dead-code-eliminated (after the abort), so the harness read uninitialized 0, not a corrupted
992
            // tree. Native disasm of layout_document proved it: 0x90 marker store → `brk #0x1` → no `bl
993
            // calculate_intrinsic_sizes` anywhere. (The prior "string absent ⇒ web_lift off" check was
994
            // wrong — panic_immediate_abort strips the message string.)
995
            // [az-diag g65 PATH-B VALIDATION] 0x40748 = stack new_tree.nodes.len() (expect 0),
996
            // 0x4074C = HEAP cache.tree.nodes.len() (expect 2 if path B sidesteps the corruption).
997
            unsafe {
998
4819
                crate::az_mark(0x60748_u32, (new_tree.nodes.len() as u32));
999
4819
                crate::az_mark(0x6074C_u32, (cache.tree.as_ref().map_or(999, |t| t.nodes.len()) as u32));
            }
4819
            cache.last_intrinsic_dirty = recon_result.intrinsic_dirty.len();
4819
            calculate_intrinsic_sizes(
4819
                &mut ctx,
4819
                &mut new_tree,
4819
                text_cache,
4819
                &recon_result.intrinsic_dirty,
            )?;
        }
4819
        crate::probe::sample_peak_rss("rss:after_calc_intrinsic");
4819
        crate::probe::sample_phase_peak("rss:peak_during_intrinsic");
        // divergence is inside calculate_intrinsic_sizes (the SIMD/text intrinsic pass).
4819
        { let _ = (0xDD00_0005u32); }
9639
        for &root_idx in &recon_result.layout_roots {
4820
            let (cb_pos, cb_size) = get_containing_block_for_node(
4820
                &new_tree,
4820
                new_dom,
4820
                root_idx,
4820
                &calculated_positions,
4820
                viewport,
4820
            );
            // 0x05, the divergence is INSIDE get_containing_block_for_node (or the for-loop
            // entry); if 0x53 but not 0x55, it's the margin logic / box_props.unpack below.
4820
            { let _ = (0xDD00_0053u32); }
            // get_containing_block_for_node(viewport)). 800 here but viewport=800 ⟹ OK;
            // 0 here with viewport=800 ⟹ get_containing_block_for_node lost it (HFA return).
            // For ROOT nodes (no parent), we need to account for their margin.
            // The containing block position from viewport is (0, 0), but the root's
            // content starts at (margin + border + padding, margin + border + padding).
            // We pass margin-adjusted position so calculate_content_box_pos works correctly.
4820
            let root_node = &new_tree.nodes[root_idx];
4820
            let root_bp = root_node.box_props.unpack();
4820
            { let _ = (0xDD00_0054u32); }
4820
            let is_root_with_margin = root_node.parent.is_none()
4810
                && (root_bp.margin.left != 0.0 || root_bp.margin.top != 0.0);
4820
            let adjusted_cb_pos = if is_root_with_margin {
1723
                LogicalPosition::new(
1723
                    cb_pos.x + root_bp.margin.left,
1723
                    cb_pos.y + root_bp.margin.top,
                )
            } else {
3097
                cb_pos
            };
4820
            { let _ = (0xDD00_0056u32); }
            // DEBUG: Log containing block info for this root
4820
            if let Some(debug_msgs) = ctx.debug_messages.as_mut() {
4144
                let dom_name = root_node
4144
                    .dom_node_id
4144
                    .and_then(|id| new_dom.node_data.as_container().internal.get(id.index())).map_or_else(|| "Unknown".to_string(), |n| format!("{:?}", n.node_type));
4144
                debug_msgs.push(LayoutDebugMessage::new(
4144
                    LayoutDebugMessageType::PositionCalculation,
4144
                    format!(
4144
                        "[LAYOUT ROOT {}] {} - CB pos=({:.2}, {:.2}), adjusted=({:.2}, {:.2}), \
4144
                         CB size=({:.2}x{:.2}), viewport=({:.2}x{:.2}), margin=({:.2}, {:.2})",
                        root_idx,
                        dom_name,
                        cb_pos.x,
                        cb_pos.y,
                        adjusted_cb_pos.x,
                        adjusted_cb_pos.y,
                        cb_size.width,
                        cb_size.height,
                        viewport.size.width,
                        viewport.size.height,
                        root_bp.margin.left,
                        root_bp.margin.top
                    ),
                ));
676
            }
            // Purge after intrinsic sizing — frees child_intrinsics Vecs,
            // IntrinsicSizeCalculator temporaries, text measurement caches.
4820
            crate::probe::hint_purge_allocator();
4820
            crate::probe::sample_peak_rss("rss:before_root_layout");
4820
            crate::probe::reset_peak();
            // 0x57 = it RETURNED. If step stays 0x55, calculate_layout_for_subtree diverges.
4820
            { let _ = (0xDD00_0055u32); }
            // This is exactly what calc_used_size reads as `viewport`. 0 here pinpoints the
            // loss to the ctx build (viewport.size → ctx.viewport_size copy).
            // 0x5E = Err. Do NOT propagate (continue to the cache store) so layout-real can
            // see whether the geometry was computed regardless of a (possibly spurious,
            // niche-Result-mis-discriminated) Err.
4820
            let clr = {
4820
                let _p = crate::probe::Probe::span("root_layout_pass");
4820
                cache::calculate_layout_for_subtree(
4820
                    &mut ctx,
4820
                    &mut new_tree,
4820
                    text_cache,
4820
                    root_idx,
4820
                    adjusted_cb_pos,
4820
                    cb_size,
4820
                    &mut calculated_positions,
4820
                    &mut reflow_needed_for_scrollbars,
4820
                    &mut cache.float_cache,
4820
                    cache::ComputeMode::PerformLayout,
                )
            };
4820
            { let _ = (if clr.is_ok() { 0xDD00_0057u32 } else { 0xDD00_005Eu32 }); }
4820
            crate::probe::sample_peak_rss("rss:after_root_layout");
4820
            crate::probe::sample_phase_peak("rss:peak_during_root_layout");
            // CRITICAL: Insert the root node's own position into calculated_positions
            // This is necessary because calculate_layout_for_subtree only inserts
            // positions for children, not for the root itself.
            //
            // For root nodes, the position should be at (margin.left, margin.top) relative
            // to the viewport origin, because the margin creates space between the viewport
            // edge and the element's border-box.
4820
            if !pos_contains(&calculated_positions, root_idx) {
4205
                let root_node = &new_tree.nodes[root_idx];
4205
                let root_bp2 = root_node.box_props.unpack();
                // Calculate the root's border-box position by adding margins to viewport origin
                // This is different from non-root nodes which inherit their position from
                // their containing block.
4205
                let root_position = LogicalPosition::new(
4205
                    cb_pos.x + root_bp2.margin.left,
4205
                    cb_pos.y + root_bp2.margin.top,
                );
                // DEBUG: Log root positioning
4205
                if let Some(debug_msgs) = ctx.debug_messages.as_mut() {
3839
                    let dom_name = root_node
3839
                        .dom_node_id
3839
                        .and_then(|id| new_dom.node_data.as_container().internal.get(id.index())).map_or_else(|| "Unknown".to_string(), |n| format!("{:?}", n.node_type));
3839
                    debug_msgs.push(LayoutDebugMessage::new(
3839
                        LayoutDebugMessageType::PositionCalculation,
3839
                        format!(
3839
                            "[ROOT POSITION {}] {} - Inserting position=({:.2}, {:.2}) (viewport origin + margin), \
3839
                             margin=({:.2}, {:.2}, {:.2}, {:.2})",
                            root_idx,
                            dom_name,
                            root_position.x,
                            root_position.y,
                            root_bp2.margin.top,
                            root_bp2.margin.right,
                            root_bp2.margin.bottom,
                            root_bp2.margin.left
                        ),
                    ));
366
                }
4205
                pos_set(&mut calculated_positions, root_idx, root_position);
615
            }
        }
        // (step 6). If step stays 5, the divergence is in calculate_layout_for_subtree.
4819
        { let _ = (0xDD00_0006u32); }
4819
        {
4819
            let _p = crate::probe::Probe::span("reposition_clean_subtrees");
4819
            cache::reposition_clean_subtrees(
4819
                new_dom,
4819
                &new_tree,
4819
                &recon_result.layout_roots,
4819
                &mut calculated_positions,
4819
            );
4819
        }
4819
        if reflow_needed_for_scrollbars {
108
            debug_log!(ctx,
108
                "Scrollbars changed container size, starting full reflow (loop {})",
                loop_count
            );
108
            recon_result.layout_roots.clear();
108
            recon_result.layout_roots.insert(new_tree.root);
            // Deliberately NOT touching intrinsic_dirty. A scrollbar toggle
            // changes AVAILABLE SPACE (container inner width shrinks/grows by
            // scrollbar_width); intrinsic min/max-content widths are CONTENT-
            // derived and cannot change because a scrollbar appeared. This
            // line used to mark EVERY node intrinsic-dirty
            // (`(0..nodes.len()).collect()`), so any resize that toggled a
            // scrollbar re-measured the whole document's intrinsics — 75 ms
            // of the measured 166 ms on big.md's first in-range resize, and
            // the same sledgehammer pattern as the old drop-the-tree-on-
            // viewport-change. Nodes whose intrinsics genuinely changed are
            // already in the set from reconciliation; the root layout_root
            // above re-resolves every SIZE against the new inner width.
108
            continue;
4711
        }
4711
        break;
    }
    // +spec:positioning:8d1286 - normal flow, relative, float, absolute positioning dispatch
    // +spec:positioning:bdfc81 - Layout divided into sizing (Step 2) then positioning (Step 3)
    // --- Step 3: Adjust Relatively Positioned Elements ---
    // +spec:positioning:a831e8 - inline content width uses pre-relative-offset positions (satisfied by post-layout relative adjustment)
    // +spec:positioning:e2647b - Relative positioning applied after line height calculation, so line height is not adjusted for relative offsets
    // +spec:positioning:77a2d2 - Relatively positioned boxes considered without their offset during auto height
    // +spec:positioning:b47ac2 - Relatively positioned boxes considered without their offset for block auto height
    // Relative offsets applied AFTER layout, so auto-height calculation sees normal-flow positions.
    // This must be done BEFORE positioning out-of-flow elements, because
    // relatively positioned elements establish containing blocks for their
    // absolutely positioned descendants. If we adjust relative positions after
    // positioning absolute elements, the absolute elements will be positioned
    // relative to the wrong (pre-adjustment) position of their containing block.
    // Pass the viewport to correctly resolve percentage offsets for the root element.
4711
    {
4711
        let _p = crate::probe::Probe::span("adjust_relative_positions");
4711
        positioning::adjust_relative_positions(
4711
            &mut ctx,
4711
            &new_tree,
4711
            &mut calculated_positions,
4711
            viewport,
4711
        );
4711
    }
    // --- Step 3.25: Adjust Sticky Positioned Elements ---
    // Sticky elements are laid out in normal flow, then their visual position
    // is clamped based on scroll offset and inset properties relative to the
    // nearest scrollport. Must happen after relative positioning but before
    // absolute positioning (sticky elements establish containing blocks).
4711
    {
4711
        let _p = crate::probe::Probe::span("adjust_sticky_positions");
4711
        positioning::adjust_sticky_positions(
4711
            &mut ctx,
4711
            &new_tree,
4711
            &mut calculated_positions,
4711
            scroll_offsets,
4711
            viewport,
4711
        );
4711
    }
    // --- Step 3.5: Position Out-of-Flow Elements ---
    // This must be done AFTER adjusting relative positions, so that absolutely
    // positioned elements are positioned relative to the final (post-adjustment)
    // position of their relatively positioned containing blocks.
4711
    {
4711
        let _p = crate::probe::Probe::span("position_out_of_flow");
4711
        positioning::position_out_of_flow_elements(
4711
            &mut ctx,
4711
            &mut new_tree,
4711
            text_cache,
4711
            &mut calculated_positions,
4711
            viewport,
4711
        );
4711
    }
    // --- Step 3.75: Compute Stable Scroll IDs ---
    // This must be done AFTER layout but BEFORE display list generation
4711
    let (scroll_ids, scroll_id_to_node_id) = {
4711
        let _p = crate::probe::Probe::span("compute_scroll_ids");
4711
        LayoutWindow::compute_scroll_ids(&new_tree, new_dom)
4711
    };
4711
    crate::probe::sample_peak_rss("rss:before_display_list");
4711
    crate::probe::reset_peak();
    // --- Step 4: Generate Display List & Update Cache ---
    // (changed-node set, geometry-base rects) of a PATCHED build; consumed
    // after the final list exists to add the changed nodes' ITEM visual
    // bounds from both the old and the new list (shadow fringes).
4711
    let mut patch_damage_pending: Option<(
4711
        std::collections::BTreeSet<usize>,
4711
        Vec<LogicalRect>,
4711
    )> = None;
4711
    let display_list = if SKIP_DISPLAY_LIST.load(core::sync::atomic::Ordering::Relaxed) {
        // Web backend: positions are done; the painter is dead weight.
        DisplayList::default()
    } else {
4711
        let _p = crate::probe::Probe::span("generate_display_list");
        // DL PATCHING (task 12 round 2): on a resize-skip pass the tree
        // object and its indices are unchanged, so the previous display
        // list's per-node item runs can substitute for the paint calls of
        // every node that neither re-flowed its inline content nor changed
        // size — their items just translate by the node's position delta.
        // `AZ_NO_DL_PATCH=1` is the runtime escape hatch (and the A/B lever
        // for the golden-equality test).
4711
        let patch_disabled = !display_list::dl_patching_enabled();
        // Two ways in: the resize-skip pass (same tree object), or a
        // STRUCTURE-PRESERVED reconcile with no CSS dirt — a text edit
        // reflowed its IFC, everything else splices (per-IFC text patching,
        // USER mandate 2026-08-17). CSS-dirty passes must not splice: their
        // items changed paint without reflowing.
        // Cascade-context equality: an @media/theme/OS flip restyles REUSED
        // nodes without touching NodeData or css_dirty (the reconcile is
        // blind to the cascade), so the structure-preserved arm must also
        // prove the context the cached DL was built under is unchanged.
        // The resize-skip arm keeps its own contract (the harvested
        // breakpoints guarantee no answer flips between crossings).
4711
        let cascade_ctx_unchanged = {
4711
            let cur = ctx
4711
                .styled_dom
4711
                .get_css_property_cache()
4711
                .dynamic_context
4711
                .as_deref();
            #[cfg(feature = "std")]
4711
            if std::env::var_os("AZ_PATCH_DEBUG").is_some() {
                match (cur, cache.last_dynamic_context.as_ref()) {
                    (Some(a), Some(b)) if a != b => {
                        eprintln!(
                            "[CTXDIFF] vw={}/{} vh={}/{} theme={} media={} pseudo={} lang={} focus={}/{} os={} cont={}",
                            a.viewport_width, b.viewport_width,
                            a.viewport_height, b.viewport_height,
                            a.theme == b.theme, a.media_type == b.media_type,
                            a.pseudo_state == b.pseudo_state,
                            a.language == b.language,
                            a.window_focused, b.window_focused,
                            a.os == b.os && a.os_version == b.os_version && a.desktop_env == b.desktop_env,
                            a.container_width.to_bits() == b.container_width.to_bits()
                                && a.container_height.to_bits() == b.container_height.to_bits()
                                && a.container_name == b.container_name,
                        );
                    }
                    (a, b) if a.is_some() != b.is_some() => {
                        eprintln!("[CTXDIFF] presence cur={} stored={}", a.is_some(), b.is_some());
                    }
                    _ => {}
                }
4711
            }
4711
            match (cur, cache.last_dynamic_context.as_ref()) {
613
                (Some(a), Some(b)) => a == b,
313
                (None, None) => true,
3785
                _ => false,
            }
        };
4711
        let structure_ok = cache.last_reconcile_was_skipped
4684
            || (cache.last_reconcile_structure_preserved
140
                && css_dirty.is_empty()
131
                && cascade_ctx_unchanged);
        #[cfg(feature = "std")]
4711
        if std::env::var_os("AZ_PATCH_DEBUG").is_some() {
            eprintln!(
                "[PATCHGATE] skipped={} preserved={} css_dirty={} structure_ok={} disabled={} \
                 prev_sizes={} nodes={} prev_pos={} pos={} ctx_same={} reflowed_ifcs={:?} fresh={:?}",
                cache.last_reconcile_was_skipped,
                cache.last_reconcile_structure_preserved,
                css_dirty.len(),
                structure_ok,
                patch_disabled,
                cache.previous_sizes.len(),
                new_tree.nodes.len(),
                cache.calculated_positions.len(),
                calculated_positions.len(),
                cascade_ctx_unchanged,
                ctx.reflowed_ifcs,
                recon_result.fresh_indices,
            );
4711
        }
        // (previous_sizes vs NODES is intended: the cache keeps one entry
        // per node, so equal lengths mean the size snapshot covers the tree.)
        #[allow(clippy::suspicious_operation_groupings)]
4711
        let patch = if structure_ok
71
            && !patch_disabled
62
            && cache.previous_sizes.len() == new_tree.nodes.len()
62
            && cache.calculated_positions.len() == calculated_positions.len()
        {
62
            cache.cached_display_list.as_ref().and_then(|(_, _, _, prev_dl)| {
44
                let new_sizes: Vec<Option<LogicalSize>> =
44
                    new_tree.nodes.iter().map(|n| n.used_size).collect();
                // Fresh/content-changed nodes must RE-EMIT, never splice —
                // their previous items describe content that no longer
                // exists (the text-edit case, and same-count replaces). The
                // intrinsic-dirty ANCESTOR chain is deliberately not here:
                // an ancestor whose geometry did not change splices its
                // unchanged items (size/position changes re-emit via
                // PatchState's own size diff).
44
                let mut reemit = ctx.reflowed_ifcs.clone();
44
                reemit.extend(recon_result.fresh_indices.iter().copied());
44
                display_list::PatchState::build(
44
                    prev_dl,
44
                    &cache.calculated_positions, // last pass's positions (replaced later)
44
                    &calculated_positions,
44
                    &cache.previous_sizes,
44
                    &new_sizes,
44
                    &reemit,
                )
44
            })
        } else {
4649
            None
        };
4711
        cache.last_build_was_patched = patch.is_some();
4711
        cache.last_patch_damage = None;
4711
        if patch.is_some() {
            // The patch's own damage, phase 1 of 2: the CHANGED-node set
            // (re-emitted OR moved/resized) plus old ∪ new NODE bounds as the
            // geometry base (covers vacated areas and items with no bounds).
            // Phase 2 — after the final list exists — unions the changed
            // nodes' ITEM visual bounds from BOTH lists on top: paint like
            // box-shadow extends OUTSIDE the node box, so node rects alone
            // under-damage a shadow fringe.
44
            let mut rects: Vec<LogicalRect> = Vec::new();
44
            let mut changed: std::collections::BTreeSet<usize> = ctx.reflowed_ifcs.clone();
44
            changed.extend(recon_result.fresh_indices.iter().copied());
606
            for (idx, node) in new_tree.nodes.iter().enumerate() {
606
                let old_pos = pos_get(&cache.calculated_positions, idx);
606
                let new_pos = pos_get(&calculated_positions, idx);
606
                let old_size = cache.previous_sizes.get(idx).copied().flatten();
606
                let new_size = node.used_size;
606
                if !(changed.contains(&idx) || old_pos != new_pos || old_size != new_size) {
365
                    continue;
241
                }
241
                changed.insert(idx);
241
                if let (Some(p), Some(sz)) = (old_pos, old_size) {
234
                    rects.push(LogicalRect::new(p, sz));
234
                }
241
                if let (Some(p), Some(sz)) = (new_pos, new_size) {
234
                    rects.push(LogicalRect::new(p, sz));
234
                }
            }
44
            patch_damage_pending = Some((changed, rects));
4667
        }
4711
        if patch.is_some() {
44
            drop(crate::probe::Probe::span("dl_patched_pass"));
            // Round-3 presentation hint from the SAME inputs the patch used.
            // The re-emit set must match PatchState's exactly: reflowed IFCs
            // PLUS size-changed nodes — a size-changed node re-emitted its
            // items, so blitting its old pixels without repainting is stale.
44
            let new_sizes: Vec<Option<LogicalSize>> =
44
                new_tree.nodes.iter().map(|n| n.used_size).collect();
44
            let mut reemit_full = ctx.reflowed_ifcs.clone();
44
            reemit_full.extend(recon_result.fresh_indices.iter().copied());
606
            for (i, (prev, new)) in cache
44
                .previous_sizes
44
                .iter()
44
                .zip(new_sizes.iter())
44
                .enumerate()
            {
606
                if prev != new {
28
                    reemit_full.insert(i);
578
                }
            }
44
            let parents: Vec<Option<usize>> =
44
                new_tree.nodes.iter().map(|n| n.parent).collect();
44
            let opaque_bg: Vec<bool> = (0..new_tree.nodes.len())
606
                .map(|i| {
606
                    new_tree
606
                        .get(LayoutNodeId::new(i))
606
                        .and_then(|node| node.dom_node_id)
606
                        .is_some_and(|dom_id| {
606
                            let state = &ctx.styled_dom.styled_nodes.as_container()
606
                                [dom_id]
606
                                .styled_node_state;
606
                            getters::get_background_color(
606
                                ctx.styled_dom,
606
                                dom_id,
606
                                state,
606
                            )
606
                            .a
606
                                == 255
606
                        })
606
                })
44
                .collect();
44
            cache.last_patch_move = display_list::compute_patch_move_summary(
44
                &cache.calculated_positions,
44
                &calculated_positions,
44
                &cache.previous_sizes,
44
                &new_sizes,
44
                &reemit_full,
44
                &parents,
44
                &opaque_bg,
            );
4667
        } else {
4667
            cache.last_patch_move = None;
4667
        }
4711
        display_list::generate_display_list_impl(
4711
            &mut ctx,
4711
            &new_tree,
4711
            &calculated_positions,
4711
            scroll_offsets,
4711
            &scroll_ids,
4711
            gpu_value_cache,
4711
            renderer_resources,
4711
            id_namespace,
4711
            dom_id,
4711
            patch,
        )?
    };
4711
    crate::probe::sample_phase_peak("rss:peak_during_display_list");
    // Move cache_map back into LayoutCache before dropping ctx
4711
    let _p_writeback = crate::probe::Probe::span("cache_writeback");
4711
    let cache_map_back = std::mem::take(&mut ctx.cache_map);
    // Cache the freshly-generated display list keyed on the root's
    // subtree_hash + viewport. If the next `layout_document` call
    // sees matching values after reconcile, it returns this clone
    // directly and skips all downstream work.
4711
    let root_subtree_hash = new_tree
4711
        .cold(LayoutNodeId::new(new_tree.root))
4711
        .map_or(layout_tree::SubtreeHash(0), |c| c.subtree_hash);
    // The DL is shared, not copied: one allocation serves the cache slot, the
    // caller's `DomLayoutResult`, and any virtual-view snapshot maps. Rare
    // post-build patches (`patch_node_image` / `patch_text_glyphs` / the
    // virtual-view placeholder swap) go through `Arc::make_mut`.
4711
    let display_list = std::sync::Arc::new(display_list);
    // Record the population fingerprint of the SAME GpuValueCache the emission
    // above consumed (the &-param is immutable for the whole pass, so entry
    // fingerprint == emission fingerprint). Keys minted after this call — the
    // scrollbar registration in the caller, animation seeding — change the
    // manager's cache, the next call's fingerprint differs, and the cache
    // correctly misses once and re-seeds.
4711
    let gpu_fp = gpu_value_cache.map_or(0, azul_core::gpu::GpuValueCache::dl_emission_fingerprint);
    #[cfg(feature = "std")]
4711
    if std::env::var_os("AZ_ANIM_DEBUG").is_some() {
        eprintln!("[dlcache] STORE fp={gpu_fp:x} items={}", display_list.items.len());
4711
    }
    // Patched-build damage, phase 2: union the changed nodes' ITEM visual
    // bounds from the OLD list (still in the cache slot here) and the NEW
    // one on top of the geometry base. `visual_bounds()` includes paint
    // that extends outside the node box (box-shadow fringes) — the same
    // bounds the item diff damages with.
4711
    if let Some((changed, mut rects)) = patch_damage_pending.take() {
        {
88
            let mut add_items = |dl: &DisplayList| {
3338
                for (i, m) in dl.layout_node_mapping.iter().enumerate() {
3338
                    if let Some((idx, _)) = m {
2934
                        if changed.contains(idx) {
2178
                            if let Some(b) = dl.items[i].visual_bounds() {
2178
                                rects.push(b);
2178
                            }
756
                        }
404
                    }
                }
88
            };
44
            if let Some((_, _, _, old_dl)) = cache.cached_display_list.as_ref() {
44
                add_items(old_dl);
44
            }
44
            add_items(&display_list);
        }
        #[cfg(feature = "std")]
44
        if std::env::var_os("AZ_PATCH_DEBUG").is_some() {
            eprintln!("[PATCHDMG] rects={rects:?}");
44
        }
44
        cache.last_patch_damage = Some(rects);
4667
    }
    // The context this build was made under — the structure-preserved patch
    // arm compares the NEXT pass's context against it. Clone only on CHANGE:
    // the steady state (every keystroke) is an equal context, and the clone
    // allocates (AzString language, container name).
    {
4711
        let cur = ctx
4711
            .styled_dom
4711
            .get_css_property_cache()
4711
            .dynamic_context
4711
            .as_deref();
4711
        let same = match (cur, cache.last_dynamic_context.as_ref()) {
613
            (Some(a), Some(b)) => a == b,
313
            (None, None) => true,
3785
            _ => false,
        };
4711
        if !same {
4007
            cache.last_dynamic_context = cur.cloned();
4009
        }
    }
4711
    cache.cached_display_list =
4711
        Some((root_subtree_hash, viewport, gpu_fp, display_list.clone()));
4711
    cache.tree = Some(*new_tree); // [g56] unbox the heap LayoutTree back into the cache
4711
    cache.previous_positions = std::mem::replace(&mut cache.calculated_positions, calculated_positions);
4711
    cache.viewport = Some(viewport);
4711
    cache.scroll_ids = scroll_ids;
4711
    cache.scroll_id_to_node_id = scroll_id_to_node_id;
    // + calculated_positions.len in the low bits. If step stays 3, it diverged earlier.
4711
    { let _ = (0xDD00_0004u32 | ((cache.calculated_positions.len() as u32 & 0xfff) << 4)); }
4711
    let _doc_tail_span = crate::probe::Probe::span("doc_tail_writeback");
4711
    cache.counters = counter_values;
4711
    cache.cache_map = cache_map_back;
4711
    crate::probe::sample_peak_rss("rss:after_layout_document");
4711
    Ok(display_list)
4894
}
// +spec:containing-block:159830 - Containing block chain: parent content-box for in-flow, viewport for initial containing block
// +spec:containing-block:22fbaa - computes the element's original containing block (before positioning effects)
// +spec:containing-block:238fc5 - containing block dimensions calculated here (CSS 2.2 §9.1.2 forward ref to §10)
// +spec:containing-block:263629 - block element's content-box establishes the containing block for its line boxes
// +spec:containing-block:2a5280 - boxes act as containing blocks for descendants; CB = parent's content box
// +spec:containing-block:6776cb - boxes positioned w.r.t. containing block but not confined; overflow allowed
// +spec:containing-block:718894 - CB derived from parent content-box edges; root uses initial CB (viewport)
// +spec:containing-block:a2aa37 - box edges act as containing block for descendants; initial containing block = viewport
// +spec:containing-block:e23b3f - CSS 2.2 §10.1: initial containing block = viewport; static/relative = parent content-box; fixed = viewport
// +spec:containing-block:e8fdb2 - Containing block resolution (CSS2 §9.1.2, §10.1)
// +spec:overflow:9a2b11 - containing block is content-box of parent; boxes may overflow it
// +spec:positioning:acc663 - containing block definition: element boxes positioned relative to containing block
6324
pub(super) fn get_containing_block_for_node(
6324
    tree: &LayoutTree,
6324
    styled_dom: &StyledDom,
6324
    node_idx: usize,
6324
    calculated_positions: &PositionVec,
6324
    viewport: LogicalRect,
6324
) -> (LogicalPosition, LogicalSize) {
6324
    if let Some(parent_idx) = tree.get(LayoutNodeId::new(node_idx)).and_then(|n| n.parent) {
20
        if let Some(parent_node) = tree.get(LayoutNodeId::new(parent_idx)) {
19
            let pos = pos_get(calculated_positions, parent_idx)
19
                .unwrap_or(viewport.origin);
19
            let size = parent_node.used_size.unwrap_or_default();
            // Position in calculated_positions is the margin-box position
            // To get content-box, add: border + padding (NOT margin, that's already in pos)
19
            let pbp = parent_node.box_props.unpack();
19
            let content_pos = LogicalPosition::new(
19
                pos.x + pbp.border.left + pbp.padding.left,
19
                pos.y + pbp.border.top + pbp.padding.top,
            );
19
            if let Some(dom_id) = parent_node.dom_node_id {
18
                let styled_node_state = &styled_dom
18
                    .styled_nodes
18
                    .as_container()
18
                    .get(dom_id)
18
                    .map(|n| &n.styled_node_state)
18
                    .copied()
18
                    .unwrap_or_default();
                // +spec:containing-block:c205e5 - writing mode of containing block used for inner_size (orthogonal flow awareness)
18
                let writing_mode =
18
                    get_writing_mode(styled_dom, dom_id, styled_node_state).unwrap_or_default();
18
                let content_size = pbp.inner_size(size, writing_mode);
18
                return (content_pos, content_size);
1
            }
1
            return (content_pos, size);
1
        }
6304
    }
    // +spec:containing-block:41bdfc - ICB equals viewport; overflow:hidden on root clips to ICB
    // +spec:containing-block:1eed60 - Initial containing block establishes a BFC; viewport is the ICB
    // +spec:containing-block:99866f - Containing block is a rectangle for sizing/positioning; ICB from viewport
    // +spec:containing-block:22f09b - viewport serves as initial containing block for root element
    // Root element's containing block is the initial containing block (CSS 2.2 §10.1, CSS Display 3 §2.8).
    // +spec:containing-block:2fd7b1 - ICB equals viewport; principal writing mode propagated to ICB
    // Root element's containing block is the initial containing block (CSS 2.2 §10.1, CSS Display 3 §2.8).
    // The principal writing mode is propagated to the ICB and viewport (css-writing-modes-4 §8.1).
    // +spec:containing-block:5efb84 - Root element's containing block is the initial containing block
    // +spec:containing-block:6278fb - initial containing block is the viewport; also serves as initial fixed containing block
    // Root element's containing block is the initial containing block (CSS 2.2 §10.1, CSS Display 3 §2.8).
    // For ROOT nodes: the containing block is the viewport (initial containing block).
    // Do NOT subtract margin here - margins are handled in calculate_used_size().
    // The margin creates space between viewport edge and element's border-box,
    // but the available space for calculating width/height percentages
    // is still the full viewport size.
6305
    (viewport.origin, viewport.size)
6324
}
// [g119 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the `Text(font_traits::LayoutError)`
// variant's String/FontSelector pointer gives `Result<T, LayoutError>` a POINTER-niche disc, which
// the web lift MIS-READS → every solver3 `?`/Result return flips Ok→Err (heisenbug; g118 = collect's
// Result<(),LayoutError> arrived as Err → rc=5 InvalidTree though the out-param content was correct).
// An explicit u8 tag (0..=4) moves the Result niche to unused tag values (5..) = a simple u8 compare
// the lift handles. Same disc-mis-lift class as InlineContent/LogicalItem/ShapedItem (g117/g118).
#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
#[derive(Debug)]
#[repr(C, u8)]
pub enum LayoutError {
    InvalidTree,
    SizingFailed,
    PositioningFailed,
    DisplayListFailed,
    Text(crate::font_traits::LayoutError),
}
impl std::fmt::Display for LayoutError {
19
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19
        match self {
5
            Self::InvalidTree => write!(f, "Invalid layout tree"),
2
            Self::SizingFailed => write!(f, "Sizing calculation failed"),
2
            Self::PositioningFailed => write!(f, "Position calculation failed"),
2
            Self::DisplayListFailed => write!(f, "Display list generation failed"),
8
            Self::Text(e) => write!(f, "Text layout error: {e:?}"),
        }
19
    }
}
impl From<crate::font_traits::LayoutError> for LayoutError {
2
    fn from(err: crate::font_traits::LayoutError) -> Self {
2
        Self::Text(err)
2
    }
}
impl std::error::Error for LayoutError {}
pub type Result<T> = std::result::Result<T, LayoutError>;
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::too_many_lines)]
mod autotest_generated {
    use azul_core::dom::{Dom, FormattingContext};
    use super::*;
    use crate::solver3::{
        geometry::{EdgeSizes, PackedBoxProps, ResolvedBoxProps},
        layout_tree::{LayoutNodeCold, LayoutNodeHot, LayoutNodeWarm},
    };
    // ==================================================================
    // Fixtures
    // ==================================================================
    fn pos(x: f32, y: f32) -> LogicalPosition {
        LogicalPosition::new(x, y)
    }
    fn size(w: f32, h: f32) -> LogicalSize {
        LogicalSize::new(w, h)
    }
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
        LogicalRect {
            origin: pos(x, y),
            size: size(w, h),
        }
    }
    fn edges(top: f32, right: f32, bottom: f32, left: f32) -> EdgeSizes {
        EdgeSizes {
            top,
            right,
            bottom,
            left,
        }
    }
    /// `ResolvedBoxProps` with the given padding + border and no margin.
    fn bp(padding: EdgeSizes, border: EdgeSizes) -> ResolvedBoxProps {
        ResolvedBoxProps {
            margin: EdgeSizes::default(),
            padding,
            border,
            ..ResolvedBoxProps::default()
        }
    }
    fn hot(
        parent: Option<usize>,
        dom_node_id: Option<NodeId>,
        used_size: Option<LogicalSize>,
        props: &ResolvedBoxProps,
    ) -> LayoutNodeHot {
        LayoutNodeHot {
            box_props: PackedBoxProps::pack(props),
            dom_node_id,
            used_size,
            formatting_context: FormattingContext::default(),
            parent,
        }
    }
    /// A `LayoutTree` carrying only what `get_containing_block_for_node` reads:
    /// hot nodes (parent link, box props, `used_size`, `dom_node_id`).
    fn tree_of(nodes: Vec<LayoutNodeHot>) -> LayoutTree {
        let n = nodes.len();
        LayoutTree {
            nodes,
            warm: vec![LayoutNodeWarm::default(); n],
            cold: vec![LayoutNodeCold::default(); n],
            root: 0,
            dom_to_layout: BTreeMap::new(),
            children_arena: Vec::new(),
            children_offsets: vec![(0, 0); n],
            subtree_needs_intrinsic: vec![false; n],
        }
    }
    /// Box props survive a lossy i16×10 pack/unpack, so geometry derived from
    /// them is compared with a tolerance well under a tenth of a pixel.
    fn close(a: f32, b: f32) -> bool {
        (a - b).abs() < 1e-3
    }
    /// `body` — one real DOM node, so `NodeId::ZERO` is always in range.
    fn body_dom() -> StyledDom {
        let mut dom = Dom::create_body();
        let (css, _warnings) = azul_css::parser2::new_from_str("");
        StyledDom::create(&mut dom, css)
    }
    // ==================================================================
    // POSITION_UNSET — the sentinel the three pos_* helpers are built on
    // ==================================================================
    #[test]
    fn position_unset_sets_both_components_to_f32_min() {
        // `pos_get`/`pos_contains` only test `x`; that shortcut is only sound
        // while the sentinel writes BOTH components. Pin the invariant here.
        assert_eq!(POSITION_UNSET.x, f32::MIN);
        assert_eq!(POSITION_UNSET.y, f32::MIN);
    }
    // ==================================================================
    // pos_get / pos_contains — bounds + sentinel (numeric)
    // ==================================================================
    #[test]
    fn pos_get_on_an_empty_vec_is_none_for_every_index() {
        let positions: PositionVec = Vec::new();
        for idx in [0usize, 1, 7, 1_000, usize::MAX / 2, usize::MAX] {
            assert!(pos_get(&positions, idx).is_none(), "idx {idx}");
            assert!(!pos_contains(&positions, idx), "idx {idx}");
        }
    }
    #[test]
    fn pos_get_past_the_end_is_none_and_never_panics() {
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 3, pos(1.0, 2.0));
        assert_eq!(positions.len(), 4);
        for idx in [4usize, 5, 100, usize::MAX] {
            assert!(pos_get(&positions, idx).is_none(), "idx {idx}");
            assert!(!pos_contains(&positions, idx), "idx {idx}");
        }
    }
    #[test]
    fn pos_get_at_zero_round_trips() {
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(0.0, 0.0));
        let got = pos_get(&positions, 0).expect("index 0 was set");
        assert_eq!(got.x, 0.0);
        assert_eq!(got.y, 0.0);
        assert!(pos_contains(&positions, 0));
    }
    #[test]
    fn an_explicitly_written_sentinel_reads_back_as_unset() {
        // Writing POSITION_UNSET is indistinguishable from never writing at all.
        // That is by design (it's how `pos_set`'s gap-fill works), but it means a
        // caller can never store the sentinel as a real position.
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, POSITION_UNSET);
        assert_eq!(positions.len(), 1);
        assert!(pos_get(&positions, 0).is_none());
        assert!(!pos_contains(&positions, 0));
    }
    #[test]
    fn a_position_whose_x_is_f32_min_reads_back_as_unset_even_with_a_real_y() {
        // Only `x` is compared against the sentinel, so `y` is silently discarded
        // whenever `x` happens to land exactly on f32::MIN.
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(f32::MIN, 42.0));
        assert!(pos_get(&positions, 0).is_none());
        assert!(!pos_contains(&positions, 0));
        // ...even though the value really is in the vec.
        assert_eq!(positions[0].y, 42.0);
    }
    #[test]
    fn negative_f32_max_is_the_same_bit_pattern_as_the_sentinel() {
        // f32::MIN == -f32::MAX, so a genuinely computed x of -f32::MAX (e.g. a
        // wildly out-of-flow element) is swallowed by the sentinel check.
        assert_eq!(f32::MIN, -f32::MAX);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(-f32::MAX, 0.0));
        assert!(pos_get(&positions, 0).is_none());
    }
    #[test]
    fn a_position_whose_y_is_f32_min_is_still_reported_as_set() {
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(0.0, f32::MIN));
        let got = pos_get(&positions, 0).expect("x is not the sentinel, so it is set");
        assert_eq!(got.x, 0.0);
        assert_eq!(got.y, f32::MIN);
        assert!(pos_contains(&positions, 0));
    }
    #[test]
    fn nan_positions_are_considered_set_and_survive_the_round_trip() {
        // NaN != f32::MIN is true, so a NaN position is "set" — it propagates into
        // layout rather than being filtered out as unset.
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(f32::NAN, f32::NAN));
        let got = pos_get(&positions, 0).expect("NaN passes the sentinel filter");
        assert!(got.x.is_nan());
        assert!(got.y.is_nan());
        assert!(pos_contains(&positions, 0));
    }
    #[test]
    fn infinite_and_extreme_positions_round_trip_unchanged() {
        let cases = [
            pos(f32::INFINITY, f32::NEG_INFINITY),
            pos(f32::MAX, -f32::MIN_POSITIVE),
            pos(-0.0, 0.0),
            pos(-1e30, 1e30),
            pos(f32::MIN_POSITIVE, f32::EPSILON),
        ];
        for (idx, case) in cases.iter().enumerate() {
            let mut positions: PositionVec = Vec::new();
            pos_set(&mut positions, idx, *case);
            let got = pos_get(&positions, idx).expect("non-sentinel x is set");
            assert_eq!(got.x.to_bits(), case.x.to_bits(), "case {idx} x");
            assert_eq!(got.y.to_bits(), case.y.to_bits(), "case {idx} y");
            assert!(pos_contains(&positions, idx), "case {idx}");
        }
    }
    #[test]
    fn pos_contains_always_agrees_with_pos_get() {
        // The predicate and the getter must never disagree — a divergence would
        // make `pos_get(..).unwrap()` guarded by `pos_contains` panic.
        let values = [
            pos(0.0, 0.0),
            pos(-0.0, -0.0),
            POSITION_UNSET,
            pos(f32::MIN, 1.0),
            pos(1.0, f32::MIN),
            pos(f32::NAN, 0.0),
            pos(f32::INFINITY, f32::INFINITY),
            pos(f32::NEG_INFINITY, 0.0),
            pos(f32::MAX, f32::MIN_POSITIVE),
            pos(-f32::MAX, 0.0),
        ];
        let mut positions: PositionVec = Vec::new();
        for (idx, v) in values.iter().enumerate() {
            pos_set(&mut positions, idx, *v);
        }
        for idx in 0..values.len() + 4 {
            assert_eq!(
                pos_contains(&positions, idx),
                pos_get(&positions, idx).is_some(),
                "idx {idx} disagrees"
            );
        }
    }
    // ==================================================================
    // pos_set — growth semantics (numeric)
    // ==================================================================
    #[test]
    fn pos_set_beyond_the_end_grows_and_fills_the_gap_with_the_sentinel() {
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 3, pos(10.0, 20.0));
        assert_eq!(positions.len(), 4, "grows to exactly idx + 1");
        for idx in 0..3 {
            assert!(pos_get(&positions, idx).is_none(), "gap idx {idx} must be unset");
            assert!(!pos_contains(&positions, idx), "gap idx {idx}");
            assert_eq!(positions[idx].x, POSITION_UNSET.x);
            assert_eq!(positions[idx].y, POSITION_UNSET.y);
        }
        let got = pos_get(&positions, 3).expect("idx 3 was set");
        assert_eq!(got.x, 10.0);
        assert_eq!(got.y, 20.0);
    }
    #[test]
    fn pos_set_inside_the_vec_neither_grows_nor_shrinks_it() {
        let mut positions: PositionVec = vec![POSITION_UNSET; 5];
        pos_set(&mut positions, 0, pos(1.0, 1.0));
        assert_eq!(positions.len(), 5);
        pos_set(&mut positions, 4, pos(2.0, 2.0));
        assert_eq!(positions.len(), 5);
        assert!(pos_contains(&positions, 0));
        assert!(pos_contains(&positions, 4));
        assert!(!pos_contains(&positions, 2), "untouched slots stay unset");
    }
    #[test]
    fn pos_set_overwrites_an_existing_entry_in_place() {
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 1, pos(1.0, 1.0));
        pos_set(&mut positions, 1, pos(-5.5, -6.5));
        assert_eq!(positions.len(), 2);
        let got = pos_get(&positions, 1).expect("still set");
        assert_eq!(got.x, -5.5);
        assert_eq!(got.y, -6.5);
    }
    #[test]
    fn pos_set_can_reset_an_entry_back_to_unset() {
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(3.0, 4.0));
        assert!(pos_contains(&positions, 0));
        pos_set(&mut positions, 0, POSITION_UNSET);
        assert!(!pos_contains(&positions, 0));
        assert!(pos_get(&positions, 0).is_none());
    }
    #[test]
    fn repeated_growth_preserves_every_earlier_entry() {
        let mut positions: PositionVec = Vec::new();
        for idx in (0..64).rev() {
            // Descending order: the first call allocates the whole vec, the rest
            // write inside it — the reverse of the ascending growth path.
            pos_set(&mut positions, idx, pos(idx as f32, -(idx as f32)));
        }
        assert_eq!(positions.len(), 64);
        for idx in 0..64 {
            let got = pos_get(&positions, idx).expect("all 64 were written");
            assert_eq!(got.x, idx as f32);
            assert_eq!(got.y, -(idx as f32));
        }
        // A single jump far past the end must keep everything already written.
        pos_set(&mut positions, 4_095, pos(1.0, 1.0));
        assert_eq!(positions.len(), 4_096);
        for idx in 0..64 {
            assert!(pos_contains(&positions, idx), "idx {idx} lost after regrow");
        }
        for idx in 64..4_095 {
            assert!(!pos_contains(&positions, idx), "new slot {idx} must be unset");
        }
        assert!(pos_contains(&positions, 4_095));
    }
    // ==================================================================
    // MAX_SCROLLBAR_REFLOW_ITERATIONS — the anti-infinite-loop bound
    // ==================================================================
    #[test]
    fn the_scrollbar_reflow_bound_is_a_usable_positive_limit() {
        // `loop_count > MAX` is the only thing standing between a pathological
        // scrollbar oscillation and a hung frame. 0 would mean "never lay out".
        const _: () = assert!(
            MAX_SCROLLBAR_REFLOW_ITERATIONS >= 1 && MAX_SCROLLBAR_REFLOW_ITERATIONS <= 64,
            "the scrollbar reflow bound must be a usable positive limit; an absurd bound = a hung frame"
        );
    }
    // ==================================================================
    // get_containing_block_for_node (numeric, private)
    // ==================================================================
    #[test]
    fn a_root_node_gets_the_viewport_as_its_containing_block() {
        let dom = body_dom();
        let tree = tree_of(vec![hot(None, Some(NodeId::ZERO), Some(size(10.0, 10.0)), &ResolvedBoxProps::default())]);
        let viewport = rect(7.0, 9.0, 800.0, 600.0);
        let (cb_pos, cb_size) =
            get_containing_block_for_node(&tree, &dom, 0, &Vec::new(), viewport);
        assert_eq!(cb_pos.x, 7.0);
        assert_eq!(cb_pos.y, 9.0);
        assert_eq!(cb_size.width, 800.0);
        assert_eq!(cb_size.height, 600.0);
    }
    #[test]
    fn an_out_of_range_node_index_falls_back_to_the_viewport() {
        let dom = body_dom();
        let tree = tree_of(vec![hot(None, None, Some(size(10.0, 10.0)), &ResolvedBoxProps::default())]);
        let viewport = rect(0.0, 0.0, 800.0, 600.0);
        for idx in [1usize, 99, usize::MAX] {
            let (cb_pos, cb_size) =
                get_containing_block_for_node(&tree, &dom, idx, &Vec::new(), viewport);
            assert_eq!(cb_pos.x, 0.0, "idx {idx}");
            assert_eq!(cb_size.width, 800.0, "idx {idx}");
            assert_eq!(cb_size.height, 600.0, "idx {idx}");
        }
    }
    #[test]
    fn a_dangling_parent_index_falls_back_to_the_viewport() {
        // Node 1 claims parent 999, which does not exist. The function must take
        // the viewport branch rather than index out of bounds.
        let dom = body_dom();
        let tree = tree_of(vec![
            hot(None, None, Some(size(10.0, 10.0)), &ResolvedBoxProps::default()),
            hot(Some(999), None, Some(size(10.0, 10.0)), &ResolvedBoxProps::default()),
        ]);
        let viewport = rect(1.0, 2.0, 300.0, 400.0);
        let (cb_pos, cb_size) =
            get_containing_block_for_node(&tree, &dom, 1, &Vec::new(), viewport);
        assert_eq!(cb_pos.x, 1.0);
        assert_eq!(cb_pos.y, 2.0);
        assert_eq!(cb_size.width, 300.0);
        assert_eq!(cb_size.height, 400.0);
    }
    #[test]
    fn a_dom_backed_parent_shrinks_the_containing_block_by_border_and_padding() {
        let dom = body_dom();
        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), edges(5.0, 5.0, 5.0, 5.0));
        let tree = tree_of(vec![
            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(30.0, 40.0));
        let (cb_pos, cb_size) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(0.0, 0.0, 800.0, 600.0),
        );
        // content origin = margin-box pos + border + padding
        assert!(close(cb_pos.x, 45.0), "x was {}", cb_pos.x);
        assert!(close(cb_pos.y, 55.0), "y was {}", cb_pos.y);
        // content size = border-box - (border + padding) on both sides
        assert!(close(cb_size.width, 170.0), "width was {}", cb_size.width);
        assert!(close(cb_size.height, 70.0), "height was {}", cb_size.height);
    }
    #[test]
    fn an_anonymous_parent_offsets_the_origin_but_keeps_the_border_box_size() {
        // The `dom_node_id == None` arm returns `used_size` verbatim — it shifts the
        // origin inward by border+padding but does NOT shrink the size, unlike the
        // DOM-backed arm above. Asserted as-is so any future fix trips this test.
        let dom = body_dom();
        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), edges(5.0, 5.0, 5.0, 5.0));
        let tree = tree_of(vec![
            hot(None, None, Some(size(200.0, 100.0)), &parent_props),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(0.0, 0.0));
        let (cb_pos, cb_size) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(0.0, 0.0, 800.0, 600.0),
        );
        assert!(close(cb_pos.x, 15.0), "x was {}", cb_pos.x);
        assert!(close(cb_pos.y, 15.0), "y was {}", cb_pos.y);
        assert_eq!(cb_size.width, 200.0, "anonymous arm does not subtract padding/border");
        assert_eq!(cb_size.height, 100.0);
    }
    #[test]
    fn an_unpositioned_parent_falls_back_to_the_viewport_origin() {
        let dom = body_dom();
        let parent_props = bp(edges(1.0, 0.0, 0.0, 2.0), edges(3.0, 0.0, 0.0, 4.0));
        let tree = tree_of(vec![
            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let viewport = rect(100.0, 200.0, 800.0, 600.0);
        // `calculated_positions` is empty: the parent has no computed position yet.
        let (cb_pos, _) = get_containing_block_for_node(&tree, &dom, 1, &Vec::new(), viewport);
        // viewport origin + border.left + padding.left, and the same on the y axis.
        assert!(close(cb_pos.x, 106.0), "x was {}", cb_pos.x);
        assert!(close(cb_pos.y, 204.0), "y was {}", cb_pos.y);
    }
    #[test]
    fn a_parent_with_a_sentinel_position_is_treated_as_unpositioned() {
        // pos_get filters the sentinel, so a parent whose stored x is f32::MIN
        // resolves against the viewport origin instead.
        let dom = body_dom();
        let tree = tree_of(vec![
            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &ResolvedBoxProps::default()),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, POSITION_UNSET);
        let (cb_pos, _) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(11.0, 13.0, 800.0, 600.0),
        );
        assert_eq!(cb_pos.x, 11.0);
        assert_eq!(cb_pos.y, 13.0);
    }
    #[test]
    fn a_parent_without_a_used_size_yields_a_zero_sized_containing_block() {
        let dom = body_dom();
        let tree = tree_of(vec![
            hot(None, Some(NodeId::ZERO), None, &ResolvedBoxProps::default()),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(0.0, 0.0));
        let (_, cb_size) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(0.0, 0.0, 800.0, 600.0),
        );
        assert_eq!(cb_size.width, 0.0);
        assert_eq!(cb_size.height, 0.0);
    }
    #[test]
    fn huge_parent_padding_saturates_instead_of_wrapping_the_origin() {
        // PackedBoxProps stores edges as i16 tenths-of-a-pixel: 1e30px clamps to
        // +3276.7px. Wrapping would push the containing block's origin NEGATIVE.
        let dom = body_dom();
        let parent_props = bp(edges(1e30, 1e30, 1e30, 1e30), edges(1e30, 1e30, 1e30, 1e30));
        let tree = tree_of(vec![
            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(0.0, 0.0));
        let (cb_pos, cb_size) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(0.0, 0.0, 800.0, 600.0),
        );
        assert!(cb_pos.x.is_finite() && cb_pos.y.is_finite());
        assert!(cb_pos.x > 0.0, "saturated padding must stay positive, got {}", cb_pos.x);
        assert!(cb_pos.x <= 2.0 * 3276.7 + 1.0, "clamped to the i16 ×10 range");
        // border + padding dwarf the border-box, so the content box floors at zero.
        assert_eq!(cb_size.width, 0.0);
        assert_eq!(cb_size.height, 0.0);
    }
    #[test]
    fn a_nan_parent_position_propagates_but_does_not_panic_or_corrupt_the_size() {
        let dom = body_dom();
        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), EdgeSizes::default());
        let tree = tree_of(vec![
            hot(None, Some(NodeId::ZERO), Some(size(200.0, 100.0)), &parent_props),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(f32::NAN, f32::NAN));
        let (cb_pos, cb_size) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(0.0, 0.0, 800.0, 600.0),
        );
        assert!(cb_pos.x.is_nan() && cb_pos.y.is_nan(), "NaN flows through unchanged");
        // The size path never touches the position, so it must stay clean.
        assert!(close(cb_size.width, 180.0), "width was {}", cb_size.width);
        assert!(close(cb_size.height, 80.0), "height was {}", cb_size.height);
    }
    #[test]
    fn a_nan_parent_used_size_floors_the_containing_block_at_zero() {
        // inner_size() ends in `.max(0.0)`, which discards NaN — the containing
        // block collapses to 0 rather than exporting NaN into sizing.
        let dom = body_dom();
        let tree = tree_of(vec![
            hot(None, Some(NodeId::ZERO), Some(size(f32::NAN, f32::NAN)), &ResolvedBoxProps::default()),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(0.0, 0.0));
        let (_, cb_size) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(0.0, 0.0, 800.0, 600.0),
        );
        assert!(!cb_size.width.is_nan() && !cb_size.height.is_nan());
        assert_eq!(cb_size.width, 0.0);
        assert_eq!(cb_size.height, 0.0);
    }
    #[test]
    fn an_infinite_parent_used_size_stays_infinite_rather_than_becoming_nan() {
        let dom = body_dom();
        let parent_props = bp(edges(10.0, 10.0, 10.0, 10.0), edges(5.0, 5.0, 5.0, 5.0));
        let tree = tree_of(vec![
            hot(
                None,
                Some(NodeId::ZERO),
                Some(size(f32::INFINITY, f32::INFINITY)),
                &parent_props,
            ),
            hot(Some(0), None, None, &ResolvedBoxProps::default()),
        ]);
        let mut positions: PositionVec = Vec::new();
        pos_set(&mut positions, 0, pos(0.0, 0.0));
        let (_, cb_size) = get_containing_block_for_node(
            &tree,
            &dom,
            1,
            &positions,
            rect(0.0, 0.0, 800.0, 600.0),
        );
        assert!(cb_size.width.is_infinite() && cb_size.width.is_sign_positive());
        assert!(cb_size.height.is_infinite() && cb_size.height.is_sign_positive());
    }
    #[test]
    fn degenerate_viewports_pass_through_the_root_arm_untouched() {
        // The root arm is a pure identity on the viewport — it neither clamps
        // negatives nor sanitises NaN. Pin that so callers know to pre-validate.
        let dom = body_dom();
        let tree = tree_of(vec![hot(None, None, None, &ResolvedBoxProps::default())]);
        let (p, s) = get_containing_block_for_node(
            &tree,
            &dom,
            0,
            &Vec::new(),
            rect(0.0, 0.0, 0.0, 0.0),
        );
        assert_eq!(s.width, 0.0);
        assert_eq!(s.height, 0.0);
        assert_eq!(p.x, 0.0);
        let (_, s) = get_containing_block_for_node(
            &tree,
            &dom,
            0,
            &Vec::new(),
            rect(0.0, 0.0, -800.0, -600.0),
        );
        assert_eq!(s.width, -800.0, "negative viewport is not clamped");
        let (p, s) = get_containing_block_for_node(
            &tree,
            &dom,
            0,
            &Vec::new(),
            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
        );
        assert!(p.x.is_nan() && s.width.is_nan(), "NaN viewport is not sanitised");
        let (_, s) = get_containing_block_for_node(
            &tree,
            &dom,
            0,
            &Vec::new(),
            rect(0.0, 0.0, f32::MAX, f32::MAX),
        );
        assert_eq!(s.width, f32::MAX);
    }
    // ==================================================================
    // LayoutError — Display (serializer)
    // ==================================================================
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn every_layout_error_variant_renders_a_distinct_non_empty_message() {
        let variants = [
            LayoutError::InvalidTree,
            LayoutError::SizingFailed,
            LayoutError::PositioningFailed,
            LayoutError::DisplayListFailed,
            LayoutError::Text(crate::font_traits::LayoutError::BidiError("boom".to_string())),
        ];
        let rendered: Vec<String> = variants.iter().map(ToString::to_string).collect();
        for msg in &rendered {
            assert!(!msg.is_empty(), "empty Display output");
            assert!(!msg.trim().is_empty(), "whitespace-only Display output");
        }
        for i in 0..rendered.len() {
            for j in (i + 1)..rendered.len() {
                assert_ne!(rendered[i], rendered[j], "variants {i} and {j} render alike");
            }
        }
    }
    #[test]
    fn layout_error_display_matches_the_documented_wording() {
        assert_eq!(LayoutError::InvalidTree.to_string(), "Invalid layout tree");
        assert_eq!(LayoutError::SizingFailed.to_string(), "Sizing calculation failed");
        assert_eq!(
            LayoutError::PositioningFailed.to_string(),
            "Position calculation failed"
        );
        assert_eq!(
            LayoutError::DisplayListFailed.to_string(),
            "Display list generation failed"
        );
    }
    #[test]
    fn layout_error_display_ignores_width_and_precision_specifiers() {
        // `write!(f, "...")` bypasses the formatter's padding/truncation, so a
        // caller aligning errors in a table gets no alignment at all.
        let e = LayoutError::InvalidTree;
        assert_eq!(format!("{e:>60}"), "Invalid layout tree");
        assert_eq!(format!("{e:.3}"), "Invalid layout tree");
        assert_eq!(format!("{e:^5}"), "Invalid layout tree");
    }
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn the_text_variant_embeds_the_inner_error_and_survives_hostile_payloads() {
        let payloads = [
            String::new(),
            "\u{202e}rtl-override \u{0}nul".to_string(),
            "日本語のエラー 🎉".to_string(),
            "x".repeat(100_000),
            "\"quotes\" and \\backslashes\\".to_string(),
        ];
        for payload in payloads {
            let err = LayoutError::Text(crate::font_traits::LayoutError::ShapingError(
                payload.clone(),
            ));
            let msg = err.to_string();
            assert!(
                msg.starts_with("Text layout error: "),
                "unexpected prefix for {} byte payload",
                payload.len()
            );
            // The inner error is rendered with `{:?}`, so it is escaped, not raw —
            // but it must never be truncated away entirely.
            assert!(msg.len() >= "Text layout error: ".len() + payload.len());
        }
    }
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn the_text_variant_renders_a_default_font_selector_without_panicking() {
        let err = LayoutError::Text(crate::font_traits::LayoutError::FontNotFound(
            crate::font_traits::FontSelector::default(),
        ));
        let msg = err.to_string();
        assert!(msg.starts_with("Text layout error: "));
        assert!(msg.contains("serif"), "the default family should show up: {msg}");
    }
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn from_text_layout_error_wraps_into_the_text_variant() {
        let inner = crate::font_traits::LayoutError::InvalidText("bad".to_string());
        let wrapped: LayoutError = inner.into();
        assert!(matches!(wrapped, LayoutError::Text(_)));
        assert!(wrapped.to_string().contains("bad"));
        // The `?` sugar used all over solver3 goes through the same From impl.
        fn propagates() -> Result<()> {
            let failed: std::result::Result<(), crate::font_traits::LayoutError> = Err(
                crate::font_traits::LayoutError::HyphenationError("nope".to_string()),
            );
            failed?;
            Ok(())
        }
        assert!(matches!(propagates(), Err(LayoutError::Text(_))));
    }
    #[test]
    fn layout_error_is_a_std_error_without_a_source() {
        use std::error::Error;
        let e = LayoutError::SizingFailed;
        assert!(e.source().is_none());
        // Debug must also be usable (it is what `Result::unwrap` prints).
        assert!(!format!("{e:?}").is_empty());
    }
    // ==================================================================
    // LayoutContext debug sinks + the lazy debug_* macros
    // ==================================================================
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    mod debug_sinks {
        use std::collections::{BTreeMap, HashMap};
        use azul_core::{dom::DomId, selection::TextSelection, styled_dom::StyledDom};
        use azul_css::{props::basic::FontRef, LayoutDebugMessage, LayoutDebugMessageType};
        use super::{body_dom, size};
        use crate::{
            font_traits::FontManager,
            solver3::{cache, LayoutContext},
        };
        /// Owns everything a `LayoutContext` borrows, so a test can build one,
        /// poke it, drop it, and then inspect the captured messages.
        struct Env {
            styled_dom: StyledDom,
            font_manager: FontManager<FontRef>,
            text_selections: BTreeMap<DomId, TextSelection>,
            counters: HashMap<(usize, String), i32>,
            image_cache: azul_core::resources::ImageCache,
            debug_messages: Option<Vec<LayoutDebugMessage>>,
        }
        impl Env {
            fn new(debug_messages: Option<Vec<LayoutDebugMessage>>) -> Self {
                Self {
                    styled_dom: body_dom(),
                    font_manager: FontManager::new(rust_fontconfig::FcFontCache::default())
                        .expect("FontManager over an empty font cache"),
                    text_selections: BTreeMap::new(),
                    counters: HashMap::new(),
                    image_cache: azul_core::resources::ImageCache::default(),
                    debug_messages,
                }
            }
            fn ctx(&mut self) -> LayoutContext<'_, FontRef> {
                LayoutContext {
                    style_cache: Default::default(),
                    scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
                    styled_dom: &self.styled_dom,
                    font_manager: &self.font_manager,
                    text_selections: &self.text_selections,
                    debug_messages: &mut self.debug_messages,
                    counters: &mut self.counters,
                    viewport_size: size(800.0, 600.0),
                    fragmentation_context: None,
            reflowed_ifcs: std::collections::BTreeSet::new(),
                    cursor_is_visible: true,
                    cursor_locations: Vec::new(),
                    preedit_text: None,
                    cache_map: cache::LayoutCacheMap::default(),
                    image_cache: &self.image_cache,
                    content_overlay: None,
                    system_style: None,
                    get_system_time_fn: azul_core::task::GetSystemTimeCallback {
                        cb: azul_core::task::get_system_time_libstd,
                    },
                }
            }
        }
        #[test]
        fn each_debug_sink_appends_exactly_one_message_of_its_own_type() {
            let mut env = Env::new(Some(Vec::new()));
            {
                let mut ctx = env.ctx();
                ctx.debug_log_inner("log".to_string());
                ctx.debug_info_inner("info".to_string());
                ctx.debug_warning_inner("warning".to_string());
                ctx.debug_error_inner("error".to_string());
                ctx.debug_box_props_inner("box_props".to_string());
                ctx.debug_css_getter_inner("css_getter".to_string());
                ctx.debug_bfc_layout_inner("bfc".to_string());
                ctx.debug_ifc_layout_inner("ifc".to_string());
                ctx.debug_table_layout_inner("table".to_string());
                ctx.debug_display_type_inner("display".to_string());
            }
            let msgs = env.debug_messages.expect("Some(vec) was passed in");
            let expected = [
                ("log", LayoutDebugMessageType::Info),
                ("info", LayoutDebugMessageType::Info),
                ("warning", LayoutDebugMessageType::Warning),
                ("error", LayoutDebugMessageType::Error),
                ("box_props", LayoutDebugMessageType::BoxProps),
                ("css_getter", LayoutDebugMessageType::CssGetter),
                ("bfc", LayoutDebugMessageType::BfcLayout),
                ("ifc", LayoutDebugMessageType::IfcLayout),
                ("table", LayoutDebugMessageType::TableLayout),
                ("display", LayoutDebugMessageType::DisplayType),
            ];
            assert_eq!(msgs.len(), expected.len(), "one message per call, in order");
            for (msg, (text, ty)) in msgs.iter().zip(expected) {
                assert_eq!(msg.message.as_str(), text);
                assert_eq!(msg.message_type, ty);
                assert!(!msg.location.as_str().is_empty(), "location must be recorded");
            }
        }
        #[test]
        fn debug_log_inner_tags_the_message_with_the_solver3_location() {
            // `debug_log_inner` builds the message by hand (location = "solver3");
            // every other sink goes through LayoutDebugMessage::* (#[track_caller]
            // → a file:line inside this module).
            let mut env = Env::new(Some(Vec::new()));
            {
                let mut ctx = env.ctx();
                ctx.debug_log_inner("hello".to_string());
                ctx.debug_info_inner("hello".to_string());
            }
            let msgs = env.debug_messages.expect("Some(vec)");
            assert_eq!(msgs[0].location.as_str(), "solver3");
            assert!(
                msgs[1].location.as_str().contains(".rs:"),
                "track_caller location, got {:?}",
                msgs[1].location.as_str()
            );
        }
        #[test]
        fn the_debug_sinks_are_no_ops_when_debug_messages_is_none() {
            // The macros guard on `is_some()`, but the inner fns must be safe when
            // called directly (they are `pub`).
            let mut env = Env::new(None);
            {
                let mut ctx = env.ctx();
                ctx.debug_log_inner("log".to_string());
                ctx.debug_error_inner("error".to_string());
                ctx.debug_table_layout_inner("table".to_string());
            }
            assert!(env.debug_messages.is_none(), "must not materialise a Vec");
        }
        #[test]
        fn debug_messages_preserve_hostile_payloads_byte_for_byte() {
            let payloads = [
                String::new(),
                "\u{0}\u{7}\u{1b}[31m".to_string(),
                "日本語 🎉 \u{202e}reversed".to_string(),
                "line\nbreak\ttab\r\n".to_string(),
                "{}{{}} {:?} %s %n".to_string(), // format-string lookalikes
                "x".repeat(200_000),
            ];
            let mut env = Env::new(Some(Vec::new()));
            {
                let mut ctx = env.ctx();
                for p in &payloads {
                    ctx.debug_info_inner(p.clone());
                }
            }
            let msgs = env.debug_messages.expect("Some(vec)");
            assert_eq!(msgs.len(), payloads.len());
            for (msg, payload) in msgs.iter().zip(&payloads) {
                assert_eq!(msg.message.as_str(), payload.as_str());
            }
        }
        #[test]
        fn the_debug_macros_push_one_message_each_when_capturing() {
            let mut env = Env::new(Some(Vec::new()));
            {
                let mut ctx = env.ctx();
                debug_log!(ctx, "log {}", 1);
                debug_info!(ctx, "info {}", 2);
                debug_warning!(ctx, "warning {}", 3);
                debug_error!(ctx, "error {}", 4);
                debug_box_props!(ctx, "box_props {}", 5);
                debug_css_getter!(ctx, "css_getter {}", 6);
                debug_bfc_layout!(ctx, "bfc {}", 7);
                debug_ifc_layout!(ctx, "ifc {}", 8);
                debug_table_layout!(ctx, "table {}", 9);
                debug_display_type!(ctx, "display {}", 10);
            }
            let msgs = env.debug_messages.expect("Some(vec)");
            assert_eq!(msgs.len(), 10);
            assert_eq!(msgs[0].message.as_str(), "log 1");
            assert_eq!(msgs[9].message.as_str(), "display 10");
        }
        #[test]
        fn the_debug_macros_do_not_evaluate_their_arguments_when_not_capturing() {
            // This laziness is the whole point of the macros: a `format!` per node
            // per pass would dominate a release layout. A regression here is silent.
            let evaluations = core::cell::Cell::new(0u32);
            let bump = |c: &core::cell::Cell<u32>| {
                c.set(c.get() + 1);
                c.get()
            };
            let mut env = Env::new(None);
            {
                let mut ctx = env.ctx();
                debug_log!(ctx, "{}", bump(&evaluations));
                debug_info!(ctx, "{}", bump(&evaluations));
                debug_warning!(ctx, "{}", bump(&evaluations));
                debug_error!(ctx, "{}", bump(&evaluations));
                debug_box_props!(ctx, "{}", bump(&evaluations));
                debug_css_getter!(ctx, "{}", bump(&evaluations));
                debug_bfc_layout!(ctx, "{}", bump(&evaluations));
                debug_ifc_layout!(ctx, "{}", bump(&evaluations));
                debug_table_layout!(ctx, "{}", bump(&evaluations));
                debug_display_type!(ctx, "{}", bump(&evaluations));
            }
            assert_eq!(evaluations.get(), 0, "format args must stay unevaluated");
            // ...and they ARE evaluated (exactly once) when capturing.
            let mut env = Env::new(Some(Vec::new()));
            {
                let mut ctx = env.ctx();
                debug_log!(ctx, "{}", bump(&evaluations));
            }
            assert_eq!(evaluations.get(), 1);
            assert_eq!(env.debug_messages.as_ref().map(Vec::len), Some(1));
        }
    }
    // ==================================================================
    // set_skip_display_list — the web-backend opt-out flag
    // ==================================================================
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn set_skip_display_list_round_trips_and_is_idempotent() {
        use core::sync::atomic::Ordering;
        let previous = SKIP_DISPLAY_LIST.load(Ordering::Relaxed);
        set_skip_display_list(true);
        assert!(SKIP_DISPLAY_LIST.load(Ordering::Relaxed));
        set_skip_display_list(true);
        assert!(SKIP_DISPLAY_LIST.load(Ordering::Relaxed), "double-set is idempotent");
        set_skip_display_list(false);
        assert!(!SKIP_DISPLAY_LIST.load(Ordering::Relaxed));
        // Restore whatever the process was using — other tests share this static.
        set_skip_display_list(previous);
        assert_eq!(SKIP_DISPLAY_LIST.load(Ordering::Relaxed), previous);
    }
    // ==================================================================
    // layout_document — the entry point, driven with degenerate viewports
    // ==================================================================
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    mod document {
        use std::collections::BTreeMap;
        use azul_core::{
            dom::{Dom, DomId},
            geom::{LogicalPosition, LogicalRect, LogicalSize},
            resources::RendererResources,
            styled_dom::StyledDom,
        };
        use azul_css::props::basic::FontRef;
        use crate::{
            font_traits::{FontManager, TextLayoutCache},
            solver3::{cache::LayoutCache, display_list::DisplayList, layout_document, Result},
        };
        fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
            LogicalRect {
                origin: LogicalPosition::new(x, y),
                size: LogicalSize::new(w, h),
            }
        }
        /// `body > div` — no text nodes, so an empty (font-less) `FontManager` is
        /// enough to exercise the whole reconcile → size → position → paint chain.
        fn simple_dom() -> StyledDom {
            let mut dom = Dom::create_body().with_child(Dom::create_div());
            let (css, _warnings) =
                azul_css::parser2::new_from_str("div { width: 50px; height: 20px; }");
            StyledDom::create(&mut dom, css)
        }
        fn run(
            cache: &mut LayoutCache,
            dom: &StyledDom,
            viewport: LogicalRect,
        ) -> Result<std::sync::Arc<DisplayList>> {
            let mut text_cache = TextLayoutCache::new();
            let font_manager: FontManager<FontRef> =
                FontManager::new(rust_fontconfig::FcFontCache::default())
                    .expect("FontManager over an empty font cache");
            let renderer_resources = RendererResources::default();
            let image_cache = azul_core::resources::ImageCache::default();
            let mut debug_messages = None;
            layout_document(
                cache,
                &mut text_cache,
                dom,
                viewport,
                &font_manager,
                &BTreeMap::new(),
                &BTreeMap::new(),
                &mut debug_messages,
                None,
                &renderer_resources,
                azul_core::resources::IdNamespace(0),
                DomId::ROOT_ID,
                false,
                Vec::new(),
                None,
                &image_cache,
                None,
                None,
                azul_core::task::GetSystemTimeCallback {
                    cb: azul_core::task::get_system_time_libstd,
                },
                &[],
            )
        }
        /// [`run`] with a caller-supplied `GpuValueCache` — for the tests that
        /// pin how the DL cache reacts to the KEY POPULATION changing.
        fn run_with_gpu(
            cache: &mut LayoutCache,
            dom: &StyledDom,
            viewport: LogicalRect,
            gpu: &azul_core::gpu::GpuValueCache,
        ) -> Result<std::sync::Arc<DisplayList>> {
            let mut text_cache = TextLayoutCache::new();
            let font_manager: FontManager<FontRef> =
                FontManager::new(rust_fontconfig::FcFontCache::default())
                    .expect("FontManager over an empty font cache");
            let renderer_resources = RendererResources::default();
            let image_cache = azul_core::resources::ImageCache::default();
            let mut debug_messages = None;
            layout_document(
                cache,
                &mut text_cache,
                dom,
                viewport,
                &font_manager,
                &BTreeMap::new(),
                &BTreeMap::new(),
                &mut debug_messages,
                Some(gpu),
                &renderer_resources,
                azul_core::resources::IdNamespace(0),
                DomId::ROOT_ID,
                false,
                Vec::new(),
                None,
                &image_cache,
                None,
                None,
                azul_core::task::GetSystemTimeCallback {
                    cb: azul_core::task::get_system_time_libstd,
                },
                &[],
            )
        }
        /// [`run`] with a caller-supplied CSS diff — the css_dirty channel.
        fn run_with_css(
            cache: &mut LayoutCache,
            dom: &StyledDom,
            viewport: LogicalRect,
            css_dirty: &[(azul_core::dom::NodeId, azul_css::props::property::RelayoutScope)],
        ) -> Result<std::sync::Arc<DisplayList>> {
            let mut text_cache = TextLayoutCache::new();
            let font_manager: FontManager<FontRef> =
                FontManager::new(rust_fontconfig::FcFontCache::default())
                    .expect("FontManager over an empty font cache");
            let renderer_resources = RendererResources::default();
            let image_cache = azul_core::resources::ImageCache::default();
            let mut debug_messages = None;
            layout_document(
                cache,
                &mut text_cache,
                dom,
                viewport,
                &font_manager,
                &BTreeMap::new(),
                &BTreeMap::new(),
                &mut debug_messages,
                None,
                &renderer_resources,
                azul_core::resources::IdNamespace(0),
                DomId::ROOT_ID,
                false,
                Vec::new(),
                None,
                &image_cache,
                None,
                None,
                azul_core::task::GetSystemTimeCallback {
                    cb: azul_core::task::get_system_time_libstd,
                },
                css_dirty,
            )
        }
        /// The AGGRESSIVE-CACHING CONTRACT of the CSS-diff channel, both
        /// directions:
        ///
        /// A PAINT-ONLY diff (`RelayoutScope::None` — colour, background)
        /// must bypass the structural-identity DL cache (fresh paint) while
        /// charging ZERO layout work — geometry byte-identical, no intrinsic
        /// recomputation. A SIZING diff must actually re-solve. And an EMPTY
        /// diff over the unchanged DOM must still hit the cache (the same
        /// Arc) — including right after a paint-only pass, which requires the
        /// early-exit path to have refreshed the cache with what it emitted,
        /// or the NEXT hit would serve the stale-paint list.
        #[test]
        fn css_diff_paint_repaints_without_layout_and_sizing_relayouts() {
            use azul_css::props::property::RelayoutScope;
            let dom = simple_dom();
            let mut cache = LayoutCache::default();
            let viewport = rect(0.0, 0.0, 800.0, 600.0);
            let Ok(first) = run(&mut cache, &dom, viewport) else {
                return; // font-less environment
            };
            let positions_before = cache.calculated_positions.clone();
            // Paint-only: fresh list (repainted), identical geometry, zero
            // sizing work.
            let div = azul_core::dom::NodeId::new(1);
            let second = run_with_css(&mut cache, &dom, viewport, &[(div, RelayoutScope::None)])
                .expect("paint-only pass");
            assert!(
                !std::sync::Arc::ptr_eq(&first, &second),
                "a paint-only diff must re-emit the display list (stale paint otherwise)"
            );
            assert_eq!(
                cache.last_intrinsic_dirty, 0,
                "a paint-only diff must charge ZERO sizing work"
            );
            assert_eq!(
                cache.calculated_positions, positions_before,
                "a paint-only diff must not move anything"
            );
            // Unchanged pass right after: must HIT (same Arc as the SECOND
            // list — the early-exit refreshed the cache with what it emitted).
            let third = run(&mut cache, &dom, viewport).expect("unchanged pass");
            assert!(
                std::sync::Arc::ptr_eq(&second, &third),
                "an unchanged pass after a paint-only repaint must hit the DL cache \
                 with the REPAINTED list, not re-emit or serve the stale one"
            );
            // Sizing: must actually re-solve.
            let fourth = run_with_css(&mut cache, &dom, viewport, &[(div, RelayoutScope::SizingOnly)])
                .expect("sizing pass");
            assert!(
                !std::sync::Arc::ptr_eq(&third, &fourth),
                "a sizing diff must not serve the cached list"
            );
            assert!(
                cache.last_intrinsic_dirty >= 1,
                "a sizing diff must charge sizing work for the dirty node"
            );
        }
        /// The structural-identity DL cache must MISS when the GPU-KEY
        /// POPULATION changes, in BOTH directions.
        ///
        /// Diff-driven animation mints its transform keys AFTER the first
        /// layout of the new DOM (First/Last need solved rects), then the
        /// shell regenerates the display list. The very next relayout of the
        /// UNCHANGED DOM used to hit this cache on (root hash, viewport)
        /// alone and serve the PRE-KEY list back — no `PushReferenceFrame`,
        /// so no GPU damage, so a frozen animation and byte-identical
        /// screenshots from the first tick on. The reverse direction is the
        /// retirement leak: a settled animation retires its key, and a cache
        /// hit would keep a reference frame whose fallback matrix is the
        /// BAKED mid-flight transform.
        #[test]
        fn dl_cache_misses_when_the_gpu_key_population_changes() {
            use azul_core::gpu::GpuValueCache;
            use azul_core::resources::TransformKey;
            use azul_core::transform::ComputedTransform3D;
            let count_refframes = |dl: &DisplayList| {
                dl.items
                    .iter()
                    .filter(|i| {
                        matches!(i, crate::solver3::display_list::DisplayListItem::PushReferenceFrame { .. })
                    })
                    .count()
            };
            let dom = simple_dom();
            let mut cache = LayoutCache::default();
            let viewport = rect(0.0, 0.0, 800.0, 600.0);
            let empty_gpu = GpuValueCache::default();
            let Ok(first) = run_with_gpu(&mut cache, &dom, viewport, &empty_gpu) else {
                // Font-less environment — nothing to compare (same escape as
                // the sibling tests).
                return;
            };
            assert!(cache.cached_display_list.is_some(), "cold pass must seed the DL cache");
            assert_eq!(count_refframes(&first), 0, "no keys, no reference frames");
            // A key is minted for a node — as animation seeding does, AFTER
            // this DOM has already been laid out and cached.
            let mut animated_gpu = GpuValueCache::default();
            let animated_node = azul_core::dom::NodeId::new(1); // the div
            animated_gpu
                .anim_transform_keys
                .insert(animated_node, TransformKey::unique());
            animated_gpu.anim_current_transform_values.insert(
                animated_node,
                ComputedTransform3D::new_translation(120.0, 0.0, 0.0),
            );
            let second = run_with_gpu(&mut cache, &dom, viewport, &animated_gpu)
                .expect("warm relayout with keys");
            assert_eq!(
                count_refframes(&second),
                1,
                "a freshly keyed node must get a reference frame — 0 means the \
                 cache served the pre-key display list back"
            );
            // Retirement: the key goes away, the frame must too. A stale hit
            // here would leave a reference frame falling back to its BAKED
            // (mid-flight) matrix — a node permanently offset by its last
            // sampled position.
            let third = run_with_gpu(&mut cache, &dom, viewport, &empty_gpu)
                .expect("warm relayout after retirement");
            assert_eq!(
                count_refframes(&third),
                0,
                "a retired key must not leave a stale reference frame"
            );
            // And the cache still WORKS when nothing changed: same population
            // twice in a row is a hit (same Arc, not merely equal contents).
            let fourth = run_with_gpu(&mut cache, &dom, viewport, &empty_gpu)
                .expect("warm relayout, unchanged population");
            assert!(
                std::sync::Arc::ptr_eq(&third, &fourth),
                "an unchanged population must still hit the cache"
            );
        }
        /// Regression: an EMPTY, unstyled div (the MicrophoneWidget pattern —
        /// zero height, auto width, only there to carry a dataset + AfterMount
        /// callback) must still receive a computed position. It used to stay at
        /// the `POSITION_UNSET` sentinel, and its Border/HitTestArea items were
        /// emitted at (f32::MIN, f32::MIN) and dropped by the display-list
        /// guard — harmless for paint, but the hit area silently vanished.
        #[test]
        fn an_empty_div_still_receives_a_position() {
            // Text siblings force anonymous-box wrapping around the inline
            // runs — the empty divs sit BETWEEN anonymous blocks, which is
            // the arrangement that used to skip them.
            let mut dom = Dom::create_body()
                .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("azul-self-test"))
                .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("probing platform APIs"))
                .with_child(Dom::create_div())
                .with_child(Dom::create_div());
            let (css, _warnings) = azul_css::parser2::new_from_str("body { font-size: 14px; }");
            let dom = StyledDom::create(&mut dom, css);
            let mut cache = LayoutCache::default();
            if run(&mut cache, &dom, rect(0.0, 0.0, 800.0, 600.0)).is_err() {
                return; // font-less environment quirk — nothing to assert
            }
            let tree = cache.tree.as_ref().expect("tree must be cached");
            for (idx, node) in tree.nodes.iter().enumerate() {
                if node.dom_node_id.is_none() {
                    continue;
                }
                assert!(
                    super::pos_get(&cache.calculated_positions, idx).is_some(),
                    "layout node {idx} (dom {:?}, {} children) never received a position",
                    node.dom_node_id,
                    tree.children(idx).len(),
                );
            }
        }
        #[test]
        fn a_zero_sized_viewport_lays_out_without_panicking() {
            let dom = simple_dom();
            let mut cache = LayoutCache::default();
            // Err is acceptable (a font-less environment may legitimately fail);
            // a panic, an infinite scrollbar reflow, or a NaN position is not.
            if run(&mut cache, &dom, rect(0.0, 0.0, 0.0, 0.0)).is_ok() {
                for p in &cache.calculated_positions {
                    assert!(!p.x.is_nan() && !p.y.is_nan(), "NaN position from a 0×0 viewport");
                }
            }
        }
        #[test]
        fn degenerate_viewports_never_panic_or_hang() {
            // MAX_SCROLLBAR_REFLOW_ITERATIONS is the only guard against a reflow
            // oscillation, so each of these must terminate through it or earlier.
            let viewports = [
                rect(0.0, 0.0, f32::NAN, f32::NAN),
                rect(f32::NAN, f32::NAN, 800.0, 600.0),
                rect(0.0, 0.0, -800.0, -600.0),
                rect(0.0, 0.0, f32::MAX, f32::MAX),
                rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
                rect(-1e30, -1e30, 1.0, 1.0),
                rect(0.0, 0.0, f32::MIN_POSITIVE, f32::MIN_POSITIVE),
            ];
            for viewport in viewports {
                let dom = simple_dom();
                let mut cache = LayoutCache::default();
                let _ = run(&mut cache, &dom, viewport);
            }
        }
        #[test]
        fn laying_out_the_same_dom_twice_is_stable_and_populates_the_cache() {
            let dom = simple_dom();
            let mut cache = LayoutCache::default();
            let viewport = rect(0.0, 0.0, 800.0, 600.0);
            let first = run(&mut cache, &dom, viewport);
            if first.is_err() {
                // No fonts available in this environment — nothing to compare.
                return;
            }
            assert!(cache.cached_display_list.is_some(), "cold pass must seed the DL cache");
            assert_eq!(cache.viewport, Some(viewport));
            let positions_after_first = cache.calculated_positions.clone();
            // Second pass: the structural-identity cache should short-circuit, and
            // must not corrupt the stored geometry on the way out.
            let second = run(&mut cache, &dom, viewport);
            assert!(second.is_ok(), "a warm relayout of an unchanged DOM must succeed");
            assert_eq!(
                cache.calculated_positions.len(),
                positions_after_first.len(),
                "warm pass changed the node count"
            );
            for (warm, cold) in cache.calculated_positions.iter().zip(&positions_after_first) {
                assert_eq!(warm.x.to_bits(), cold.x.to_bits(), "warm pass moved a node");
                assert_eq!(warm.y.to_bits(), cold.y.to_bits(), "warm pass moved a node");
            }
        }
    }
}