Route web surface scrolls to Servo hit point

This commit is contained in:
2026-05-12 23:29:40 -04:00
parent 6f279bf3e6
commit 0db1caad69
15 changed files with 220 additions and 126 deletions
@@ -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)
}
@@ -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<u32>,
click_y: Option<u32>,
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
input: LiveInput,
) -> Result<bool, LiveSidecarError> {
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<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
}
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,
@@ -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<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
#[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),
@@ -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 {
+2
View File
@@ -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)]
+11 -18
View File
@@ -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<f32, DeviceIndependentPixel,
/// DevicePixel>`. 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<f32, DeviceIndependentPixel, DevicePixel> {
fn hidpi_scale_from_factor(scale_factor: f32) -> Scale<f32, DeviceIndependentPixel, DevicePixel> {
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();
+21 -6
View File
@@ -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))
}
+7 -1
View File
@@ -226,7 +226,13 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
}
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)?;