From 0db1caad696ef61452e8aebb58985bb4c19123c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 12 May 2026 23:29:40 -0400 Subject: [PATCH] Route web surface scrolls to Servo hit point --- crates/ely_app/src/services/servo_live.rs | 16 +-- crates/ely_app/src/shell/web_surface.rs | 46 ++++--- .../src/shell/web_surface_controller.rs | 2 + .../ely_app/src/shell/web_surface_runtime.rs | 22 +++- crates/ely_app/src/shell/web_surface_state.rs | 3 + crates/ely_app/src/shell/web_surface_tests.rs | 38 ++++-- crates/ely_app/src/shell/web_surface_view.rs | 15 +-- .../src/bin/ely_servo_sidecar.rs | 2 + .../src/bin/ely_servo_sidecar/live.rs | 114 ++++++++++-------- .../bin/ely_servo_sidecar/live_protocol.rs | 9 +- .../src/bin/ely_servo_sidecar/perf.rs | 13 +- crates/ely_servo_host/src/host.rs | 2 + crates/ely_servo_host/src/runtime.rs | 29 ++--- .../ely_servo_host/tests/live_perf_bench.rs | 27 ++++- crates/ely_servo_host/tests/software_host.rs | 8 +- 15 files changed, 220 insertions(+), 126 deletions(-) diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index 5d5a74e..7fde30d 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -69,7 +69,6 @@ impl ServoLiveClient { }) } - pub fn ensure( &mut self, request: ServoLiveEnsureRequest, @@ -84,6 +83,8 @@ impl ServoLiveClient { device_pixel_ratio: request.device_pixel_ratio, scroll_delta_x: request.scroll_delta_x, scroll_delta_y: request.scroll_delta_y, + scroll_point_x: request.scroll_point_x, + scroll_point_y: request.scroll_point_y, click_x: request.click_x, click_y: request.click_y, hover_x: request.hover_x, @@ -136,9 +137,8 @@ impl ServoLiveClient { // upper limit is `width * height * 4` (RGBA8); `0` is the // explicit "hardware path active, sample the IOSurface // instead" signal — anything else is a protocol violation. - let pixel_byte_count = (report.width as u64) - .saturating_mul(report.height as u64) - .saturating_mul(4); + let pixel_byte_count = + (report.width as u64).saturating_mul(report.height as u64).saturating_mul(4); let advertised = report.rgba_byte_count as u64; if advertised != 0 && advertised != pixel_byte_count { return Err(ServoLiveError::FrameBudgetExceeded { @@ -157,9 +157,7 @@ impl ServoLiveClient { // fs::read, no temp file. let mut rgba_bytes = vec![0u8; report.rgba_byte_count]; if report.rgba_byte_count > 0 { - self.stdout - .read_exact(&mut rgba_bytes) - .map_err(ServoLiveError::FrameRead)?; + self.stdout.read_exact(&mut rgba_bytes).map_err(ServoLiveError::FrameRead)?; } let mut frame = ServoLiveFrame::from_parts(report, rgba_bytes); @@ -220,6 +218,8 @@ pub(crate) struct ServoLiveEnsureRequest { pub(crate) device_pixel_ratio: f32, pub(crate) scroll_delta_x: i32, pub(crate) scroll_delta_y: i32, + pub(crate) scroll_point_x: Option, + pub(crate) scroll_point_y: Option, pub(crate) click_x: Option, pub(crate) click_y: Option, pub(crate) hover_x: Option, @@ -410,6 +410,8 @@ enum LiveRequest { device_pixel_ratio: f32, scroll_delta_x: i32, scroll_delta_y: i32, + scroll_point_x: Option, + scroll_point_y: Option, click_x: Option, click_y: Option, hover_x: Option, diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs index 9dd3211..a0e9c79 100644 --- a/crates/ely_app/src/shell/web_surface.rs +++ b/crates/ely_app/src/shell/web_surface.rs @@ -27,11 +27,7 @@ pub(super) struct WebSurfaceStore { impl WebSurfaceStore { pub(super) fn new() -> Self { - Self { - runtime: WebSurfaceRuntime::new(), - surfaces: BTreeMap::new(), - keyboard_focus: None, - } + Self { runtime: WebSurfaceRuntime::new(), surfaces: BTreeMap::new(), keyboard_focus: None } } pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> { @@ -102,11 +98,21 @@ impl WebSurfaceStore { tab_id: &TabId, requested_url: &str, delta: Point, + position: Point, scale_factor: f32, ) -> WebSurfaceInputOutcome { let Some(delta) = WebSurfaceScrollDelta::from_point(delta, scale_factor) else { return WebSurfaceInputOutcome::DroppedZeroDelta; }; + let Some(bounds) = self.surfaces.get(tab_id).and_then(|surface| surface.viewport_bounds) + else { + return WebSurfaceInputOutcome::DroppedNoViewportBounds; + }; + let Some(point) = + WebSurfaceClickPoint::from_window_position(bounds, position, scale_factor) + else { + return WebSurfaceInputOutcome::DroppedOutOfBounds; + }; let surface = self.surface_mut(tab_id); let scroll = surface @@ -121,6 +127,7 @@ impl WebSurfaceStore { Some(current) => current.combined_with(delta), None => delta, }); + surface.pending_scroll_point = Some(point); // Drop any buffered click — its viewport coordinates were // captured against the pre-scroll page, so applying it after // the scroll would land on the wrong DOM element. Keep @@ -170,12 +177,14 @@ impl WebSurfaceStore { position: Point, scale_factor: f32, ) -> WebSurfaceInputOutcome { - let surface = self.surfaces.get_mut(tab_id).filter(|surface| surface.viewport_bounds.is_some()); + let surface = + self.surfaces.get_mut(tab_id).filter(|surface| surface.viewport_bounds.is_some()); let Some(surface) = surface else { return WebSurfaceInputOutcome::DroppedNoViewportBounds; }; let bounds = surface.viewport_bounds.expect("viewport_bounds checked above"); - let Some(point) = WebSurfaceClickPoint::from_window_position(bounds, position, scale_factor) + let Some(point) = + WebSurfaceClickPoint::from_window_position(bounds, position, scale_factor) else { return WebSurfaceInputOutcome::DroppedOutOfBounds; }; @@ -190,12 +199,12 @@ impl WebSurfaceStore { position: Point, scale_factor: f32, ) -> WebSurfaceInputOutcome { - let Some(bounds) = - self.surfaces.get(tab_id).and_then(|surface| surface.viewport_bounds) + let Some(bounds) = self.surfaces.get(tab_id).and_then(|surface| surface.viewport_bounds) else { return WebSurfaceInputOutcome::DroppedNoViewportBounds; }; - let Some(point) = WebSurfaceClickPoint::from_window_position(bounds, position, scale_factor) + let Some(point) = + WebSurfaceClickPoint::from_window_position(bounds, position, scale_factor) else { return WebSurfaceInputOutcome::DroppedOutOfBounds; }; @@ -206,11 +215,8 @@ impl WebSurfaceStore { .map(|surface| surface.scroll_offset_for(requested_url)) .unwrap_or_default(); - let state = WebSurfaceClickState { - requested_url: requested_url.to_string(), - scroll_offset, - point, - }; + let state = + WebSurfaceClickState { requested_url: requested_url.to_string(), scroll_offset, point }; self.keyboard_focus = Some(WebSurfaceKeyboardFocusState { tab_id: tab_id.clone(), requested_url: requested_url.to_string(), @@ -273,6 +279,7 @@ impl WebSurfaceStore { let surface = self.surface_mut(tab_id); let scroll_offset = surface.scroll_offset_for(requested_url); let scroll_delta = surface.pending_scroll_delta.take(); + let scroll_point = surface.pending_scroll_point.take(); let click_point = surface .click_point .take() @@ -287,7 +294,14 @@ impl WebSurfaceStore { .map(|state| state.text); let hover_point = surface.hover_point.take(); - WebSurfacePendingInput { scroll_offset, scroll_delta, click_point, hover_point, typed_text } + WebSurfacePendingInput { + scroll_offset, + scroll_delta, + scroll_point, + click_point, + hover_point, + typed_text, + } } fn previous_ready_frame( diff --git a/crates/ely_app/src/shell/web_surface_controller.rs b/crates/ely_app/src/shell/web_surface_controller.rs index 4c53ce6..5a08b6d 100644 --- a/crates/ely_app/src/shell/web_surface_controller.rs +++ b/crates/ely_app/src/shell/web_surface_controller.rs @@ -67,6 +67,7 @@ impl ElyShell { tab_id: TabId, requested_url: String, delta: Point, + position: Point, scale_factor: f32, cx: &mut Context, ) { @@ -74,6 +75,7 @@ impl ElyShell { &tab_id, requested_url.as_str(), delta, + position, scale_factor, ) == WebSurfaceInputOutcome::Applied { diff --git a/crates/ely_app/src/shell/web_surface_runtime.rs b/crates/ely_app/src/shell/web_surface_runtime.rs index 4e54d55..29d9120 100644 --- a/crates/ely_app/src/shell/web_surface_runtime.rs +++ b/crates/ely_app/src/shell/web_surface_runtime.rs @@ -45,6 +45,8 @@ impl WebSurfaceRuntime { if active_scope != &scope { return Err(active_scope.error_for(&scope)); } + let (scroll_delta_x, scroll_delta_y, scroll_point_x, scroll_point_y) = + scroll_wire_fields(input.scroll_delta, input.scroll_point)?; let session = sessions.entry(tab.id().clone()).or_insert_with(WebSurfaceSession::default); let next_scroll_offset = input.scroll_offset; @@ -58,8 +60,10 @@ impl WebSurfaceRuntime { height: size.height, page_zoom_percent: zoom_percent, device_pixel_ratio: size.device_pixel_ratio_f32(), - scroll_delta_x: input.scroll_delta.map_or(0, |delta| delta.x()), - scroll_delta_y: input.scroll_delta.map_or(0, |delta| delta.y()), + scroll_delta_x, + scroll_delta_y, + scroll_point_x, + scroll_point_y, click_x: input.click_point.map(|point| point.x()), click_y: input.click_point.map(|point| point.y()), hover_x: input.hover_point.map(|point| point.x()), @@ -227,6 +231,20 @@ fn config_dir_for_scope( } } +fn scroll_wire_fields( + delta: Option, + point: Option, +) -> Result<(i32, i32, Option, Option), String> { + match delta { + Some(delta) => { + let point = point + .ok_or_else(|| "Servo scroll input is missing a viewport point".to_string())?; + Ok((delta.x(), delta.y(), Some(point.x()), Some(point.y()))) + } + None => Ok((0, 0, None, None)), + } +} + impl From<&WebSurfaceSitePermission> for ServoLiveSitePermission { fn from(permission: &WebSurfaceSitePermission) -> Self { Self::new( diff --git a/crates/ely_app/src/shell/web_surface_state.rs b/crates/ely_app/src/shell/web_surface_state.rs index 8632389..3e456f9 100644 --- a/crates/ely_app/src/shell/web_surface_state.rs +++ b/crates/ely_app/src/shell/web_surface_state.rs @@ -45,6 +45,7 @@ pub(super) struct WebSurfaceTextInputState { pub(super) struct WebSurfacePendingInput { pub(super) scroll_offset: WebSurfaceScrollOffset, pub(super) scroll_delta: Option, + pub(super) scroll_point: Option, pub(super) click_point: Option, pub(super) hover_point: Option, pub(super) typed_text: Option, @@ -116,6 +117,7 @@ pub(super) struct PerTabSurface { pub(super) hover_point: Option, pub(super) click_point: Option, pub(super) pending_scroll_delta: Option, + pub(super) pending_scroll_point: Option, pub(super) scroll_offset: Option, pub(super) typed_text: Option, pub(super) state: Option, @@ -130,6 +132,7 @@ impl PerTabSurface { hover_point: None, click_point: None, pending_scroll_delta: None, + pending_scroll_point: None, scroll_offset: None, typed_text: None, state: None, diff --git a/crates/ely_app/src/shell/web_surface_tests.rs b/crates/ely_app/src/shell/web_surface_tests.rs index 0bcb526..c42639e 100644 --- a/crates/ely_app/src/shell/web_surface_tests.rs +++ b/crates/ely_app/src/shell/web_surface_tests.rs @@ -42,12 +42,14 @@ fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box> tab.id(), tab.url().as_str(), point(px(0.0), px(140.0)), + point(px(320.0), px(240.0)), 1.0, )); assert_applied(store.record_scroll_delta( tab.id(), tab.url().as_str(), point(px(0.0), px(60.0)), + point(px(300.0), px(220.0)), 1.0, )); @@ -55,6 +57,7 @@ fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box> assert_eq!(input.scroll_offset.y(), 200); assert_eq!(input.scroll_delta.map(|delta| (delta.x(), delta.y())), Some((0, 200))); + assert_eq!(input.scroll_point.map(|point| (point.x(), point.y())), Some((300, 220))); Ok(()) } @@ -91,7 +94,13 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box let tab = web_tab("https://example.com/form")?; assert_eq!( - store.record_click_point( - tab.id(), - tab.url().as_str(), - point(px(160.0), px(120.0)), - 1.0, - ), + store.record_click_point(tab.id(), tab.url().as_str(), point(px(160.0), px(120.0)), 1.0,), WebSurfaceInputOutcome::DroppedNoViewportBounds, ); Ok(()) @@ -206,6 +215,7 @@ fn zero_wheel_delta_reports_zero_delta() -> Result<(), Box> { tab.id(), tab.url().as_str(), point(px(0.0), px(0.0)), + point(px(160.0), px(120.0)), 1.0, ), WebSurfaceInputOutcome::DroppedZeroDelta, @@ -276,7 +286,13 @@ fn zero_wheel_delta_must_not_erase_buffered_click() -> Result<(), Box assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 1.0)); assert_eq!( - store.record_scroll_delta(tab.id(), url, point(px(0.0), px(0.0)), 1.0), + store.record_scroll_delta( + tab.id(), + url, + point(px(0.0), px(0.0)), + point(px(160.0), px(120.0)), + 1.0, + ), WebSurfaceInputOutcome::DroppedZeroDelta, ); diff --git a/crates/ely_app/src/shell/web_surface_view.rs b/crates/ely_app/src/shell/web_surface_view.rs index 4bd15ce..52b83ca 100644 --- a/crates/ely_app/src/shell/web_surface_view.rs +++ b/crates/ely_app/src/shell/web_surface_view.rs @@ -72,12 +72,7 @@ fn error_page(message: &str) -> impl IntoElement { .text_color(rgb(colors::INK)) .child("Page unavailable"), ) - .child( - div() - .text_size(px(14.0)) - .text_color(rgb(colors::INK_3)) - .child(message.to_string()), - ) + .child(div().text_size(px(14.0)).text_color(rgb(colors::INK_3)).child(message.to_string())) } fn render_web_surface( @@ -95,12 +90,7 @@ fn render_web_surface( .size_full() .min_w_0() .overflow_hidden() - .child( - div() - .absolute() - .inset_0() - .child(content), - ) + .child(div().absolute().inset_0().child(content)) .child(render_viewport_tracker(tab.id().clone(), tracker_entity)) .child(render_input_overlay(input_tab_id, input_url, input_entity)) .into_any_element() @@ -169,6 +159,7 @@ fn render_input_overlay( scroll_tab_id.clone(), scroll_url.clone(), delta, + event.position, scale_factor, cx, ); diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index 33780a6..1b274f4 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -150,6 +150,8 @@ fn apply_scroll_if_requested( webview_id: webview_id.clone(), delta_x: args.scroll_x, delta_y: args.scroll_y, + point_x: 0, + point_y: 0, })?; wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) } diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs index 20a8451..aaa4b2f 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs @@ -14,10 +14,10 @@ use ely_servo_host::{ }; use super::args::LiveArgs; +pub(super) use super::live_protocol::LiveSidecarError; use super::live_protocol::{ LiveFrameReport, LiveOutcome, LiveRequest, LiveSitePermission, PartialFrameTimings, }; -pub(super) use super::live_protocol::LiveSidecarError; use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns}; /// Per-`Ensure` budget for Servo to paint after input dispatch. @@ -65,13 +65,7 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> { ), Err(error) => Err(LiveSidecarError::Json(error)), }; - write_outcome( - &mut stdout, - &mut perf, - &mut pending_summary, - outcome, - frame_started_at, - )?; + write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome, frame_started_at)?; } Ok(()) @@ -102,6 +96,8 @@ fn handle_request( device_pixel_ratio, scroll_delta_x, scroll_delta_y, + scroll_point_x, + scroll_point_y, click_x, click_y, hover_x, @@ -115,14 +111,7 @@ fn handle_request( let session = ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?; - if apply_layout( - host, - session, - width, - height, - page_zoom_percent, - device_pixel_ratio, - )? { + if apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)? { session.awaiting_visible_frame = true; } apply_permissions(host, session, &profile, site_permissions)?; @@ -141,17 +130,18 @@ fn handle_request( // the gate skip blank loading frames again. session.ever_visible_frame = false; } - if apply_input( - host, - session, + let input = LiveInput { scroll_delta_x, scroll_delta_y, + scroll_point_x, + scroll_point_y, click_x, click_y, hover_x, hover_y, typed_text, - )? { + }; + if apply_input(host, session, input)? { // Tell poll_frame to actually wait for Servo to paint // a response to this input. The visible-content gate // is bypassed on the hardware path inside poll_frame, @@ -162,7 +152,13 @@ fn handle_request( } let webview_id = session.webview_id.clone(); let mut outcome = poll_frame(host, session, rendering_context_kind)?; - populate_surface_fields(host, &webview_id, &tab_id, published_surface_ids, &mut outcome); + populate_surface_fields( + host, + &webview_id, + &tab_id, + published_surface_ids, + &mut outcome, + ); Ok(outcome) } LiveRequest::Poll { tab_id } => { @@ -171,7 +167,13 @@ fn handle_request( }; let webview_id = session.webview_id.clone(); let mut outcome = poll_frame(host, session, rendering_context_kind)?; - populate_surface_fields(host, &webview_id, &tab_id, published_surface_ids, &mut outcome); + populate_surface_fields( + host, + &webview_id, + &tab_id, + published_surface_ids, + &mut outcome, + ); Ok(outcome) } } @@ -255,18 +257,18 @@ fn write_outcome( // header so the client knows nothing follows). At 1080p × 60 fps // that's 8 MB × 60 = ~480 MB/s of pipe traffic eliminated. let drop_rgba_payload = outcome.response.current_surface_id.is_some(); - if drop_rgba_payload { - if let Some(report) = outcome.response.frame.as_mut() { - report.rgba_byte_count = 0; - } + if drop_rgba_payload + && let Some(report) = outcome.response.frame.as_mut() + { + report.rgba_byte_count = 0; } let write_started_at = Instant::now(); serde_json::to_writer(&mut *stdout, &outcome.response)?; stdout.write_all(b"\n")?; - if !drop_rgba_payload { - if let Some(frame) = outcome.frame.as_ref() { - stdout.write_all(frame.rgba_bytes())?; - } + if !drop_rgba_payload + && let Some(frame) = outcome.frame.as_ref() + { + stdout.write_all(frame.rgba_bytes())?; } stdout.flush()?; if frame_present { @@ -390,37 +392,34 @@ fn apply_permissions( fn apply_input( host: &mut SoftwareServoHost, session: &mut LiveSession, - scroll_delta_x: i32, - scroll_delta_y: i32, - click_x: Option, - click_y: Option, - hover_x: Option, - hover_y: Option, - typed_text: Option, + input: LiveInput, ) -> Result { let mut changed = false; - if scroll_delta_x != 0 || scroll_delta_y != 0 { + if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 { + let (point_x, point_y) = input.scroll_point()?; host.scroll(ScrollRequest { webview_id: session.webview_id.clone(), - delta_x: scroll_delta_x, - delta_y: scroll_delta_y, + delta_x: input.scroll_delta_x, + delta_y: input.scroll_delta_y, + point_x, + point_y, })?; - session.scroll_x = positive_scroll_component(session.scroll_x, scroll_delta_x); - session.scroll_y = positive_scroll_component(session.scroll_y, scroll_delta_y); + session.scroll_x = positive_scroll_component(session.scroll_x, input.scroll_delta_x); + session.scroll_y = positive_scroll_component(session.scroll_y, input.scroll_delta_y); changed = true; } - if let (Some(x), Some(y)) = (hover_x, hover_y) { + if let (Some(x), Some(y)) = (input.hover_x, input.hover_y) { host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?; changed = true; } - if let (Some(x), Some(y)) = (click_x, click_y) { + if let (Some(x), Some(y)) = (input.click_x, input.click_y) { host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?; changed = true; } - if let Some(text) = typed_text { + if let Some(text) = input.typed_text { host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?; changed = true; } @@ -428,6 +427,28 @@ fn apply_input( Ok(changed) } +struct LiveInput { + scroll_delta_x: i32, + scroll_delta_y: i32, + scroll_point_x: Option, + scroll_point_y: Option, + click_x: Option, + click_y: Option, + hover_x: Option, + hover_y: Option, + typed_text: Option, +} + +impl LiveInput { + fn scroll_point(&self) -> Result<(u32, u32), LiveSidecarError> { + let point = match (self.scroll_point_x, self.scroll_point_y) { + (Some(x), Some(y)) => (x, y), + _ => return Err(LiveSidecarError::IncompleteScrollPoint), + }; + Ok(point) + } +} + fn poll_frame( host: &mut SoftwareServoHost, session: &mut LiveSession, @@ -469,8 +490,7 @@ fn poll_frame( let has_visible_content = match rendering_context_kind { RenderingContextKind::Software => { session.ever_visible_frame - || (frame.non_white_pixel_count() > 0 - && frame.content_pixel_count() > 0) + || (frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0) } #[cfg(feature = "hardware-render")] RenderingContextKind::Hardware => true, diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs index af94957..29c80a0 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs @@ -3,7 +3,9 @@ use std::io; -use ely_servo_host::{IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState}; +use ely_servo_host::{ + IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState, +}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -29,6 +31,8 @@ pub(super) enum LiveRequest { device_pixel_ratio: f32, scroll_delta_x: i32, scroll_delta_y: i32, + scroll_point_x: Option, + scroll_point_y: Option, click_x: Option, click_y: Option, #[serde(default)] @@ -195,6 +199,9 @@ pub(super) enum LiveSidecarError { #[error("live session is unavailable after creation")] SessionUnavailable, + #[error("scroll input requires both scroll_point_x and scroll_point_y")] + IncompleteScrollPoint, + #[error(transparent)] Domain(#[from] ely_domain::DomainError), diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs index 5fe5ade..b07e002 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs @@ -262,17 +262,19 @@ mod tests { } #[test] - fn aggregator_emits_summary_after_window_size_records() { + fn aggregator_emits_summary_after_window_size_records() -> Result<(), &'static str> { let mut aggregator = FramePerfAggregator::new("software", FramePerfAggregator::DEFAULT_WINDOW_SIZE); for index in 0..(FramePerfAggregator::DEFAULT_WINDOW_SIZE - 1) { let result = aggregator.record(constant_timing()); assert!(result.is_none(), "should not flush at frame {index}"); } - let summary = aggregator.record(constant_timing()); - let summary = summary.expect("aggregator must flush at window boundary"); + let summary = aggregator + .record(constant_timing()) + .ok_or("aggregator must flush at window boundary")?; assert_eq!(summary.window, FramePerfAggregator::DEFAULT_WINDOW_SIZE); assert_eq!(summary.context, "software"); + Ok(()) } #[test] @@ -286,7 +288,7 @@ mod tests { } #[test] - fn aggregator_percentiles_track_increasing_paint_durations() { + fn aggregator_percentiles_track_increasing_paint_durations() -> Result<(), &'static str> { let mut aggregator = FramePerfAggregator::new("software", 4); let paint_durations_us = [10u64, 100, 1_000, 10_000]; let mut summary = None; @@ -298,11 +300,12 @@ mod tests { Duration::from_micros(paint_us + 2), )); } - let summary = summary.expect("4-frame window must flush"); + let summary = summary.ok_or("4-frame window must flush")?; assert!( summary.paint_p50_us < summary.paint_p99_us, "p99 must dominate p50 for increasing samples: {summary:?}" ); + Ok(()) } fn constant_timing() -> FrameStageTimings { diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 10d4e2a..6878213 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -220,6 +220,8 @@ pub struct ScrollRequest { pub webview_id: WebViewId, pub delta_x: i32, pub delta_y: i32, + pub point_x: u32, + pub point_y: u32, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index 9fb46e2..d8fd50d 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -16,17 +16,15 @@ use dpi::PhysicalSize; use ely_domain::{ProfileId, TabId, WebViewId}; use euclid::Scale; use servo::{ - DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel, - DevicePoint, DeviceVector2D, Opts, RenderingContext, Scroll, Servo, ServoBuilder, - WebViewBuilder, WebViewPoint, WebViewVector, + DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel, DevicePoint, + DeviceVector2D, Opts, RenderingContext, Scroll, Servo, ServoBuilder, WebViewBuilder, + WebViewPoint, WebViewVector, }; /// Wrap an `f32` scale factor in Servo's typed `Scale`. The clamp guards against `NaN`/`inf` reaching Servo's /// layout (which assumes a positive finite scale). -fn hidpi_scale_from_factor( - scale_factor: f32, -) -> Scale { +fn hidpi_scale_from_factor(scale_factor: f32) -> Scale { let safe = if scale_factor.is_finite() && scale_factor > 0.0 { scale_factor.clamp(0.5, 5.0) } else { @@ -37,10 +35,10 @@ fn hidpi_scale_from_factor( use url::Url; use crate::{ - HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, - MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, - RenderedFrame, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, ServoHostError, - TouchTapRequest, WebViewSnapshot, WebViewState, + HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, + NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, + ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, + WebViewSnapshot, WebViewState, runtime_input::{ send_keyboard_text, send_mouse_click, send_mouse_drag, send_mouse_hover, send_touch_tap, }, @@ -265,7 +263,7 @@ impl ServoHost for SoftwareServoHost { request.delta_x as f32, request.delta_y as f32, ))), - WebViewPoint::Device(DevicePoint::zero()), + WebViewPoint::Device(DevicePoint::new(request.point_x as f32, request.point_y as f32)), ); Ok(()) } @@ -544,9 +542,7 @@ impl SoftwareServoHost { crate::HardwareOffscreenContext::new(size.physical()) .map_err(|_| ServoHostError::RenderingContextUnavailable)?, ); - hardware - .make_current() - .map_err(|_| ServoHostError::RenderingContextNotCurrent)?; + hardware.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?; Ok(RenderingContextHandles { rendering_context: hardware.clone(), hardware_context: Some(hardware), @@ -576,10 +572,7 @@ impl SoftwareServoHost { /// Re-asserting per dispatch keeps the invariant on the dispatch /// path instead of spread across creation, navigation, and /// tab-switching. - fn webview_for_input( - &self, - webview_id: &WebViewId, - ) -> Result<&HostWebView, ServoHostError> { + fn webview_for_input(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> { let webview = self.webview(webview_id)?; webview.webview.show(); webview.webview.focus(); diff --git a/crates/ely_servo_host/tests/live_perf_bench.rs b/crates/ely_servo_host/tests/live_perf_bench.rs index 7ba0e4f..27a3e13 100644 --- a/crates/ely_servo_host/tests/live_perf_bench.rs +++ b/crates/ely_servo_host/tests/live_perf_bench.rs @@ -332,6 +332,11 @@ fn build_ensure( ) -> String { let hover_x = if include_hover { Some(256u32) } else { None }; let hover_y = if include_hover { Some(256u32) } else { None }; + let scroll_point = if scroll_dx != 0 || scroll_dy != 0 { + Some((256u32, 256u32)) + } else { + None + }; let hover_x_json = match hover_x { Some(value) => format!("{value}"), None => "null".to_string(), @@ -340,8 +345,16 @@ fn build_ensure( Some(value) => format!("{value}"), None => "null".to_string(), }; + let scroll_point_x_json = match scroll_point { + Some((x, _)) => format!("{x}"), + None => "null".to_string(), + }; + let scroll_point_y_json = match scroll_point { + Some((_, y)) => format!("{y}"), + None => "null".to_string(), + }; format!( - r#"{{"type":"ensure","tab_id":"{tab}","profile_id":"{profile}","url":{url},"width":{w},"height":{h},"page_zoom_percent":100,"scroll_delta_x":{dx},"scroll_delta_y":{dy},"click_x":null,"click_y":null,"hover_x":{hx},"hover_y":{hy},"typed_text":null,"site_permissions":[]}}"#, + r#"{{"type":"ensure","tab_id":"{tab}","profile_id":"{profile}","url":{url},"width":{w},"height":{h},"page_zoom_percent":100,"scroll_delta_x":{dx},"scroll_delta_y":{dy},"scroll_point_x":{sx},"scroll_point_y":{sy},"click_x":null,"click_y":null,"hover_x":{hx},"hover_y":{hy},"typed_text":null,"site_permissions":[]}}"#, tab = tab.as_str(), profile = profile_id.as_str(), url = serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()), @@ -349,6 +362,8 @@ fn build_ensure( h = VIEWPORT_HEIGHT, dx = scroll_dx, dy = scroll_dy, + sx = scroll_point_x_json, + sy = scroll_point_y_json, hx = hover_x_json, hy = hover_y_json, ) @@ -391,11 +406,11 @@ fn read_response_with_bytes( } let response: LiveResponse = serde_json::from_str(json_line.trim_end())?; let mut rgba = Vec::new(); - if let Some(frame) = response.frame.as_ref() { - if frame.rgba_byte_count > 0 { - rgba.resize(frame.rgba_byte_count, 0); - reader.read_exact(&mut rgba)?; - } + if let Some(frame) = response.frame.as_ref() + && frame.rgba_byte_count > 0 + { + rgba.resize(frame.rgba_byte_count, 0); + reader.read_exact(&mut rgba)?; } Ok((response, rgba)) } diff --git a/crates/ely_servo_host/tests/software_host.rs b/crates/ely_servo_host/tests/software_host.rs index aac8b4e..b0d5049 100644 --- a/crates/ely_servo_host/tests/software_host.rs +++ b/crates/ely_servo_host/tests/software_host.rs @@ -226,7 +226,13 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box> { } let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); - host.scroll(ScrollRequest { webview_id: webview_id.clone(), delta_x: 0, delta_y: 480 })?; + host.scroll(ScrollRequest { + webview_id: webview_id.clone(), + delta_x: 0, + delta_y: 480, + point_x: 0, + point_y: 0, + })?; let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?; assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}"); assert_rendered_frame_has_content(&host, "https://servo.org scrolled", MINIMUM_CONTENT_PIXELS)?;