453 lines
16 KiB
Rust
453 lines
16 KiB
Rust
use std::time::{Duration, Instant};
|
|
|
|
use ely_domain::{ProfileId, TabId};
|
|
use gpui::{Bounds, Pixels};
|
|
|
|
use crate::services::ProfileDataMode;
|
|
use crate::services::servo_live::ServoLivePermissionGrant;
|
|
|
|
use super::{
|
|
web_surface_cadence::ACTIVE_POLL_INTERVAL,
|
|
web_surface_frame::WebSurfaceFrame,
|
|
web_surface_geometry::{
|
|
WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceScrollOffset, WebSurfaceSize,
|
|
},
|
|
web_surface_metadata::{WebSurfaceMetadataTracker, WebSurfacePageMetadata},
|
|
web_surface_permissions::WebSurfaceSitePermission,
|
|
web_surface_runtime::WebSurfaceUrlChange,
|
|
};
|
|
|
|
pub(super) struct WebSurfaceScrollState {
|
|
pub(super) requested_url: String,
|
|
pub(super) offset: WebSurfaceScrollOffset,
|
|
}
|
|
|
|
impl WebSurfaceScrollState {
|
|
pub(super) fn new(requested_url: String) -> Self {
|
|
Self { requested_url, offset: WebSurfaceScrollOffset::default() }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(super) struct WebSurfaceClickState {
|
|
pub(super) requested_url: String,
|
|
pub(super) scroll_offset: WebSurfaceScrollOffset,
|
|
pub(super) point: WebSurfaceClickPoint,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(super) struct WebSurfaceKeyboardFocusState {
|
|
pub(super) tab_id: TabId,
|
|
pub(super) requested_url: String,
|
|
pub(super) scroll_offset: WebSurfaceScrollOffset,
|
|
pub(super) click_point: WebSurfaceClickPoint,
|
|
}
|
|
|
|
pub(super) struct WebSurfaceTextInputState {
|
|
pub(super) requested_url: String,
|
|
pub(super) scroll_offset: WebSurfaceScrollOffset,
|
|
pub(super) click_point: WebSurfaceClickPoint,
|
|
pub(super) text: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(super) struct WebSurfacePendingInput {
|
|
pub(super) enqueued_at: Option<Instant>,
|
|
pub(super) scroll_offset: WebSurfaceScrollOffset,
|
|
pub(super) scroll_delta: Option<WebSurfaceScrollDelta>,
|
|
pub(super) scroll_point: Option<WebSurfaceClickPoint>,
|
|
pub(super) click_point: Option<WebSurfaceClickPoint>,
|
|
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
|
pub(super) typed_text: Option<String>,
|
|
}
|
|
|
|
/// Outcome of a `WebSurfaceStore::record_*` call.
|
|
///
|
|
/// Replaces the previous `-> bool` return so silent rejections name
|
|
/// themselves at the call site. Callers only branch on `Applied`
|
|
/// (every other variant means "do nothing, don't notify"), but each
|
|
/// `Dropped*` / non-`Applied` variant pins down *why* a coordinate
|
|
/// or keystroke never reached the runtime — so the next regression
|
|
/// shows up as a specific variant in tests instead of a missing
|
|
/// repaint. `#[must_use]` keeps a future caller from dropping the
|
|
/// outcome on the floor and reintroducing the silent-fail pattern.
|
|
#[must_use]
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(super) enum WebSurfaceInputOutcome {
|
|
/// State changed and the renderer should re-notify.
|
|
Applied,
|
|
/// State changed and the active cadence timer will flush it.
|
|
Buffered,
|
|
/// Same value as currently recorded — nothing to flush downstream.
|
|
NoChange,
|
|
/// Geometry constructor rejected the input (zero/NaN/negative
|
|
/// bounds). The viewport never measured cleanly.
|
|
DroppedInvalidBounds,
|
|
/// Viewport bounds have not been recorded yet, so window-relative
|
|
/// coordinates can't be translated into the page coordinate space.
|
|
DroppedNoViewportBounds,
|
|
/// Window position falls outside the viewport rect after scaling.
|
|
DroppedOutOfBounds,
|
|
/// Wheel delta rounded to zero device pixels in both axes.
|
|
DroppedZeroDelta,
|
|
/// Empty string passed to `record_typed_text` — nothing to buffer.
|
|
DroppedEmptyText,
|
|
/// `record_typed_text` ran before any click established
|
|
/// keyboard focus on this surface.
|
|
DroppedNoKeyboardFocus,
|
|
/// Keyboard focus belongs to a different tab or the URL drifted
|
|
/// (redirect / trailing-slash mismatch) since the focusing click.
|
|
DroppedFocusMismatch,
|
|
}
|
|
|
|
pub(super) enum WebSurfaceState {
|
|
Loading { requested_url: String, previous_frame: Option<WebSurfaceFrame> },
|
|
Ready(WebSurfaceFrame),
|
|
Failed { message: String },
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(super) struct WebSurfaceTickResult {
|
|
pub(super) changed: bool,
|
|
pub(super) url_changes: Vec<WebSurfaceUrlChange>,
|
|
pub(super) page_metadata: Vec<WebSurfacePageMetadata>,
|
|
pub(super) permission_transfers: Vec<ServoLivePermissionGrant>,
|
|
pub(super) permission_consumptions: Vec<ServoLivePermissionGrant>,
|
|
}
|
|
|
|
/// All per-tab surface invariants in one owner.
|
|
///
|
|
/// Replaces the previous 11 parallel `BTreeMap<TabId, _>` fields on
|
|
/// `WebSurfaceStore`. Every per-tab field lives here so a single
|
|
/// lookup yields the full input/render context for that tab; the
|
|
/// remaining cross-tab state (the runtime backend and the singular
|
|
/// `keyboard_focus` pointer to the active tab) stays on the store.
|
|
///
|
|
/// All inputs that depend on a measured viewport take their
|
|
/// pre-conditions from this struct (`viewport_bounds`,
|
|
/// `viewport_size`), so "viewport not ready" turns into a guard at
|
|
/// the call site instead of a `get` on a parallel map that may or
|
|
/// may not be populated.
|
|
pub(super) struct PerTabSurface {
|
|
pub(super) viewport_bounds: Option<Bounds<Pixels>>,
|
|
pub(super) viewport_size: Option<WebSurfaceSize>,
|
|
/// When [`viewport_size`] last *transitioned* to a new value. The
|
|
/// initial measurement does not update this; only subsequent
|
|
/// genuine size changes do. Drives [`viewport_size_is_settling`]
|
|
/// so a sidebar / window animation that emits a viewport bounds on
|
|
/// every GPUI paint does not translate into a Servo framebuffer
|
|
/// destroy/recreate every frame (the "page flashing" symptom).
|
|
viewport_size_changed_at: Option<Instant>,
|
|
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
|
|
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
|
last_hover_enqueued_at: Option<Instant>,
|
|
pub(super) click_point: Option<WebSurfaceClickState>,
|
|
pub(super) pending_scroll_delta: Option<WebSurfaceScrollDelta>,
|
|
pub(super) pending_scroll_point: Option<WebSurfaceClickPoint>,
|
|
pub(super) pending_input_started_at: Option<Instant>,
|
|
pub(super) scroll_offset: Option<WebSurfaceScrollState>,
|
|
pub(super) typed_text: Option<WebSurfaceTextInputState>,
|
|
pub(super) state: Option<WebSurfaceState>,
|
|
last_input_flushed_at: Option<Instant>,
|
|
metadata_tracker: WebSurfaceMetadataTracker,
|
|
}
|
|
|
|
impl PerTabSurface {
|
|
pub(super) fn new() -> Self {
|
|
Self {
|
|
viewport_bounds: None,
|
|
viewport_size: None,
|
|
viewport_size_changed_at: None,
|
|
last_ensure_key: None,
|
|
hover_point: None,
|
|
last_hover_enqueued_at: None,
|
|
click_point: None,
|
|
pending_scroll_delta: None,
|
|
pending_scroll_point: None,
|
|
pending_input_started_at: None,
|
|
scroll_offset: None,
|
|
typed_text: None,
|
|
state: None,
|
|
last_input_flushed_at: None,
|
|
metadata_tracker: WebSurfaceMetadataTracker::default(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn mark_pending_input_started(&mut self) {
|
|
self.pending_input_started_at.get_or_insert_with(Instant::now);
|
|
}
|
|
|
|
pub(super) fn hover_is_throttled(&self, now: Instant) -> bool {
|
|
self.last_hover_enqueued_at
|
|
.is_some_and(|last| now.duration_since(last) < HOVER_INPUT_MIN_INTERVAL)
|
|
}
|
|
|
|
pub(super) fn mark_hover_enqueued(&mut self, now: Instant) {
|
|
self.last_hover_enqueued_at = Some(now);
|
|
}
|
|
|
|
pub(super) fn input_flush_is_throttled(&self, now: Instant) -> bool {
|
|
self.last_input_flushed_at
|
|
.is_some_and(|last| now.duration_since(last) < ACTIVE_POLL_INTERVAL)
|
|
}
|
|
|
|
pub(super) fn mark_input_flushed(&mut self, now: Instant) {
|
|
self.last_input_flushed_at = Some(now);
|
|
}
|
|
|
|
pub(super) fn should_ensure(&self, key: &WebSurfaceEnsureKey) -> bool {
|
|
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
|
|
}
|
|
|
|
pub(super) fn should_defer_resize(&self, key: &WebSurfaceEnsureKey, now: Instant) -> bool {
|
|
self.last_ensure_key.as_ref().is_some_and(|last| last.permissions == key.permissions)
|
|
&& self.viewport_size_is_settling(now)
|
|
}
|
|
|
|
pub(super) fn has_scope(
|
|
&self,
|
|
profile_id: &ProfileId,
|
|
profile_data_mode: ProfileDataMode,
|
|
) -> bool {
|
|
self.last_ensure_key.as_ref().is_some_and(|key| {
|
|
key.profile_id == *profile_id && key.profile_data_mode == profile_data_mode
|
|
})
|
|
}
|
|
|
|
pub(super) fn reset_for_scope_change(&mut self, key: &WebSurfaceEnsureKey) -> bool {
|
|
let Some(previous_key) = self.last_ensure_key.as_ref() else {
|
|
return false;
|
|
};
|
|
if previous_key.has_same_scope(key) {
|
|
return false;
|
|
}
|
|
|
|
self.last_ensure_key = None;
|
|
self.hover_point = None;
|
|
self.last_hover_enqueued_at = None;
|
|
self.click_point = None;
|
|
self.pending_scroll_delta = None;
|
|
self.pending_scroll_point = None;
|
|
self.pending_input_started_at = None;
|
|
self.scroll_offset = None;
|
|
self.typed_text = None;
|
|
self.state = None;
|
|
self.last_input_flushed_at = None;
|
|
self.metadata_tracker = WebSurfaceMetadataTracker::default();
|
|
true
|
|
}
|
|
|
|
/// True when the viewport bounds have changed within the last
|
|
/// [`VIEWPORT_RESIZE_DEBOUNCE`] window. The renderer treats this
|
|
/// as "the user is mid-animation" and holds off telling Servo to
|
|
/// resize its rendering context until the gesture settles —
|
|
/// Servo's surfman backend recreates the framebuffer on every
|
|
/// `rendering_context.resize`, which would otherwise flash a
|
|
/// blank surface for every animation frame.
|
|
pub(super) fn viewport_size_is_settling(&self, now: Instant) -> bool {
|
|
self.viewport_size_changed_at
|
|
.is_some_and(|last| now.duration_since(last) < VIEWPORT_RESIZE_DEBOUNCE)
|
|
}
|
|
|
|
/// Stamp the moment a genuine viewport size transition happened.
|
|
/// Only called when [`viewport_size`] is actually moving to a new
|
|
/// value — the very first measurement does *not* invoke this so
|
|
/// `viewport_size_is_settling` stays false at page-load time and
|
|
/// the initial `ensure_surface` is allowed to fire immediately.
|
|
pub(super) fn mark_viewport_size_changed(&mut self, now: Instant) {
|
|
self.viewport_size_changed_at = Some(now);
|
|
}
|
|
|
|
pub(super) fn mark_ensured(&mut self, key: WebSurfaceEnsureKey) {
|
|
self.last_ensure_key = Some(key);
|
|
}
|
|
|
|
pub(super) fn matches_ready(&self, frame: &WebSurfaceFrame) -> bool {
|
|
match self.state.as_ref() {
|
|
Some(WebSurfaceState::Ready(current)) => current.has_same_render_as(frame),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(super) fn changed_page_metadata(
|
|
&mut self,
|
|
tab_id: &TabId,
|
|
frame: &WebSurfaceFrame,
|
|
) -> Option<WebSurfacePageMetadata> {
|
|
self.metadata_tracker.changed_metadata_for(tab_id, frame)
|
|
}
|
|
|
|
fn has_pending_input(&self) -> bool {
|
|
self.hover_point.is_some()
|
|
|| self.click_point.is_some()
|
|
|| self.pending_scroll_delta.is_some()
|
|
|| self.pending_scroll_point.is_some()
|
|
|| self.typed_text.is_some()
|
|
}
|
|
|
|
pub(super) fn scroll_offset_for(&self, requested_url: &str) -> WebSurfaceScrollOffset {
|
|
self.scroll_offset
|
|
.as_ref()
|
|
.filter(|state| state.requested_url == requested_url)
|
|
.map(|state| state.offset)
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32);
|
|
|
|
/// How long the viewport must hold its new size before we propagate
|
|
/// the resize down to Servo. 80 ms is short enough that a user-driven
|
|
/// drag still feels live, and long enough to collapse a 60 Hz sidebar
|
|
/// animation (~17 ms per frame) into a single trailing-edge resize.
|
|
const VIEWPORT_RESIZE_DEBOUNCE: Duration = Duration::from_millis(80);
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(super) struct WebSurfaceEnsureKey {
|
|
requested_url: String,
|
|
size: WebSurfaceSize,
|
|
profile_id: ProfileId,
|
|
profile_data_mode: ProfileDataMode,
|
|
zoom_percent: u16,
|
|
permissions: Vec<WebSurfaceSitePermission>,
|
|
}
|
|
|
|
impl WebSurfaceEnsureKey {
|
|
pub(super) fn new(
|
|
requested_url: String,
|
|
size: WebSurfaceSize,
|
|
profile_id: ProfileId,
|
|
profile_data_mode: ProfileDataMode,
|
|
zoom_percent: u16,
|
|
permissions: &[WebSurfaceSitePermission],
|
|
) -> Self {
|
|
Self {
|
|
requested_url,
|
|
size,
|
|
profile_id,
|
|
profile_data_mode,
|
|
zoom_percent,
|
|
permissions: permissions.to_vec(),
|
|
}
|
|
}
|
|
|
|
fn has_same_scope(&self, other: &Self) -> bool {
|
|
self.profile_id == other.profile_id && self.profile_data_mode == other.profile_data_mode
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use gpui::{point, px};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn unchanged_surface_without_input_skips_ensure() {
|
|
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
|
let mut surface = PerTabSurface::new();
|
|
|
|
assert!(surface.should_ensure(&key));
|
|
|
|
surface.mark_ensured(key.clone());
|
|
|
|
assert!(!surface.should_ensure(&key));
|
|
}
|
|
|
|
#[test]
|
|
fn pending_input_forces_ensure_even_when_key_matches() {
|
|
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
|
let mut surface = PerTabSurface::new();
|
|
surface.mark_ensured(key.clone());
|
|
surface.pending_scroll_delta =
|
|
WebSurfaceScrollDelta::from_point(point(px(0.0), px(120.0)), 1.0);
|
|
|
|
assert!(surface.should_ensure(&key));
|
|
}
|
|
|
|
#[test]
|
|
fn viewport_change_forces_ensure() {
|
|
let profile_id = ProfileId::new();
|
|
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
|
|
let new_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
|
|
let mut surface = PerTabSurface::new();
|
|
surface.mark_ensured(old_key);
|
|
|
|
assert!(surface.should_ensure(&new_key));
|
|
}
|
|
|
|
#[test]
|
|
fn permission_change_bypasses_viewport_resize_debounce()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let now = Instant::now();
|
|
let profile_id = ProfileId::new();
|
|
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
|
|
let resized_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
|
|
let mut new_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
|
|
new_key.permissions.push(WebSurfaceSitePermission::new(
|
|
ely_domain::SiteOrigin::parse("https://example.com")?,
|
|
ely_domain::SitePermissionFeature::Camera,
|
|
crate::shell::web_surface_permissions::WebSurfaceSitePermissionState::Decision(
|
|
ely_domain::SitePermissionDecision::DenyAlways,
|
|
),
|
|
1,
|
|
));
|
|
let mut surface = PerTabSurface::new();
|
|
surface.mark_ensured(old_key);
|
|
surface.mark_viewport_size_changed(now);
|
|
|
|
assert!(surface.should_defer_resize(&resized_key, now));
|
|
assert!(!surface.should_defer_resize(&new_key, now));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn profile_change_forces_ensure() {
|
|
let old_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
|
let new_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
|
let mut surface = PerTabSurface::new();
|
|
surface.mark_ensured(old_key);
|
|
|
|
assert!(surface.should_ensure(&new_key));
|
|
}
|
|
|
|
#[test]
|
|
fn profile_data_mode_change_forces_ensure() {
|
|
let profile_id = ProfileId::new();
|
|
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
|
|
let mut new_key = old_key.clone();
|
|
new_key.profile_data_mode = ProfileDataMode::Transient;
|
|
let mut surface = PerTabSurface::new();
|
|
surface.mark_ensured(old_key);
|
|
|
|
assert!(surface.should_ensure(&new_key));
|
|
}
|
|
|
|
#[test]
|
|
fn recent_input_flush_throttles_immediate_flush() {
|
|
let start = Instant::now();
|
|
let mut surface = PerTabSurface::new();
|
|
|
|
surface.mark_input_flushed(start);
|
|
|
|
assert!(surface.input_flush_is_throttled(start + Duration::from_millis(7)));
|
|
assert!(!surface.input_flush_is_throttled(start + Duration::from_millis(8)));
|
|
}
|
|
|
|
fn ensure_key(
|
|
url: &str,
|
|
width: u32,
|
|
height: u32,
|
|
profile_id: &ProfileId,
|
|
) -> WebSurfaceEnsureKey {
|
|
WebSurfaceEnsureKey::new(
|
|
url.to_string(),
|
|
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
|
|
profile_id.clone(),
|
|
ProfileDataMode::Persistent,
|
|
100,
|
|
&[],
|
|
)
|
|
}
|
|
}
|