Rewild Your Web
web
browser
dweb
1--- original
2+++ modified
3@@ -37,7 +37,7 @@
4 use crate::refresh_driver::BaseRefreshDriver;
5 use crate::touch::{PendingTouchInputEvent, TouchHandler, TouchMoveAllowed, TouchSequenceState};
6
7-#[derive(Clone, Copy)]
8+#[derive(Clone, Copy, Debug)]
9 pub(crate) struct ScrollEvent {
10 /// Scroll by this offset, or to Start or End
11 pub scroll: Scroll,
12@@ -74,6 +74,18 @@
13 DidNotPinchZoom,
14 }
15
16+/// Result of processing pending scroll and pinch zoom events.
17+#[derive(Debug)]
18+pub(crate) struct ScrollZoomProcessingResult {
19+ /// Whether pinch zoom occurred.
20+ pub pinch_zoom_result: PinchZoomResult,
21+ /// The scroll result if scrolling was consumed.
22+ pub scroll_result: Option<ScrollResult>,
23+ /// The unconsumed scroll event if scrolling was not consumed.
24+ /// This can be used to bubble the scroll to a parent webview.
25+ pub unconsumed_scroll: Option<ScrollEvent>,
26+}
27+
28 /// A renderer for a libservo `WebView`. This is essentially the [`ServoRenderer`]'s interface to a
29 /// libservo `WebView`, but the code here cannot depend on libservo in order to prevent circular
30 /// dependencies, which is why we store a `dyn WebViewTrait` here instead of the `WebView` itself.
31@@ -115,6 +127,10 @@
32 /// and initial values for zoom derived from the `viewport` meta tag in web content.
33 viewport_description: Option<ViewportDescription>,
34
35+ /// Whether this is an embedded webview. Embedded webviews have different zoom behavior:
36+ /// page zoom is applied inside the display list rather than as an external transform.
37+ is_embedded_webview: bool,
38+
39 //
40 // Data that is shared with the parent renderer.
41 //
42@@ -153,6 +169,7 @@
43 hidden: false,
44 animating: false,
45 viewport_description: None,
46+ is_embedded_webview: false,
47 embedder_to_constellation_sender,
48 refresh_driver,
49 webrender_document,
50@@ -188,6 +205,16 @@
51 new_value != old_value
52 }
53
54+ /// Mark this [`WebViewRenderer`] as an embedded webview. This affects how page zoom is applied:
55+ /// for embedded webviews, zoom is applied inside the display list rather than externally.
56+ pub(crate) fn set_is_embedded_webview(&mut self, is_embedded: bool) {
57+ self.is_embedded_webview = is_embedded;
58+ // When becoming an embedded webview, resend window size with the new zoom handling
59+ if is_embedded {
60+ self.send_window_size_message();
61+ }
62+ }
63+
64 /// Returns the [`PipelineDetails`] for the given [`PipelineId`], creating it if needed.
65 pub(crate) fn ensure_pipeline_details(
66 &mut self,
67@@ -353,10 +380,9 @@
68 _ => None,
69 }
70 .or_else(|| self.hit_test(render_api, point).into_iter().nth(0));
71- if hit_test_result.is_none() {
72- warn!("Empty hit test result for input event, ignoring.");
73- return false;
74- }
75+ // Even if WebRender hit test returns empty, we still send the event to
76+ // the script thread for DOM hit testing. The script thread will use the
77+ // original event point for DOM hit testing when hit_test_result is None.
78 hit_test_result
79 },
80 None => None,
81@@ -673,6 +699,89 @@
82 self.on_scroll_window_event(scroll, point);
83 }
84
85+ /// Try to scroll the root scroll node in the root pipeline without hit testing.
86+ /// Only tries the root scroll node (document viewport) to allow proper scroll
87+ /// bubbling to parent webviews when the embedded content can't scroll in the
88+ /// requested direction.
89+ /// Returns the scroll result without dispatching scroll events (caller should dispatch).
90+ fn try_scroll_root_pipeline(
91+ &mut self,
92+ scroll_location: ScrollLocation,
93+ ) -> Option<ScrollResult> {
94+ let root_pipeline_id = self.root_pipeline_id?;
95+ let root_pipeline = self.pipelines.get_mut(&root_pipeline_id)?;
96+
97+ // Only try the root scroll node (ExternalScrollId(0, pipeline_id)), not all nodes.
98+ // This ensures that if the document viewport can't scroll in the requested
99+ // direction, the scroll event bubbles up to the parent webview instead of
100+ // being captured by some random scrollable element elsewhere on the page.
101+ let root_scroll_id = ExternalScrollId(0, root_pipeline_id.into());
102+ let (external_scroll_id, offset) = root_pipeline.scroll_tree.scroll_node_or_ancestor(
103+ root_scroll_id,
104+ scroll_location,
105+ ScrollType::InputEvents,
106+ )?;
107+
108+ let hit_test_result = PaintHitTestResult {
109+ pipeline_id: root_pipeline_id,
110+ point_in_viewport: Default::default(),
111+ external_scroll_id,
112+ };
113+
114+ Some(ScrollResult {
115+ hit_test_result,
116+ external_scroll_id,
117+ offset,
118+ })
119+ }
120+
121+ /// Try to scroll any scrollable node in the parent document.
122+ /// This is used for bubbling scroll events from embedded iframes where
123+ /// hit-testing in layout coordinates doesn't work because the visual
124+ /// position has changed due to scrolling.
125+ ///
126+ /// Unlike `try_scroll_root_pipeline` which only tries the root scroll node,
127+ /// this method tries ALL scroll nodes because the parent's scrollable element
128+ /// (like a horizontal panel container) might not be the root scroll node.
129+ pub(crate) fn try_scroll_any(&mut self, scroll: Scroll) -> Option<ScrollResult> {
130+ let device_pixels_per_page_pixel = self.device_pixels_per_page_pixel();
131+
132+ let scroll_location = match scroll {
133+ Scroll::Delta(delta) => {
134+ let delta = delta.as_device_vector(device_pixels_per_page_pixel);
135+ let delta_for_scroll = delta / device_pixels_per_page_pixel;
136+ ScrollLocation::Delta(delta_for_scroll.cast_unit())
137+ },
138+ Scroll::Start => ScrollLocation::Start,
139+ Scroll::End => ScrollLocation::End,
140+ };
141+
142+ let root_pipeline_id = self.root_pipeline_id?;
143+ let root_pipeline = self.pipelines.get_mut(&root_pipeline_id)?;
144+
145+ // Try any scrollable node in the tree, not just the root.
146+ // This is needed for parent bubbling because the scrollable element
147+ // (like a horizontal panel container) might not be the root scroll node.
148+ let (external_scroll_id, offset) = root_pipeline
149+ .scroll_tree
150+ .try_scroll_any_node(scroll_location, ScrollType::InputEvents)?;
151+
152+ let hit_test_result = PaintHitTestResult {
153+ pipeline_id: root_pipeline_id,
154+ point_in_viewport: Default::default(),
155+ external_scroll_id,
156+ };
157+
158+ self.send_scroll_positions_to_layout_for_pipeline(root_pipeline_id);
159+ self.dispatch_scroll_event(external_scroll_id, hit_test_result.clone());
160+
161+ Some(ScrollResult {
162+ hit_test_result,
163+ external_scroll_id,
164+ offset,
165+ })
166+ }
167+
168 fn on_scroll_window_event(&mut self, scroll: Scroll, cursor: DevicePoint) {
169 self.pending_scroll_zoom_events
170 .push(ScrollZoomEvent::Scroll(ScrollEvent {
171@@ -682,18 +791,25 @@
172 }));
173 }
174
175- /// Process pending scroll events for this [`WebViewRenderer`]. Returns a tuple containing:
176+ /// Process pending scroll events for this [`WebViewRenderer`]. Returns a
177+ /// [`ScrollZoomProcessingResult`] containing:
178 ///
179- /// - A boolean that is true if a zoom occurred.
180- /// - An optional [`ScrollResult`] if a scroll occurred.
181+ /// - Whether pinch zoom occurred.
182+ /// - An optional [`ScrollResult`] if scrolling was consumed.
183+ /// - An optional unconsumed [`ScrollEvent`] if scrolling was not consumed, which can
184+ /// be forwarded to a parent webview.
185 ///
186 /// It is up to the caller to ensure that these events update the rendering appropriately.
187 pub(crate) fn process_pending_scroll_and_pinch_zoom_events(
188 &mut self,
189 render_api: &RenderApi,
190- ) -> (PinchZoomResult, Option<ScrollResult>) {
191+ ) -> ScrollZoomProcessingResult {
192 if self.pending_scroll_zoom_events.is_empty() {
193- return (PinchZoomResult::DidNotPinchZoom, None);
194+ return ScrollZoomProcessingResult {
195+ pinch_zoom_result: PinchZoomResult::DidNotPinchZoom,
196+ scroll_result: None,
197+ unconsumed_scroll: None,
198+ };
199 }
200
201 // Batch up all scroll events and changes to pinch zoom into a single change, or
202@@ -747,15 +863,24 @@
203 }
204 }
205
206+ // Save the original scroll before pan() modifies it, so we can return it
207+ // as unconsumed if neither pan nor scroll consumed the event.
208+ let original_scroll_event = combined_scroll_event;
209+
210 // When zoomed in via pinch zoom, first try to move the center of the zoom and use the rest
211 // of the delta for scrolling. This allows moving the zoomed into viewport around in the
212 // unzoomed viewport before actually scrolling the underlying layers.
213- if let Some(combined_scroll_event) = combined_scroll_event.as_mut() {
214- new_pinch_zoom.pan(
215- &mut combined_scroll_event.scroll,
216- self.device_pixels_per_page_pixel(),
217- )
218- }
219+ let pan_consumed_scroll =
220+ if let Some(combined_scroll_event) = combined_scroll_event.as_mut() {
221+ let original_scroll = combined_scroll_event.scroll;
222+ new_pinch_zoom.pan(
223+ &mut combined_scroll_event.scroll,
224+ self.device_pixels_per_page_pixel(),
225+ );
226+ original_scroll != combined_scroll_event.scroll
227+ } else {
228+ false
229+ };
230
231 let scroll_result = combined_scroll_event.and_then(|combined_event| {
232 self.scroll_node_at_device_point(
233@@ -764,6 +889,21 @@
234 combined_event.scroll,
235 )
236 });
237+
238+ // Determine if the scroll was consumed or not.
239+ // If scroll failed and pan didn't consume anything, return the original scroll event
240+ // so it can bubble up to the parent. If pan consumed some delta, return the remaining
241+ // (post-pan) scroll as unconsumed.
242+ let unconsumed_scroll = if scroll_result.is_some() {
243+ None
244+ } else if pan_consumed_scroll {
245+ // Pan consumed some scroll, return the remaining delta (which might be zero)
246+ combined_scroll_event
247+ } else {
248+ // Nothing consumed the scroll, return the original to bubble up
249+ original_scroll_event
250+ };
251+
252 if let Some(ref scroll_result) = scroll_result {
253 self.send_scroll_positions_to_layout_for_pipeline(
254 scroll_result.hit_test_result.pipeline_id,
255@@ -782,7 +922,11 @@
256 self.send_pinch_zoom_infos_to_script();
257 }
258
259- (pinch_zoom_result, scroll_result)
260+ ScrollZoomProcessingResult {
261+ pinch_zoom_result,
262+ scroll_result,
263+ unconsumed_scroll,
264+ }
265 }
266
267 /// Perform a hit test at the given [`DevicePoint`] and apply the [`Scroll`]
268@@ -789,7 +933,7 @@
269 /// scrolling to the applicable scroll node under that point. If a scroll was
270 /// performed, returns the hit test result contains [`PipelineId`] of the node
271 /// scrolled, the id, and the final scroll delta.
272- fn scroll_node_at_device_point(
273+ pub(crate) fn scroll_node_at_device_point(
274 &mut self,
275 render_api: &RenderApi,
276 cursor: DevicePoint,
277@@ -817,7 +961,10 @@
278 // its ancestor pipelines.
279 let mut previous_pipeline_id = None;
280 for hit_test_result in hit_test_results {
281- let pipeline_details = self.pipelines.get_mut(&hit_test_result.pipeline_id)?;
282+ let Some(pipeline_details) = self.pipelines.get_mut(&hit_test_result.pipeline_id)
283+ else {
284+ continue;
285+ };
286 if previous_pipeline_id.replace(hit_test_result.pipeline_id) !=
287 Some(hit_test_result.pipeline_id)
288 {
289@@ -844,7 +991,11 @@
290 }
291 }
292 }
293- None
294+
295+ // If hit test returned no matching pipelines (e.g., for embedded webviews where
296+ // coordinates are in embedded space but hit test uses parent's WebRender document),
297+ // fall back to scrolling the root scroll node in our root pipeline.
298+ self.try_scroll_root_pipeline(scroll_location)
299 }
300
301 /// Scroll the viewport (root pipeline, root scroll node) of this WebView, but first
302@@ -1000,20 +1151,45 @@
303 }
304
305 fn send_window_size_message(&self) {
306- // The device pixel ratio used by the style system should include the scale from page pixels
307- // to device pixels, but not including any pinch zoom.
308+ // Both top-level and embedded webviews include page_zoom in hidpi_scale_factor
309+ // to cause layout to reflow at the zoomed viewport size.
310+ //
311+ // The difference is in how the visual scaling is applied:
312+ // - Top-level: zoom transform applied externally by the painter as a reference frame
313+ // - Embedded: zoom transform applied inside the display list via page_zoom_for_rendering
314+ //
315+ // This matches Firefox/servoshell behavior where zoom causes layout reflow.
316 let device_pixel_ratio = self.device_pixels_per_page_pixel_not_including_pinch_zoom();
317 let initial_viewport = self.rect.size().to_f32() / device_pixel_ratio;
318- let _ = self.embedder_to_constellation_sender.send(
319- EmbedderToConstellationMessage::ChangeViewportDetails(
320- self.id,
321- ViewportDetails {
322- hidpi_scale_factor: device_pixel_ratio,
323- size: initial_viewport,
324- },
325- WindowSizeType::Resize,
326- ),
327- );
328+
329+ if self.is_embedded_webview {
330+ let page_zoom = self.page_zoom.get();
331+ let _ = self.embedder_to_constellation_sender.send(
332+ EmbedderToConstellationMessage::ChangeViewportDetails(
333+ self.id,
334+ ViewportDetails {
335+ hidpi_scale_factor: device_pixel_ratio,
336+ size: initial_viewport,
337+ page_zoom_for_rendering: Some(page_zoom),
338+ },
339+ WindowSizeType::Resize,
340+ ),
341+ );
342+ } else {
343+ // For top-level webviews: no page_zoom_for_rendering needed since the
344+ // painter applies the zoom transform externally.
345+ let _ = self.embedder_to_constellation_sender.send(
346+ EmbedderToConstellationMessage::ChangeViewportDetails(
347+ self.id,
348+ ViewportDetails {
349+ hidpi_scale_factor: device_pixel_ratio,
350+ size: initial_viewport,
351+ page_zoom_for_rendering: None,
352+ },
353+ WindowSizeType::Resize,
354+ ),
355+ );
356+ }
357 }
358
359 /// Set the `hidpi_scale_factor` for this renderer, returning `true` if the value actually changed.
360@@ -1079,8 +1255,21 @@
361 if let Some(wheel_event) = self.pending_wheel_events.remove(&id) {
362 if !result.contains(InputEventResult::DefaultPrevented) {
363 // A scroll delta for a wheel event is the inverse of the wheel delta.
364- let scroll_delta =
365+ let mut scroll_delta =
366 DeviceVector2D::new(-wheel_event.delta.x as f32, -wheel_event.delta.y as f32);
367+
368+ // Apply direction locking to prevent diagonal scrolls from interfering.
369+ // When one axis dominates, zero out the minor axis.
370+ // This helps horizontal panel switching work correctly when the user
371+ // intends to scroll horizontally but the trackpad sends diagonal events.
372+ let abs_dx = scroll_delta.x.abs();
373+ let abs_dy = scroll_delta.y.abs();
374+ if abs_dx > abs_dy {
375+ scroll_delta.y = 0.0;
376+ } else {
377+ scroll_delta.x = 0.0;
378+ }
379+
380 self.notify_scroll_event(Scroll::Delta(scroll_delta.into()), wheel_event.point);
381 }
382 }