Files
ELY-Browser/crates/ely_app/src/shell/web_surface.rs
T
ZacharyZhang-NY 4c6d9c757a 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).
2026-05-09 16:21:43 -04:00

323 lines
11 KiB
Rust

use std::collections::BTreeMap;
use ely_domain::{BrowserTab, TabId};
use gpui::{Bounds, Pixels, Point};
use crate::services::ProfileDataMode;
use super::{
web_surface_frame::WebSurfaceFrame,
web_surface_geometry::{
WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceScrollOffset, WebSurfaceSize,
},
web_surface_permissions::WebSurfaceSitePermission,
web_surface_runtime::{WebSurfaceRuntime, WebSurfaceRuntimeFrame},
web_surface_state::{
WebSurfaceClickState, WebSurfaceKeyboardFocusState, WebSurfacePendingInput,
WebSurfaceScrollState, WebSurfaceState, WebSurfaceTextInputState,
},
};
pub(super) struct WebSurfaceStore {
runtime: WebSurfaceRuntime,
pending_viewport_sizes: BTreeMap<TabId, WebSurfaceSize>,
click_points: BTreeMap<TabId, WebSurfaceClickState>,
hover_points: BTreeMap<TabId, WebSurfaceClickPoint>,
keyboard_focus: Option<WebSurfaceKeyboardFocusState>,
pending_scroll_deltas: BTreeMap<TabId, WebSurfaceScrollDelta>,
scroll_offsets: BTreeMap<TabId, WebSurfaceScrollState>,
typed_texts: BTreeMap<TabId, WebSurfaceTextInputState>,
viewport_bounds: BTreeMap<TabId, Bounds<Pixels>>,
viewport_sizes: BTreeMap<TabId, WebSurfaceSize>,
states: BTreeMap<TabId, WebSurfaceState>,
}
impl WebSurfaceStore {
pub(super) fn new() -> Self {
Self {
runtime: WebSurfaceRuntime::new(),
pending_viewport_sizes: BTreeMap::new(),
click_points: BTreeMap::new(),
hover_points: BTreeMap::new(),
keyboard_focus: None,
pending_scroll_deltas: BTreeMap::new(),
scroll_offsets: BTreeMap::new(),
typed_texts: BTreeMap::new(),
viewport_bounds: BTreeMap::new(),
viewport_sizes: BTreeMap::new(),
states: BTreeMap::new(),
}
}
pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> {
self.states.get(tab_id)
}
pub(super) fn ensure_surface(
&mut self,
tab: &BrowserTab,
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
) {
if !is_external_web_url(tab.url().as_str()) {
return;
}
let requested_url = tab.url().as_str().to_string();
let Some(size) = self.viewport_sizes.get(tab.id()).copied() else {
return;
};
let input = self.take_pending_input(tab.id(), requested_url.as_str());
let previous_frame =
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
match self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input) {
Ok(result) if result.frame.is_some() => {
let Some(frame) = result.frame else {
return;
};
self.states.insert(tab.id().clone(), WebSurfaceState::Ready(frame));
}
Ok(result) if result.started_loading => {
self.states.insert(
tab.id().clone(),
WebSurfaceState::Loading {
requested_url: result.requested_url,
previous_frame,
},
);
}
Ok(_) => {}
Err(message) => {
self.states.insert(tab.id().clone(), WebSurfaceState::Failed { message });
}
}
}
pub(super) fn tick(&mut self) -> bool {
let frames = self.runtime.tick();
let mut changed = false;
for frame in frames {
match frame {
WebSurfaceRuntimeFrame::Ready { tab_id, frame } => {
self.states.insert(tab_id, WebSurfaceState::Ready(frame));
changed = true;
}
WebSurfaceRuntimeFrame::Failed { tab_id, message } => {
self.states.insert(tab_id, WebSurfaceState::Failed { message });
changed = true;
}
}
}
changed
}
pub(super) fn record_scroll_delta(
&mut self,
tab_id: &TabId,
requested_url: &str,
delta: Point<Pixels>,
) -> bool {
let Some(delta) = WebSurfaceScrollDelta::from_point(delta) else {
return false;
};
let state = self
.scroll_offsets
.entry(tab_id.clone())
.or_insert_with(|| WebSurfaceScrollState::new(requested_url.to_string()));
if state.requested_url != requested_url {
*state = WebSurfaceScrollState::new(requested_url.to_string());
}
state.offset = state.offset.scrolled_by(delta);
self.pending_scroll_deltas
.entry(tab_id.clone())
.and_modify(|current| *current = current.combined_with(delta))
.or_insert(delta);
self.click_points.remove(tab_id);
self.typed_texts.remove(tab_id);
self.keyboard_focus = None;
true
}
pub(super) fn record_viewport_size(&mut self, tab_id: &TabId, bounds: Bounds<Pixels>) -> bool {
let Some(size) = WebSurfaceSize::from_bounds(bounds) else {
return false;
};
self.viewport_bounds.insert(tab_id.clone(), bounds);
let Some(current_size) = self.viewport_sizes.get(tab_id).copied() else {
self.viewport_sizes.insert(tab_id.clone(), size);
self.pending_viewport_sizes.remove(tab_id);
return true;
};
if current_size == size {
self.pending_viewport_sizes.remove(tab_id);
return false;
}
if self.pending_viewport_sizes.get(tab_id) != Some(&size) {
self.pending_viewport_sizes.insert(tab_id.clone(), size);
return false;
}
self.pending_viewport_sizes.remove(tab_id);
self.viewport_sizes.insert(tab_id.clone(), size);
true
}
pub(super) fn record_hover_point(
&mut self,
tab_id: &TabId,
position: Point<Pixels>,
) -> bool {
let Some(bounds) = self.viewport_bounds.get(tab_id).copied() else {
return false;
};
let Some(point) = WebSurfaceClickPoint::from_window_position(bounds, position) else {
return false;
};
self.hover_points.insert(tab_id.clone(), point);
true
}
pub(super) fn record_click_point(
&mut self,
tab_id: &TabId,
requested_url: &str,
position: Point<Pixels>,
) -> bool {
let Some(bounds) = self.viewport_bounds.get(tab_id).copied() else {
return false;
};
let Some(point) = WebSurfaceClickPoint::from_window_position(bounds, position) else {
return false;
};
let state = WebSurfaceClickState {
requested_url: requested_url.to_string(),
scroll_offset: self.scroll_offset_for(tab_id, requested_url),
point,
};
self.keyboard_focus = Some(WebSurfaceKeyboardFocusState {
tab_id: tab_id.clone(),
requested_url: requested_url.to_string(),
scroll_offset: state.scroll_offset,
click_point: state.point,
});
self.typed_texts.remove(tab_id);
self.click_points.insert(tab_id.clone(), state);
true
}
pub(super) fn record_typed_text(
&mut self,
tab_id: &TabId,
requested_url: &str,
text: &str,
) -> bool {
if text.is_empty() {
return false;
}
let Some(focus) = self.keyboard_focus.as_ref() else {
return false;
};
if focus.tab_id != *tab_id || focus.requested_url != requested_url {
return false;
}
let entry =
self.typed_texts.entry(tab_id.clone()).or_insert_with(|| WebSurfaceTextInputState {
requested_url: requested_url.to_string(),
scroll_offset: focus.scroll_offset,
click_point: focus.click_point,
text: String::new(),
});
if entry.requested_url != requested_url
|| entry.scroll_offset != focus.scroll_offset
|| entry.click_point != focus.click_point
{
*entry = WebSurfaceTextInputState {
requested_url: requested_url.to_string(),
scroll_offset: focus.scroll_offset,
click_point: focus.click_point,
text: String::new(),
};
}
entry.text.push_str(text);
true
}
fn take_pending_input(
&mut self,
tab_id: &TabId,
requested_url: &str,
) -> WebSurfacePendingInput {
let scroll_offset = self.scroll_offset_for(tab_id, requested_url);
let scroll_delta = self.pending_scroll_deltas.remove(tab_id);
let click_point = self
.click_points
.remove(tab_id)
.filter(|state| {
state.requested_url == requested_url && state.scroll_offset == scroll_offset
})
.map(|state| state.point);
let typed_text = self
.typed_texts
.remove(tab_id)
.filter(|state| state.requested_url == requested_url)
.map(|state| state.text);
let hover_point = self.hover_points.remove(tab_id);
WebSurfacePendingInput { scroll_offset, scroll_delta, click_point, hover_point, typed_text }
}
fn previous_ready_frame(
&self,
tab_id: &TabId,
requested_url: &str,
zoom_percent: u16,
) -> Option<WebSurfaceFrame> {
match self.states.get(tab_id) {
Some(WebSurfaceState::Ready(frame))
if frame.requested_url == requested_url && frame.zoom_percent() == zoom_percent =>
{
Some(frame.clone())
}
Some(WebSurfaceState::Loading {
requested_url: current_url, previous_frame, ..
}) if current_url == requested_url => previous_frame
.as_ref()
.filter(|frame| frame.zoom_percent() == zoom_percent)
.cloned(),
_ => None,
}
}
fn scroll_offset_for(&self, tab_id: &TabId, requested_url: &str) -> WebSurfaceScrollOffset {
self.scroll_offsets
.get(tab_id)
.filter(|state| state.requested_url == requested_url)
.map(|state| state.offset)
.unwrap_or_default()
}
}
pub(super) fn is_external_web_url(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
#[cfg(test)]
#[path = "web_surface_tests.rs"]
mod tests;
#[cfg(all(test, feature = "live-site-smoke"))]
#[path = "web_surface_live_site_tests.rs"]
mod web_surface_live_site_tests;