diff --git a/moli-layout/src/overflow.rs b/moli-layout/src/overflow.rs index ffda3dce9e..9c8639210e 100644 --- a/moli-layout/src/overflow.rs +++ b/moli-layout/src/overflow.rs @@ -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(&self, world: &LayoutWorld, 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( &self, world: &LayoutWorld, @@ -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(&self, world: &LayoutWorld, child: LayoutBoxId) -> LayoutRect + fn child_contribution( + &self, + world: &LayoutWorld, + 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], + 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, + reversed: taffy::Point, +) -> 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( world: &LayoutWorld, id: LayoutBoxId, diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index 336836f5b5..ab1407a653 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -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) }; diff --git a/moli-layout/src/style.rs b/moli-layout/src/style.rs index f95a3bb31f..cec232d41b 100644 --- a/moli-layout/src/style.rs +++ b/moli-layout/src/style.rs @@ -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 { + 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 { self.writing_mode.vertical_block_start_is_right() } diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index ac673a63ed..0e6cee8000 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -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(world: &mut LayoutWorld) 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; } } diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/chromium.rs b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/chromium.rs index c588b867f0..4052beee49 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/chromium.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/chromium.rs @@ -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#"
"#, + ))?; + 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#"
"#, + ))?; + 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#"
"#, + ))?; + 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#"
"#, + ))?; + 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#"
"#, + ))?; + 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#"
"#, + ))?; + 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 diff --git a/scripts/layout-rtl-overflow-chromium-differential.py b/scripts/layout-rtl-overflow-chromium-differential.py new file mode 100644 index 0000000000..52723669a2 --- /dev/null +++ b/scripts/layout-rtl-overflow-chromium-differential.py @@ -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 = '
' + 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'' + f'
{inner}
' + ) + # 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"""
""" + # 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"""
""" + for direction, clip in itertools.product(("ltr", "rtl"), ("x", "y")): + cases[f"horizontal-tb/{direction}-clip-{clip}-visible"] = f"""
""" + 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"""
""" + 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"""
""" + 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()