Skip to main content

azul_layout/solver3/
positioning.rs

1//! Final positioning of layout nodes (relative, absolute, and fixed schemes)
2// +spec:positioning:79d47e - Implements relative, absolute, and fixed positioning schemes
3
4use crate::debug_log;
5use std::collections::BTreeMap;
6
7use azul_core::{
8    dom::{NodeId, NodeType},
9    geom::{LogicalPosition, LogicalRect, LogicalSize},
10    hit_test::ScrollPosition,
11    resources::RendererResources,
12    styled_dom::StyledDom,
13};
14use azul_css::{
15    corety::LayoutDebugMessage,
16    css::CssPropertyValue,
17    props::{
18        basic::pixel::PixelValue,
19        layout::{LayoutPosition, LayoutWritingMode},
20        property::{CssProperty, CssPropertyType},
21    },
22};
23
24use crate::{
25    font_traits::{FontLoaderTrait, ParsedFontTrait, TextLayoutCache},
26    solver3::{
27        fc::{layout_formatting_context, FloatingContext, LayoutConstraints, TextAlign},
28        getters::{
29            get_aspect_ratio_property, get_direction_property, get_display_property, get_writing_mode, get_position, MultiValue,
30            get_css_top, get_css_bottom, get_css_left, get_css_right,
31            get_css_height, get_css_width,
32        },
33        layout_tree::LayoutTree,
34        LayoutContext, LayoutError, Result,
35    },
36};
37
38#[derive(Debug, Default)]
39pub(crate) struct PositionOffsets {
40    pub(crate) top: Option<f32>,
41    pub(crate) right: Option<f32>,
42    pub(crate) bottom: Option<f32>,
43    pub(crate) left: Option<f32>,
44}
45
46// +spec:positioning:94ef0f - position property: static|relative|absolute|sticky|fixed, initial static, applies to all elements except table-column-group/table-column
47/// Looks up the `position` property using the compact-cache-aware getter.
48// +spec:positioning:ba937d - positioned elements have position != static
49#[must_use] pub fn get_position_type(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> LayoutPosition {
50    let Some(id) = dom_id else {
51        return LayoutPosition::Static;
52    };
53    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
54    get_position(styled_dom, id, node_state).unwrap_or_default()
55}
56
57// +spec:positioning:bda1d5 - resolves inset properties (top/right/bottom/left) as inward offsets per CSS Position 3 §3.1
58// +spec:positioning:bf9168 - resolves inset properties (top/right/bottom/left) to control positioned box location
59// +spec:positioning:f8e0a1 - inset properties (top/right/bottom/left) resolved for positioned elements; auto = unconstrained
60/// Reads and resolves `top`, `right`, `bottom`, `left` properties,
61/// including percentages relative to the containing block's size, and em/rem units.
62// +spec:positioning:7ec143 - top/right/bottom/left offset resolution with percentage against containing block
63#[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
64pub(crate) fn resolve_position_offsets(
65    styled_dom: &StyledDom,
66    dom_id: Option<NodeId>,
67    cb_size: LogicalSize,
68    viewport_size: LogicalSize,
69) -> PositionOffsets {
70    use azul_css::props::basic::pixel::{PhysicalSize, PropertyContext, ResolutionContext};
71
72    use crate::solver3::getters::{
73        get_element_font_size, get_parent_font_size, get_root_font_size,
74    };
75
76    let Some(id) = dom_id else {
77        return PositionOffsets::default();
78    };
79    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
80
81    // Create resolution context with font sizes and containing block size
82    let element_font_size = get_element_font_size(styled_dom, id, node_state);
83    let parent_font_size = get_parent_font_size(styled_dom, id, node_state);
84    let root_font_size = get_root_font_size(styled_dom, node_state);
85
86    let containing_block_size = PhysicalSize::new(cb_size.width, cb_size.height);
87
88    let resolution_context = ResolutionContext {
89        element_font_size,
90        parent_font_size,
91        root_font_size,
92        containing_block_size,
93        element_size: None, // Not needed for position offsets
94        viewport_size: PhysicalSize::new(viewport_size.width, viewport_size.height),
95    };
96
97    let mut offsets = PositionOffsets::default();
98
99    // +spec:containing-block:d4b3b9 - percentage offsets resolve against CB width (left/right) or height (top/bottom)
100    // Resolve offsets using compact-cache-aware getters
101    // top/bottom use Height context (% refers to containing block height)
102    offsets.top = match get_css_top(styled_dom, id, node_state) {
103        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Height)),
104        _ => None,
105    };
106
107    offsets.bottom = match get_css_bottom(styled_dom, id, node_state) {
108        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Height)),
109        _ => None,
110    };
111
112    // left/right use Width context (% refers to containing block width)
113    offsets.left = match get_css_left(styled_dom, id, node_state) {
114        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Width)),
115        _ => None,
116    };
117
118    offsets.right = match get_css_right(styled_dom, id, node_state) {
119        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Width)),
120        _ => None,
121    };
122
123    offsets
124}
125
126// +spec:block-formatting-context:f5f992 - Out-of-flow: floated or absolutely positioned boxes laid out outside normal flow
127// +spec:positioning:bb19f8 - absolute/fixed positioning: out-of-flow, positioned relative to containing block/viewport
128/// After the main layout pass, this function iterates through the tree and correctly
129/// calculates the final positions of out-of-flow elements (`absolute`, `fixed`).
130// +spec:positioning:5bfef3 - abspos elements use static position for auto offsets, resolve against nearest positioned ancestor CB
131// +spec:positioning:7fff75 - Absolute positioning: removed from flow, offset relative to containing block, establishes new CB
132// +spec:positioning:839cbb - absolute elements positioned/sized solely relative to their containing block, modified by inset properties
133// +spec:positioning:898590 - absolute positioning takes elements out of flow and positions them relative to containing block
134// +spec:positioning:c37c1b - abspos boxes laid out in containing block after its final size is determined
135// +spec:positioning:cbe481 - absolute positioning removes elements from flow and positions them relative to containing block
136// +spec:positioning:ebff77 - absolute positioning layout model (replaces old §6 abspos model)
137// +spec:positioning:3b3ba4 - Absolute positioning: box offset from containing block, removed from normal flow; fixed positioning: CB = viewport
138#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
139/// # Panics
140///
141/// Panics if a resolved offset (`top`/`bottom`) is None where both edges are expected.
142pub fn position_out_of_flow_elements<T: ParsedFontTrait>(
143    ctx: &mut LayoutContext<'_, T>,
144    tree: &mut LayoutTree,
145    text_cache: &mut TextLayoutCache,
146    calculated_positions: &mut super::PositionVec,
147    viewport: LogicalRect,
148) {
149    use azul_css::props::style::StyleDirection;
150    // Returns `()` (not Result<()>): inner fallible calls use skip-on-err (see above), so this fn
151    // never propagates Err. Avoids the lift-fragile Result<(),LayoutError> Ok-niche read.
152    for node_index in 0..tree.nodes.len() {
153        let node = &tree.nodes[node_index];
154        let Some(dom_id) = node.dom_node_id else {
155            continue;
156        };
157
158        let position_type = get_position_type(ctx.styled_dom, Some(dom_id));
159
160        // +spec:positioning:1d87f6 - Fixed/absolute positioning schemes with box offset resolution (top/right/bottom/left)
161        // +spec:positioning:8bde1d - absolute: out of flow, positioned by containing block
162        // +spec:positioning:c11be9 - absolute positioning: effect of box offsets depends on which properties are auto (non-replaced) or intrinsic dimensions (replaced)
163        // +spec:positioning:9020aa - "absolutely positioned" means position:absolute or position:fixed
164        if position_type == LayoutPosition::Absolute || position_type == LayoutPosition::Fixed {
165            // is a grid container have their CB determined by grid-placement properties;
166            // Taffy already handles this during grid layout, so skip re-positioning here.
167            // Same applies to flex containers (Flexbox §4.1).
168            {
169                use azul_core::dom::FormattingContext;
170                let parent_is_flex_or_grid = node.parent.and_then(|p| tree.get(p)).is_some_and(|pn| {
171                    matches!(pn.formatting_context, FormattingContext::Flex | FormattingContext::Grid)
172                });
173                if parent_is_flex_or_grid {
174                    continue;
175                }
176            }
177
178            // Get parent info before any mutable borrows
179            let parent_info: Option<(usize, LogicalPosition, f32, f32, f32, f32)> = {
180                let node = &tree.nodes[node_index];
181                node.parent.and_then(|parent_idx| {
182                    let parent_node = tree.get(parent_idx)?;
183                    let parent_dom_id = parent_node.dom_node_id?;
184                    let parent_position = get_position_type(ctx.styled_dom, Some(parent_dom_id));
185                    if parent_position == LayoutPosition::Absolute
186                        || parent_position == LayoutPosition::Fixed
187                    {
188                        calculated_positions.get(parent_idx).map(|parent_pos| {
189                            let pbp = parent_node.box_props.unpack();
190                            (
191                                parent_idx,
192                                *parent_pos,
193                                pbp.border.left,
194                                pbp.border.top,
195                                pbp.padding.left,
196                                pbp.padding.top,
197                            )
198                        })
199                    } else {
200                        None
201                    }
202                })
203            };
204
205            // +spec:containing-block:17a946 - fixed boxes use viewport as containing block
206            // +spec:containing-block:83a32a - fixed positioning: containing block is viewport; absolute: nearest positioned ancestor or initial CB
207            // +spec:containing-block:9b617d - fixed elements use viewport (initial fixed containing block)
208            // +spec:containing-block:899e47 - fixed elements use viewport (initial fixed containing block)
209            // +spec:containing-block:faa9a3 - fixed positioning falls back to initial containing block (viewport) when no ancestor establishes one
210            // +spec:containing-block:faa9a3 - fixed positioning CB falls back to initial containing block (viewport) when no ancestor establishes one
211            // +spec:positioning:067eab - CB for fixed = viewport, for absolute = nearest positioned ancestor
212            // +spec:positioning:067eab - fixed CB is viewport; absolute CB is nearest positioned ancestor's padding-box
213            // +spec:positioning:9777da - fixed positioning uses viewport as containing block
214            // +spec:positioning:9777da - Fixed positioning uses viewport as containing block
215            // +spec:positioning:9ccf9a - fixed-position CB is viewport (transform/will-change/contain could override, not yet implemented)
216            // +spec:positioning:a68970 - fixed positioning uses viewport as containing block
217            // +spec:positioning:8fff44 - fixed: same as absolute but positioned relative to viewport
218            // +spec:positioning:744713 - fixed position uses viewport as containing block
219            // +spec:positioning:f0ad47 - fixed elements use viewport as containing block; content outside viewport cannot be scrolled to
220            // +spec:containing-block:df8387 - fixed positioning: containing block is the viewport
221            let containing_block_rect = if position_type == LayoutPosition::Fixed {
222                viewport
223            } else {
224                // skip-on-err (was `?`): a CB-resolution failure for one out-of-flow node skips
225                // that node rather than aborting the whole layout. Lets this fn return `()`
226                // (no Result<(),LayoutError> Ok-niche read, which the remill→wasm lift mis-lowers).
227                match find_absolute_containing_block_rect(
228                    tree,
229                    node_index,
230                    ctx.styled_dom,
231                    calculated_positions,
232                    viewport,
233                ) {
234                    Ok(r) => r,
235                    Err(_) => continue,
236                }
237            };
238
239            // Get node again after containing block calculation
240            let node = &tree.nodes[node_index];
241
242            // Calculate used size for out-of-flow elements (they don't get sized during normal
243            // layout)
244            let element_size = if let Some(size) = node.used_size {
245                size
246            } else {
247                // Element hasn't been sized yet - calculate it now using containing block
248                let intrinsic = tree.warm(node_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
249                let Ok(size) = crate::solver3::sizing::calculate_used_size_for_node(
250                    ctx.styled_dom,
251                    Some(dom_id),
252                    &containing_block_rect.size,
253                    intrinsic,
254                    &node.box_props.unpack(),
255                    &ctx.viewport_size,
256                ) else {
257                    continue;
258                };
259
260                // Store the calculated size in the tree node
261                if let Some(node_mut) = tree.get_mut(node_index) {
262                    node_mut.used_size = Some(size);
263                }
264
265                size
266            };
267
268            // +spec:positioning:dc23fa - sizing/positioning into inset-modified containing block (§4)
269            // +spec:positioning:623e45 - inset properties reduce the containing block into the inset-modified containing block
270            // Resolve offsets using the now-known containing block size.
271            let offsets =
272                resolve_position_offsets(ctx.styled_dom, Some(dom_id), containing_block_rect.size, viewport.size);
273
274            // +spec:box-model:ae3899 - static position is the margin-edge position from normal flow
275            // +spec:positioning:9a90a3 - static position: the position the element would have had in normal flow
276            // +spec:positioning:ca3e89 - static-position rectangle uses block-start inline-start alignment (CSS2.1 hypothetical box)
277            let mut static_pos = calculated_positions
278                .get(node_index)
279                .copied()
280                .unwrap_or_default();
281
282            // Special case: If this is a fixed-position element and it has a positioned
283            // parent, update static_pos to be relative to the parent's final absolute
284            // position (content-box). The initial static_pos from process_out_of_flow_children
285            // may include border/padding offsets, so we must always recalculate here.
286            if position_type == LayoutPosition::Fixed {
287                if let Some((_, parent_pos, border_left, border_top, padding_left, padding_top)) =
288                    parent_info
289                {
290                    // Add parent's border and padding to get content-box position
291                    static_pos = LogicalPosition::new(
292                        parent_pos.x + border_left + padding_left,
293                        parent_pos.y + border_top + padding_top,
294                    );
295                }
296            }
297
298            let mut final_pos = LogicalPosition::zero();
299
300            // +spec:box-model:ea2f43 - top + margin + border + padding + height + bottom = CB height
301            // +spec:box-model:b4f5b3 - vertical constraint equation for abs-pos non-replaced elements
302            // +spec:positioning:16d82c - vertical dimension constraint for abs-positioned non-replaced elements
303            // +spec:positioning:8f474b - §10.6.4 vertical constraint for absolutely positioned non-replaced elements
304            // +spec:positioning:50218d - absolute: top margin edge offset below containing block top edge
305            // top + margin-top + border-top + padding-top + height + padding-bottom +
306            // border-bottom + margin-bottom + bottom = containing block height
307            let node_state = &ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
308
309            // Extract all box_props values upfront to avoid borrow conflicts with tree.get_mut()
310            let (margin_top_val, margin_bottom_val, margin_auto,
311                 margin_left_val, margin_right_val, margin_left_auto_flag, margin_right_auto_flag) = {
312                let node = &tree.nodes[node_index];
313                let nbp = node.box_props.unpack();
314                (nbp.margin.top, nbp.margin.bottom,
315                 nbp.margin_auto,
316                 nbp.margin.left, nbp.margin.right,
317                 nbp.margin_auto.left, nbp.margin_auto.right)
318            };
319            // +spec:positioning:d730e5 - CB height is independent of the abspos element, so percentage heights always resolve
320            let cb_height = containing_block_rect.size.height;
321
322            let css_height = get_css_height(ctx.styled_dom, dom_id, node_state);
323            // +spec:replaced-elements:7d8ba8 - §10.6.5: for absolutely positioned replaced
324            // elements, height is determined first (as for inline replaced elements), so treat
325            // it as "not auto" in the constraint equation even if CSS says auto.
326            let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
327            let is_replaced = matches!(node_data.node_type, NodeType::Image(_))
328                || node_data.is_virtual_view_node();
329            let height_is_auto = css_height.is_auto() && !is_replaced;
330            // +spec:overflow:941a06 - resolve auto inset properties: if only one is auto, solved to zero via constraint; if both auto, use static position
331            let top_is_auto = offsets.top.is_none();
332            let bottom_is_auto = offsets.bottom.is_none();
333
334            // element_size is border-box (includes border + padding + content).
335            // The constraint equation is:
336            //   top + margin-top + border-box-height + margin-bottom + bottom = CB height
337            // (border-top, padding-top, content-height, padding-bottom, border-bottom
338            //  are all inside border-box-height)
339            let mut used_height = element_size.height;
340            // +spec:height-calculation:44939a - set auto values for margin-top/margin-bottom to 0
341            // +spec:height-calculation:2f6e10 - if bottom is auto, replace auto margin-top/margin-bottom with 0
342            let mut used_margin_top = if margin_auto.top { 0.0 } else { margin_top_val };
343            let mut used_margin_bottom = if margin_auto.bottom { 0.0 } else { margin_bottom_val };
344
345            // +spec:box-model:3a9c2a - resolving auto insets: static position fallback when insets are auto
346            // +spec:box-model:bd442c - weaker inset resolves to align margin box with inset-modified CB edge
347            // +spec:height-calculation:93e91c - abs non-replaced height: auto margin centering, single auto margin solve, over-constrained ignore bottom
348            // +spec:positioning:6e7732 - §10.6.4 vertical constraint equation for abspos non-replaced elements
349            // +spec:positioning:b63d0f - absolute positioning with top:auto uses static position (change bars example)
350            // +spec:positioning:da8a0c - resolving auto insets: normal alignment treated as start, so auto insets resolve to static position
351            // +spec:positioning:820b22 - 10.6.4: absolutely positioned non-replaced elements vertical constraint equation and 6 rules
352            if top_is_auto && height_is_auto && bottom_is_auto {
353                // +spec:positioning:08e0ac - absolute element with top:auto uses static position (current line)
354                // +spec:positioning:aab294 - both inset properties auto: resolve to static position
355                // +spec:positioning:d9bb3c - hypothetical position: UA may guess static position rather than fully computing hypothetical box
356                // All three auto: set top to static position, height from content, solve for bottom
357                // +spec:height-calculation:51627d - auto margins to 0, top = static position, height from content (rule 3)
358                // +spec:positioning:460f2f - All three auto: set top to static position, height from content, solve for bottom
359                final_pos.y = static_pos.y;
360            } else if !top_is_auto && !height_is_auto && !bottom_is_auto {
361                // +spec:overflow:fc0c9e - over-constrained abspos: auto margins minimize overflow (CSS2.1 equivalent of Box Alignment 3 safe alignment)
362                // +spec:positioning:88f760 - auto margins of absolutely-positioned boxes (vertical)
363                // None are auto: over-constrained case
364                // +spec:height-calculation:03c071 - none auto: equal auto margins, solve single auto margin, or ignore bottom if over-constrained
365                let top_val = offsets.top.unwrap();
366                let bottom_val = offsets.bottom.unwrap();
367                if margin_auto.top && margin_auto.bottom {
368                    // +spec:height-calculation:5112a4 - both margin-top/bottom auto: solve with equal values
369                    let available = cb_height - top_val - used_height - bottom_val;
370                    let each = available / 2.0;
371                    used_margin_top = each;
372                    used_margin_bottom = each;
373                } else if margin_auto.top {
374                    used_margin_top = cb_height - top_val - used_height - used_margin_bottom - bottom_val;
375                } else if margin_auto.bottom {
376                    used_margin_bottom = cb_height - top_val - used_height - used_margin_top - bottom_val;
377                }
378                // else: over-constrained, ignore bottom
379                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
380            } else if top_is_auto && height_is_auto && !bottom_is_auto {
381                // +spec:height-calculation:909b50 - top and height auto, bottom not auto: height from BFC auto heights, solve for top
382                // Rule 1: height from content, auto margins to 0, solve for top
383                let bottom_val = offsets.bottom.unwrap();
384                let top_val = cb_height - used_margin_top - used_height - used_margin_bottom - bottom_val;
385                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
386            } else if top_is_auto && bottom_is_auto && !height_is_auto {
387                // +spec:positioning:64e1ba - top+bottom auto, height not auto: set top to static position, solve for bottom
388                final_pos.y = static_pos.y;
389            } else if height_is_auto && bottom_is_auto && !top_is_auto {
390                // Rule 3: height from content, auto margins to 0, solve for bottom
391                let top_val = offsets.top.unwrap();
392                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
393            } else if top_is_auto && !height_is_auto && !bottom_is_auto {
394                // +spec:height-calculation:33dce8 - top auto, height and bottom not auto: solve for top
395                // Rule 4: auto margins to 0, solve for top
396                let bottom_val = offsets.bottom.unwrap();
397                let top_val = cb_height - used_margin_top - used_height - used_margin_bottom - bottom_val;
398                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
399            } else if height_is_auto && !top_is_auto && !bottom_is_auto {
400                // +spec:intrinsic-sizing:566a43 - abspos auto height with non-auto insets: stretch-fit size
401                // +spec:intrinsic-sizing:c7227f - except: if box has aspect-ratio, ratio-dependent axis uses max-content
402                let has_aspect_ratio = matches!(
403                    get_aspect_ratio_property(ctx.styled_dom, dom_id, node_state),
404                    MultiValue::Exact(azul_css::props::style::effects::StyleAspectRatio::Ratio(_))
405                );
406                let top_val = offsets.top.unwrap();
407                let bottom_val = offsets.bottom.unwrap();
408                if !has_aspect_ratio {
409                    // solve for height from constraint equation (stretch-fit):
410                    // height = cb_height - top - margin_top - margin_bottom - bottom
411                    // +spec:containing-block:b3f0dd - clamp effective CB size to zero when insets exceed it (weaker inset reduced)
412                    used_height = (cb_height - top_val - used_margin_top - used_margin_bottom - bottom_val).max(0.0);
413                }
414                // else: keep content-based height (max-content) per aspect-ratio exception
415                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
416                // Update the element size with the resolved height
417                if let Some(node_mut) = tree.get_mut(node_index) {
418                    if let Some(ref mut size) = node_mut.used_size {
419                        size.height = used_height;
420                    }
421                }
422            } else if bottom_is_auto && !top_is_auto && !height_is_auto {
423                // Rule 6: auto margins to 0, solve for bottom
424                let top_val = offsets.top.unwrap();
425                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
426            } else {
427                // Fallback to static position
428                final_pos.y = static_pos.y;
429            }
430
431            // +spec:box-model:984243 - horizontal constraint equation for abs-pos non-replaced elements
432            // +spec:positioning:3be194 - position abs replaced element after establishing width
433            // Constraint: left + margin-left + border-left + padding-left + width +
434            // +spec:width-calculation:1661b4 - constraint equation and six rules for abs-pos horizontal (§10.3.7)
435            // left + margin-left + border-left + padding-left + width +
436            //   padding-right + border-right + margin-right + right = CB width
437            // Since element_size.width is border-box (border + padding + content),
438            // simplifies to: left + margin-left + border_box_width + margin-right + right = CB width
439            {
440                let margin_left = margin_left_val;
441                let margin_right = margin_right_val;
442                let margin_left_auto = margin_left_auto_flag;
443                let margin_right_auto = margin_right_auto_flag;
444                let cb_width = containing_block_rect.size.width;
445                let border_box_width = element_size.width;
446                let left_val = offsets.left;
447                let right_val = offsets.right;
448                let left_is_auto = left_val.is_none();
449                let right_is_auto = right_val.is_none();
450
451                // Get direction of containing block for over-constrained resolution
452                let cb_direction = {
453                    let cb_dom_id = if position_type == LayoutPosition::Fixed {
454                        None // viewport CB, default LTR
455                    } else {
456                        let mut parent = tree.nodes[node_index].parent;
457                        let mut found = None;
458                        while let Some(pidx) = parent {
459                            if let Some(pnode) = tree.get(pidx) {
460                                if get_position_type(ctx.styled_dom, pnode.dom_node_id).is_positioned() {
461                                    found = pnode.dom_node_id;
462                                    break;
463                                }
464                                parent = pnode.parent;
465                            } else {
466                                break;
467                            }
468                        }
469                        found
470                    };
471                    match cb_dom_id {
472                        Some(cb_id) => {
473                            let cb_ns = &ctx.styled_dom.styled_nodes.as_container()[cb_id].styled_node_state;
474                            match get_direction_property(ctx.styled_dom, cb_id, cb_ns) {
475                                MultiValue::Exact(v) => v,
476                                _ => StyleDirection::Ltr,
477                            }
478                        }
479                        None => StyleDirection::Ltr,
480                    }
481                };
482
483                // +spec:replaced-elements:7d8ba8 - §10.3.8: for absolutely positioned replaced elements, width is determined
484                // first (as for inline replaced), so treat as "not auto" in the constraint.
485                let width_is_auto = get_css_width(ctx.styled_dom, dom_id, node_state).is_auto() && !is_replaced;
486
487                if !left_is_auto && !width_is_auto && !right_is_auto {
488                    // +spec:positioning:88f760 - auto margins of absolutely-positioned boxes (horizontal)
489                    // +spec:width-calculation:942c77 - abs-pos non-replaced width: auto margins, over-constrained resolution
490                    // None of left/width/right are auto — solve for margins or handle over-constrained
491                    // +spec:width-calculation:dff69d - §10.3.7 abs-pos non-replaced: none auto → equal auto margins, solve single auto margin, or over-constrained
492                    let left = left_val.unwrap();
493                    let right = right_val.unwrap();
494                    let remaining = cb_width - left - border_box_width - right;
495
496                    // +spec:writing-modes:9c3b40 - abspos auto margins: if negative remaining in inline axis, start margin=0, end margin gets remainder
497                    if margin_left_auto && margin_right_auto {
498                        // +spec:positioning:ab47b3 - auto margins can be negative in absolute positioning
499                        // Both margins auto: equal values unless negative
500                        let each_margin = remaining / 2.0;
501                        if each_margin < 0.0 {
502                            match cb_direction {
503                                StyleDirection::Ltr => {
504                                    final_pos.x = containing_block_rect.origin.x + left;
505                                }
506                                StyleDirection::Rtl => {
507                                    final_pos.x = containing_block_rect.origin.x + left + remaining;
508                                }
509                            }
510                        } else {
511                            final_pos.x = containing_block_rect.origin.x + left + each_margin;
512                        }
513                    } else if margin_left_auto {
514                        let solved_margin_left = remaining - margin_right;
515                        final_pos.x = containing_block_rect.origin.x + left + solved_margin_left;
516                    } else if margin_right_auto {
517                        final_pos.x = containing_block_rect.origin.x + left + margin_left;
518                    } else {
519                        // Over-constrained: ignore right (LTR) or left (RTL)
520                        match cb_direction {
521                            StyleDirection::Ltr => {
522                                final_pos.x = containing_block_rect.origin.x + left + margin_left;
523                            }
524                            StyleDirection::Rtl => {
525                                let solved_left = cb_width - margin_left - border_box_width - margin_right - right;
526                                final_pos.x = containing_block_rect.origin.x + solved_left + margin_left;
527                            }
528                        }
529                    }
530                } else {
531                    // +spec:overflow:f323cb - auto inset: align margin box to stronger inset edge (may overflow CB)
532                    // +spec:width-calculation:bbf97a - set auto margins to 0 for abspos when left/width/right has auto
533                    // Set auto margins to 0, apply six rules
534                    // +spec:box-model:2da091 - if either inset is auto, auto margins resolve to zero
535                    // +spec:intrinsic-sizing:087b57 - abspos auto margins resolve to 0 when any inset is auto
536                    // +spec:width-calculation:0c29ce - set auto margins to 0, then apply six rules for abs pos width
537                    let m_left = if margin_left_auto { 0.0 } else { margin_left };
538                    let m_right = if margin_right_auto { 0.0 } else { margin_right };
539
540                    // +spec:width-calculation:2b2852 - all three auto: set auto margins to 0, use static position for left (LTR)
541                    // +spec:width-calculation:c120b3 - all three of left/width/right auto: set auto margins to 0, then use direction to pick static position
542                    if left_is_auto && width_is_auto && right_is_auto {
543                        match cb_direction {
544                            StyleDirection::Ltr => {
545                                // Set left to static position, apply rule 3 (width from content, solve for right)
546                                final_pos.x = static_pos.x;
547                            }
548                            StyleDirection::Rtl => {
549                                // Set right to static position, apply rule 1 (width from content, solve for left)
550                                let static_offset = static_pos.x - containing_block_rect.origin.x;
551                                let right_static = (cb_width - static_offset - border_box_width).max(0.0);
552                                let solved_left = cb_width - m_left - border_box_width - m_right - right_static;
553                                final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
554                            }
555                        }
556                    } else if left_is_auto && width_is_auto && !right_is_auto {
557                        // left+width auto, right not auto: width from content, solve for left
558                        let right = right_val.unwrap();
559                        let solved_left = cb_width - m_left - border_box_width - m_right - right;
560                        final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
561                    } else if left_is_auto && !width_is_auto && right_is_auto {
562                        // left+right auto: set left to static position (LTR)
563                        final_pos.x = static_pos.x;
564                    } else if !left_is_auto && width_is_auto && right_is_auto {
565                        // width+right auto: position from left
566                        let left = left_val.unwrap();
567                        final_pos.x = containing_block_rect.origin.x + left + m_left;
568                    } else if left_is_auto && !width_is_auto && !right_is_auto {
569                        // left auto: solve for left
570                        let right = right_val.unwrap();
571                        let solved_left = cb_width - m_left - border_box_width - m_right - right;
572                        final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
573                    } else if !left_is_auto && width_is_auto && !right_is_auto {
574                        // +spec:intrinsic-sizing:566a43 - abspos auto width with non-auto insets: stretch-fit size
575                        // +spec:intrinsic-sizing:c7227f - except: if box has aspect-ratio, ratio-dependent axis uses max-content
576                        let has_aspect_ratio = matches!(
577                            get_aspect_ratio_property(ctx.styled_dom, dom_id, node_state),
578                            MultiValue::Exact(azul_css::props::style::effects::StyleAspectRatio::Ratio(_))
579                        );
580                        let left = left_val.unwrap();
581                        let right = right_val.unwrap();
582                        if !has_aspect_ratio {
583                            // width = cb_width - left - margin_left - margin_right - right
584                            let used_width = (cb_width - left - m_left - m_right - right).max(0.0);
585                            if let Some(node_mut) = tree.get_mut(node_index) {
586                                if let Some(ref mut size) = node_mut.used_size {
587                                    size.width = used_width;
588                                }
589                            }
590                        }
591                        // else: keep content-based width (max-content) per aspect-ratio exception
592                        final_pos.x = containing_block_rect.origin.x + left + m_left;
593                    } else if !left_is_auto && !width_is_auto && right_is_auto {
594                        // right auto: position from left
595                        let left = left_val.unwrap();
596                        final_pos.x = containing_block_rect.origin.x + left + m_left;
597                    } else {
598                        final_pos.x = static_pos.x;
599                    }
600                }
601            }
602
603            super::pos_set(calculated_positions, node_index, final_pos);
604
605            // The absolute box is now at its FINAL, definite size. Lay out its
606            // content against that box if a percentage-height child collapsed —
607            // which happens because (a) the taffy-bridge layout path that handles
608            // flex-nested blocks never runs `process_out_of_flow_children`, so an
609            // abs child's subtree is otherwise NEVER laid out, and (b) even on the
610            // solver3 path the subtree is laid out BEFORE the stretch-fit height is
611            // resolved here. Either way the child saw a 0-height containing block.
612            // Re-flowing now (the abs height is independent of its content, so this
613            // can't loop) lets `height:100%` children resolve against the real box.
614            // (Root cause of the slippy-map VirtualView blank-bounds bug.)
615            if height_is_auto {
616                let (used_size, inner, child_collapsed) = {
617                    let n = &tree.nodes[node_index];
618                    let used = n.used_size.unwrap_or_default();
619                    let inner = n.box_props.inner_size(used, LayoutWritingMode::HorizontalTb);
620                    let collapsed = inner.height > 1.0
621                        && tree.children(node_index).iter().any(|&c| {
622                            tree.get(c)
623                                .and_then(|cn| cn.used_size)
624                                .is_none_or(|s| s.height < 1.0)
625                        });
626                    (used, inner, collapsed)
627                };
628                let _ = used_size;
629                if child_collapsed {
630                    let constraints = LayoutConstraints {
631                        available_size: inner,
632                        writing_mode: LayoutWritingMode::HorizontalTb,
633                        writing_mode_ctx: super::geometry::WritingModeContext::default(),
634                        bfc_state: None,
635                        text_align: TextAlign::Start,
636                        containing_block_size: inner,
637                        available_width_type:
638                            crate::text3::cache::AvailableSpace::Definite(inner.width),
639                    };
640                    let mut reflow_float_cache: std::collections::HashMap<usize, FloatingContext> =
641                        std::collections::HashMap::new();
642                    drop(layout_formatting_context(
643                        ctx,
644                        tree,
645                        text_cache,
646                        node_index,
647                        &constraints,
648                        &mut reflow_float_cache,
649                    ));
650                }
651            }
652        }
653    }
654}
655
656// +spec:positioning:5b0d7f - relative positioning: offset from normal flow position, siblings unaffected
657// +spec:positioning:8afbe2 - Relative positioning preserves normal flow size and space; only visual offset applied after layout
658// +spec:positioning:3502d5 - relative and absolute positioning supported for combined use
659// +spec:positioning:b22222 - relative positioning: offset from static position, purely visual effect
660// +spec:positioning:b814b6 - relative/absolute/fixed positioning scheme (CSS Positioned Layout Module Level 3)
661/// Final pass to shift relatively positioned elements from their static flow position.
662// +spec:block-formatting-context:60ccf9 - relative positioning shifts inline boxes as a unit after normal flow
663// +spec:display-property:17239f - relative positioning offsets element after normal flow; abspos elements taken out of flow
664// +spec:positioning:cbe066 - relative positioning implementation
665///
666/// Resolves percentage-based offsets for `top`, `left`, etc.
667/// For relatively positioned elements, percentages are
668/// relative to the dimensions of the parent element's content box.
669// +spec:positioning:2d8e15 - relative positioning shifts elements as a unit after normal flow without affecting surrounding content
670#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
671pub fn adjust_relative_positions<T: ParsedFontTrait>(
672    ctx: &mut LayoutContext<'_, T>,
673    tree: &LayoutTree,
674    calculated_positions: &mut super::PositionVec,
675    viewport: LogicalRect, // The viewport is needed if the root element is relative.
676) {
677    use azul_css::props::style::StyleDirection;
678    // NOTE: returns `()` (not `Result<()>`). This fn is Ok-always — its only `?` are on `Option`
679    // inside `.and_then` closures, never propagating to the fn body. The previous `Result<(),
680    // LayoutError>` return forced the `?` at the call site to read an Ok-niche discriminant, which
681    // the remill→wasm lift mis-lowers (per-build, ASLR-dependent) → a FALSE Err that aborted the
682    // whole layout in the web backend (rect=0). Matches sibling reposition_* fns that return ().
683    // Iterate through all nodes. We need the index to modify the position map.
684    for node_index in 0..tree.nodes.len() {
685        let node = &tree.nodes[node_index];
686        let position_type = get_position_type(ctx.styled_dom, node.dom_node_id);
687
688        // +spec:block-formatting-context:faa1cf - static boxes: top/right/bottom/left do not apply
689        // Early continue for non-relative positioning
690        // +spec:overflow:cfb09a - Sticky positioning uses relative-like offsets, clamped to nearest scrollport at scroll time
691        if position_type != LayoutPosition::Relative && position_type != LayoutPosition::Sticky {
692            continue;
693        }
694
695        // +spec:table-layout:6cb73b - position:relative effect on table elements is undefined; skip them
696        // +spec:table-layout:718f91 - relative positioning on table-row/row-group shifts all contents
697        {
698            use azul_css::props::layout::LayoutDisplay;
699            let display = get_display_property(ctx.styled_dom, node.dom_node_id);
700            if let MultiValue::Exact(d) = display {
701                // +spec:positioning:4614dd - position does not apply to table-column-group or table-column boxes
702                // Table-row and row-group elements DO support relative positioning:
703                // the shift affects all contents including cells originating in the row.
704                // Table-column, table-column-group, table-cell, and table-caption do not.
705                if matches!(
706                    d,
707                    LayoutDisplay::TableColumnGroup
708                        | LayoutDisplay::TableColumn
709                        | LayoutDisplay::TableCell
710                        | LayoutDisplay::TableCaption
711                ) {
712                    continue;
713                }
714            }
715        }
716
717        // Determine the containing block size for resolving percentages.
718        // For `position: relative`, this is the parent's content box size.
719        let containing_block_size = node.parent
720            .and_then(|parent_idx| tree.get(parent_idx))
721            .map_or(viewport.size, |parent_node| {
722                // Get parent's writing mode to correctly calculate its inner (content) size.
723                let parent_wm = parent_node.dom_node_id
724                    .map(|pid| {
725                        let ps = &ctx.styled_dom.styled_nodes.as_container()[pid].styled_node_state;
726                        get_writing_mode(ctx.styled_dom, pid, ps).unwrap_or_default()
727                    })
728                    .unwrap_or_default();
729                let parent_used_size = parent_node.used_size.unwrap_or_default();
730                parent_node.box_props.inner_size(parent_used_size, parent_wm)
731            });
732
733        // +spec:positioning:418c74 - inset percentages resolve against containing block size per axis; auto is unconstrained
734        let offsets =
735            resolve_position_offsets(ctx.styled_dom, node.dom_node_id, containing_block_size, viewport.size);
736
737        // Get a mutable reference to the position and apply the offsets.
738        let Some(current_pos) = calculated_positions.get_mut(node_index) else {
739            continue;
740        };
741
742        let initial_pos = *current_pos;
743
744        // +spec:positioning:5eb813 - relative positioning offsets contents from normal flow position
745        // +spec:positioning:a2e5f1 - relative positioning shifts element from static position (vs absolute/float)
746        // top/bottom/left/right offsets are applied relative to the static position.
747        let mut delta_x = 0.0;
748        let mut delta_y = 0.0;
749
750        // +spec:positioning:218b50 - Relative positioning: top=-bottom, left=-right, direction-dependent resolution, top wins over bottom
751        // According to CSS 2.1 Section 9.4.3:
752        // - For `top` and `bottom`: if both are specified, `top` wins and `bottom` is ignored
753        // - For `left` and `right`: depends on direction (ltr/rtl)
754        //   - In LTR: if both specified, `left` wins and `right` is ignored
755        //   - In RTL: if both specified, `right` wins and `left` is ignored
756
757        // +spec:overflow:53dffd - both left/right auto → used values are 0, boxes stay in original position
758        // +spec:positioning:5a099e - negative offsets can cause overlapping (no clamping applied)
759        // +spec:positioning:d189de - bottom offset for relative positioning is with respect to the box's own bottom edge
760        // +spec:positioning:d80f47 - opposing inset values are negations: top wins over bottom, left/right per direction
761        // +spec:positioning:ecc27c - relative positioning: left/right move box horizontally without changing size, left = -right
762        // +spec:positioning:50218d - relative: offset from static position (top edges of box itself)
763        // both auto → 0; one auto → negative of other; neither auto → bottom ignored (top wins)
764        // +spec:positioning:ac768b - relative positioning: both auto→0, one auto→neg of other, neither→top wins; direction-aware left/right
765        // +spec:positioning:e3727e - top/bottom: both auto→0, one auto→negative of other, neither auto→bottom ignored
766        // Vertical positioning: `top` takes precedence over `bottom`
767        if let Some(top) = offsets.top {
768            delta_y = top;
769        } else if let Some(bottom) = offsets.bottom {
770            delta_y = -bottom;
771        }
772
773        // +spec:positioning:1732e8 - left/right for relatively positioned elements determined by 9.4.3 rules
774        // Spec: "If the 'direction' property of the containing block is 'ltr', the value of 'left' wins"
775        // Get the direction of the containing block (parent), not the element itself
776        let cb_direction = node.parent
777            .and_then(|parent_idx| tree.get(parent_idx))
778            .and_then(|parent_node| {
779                let parent_dom_id = parent_node.dom_node_id?;
780                let parent_state =
781                    &ctx.styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
782                match get_direction_property(ctx.styled_dom, parent_dom_id, parent_state) {
783                    MultiValue::Exact(v) => Some(v),
784                    _ => None,
785                }
786            })
787            .unwrap_or(StyleDirection::Ltr);
788        // +spec:containing-block:6d4fb1 - over-constrained relative positioning: ltr→left wins, rtl→right wins
789        match cb_direction {
790            StyleDirection::Ltr => {
791                if let Some(left) = offsets.left {
792                    delta_x = left;
793                } else if let Some(right) = offsets.right {
794                    // +spec:overflow:fb426c - left auto: used value is minus the value of right
795                    delta_x = -right;
796                }
797            }
798            StyleDirection::Rtl => {
799                if let Some(right) = offsets.right {
800                    delta_x = -right;
801                } else if let Some(left) = offsets.left {
802                    delta_x = left;
803                }
804            }
805        }
806
807        // +spec:overflow:f1e1ce - relative positioning may cause overflow:auto/scroll boxes to need scrollbars
808        // Only apply the shift if there is a non-zero delta.
809        if delta_x != 0.0 || delta_y != 0.0 {
810            current_pos.x += delta_x;
811            current_pos.y += delta_y;
812
813            debug_log!(ctx, "Adjusted relative element #{} from {:?} to {:?} (delta: {}, {})",
814                node_index, initial_pos, *current_pos, delta_x, delta_y);
815
816            // +spec:table-layout:ec2600 - For table-row-group, table-header-group, table-footer-group, or table-row,
817            // the relative shift affects all contents of the box including table cells.
818            // Propagate the delta to all descendant nodes.
819            {
820                use azul_css::props::layout::LayoutDisplay;
821                let display = get_display_property(ctx.styled_dom, node.dom_node_id);
822                let is_table_row_like = matches!(
823                    display,
824                    MultiValue::Exact(
825                        LayoutDisplay::TableRowGroup
826                        | LayoutDisplay::TableHeaderGroup
827                        | LayoutDisplay::TableFooterGroup
828                        | LayoutDisplay::TableRow
829                    )
830                );
831                if is_table_row_like {
832                    // Shift all children (and their descendants) by the same delta
833                    let mut stack = tree.children(node_index).to_vec();
834                    while let Some(child_idx) = stack.pop() {
835                        if let Some(child_pos) = calculated_positions.get_mut(child_idx) {
836                            child_pos.x += delta_x;
837                            child_pos.y += delta_y;
838                        }
839                        stack.extend_from_slice(tree.children(child_idx));
840                    }
841                }
842            }
843        }
844    }
845}
846
847// +spec:overflow:bac4e5 - sticky view rectangle from inset properties relative to nearest scrollport
848
849/// Finds the nearest scrollport (ancestor with overflow: scroll or auto) for a node.
850/// Returns the content-box rect of the scrollport, or the viewport if none found.
851fn find_nearest_scrollport(
852    tree: &LayoutTree,
853    node_index: usize,
854    styled_dom: &StyledDom,
855    calculated_positions: &super::PositionVec,
856    viewport: LogicalRect,
857) -> LogicalRect {
858    use crate::solver3::getters::{get_overflow_x, get_overflow_y};
859    use azul_css::props::layout::LayoutOverflow;
860
861    let mut current_parent_idx = tree.get(node_index).and_then(|n| n.parent);
862
863    while let Some(parent_index) = current_parent_idx {
864        let Some(parent_node) = tree.get(parent_index) else {
865            break;
866        };
867        let Some(parent_dom_id) = parent_node.dom_node_id else {
868            current_parent_idx = parent_node.parent;
869            continue;
870        };
871
872        let node_state = &styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
873        let ox = get_overflow_x(styled_dom, parent_dom_id, node_state);
874        let oy = get_overflow_y(styled_dom, parent_dom_id, node_state);
875
876        let is_scrollport = matches!(
877            ox,
878            MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
879        ) || matches!(
880            oy,
881            MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
882        );
883
884        if is_scrollport {
885            let margin_box_pos = calculated_positions
886                .get(parent_index)
887                .copied()
888                .unwrap_or_default();
889            let border_box_size = parent_node.used_size.unwrap_or_default();
890
891            // Content-box = margin-box pos + border + padding, size - border - padding
892            let pbp = parent_node.box_props.unpack();
893            let content_pos = LogicalPosition::new(
894                margin_box_pos.x
895                    + pbp.border.left
896                    + pbp.padding.left,
897                margin_box_pos.y
898                    + pbp.border.top
899                    + pbp.padding.top,
900            );
901            let content_size = LogicalSize::new(
902                (border_box_size.width
903                    - pbp.border.left
904                    - pbp.border.right
905                    - pbp.padding.left
906                    - pbp.padding.right)
907                    .max(0.0),
908                (border_box_size.height
909                    - pbp.border.top
910                    - pbp.border.bottom
911                    - pbp.padding.top
912                    - pbp.padding.bottom)
913                    .max(0.0),
914            );
915            return LogicalRect::new(content_pos, content_size);
916        }
917
918        current_parent_idx = parent_node.parent;
919    }
920
921    viewport
922}
923
924/// Find the scroll offset of the nearest scroll container ancestor.
925/// Returns the scroll offset as a `LogicalPosition` (how far the content has scrolled).
926fn find_nearest_scroll_offset(
927    tree: &LayoutTree,
928    node_index: usize,
929    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
930) -> LogicalPosition {
931    let mut parent = tree.get(node_index).and_then(|n| n.parent);
932    while let Some(pidx) = parent {
933        if let Some(pnode) = tree.get(pidx) {
934            if let Some(dom_id) = pnode.dom_node_id {
935                if let Some(scroll_pos) = scroll_offsets.get(&dom_id) {
936                    let offset_x = scroll_pos.children_rect.origin.x - scroll_pos.parent_rect.origin.x;
937                    let offset_y = scroll_pos.children_rect.origin.y - scroll_pos.parent_rect.origin.y;
938                    return LogicalPosition::new(offset_x, offset_y);
939                }
940            }
941            parent = pnode.parent;
942        } else {
943            break;
944        }
945    }
946    LogicalPosition::zero()
947}
948
949/// Adjusts positions of sticky-positioned elements based on scroll offset.
950///
951/// Sticky positioning works like relative positioning, but the element's position
952/// is constrained by its inset properties (top/right/bottom/left) relative to the
953/// nearest scrollport (scroll container ancestor). The margin box is further
954/// constrained to remain within the containing block.
955///
956/// +spec:position-sticky:9449f1 - for sticky positioning, insets represent offsets from scrollport edge
957/// +spec:position-sticky:75412d - multiple sticky boxes in same container offset independently
958/// +spec:box-model:af9af8 - sticky positioning: shift element to stay within sticky view rectangle, margin box constrained to containing block
959/// +spec:overflow:bac4e5 - compute sticky view rectangle, clamp end-edge insets to border box size
960#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
961pub fn adjust_sticky_positions<T: ParsedFontTrait>(
962    ctx: &mut LayoutContext<'_, T>,
963    tree: &LayoutTree,
964    calculated_positions: &mut super::PositionVec,
965    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
966    viewport: LogicalRect,
967) {
968    // Returns `()` (not `Result<()>`): Ok-always (its only `?` is Option-`?` in an `.and_then`
969    // closure). Avoids the lift-fragile Result<(),LayoutError> Ok-niche read at the call site.
970    for node_index in 0..tree.nodes.len() {
971        let node = &tree.nodes[node_index];
972        let position_type = get_position_type(ctx.styled_dom, node.dom_node_id);
973
974        if position_type != LayoutPosition::Sticky {
975            continue;
976        }
977
978        let Some(dom_id) = node.dom_node_id else {
979            continue;
980        };
981
982        // Find the nearest scrollport for this sticky element
983        let scrollport = find_nearest_scrollport(
984            tree,
985            node_index,
986            ctx.styled_dom,
987            calculated_positions,
988            viewport,
989        );
990
991        // The containing block for percentage resolution is the parent's content box
992        let containing_block = node.parent
993            .and_then(|parent_idx| {
994                let parent_node = tree.get(parent_idx)?;
995                let parent_pos = calculated_positions.get(parent_idx).copied().unwrap_or_default();
996                let parent_size = parent_node.used_size.unwrap_or_default();
997                let parent_wm = parent_node.dom_node_id
998                    .map(|pid| {
999                        let ps = &ctx.styled_dom.styled_nodes.as_container()[pid].styled_node_state;
1000                        get_writing_mode(ctx.styled_dom, pid, ps).unwrap_or_default()
1001                    })
1002                    .unwrap_or_default();
1003                let pbp = parent_node.box_props.unpack();
1004                let content_size = pbp.inner_size(parent_size, parent_wm);
1005                let content_origin = LogicalPosition::new(
1006                    parent_pos.x + pbp.border.left + pbp.padding.left,
1007                    parent_pos.y + pbp.border.top + pbp.padding.top,
1008                );
1009                Some(LogicalRect::new(content_origin, content_size))
1010            })
1011            .unwrap_or(viewport);
1012
1013        // Resolve inset properties (top, right, bottom, left)
1014        let offsets = resolve_position_offsets(ctx.styled_dom, Some(dom_id), scrollport.size, viewport.size);
1015
1016        // Get the scroll offset from the nearest scroll container
1017        let scroll_offset = find_nearest_scroll_offset(tree, node_index, scroll_offsets);
1018
1019        let Some(current_pos) = calculated_positions.get_mut(node_index) else {
1020            continue;
1021        };
1022
1023        let static_pos = *current_pos;
1024        let element_size = node.used_size.unwrap_or_default();
1025        let nbp = node.box_props.unpack();
1026        let margin = &nbp.margin;
1027
1028        let mut shift_x = 0.0f32;
1029        let mut shift_y = 0.0f32;
1030
1031        // For each side: if inset is not auto, clamp the border edge to stay
1032        // within the sticky view rectangle (scrollport inset by the specified amount).
1033        // The scroll offset shifts the effective scrollport position.
1034        if let Some(top_inset) = offsets.top {
1035            let sticky_edge = scrollport.origin.y + scroll_offset.y + top_inset;
1036            let border_top = current_pos.y;
1037            if border_top < sticky_edge {
1038                shift_y = shift_y.max(sticky_edge - border_top);
1039            }
1040        }
1041
1042        if let Some(bottom_inset) = offsets.bottom {
1043            let sticky_edge = scrollport.origin.y + scroll_offset.y + scrollport.size.height - bottom_inset;
1044            let border_bottom = current_pos.y + element_size.height;
1045            if border_bottom > sticky_edge {
1046                shift_y = shift_y.min(sticky_edge - border_bottom);
1047            }
1048        }
1049
1050        if let Some(left_inset) = offsets.left {
1051            let sticky_edge = scrollport.origin.x + scroll_offset.x + left_inset;
1052            let border_left = current_pos.x;
1053            if border_left < sticky_edge {
1054                shift_x = shift_x.max(sticky_edge - border_left);
1055            }
1056        }
1057
1058        if let Some(right_inset) = offsets.right {
1059            let sticky_edge = scrollport.origin.x + scroll_offset.x + scrollport.size.width - right_inset;
1060            let border_right = current_pos.x + element_size.width;
1061            if border_right > sticky_edge {
1062                shift_x = shift_x.min(sticky_edge - border_right);
1063            }
1064        }
1065
1066        // Constrain: the margin box must remain within the containing block
1067        if shift_y != 0.0 {
1068            let margin_box_top = current_pos.y - margin.top + shift_y;
1069            let margin_box_bottom = current_pos.y + element_size.height + margin.bottom + shift_y;
1070            if margin_box_top < containing_block.origin.y {
1071                shift_y += containing_block.origin.y - margin_box_top;
1072            }
1073            let cb_bottom = containing_block.origin.y + containing_block.size.height;
1074            if margin_box_bottom > cb_bottom {
1075                shift_y -= margin_box_bottom - cb_bottom;
1076            }
1077        }
1078
1079        if shift_x != 0.0 {
1080            let margin_box_left = current_pos.x - margin.left + shift_x;
1081            let margin_box_right = current_pos.x + element_size.width + margin.right + shift_x;
1082            if margin_box_left < containing_block.origin.x {
1083                shift_x += containing_block.origin.x - margin_box_left;
1084            }
1085            let cb_right = containing_block.origin.x + containing_block.size.width;
1086            if margin_box_right > cb_right {
1087                shift_x -= margin_box_right - cb_right;
1088            }
1089        }
1090
1091        if shift_x != 0.0 || shift_y != 0.0 {
1092            current_pos.x += shift_x;
1093            current_pos.y += shift_y;
1094
1095            debug_log!(ctx, "Adjusted sticky element #{} from {:?} to {:?}",
1096                node_index, static_pos, *current_pos);
1097        }
1098    }
1099}
1100
1101// +spec:positioning:22f165 - absolute/fixed containing block: nearest positioned ancestor's padding-box, or initial CB
1102/// Helper to find the containing block for an absolutely positioned element.
1103/// CSS 2.1 Section 10.1: The containing block for absolutely positioned elements
1104/// is the padding box of the nearest positioned ancestor.
1105// +spec:containing-block:10af51 - absolutely positioned element's CB is nearest positioned ancestor
1106// +spec:positioning:2d0dbb - containing block for abspos is padding-box of nearest positioned ancestor, or initial CB
1107// +spec:positioning:3ac06c - abspos positioned relative to containing block ignoring fragmentation breaks
1108// +spec:positioning:d7e4b4 - containing block of abspos element is always definite (returns concrete LogicalRect)
1109// +spec:positioning:fc9dba - containing block resolution for absolutely positioned boxes
1110///
1111/// Returns a `LogicalRect` representing the padding-box of the nearest
1112/// positioned ancestor, or the viewport (initial containing block) if none exists.
1113/// This is the unified entry point used by both sizing and positioning phases.
1114// +spec:containing-block:18ae8e - Absolute positioning: abs-pos box establishes new CB for normal flow and abs-pos (but not fixed) descendants
1115// +spec:containing-block:b6cb8b - containing block for abs-pos is nearest positioned ancestor
1116// +spec:display-property:5a39bc - containing block for abspos is nearest positioned ancestor or initial containing block
1117// +spec:positioning:09a0fa - Absolute positioning: CB is padding-box of nearest positioned ancestor
1118// +spec:positioning:467cb1 - Containing block for abs pos = nearest positioned ancestor or initial CB
1119// +spec:positioning:99d0bb - containing block for absolute elements is nearest positioned ancestor
1120// +spec:positioning:92e099 - containing block for abs pos is nearest positioned ancestor or initial CB
1121// +spec:positioning:f57523 - containing block of abspos element is always definite (returns concrete LogicalRect)
1122// +spec:width-calculation:bf1aa6 - abspos CB is nearest positioned ancestor, else initial CB
1123// Containing block for absolutely positioned elements is established by
1124// nearest positioned ancestor (relative/absolute/fixed), or initial containing block if none.
1125// +spec:positioning:8f50de - relatively positioned parent serves as containing block for abspos descendants
1126// +spec:containing-block:6bcb0c - containing block is padding edge of nearest positioned ancestor, or initial containing block if none
1127// +spec:containing-block:bf17e5 - containing block for abspos is padding box of nearest positioned ancestor, or initial CB
1128// +spec:containing-block:d0f92d - containing block for positioned box is nearest positioned ancestor, or initial containing block
1129// +spec:containing-block:d7e013 - containing block for positioned box is nearest positioned ancestor or initial CB
1130// +spec:containing-block:05bc0d - positioning an element changes which ancestor establishes the CB for its descendants
1131// +spec:positioning:355ee4 - CB for abspos is padding edge of nearest positioned ancestor, or initial CB
1132// +spec:positioning:383794 - Containing block for abspos is nearest positioned ancestor, or initial containing block if none
1133// +spec:positioning:5b3e43 - Containing block for abs-pos is padding box of nearest positioned ancestor, or initial CB
1134// +spec:positioning:882e67 - containing block for abs pos is nearest positioned ancestor or initial CB
1135// +spec:positioning:292c5c - relative parent serves as containing block for absolute descendants
1136// +spec:positioning:00ce38 - CB for absolute is padding edge of nearest positioned ancestor
1137pub(crate) fn find_absolute_containing_block_rect(
1138    tree: &LayoutTree,
1139    node_index: usize,
1140    styled_dom: &StyledDom,
1141    calculated_positions: &super::PositionVec,
1142    viewport: LogicalRect,
1143) -> Result<LogicalRect> {
1144    // +spec:positioning:748d87 - walk up to nearest positioned ancestor for CB
1145    let mut current_parent_idx = tree.get(node_index).and_then(|n| n.parent);
1146
1147    // +spec:positioning:aa361e - values other than static make a box positioned and establish an abspos containing block
1148    while let Some(parent_index) = current_parent_idx {
1149        let parent_node = tree.get(parent_index).ok_or(LayoutError::InvalidTree)?;
1150
1151        if get_position_type(styled_dom, parent_node.dom_node_id).is_positioned() {
1152            // calculated_positions stores margin-box positions
1153            let margin_box_pos = calculated_positions
1154                .get(parent_index)
1155                .copied()
1156                .unwrap_or_default();
1157            // used_size is the border-box size
1158            let border_box_size = parent_node.used_size.unwrap_or_default();
1159
1160            // +spec:containing-block:6bcb0c - containing block formed by padding edge of nearest positioned ancestor
1161            // +spec:positioning:df1921 - abs-pos percentage widths resolve against padding box of containing block
1162            // Calculate padding-box origin (margin-box + border)
1163            let pbp = parent_node.box_props.unpack();
1164            let padding_box_pos = LogicalPosition::new(
1165                margin_box_pos.x + pbp.border.left,
1166                margin_box_pos.y + pbp.border.top,
1167            );
1168
1169            // Calculate padding-box size (border-box - borders)
1170            let padding_box_size = LogicalSize::new(
1171                (border_box_size.width
1172                    - pbp.border.left
1173                    - pbp.border.right)
1174                    .max(0.0),
1175                (border_box_size.height
1176                    - pbp.border.top
1177                    - pbp.border.bottom)
1178                    .max(0.0),
1179            );
1180
1181            return Ok(LogicalRect::new(padding_box_pos, padding_box_size));
1182        }
1183        current_parent_idx = parent_node.parent;
1184    }
1185
1186    // +spec:positioning:3d88c9 - abspos available space is always definite (viewport or positioned ancestor padding box)
1187    // No positioned ancestor found: fall back to initial containing block (viewport)
1188    // +spec:containing-block:141dcc - absolute element with no positioned ancestor uses initial containing block
1189    // +spec:containing-block:657f2f - containing block becomes initial containing block when no positioned ancestors
1190    // +spec:containing-block:7f5090 - if no ancestor establishes one, absolute positioning CB is initial containing block
1191    // +spec:containing-block:7f5090 - fallback to initial containing block when no positioned ancestor
1192    // +spec:containing-block:ad5ebc - no positioned ancestor: containing block becomes the initial containing block
1193    // +spec:display-property:813192 - abspos containing block falls back to initial containing block (viewport) when no positioned ancestor
1194    Ok(viewport)
1195}
1196
1197#[cfg(test)]
1198#[allow(clippy::float_cmp, clippy::too_many_lines)]
1199mod autotest_generated {
1200    use azul_core::dom::{Dom, FormattingContext, IdOrClass};
1201
1202    use super::*;
1203    use crate::solver3::{
1204        geometry::{EdgeSizes, MarginAuto, PackedBoxProps, ResolvedBoxProps},
1205        layout_tree::{LayoutNodeCold, LayoutNodeHot, LayoutNodeWarm},
1206        pos_set, PositionVec, POSITION_UNSET,
1207    };
1208
1209    // ==================================================================
1210    // Fixtures
1211    // ==================================================================
1212
1213    fn close(a: f32, b: f32, eps: f32) -> bool {
1214        (a - b).abs() <= eps
1215    }
1216
1217    fn viewport() -> LogicalRect {
1218        LogicalRect::new(
1219            LogicalPosition::new(0.0, 0.0),
1220            LogicalSize::new(800.0, 600.0),
1221        )
1222    }
1223
1224    fn styled(dom: Dom, css_str: &str) -> StyledDom {
1225        let mut dom = dom;
1226        let (css, _warnings) = azul_css::parser2::new_from_str(css_str);
1227        StyledDom::create(&mut dom, css)
1228    }
1229
1230    fn div_class(class: &str) -> Dom {
1231        Dom::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
1232    }
1233
1234    fn body_class(class: &str) -> Dom {
1235        Dom::create_body().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
1236    }
1237
1238    /// Structural lookup — never hard-code `CompactDom` pre-order indices.
1239    fn node_by_class(sd: &StyledDom, class: &str) -> NodeId {
1240        let container = sd.node_data.as_container();
1241        for i in 0..sd.node_data.len() {
1242            let id = NodeId::new(i);
1243            let ids_and_classes = container[id].get_ids_and_classes();
1244            let hit = ids_and_classes
1245                .as_ref()
1246                .iter()
1247                .any(|ioc| matches!(ioc, IdOrClass::Class(c) if c.as_str() == class));
1248            if hit {
1249                return id;
1250            }
1251        }
1252        panic!("no node with class {class:?}");
1253    }
1254
1255    fn edges(top: f32, right: f32, bottom: f32, left: f32) -> EdgeSizes {
1256        EdgeSizes {
1257            top,
1258            right,
1259            bottom,
1260            left,
1261        }
1262    }
1263
1264    fn uniform(v: f32) -> EdgeSizes {
1265        edges(v, v, v, v)
1266    }
1267
1268    fn bp(margin: EdgeSizes, padding: EdgeSizes, border: EdgeSizes) -> PackedBoxProps {
1269        PackedBoxProps::pack(&ResolvedBoxProps {
1270            margin,
1271            padding,
1272            border,
1273            margin_auto: MarginAuto::default(),
1274        })
1275    }
1276
1277    fn bp_auto_margins(margin_auto: MarginAuto) -> PackedBoxProps {
1278        PackedBoxProps::pack(&ResolvedBoxProps {
1279            margin: uniform(0.0),
1280            padding: uniform(0.0),
1281            border: uniform(0.0),
1282            margin_auto,
1283        })
1284    }
1285
1286    fn hot(parent: Option<usize>, dom_node_id: Option<NodeId>) -> LayoutNodeHot {
1287        LayoutNodeHot {
1288            box_props: PackedBoxProps::default(),
1289            dom_node_id,
1290            used_size: None,
1291            formatting_context: FormattingContext::Block {
1292                establishes_new_context: false,
1293            },
1294            parent,
1295        }
1296    }
1297
1298    /// Hand-assembles a `LayoutTree` so the index / dangling-parent edge cases the
1299    /// real builder can never produce stay reachable.
1300    fn raw_tree(nodes: Vec<LayoutNodeHot>, child_lists: &[Vec<usize>]) -> LayoutTree {
1301        let n = nodes.len();
1302        let mut children_arena: Vec<usize> = Vec::new();
1303        let mut children_offsets: Vec<(u32, u32)> = Vec::with_capacity(n);
1304        for cl in child_lists {
1305            let start = u32::try_from(children_arena.len()).unwrap();
1306            children_arena.extend_from_slice(cl);
1307            children_offsets.push((start, u32::try_from(cl.len()).unwrap()));
1308        }
1309        while children_offsets.len() < n {
1310            children_offsets.push((0, 0));
1311        }
1312        LayoutTree {
1313            nodes,
1314            warm: vec![LayoutNodeWarm::default(); n],
1315            cold: vec![LayoutNodeCold::default(); n],
1316            root: 0,
1317            dom_to_layout: BTreeMap::new(),
1318            children_arena,
1319            children_offsets,
1320            subtree_needs_intrinsic: Vec::new(),
1321        }
1322    }
1323
1324    /// `body.root > div.child`, both mirrored 1:1 into a two-node layout tree.
1325    fn two_level(css: &str) -> (StyledDom, LayoutTree) {
1326        let sd = styled(body_class("root").with_child(div_class("child")), css);
1327        let root = node_by_class(&sd, "root");
1328        let child = node_by_class(&sd, "child");
1329        let tree = raw_tree(
1330            vec![hot(None, Some(root)), hot(Some(0), Some(child))],
1331            &[vec![1], vec![]],
1332        );
1333        (sd, tree)
1334    }
1335
1336    /// `body.root > div.mid > div.child`.
1337    fn three_level(css: &str) -> (StyledDom, LayoutTree) {
1338        let sd = styled(
1339            body_class("root").with_child(div_class("mid").with_child(div_class("child"))),
1340            css,
1341        );
1342        let root = node_by_class(&sd, "root");
1343        let mid = node_by_class(&sd, "mid");
1344        let child = node_by_class(&sd, "child");
1345        let tree = raw_tree(
1346            vec![
1347                hot(None, Some(root)),
1348                hot(Some(0), Some(mid)),
1349                hot(Some(1), Some(child)),
1350            ],
1351            &[vec![1], vec![2], vec![]],
1352        );
1353        (sd, tree)
1354    }
1355
1356    fn positions(list: &[(f32, f32)]) -> PositionVec {
1357        list.iter()
1358            .map(|&(x, y)| LogicalPosition::new(x, y))
1359            .collect()
1360    }
1361
1362    // ==================================================================
1363    // get_position_type (other / no-panic smoke + invariants)
1364    // ==================================================================
1365
1366    #[test]
1367    fn get_position_type_none_dom_id_is_static() {
1368        let (sd, _tree) = two_level("");
1369        assert_eq!(get_position_type(&sd, None), LayoutPosition::Static);
1370    }
1371
1372    #[test]
1373    fn get_position_type_unstyled_node_is_static() {
1374        let (sd, _tree) = two_level("");
1375        let child = node_by_class(&sd, "child");
1376        assert_eq!(get_position_type(&sd, Some(child)), LayoutPosition::Static);
1377    }
1378
1379    #[test]
1380    fn get_position_type_reads_every_keyword() {
1381        let sd = styled(
1382            body_class("root")
1383                .with_child(div_class("st"))
1384                .with_child(div_class("rel"))
1385                .with_child(div_class("abs"))
1386                .with_child(div_class("fix"))
1387                .with_child(div_class("sticky")),
1388            ".st { position: static; } .rel { position: relative; } \
1389             .abs { position: absolute; } .fix { position: fixed; } \
1390             .sticky { position: sticky; }",
1391        );
1392        for (class, expected) in [
1393            ("st", LayoutPosition::Static),
1394            ("rel", LayoutPosition::Relative),
1395            ("abs", LayoutPosition::Absolute),
1396            ("fix", LayoutPosition::Fixed),
1397            ("sticky", LayoutPosition::Sticky),
1398        ] {
1399            let id = node_by_class(&sd, class);
1400            assert_eq!(get_position_type(&sd, Some(id)), expected, "class {class}");
1401        }
1402    }
1403
1404    #[test]
1405    fn get_position_type_garbage_value_falls_back_to_static() {
1406        // An unparseable declaration must not leak a bogus enum — it is dropped
1407        // by the parser, so the cascade yields the initial value.
1408        let (sd, _tree) = two_level(".child { position: rubbish-42; }");
1409        let child = node_by_class(&sd, "child");
1410        assert_eq!(get_position_type(&sd, Some(child)), LayoutPosition::Static);
1411    }
1412
1413    #[test]
1414    fn get_position_type_is_pure_and_stable_across_calls() {
1415        let (sd, _tree) = two_level(".child { position: sticky; }");
1416        let child = node_by_class(&sd, "child");
1417        let a = get_position_type(&sd, Some(child));
1418        let b = get_position_type(&sd, Some(child));
1419        assert_eq!(a, b);
1420        assert_eq!(a, LayoutPosition::Sticky);
1421        // The invariant the whole positioning pass leans on.
1422        assert!(a.is_positioned());
1423    }
1424
1425    // ==================================================================
1426    // resolve_position_offsets (numeric)
1427    // ==================================================================
1428
1429    #[test]
1430    fn resolve_position_offsets_none_dom_id_is_all_none() {
1431        let (sd, _tree) = two_level(".child { top: 10px; }");
1432        let o = resolve_position_offsets(
1433            &sd,
1434            None,
1435            LogicalSize::new(100.0, 100.0),
1436            LogicalSize::new(800.0, 600.0),
1437        );
1438        assert!(o.top.is_none() && o.right.is_none() && o.bottom.is_none() && o.left.is_none());
1439    }
1440
1441    #[test]
1442    fn resolve_position_offsets_unset_insets_are_none_not_zero() {
1443        // `auto` must stay distinguishable from `0px` — the entire abspos
1444        // constraint solver branches on it.
1445        let (sd, _tree) = two_level("");
1446        let child = node_by_class(&sd, "child");
1447        let o = resolve_position_offsets(
1448            &sd,
1449            Some(child),
1450            LogicalSize::new(100.0, 100.0),
1451            LogicalSize::new(800.0, 600.0),
1452        );
1453        assert!(o.top.is_none() && o.right.is_none() && o.bottom.is_none() && o.left.is_none());
1454    }
1455
1456    #[test]
1457    fn resolve_position_offsets_zero_px_is_some_zero() {
1458        let (sd, _tree) = two_level(".child { top: 0px; left: 0px; }");
1459        let child = node_by_class(&sd, "child");
1460        let o = resolve_position_offsets(
1461            &sd,
1462            Some(child),
1463            LogicalSize::new(0.0, 0.0),
1464            LogicalSize::new(0.0, 0.0),
1465        );
1466        assert_eq!(o.top, Some(0.0));
1467        assert_eq!(o.left, Some(0.0));
1468        assert!(o.right.is_none() && o.bottom.is_none());
1469    }
1470
1471    #[test]
1472    fn resolve_position_offsets_px_values_round_trip() {
1473        let (sd, _tree) =
1474            two_level(".child { top: 11px; right: 22px; bottom: 33px; left: 44px; }");
1475        let child = node_by_class(&sd, "child");
1476        let o = resolve_position_offsets(
1477            &sd,
1478            Some(child),
1479            LogicalSize::new(200.0, 100.0),
1480            LogicalSize::new(800.0, 600.0),
1481        );
1482        assert_eq!(o.top, Some(11.0));
1483        assert_eq!(o.right, Some(22.0));
1484        assert_eq!(o.bottom, Some(33.0));
1485        assert_eq!(o.left, Some(44.0));
1486    }
1487
1488    #[test]
1489    fn resolve_position_offsets_percent_uses_the_correct_axis() {
1490        // +spec:containing-block:d4b3b9 — top/bottom resolve against CB height,
1491        // left/right against CB width. Swapping the axes is the classic bug here.
1492        let (sd, _tree) =
1493            two_level(".child { top: 50%; bottom: 25%; left: 50%; right: 10%; }");
1494        let child = node_by_class(&sd, "child");
1495        let o = resolve_position_offsets(
1496            &sd,
1497            Some(child),
1498            LogicalSize::new(400.0, 200.0),
1499            LogicalSize::new(800.0, 600.0),
1500        );
1501        assert_eq!(o.top, Some(100.0), "50% of CB height 200");
1502        assert_eq!(o.bottom, Some(50.0), "25% of CB height 200");
1503        assert_eq!(o.left, Some(200.0), "50% of CB width 400");
1504        assert_eq!(o.right, Some(40.0), "10% of CB width 400");
1505    }
1506
1507    #[test]
1508    fn resolve_position_offsets_percent_of_zero_containing_block_is_zero() {
1509        let (sd, _tree) = two_level(".child { top: 75%; left: 75%; }");
1510        let child = node_by_class(&sd, "child");
1511        let o = resolve_position_offsets(
1512            &sd,
1513            Some(child),
1514            LogicalSize::new(0.0, 0.0),
1515            LogicalSize::new(800.0, 600.0),
1516        );
1517        assert_eq!(o.top, Some(0.0));
1518        assert_eq!(o.left, Some(0.0));
1519    }
1520
1521    #[test]
1522    fn resolve_position_offsets_negative_values_stay_negative() {
1523        let (sd, _tree) = two_level(".child { top: -40px; left: -25%; }");
1524        let child = node_by_class(&sd, "child");
1525        let o = resolve_position_offsets(
1526            &sd,
1527            Some(child),
1528            LogicalSize::new(400.0, 200.0),
1529            LogicalSize::new(800.0, 600.0),
1530        );
1531        assert_eq!(o.top, Some(-40.0));
1532        assert_eq!(o.left, Some(-100.0), "-25% of CB width 400");
1533    }
1534
1535    #[test]
1536    fn resolve_position_offsets_em_uses_element_font_size_rem_uses_root() {
1537        let sd = styled(
1538            body_class("root").with_child(div_class("child")),
1539            ".root { font-size: 10px; } .child { font-size: 20px; top: 2em; left: 3rem; }",
1540        );
1541        let child = node_by_class(&sd, "child");
1542        let o = resolve_position_offsets(
1543            &sd,
1544            Some(child),
1545            LogicalSize::new(400.0, 200.0),
1546            LogicalSize::new(800.0, 600.0),
1547        );
1548        assert_eq!(o.top, Some(40.0), "2em of the element's own 20px font");
1549        assert_eq!(o.left, Some(30.0), "3rem of the 10px root font");
1550    }
1551
1552    #[test]
1553    fn resolve_position_offsets_viewport_units_use_the_viewport_not_the_containing_block() {
1554        let (sd, _tree) = two_level(".child { top: 10vh; left: 10vw; }");
1555        let child = node_by_class(&sd, "child");
1556        let o = resolve_position_offsets(
1557            &sd,
1558            Some(child),
1559            LogicalSize::new(50.0, 50.0), // deliberately not the viewport
1560            LogicalSize::new(800.0, 600.0),
1561        );
1562        assert_eq!(o.top, Some(60.0), "10vh of a 600px viewport");
1563        assert_eq!(o.left, Some(80.0), "10vw of an 800px viewport");
1564    }
1565
1566    #[test]
1567    fn resolve_position_offsets_huge_px_bypasses_the_i16_compact_cache_intact() {
1568        // The compact cache encodes insets as i16 ×10 (±3276.7px) and emits a
1569        // sentinel outside that range. The sentinel MUST fall through to the slow
1570        // cascade path with the value intact — silently saturating to 3276.7px
1571        // (or wrapping to a negative!) would be the nasty failure here.
1572        let (sd, _tree) = two_level(".child { top: 100000px; left: -100000px; }");
1573        let child = node_by_class(&sd, "child");
1574        let o = resolve_position_offsets(
1575            &sd,
1576            Some(child),
1577            LogicalSize::new(400.0, 200.0),
1578            LogicalSize::new(800.0, 600.0),
1579        );
1580        assert_eq!(o.top, Some(100_000.0));
1581        assert_eq!(o.left, Some(-100_000.0));
1582    }
1583
1584    #[test]
1585    fn resolve_position_offsets_around_the_i16_cache_boundary_agree_within_a_tenth_px() {
1586        // 3276.3px is the largest encodable value; 3276.4px trips the sentinel and
1587        // takes the slow path. Both paths must land on the authored value.
1588        let (sd, _tree) = two_level(".child { top: 3276.3px; bottom: 3276.4px; }");
1589        let child = node_by_class(&sd, "child");
1590        let o = resolve_position_offsets(
1591            &sd,
1592            Some(child),
1593            LogicalSize::new(400.0, 200.0),
1594            LogicalSize::new(800.0, 600.0),
1595        );
1596        let top = o.top.expect("top is set");
1597        let bottom = o.bottom.expect("bottom is set");
1598        assert!(close(top, 3276.3, 0.1), "top was {top}");
1599        assert!(close(bottom, 3276.4, 0.1), "bottom was {bottom}");
1600    }
1601
1602    #[test]
1603    fn resolve_position_offsets_sub_tenth_px_precision_loss_is_bounded() {
1604        // The i16 ×10 cache quantises to 0.1px. That is allowed — but it must not
1605        // drift further than that.
1606        let (sd, _tree) = two_level(".child { top: 10.567px; }");
1607        let child = node_by_class(&sd, "child");
1608        let o = resolve_position_offsets(
1609            &sd,
1610            Some(child),
1611            LogicalSize::new(400.0, 200.0),
1612            LogicalSize::new(800.0, 600.0),
1613        );
1614        let top = o.top.expect("top is set");
1615        assert!(close(top, 10.567, 0.05), "top was {top}");
1616    }
1617
1618    #[test]
1619    fn resolve_position_offsets_nan_containing_block_yields_nan_not_a_panic() {
1620        let (sd, _tree) = two_level(".child { top: 50%; left: 50%; }");
1621        let child = node_by_class(&sd, "child");
1622        let o = resolve_position_offsets(
1623            &sd,
1624            Some(child),
1625            LogicalSize::new(f32::NAN, f32::NAN),
1626            LogicalSize::new(800.0, 600.0),
1627        );
1628        assert!(o.top.expect("top is set").is_nan());
1629        assert!(o.left.expect("left is set").is_nan());
1630    }
1631
1632    #[test]
1633    fn resolve_position_offsets_infinite_containing_block_yields_infinity_not_a_panic() {
1634        let (sd, _tree) = two_level(".child { top: 50%; left: 50%; }");
1635        let child = node_by_class(&sd, "child");
1636        let o = resolve_position_offsets(
1637            &sd,
1638            Some(child),
1639            LogicalSize::new(f32::INFINITY, f32::INFINITY),
1640            LogicalSize::new(800.0, 600.0),
1641        );
1642        assert_eq!(o.top, Some(f32::INFINITY));
1643        assert_eq!(o.left, Some(f32::INFINITY));
1644    }
1645
1646    #[test]
1647    fn resolve_position_offsets_at_f32_max_containing_block_does_not_panic() {
1648        let (sd, _tree) = two_level(".child { top: 100%; left: 100%; }");
1649        let child = node_by_class(&sd, "child");
1650        let o = resolve_position_offsets(
1651            &sd,
1652            Some(child),
1653            LogicalSize::new(f32::MAX, f32::MAX),
1654            LogicalSize::new(f32::MAX, f32::MAX),
1655        );
1656        // 100% of MAX is MAX (the normalized 1.0 multiply is exact).
1657        assert_eq!(o.top, Some(f32::MAX));
1658        assert_eq!(o.left, Some(f32::MAX));
1659    }
1660
1661    // ==================================================================
1662    // find_absolute_containing_block_rect (numeric)
1663    // ==================================================================
1664
1665    #[test]
1666    fn find_absolute_cb_rect_root_without_parent_is_the_viewport() {
1667        let (sd, tree) = two_level(".root { position: relative; }");
1668        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1669        let got = find_absolute_containing_block_rect(&tree, 0, &sd, &pos, viewport())
1670            .expect("root resolves to the initial CB");
1671        assert_eq!(got, viewport());
1672    }
1673
1674    #[test]
1675    fn find_absolute_cb_rect_out_of_range_index_is_the_viewport_not_a_panic() {
1676        let (sd, tree) = two_level(".root { position: relative; }");
1677        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1678        let got = find_absolute_containing_block_rect(&tree, 9_999, &sd, &pos, viewport())
1679            .expect("an out-of-range index falls back to the initial CB");
1680        assert_eq!(got, viewport());
1681    }
1682
1683    #[test]
1684    fn find_absolute_cb_rect_dangling_parent_index_is_an_error_not_a_panic() {
1685        let (sd, mut tree) = two_level(".root { position: relative; }");
1686        tree.nodes[1].parent = Some(9_999); // corrupt the tree
1687        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1688        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport());
1689        assert!(matches!(got, Err(LayoutError::InvalidTree)));
1690    }
1691
1692    #[test]
1693    fn find_absolute_cb_rect_static_ancestors_fall_back_to_the_viewport() {
1694        let (sd, mut tree) = three_level("");
1695        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
1696        tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 100.0));
1697        let pos = positions(&[(0.0, 0.0), (10.0, 10.0), (20.0, 20.0)]);
1698        let got = find_absolute_containing_block_rect(&tree, 2, &sd, &pos, viewport())
1699            .expect("no positioned ancestor → initial CB");
1700        assert_eq!(got, viewport());
1701    }
1702
1703    #[test]
1704    fn find_absolute_cb_rect_is_the_padding_box_of_the_positioned_ancestor() {
1705        // CSS 2.1 §10.1: padding box, i.e. margin-box origin + border, size - borders.
1706        let (sd, mut tree) = two_level(".root { position: relative; }");
1707        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
1708        tree.nodes[0].box_props = bp(uniform(0.0), uniform(5.0), uniform(10.0));
1709        let pos = positions(&[(20.0, 30.0), (0.0, 0.0)]);
1710        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
1711            .expect("relative parent is the CB");
1712        assert_eq!(got.origin, LogicalPosition::new(30.0, 40.0));
1713        assert_eq!(got.size, LogicalSize::new(380.0, 280.0));
1714    }
1715
1716    #[test]
1717    fn find_absolute_cb_rect_accepts_every_positioned_ancestor_kind() {
1718        for keyword in ["relative", "absolute", "fixed", "sticky"] {
1719            let css = format!(".root {{ position: {keyword}; }}");
1720            let (sd, mut tree) = two_level(&css);
1721            tree.nodes[0].used_size = Some(LogicalSize::new(100.0, 100.0));
1722            let pos = positions(&[(5.0, 5.0), (0.0, 0.0)]);
1723            let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
1724                .expect("positioned ancestor resolves");
1725            assert_eq!(
1726                got,
1727                LogicalRect::new(
1728                    LogicalPosition::new(5.0, 5.0),
1729                    LogicalSize::new(100.0, 100.0)
1730                ),
1731                "position: {keyword}"
1732            );
1733        }
1734    }
1735
1736    #[test]
1737    fn find_absolute_cb_rect_picks_the_nearest_positioned_ancestor() {
1738        let (sd, mut tree) = three_level(".root { position: relative; } .mid { position: absolute; }");
1739        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
1740        tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 100.0));
1741        let pos = positions(&[(0.0, 0.0), (50.0, 60.0), (0.0, 0.0)]);
1742        let got = find_absolute_containing_block_rect(&tree, 2, &sd, &pos, viewport())
1743            .expect("nearest positioned ancestor");
1744        assert_eq!(got.origin, LogicalPosition::new(50.0, 60.0), "mid, not root");
1745        assert_eq!(got.size, LogicalSize::new(200.0, 100.0));
1746    }
1747
1748    #[test]
1749    fn find_absolute_cb_rect_saturating_borders_clamp_the_padding_box_to_zero() {
1750        // PackedBoxProps saturates each edge at 3276.7px. Two of those exceed a
1751        // 100px border box — the padding box must clamp to 0, never go negative.
1752        let (sd, mut tree) = two_level(".root { position: relative; }");
1753        tree.nodes[0].used_size = Some(LogicalSize::new(100.0, 100.0));
1754        tree.nodes[0].box_props = bp(uniform(0.0), uniform(0.0), uniform(1e30));
1755        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1756        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
1757            .expect("saturated borders still resolve");
1758        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
1759        assert!(got.size.width >= 0.0 && got.size.height >= 0.0);
1760        assert!(got.origin.x.is_finite() && got.origin.y.is_finite());
1761    }
1762
1763    #[test]
1764    fn find_absolute_cb_rect_unsized_ancestor_is_a_zero_sized_padding_box() {
1765        let (sd, tree) = two_level(".root { position: relative; }"); // used_size stays None
1766        let pos = positions(&[(7.0, 9.0), (0.0, 0.0)]);
1767        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
1768            .expect("an unsized ancestor still resolves");
1769        assert_eq!(got.origin, LogicalPosition::new(7.0, 9.0));
1770        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
1771    }
1772
1773    #[test]
1774    fn find_absolute_cb_rect_missing_position_entry_defaults_to_the_origin() {
1775        let (sd, mut tree) = two_level(".root { position: relative; }");
1776        tree.nodes[0].used_size = Some(LogicalSize::new(100.0, 100.0));
1777        let pos: PositionVec = Vec::new(); // nothing laid out yet
1778        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
1779            .expect("an empty position vec still resolves");
1780        assert_eq!(got.origin, LogicalPosition::new(0.0, 0.0));
1781        assert_eq!(got.size, LogicalSize::new(100.0, 100.0));
1782    }
1783
1784    // ==================================================================
1785    // find_nearest_scrollport (numeric)
1786    // ==================================================================
1787
1788    #[test]
1789    fn find_nearest_scrollport_without_a_scroll_ancestor_is_the_viewport() {
1790        let (sd, tree) = two_level("");
1791        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1792        assert_eq!(
1793            find_nearest_scrollport(&tree, 1, &sd, &pos, viewport()),
1794            viewport()
1795        );
1796    }
1797
1798    #[test]
1799    fn find_nearest_scrollport_out_of_range_index_is_the_viewport_not_a_panic() {
1800        let (sd, tree) = two_level(".root { overflow-y: scroll; }");
1801        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1802        assert_eq!(
1803            find_nearest_scrollport(&tree, 9_999, &sd, &pos, viewport()),
1804            viewport()
1805        );
1806    }
1807
1808    #[test]
1809    fn find_nearest_scrollport_returns_the_ancestor_content_box() {
1810        for css in [
1811            ".root { overflow-x: scroll; }",
1812            ".root { overflow-y: scroll; }",
1813            ".root { overflow-x: auto; }",
1814            ".root { overflow-y: auto; }",
1815        ] {
1816            let (sd, mut tree) = two_level(css);
1817            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 150.0));
1818            tree.nodes[0].box_props = bp(uniform(0.0), uniform(5.0), uniform(10.0));
1819            let pos = positions(&[(20.0, 30.0), (0.0, 0.0)]);
1820            let got = find_nearest_scrollport(&tree, 1, &sd, &pos, viewport());
1821            // content box = margin-box pos + border + padding, size - 2*(border+padding)
1822            assert_eq!(got.origin, LogicalPosition::new(35.0, 45.0), "{css}");
1823            assert_eq!(got.size, LogicalSize::new(170.0, 120.0), "{css}");
1824        }
1825    }
1826
1827    #[test]
1828    fn find_nearest_scrollport_ignores_non_scrolling_overflow() {
1829        for css in [
1830            ".root { overflow-x: hidden; }",
1831            ".root { overflow-y: visible; }",
1832            ".root { overflow-x: clip; }",
1833        ] {
1834            let (sd, mut tree) = two_level(css);
1835            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 150.0));
1836            let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1837            assert_eq!(
1838                find_nearest_scrollport(&tree, 1, &sd, &pos, viewport()),
1839                viewport(),
1840                "{css}"
1841            );
1842        }
1843    }
1844
1845    #[test]
1846    fn find_nearest_scrollport_picks_the_nearest_of_two_scroll_ancestors() {
1847        let (sd, mut tree) =
1848            three_level(".root { overflow-y: scroll; } .mid { overflow-y: scroll; }");
1849        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
1850        tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 100.0));
1851        let pos = positions(&[(0.0, 0.0), (11.0, 12.0), (0.0, 0.0)]);
1852        let got = find_nearest_scrollport(&tree, 2, &sd, &pos, viewport());
1853        assert_eq!(got.origin, LogicalPosition::new(11.0, 12.0), "mid, not root");
1854        assert_eq!(got.size, LogicalSize::new(200.0, 100.0));
1855    }
1856
1857    #[test]
1858    fn find_nearest_scrollport_walks_past_anonymous_boxes() {
1859        // An anonymous box (dom_node_id: None) has no style — it must be skipped,
1860        // not treated as the end of the ancestor chain.
1861        let (sd, mut tree) = three_level(".root { overflow-y: scroll; }");
1862        tree.nodes[1].dom_node_id = None; // .mid becomes anonymous
1863        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
1864        let pos = positions(&[(1.0, 2.0), (0.0, 0.0), (0.0, 0.0)]);
1865        let got = find_nearest_scrollport(&tree, 2, &sd, &pos, viewport());
1866        assert_eq!(got.origin, LogicalPosition::new(1.0, 2.0));
1867        assert_eq!(got.size, LogicalSize::new(400.0, 300.0));
1868    }
1869
1870    #[test]
1871    fn find_nearest_scrollport_clamps_the_content_box_to_zero_when_padding_exceeds_the_box() {
1872        let (sd, mut tree) = two_level(".root { overflow-y: scroll; }");
1873        tree.nodes[0].used_size = Some(LogicalSize::new(10.0, 10.0));
1874        tree.nodes[0].box_props = bp(uniform(0.0), uniform(1e30), uniform(1e30));
1875        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
1876        let got = find_nearest_scrollport(&tree, 1, &sd, &pos, viewport());
1877        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
1878        assert!(got.size.width >= 0.0 && got.size.height >= 0.0);
1879    }
1880
1881    #[test]
1882    fn find_nearest_scrollport_unsized_scrollport_is_zero_sized() {
1883        let (sd, tree) = two_level(".root { overflow-y: scroll; }"); // used_size None
1884        let pos: PositionVec = Vec::new();
1885        let got = find_nearest_scrollport(&tree, 1, &sd, &pos, viewport());
1886        assert_eq!(got.origin, LogicalPosition::new(0.0, 0.0));
1887        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
1888    }
1889
1890    // ==================================================================
1891    // find_nearest_scroll_offset (numeric)
1892    // ==================================================================
1893
1894    fn scroll_at(parent: (f32, f32), children: (f32, f32)) -> ScrollPosition {
1895        ScrollPosition {
1896            parent_rect: LogicalRect::new(
1897                LogicalPosition::new(parent.0, parent.1),
1898                LogicalSize::new(100.0, 100.0),
1899            ),
1900            children_rect: LogicalRect::new(
1901                LogicalPosition::new(children.0, children.1),
1902                LogicalSize::new(100.0, 400.0),
1903            ),
1904        }
1905    }
1906
1907    #[test]
1908    fn find_nearest_scroll_offset_empty_map_is_zero() {
1909        let (_sd, tree) = two_level("");
1910        let offsets: BTreeMap<NodeId, ScrollPosition> = BTreeMap::new();
1911        assert_eq!(
1912            find_nearest_scroll_offset(&tree, 1, &offsets),
1913            LogicalPosition::zero()
1914        );
1915    }
1916
1917    #[test]
1918    fn find_nearest_scroll_offset_out_of_range_index_is_zero_not_a_panic() {
1919        let (sd, tree) = two_level("");
1920        let mut offsets = BTreeMap::new();
1921        offsets.insert(node_by_class(&sd, "root"), scroll_at((0.0, 0.0), (0.0, -50.0)));
1922        assert_eq!(
1923            find_nearest_scroll_offset(&tree, 9_999, &offsets),
1924            LogicalPosition::zero()
1925        );
1926    }
1927
1928    #[test]
1929    fn find_nearest_scroll_offset_ignores_the_nodes_own_entry() {
1930        // The walk starts at the PARENT — a node's own scroll offset must not
1931        // shift the node itself.
1932        let (sd, tree) = two_level("");
1933        let mut offsets = BTreeMap::new();
1934        offsets.insert(
1935            node_by_class(&sd, "child"),
1936            scroll_at((0.0, 0.0), (0.0, -50.0)),
1937        );
1938        assert_eq!(
1939            find_nearest_scroll_offset(&tree, 1, &offsets),
1940            LogicalPosition::zero()
1941        );
1942    }
1943
1944    #[test]
1945    fn find_nearest_scroll_offset_is_children_origin_minus_parent_origin() {
1946        let (sd, tree) = two_level("");
1947        let mut offsets = BTreeMap::new();
1948        offsets.insert(
1949            node_by_class(&sd, "root"),
1950            scroll_at((10.0, 20.0), (-5.0, -80.0)),
1951        );
1952        assert_eq!(
1953            find_nearest_scroll_offset(&tree, 1, &offsets),
1954            LogicalPosition::new(-15.0, -100.0)
1955        );
1956    }
1957
1958    #[test]
1959    fn find_nearest_scroll_offset_picks_the_nearest_ancestor() {
1960        let (sd, tree) = three_level("");
1961        let mut offsets = BTreeMap::new();
1962        offsets.insert(node_by_class(&sd, "root"), scroll_at((0.0, 0.0), (0.0, -999.0)));
1963        offsets.insert(node_by_class(&sd, "mid"), scroll_at((0.0, 0.0), (0.0, -7.0)));
1964        assert_eq!(
1965            find_nearest_scroll_offset(&tree, 2, &offsets),
1966            LogicalPosition::new(0.0, -7.0),
1967            "mid wins over root"
1968        );
1969    }
1970
1971    #[test]
1972    fn find_nearest_scroll_offset_walks_past_anonymous_ancestors() {
1973        let (sd, mut tree) = three_level("");
1974        tree.nodes[1].dom_node_id = None;
1975        let mut offsets = BTreeMap::new();
1976        offsets.insert(node_by_class(&sd, "root"), scroll_at((0.0, 0.0), (0.0, -30.0)));
1977        assert_eq!(
1978            find_nearest_scroll_offset(&tree, 2, &offsets),
1979            LogicalPosition::new(0.0, -30.0)
1980        );
1981    }
1982
1983    #[test]
1984    fn find_nearest_scroll_offset_at_f32_extremes_stays_deterministic() {
1985        let (sd, tree) = two_level("");
1986        let mut offsets = BTreeMap::new();
1987        offsets.insert(
1988            node_by_class(&sd, "root"),
1989            scroll_at((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)),
1990        );
1991        let got = find_nearest_scroll_offset(&tree, 1, &offsets);
1992        // MIN - MAX overflows f32 → -inf. It must not be NaN (which would poison
1993        // every downstream sticky comparison silently).
1994        assert!(!got.x.is_nan() && !got.y.is_nan());
1995        assert_eq!(got.x, f32::NEG_INFINITY);
1996        assert_eq!(got.y, f32::NEG_INFINITY);
1997    }
1998
1999    // ==================================================================
2000    // The three passes that need a LayoutContext (and therefore a FontManager).
2001    // ==================================================================
2002    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
2003    mod with_ctx {
2004        use std::collections::HashMap;
2005
2006        use azul_core::{dom::DomId, selection::TextSelection};
2007        use azul_css::props::basic::FontRef;
2008
2009        use super::*;
2010        use crate::{
2011            font_traits::{FontManager, TextLayoutCache},
2012            solver3::{cache, LayoutContext},
2013        };
2014
2015        /// Owns everything a `LayoutContext` borrows.
2016        struct Env {
2017            styled_dom: StyledDom,
2018            font_manager: FontManager<FontRef>,
2019            text_selections: BTreeMap<DomId, TextSelection>,
2020            counters: HashMap<(usize, String), i32>,
2021            image_cache: azul_core::resources::ImageCache,
2022            debug_messages: Option<Vec<LayoutDebugMessage>>,
2023        }
2024
2025        impl Env {
2026            fn new(styled_dom: StyledDom) -> Self {
2027                Self {
2028                    styled_dom,
2029                    font_manager: FontManager::new(rust_fontconfig::FcFontCache::default())
2030                        .expect("FontManager over an empty font cache"),
2031                    text_selections: BTreeMap::new(),
2032                    counters: HashMap::new(),
2033                    image_cache: azul_core::resources::ImageCache::default(),
2034                    debug_messages: None,
2035                }
2036            }
2037
2038            fn ctx(&mut self) -> LayoutContext<'_, FontRef> {
2039                LayoutContext {
2040                    scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
2041                    styled_dom: &self.styled_dom,
2042                    font_manager: &self.font_manager,
2043                    text_selections: &self.text_selections,
2044                    debug_messages: &mut self.debug_messages,
2045                    counters: &mut self.counters,
2046                    viewport_size: LogicalSize::new(800.0, 600.0),
2047                    fragmentation_context: None,
2048                    cursor_is_visible: true,
2049                    cursor_locations: Vec::new(),
2050                    preedit_text: None,
2051                    dirty_text_overrides: BTreeMap::new(),
2052                    cache_map: cache::LayoutCacheMap::default(),
2053                    image_cache: &self.image_cache,
2054                    system_style: None,
2055                    get_system_time_fn: azul_core::task::GetSystemTimeCallback {
2056                        cb: azul_core::task::get_system_time_libstd,
2057                    },
2058                }
2059            }
2060        }
2061
2062        /// `.root` = relative, 400×300 border box, 10px border + 5px padding, at (20,30).
2063        /// Its padding box — the CB every abspos child below resolves against — is
2064        /// therefore origin (30,40), size 380×280.
2065        fn abs_fixture(css: &str) -> (Env, LayoutTree, PositionVec) {
2066            let (sd, mut tree) = two_level(css);
2067            tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
2068            tree.nodes[0].box_props = bp(uniform(0.0), uniform(5.0), uniform(10.0));
2069            tree.nodes[1].used_size = Some(LogicalSize::new(50.0, 50.0));
2070            let pos = positions(&[(20.0, 30.0), (0.0, 0.0)]);
2071            (Env::new(sd), tree, pos)
2072        }
2073
2074        fn run_oof(env: &mut Env, tree: &mut LayoutTree, pos: &mut PositionVec, vp: LogicalRect) {
2075            let mut text_cache = TextLayoutCache::default();
2076            let mut ctx = env.ctx();
2077            position_out_of_flow_elements(&mut ctx, tree, &mut text_cache, pos, vp);
2078        }
2079
2080        // --------------------------------------------------------------
2081        // position_out_of_flow_elements
2082        // --------------------------------------------------------------
2083
2084        #[test]
2085        fn out_of_flow_top_left_offset_from_the_ancestor_padding_box() {
2086            let (mut env, mut tree, mut pos) = abs_fixture(
2087                ".root { position: relative; } \
2088                 .child { position: absolute; top: 25px; left: 15px; }",
2089            );
2090            run_oof(&mut env, &mut tree, &mut pos, viewport());
2091            assert_eq!(pos[1], LogicalPosition::new(45.0, 65.0));
2092        }
2093
2094        #[test]
2095        fn out_of_flow_zero_insets_land_exactly_on_the_padding_box_origin() {
2096            let (mut env, mut tree, mut pos) = abs_fixture(
2097                ".root { position: relative; } .child { position: absolute; top: 0px; left: 0px; }",
2098            );
2099            run_oof(&mut env, &mut tree, &mut pos, viewport());
2100            assert_eq!(pos[1], LogicalPosition::new(30.0, 40.0));
2101        }
2102
2103        #[test]
2104        fn out_of_flow_all_auto_keeps_the_static_position() {
2105            // +spec:positioning:aab294 — both insets auto → static position.
2106            let (mut env, mut tree, mut pos) =
2107                abs_fixture(".root { position: relative; } .child { position: absolute; }");
2108            pos_set(&mut pos, 1, LogicalPosition::new(7.0, 9.0));
2109            run_oof(&mut env, &mut tree, &mut pos, viewport());
2110            assert_eq!(pos[1], LogicalPosition::new(7.0, 9.0));
2111        }
2112
2113        #[test]
2114        fn out_of_flow_fixed_resolves_against_the_viewport_not_the_ancestor() {
2115            let (mut env, mut tree, mut pos) = abs_fixture(
2116                ".root { position: relative; } .child { position: fixed; top: 25px; left: 15px; }",
2117            );
2118            run_oof(&mut env, &mut tree, &mut pos, viewport());
2119            assert_eq!(pos[1], LogicalPosition::new(15.0, 25.0));
2120        }
2121
2122        #[test]
2123        fn out_of_flow_over_constrained_ignores_the_end_insets_in_ltr() {
2124            // top/height/bottom and left/width/right all given: bottom/right lose.
2125            let (mut env, mut tree, mut pos) = abs_fixture(
2126                ".root { position: relative; } \
2127                 .child { position: absolute; top: 10px; bottom: 10px; left: 10px; \
2128                          right: 10px; width: 50px; height: 50px; }",
2129            );
2130            run_oof(&mut env, &mut tree, &mut pos, viewport());
2131            assert_eq!(pos[1], LogicalPosition::new(40.0, 50.0));
2132        }
2133
2134        #[test]
2135        fn out_of_flow_auto_margins_center_the_box_in_both_axes() {
2136            // +spec:height-calculation:5112a4 — both auto margins solve to equal values.
2137            let (mut env, mut tree, mut pos) = abs_fixture(
2138                ".root { position: relative; } \
2139                 .child { position: absolute; top: 0px; bottom: 0px; left: 0px; \
2140                          right: 0px; width: 100px; height: 100px; }",
2141            );
2142            tree.nodes[1].used_size = Some(LogicalSize::new(100.0, 100.0));
2143            tree.nodes[1].box_props = bp_auto_margins(MarginAuto {
2144                top: true,
2145                bottom: true,
2146                left: true,
2147                right: true,
2148            });
2149            run_oof(&mut env, &mut tree, &mut pos, viewport());
2150            // CB 380×280 at (30,40): (380-100)/2 = 140, (280-100)/2 = 90.
2151            assert_eq!(pos[1], LogicalPosition::new(170.0, 130.0));
2152        }
2153
2154        #[test]
2155        fn out_of_flow_negative_free_space_with_auto_margins_pins_to_the_start_edge_in_ltr() {
2156            // +spec:writing-modes:9c3b40 — negative remaining space: start margin is 0.
2157            let (mut env, mut tree, mut pos) = abs_fixture(
2158                ".root { position: relative; } \
2159                 .child { position: absolute; left: 0px; right: 0px; width: 500px; }",
2160            );
2161            tree.nodes[1].used_size = Some(LogicalSize::new(500.0, 50.0));
2162            tree.nodes[1].box_props = bp_auto_margins(MarginAuto {
2163                left: true,
2164                right: true,
2165                top: false,
2166                bottom: false,
2167            });
2168            run_oof(&mut env, &mut tree, &mut pos, viewport());
2169            // remaining = 380 - 0 - 500 - 0 = -120 → each margin < 0 → pin left.
2170            assert_eq!(pos[1].x, 30.0);
2171        }
2172
2173        #[test]
2174        fn out_of_flow_over_constrained_ignores_the_left_inset_in_rtl() {
2175            let (mut env, mut tree, mut pos) = abs_fixture(
2176                ".root { position: relative; direction: rtl; } \
2177                 .child { position: absolute; left: 10px; right: 10px; width: 50px; }",
2178            );
2179            run_oof(&mut env, &mut tree, &mut pos, viewport());
2180            // RTL solves for left: 380 - 50 - 10 = 320 → 30 + 320.
2181            assert_eq!(pos[1].x, 350.0);
2182        }
2183
2184        #[test]
2185        fn out_of_flow_auto_height_and_width_stretch_between_the_insets() {
2186            // +spec:intrinsic-sizing:566a43 — stretch-fit sizing on both axes.
2187            let (mut env, mut tree, mut pos) = abs_fixture(
2188                ".root { position: relative; } \
2189                 .child { position: absolute; top: 10px; bottom: 20px; left: 30px; right: 40px; }",
2190            );
2191            run_oof(&mut env, &mut tree, &mut pos, viewport());
2192            assert_eq!(pos[1], LogicalPosition::new(60.0, 50.0));
2193            let used = tree.nodes[1].used_size.expect("size was resolved");
2194            assert_eq!(used, LogicalSize::new(310.0, 250.0));
2195        }
2196
2197        #[test]
2198        fn out_of_flow_insets_larger_than_the_containing_block_clamp_the_size_to_zero() {
2199            let (mut env, mut tree, mut pos) = abs_fixture(
2200                ".root { position: relative; } \
2201                 .child { position: absolute; top: 500px; bottom: 500px; \
2202                          left: 500px; right: 500px; }",
2203            );
2204            run_oof(&mut env, &mut tree, &mut pos, viewport());
2205            let used = tree.nodes[1].used_size.expect("size was resolved");
2206            assert_eq!(used, LogicalSize::new(0.0, 0.0), "never negative");
2207            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
2208        }
2209
2210        #[test]
2211        fn out_of_flow_huge_insets_bypass_the_i16_cache_and_stay_finite() {
2212            let (mut env, mut tree, mut pos) = abs_fixture(
2213                ".root { position: relative; } \
2214                 .child { position: absolute; top: 3300px; left: 100000px; }",
2215            );
2216            run_oof(&mut env, &mut tree, &mut pos, viewport());
2217            assert_eq!(pos[1], LogicalPosition::new(100_030.0, 3340.0));
2218            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
2219        }
2220
2221        #[test]
2222        fn out_of_flow_negative_insets_move_the_box_outside_the_containing_block() {
2223            let (mut env, mut tree, mut pos) = abs_fixture(
2224                ".root { position: relative; } \
2225                 .child { position: absolute; top: -100px; left: -200px; }",
2226            );
2227            run_oof(&mut env, &mut tree, &mut pos, viewport());
2228            assert_eq!(pos[1], LogicalPosition::new(-170.0, -60.0));
2229        }
2230
2231        #[test]
2232        fn out_of_flow_nan_viewport_clamps_the_stretch_height_to_zero_and_keeps_the_position_finite()
2233        {
2234            // f32::max(NaN, 0.0) == 0.0, so the stretch-fit height degrades to 0
2235            // rather than propagating NaN into the display list.
2236            let (mut env, mut tree, mut pos) = abs_fixture(
2237                ".root { position: relative; } \
2238                 .child { position: fixed; top: 10px; bottom: 20px; }",
2239            );
2240            let nan_vp = LogicalRect::new(
2241                LogicalPosition::new(0.0, 0.0),
2242                LogicalSize::new(f32::NAN, f32::NAN),
2243            );
2244            run_oof(&mut env, &mut tree, &mut pos, nan_vp);
2245            let used = tree.nodes[1].used_size.expect("size was resolved");
2246            assert_eq!(used.height, 0.0);
2247            assert_eq!(pos[1].y, 10.0);
2248            assert!(pos[1].y.is_finite());
2249        }
2250
2251        #[test]
2252        fn out_of_flow_infinite_viewport_keeps_the_position_finite() {
2253            let (mut env, mut tree, mut pos) = abs_fixture(
2254                ".root { position: relative; } \
2255                 .child { position: fixed; top: 10px; bottom: 20px; }",
2256            );
2257            let inf_vp = LogicalRect::new(
2258                LogicalPosition::new(0.0, 0.0),
2259                LogicalSize::new(f32::INFINITY, f32::INFINITY),
2260            );
2261            run_oof(&mut env, &mut tree, &mut pos, inf_vp);
2262            assert_eq!(pos[1].y, 10.0);
2263            let used = tree.nodes[1].used_size.expect("size was resolved");
2264            assert!(used.height.is_infinite() && used.height > 0.0);
2265        }
2266
2267        #[test]
2268        fn out_of_flow_every_auto_combination_of_top_height_bottom_is_panic_free() {
2269            // The rustdoc claims a panic when a resolved offset is None where both
2270            // edges are expected. Walk all 8 auto/non-auto combinations per axis and
2271            // prove every `unwrap()` in the constraint solver is actually guarded.
2272            for top in ["", "top: 10px;"] {
2273                for bottom in ["", "bottom: 20px;"] {
2274                    for height in ["", "height: 30px;"] {
2275                        for left in ["", "left: 10px;"] {
2276                            for right in ["", "right: 20px;"] {
2277                                for width in ["", "width: 30px;"] {
2278                                    let css = format!(
2279                                        ".root {{ position: relative; }} \
2280                                         .child {{ position: absolute; {top}{bottom}{height}\
2281                                         {left}{right}{width} }}"
2282                                    );
2283                                    let (mut env, mut tree, mut pos) = abs_fixture(&css);
2284                                    run_oof(&mut env, &mut tree, &mut pos, viewport());
2285                                    assert!(
2286                                        pos[1].x.is_finite() && pos[1].y.is_finite(),
2287                                        "non-finite position for {css}"
2288                                    );
2289                                }
2290                            }
2291                        }
2292                    }
2293                }
2294            }
2295        }
2296
2297        #[test]
2298        fn out_of_flow_skips_children_of_flex_and_grid_parents() {
2299            // Taffy already placed those during flex/grid layout — re-positioning
2300            // here would double-apply the insets.
2301            for fc in [FormattingContext::Flex, FormattingContext::Grid] {
2302                let (mut env, mut tree, mut pos) = abs_fixture(
2303                    ".root { position: relative; } \
2304                     .child { position: absolute; top: 25px; left: 15px; }",
2305                );
2306                tree.nodes[0].formatting_context = fc;
2307                pos_set(&mut pos, 1, LogicalPosition::new(3.0, 4.0));
2308                run_oof(&mut env, &mut tree, &mut pos, viewport());
2309                assert_eq!(pos[1], LogicalPosition::new(3.0, 4.0), "{fc:?}");
2310            }
2311        }
2312
2313        #[test]
2314        fn out_of_flow_leaves_static_and_relative_nodes_alone() {
2315            for keyword in ["static", "relative", "sticky"] {
2316                let css = format!(
2317                    ".root {{ position: relative; }} \
2318                     .child {{ position: {keyword}; top: 25px; left: 15px; }}"
2319                );
2320                let (mut env, mut tree, mut pos) = abs_fixture(&css);
2321                pos_set(&mut pos, 1, LogicalPosition::new(3.0, 4.0));
2322                run_oof(&mut env, &mut tree, &mut pos, viewport());
2323                assert_eq!(pos[1], LogicalPosition::new(3.0, 4.0), "{keyword}");
2324            }
2325        }
2326
2327        #[test]
2328        fn out_of_flow_short_position_vec_grows_instead_of_panicking() {
2329            let (mut env, mut tree, _pos) = abs_fixture(
2330                ".root { position: relative; } \
2331                 .child { position: absolute; top: 25px; left: 15px; }",
2332            );
2333            let mut pos: PositionVec = Vec::new(); // nothing laid out yet
2334            run_oof(&mut env, &mut tree, &mut pos, viewport());
2335            assert_eq!(pos.len(), 2, "pos_set grew the vec");
2336            // The CB origin now comes from a default (0,0) ancestor position.
2337            assert_eq!(pos[1], LogicalPosition::new(25.0, 35.0));
2338        }
2339
2340        #[test]
2341        fn out_of_flow_unsized_node_is_sized_on_the_fly_without_panicking() {
2342            let (mut env, mut tree, mut pos) = abs_fixture(
2343                ".root { position: relative; } \
2344                 .child { position: absolute; top: 10px; left: 10px; }",
2345            );
2346            tree.nodes[1].used_size = None; // never sized by the main pass
2347            run_oof(&mut env, &mut tree, &mut pos, viewport());
2348            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
2349        }
2350
2351        // --------------------------------------------------------------
2352        // adjust_relative_positions
2353        // --------------------------------------------------------------
2354
2355        /// `.root` = 200×100 border box with 10px padding → 180×80 content box,
2356        /// which is the CB percentages resolve against for the relative child.
2357        fn rel_fixture(css: &str) -> (Env, LayoutTree, PositionVec) {
2358            let (sd, mut tree) = two_level(css);
2359            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 100.0));
2360            tree.nodes[0].box_props = bp(uniform(0.0), uniform(10.0), uniform(0.0));
2361            tree.nodes[1].used_size = Some(LogicalSize::new(50.0, 20.0));
2362            let pos = positions(&[(0.0, 0.0), (100.0, 100.0)]);
2363            (Env::new(sd), tree, pos)
2364        }
2365
2366        fn run_rel(env: &mut Env, tree: &LayoutTree, pos: &mut PositionVec) {
2367            let mut ctx = env.ctx();
2368            adjust_relative_positions(&mut ctx, tree, pos, viewport());
2369        }
2370
2371        #[test]
2372        fn relative_px_offsets_shift_from_the_static_position() {
2373            let (mut env, tree, mut pos) =
2374                rel_fixture(".child { position: relative; top: 10px; left: 5px; }");
2375            run_rel(&mut env, &tree, &mut pos);
2376            assert_eq!(pos[1], LogicalPosition::new(105.0, 110.0));
2377        }
2378
2379        #[test]
2380        fn relative_percentages_resolve_against_the_parent_content_box() {
2381            let (mut env, tree, mut pos) =
2382                rel_fixture(".child { position: relative; top: 50%; left: 50%; }");
2383            run_rel(&mut env, &tree, &mut pos);
2384            // content box is 180×80 → +90 x, +40 y.
2385            assert_eq!(pos[1], LogicalPosition::new(190.0, 140.0));
2386        }
2387
2388        #[test]
2389        fn relative_top_wins_over_bottom() {
2390            // +spec:positioning:e3727e — neither auto → bottom is ignored.
2391            let (mut env, tree, mut pos) =
2392                rel_fixture(".child { position: relative; top: 10px; bottom: 30px; }");
2393            run_rel(&mut env, &tree, &mut pos);
2394            assert_eq!(pos[1].y, 110.0);
2395        }
2396
2397        #[test]
2398        fn relative_bottom_alone_is_the_negation_of_top() {
2399            let (mut env, tree, mut pos) =
2400                rel_fixture(".child { position: relative; bottom: 30px; }");
2401            run_rel(&mut env, &tree, &mut pos);
2402            assert_eq!(pos[1].y, 70.0);
2403        }
2404
2405        #[test]
2406        fn relative_right_alone_is_the_negation_of_left() {
2407            // +spec:overflow:fb426c — left auto → used value is minus right.
2408            let (mut env, tree, mut pos) =
2409                rel_fixture(".child { position: relative; right: 20px; }");
2410            run_rel(&mut env, &tree, &mut pos);
2411            assert_eq!(pos[1].x, 80.0);
2412        }
2413
2414        #[test]
2415        fn relative_left_wins_in_ltr_and_right_wins_in_rtl() {
2416            // +spec:containing-block:6d4fb1 — direction of the CONTAINING BLOCK decides.
2417            let (mut env, tree, mut pos) =
2418                rel_fixture(".child { position: relative; left: 5px; right: 20px; }");
2419            run_rel(&mut env, &tree, &mut pos);
2420            assert_eq!(pos[1].x, 105.0, "ltr: left wins");
2421
2422            let (mut env, tree, mut pos) = rel_fixture(
2423                ".root { direction: rtl; } \
2424                 .child { position: relative; left: 5px; right: 20px; }",
2425            );
2426            run_rel(&mut env, &tree, &mut pos);
2427            assert_eq!(pos[1].x, 80.0, "rtl: right wins → -20");
2428        }
2429
2430        #[test]
2431        fn relative_zero_offsets_are_a_no_op() {
2432            let (mut env, tree, mut pos) =
2433                rel_fixture(".child { position: relative; top: 0px; left: 0px; }");
2434            run_rel(&mut env, &tree, &mut pos);
2435            assert_eq!(pos[1], LogicalPosition::new(100.0, 100.0));
2436        }
2437
2438        #[test]
2439        fn relative_leaves_static_absolute_and_fixed_nodes_untouched() {
2440            for keyword in ["static", "absolute", "fixed"] {
2441                let css =
2442                    format!(".child {{ position: {keyword}; top: 10px; left: 5px; }}");
2443                let (mut env, tree, mut pos) = rel_fixture(&css);
2444                run_rel(&mut env, &tree, &mut pos);
2445                assert_eq!(pos[1], LogicalPosition::new(100.0, 100.0), "{keyword}");
2446            }
2447        }
2448
2449        #[test]
2450        fn relative_also_offsets_sticky_boxes() {
2451            // Sticky deliberately shares the relative path (the pre-scroll offset);
2452            // adjust_sticky_positions then clamps it. Pinning this so the two passes
2453            // can't silently start disagreeing.
2454            let (mut env, tree, mut pos) =
2455                rel_fixture(".child { position: relative; top: 10px; }");
2456            run_rel(&mut env, &tree, &mut pos);
2457            let relative_y = pos[1].y;
2458
2459            let (mut env, tree, mut pos) =
2460                rel_fixture(".child { position: sticky; top: 10px; }");
2461            run_rel(&mut env, &tree, &mut pos);
2462            assert_eq!(pos[1].y, relative_y);
2463        }
2464
2465        #[test]
2466        fn relative_is_undefined_for_table_cells_and_captions_so_they_are_skipped() {
2467            for display in ["table-cell", "table-caption", "table-column"] {
2468                let css = format!(
2469                    ".child {{ position: relative; display: {display}; top: 10px; left: 5px; }}"
2470                );
2471                let (mut env, tree, mut pos) = rel_fixture(&css);
2472                run_rel(&mut env, &tree, &mut pos);
2473                assert_eq!(pos[1], LogicalPosition::new(100.0, 100.0), "{display}");
2474            }
2475        }
2476
2477        #[test]
2478        fn relative_table_rows_drag_their_whole_subtree() {
2479            // +spec:table-layout:ec2600 — the shift affects all contents of the row.
2480            let (sd, mut tree) = three_level(
2481                ".mid { position: relative; display: table-row; top: 10px; left: 5px; } \
2482                 .child { display: table-cell; }",
2483            );
2484            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 100.0));
2485            tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 50.0));
2486            tree.nodes[2].used_size = Some(LogicalSize::new(100.0, 50.0));
2487            let mut pos = positions(&[(0.0, 0.0), (10.0, 20.0), (10.0, 20.0)]);
2488            let mut env = Env::new(sd);
2489            run_rel(&mut env, &tree, &mut pos);
2490            assert_eq!(pos[1], LogicalPosition::new(15.0, 30.0), "the row itself");
2491            assert_eq!(pos[2], LogicalPosition::new(15.0, 30.0), "the cell follows");
2492        }
2493
2494        #[test]
2495        fn relative_short_position_vec_is_skipped_not_panicked_on() {
2496            let (mut env, tree, _pos) =
2497                rel_fixture(".child { position: relative; top: 10px; left: 5px; }");
2498            let mut pos: PositionVec = Vec::new();
2499            run_rel(&mut env, &tree, &mut pos);
2500            assert!(pos.is_empty(), "nothing to shift, nothing added");
2501        }
2502
2503        #[test]
2504        fn relative_huge_and_negative_offsets_stay_finite() {
2505            let (mut env, tree, mut pos) =
2506                rel_fixture(".child { position: relative; top: 100000px; left: -100000px; }");
2507            run_rel(&mut env, &tree, &mut pos);
2508            assert_eq!(pos[1], LogicalPosition::new(-99_900.0, 100_100.0));
2509            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
2510        }
2511
2512        #[test]
2513        fn relative_unset_sentinel_position_is_not_silently_shifted_into_a_real_one() {
2514            // POSITION_UNSET is f32::MIN. Adding a finite delta to it must stay
2515            // absurdly negative (it must NOT round into a plausible coordinate) —
2516            // a caller can still detect the node was never laid out.
2517            let (mut env, tree, mut pos) =
2518                rel_fixture(".child { position: relative; top: 10px; left: 5px; }");
2519            pos[1] = POSITION_UNSET;
2520            run_rel(&mut env, &tree, &mut pos);
2521            assert!(pos[1].x < -1e30 && pos[1].y < -1e30);
2522        }
2523
2524        // --------------------------------------------------------------
2525        // adjust_sticky_positions
2526        // --------------------------------------------------------------
2527
2528        /// `.root` = a 200×200 scrollport at (0,0); `.child` = 50×20 sticky box at (0,0).
2529        fn sticky_fixture(css: &str) -> (Env, LayoutTree, PositionVec) {
2530            let (sd, mut tree) = two_level(css);
2531            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 200.0));
2532            tree.nodes[1].used_size = Some(LogicalSize::new(50.0, 20.0));
2533            let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
2534            (Env::new(sd), tree, pos)
2535        }
2536
2537        fn run_sticky(
2538            env: &mut Env,
2539            tree: &LayoutTree,
2540            pos: &mut PositionVec,
2541            offsets: &BTreeMap<NodeId, ScrollPosition>,
2542        ) {
2543            let mut ctx = env.ctx();
2544            adjust_sticky_positions(&mut ctx, tree, pos, offsets, viewport());
2545        }
2546
2547        #[test]
2548        fn sticky_top_inset_pins_the_box_to_the_scrollport_edge() {
2549            let (mut env, tree, mut pos) = sticky_fixture(
2550                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
2551            );
2552            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2553            assert_eq!(pos[1], LogicalPosition::new(0.0, 10.0));
2554        }
2555
2556        #[test]
2557        fn sticky_without_insets_does_not_move() {
2558            let (mut env, tree, mut pos) =
2559                sticky_fixture(".root { overflow-y: scroll; } .child { position: sticky; }");
2560            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2561            assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0));
2562        }
2563
2564        #[test]
2565        fn sticky_ignores_non_sticky_positions() {
2566            for keyword in ["static", "relative", "absolute", "fixed"] {
2567                let css = format!(
2568                    ".root {{ overflow-y: scroll; }} \
2569                     .child {{ position: {keyword}; top: 10px; }}"
2570                );
2571                let (mut env, tree, mut pos) = sticky_fixture(&css);
2572                run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2573                assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0), "{keyword}");
2574            }
2575        }
2576
2577        #[test]
2578        fn sticky_edge_moves_with_the_scroll_offset_of_the_nearest_container() {
2579            let (mut env, tree, mut pos) = sticky_fixture(
2580                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
2581            );
2582            let root = node_by_class(&env.styled_dom, "root");
2583            let mut offsets = BTreeMap::new();
2584            offsets.insert(root, scroll_at((0.0, 0.0), (0.0, 50.0)));
2585            run_sticky(&mut env, &tree, &mut pos, &offsets);
2586            // sticky edge = scrollport.y (0) + scroll (50) + inset (10).
2587            assert_eq!(pos[1].y, 60.0);
2588        }
2589
2590        #[test]
2591        fn sticky_percentage_inset_resolves_against_the_scrollport() {
2592            let (mut env, tree, mut pos) = sticky_fixture(
2593                ".root { overflow-y: scroll; } .child { position: sticky; top: 10%; }",
2594            );
2595            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2596            assert_eq!(pos[1].y, 20.0, "10% of the 200px scrollport");
2597        }
2598
2599        #[test]
2600        fn sticky_bottom_inset_pulls_the_box_back_up_into_the_scrollport() {
2601            let (mut env, tree, mut pos) = sticky_fixture(
2602                ".root { overflow-y: scroll; } .child { position: sticky; bottom: 10px; }",
2603            );
2604            pos_set(&mut pos, 1, LogicalPosition::new(0.0, 250.0));
2605            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2606            // bottom edge must sit at 200 - 10 = 190 → top = 190 - 20.
2607            assert_eq!(pos[1].y, 170.0);
2608        }
2609
2610        #[test]
2611        fn sticky_shift_is_clamped_by_the_containing_block() {
2612            // +spec:box-model:af9af8 — the margin box must stay inside the CB, even
2613            // when the scrollport would let the box travel further.
2614            let (sd, mut tree) = three_level(
2615                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
2616            );
2617            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 200.0));
2618            tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 25.0)); // short CB
2619            tree.nodes[2].used_size = Some(LogicalSize::new(50.0, 20.0));
2620            let mut pos = positions(&[(0.0, 0.0), (0.0, 0.0), (0.0, 0.0)]);
2621            let mut env = Env::new(sd);
2622            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2623            // Unclamped the shift would be 10 (bottom = 30 > CB bottom 25) → 5.
2624            assert_eq!(pos[2].y, 5.0);
2625        }
2626
2627        #[test]
2628        fn sticky_huge_inset_clamps_to_the_containing_block_instead_of_flying_away() {
2629            let (mut env, tree, mut pos) = sticky_fixture(
2630                ".root { overflow-y: scroll; } .child { position: sticky; top: 100000px; }",
2631            );
2632            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2633            // The margin box is pushed back until its bottom sits on the CB bottom
2634            // (200) → top = 200 - 20 = 180.
2635            assert_eq!(pos[1].y, 180.0);
2636            assert!(pos[1].y.is_finite());
2637        }
2638
2639        #[test]
2640        fn sticky_negative_inset_is_deterministic_and_finite() {
2641            let (mut env, tree, mut pos) = sticky_fixture(
2642                ".root { overflow-y: scroll; } .child { position: sticky; top: -50px; }",
2643            );
2644            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2645            // sticky edge = -50, border top = 0, already past it → no shift.
2646            assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0));
2647        }
2648
2649        #[test]
2650        fn sticky_without_a_scroll_ancestor_falls_back_to_the_viewport() {
2651            let (mut env, tree, mut pos) =
2652                sticky_fixture(".child { position: sticky; top: 10px; }"); // .root does not scroll
2653            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2654            // Scrollport = viewport (0,0,800×600); CB = the parent's 200×200 content
2655            // box, which comfortably contains the 10px shift.
2656            assert_eq!(pos[1].y, 10.0);
2657        }
2658
2659        #[test]
2660        fn sticky_left_and_right_insets_shift_the_inline_axis() {
2661            let (mut env, tree, mut pos) = sticky_fixture(
2662                ".root { overflow-x: scroll; } .child { position: sticky; left: 15px; }",
2663            );
2664            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2665            assert_eq!(pos[1].x, 15.0);
2666
2667            let (mut env, tree, mut pos) = sticky_fixture(
2668                ".root { overflow-x: scroll; } .child { position: sticky; right: 10px; }",
2669            );
2670            pos_set(&mut pos, 1, LogicalPosition::new(300.0, 0.0));
2671            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2672            // right edge pinned at 200 - 10 = 190 → x = 190 - 50.
2673            assert_eq!(pos[1].x, 140.0);
2674        }
2675
2676        #[test]
2677        fn sticky_short_position_vec_is_skipped_not_panicked_on() {
2678            let (mut env, tree, _pos) = sticky_fixture(
2679                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
2680            );
2681            let mut pos: PositionVec = Vec::new();
2682            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
2683            assert!(pos.is_empty());
2684        }
2685
2686        #[test]
2687        fn sticky_nan_scroll_offset_never_panics() {
2688            let (mut env, tree, mut pos) = sticky_fixture(
2689                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
2690            );
2691            let root = node_by_class(&env.styled_dom, "root");
2692            let mut offsets = BTreeMap::new();
2693            offsets.insert(root, scroll_at((f32::NAN, f32::NAN), (f32::NAN, f32::NAN)));
2694            run_sticky(&mut env, &tree, &mut pos, &offsets);
2695            // NaN comparisons are all false → no shift is ever applied.
2696            assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0));
2697        }
2698    }
2699}