Implement renderer-backed nested scrolling
This commit is contained in:
1 parent
9fac700464
commit
478f888a6b
6 files changed
+1580
-66
No files matched your search
@@ -282,6 +282,49 @@ pub struct DomLayout {
|
||||
pub(crate) generated_boxes: Vec<GeneratedBox>,
|
||||
}
|
||||
|
||||
/// Dense identifier for one scrolling area in a prepared render. Index zero
|
||||
/// is always the root viewport; element scroll containers follow in DOM order.
|
||||
/// The ids are rebuild-local and must never be persisted by an embedding
|
||||
/// runtime (persist element offsets by `NodeId` instead).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ScrollId(pub(crate) u32);
|
||||
|
||||
impl ScrollId {
|
||||
pub(crate) const ROOT: Self = Self(0);
|
||||
|
||||
pub(crate) fn index(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct ScrollContainer {
|
||||
pub node: Option<NodeId>,
|
||||
pub parent: Option<ScrollId>,
|
||||
pub client_size: (f32, f32),
|
||||
pub content_size: (f32, f32),
|
||||
pub max_offset: (f32, f32),
|
||||
}
|
||||
|
||||
/// Immutable scrolling topology derived from one final layout. Hot paint and
|
||||
/// geometry paths index the two node vectors directly by `NodeId`; they never
|
||||
/// walk ancestors or hash a node to discover which scrolling area moves it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ScrollTree {
|
||||
pub containers: Vec<ScrollContainer>,
|
||||
/// Scrolling area established by this node, if any.
|
||||
pub node_container: Vec<Option<ScrollId>>,
|
||||
/// CSSOM scrolling-overflow size for every node with a layout box. This is
|
||||
/// populated even for `overflow:visible` and `overflow:clip`: those values
|
||||
/// expose real `scrollWidth`/`scrollHeight`, although only an actual scroll
|
||||
/// container receives a `ScrollId` and can change its offset.
|
||||
pub node_content_size: Vec<Option<(f32, f32)>>,
|
||||
/// Area whose content movement applies to this node's own box. A scroll
|
||||
/// container's border/background therefore retain the parent's owner,
|
||||
/// while its descendants use the container itself.
|
||||
pub movement_owner: Vec<Option<ScrollId>>,
|
||||
}
|
||||
|
||||
/// Root-scroll sticky-position constraints captured from normal-flow layout.
|
||||
///
|
||||
/// The normal boxes stay immutable in the layout cache. A scroll offset is
|
||||
@@ -472,6 +515,283 @@ impl DomLayout {
|
||||
fixed
|
||||
}
|
||||
|
||||
/// Build the dense element-scroll topology and local scrolling overflow.
|
||||
///
|
||||
/// This is deliberately one pair of DOM-order passes. The first assigns
|
||||
/// each box to its nearest scrolling-content owner; the second unions each
|
||||
/// box into exactly that owner. A nested scroller's own border box thus
|
||||
/// contributes to the outer area, while its hidden inner overflow only
|
||||
/// contributes to the nested area.
|
||||
pub(crate) fn scroll_tree(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
root_content_size: (f32, f32),
|
||||
viewport_fixed: &HashSet<NodeId>,
|
||||
) -> ScrollTree {
|
||||
let nodes = tree.descendants(tree.document());
|
||||
let node_capacity = nodes
|
||||
.iter()
|
||||
.map(|id| id.index())
|
||||
.max()
|
||||
.map_or(0, |max| max + 1);
|
||||
let mut node_container = vec![None; node_capacity];
|
||||
let mut movement_owner = vec![None; node_capacity];
|
||||
let mut node_content_size = vec![None; node_capacity];
|
||||
let mut containers = vec![ScrollContainer {
|
||||
node: None,
|
||||
parent: None,
|
||||
client_size: viewport,
|
||||
content_size: root_content_size,
|
||||
max_offset: (
|
||||
crate::quantized_scroll_range(root_content_size.0, viewport.0, 1.0),
|
||||
crate::quantized_scroll_range(root_content_size.1, viewport.1, 1.0),
|
||||
),
|
||||
}];
|
||||
|
||||
// `transforms` is accumulated top-down, so a non-translation matrix on
|
||||
// the candidate identifies either its own transform or an affine
|
||||
// ancestor. Stage 1 cannot insert scroll movement in that coordinate
|
||||
// space without making geometry and pixels disagree. A transformed
|
||||
// *descendant* is safe: its visual overflow is measured below and the
|
||||
// scrolling translation applies to the already transformed subtree.
|
||||
let mut affine_coordinate_space = vec![false; node_capacity];
|
||||
for &id in &nodes {
|
||||
if let Some(slot) = affine_coordinate_space.get_mut(id.index()) {
|
||||
*slot = self
|
||||
.transforms
|
||||
.get(&id)
|
||||
.is_some_and(|transform| !transform.is_translation());
|
||||
}
|
||||
}
|
||||
|
||||
let root = nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| tree.get_node(*id).is_some_and(|node| node.is_element()));
|
||||
fn assign(
|
||||
laid: &DomLayout,
|
||||
tree: &DomTree,
|
||||
id: NodeId,
|
||||
inherited_owner: Option<ScrollId>,
|
||||
viewport_fixed: &HashSet<NodeId>,
|
||||
affine_coordinate_space: &[bool],
|
||||
containers: &mut Vec<ScrollContainer>,
|
||||
node_container: &mut [Option<ScrollId>],
|
||||
movement_owner: &mut [Option<ScrollId>],
|
||||
) {
|
||||
let fixed = viewport_fixed.contains(&id);
|
||||
let parent_fixed = tree
|
||||
.get_node(id)
|
||||
.and_then(|node| node.parent)
|
||||
.is_some_and(|parent| viewport_fixed.contains(&parent));
|
||||
// Only the boundary that enters the viewport-fixed coordinate
|
||||
// space drops root movement. Descendants inherit that zero-based
|
||||
// space and may establish ordinary nested scrolling areas.
|
||||
let starts_viewport_fixed = fixed && !parent_fixed;
|
||||
let owner = if starts_viewport_fixed {
|
||||
None
|
||||
} else {
|
||||
inherited_owner
|
||||
};
|
||||
if let Some(slot) = movement_owner.get_mut(id.index()) {
|
||||
*slot = owner;
|
||||
}
|
||||
|
||||
let style = laid.styles.get(&id);
|
||||
let rect = laid.rects.get(&id).copied();
|
||||
let establishes = style.is_some_and(|style| {
|
||||
style.overflow_scroll_container && !style.overflow_propagated_to_viewport
|
||||
})
|
||||
&& rect.is_some()
|
||||
&& !affine_coordinate_space
|
||||
.get(id.index())
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
let child_owner = if establishes {
|
||||
let style = style.expect("scroll container style");
|
||||
let rect = rect.expect("scroll container rect");
|
||||
let client = (
|
||||
(rect.width - style.border.left - style.border.right).max(0.0),
|
||||
(rect.height - style.border.top - style.border.bottom).max(0.0),
|
||||
);
|
||||
let sid = ScrollId(containers.len() as u32);
|
||||
containers.push(ScrollContainer {
|
||||
node: Some(id),
|
||||
parent: owner,
|
||||
client_size: client,
|
||||
content_size: client,
|
||||
max_offset: (0.0, 0.0),
|
||||
});
|
||||
if let Some(slot) = node_container.get_mut(id.index()) {
|
||||
*slot = Some(sid);
|
||||
}
|
||||
Some(sid)
|
||||
} else {
|
||||
owner
|
||||
};
|
||||
|
||||
for child in rendered_children(tree, id) {
|
||||
assign(
|
||||
laid,
|
||||
tree,
|
||||
child,
|
||||
child_owner,
|
||||
viewport_fixed,
|
||||
affine_coordinate_space,
|
||||
containers,
|
||||
node_container,
|
||||
movement_owner,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(root) = root {
|
||||
assign(
|
||||
self,
|
||||
tree,
|
||||
root,
|
||||
Some(ScrollId::ROOT),
|
||||
viewport_fixed,
|
||||
&affine_coordinate_space,
|
||||
&mut containers,
|
||||
&mut node_container,
|
||||
&mut movement_owner,
|
||||
);
|
||||
}
|
||||
|
||||
// Compute scrolling overflow for every boxed node in one reverse pass.
|
||||
// A clipping/scrolling child contributes only its own border box to an
|
||||
// ancestor, while still exposing its complete local overflow through
|
||||
// CSSOM. Visible descendants propagate their union upward.
|
||||
let mut overflow_bounds = vec![None; node_capacity];
|
||||
for &id in nodes.iter().rev() {
|
||||
let Some(rect) = self.rects.get(&id).copied() else {
|
||||
continue;
|
||||
};
|
||||
let style = self.styles.get(&id);
|
||||
let visual = |rect: Rect| {
|
||||
self.transforms
|
||||
.get(&id)
|
||||
.copied()
|
||||
.map(|transform| transform.map_rect(rect))
|
||||
.unwrap_or(rect)
|
||||
};
|
||||
let padding_rect = style.map_or(rect, |style| Rect {
|
||||
x: rect.x + style.border.left,
|
||||
y: rect.y + style.border.top,
|
||||
width: (rect.width - style.border.left - style.border.right).max(0.0),
|
||||
height: (rect.height - style.border.top - style.border.bottom).max(0.0),
|
||||
});
|
||||
let visual_padding = visual(padding_rect);
|
||||
let mut local = visual_padding;
|
||||
for child in rendered_children(tree, id) {
|
||||
let Some(child_overflow) = overflow_bounds
|
||||
.get(child.index())
|
||||
.copied()
|
||||
.flatten()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let contribution = if let Some(style) = self.styles.get(&child).filter(|style| {
|
||||
style.overflow_hidden && !style.overflow_propagated_to_viewport
|
||||
}) {
|
||||
let border_box = self.rects
|
||||
.get(&child)
|
||||
.copied()
|
||||
.map(|rect| {
|
||||
self.transforms
|
||||
.get(&child)
|
||||
.copied()
|
||||
.map(|transform| transform.map_rect(rect))
|
||||
.unwrap_or(rect)
|
||||
})
|
||||
.unwrap_or(child_overflow);
|
||||
let x0 = if style.clips_overflow_x() {
|
||||
border_box.x
|
||||
} else {
|
||||
child_overflow.x
|
||||
};
|
||||
let x1 = if style.clips_overflow_x() {
|
||||
border_box.x + border_box.width
|
||||
} else {
|
||||
child_overflow.x + child_overflow.width
|
||||
};
|
||||
let y0 = if style.clips_overflow_y() {
|
||||
border_box.y
|
||||
} else {
|
||||
child_overflow.y
|
||||
};
|
||||
let y1 = if style.clips_overflow_y() {
|
||||
border_box.y + border_box.height
|
||||
} else {
|
||||
child_overflow.y + child_overflow.height
|
||||
};
|
||||
Rect {
|
||||
x: x0,
|
||||
y: y0,
|
||||
width: (x1 - x0).max(0.0),
|
||||
height: (y1 - y0).max(0.0),
|
||||
}
|
||||
} else {
|
||||
child_overflow
|
||||
};
|
||||
local = local.union(&contribution);
|
||||
}
|
||||
let mut content = (
|
||||
(local.x + local.width - visual_padding.x)
|
||||
.max(visual_padding.width),
|
||||
(local.y + local.height - visual_padding.y)
|
||||
.max(visual_padding.height),
|
||||
);
|
||||
// Scroll containers include trailing end padding in their
|
||||
// scrolling area. Visible/clip boxes expose descendant overflow
|
||||
// but, matching Chromium, do not append that trailing padding.
|
||||
if let Some(style) = style.filter(|style| style.overflow_scroll_container) {
|
||||
if content.0 > visual_padding.width {
|
||||
content.0 += style.padding.right;
|
||||
}
|
||||
if content.1 > visual_padding.height {
|
||||
content.1 += style.padding.bottom;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = node_content_size.get_mut(id.index()) {
|
||||
*slot = Some(if style.is_some_and(|style| style.ignores_used_box_sizes()) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
content
|
||||
});
|
||||
}
|
||||
if let Some(slot) = overflow_bounds.get_mut(id.index()) {
|
||||
*slot = Some(local);
|
||||
}
|
||||
}
|
||||
for container in containers.iter_mut().skip(1) {
|
||||
container.content_size = container
|
||||
.node
|
||||
.and_then(|node| node_content_size.get(node.index()).copied().flatten())
|
||||
.unwrap_or(container.client_size);
|
||||
container.max_offset = (
|
||||
crate::quantized_scroll_range(
|
||||
container.content_size.0,
|
||||
container.client_size.0,
|
||||
1.0,
|
||||
),
|
||||
crate::quantized_scroll_range(
|
||||
container.content_size.1,
|
||||
container.client_size.1,
|
||||
1.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ScrollTree {
|
||||
containers,
|
||||
node_container,
|
||||
node_content_size,
|
||||
movement_owner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Root scrolling overflow in CSS pixels. This is the union of laid-out
|
||||
/// document boxes after translate transforms, bounded by inherited
|
||||
/// overflow clips. Viewport-fixed subtrees do not enlarge the document.
|
||||
|
||||
@@ -93,9 +93,11 @@ mod image_capability_tests {
|
||||
mod paint;
|
||||
#[cfg(feature = "paint")]
|
||||
pub use paint::{
|
||||
paint_dom, paint_dom_scrolled, paint_prepared, prepare_dom, prepare_dom_with_dynamic_fonts,
|
||||
screenshot_png, screenshot_png_scrolled, screenshot_prepared, DynamicFontFace,
|
||||
PreparedRender, RenderResourceCache, RenderResourceLoader, SelectedImage,
|
||||
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_with_scroll, prepare_dom,
|
||||
prepare_dom_with_dynamic_fonts, screenshot_png, screenshot_png_scrolled,
|
||||
screenshot_prepared, screenshot_prepared_with_scroll, DynamicFontFace,
|
||||
ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader,
|
||||
ResolvedScrollState, SelectedImage,
|
||||
};
|
||||
|
||||
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
||||
@@ -357,6 +359,36 @@ impl Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a CSS scrolling value to the raster device-pixel grid.
|
||||
///
|
||||
/// Captures currently use one device pixel per CSS pixel, but keeping the
|
||||
/// scale explicit makes the same rule usable when page-level device scale is
|
||||
/// introduced. CSSOM integer extents and effective offsets must share this
|
||||
/// quantization or a fractional range can move geometry without moving a
|
||||
/// screenshot pixel.
|
||||
#[doc(hidden)]
|
||||
pub fn quantize_scroll_value(value: f32, device_scale_factor: f32) -> f32 {
|
||||
if !value.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let scale = if device_scale_factor.is_finite() && device_scale_factor > 0.0 {
|
||||
device_scale_factor
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(value * scale).round() / scale
|
||||
}
|
||||
|
||||
pub(crate) fn quantized_scroll_range(
|
||||
content: f32,
|
||||
client: f32,
|
||||
device_scale_factor: f32,
|
||||
) -> f32 {
|
||||
(quantize_scroll_value(content, device_scale_factor)
|
||||
- quantize_scroll_value(client, device_scale_factor))
|
||||
.max(0.0)
|
||||
}
|
||||
|
||||
/// Per-edge box values (margin / padding / border) in CSS pixels.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Edges {
|
||||
|
||||
@@ -306,6 +306,48 @@ pub struct SelectedImage {
|
||||
pub density: f32,
|
||||
}
|
||||
|
||||
/// CSSOM scrolling metrics for one element box. Non-scroll containers expose
|
||||
/// their padding-box size on both surfaces and retain a zero offset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ElementScrollMetrics {
|
||||
pub client_size: (f32, f32),
|
||||
pub content_size: (f32, f32),
|
||||
pub offset: (f32, f32),
|
||||
pub max_offset: (f32, f32),
|
||||
}
|
||||
|
||||
/// One fully resolved scrolling snapshot shared by geometry and paint. Node
|
||||
/// movement and inherited clips are dense vectors indexed directly by
|
||||
/// `NodeId`, so a capture pays one top-down resolution pass and no hot-path
|
||||
/// ancestor walks or per-node scroll hash lookups.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ResolvedScrollState {
|
||||
root_offset: (f32, f32),
|
||||
container_offsets: Vec<(f32, f32)>,
|
||||
node_movement: Vec<(f32, f32)>,
|
||||
inherited_clips: Vec<Option<crate::dom::OverflowClip>>,
|
||||
}
|
||||
|
||||
impl ResolvedScrollState {
|
||||
pub fn root_offset(&self) -> (f32, f32) {
|
||||
self.root_offset
|
||||
}
|
||||
|
||||
fn movement_for(&self, id: obscura_dom::tree::NodeId) -> (f32, f32) {
|
||||
self.node_movement
|
||||
.get(id.index())
|
||||
.copied()
|
||||
.unwrap_or((0.0, 0.0))
|
||||
}
|
||||
|
||||
fn inherited_clip_for(
|
||||
&self,
|
||||
id: obscura_dom::tree::NodeId,
|
||||
) -> Option<crate::dom::OverflowClip> {
|
||||
self.inherited_clips.get(id.index()).copied().flatten()
|
||||
}
|
||||
}
|
||||
|
||||
/// A final image/font-aware document layout retained across viewport paints.
|
||||
/// The DOM must not be mutated while this value is reused.
|
||||
pub struct PreparedRender {
|
||||
@@ -315,6 +357,7 @@ pub struct PreparedRender {
|
||||
content_size: (f32, f32),
|
||||
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||
sticky: crate::StickyLayout,
|
||||
scroll_tree: crate::dom::ScrollTree,
|
||||
selected_images: HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||
svg_fonts: Arc<usvg::fontdb::Database>,
|
||||
layout: crate::DomLayout,
|
||||
@@ -350,7 +393,10 @@ impl PreparedRender {
|
||||
pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) {
|
||||
let clamp_axis = |requested: f32, content: f32, viewport: f32| {
|
||||
if requested.is_finite() {
|
||||
requested.clamp(0.0, (content - viewport).max(0.0))
|
||||
crate::quantize_scroll_value(requested, 1.0).clamp(
|
||||
0.0,
|
||||
crate::quantized_scroll_range(content, viewport, 1.0),
|
||||
)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
@@ -361,6 +407,173 @@ impl PreparedRender {
|
||||
)
|
||||
}
|
||||
|
||||
/// Element scroll containers in this prepared layout, in stable DOM order.
|
||||
pub fn scroll_container_nodes(&self) -> impl Iterator<Item = obscura_dom::tree::NodeId> + '_ {
|
||||
self.scroll_tree
|
||||
.containers
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|container| container.node)
|
||||
}
|
||||
|
||||
/// Resolve persistent NodeId-keyed offsets into this layout's dense scroll
|
||||
/// topology. Unknown/removed nodes are ignored; every retained offset is
|
||||
/// clamped against the final local scrolling overflow.
|
||||
pub fn resolve_scroll_state(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
requested_root: (f32, f32),
|
||||
requested_elements: &HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||
) -> ResolvedScrollState {
|
||||
let root = self.clamp_scroll(requested_root);
|
||||
let mut offsets = vec![(0.0, 0.0); self.scroll_tree.containers.len()];
|
||||
offsets[0] = root;
|
||||
for (index, container) in self.scroll_tree.containers.iter().enumerate().skip(1) {
|
||||
let requested = container
|
||||
.node
|
||||
.and_then(|node| requested_elements.get(&node).copied())
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let clamp = |value: f32, max: f32| {
|
||||
if value.is_finite() {
|
||||
crate::quantize_scroll_value(value, 1.0).clamp(0.0, max)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
offsets[index] = (
|
||||
clamp(requested.0, container.max_offset.0),
|
||||
clamp(requested.1, container.max_offset.1),
|
||||
);
|
||||
}
|
||||
|
||||
let mut cumulative = vec![(0.0, 0.0); offsets.len()];
|
||||
cumulative[0] = (-root.0, -root.1);
|
||||
for index in 1..offsets.len() {
|
||||
let container = self.scroll_tree.containers[index];
|
||||
let inherited = container
|
||||
.parent
|
||||
.map(|parent| cumulative[parent.index()])
|
||||
.unwrap_or((0.0, 0.0));
|
||||
cumulative[index] = (
|
||||
inherited.0 - offsets[index].0,
|
||||
inherited.1 - offsets[index].1,
|
||||
);
|
||||
}
|
||||
|
||||
let node_len = self.scroll_tree.movement_owner.len();
|
||||
let mut node_movement = vec![(0.0, 0.0); node_len];
|
||||
for (index, owner) in self.scroll_tree.movement_owner.iter().copied().enumerate() {
|
||||
if let Some(owner) = owner {
|
||||
node_movement[index] = cumulative[owner.index()];
|
||||
}
|
||||
}
|
||||
// Stage 1 keeps the established root sticky behavior. Nested sticky is
|
||||
// intentionally absent from ScrollTree until its constraints are made
|
||||
// scroll-container-relative in Stage 2.
|
||||
for (id, sticky) in self.sticky.translations(self.viewport, root) {
|
||||
if let Some(movement) = node_movement.get_mut(id.index()) {
|
||||
movement.0 += sticky.0;
|
||||
movement.1 += sticky.1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut inherited_clips = vec![None; node_len];
|
||||
fn resolve_clips(
|
||||
tree: &DomTree,
|
||||
laid: &crate::DomLayout,
|
||||
movement: &[(f32, f32)],
|
||||
id: obscura_dom::tree::NodeId,
|
||||
inherited: Option<crate::dom::OverflowClip>,
|
||||
out: &mut [Option<crate::dom::OverflowClip>],
|
||||
) {
|
||||
if let Some(slot) = out.get_mut(id.index()) {
|
||||
*slot = inherited;
|
||||
}
|
||||
let next = match (laid.styles.get(&id), laid.rects.get(&id)) {
|
||||
(Some(style), Some(rect))
|
||||
if style.overflow_hidden && !style.overflow_propagated_to_viewport =>
|
||||
{
|
||||
let authored = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0));
|
||||
let scroll = movement.get(id.index()).copied().unwrap_or((0.0, 0.0));
|
||||
let own = crate::dom::OverflowClip::for_box(
|
||||
rect,
|
||||
style,
|
||||
authored.0 + scroll.0,
|
||||
authored.1 + scroll.1,
|
||||
);
|
||||
Some(match inherited {
|
||||
Some(clip) => clip.intersect(own),
|
||||
None => own,
|
||||
})
|
||||
}
|
||||
_ => inherited,
|
||||
};
|
||||
for child in crate::dom::rendered_children(tree, id) {
|
||||
resolve_clips(tree, laid, movement, child, next, out);
|
||||
}
|
||||
}
|
||||
if let Some(root_node) = tree
|
||||
.descendants(tree.document())
|
||||
.into_iter()
|
||||
.find(|id| tree.get_node(*id).is_some_and(|node| node.is_element()))
|
||||
{
|
||||
resolve_clips(
|
||||
tree,
|
||||
&self.layout,
|
||||
&node_movement,
|
||||
root_node,
|
||||
None,
|
||||
&mut inherited_clips,
|
||||
);
|
||||
}
|
||||
|
||||
ResolvedScrollState {
|
||||
root_offset: root,
|
||||
container_offsets: offsets,
|
||||
node_movement,
|
||||
inherited_clips,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn element_scroll_metrics(
|
||||
&self,
|
||||
id: obscura_dom::tree::NodeId,
|
||||
state: &ResolvedScrollState,
|
||||
) -> Option<ElementScrollMetrics> {
|
||||
let client = self.client_size(id)?;
|
||||
let sid = self
|
||||
.scroll_tree
|
||||
.node_container
|
||||
.get(id.index())
|
||||
.copied()
|
||||
.flatten();
|
||||
let Some(sid) = sid else {
|
||||
let content = self
|
||||
.scroll_tree
|
||||
.node_content_size
|
||||
.get(id.index())
|
||||
.copied()
|
||||
.flatten()?;
|
||||
return Some(ElementScrollMetrics {
|
||||
client_size: client,
|
||||
content_size: content,
|
||||
offset: (0.0, 0.0),
|
||||
max_offset: (0.0, 0.0),
|
||||
});
|
||||
};
|
||||
let container = self.scroll_tree.containers[sid.index()];
|
||||
Some(ElementScrollMetrics {
|
||||
client_size: container.client_size,
|
||||
content_size: container.content_size,
|
||||
offset: state
|
||||
.container_offsets
|
||||
.get(sid.index())
|
||||
.copied()
|
||||
.unwrap_or((0.0, 0.0)),
|
||||
max_offset: container.max_offset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Axis-aligned bounds of the transformed border box in immutable document
|
||||
/// space, excluding root-scroll and sticky movement.
|
||||
pub fn document_rect(
|
||||
@@ -797,6 +1010,21 @@ impl PreparedRender {
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
/// Border box in the viewport using a pre-resolved root + element scroll
|
||||
/// snapshot. This is the production CSSOM path; the tuple-only method
|
||||
/// above remains for compatibility callers that cannot own element state.
|
||||
pub fn viewport_rect_with_scroll(
|
||||
&self,
|
||||
id: obscura_dom::tree::NodeId,
|
||||
scroll: &ResolvedScrollState,
|
||||
) -> Option<crate::Rect> {
|
||||
let mut rect = self.document_rect(id)?;
|
||||
let movement = scroll.movement_for(id);
|
||||
rect.x += movement.0;
|
||||
rect.y += movement.1;
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
/// Every CSS border-box fragment in the current root viewport. Ordinary
|
||||
/// inlines can have one fragment per line; all other boxes expose their
|
||||
/// single border box. Keeping this separate from the bounding union is
|
||||
@@ -840,6 +1068,39 @@ impl PreparedRender {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn viewport_client_rects_with_scroll(
|
||||
&self,
|
||||
id: obscura_dom::tree::NodeId,
|
||||
scroll: &ResolvedScrollState,
|
||||
) -> Option<Vec<crate::Rect>> {
|
||||
let source = self
|
||||
.layout
|
||||
.inline_fragments
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.or_else(|| self.layout.rects.get(&id).copied().map(|rect| vec![rect]))?;
|
||||
let movement = scroll.movement_for(id);
|
||||
Some(
|
||||
source
|
||||
.into_iter()
|
||||
.map(|rect| {
|
||||
let rect = self
|
||||
.layout
|
||||
.transforms
|
||||
.get(&id)
|
||||
.copied()
|
||||
.map(|transform| transform.map_rect(rect))
|
||||
.unwrap_or(rect);
|
||||
crate::Rect {
|
||||
x: rect.x + movement.0,
|
||||
y: rect.y + movement.1,
|
||||
..rect
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn selected_image(
|
||||
&self,
|
||||
id: obscura_dom::tree::NodeId,
|
||||
@@ -1061,6 +1322,7 @@ pub fn prepare_dom_with_dynamic_fonts(
|
||||
let content_size = laid.scrolling_content_size(tree, viewport);
|
||||
let viewport_fixed = laid.viewport_fixed_nodes(tree);
|
||||
let sticky = laid.root_sticky_layout(tree, viewport);
|
||||
let scroll_tree = laid.scroll_tree(tree, viewport, content_size, &viewport_fixed);
|
||||
let root_font_size = tree
|
||||
.query_selector("html")
|
||||
.ok()
|
||||
@@ -1075,6 +1337,7 @@ pub fn prepare_dom_with_dynamic_fonts(
|
||||
content_size,
|
||||
viewport_fixed,
|
||||
sticky,
|
||||
scroll_tree,
|
||||
selected_images,
|
||||
svg_fonts,
|
||||
layout: laid,
|
||||
@@ -1097,6 +1360,41 @@ pub fn paint_prepared(
|
||||
prepared.viewport,
|
||||
prepared.base_url.as_deref(),
|
||||
scroll,
|
||||
None,
|
||||
pixmap,
|
||||
resources,
|
||||
&prepared.selected_images,
|
||||
&prepared.svg_fonts,
|
||||
prepared.content_size,
|
||||
&prepared.viewport_fixed,
|
||||
&prepared.sticky,
|
||||
&mut prepared.layout,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
(0.0, 0.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Paint from the same resolved root + element scroll snapshot used by CSSOM
|
||||
/// geometry. No layout or scroll-tree work is performed by this call.
|
||||
pub fn paint_prepared_with_scroll(
|
||||
tree: &DomTree,
|
||||
prepared: &mut PreparedRender,
|
||||
resources: &mut RenderResourceCache,
|
||||
scroll: &ResolvedScrollState,
|
||||
) -> Option<Pixmap> {
|
||||
let (w, h) = (prepared.viewport.0 as u32, prepared.viewport.1 as u32);
|
||||
let mut pixmap = Pixmap::new(w, h)?;
|
||||
pixmap.fill(Color::WHITE);
|
||||
paint_laid_dom_scrolled(
|
||||
tree,
|
||||
prepared.viewport,
|
||||
prepared.base_url.as_deref(),
|
||||
scroll.root_offset(),
|
||||
Some(scroll),
|
||||
pixmap,
|
||||
resources,
|
||||
&prepared.selected_images,
|
||||
@@ -1217,6 +1515,7 @@ fn paint_laid_dom_scrolled(
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
scroll: (f32, f32),
|
||||
resolved_scroll: Option<&ResolvedScrollState>,
|
||||
mut pixmap: Pixmap,
|
||||
image_cache: &mut RenderResourceCache,
|
||||
selected_images: &HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||
@@ -1232,16 +1531,26 @@ fn paint_laid_dom_scrolled(
|
||||
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||
surface_offset: (f32, f32),
|
||||
) -> Option<Pixmap> {
|
||||
let scroll_state = ScrollPaintState::new(
|
||||
tree,
|
||||
viewport,
|
||||
scroll,
|
||||
content_size,
|
||||
viewport_fixed,
|
||||
sticky,
|
||||
clip_scope_root,
|
||||
surface_offset,
|
||||
);
|
||||
let scroll_state = match resolved_scroll {
|
||||
Some(resolved) => ScrollPaintState::from_resolved(
|
||||
tree,
|
||||
viewport,
|
||||
viewport_fixed,
|
||||
resolved,
|
||||
clip_scope_root,
|
||||
surface_offset,
|
||||
),
|
||||
None => ScrollPaintState::new(
|
||||
tree,
|
||||
viewport,
|
||||
scroll,
|
||||
content_size,
|
||||
viewport_fixed,
|
||||
sticky,
|
||||
clip_scope_root,
|
||||
surface_offset,
|
||||
),
|
||||
};
|
||||
let root_font_size = tree
|
||||
.query_selector("html")
|
||||
.ok()
|
||||
@@ -1410,6 +1719,7 @@ fn paint_laid_dom_scrolled(
|
||||
viewport,
|
||||
base_url,
|
||||
scroll,
|
||||
resolved_scroll,
|
||||
pixmap,
|
||||
image_cache,
|
||||
selected_images,
|
||||
@@ -1490,6 +1800,7 @@ fn paint_laid_dom_scrolled(
|
||||
viewport,
|
||||
base_url,
|
||||
scroll,
|
||||
resolved_scroll,
|
||||
pixmap,
|
||||
image_cache,
|
||||
selected_images,
|
||||
@@ -1558,6 +1869,7 @@ fn paint_laid_dom_scrolled(
|
||||
viewport,
|
||||
base_url,
|
||||
scroll,
|
||||
resolved_scroll,
|
||||
layer,
|
||||
image_cache,
|
||||
selected_images,
|
||||
@@ -1623,6 +1935,7 @@ fn paint_laid_dom_scrolled(
|
||||
viewport,
|
||||
base_url,
|
||||
scroll,
|
||||
resolved_scroll,
|
||||
layer,
|
||||
image_cache,
|
||||
selected_images,
|
||||
@@ -2488,6 +2801,7 @@ struct ScrollPaintState<'a> {
|
||||
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||
sticky: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||
resolved: Option<&'a ResolvedScrollState>,
|
||||
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||
surface_offset: (f32, f32),
|
||||
active: bool,
|
||||
@@ -2505,16 +2819,18 @@ impl<'a> ScrollPaintState<'a> {
|
||||
surface_offset: (f32, f32),
|
||||
) -> Self {
|
||||
let scroll_x = if requested.0.is_finite() {
|
||||
requested
|
||||
.0
|
||||
.clamp(0.0, (content.0 - viewport.0).max(0.0))
|
||||
crate::quantize_scroll_value(requested.0, 1.0).clamp(
|
||||
0.0,
|
||||
crate::quantized_scroll_range(content.0, viewport.0, 1.0),
|
||||
)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let scroll_y = if requested.1.is_finite() {
|
||||
requested
|
||||
.1
|
||||
.clamp(0.0, (content.1 - viewport.1).max(0.0))
|
||||
crate::quantize_scroll_value(requested.1, 1.0).clamp(
|
||||
0.0,
|
||||
crate::quantized_scroll_range(content.1, viewport.1, 1.0),
|
||||
)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -2528,6 +2844,7 @@ impl<'a> ScrollPaintState<'a> {
|
||||
viewport_fixed,
|
||||
sticky: std::collections::HashMap::new(),
|
||||
sticky_clips: std::collections::HashMap::new(),
|
||||
resolved: None,
|
||||
clip_scope_root,
|
||||
surface_offset,
|
||||
active,
|
||||
@@ -2543,18 +2860,48 @@ impl<'a> ScrollPaintState<'a> {
|
||||
viewport_fixed,
|
||||
sticky,
|
||||
sticky_clips,
|
||||
resolved: None,
|
||||
clip_scope_root,
|
||||
surface_offset,
|
||||
active,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_resolved(
|
||||
tree: &'a DomTree,
|
||||
viewport: (f32, f32),
|
||||
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||
resolved: &'a ResolvedScrollState,
|
||||
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||
surface_offset: (f32, f32),
|
||||
) -> Self {
|
||||
Self {
|
||||
tree,
|
||||
viewport,
|
||||
scroll: resolved.root_offset(),
|
||||
viewport_fixed,
|
||||
sticky: std::collections::HashMap::new(),
|
||||
sticky_clips: std::collections::HashMap::new(),
|
||||
resolved: Some(resolved),
|
||||
clip_scope_root,
|
||||
surface_offset,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn translation_for(
|
||||
&self,
|
||||
laid: &crate::DomLayout,
|
||||
id: obscura_dom::tree::NodeId,
|
||||
) -> (f32, f32) {
|
||||
let base = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0));
|
||||
if let Some(resolved) = self.resolved {
|
||||
let movement = resolved.movement_for(id);
|
||||
return (
|
||||
base.0 + movement.0 + self.surface_offset.0,
|
||||
base.1 + movement.1 + self.surface_offset.1,
|
||||
);
|
||||
}
|
||||
if !self.active || self.viewport_fixed.contains(&id) {
|
||||
return (base.0 + self.surface_offset.0, base.1 + self.surface_offset.1);
|
||||
}
|
||||
@@ -2615,6 +2962,11 @@ impl<'a> ScrollPaintState<'a> {
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
if let Some(resolved) = self.resolved {
|
||||
let mut clip = resolved.inherited_clip_for(id)?;
|
||||
clip.translate(self.surface_offset.0, self.surface_offset.1);
|
||||
return Some(clip);
|
||||
}
|
||||
let mut clip = laid.clip_rects.get(&id).copied().flatten()?;
|
||||
if self.active && !self.viewport_fixed.contains(&id) {
|
||||
let sticky = self
|
||||
@@ -3380,6 +3732,17 @@ pub fn screenshot_prepared(
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn screenshot_prepared_with_scroll(
|
||||
tree: &DomTree,
|
||||
prepared: &mut PreparedRender,
|
||||
resources: &mut RenderResourceCache,
|
||||
scroll: &ResolvedScrollState,
|
||||
) -> Option<Vec<u8>> {
|
||||
paint_prepared_with_scroll(tree, prepared, resources, scroll)?
|
||||
.encode_png()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A representative visible color for `background-clip: text` text whose own
|
||||
/// color is transparent, used on the word-split paint path (the cosmic-text IFC
|
||||
/// path samples the gradient per glyph in `inline`). Returns the gradient's mid
|
||||
@@ -9429,4 +9792,90 @@ mod tests {
|
||||
vec!["https://example.test/fonts/fixture.ttf".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_nested_scroll_keeps_owner_and_clip_stationary_while_pixels_move() {
|
||||
let tree = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="outer" style="box-sizing:border-box;width:120px;height:100px;
|
||||
border:4px solid red;overflow:hidden;position:relative;background:red">
|
||||
<div id="inner" style="width:220px;height:200px;overflow:hidden;
|
||||
position:relative;background:blue">
|
||||
<div id="target" style="position:absolute;left:300px;top:280px;
|
||||
width:30px;height:20px;background:lime"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let outer = tree.get_element_by_id("outer").expect("outer");
|
||||
let inner = tree.get_element_by_id("inner").expect("inner");
|
||||
let target = tree.get_element_by_id("target").expect("target");
|
||||
let mut resources = RenderResourceCache::with_loader(|_url: &str| None);
|
||||
let mut prepared = prepare_dom(&tree, (360.0, 240.0), None, &mut resources)
|
||||
.expect("prepared render");
|
||||
let top_state = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new());
|
||||
let top_outer = prepared
|
||||
.viewport_rect_with_scroll(outer, &top_state)
|
||||
.expect("top outer");
|
||||
let top_target = prepared
|
||||
.viewport_rect_with_scroll(target, &top_state)
|
||||
.expect("top target");
|
||||
let top = paint_prepared_with_scroll(&tree, &mut prepared, &mut resources, &top_state)
|
||||
.expect("top paint");
|
||||
|
||||
let offsets = HashMap::from([(outer, (9999.0, 9999.0)), (inner, (9999.0, 9999.0))]);
|
||||
let scrolled_state = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &offsets);
|
||||
let outer_metrics = prepared
|
||||
.element_scroll_metrics(outer, &scrolled_state)
|
||||
.expect("outer metrics");
|
||||
let inner_metrics = prepared
|
||||
.element_scroll_metrics(inner, &scrolled_state)
|
||||
.expect("inner metrics");
|
||||
assert_eq!(outer_metrics.client_size, (112.0, 92.0));
|
||||
assert_eq!(outer_metrics.content_size, (220.0, 200.0));
|
||||
assert_eq!(outer_metrics.offset, (108.0, 108.0));
|
||||
assert_eq!(inner_metrics.content_size, (330.0, 300.0));
|
||||
assert_eq!(inner_metrics.offset, (110.0, 100.0));
|
||||
|
||||
let scrolled_outer = prepared
|
||||
.viewport_rect_with_scroll(outer, &scrolled_state)
|
||||
.expect("scrolled outer");
|
||||
let scrolled_target = prepared
|
||||
.viewport_rect_with_scroll(target, &scrolled_state)
|
||||
.expect("scrolled target");
|
||||
assert_eq!(scrolled_outer, top_outer, "scroller chrome must not move with its content");
|
||||
assert_eq!(scrolled_target.x, top_target.x - 218.0);
|
||||
assert_eq!(scrolled_target.y, top_target.y - 208.0);
|
||||
|
||||
let scrolled = paint_prepared_with_scroll(
|
||||
&tree,
|
||||
&mut prepared,
|
||||
&mut resources,
|
||||
&scrolled_state,
|
||||
)
|
||||
.expect("scrolled paint");
|
||||
let repeated = paint_prepared_with_scroll(
|
||||
&tree,
|
||||
&mut prepared,
|
||||
&mut resources,
|
||||
&scrolled_state,
|
||||
)
|
||||
.expect("repeated paint");
|
||||
assert_ne!(top.data(), scrolled.data());
|
||||
assert_eq!(scrolled.data(), repeated.data(), "scroll deltas must not accumulate");
|
||||
let pixel = |pixmap: &Pixmap, x: u32, y: u32| {
|
||||
pixmap.pixels()[(y * pixmap.width() + x) as usize]
|
||||
};
|
||||
assert_eq!(pixel(&top, 2, 2), pixel(&scrolled, 2, 2), "outer border moved");
|
||||
assert_eq!(
|
||||
pixel(&top, 125, 50),
|
||||
pixel(&scrolled, 125, 50),
|
||||
"content escaped the stationary outer clip"
|
||||
);
|
||||
assert_ne!(
|
||||
pixel(&top, 95, 85),
|
||||
pixel(&scrolled, 95, 85),
|
||||
"newly visible nested content did not move into the scrollport"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user