Files
ELY-Browser/crates/ely_app/src/shell/web_surface_state.rs
T

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,
&[],
)
}
}