forked from
me.webbeef.org/browser.html
Rewild Your Web
1--- original
2+++ modified
3@@ -38,7 +38,7 @@
4 PendingTouchInputEvent, TouchHandler, TouchIdMoveTracking, TouchMoveAllowed, TouchSequenceState,
5 };
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@@ -75,6 +75,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@@ -116,6 +128,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@@ -154,6 +170,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@@ -189,6 +206,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@@ -364,10 +391,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@@ -699,6 +725,88 @@
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, external_scroll_id);
159+
160+ Some(ScrollResult {
161+ hit_test_result,
162+ external_scroll_id,
163+ offset,
164+ })
165+ }
166+
167 fn on_scroll_window_event(&mut self, scroll: Scroll, cursor: DevicePoint) {
168 self.pending_scroll_zoom_events
169 .push(ScrollZoomEvent::Scroll(ScrollEvent {
170@@ -708,18 +816,25 @@
171 }));
172 }
173
174- /// Process pending scroll events for this [`WebViewRenderer`]. Returns a tuple containing:
175+ /// Process pending scroll events for this [`WebViewRenderer`]. Returns a
176+ /// [`ScrollZoomProcessingResult`] containing:
177 ///
178- /// - A boolean that is true if a zoom occurred.
179- /// - An optional [`ScrollResult`] if a scroll occurred.
180+ /// - Whether pinch zoom occurred.
181+ /// - An optional [`ScrollResult`] if scrolling was consumed.
182+ /// - An optional unconsumed [`ScrollEvent`] if scrolling was not consumed, which can
183+ /// be forwarded to a parent webview.
184 ///
185 /// It is up to the caller to ensure that these events update the rendering appropriately.
186 pub(crate) fn process_pending_scroll_and_pinch_zoom_events(
187 &mut self,
188 render_api: &RenderApi,
189- ) -> (PinchZoomResult, Option<ScrollResult>) {
190+ ) -> ScrollZoomProcessingResult {
191 if self.pending_scroll_zoom_events.is_empty() {
192- return (PinchZoomResult::DidNotPinchZoom, None);
193+ return ScrollZoomProcessingResult {
194+ pinch_zoom_result: PinchZoomResult::DidNotPinchZoom,
195+ scroll_result: None,
196+ unconsumed_scroll: None,
197+ };
198 }
199
200 // Batch up all scroll events and changes to pinch zoom into a single change, or
201@@ -773,15 +888,24 @@
202 }
203 }
204
205+ // Save the original scroll before pan() modifies it, so we can return it
206+ // as unconsumed if neither pan nor scroll consumed the event.
207+ let original_scroll_event = combined_scroll_event;
208+
209 // When zoomed in via pinch zoom, first try to move the center of the zoom and use the rest
210 // of the delta for scrolling. This allows moving the zoomed into viewport around in the
211 // unzoomed viewport before actually scrolling the underlying layers.
212- if let Some(combined_scroll_event) = combined_scroll_event.as_mut() {
213- new_pinch_zoom.pan(
214- &mut combined_scroll_event.scroll,
215- self.device_pixels_per_page_pixel(),
216- )
217- }
218+ let pan_consumed_scroll =
219+ if let Some(combined_scroll_event) = combined_scroll_event.as_mut() {
220+ let original_scroll = combined_scroll_event.scroll;
221+ new_pinch_zoom.pan(
222+ &mut combined_scroll_event.scroll,
223+ self.device_pixels_per_page_pixel(),
224+ );
225+ original_scroll != combined_scroll_event.scroll
226+ } else {
227+ false
228+ };
229
230 let scroll_result = combined_scroll_event.and_then(|combined_event| {
231 self.scroll_node_at_device_point(
232@@ -790,6 +914,21 @@
233 combined_event.scroll,
234 )
235 });
236+
237+ // Determine if the scroll was consumed or not.
238+ // If scroll failed and pan didn't consume anything, return the original scroll event
239+ // so it can bubble up to the parent. If pan consumed some delta, return the remaining
240+ // (post-pan) scroll as unconsumed.
241+ let unconsumed_scroll = if scroll_result.is_some() {
242+ None
243+ } else if pan_consumed_scroll {
244+ // Pan consumed some scroll, return the remaining delta (which might be zero)
245+ combined_scroll_event
246+ } else {
247+ // Nothing consumed the scroll, return the original to bubble up
248+ original_scroll_event
249+ };
250+
251 if let Some(ref scroll_result) = scroll_result {
252 self.send_scroll_positions_to_layout_for_pipeline(
253 scroll_result.hit_test_result.pipeline_id,
254@@ -805,7 +944,11 @@
255 self.send_pinch_zoom_infos_to_script();
256 }
257
258- (pinch_zoom_result, scroll_result)
259+ ScrollZoomProcessingResult {
260+ pinch_zoom_result,
261+ scroll_result,
262+ unconsumed_scroll,
263+ }
264 }
265
266 /// Perform a hit test at the given [`DevicePoint`] and apply the [`Scroll`]
267@@ -812,7 +955,7 @@
268 /// scrolling to the applicable scroll node under that point. If a scroll was
269 /// performed, returns the hit test result contains [`PipelineId`] of the node
270 /// scrolled, the id, and the final scroll delta.
271- fn scroll_node_at_device_point(
272+ pub(crate) fn scroll_node_at_device_point(
273 &mut self,
274 render_api: &RenderApi,
275 cursor: DevicePoint,
276@@ -840,7 +983,10 @@
277 // its ancestor pipelines.
278 let mut previous_pipeline_id = None;
279 for hit_test_result in hit_test_results {
280- let pipeline_details = self.pipelines.get_mut(&hit_test_result.pipeline_id)?;
281+ let Some(pipeline_details) = self.pipelines.get_mut(&hit_test_result.pipeline_id)
282+ else {
283+ continue;
284+ };
285 if previous_pipeline_id.replace(hit_test_result.pipeline_id) !=
286 Some(hit_test_result.pipeline_id)
287 {
288@@ -867,7 +1013,11 @@
289 }
290 }
291 }
292- None
293+
294+ // If hit test returned no matching pipelines (e.g., for embedded webviews where
295+ // coordinates are in embedded space but hit test uses parent's WebRender document),
296+ // fall back to scrolling the root scroll node in our root pipeline.
297+ self.try_scroll_root_pipeline(scroll_location)
298 }
299
300 /// Scroll the viewport (root pipeline, root scroll node) of this WebView, but first
301@@ -1006,20 +1156,45 @@
302 }
303
304 fn send_window_size_message(&self) {
305- // The device pixel ratio used by the style system should include the scale from page pixels
306- // to device pixels, but not including any pinch zoom.
307+ // Both top-level and embedded webviews include page_zoom in hidpi_scale_factor
308+ // to cause layout to reflow at the zoomed viewport size.
309+ //
310+ // The difference is in how the visual scaling is applied:
311+ // - Top-level: zoom transform applied externally by the painter as a reference frame
312+ // - Embedded: zoom transform applied inside the display list via page_zoom_for_rendering
313+ //
314+ // This matches Firefox/servoshell behavior where zoom causes layout reflow.
315 let device_pixel_ratio = self.device_pixels_per_page_pixel_not_including_pinch_zoom();
316 let initial_viewport = self.rect.size().to_f32() / device_pixel_ratio;
317- let _ = self.embedder_to_constellation_sender.send(
318- EmbedderToConstellationMessage::ChangeViewportDetails(
319- self.id,
320- ViewportDetails {
321- hidpi_scale_factor: device_pixel_ratio,
322- size: initial_viewport,
323- },
324- WindowSizeType::Resize,
325- ),
326- );
327+
328+ if self.is_embedded_webview {
329+ let page_zoom = self.page_zoom.get();
330+ let _ = self.embedder_to_constellation_sender.send(
331+ EmbedderToConstellationMessage::ChangeViewportDetails(
332+ self.id,
333+ ViewportDetails {
334+ hidpi_scale_factor: device_pixel_ratio,
335+ size: initial_viewport,
336+ page_zoom_for_rendering: Some(page_zoom),
337+ },
338+ WindowSizeType::Resize,
339+ ),
340+ );
341+ } else {
342+ // For top-level webviews: no page_zoom_for_rendering needed since the
343+ // painter applies the zoom transform externally.
344+ let _ = self.embedder_to_constellation_sender.send(
345+ EmbedderToConstellationMessage::ChangeViewportDetails(
346+ self.id,
347+ ViewportDetails {
348+ hidpi_scale_factor: device_pixel_ratio,
349+ size: initial_viewport,
350+ page_zoom_for_rendering: None,
351+ },
352+ WindowSizeType::Resize,
353+ ),
354+ );
355+ }
356 }
357
358 /// Set the `hidpi_scale_factor` for this renderer, returning `true` if the value actually changed.
359@@ -1085,8 +1260,21 @@
360 if let Some(wheel_event) = self.pending_wheel_events.remove(&id) {
361 if !result.contains(InputEventResult::DefaultPrevented) {
362 // A scroll delta for a wheel event is the inverse of the wheel delta.
363- let scroll_delta =
364+ let mut scroll_delta =
365 DeviceVector2D::new(-wheel_event.delta.x as f32, -wheel_event.delta.y as f32);
366+
367+ // Apply direction locking to prevent diagonal scrolls from interfering.
368+ // When one axis dominates, zero out the minor axis.
369+ // This helps horizontal panel switching work correctly when the user
370+ // intends to scroll horizontally but the trackpad sends diagonal events.
371+ let abs_dx = scroll_delta.x.abs();
372+ let abs_dy = scroll_delta.y.abs();
373+ if abs_dx > abs_dy {
374+ scroll_delta.y = 0.0;
375+ } else {
376+ scroll_delta.x = 0.0;
377+ }
378+
379 self.notify_scroll_event(Scroll::Delta(scroll_delta.into()), wheel_event.point);
380 }
381 }