diff --git a/Cargo.lock b/Cargo.lock index 239c6074f1..c291992be0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 09e55372e4..f9f4d42155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/moli-layout/src/overflow.rs b/moli-layout/src/overflow.rs index 1db2cb59f9..59df3db35a 100644 --- a/moli-layout/src/overflow.rs +++ b/moli-layout/src/overflow.rs @@ -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, + ) { + 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) -> 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, + 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( &self, world: &LayoutWorld, @@ -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(world: &LayoutWorld, id: LayoutBoxId) -> Option( @@ -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 = 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(world: &LayoutWorld, id: LayoutBoxId) -> bool +fn overflow_modes(world: &LayoutWorld, 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)); + } +} diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index dee0c98a63..d84ee45b4a 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -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 diff --git a/moli-layout/src/table.rs b/moli-layout/src/table.rs index 52e65aced5..3b72acbbbb 100644 --- a/moli-layout/src/table.rs +++ b/moli-layout/src/table.rs @@ -1377,6 +1377,10 @@ fn set_table_part_layout( 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( 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, diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 017d84c32c..3dd173c381 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -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( 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, + relative_offset: Point, output: LayoutOutput, order: usize, parent_width: Option, @@ -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, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/directional_overflow.rs b/moli-renderer-v8/src/runtime/page_vm/tests/directional_overflow.rs new file mode 100644 index 0000000000..2d24a82811 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/directional_overflow.rs @@ -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"); +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index a3a2eb7c2a..635ae902c4 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -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; diff --git a/moli-renderer-v8/tests/fixtures/directional-scroll-overflow.html b/moli-renderer-v8/tests/fixtures/directional-scroll-overflow.html new file mode 100644 index 0000000000..ef3d28199e --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/directional-scroll-overflow.html @@ -0,0 +1,137 @@ + + +Directional scroll overflow preserves normal-flow and visual geometry + + + +