diff --git a/Cargo.lock b/Cargo.lock index a5b59f056..0f4b81684 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7430,8 +7430,7 @@ dependencies = [ [[package]] name = "taffy" version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "639627c87f43b9181c811f40a6296409e093a17bc761214cba3c15df74f86b99" +source = "git+https://github.com/DioxusLabs/taffy?rev=4da301158398d53ab898eb90f97b30bbaace6b67#4da301158398d53ab898eb90f97b30bbaace6b67" dependencies = [ "arrayvec", "serde", diff --git a/Cargo.toml b/Cargo.toml index 619da106f..130cbefd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,7 +96,7 @@ dioxus-cli-config = { version = "0.7.3" } dioxus-core-macro = { version = "0.7.3" } # Taffy + Parley + Fontations -taffy = { version = "0.14.0", default-features = false, features = [ +taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "4da301158398d53ab898eb90f97b30bbaace6b67", default-features = false, features = [ "std", "flexbox", "grid", diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 3f5d79184..6c31b90fe 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1784,9 +1784,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..56203aada 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -1,14 +1,12 @@ -use blitz_traits::node_id::NodeId; -use std::ops::Range; - use crate::Node; use crate::net::ResourceHandler; use crate::node::NodeFlags; use crate::{ BaseDocument, net::ImageHandler, node::ImageResourceData, node::Status, util::ImageLayerKind, }; +use blitz_traits::node_id::NodeId; +use kurbo::Rect; use style::properties::ComputedValues; -use style::properties::generated::longhands::position::computed_value::T as Position; use style::selector_parser::RestyleDamage; use style::url::ComputedUrl; use style::values::computed::Float; @@ -16,7 +14,6 @@ use style::values::generics::image::Image as StyloImage; use style::values::specified::align::AlignFlags; use style::values::specified::box_::DisplayInside; use style::values::specified::box_::DisplayOutside; -use taffy::Rect; use thin_vec::ThinVec; pub(crate) const CONSTRUCT_BOX: RestyleDamage = @@ -47,6 +44,11 @@ impl BaseDocument { return RestyleDamage::empty(); }; damage |= damage_from_parent; + if damage.contains(RestyleDamage::REBUILD_STACKING_CONTEXT) + || damage.intersects(CONSTRUCT_BOX | CONSTRUCT_FC | CONSTRUCT_DESCENDENT) + { + self.nodes[node_id].stacking_dirty_self.set(true); + } // Skip subtrees which contain no damage. Anonymous nodes are never // skipped themselves because damage marking walks the DOM parent @@ -166,6 +168,8 @@ impl BaseDocument { node.clear_damage_mut(); node.unset_damaged_descendants(); node.unset_dirty_descendants(); + node.stacking_dirty_self.set(false); + node.spatial_dirty_self.set(false); } } @@ -292,94 +296,71 @@ pub(crate) fn compute_layout_damage(old: &ComputedValues, new: &ComputedValues) } } -/// A child with a z_index that is hoisted up to it's containing Stacking Context for paint purposes -#[derive(Debug, Clone)] -pub struct HoistedPaintChild { - pub node_id: NodeId, - pub z_index: i32, - pub position: taffy::Point, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackingLevel { + Negative(i32), + Auto, + Zero, + Positive(i32), } -#[derive(Debug)] -pub struct HoistedPaintChildren { - pub children: Vec, - /// The number of hoisted point children with negative z_index - pub negative_z_count: u32, - - pub content_area: taffy::Rect, +/// An atomic stacking context or positioned `z-index:auto` container in a +/// real stacking context's paint order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StackingEntry { + pub node_id: NodeId, + pub level: StackingLevel, } -impl HoistedPaintChildren { - fn new() -> Self { - Self { - children: Vec::new(), - negative_z_count: 0, - content_area: taffy::Rect::ZERO, - } - } +#[derive(Debug, Default)] +pub struct StackingContext { + pub negative: Vec, + pub auto_and_zero: Vec, + pub positive: Vec, + /// Bounds of all retained entries in the context root's local coordinate + /// space. `None` represents either an empty context or conservatively + /// disables pruning when scrolling can move entries independently. + pub content_bounds: Option, + pub(crate) bounds_dirty: bool, +} - pub fn reset(&mut self) { - self.children.clear(); - self.negative_z_count = 0; +impl StackingContext { + pub fn has_entries(&self) -> bool { + !self.negative.is_empty() || !self.auto_and_zero.is_empty() || !self.positive.is_empty() } - pub fn compute_content_size(&mut self, doc: &BaseDocument) { - fn child_pos(child: &HoistedPaintChild, doc: &BaseDocument) -> Rect { - let node = &doc.nodes[child.node_id]; - let left = child.position.x + node.final_layout().location.x; - let top = child.position.y + node.final_layout().location.y; - let right = left + node.final_layout().size.width; - let bottom = top + node.final_layout().size.height; - - taffy::Rect { - top, - left, - bottom, - right, - } - } - - if self.children.is_empty() { - self.content_area = taffy::Rect::ZERO; - } else { - self.content_area = child_pos(&self.children[0], doc); - for child in self.children[1..].iter() { - let pos = child_pos(child, doc); - self.content_area.left = self.content_area.left.min(pos.left); - self.content_area.top = self.content_area.top.min(pos.top); - self.content_area.right = self.content_area.right.max(pos.right); - self.content_area.bottom = self.content_area.bottom.max(pos.bottom); - } + pub fn push(&mut self, entry: StackingEntry) { + match entry.level { + StackingLevel::Negative(_) => self.negative.push(entry), + StackingLevel::Auto | StackingLevel::Zero => self.auto_and_zero.push(entry), + StackingLevel::Positive(_) => self.positive.push(entry), } } pub fn sort(&mut self) { - self.children.sort_by_key(|c| c.z_index); - self.negative_z_count = self.children.iter().take_while(|c| c.z_index < 0).count() as u32; - } - - pub fn neg_z_range(&self) -> Range { - 0..(self.negative_z_count as usize) - } - - pub fn pos_z_range(&self) -> Range { - (self.negative_z_count as usize)..self.children.len() - } - - pub fn neg_z_hoisted_children( - &self, - ) -> impl ExactSizeIterator + DoubleEndedIterator { - self.children[self.neg_z_range()].iter() - } - - pub fn pos_z_hoisted_children( - &self, - ) -> impl ExactSizeIterator + DoubleEndedIterator { - self.children[self.pos_z_range()].iter() + self.negative.sort_by_key(|entry| match entry.level { + StackingLevel::Negative(z) => z, + _ => unreachable!(), + }); + self.positive.sort_by_key(|entry| match entry.level { + StackingLevel::Positive(z) => z, + _ => unreachable!(), + }); } } impl BaseDocument { + pub(crate) fn dirty_stacking_context_bounds_for(&mut self, node_id: NodeId) { + let mut owner = self.nodes[node_id].stacking_context_owner.get(); + while let Some(context_root) = owner { + let node = &mut self.nodes[context_root]; + if let Some(context) = &mut node.stacking_context { + context.bounds_dirty = true; + } + owner = node.stacking_context_owner.get(); + } + } + pub(crate) fn invalidate_inline_contexts(&mut self) { let scale = self.viewport.scale(); @@ -418,8 +399,25 @@ impl BaseDocument { } } - pub fn flush_styles_to_layout(&mut self, node_id: NodeId) { - self.flush_styles_to_layout_impl(node_id, None); + pub fn clear_layout_caches(&mut self, node_id: NodeId) { + if !self.nodes.contains_key(node_id) { + return; + } + let children = self.nodes[node_id].layout_children.borrow().clone(); + let node = &mut self.nodes[node_id]; + node.clear_layout_cache(); + if let Some(inline_layout) = node + .data + .downcast_element_mut() + .and_then(|element| element.inline_layout_data.as_mut()) + { + inline_layout.content_widths = None; + } + if let Some(children) = children { + for child_id in children { + self.clear_layout_caches(child_id); + } + } } /// Flush the image layers of nodes whose style changed during the last @@ -531,170 +529,208 @@ impl BaseDocument { } } - /// Walk the whole tree, rebuilding paint children and hoisting z-indexed boxes - fn flush_styles_to_layout_impl( + pub fn rebuild_stacking_contexts(&mut self, root_id: NodeId) { + if !self.incremental_layout || self.nodes[root_id].stacking_context.is_none() { + self.rebuild_stacking_context(root_id); + return; + } + + let mut dirty_contexts = Vec::new(); + self.collect_dirty_stacking_contexts(root_id, root_id, false, &mut dirty_contexts); + dirty_contexts.retain(|context_id| self.nodes.contains_key(*context_id)); + dirty_contexts + .sort_unstable_by_key(|context_id| (self.layout_depth(*context_id), *context_id)); + dirty_contexts.dedup(); + for context_id in dirty_contexts { + if !self.is_current_stacking_context_root(context_id, root_id) { + self.nodes[context_id].stacking_context = None; + continue; + } + self.rebuild_stacking_context(context_id); + } + } + + fn is_current_stacking_context_root(&self, node_id: NodeId, root_id: NodeId) -> bool { + if node_id == root_id { + return true; + } + + let is_flex_or_grid_item = self.nodes[node_id] + .layout_parent + .get() + .and_then(|parent_id| self.nodes.get(parent_id)) + .and_then(Node::display_style) + .is_some_and(|display| { + matches!(display.inside(), DisplayInside::Flex | DisplayInside::Grid) + }); + self.nodes[node_id].is_stacking_context_root(is_flex_or_grid_item) + } + + fn layout_depth(&self, mut node_id: NodeId) -> usize { + let mut depth = 0; + while let Some(parent_id) = self.nodes[node_id].layout_parent.get() { + depth += 1; + node_id = parent_id; + } + depth + } + + fn collect_dirty_stacking_contexts( &mut self, node_id: NodeId, - parent_stacking_context: Option<&mut HoistedPaintChildren>, + containing_context: NodeId, + is_flex_or_grid_item: bool, + dirty_contexts: &mut Vec, ) { - let mut new_stacking_context: HoistedPaintChildren = HoistedPaintChildren::new(); - let stacking_context = &mut new_stacking_context; - - let incremental = self.incremental_layout; - let display = { - let node = self.nodes.get_mut(node_id).unwrap(); - - let Some(display) = node.display_style() else { - return; - }; + if !self.nodes.contains_key(node_id) { + return; + } + let node = &self.nodes[node_id]; + if node_id != containing_context + && !node.stacking_dirty_self.get() + && !node.has_damaged_descendants() + && !node.is_anonymous() + { + return; + } - // In non-incremental mode we unconditionally clear the Taffy cache. - // In incremental mode this is handled as part of damage propagation. - if !incremental { - node.clear_layout_cache(); - if let Some(inline_layout) = node - .data - .downcast_element_mut() - .and_then(|el| el.inline_layout_data.as_mut()) - { - inline_layout.content_widths = None; - } + let is_context = + node_id == containing_context || node.is_stacking_context_root(is_flex_or_grid_item); + if node.stacking_dirty_self.get() { + if let Some(old_owner) = node.stacking_context_owner.get() { + dirty_contexts.push(old_owner); + } + dirty_contexts.push(containing_context); + let rebuild_owned_context = node.stacking_context.is_none() + || node + .damage() + .is_some_and(|damage| damage.contains(RestyleDamage::RECALCULATE_OVERFLOW)); + if is_context && node_id != containing_context && rebuild_owned_context { + dirty_contexts.push(node_id); } + } - display + let child_context = if is_context { + node_id + } else { + containing_context }; - - // If the node has children, then take those children and... + let is_flex_or_grid = node.display_style().is_some_and(|display| { + matches!(display.inside(), DisplayInside::Flex | DisplayInside::Grid) + }); let children = self.nodes[node_id].layout_children.borrow_mut().take(); - if let Some(mut children) = children { - let is_flex_or_grid = - matches!(display.inside(), DisplayInside::Flex | DisplayInside::Grid); - - // Recursively call flush_styles_to_layout on each child - for &child in children.iter() { - self.flush_styles_to_layout_impl( - child, - match self.nodes[child].is_stacking_context_root(is_flex_or_grid) { - true => None, - false => Some(stacking_context), - }, - ); - } + for child_id in children.as_deref().unwrap_or(&[]).iter().copied() { + self.collect_dirty_stacking_contexts( + child_id, + child_context, + is_flex_or_grid, + dirty_contexts, + ); + } + *self.nodes[node_id].layout_children.borrow_mut() = children; + } - // Sort layout_children - if is_flex_or_grid { - children.sort_by(|left, right| { - let left_node = self.nodes.get(*left).unwrap(); - let right_node = self.nodes.get(*right).unwrap(); - left_node.order().cmp(&right_node.order()) - }); - } + fn rebuild_stacking_context(&mut self, context_root: NodeId) { + let mut context = StackingContext::default(); + self.collect_stacking_children(context_root, context_root, &mut context); + context.sort(); + context.bounds_dirty = true; + self.nodes[context_root].stacking_context = Some(Box::new(context)); + } + + fn collect_stacking_children( + &mut self, + context_root: NodeId, + node_id: NodeId, + context: &mut StackingContext, + ) { + let is_flex_or_grid = self.nodes[node_id].display_style().is_some_and(|display| { + matches!(display.inside(), DisplayInside::Flex | DisplayInside::Grid) + }); + let children = self.nodes[node_id].layout_children.borrow_mut().take(); + let mut paint_children = ThinVec::with_capacity(children.as_ref().map_or(0, ThinVec::len)); - // Reserve space for paint_children - let mut paint_children = self.nodes[node_id].paint_children.borrow_mut(); - if paint_children.is_none() { - *paint_children = Some(ThinVec::new()); + for child_id in children.as_deref().unwrap_or(&[]).iter().copied() { + if !self.nodes.contains_key(child_id) { + continue; } - let paint_children = paint_children.as_mut().unwrap(); - paint_children.clear(); - paint_children.reserve(children.len()); - - // Push children to either paint_children or layout_children depending on - for &child_id in children.iter() { - let child = &self.nodes[child_id]; - - let Some(style) = child.primary_styles() else { - paint_children.push(child_id); - continue; - }; - - let position = style.clone_position(); - let z_index = style.clone_z_index().integer_or(0); - - // TODO: more complete hoisting detection - // z-index applies to static flex/grid items too - // (css-flexbox-1 §painting, css-grid-1 §z-order). - if z_index != 0 && (position != Position::Static || is_flex_or_grid) { - stacking_context.children.push(HoistedPaintChild { - node_id: child_id, - z_index, - position: taffy::Point::ZERO, - }) - } else { - paint_children.push(child_id); + self.nodes[child_id] + .stacking_context_owner + .set(Some(context_root)); + let child_is_context = self.nodes[child_id].is_stacking_context_root(is_flex_or_grid); + if child_is_context { + if self.nodes[child_id].stacking_context.is_none() { + self.rebuild_stacking_context(child_id); } + context.push(StackingEntry { + node_id: child_id, + level: stacking_level(&self.nodes[child_id], is_flex_or_grid), + }); + continue; } - // Sort paint_children - paint_children.sort_by(|left, right| { - let left_node = self.nodes.get(*left).unwrap(); - let right_node = self.nodes.get(*right).unwrap(); - node_to_paint_order(left_node, is_flex_or_grid) - .cmp(&node_to_paint_order(right_node, is_flex_or_grid)) - }); + if self.nodes[child_id].stacking_context.is_some() { + self.nodes[child_id].stacking_context = None; + } - // Put children back - *self.nodes[node_id].layout_children.borrow_mut() = Some(children); + if self.nodes[child_id].is_positioned_stacking_container() { + context.push(StackingEntry { + node_id: child_id, + level: StackingLevel::Auto, + }); + } else { + paint_children.push(child_id); + } + self.collect_stacking_children(context_root, child_id, context); } - if let Some(parent_stacking_context) = parent_stacking_context { - let position = self.nodes[node_id].final_layout().location; - let scroll_offset = *self.nodes[node_id].scroll_offset(); - for hoisted in stacking_context.children.iter_mut() { - hoisted.position.x += position.x - scroll_offset.x as f32; - hoisted.position.y += position.y - scroll_offset.y as f32; + paint_children.sort_by_key(|child_id| { + match self.nodes[*child_id] + .primary_styles() + .map(|style| style.clone_float()) + { + Some(Float::None) | None => 0, + Some(_) => 1, } - parent_stacking_context - .children - .extend(stacking_context.children.iter().cloned()); - } else { - stacking_context.sort(); - stacking_context.compute_content_size(self); - self.nodes[node_id].stacking_context = Some(Box::new(new_stacking_context)); - } + }); + *self.nodes[node_id].layout_children.borrow_mut() = children; + *self.nodes[node_id].paint_children.borrow_mut() = Some(paint_children); } -} -#[inline(always)] -fn position_to_order(pos: Position) -> i32 { - match pos { - Position::Static => 0, - // All positioned descendants with z-index: auto share one paint - // level (CSS 2.1 Appendix E step 8); the stable sort keeps them in - // tree order among themselves, above in-flow content and floats. - Position::Relative | Position::Sticky | Position::Absolute | Position::Fixed => 2, - } -} -#[inline(always)] -fn float_to_order(pos: Float) -> i32 { - match pos { - Float::None => 0, - _ => 1, + pub(crate) fn sort_layout_children(&mut self, node_id: NodeId) { + let is_flex_or_grid = self.nodes[node_id].display_style().is_some_and(|display| { + matches!(display.inside(), DisplayInside::Flex | DisplayInside::Grid) + }); + if !is_flex_or_grid { + return; + } + + if let Some(children) = self.nodes[node_id].layout_children.borrow_mut().as_mut() { + children.sort_by_key(|child_id| { + let child = &self.nodes[*child_id]; + if child.taffy_position().is_out_of_flow() { + 0 + } else { + child.order() + } + }); + } } } -/// Paint sort key: (paint level, order-modified position). Positioned -/// (z-index: auto) descendants paint above in-flow content (CSS 2.1 -/// 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) { +fn stacking_level(node: &Node, is_flex_or_grid_item: bool) -> StackingLevel { let Some(style) = node.primary_styles() else { - return (0, 0); + return StackingLevel::Zero; }; - let position = style.clone_position(); - if is_flex_or_grid { - match position { - Position::Static => (0, style.clone_order()), - Position::Relative | Position::Sticky => (2, style.clone_order()), - // Out-of-flow children are not flex/grid items: `order` does - // not apply; tree order does. - Position::Absolute | Position::Fixed => (2, 0), - } - } else { - ( - position_to_order(position) + float_to_order(style.clone_float()), - 0, - ) + if node.taffy_position() == taffy::Position::Static && !is_flex_or_grid_item { + return StackingLevel::Zero; + } + if style.clone_z_index().is_auto() { + return StackingLevel::Zero; + } + match style.clone_z_index().integer_or(0) { + z if z < 0 => StackingLevel::Negative(z), + 0 => StackingLevel::Zero, + z => StackingLevel::Positive(z), } } diff --git a/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs index 0b46d3b77..d41357147 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 @@ -275,6 +275,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, @@ -304,10 +312,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 { @@ -474,7 +482,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() @@ -502,6 +513,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")] { @@ -572,7 +587,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, ); @@ -596,10 +611,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); @@ -703,119 +730,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); + } + } } } } @@ -833,13 +890,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: { @@ -860,6 +927,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, + }, + }), } } } @@ -868,322 +943,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..6d6c04f64 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -14,10 +14,12 @@ 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, OofClaims, ResolveOrZero, RoundTree, TraversePartialTree, + TraverseTree, compute_block_layout, compute_cached_layout, compute_flexbox_layout, + compute_grid_layout, compute_leaf_layout, compute_oof_layout, prelude::*, }; +use thin_vec::ThinVec; pub(crate) mod construct; pub(crate) mod damage; @@ -430,11 +432,96 @@ 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 new_children: ThinVec = + hoisted.iter().copied().map(dom_node_id).collect(); + let old_children = { + let node = self.node_from_id(node_id); + let mut vec = node.hoisted_children.borrow_mut(); + if *vec == new_children { + return; + } + std::mem::replace(&mut *vec, new_children.clone()) + }; + + self.nodes[containing_block].spatial_dirty_self.set(true); + for old_id in old_children { + if !self.nodes.contains_key(old_id) { + continue; + } + let old_node = &self.nodes[old_id]; + if old_node.oof_containing_block.get() == Some(containing_block) + && !new_children.contains(&old_id) + { + old_node.oof_containing_block.set(None); + old_node.spatial_dirty_self.set(true); + } + } + for &hoisted_id in hoisted { + let child = self.node_from_id(hoisted_id); + child.oof_containing_block.set(Some(containing_block)); + child.spatial_dirty_self.set(true); + } + } + + 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); + self.nodes[containing_block].spatial_dirty_self.set(true); + for &hoisted_id in hoisted { + let child = self.node_from_id(hoisted_id); + child.oof_containing_block.set(Some(containing_block)); + child.spatial_dirty_self.set(true); + } + } + + fn oof_claims(&self, node_id: NodeId) -> OofClaims { + let node = self.node_from_id(node_id); + let is_positioned = node.layout_style().position().is_positioned(); + let establishes_fixed_cb = node.establishes_fixed_containing_block(); + OofClaims { + absolute: is_positioned + || establishes_fixed_cb + || node.establishes_absolute_containing_block(), + fixed: establishes_fixed_cb, + } + } + + 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 +580,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 +635,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)); } } } @@ -555,7 +646,25 @@ 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; + let node_id = dom_node_id(node_id); + if self.nodes[node_id].final_layout() != layout { + *self.nodes[node_id].final_layout_mut() = *layout; + self.nodes[node_id].spatial_dirty_self.set(true); + self.dirty_stacking_context_bounds_for(node_id); + } + } + + fn is_hoisted(&self, node_id: NodeId) -> bool { + let node = self.node_from_id(node_id); + node.taffy_position().is_out_of_flow() && node.taffy_display() != Display::None + } + + 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]) } } diff --git a/packages/blitz-dom/src/lib.rs b/packages/blitz-dom/src/lib.rs index 4999cab85..89ac7fd18 100644 --- a/packages/blitz-dom/src/lib.rs +++ b/packages/blitz-dom/src/lib.rs @@ -75,6 +75,7 @@ pub mod util; #[cfg(feature = "accessibility")] mod accessibility; +pub use crate::layout::damage::{StackingEntry, StackingLevel}; pub use crate::layout::replaced::IntrinsicSizes; #[cfg(feature = "custom-widget")] pub use crate::node::Widget; diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index b3ab71db0..90ac2cd16 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 91f7f6e73..5a1faf36d 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1,5 +1,5 @@ use crate::Document; -use crate::layout::damage::HoistedPaintChildren; +use crate::layout::damage::StackingContext; use bitflags::bitflags; use blitz_traits::events::{ BlitzPointerEvent, BlitzPointerId, DomEventData, HitResult, PointerCoords, @@ -18,6 +18,7 @@ use std::fmt::Write; use std::ops::Deref; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use style::dom::TElement; use style::invalidation::element::restyle_hints::RestyleHint; use style::properties::ComputedValues; use style::properties::generated::longhands::position::computed_value::T as Position; @@ -25,8 +26,8 @@ use style::selector_parser::RestyleDamage; use style::servo_arc::Arc as ServoArc; use style::shared_lock::SharedRwLock; use style::stylesheets::UrlExtraData; -use style::values::computed::CSSPixelLength; use style::values::computed::Display as StyloDisplay; +use style::values::computed::{CSSPixelLength, Contain, Overflow}; use style::values::specified::box_::{DisplayInside, DisplayOutside}; use style_dom::ElementState; use style_traits::values::ToCss; @@ -96,17 +97,37 @@ pub struct Node { pub children: ThinVec, /// Our parent in the layout hierachy: a separate list that includes anonymous collections of inline elements pub layout_parent: Cell>, + /// The containing block that owns this out-of-flow box's layout geometry. + /// + /// This is independent from `layout_parent`: out-of-flow layout locations + /// are relative to this node, while paint order and effect ancestry remain + /// structural. + pub oof_containing_block: 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 /// `children` list), so we track the ones we own here to be able to /// deallocate them when this node is reconstructed. pub anonymous_blocks: ThinVec, - /// The same as layout_children, but sorted by z-index + /// Direct children painted as part of this node's ordinary in-flow subtree. + /// Independently stacked descendants are stored on their real stacking + /// context owner instead. pub paint_children: RefCell>>, - pub stacking_context: Option>, + pub stacking_context: Option>, + pub stacking_context_owner: Cell>, + /// Whether this node's own style/construction damage can change stacking + /// membership. Ancestor damage propagated from descendants does not set + /// this bit. + pub stacking_dirty_self: Cell, + /// Geometry relations changed after damage propagation (for example, + /// Taffy selected a different out-of-flow containing block). + pub spatial_dirty_self: Cell, // Flags pub flags: NodeFlags, @@ -384,10 +405,15 @@ impl Node { parent: None, children: ThinVec::new(), layout_parent: Cell::new(None), + oof_containing_block: 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, + stacking_context_owner: Cell::new(None), + stacking_dirty_self: Cell::new(true), + spatial_dirty_self: Cell::new(true), flags: NodeFlags::empty(), data, @@ -1207,6 +1233,14 @@ 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) + } + pub fn text_content(&self) -> String { let mut out = String::new(); self.write_text_content(&mut out); @@ -1250,6 +1284,13 @@ impl Node { // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context#features_creating_stacking_contexts pub fn is_stacking_context_root(&self, is_flex_or_grid_item: bool) -> bool { + use style::computed_values::isolation::T as Isolation; + use style::computed_values::mix_blend_mode::T as MixBlendMode; + use style::values::computed::{Perspective, Rotate, Scale, Translate}; + use style::values::generics::basic_shape::ClipPath; + use style::values::generics::image::Image; + use style::values::specified::box_::{Contain, ContainerType, WillChangeBits}; + let Some(style) = self.primary_styles() else { return false; }; @@ -1257,7 +1298,12 @@ impl Node { let position = style.clone_position(); let has_z_index = !style.clone_z_index().is_auto(); - if style.clone_opacity() != 1.0 { + let effects = style.get_effects(); + if effects.opacity != 1.0 + || effects.mix_blend_mode != MixBlendMode::Normal + || !effects.filter.0.is_empty() + || !effects.backdrop_filter.0.is_empty() + { return true; } @@ -1270,30 +1316,249 @@ impl Node { return true; } - if self.transform().is_some() { + 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; } - // TODO: mix-blend-mode - // TODO: filter - // TODO: clip-path - // TODO: mask - // TODO: isolation - // TODO: contain + if style.get_svg().clip_path != ClipPath::None + || style + .get_svg() + .mask_image + .0 + .iter() + .any(|image| !matches!(image, Image::None)) + || box_style.isolation == Isolation::Isolate + || box_style + .contain + .intersects(Contain::LAYOUT | Contain::PAINT) + || box_style + .container_type + .intersects(ContainerType::SIZE | ContainerType::INLINE_SIZE) + || box_style.will_change.bits.intersects( + WillChangeBits::STACKING_CONTEXT_UNCONDITIONAL + | WillChangeBits::TRANSFORM + | WillChangeBits::OPACITY + | WillChangeBits::PERSPECTIVE + | WillChangeBits::CONTAIN, + ) + { + return true; + } false } + pub fn is_positioned_stacking_container(&self) -> bool { + self.primary_styles().is_some_and(|style| { + style.clone_position() != Position::Static && style.clone_z_index().is_auto() + }) + } + + /// The parent whose coordinate system contains this node's + /// `Layout.location`. + pub fn paint_geometry_parent(&self) -> Option { + self.oof_containing_block + .get() + .or_else(|| self.layout_parent.get()) + } + + /// The coordinate origin of a stacked entry's geometry parent relative to + /// this stacking-context root. + pub fn stacking_entry_position(&self, child_id: NodeId) -> taffy::Point { + fn geometry_origin(node: &Node, mut current: Option) -> taffy::Point { + let mut origin = taffy::Point::::ZERO; + while let Some(id) = current { + let current_node = node.with(id); + let location = current_node.final_layout().location; + origin.x += location.x; + origin.y += location.y; + current = current_node.paint_geometry_parent(); + } + origin + } + + let child_parent_origin = + geometry_origin(self, self.with(child_id).paint_geometry_parent()); + let context_origin = geometry_origin(self, Some(self.id)); + let mut position = taffy::Point { + x: child_parent_origin.x - context_origin.x, + y: child_parent_origin.y - context_origin.y, + }; + + let child = self.with(child_id); + let containing_block = child.oof_containing_block.get(); + let mut applies_spatial_effects = containing_block.is_none(); + let mut structural_parent = child.layout_parent.get(); + while let Some(id) = structural_parent { + if id == self.id { + break; + } + let node = self.with(id); + applies_spatial_effects |= containing_block == Some(id); + if applies_spatial_effects { + let scroll = *node.scroll_offset(); + position.x -= scroll.x as f32; + position.y -= scroll.y as f32; + } + structural_parent = node.layout_parent.get(); + } + position + } + + pub fn clips_overflow(&self) -> bool { + let Some(style) = self.primary_styles() else { + return false; + }; + let display = style.clone_display(); + let contain_paint = style.get_box().clone_contain().contains(Contain::PAINT) + && !display.is_inline_flow() + && !(display.outside() == DisplayOutside::InternalTable + && display.inside() != DisplayInside::TableCell); + let is_replaced_content = self.element_data().is_some_and(|element| { + element.raster_image_data().is_some() + || element.sub_doc_data().is_some() + || element.text_input_data().is_some() + }); + + self.local_name() != "html" + && (is_replaced_content + || contain_paint + || !matches!(style.get_box().overflow_x, Overflow::Visible) + || !matches!(style.get_box().overflow_y, Overflow::Visible)) + } + + fn stacking_entry_clips_point(&self, child_id: NodeId, x: f32, y: f32) -> bool { + let child = self.with(child_id); + let containing_block = child.oof_containing_block.get(); + let mut applies_spatial_effects = containing_block.is_none(); + let mut current = child.layout_parent.get(); + while let Some(id) = current { + if id == self.id { + break; + } + let node = self.with(id); + applies_spatial_effects |= containing_block == Some(id); + if applies_spatial_effects && node.clips_overflow() { + let position = self.stacking_entry_position(id); + let layout = node.final_layout(); + let left = position.x + layout.location.x + layout.border.left; + let top = position.y + layout.location.y + layout.border.top; + let right = + position.x + layout.location.x + layout.size.width - layout.border.right; + let bottom = + position.y + layout.location.y + layout.size.height - layout.border.bottom; + if x < left || x > right || y < top || y > bottom { + return false; + } + } + current = node.layout_parent.get(); + } + true + } + + fn fixed_stacking_entry_scroll( + &self, + child_id: NodeId, + viewport_scroll: taffy::Point, + ) -> taffy::Point { + let child = self.with(child_id); + if child.taffy_position() != taffy::Position::Fixed { + return taffy::Point::ZERO; + } + let containing_block = child.oof_containing_block.get().unwrap_or(self.id); + if containing_block == self.id { + taffy::Point { + x: self.scroll_offset().x as f32 + viewport_scroll.x, + y: self.scroll_offset().y as f32 + viewport_scroll.y, + } + } else { + let scroll = *self.with(containing_block).scroll_offset(); + taffy::Point { + x: scroll.x as f32, + y: scroll.y as f32, + } + } + } + + /// Whether this node's styles establish a containing block for + /// `position: fixed` (and therefore also `position: absolute`) descendants. + /// + /// + pub(crate) fn establishes_fixed_containing_block(&self) -> bool { + use style::values::computed::{Perspective, Rotate, Scale, Translate}; + use style::values::specified::box_::{Contain, ContainerType, WillChangeBits}; + + let Some(style) = self.primary_styles() else { + return false; + }; + + 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 this node's styles establish a containing block for + /// `position: absolute` descendants even when the node is not positioned + /// (e.g. `will-change: position`). + /// + /// + pub(crate) fn establishes_absolute_containing_block(&self) -> bool { + use style::values::specified::box_::WillChangeBits; + + let Some(style) = self.primary_styles() else { + return false; + }; + + style + .get_box() + .will_change + .bits + .intersects(WillChangeBits::POSITION) + } + /// 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 /// - The result of recursively calling child.hit() on the the child element that is /// positioned at that position if there is one. /// - /// 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 @@ -1306,6 +1571,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; @@ -1347,16 +1617,19 @@ impl Node { || y < 0.0 || y > overflow_rect.bottom + self.scroll_offset().y as f32); - let matches_hoisted_content = match &self.stacking_context { - Some(sc) => { - let content_area = sc.content_area; - x >= content_area.left + self.scroll_offset().x as f32 - && x <= content_area.right + self.scroll_offset().x as f32 - && y >= content_area.top + self.scroll_offset().y as f32 - && y <= content_area.bottom + self.scroll_offset().y as f32 + let matches_stacked_content = self.stacking_context.as_ref().is_some_and(|context| { + if !context.has_entries() { + return false; } - None => false, - }; + match context.content_bounds { + Some(bounds) => { + let x = x as f64 * scale; + let y = y as f64 * scale; + x >= bounds.x0 && x <= bounds.x1 && y >= bounds.y0 && y <= bounds.y1 + } + None => true, + } + }); // `scrollable_overflow` is stored in device (scaled) pixels, whereas the // coordinates here are in CSS pixels, so unscale it before comparing. @@ -1367,7 +1640,7 @@ impl Node { && y >= (overflow.y0 / scale) as f32 && y <= (overflow.y1 / scale) as f32; - if !matches_self && !matches_content && !matches_hoisted_content && !matches_overflow { + if !matches_self && !matches_content && !matches_stacked_content && !matches_overflow { return None; } @@ -1382,50 +1655,61 @@ 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; } - // Positive z_index hoisted children - if matches_hoisted_content { - if let Some(hoisted) = &self.stacking_context { - 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) - { - return Some(hit); - } + if matches_stacked_content && let Some(context) = &self.stacking_context { + for entry in context + .positive + .iter() + .rev() + .chain(context.auto_and_zero.iter().rev()) + { + if !self.stacking_entry_clips_point(entry.node_id, x, y) { + continue; + } + let position = self.stacking_entry_position(entry.node_id); + let child = self.with(entry.node_id); + let scroll = self.fixed_stacking_entry_scroll(entry.node_id, viewport_scroll); + let child_x = x - position.x - scroll.x; + let child_y = y - position.y - scroll.y; + if let Some(hit) = + child.hit_inner(child_x, child_y, scale, scrollbar, taffy::Point::ZERO) + { + return Some(hit); } } } // 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); + if let Some(hit) = child.hit_inner(x, y, scale, scrollbar, taffy::Point::ZERO) { return Some(hit); } } - // Negative z_index hoisted children - if matches_hoisted_content { - if let Some(hoisted) = &self.stacking_context { - 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) - { - return Some(hit); - } + if matches_stacked_content && let Some(context) = &self.stacking_context { + for entry in context.negative.iter().rev() { + if !self.stacking_entry_clips_point(entry.node_id, x, y) { + continue; + } + let position = self.stacking_entry_position(entry.node_id); + let scroll = self.fixed_stacking_entry_scroll(entry.node_id, viewport_scroll); + if let Some(hit) = self.with(entry.node_id).hit_inner( + x - position.x - scroll.x, + y - position.y - scroll.y, + scale, + scrollbar, + taffy::Point::ZERO, + ) { + return Some(hit); } } } @@ -1528,9 +1812,8 @@ impl Node { let x = x + self.final_layout().location.x - self.scroll_offset().x as f32; let y = y + self.final_layout().location.y - self.scroll_offset().y as f32; - // Recurse up the layout hierarchy - self.layout_parent - .get() + // Recurse through the coordinate-system hierarchy. + self.paint_geometry_parent() .map(|i| self.with(i).absolute_position(x, y)) .unwrap_or(crate::util::Point { x, y }) } diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index c9ac05e1e..807c3b02f 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -100,16 +100,21 @@ impl BaseDocument { self.flush_pending_style_images(); timer.record_time("pconstruct"); - // Merge stylo into taffy - self.flush_styles_to_layout(root_node_id); - timer.record_time("flush"); + if !self.incremental_layout { + self.clear_layout_caches(root_node_id); + } // Next we resolve layout with the data resolved by stlist self.resolve_layout(); timer.record_time("layout"); - // Resolve transforms + self.rebuild_stacking_contexts(root_node_id); + timer.record_time("stacking"); + + // Resolve transforms and overflow through layout/containing-block + // geometry after stacking membership has been finalized. self.resolve_transforms(root_node_id); + self.resolve_stacking_context_bounds(root_node_id, self.viewport.scale_f64()); timer.record_time("transform"); // Clear all damage and dirty flags, walking only subtrees which are @@ -170,6 +175,7 @@ impl BaseDocument { .damage() .map(|d| d.contains(style::selector_parser::RestyleDamage::RECALCULATE_OVERFLOW)) .unwrap_or(false) + && !self.nodes[node_id].spatial_dirty_self.get() { let node = &self.nodes[node_id]; let location = node.final_layout().location.map(|v| v as f64 * scale); @@ -193,10 +199,26 @@ 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: their overflow contribution is + // accounted for at the containing block (below) instead. + if self.nodes[child_id].taffy_position().is_out_of_flow() { + continue; + } let child_rect_in_self = self.resolve_transforms(child_id); overflow = overflow.union(child_rect_in_self); } } + 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); + } + *self.nodes[node_id].hoisted_children.borrow_mut() = hoisted_children; if let Some(before) = self.nodes[node_id].before() { let child_rect_in_self = self.resolve_transforms(before); overflow = overflow.union(child_rect_in_self); @@ -206,7 +228,11 @@ impl BaseDocument { overflow = overflow.union(child_rect_in_self); } + let overflow_changed = *self.nodes[node_id].scrollable_overflow() != overflow; *self.nodes[node_id].scrollable_overflow_mut() = overflow; + if overflow_changed || self.nodes[node_id].spatial_dirty_self.get() { + self.dirty_stacking_context_bounds_for(node_id); + } *self.nodes[node_id].layout_children.get_mut() = layout_children; let scaled_x = self.nodes[node_id].final_layout().location.x as f64 * scale; @@ -221,6 +247,106 @@ impl BaseDocument { full.transform_rect_bbox(overflow) } + fn resolve_stacking_context_bounds(&mut self, context_root: NodeId, scale: f64) { + let Some(mut context) = self.nodes[context_root].stacking_context.take() else { + return; + }; + let root = &self.nodes[context_root]; + let needs_recompute = !self.incremental_layout + || context.bounds_dirty + || root.spatial_dirty_self.get() + || root + .damage() + .is_some_and(|damage| damage.contains(RestyleDamage::RECALCULATE_OVERFLOW)); + if !needs_recompute { + self.nodes[context_root].stacking_context = Some(context); + return; + } + + let mut content_bounds: Option = None; + for entry in context + .negative + .iter() + .chain(&context.auto_and_zero) + .chain(&context.positive) + { + if self.nodes[entry.node_id].stacking_context.is_some() { + self.resolve_stacking_context_bounds(entry.node_id, scale); + } + let Some(entry_bounds) = self.stacking_entry_bounds(context_root, entry.node_id, scale) + else { + context.content_bounds = None; + context.bounds_dirty = false; + self.nodes[context_root].stacking_context = Some(context); + return; + }; + content_bounds = Some(match content_bounds { + Some(bounds) => bounds.union(entry_bounds), + None => entry_bounds, + }); + } + context.content_bounds = content_bounds; + context.bounds_dirty = false; + self.nodes[context_root].stacking_context = Some(context); + } + + fn stacking_entry_bounds( + &self, + context_root: NodeId, + entry_id: NodeId, + scale: f64, + ) -> Option { + if !self.stacking_entry_bounds_are_scroll_independent(context_root, entry_id) { + return None; + } + + let child = &self.nodes[entry_id]; + let mut bounds = *child.scrollable_overflow(); + if let Some(context) = &child.stacking_context + && context.has_entries() + { + bounds = bounds.union(context.content_bounds?); + } + + let position = self.nodes[context_root].stacking_entry_position(entry_id); + let location = child.final_layout().location; + let mut transform = Affine::translate(( + (position.x + location.x) as f64 * scale, + (position.y + location.y) as f64 * scale, + )); + if let Some(child_transform) = child.transform() { + transform *= *child_transform; + } + Some(transform.transform_rect_bbox(bounds)) + } + + fn stacking_entry_bounds_are_scroll_independent( + &self, + context_root: NodeId, + entry_id: NodeId, + ) -> bool { + let child = &self.nodes[entry_id]; + if child.taffy_position() == taffy::Position::Fixed { + return false; + } + + let containing_block = child.oof_containing_block.get(); + let mut applies_spatial_effects = containing_block.is_none(); + let mut current = child.layout_parent.get(); + while let Some(id) = current { + if id == context_root { + return true; + } + let ancestor = &self.nodes[id]; + applies_spatial_effects |= containing_block == Some(id); + if applies_spatial_effects && ancestor.clips_overflow() { + return false; + } + current = ancestor.layout_parent.get(); + } + false + } + /// 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); @@ -291,6 +417,7 @@ impl BaseDocument { // damage.insert(RestyleDamage::RELAYOUT | RestyleDamage::REPAINT); } + doc.sort_layout_children(node_id); doc.nodes[node_id].set_damage(damage); } } diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index 0fc1264a5..afd856eb7 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -40,7 +40,7 @@ use style::{ }, }; -use kurbo::{self, Affine, Insets, Point, Rect, Shape, Size, Stroke, Vec2}; +use kurbo::{self, Affine, BezPath, Insets, Point, Rect, Shape, Size, Stroke, Vec2}; use peniko::{self, Fill, ImageData, ImageSampler}; use style::values::generics::color::GenericColor; use taffy::Layout; @@ -380,6 +380,12 @@ impl<'dom, 'a> BlitzDomPainter<'dom, 'a> { // Don't render things that are out of view let overflow = *node.scrollable_overflow(); + let paint_bounds = match &node.stacking_context { + Some(context) if context.has_entries() => { + context.content_bounds.map(|bounds| overflow.union(bounds)) + } + _ => Some(overflow), + }; let transform = parent_style_transform * Affine::translate(box_position) * node.transform().unwrap_or_default(); @@ -388,17 +394,19 @@ impl<'dom, 'a> BlitzDomPainter<'dom, 'a> { x: -self.initial_x, y: -self.initial_y, }) * transform; - let screen_bbox = screen_transform.transform_rect_bbox(overflow.union(border_box)); - - // Cull elements that fall entirely outside the current clip rectangle. In addition to - // the viewport, `clip_rect` is narrowed by any ancestor scrollport (see below), so this - // also culls elements scrolled out of view inside a clipping/scrolling container. - if screen_bbox.x1 < clip_rect.x0 - || screen_bbox.x0 > clip_rect.x1 - || screen_bbox.y1 < clip_rect.y0 - || screen_bbox.y0 > clip_rect.y1 - { - return; + if let Some(paint_bounds) = paint_bounds { + let screen_bbox = screen_transform.transform_rect_bbox(paint_bounds.union(border_box)); + + // Cull elements that fall entirely outside the current clip rectangle. In addition to + // the viewport, `clip_rect` is narrowed by any ancestor scrollport (see below), so this + // also culls elements scrolled out of view inside a clipping/scrolling container. + if screen_bbox.x1 < clip_rect.x0 + || screen_bbox.x0 > clip_rect.x1 + || screen_bbox.y1 < clip_rect.y0 + || screen_bbox.y0 > clip_rect.y1 + { + return; + } } // Optimise zero-area (/very small area) clips by not rendering at all @@ -527,6 +535,19 @@ impl<'dom, 'a> BlitzDomPainter<'dom, 'a> { x: -node.scroll_offset().x * self.scale, y: -node.scroll_offset().y * self.scale, }); + if cx + .stacking_context_intersects_clip(cx.transform, child_clip_rect) + { + cx.draw_stacking_entries( + scene, + node.stacking_context + .as_ref() + .map(|context| context.negative.as_slice()) + .unwrap_or_default(), + cx.transform, + child_clip_rect, + ); + } cx.draw_image(scene); #[cfg(feature = "svg")] cx.draw_svg(scene); @@ -968,47 +989,152 @@ impl ElementCx<'_, '_> { parent_style_transform: Affine, clip_rect: Rect, ) { - // Negative z_index hoisted nodes - - if let Some(hoisted) = &self.node.stacking_context { - for hoisted_child in hoisted.neg_z_hoisted_children() { - let pos = kurbo::Vec2 { - x: hoisted_child.position.x as f64 * self.scale, - y: hoisted_child.position.y as f64 * self.scale, - }; - self.render_node( - scene, - hoisted_child.node_id, - parent_style_transform.pre_translate(pos), - clip_rect, - ); - } - } - // 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); } } - // Positive z_index hoisted nodes - if let Some(hoisted) = &self.node.stacking_context { - for hoisted_child in hoisted.pos_z_hoisted_children() { - let pos = kurbo::Vec2 { - x: hoisted_child.position.x as f64 * self.scale, - y: hoisted_child.position.y as f64 * self.scale, + if self.stacking_context_intersects_clip(parent_style_transform, clip_rect) + && let Some(context) = &self.node.stacking_context + { + self.draw_stacking_entries( + scene, + &context.auto_and_zero, + parent_style_transform, + clip_rect, + ); + self.draw_stacking_entries(scene, &context.positive, parent_style_transform, clip_rect); + } + } + + fn draw_stacking_entries( + &self, + scene: &mut impl PaintScene, + entries: &[blitz_dom::StackingEntry], + parent_style_transform: Affine, + clip_rect: Rect, + ) { + for entry in entries { + let child = &self.context.dom.as_ref().tree()[entry.node_id]; + let position = self.node.stacking_entry_position(entry.node_id); + let mut transform = parent_style_transform.pre_translate(kurbo::Vec2 { + x: position.x as f64 * self.scale, + y: position.y as f64 * self.scale, + }); + if child.taffy_position() == taffy::Position::Fixed { + let containing_block = child + .oof_containing_block + .get() + .unwrap_or(self.context.root_element_id.unwrap_or(self.node.id)); + let scroll = if Some(containing_block) == self.context.root_element_id { + self.context.dom.as_ref().viewport_scroll() + } else { + *self.context.dom.as_ref().tree()[containing_block].scroll_offset() }; - self.render_node( + transform = transform.pre_translate(kurbo::Vec2 { + x: scroll.x * self.scale, + y: scroll.y * self.scale, + }); + } + + let mut clip_layers: Vec<(Affine, BezPath)> = Vec::new(); + let containing_block = child.oof_containing_block.get(); + let mut applies_spatial_effects = containing_block.is_none(); + let mut current = child.layout_parent.get(); + while let Some(id) = current { + if id == self.node.id { + break; + } + let ancestor = &self.context.dom.as_ref().tree()[id]; + applies_spatial_effects |= containing_block == Some(id); + if applies_spatial_effects + && ancestor.clips_overflow() + && let Some(style) = ancestor.primary_styles() + { + let layout = ancestor.final_layout(); + let position = self.node.stacking_entry_position(id); + let origin = Vec2::new( + (position.x + layout.location.x) as f64, + (position.y + layout.location.y) as f64, + ) * self.scale; + let frame = create_css_rect(&style, layout, self.scale); + clip_layers.push(( + parent_style_transform.pre_translate(origin), + frame.padding_box_path(), + )); + } + current = ancestor.layout_parent.get(); + } + clip_layers.reverse(); + + let mut pushed_layers = 0; + for (clip_transform, clip_path) in &clip_layers { + if self.context.layer_manager.maybe_push_layer( scene, - hoisted_child.node_id, - parent_style_transform.pre_translate(pos), - clip_rect, - ); + true, + 1.0, + *clip_transform, + clip_path, + None, + None, + ) { + pushed_layers += 1; + } + } + self.render_node(scene, entry.node_id, transform, clip_rect); + for _ in 0..pushed_layers { + self.context.layer_manager.maybe_pop_layer(scene, true); } } } + fn stacking_context_intersects_clip( + &self, + parent_style_transform: Affine, + clip_rect: Rect, + ) -> bool { + let Some(context) = &self.node.stacking_context else { + return false; + }; + if !context.has_entries() { + return false; + } + let Some(bounds) = context.content_bounds else { + return true; + }; + + let screen_transform = Affine::translate(Vec2 { + x: -self.initial_x, + y: -self.initial_y, + }) * parent_style_transform; + let screen_bounds = screen_transform.transform_rect_bbox(bounds); + screen_bounds.x1 >= clip_rect.x0 + && screen_bounds.x0 <= clip_rect.x1 + && screen_bounds.y1 >= clip_rect.y0 + && screen_bounds.y0 <= clip_rect.y1 + } + #[cfg(feature = "svg")] fn draw_svg(&self, scene: &mut impl PaintScene) { use style::properties::generated::longhands::object_fit::computed_value::T as ObjectFit; diff --git a/packages/stylo_taffy/src/convert.rs b/packages/stylo_taffy/src/convert.rs index a159337ac..86368adb1 100644 --- a/packages/stylo_taffy/src/convert.rs +++ b/packages/stylo_taffy/src/convert.rs @@ -249,13 +249,11 @@ 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, } } diff --git a/packages/stylo_taffy/src/wrapper.rs b/packages/stylo_taffy/src/wrapper.rs index 872a9e34d..9c0c09458 100644 --- a/packages/stylo_taffy/src/wrapper.rs +++ b/packages/stylo_taffy/src/wrapper.rs @@ -627,3 +627,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..3854a4e9e --- /dev/null +++ b/tests/blitz-tests/tests/oof_dynamic_cb.rs @@ -0,0 +1,287 @@ +//! 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)); +} + +/// 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), + "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); +} diff --git a/tests/blitz-tests/tests/paint_order.rs b/tests/blitz-tests/tests/paint_order.rs index 4a502a2e5..f16aa16da 100644 --- a/tests/blitz-tests/tests/paint_order.rs +++ b/tests/blitz-tests/tests/paint_order.rs @@ -3,13 +3,15 @@ use anyrender::render_to_buffer; use anyrender_vello_cpu::VelloCpuImageRenderer; -use blitz_dom::DocumentConfig; +use blitz_dom::{DocumentConfig, ScrollBehavior}; use blitz_html::{HtmlDocument, HtmlProvider}; use blitz_paint::paint_scene; +use blitz_test_harness::Harness; use blitz_traits::shell::{ColorScheme, Viewport}; +use markup5ever::{QualName, local_name, ns}; use std::sync::Arc; -fn center_pixel(html: &str) -> [u8; 3] { +fn pixel_at(html: &str, x: usize, y: usize) -> [u8; 3] { let mut doc = HtmlDocument::from_html( html, DocumentConfig { @@ -24,10 +26,50 @@ fn center_pixel(html: &str) -> [u8; 3] { 100, 100, ); - let idx = (50 * 100 + 50) * 4; + let idx = (y * 100 + x) * 4; + [buffer[idx], buffer[idx + 1], buffer[idx + 2]] +} + +fn pixel_after_scroll(html: &str, x: usize, y: usize) -> [u8; 3] { + let mut doc = HtmlDocument::from_html( + html, + DocumentConfig { + viewport: Some(Viewport::new(100, 100, 1.0, ColorScheme::Light)), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + }, + ); + doc.resolve(0.0); + let scroller = doc.query_selector("#scroller").unwrap().expect("#scroller"); + doc.scroll_by(scroller, 0.0, 75.0, ScrollBehavior::Instant); + let buffer = render_to_buffer::( + |scene| paint_scene(scene, &mut doc, 1.0, 100, 100, 0, 0), + 100, + 100, + ); + let idx = (y * 100 + x) * 4; [buffer[idx], buffer[idx + 1], buffer[idx + 2]] } +fn center_pixel(html: &str) -> [u8; 3] { + pixel_at(html, 50, 50) +} + +fn harness_center_pixel(harness: &mut Harness) -> [u8; 4] { + let buffer = render_to_buffer::( + |scene| paint_scene(scene, harness.doc.as_mut(), 1.0, 100, 100, 0, 0), + 100, + 100, + ); + let idx = (50 * 100 + 50) * 4; + [ + buffer[idx], + buffer[idx + 1], + buffer[idx + 2], + buffer[idx + 3], + ] +} + #[test] fn later_relative_sibling_paints_above_earlier_abspos() { let px = center_pixel( @@ -76,3 +118,255 @@ fn earlier_abspos_stays_below_static_when_later_in_tree_order_is_static() { "positioned content paints above in-flow content regardless of tree order" ); } + +#[test] +fn nested_stacking_context_is_atomic() { + let px = center_pixel( + r#" +
+
+
+
+
+
+ "#, + ); + assert_eq!( + px, + [0, 255, 0], + "a descendant cannot escape its real stacking context" + ); +} + +#[test] +fn z_index_does_not_apply_to_static_non_flex_grid_contexts() { + let px = center_pixel( + r#" +
+
+
+
+ "#, + ); + assert_eq!( + px, + [0, 255, 0], + "z-index must not apply to a static non-flex/grid stacking context" + ); +} + +#[test] +fn positioned_auto_container_is_not_atomic() { + let px = center_pixel( + r#" +
+
+
+
+
+
+ "#, + ); + assert_eq!( + px, + [255, 0, 0], + "stacked descendants escape a z-index:auto container" + ); +} + +#[test] +fn out_of_flow_order_uses_structural_position() { + let px = center_pixel( + r#" +
+
+
+
+
+
+ "#, + ); + assert_eq!( + px, + [0, 0, 255], + "containing-block ownership must not replace structural paint order" + ); +} + +#[test] +fn out_of_flow_flex_children_ignore_order() { + let px = center_pixel( + r#" +
+
+
+
+ "#, + ); + assert_eq!( + px, + [0, 0, 255], + "order must not reorder out-of-flow flex-container children" + ); +} + +#[test] +fn stacked_content_keeps_structural_overflow_clips() { + let html = r#" +
+
+
+ "#; + assert_eq!(pixel_at(html, 25, 50), [255, 0, 0]); + assert_eq!(pixel_at(html, 75, 50), [0, 0, 255]); + + let harness = Harness::from_html( + r#" +
+
+
+ "#, + ); + let child = harness.node("#child"); + assert_eq!(harness.hit_node(25.0, 50.0), child); + assert_ne!(harness.hit_node(75.0, 50.0), child); +} + +#[test] +fn out_of_flow_content_ignores_clips_between_it_and_its_containing_block() { + let html = r#" +
+
+
+ "#; + assert_eq!(pixel_at(html, 75, 50), [255, 0, 0]); +} + +#[test] +fn stacked_out_of_flow_content_uses_its_spatial_scroll_ancestry() { + let html = r#" +
+
+
+
+ "#; + assert_eq!(pixel_after_scroll(html, 50, 50), [255, 0, 0]); +} + +#[test] +fn negative_context_paints_above_context_background_but_below_in_flow_content() { + let px = center_pixel( + r#" +
+
+
+
+ "#, + ); + assert_eq!(px, [0, 255, 0]); + + let px = center_pixel( + r#" +
+
+
+ "#, + ); + assert_eq!(px, [255, 0, 0]); +} + +fn style_attr() -> QualName { + QualName::new(None, ns!(), local_name!("style")) +} + +#[test] +fn z_index_restyle_updates_shared_paint_and_hit_order() { + let mut harness = Harness::from_html( + r#" +
+
+
+
+ "#, + ); + let a = harness.node("#a"); + let b = harness.node("#b"); + assert_eq!(harness.hit_node(50.0, 50.0), b); + + harness.base_mut().mutate().set_attribute( + a, + style_attr(), + "position:absolute; z-index:3; inset:0;", + ); + harness.pump(); + assert_eq!(harness.hit_node(50.0, 50.0), a); +} + +#[test] +fn stacking_context_boundary_restyle_updates_old_and_new_owners() { + let mut harness = Harness::from_html( + r#" +
+
+
+
+
+
+ "#, + ); + let wrapper = harness.node("#wrapper"); + let child = harness.node("#child"); + let sibling = harness.node("#sibling"); + assert_eq!(harness.hit_node(50.0, 50.0), child); + + harness.base_mut().mutate().set_attribute( + wrapper, + style_attr(), + "position:relative; width:100px; height:100px; opacity:0.9;", + ); + harness.pump(); + assert_eq!(harness.hit_node(50.0, 50.0), sibling); + + harness.base_mut().mutate().set_attribute( + wrapper, + style_attr(), + "position:relative; width:100px; height:100px;", + ); + harness.pump(); + assert_eq!(harness.hit_node(50.0, 50.0), child); +} + +#[test] +fn removed_stacking_context_is_not_rebuilt_from_stale_owner() { + let initial = r#" +
+
+
+ "#; + let final_html = r#" +
+
+
+ "#; + + let mut harness = Harness::from_html(initial); + let a = harness.node("#a"); + let b = harness.node("#b"); + harness + .base_mut() + .mutate() + .set_attribute(a, style_attr(), "width:100px; height:100px;"); + harness.base_mut().mutate().set_attribute( + b, + style_attr(), + "position:relative; width:100px; height:100px; background:rgba(255,0,0,0.5); color:blue;", + ); + harness.pump(); + + let mut fresh = Harness::from_html(final_html); + assert_eq!( + harness_center_pixel(&mut harness), + harness_center_pixel(&mut fresh), + "incremental paint must match a fresh document after removing a stacking context" + ); +} diff --git a/tests/blitz-tests/tests/stacking_context_bounds.rs b/tests/blitz-tests/tests/stacking_context_bounds.rs new file mode 100644 index 000000000..246f7c9b3 --- /dev/null +++ b/tests/blitz-tests/tests/stacking_context_bounds.rs @@ -0,0 +1,183 @@ +use anyrender::render_to_buffer; +use anyrender_vello_cpu::VelloCpuImageRenderer; +use blitz_dom::DocumentConfig; +use blitz_html::{HtmlDocument, HtmlProvider}; +use blitz_paint::paint_scene; +use blitz_test_harness::Harness; +use blitz_traits::node_id::NodeId; +use blitz_traits::shell::{ColorScheme, Viewport}; +use markup5ever::{QualName, local_name, ns}; +use std::sync::Arc; + +const ESCAPING_ENTRY_PAGE: &str = r#" + + +
+"#; + +fn style_attr() -> QualName { + QualName::new(None, ns!(), local_name!("style")) +} + +fn set_style(harness: &mut Harness, node: NodeId, style: &str) { + harness + .base_mut() + .mutate() + .set_attribute(node, style_attr(), style); + harness.pump(); +} + +#[test] +fn abspos_escaping_stacking_context_is_hit() { + let mut harness = Harness::from_html(ESCAPING_ENTRY_PAGE); + let escaped = harness.node("#escaped"); + let rect = harness.layout_rect("#escaped"); + assert_eq!((rect.x, rect.y), (500.0, 300.0)); + + assert_eq!(harness.hit_node(550.0, 350.0), escaped); + assert_eq!(harness.hit_node(300.0, 200.0), harness.node("#cb")); + assert_eq!(harness.hit_node(120.0, 20.0), harness.node("#sc")); + + let cb = harness.node("#cb"); + set_style( + &mut harness, + cb, + "position: relative; width: 300px; height: 200px", + ); + let rect = harness.layout_rect("#escaped"); + assert_eq!((rect.x, rect.y), (200.0, 100.0)); + assert_eq!(harness.hit_node(250.0, 150.0), escaped); + assert_ne!( + harness.hit(550.0, 350.0).map(|hit| hit.node_id), + Some(escaped) + ); + + set_style(&mut harness, escaped, "left: 0; bottom: 0; top: auto"); + let rect = harness.layout_rect("#escaped"); + assert_eq!((rect.x, rect.y), (0.0, 100.0)); + assert_eq!(harness.hit_node(50.0, 150.0), escaped); + assert_ne!( + harness.hit(250.0, 150.0).map(|hit| hit.node_id), + Some(escaped) + ); +} + +#[test] +fn abspos_escaping_scrolled_out_stacking_context_is_painted() { + const HTML: &str = r#" + +
"#; + + let mut doc = HtmlDocument::from_html( + HTML, + DocumentConfig { + viewport: Some(Viewport::new(100, 100, 1.0, ColorScheme::Light)), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + }, + ); + doc.resolve(0.0); + doc.set_viewport_scroll(blitz_dom::Point { x: 0.0, y: 900.0 }); + let buffer = render_to_buffer::( + |scene| paint_scene(scene, &mut doc, 1.0, 100, 100, 0, 0), + 100, + 100, + ); + let idx = (75 * 100 + 50) * 4; + assert_eq!([buffer[idx], buffer[idx + 1], buffer[idx + 2]], [0, 128, 0]); +} + +#[test] +fn distant_stacking_context_text_is_not_hit() { + const HTML: &str = r##" + + + +
+

1.2.3 Heading text

+ "##; + + let mut harness = Harness::from_html(HTML); + let link = harness.node("#toc-link"); + let rect = harness + .base() + .inline_fragment_rects(link) + .expect("link is a non-atomic inline")[0]; + let (x, y) = ( + (rect.x + rect.width / 2.0) as f32, + (rect.y + rect.height / 2.0) as f32, + ); + + assert_eq!(harness.hit_node(x, y), link); + harness.move_mouse_to(x, y); + assert_eq!(harness.hovered(), Some(link)); + assert_eq!(harness.hit_node(600.0, y), harness.node("#toc")); + assert_eq!(harness.hit_node(x, 200.0), harness.node("#spacer")); + + let heading = harness.node("h4"); + let (hx, hy) = harness.layout_rect("h4").center(); + let hit = harness.hit_node(hx, hy); + assert!(hit == heading || hit == harness.node(".secno")); + let self_link = harness.node(".self-link"); + let (sx, sy) = harness.layout_rect_of(self_link).center(); + assert_eq!(harness.hit_node(sx, sy), self_link); +} + +#[test] +fn independently_scrolled_entries_disable_context_pruning() { + let harness = Harness::from_html( + r#" + + +
entry
+ "#, + ); + let base = harness.base(); + let context = &base.tree()[harness.node("#context")]; + let stacking_context = context.stacking_context.as_ref().unwrap(); + assert!(stacking_context.has_entries()); + assert_eq!(stacking_context.content_bounds, None); +} + +#[test] +fn context_bounds_include_entry_transforms() { + let harness = Harness::from_html( + r#" + +
"#, + ); + assert_eq!(harness.hit_node(325.0, 25.0), harness.node("#entry")); + assert_eq!(harness.hit_node(125.0, 25.0), harness.node("#cb")); +}