Rewild Your Web
web browser dweb
at main 382 lines 17 kB view raw
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 }