Add mouse hover tracking across the Servo input pipeline

Without continuous MouseMove events, Servo never updates hover state:
no cursor changes over links, no :hover CSS effects, no mouseenter
JavaScript events. The web page appears completely non-interactive.

Track mouse position from GPUI on_mouse_move through the full IPC
pipeline to Servo. Hover position is included with each ensure
request so Servo updates hover state at frame rate (~60fps).
This commit is contained in:
2026-05-09 16:21:43 -04:00
parent e621c43757
commit 4c6d9c757a
9 changed files with 1219 additions and 433 deletions
@@ -0,0 +1,428 @@
use std::{
collections::HashMap,
fs,
io::{self, BufRead, Write},
path::PathBuf,
thread,
time::{Duration, Instant},
};
use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId, UrlText};
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, NavigationRequest, PageZoomRequest, PermissionDecision,
PermissionRequest, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost, ServoHostError,
ServoSurfaceSize, SoftwareServoHost, WebViewSnapshot, WebViewState,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::args::LiveArgs;
const LIVE_FRAME_WAIT_TIMEOUT: Duration = Duration::from_millis(60);
const LIVE_FRAME_WAIT_INTERVAL: Duration = Duration::from_millis(2);
pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
fs::create_dir_all(&args.profile_data_dir)?;
let mut host = SoftwareServoHost::new_with_config_dir(
ServoSurfaceSize::new(1, 1),
Some(args.profile_data_dir),
)?;
let mut sessions = HashMap::new();
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let response = match serde_json::from_str::<LiveRequest>(&line) {
Ok(request) => handle_request(&mut host, &mut sessions, request),
Err(error) => Err(LiveSidecarError::Json(error)),
};
write_response(&mut stdout, response)?;
}
Ok(())
}
fn handle_request(
host: &mut SoftwareServoHost,
sessions: &mut HashMap<String, LiveSession>,
request: LiveRequest,
) -> Result<LiveResponse, LiveSidecarError> {
match request {
LiveRequest::Ensure {
tab_id,
profile_id,
url,
width,
height,
page_zoom_percent,
scroll_delta_x,
scroll_delta_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
site_permissions,
rgba_out,
} => {
let tab = TabId::parse(tab_id.clone())?;
let profile = ProfileId::parse(profile_id)?;
let url = UrlText::parse(url)?;
let session = ensure_session(host, sessions, tab_id, &tab, &profile, width, height)?;
if apply_layout(host, session, width, height, page_zoom_percent)? {
session.awaiting_visible_frame = true;
}
apply_permissions(host, session, &profile, site_permissions)?;
if session.requested_url != url.as_str() {
host.navigate(NavigationRequest {
webview_id: session.webview_id.clone(),
tab_id: tab,
url: url.clone(),
})?;
session.requested_url = url.as_str().to_string();
session.scroll_x = 0;
session.scroll_y = 0;
session.awaiting_visible_frame = true;
}
if apply_input(
host,
session,
scroll_delta_x,
scroll_delta_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
)? {
session.awaiting_visible_frame = true;
}
poll_frame(host, session, rgba_out.into())
}
LiveRequest::Poll { tab_id, rgba_out } => {
let Some(session) = sessions.get_mut(&tab_id) else {
return Ok(LiveResponse::empty());
};
poll_frame(host, session, rgba_out.into())
}
}
}
fn write_response(
stdout: &mut impl Write,
response: Result<LiveResponse, LiveSidecarError>,
) -> Result<(), LiveSidecarError> {
let response = match response {
Ok(response) => response,
Err(error) => LiveResponse::error(error.to_string()),
};
serde_json::to_writer(&mut *stdout, &response)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
Ok(())
}
fn ensure_session<'a>(
host: &mut SoftwareServoHost,
sessions: &'a mut HashMap<String, LiveSession>,
key: String,
tab_id: &TabId,
profile_id: &ProfileId,
width: u32,
height: u32,
) -> Result<&'a mut LiveSession, LiveSidecarError> {
if !sessions.contains_key(&key) {
let webview_id = host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
ServoSurfaceSize::new(width, height),
)?;
sessions.insert(key.clone(), LiveSession::new(webview_id, width, height));
}
sessions.get_mut(&key).ok_or(LiveSidecarError::SessionUnavailable)
}
fn apply_layout(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
width: u32,
height: u32,
page_zoom_percent: u16,
) -> Result<bool, LiveSidecarError> {
let mut changed = false;
if session.width != width || session.height != height {
host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?;
session.width = width;
session.height = height;
changed = true;
}
if session.page_zoom_percent != page_zoom_percent {
host.set_page_zoom(PageZoomRequest {
webview_id: session.webview_id.clone(),
zoom_factor: f32::from(page_zoom_percent) / 100.0,
})?;
session.page_zoom_percent = page_zoom_percent;
changed = true;
}
Ok(changed)
}
fn apply_permissions(
host: &mut SoftwareServoHost,
session: &LiveSession,
profile_id: &ProfileId,
permissions: Vec<LiveSitePermission>,
) -> Result<(), LiveSidecarError> {
for permission in permissions {
host.set_permission(
PermissionRequest {
webview_id: session.webview_id.clone(),
profile_id: profile_id.clone(),
origin: ely_domain::SiteOrigin::parse(permission.origin)?,
feature: ely_domain::SitePermissionFeature::parse(permission.feature.as_str())?,
},
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
permission.decision.as_str(),
)?),
)?;
}
Ok(())
}
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>,
) -> Result<bool, LiveSidecarError> {
let mut changed = false;
if scroll_delta_x != 0 || scroll_delta_y != 0 {
host.scroll(ScrollRequest {
webview_id: session.webview_id.clone(),
delta_x: scroll_delta_x,
delta_y: scroll_delta_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);
changed = true;
}
if let (Some(x), Some(y)) = (hover_x, hover_y) {
host.hover(x, y)?;
changed = true;
}
if let (Some(x), Some(y)) = (click_x, click_y) {
host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?;
changed = true;
}
if let Some(text) = typed_text {
host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?;
changed = true;
}
Ok(changed)
}
fn poll_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
rgba_out: PathBuf,
) -> Result<LiveResponse, LiveSidecarError> {
let started_at = Instant::now();
let mut latest_frame = None;
loop {
host.tick();
let snapshot = host.snapshot(&session.webview_id)?;
if snapshot.has_pending_frame() {
host.paint(&session.webview_id)?;
let snapshot = host.snapshot(&session.webview_id)?;
let frame = host.last_rendered_frame()?;
let has_visible_content =
frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0;
fs::write(&rgba_out, frame.rgba_bytes())?;
let response =
LiveResponse::frame(LiveFrameReport::new(&snapshot, &frame, rgba_out.clone()));
if has_visible_content {
session.awaiting_visible_frame = false;
return Ok(response);
}
if !session.awaiting_visible_frame {
return Ok(response);
}
latest_frame = Some(response);
}
if !session.awaiting_visible_frame {
return Ok(LiveResponse::empty());
}
if started_at.elapsed() >= LIVE_FRAME_WAIT_TIMEOUT {
return Ok(latest_frame.unwrap_or_else(LiveResponse::empty));
}
thread::sleep(LIVE_FRAME_WAIT_INTERVAL);
}
}
#[derive(Clone)]
struct LiveSession {
webview_id: ely_domain::WebViewId,
requested_url: String,
width: u32,
height: u32,
page_zoom_percent: u16,
scroll_x: i32,
scroll_y: i32,
awaiting_visible_frame: bool,
}
impl LiveSession {
fn new(webview_id: ely_domain::WebViewId, width: u32, height: u32) -> Self {
Self {
webview_id,
requested_url: String::new(),
width: width.max(1),
height: height.max(1),
page_zoom_percent: DEFAULT_ZOOM_PERCENT,
scroll_x: 0,
scroll_y: 0,
awaiting_visible_frame: false,
}
}
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum LiveRequest {
Ensure {
tab_id: String,
profile_id: String,
url: String,
width: u32,
height: u32,
page_zoom_percent: u16,
scroll_delta_x: i32,
scroll_delta_y: i32,
click_x: Option<u32>,
click_y: Option<u32>,
#[serde(default)]
hover_x: Option<u32>,
#[serde(default)]
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<LiveSitePermission>,
rgba_out: String,
},
Poll {
tab_id: String,
rgba_out: String,
},
}
#[derive(Deserialize)]
struct LiveSitePermission {
origin: String,
feature: String,
decision: String,
}
#[derive(Serialize)]
struct LiveResponse {
error: Option<String>,
frame: Option<LiveFrameReport>,
}
impl LiveResponse {
fn empty() -> Self {
Self { error: None, frame: None }
}
fn frame(frame: LiveFrameReport) -> Self {
Self { error: None, frame: Some(frame) }
}
fn error(message: String) -> Self {
Self { error: Some(message), frame: None }
}
}
#[derive(Serialize)]
struct LiveFrameReport {
loaded_url: Option<String>,
title: Option<String>,
state: &'static str,
width: u32,
height: u32,
rgba_path: PathBuf,
rgba_byte_count: usize,
non_white_pixel_count: u64,
content_pixel_count: u64,
sample_hash: u64,
}
impl LiveFrameReport {
fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame, rgba_path: PathBuf) -> Self {
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()),
width: frame.width(),
height: frame.height(),
rgba_path,
rgba_byte_count: frame.rgba_bytes().len(),
non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(),
}
}
}
#[derive(Debug, Error)]
pub(super) enum LiveSidecarError {
#[error("live session is unavailable after creation")]
SessionUnavailable,
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}
fn positive_scroll_component(current: i32, delta: i32) -> i32 {
let value = i64::from(current) + i64::from(delta);
value.clamp(0, i64::from(i32::MAX)) as i32
}
+76 -35
View File
@@ -23,7 +23,9 @@ use crate::{
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_input::{
send_keyboard_text, send_mouse_click, send_mouse_drag, send_mouse_hover, send_touch_tap,
},
runtime_permissions::{PermissionStore, set_permission_decision},
runtime_waker::ServoWakeFlag,
runtime_webview::{HostWebView, HostWebViewDelegate},
@@ -52,7 +54,7 @@ impl ServoSurfaceSize {
pub struct SoftwareServoHost {
servo: Servo,
rendering_context: Rc<dyn RenderingContext>,
default_surface_size: ServoSurfaceSize,
webviews: HashMap<WebViewId, HostWebView>,
permissions: PermissionStore,
wake_requested: Arc<AtomicBool>,
@@ -82,16 +84,19 @@ impl SoftwareServoHost {
host
}
pub fn create_webview_with_size(
&mut self,
tab_id: TabId,
profile_id: ProfileId,
size: ServoSurfaceSize,
) -> Result<WebViewId, ServoHostError> {
self.create_webview_in_context(tab_id, profile_id, size)
}
fn new_started(
size: ServoSurfaceSize,
config_dir: Option<PathBuf>,
) -> Result<Self, ServoHostError> {
let rendering_context = Rc::new(
servo::SoftwareRenderingContext::new(size.physical())
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
);
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
let wake_requested = Arc::new(AtomicBool::new(false));
let mut builder = ServoBuilder::default()
.event_loop_waker(Box::new(ServoWakeFlag::new(wake_requested.clone())));
@@ -102,7 +107,7 @@ impl SoftwareServoHost {
Ok(Self {
servo,
rendering_context,
default_surface_size: size,
webviews: HashMap::new(),
permissions: Rc::new(RefCell::new(HashMap::new())),
wake_requested,
@@ -117,19 +122,7 @@ impl ServoHost for SoftwareServoHost {
tab_id: TabId,
profile_id: ProfileId,
) -> Result<WebViewId, ServoHostError> {
let webview_id = WebViewId::new();
let delegate =
Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone()));
let webview = WebViewBuilder::new(&self.servo, self.rendering_context.clone())
.delegate(delegate.clone())
.build();
self.webviews.insert(
webview_id.clone(),
HostWebView { tab_id, profile_id, webview, delegate, requested_url: None },
);
Ok(webview_id)
self.create_webview_in_context(tab_id, profile_id, self.default_surface_size)
}
fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError> {
@@ -138,7 +131,6 @@ impl ServoHost for SoftwareServoHost {
})?;
self.servo.spin_event_loop();
let servo = self.servo.clone();
let rendering_context = self.rendering_context.clone();
let webview = self
.webviews
.get_mut(&request.webview_id)
@@ -151,7 +143,7 @@ impl ServoHost for SoftwareServoHost {
webview.delegate.set_state(WebViewState::Loading);
if should_create_initial_document {
webview.webview = WebViewBuilder::new(&servo, rendering_context)
webview.webview = WebViewBuilder::new(&servo, webview.rendering_context.clone())
.delegate(webview.delegate.clone())
.url(url)
.build();
@@ -188,7 +180,9 @@ impl ServoHost for SoftwareServoHost {
.get(&request.webview_id)
.ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?;
webview.webview.resize(PhysicalSize::new(request.width, request.height));
let size = PhysicalSize::new(request.width, request.height);
webview.rendering_context.resize(size);
webview.webview.resize(size);
Ok(())
}
@@ -202,6 +196,13 @@ impl ServoHost for SoftwareServoHost {
Ok(())
}
fn hover(&mut self, x: u32, y: u32) -> Result<(), ServoHostError> {
if let Some(webview) = self.webviews.values().next() {
send_mouse_hover(&webview.webview, x, y);
}
Ok(())
}
fn click(&mut self, request: MouseClickRequest) -> Result<(), ServoHostError> {
let webview = self
.webviews
@@ -319,16 +320,15 @@ impl ServoHost for SoftwareServoHost {
}
fn paint(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> {
self.rendering_context
.make_current()
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
self.rendering_context.prepare_for_rendering();
let rendering_context = self.webview(webview_id)?.rendering_context.clone();
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
rendering_context.prepare_for_rendering();
{
let webview = self.webview(webview_id)?;
webview.webview.paint();
}
let rendered_frame = self.read_rendered_frame()?;
self.rendering_context.present();
let rendered_frame = Self::read_rendered_frame(rendering_context.as_ref())?;
rendering_context.present();
self.webview(webview_id)?.delegate.mark_frame_presented();
self.last_rendered_frame = Some(rendered_frame);
Ok(())
@@ -340,14 +340,56 @@ impl ServoHost for SoftwareServoHost {
}
impl SoftwareServoHost {
fn create_webview_in_context(
&mut self,
tab_id: TabId,
profile_id: ProfileId,
size: ServoSurfaceSize,
) -> Result<WebViewId, ServoHostError> {
let webview_id = WebViewId::new();
let rendering_context = Self::new_rendering_context(size)?;
let delegate =
Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone()));
let webview = WebViewBuilder::new(&self.servo, rendering_context.clone())
.delegate(delegate.clone())
.build();
self.webviews.insert(
webview_id.clone(),
HostWebView {
tab_id,
profile_id,
rendering_context,
webview,
delegate,
requested_url: None,
},
);
Ok(webview_id)
}
fn new_rendering_context(
size: ServoSurfaceSize,
) -> Result<Rc<dyn RenderingContext>, ServoHostError> {
let rendering_context = Rc::new(
servo::SoftwareRenderingContext::new(size.physical())
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
);
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(rendering_context)
}
fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> {
self.webviews
.get(webview_id)
.ok_or_else(|| ServoHostError::WebViewNotFound { id: webview_id.clone() })
}
fn read_rendered_frame(&self) -> Result<RenderedFrame, ServoHostError> {
let size = self.rendering_context.size();
fn read_rendered_frame(
rendering_context: &dyn RenderingContext,
) -> Result<RenderedFrame, ServoHostError> {
let size = rendering_context.size();
let width =
i32::try_from(size.width).map_err(|_| ServoHostError::RenderedFrameUnavailable)?;
let height =
@@ -356,8 +398,7 @@ impl SoftwareServoHost {
DeviceIntPoint::new(0, 0),
DeviceIntSize::new(width, height),
);
let image = self
.rendering_context
let image = rendering_context
.read_to_image(frame_rect)
.ok_or(ServoHostError::RenderedFrameUnavailable)?;
@@ -6,6 +6,10 @@ use servo::{
use crate::keyboard::key_and_code_for_character;
pub(super) fn send_mouse_hover(webview: &WebView, x: u32, y: u32) {
webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(point(x, y))));
}
pub(super) fn send_mouse_click(webview: &WebView, x: u32, y: u32) {
let point = point(x, y);
webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(point)));