fix(layout): project scroll overflow in physical coordinates

Aggregate physical descendants once instead of treating Taffy's logical
scroll origin as a physical top-left corner. Share reachable scroll bounds
between scrollbar admission and final projection, including vertical writing
modes and reversed flex flows.

Preserve grid track areas, per-axis clipping, transformed borders, normal-flow
margins and end padding. Anchor vertical RTL block flow at the physical bottom
while keeping relative insets physical.

Add five native browser guards and a live Chromium differential covering
140 fixtures, two scrollbar modes and three fresh-document rounds.

Validation: root fmt, strict workspace/all-target/all-feature Clippy, and
Nextest (20380 passed, 18 skipped); Release; 840 Chromium comparisons;
960 flex geometry controls; MOL-463-468 probes; full 13631-case WPT Cross.
Three old/new control rounds found no new stable case, assertion or harness
regression. Cases are refreshed in the following commit.

Original-commit: d433653f0d
This commit is contained in:
ldm0
2026-10-21 17:55:02 +08:00
committed by Donough Liu
parent da0b0eeae7
commit 5a1c1cd7ab
6 changed files with 824 additions and 68 deletions
+197 -39
View File
@@ -1,8 +1,9 @@
use std::{collections::VecDeque, fmt::Debug, hash::Hash};
use crate::{
LayoutBoxId, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutTransform2D, LayoutViewport,
LayoutWorld, style::ResolvedLayoutTransform,
LayoutBoxId, LayoutDisplay, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutSize,
LayoutTransform2D, LayoutViewport, LayoutWorld,
style::{LayoutOverflowMode, ResolvedLayoutTransform},
};
/// Geometry needed to resolve scrollable overflow and no other projection
@@ -84,6 +85,26 @@ impl OverflowProjection {
self.scrollable_overflow[id.index()]
}
pub(crate) fn scroll_size<N>(&self, world: &LayoutWorld<N>, id: LayoutBoxId) -> LayoutSize
where
N: Copy + Debug + Eq + Hash,
{
let scrollport = self.geometry(id).local_scrollport;
let overflow = self.scrollable_overflow(id);
let reversed = world.boxes[id.index()].style.reversed_scroll_axes();
let width = if reversed.x {
scrollport.right() - overflow.x
} else {
overflow.right() - scrollport.x
};
let height = if reversed.y {
scrollport.bottom() - overflow.y
} else {
overflow.bottom() - scrollport.y
};
LayoutSize::new(scrollport.width.max(width), scrollport.height.max(height))
}
pub(crate) fn overflowing_axes<N>(
&self,
world: &LayoutWorld<N>,
@@ -96,17 +117,9 @@ impl OverflowProjection {
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;
let size = self.scroll_size(world, id);
let horizontal_range = size.width - geometry.local_scrollport.width;
let vertical_range = size.height - geometry.local_scrollport.height;
(
horizontal_range > f32::EPSILON,
vertical_range > f32::EPSILON,
@@ -224,32 +237,95 @@ impl OverflowProjection {
{
let mut overflow = self.geometries[id.index()].local_overflow;
for child in self.children[id.index()].iter().copied() {
overflow = overflow.union(self.child_contribution(world, child));
overflow = overflow.union(self.child_contribution(world, id, child));
}
self.scrollable_overflow[id.index()] = overflow;
}
fn child_contribution<N>(&self, world: &LayoutWorld<N>, child: LayoutBoxId) -> LayoutRect
fn child_contribution<N>(
&self,
world: &LayoutWorld<N>,
parent: LayoutBoxId,
child: LayoutBoxId,
) -> LayoutRect
where
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()];
if clips_overflow(world, child) {
let modes = world.boxes[child.index()].style.overflow_modes();
// Clip/visible does not establish a scroll container. Clip just
// that physical axis and keep overflow on the other axis. Native
// controls and scrolling mechanisms still clip both axes.
let independent_clip = !establishes_scroll_container(world, child)
&& modes.contains(&LayoutOverflowMode::Clip);
if !independent_clip || modes[0] == LayoutOverflowMode::Clip {
visual_overflow.x = geometry.border_box.x;
visual_overflow.width = geometry.border_box.width;
}
if !independent_clip || modes[1] == LayoutOverflowMode::Clip {
visual_overflow.y = geometry.border_box.y;
visual_overflow.height = geometry.border_box.height;
}
}
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)
let parent_box = &world.boxes[parent.index()];
let relative =
if world.boxes[child.index()].style.position() == crate::LayoutPosition::Relative {
crate::inline::relative_atomic_inset_offset(
&world.boxes[child.index()].style.taffy,
taffy::Size {
width: self.geometries[parent.index()].content_box.width,
height: self.geometries[parent.index()].content_box.height,
},
parent_box.style.direction(),
)
} else {
taffy::Point::ZERO
};
let normal_flow_translation =
LayoutTransform2D::translation(location.x - relative.x, location.y - relative.y);
let parent_is_grid = matches!(
parent_box.style.display(),
LayoutDisplay::Grid | LayoutDisplay::InlineGrid
);
let margin_to_parent =
if parent_is_grid && !world.boxes[child.index()].style.is_out_of_flow() {
// A grid's untransformed floor is its grid area, not the original
// border box of an item overflowing its track. Keep the item's
// margins in the same transformed physical space as its border.
local_to_parent
} else {
normal_flow_translation
};
let mut contribution = local_to_parent
.map_rect(visual_overflow.union(geometry.border_box))
.bounding_rect()
.union(
layout_translation
margin_to_parent
.map_rect(geometry.margin_box)
.bounding_rect(),
)
);
if establishes_scroll_container(world, parent)
&& !world.boxes[child.index()].style.is_out_of_flow()
&& !parent_is_grid
{
// End padding extends the direct in-flow margin box, not its
// escaping descendants or transformed visual bounds. A grid
// item's overflow does not grow its containing grid area.
let padding = parent_box.final_layout.padding;
let padded = extend_end_padding(
geometry.margin_box,
padding,
parent_box.style.reversed_scroll_axes(),
);
contribution =
contribution.union(normal_flow_translation.map_rect(padded).bounding_rect());
}
contribution
}
}
@@ -325,21 +401,50 @@ where
horizontal_gutter,
horizontal_leading_gutter,
);
let mut local_overflow = LayoutRect::new(
local_scrollport.x,
local_scrollport.y,
local_scrollport
.width
.max(layout.scrollable_overflow_rect.right)
.max(0.0),
local_scrollport
.height
.max(layout.scrollable_overflow_rect.bottom)
.max(0.0),
);
// Taffy's scrollable_overflow_rect is relative to a logical scroll origin,
// not the physical top-left. It also already aggregates descendants.
// Reusing its right/bottom here both mirrors mixed-direction overflow and
// double counts it when resolve_one adds the physical descendants below.
// Start with this box's scrollport and own inline fragments; aggregate
// descendant bounds exactly once in the physical coordinate system.
let mut local_overflow = local_scrollport;
if is_root {
local_overflow = local_overflow.union(padding_box);
}
if let Some(taffy::DetailedLayoutInfo::Grid(grid)) = world.native_grid_details.get(&id) {
// Track positions already include physical border/padding/gaps and
// alignment. Empty or undersized items must not erase a large grid
// area, and overflowing items must not enlarge that untransformed area.
let reversed = layout_box.style.reversed_scroll_axes();
let padding = if establishes_scroll_container(world, id) {
layout.padding
} else {
taffy::Rect::ZERO
};
let (left, right) = grid_track_overflow_axis(
&grid.columns.positions,
local_overflow.x,
local_overflow.right(),
if reversed.x {
padding.left
} else {
padding.right
},
reversed.x,
);
let (top, bottom) = grid_track_overflow_axis(
&grid.rows.positions,
local_overflow.y,
local_overflow.bottom(),
if reversed.y {
padding.top
} else {
padding.bottom
},
reversed.y,
);
local_overflow = LayoutRect::new(left, top, right - left, bottom - top);
}
if let Some(context) = layout_box.inline_layout.as_ref() {
let origin = LayoutPoint::new(
layout.border.left
@@ -357,14 +462,26 @@ where
horizontal_leading_gutter
},
);
let inline_bounds = |rect| {
let rect = offset_rect(rect, origin);
if establishes_scroll_container(world, id) {
extend_end_padding(
rect,
layout.padding,
layout_box.style.reversed_scroll_axes(),
)
} else {
rect
}
};
for line in &context.fragments.lines {
local_overflow = local_overflow.union(offset_rect(line.rect, origin));
local_overflow = local_overflow.union(inline_bounds(line.rect));
}
for fragment in &context.fragments.text {
local_overflow = local_overflow.union(offset_rect(fragment.rect, origin));
local_overflow = local_overflow.union(inline_bounds(fragment.rect));
}
for fragment in &context.fragments.boxes {
local_overflow = local_overflow.union(offset_rect(fragment.rect, origin));
local_overflow = local_overflow.union(inline_bounds(fragment.rect));
}
}
OverflowBoxGeometry {
@@ -382,6 +499,47 @@ where
}
}
fn grid_track_overflow_axis(
tracks: &[taffy::Line<f32>],
start: f32,
end: f32,
end_padding: f32,
reversed: bool,
) -> (f32, f32) {
tracks.iter().fold((start, end), |(start, end), track| {
(
start.min(track.start - if reversed { end_padding } else { 0.0 }),
end.max(track.end + if reversed { 0.0 } else { end_padding }),
)
})
}
fn extend_end_padding(
mut rect: LayoutRect,
padding: taffy::Rect<f32>,
reversed: taffy::Point<bool>,
) -> LayoutRect {
let x = if reversed.x {
padding.left
} else {
padding.right
};
let y = if reversed.y {
padding.top
} else {
padding.bottom
};
if reversed.x {
rect.x -= x;
}
if reversed.y {
rect.y -= y;
}
rect.width += x;
rect.height += y;
rect
}
fn scrollbar_gutter_thickness<N>(
world: &LayoutWorld<N>,
id: LayoutBoxId,
+12 -19
View File
@@ -493,30 +493,23 @@ 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 scroll_size = overflow_projection.scroll_size(self.world, id);
let horizontal_range = (scroll_size.width - scrollport.width).max(0.0);
let vertical_range = (scroll_size.height - scrollport.height).max(0.0);
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),
)
}
let reversed = self.world.boxes[index].style.reversed_scroll_axes();
(
LayoutPoint::new(
if reversed.x { -horizontal_range } else { 0.0 },
if reversed.y { -vertical_range } else { 0.0 },
),
LayoutPoint::new(
if reversed.x { 0.0 } else { horizontal_range },
if reversed.y { 0.0 } else { vertical_range },
),
)
} else {
(LayoutPoint::ZERO, LayoutPoint::ZERO)
};
+31
View File
@@ -1968,6 +1968,37 @@ impl ResolvedLayoutStyle {
self.writing_mode.is_horizontal()
}
/// Physical axes whose reachable scroll direction points toward negative coordinates.
pub(crate) fn reversed_scroll_axes(&self) -> taffy::Point<bool> {
let rtl = self.direction == InlineDirection::Rtl;
let mut reversed = match self.writing_mode {
LayoutWritingMode::HorizontalTb => taffy::Point { x: rtl, y: false },
LayoutWritingMode::VerticalRl => taffy::Point { x: true, y: rtl },
LayoutWritingMode::VerticalLr => taffy::Point { x: false, y: rtl },
};
if matches!(
self.display,
LayoutDisplay::Flex | LayoutDisplay::InlineFlex
) {
let row = matches!(
self.taffy.flex_direction,
taffy::FlexDirection::Row | taffy::FlexDirection::RowReverse
);
let main_x = row == self.uses_horizontal_writing_mode();
let main_reverse = matches!(
self.taffy.flex_direction,
taffy::FlexDirection::RowReverse | taffy::FlexDirection::ColumnReverse
);
let cross_reverse = matches!(
self.taffy.flex_wrap,
taffy::FlexWrap::WrapReverse | taffy::FlexWrap::BalanceReverse
);
reversed.x ^= if main_x { main_reverse } else { cross_reverse };
reversed.y ^= if main_x { cross_reverse } else { main_reverse };
}
reversed
}
pub(crate) const fn vertical_block_start_is_right(&self) -> Option<bool> {
self.writing_mode.vertical_block_start_is_right()
}
+45 -10
View File
@@ -254,15 +254,14 @@ where
world.finish_numeric_layout_tracking()
}
/// Converts Taffy's horizontal block-start anchor into the physical edge used
/// by a vertical writing mode.
/// Converts Taffy's horizontal block anchors into the physical edges used by
/// a vertical writing mode.
///
/// Taffy deliberately has no writing-mode input yet. Its block algorithm does
/// still produce the right border-box size, margins and physical relative
/// inset, but it chooses the x anchor from CSS `direction`: left for LTR and
/// Taffy's block algorithm produces physical sizes, margins and relative
/// insets, but it still chooses the x anchor from CSS `direction`: left for LTR and
/// right for RTL. In a vertical formatting context, direction controls the
/// vertical inline axis instead; block-start is always left for `vertical-lr`
/// and right for `vertical-rl`.
/// and right for `vertical-rl`. RTL also anchors the inline axis at the bottom.
///
/// Apply this adapter immediately after numeric layout so positioned, sticky,
/// scroll-overflow, CSSOM and paint all observe the same real box coordinates.
@@ -271,7 +270,7 @@ fn physicalize_vertical_block_flow<N>(world: &mut LayoutWorld<N>)
where
N: Copy + Debug + Eq + Hash,
{
let mut adjusted_x = Vec::new();
let mut adjusted_locations = Vec::new();
for parent_index in 0..world.boxes.len() {
let parent = LayoutBoxId::from_index(parent_index);
@@ -292,6 +291,12 @@ where
- parent_layout.border.right
- parent_layout.padding.right
- scrollbar.right;
let content_top = parent_layout.border.top + parent_layout.padding.top + scrollbar.top;
let content_bottom = parent_layout.size.height
- parent_layout.border.bottom
- parent_layout.padding.bottom
- scrollbar.bottom;
let direction = world.boxes[parent_index].style.direction();
let taffy_anchored_right =
world.boxes[parent_index].style.taffy.direction == taffy::Direction::Rtl;
@@ -317,12 +322,42 @@ where
} else {
left_anchor
};
adjusted_x.push((child, physical_anchor + (layout.location.x - taffy_anchor)));
let y = if direction == InlineDirection::Rtl {
let relative_y = if child_box.style.position() == crate::LayoutPosition::Relative {
relative_atomic_inset_offset(
&child_box.style.taffy,
Size {
width: content_right - content_left,
height: content_bottom - content_top,
},
direction,
)
.y
} else {
0.0
};
// Mirror the normal-flow inline anchor, while physical top /
// bottom margins and relative insets retain their own edges.
content_top + content_bottom + layout.margin.top
- layout.margin.bottom
- layout.location.y
- layout.size.height
+ 2.0 * relative_y
} else {
layout.location.y
};
adjusted_locations.push((
child,
Point {
x: physical_anchor + (layout.location.x - taffy_anchor),
y,
},
));
}
}
for (child, x) in adjusted_x {
world.boxes[child.index()].unrounded_layout.location.x = x;
for (child, location) in adjusted_locations {
world.boxes[child.index()].unrounded_layout.location = location;
}
}
@@ -7,6 +7,238 @@
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn chromium_mixed_direction_overflow_keeps_physical_edges() {
// Live oracle: scripts/layout-rtl-overflow-chromium-differential.py.
// An opposite-direction visible descendant overflows before the parent's
// scroll origin; it must not create a scrollbar on the opposite edge.
run_page_vm_async_test(async move {
for display in ["block", "flex", "grid"] {
for (outer, inner, width) in [
("ltr", "ltr", 277),
("ltr", "rtl", 133),
("rtl", "ltr", 133),
("rtl", "rtl", 271),
] {
let mut page = page_with_size_fixture(&format!(
r#"<style>
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
overflow:auto;direction:{outer};display:{display};
grid-template-columns:80px;grid-template-rows:50px}}
#child{{flex:none;min-width:0;min-height:0;width:80px;height:50px;
direction:{inner};overflow:visible}}#descendant{{width:260px;height:180px}}
</style><div id=scroller><div id=child><div id=descendant></div></div></div>"#,
))?;
publish_size_layout(&mut page)?;
assert_eq!(
read_without_layout(&mut page,
"[scroller.scrollWidth,scroller.scrollHeight,scroller.clientLeft,scroller.clientWidth]")?,
json!([width, 183, if outer == "rtl" { 34 } else { 19 }, 133]),
"{display}, {outer} outer / {inner} inner"
);
}
}
Ok::<_, anyhow::Error>(())
})
.await
.expect("physical overflow bounds must match Chromium for mixed directions");
}
#[tokio::test(flavor = "current_thread")]
async fn chromium_clip_visible_overflow_preserves_the_unclipped_physical_axis() {
run_page_vm_async_test(async move {
for direction in ["ltr", "rtl"] {
for (clip, expected) in [("x", [85, 180]), ("y", [260, 65])] {
let mut page = page_with_size_fixture(&format!(
r#"<style>
#scroller{{width:100px;height:80px;overflow:auto;direction:{direction}}}
#child{{width:80px;height:50px;overflow-{clip}:clip}}
#descendant{{width:260px;height:180px}}
</style><div id=scroller><div id=child><div id=descendant></div></div></div>"#,
))?;
publish_size_layout(&mut page)?;
assert_eq!(
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
json!(expected),
"{direction}, clip {clip} / visible other axis"
);
}
}
Ok::<_, anyhow::Error>(())
})
.await
.expect("single-axis clipping must preserve the visible axis");
}
#[tokio::test(flavor = "current_thread")]
async fn chromium_transformed_overflow_keeps_untransformed_padding_bounds() {
run_page_vm_async_test(async move {
for direction in ["ltr", "rtl"] {
for display in ["block", "flex"] {
for (transform, ltr, rtl) in [
("translate(30px,20px)", [307, 203], [288, 203]),
("translate(-30px,-20px)", [288, 190], [301, 190]),
("scale(2)", [407, 273], [401, 273]),
] {
let mut page = page_with_size_fixture(&format!(
r#"<style>
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
overflow:auto;direction:{direction};display:{display}}}
#child{{flex:none;width:260px;height:180px;transform:{transform}}}
</style><div id=scroller><div id=child></div></div>"#,
))?;
publish_size_layout(&mut page)?;
assert_eq!(
read_without_layout(
&mut page,
"[scroller.scrollWidth,scroller.scrollHeight]"
)?,
json!(if direction == "rtl" { rtl } else { ltr }),
"{direction}, {display}, {transform}"
);
}
}
}
Ok::<_, anyhow::Error>(())
})
.await
.expect("transforms must extend overflow without transforming parent end padding");
}
#[tokio::test(flavor = "current_thread")]
async fn chromium_reversed_flex_flow_keeps_physical_scroll_origins() {
run_page_vm_async_test(async move {
// Native-scrollbar geometry recorded by the live Chromium differential.
for (mode, direction, flex, wrap, minimum, maximum) in [
(
"horizontal-tb",
"ltr",
"row-reverse",
"nowrap",
[-155, 0],
[0, 105],
),
(
"horizontal-tb",
"rtl",
"row-reverse",
"nowrap",
[0, 0],
[155, 105],
),
(
"horizontal-tb",
"ltr",
"column-reverse",
"nowrap",
[0, -105],
[155, 0],
),
(
"horizontal-tb",
"rtl",
"column-reverse",
"wrap-reverse",
[0, -105],
[155, 0],
),
(
"vertical-rl",
"rtl",
"row-reverse",
"wrap-reverse",
[0, 0],
[155, 105],
),
(
"vertical-lr",
"ltr",
"column-reverse",
"wrap-reverse",
[-155, -105],
[0, 0],
),
] {
let mut page = page_with_size_fixture(&format!(
r#"<style>
#scroller{{display:flex;width:120px;height:90px;overflow:auto;
writing-mode:{mode};direction:{direction};flex-direction:{flex};flex-wrap:{wrap};
align-items:flex-start;align-content:flex-start}}
#child{{flex:none;width:260px;height:180px}}
</style><div id=scroller><div id=child></div></div>"#,
))?;
publish_size_layout(&mut page)?;
assert_eq!(
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
json!([260, 180])
);
for (value, expected) in [(-10000, minimum), (10000, maximum)] {
page.vm_mut().eval(&format!(
"scroller.scrollLeft={value};scroller.scrollTop={value};'scrolled'"
))?;
publish_size_layout(&mut page)?;
assert_eq!(
read_without_layout(&mut page, "[scroller.scrollLeft,scroller.scrollTop]")?,
json!(expected),
"{mode}, {direction}, {flex}, {wrap}, requested {value}"
);
}
}
Ok::<_, anyhow::Error>(())
})
.await
.expect("flex main/cross reversal must be projected onto physical scrolling axes");
}
#[tokio::test(flavor = "current_thread")]
async fn chromium_grid_overflow_keeps_track_area_and_transformed_item_bounds() {
run_page_vm_async_test(async move {
for direction in ["ltr", "rtl"] {
for child in ["width:40px;height:30px", "display:none"] {
let mut page = page_with_size_fixture(&format!(
r#"<style>
#scroller{{position:absolute;left:40px;top:30px;display:grid;width:120px;height:90px;
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
overflow:auto;direction:{direction};grid-template-columns:260px;grid-template-rows:180px}}
#child{{{child}}}
</style><div id=scroller><div id=child></div></div>"#,
))?;
publish_size_layout(&mut page)?;
assert_eq!(
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
json!([288, 190]),
"{direction}, {child}"
);
}
}
for (direction, transform, expected) in [
("ltr", "translate(-30px,-20px)", [247, 163]),
("rtl", "translate(30px,20px)", [241, 203]),
("rtl", "translate(-30px,-20px)", [301, 163]),
] {
let mut page = page_with_size_fixture(&format!(
r#"<style>
#scroller{{position:absolute;left:40px;top:30px;display:grid;width:120px;height:90px;
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
overflow:auto;direction:{direction};grid-template-columns:80px;grid-template-rows:50px}}
#child{{width:260px;height:180px;transform:{transform}}}
</style><div id=scroller><div id=child></div></div>"#,
))?;
publish_size_layout(&mut page)?;
assert_eq!(
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
json!(expected),
"{direction}, {transform}"
);
}
Ok::<_, anyhow::Error>(())
})
.await
.expect("grid area and item visual overflow have distinct physical bounds");
}
#[tokio::test(flavor = "current_thread")]
async fn computed_size_chromium_resolved_value_display_and_unit_matrix() {
// fast/css/getComputedStyle/getComputedStyle-resolved-values.html and its
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Compare physical RTL overflow geometry and scrolling against live Chromium.
uv run --project moli-benchmark python \
scripts/layout-rtl-overflow-chromium-differential.py --moli-bin target/release/moli
Runs with hidden and native scrollbars. Every round navigates fresh documents;
scroll writes and geometry reads cross rendering/task boundaries. A mismatch
is an error, including when both engines would otherwise match an old snapshot.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import hashlib
import importlib.util
import itertools
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.parse
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"rtl_overflow_cdp", ROOT / "scripts/layout-phase5-chromium-differential.py"
)
assert SPEC is not None and SPEC.loader is not None
cdp = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = cdp
SPEC.loader.exec_module(cdp)
def fixture_cases() -> dict[str, str]:
cases = {}
for direction, display, shape in itertools.product(
("ltr", "rtl"), ("block", "flex", "grid"),
("fit", "wide", "nested-ltr", "nested-rtl", "translated-positive", "translated-negative", "scaled", "scaled-border"),
):
wide = shape != "fit"
# Unequal physical edges detect mirroring, axis swaps, and
# adding the leading scrollbar gutter a second time.
css = f"""
html,body{{margin:0;padding:0}}
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
overflow:auto;direction:{direction};display:{display}}}
#child{{flex:none;min-width:0;min-height:0;width:{260 if wide else 40}px;
height:{180 if wide else 30}px}}
"""
inner = ""
if shape.startswith("nested-"):
inner_direction = shape.removeprefix("nested-")
css += f"""
#child{{width:80px;height:50px;direction:{inner_direction};overflow:visible}}
#descendant{{width:260px;height:180px}}
"""
inner = '<div id="descendant"></div>'
if display == "grid":
css += "#scroller{grid-template-columns:80px;grid-template-rows:50px}"
transforms = {"translated-positive": "translate(30px,20px)",
"translated-negative": "translate(-30px,-20px)",
"scaled": "scale(2)", "scaled-border": "scale(2)"}
if shape in transforms:
css += f"#child{{transform:{transforms[shape]}}}"
if shape == "scaled-border":
css += "#child{border-style:solid;border-width:2px 4px 6px 8px}"
cases[f"horizontal-tb/{direction}-{display}-{shape}"] = (
f'<!doctype html><style>{css}</style>'
f'<div id="scroller"><div id="child">{inner}</div></div>'
)
# Ordinary vertical block flow also needs to anchor RTL at physical bottom;
# otherwise correct overflow reachability can hide a necessary scrollbar.
for mode, direction, shape in itertools.product(
("vertical-rl", "vertical-lr"), ("ltr", "rtl"), ("fit", "wide", "relative-margin"),
):
wide = shape != "fit"
extra = "position:relative;top:9px;margin:7px 5px 11px 13px" if shape == "relative-margin" else ""
cases[f"{mode}/{direction}-block-flow-{shape}"] = f"""<!doctype html><style>
html,body{{margin:0;padding:0}}
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
overflow:auto;writing-mode:{mode};direction:{direction}}}
#child{{width:{260 if wide else 40}px;height:{180 if wide else 30}px;{extra}}}
</style><div id="scroller"><div id="child"></div></div>"""
# Explicit physical insets isolate vertical scroll-origin conversion from
# the separate normal-flow/grid writing-mode layout algorithms. Exercise
# overflow on both physical axes, including negative reachable extents.
shapes = (
("fit", 20, 10, 40, 30),
("positive", 20, 10, 260, 180),
("negative-x", -160, 10, 260, 180),
("negative-y", 20, -130, 260, 180),
("negative-both", -160, -130, 260, 180),
("end-corner", 80, 60, 40, 30),
)
for mode, direction, (shape, x, y, width, height) in itertools.product(
("vertical-rl", "vertical-lr"), ("ltr", "rtl"), shapes,
):
cases[f"{mode}/{direction}-positioned-{shape}"] = f"""<!doctype html><style>
html,body{{margin:0;padding:0}}
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
overflow:auto;writing-mode:{mode};direction:{direction}}}
#child{{position:absolute;left:{x}px;top:{y}px;width:{width}px;height:{height}px}}
</style><div id="scroller"><div id="child"></div></div>"""
for direction, clip in itertools.product(("ltr", "rtl"), ("x", "y")):
cases[f"horizontal-tb/{direction}-clip-{clip}-visible"] = f"""<!doctype html><style>
html,body{{margin:0;padding:0}}
#scroller{{width:100px;height:80px;overflow:auto;direction:{direction}}}
#child{{width:80px;height:50px;overflow-{clip}:clip}}
#descendant{{width:260px;height:180px}}
</style><div id="scroller"><div id="child"><div id="descendant"></div></div></div>"""
for mode, direction, flex, wrap in itertools.product(
("horizontal-tb", "vertical-rl", "vertical-lr"), ("ltr", "rtl"),
("row", "row-reverse", "column", "column-reverse"), ("nowrap", "wrap-reverse"),
):
cases[f"{mode}/{direction}-flow-{flex}-{wrap}"] = f"""<!doctype html><style>
html,body{{margin:0;padding:0}}
#scroller{{display:flex;width:120px;height:90px;overflow:auto;
writing-mode:{mode};direction:{direction};flex-direction:{flex};flex-wrap:{wrap};
align-items:flex-start;align-content:flex-start}}
#child{{flex:none;width:260px;height:180px}}
</style><div id="scroller"><div id="child"></div></div>"""
for direction, empty in itertools.product(("ltr", "rtl"), (False, True)):
child = "display:none" if empty else "width:40px;height:30px"
cases[f"horizontal-tb/{direction}-grid-area-{'empty' if empty else 'small-item'}"] = f"""<!doctype html><style>
html,body{{margin:0;padding:0}}
#scroller{{position:absolute;left:40px;top:30px;display:grid;width:120px;height:90px;
padding:3px 11px 7px 17px;border-style:solid;border-width:2px 5px 13px 19px;
overflow:auto;direction:{direction};grid-template-columns:260px;grid-template-rows:180px}}
#child{{{child}}}
</style><div id="scroller"><div id="child"></div></div>"""
return cases
SNAPSHOT = """(() => {
const s = document.getElementById('scroller');
const rect = e => { const r=e.getBoundingClientRect();
return [r.x,r.y,r.width,r.height]; };
return {
scroll: [s.scrollWidth,s.scrollHeight,s.scrollLeft,s.scrollTop],
client: [s.clientLeft,s.clientTop,s.clientWidth,s.clientHeight],
scroller: rect(s), child: rect(document.getElementById('child')),
descendant: document.getElementById('descendant')
? rect(document.getElementById('descendant')) : null
};
})()"""
async def evaluate(client, session: str, expression: str) -> Any:
result, _ = await cdp.command(client, "Runtime.evaluate", {
"expression": expression, "returnByValue": True, "awaitPromise": True,
}, session_id=session)
if result.get("exceptionDetails"):
raise RuntimeError(result["exceptionDetails"])
return result["result"].get("value")
async def settle(client, session: str) -> None:
# Moli publishes layout at rendering boundaries. Reading inside the same
# script as a scroll mutation would test its synchronous sampling contract.
await evaluate(client, session, "new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
# getLayoutMetrics deliberately reuses Moli's last published frame. A
# capture explicitly publishes fresh visual geometry after each mutation.
await cdp.command(client, "Page.captureScreenshot", {
"clip": {"x": 0, "y": 0, "width": 1, "height": 1, "scale": 1},
}, session_id=session)
async def measure(client, session: str, html: str) -> dict[str, Any]:
_, messages = await cdp.command(client, "Page.navigate", {
"url": "data:text/html;charset=utf-8," + urllib.parse.quote(html),
}, session_id=session)
await cdp.wait_for_load(client, session, messages)
await settle(client, session)
values = {"initial": await evaluate(client, session, SNAPSHOT)}
for name, x, y in (("negative", -10000, -10000), ("positive", 10000, 10000)):
await evaluate(client, session, f"""(() => {{
const s=document.getElementById('scroller');
s.scrollLeft={x};s.scrollTop={y};
}})()""")
await settle(client, session)
values[name] = await evaluate(client, session, SNAPSHOT)
json.dumps(values, allow_nan=False)
return values
async def run_engine(binary: Path, engine: str, scrollbars: bool, rounds: int,
output: Path) -> dict[str, Any]:
port = cdp.reserve_port()
endpoint = f"http://127.0.0.1:{port}"
label = f"{engine}-{'native' if scrollbars else 'hidden'}"
with tempfile.TemporaryDirectory(prefix=f"moli-rtl-{label}-", ignore_cleanup_errors=True) as profile:
if engine == "moli":
command = [str(binary), "serve", "--host", "127.0.0.1", "--port", str(port), "--layout"]
if scrollbars:
command.append("--scrollbars")
else:
command = [str(binary), "--headless=new", "--disable-gpu", "--no-sandbox",
"--disable-background-networking", "--no-first-run",
"--remote-debugging-address=127.0.0.1", f"--remote-debugging-port={port}",
f"--user-data-dir={profile}", "about:blank"]
if not scrollbars:
command.append("--hide-scrollbars")
env = {k: v for k, v in os.environ.items()
if k.lower() not in {"http_proxy", "https_proxy", "all_proxy", "no_proxy",
"moli_scrollbars", "moli_layout"}}
with (output / f"{label}.log").open("w") as log:
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, env=env)
client = None
try:
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError(f"{label} exited: {process.returncode}; see {log.name}")
try:
client = await cdp.connect_raw_cdp(endpoint)
break
except (OSError, TimeoutError, ValueError):
await asyncio.sleep(0.05)
if client is None:
raise TimeoutError(f"{label}: CDP startup timed out")
version, _ = await cdp.command(client, "Browser.getVersion")
target, _ = await cdp.command(client, "Target.createTarget", {"url": "about:blank"})
attached, _ = await cdp.command(client, "Target.attachToTarget", {
"targetId": target["targetId"], "flatten": True,
})
session = attached["sessionId"]
await cdp.command(client, "Page.enable", session_id=session)
await cdp.command(client, "Emulation.setDeviceMetricsOverride", {
"width": 800, "height": 600, "deviceScaleFactor": 1, "mobile": False,
}, session_id=session)
observations = []
for number in range(1, rounds + 1):
cases = {name: await measure(client, session, html)
for name, html in fixture_cases().items()}
observations.append(cases)
print(f"{label}: round {number}, {len(cases)} cases", flush=True)
return {"command": command, "version": version,
"binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
"rounds": observations}
finally:
if client is not None:
with contextlib.suppress(Exception):
await cdp.command(client, "Browser.close")
with contextlib.suppress(Exception):
await client.websocket.close()
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
async def run(args, output: Path) -> bool:
report: dict[str, Any] = {"tolerance_css_px": cdp.TOLERANCE,
"fixtures": fixture_cases(), "engines": {}, "differences": []}
for scrollbars in (False, True):
mode = "native" if scrollbars else "hidden"
expected = await run_engine(args.chromium, "chromium", scrollbars, args.rounds, output)
report["engines"][f"chromium-{mode}"] = expected
(output / "comparison.json").write_text(json.dumps(report, indent=2) + "\n")
actual = await run_engine(args.moli_bin, "moli", scrollbars, args.rounds, output)
report["engines"][f"moli-{mode}"] = actual
for number, (moli_round, chrome_round) in enumerate(zip(actual["rounds"], expected["rounds"]), 1):
for name, oracle in chrome_round.items():
try:
cdp.assert_value(f"{mode}/{name}", moli_round[name], oracle)
except AssertionError as error:
report["differences"].append({"mode": mode, "case": name, "round": number,
"error": str(error)})
(output / "comparison.json").write_text(json.dumps(report, indent=2) + "\n")
report["passed"] = not report["differences"]
report["complete"] = True
(output / "comparison.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps({"passed": report["passed"], "differences": report["differences"],
"report": str(output / "comparison.json")}, indent=2))
return report["passed"]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--moli-bin", type=Path, required=True)
parser.add_argument("--chromium", type=Path, default=Path(shutil.which("chromium") or cdp.DEFAULT_CHROMIUM))
parser.add_argument("--rounds", type=int, default=3)
parser.add_argument("--output-dir", type=Path)
args = parser.parse_args()
if args.rounds < 1:
parser.error("--rounds must be positive")
args.moli_bin = args.moli_bin.expanduser().resolve(strict=True)
args.chromium = args.chromium.expanduser().resolve(strict=True)
output = args.output_dir or ROOT / "moli-benchmark/results/rtl-overflow" / time.strftime("%Y%m%d-%H%M%S")
output.mkdir(parents=True, exist_ok=False)
raise SystemExit(0 if asyncio.run(run(args, output)) else 1)
if __name__ == "__main__":
main()