From b70fa71a9fc8d7ce3c0a091765daf92de6bb6642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 11 May 2026 00:46:32 -0400 Subject: [PATCH] T13: push display scale factor into Servo's hidpi so Retina pages lay out at logical CSS dimensions --- crates/ely_app/src/services/servo_live.rs | 7 +++ .../ely_app/src/shell/web_surface_geometry.rs | 23 ++++++++++ .../ely_app/src/shell/web_surface_runtime.rs | 1 + crates/ely_servo_host/Cargo.toml | 3 +- .../src/bin/ely_servo_sidecar/live.rs | 46 ++++++++++++++++++- .../bin/ely_servo_sidecar/live_protocol.rs | 12 +++++ crates/ely_servo_host/src/host.rs | 13 ++++++ crates/ely_servo_host/src/lib.rs | 8 ++-- crates/ely_servo_host/src/runtime.rs | 39 +++++++++++++--- 9 files changed, 139 insertions(+), 13 deletions(-) diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index 02701d9..5d5a74e 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -81,6 +81,7 @@ impl ServoLiveClient { width: request.width, height: request.height, page_zoom_percent: request.page_zoom_percent, + device_pixel_ratio: request.device_pixel_ratio, scroll_delta_x: request.scroll_delta_x, scroll_delta_y: request.scroll_delta_y, click_x: request.click_x, @@ -212,6 +213,11 @@ pub(crate) struct ServoLiveEnsureRequest { pub(crate) width: u32, pub(crate) height: u32, pub(crate) page_zoom_percent: u16, + /// Display scale factor (1.0 standard, 2.0 Retina). Servo's + /// WebView lays out CSS pixels = device pixels / hidpi factor; + /// without this, a Retina viewport gets desktop-CSS-pixel layout + /// and every visible element renders at half its expected size. + pub(crate) device_pixel_ratio: f32, pub(crate) scroll_delta_x: i32, pub(crate) scroll_delta_y: i32, pub(crate) click_x: Option, @@ -401,6 +407,7 @@ enum LiveRequest { width: u32, height: u32, page_zoom_percent: u16, + device_pixel_ratio: f32, scroll_delta_x: i32, scroll_delta_y: i32, click_x: Option, diff --git a/crates/ely_app/src/shell/web_surface_geometry.rs b/crates/ely_app/src/shell/web_surface_geometry.rs index c32877b..f99a677 100644 --- a/crates/ely_app/src/shell/web_surface_geometry.rs +++ b/crates/ely_app/src/shell/web_surface_geometry.rs @@ -4,6 +4,12 @@ use gpui::{Bounds, Pixels, Point}; pub(super) struct WebSurfaceSize { pub(super) width: u32, pub(super) height: u32, + /// Encoded as percent × 1 (e.g. 100 = 1.0 DPR, 200 = 2.0 DPR on + /// Retina). u16 instead of f32 so the struct keeps `Eq` — + /// `WebSurfaceSession::started_loading` compares sizes by value + /// and a float wouldn't compose with that. Convert to/from f32 + /// at the wire boundary via `device_pixel_ratio_f32`. + pub(super) device_pixel_ratio_percent: u16, } impl WebSurfaceSize { @@ -11,8 +17,25 @@ impl WebSurfaceSize { Some(Self { width: viewport_dimension(bounds.size.width, scale_factor)?, height: viewport_dimension(bounds.size.height, scale_factor)?, + device_pixel_ratio_percent: encode_scale_factor(scale_factor), }) } + + pub(super) fn device_pixel_ratio_f32(&self) -> f32 { + f32::from(self.device_pixel_ratio_percent) / 100.0 + } +} + +/// Round `scale_factor` to the nearest whole percent and clamp into a +/// sane range. macOS reports 1.0 / 2.0 typically, fractional values +/// (1.25 / 1.5 / 1.75) show up on Windows / mixed-DPI setups. The +/// clamp guards against `inf`/`nan` from a misbehaving platform. +fn encode_scale_factor(scale_factor: f32) -> u16 { + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return 100; + } + let scaled = (scale_factor * 100.0).round(); + scaled.clamp(50.0, 500.0) as u16 } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/ely_app/src/shell/web_surface_runtime.rs b/crates/ely_app/src/shell/web_surface_runtime.rs index 01d6023..4e54d55 100644 --- a/crates/ely_app/src/shell/web_surface_runtime.rs +++ b/crates/ely_app/src/shell/web_surface_runtime.rs @@ -57,6 +57,7 @@ impl WebSurfaceRuntime { width: size.width, 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()), click_x: input.click_point.map(|point| point.x()), diff --git a/crates/ely_servo_host/Cargo.toml b/crates/ely_servo_host/Cargo.toml index e22d668..6682a81 100644 --- a/crates/ely_servo_host/Cargo.toml +++ b/crates/ely_servo_host/Cargo.toml @@ -7,10 +7,9 @@ rust-version.workspace = true [features] default = [] -servo-engine = ["dep:dpi", "dep:serde", "dep:serde_json", "dep:servo", "dep:url"] +servo-engine = ["dep:dpi", "dep:euclid", "dep:serde", "dep:serde_json", "dep:servo", "dep:url"] hardware-render = [ "servo-engine", - "dep:euclid", "dep:gleam", "dep:glow", "dep:image", 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 aa9ab87..20a8451 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 @@ -99,6 +99,7 @@ fn handle_request( width, height, page_zoom_percent, + device_pixel_ratio, scroll_delta_x, scroll_delta_y, click_x, @@ -114,7 +115,14 @@ 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)? { + 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)?; @@ -305,8 +313,26 @@ fn apply_layout( width: u32, height: u32, page_zoom_percent: u16, + device_pixel_ratio: f32, ) -> Result { let mut changed = false; + // Push the device pixel ratio BEFORE resize. Servo's WebView + // defaults hidpi to 1.0; without this the first layout treats + // physical-pixel viewport widths as CSS-pixel widths and the page + // lays out half the size you'd expect on a Retina display. The + // sidecar-side `LiveSession::hidpi_scale_milli` keeps a u32 of + // (scale × 1000) so f32 jitter from JSON parsing doesn't churn + // the setter every frame. + let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio); + if session.hidpi_scale_milli != hidpi_scale_milli { + host.set_hidpi_scale(ely_servo_host::HidpiScaleRequest { + webview_id: session.webview_id.clone(), + scale_factor: hidpi_scale_milli_to_f32(hidpi_scale_milli), + })?; + session.hidpi_scale_milli = hidpi_scale_milli; + changed = true; + } + if session.width != width || session.height != height { host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?; session.width = width; @@ -326,6 +352,18 @@ fn apply_layout( Ok(changed) } +fn encode_hidpi_scale_milli(scale: f32) -> u32 { + if !scale.is_finite() || scale <= 0.0 { + return 1_000; + } + let scaled = (scale * 1_000.0).round(); + scaled.clamp(500.0, 5_000.0) as u32 +} + +fn hidpi_scale_milli_to_f32(milli: u32) -> f32 { + milli as f32 / 1_000.0 +} + fn apply_permissions( host: &mut SoftwareServoHost, session: &LiveSession, @@ -474,6 +512,11 @@ struct LiveSession { width: u32, height: u32, page_zoom_percent: u16, + /// Last hidpi factor pushed to Servo, encoded as `(scale × 1000)`. + /// Stored as a u32 so equality is cheap and stable across the + /// f32 jitter that JSON parsing can introduce. Init to 0 so the + /// first apply_layout call always pushes a real value. + hidpi_scale_milli: u32, scroll_x: i32, scroll_y: i32, awaiting_visible_frame: bool, @@ -497,6 +540,7 @@ impl LiveSession { width: width.max(1), height: height.max(1), page_zoom_percent: DEFAULT_ZOOM_PERCENT, + hidpi_scale_milli: 0, scroll_x: 0, scroll_y: 0, awaiting_visible_frame: false, 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 64632c3..af94957 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 @@ -19,6 +19,14 @@ pub(super) enum LiveRequest { width: u32, height: u32, page_zoom_percent: u16, + /// Display scale factor reported by the host's window + /// (1.0 standard, 2.0 Retina). The sidecar plumbs this into + /// Servo's `WebView::set_hidpi_scale_factor` so CSS layout + /// happens at logical-pixel dimensions instead of physical. + /// Defaults to 1.0 for backward compatibility if a client + /// (e.g. the live perf bench) omits the field. + #[serde(default = "default_device_pixel_ratio")] + device_pixel_ratio: f32, scroll_delta_x: i32, scroll_delta_y: i32, click_x: Option, @@ -35,6 +43,10 @@ pub(super) enum LiveRequest { }, } +fn default_device_pixel_ratio() -> f32 { + 1.0 +} + #[derive(Deserialize)] pub(super) struct LiveSitePermission { pub origin: String, diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 5e21096..10d4e2a 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -235,6 +235,17 @@ pub struct PageZoomRequest { pub zoom_factor: f32, } +/// Set the WebView's hidpi (device → CSS pixel) scale. Servo's +/// builder defaults this to 1.0; on Retina hosts that produces +/// half-size layout because the page treats physical pixels as CSS +/// pixels. The embedder should mirror the platform's reported scale +/// factor on every viewport-bound change. +#[derive(Clone, Debug, PartialEq)] +pub struct HidpiScaleRequest { + pub webview_id: WebViewId, + pub scale_factor: f32, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct MouseClickRequest { pub webview_id: WebViewId, @@ -316,6 +327,8 @@ pub trait ServoHost { fn set_page_zoom(&mut self, request: PageZoomRequest) -> Result<(), ServoHostError>; + fn set_hidpi_scale(&mut self, request: HidpiScaleRequest) -> Result<(), ServoHostError>; + fn click(&mut self, request: MouseClickRequest) -> Result<(), ServoHostError>; fn hover(&mut self, request: MouseHoverRequest) -> Result<(), ServoHostError>; diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index 51e5176..06b57be 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -21,10 +21,10 @@ pub use error::ServoHostError; pub use hardware_rendering_context::HardwareOffscreenContext; pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity}; pub use host::{ - KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, - NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, - RenderedFrameSummary, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, - TouchTapRequest, WebViewSnapshot, WebViewState, + HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, + MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, + RenderedFrame, RenderedFrameSummary, ResizeRequest, ScreenshotRequest, ScrollRequest, + ServoHost, TouchTapRequest, WebViewSnapshot, WebViewState, }; #[cfg(feature = "servo-engine")] pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost}; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index 1f893fb..7ad005c 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -13,17 +13,33 @@ use std::{ use dpi::PhysicalSize; use ely_domain::{ProfileId, TabId, WebViewId}; +use euclid::Scale; use servo::{ - DeviceIntPoint, DeviceIntRect, DeviceIntSize, 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 { + let safe = if scale_factor.is_finite() && scale_factor > 0.0 { + scale_factor.clamp(0.5, 5.0) + } else { + 1.0 + }; + Scale::new(safe) +} use url::Url; use crate::{ - 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, }, @@ -254,6 +270,17 @@ impl ServoHost for SoftwareServoHost { Ok(()) } + fn set_hidpi_scale(&mut self, request: HidpiScaleRequest) -> Result<(), ServoHostError> { + let webview = self + .webviews + .get(&request.webview_id) + .ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?; + + let scale = hidpi_scale_from_factor(request.scale_factor); + webview.webview.set_hidpi_scale_factor(scale); + Ok(()) + } + fn hover(&mut self, request: MouseHoverRequest) -> Result<(), ServoHostError> { let webview = self.webview_for_input(&request.webview_id)?; send_mouse_hover(&webview.webview, request.x, request.y);