layout: Take into account sticky in ScrollTree node to world transform and cache the value

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
Co-authored-by: Oriol Brufau <obrufau@igalia.com>
This commit is contained in:
Martin Robinson 2025-07-30 17:27:22 +02:00
parent 815ed10b5f
commit fc639f8d62
2 changed files with 188 additions and 36 deletions

View file

@ -65,7 +65,7 @@ fn root_transform_for_layout_node(
.spatial_tree_node
.borrow()
.expect("Should always have a scroll tree node when querying bounding box.");
Some(scroll_tree.cumulative_node_transform(&scroll_tree_node_id))
Some(scroll_tree.cumulative_node_to_root_transform(&scroll_tree_node_id))
}
pub(crate) fn process_content_box_request(

View file

@ -4,6 +4,7 @@
//! Defines data structures which are consumed by the Compositor.
use std::cell::Cell;
use std::collections::HashMap;
use base::id::ScrollTreeNodeId;
@ -190,6 +191,24 @@ impl ScrollableNodeInfo {
}
}
/// A cached of transforms of a particular [`ScrollTree`] node in both directions:
/// mapping from node-relative points to root-relative points and vice-versa.
///
/// Potential ideas for improvement:
/// - Test optimizing simple translations to avoid having to do full matrix
/// multiplication when transforms are not involved.
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct ScrollTreeNodeTransformationCache {
node_to_root_transform: LayoutTransform,
root_to_node_transform: Option<LayoutTransform>,
}
#[derive(Default)]
struct AncestorStickyInfo {
nearest_scrolling_ancestor_offset: LayoutVector2D,
nearest_scrolling_ancestor_viewport: LayoutRect,
}
#[derive(Debug, Deserialize, Serialize)]
/// A node in a tree of scroll nodes. This may either be a scrollable
/// node which responds to scroll events or a non-scrollable one.
@ -205,6 +224,10 @@ pub struct ScrollTreeNode {
/// Specific information about this node, depending on whether it is a scroll node
/// or a reference frame.
pub info: SpatialTreeNodeInfo,
/// Cached transformation information that's used to do things like hit testing
/// and viewport bounding box calculation.
transformation_cache: Cell<Option<ScrollTreeNodeTransformationCache>>,
}
impl ScrollTreeNode {
@ -323,6 +346,7 @@ impl ScrollTree {
parent: parent.cloned(),
webrender_id: None,
info,
transformation_cache: Cell::default(),
});
ScrollTreeNodeId {
index: self.nodes.len() - 1,
@ -426,50 +450,178 @@ impl ScrollTree {
}
/// Traverse a scroll node to its root to calculate the transform.
///
/// TODO(stevennovaryo): Add caching mechanism for this.
pub fn cumulative_node_transform(&self, node_id: &ScrollTreeNodeId) -> LayoutTransform {
let current_node = self.get_node(node_id);
pub fn cumulative_node_to_root_transform(&self, node_id: &ScrollTreeNodeId) -> LayoutTransform {
let node = self.get_node(node_id);
if let Some(cached_transforms) = node.transformation_cache.get() {
return cached_transforms.node_to_root_transform;
}
let (transforms, _) = self.cumulative_node_transform_inner(node);
node.transformation_cache.set(Some(transforms));
transforms.node_to_root_transform
}
/// Traverse a scroll node to its root to calculate the transform.
fn cumulative_node_transform_inner(
&self,
node: &ScrollTreeNode,
) -> (ScrollTreeNodeTransformationCache, AncestorStickyInfo) {
let (parent_transforms, mut sticky_info) = match node.parent {
Some(parent_id) => self.cumulative_node_transform_inner(self.get_node(&parent_id)),
None => (Default::default(), Default::default()),
};
let change_basis =
|transform: &Transform3D<f32, LayoutPixel, LayoutPixel>, x: f32, y: f32, z: f32| {
let pre_translation = Transform3D::translation(x, y, z);
let post_translation = Transform3D::translation(-x, -y, -z);
|transform: &Transform3D<f32, LayoutPixel, LayoutPixel>, x: f32, y: f32| {
let pre_translation = Transform3D::translation(x, y, 0.0);
let post_translation = Transform3D::translation(-x, -y, 0.0);
post_translation.then(transform).then(&pre_translation)
};
// FIXME(stevennovaryo): Ideally we should optimize the computation of simpler
// transformation like translate as it could be done
// in smaller amount of operation compared to a normal
// matrix multiplication.
let node_transform = match &current_node.info {
// To apply a transformation we need to make sure the rectangle's
// coordinate space is the same as reference frame's coordinate space.
// TODO(stevennovaryo): contrary to how Firefox are handling the coordinate space,
// we are ignoring zoom in transforming the coordinate
// space, and we might need to consider zoom here if it was
// implemented completely.
SpatialTreeNodeInfo::ReferenceFrame(info) => change_basis(
&info.transform,
info.frame_origin_for_query.x,
info.frame_origin_for_query.y,
0.0,
),
SpatialTreeNodeInfo::Scroll(info) => {
Transform3D::translation(-info.offset.x, -info.offset.y, 0.0)
let (node_to_parent_transform, parent_to_node_transform) = match &node.info {
SpatialTreeNodeInfo::ReferenceFrame(info) => {
// To apply a transformation we need to make sure the rectangle's
// coordinate space is the same as reference frame's coordinate space.
let node_to_parent_transform = change_basis(
&info.transform,
info.frame_origin_for_query.x,
info.frame_origin_for_query.y,
);
let parent_to_node_transform = info.transform.inverse().map(|inverse_transform| {
change_basis(&inverse_transform, -info.origin.x, info.origin.y)
});
sticky_info.nearest_scrolling_ancestor_viewport = sticky_info
.nearest_scrolling_ancestor_viewport
.translate(-info.origin.to_vector());
(node_to_parent_transform, parent_to_node_transform)
},
SpatialTreeNodeInfo::Scroll(info) => {
sticky_info.nearest_scrolling_ancestor_viewport = info.clip_rect;
sticky_info.nearest_scrolling_ancestor_offset = -info.offset;
(
Transform3D::translation(-info.offset.x, -info.offset.y, 0.0),
Some(Transform3D::translation(info.offset.x, info.offset.y, 0.0)),
)
},
SpatialTreeNodeInfo::Sticky(info) => {
let offset = Self::calculate_sticky_offset(&sticky_info, info);
sticky_info.nearest_scrolling_ancestor_offset += offset;
(
Transform3D::translation(offset.x, offset.y, 0.0),
Some(Transform3D::translation(-offset.x, -offset.y, 0.0)),
)
},
// TODO(stevennovaryo): Need to consider sticky frame accurately.
SpatialTreeNodeInfo::Sticky(_) => Default::default(),
};
match current_node.parent {
// If a node is not a root, accumulate the transforms.
Some(parent_id) => {
let ancestors_transform = self.cumulative_node_transform(&parent_id);
node_transform.then(&ancestors_transform)
},
None => node_transform,
let node_to_root_transform =
node_to_parent_transform.then(&parent_transforms.node_to_root_transform);
let root_to_node_transform =
parent_transforms
.root_to_node_transform
.and_then(|parent_transform| {
parent_to_node_transform.map(|parent_to_node_transform| {
parent_transform.then(&parent_to_node_transform)
})
});
let transforms = ScrollTreeNodeTransformationCache {
node_to_root_transform,
root_to_node_transform,
};
(transforms, sticky_info)
}
fn calculate_sticky_offset(
ancestor_sticky_info: &AncestorStickyInfo,
info: &StickyNodeInfo,
) -> LayoutVector2D {
let viewport_scroll_offset = &ancestor_sticky_info.nearest_scrolling_ancestor_offset;
let viewport_rect = &ancestor_sticky_info.nearest_scrolling_ancestor_viewport;
if info.margins.top.is_none() &&
info.margins.bottom.is_none() &&
info.margins.left.is_none() &&
info.margins.right.is_none()
{
return LayoutVector2D::zero();
}
// The viewport and margins of the item establishes the maximum amount that it can
// be offset in order to keep it on screen. Since we care about the relationship
// between the scrolled content and unscrolled viewport we adjust the viewport's
// position by the scroll offset in order to work with their relative positions on the
// page.
let mut sticky_rect = info.frame_rect.translate(*viewport_scroll_offset);
let mut sticky_offset = LayoutVector2D::zero();
if let Some(margin) = info.margins.top {
let top_viewport_edge = viewport_rect.min.y + margin;
if sticky_rect.min.y < top_viewport_edge {
// If the sticky rect is positioned above the top edge of the viewport (plus margin)
// we move it down so that it is fully inside the viewport.
sticky_offset.y = top_viewport_edge - sticky_rect.min.y;
}
}
// If we don't have a sticky-top offset (sticky_offset.y + info.previously_applied_offset.y
// == 0), or if we have a previously-applied bottom offset (previously_applied_offset.y < 0)
// then we check for handling the bottom margin case. Note that the "don't have a sticky-top
// offset" case includes the case where we *had* a sticky-top offset but we reduced it to
// zero in the above block.
if sticky_offset.y <= 0.0 {
if let Some(margin) = info.margins.bottom {
// If sticky_offset.y is nonzero that means we must have set it
// in the sticky-top handling code above, so this item must have
// both top and bottom sticky margins. We adjust the item's rect
// by the top-sticky offset, and then combine any offset from
// the bottom-sticky calculation into sticky_offset below.
sticky_rect.min.y += sticky_offset.y;
sticky_rect.max.y += sticky_offset.y;
// Same as the above case, but inverted for bottom-sticky items. Here
// we adjust items upwards, resulting in a negative sticky_offset.y,
// or reduce the already-present upward adjustment, resulting in a positive
// sticky_offset.y.
let bottom_viewport_edge = viewport_rect.max.y - margin;
if sticky_rect.max.y > bottom_viewport_edge {
sticky_offset.y += bottom_viewport_edge - sticky_rect.max.y;
}
}
}
// Same as above, but for the x-axis.
if let Some(margin) = info.margins.left {
let left_viewport_edge = viewport_rect.min.x + margin;
if sticky_rect.min.x < left_viewport_edge {
sticky_offset.x = left_viewport_edge - sticky_rect.min.x;
}
}
if sticky_offset.x <= 0.0 {
if let Some(margin) = info.margins.right {
sticky_rect.min.x += sticky_offset.x;
sticky_rect.max.x += sticky_offset.x;
let right_viewport_edge = viewport_rect.max.x - margin;
if sticky_rect.max.x > right_viewport_edge {
sticky_offset.x += right_viewport_edge - sticky_rect.max.x;
}
}
}
// The total "sticky offset" (which is the sum that was already applied by
// the calling code, stored in info.previously_applied_offset, and the extra amount we
// computed as a result of scrolling, stored in sticky_offset) needs to be
// clamped to the provided bounds.
let clamp =
|value: f32, bounds: &StickyOffsetBounds| (value).max(bounds.min).min(bounds.max);
sticky_offset.y = clamp(sticky_offset.y, &info.vertical_offset_bounds);
sticky_offset.x = clamp(sticky_offset.x, &info.horizontal_offset_bounds);
sticky_offset
}
}