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 std::collections::BTreeMap;
5
6use azul_core::{
7    dom::{NodeId, NodeType},
8    geom::{LogicalPosition, LogicalRect, LogicalSize},
9    hit_test::ScrollPosition,
10    resources::RendererResources,
11    styled_dom::StyledDom,
12};
13use azul_css::{
14    corety::LayoutDebugMessage,
15    css::CssPropertyValue,
16    props::{
17        basic::pixel::PixelValue,
18        layout::{LayoutPosition, LayoutWritingMode},
19        property::{CssProperty, CssPropertyType},
20    },
21};
22
23use crate::{
24    font_traits::{FontLoaderTrait, ParsedFontTrait},
25    solver3::{
26        fc::{layout_formatting_context, LayoutConstraints, TextAlign},
27        getters::{
28            get_aspect_ratio_property, get_direction_property, get_display_property, get_writing_mode, get_position, MultiValue,
29            get_css_top, get_css_bottom, get_css_left, get_css_right,
30            get_css_height, get_css_width,
31        },
32        layout_tree::LayoutTree,
33        LayoutContext, LayoutError, Result,
34    },
35};
36
37#[derive(Debug, Default)]
38struct PositionOffsets {
39    top: Option<f32>,
40    right: Option<f32>,
41    bottom: Option<f32>,
42    left: Option<f32>,
43}
44
45// +spec:positioning:94ef0f - position property: static|relative|absolute|sticky|fixed, initial static, applies to all elements except table-column-group/table-column
46/// Looks up the `position` property using the compact-cache-aware getter.
47// +spec:positioning:ba937d - positioned elements have position != static
48pub fn get_position_type(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> LayoutPosition {
49    let Some(id) = dom_id else {
50        return LayoutPosition::Static;
51    };
52    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
53    get_position(styled_dom, id, node_state).unwrap_or_default()
54}
55
56// +spec:positioning:bda1d5 - resolves inset properties (top/right/bottom/left) as inward offsets per CSS Position 3 §3.1
57// +spec:positioning:bf9168 - resolves inset properties (top/right/bottom/left) to control positioned box location
58// +spec:positioning:f8e0a1 - inset properties (top/right/bottom/left) resolved for positioned elements; auto = unconstrained
59/// Reads and resolves `top`, `right`, `bottom`, `left` properties,
60/// including percentages relative to the containing block's size, and em/rem units.
61// +spec:positioning:7ec143 - top/right/bottom/left offset resolution with percentage against containing block
62fn resolve_position_offsets(
63    styled_dom: &StyledDom,
64    dom_id: Option<NodeId>,
65    cb_size: LogicalSize,
66) -> PositionOffsets {
67    use azul_css::props::basic::pixel::{PhysicalSize, PropertyContext, ResolutionContext};
68
69    use crate::solver3::getters::{
70        get_element_font_size, get_parent_font_size, get_root_font_size,
71    };
72
73    let Some(id) = dom_id else {
74        return PositionOffsets::default();
75    };
76    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
77
78    // Create resolution context with font sizes and containing block size
79    let element_font_size = get_element_font_size(styled_dom, id, node_state);
80    let parent_font_size = get_parent_font_size(styled_dom, id, node_state);
81    let root_font_size = get_root_font_size(styled_dom, node_state);
82
83    let containing_block_size = PhysicalSize::new(cb_size.width, cb_size.height);
84
85    let resolution_context = ResolutionContext {
86        element_font_size,
87        parent_font_size,
88        root_font_size,
89        containing_block_size,
90        element_size: None, // Not needed for position offsets
91        viewport_size: PhysicalSize::new(0.0, 0.0),
92    };
93
94    let mut offsets = PositionOffsets::default();
95
96    // +spec:containing-block:d4b3b9 - percentage offsets resolve against CB width (left/right) or height (top/bottom)
97    // Resolve offsets using compact-cache-aware getters
98    // top/bottom use Height context (% refers to containing block height)
99    offsets.top = match get_css_top(styled_dom, id, node_state) {
100        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Height)),
101        _ => None,
102    };
103
104    offsets.bottom = match get_css_bottom(styled_dom, id, node_state) {
105        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Height)),
106        _ => None,
107    };
108
109    // left/right use Width context (% refers to containing block width)
110    offsets.left = match get_css_left(styled_dom, id, node_state) {
111        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Width)),
112        _ => None,
113    };
114
115    offsets.right = match get_css_right(styled_dom, id, node_state) {
116        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Width)),
117        _ => None,
118    };
119
120    offsets
121}
122
123// +spec:block-formatting-context:f5f992 - Out-of-flow: floated or absolutely positioned boxes laid out outside normal flow
124// +spec:positioning:bb19f8 - absolute/fixed positioning: out-of-flow, positioned relative to containing block/viewport
125/// After the main layout pass, this function iterates through the tree and correctly
126/// calculates the final positions of out-of-flow elements (`absolute`, `fixed`).
127// +spec:positioning:5bfef3 - abspos elements use static position for auto offsets, resolve against nearest positioned ancestor CB
128// +spec:positioning:7fff75 - Absolute positioning: removed from flow, offset relative to containing block, establishes new CB
129// +spec:positioning:839cbb - absolute elements positioned/sized solely relative to their containing block, modified by inset properties
130// +spec:positioning:898590 - absolute positioning takes elements out of flow and positions them relative to containing block
131// +spec:positioning:c37c1b - abspos boxes laid out in containing block after its final size is determined
132// +spec:positioning:cbe481 - absolute positioning removes elements from flow and positions them relative to containing block
133// +spec:positioning:ebff77 - absolute positioning layout model (replaces old §6 abspos model)
134// +spec:positioning:3b3ba4 - Absolute positioning: box offset from containing block, removed from normal flow; fixed positioning: CB = viewport
135pub fn position_out_of_flow_elements<T: ParsedFontTrait>(
136    ctx: &mut LayoutContext<'_, T>,
137    tree: &mut LayoutTree,
138    calculated_positions: &mut super::PositionVec,
139    viewport: LogicalRect,
140) -> Result<()> {
141    for node_index in 0..tree.nodes.len() {
142        let node = &tree.nodes[node_index];
143        let dom_id = match node.dom_node_id {
144            Some(id) => id,
145            None => continue,
146        };
147
148        let position_type = get_position_type(ctx.styled_dom, Some(dom_id));
149
150        // +spec:positioning:1d87f6 - Fixed/absolute positioning schemes with box offset resolution (top/right/bottom/left)
151        // +spec:positioning:8bde1d - absolute: out of flow, positioned by containing block
152        // +spec:positioning:c11be9 - absolute positioning: effect of box offsets depends on which properties are auto (non-replaced) or intrinsic dimensions (replaced)
153        // +spec:positioning:9020aa - "absolutely positioned" means position:absolute or position:fixed
154        if position_type == LayoutPosition::Absolute || position_type == LayoutPosition::Fixed {
155            // is a grid container have their CB determined by grid-placement properties;
156            // Taffy already handles this during grid layout, so skip re-positioning here.
157            // Same applies to flex containers (Flexbox §4.1).
158            {
159                use azul_core::dom::FormattingContext;
160                let parent_is_flex_or_grid = node.parent.and_then(|p| tree.get(p)).map_or(false, |pn| {
161                    matches!(pn.formatting_context, FormattingContext::Flex | FormattingContext::Grid)
162                });
163                if parent_is_flex_or_grid {
164                    continue;
165                }
166            }
167
168            // Get parent info before any mutable borrows
169            let parent_info: Option<(usize, LogicalPosition, f32, f32, f32, f32)> = {
170                let node = &tree.nodes[node_index];
171                node.parent.and_then(|parent_idx| {
172                    let parent_node = tree.get(parent_idx)?;
173                    let parent_dom_id = parent_node.dom_node_id?;
174                    let parent_position = get_position_type(ctx.styled_dom, Some(parent_dom_id));
175                    if parent_position == LayoutPosition::Absolute
176                        || parent_position == LayoutPosition::Fixed
177                    {
178                        calculated_positions.get(parent_idx).map(|parent_pos| {
179                            let pbp = parent_node.box_props.unpack();
180                            (
181                                parent_idx,
182                                *parent_pos,
183                                pbp.border.left,
184                                pbp.border.top,
185                                pbp.padding.left,
186                                pbp.padding.top,
187                            )
188                        })
189                    } else {
190                        None
191                    }
192                })
193            };
194
195            // +spec:containing-block:17a946 - fixed boxes use viewport as containing block
196            // +spec:containing-block:83a32a - fixed positioning: containing block is viewport; absolute: nearest positioned ancestor or initial CB
197            // +spec:containing-block:9b617d - fixed elements use viewport (initial fixed containing block)
198            // +spec:containing-block:899e47 - fixed elements use viewport (initial fixed containing block)
199            // +spec:containing-block:faa9a3 - fixed positioning falls back to initial containing block (viewport) when no ancestor establishes one
200            // +spec:containing-block:faa9a3 - fixed positioning CB falls back to initial containing block (viewport) when no ancestor establishes one
201            // +spec:positioning:067eab - CB for fixed = viewport, for absolute = nearest positioned ancestor
202            // +spec:positioning:067eab - fixed CB is viewport; absolute CB is nearest positioned ancestor's padding-box
203            // +spec:positioning:9777da - fixed positioning uses viewport as containing block
204            // +spec:positioning:9777da - Fixed positioning uses viewport as containing block
205            // +spec:positioning:9ccf9a - fixed-position CB is viewport (transform/will-change/contain could override, not yet implemented)
206            // +spec:positioning:a68970 - fixed positioning uses viewport as containing block
207            // +spec:positioning:8fff44 - fixed: same as absolute but positioned relative to viewport
208            // +spec:positioning:744713 - fixed position uses viewport as containing block
209            // +spec:positioning:f0ad47 - fixed elements use viewport as containing block; content outside viewport cannot be scrolled to
210            // +spec:containing-block:df8387 - fixed positioning: containing block is the viewport
211            let containing_block_rect = if position_type == LayoutPosition::Fixed {
212                viewport
213            } else {
214                find_absolute_containing_block_rect(
215                    tree,
216                    node_index,
217                    ctx.styled_dom,
218                    calculated_positions,
219                    viewport,
220                )?
221            };
222
223            // Get node again after containing block calculation
224            let node = &tree.nodes[node_index];
225
226            // Calculate used size for out-of-flow elements (they don't get sized during normal
227            // layout)
228            let element_size = if let Some(size) = node.used_size {
229                size
230            } else {
231                // Element hasn't been sized yet - calculate it now using containing block
232                let intrinsic = tree.warm(node_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
233                let size = crate::solver3::sizing::calculate_used_size_for_node(
234                    ctx.styled_dom,
235                    Some(dom_id),
236                    containing_block_rect.size,
237                    intrinsic,
238                    &node.box_props.unpack(),
239                    ctx.viewport_size,
240                )?;
241
242                // Store the calculated size in the tree node
243                if let Some(node_mut) = tree.get_mut(node_index) {
244                    node_mut.used_size = Some(size);
245                }
246
247                size
248            };
249
250            // +spec:positioning:dc23fa - sizing/positioning into inset-modified containing block (§4)
251            // +spec:positioning:623e45 - inset properties reduce the containing block into the inset-modified containing block
252            // Resolve offsets using the now-known containing block size.
253            let offsets =
254                resolve_position_offsets(ctx.styled_dom, Some(dom_id), containing_block_rect.size);
255
256            // +spec:box-model:ae3899 - static position is the margin-edge position from normal flow
257            // +spec:positioning:9a90a3 - static position: the position the element would have had in normal flow
258            // +spec:positioning:ca3e89 - static-position rectangle uses block-start inline-start alignment (CSS2.1 hypothetical box)
259            let mut static_pos = calculated_positions
260                .get(node_index)
261                .copied()
262                .unwrap_or_default();
263
264            // Special case: If this is a fixed-position element and it has a positioned
265            // parent, update static_pos to be relative to the parent's final absolute
266            // position (content-box). The initial static_pos from process_out_of_flow_children
267            // may include border/padding offsets, so we must always recalculate here.
268            if position_type == LayoutPosition::Fixed {
269                if let Some((_, parent_pos, border_left, border_top, padding_left, padding_top)) =
270                    parent_info
271                {
272                    // Add parent's border and padding to get content-box position
273                    static_pos = LogicalPosition::new(
274                        parent_pos.x + border_left + padding_left,
275                        parent_pos.y + border_top + padding_top,
276                    );
277                }
278            }
279
280            let mut final_pos = LogicalPosition::zero();
281
282            // +spec:box-model:ea2f43 - top + margin + border + padding + height + bottom = CB height
283            // +spec:box-model:b4f5b3 - vertical constraint equation for abs-pos non-replaced elements
284            // +spec:positioning:16d82c - vertical dimension constraint for abs-positioned non-replaced elements
285            // +spec:positioning:8f474b - §10.6.4 vertical constraint for absolutely positioned non-replaced elements
286            // +spec:positioning:50218d - absolute: top margin edge offset below containing block top edge
287            // top + margin-top + border-top + padding-top + height + padding-bottom +
288            // border-bottom + margin-bottom + bottom = containing block height
289            let node_state = &ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
290
291            // Extract all box_props values upfront to avoid borrow conflicts with tree.get_mut()
292            let (margin_top_val, margin_bottom_val, margin_auto,
293                 margin_left_val, margin_right_val, margin_left_auto_flag, margin_right_auto_flag) = {
294                let node = &tree.nodes[node_index];
295                let nbp = node.box_props.unpack();
296                (nbp.margin.top, nbp.margin.bottom,
297                 nbp.margin_auto,
298                 nbp.margin.left, nbp.margin.right,
299                 nbp.margin_auto.left, nbp.margin_auto.right)
300            };
301            // +spec:positioning:d730e5 - CB height is independent of the abspos element, so percentage heights always resolve
302            let cb_height = containing_block_rect.size.height;
303
304            let css_height = get_css_height(ctx.styled_dom, dom_id, node_state);
305            // +spec:replaced-elements:7d8ba8 - §10.6.5: for absolutely positioned replaced
306            // elements, height is determined first (as for inline replaced elements), so treat
307            // it as "not auto" in the constraint equation even if CSS says auto.
308            let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
309            let is_replaced = matches!(node_data.node_type, NodeType::Image(_))
310                || node_data.is_virtual_view_node();
311            let height_is_auto = css_height.is_auto() && !is_replaced;
312            // +spec:overflow:941a06 - resolve auto inset properties: if only one is auto, solved to zero via constraint; if both auto, use static position
313            let top_is_auto = offsets.top.is_none();
314            let bottom_is_auto = offsets.bottom.is_none();
315
316            // element_size is border-box (includes border + padding + content).
317            // The constraint equation is:
318            //   top + margin-top + border-box-height + margin-bottom + bottom = CB height
319            // (border-top, padding-top, content-height, padding-bottom, border-bottom
320            //  are all inside border-box-height)
321            let mut used_height = element_size.height;
322            // +spec:height-calculation:44939a - set auto values for margin-top/margin-bottom to 0
323            // +spec:height-calculation:2f6e10 - if bottom is auto, replace auto margin-top/margin-bottom with 0
324            let mut used_margin_top = if margin_auto.top { 0.0 } else { margin_top_val };
325            let mut used_margin_bottom = if margin_auto.bottom { 0.0 } else { margin_bottom_val };
326
327            // +spec:box-model:3a9c2a - resolving auto insets: static position fallback when insets are auto
328            // +spec:box-model:bd442c - weaker inset resolves to align margin box with inset-modified CB edge
329            // +spec:height-calculation:93e91c - abs non-replaced height: auto margin centering, single auto margin solve, over-constrained ignore bottom
330            // +spec:positioning:6e7732 - §10.6.4 vertical constraint equation for abspos non-replaced elements
331            // +spec:positioning:b63d0f - absolute positioning with top:auto uses static position (change bars example)
332            // +spec:positioning:da8a0c - resolving auto insets: normal alignment treated as start, so auto insets resolve to static position
333            // +spec:positioning:820b22 - 10.6.4: absolutely positioned non-replaced elements vertical constraint equation and 6 rules
334            if top_is_auto && height_is_auto && bottom_is_auto {
335                // +spec:positioning:08e0ac - absolute element with top:auto uses static position (current line)
336                // +spec:positioning:aab294 - both inset properties auto: resolve to static position
337                // +spec:positioning:d9bb3c - hypothetical position: UA may guess static position rather than fully computing hypothetical box
338                // All three auto: set top to static position, height from content, solve for bottom
339                // +spec:height-calculation:51627d - auto margins to 0, top = static position, height from content (rule 3)
340                // +spec:positioning:460f2f - All three auto: set top to static position, height from content, solve for bottom
341                final_pos.y = static_pos.y;
342            } else if !top_is_auto && !height_is_auto && !bottom_is_auto {
343                // +spec:overflow:fc0c9e - over-constrained abspos: auto margins minimize overflow (CSS2.1 equivalent of Box Alignment 3 safe alignment)
344                // +spec:positioning:88f760 - auto margins of absolutely-positioned boxes (vertical)
345                // None are auto: over-constrained case
346                // +spec:height-calculation:03c071 - none auto: equal auto margins, solve single auto margin, or ignore bottom if over-constrained
347                let top_val = offsets.top.unwrap();
348                let bottom_val = offsets.bottom.unwrap();
349                if margin_auto.top && margin_auto.bottom {
350                    // +spec:height-calculation:5112a4 - both margin-top/bottom auto: solve with equal values
351                    let available = cb_height - top_val - used_height - bottom_val;
352                    let each = available / 2.0;
353                    used_margin_top = each;
354                    used_margin_bottom = each;
355                } else if margin_auto.top {
356                    used_margin_top = cb_height - top_val - used_height - used_margin_bottom - bottom_val;
357                } else if margin_auto.bottom {
358                    used_margin_bottom = cb_height - top_val - used_height - used_margin_top - bottom_val;
359                }
360                // else: over-constrained, ignore bottom
361                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
362            } else if top_is_auto && height_is_auto && !bottom_is_auto {
363                // +spec:height-calculation:909b50 - top and height auto, bottom not auto: height from BFC auto heights, solve for top
364                // Rule 1: height from content, auto margins to 0, solve for top
365                let bottom_val = offsets.bottom.unwrap();
366                let top_val = cb_height - used_margin_top - used_height - used_margin_bottom - bottom_val;
367                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
368            } else if top_is_auto && bottom_is_auto && !height_is_auto {
369                // +spec:positioning:64e1ba - top+bottom auto, height not auto: set top to static position, solve for bottom
370                final_pos.y = static_pos.y;
371            } else if height_is_auto && bottom_is_auto && !top_is_auto {
372                // Rule 3: height from content, auto margins to 0, solve for bottom
373                let top_val = offsets.top.unwrap();
374                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
375            } else if top_is_auto && !height_is_auto && !bottom_is_auto {
376                // +spec:height-calculation:33dce8 - top auto, height and bottom not auto: solve for top
377                // Rule 4: auto margins to 0, solve for top
378                let bottom_val = offsets.bottom.unwrap();
379                let top_val = cb_height - used_margin_top - used_height - used_margin_bottom - bottom_val;
380                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
381            } else if height_is_auto && !top_is_auto && !bottom_is_auto {
382                // +spec:intrinsic-sizing:566a43 - abspos auto height with non-auto insets: stretch-fit size
383                // +spec:intrinsic-sizing:c7227f - except: if box has aspect-ratio, ratio-dependent axis uses max-content
384                let has_aspect_ratio = matches!(
385                    get_aspect_ratio_property(ctx.styled_dom, dom_id, node_state),
386                    MultiValue::Exact(azul_css::props::style::effects::StyleAspectRatio::Ratio(_))
387                );
388                let top_val = offsets.top.unwrap();
389                let bottom_val = offsets.bottom.unwrap();
390                if !has_aspect_ratio {
391                    // solve for height from constraint equation (stretch-fit):
392                    // height = cb_height - top - margin_top - margin_bottom - bottom
393                    // +spec:containing-block:b3f0dd - clamp effective CB size to zero when insets exceed it (weaker inset reduced)
394                    used_height = (cb_height - top_val - used_margin_top - used_margin_bottom - bottom_val).max(0.0);
395                }
396                // else: keep content-based height (max-content) per aspect-ratio exception
397                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
398                // Update the element size with the resolved height
399                if let Some(node_mut) = tree.get_mut(node_index) {
400                    if let Some(ref mut size) = node_mut.used_size {
401                        size.height = used_height;
402                    }
403                }
404            } else if bottom_is_auto && !top_is_auto && !height_is_auto {
405                // Rule 6: auto margins to 0, solve for bottom
406                let top_val = offsets.top.unwrap();
407                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
408            } else {
409                // Fallback to static position
410                final_pos.y = static_pos.y;
411            }
412
413            // +spec:box-model:984243 - horizontal constraint equation for abs-pos non-replaced elements
414            // +spec:positioning:3be194 - position abs replaced element after establishing width
415            // Constraint: left + margin-left + border-left + padding-left + width +
416            // +spec:width-calculation:1661b4 - constraint equation and six rules for abs-pos horizontal (§10.3.7)
417            // left + margin-left + border-left + padding-left + width +
418            //   padding-right + border-right + margin-right + right = CB width
419            // Since element_size.width is border-box (border + padding + content),
420            // simplifies to: left + margin-left + border_box_width + margin-right + right = CB width
421            {
422                let margin_left = margin_left_val;
423                let margin_right = margin_right_val;
424                let margin_left_auto = margin_left_auto_flag;
425                let margin_right_auto = margin_right_auto_flag;
426                let cb_width = containing_block_rect.size.width;
427                let border_box_width = element_size.width;
428                let left_val = offsets.left;
429                let right_val = offsets.right;
430                let left_is_auto = left_val.is_none();
431                let right_is_auto = right_val.is_none();
432
433                // Get direction of containing block for over-constrained resolution
434                use azul_css::props::style::StyleDirection;
435                let cb_direction = {
436                    let cb_dom_id = if position_type == LayoutPosition::Fixed {
437                        None // viewport CB, default LTR
438                    } else {
439                        let mut parent = tree.nodes[node_index].parent;
440                        let mut found = None;
441                        while let Some(pidx) = parent {
442                            if let Some(pnode) = tree.get(pidx) {
443                                if get_position_type(ctx.styled_dom, pnode.dom_node_id).is_positioned() {
444                                    found = pnode.dom_node_id;
445                                    break;
446                                }
447                                parent = pnode.parent;
448                            } else {
449                                break;
450                            }
451                        }
452                        found
453                    };
454                    match cb_dom_id {
455                        Some(cb_id) => {
456                            let cb_ns = &ctx.styled_dom.styled_nodes.as_container()[cb_id].styled_node_state;
457                            match get_direction_property(ctx.styled_dom, cb_id, cb_ns) {
458                                MultiValue::Exact(v) => v,
459                                _ => StyleDirection::Ltr,
460                            }
461                        }
462                        None => StyleDirection::Ltr,
463                    }
464                };
465
466                // +spec:replaced-elements:7d8ba8 - §10.3.8: for absolutely positioned replaced elements, width is determined
467                // first (as for inline replaced), so treat as "not auto" in the constraint.
468                let width_is_auto = get_css_width(ctx.styled_dom, dom_id, node_state).is_auto() && !is_replaced;
469
470                if !left_is_auto && !width_is_auto && !right_is_auto {
471                    // +spec:positioning:88f760 - auto margins of absolutely-positioned boxes (horizontal)
472                    // +spec:width-calculation:942c77 - abs-pos non-replaced width: auto margins, over-constrained resolution
473                    // None of left/width/right are auto — solve for margins or handle over-constrained
474                    // +spec:width-calculation:dff69d - §10.3.7 abs-pos non-replaced: none auto → equal auto margins, solve single auto margin, or over-constrained
475                    let left = left_val.unwrap();
476                    let right = right_val.unwrap();
477                    let remaining = cb_width - left - border_box_width - right;
478
479                    // +spec:writing-modes:9c3b40 - abspos auto margins: if negative remaining in inline axis, start margin=0, end margin gets remainder
480                    if margin_left_auto && margin_right_auto {
481                        // +spec:positioning:ab47b3 - auto margins can be negative in absolute positioning
482                        // Both margins auto: equal values unless negative
483                        let each_margin = remaining / 2.0;
484                        if each_margin < 0.0 {
485                            match cb_direction {
486                                StyleDirection::Ltr => {
487                                    final_pos.x = containing_block_rect.origin.x + left;
488                                }
489                                StyleDirection::Rtl => {
490                                    final_pos.x = containing_block_rect.origin.x + left + remaining;
491                                }
492                            }
493                        } else {
494                            final_pos.x = containing_block_rect.origin.x + left + each_margin;
495                        }
496                    } else if margin_left_auto {
497                        let solved_margin_left = remaining - margin_right;
498                        final_pos.x = containing_block_rect.origin.x + left + solved_margin_left;
499                    } else if margin_right_auto {
500                        final_pos.x = containing_block_rect.origin.x + left + margin_left;
501                    } else {
502                        // Over-constrained: ignore right (LTR) or left (RTL)
503                        match cb_direction {
504                            StyleDirection::Ltr => {
505                                final_pos.x = containing_block_rect.origin.x + left + margin_left;
506                            }
507                            StyleDirection::Rtl => {
508                                let solved_left = cb_width - margin_left - border_box_width - margin_right - right;
509                                final_pos.x = containing_block_rect.origin.x + solved_left + margin_left;
510                            }
511                        }
512                    }
513                } else {
514                    // +spec:overflow:f323cb - auto inset: align margin box to stronger inset edge (may overflow CB)
515                    // +spec:width-calculation:bbf97a - set auto margins to 0 for abspos when left/width/right has auto
516                    // Set auto margins to 0, apply six rules
517                    // +spec:box-model:2da091 - if either inset is auto, auto margins resolve to zero
518                    // +spec:intrinsic-sizing:087b57 - abspos auto margins resolve to 0 when any inset is auto
519                    // +spec:width-calculation:0c29ce - set auto margins to 0, then apply six rules for abs pos width
520                    let m_left = if margin_left_auto { 0.0 } else { margin_left };
521                    let m_right = if margin_right_auto { 0.0 } else { margin_right };
522
523                    // +spec:width-calculation:2b2852 - all three auto: set auto margins to 0, use static position for left (LTR)
524                    // +spec:width-calculation:c120b3 - all three of left/width/right auto: set auto margins to 0, then use direction to pick static position
525                    if left_is_auto && width_is_auto && right_is_auto {
526                        match cb_direction {
527                            StyleDirection::Ltr => {
528                                // Set left to static position, apply rule 3 (width from content, solve for right)
529                                final_pos.x = static_pos.x;
530                            }
531                            StyleDirection::Rtl => {
532                                // Set right to static position, apply rule 1 (width from content, solve for left)
533                                let static_offset = static_pos.x - containing_block_rect.origin.x;
534                                let right_static = (cb_width - static_offset - border_box_width).max(0.0);
535                                let solved_left = cb_width - m_left - border_box_width - m_right - right_static;
536                                final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
537                            }
538                        }
539                    } else if left_is_auto && width_is_auto && !right_is_auto {
540                        // left+width auto, right not auto: width from content, solve for left
541                        let right = right_val.unwrap();
542                        let solved_left = cb_width - m_left - border_box_width - m_right - right;
543                        final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
544                    } else if left_is_auto && !width_is_auto && right_is_auto {
545                        // left+right auto: set left to static position (LTR)
546                        final_pos.x = static_pos.x;
547                    } else if !left_is_auto && width_is_auto && right_is_auto {
548                        // width+right auto: position from left
549                        let left = left_val.unwrap();
550                        final_pos.x = containing_block_rect.origin.x + left + m_left;
551                    } else if left_is_auto && !width_is_auto && !right_is_auto {
552                        // left auto: solve for left
553                        let right = right_val.unwrap();
554                        let solved_left = cb_width - m_left - border_box_width - m_right - right;
555                        final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
556                    } else if !left_is_auto && width_is_auto && !right_is_auto {
557                        // +spec:intrinsic-sizing:566a43 - abspos auto width with non-auto insets: stretch-fit size
558                        // +spec:intrinsic-sizing:c7227f - except: if box has aspect-ratio, ratio-dependent axis uses max-content
559                        let has_aspect_ratio = matches!(
560                            get_aspect_ratio_property(ctx.styled_dom, dom_id, node_state),
561                            MultiValue::Exact(azul_css::props::style::effects::StyleAspectRatio::Ratio(_))
562                        );
563                        let left = left_val.unwrap();
564                        let right = right_val.unwrap();
565                        if !has_aspect_ratio {
566                            // width = cb_width - left - margin_left - margin_right - right
567                            let used_width = (cb_width - left - m_left - m_right - right).max(0.0);
568                            if let Some(node_mut) = tree.get_mut(node_index) {
569                                if let Some(ref mut size) = node_mut.used_size {
570                                    size.width = used_width;
571                                }
572                            }
573                        }
574                        // else: keep content-based width (max-content) per aspect-ratio exception
575                        final_pos.x = containing_block_rect.origin.x + left + m_left;
576                    } else if !left_is_auto && !width_is_auto && right_is_auto {
577                        // right auto: position from left
578                        let left = left_val.unwrap();
579                        final_pos.x = containing_block_rect.origin.x + left + m_left;
580                    } else {
581                        final_pos.x = static_pos.x;
582                    }
583                }
584            }
585
586            super::pos_set(calculated_positions, node_index, final_pos);
587        }
588    }
589    Ok(())
590}
591
592// +spec:positioning:5b0d7f - relative positioning: offset from normal flow position, siblings unaffected
593// +spec:positioning:8afbe2 - Relative positioning preserves normal flow size and space; only visual offset applied after layout
594// +spec:positioning:3502d5 - relative and absolute positioning supported for combined use
595// +spec:positioning:b22222 - relative positioning: offset from static position, purely visual effect
596// +spec:positioning:b814b6 - relative/absolute/fixed positioning scheme (CSS Positioned Layout Module Level 3)
597/// Final pass to shift relatively positioned elements from their static flow position.
598// +spec:block-formatting-context:60ccf9 - relative positioning shifts inline boxes as a unit after normal flow
599// +spec:display-property:17239f - relative positioning offsets element after normal flow; abspos elements taken out of flow
600// +spec:positioning:cbe066 - relative positioning implementation
601///
602/// Resolves percentage-based offsets for `top`, `left`, etc.
603/// For relatively positioned elements, percentages are
604/// relative to the dimensions of the parent element's content box.
605// +spec:positioning:2d8e15 - relative positioning shifts elements as a unit after normal flow without affecting surrounding content
606pub fn adjust_relative_positions<T: ParsedFontTrait>(
607    ctx: &mut LayoutContext<'_, T>,
608    tree: &LayoutTree,
609    calculated_positions: &mut super::PositionVec,
610    viewport: LogicalRect, // The viewport is needed if the root element is relative.
611) -> Result<()> {
612    // Iterate through all nodes. We need the index to modify the position map.
613    for node_index in 0..tree.nodes.len() {
614        let node = &tree.nodes[node_index];
615        let position_type = get_position_type(ctx.styled_dom, node.dom_node_id);
616
617        // +spec:block-formatting-context:faa1cf - static boxes: top/right/bottom/left do not apply
618        // Early continue for non-relative positioning
619        // +spec:overflow:cfb09a - Sticky positioning uses relative-like offsets, clamped to nearest scrollport at scroll time
620        if position_type != LayoutPosition::Relative && position_type != LayoutPosition::Sticky {
621            continue;
622        }
623
624        // +spec:table-layout:6cb73b - position:relative effect on table elements is undefined; skip them
625        // +spec:table-layout:718f91 - relative positioning on table-row/row-group shifts all contents
626        {
627            use azul_css::props::layout::LayoutDisplay;
628            let display = get_display_property(ctx.styled_dom, node.dom_node_id);
629            if let MultiValue::Exact(d) = display {
630                // +spec:positioning:4614dd - position does not apply to table-column-group or table-column boxes
631                // Table-row and row-group elements DO support relative positioning:
632                // the shift affects all contents including cells originating in the row.
633                // Table-column, table-column-group, table-cell, and table-caption do not.
634                if matches!(
635                    d,
636                    LayoutDisplay::TableColumnGroup
637                        | LayoutDisplay::TableColumn
638                        | LayoutDisplay::TableCell
639                        | LayoutDisplay::TableCaption
640                ) {
641                    continue;
642                }
643            }
644        }
645
646        // Determine the containing block size for resolving percentages.
647        // For `position: relative`, this is the parent's content box size.
648        let containing_block_size = node.parent
649            .and_then(|parent_idx| tree.get(parent_idx))
650            .map(|parent_node| {
651                // Get parent's writing mode to correctly calculate its inner (content) size.
652                let parent_wm = parent_node.dom_node_id
653                    .map(|pid| {
654                        let ps = &ctx.styled_dom.styled_nodes.as_container()[pid].styled_node_state;
655                        get_writing_mode(ctx.styled_dom, pid, ps).unwrap_or_default()
656                    })
657                    .unwrap_or_default();
658                let parent_used_size = parent_node.used_size.unwrap_or_default();
659                parent_node.box_props.inner_size(parent_used_size, parent_wm)
660            })
661            // The root element is relatively positioned. Its containing block is the viewport.
662            .unwrap_or(viewport.size);
663
664        // +spec:positioning:418c74 - inset percentages resolve against containing block size per axis; auto is unconstrained
665        let offsets =
666            resolve_position_offsets(ctx.styled_dom, node.dom_node_id, containing_block_size);
667
668        // Get a mutable reference to the position and apply the offsets.
669        let Some(current_pos) = calculated_positions.get_mut(node_index) else {
670            continue;
671        };
672
673        let initial_pos = *current_pos;
674
675        // +spec:positioning:5eb813 - relative positioning offsets contents from normal flow position
676        // +spec:positioning:a2e5f1 - relative positioning shifts element from static position (vs absolute/float)
677        // top/bottom/left/right offsets are applied relative to the static position.
678        let mut delta_x = 0.0;
679        let mut delta_y = 0.0;
680
681        // +spec:positioning:218b50 - Relative positioning: top=-bottom, left=-right, direction-dependent resolution, top wins over bottom
682        // According to CSS 2.1 Section 9.4.3:
683        // - For `top` and `bottom`: if both are specified, `top` wins and `bottom` is ignored
684        // - For `left` and `right`: depends on direction (ltr/rtl)
685        //   - In LTR: if both specified, `left` wins and `right` is ignored
686        //   - In RTL: if both specified, `right` wins and `left` is ignored
687
688        // +spec:overflow:53dffd - both left/right auto → used values are 0, boxes stay in original position
689        // +spec:positioning:5a099e - negative offsets can cause overlapping (no clamping applied)
690        // +spec:positioning:d189de - bottom offset for relative positioning is with respect to the box's own bottom edge
691        // +spec:positioning:d80f47 - opposing inset values are negations: top wins over bottom, left/right per direction
692        // +spec:positioning:ecc27c - relative positioning: left/right move box horizontally without changing size, left = -right
693        // +spec:positioning:50218d - relative: offset from static position (top edges of box itself)
694        // both auto → 0; one auto → negative of other; neither auto → bottom ignored (top wins)
695        // +spec:positioning:ac768b - relative positioning: both auto→0, one auto→neg of other, neither→top wins; direction-aware left/right
696        // +spec:positioning:e3727e - top/bottom: both auto→0, one auto→negative of other, neither auto→bottom ignored
697        // Vertical positioning: `top` takes precedence over `bottom`
698        if let Some(top) = offsets.top {
699            delta_y = top;
700        } else if let Some(bottom) = offsets.bottom {
701            delta_y = -bottom;
702        }
703
704        // +spec:positioning:1732e8 - left/right for relatively positioned elements determined by 9.4.3 rules
705        // Spec: "If the 'direction' property of the containing block is 'ltr', the value of 'left' wins"
706        // Get the direction of the containing block (parent), not the element itself
707        use azul_css::props::style::StyleDirection;
708        let cb_direction = node.parent
709            .and_then(|parent_idx| tree.get(parent_idx))
710            .and_then(|parent_node| {
711                let parent_dom_id = parent_node.dom_node_id?;
712                let parent_state =
713                    &ctx.styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
714                match get_direction_property(ctx.styled_dom, parent_dom_id, parent_state) {
715                    MultiValue::Exact(v) => Some(v),
716                    _ => None,
717                }
718            })
719            .unwrap_or(StyleDirection::Ltr);
720        // +spec:containing-block:6d4fb1 - over-constrained relative positioning: ltr→left wins, rtl→right wins
721        match cb_direction {
722            StyleDirection::Ltr => {
723                if let Some(left) = offsets.left {
724                    delta_x = left;
725                } else if let Some(right) = offsets.right {
726                    // +spec:overflow:fb426c - left auto: used value is minus the value of right
727                    delta_x = -right;
728                }
729            }
730            StyleDirection::Rtl => {
731                if let Some(right) = offsets.right {
732                    delta_x = -right;
733                } else if let Some(left) = offsets.left {
734                    delta_x = left;
735                }
736            }
737        }
738
739        // +spec:overflow:f1e1ce - relative positioning may cause overflow:auto/scroll boxes to need scrollbars
740        // Only apply the shift if there is a non-zero delta.
741        if delta_x != 0.0 || delta_y != 0.0 {
742            current_pos.x += delta_x;
743            current_pos.y += delta_y;
744
745            ctx.debug_log(&format!(
746                "Adjusted relative element #{} from {:?} to {:?} (delta: {}, {})",
747                node_index, initial_pos, *current_pos, delta_x, delta_y
748            ));
749
750            // +spec:table-layout:ec2600 - For table-row-group, table-header-group, table-footer-group, or table-row,
751            // the relative shift affects all contents of the box including table cells.
752            // Propagate the delta to all descendant nodes.
753            {
754                use azul_css::props::layout::LayoutDisplay;
755                let display = get_display_property(ctx.styled_dom, node.dom_node_id);
756                let is_table_row_like = matches!(
757                    display,
758                    MultiValue::Exact(
759                        LayoutDisplay::TableRowGroup
760                        | LayoutDisplay::TableHeaderGroup
761                        | LayoutDisplay::TableFooterGroup
762                        | LayoutDisplay::TableRow
763                    )
764                );
765                if is_table_row_like {
766                    // Shift all children (and their descendants) by the same delta
767                    let mut stack = tree.children(node_index).to_vec();
768                    while let Some(child_idx) = stack.pop() {
769                        if let Some(child_pos) = calculated_positions.get_mut(child_idx) {
770                            child_pos.x += delta_x;
771                            child_pos.y += delta_y;
772                        }
773                        stack.extend_from_slice(tree.children(child_idx));
774                    }
775                }
776            }
777        }
778    }
779    Ok(())
780}
781
782/// Sticky positioning constraints computed at layout time.
783/// At scroll time, the sticky box's position is clamped so that
784/// it remains within the sticky view rectangle (scrollport inset by these values).
785// +spec:overflow:bac4e5 - sticky view rectangle from inset properties relative to nearest scrollport
786#[derive(Debug, Clone)]
787pub struct StickyConstraints {
788    /// Inset from the top edge of the nearest scrollport (0 if auto).
789    pub top_inset: f32,
790    /// Inset from the right edge of the nearest scrollport (0 if auto).
791    pub right_inset: f32,
792    /// Inset from the bottom edge of the nearest scrollport (0 if auto).
793    pub bottom_inset: f32,
794    /// Inset from the left edge of the nearest scrollport (0 if auto).
795    pub left_inset: f32,
796    /// Normal-flow position of the sticky element (border-box origin).
797    pub normal_flow_position: LogicalPosition,
798    /// Border-box size of the sticky element.
799    pub border_box_size: LogicalSize,
800    /// The scrollport rect (content-box of nearest scroll container).
801    pub scrollport: LogicalRect,
802}
803
804/// Finds the nearest scrollport (ancestor with overflow: scroll or auto) for a node.
805/// Returns the content-box rect of the scrollport, or the viewport if none found.
806fn find_nearest_scrollport(
807    tree: &LayoutTree,
808    node_index: usize,
809    styled_dom: &StyledDom,
810    calculated_positions: &super::PositionVec,
811    viewport: LogicalRect,
812) -> LogicalRect {
813    use crate::solver3::getters::{get_overflow_x, get_overflow_y};
814    use azul_css::props::layout::LayoutOverflow;
815
816    let mut current_parent_idx = tree.get(node_index).and_then(|n| n.parent);
817
818    while let Some(parent_index) = current_parent_idx {
819        let parent_node = match tree.get(parent_index) {
820            Some(n) => n,
821            None => break,
822        };
823        let parent_dom_id = match parent_node.dom_node_id {
824            Some(id) => id,
825            None => {
826                current_parent_idx = parent_node.parent;
827                continue;
828            }
829        };
830
831        let node_state = &styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
832        let ox = get_overflow_x(styled_dom, parent_dom_id, node_state);
833        let oy = get_overflow_y(styled_dom, parent_dom_id, node_state);
834
835        let is_scrollport = matches!(
836            ox,
837            MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
838        ) || matches!(
839            oy,
840            MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
841        );
842
843        if is_scrollport {
844            let margin_box_pos = calculated_positions
845                .get(parent_index)
846                .copied()
847                .unwrap_or_default();
848            let border_box_size = parent_node.used_size.unwrap_or_default();
849
850            // Content-box = margin-box pos + border + padding, size - border - padding
851            let pbp = parent_node.box_props.unpack();
852            let content_pos = LogicalPosition::new(
853                margin_box_pos.x
854                    + pbp.border.left
855                    + pbp.padding.left,
856                margin_box_pos.y
857                    + pbp.border.top
858                    + pbp.padding.top,
859            );
860            let content_size = LogicalSize::new(
861                (border_box_size.width
862                    - pbp.border.left
863                    - pbp.border.right
864                    - pbp.padding.left
865                    - pbp.padding.right)
866                    .max(0.0),
867                (border_box_size.height
868                    - pbp.border.top
869                    - pbp.border.bottom
870                    - pbp.padding.top
871                    - pbp.padding.bottom)
872                    .max(0.0),
873            );
874            return LogicalRect::new(content_pos, content_size);
875        }
876
877        current_parent_idx = parent_node.parent;
878    }
879
880    viewport
881}
882
883/// Find the scroll offset of the nearest scroll container ancestor.
884/// Returns the scroll offset as a LogicalPosition (how far the content has scrolled).
885fn find_nearest_scroll_offset(
886    tree: &LayoutTree,
887    node_index: usize,
888    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
889) -> LogicalPosition {
890    let mut parent = tree.get(node_index).and_then(|n| n.parent);
891    while let Some(pidx) = parent {
892        if let Some(pnode) = tree.get(pidx) {
893            if let Some(dom_id) = pnode.dom_node_id {
894                if let Some(scroll_pos) = scroll_offsets.get(&dom_id) {
895                    let offset_x = scroll_pos.children_rect.origin.x - scroll_pos.parent_rect.origin.x;
896                    let offset_y = scroll_pos.children_rect.origin.y - scroll_pos.parent_rect.origin.y;
897                    return LogicalPosition::new(offset_x, offset_y);
898                }
899            }
900            parent = pnode.parent;
901        } else {
902            break;
903        }
904    }
905    LogicalPosition::zero()
906}
907
908/// Adjusts positions of sticky-positioned elements based on scroll offset.
909///
910/// Sticky positioning works like relative positioning, but the element's position
911/// is constrained by its inset properties (top/right/bottom/left) relative to the
912/// nearest scrollport (scroll container ancestor). The margin box is further
913/// constrained to remain within the containing block.
914///
915/// +spec:position-sticky:9449f1 - for sticky positioning, insets represent offsets from scrollport edge
916/// +spec:position-sticky:75412d - multiple sticky boxes in same container offset independently
917/// +spec:box-model:af9af8 - sticky positioning: shift element to stay within sticky view rectangle, margin box constrained to containing block
918/// +spec:overflow:bac4e5 - compute sticky view rectangle, clamp end-edge insets to border box size
919pub fn adjust_sticky_positions<T: ParsedFontTrait>(
920    ctx: &mut LayoutContext<'_, T>,
921    tree: &LayoutTree,
922    calculated_positions: &mut super::PositionVec,
923    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
924    viewport: LogicalRect,
925) -> Result<()> {
926    for node_index in 0..tree.nodes.len() {
927        let node = &tree.nodes[node_index];
928        let position_type = get_position_type(ctx.styled_dom, node.dom_node_id);
929
930        if position_type != LayoutPosition::Sticky {
931            continue;
932        }
933
934        let dom_id = match node.dom_node_id {
935            Some(id) => id,
936            None => continue,
937        };
938
939        // Find the nearest scrollport for this sticky element
940        let scrollport = find_nearest_scrollport(
941            tree,
942            node_index,
943            ctx.styled_dom,
944            calculated_positions,
945            viewport,
946        );
947
948        // The containing block for percentage resolution is the parent's content box
949        let containing_block = node.parent
950            .and_then(|parent_idx| {
951                let parent_node = tree.get(parent_idx)?;
952                let parent_pos = calculated_positions.get(parent_idx).copied().unwrap_or_default();
953                let parent_size = parent_node.used_size.unwrap_or_default();
954                let parent_wm = parent_node.dom_node_id
955                    .map(|pid| {
956                        let ps = &ctx.styled_dom.styled_nodes.as_container()[pid].styled_node_state;
957                        get_writing_mode(ctx.styled_dom, pid, ps).unwrap_or_default()
958                    })
959                    .unwrap_or_default();
960                let pbp = parent_node.box_props.unpack();
961                let content_size = pbp.inner_size(parent_size, parent_wm);
962                let content_origin = LogicalPosition::new(
963                    parent_pos.x + pbp.border.left + pbp.padding.left,
964                    parent_pos.y + pbp.border.top + pbp.padding.top,
965                );
966                Some(LogicalRect::new(content_origin, content_size))
967            })
968            .unwrap_or(viewport);
969
970        // Resolve inset properties (top, right, bottom, left)
971        let offsets = resolve_position_offsets(ctx.styled_dom, Some(dom_id), scrollport.size);
972
973        // Get the scroll offset from the nearest scroll container
974        let scroll_offset = find_nearest_scroll_offset(tree, node_index, scroll_offsets);
975
976        let Some(current_pos) = calculated_positions.get_mut(node_index) else {
977            continue;
978        };
979
980        let static_pos = *current_pos;
981        let element_size = node.used_size.unwrap_or_default();
982        let nbp = node.box_props.unpack();
983        let margin = &nbp.margin;
984
985        let mut shift_x = 0.0f32;
986        let mut shift_y = 0.0f32;
987
988        // For each side: if inset is not auto, clamp the border edge to stay
989        // within the sticky view rectangle (scrollport inset by the specified amount).
990        // The scroll offset shifts the effective scrollport position.
991        if let Some(top_inset) = offsets.top {
992            let sticky_edge = scrollport.origin.y + scroll_offset.y + top_inset;
993            let border_top = current_pos.y;
994            if border_top < sticky_edge {
995                shift_y = shift_y.max(sticky_edge - border_top);
996            }
997        }
998
999        if let Some(bottom_inset) = offsets.bottom {
1000            let sticky_edge = scrollport.origin.y + scroll_offset.y + scrollport.size.height - bottom_inset;
1001            let border_bottom = current_pos.y + element_size.height;
1002            if border_bottom > sticky_edge {
1003                shift_y = shift_y.min(sticky_edge - border_bottom);
1004            }
1005        }
1006
1007        if let Some(left_inset) = offsets.left {
1008            let sticky_edge = scrollport.origin.x + scroll_offset.x + left_inset;
1009            let border_left = current_pos.x;
1010            if border_left < sticky_edge {
1011                shift_x = shift_x.max(sticky_edge - border_left);
1012            }
1013        }
1014
1015        if let Some(right_inset) = offsets.right {
1016            let sticky_edge = scrollport.origin.x + scroll_offset.x + scrollport.size.width - right_inset;
1017            let border_right = current_pos.x + element_size.width;
1018            if border_right > sticky_edge {
1019                shift_x = shift_x.min(sticky_edge - border_right);
1020            }
1021        }
1022
1023        // Constrain: the margin box must remain within the containing block
1024        if shift_y != 0.0 {
1025            let margin_box_top = current_pos.y - margin.top + shift_y;
1026            let margin_box_bottom = current_pos.y + element_size.height + margin.bottom + shift_y;
1027            if margin_box_top < containing_block.origin.y {
1028                shift_y += containing_block.origin.y - margin_box_top;
1029            }
1030            let cb_bottom = containing_block.origin.y + containing_block.size.height;
1031            if margin_box_bottom > cb_bottom {
1032                shift_y -= margin_box_bottom - cb_bottom;
1033            }
1034        }
1035
1036        if shift_x != 0.0 {
1037            let margin_box_left = current_pos.x - margin.left + shift_x;
1038            let margin_box_right = current_pos.x + element_size.width + margin.right + shift_x;
1039            if margin_box_left < containing_block.origin.x {
1040                shift_x += containing_block.origin.x - margin_box_left;
1041            }
1042            let cb_right = containing_block.origin.x + containing_block.size.width;
1043            if margin_box_right > cb_right {
1044                shift_x -= margin_box_right - cb_right;
1045            }
1046        }
1047
1048        if shift_x != 0.0 || shift_y != 0.0 {
1049            current_pos.x += shift_x;
1050            current_pos.y += shift_y;
1051
1052            ctx.debug_log(&format!(
1053                "Adjusted sticky element #{} from {:?} to {:?}",
1054                node_index, static_pos, *current_pos
1055            ));
1056        }
1057    }
1058    Ok(())
1059}
1060
1061// +spec:positioning:22f165 - absolute/fixed containing block: nearest positioned ancestor's padding-box, or initial CB
1062/// Helper to find the containing block for an absolutely positioned element.
1063/// CSS 2.1 Section 10.1: The containing block for absolutely positioned elements
1064/// is the padding box of the nearest positioned ancestor.
1065// +spec:containing-block:10af51 - absolutely positioned element's CB is nearest positioned ancestor
1066// +spec:positioning:2d0dbb - containing block for abspos is padding-box of nearest positioned ancestor, or initial CB
1067// +spec:positioning:3ac06c - abspos positioned relative to containing block ignoring fragmentation breaks
1068// +spec:positioning:d7e4b4 - containing block of abspos element is always definite (returns concrete LogicalRect)
1069// +spec:positioning:fc9dba - containing block resolution for absolutely positioned boxes
1070///
1071/// Returns a `LogicalRect` representing the padding-box of the nearest
1072/// positioned ancestor, or the viewport (initial containing block) if none exists.
1073/// This is the unified entry point used by both sizing and positioning phases.
1074// +spec:containing-block:18ae8e - Absolute positioning: abs-pos box establishes new CB for normal flow and abs-pos (but not fixed) descendants
1075// +spec:containing-block:b6cb8b - containing block for abs-pos is nearest positioned ancestor
1076// +spec:display-property:5a39bc - containing block for abspos is nearest positioned ancestor or initial containing block
1077// +spec:positioning:09a0fa - Absolute positioning: CB is padding-box of nearest positioned ancestor
1078// +spec:positioning:467cb1 - Containing block for abs pos = nearest positioned ancestor or initial CB
1079// +spec:positioning:99d0bb - containing block for absolute elements is nearest positioned ancestor
1080// +spec:positioning:92e099 - containing block for abs pos is nearest positioned ancestor or initial CB
1081// +spec:positioning:f57523 - containing block of abspos element is always definite (returns concrete LogicalRect)
1082// +spec:width-calculation:bf1aa6 - abspos CB is nearest positioned ancestor, else initial CB
1083// Containing block for absolutely positioned elements is established by
1084// nearest positioned ancestor (relative/absolute/fixed), or initial containing block if none.
1085// +spec:positioning:8f50de - relatively positioned parent serves as containing block for abspos descendants
1086// +spec:containing-block:6bcb0c - containing block is padding edge of nearest positioned ancestor, or initial containing block if none
1087// +spec:containing-block:bf17e5 - containing block for abspos is padding box of nearest positioned ancestor, or initial CB
1088// +spec:containing-block:d0f92d - containing block for positioned box is nearest positioned ancestor, or initial containing block
1089// +spec:containing-block:d7e013 - containing block for positioned box is nearest positioned ancestor or initial CB
1090// +spec:containing-block:05bc0d - positioning an element changes which ancestor establishes the CB for its descendants
1091// +spec:positioning:355ee4 - CB for abspos is padding edge of nearest positioned ancestor, or initial CB
1092// +spec:positioning:383794 - Containing block for abspos is nearest positioned ancestor, or initial containing block if none
1093// +spec:positioning:5b3e43 - Containing block for abs-pos is padding box of nearest positioned ancestor, or initial CB
1094// +spec:positioning:882e67 - containing block for abs pos is nearest positioned ancestor or initial CB
1095// +spec:positioning:292c5c - relative parent serves as containing block for absolute descendants
1096// +spec:positioning:00ce38 - CB for absolute is padding edge of nearest positioned ancestor
1097pub fn find_absolute_containing_block_rect(
1098    tree: &LayoutTree,
1099    node_index: usize,
1100    styled_dom: &StyledDom,
1101    calculated_positions: &super::PositionVec,
1102    viewport: LogicalRect,
1103) -> Result<LogicalRect> {
1104    // +spec:positioning:748d87 - walk up to nearest positioned ancestor for CB
1105    let mut current_parent_idx = tree.get(node_index).and_then(|n| n.parent);
1106
1107    // +spec:positioning:aa361e - values other than static make a box positioned and establish an abspos containing block
1108    while let Some(parent_index) = current_parent_idx {
1109        let parent_node = tree.get(parent_index).ok_or(LayoutError::InvalidTree)?;
1110
1111        if get_position_type(styled_dom, parent_node.dom_node_id).is_positioned() {
1112            // calculated_positions stores margin-box positions
1113            let margin_box_pos = calculated_positions
1114                .get(parent_index)
1115                .copied()
1116                .unwrap_or_default();
1117            // used_size is the border-box size
1118            let border_box_size = parent_node.used_size.unwrap_or_default();
1119
1120            // +spec:containing-block:6bcb0c - containing block formed by padding edge of nearest positioned ancestor
1121            // +spec:positioning:df1921 - abs-pos percentage widths resolve against padding box of containing block
1122            // Calculate padding-box origin (margin-box + border)
1123            let pbp = parent_node.box_props.unpack();
1124            let padding_box_pos = LogicalPosition::new(
1125                margin_box_pos.x + pbp.border.left,
1126                margin_box_pos.y + pbp.border.top,
1127            );
1128
1129            // Calculate padding-box size (border-box - borders)
1130            let padding_box_size = LogicalSize::new(
1131                border_box_size.width
1132                    - pbp.border.left
1133                    - pbp.border.right,
1134                border_box_size.height
1135                    - pbp.border.top
1136                    - pbp.border.bottom,
1137            );
1138
1139            return Ok(LogicalRect::new(padding_box_pos, padding_box_size));
1140        }
1141        current_parent_idx = parent_node.parent;
1142    }
1143
1144    // +spec:positioning:3d88c9 - abspos available space is always definite (viewport or positioned ancestor padding box)
1145    // No positioned ancestor found: fall back to initial containing block (viewport)
1146    // +spec:containing-block:141dcc - absolute element with no positioned ancestor uses initial containing block
1147    // +spec:containing-block:657f2f - containing block becomes initial containing block when no positioned ancestors
1148    // +spec:containing-block:7f5090 - if no ancestor establishes one, absolute positioning CB is initial containing block
1149    // +spec:containing-block:7f5090 - fallback to initial containing block when no positioned ancestor
1150    // +spec:containing-block:ad5ebc - no positioned ancestor: containing block becomes the initial containing block
1151    // +spec:display-property:813192 - abspos containing block falls back to initial containing block (viewport) when no positioned ancestor
1152    Ok(viewport)
1153}