diff --git a/Cargo.lock b/Cargo.lock index 0f3010452..4d80e4055 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7935,7 +7935,7 @@ dependencies = [ [[package]] name = "taffy" version = "0.14.0" -source = "git+https://github.com/DioxusLabs/taffy?rev=63b273733cb913cbe32548329524f51e45dbd438#63b273733cb913cbe32548329524f51e45dbd438" +source = "git+https://github.com/DioxusLabs/taffy?rev=cd88cea303f92a419d6d7e83a7538a2ca729055b#cd88cea303f92a419d6d7e83a7538a2ca729055b" dependencies = [ "arrayvec", "serde", diff --git a/Cargo.toml b/Cargo.toml index 251deac9e..b80f53b4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,7 @@ dioxus-cli-config = { version = "0.7.3" } dioxus-core-macro = { version = "0.7.3" } # Taffy + Parley + Fontations -taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "63b273733cb913cbe32548329524f51e45dbd438", default-features = false, features = [ +taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "cd88cea303f92a419d6d7e83a7538a2ca729055b", default-features = false, features = [ "std", "flexbox", "grid", diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 8e9b3de61..cd13af8f8 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1801,9 +1801,16 @@ impl BaseDocument { return (None, None); } let mut scrollbar = None; - let hit = self - .root_element() - .hit_inner(x, y, self.viewport().scale_f64(), &mut scrollbar); + let hit = self.root_element().hit_inner( + x, + y, + self.viewport().scale_f64(), + &mut scrollbar, + taffy::Point { + x: self.viewport_scroll.x as f32, + y: self.viewport_scroll.y as f32, + }, + ); (hit, scrollbar) } diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index b352ffbef..4a66d7f86 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -611,6 +611,34 @@ impl BaseDocument { let position = style.clone_position(); let z_index = style.clone_z_index().integer_or(0); + // Out-of-flow boxes are hoisted to their containing block by Taffy's + // out-of-flow positioning pass and their layout location is relative to + // the containing block's border box. When this node is the child's + // containing block (it claims the child's position type), the child + // stays in this node's paint list, preserving tree order among + // positioned siblings. Otherwise it is painted (and hit-tested) via + // the containing block's `hoisted_children` list (see + // `attach_hoisted_children`), not via its DOM parent. + if matches!(position, Position::Absolute | Position::Fixed) { + let parent = &self.nodes[node_id]; + // The root element is the initial containing block: it claims + // every candidate not claimed by a closer containing block. + let is_root = self.try_root_element().is_some_and(|el| el.id == node_id); + let parent_claims = is_root + || parent + .containing_block_claims() + .for_position(stylo_taffy::convert::position(position)); + if !parent_claims { + continue; + } + // Z-indexed out-of-flow boxes are routed to their stacking + // context by `attach_hoisted_children` after layout, when + // their containing-block-relative location is known. + if z_index != 0 { + continue; + } + } + // TODO: more complete hoisting detection // z-index applies to static flex/grid items too // (css-flexbox-1 §painting, css-grid-1 §z-order). @@ -678,7 +706,7 @@ fn float_to_order(pos: Float) -> i32 { /// Appendix E step 8); within a level the stable sort preserves /// (order-modified) document order. #[inline(always)] -fn node_to_paint_order(node: &Node, is_flex_or_grid: bool) -> (i32, i32) { +pub(crate) fn node_to_paint_order(node: &Node, is_flex_or_grid: bool) -> (i32, i32) { let Some(style) = node.primary_styles() else { return (0, 0); }; diff --git a/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs index 14362bc3a..7364d4e26 100644 --- a/packages/blitz-dom/src/layout/inline.rs +++ b/packages/blitz-dom/src/layout/inline.rs @@ -5,8 +5,8 @@ use style::values::{computed::CSSPixelLength, generics::text::GenericTextIndent} use taffy::{ AvailableSpace, BlockContext, BlockFormattingContext, BoxSizing, CollapsibleMarginSet, CoreStyle as _, Direction, LayoutInput, LayoutOutput, LayoutPartialTree as _, MaybeMath as _, - MaybeResolve as _, Overflow, Point, Position, RequestedAxis, ResolveOrZero as _, RunMode, Size, - SizingMode, + MaybeResolve as _, OofCandidate, OofCandidates, OofPositioningArea, Overflow, Point, + RequestedAxis, ResolveOrZero as _, RunMode, Size, SizingMode, StaticEdge, StaticPosition, }; #[cfg(feature = "floats")] @@ -189,7 +189,7 @@ impl BaseDocument { let has_styles_preventing_being_collapsed_through = !style.is_block() || style.overflow().x.is_scroll_container() || style.overflow().y.is_scroll_container() - || style.position() == Position::Absolute + || style.position().is_out_of_flow() || padding.top > 0.0 || padding.bottom > 0.0 || border.top > 0.0 @@ -277,6 +277,14 @@ impl BaseDocument { }), }; + let perform_layout = inputs.run_mode == taffy::RunMode::PerformLayout; + + // Measure passes must not leave measure-time state (inline box sizes, line breaks) + // in the persistent inline layout: painting uses that state, and a cache hit on a + // later full layout pass would not recompute it. Snapshot it here and restore it + // before returning. + let saved_layout = (!perform_layout).then(|| inline_layout.layout.clone()); + // Compute size of inline boxes let child_inputs = taffy::tree::LayoutInput { known_dimensions: Size::NONE, @@ -306,10 +314,10 @@ impl BaseDocument { #[cfg(not(feature = "floats"))] let is_floated = false; - let is_absolute = style.position() == Position::Absolute; + let is_out_of_flow = style.position().is_out_of_flow(); drop(style); - if is_absolute || is_floated { + if is_out_of_flow || is_floated { ibox.width = 0.0; ibox.height = 0.0; } else { @@ -476,7 +484,10 @@ impl BaseDocument { if inputs.run_mode == taffy::RunMode::ComputeSize && inputs.axis == RequestedAxis::Horizontal { - // Put layout back + // Restore the pre-measure inline layout state and put the layout back + if let Some(saved) = saved_layout { + inline_layout.layout = saved; + } self.nodes[node_id] .data .downcast_element_mut() @@ -504,6 +515,10 @@ impl BaseDocument { inline_layout.layout.break_all_lines(Some(width)); } + // Out-of-flow candidates bubbled up from this container and its in-flow subtree. + // These are laid out by the out-of-flow positioning pass (`compute_oof_layout`). + let mut oof_candidates = OofCandidates::new(); + // Perform inline layout #[cfg(feature = "floats")] { @@ -574,7 +589,7 @@ impl BaseDocument { let margin_sum = margin.sum_axes(); - let output = self.compute_child_layout( + let mut output = self.compute_child_layout( crate::taffy_node_id(node_id), float_child_inputs, ); @@ -598,10 +613,22 @@ impl BaseDocument { state.set_line_x(next_slot.x * scale); state.set_line_y((next_slot.y * scale) as f64); - let layout = self.nodes[node_id].unrounded_layout_mut(); - layout.size = output.size; - layout.location.x = pos.x + margin.left + container_pb.left; - layout.location.y = pos.y + margin.top + container_pb.top; + let location = taffy::Point { + x: pos.x + margin.left + container_pb.left, + y: pos.y + margin.top + container_pb.top, + }; + if perform_layout { + let layout = self.nodes[node_id].unrounded_layout_mut(); + layout.size = output.size; + layout.location = location; + } + + // Translate anchors from item-relative to container-relative + // coordinates and collect candidates bubbled from the float's subtree + if !output.oof_candidates.is_empty() { + output.oof_candidates.translate(location); + oof_candidates.append(&mut output.oof_candidates); + } // dbg!(&layout.size); // dbg!(&layout.location); @@ -705,119 +732,149 @@ impl BaseDocument { let container_direction = self.nodes[node_id].layout_style().direction(); // Store sizes and positions of inline boxes - for line in inline_layout.layout.lines() { - for item in line.items() { - if let parley::layout::PositionedLayoutItem::InlineBox(ibox) = item { - let node = &self.nodes[NodeId::from_u64(ibox.id)]; - let style = node.layout_style(); - let padding = style - .padding() - .resolve_or_zero(child_inputs.parent_size, resolve_calc_value); - let border = style - .border() - .resolve_or_zero(child_inputs.parent_size, resolve_calc_value); - let margin = style - .margin() - .resolve_or_zero(child_inputs.parent_size, resolve_calc_value); - - #[cfg(feature = "floats")] - let is_floated = style.float() != Float::None; - #[cfg(not(feature = "floats"))] - let is_floated = false; - - let is_absolute = style.position() == Position::Absolute; - let direction = style.direction(); - - // The static position of an absolutely positioned box depends on the - // display its hypothetical box would have had (the display specified - // before position:absolute blockified it): inline-level boxes sit at - // their position within the line, while block-level boxes start at the - // content-box left edge of their containing block. - let is_inline_level = - style.style.get_box().original_display.outside() == DisplayOutside::Inline; - - // Resolve relative inset offsets against the containing block - // (the content box of the inline container). - let container_content_size = final_size - content_box_inset.sum_axes(); - let inset_style = style.inset(); - let inset = taffy::Rect { - left: inset_style - .left - .maybe_resolve(container_content_size.width, resolve_calc_value), - right: inset_style - .right - .maybe_resolve(container_content_size.width, resolve_calc_value), - top: inset_style - .top - .maybe_resolve(container_content_size.height, resolve_calc_value), - bottom: inset_style - .bottom - .maybe_resolve(container_content_size.height, resolve_calc_value), - }; - drop(style); - - if is_absolute { - // Inline-level boxes are placed at the top of the line box they would - // have occupied (`ibox.y` is the baseline as out-of-flow boxes are - // zero-sized), and block-level boxes below it. - let line_metrics = line.metrics(); - let static_position = taffy::Point { - x: if is_inline_level { - (ibox.x / scale) + container_pb.left - } else { - container_pb.left - }, - y: if is_inline_level { - (line_metrics.block_min_coord / scale) + container_pb.top - } else { - (line_metrics.block_max_coord / scale) + container_pb.top - }, + let mut ibox_order: u32 = 0; + if perform_layout { + for line in inline_layout.layout.lines() { + for item in line.items() { + if let parley::layout::PositionedLayoutItem::InlineBox(ibox) = item { + let order = ibox_order; + ibox_order += 1; + let node = &self.nodes[NodeId::from_u64(ibox.id)]; + let style = node.layout_style(); + let padding = style + .padding() + .resolve_or_zero(child_inputs.parent_size, resolve_calc_value); + let border = style + .border() + .resolve_or_zero(child_inputs.parent_size, resolve_calc_value); + let margin = style + .margin() + .resolve_or_zero(child_inputs.parent_size, resolve_calc_value); + + #[cfg(feature = "floats")] + let is_floated = style.float() != Float::None; + #[cfg(not(feature = "floats"))] + let is_floated = false; + + let position = style.position(); + let is_absolute = position.is_out_of_flow(); + + // The static position of an absolutely positioned box depends on the + // display its hypothetical box would have had (the display specified + // before position:absolute blockified it): inline-level boxes sit at + // their position within the line, while block-level boxes start at the + // content-box left edge of their containing block. + let is_inline_level = style.style.get_box().original_display.outside() + == DisplayOutside::Inline; + + // Resolve relative inset offsets against the containing block + // (the content box of the inline container). + let container_content_size = final_size - content_box_inset.sum_axes(); + let inset_style = style.inset(); + let inset = taffy::Rect { + left: inset_style + .left + .maybe_resolve(container_content_size.width, resolve_calc_value), + right: inset_style + .right + .maybe_resolve(container_content_size.width, resolve_calc_value), + top: inset_style + .top + .maybe_resolve(container_content_size.height, resolve_calc_value), + bottom: inset_style + .bottom + .maybe_resolve(container_content_size.height, resolve_calc_value), }; + drop(style); + + if is_absolute { + // Inline-level boxes are placed at the top of the line box they would + // have occupied (`ibox.y` is the baseline as out-of-flow boxes are + // zero-sized), and block-level boxes below it. + let line_metrics = line.metrics(); + let static_position = taffy::Point { + x: if is_inline_level { + (ibox.x / scale) + container_pb.left + } else { + container_pb.left + }, + y: if is_inline_level { + (line_metrics.block_min_coord / scale) + container_pb.top + } else { + (line_metrics.block_max_coord / scale) + container_pb.top + }, + }; - layout_abspos_child( - self, - ibox.id, - static_position, - is_inline_level, - final_size, - taffy::Point::ZERO, - direction, - ); - } else if is_floated { - let layout = self.nodes[NodeId::from_u64(ibox.id)].unrounded_layout_mut(); - layout.padding = padding; //.map(|p| p / scale); - layout.border = border; //.map(|p| p / scale); - } else { - // Re-measure the box to get its border-box size (this hits the layout - // cache). The size cannot be recovered from `ibox` dimensions as the - // space reserved in the line is clamped to be non-negative. - let size = self - .compute_child_layout(taffy::NodeId::from(ibox.id), child_inputs) - .size; - let node = &mut self.nodes[NodeId::from_u64(ibox.id)]; - - let inset_offset = taffy::Point { - x: if container_direction == Direction::Rtl { - inset.right.map(|x| -x).or(inset.left).unwrap_or(0.0) + oof_candidates.push(OofCandidate { + node: taffy::NodeId::from(ibox.id), + order, + position, + static_position: taffy::Point { + x: StaticPosition::from_edge( + static_position.x, + if container_direction == Direction::Rtl && is_inline_level + { + StaticEdge::End + } else { + StaticEdge::Start + }, + ), + y: StaticPosition::from_edge( + static_position.y, + StaticEdge::Start, + ), + }, + }); + } else if is_floated { + let layout = + self.nodes[NodeId::from_u64(ibox.id)].unrounded_layout_mut(); + layout.padding = padding; //.map(|p| p / scale); + layout.border = border; //.map(|p| p / scale); + } else { + // Re-measure the box to get its border-box size (this hits the layout + // cache). The size cannot be recovered from `ibox` dimensions as the + // space reserved in the line is clamped to be non-negative. + let mut output = self + .compute_child_layout(taffy::NodeId::from(ibox.id), child_inputs); + let size = output.size; + let node = &mut self.nodes[NodeId::from_u64(ibox.id)]; + + let is_relative = position == taffy::Position::Relative; + let inset_offset = if is_relative { + taffy::Point { + x: if container_direction == Direction::Rtl { + inset.right.map(|x| -x).or(inset.left).unwrap_or(0.0) + } else { + inset.left.or(inset.right.map(|x| -x)).unwrap_or(0.0) + }, + y: inset.top.or(inset.bottom.map(|x| -x)).unwrap_or(0.0), + } } else { - inset.left.or(inset.right.map(|x| -x)).unwrap_or(0.0) - }, - y: inset.top.or(inset.bottom.map(|x| -x)).unwrap_or(0.0), - }; + taffy::Point::ZERO + }; - let layout = node.unrounded_layout_mut(); - layout.size = size; - layout.location.x = - (ibox.x / scale) + margin.left + container_pb.left + inset_offset.x; - // A negative `margin-top` shrinks the space the box reserves in the - // line but does not move the box itself, which stays anchored to the - // bottom of the reserved space. - layout.location.y = (ibox.y / scale) - + margin.top.max(0.0) - + container_pb.top - + inset_offset.y; - layout.padding = padding; //.map(|p| p / scale); - layout.border = border; //.map(|p| p / scale); + let layout = node.unrounded_layout_mut(); + layout.size = size; + layout.location.x = + (ibox.x / scale) + margin.left + container_pb.left + inset_offset.x; + // A negative `margin-top` shrinks the space the box reserves in the + // line but does not move the box itself, which stays anchored to the + // bottom of the reserved space. + layout.location.y = (ibox.y / scale) + + margin.top.max(0.0) + + container_pb.top + + inset_offset.y; + layout.padding = padding; //.map(|p| p / scale); + layout.border = border; //.map(|p| p / scale); + + // Translate anchors from item-relative to container-relative + // coordinates and collect candidates bubbled from the box's subtree + if !output.oof_candidates.is_empty() { + let location = layout.location; + output.oof_candidates.translate(location); + oof_candidates.append(&mut output.oof_candidates); + } + } } } } @@ -835,13 +892,23 @@ impl BaseDocument { .next() .map(|line| (line.metrics().baseline / scale) + container_pb.top); - // Put layout back + // Restore the pre-measure inline layout state and put the layout back + if let Some(saved) = saved_layout { + inline_layout.layout = saved; + } self.nodes[node_id] .data .downcast_element_mut() .unwrap() .inline_layout_data = Some(inline_layout); + let oof_position_inset = taffy::Rect { + left: border.left, + right: border.right + scrollbar_gutter.x, + top: border.top, + bottom: border.bottom + scrollbar_gutter.y, + }; + LayoutOutput { size: final_size, scrollable_overflow_rect: { @@ -862,6 +929,14 @@ impl BaseDocument { margins_can_collapse_through: !has_styles_preventing_being_collapsed_through && final_size.height == 0.0 && measured_size.height == 0.0, + oof_candidates, + oof_positioning_area: Some(OofPositioningArea { + size: final_size - oof_position_inset.sum_axes(), + offset: Point { + x: oof_position_inset.left, + y: oof_position_inset.top, + }, + }), } } } @@ -870,322 +945,3 @@ impl BaseDocument { fn f32_max(a: f32, b: f32) -> f32 { a.max(b) } - -/// Perform absolute layout on all absolutely positioned children. -#[inline] -fn layout_abspos_child( - tree: &mut impl taffy::LayoutBlockContainer, - item_id: u64, - static_position: Point, - is_inline_level: bool, - area_size: Size, - area_offset: Point, - direction: taffy::Direction, -) { - let area_width = area_size.width; - let area_height = area_size.height; - - let node_id = taffy::NodeId::from(item_id); - let child_style = tree.get_block_child_style(node_id); - - // Skip items that are display:none or are not position:absolute - if child_style.box_generation_mode() == taffy::BoxGenerationMode::None - || child_style.position() != taffy::Position::Absolute - { - return; - } - - let aspect_ratio = child_style.aspect_ratio(); - let overflow = child_style.overflow(); - let scrollbar_width = child_style.scrollbar_width(); - let margin = child_style - .margin() - .map(|margin| margin.resolve_to_option(area_width, resolve_calc_value)); - let padding = child_style - .padding() - .resolve_or_zero(Some(area_width), resolve_calc_value); - let border = child_style - .border() - .resolve_or_zero(Some(area_width), resolve_calc_value); - let padding_border_sum = (padding + border).sum_axes(); - let box_sizing_adjustment = if child_style.box_sizing() == taffy::BoxSizing::ContentBox { - padding_border_sum - } else { - Size::ZERO - }; - - // Resolve inset - let left = child_style - .inset() - .left - .maybe_resolve(area_width, resolve_calc_value); - let right = child_style - .inset() - .right - .maybe_resolve(area_width, resolve_calc_value); - let top = child_style - .inset() - .top - .maybe_resolve(area_height, resolve_calc_value); - let bottom = child_style - .inset() - .bottom - .maybe_resolve(area_height, resolve_calc_value); - - // Compute known dimensions from min/max/inherent size styles - let style_size = child_style - .size() - .maybe_resolve(area_size, resolve_calc_value) - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_add(box_sizing_adjustment); - let min_size = child_style - .min_size() - .maybe_resolve(area_size, resolve_calc_value) - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_add(box_sizing_adjustment) - .or(padding_border_sum.map(Some)) - .maybe_max(padding_border_sum); - let max_size = child_style - .max_size() - .maybe_resolve(area_size, resolve_calc_value) - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_add(box_sizing_adjustment); - let mut known_dimensions = style_size.maybe_clamp(min_size, max_size); - - drop(child_style); - - // Fill in width from left/right and reapply aspect ratio if: - // - Width is not already known - // - Item has both left and right inset properties set - if let (None, Some(left), Some(right)) = (known_dimensions.width, left, right) { - let new_width_raw = - area_width.maybe_sub(margin.left).maybe_sub(margin.right) - left - right; - known_dimensions.width = Some(f32_max(new_width_raw, 0.0)); - known_dimensions = known_dimensions - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_clamp(min_size, max_size); - } - - // Fill in height from top/bottom and reapply aspect ratio if: - // - Height is not already known - // - Item has both top and bottom inset properties set - if let (None, Some(top), Some(bottom)) = (known_dimensions.height, top, bottom) { - let new_height_raw = - area_height.maybe_sub(margin.top).maybe_sub(margin.bottom) - top - bottom; - known_dimensions.height = Some(f32_max(new_height_raw, 0.0)); - known_dimensions = known_dimensions - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_clamp(min_size, max_size); - } - - let measured_size = tree - .compute_child_layout( - node_id, - taffy::LayoutInput { - known_dimensions, - known_dimensions_are_definite: taffy::Size { - width: true, - height: true, - }, - parent_size: area_size.map(Some), - available_space: Size { - width: AvailableSpace::Definite( - area_width.maybe_clamp(min_size.width, max_size.width), - ), - height: AvailableSpace::Definite( - area_height.maybe_clamp(min_size.height, max_size.height), - ), - }, - sizing_mode: SizingMode::ContentSize, - run_mode: RunMode::ComputeSize, - axis: taffy::RequestedAxis::Both, - vertical_margins_are_collapsible: taffy::Line::FALSE, - }, - ) - .size; - - let final_size = known_dimensions - .unwrap_or(measured_size) - .maybe_clamp(min_size, max_size); - - let layout_output = tree.compute_child_layout( - node_id, - taffy::LayoutInput { - known_dimensions: final_size.map(Some), - known_dimensions_are_definite: taffy::Size { - width: true, - height: true, - }, - parent_size: area_size.map(Some), - available_space: Size { - width: AvailableSpace::Definite( - area_width.maybe_clamp(min_size.width, max_size.width), - ), - height: AvailableSpace::Definite( - area_height.maybe_clamp(min_size.height, max_size.height), - ), - }, - sizing_mode: SizingMode::ContentSize, - run_mode: RunMode::PerformLayout, - axis: taffy::RequestedAxis::Both, - vertical_margins_are_collapsible: taffy::Line::FALSE, - }, - ); - - let non_auto_margin = taffy::Rect { - left: if left.is_some() { - margin.left.unwrap_or(0.0) - } else { - 0.0 - }, - right: if right.is_some() { - margin.right.unwrap_or(0.0) - } else { - 0.0 - }, - top: if top.is_some() { - margin.top.unwrap_or(0.0) - } else { - 0.0 - }, - bottom: if bottom.is_some() { - margin.bottom.unwrap_or(0.0) - } else { - 0.0 - }, - }; - - // Expand auto margins to fill available space - // https://www.w3.org/TR/CSS21/visudet.html#abs-non-replaced-width - let auto_margin = { - // Auto margins for absolutely positioned elements in block containers only resolve - // if inset is set. Otherwise they resolve to 0. - let absolute_auto_margin_space = Point { - x: right - .map(|right| area_size.width - right - left.unwrap_or(0.0)) - .unwrap_or(final_size.width), - y: bottom - .map(|bottom| area_size.height - bottom - top.unwrap_or(0.0)) - .unwrap_or(final_size.height), - }; - let free_space = Size { - width: absolute_auto_margin_space.x - - final_size.width - - non_auto_margin.horizontal_axis_sum(), - height: absolute_auto_margin_space.y - - final_size.height - - non_auto_margin.vertical_axis_sum(), - }; - - let auto_margin_size = Size { - // If all three of 'left', 'width', and 'right' are 'auto': First set any 'auto' values for 'margin-left' and 'margin-right' to 0. - // Then, if the 'direction' property of the element establishing the static-position containing block is 'ltr' set 'left' to the - // static position and apply rule number three below; otherwise, set 'right' to the static position and apply rule number one below. - // - // If none of the three is 'auto': If both 'margin-left' and 'margin-right' are 'auto', solve the equation under the extra constraint - // that the two margins get equal values, unless this would make them negative, in which case when direction of the containing block is - // 'ltr' ('rtl'), set 'margin-left' ('margin-right') to zero and solve for 'margin-right' ('margin-left'). If one of 'margin-left' or - // 'margin-right' is 'auto', solve the equation for that value. If the values are over-constrained, ignore the value for 'left' (in case - // the 'direction' property of the containing block is 'rtl') or 'right' (in case 'direction' is 'ltr') and solve for that value. - width: { - let auto_margin_count = margin.left.is_none() as u8 + margin.right.is_none() as u8; - if auto_margin_count == 2 - && (style_size.width.is_none() || style_size.width.unwrap() >= free_space.width) - { - 0.0 - } else if auto_margin_count > 0 { - free_space.width / auto_margin_count as f32 - } else { - 0.0 - } - }, - height: { - let auto_margin_count = margin.top.is_none() as u8 + margin.bottom.is_none() as u8; - if auto_margin_count == 2 - && (style_size.height.is_none() - || style_size.height.unwrap() >= free_space.height) - { - 0.0 - } else if auto_margin_count > 0 { - free_space.height / auto_margin_count as f32 - } else { - 0.0 - } - }, - }; - - taffy::Rect { - left: margin.left.map(|_| 0.0).unwrap_or(auto_margin_size.width), - right: margin.right.map(|_| 0.0).unwrap_or(auto_margin_size.width), - top: margin.top.map(|_| 0.0).unwrap_or(auto_margin_size.height), - bottom: margin - .bottom - .map(|_| 0.0) - .unwrap_or(auto_margin_size.height), - } - }; - - let resolved_margin = taffy::Rect { - left: margin.left.unwrap_or(auto_margin.left), - right: margin.right.unwrap_or(auto_margin.right), - top: margin.top.unwrap_or(auto_margin.top), - bottom: margin.bottom.unwrap_or(auto_margin.bottom), - }; - - let x_offset = match (left, right) { - (Some(left), Some(right)) => { - if direction == Direction::Rtl { - area_size.width - final_size.width - right - resolved_margin.right - } else { - left + resolved_margin.left - } - } - (Some(left), None) => left + resolved_margin.left, - (None, Some(right)) => area_size.width - final_size.width - right - resolved_margin.right, - (None, None) => { - if direction == Direction::Rtl && is_inline_level { - static_position.x - final_size.width - resolved_margin.right - area_offset.x - } else { - static_position.x + resolved_margin.left - area_offset.x - } - } - }; - let location = Point { - x: x_offset + area_offset.x, - y: top - .map(|top| top + resolved_margin.top) - .or(bottom.map(|bottom| { - area_size.height - final_size.height - bottom - resolved_margin.bottom - })) - .maybe_add(area_offset.y) - .unwrap_or(static_position.y + resolved_margin.top), - }; - // Note: axis intentionally switched here as scrollbars take up space in the opposite axis - // to the axis in which scrolling is enabled. - let scrollbar_size = Size { - width: if overflow.y == Overflow::Scroll { - scrollbar_width - } else { - 0.0 - }, - height: if overflow.x == Overflow::Scroll { - scrollbar_width - } else { - 0.0 - }, - }; - - tree.set_unrounded_layout( - node_id, - &taffy::Layout { - order: 0, // TODO: order - size: final_size, - scrollable_overflow_rect: layout_output.scrollable_overflow_rect, - scrollbar_size, - location, - padding, - border, - margin: resolved_margin, - }, - ); -} diff --git a/packages/blitz-dom/src/layout/mod.rs b/packages/blitz-dom/src/layout/mod.rs index 4b8e65964..5480eb391 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -14,9 +14,10 @@ use style::values::computed::CSSPixelLength; use style::values::computed::length_percentage::CalcLengthPercentage; use stylo_taffy::TaffyStyloStyle; use taffy::{ - BlockContext, CoreStyle as _, FlexDirection, LayoutPartialTree, NodeId, ResolveOrZero, - RoundTree, TraversePartialTree, TraverseTree, compute_block_layout, compute_cached_layout, - compute_flexbox_layout, compute_grid_layout, compute_leaf_layout, prelude::*, + BlockContext, CoreStyle as _, DetailedLayoutInfo, FlexDirection, LayoutContainingBlock, + LayoutPartialTree, NodeId, ResolveOrZero, RoundTree, TraversePartialTree, TraverseTree, + compute_block_layout, compute_cached_layout, compute_flexbox_layout, compute_grid_layout, + compute_leaf_layout, compute_oof_layout, prelude::*, }; pub(crate) mod construct; @@ -430,11 +431,64 @@ impl LayoutPartialTree for BaseDocument { inputs: taffy::LayoutInput, ) -> taffy::LayoutOutput { compute_cached_layout(self, node_id, inputs, |tree, node_id, inputs| { - tree.compute_child_layout_internal(node_id, inputs, None) + let mut output = tree.compute_child_layout_internal(node_id, inputs, None); + if inputs.run_mode == taffy::RunMode::PerformLayout { + compute_oof_layout(tree, node_id, &mut output); + } + output }) } } +impl LayoutContainingBlock for BaseDocument { + type OofItemStyle<'a> + = TaffyStyloStyle> + where + Self: 'a; + + fn get_oof_item_style(&self, node_id: NodeId) -> Self::OofItemStyle<'_> { + self.node_from_id(node_id).layout_style() + } + + fn set_hoisted_children(&mut self, node_id: NodeId, hoisted: &[NodeId]) { + let containing_block = dom_node_id(node_id); + let node = self.node_from_id(node_id); + let mut vec = node.hoisted_children.borrow_mut(); + vec.clear(); + vec.extend(hoisted.iter().copied().map(dom_node_id)); + drop(vec); + for &hoisted_id in hoisted { + self.node_from_id(hoisted_id) + .layout_parent + .set(Some(containing_block)); + } + } + + fn add_hoisted_children(&mut self, node_id: NodeId, hoisted: &[NodeId]) { + let containing_block = dom_node_id(node_id); + let node = self.node_from_id(node_id); + let mut vec = node.hoisted_children.borrow_mut(); + for id in hoisted.iter().copied().map(dom_node_id) { + if !vec.contains(&id) { + vec.push(id); + } + } + drop(vec); + for &hoisted_id in hoisted { + self.node_from_id(hoisted_id) + .layout_parent + .set(Some(containing_block)); + } + } + + fn get_detailed_layout_info(&self, node_id: NodeId) -> &DetailedLayoutInfo { + self.node_from_id(node_id) + .element_data() + .map(|element| &element.detailed_layout_info) + .unwrap_or(&DetailedLayoutInfo::None) + } +} + impl taffy::CacheTree for BaseDocument { #[inline] fn cache_get( @@ -493,7 +547,11 @@ impl taffy::LayoutBlockContainer for BaseDocument { block_ctx: Option<&mut BlockContext<'_>>, ) -> taffy::LayoutOutput { compute_cached_layout(self, node_id, inputs, |tree, node_id, inputs| { - tree.compute_child_layout_internal(node_id, inputs, block_ctx) + let mut output = tree.compute_child_layout_internal(node_id, inputs, block_ctx); + if inputs.run_mode == taffy::RunMode::PerformLayout { + compute_oof_layout(tree, node_id, &mut output); + } + output }) } } @@ -544,7 +602,7 @@ impl taffy::LayoutGridContainer for BaseDocument { ) { let node = self.node_from_id_mut(node_id); if let Some(element) = node.element_data_mut() { - element.detailed_grid_info = Some(Box::new(detailed_grid_info)); + element.detailed_layout_info = DetailedLayoutInfo::Grid(Box::new(detailed_grid_info)); } } } @@ -557,6 +615,18 @@ impl RoundTree for BaseDocument { fn set_final_layout(&mut self, node_id: NodeId, layout: &Layout) { *self.node_from_id_mut(node_id).final_layout_mut() = *layout; } + + fn is_hoisted(&self, node_id: NodeId) -> bool { + self.node_from_id(node_id).is_hoisted() + } + + fn hoisted_child_count(&self, node_id: NodeId) -> usize { + self.node_from_id(node_id).hoisted_children.borrow().len() + } + + fn get_hoisted_child_id(&self, node_id: NodeId, index: usize) -> NodeId { + taffy_node_id(self.node_from_id(node_id).hoisted_children.borrow()[index]) + } } impl PrintTree for BaseDocument { diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index ccce2e186..501da7b6c 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -105,9 +105,10 @@ pub struct ElementData { pub before: Option, pub after: Option, - /// Detailed grid track sizing information from the most recent layout - /// (grid containers only). Used by devtools grid inspection. - pub detailed_grid_info: Option>>, + /// Detailed layout information from the most recent layout (currently + /// grid track sizing information for grid containers only). Used by + /// devtools grid inspection and out-of-flow grid-area positioning. + pub detailed_layout_info: taffy::DetailedLayoutInfo, // Taffy layout data: pub display_constructed_as: StyloDisplay, @@ -314,7 +315,7 @@ impl Clone for ElementData { damaged_descendants: AtomicBool::new(true), before: None, after: None, - detailed_grid_info: None, + detailed_layout_info: taffy::DetailedLayoutInfo::None, display_constructed_as: StyloDisplay::Block, layout_data: None, transform: None, @@ -421,7 +422,7 @@ impl ElementData { damaged_descendants: AtomicBool::new(true), before: None, after: None, - detailed_grid_info: None, + detailed_layout_info: taffy::DetailedLayoutInfo::None, display_constructed_as: StyloDisplay::Block, layout_data: None, transform: None, diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index 3717cba6e..aed39d0f4 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -98,6 +98,10 @@ pub struct Node { pub layout_parent: Cell>, /// A separate child list that includes anonymous collections of inline elements pub layout_children: RefCell>>, + /// Out-of-flow (absolutely/fixed positioned) boxes for which this node is the + /// containing block. Recorded by Taffy's out-of-flow positioning pass. The + /// `Layout.location` of these boxes is relative to this node's border box. + pub hoisted_children: RefCell>, /// Anonymous block boxes created for this node during layout construction. /// /// Anonymous blocks live only in the slab (they are not part of the DOM @@ -385,6 +389,7 @@ impl Node { children: ThinVec::new(), layout_parent: Cell::new(None), layout_children: RefCell::new(None), + hoisted_children: RefCell::new(ThinVec::new()), anonymous_blocks: ThinVec::new(), paint_children: RefCell::new(None), stacking_context: None, @@ -1201,6 +1206,25 @@ impl Node { .unwrap_or(taffy::Display::Block) } + /// The node's `position` as a [`taffy::Position`]. Returns [`taffy::Position::Static`] + /// for nodes without computed styles (e.g. text nodes). + pub fn taffy_position(&self) -> taffy::Position { + self.primary_styles() + .map(|s| stylo_taffy::convert::position(s.get_box().position)) + .unwrap_or(taffy::Position::Static) + } + + /// Whether the node is an out-of-flow box that Taffy positions from its containing + /// block's hoisted child list rather than from its parent (`display: none` boxes + /// generate no box and are never hoisted). + pub fn is_hoisted(&self) -> bool { + self.primary_styles().is_some_and(|s| { + let box_style = s.get_box(); + stylo_taffy::convert::position(box_style.position).is_out_of_flow() + && stylo_taffy::convert::display(box_style.display) != taffy::Display::None + }) + } + pub fn text_content(&self) -> String { let mut out = String::new(); self.write_text_content(&mut out); @@ -1268,16 +1292,57 @@ impl Node { return true; } + if self.applies_atomic_paint_effect() { + return true; + } + // TODO: mix-blend-mode - // TODO: filter - // TODO: clip-path - // TODO: mask // TODO: isolation // TODO: contain false } + /// Whether this node's styles apply an atomic paint effect (opacity, filter, + /// clip-path, mask) to its subtree. Such effects apply to out-of-flow descendants + /// even when this node is not their containing block. + pub(crate) fn applies_atomic_paint_effect(&self) -> bool { + use style::values::computed::basic_shape::ClipPath; + use style::values::generics::image::GenericImage; + + let Some(style) = self.primary_styles() else { + return false; + }; + + if style.clone_opacity() != 1.0 { + return true; + } + + let effects = style.get_effects(); + if !effects.filter.0.is_empty() { + return true; + } + + if !matches!(style.clone_clip_path(), ClipPath::None) { + return true; + } + + style + .get_svg() + .mask_image + .0 + .iter() + .any(|image| !matches!(image, GenericImage::None)) + } + + /// Which out-of-flow positions this node's styles establish a containing block for + /// (see [`stylo_taffy::convert::containing_block_claims`]). Nodes without styles claim nothing. + pub(crate) fn containing_block_claims(&self) -> taffy::ContainingBlockClaims { + self.primary_styles() + .map(|style| stylo_taffy::convert::containing_block_claims(&style)) + .unwrap_or(taffy::ContainingBlockClaims::NONE) + } + /// Takes an (x, y) position (relative to the *parent's* top-left corner) and returns: /// - None if the position is outside of this node's bounds /// - Some(HitResult) if the position is within the node but doesn't match any children @@ -1287,7 +1352,7 @@ impl Node { /// TODO: z-index /// (If multiple children are positioned at the position then a random one will be recursed into) pub fn hit(&self, x: f32, y: f32, scale: f64) -> Option { - self.hit_inner(x, y, scale, &mut None) + self.hit_inner(x, y, scale, &mut None, taffy::Point::ZERO) } /// [`hit`](Self::hit), also resolving the innermost overlay scrollbar @@ -1300,6 +1365,11 @@ impl Node { y: f32, scale: f64, scrollbar: &mut Option, + // The viewport scroll offset, passed in by the document for the root + // element only (the root element scrolls the viewport, so its scroll + // offset is stored on the document): fixed-position children of the + // root must not move with it. Zero for all other nodes. + viewport_scroll: taffy::Point, ) -> Option { use style::computed_values::pointer_events::T as PointerEvents; use style::computed_values::visibility::T as Visibility; @@ -1376,11 +1446,11 @@ impl Node { *scrollbar = Some(sb); } + let content_box_offset = taffy::Point { + x: self.final_layout().padding.left + self.final_layout().border.left, + y: self.final_layout().padding.top + self.final_layout().border.top, + }; if self.flags.is_inline_root() { - let content_box_offset = taffy::Point { - x: self.final_layout().padding.left + self.final_layout().border.left, - y: self.final_layout().padding.top + self.final_layout().border.top, - }; x -= content_box_offset.x; y -= content_box_offset.y; } @@ -1391,10 +1461,13 @@ impl Node { for hoisted_child in hoisted.pos_z_hoisted_children().rev() { let x = x - hoisted_child.position.x; let y = y - hoisted_child.position.y; - if let Some(hit) = self - .with(hoisted_child.node_id) - .hit_inner(x, y, scale, scrollbar) - { + if let Some(hit) = self.with(hoisted_child.node_id).hit_inner( + x, + y, + scale, + scrollbar, + taffy::Point::ZERO, + ) { return Some(hit); } } @@ -1403,7 +1476,26 @@ impl Node { // Call `.hit()` on each child in turn. If any return `Some` then return that value. Else return `Some(self.id). for child_id in self.paint_children.borrow().iter().flatten().rev() { - if let Some(hit) = self.with(*child_id).hit_inner(x, y, scale, scrollbar) { + let child = self.with(*child_id); + let child_position = child.taffy_position(); + let mut child_x = x; + let mut child_y = y; + if child_position.is_out_of_flow() { + // Out-of-flow children's layout location is relative to this node's + // border box, so undo the inline-root content-box offset applied above + if self.flags.is_inline_root() { + child_x += content_box_offset.x; + child_y += content_box_offset.y; + } + // Fixed-position children do not scroll with their containing block + if child_position == taffy::Position::Fixed { + child_x -= self.scroll_offset().x as f32 + viewport_scroll.x; + child_y -= self.scroll_offset().y as f32 + viewport_scroll.y; + } + } + if let Some(hit) = + child.hit_inner(child_x, child_y, scale, scrollbar, taffy::Point::ZERO) + { return Some(hit); } } @@ -1414,10 +1506,13 @@ impl Node { for hoisted_child in hoisted.neg_z_hoisted_children().rev() { let x = x - hoisted_child.position.x; let y = y - hoisted_child.position.y; - if let Some(hit) = self - .with(hoisted_child.node_id) - .hit_inner(x, y, scale, scrollbar) - { + if let Some(hit) = self.with(hoisted_child.node_id).hit_inner( + x, + y, + scale, + scrollbar, + taffy::Point::ZERO, + ) { return Some(hit); } } diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index c89b73609..8ca65ec37 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -108,6 +108,10 @@ impl BaseDocument { self.resolve_layout(); timer.record_time("layout"); + // Attach out-of-flow boxes to their containing block for painting and + // hit-testing, and repoint their layout_parent at the containing block + self.attach_hoisted_children(); + // Resolve transforms self.resolve_transforms(root_node_id); timer.record_time("transform"); @@ -193,16 +197,32 @@ impl BaseDocument { if let Some(ref children) = layout_children { for &child_id in children { + // Out-of-flow children are laid out relative to their containing + // block, not their DOM parent: they are visited (and their overflow + // accounted for) via the containing block's hoisted list below. + if self.nodes[child_id].is_hoisted() { + continue; + } let child_rect_in_self = self.resolve_transforms(child_id); overflow = overflow.union(child_rect_in_self); } } - if let Some(before) = self.nodes[node_id].before() { - let child_rect_in_self = self.resolve_transforms(before); + let hoisted_children = + std::mem::take(&mut *self.nodes[node_id].hoisted_children.borrow_mut()); + for &child_id in &hoisted_children { + if !self.nodes.contains_key(child_id) { + continue; + } + let child_rect_in_self = self.resolve_transforms(child_id); overflow = overflow.union(child_rect_in_self); } - if let Some(after) = self.nodes[node_id].after() { - let child_rect_in_self = self.resolve_transforms(after); + *self.nodes[node_id].hoisted_children.borrow_mut() = hoisted_children; + for pseudo in [self.nodes[node_id].before(), self.nodes[node_id].after()] { + let Some(pseudo) = pseudo else { continue }; + if self.nodes[pseudo].is_hoisted() { + continue; + } + let child_rect_in_self = self.resolve_transforms(pseudo); overflow = overflow.union(child_rect_in_self); } @@ -221,6 +241,227 @@ impl BaseDocument { full.transform_rect_bbox(overflow) } + /// The innermost DOM ancestor of `node_id`, strictly below `cb_id`, that applies + /// an atomic paint effect (opacity, clip-path, mask) to its subtree. + fn innermost_paint_effect_ancestor(&self, node_id: NodeId, cb_id: NodeId) -> Option { + let mut ancestor = self.nodes[node_id].parent; + while let Some(ancestor_id) = ancestor { + if ancestor_id == cb_id { + return None; + } + let node = &self.nodes[ancestor_id]; + if node.applies_atomic_paint_effect() { + return Some(ancestor_id); + } + ancestor = node.parent; + } + None + } + + /// Whether `a` precedes `b` in DOM pre-order (each node's ancestor path is + /// compared by child index at the first point of divergence; an ancestor + /// precedes its descendants). + fn is_before_in_tree_order(&self, a: NodeId, b: NodeId) -> bool { + if a == b { + return false; + } + let path = |mut id: NodeId| { + let mut path = vec![id]; + while let Some(parent) = self.nodes[id].parent { + path.push(parent); + id = parent; + } + path.reverse(); + path + }; + let path_a = path(a); + let path_b = path(b); + for (i, (&na, &nb)) in path_a.iter().zip(path_b.iter()).enumerate() { + if na != nb { + if i == 0 { + // Disjoint trees; fall back to a stable arbitrary order + return na < nb; + } + let parent = &self.nodes[path_a[i - 1]]; + return parent.index_of_child(na) < parent.index_of_child(nb); + } + } + // One is an ancestor of the other: the shorter path comes first + path_a.len() < path_b.len() + } + + /// Attach out-of-flow (absolutely/fixed positioned) boxes to their containing + /// block, as recorded by Taffy's out-of-flow positioning pass in each node's + /// `hoisted_children` list: + /// + /// - repoints each hoisted box's `layout_parent` at its containing block so + /// that coordinate accumulation (e.g. `absolute_position`) follows the + /// containing block chain that its `Layout.location` is relative to; and + /// - appends each hoisted box to its containing block's `paint_children` so + /// that paint and hit-testing visit it with the correct coordinates + /// (out-of-flow boxes are skipped in their DOM parent's paint list). + fn attach_hoisted_children(&mut self) { + let mut modified_stacking_roots: Vec = Vec::new(); + let mut pairs: Vec<(NodeId, Vec)> = Vec::new(); + for (cb_id, node) in self.nodes.iter() { + let hoisted = node.hoisted_children.borrow(); + if !hoisted.is_empty() { + pairs.push((cb_id, hoisted.iter().copied().collect())); + } + } + + for (cb_id, hoisted) in pairs { + // Nodes can be removed from the slab between layout passes; drop stale ids + let mut valid: Vec = hoisted + .into_iter() + .filter(|id| self.nodes.contains_key(*id)) + .collect(); + // Sort by z-index (stable sort preserves document order within a z-index) + valid.sort_by_key(|id| self.nodes[*id].z_index()); + + for &child_id in &valid { + self.nodes[child_id].layout_parent.set(Some(cb_id)); + } + + // Boxes whose containing block is their direct layout parent were kept + // in its paint tree by `flush_styles_to_layout` (paint_children or the + // stacking context, depending on z-index) in tree order, so only append + // the ones hoisted past their DOM parent. + let direct_layout_children: Vec = self.nodes[cb_id] + .layout_children + .borrow() + .as_ref() + .map(|c| c.to_vec()) + .unwrap_or_default(); + + let cb_is_flex_or_grid = matches!( + self.nodes[cb_id].taffy_display(), + taffy::Display::Flex | taffy::Display::Grid + ); + let mut z_indexed: Vec = Vec::new(); + let mut effect_attached: Vec<(NodeId, NodeId)> = Vec::new(); + { + let mut paint_children = self.nodes[cb_id].paint_children.borrow_mut(); + let paint_children = paint_children.get_or_insert_with(thin_vec::ThinVec::new); + for &child_id in &valid { + if self.nodes[child_id].z_index() != 0 { + z_indexed.push(child_id); + continue; + } + if direct_layout_children.contains(&child_id) { + continue; + } + // Atomic paint effects (opacity, clip-path, mask) on a DOM ancestor + // apply to out-of-flow descendants even when the ancestor is not + // their containing block, so paint the box inside the innermost such + // ancestor's effect layers rather than directly under the containing + // block. + if let Some(effect_ancestor) = + self.innermost_paint_effect_ancestor(child_id, cb_id) + { + effect_attached.push((child_id, effect_ancestor)); + continue; + } + if !paint_children.contains(&child_id) { + // Splice into the containing block's paint list at the + // hoisted box's (paint level, tree order) position: z-index: + // auto positioned boxes paint in tree order among positioned + // siblings (CSS 2.1 Appendix E step 8), not last. + let key = crate::layout::damage::node_to_paint_order( + &self.nodes[child_id], + cb_is_flex_or_grid, + ); + let insert_at = paint_children + .iter() + .position(|&existing| { + let existing_key = crate::layout::damage::node_to_paint_order( + &self.nodes[existing], + cb_is_flex_or_grid, + ); + existing_key > key + || (existing_key == key + && self.is_before_in_tree_order(child_id, existing)) + }) + .unwrap_or(paint_children.len()); + paint_children.insert(insert_at, child_id); + } + } + } + + // Boxes painted inside an intermediate effect ancestor keep their + // containing-block-relative layout location, so record the offset of the + // effect ancestor relative to the containing block to compensate. + for (child_id, effect_ancestor) in effect_attached { + let mut position = taffy::Point::::ZERO; + let mut ancestor = effect_ancestor; + while ancestor != cb_id { + let node = &self.nodes[ancestor]; + let location = node.final_layout().location; + let scroll = *node.scroll_offset(); + position.x -= location.x - scroll.x as f32; + position.y -= location.y - scroll.y as f32; + let Some(parent) = node.layout_parent.get() else { + break; + }; + ancestor = parent; + } + if let Some(sc) = self.nodes[effect_ancestor].stacking_context.as_mut() { + if !sc.children.iter().any(|c| c.node_id == child_id) { + sc.children.push(crate::layout::damage::HoistedPaintChild { + node_id: child_id, + z_index: 0, + position, + }); + modified_stacking_roots.push(effect_ancestor); + } + } + } + + // Children with a z-index belong to the nearest stacking context at + // or above the containing block: hoist them there (matching + // `flush_styles_to_layout`'s z-index hoisting), with their position + // recorded relative to the stacking context root. + for child_id in z_indexed { + let mut position = taffy::Point::::ZERO; + let mut sc_root = cb_id; + while self.nodes[sc_root].stacking_context.is_none() { + let node = &self.nodes[sc_root]; + let location = node.final_layout().location; + let scroll = *node.scroll_offset(); + position.x += location.x - scroll.x as f32; + position.y += location.y - scroll.y as f32; + let Some(parent) = node.layout_parent.get() else { + break; + }; + sc_root = parent; + } + + let z_index = self.nodes[child_id].z_index(); + if let Some(sc) = self.nodes[sc_root].stacking_context.as_mut() { + if !sc.children.iter().any(|c| c.node_id == child_id) { + sc.children.push(crate::layout::damage::HoistedPaintChild { + node_id: child_id, + z_index, + position, + }); + modified_stacking_roots.push(sc_root); + } + } + } + } + + modified_stacking_roots.sort_unstable(); + modified_stacking_roots.dedup(); + for sc_root in modified_stacking_roots { + let mut sc = self.nodes[sc_root].stacking_context.take(); + if let Some(sc) = sc.as_mut() { + sc.sort(); + sc.compute_content_size(self); + } + self.nodes[sc_root].stacking_context = sc; + } + } + /// Ensure that the layout_children field is populated for all nodes pub fn resolve_layout_children(&mut self) { resolve_layout_children_recursive(self, self.root_node().id); diff --git a/packages/blitz-dom/src/resolved_style.rs b/packages/blitz-dom/src/resolved_style.rs index f39ec75f8..d37421f85 100644 --- a/packages/blitz-dom/src/resolved_style.rs +++ b/packages/blitz-dom/src/resolved_style.rs @@ -288,9 +288,12 @@ impl BaseDocument { "grid-template-columns" | "grid-template-rows" if display.inside() == DisplayInside::Grid => { - if let Some(info) = node - .element_data() - .and_then(|data| data.detailed_grid_info.as_ref()) + if let Some(info) = + node.element_data() + .and_then(|data| match &data.detailed_layout_info { + taffy::DetailedLayoutInfo::Grid(info) => Some(info), + _ => None, + }) { return if property_name == "grid-template-columns" { info.grid_template_columns() diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index f819b2a43..40967aac7 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -988,7 +988,26 @@ impl ElementCx<'_, '_> { // Regular children if let Some(children) = &*self.node.paint_children.borrow() { for child_id in children { - self.render_node(scene, *child_id, parent_style_transform, clip_rect); + // Fixed-position children do not scroll with their containing block + // (their layout location is relative to its unscrolled border box), + // so cancel out the scroll offset applied to the transform above. + let child = &self.context.dom.as_ref().tree()[*child_id]; + let child_transform = if child.taffy_position() == taffy::Position::Fixed { + // The root element's scroll is the viewport scroll (applied in + // `paint_scene`), not the node's own scroll offset. + let scroll = if Some(self.node.id) == self.context.root_element_id { + self.context.dom.as_ref().viewport_scroll() + } else { + *self.node.scroll_offset() + }; + parent_style_transform.pre_translate(kurbo::Vec2 { + x: scroll.x * self.scale, + y: scroll.y * self.scale, + }) + } else { + parent_style_transform + }; + self.render_node(scene, *child_id, child_transform, clip_rect); } } diff --git a/packages/stylo_taffy/src/convert.rs b/packages/stylo_taffy/src/convert.rs index a159337ac..17d56ec16 100644 --- a/packages/stylo_taffy/src/convert.rs +++ b/packages/stylo_taffy/src/convert.rs @@ -249,17 +249,85 @@ pub fn box_sizing(input: stylo::BoxSizing) -> taffy::BoxSizing { #[inline] pub fn position(input: stylo::Position) -> taffy::Position { match input { - // TODO: support position:static + stylo::Position::Static => taffy::Position::Static, stylo::Position::Relative => taffy::Position::Relative, - stylo::Position::Static => taffy::Position::Relative, - - // TODO: support position:fixed and sticky stylo::Position::Absolute => taffy::Position::Absolute, - stylo::Position::Fixed => taffy::Position::Absolute, + stylo::Position::Fixed => taffy::Position::Fixed, + // TODO: support position:sticky stylo::Position::Sticky => taffy::Position::Relative, } } +/// Whether a style establishes a containing block for `position: fixed` (and therefore also +/// `position: absolute`) descendants, independently of its `position` value. +/// +/// +pub fn establishes_fixed_containing_block(style: &stylo::ComputedValues) -> bool { + use style::values::computed::{Perspective, Rotate, Scale, Translate}; + use style::values::specified::box_::{Contain, ContainerType, WillChangeBits}; + + let box_style = style.get_box(); + if !box_style.transform.0.is_empty() + || !matches!(box_style.translate, Translate::None) + || !matches!(box_style.rotate, Rotate::None) + || !matches!(box_style.scale, Scale::None) + || !matches!(box_style.perspective, Perspective::None) + { + return true; + } + if box_style.will_change.bits.intersects( + WillChangeBits::TRANSFORM + | WillChangeBits::PERSPECTIVE + | WillChangeBits::FIXPOS_CB_NON_SVG + | WillChangeBits::CONTAIN, + ) { + return true; + } + if box_style + .contain + .intersects(Contain::LAYOUT | Contain::PAINT) + { + return true; + } + if box_style + .container_type + .intersects(ContainerType::SIZE | ContainerType::INLINE_SIZE) + { + return true; + } + + let effects = style.get_effects(); + !effects.filter.0.is_empty() || !effects.backdrop_filter.0.is_empty() +} + +/// Whether a style establishes a containing block for `position: absolute` descendants even +/// when it is not positioned (e.g. `will-change: position`). +/// +/// +pub fn establishes_absolute_containing_block(style: &stylo::ComputedValues) -> bool { + use style::values::specified::box_::WillChangeBits; + + style + .get_box() + .will_change + .bits + .intersects(WillChangeBits::POSITION) +} + +/// Which out-of-flow positions a style establishes a containing block for. +/// +/// Positioned (non-`static`) elements are containing blocks for `absolute` boxes; elements that +/// establish a fixed containing block (transforms, filters, `will-change`, `contain`, ...) are +/// containing blocks for both `absolute` and `fixed` boxes. +pub fn containing_block_claims(style: &stylo::ComputedValues) -> taffy::ContainingBlockClaims { + let is_positioned = style.get_box().position != stylo::Position::Static; + let fixed = establishes_fixed_containing_block(style); + taffy::ContainingBlockClaims { + absolute: is_positioned || fixed || establishes_absolute_containing_block(style), + fixed, + } +} + #[inline] pub fn overflow(input: stylo::Overflow) -> taffy::Overflow { match input { diff --git a/packages/stylo_taffy/src/wrapper.rs b/packages/stylo_taffy/src/wrapper.rs index 872a9e34d..bf0b92a35 100644 --- a/packages/stylo_taffy/src/wrapper.rs +++ b/packages/stylo_taffy/src/wrapper.rs @@ -112,6 +112,11 @@ impl> taffy::CoreStyle for TaffyStyloStyle convert::position(self.style.get_box().position) } + #[inline] + fn is_containing_block(&self) -> taffy::ContainingBlockClaims { + convert::containing_block_claims(&self.style) + } + #[inline] fn inset(&self) -> taffy::Rect { let position_styles = self.style.get_position(); @@ -627,3 +632,23 @@ impl> taffy::GridItemStyle for TaffyStyloStyle ) } } + +impl> taffy::OofItemStyle for TaffyStyloStyle { + #[inline] + fn grid_row(&self) -> taffy::Line> { + let position_styles = self.style.get_position(); + taffy::Line { + start: convert::grid_line(&position_styles.grid_row_start), + end: convert::grid_line(&position_styles.grid_row_end), + } + } + + #[inline] + fn grid_column(&self) -> taffy::Line> { + let position_styles = self.style.get_position(); + taffy::Line { + start: convert::grid_line(&position_styles.grid_column_start), + end: convert::grid_line(&position_styles.grid_column_end), + } + } +} diff --git a/tests/blitz-tests/tests/measure_clobber.rs b/tests/blitz-tests/tests/measure_clobber.rs new file mode 100644 index 000000000..b20b6db4f --- /dev/null +++ b/tests/blitz-tests/tests/measure_clobber.rs @@ -0,0 +1,77 @@ +//! Regression test: a measure (ComputeSize) pass must not overwrite the stored +//! layouts of a node's children. If it does, a later cache hit on the parent's +//! PerformLayout leaves the children with measure-time geometry (observed as +//! the README image becoming too wide on github.com after a relayout). + +use blitz_test_harness::Harness; +use markup5ever::{QualName, local_name, ns}; +use taffy::{AvailableSpace, LayoutPartialTree as _, Size}; + +fn style_attr() -> QualName { + QualName::new(None, ns!(), local_name!("style")) +} + +#[test] +fn measure_pass_does_not_clobber_inline_child_layout() { + let html = r#" + + + +
+

+ +

+ + + "#; + let mut harness = Harness::from_html(html); + + let width_before = harness.layout_rect("img").width; + assert_eq!(width_before, 800.0); + + // Measure the paragraph at a different width, as e.g. block or flexbox + // intrinsic-height sizing does when a distant ancestor relayouts. + let p_id = harness.query("#target").unwrap(); + let mut doc = harness.base_mut(); + doc.compute_child_layout( + blitz_dom::taffy_node_id(p_id), + taffy::LayoutInput { + run_mode: taffy::RunMode::ComputeSize, + sizing_mode: taffy::SizingMode::InherentSize, + axis: taffy::RequestedAxis::Both, + known_dimensions: Size { + width: Some(500.0), + height: None, + }, + known_dimensions_are_definite: taffy::geometry::Size { + width: true, + height: false, + }, + parent_size: Size { + width: Some(500.0), + height: None, + }, + available_space: Size { + width: AvailableSpace::Definite(500.0), + height: AvailableSpace::MaxContent, + }, + vertical_margins_are_collapsible: taffy::Line::FALSE, + }, + ); + drop(doc); + + // Trigger a relayout in which the paragraph's PerformLayout is a cache hit, + // so its children keep whatever geometry is stored for them. + let other_id = harness.query("#other").unwrap(); + harness + .base_mut() + .mutate() + .set_attribute(other_id, style_attr(), "height: 20px"); + harness.pump(); + + let width_after = harness.layout_rect("img").width; + assert_eq!( + width_after, 800.0, + "measure pass must not modify stored child layouts" + ); +} diff --git a/tests/blitz-tests/tests/oof_dynamic_cb.rs b/tests/blitz-tests/tests/oof_dynamic_cb.rs new file mode 100644 index 000000000..f40575ddf --- /dev/null +++ b/tests/blitz-tests/tests/oof_dynamic_cb.rs @@ -0,0 +1,290 @@ +//! Dynamic containing-block changes: when a style change adds or removes a +//! containing-block-establishing property (transform, will-change, filter, +//! contain, position) on an ancestor, hoisted `position: absolute` / `fixed` +//! descendants must move to their new containing block on the next relayout, +//! even with incremental layout's hot caches. +//! +//! Rust ports of the script-driven WPT tests +//! `css/css-transforms/transform-containing-block-dynamic-1b.html`, +//! `css/filter-effects/filter-cb-dynamic-1b.html`, +//! `css/css-will-change/will-change-abspos-cb-dynamic-001.html` and +//! `css/css-contain/contain-layout-020.html`, which Blitz's WPT runner cannot +//! run (they require script). + +use blitz_test_harness::Harness; +use blitz_traits::node_id::NodeId; +use markup5ever::{QualName, local_name, ns}; + +fn style_attr() -> QualName { + QualName::new(None, ns!(), local_name!("style")) +} + +/// A fixed box nested inside `#anc` (offset 50,50 from the page origin). +/// Without a CB-establishing property on `#anc` the fixed box is positioned +/// against the viewport at (10, 10); with one it is positioned against +/// `#anc` at (60, 60) in page coordinates. +fn fixed_page(anc_style: &str) -> Harness { + let html = format!( + "\ +
\ +
\ +
\ +
" + ); + Harness::from_html(&html) +} + +fn set_style(harness: &mut Harness, node: NodeId, style: &str) { + harness + .base_mut() + .mutate() + .set_attribute(node, style_attr(), style); + harness.pump(); +} + +const ANC_BASE: &str = "margin: 50px; width: 300px; height: 300px;"; + +fn assert_fixed_cb_toggle(cb_prop: &str) { + // Add the CB-establishing property dynamically + let mut harness = fixed_page(""); + let anc = harness.node("#anc"); + assert_eq!( + harness.layout_rect("#target").x, + 10.0, + "initial (viewport CB)" + ); + assert_eq!( + harness.layout_rect("#target").y, + 10.0, + "initial (viewport CB)" + ); + + set_style(&mut harness, anc, &format!("{ANC_BASE} {cb_prop}")); + assert_eq!( + harness.layout_rect("#target").x, + 60.0, + "after adding `{cb_prop}`" + ); + assert_eq!( + harness.layout_rect("#target").y, + 60.0, + "after adding `{cb_prop}`" + ); + + // And remove it again + set_style(&mut harness, anc, ANC_BASE); + assert_eq!( + harness.layout_rect("#target").x, + 10.0, + "after removing `{cb_prop}`" + ); + assert_eq!( + harness.layout_rect("#target").y, + 10.0, + "after removing `{cb_prop}`" + ); +} + +#[test] +fn transform_toggles_fixed_containing_block() { + assert_fixed_cb_toggle("transform: translateX(0px)"); +} + +#[test] +fn will_change_toggles_fixed_containing_block() { + assert_fixed_cb_toggle("will-change: transform"); +} + +#[test] +fn filter_toggles_fixed_containing_block() { + assert_fixed_cb_toggle("filter: grayscale(50%)"); +} + +#[test] +fn contain_toggles_fixed_containing_block() { + assert_fixed_cb_toggle("contain: layout"); +} + +/// Toggling `position: relative` on a static intermediate ancestor moves an +/// absolutely positioned descendant between containing blocks. +#[test] +fn position_toggles_absolute_containing_block() { + let html = "\ +
\ +
\ +
\ +
"; + let mut harness = Harness::from_html(html); + let mid = harness.node("#mid"); + + // CB is #outer at (20, 30): #mid's 30px top margin collapses through #outer + assert_eq!(harness.layout_rect("#target").x, 30.0); + assert_eq!(harness.layout_rect("#target").y, 40.0); + + // Make #mid positioned: CB becomes #mid at (50, 30) + set_style( + &mut harness, + mid, + "position: relative; margin: 30px; width: 300px; height: 300px", + ); + assert_eq!(harness.layout_rect("#target").x, 60.0); + assert_eq!(harness.layout_rect("#target").y, 40.0); + + // Back to static: CB is #outer again + set_style( + &mut harness, + mid, + "margin: 30px; width: 300px; height: 300px", + ); + assert_eq!(harness.layout_rect("#target").x, 30.0); + assert_eq!(harness.layout_rect("#target").y, 40.0); +} + +/// CB changes driven purely by a restyle (`:hover`), with no DOM mutation. This +/// exercises the pure restyle-damage path (DOM mutations like `set_attribute` +/// insert full damage unconditionally, masking under-damaging bugs). +fn assert_hover_toggles_fixed_cb(cb_prop: &str) { + let html = format!( + "\ +
\ +
\ +
" + ); + let mut harness = Harness::from_html(&html); + assert_eq!( + harness.layout_rect("#target").x, + 10.0, + "initial (viewport CB)" + ); + + // Hover #anc: it now establishes the fixed containing block + harness.base_mut().set_hover_to(60.0, 60.0); + harness.pump(); + assert_eq!( + harness.layout_rect("#target").x, + 60.0, + "hovered: `{cb_prop}` makes #anc the CB" + ); + + // Unhover: back to the viewport + harness.base_mut().set_hover_to(700.0, 500.0); + harness.pump(); + assert_eq!( + harness.layout_rect("#target").x, + 10.0, + "unhovered (viewport CB)" + ); +} + +#[test] +fn hover_transform_toggles_fixed_containing_block() { + assert_hover_toggles_fixed_cb("transform: translateX(0px)"); +} + +#[test] +fn hover_will_change_toggles_fixed_containing_block() { + assert_hover_toggles_fixed_cb("will-change: transform"); +} + +#[test] +fn hover_filter_toggles_fixed_containing_block() { + assert_hover_toggles_fixed_cb("filter: grayscale(50%)"); +} + +/// Dynamically inserting and removing a hoisted box (the common fixed-position +/// popup/modal pattern). The box must be laid out via its containing block on +/// insertion and fully disappear from layout/paint/hit-test on removal. +#[test] +fn insert_and_remove_hoisted_box() { + let html = "\ +
\ + "; + let mut harness = Harness::from_html(html); + let anc = harness.node("#anc"); + + // Insert a fixed box inside #anc + let target = { + let mut doc = harness.base_mut(); + let mut mutator = doc.mutate(); + let target = mutator.create_element( + QualName::new(None, ns!(html), local_name!("div")), + vec![blitz_dom::Attribute { + name: style_attr(), + value: "position: fixed; top: 10px; left: 10px; width: 20px; height: 20px".into(), + }], + ); + mutator.append_children(anc, &[target]); + target + }; + harness.pump(); + + // Positioned against the viewport, and hit-testable there + assert_eq!(harness.layout_rect_of(target).x, 10.0); + assert_eq!(harness.layout_rect_of(target).y, 10.0); + assert_eq!(harness.hit_node(15.0, 15.0), target); + + // Remove it again: it must stop being laid out / hit-testable + harness.base_mut().mutate().remove_node(target); + harness.pump(); + assert_ne!( + harness.hit(15.0, 15.0).map(|h| h.node_id), + Some(target.into()) + ); +} + +/// Toggling `display: none` on a hoisted box's static parent must hide and +/// re-show the hoisted box. +#[test] +fn display_none_toggle_on_static_parent() { + let html = "\ +
\ +
\ +
"; + let mut harness = Harness::from_html(html); + let parent = harness.node("#parent"); + let target = harness.node("#target"); + + assert_eq!(harness.hit_node(15.0, 15.0), target); + + set_style( + &mut harness, + parent, + "display: none; width: 300px; height: 300px", + ); + assert_ne!( + harness.hit(15.0, 15.0).map(|h| h.node_id), + Some(target.into()), + "hidden with its parent" + ); + + set_style(&mut harness, parent, "width: 300px; height: 300px"); + assert_eq!( + harness.hit_node(15.0, 15.0), + target, + "re-shown with its parent" + ); + assert_eq!(harness.layout_rect("#target").x, 10.0); +} + +/// A layout change (not a CB change) inside a hoisted subtree must relayout the +/// hoisted box through its containing block. +#[test] +fn content_change_inside_hoisted_subtree() { + let html = "\ +
\ +
\ +
\ +
"; + let mut harness = Harness::from_html(html); + let inner = harness.node("#inner"); + + assert_eq!(harness.layout_rect("#target").width, 20.0); + + set_style(&mut harness, inner, "width: 50px; height: 40px"); + assert_eq!(harness.layout_rect("#target").width, 50.0); + assert_eq!(harness.layout_rect("#target").height, 40.0); +}