Add Servo page zoom support

This commit is contained in:
2026-05-09 12:32:44 -04:00
parent 088d1166c4
commit 1faa7423c1
26 changed files with 697 additions and 280 deletions
@@ -6,9 +6,9 @@ use std::{
use ely_domain::TabId;
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PermissionRequest,
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest,
WebViewSnapshot, WebViewState,
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
PermissionRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize,
SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState,
};
use thiserror::Error;
@@ -59,6 +59,10 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> {
let tab_id = TabId::new();
let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?;
apply_site_permissions(&mut host, &webview_id, &args)?;
host.set_page_zoom(PageZoomRequest {
webview_id: webview_id.clone(),
zoom_factor: f32::from(args.page_zoom_percent) / 100.0,
})?;
host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
@@ -1,6 +1,9 @@
use std::{env, num::ParseIntError, path::PathBuf};
use ely_domain::{ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText};
use ely_domain::{
DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature,
UrlText, validate_zoom_percent,
};
use serde::Deserialize;
use thiserror::Error;
@@ -17,6 +20,7 @@ pub(super) struct SnapshotArgs {
pub(super) height: u32,
pub(super) scroll_x: i32,
pub(super) scroll_y: i32,
pub(super) page_zoom_percent: u16,
pub(super) click_point: Option<ClickPoint>,
pub(super) drag_points: Option<DragPoints>,
pub(super) touch_point: Option<ClickPoint>,
@@ -122,6 +126,7 @@ fn parse_snapshot_args(
let mut height = None;
let mut scroll_x = 0;
let mut scroll_y = 0;
let mut page_zoom_percent = DEFAULT_ZOOM_PERCENT;
let mut click_x = None;
let mut click_y = None;
let mut drag_from_x = None;
@@ -162,6 +167,12 @@ fn parse_snapshot_args(
scroll_y =
parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)?
}
"--page-zoom-percent" => {
page_zoom_percent = parse_zoom_percent(
"--page-zoom-percent",
next_argument(&mut args, "--page-zoom-percent")?,
)?
}
"--click-x" => {
click_x = Some(parse_click_coordinate(
"--click-x",
@@ -248,6 +259,7 @@ fn parse_snapshot_args(
height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?,
scroll_x,
scroll_y,
page_zoom_percent,
click_point,
drag_points,
touch_point,
@@ -302,6 +314,15 @@ fn parse_click_coordinate(name: &'static str, value: String) -> Result<u32, Side
value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
}
fn parse_zoom_percent(name: &'static str, value: String) -> Result<u16, SidecarArgsError> {
let percent = value.parse::<u16>().map_err(|source| SidecarArgsError::InvalidInteger {
name,
value,
source,
})?;
Ok(validate_zoom_percent(percent)?)
}
fn parse_path(name: &'static str, value: String) -> Result<PathBuf, SidecarArgsError> {
if value.trim().is_empty() {
return Err(SidecarArgsError::EmptyPath { name });
@@ -315,7 +336,9 @@ mod tests {
use std::{env, path::PathBuf};
use super::{SidecarArgsError, SidecarCommand, parse_command};
use ely_domain::{DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature};
use ely_domain::{
DEFAULT_ZOOM_PERCENT, DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature,
};
#[test]
fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> {
@@ -326,6 +349,7 @@ mod tests {
let SidecarCommand::Snapshot(args) = command;
assert_eq!(args.profile_id, profile_id);
assert_eq!(args.profile_data_dir, profile_data_dir);
assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT);
Ok(())
}
@@ -378,6 +402,33 @@ mod tests {
Ok(())
}
#[test]
fn parses_snapshot_page_zoom_percent() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--page-zoom-percent".to_string());
command.push("125".to_string());
let SidecarCommand::Snapshot(args) = parse_command(command)?;
assert_eq!(args.page_zoom_percent, 125);
Ok(())
}
#[test]
fn rejects_out_of_range_snapshot_page_zoom_percent() {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--page-zoom-percent".to_string());
command.push("5".to_string());
assert!(matches!(
parse_command(command),
Err(SidecarArgsError::Domain(DomainError::InvalidZoomPercent { value: 5, .. }))
));
}
fn parse_snapshot_command(
profile_id: &ProfileId,
profile_data_dir: PathBuf,
@@ -28,6 +28,7 @@ pub(super) struct SnapshotReport {
sample_hash: u64,
scroll_x: i32,
scroll_y: i32,
page_zoom_percent: u16,
scroll_changed_frame: bool,
click_x: Option<u32>,
click_y: Option<u32>,
@@ -67,6 +68,7 @@ impl SnapshotReport {
sample_hash: frame.sample_hash(),
scroll_x: args.scroll_x,
scroll_y: args.scroll_y,
page_zoom_percent: args.page_zoom_percent,
scroll_changed_frame: changes.scroll,
click_x: args.click_point.map(|point| point.x),
click_y: args.click_point.map(|point| point.y),
+8
View File
@@ -229,6 +229,12 @@ pub struct ResizeRequest {
pub height: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PageZoomRequest {
pub webview_id: WebViewId,
pub zoom_factor: f32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MouseClickRequest {
pub webview_id: WebViewId,
@@ -301,6 +307,8 @@ pub trait ServoHost {
fn resize(&mut self, request: ResizeRequest) -> Result<(), ServoHostError>;
fn set_page_zoom(&mut self, request: PageZoomRequest) -> Result<(), ServoHostError>;
fn click(&mut self, request: MouseClickRequest) -> Result<(), ServoHostError>;
fn drag(&mut self, request: MouseDragRequest) -> Result<(), ServoHostError>;
+3 -1
View File
@@ -10,10 +10,12 @@ mod runtime_input;
mod runtime_permissions;
#[cfg(feature = "servo-engine")]
mod runtime_waker;
#[cfg(feature = "servo-engine")]
mod runtime_webview;
pub use error::ServoHostError;
pub use host::{
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest,
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
PermissionDecision, PermissionRequest, RenderedFrame, RenderedFrameSummary, ResizeRequest,
ScreenshotRequest, ScrollRequest, ServoHost, TouchTapRequest, WebViewSnapshot, WebViewState,
};
+16 -146
View File
@@ -1,5 +1,5 @@
use std::{
cell::{Cell, RefCell},
cell::RefCell,
collections::HashMap,
path::PathBuf,
rc::Rc,
@@ -14,21 +14,19 @@ use std::{
use dpi::PhysicalSize;
use ely_domain::{ProfileId, TabId, WebViewId};
use servo::{
DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePoint, DeviceVector2D, LoadStatus, Opts,
RenderingContext, Scroll, Servo, ServoBuilder, WebView, WebViewBuilder, WebViewDelegate,
WebViewPoint, WebViewVector,
DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePoint, DeviceVector2D, Opts,
RenderingContext, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint, WebViewVector,
};
use url::Url;
use crate::{
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest,
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, 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_touch_tap},
runtime_permissions::{
PermissionStore, permission_decision_for_webview, set_permission_decision,
},
runtime_permissions::{PermissionStore, set_permission_decision},
runtime_waker::ServoWakeFlag,
runtime_webview::{HostWebView, HostWebViewDelegate},
};
static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false);
@@ -194,6 +192,16 @@ impl ServoHost for SoftwareServoHost {
Ok(())
}
fn set_page_zoom(&mut self, request: PageZoomRequest) -> Result<(), ServoHostError> {
let webview = self
.webviews
.get(&request.webview_id)
.ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?;
webview.webview.set_page_zoom(request.zoom_factor);
Ok(())
}
fn click(&mut self, request: MouseClickRequest) -> Result<(), ServoHostError> {
let webview = self
.webviews
@@ -356,141 +364,3 @@ impl SoftwareServoHost {
Ok(RenderedFrame::from_rgba_bytes(size.width, size.height, image.into_raw()))
}
}
struct HostWebView {
tab_id: TabId,
profile_id: ProfileId,
webview: WebView,
delegate: Rc<HostWebViewDelegate>,
requested_url: Option<String>,
}
impl HostWebView {
fn snapshot(&self, webview_id: &WebViewId) -> WebViewSnapshot {
WebViewSnapshot::new(
webview_id.clone(),
self.tab_id.clone(),
self.profile_id.clone(),
self.state(),
self.current_url(),
self.current_title(),
self.delegate.has_pending_frame(),
)
}
fn state(&self) -> WebViewState {
let state = self.delegate.state();
if matches!(state, WebViewState::Crashed | WebViewState::Sleeping) {
return state;
}
if let Some(requested_url) = &self.requested_url
&& self.current_url().as_deref() != Some(requested_url.as_str())
{
return WebViewState::Loading;
}
state
}
fn current_url(&self) -> Option<String> {
self.webview.url().map(|url| url.to_string()).or_else(|| self.delegate.url())
}
fn current_title(&self) -> Option<String> {
self.webview.page_title().or_else(|| self.delegate.title())
}
}
struct HostWebViewDelegate {
profile_id: ProfileId,
permissions: PermissionStore,
state: RefCell<WebViewState>,
url: RefCell<Option<String>>,
title: RefCell<Option<String>>,
has_pending_frame: Cell<bool>,
}
impl HostWebViewDelegate {
fn new(profile_id: ProfileId, permissions: PermissionStore) -> Self {
Self {
profile_id,
permissions,
state: RefCell::new(WebViewState::Created),
url: RefCell::new(None),
title: RefCell::new(None),
has_pending_frame: Cell::new(false),
}
}
fn set_state(&self, state: WebViewState) {
self.state.replace(state);
}
fn state(&self) -> WebViewState {
self.state.borrow().clone()
}
fn url(&self) -> Option<String> {
self.url.borrow().clone()
}
fn title(&self) -> Option<String> {
self.title.borrow().clone()
}
fn has_pending_frame(&self) -> bool {
self.has_pending_frame.get()
}
fn mark_frame_presented(&self) {
self.has_pending_frame.set(false);
}
}
impl WebViewDelegate for HostWebViewDelegate {
fn notify_url_changed(&self, _webview: WebView, url: Url) {
self.url.replace(Some(url.to_string()));
}
fn notify_page_title_changed(&self, _webview: WebView, title: Option<String>) {
self.title.replace(title);
}
fn notify_load_status_changed(&self, _webview: WebView, status: LoadStatus) {
let state = match status {
LoadStatus::Started | LoadStatus::HeadParsed => WebViewState::Loading,
LoadStatus::Complete => WebViewState::Complete,
};
self.set_state(state);
}
fn notify_new_frame_ready(&self, _webview: WebView) {
self.has_pending_frame.set(true);
}
fn notify_crashed(&self, _webview: WebView, _reason: String, _backtrace: Option<String>) {
self.set_state(WebViewState::Crashed);
}
fn request_navigation(&self, _webview: WebView, navigation_request: servo::NavigationRequest) {
navigation_request.allow();
}
fn request_permission(&self, webview: WebView, permission_request: servo::PermissionRequest) {
match permission_decision_for_webview(
&self.permissions,
&self.profile_id,
&webview,
self.url(),
permission_request.feature(),
) {
Some(PermissionDecision::AllowOnce | PermissionDecision::AllowAlways) => {
permission_request.allow();
}
Some(PermissionDecision::DenyAlways) | None => {
permission_request.deny();
}
}
}
}
@@ -0,0 +1,148 @@
use std::{cell::Cell, cell::RefCell, rc::Rc};
use ely_domain::{ProfileId, TabId, WebViewId};
use servo::{LoadStatus, WebView, WebViewDelegate};
use url::Url;
use crate::{
PermissionDecision, WebViewSnapshot, WebViewState,
runtime_permissions::{PermissionStore, permission_decision_for_webview},
};
pub(super) struct HostWebView {
pub(super) tab_id: TabId,
pub(super) profile_id: ProfileId,
pub(super) webview: WebView,
pub(super) delegate: Rc<HostWebViewDelegate>,
pub(super) requested_url: Option<String>,
}
impl HostWebView {
pub(super) fn snapshot(&self, webview_id: &WebViewId) -> WebViewSnapshot {
WebViewSnapshot::new(
webview_id.clone(),
self.tab_id.clone(),
self.profile_id.clone(),
self.state(),
self.current_url(),
self.current_title(),
self.delegate.has_pending_frame(),
)
}
pub(super) fn state(&self) -> WebViewState {
let state = self.delegate.state();
if matches!(state, WebViewState::Crashed | WebViewState::Sleeping) {
return state;
}
if let Some(requested_url) = &self.requested_url
&& self.current_url().as_deref() != Some(requested_url.as_str())
{
return WebViewState::Loading;
}
state
}
pub(super) fn current_url(&self) -> Option<String> {
self.webview.url().map(|url| url.to_string()).or_else(|| self.delegate.url())
}
fn current_title(&self) -> Option<String> {
self.webview.page_title().or_else(|| self.delegate.title())
}
}
pub(super) struct HostWebViewDelegate {
profile_id: ProfileId,
permissions: PermissionStore,
state: RefCell<WebViewState>,
url: RefCell<Option<String>>,
title: RefCell<Option<String>>,
has_pending_frame: Cell<bool>,
}
impl HostWebViewDelegate {
pub(super) fn new(profile_id: ProfileId, permissions: PermissionStore) -> Self {
Self {
profile_id,
permissions,
state: RefCell::new(WebViewState::Created),
url: RefCell::new(None),
title: RefCell::new(None),
has_pending_frame: Cell::new(false),
}
}
pub(super) fn set_state(&self, state: WebViewState) {
self.state.replace(state);
}
fn state(&self) -> WebViewState {
self.state.borrow().clone()
}
fn url(&self) -> Option<String> {
self.url.borrow().clone()
}
fn title(&self) -> Option<String> {
self.title.borrow().clone()
}
fn has_pending_frame(&self) -> bool {
self.has_pending_frame.get()
}
pub(super) fn mark_frame_presented(&self) {
self.has_pending_frame.set(false);
}
}
impl WebViewDelegate for HostWebViewDelegate {
fn notify_url_changed(&self, _webview: WebView, url: Url) {
self.url.replace(Some(url.to_string()));
}
fn notify_page_title_changed(&self, _webview: WebView, title: Option<String>) {
self.title.replace(title);
}
fn notify_load_status_changed(&self, _webview: WebView, status: LoadStatus) {
let state = match status {
LoadStatus::Started | LoadStatus::HeadParsed => WebViewState::Loading,
LoadStatus::Complete => WebViewState::Complete,
};
self.set_state(state);
}
fn notify_new_frame_ready(&self, _webview: WebView) {
self.has_pending_frame.set(true);
}
fn notify_crashed(&self, _webview: WebView, _reason: String, _backtrace: Option<String>) {
self.set_state(WebViewState::Crashed);
}
fn request_navigation(&self, _webview: WebView, navigation_request: servo::NavigationRequest) {
navigation_request.allow();
}
fn request_permission(&self, webview: WebView, permission_request: servo::PermissionRequest) {
match permission_decision_for_webview(
&self.permissions,
&self.profile_id,
&webview,
self.url(),
permission_request.feature(),
) {
Some(PermissionDecision::AllowOnce | PermissionDecision::AllowAlways) => {
permission_request.allow();
}
Some(PermissionDecision::DenyAlways) | None => {
permission_request.deny();
}
}
}
}
+13 -1
View File
@@ -10,7 +10,7 @@ use std::{
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest,
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
PermissionDecision, PermissionRequest, ResizeRequest, ScreenshotRequest, ScrollRequest,
ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState,
};
@@ -120,6 +120,18 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
);
assert_rendered_frame_has_content(&host, "data:text/html", 1)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.set_page_zoom(PageZoomRequest { webview_id: webview_id.clone(), zoom_factor: 1.25 })?;
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, "data:text/html zoomed", 1)?;
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.set_page_zoom(PageZoomRequest { webview_id: webview_id.clone(), zoom_factor: 1.0 })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.click(MouseClickRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;