mirror of
https://github.com/lexmount/moli.git
synced 2026-10-07 08:01:00 +00:00
fix(layout): derive directional scroll geometry from fragment bounds
Preserve pre-relative in-flow positions and used margins in the Taffy layout result, keeping alignment bounds separate from transformed visual overflow. Resolve reachable regions and signed scroll ranges once for scrollbar feedback and frozen CSSOM/paint projection. Retain fixed descendants of transformed containing blocks in their overflow graph. Cover writing directions, reverse flex packing, relative and transformed fragments, collapsing and negative margins, zero-area bounds, fixed containing blocks, and removal after capture. Chromium-checked release probes match 1668 geometry and 180 pixel checks; overflow focus gains 10 cases while the previous 306-case focus is unchanged. Full workspace fmt, Clippy and 17193 Nextest tests pass.
This commit is contained in:
Generated
+1
-1
@@ -4842,7 +4842,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/ldm0/taffy?rev=151ce0426f1b0d8a0fa312192f4f1356b982bd51#151ce0426f1b0d8a0fa312192f4f1356b982bd51"
|
||||
source = "git+https://github.com/ldm0/taffy?rev=1301f8118a0fd0e7e151eb61d16ec137df6f2039#1301f8118a0fd0e7e151eb61d16ec137df6f2039"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ stylo_atoms = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e937
|
||||
stylo_dom = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" }
|
||||
stylo_static_prefs = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" }
|
||||
stylo_traits = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" }
|
||||
taffy = { git = "https://github.com/ldm0/taffy", rev = "151ce0426f1b0d8a0fa312192f4f1356b982bd51" }
|
||||
taffy = { git = "https://github.com/ldm0/taffy", rev = "1301f8118a0fd0e7e151eb61d16ec137df6f2039" }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
disallowed_methods = "deny"
|
||||
|
||||
+300
-54
@@ -1,10 +1,149 @@
|
||||
use std::{collections::VecDeque, fmt::Debug, hash::Hash};
|
||||
|
||||
use crate::{
|
||||
LayoutBoxId, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutTransform2D, LayoutViewport,
|
||||
LayoutWorld, style::ResolvedLayoutTransform,
|
||||
LayoutBoxId, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutSize, LayoutTransform2D,
|
||||
LayoutViewport, LayoutWorld,
|
||||
style::{LayoutOverflowMode, ResolvedLayoutStyle, ResolvedLayoutTransform},
|
||||
};
|
||||
|
||||
/// The physical edge from which an axis scrolls. Flex packing can reverse it
|
||||
/// independently of the writing direction (including with wrap-reverse).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ScrollStartEdge {
|
||||
Min,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl ScrollStartEdge {
|
||||
fn from_reversed(reversed: bool) -> Self {
|
||||
if reversed { Self::Max } else { Self::Min }
|
||||
}
|
||||
|
||||
fn reachable_edges(self, min: f32, max: f32, port_min: f32, port_max: f32) -> (f32, f32) {
|
||||
match self {
|
||||
Self::Min => (min.max(port_min), max.max(port_min)),
|
||||
Self::Max => (min.min(port_max), max.min(port_max)),
|
||||
}
|
||||
}
|
||||
|
||||
fn flow_margins(self, min: f32, max: f32, size: f32) -> (f32, f32) {
|
||||
// Negative margins can retract the scroll-end edge, but cannot remove
|
||||
// more than the fragment's size or retract the opposite edge.
|
||||
match self {
|
||||
Self::Min => (min.max(0.0), max.max(-size)),
|
||||
Self::Max => (min.max(-size), max.max(0.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ScrollOrigin {
|
||||
horizontal: ScrollStartEdge,
|
||||
vertical: ScrollStartEdge,
|
||||
}
|
||||
|
||||
impl ScrollOrigin {
|
||||
fn for_style(style: &ResolvedLayoutStyle, is_viewport: bool) -> Self {
|
||||
let mode = style.writing_mode();
|
||||
let mut inline_reversed = mode.is_inline_flow_reversed(style.taffy.direction);
|
||||
let mut block_reversed = mode.is_block_flow_reversed();
|
||||
if !is_viewport && style.taffy.display == taffy::Display::Flex {
|
||||
let reverse = matches!(
|
||||
style.taffy.flex_direction,
|
||||
taffy::FlexDirection::RowReverse | taffy::FlexDirection::ColumnReverse
|
||||
);
|
||||
let wrap_reverse = style.taffy.flex_wrap == taffy::FlexWrap::WrapReverse;
|
||||
if matches!(
|
||||
style.taffy.flex_direction,
|
||||
taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse
|
||||
) {
|
||||
block_reversed ^= reverse;
|
||||
inline_reversed ^= wrap_reverse;
|
||||
} else {
|
||||
inline_reversed ^= reverse;
|
||||
block_reversed ^= wrap_reverse;
|
||||
}
|
||||
}
|
||||
let reversed = mode.to_physical(taffy::LogicalSize {
|
||||
inline_size: inline_reversed,
|
||||
block_size: block_reversed,
|
||||
});
|
||||
Self {
|
||||
horizontal: ScrollStartEdge::from_reversed(reversed.width),
|
||||
vertical: ScrollStartEdge::from_reversed(reversed.height),
|
||||
}
|
||||
}
|
||||
|
||||
fn reachable_rect(self, rect: LayoutRect, port: LayoutRect) -> LayoutRect {
|
||||
let (left, right) =
|
||||
self.horizontal
|
||||
.reachable_edges(rect.x, rect.right(), port.x, port.right());
|
||||
let (top, bottom) =
|
||||
self.vertical
|
||||
.reachable_edges(rect.y, rect.bottom(), port.y, port.bottom());
|
||||
LayoutRect::new(left, top, right - left, bottom - top)
|
||||
}
|
||||
|
||||
fn add_visual_overflow(
|
||||
self,
|
||||
overflow: &mut LayoutRect,
|
||||
contribution: LayoutRect,
|
||||
scrollport: Option<LayoutRect>,
|
||||
) {
|
||||
let reachable =
|
||||
scrollport.map_or(contribution, |port| self.reachable_rect(contribution, port));
|
||||
// Clip before union: a rectangle unreachable in one axis must not
|
||||
// extend the other axis through the empty space back to the scrollport.
|
||||
if reachable.width > 0.0 && reachable.height > 0.0 {
|
||||
*overflow = overflow.union(reachable);
|
||||
}
|
||||
}
|
||||
|
||||
fn flow_bounds(self, flow: taffy::InFlowLayout, size: taffy::Size<f32>) -> LayoutRect {
|
||||
let (left, right) =
|
||||
self.horizontal
|
||||
.flow_margins(flow.margin.left, flow.margin.right, size.width);
|
||||
let (top, bottom) =
|
||||
self.vertical
|
||||
.flow_margins(flow.margin.top, flow.margin.bottom, size.height);
|
||||
LayoutRect::new(
|
||||
flow.location.x - left,
|
||||
flow.location.y - top,
|
||||
size.width + left + right,
|
||||
size.height + top + bottom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One derivation of content extent and signed scroll range, shared by
|
||||
/// automatic scrollbar feedback and frozen CSSOM/paint projection.
|
||||
pub(crate) struct ScrollDimensions {
|
||||
pub(crate) size: LayoutSize,
|
||||
pub(crate) minimum: LayoutPoint,
|
||||
pub(crate) maximum: LayoutPoint,
|
||||
}
|
||||
|
||||
impl ScrollDimensions {
|
||||
fn from_rects(port: LayoutRect, overflow: LayoutRect) -> Self {
|
||||
let overflow = port.union(overflow);
|
||||
Self {
|
||||
size: LayoutSize::new(overflow.width, overflow.height),
|
||||
minimum: LayoutPoint::new(overflow.x - port.x, overflow.y - port.y),
|
||||
maximum: LayoutPoint::new(
|
||||
overflow.right() - port.right(),
|
||||
overflow.bottom() - port.bottom(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn overflowing_axes(&self) -> (bool, bool) {
|
||||
(
|
||||
self.maximum.x - self.minimum.x > f32::EPSILON,
|
||||
self.maximum.y - self.minimum.y > f32::EPSILON,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry needed to resolve scrollable overflow and no other projection
|
||||
/// concern. Keeping this sidecar smaller than `OutputProjection` lets automatic
|
||||
/// scrollbar feedback converge without allocating sources, fragments, clips,
|
||||
@@ -22,6 +161,8 @@ pub(crate) struct OverflowBoxGeometry {
|
||||
pub(crate) horizontal_gutter: f32,
|
||||
pub(crate) horizontal_leading_gutter: f32,
|
||||
local_overflow: LayoutRect,
|
||||
inflow_bounds: Option<LayoutRect>,
|
||||
scroll_origin: ScrollOrigin,
|
||||
}
|
||||
|
||||
/// One pass-local, incrementally refreshed scrollable-overflow projection.
|
||||
@@ -84,6 +225,14 @@ impl OverflowProjection {
|
||||
self.scrollable_overflow[id.index()]
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_dimensions(&self, id: LayoutBoxId) -> ScrollDimensions {
|
||||
let geometry = self.geometry(id);
|
||||
let reachable = geometry
|
||||
.scroll_origin
|
||||
.reachable_rect(self.scrollable_overflow(id), geometry.local_scrollport);
|
||||
ScrollDimensions::from_rects(geometry.local_scrollport, reachable)
|
||||
}
|
||||
|
||||
pub(crate) fn overflowing_axes<N>(
|
||||
&self,
|
||||
world: &LayoutWorld<N>,
|
||||
@@ -95,22 +244,7 @@ impl OverflowProjection {
|
||||
if !establishes_scroll_container(world, id) {
|
||||
return (false, false);
|
||||
}
|
||||
let geometry = self.geometry(id);
|
||||
let overflow = self.scrollable_overflow(id);
|
||||
let horizontal_range = geometry
|
||||
.local_scrollport
|
||||
.width
|
||||
.max((overflow.right() - geometry.local_scrollport.x).max(0.0))
|
||||
- geometry.local_scrollport.width;
|
||||
let vertical_range = geometry
|
||||
.local_scrollport
|
||||
.height
|
||||
.max((overflow.bottom() - geometry.local_scrollport.y).max(0.0))
|
||||
- geometry.local_scrollport.height;
|
||||
(
|
||||
horizontal_range > f32::EPSILON,
|
||||
vertical_range > f32::EPSILON,
|
||||
)
|
||||
self.scroll_dimensions(id).overflowing_axes()
|
||||
}
|
||||
|
||||
/// Reprojects the boxes changed by numeric layout and their overflow
|
||||
@@ -222,9 +356,40 @@ impl OverflowProjection {
|
||||
where
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
{
|
||||
let mut overflow = self.geometries[id.index()].local_overflow;
|
||||
let geometry = self.geometries[id.index()];
|
||||
let mut overflow = geometry.local_overflow;
|
||||
let is_scroller = establishes_scroll_container(world, id);
|
||||
let mut inflow_bounds = is_scroller.then_some(geometry.inflow_bounds).flatten();
|
||||
for child in self.children[id.index()].iter().copied() {
|
||||
overflow = overflow.union(self.child_contribution(world, child));
|
||||
geometry.scroll_origin.add_visual_overflow(
|
||||
&mut overflow,
|
||||
self.child_contribution(world, child),
|
||||
is_scroller.then_some(geometry.local_scrollport),
|
||||
);
|
||||
if is_scroller {
|
||||
let layout = world.boxes[child.index()].final_layout;
|
||||
if let Some(flow) = layout.in_flow {
|
||||
let bounds = geometry.scroll_origin.flow_bounds(flow, layout.size);
|
||||
inflow_bounds =
|
||||
Some(inflow_bounds.map_or(bounds, |current| current.union(bounds)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(bounds) = inflow_bounds {
|
||||
let padding = world.boxes[id.index()].final_layout.padding;
|
||||
let padded = outset_rect(
|
||||
bounds,
|
||||
padding.top,
|
||||
padding.right,
|
||||
padding.bottom,
|
||||
padding.left,
|
||||
);
|
||||
// Even zero-area in-flow fragments establish trailing padding.
|
||||
overflow = overflow.union(
|
||||
geometry
|
||||
.scroll_origin
|
||||
.reachable_rect(padded, geometry.local_scrollport),
|
||||
);
|
||||
}
|
||||
self.scrollable_overflow[id.index()] = overflow;
|
||||
}
|
||||
@@ -234,22 +399,23 @@ impl OverflowProjection {
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
{
|
||||
let geometry = self.geometries[child.index()];
|
||||
let visual_overflow = if clips_overflow(world, child) {
|
||||
geometry.border_box
|
||||
} else {
|
||||
self.scrollable_overflow[child.index()]
|
||||
};
|
||||
let mut visual_overflow = self.scrollable_overflow[child.index()];
|
||||
let modes = overflow_modes(world, child);
|
||||
if modes[0] != LayoutOverflowMode::Visible {
|
||||
let right = visual_overflow.right().min(geometry.border_box.right());
|
||||
visual_overflow.x = visual_overflow.x.max(geometry.border_box.x);
|
||||
visual_overflow.width = (right - visual_overflow.x).max(0.0);
|
||||
}
|
||||
if modes[1] != LayoutOverflowMode::Visible {
|
||||
let bottom = visual_overflow.bottom().min(geometry.border_box.bottom());
|
||||
visual_overflow.y = visual_overflow.y.max(geometry.border_box.y);
|
||||
visual_overflow.height = (bottom - visual_overflow.y).max(0.0);
|
||||
}
|
||||
let visual_overflow = geometry.border_box.union(visual_overflow);
|
||||
let location = world.boxes[child.index()].final_layout.location;
|
||||
let layout_translation = LayoutTransform2D::translation(location.x, location.y);
|
||||
let local_to_parent = layout_translation.concatenate(geometry.resolved_transform.transform);
|
||||
local_to_parent
|
||||
.map_rect(visual_overflow)
|
||||
.bounding_rect()
|
||||
.union(
|
||||
layout_translation
|
||||
.map_rect(geometry.margin_box)
|
||||
.bounding_rect(),
|
||||
)
|
||||
local_to_parent.map_rect(visual_overflow).bounding_rect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,10 +423,17 @@ fn overflow_parent<N>(world: &LayoutWorld<N>, id: LayoutBoxId) -> Option<LayoutB
|
||||
where
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
{
|
||||
if id == world.root || world.boxes[id.index()].style.is_fixed_positioned() {
|
||||
let layout_box = &world.boxes[id.index()];
|
||||
// Only viewport-anchored fixed boxes are outside the document's scrolling
|
||||
// contents. A fixed child of a transformed containing block still belongs
|
||||
// to that block's overflow and scroll translation.
|
||||
if id == world.root
|
||||
|| (layout_box.style.is_fixed_positioned()
|
||||
&& layout_box.positioned_containing_block.is_none())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
world.boxes[id.index()].layout_parent.or(Some(world.root))
|
||||
layout_box.layout_parent.or(Some(world.root))
|
||||
}
|
||||
|
||||
fn project_box<N>(
|
||||
@@ -325,18 +498,10 @@ where
|
||||
horizontal_gutter,
|
||||
horizontal_leading_gutter,
|
||||
);
|
||||
let mut local_overflow = LayoutRect::new(
|
||||
local_scrollport.x,
|
||||
local_scrollport.y,
|
||||
local_scrollport
|
||||
.width
|
||||
.max(layout.content_size.width)
|
||||
.max(0.0),
|
||||
local_scrollport
|
||||
.height
|
||||
.max(layout.content_size.height)
|
||||
.max(0.0),
|
||||
);
|
||||
let mut local_overflow = local_scrollport;
|
||||
let scroll_origin = ScrollOrigin::for_style(&layout_box.style, is_root);
|
||||
let clip_to_scrollport = establishes_scroll_container(world, id).then_some(local_scrollport);
|
||||
let mut inflow_bounds: Option<LayoutRect> = None;
|
||||
if is_root {
|
||||
local_overflow = local_overflow.union(padding_box);
|
||||
}
|
||||
@@ -358,15 +523,27 @@ where
|
||||
},
|
||||
);
|
||||
for line in &context.fragments.lines {
|
||||
local_overflow = local_overflow.union(offset_rect(line.rect, origin));
|
||||
let rect = offset_rect(line.rect, origin);
|
||||
if rect.width > 0.0 && rect.height > 0.0 {
|
||||
local_overflow = local_overflow.union(rect);
|
||||
inflow_bounds = Some(inflow_bounds.map_or(rect, |current| current.union(rect)));
|
||||
}
|
||||
}
|
||||
for fragment in &context.fragments.text {
|
||||
if fragment.kind == crate::inline::InlineTextFragmentKind::Content {
|
||||
local_overflow = local_overflow.union(offset_rect(fragment.rect, origin));
|
||||
scroll_origin.add_visual_overflow(
|
||||
&mut local_overflow,
|
||||
offset_rect(fragment.rect, origin),
|
||||
clip_to_scrollport,
|
||||
);
|
||||
}
|
||||
}
|
||||
for fragment in &context.fragments.boxes {
|
||||
local_overflow = local_overflow.union(offset_rect(fragment.box_model.border, origin));
|
||||
scroll_origin.add_visual_overflow(
|
||||
&mut local_overflow,
|
||||
offset_rect(fragment.box_model.border, origin),
|
||||
clip_to_scrollport,
|
||||
);
|
||||
}
|
||||
}
|
||||
OverflowBoxGeometry {
|
||||
@@ -381,6 +558,8 @@ where
|
||||
horizontal_gutter,
|
||||
horizontal_leading_gutter,
|
||||
local_overflow,
|
||||
inflow_bounds,
|
||||
scroll_origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,16 +618,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn clips_overflow<N>(world: &LayoutWorld<N>, id: LayoutBoxId) -> bool
|
||||
fn overflow_modes<N>(world: &LayoutWorld<N>, id: LayoutBoxId) -> [LayoutOverflowMode; 2]
|
||||
where
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
{
|
||||
if id == world.root {
|
||||
world.viewport_scroll_policy.clips_overflow()
|
||||
world.viewport_scroll_policy.effective_overflow
|
||||
} else if world.is_viewport_defining_body(id) {
|
||||
false
|
||||
[LayoutOverflowMode::Visible; 2]
|
||||
} else {
|
||||
world.boxes[id.index()].style.clips_overflow()
|
||||
world.boxes[id.index()].style.overflow_modes()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,3 +698,70 @@ fn scrollport_for_box(
|
||||
scrollport.y += horizontal_leading_gutter;
|
||||
scrollport
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_scroll_ranges_derive_from_the_port_and_overflow_rectangles() {
|
||||
let dimensions = ScrollDimensions::from_rects(
|
||||
LayoutRect::new(12.0, 8.0, 100.0, 200.0),
|
||||
LayoutRect::new(-8.0, 8.0, 120.0, 250.0),
|
||||
);
|
||||
assert_eq!(dimensions.size, LayoutSize::new(120.0, 250.0));
|
||||
assert_eq!(dimensions.minimum, LayoutPoint::new(-20.0, 0.0));
|
||||
assert_eq!(dimensions.maximum, LayoutPoint::new(0.0, 50.0));
|
||||
assert_eq!(dimensions.overflowing_axes(), (true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_area_in_one_axis_does_not_create_scroll_in_the_other() {
|
||||
let port = LayoutRect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let origin = ScrollOrigin {
|
||||
horizontal: ScrollStartEdge::Min,
|
||||
vertical: ScrollStartEdge::Max,
|
||||
};
|
||||
let unreachable = origin.reachable_rect(LayoutRect::new(200.0, 200.0, 100.0, 100.0), port);
|
||||
assert_eq!(unreachable, LayoutRect::new(200.0, 100.0, 100.0, 0.0));
|
||||
let mut overflow = port;
|
||||
origin.add_visual_overflow(
|
||||
&mut overflow,
|
||||
LayoutRect::new(200.0, 200.0, 100.0, 100.0),
|
||||
Some(port),
|
||||
);
|
||||
assert_eq!(overflow, port);
|
||||
// Non-scrolling ancestors must retain it for a later scroll container
|
||||
// to evaluate in that container's own coordinate system.
|
||||
origin.add_visual_overflow(
|
||||
&mut overflow,
|
||||
LayoutRect::new(200.0, 200.0, 100.0, 100.0),
|
||||
None,
|
||||
);
|
||||
assert_eq!(overflow, LayoutRect::new(0.0, 0.0, 300.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_flow_margins_retract_only_the_scroll_end_and_preserve_empty_bounds() {
|
||||
let origin = ScrollOrigin {
|
||||
horizontal: ScrollStartEdge::Max,
|
||||
vertical: ScrollStartEdge::Min,
|
||||
};
|
||||
let bounds = origin.flow_bounds(
|
||||
taffy::InFlowLayout {
|
||||
location: taffy::Point { x: 100.0, y: 100.0 },
|
||||
margin: taffy::Rect {
|
||||
left: -30.0,
|
||||
right: -6.0,
|
||||
top: -4.0,
|
||||
bottom: -100.0,
|
||||
},
|
||||
},
|
||||
taffy::Size {
|
||||
width: 20.0,
|
||||
height: 10.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(bounds, LayoutRect::new(120.0, 100.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,30 +479,13 @@ where
|
||||
} else {
|
||||
local_scrollport
|
||||
};
|
||||
let scroll_size = LayoutSize::new(
|
||||
local_scrollport
|
||||
.width
|
||||
.max((overflow.right() - local_scrollport.x).max(0.0)),
|
||||
local_scrollport
|
||||
.height
|
||||
.max((overflow.bottom() - local_scrollport.y).max(0.0)),
|
||||
);
|
||||
let horizontal_range = (scroll_size.width - scrollport.width).max(0.0);
|
||||
let vertical_range = (scroll_size.height - scrollport.height).max(0.0);
|
||||
let dimensions = overflow_projection.scroll_dimensions(id);
|
||||
let scroll_size = dimensions.size;
|
||||
let (horizontal_overflow, vertical_overflow) = dimensions.overflowing_axes();
|
||||
let is_scroll_container = self.establishes_scroll_container(id);
|
||||
let requested = finite_point(self.world.boxes[index].scroll_offset);
|
||||
let (minimum_offset, maximum_offset) = if is_scroll_container {
|
||||
if self.world.boxes[index].style.direction() == crate::style::InlineDirection::Rtl {
|
||||
(
|
||||
LayoutPoint::new(-horizontal_range, 0.0),
|
||||
LayoutPoint::new(0.0, vertical_range),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
LayoutPoint::ZERO,
|
||||
LayoutPoint::new(horizontal_range, vertical_range),
|
||||
)
|
||||
}
|
||||
(dimensions.minimum, dimensions.maximum)
|
||||
} else {
|
||||
(LayoutPoint::ZERO, LayoutPoint::ZERO)
|
||||
};
|
||||
@@ -515,11 +498,7 @@ where
|
||||
LayoutPoint::ZERO
|
||||
};
|
||||
let horizontal_scrollbar = self
|
||||
.has_scrollbar(
|
||||
id,
|
||||
LayoutScrollbarAxis::Horizontal,
|
||||
horizontal_range > f32::EPSILON,
|
||||
)
|
||||
.has_scrollbar(id, LayoutScrollbarAxis::Horizontal, horizontal_overflow)
|
||||
.then(|| {
|
||||
let frame = LayoutRect::new(
|
||||
scrollport.x,
|
||||
@@ -539,11 +518,7 @@ where
|
||||
})
|
||||
.filter(|bar| bar.frame.width > 0.0 && bar.frame.height > 0.0);
|
||||
let vertical_scrollbar = self
|
||||
.has_scrollbar(
|
||||
id,
|
||||
LayoutScrollbarAxis::Vertical,
|
||||
vertical_range > f32::EPSILON,
|
||||
)
|
||||
.has_scrollbar(id, LayoutScrollbarAxis::Vertical, vertical_overflow)
|
||||
.then(|| {
|
||||
let x = if vertical_scrollbar_on_left {
|
||||
scrollport.x - scrollbar_thickness
|
||||
|
||||
@@ -1377,6 +1377,10 @@ fn set_table_part_layout<N>(
|
||||
world.boxes[id.index()].unrounded_layout = Layout {
|
||||
order: 0,
|
||||
location: Point { x, y },
|
||||
in_flow: Some(taffy::InFlowLayout {
|
||||
location: Point { x, y },
|
||||
margin: Rect::ZERO,
|
||||
}),
|
||||
size: Size { width, height },
|
||||
content_size: Size { width, height },
|
||||
scrollbar_size: Size::ZERO,
|
||||
@@ -1409,6 +1413,7 @@ fn set_box_layout<N>(
|
||||
world.boxes[id.index()].unrounded_layout = Layout {
|
||||
order: u32::try_from(order).unwrap_or(u32::MAX),
|
||||
location,
|
||||
in_flow: Some(taffy::InFlowLayout { location, margin }),
|
||||
size: output.size,
|
||||
content_size: output.content_size,
|
||||
scrollbar_size: Size::ZERO,
|
||||
|
||||
@@ -329,6 +329,10 @@ where
|
||||
fn scale_layout(layout: Layout, factor: f32) -> Layout {
|
||||
Layout {
|
||||
location: layout.location.map(|value| value * factor),
|
||||
in_flow: layout.in_flow.map(|flow| taffy::InFlowLayout {
|
||||
location: flow.location.map(|value| value * factor),
|
||||
margin: flow.margin.map(|value| value * factor),
|
||||
}),
|
||||
size: layout.size.map(|value| value * factor),
|
||||
content_size: layout.content_size.map(|value| value * factor),
|
||||
scrollbar_size: layout.scrollbar_size.map(|value| value * factor),
|
||||
@@ -621,6 +625,7 @@ where
|
||||
world.set_inline_child_layout(
|
||||
marker,
|
||||
Point { x, y },
|
||||
Point::ZERO,
|
||||
output,
|
||||
marker.index(),
|
||||
Some(parent_width),
|
||||
@@ -679,6 +684,7 @@ where
|
||||
world.set_inline_child_layout(
|
||||
content,
|
||||
Point { x, y },
|
||||
Point::ZERO,
|
||||
output,
|
||||
content.index(),
|
||||
Some(content_width),
|
||||
@@ -1405,6 +1411,7 @@ fn layout_inline_absolute_child<N>(
|
||||
let scrollbar_size = world.get_scrollbar_insets(child.to_taffy()).sum_axes();
|
||||
world.boxes[child.index()].unrounded_layout = Layout {
|
||||
order: 0,
|
||||
in_flow: None,
|
||||
size: final_size,
|
||||
content_size: output.content_size,
|
||||
scrollbar_size,
|
||||
@@ -2566,6 +2573,7 @@ where
|
||||
self.set_inline_child_layout(
|
||||
floated.child,
|
||||
floated.location,
|
||||
Point::ZERO,
|
||||
floated.output,
|
||||
floated.order,
|
||||
floated.parent_width,
|
||||
@@ -2642,6 +2650,7 @@ where
|
||||
+ vertical_offset
|
||||
+ inset_offset.y,
|
||||
},
|
||||
inset_offset,
|
||||
atomic.output,
|
||||
object_index,
|
||||
measurement.percentage_basis,
|
||||
@@ -2654,6 +2663,7 @@ where
|
||||
&mut self,
|
||||
child: LayoutBoxId,
|
||||
location: Point<f32>,
|
||||
relative_offset: Point<f32>,
|
||||
output: LayoutOutput,
|
||||
order: usize,
|
||||
parent_width: Option<f32>,
|
||||
@@ -2672,6 +2682,13 @@ where
|
||||
self.boxes[child.index()].unrounded_layout = Layout {
|
||||
order: u32::try_from(order).unwrap_or(u32::MAX),
|
||||
location,
|
||||
in_flow: Some(taffy::InFlowLayout {
|
||||
location: Point {
|
||||
x: location.x - relative_offset.x,
|
||||
y: location.y - relative_offset.y,
|
||||
},
|
||||
margin,
|
||||
}),
|
||||
size: output.size,
|
||||
content_size: output.content_size,
|
||||
scrollbar_size,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn screenshot_resolves_directional_scroll_ranges_and_normal_flow_bounds() {
|
||||
run_page_vm_async_test(async move {
|
||||
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?;
|
||||
let mut page = test_page_vm_with_loader_and_document_url(
|
||||
&loader,
|
||||
Vec::new(),
|
||||
Url::parse("https://example.com/directional-overflow.html")?,
|
||||
);
|
||||
page.set_viewport_surface(Some(crate::protocol_types::ViewportSurface {
|
||||
inner_width: 1280,
|
||||
inner_height: 3600,
|
||||
outer_width: 1280,
|
||||
outer_height: 3600,
|
||||
device_pixel_ratio: 1.0,
|
||||
screen_width: 1280,
|
||||
screen_height: 3600,
|
||||
screen_avail_width: 1280,
|
||||
screen_avail_height: 3600,
|
||||
}))?;
|
||||
page.vm_mut()
|
||||
.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand);
|
||||
page.vm_mut().eval(&format!(
|
||||
"document.open();document.write({});document.close()",
|
||||
serde_json::to_string(include_str!(
|
||||
"../../../../tests/fixtures/directional-scroll-overflow.html"
|
||||
))?,
|
||||
))?;
|
||||
page.vm_mut()
|
||||
.prime_document_lifecycle_processing_and_record_stylesheet_network_results();
|
||||
// Every observation follows an explicit capture. Synchronous geometry
|
||||
// remains intentionally stale between layout demands, including after
|
||||
// the final display:none mutation removes the overflow contribution.
|
||||
for phase in ["initial", "end", "start", "hidden"] {
|
||||
page.vm_mut()
|
||||
.eval(&format!("setOverflowPhase('{phase}')"))?;
|
||||
let crate::runtime::RendererCaptureScreenshotReply::Captured(screenshot) = page
|
||||
.capture_screenshot(
|
||||
crate::runtime::RendererCaptureScreenshotRequest::viewport_png(),
|
||||
)?
|
||||
else {
|
||||
panic!("explicit screenshot demand must produce pixels");
|
||||
};
|
||||
let image = moli_image::decode_png(&screenshot.bytes)?;
|
||||
let checks: serde_json::Value = serde_json::from_str(
|
||||
&page
|
||||
.vm_mut()
|
||||
.eval("JSON.stringify(collectOverflowChecks())")?,
|
||||
)?;
|
||||
let checks = checks.as_array().expect("overflow checks");
|
||||
assert_eq!(checks.len(), if phase == "hidden" { 282 } else { 462 });
|
||||
for check in checks {
|
||||
assert_eq!(check["actual"], check["expected"], "{phase}: {check}");
|
||||
if let Some(pixel) = check.get("pixel") {
|
||||
let x = pixel[0].as_f64().expect("pixel x") as usize;
|
||||
let y = pixel[1].as_f64().expect("pixel y") as usize;
|
||||
let offset = (y * image.width as usize + x) * 4;
|
||||
assert_eq!(
|
||||
&image.rgba[offset..offset + 4],
|
||||
&[31, 127, 63, 255],
|
||||
"{phase}: {} paint",
|
||||
check["id"]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("directional overflow fixture should run");
|
||||
}
|
||||
@@ -119,6 +119,7 @@ mod command_checkpoint;
|
||||
mod computed_size;
|
||||
mod cssom_zoom;
|
||||
mod dedicated_worker_client_event;
|
||||
mod directional_overflow;
|
||||
mod document_script_completion;
|
||||
mod element_toggle_event;
|
||||
mod fetch_xhr;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Directional scroll overflow preserves normal-flow and visual geometry</title>
|
||||
<link rel="help" href="https://drafts.csswg.org/css-overflow-3/#scrollable">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; }
|
||||
html { overflow: hidden; }
|
||||
.frame { position: absolute; width: 100px; height: 80px; padding: 10px;
|
||||
border: 2px solid black; overflow: auto; scrollbar-width: none; }
|
||||
.item { width: 180px; height: 140px; margin: 10px; flex: none;
|
||||
background: rgb(31,127,63); }
|
||||
</style>
|
||||
<body>
|
||||
<script>
|
||||
const modes = ['horizontal-tb','vertical-rl','vertical-lr','sideways-rl','sideways-lr'];
|
||||
const contexts = [
|
||||
['block','display:flow-root',false,false],
|
||||
['grid','display:grid',false,false],
|
||||
['row','display:flex',false,false],
|
||||
['row-reverse','display:flex;flex-direction:row-reverse',true,false],
|
||||
['column-reverse','display:flex;flex-direction:column-reverse',false,true],
|
||||
['column-wrap-reverse','display:flex;flex-direction:column;flex-wrap:wrap-reverse',true,false],
|
||||
];
|
||||
const checks = [];
|
||||
const probes = [];
|
||||
let phase = 'initial';
|
||||
for (const mode of modes) for (const direction of ['ltr','rtl']) {
|
||||
for (const [name,css,reverseInline,reverseBlock] of contexts) {
|
||||
const inlineReverse = (direction === 'rtl') !== (mode === 'sideways-lr') !== reverseInline;
|
||||
const blockReverse = (mode === 'vertical-rl' || mode === 'sideways-rl') !== reverseBlock;
|
||||
const reverseX = mode === 'horizontal-tb' ? inlineReverse : blockReverse;
|
||||
const reverseY = mode === 'horizontal-tb' ? blockReverse : inlineReverse;
|
||||
for (const placement of ['flow','relative','transform']) {
|
||||
const index = checks.length;
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'frame';
|
||||
frame.id = `${mode}-${direction}-${name}-${placement}`;
|
||||
frame.style.cssText = `${css};writing-mode:${mode};direction:${direction};left:${index%8*160}px;top:${Math.floor(index/8)*150}px`;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item';
|
||||
const shiftX = placement === 'flow' ? 0 : reverseX ? 1000 : -1000;
|
||||
const shiftY = placement === 'flow' ? 0 : reverseY ? 1000 : -1000;
|
||||
if (placement === 'relative') item.style.cssText = `position:relative;left:${shiftX}px;top:${shiftY}px`;
|
||||
if (placement === 'transform') item.style.transform = `translate(${shiftX}px,${shiftY}px)`;
|
||||
frame.append(item);
|
||||
document.body.append(frame);
|
||||
checks.push({frame,item,placement,reverseX,reverseY,shiftX,shiftY});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These geometry-only probes live outside the captured viewport. Each has
|
||||
// its own element: no synchronous read depends on a preceding style mutation
|
||||
// being implicitly flushed, unlike the upstream dynamic testharness loops.
|
||||
function addProbe(id, css, html, expected, empty = [100,100]) {
|
||||
const frame = document.createElement('div');
|
||||
frame.id = id;
|
||||
frame.style.cssText = `position:absolute;left:1400px;top:0;width:100px;height:100px;scrollbar-width:none;${css}`;
|
||||
frame.innerHTML = html;
|
||||
document.body.append(frame);
|
||||
probes.push({frame,expected,empty});
|
||||
}
|
||||
for (const mode of modes) for (const direction of ['ltr','rtl']) {
|
||||
for (const [name,css,reverseInline,reverseBlock] of contexts) {
|
||||
const inlineReverse = (direction === 'rtl') !== (mode === 'sideways-lr') !== reverseInline;
|
||||
const blockReverse = (mode === 'vertical-rl' || mode === 'sideways-rl') !== reverseBlock;
|
||||
const reverseX = mode === 'horizontal-tb' ? inlineReverse : blockReverse;
|
||||
const reverseY = mode === 'horizontal-tb' ? blockReverse : inlineReverse;
|
||||
addProbe(`scaled-${mode}-${direction}-${name}`, `${css};writing-mode:${mode};direction:${direction};height:200px;overflow:scroll`,
|
||||
'<div style="width:100px;height:200px;transform:translate(-3px,-6px) scale(1.1)"></div>',
|
||||
[reverseX ? 108 : 102, reverseY ? 216 : 204], [100,200]);
|
||||
}
|
||||
}
|
||||
for (const display of ['flex','grid']) for (const vertical of [false,true]) {
|
||||
for (const overflow of ['auto','scroll','hidden','clip','visible','visible clip','visible hidden','visible auto']) {
|
||||
const scrolls = overflow.includes('auto') || overflow.includes('scroll') || overflow.includes('hidden');
|
||||
const extent = scrolls ? (display === 'flex' ? 950 : 1000) : 100;
|
||||
addProbe(`margin-${display}-${vertical}-${overflow}`, `display:${display};overflow:${overflow};writing-mode:${vertical?'vertical-rl':'horizontal-tb'}`,
|
||||
'<div style="width:50px;height:50px;margin-inline-end:950px"></div>', vertical ? [100,extent] : [extent,100]);
|
||||
}
|
||||
}
|
||||
addProbe('transformed-absolute-margin', 'width:200px;height:200px;overflow:auto',
|
||||
'<div style="position:absolute;width:150px;height:150px;margin-left:100px;transform:translateX(-100px)"></div>', [200,200], [200,200]);
|
||||
for (const positioned of [false,true]) for (const translation of ['300px,-300px','-300px,300px']) {
|
||||
addProbe(`unreachable-${positioned}-${translation}`, 'overflow:auto',
|
||||
`<div style="${positioned?'position:absolute;':''}width:100px;height:100px;transform:translate(${translation})"></div>`, [100,100]);
|
||||
}
|
||||
addProbe('self-collapsing-end-strut', 'overflow:auto',
|
||||
'<div style="height:100px;margin-bottom:10px"></div><div style="margin-bottom:50px"></div>', [100,150]);
|
||||
addProbe('visible-trailing-margin', 'height:20px;overflow:visible',
|
||||
'<div style="height:40px;margin-bottom:15px"></div>', [100,40], [100,20]);
|
||||
addProbe('zero-width-relative-inflow', 'overflow:auto;padding:10px 5px',
|
||||
'<div style="position:relative;width:0;height:200px;margin-bottom:50px;top:-1000px"></div>', [110,270], [110,120]);
|
||||
addProbe('viewport-fixed-is-not-a-scroll-contribution', 'overflow:auto',
|
||||
'<div style="position:fixed;left:0;top:0;width:300px;height:250px"></div>', [100,100]);
|
||||
addProbe('containing-block-fixed-is-a-scroll-contribution', 'overflow:auto;transform:translate(0px)',
|
||||
'<div style="position:fixed;left:0;top:0;width:300px;height:250px"></div>', [300,250]);
|
||||
|
||||
function setOverflowPhase(value) {
|
||||
phase = value;
|
||||
for (const c of checks) {
|
||||
if (phase === 'hidden') c.item.style.display = 'none';
|
||||
c.frame.scrollLeft = phase === 'end' ? (c.reverseX ? -10000 : 10000) : 0;
|
||||
c.frame.scrollTop = phase === 'end' ? (c.reverseY ? -10000 : 10000) : 0;
|
||||
}
|
||||
if (phase === 'hidden') for (const c of probes) {
|
||||
for (const child of c.frame.children) child.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function collectOverflowChecks() {
|
||||
const results = [];
|
||||
for (const c of checks) {
|
||||
const x = phase === 'end' ? (c.reverseX ? -100 : 100) : 0;
|
||||
const y = phase === 'end' ? (c.reverseY ? -80 : 80) : 0;
|
||||
results.push({id:`${c.frame.id}:scroll`,
|
||||
actual:[c.frame.scrollWidth,c.frame.scrollHeight,c.frame.clientWidth,c.frame.clientHeight,c.frame.scrollLeft,c.frame.scrollTop],
|
||||
expected:[phase === 'hidden' ? 120 : 220,phase === 'hidden' ? 100 : 180,120,100,x,y]});
|
||||
if (phase === 'hidden') continue;
|
||||
const frame = c.frame.getBoundingClientRect();
|
||||
const item = c.item.getBoundingClientRect();
|
||||
// Constants come from the authored boxes: 2px border + 10px padding +
|
||||
// 10px margin; reversing packs from the other edge of the 120x100 port.
|
||||
const expectedX = (c.reverseX ? -78 : 22) + c.shiftX - x;
|
||||
const expectedY = (c.reverseY ? -58 : 22) + c.shiftY - y;
|
||||
const result = {id:`${c.frame.id}:fragment`, actual:[item.x-frame.x,item.y-frame.y,item.width,item.height],
|
||||
expected:[expectedX,expectedY,180,140]};
|
||||
if (c.placement === 'flow') {
|
||||
result.pixel = [frame.x + Math.max(3,expectedX+1),frame.y + Math.max(3,expectedY+1)];
|
||||
result.color = [31,127,63,255];
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
return results.concat(probes.map(c => ({id:c.frame.id,
|
||||
actual:[c.frame.scrollWidth,c.frame.scrollHeight], expected:phase === 'hidden' ? c.empty : c.expected})));
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user